Start of python rewrite
This commit is contained in:
parent
14c46fc5f2
commit
67f98312ca
7 changed files with 488 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# Don't commit pycache!
|
||||
*__pycache__*
|
||||
2
mpvremote/__init__.py
Normal file
2
mpvremote/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from mpvremote.app import App
|
||||
from mpvremote.config import SOCKET
|
||||
25
mpvremote/__main__.py
Normal file
25
mpvremote/__main__.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
#!/usr/bin/env python3
|
||||
import sys
|
||||
import os
|
||||
import curses
|
||||
from mpvremote.app import App
|
||||
from mpvremote.config import SOCKET
|
||||
|
||||
|
||||
def main():
|
||||
if not os.path.exists(SOCKET):
|
||||
print(f"Error: Socket {SOCKET} not found or mpv is not running.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
def run_app(stdscr):
|
||||
app = App(stdscr)
|
||||
app.run()
|
||||
|
||||
try:
|
||||
curses.wrapper(run_app)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
146
mpvremote/app.py
Normal file
146
mpvremote/app.py
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import curses
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from mpvremote import config
|
||||
from mpvremote.mpv_client import MpvClient
|
||||
from mpvremote.views import MainView, PlaylistView, QueueView
|
||||
|
||||
|
||||
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)
|
||||
|
||||
self._init_colors()
|
||||
|
||||
self.height, self.width = stdscr.getmaxyx()
|
||||
|
||||
self.views = {
|
||||
'main': MainView(self),
|
||||
'playlist': PlaylistView(self),
|
||||
'queue': QueueView(self),
|
||||
}
|
||||
self.current_view = self.views['main']
|
||||
self.messages = []
|
||||
|
||||
# Video cache + background rescan
|
||||
self._video_cache = []
|
||||
self._video_cache_lock = threading.Lock()
|
||||
self._rescan_stop = threading.Event()
|
||||
self._scan_videos()
|
||||
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():
|
||||
with self._video_cache_lock:
|
||||
self._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)))
|
||||
|
||||
with self._video_cache_lock:
|
||||
self._video_cache = sorted(videos, key=lambda x: x[0].lower())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _rescan_loop(self):
|
||||
"""Background thread that rescans every 2 minutes."""
|
||||
while not self._rescan_stop.is_set():
|
||||
for _ in range(120):
|
||||
if self._rescan_stop.is_set():
|
||||
break
|
||||
time.sleep(1)
|
||||
if not self._rescan_stop.is_set():
|
||||
self._scan_videos()
|
||||
|
||||
def get_videos(self):
|
||||
"""Return cached video list. Thread-safe."""
|
||||
with self._video_cache_lock:
|
||||
return self._video_cache[:]
|
||||
|
||||
def run(self):
|
||||
while self.running:
|
||||
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
|
||||
21
mpvremote/config.py
Normal file
21
mpvremote/config.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import os
|
||||
|
||||
SOCKET = "/tmp/mpv-socket"
|
||||
MEDIA_DIR = os.path.expanduser("~/GDrive/Videos")
|
||||
VIDEO_EXTS = ["mp4", "mkv", "avi", "webm"]
|
||||
|
||||
# Key codes
|
||||
KEY_PAUSE = ord(' ')
|
||||
KEY_SEEK_BACK = ord('h')
|
||||
KEY_SEEK_FWD = ord('l')
|
||||
KEY_VOL_UP = ord('k')
|
||||
KEY_VOL_DOWN = ord('j')
|
||||
KEY_SUB = ord('s')
|
||||
KEY_NEXT = ord('n')
|
||||
KEY_PREV = ord('p')
|
||||
KEY_LOOP_FILE = ord('r')
|
||||
KEY_LOOP_PLIST = ord('R')
|
||||
KEY_CONTROLS = ord('c')
|
||||
KEY_QUEUE = ord('o')
|
||||
KEY_PLAYLIST = ord('e')
|
||||
KEY_QUIT = ord('q')
|
||||
32
mpvremote/mpv_client.py
Normal file
32
mpvremote/mpv_client.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import json
|
||||
import socket
|
||||
|
||||
|
||||
class MpvClient:
|
||||
"""Low-level mpv IPC client. Knows nothing about curses or UI."""
|
||||
|
||||
def __init__(self, socket_path):
|
||||
self.socket_path = socket_path
|
||||
|
||||
def _send(self, data):
|
||||
try:
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
|
||||
sock.settimeout(2)
|
||||
sock.connect(self.socket_path)
|
||||
payload = json.dumps(data) + "\n"
|
||||
sock.sendall(payload.encode())
|
||||
response = sock.recv(8192).decode()
|
||||
if response:
|
||||
return json.loads(response)
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def command(self, *args):
|
||||
return self._send({"command": list(args)})
|
||||
|
||||
def get_property(self, prop):
|
||||
result = self._send({"command": ["get_property", prop]})
|
||||
if result and "data" in result:
|
||||
return result["data"]
|
||||
return None
|
||||
260
mpvremote/views.py
Normal file
260
mpvremote/views.py
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
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()
|
||||
|
||||
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")
|
||||
|
||||
pause_str = "|| Paused" if pause else "> Playing"
|
||||
vol_str = f"Vol: {int(vol)}" if vol is not None else "Vol: ?"
|
||||
avail = max(1, self.app.width - len(pause_str) - len(vol_str) - 10)
|
||||
title_display = title[:avail]
|
||||
|
||||
text = f" {pause_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 [h/l]Seek [j/k]Vol [s]Sub [e]Plist [o]Queue [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 == config.KEY_SEEK_BACK:
|
||||
self.app.client.command("seek", "-10")
|
||||
self.app.add_message(">> Seeked -10s")
|
||||
elif key == config.KEY_SEEK_FWD:
|
||||
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 | [h/l] Seek -10s/+10s | [j/k] Vol -/+")
|
||||
self.app.add_message("[s] Subtitle | [o] Queue | [e] Playlist | [n/p] Next/Prev")
|
||||
self.app.add_message("[r] Loop File | [R] Loop Playlist | [q] Quit")
|
||||
elif key == config.KEY_PLAYLIST:
|
||||
self.app.switch_view('playlist')
|
||||
elif key == config.KEY_QUEUE:
|
||||
self.app.switch_view('queue')
|
||||
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]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):
|
||||
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
|
||||
|
||||
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()]
|
||||
|
||||
if self.search != self._last_search:
|
||||
self.selected = 0
|
||||
self.scroll_offset = 0
|
||||
self._last_search = self.search
|
||||
|
||||
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
|
||||
|
||||
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.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):
|
||||
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(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()
|
||||
return True
|
||||
Loading…
Add table
Add a link
Reference in a new issue