175 lines
5.8 KiB
Python
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
|