439 lines
17 KiB
Python
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
|