mpvremote/mpvremote/views.py

439 lines
17 KiB
Python

import curses
from mpvremote import config
class MainView:
def __init__(self, app):
self.app = app
def draw(self):
self._draw_status()
self._draw_messages()
self._draw_help()
@staticmethod
def _fmt_time(seconds):
if seconds is None:
return "?"
seconds = int(seconds)
h, rem = divmod(seconds, 3600)
m, s = divmod(rem, 60)
if h:
return f"{h}:{m:02d}:{s:02d}"
return f"{m}:{s:02d}"
@staticmethod
def _loop_str(f_loop, p_loop):
parts = []
if f_loop not in (None, "no", False):
parts.append("F")
if p_loop not in (None, "no", False):
parts.append("P")
return ("L:" + "+".join(parts)) if parts else "L:--"
def _draw_status(self):
try:
title = self.app.client.get_property("media-title") or "Unknown"
pause = self.app.client.get_property("pause")
vol = self.app.client.get_property("volume")
pos = self.app.client.get_property("time-pos")
dur = self.app.client.get_property("duration")
f_loop = self.app.client.get_property("loop-file")
p_loop = self.app.client.get_property("loop-playlist")
pause_str = "|| Paused" if pause else "> Playing"
vol_str = f"Vol: {int(vol)}" if vol is not None else "Vol: ?"
time_str = f"{self._fmt_time(pos)} / {self._fmt_time(dur)}"
loop_str = self._loop_str(f_loop, p_loop)
avail = max(1, self.app.width - len(pause_str) - len(vol_str) - len(time_str) - len(loop_str) - 16)
title_display = title[:avail]
text = f" {pause_str} | {time_str} | {loop_str} | {title_display} | {vol_str}"
self.app.draw_status_bar(text)
except Exception:
self.app.draw_status_bar(" MPV Remote")
def _draw_messages(self):
self.app.draw_messages()
def _draw_help(self):
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):
if key == config.KEY_QUIT:
return False
elif key == config.KEY_PAUSE:
self.app.client.command("cycle", "pause")
is_paused = self.app.client.get_property("pause")
msg = ">> Video Paused..." if is_paused else ">> Video Unpaused..."
self.app.add_message(msg)
elif key in (config.KEY_SEEK_BACK, curses.KEY_LEFT):
self.app.client.command("seek", "-10")
self.app.add_message(">> Seeked -10s")
elif key in (config.KEY_SEEK_FWD, curses.KEY_RIGHT):
self.app.client.command("seek", "10")
self.app.add_message(">> Seeked +10s")
elif key == config.KEY_VOL_UP:
self.app.client.command("add", "volume", "5")
vol = self.app.client.get_property("volume")
self.app.add_message(f">> Volume Level: {int(vol)}" if vol else ">> Volume: ?")
elif key == config.KEY_VOL_DOWN:
self.app.client.command("add", "volume", "-5")
vol = self.app.client.get_property("volume")
self.app.add_message(f">> Volume Level: {int(vol)}" if vol else ">> Volume: ?")
elif key == config.KEY_SUB:
self.app.client.command("cycle", "sub")
sub = self.app.client.get_property("sub")
if sub in (False, None, "no"):
self.app.add_message(">> Subtitles Disabled...")
else:
self.app.add_message(f">> Subtitles Enabled... ({sub})")
elif key == config.KEY_NEXT:
self.app.client.command("playlist-next")
self.app.add_message(">> Next Track")
elif key == config.KEY_PREV:
self.app.client.command("playlist-prev")
self.app.add_message(">> Prev Track")
elif key == config.KEY_LOOP_FILE:
f_loop = self.app.client.get_property("loop-file")
if f_loop in (None, "no", False):
self.app.client.command("set_property", "loop-file", "inf")
self.app.add_message(">> Loop Current Vid: yes (inf)")
else:
self.app.client.command("set_property", "loop-file", "no")
self.app.add_message(">> Loop Current Vid: no")
elif key == config.KEY_LOOP_PLIST:
p_loop = self.app.client.get_property("loop-playlist")
if p_loop in (None, "no", False):
self.app.client.command("set_property", "loop-playlist", "inf")
self.app.add_message(">> Loop Current Playlist: yes (inf)")
else:
self.app.client.command("set_property", "loop-playlist", "no")
self.app.add_message(">> Loop Current Playlist: no")
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 | [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.views['queue'].open()
self.app.switch_view('queue')
elif key == config.KEY_SETTINGS:
self.app.views['settings'].open()
self.app.switch_view('settings')
return True
class PlaylistView:
def __init__(self, app):
self.app = app
self.playlist = []
self.selected = 0
self.scroll_offset = 0
def _load_playlist(self):
data = self.app.client.get_property("playlist")
if isinstance(data, list):
self.playlist = data
else:
self.playlist = []
if self.selected >= len(self.playlist):
self.selected = max(0, len(self.playlist) - 1)
def draw(self):
self._load_playlist()
self.app.draw_status_bar(f" Playlist ({len(self.playlist)} items)")
content_height = self.app.height - 3
if len(self.playlist) > 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
for i in range(content_height):
idx = self.scroll_offset + i
row = 2 + i
if row >= self.app.height - 1:
break
if idx >= len(self.playlist):
break
item = self.playlist[idx]
prefix = "> " if item.get("current") else " "
title = item.get("title") or item.get("filename", "Unknown").split("/")[-1]
text = f"{prefix}{idx + 1}. {title}"
attrs = 0
if idx == self.selected:
attrs = curses.color_pair(2) | curses.A_BOLD
text = f"> {idx + 1}. {title}"
elif item.get("current"):
attrs = curses.color_pair(2)
text = text[:self.app.width - 1]
try:
self.app.stdscr.addstr(row, 0, text.ljust(self.app.width - 1), attrs)
except curses.error:
pass
self.app.draw_help_bar(" [Up/Down]Navigate [Enter]Skip [Del/d]Remove [Esc/q/Left]Back")
def handle_key(self, key):
if key in (27, ord('q'), curses.KEY_LEFT):
self.app.switch_view('main')
elif key == curses.KEY_UP:
self.selected = max(0, self.selected - 1)
elif key == curses.KEY_DOWN:
self.selected = min(len(self.playlist) - 1, self.selected + 1)
elif key in (10, 13):
if 0 <= self.selected < len(self.playlist):
self.app.client.command("set_property", "playlist-pos", self.selected)
self.app.add_message(f">> Skipped to item {self.selected + 1}")
self.app.switch_view('main')
elif key in (330, curses.KEY_DC, config.KEY_DELETE_PLAYLIST):
if 0 <= self.selected < len(self.playlist):
self.app.client.command("playlist-remove", self.selected)
self.app.add_message(f">> Removed item {self.selected + 1}")
if self.selected >= len(self.playlist) - 1 and self.selected > 0:
self.selected -= 1
return True
class QueueView:
def __init__(self, app):
self.app = app
self.filtered = []
self.search = ""
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, 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 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):
search_display = self.search if self.search else "(type to search)"
self.app.draw_status_bar(f" Queue Video | Search: {search_display}")
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 = scroll_offset + i
row = 2 + i
if row >= self.app.height - 1:
break
if idx >= len(self.filtered):
break
rel_path, _ = self.filtered[idx]
attrs = 0
if idx == self.selected:
attrs = curses.color_pair(2) | curses.A_BOLD
text = f"> {rel_path}"
else:
text = f" {rel_path}"
text = text[:self.app.width - 1]
try:
self.app.stdscr.addstr(row, 0, text.ljust(self.app.width - 1), attrs)
except curses.error:
pass
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 = ""
self._apply_filter()
else:
self.app.switch_view('main')
elif key == curses.KEY_UP:
self.selected = max(0, self.selected - 1)
elif key == curses.KEY_DOWN:
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]
self.app.client.command("loadfile", abs_path, "append-play")
self.app.client.command("set_property", "pause", "no")
self.app.add_message(f">> Queued: {abs_path.split('/')[-1]}")
self.app.switch_view('main')
elif key in (127, curses.KEY_BACKSPACE):
if self.search:
self.search = self.search[:-1]
self._apply_filter()
elif 32 <= key <= 126:
self.search += chr(key)
self._apply_filter()
self._ensure_selection_visible()
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