197 lines
5.6 KiB
Python
197 lines
5.6 KiB
Python
#!/usr/bin/env python3
|
|
"""MiniMax Music Player — Minimal single-line playback bar."""
|
|
|
|
import curses
|
|
import json
|
|
import locale
|
|
import os
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
|
|
import tempfile
|
|
|
|
SOCKET_PATH = os.path.join(tempfile.gettempdir(), "mpv-minimax-ipc")
|
|
|
|
|
|
class MpvIPC:
|
|
def __init__(self):
|
|
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
self.sock.connect(SOCKET_PATH)
|
|
self.sock.settimeout(0.3)
|
|
self._buf = ""
|
|
self._rid = 0
|
|
|
|
def _cmd(self, *args):
|
|
self._rid += 1
|
|
rid = self._rid
|
|
try:
|
|
self.sock.sendall(
|
|
(json.dumps({"command": list(args), "request_id": rid}) + "\n").encode()
|
|
)
|
|
except Exception:
|
|
return None
|
|
end = time.time() + 0.4
|
|
while time.time() < end:
|
|
try:
|
|
self._buf += self.sock.recv(8192).decode("utf-8", errors="replace")
|
|
except socket.timeout:
|
|
pass
|
|
while "\n" in self._buf:
|
|
line, self._buf = self._buf.split("\n", 1)
|
|
try:
|
|
r = json.loads(line)
|
|
if r.get("request_id") == rid:
|
|
return r.get("data") if r.get("error") == "success" else None
|
|
except (json.JSONDecodeError, KeyError):
|
|
continue
|
|
return None
|
|
|
|
def get(self, prop):
|
|
return self._cmd("get_property", prop)
|
|
|
|
def set(self, prop, val):
|
|
return self._cmd("set_property", prop, val)
|
|
|
|
def seek(self, secs):
|
|
return self._cmd("seek", secs, "relative")
|
|
|
|
def toggle_pause(self):
|
|
p = self.get("pause")
|
|
if p is not None:
|
|
self.set("pause", not p)
|
|
return not p
|
|
return False
|
|
|
|
def close(self):
|
|
try:
|
|
self.sock.close()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def fmt(s):
|
|
if not s or s < 0:
|
|
return "0:00"
|
|
return f"{int(s) // 60}:{int(s) % 60:02d}"
|
|
|
|
|
|
def main(stdscr, source, song_name):
|
|
locale.setlocale(locale.LC_ALL, "")
|
|
curses.curs_set(0)
|
|
stdscr.nodelay(True)
|
|
stdscr.timeout(300)
|
|
|
|
curses.start_color()
|
|
curses.use_default_colors()
|
|
curses.init_pair(1, curses.COLOR_GREEN, -1)
|
|
curses.init_pair(2, curses.COLOR_CYAN, -1)
|
|
curses.init_pair(3, curses.COLOR_WHITE, -1)
|
|
curses.init_pair(4, curses.COLOR_MAGENTA, -1)
|
|
|
|
GREEN = curses.color_pair(1) | curses.A_BOLD
|
|
CYAN = curses.color_pair(2) | curses.A_BOLD
|
|
DIM = curses.color_pair(3) | curses.A_DIM
|
|
MAG = curses.color_pair(4) | curses.A_BOLD
|
|
|
|
# Start mpv
|
|
if os.path.exists(SOCKET_PATH):
|
|
os.remove(SOCKET_PATH)
|
|
proc = subprocess.Popen(
|
|
["mpv", "--no-video", "--really-quiet",
|
|
f"--input-ipc-server={SOCKET_PATH}", source],
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
)
|
|
for _ in range(50):
|
|
if os.path.exists(SOCKET_PATH):
|
|
break
|
|
time.sleep(0.1)
|
|
|
|
try:
|
|
mpv = MpvIPC()
|
|
except Exception:
|
|
stdscr.addstr(0, 0, "Error: cannot connect to mpv")
|
|
stdscr.refresh()
|
|
time.sleep(2)
|
|
proc.kill()
|
|
return
|
|
|
|
try:
|
|
while proc.poll() is None:
|
|
h, w = stdscr.getmaxyx()
|
|
stdscr.erase()
|
|
|
|
pos = mpv.get("time-pos") or 0
|
|
dur = mpv.get("duration") or 0
|
|
vol = mpv.get("volume") or 100
|
|
paused = mpv.get("pause") or False
|
|
|
|
# Row 0: icon + song name
|
|
icon = "▐▐ " if paused else "▶ "
|
|
name = song_name if len(song_name) < w - 6 else song_name[:w - 9] + "..."
|
|
try:
|
|
stdscr.addstr(0, 1, icon, GREEN)
|
|
stdscr.addstr(0, 4, name, CYAN)
|
|
except curses.error:
|
|
pass
|
|
|
|
# Row 1: progress bar
|
|
time_l = fmt(pos)
|
|
time_r = fmt(dur)
|
|
bar_w = w - len(time_l) - len(time_r) - 5
|
|
if bar_w > 4 and dur > 0:
|
|
pct = min(pos / dur, 1.0)
|
|
filled = int(bar_w * pct)
|
|
bar = "━" * filled + "─" * (bar_w - filled)
|
|
try:
|
|
stdscr.addstr(1, 1, time_l, DIM)
|
|
stdscr.addstr(1, len(time_l) + 2, bar, MAG)
|
|
stdscr.addstr(1, len(time_l) + 2 + bar_w + 1, time_r, DIM)
|
|
except curses.error:
|
|
pass
|
|
|
|
# Row 2: controls hint
|
|
ctrl = "[space]pause [<>]seek [^v]vol [q]quit"
|
|
if len(ctrl) < w:
|
|
try:
|
|
stdscr.addstr(2, 1, ctrl, DIM)
|
|
except curses.error:
|
|
pass
|
|
|
|
stdscr.refresh()
|
|
|
|
key = stdscr.getch()
|
|
if key in (ord("q"), ord("Q")):
|
|
break
|
|
elif key == ord(" "):
|
|
mpv.toggle_pause()
|
|
elif key == curses.KEY_RIGHT:
|
|
mpv.seek(5)
|
|
elif key == curses.KEY_LEFT:
|
|
mpv.seek(-5)
|
|
elif key == curses.KEY_UP:
|
|
mpv.set("volume", min(150, int(vol) + 5))
|
|
elif key == curses.KEY_DOWN:
|
|
mpv.set("volume", max(0, int(vol) - 5))
|
|
|
|
finally:
|
|
mpv.close()
|
|
proc.terminate()
|
|
try:
|
|
proc.wait(timeout=2)
|
|
except subprocess.TimeoutExpired:
|
|
proc.kill()
|
|
if os.path.exists(SOCKET_PATH):
|
|
os.remove(SOCKET_PATH)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if len(sys.argv) < 2:
|
|
print("Usage: vinyl_player.py <file_or_url> [song_name]")
|
|
sys.exit(1)
|
|
|
|
source = sys.argv[1]
|
|
name = sys.argv[2] if len(sys.argv) > 2 else os.path.basename(source)
|
|
curses.wrapper(main, source, name)
|