diff --git a/README.md b/README.md index ce907ff..050a43d 100644 --- a/README.md +++ b/README.md @@ -53,12 +53,13 @@ python3 -m mpvremote ## Configuration -All settings live in `mpvremote/config.py`: +The IPC socket and video extensions are set in `mpvremote/config.py`. The media +directory defaults to `~/Videos` and can be changed in the app's Settings view +(`x`). The selected path is stored in `~/.config/mpvremotemedia`. | Setting | Default | Description | |--------------|--------------------|---------------------------------------| | `SOCKET` | `/tmp/mpv-socket` | Path to mpv's IPC socket | -| `MEDIA_DIR` | `~/GDrive/Videos` | Directory scanned for queueable video | | `VIDEO_EXTS` | `mp4 mkv avi webm` | File extensions treated as video | ## Keybindings @@ -68,7 +69,7 @@ All settings live in `mpvremote/config.py`: | Key | Action | |---------|----------------------------| | `Space` | Pause / resume | -| `h`/`l` | Seek -10s / +10s | +| `h`/`l`, `←`/`→` | Seek -10s / +10s | | `j`/`k` | Volume -5 / +5 | | `s` | Cycle subtitles | | `n`/`p` | Next / previous track | @@ -76,6 +77,7 @@ All settings live in `mpvremote/config.py`: | `R` | Toggle loop playlist | | `e` | Playlist view | | `o` | Queue view | +| `x` | Settings (media directory) | | `c` | Show controls help | | `q` | Quit | @@ -85,7 +87,7 @@ All settings live in `mpvremote/config.py`: |----------------|--------------------------| | `Up`/`Down` | Navigate | | `Enter` | Skip to selected item | -| `Del` | Remove selected item | +| `Del`/`d` | Remove selected item | | `Esc`/`q`/`←` | Back to main view | ### Queue view diff --git a/mpvremote/app.py b/mpvremote/app.py index 2167396..70d1ee8 100644 --- a/mpvremote/app.py +++ b/mpvremote/app.py @@ -4,7 +4,7 @@ import time from pathlib import Path from mpvremote import config from mpvremote.mpv_client import MpvClient -from mpvremote.views import MainView, PlaylistView, QueueView +from mpvremote.views import MainView, PlaylistView, QueueView, SettingsView class App: @@ -25,6 +25,7 @@ class App: 'main': MainView(self), 'playlist': PlaylistView(self), 'queue': QueueView(self), + 'settings': SettingsView(self), } self.current_view = self.views['main'] self.messages = [] @@ -89,6 +90,10 @@ class App: with self._video_cache_lock: return self._video_cache[:] + def refresh_videos(self): + """Rescan the configured media directory immediately.""" + self._scan_videos() + def run(self): while self.running: self.draw() diff --git a/mpvremote/config.py b/mpvremote/config.py index 5bc3923..bf14d49 100644 --- a/mpvremote/config.py +++ b/mpvremote/config.py @@ -1,7 +1,32 @@ import os +from pathlib import Path SOCKET = "/tmp/mpv-socket" -MEDIA_DIR = os.path.expanduser("~/GDrive/Videos") +DEFAULT_MEDIA_DIR = os.path.expanduser("~/Videos") +MEDIA_DIR_FILE = Path.home() / ".config" / "mpvremotemedia" + + +def _load_media_dir(): + try: + configured_dir = MEDIA_DIR_FILE.read_text(encoding="utf-8").strip() + except (OSError, UnicodeError): + configured_dir = "" + return os.path.expanduser(configured_dir or DEFAULT_MEDIA_DIR) + + +def set_media_dir(path): + """Persist and activate the configured media directory.""" + path = path.strip() + if not path: + raise ValueError("Media directory cannot be empty") + + MEDIA_DIR_FILE.parent.mkdir(parents=True, exist_ok=True) + MEDIA_DIR_FILE.write_text(path + "\n", encoding="utf-8") + global MEDIA_DIR + MEDIA_DIR = os.path.expanduser(path) + + +MEDIA_DIR = _load_media_dir() VIDEO_EXTS = ["mp4", "mkv", "avi", "webm"] # Key codes @@ -20,3 +45,4 @@ KEY_QUEUE = ord('o') KEY_PLAYLIST = ord('e') KEY_QUIT = ord('q') KEY_DELETE_PLAYLIST = ord('d') +KEY_SETTINGS = ord('x') diff --git a/mpvremote/views.py b/mpvremote/views.py index 2fa4bcd..8158c30 100644 --- a/mpvremote/views.py +++ b/mpvremote/views.py @@ -57,7 +57,7 @@ class MainView: self.app.draw_messages() def _draw_help(self): - text = " [Space]Pause [KLeft/KRight]Seek [j/k]Vol [s]Sub [e]Plist [o]Queue [r/R]Loop [q]Quit" + text = " [Space]Pause [KLeft/KRight]Seek [j/k]Vol [s]Sub [e]Plist [o]Queue [x]Settings [r/R]Loop [q]Quit" self.app.draw_help_bar(text) def handle_key(self, key): @@ -114,12 +114,15 @@ class MainView: elif key == config.KEY_CONTROLS: self.app.add_message("=== Controls ===") self.app.add_message("[Space] Pause | [KLeft/KRight] Seek -10s/+10s | [j/k] Vol -/+") - self.app.add_message("[s] Subtitle | [o] Queue | [e] Playlist | [n/p] Next/Prev") + self.app.add_message("[s] Subtitle | [o] Queue | [e] Playlist | [x] Settings | [n/p] Next/Prev") self.app.add_message("[r/R] Loop File/Playlist (state shown in status bar) | [q] Quit") elif key == config.KEY_PLAYLIST: self.app.switch_view('playlist') elif key == config.KEY_QUEUE: self.app.switch_view('queue') + elif key == config.KEY_SETTINGS: + self.app.views['settings'].open() + self.app.switch_view('settings') return True @@ -284,3 +287,118 @@ class QueueView: self.search += chr(key) self._apply_filter() return True + + +class SettingsView: + def __init__(self, app): + self.app = app + self.path = config.MEDIA_DIR + self.cursor = len(self.path) + self.focus = 0 # 0: path field, 1: OK button + self.error = "" + + def open(self): + self.path = config.MEDIA_DIR + self.cursor = len(self.path) + self.focus = 0 + self.error = "" + + def _put(self, row, col, text, attrs=0): + if row < 0 or row >= self.app.height or col >= self.app.width: + return + try: + self.app.stdscr.addstr(row, col, text[:max(0, self.app.width - col)], attrs) + except curses.error: + pass + + def draw(self): + self.app.draw_status_bar(" Settings | Media Directory") + panel_width = max(1, min(72, self.app.width - 4)) + panel_height = 9 + left = max(0, (self.app.width - panel_width) // 2) + top = max(1, (self.app.height - panel_height) // 2) + inner_width = max(1, panel_width - 2) + + self._put(top, left, "+" + "-" * max(0, panel_width - 2) + "+") + for row in range(top + 1, min(top + panel_height - 1, self.app.height - 1)): + self._put(row, left, "|" + " " * max(0, panel_width - 2) + "|") + self._put(top + panel_height - 1, left, "+" + "-" * max(0, panel_width - 2) + "+") + + self._put(top + 1, left + 2, "Media directory (use Tab to focus OK):") + field_row = top + 3 + field_col = left + 2 + field_width = max(1, inner_width - 4) + cursor = self.cursor + start = max(0, cursor - field_width + 1) + visible_path = self.path[start:start + field_width] + field_attrs = curses.A_REVERSE if self.focus == 0 else 0 + self._put(field_row, field_col, visible_path.ljust(field_width), field_attrs) + + button = "[ OK ]" + button_col = left + max(2, (panel_width - len(button)) // 2) + button_attrs = curses.color_pair(2) | curses.A_BOLD if self.focus == 1 else 0 + self._put(top + 5, button_col, button, button_attrs) + if self.error: + self._put(top + 6, left + 2, self.error[:inner_width], curses.color_pair(3)) + + self.app.draw_help_bar(" [Tab]Focus [Enter]Save [Esc]Cancel") + try: + if self.focus == 0: + curses.curs_set(1) + cursor_col = min(field_col + cursor - start, field_col + field_width - 1) + self.app.stdscr.move(field_row, cursor_col) + else: + curses.curs_set(0) + except curses.error: + pass + + def _close(self): + try: + curses.curs_set(0) + except curses.error: + pass + self.app.switch_view('main') + + def _save(self): + try: + config.set_media_dir(self.path) + except (OSError, ValueError) as exc: + self.error = f"Could not save directory: {exc}" + return + self.app.refresh_videos() + self.app.add_message(f">> Media directory set to {config.MEDIA_DIR}") + self._close() + + def handle_key(self, key): + if key == 27: + self._close() + elif key in (9, curses.KEY_BTAB): + self.focus = 0 if key == curses.KEY_BTAB else 1 - self.focus + self.error = "" + elif self.focus == 1: + if key in (10, 13, curses.KEY_ENTER): + self._save() + elif key in (10, 13, curses.KEY_ENTER): + self.focus = 1 + elif key in (curses.KEY_BACKSPACE, 127, 8): + if self.cursor: + self.path = self.path[:self.cursor - 1] + self.path[self.cursor:] + self.cursor -= 1 + self.error = "" + elif key == curses.KEY_DC: + if self.cursor < len(self.path): + self.path = self.path[:self.cursor] + self.path[self.cursor + 1:] + self.error = "" + elif key == curses.KEY_LEFT: + self.cursor = max(0, self.cursor - 1) + elif key == curses.KEY_RIGHT: + self.cursor = min(len(self.path), self.cursor + 1) + elif key == curses.KEY_HOME: + self.cursor = 0 + elif key == curses.KEY_END: + self.cursor = len(self.path) + elif 32 <= key <= 126: + self.path = self.path[:self.cursor] + chr(key) + self.path[self.cursor:] + self.cursor += 1 + self.error = "" + return True