diff --git a/mpvremote/app.py b/mpvremote/app.py index 70d1ee8..d2cd2f2 100644 --- a/mpvremote/app.py +++ b/mpvremote/app.py @@ -16,6 +16,10 @@ class App: curses.curs_set(0) stdscr.keypad(True) stdscr.nodelay(False) + try: + curses.set_escdelay(50) + except (AttributeError, curses.error): + pass self._init_colors() @@ -31,7 +35,8 @@ class App: self.messages = [] # Video cache + background rescan - self._video_cache = [] + self._video_cache = () + self._video_cache_version = 0 self._video_cache_lock = threading.Lock() self._rescan_stop = threading.Event() self._scan_videos() @@ -60,8 +65,7 @@ class App: try: media_path = Path(config.MEDIA_DIR) if not media_path.exists(): - with self._video_cache_lock: - self._video_cache = [] + self._commit_video_cache([]) return videos = [] @@ -70,11 +74,18 @@ class App: 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()) + self._commit_video_cache(videos) except Exception: pass + 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): """Background thread that rescans every 2 minutes.""" while not self._rescan_stop.is_set(): @@ -88,7 +99,12 @@ class App: def get_videos(self): """Return cached video list. Thread-safe.""" with self._video_cache_lock: - return self._video_cache[:] + 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 refresh_videos(self): """Rescan the configured media directory immediately.""" diff --git a/mpvremote/views.py b/mpvremote/views.py index 8158c30..cf51cb2 100644 --- a/mpvremote/views.py +++ b/mpvremote/views.py @@ -119,6 +119,7 @@ class MainView: elif key == config.KEY_PLAYLIST: self.app.switch_view('playlist') elif key == config.KEY_QUEUE: + self.app.views['queue'].open() self.app.switch_view('queue') elif key == config.KEY_SETTINGS: self.app.views['settings'].open() @@ -210,34 +211,65 @@ class QueueView: self.selected = 0 self.scroll_offset = 0 self._last_search = None + self._last_cache_version = -1 + self._indexed_videos = () + self._all_videos = [] + + def open(self): + self._apply_filter() 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()] + videos, cache_version = self.app.get_videos_with_version() + search_changed = self.search != self._last_search + cache_changed = cache_version != self._last_cache_version + if not search_changed and not cache_changed: + return - if self.search != self._last_search: + if cache_changed: + self._indexed_videos = videos + self._all_videos = [(name, path) for name, path, _ in videos] + self._last_cache_version = cache_version + + if search_changed: self.selected = 0 self.scroll_offset = 0 self._last_search = self.search + query = self.search.casefold() + if not query: + self.filtered = self._all_videos + else: + self.filtered = [ + (name, path) + for name, path, normalized_name in self._indexed_videos + if query in normalized_name + ] + + self.selected = min(self.selected, max(0, len(self.filtered) - 1)) + self._ensure_selection_visible() + + def _ensure_selection_visible(self): + content_height = max(1, self.app.height - 3) + 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 + max_scroll = max(0, len(self.filtered) - content_height) + self.scroll_offset = min(max(0, self.scroll_offset), max_scroll) + 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 + content_height = max(1, self.app.height - 3) + scroll_offset = self.scroll_offset + if self.selected < scroll_offset: + scroll_offset = self.selected + elif self.selected >= scroll_offset + content_height: + scroll_offset = self.selected - content_height + 1 for i in range(content_height): - idx = self.scroll_offset + i + idx = scroll_offset + i row = 2 + i if row >= self.app.height - 1: break @@ -262,6 +294,8 @@ class QueueView: self.app.draw_help_bar(" [Type]Filter [Up/Down]Navigate [Enter]Queue [Esc]Back") def handle_key(self, key): + # Check cheaply for a completed background scan before processing input. + self._apply_filter() if key == 27: # Esc if self.search: self.search = "" @@ -271,7 +305,7 @@ class QueueView: 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) + self.selected = min(max(0, 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] @@ -286,6 +320,7 @@ class QueueView: elif 32 <= key <= 126: self.search += chr(key) self._apply_filter() + self._ensure_selection_visible() return True