diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2b7ce61 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +# Don't commit pycache! +*__pycache__* diff --git a/mpvremote/__init__.py b/mpvremote/__init__.py new file mode 100644 index 0000000..f16580b --- /dev/null +++ b/mpvremote/__init__.py @@ -0,0 +1,2 @@ +from mpvremote.app import App +from mpvremote.config import SOCKET diff --git a/mpvremote/__main__.py b/mpvremote/__main__.py new file mode 100644 index 0000000..a484907 --- /dev/null +++ b/mpvremote/__main__.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +import sys +import os +import curses +from mpvremote.app import App +from mpvremote.config import SOCKET + + +def main(): + if not os.path.exists(SOCKET): + print(f"Error: Socket {SOCKET} not found or mpv is not running.", file=sys.stderr) + sys.exit(1) + + def run_app(stdscr): + app = App(stdscr) + app.run() + + try: + curses.wrapper(run_app) + except KeyboardInterrupt: + pass + + +if __name__ == '__main__': + main() diff --git a/mpvremote/app.py b/mpvremote/app.py new file mode 100644 index 0000000..2167396 --- /dev/null +++ b/mpvremote/app.py @@ -0,0 +1,146 @@ +import curses +import threading +import time +from pathlib import Path +from mpvremote import config +from mpvremote.mpv_client import MpvClient +from mpvremote.views import MainView, PlaylistView, QueueView + + +class App: + def __init__(self, stdscr): + self.stdscr = stdscr + self.client = MpvClient(config.SOCKET) + self.running = True + + curses.curs_set(0) + stdscr.keypad(True) + stdscr.nodelay(False) + + self._init_colors() + + self.height, self.width = stdscr.getmaxyx() + + self.views = { + 'main': MainView(self), + 'playlist': PlaylistView(self), + 'queue': QueueView(self), + } + self.current_view = self.views['main'] + self.messages = [] + + # Video cache + background rescan + self._video_cache = [] + self._video_cache_lock = threading.Lock() + self._rescan_stop = threading.Event() + self._scan_videos() + self._rescan_thread = threading.Thread(target=self._rescan_loop, daemon=True) + self._rescan_thread.start() + + def _init_colors(self): + curses.start_color() + curses.use_default_colors() + + navy_bg = curses.COLOR_BLUE + if curses.can_change_color() and curses.COLORS >= 256: + try: + curses.init_color(16, 0, 0, 500) + navy_bg = 16 + except curses.error: + pass + + curses.init_pair(1, curses.COLOR_WHITE, navy_bg) + curses.init_pair(2, curses.COLOR_GREEN, -1) + curses.init_pair(3, curses.COLOR_YELLOW, -1) + curses.init_pair(4, curses.COLOR_WHITE, navy_bg) + + def _scan_videos(self): + """Scan MEDIA_DIR for video files. Thread-safe.""" + try: + media_path = Path(config.MEDIA_DIR) + if not media_path.exists(): + with self._video_cache_lock: + self._video_cache = [] + return + + videos = [] + for ext in config.VIDEO_EXTS: + for file_path in media_path.rglob(f"*.{ext}"): + rel = str(file_path.relative_to(media_path)) + videos.append((rel, str(file_path))) + + with self._video_cache_lock: + self._video_cache = sorted(videos, key=lambda x: x[0].lower()) + except Exception: + pass + + def _rescan_loop(self): + """Background thread that rescans every 2 minutes.""" + while not self._rescan_stop.is_set(): + for _ in range(120): + if self._rescan_stop.is_set(): + break + time.sleep(1) + if not self._rescan_stop.is_set(): + self._scan_videos() + + def get_videos(self): + """Return cached video list. Thread-safe.""" + with self._video_cache_lock: + return self._video_cache[:] + + def run(self): + while self.running: + self.draw() + key = self.stdscr.getch() + if key == -1: + continue + if not self.current_view.handle_key(key): + self.running = False + self._rescan_stop.set() + self._rescan_thread.join(timeout=5) + + def draw(self): + self.stdscr.erase() + self.current_view.draw() + self.stdscr.noutrefresh() + curses.doupdate() + + def switch_view(self, name): + self.current_view = self.views[name] + self.stdscr.clear() + self.stdscr.refresh() + + def add_message(self, msg): + self.messages.append(msg) + max_msgs = max(1, self.height - 4) + while len(self.messages) > max_msgs: + self.messages.pop(0) + + def draw_status_bar(self, text): + text = text[:self.width - 1].ljust(self.width - 1) + try: + self.stdscr.addstr(0, 0, text, curses.color_pair(1) | curses.A_BOLD) + except curses.error: + pass + + def draw_help_bar(self, text): + row = self.height - 1 + text = text[:self.width - 1].ljust(self.width - 1) + try: + self.stdscr.addstr(row, 0, text, curses.color_pair(4)) + except curses.error: + pass + + def draw_messages(self, start_row=2): + max_rows = max(0, self.height - start_row - 1) + visible = self.messages[-max_rows:] if max_rows > 0 else [] + for i, msg in enumerate(visible): + row = start_row + i + if row >= self.height - 1: + break + text = msg[:self.width - 1] + try: + self.stdscr.addstr(row, 0, text, curses.color_pair(3)) + except curses.error: + pass diff --git a/mpvremote/config.py b/mpvremote/config.py new file mode 100644 index 0000000..5b75a42 --- /dev/null +++ b/mpvremote/config.py @@ -0,0 +1,21 @@ +import os + +SOCKET = "/tmp/mpv-socket" +MEDIA_DIR = os.path.expanduser("~/GDrive/Videos") +VIDEO_EXTS = ["mp4", "mkv", "avi", "webm"] + +# Key codes +KEY_PAUSE = ord(' ') +KEY_SEEK_BACK = ord('h') +KEY_SEEK_FWD = ord('l') +KEY_VOL_UP = ord('k') +KEY_VOL_DOWN = ord('j') +KEY_SUB = ord('s') +KEY_NEXT = ord('n') +KEY_PREV = ord('p') +KEY_LOOP_FILE = ord('r') +KEY_LOOP_PLIST = ord('R') +KEY_CONTROLS = ord('c') +KEY_QUEUE = ord('o') +KEY_PLAYLIST = ord('e') +KEY_QUIT = ord('q') diff --git a/mpvremote/mpv_client.py b/mpvremote/mpv_client.py new file mode 100644 index 0000000..6d21e9a --- /dev/null +++ b/mpvremote/mpv_client.py @@ -0,0 +1,32 @@ +import json +import socket + + +class MpvClient: + """Low-level mpv IPC client. Knows nothing about curses or UI.""" + + def __init__(self, socket_path): + self.socket_path = socket_path + + def _send(self, data): + try: + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock: + sock.settimeout(2) + sock.connect(self.socket_path) + payload = json.dumps(data) + "\n" + sock.sendall(payload.encode()) + response = sock.recv(8192).decode() + if response: + return json.loads(response) + return None + except Exception: + return None + + def command(self, *args): + return self._send({"command": list(args)}) + + def get_property(self, prop): + result = self._send({"command": ["get_property", prop]}) + if result and "data" in result: + return result["data"] + return None diff --git a/mpvremote/views.py b/mpvremote/views.py new file mode 100644 index 0000000..714d1e2 --- /dev/null +++ b/mpvremote/views.py @@ -0,0 +1,260 @@ +import curses +from mpvremote import config + + +class MainView: + def __init__(self, app): + self.app = app + + def draw(self): + self._draw_status() + self._draw_messages() + self._draw_help() + + def _draw_status(self): + try: + title = self.app.client.get_property("media-title") or "Unknown" + pause = self.app.client.get_property("pause") + vol = self.app.client.get_property("volume") + + pause_str = "|| Paused" if pause else "> Playing" + vol_str = f"Vol: {int(vol)}" if vol is not None else "Vol: ?" + avail = max(1, self.app.width - len(pause_str) - len(vol_str) - 10) + title_display = title[:avail] + + text = f" {pause_str} | {title_display} | {vol_str}" + self.app.draw_status_bar(text) + except Exception: + self.app.draw_status_bar(" MPV Remote") + + def _draw_messages(self): + self.app.draw_messages() + + def _draw_help(self): + text = " [Space]Pause [h/l]Seek [j/k]Vol [s]Sub [e]Plist [o]Queue [q]Quit" + self.app.draw_help_bar(text) + + def handle_key(self, key): + if key == config.KEY_QUIT: + return False + elif key == config.KEY_PAUSE: + self.app.client.command("cycle", "pause") + is_paused = self.app.client.get_property("pause") + msg = ">> Video Paused..." if is_paused else ">> Video Unpaused..." + self.app.add_message(msg) + elif key == config.KEY_SEEK_BACK: + self.app.client.command("seek", "-10") + self.app.add_message(">> Seeked -10s") + elif key == config.KEY_SEEK_FWD: + self.app.client.command("seek", "10") + self.app.add_message(">> Seeked +10s") + elif key == config.KEY_VOL_UP: + self.app.client.command("add", "volume", "5") + vol = self.app.client.get_property("volume") + self.app.add_message(f">> Volume Level: {int(vol)}" if vol else ">> Volume: ?") + elif key == config.KEY_VOL_DOWN: + self.app.client.command("add", "volume", "-5") + vol = self.app.client.get_property("volume") + self.app.add_message(f">> Volume Level: {int(vol)}" if vol else ">> Volume: ?") + elif key == config.KEY_SUB: + self.app.client.command("cycle", "sub") + sub = self.app.client.get_property("sub") + if sub in (False, None, "no"): + self.app.add_message(">> Subtitles Disabled...") + else: + self.app.add_message(f">> Subtitles Enabled... ({sub})") + elif key == config.KEY_NEXT: + self.app.client.command("playlist-next") + self.app.add_message(">> Next Track") + elif key == config.KEY_PREV: + self.app.client.command("playlist-prev") + self.app.add_message(">> Prev Track") + elif key == config.KEY_LOOP_FILE: + f_loop = self.app.client.get_property("loop-file") + if f_loop in (None, "no", False): + self.app.client.command("set_property", "loop-file", "inf") + self.app.add_message(">> Loop Current Vid: yes (inf)") + else: + self.app.client.command("set_property", "loop-file", "no") + self.app.add_message(">> Loop Current Vid: no") + elif key == config.KEY_LOOP_PLIST: + p_loop = self.app.client.get_property("loop-playlist") + if p_loop in (None, "no", False): + self.app.client.command("set_property", "loop-playlist", "inf") + self.app.add_message(">> Loop Current Playlist: yes (inf)") + else: + self.app.client.command("set_property", "loop-playlist", "no") + self.app.add_message(">> Loop Current Playlist: no") + elif key == config.KEY_CONTROLS: + self.app.add_message("=== Controls ===") + self.app.add_message("[Space] Pause | [h/l] Seek -10s/+10s | [j/k] Vol -/+") + self.app.add_message("[s] Subtitle | [o] Queue | [e] Playlist | [n/p] Next/Prev") + self.app.add_message("[r] Loop File | [R] Loop Playlist | [q] Quit") + elif key == config.KEY_PLAYLIST: + self.app.switch_view('playlist') + elif key == config.KEY_QUEUE: + self.app.switch_view('queue') + return True + + +class PlaylistView: + def __init__(self, app): + self.app = app + self.playlist = [] + self.selected = 0 + self.scroll_offset = 0 + + def _load_playlist(self): + data = self.app.client.get_property("playlist") + if isinstance(data, list): + self.playlist = data + else: + self.playlist = [] + if self.selected >= len(self.playlist): + self.selected = max(0, len(self.playlist) - 1) + + def draw(self): + self._load_playlist() + self.app.draw_status_bar(f" Playlist ({len(self.playlist)} items)") + + content_height = self.app.height - 3 + if len(self.playlist) > content_height: + if self.selected < self.scroll_offset: + self.scroll_offset = self.selected + elif self.selected >= self.scroll_offset + content_height: + self.scroll_offset = self.selected - content_height + 1 + + for i in range(content_height): + idx = self.scroll_offset + i + row = 2 + i + if row >= self.app.height - 1: + break + if idx >= len(self.playlist): + break + + item = self.playlist[idx] + prefix = "> " if item.get("current") else " " + title = item.get("title") or item.get("filename", "Unknown").split("/")[-1] + text = f"{prefix}{idx + 1}. {title}" + + attrs = 0 + if idx == self.selected: + attrs = curses.color_pair(2) | curses.A_BOLD + text = f"> {idx + 1}. {title}" + elif item.get("current"): + attrs = curses.color_pair(2) + + text = text[:self.app.width - 1] + try: + self.app.stdscr.addstr(row, 0, text.ljust(self.app.width - 1), attrs) + except curses.error: + pass + + self.app.draw_help_bar(" [Up/Down]Navigate [Enter]Skip [Del]Remove [Esc/q/Left]Back") + + def handle_key(self, key): + if key in (27, ord('q'), curses.KEY_LEFT): + self.app.switch_view('main') + elif key == curses.KEY_UP: + self.selected = max(0, self.selected - 1) + elif key == curses.KEY_DOWN: + self.selected = min(len(self.playlist) - 1, self.selected + 1) + elif key in (10, 13): + if 0 <= self.selected < len(self.playlist): + self.app.client.command("set_property", "playlist-pos", self.selected) + self.app.add_message(f">> Skipped to item {self.selected + 1}") + self.app.switch_view('main') + elif key in (330, curses.KEY_DC): + if 0 <= self.selected < len(self.playlist): + self.app.client.command("playlist-remove", self.selected) + self.app.add_message(f">> Removed item {self.selected + 1}") + if self.selected >= len(self.playlist) - 1 and self.selected > 0: + self.selected -= 1 + return True + + +class QueueView: + def __init__(self, app): + self.app = app + self.filtered = [] + self.search = "" + self.selected = 0 + self.scroll_offset = 0 + self._last_search = None + + def _apply_filter(self): + videos = self.app.get_videos() + if not self.search: + self.filtered = videos[:] + else: + s = self.search.lower() + self.filtered = [(r, a) for r, a in videos if s in r.lower()] + + if self.search != self._last_search: + self.selected = 0 + self.scroll_offset = 0 + self._last_search = self.search + + def draw(self): + self._apply_filter() + search_display = self.search if self.search else "(type to search)" + self.app.draw_status_bar(f" Queue Video | Search: {search_display}") + + content_height = self.app.height - 3 + if len(self.filtered) > content_height: + if self.selected < self.scroll_offset: + self.scroll_offset = self.selected + elif self.selected >= self.scroll_offset + content_height: + self.scroll_offset = self.selected - content_height + 1 + + for i in range(content_height): + idx = self.scroll_offset + i + row = 2 + i + if row >= self.app.height - 1: + break + if idx >= len(self.filtered): + break + + rel_path, _ = self.filtered[idx] + + attrs = 0 + if idx == self.selected: + attrs = curses.color_pair(2) | curses.A_BOLD + text = f"> {rel_path}" + else: + text = f" {rel_path}" + + text = text[:self.app.width - 1] + try: + self.app.stdscr.addstr(row, 0, text.ljust(self.app.width - 1), attrs) + except curses.error: + pass + + self.app.draw_help_bar(" [Type]Filter [Up/Down]Navigate [Enter]Queue [Esc]Back") + + def handle_key(self, key): + if key == 27: # Esc + if self.search: + self.search = "" + self._apply_filter() + else: + self.app.switch_view('main') + elif key == curses.KEY_UP: + self.selected = max(0, self.selected - 1) + elif key == curses.KEY_DOWN: + self.selected = min(len(self.filtered) - 1, self.selected + 1) + elif key in (10, 13): # Enter + if 0 <= self.selected < len(self.filtered): + _, abs_path = self.filtered[self.selected] + self.app.client.command("loadfile", abs_path, "append-play") + self.app.client.command("set_property", "pause", "no") + self.app.add_message(f">> Queued: {abs_path.split('/')[-1]}") + self.app.switch_view('main') + elif key in (127, curses.KEY_BACKSPACE): + if self.search: + self.search = self.search[:-1] + self._apply_filter() + elif 32 <= key <= 126: + self.search += chr(key) + self._apply_filter() + return True