Start of python rewrite

This commit is contained in:
mrkmntal 2026-09-25 23:08:19 -04:00
commit 67f98312ca
7 changed files with 488 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
# Don't commit pycache!
*__pycache__*

2
mpvremote/__init__.py Normal file
View file

@ -0,0 +1,2 @@
from mpvremote.app import App
from mpvremote.config import SOCKET

25
mpvremote/__main__.py Normal file
View 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
View 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
View 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
View 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
View 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