mpvremote/mpvremote/app.py

175 lines
5.8 KiB
Python

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