import curses import threading from pathlib import Path from mpvremote import config from mpvremote.mpv_client import MpvClient from mpvremote.views import AboutView, MainView, PlaylistView, QueueView, SettingsView 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) try: curses.set_escdelay(50) except (AttributeError, curses.error): pass self._init_colors() self.height, self.width = stdscr.getmaxyx() self.views = { 'main': MainView(self), 'playlist': PlaylistView(self), 'queue': QueueView(self), 'settings': SettingsView(self), 'about': AboutView(self), } self.current_view = self.views['main'] self.messages = [] # Video cache + background rescan self._video_cache = () self._video_cache_version = 0 self._video_cache_lock = threading.Lock() self._initial_scan_complete = threading.Event() self._rescan_stop = threading.Event() 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(): self._commit_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))) self._commit_video_cache(videos) except Exception: pass finally: self._initial_scan_complete.set() def _commit_video_cache(self, videos): """Publish an immutable, pre-normalized snapshot of scanned videos.""" sorted_videos = sorted(videos, key=lambda item: item[0].casefold()) cache = tuple((name, path, name.casefold()) for name, path in sorted_videos) with self._video_cache_lock: self._video_cache = cache self._video_cache_version += 1 def _rescan_loop(self): """Scan immediately, then rescan every 2 minutes in the background.""" while not self._rescan_stop.is_set(): self._scan_videos() if self._rescan_stop.wait(120): break def get_videos(self): """Return cached video list. Thread-safe.""" with self._video_cache_lock: return [(name, path) for name, path, _ in self._video_cache] def get_videos_with_version(self): """Return an immutable normalized snapshot and its cache version.""" with self._video_cache_lock: return self._video_cache, self._video_cache_version def video_scan_complete(self): """Return whether at least one media-directory scan has finished.""" return self._initial_scan_complete.is_set() def refresh_videos(self): """Rescan the configured media directory immediately.""" self._scan_videos() def run(self): while self.running: queue_active = self.current_view is self.views['queue'] self.stdscr.timeout(100 if queue_active else -1) update = getattr(self.current_view, "update", None) if update is not None: update() 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