This commit is contained in:
nose
2025-12-12 21:55:38 -08:00
parent e2ffcab030
commit 85750247cc
78 changed files with 5726 additions and 6239 deletions

View File

@@ -4,6 +4,31 @@ local msg = require 'mp.msg'
local M = {}
-- Lyrics overlay toggle
-- The Python helper (python -m MPV.lyric) will read this property via IPC.
local LYRIC_VISIBLE_PROP = "user-data/medeia-lyric-visible"
local function lyric_get_visible()
local ok, v = pcall(mp.get_property_native, LYRIC_VISIBLE_PROP)
if not ok or v == nil then
return true
end
return v and true or false
end
local function lyric_set_visible(v)
pcall(mp.set_property_native, LYRIC_VISIBLE_PROP, v and true or false)
end
local function lyric_toggle()
local now = not lyric_get_visible()
lyric_set_visible(now)
mp.osd_message("Lyrics: " .. (now and "on" or "off"), 1)
end
-- Default to visible unless user overrides.
lyric_set_visible(true)
-- Configuration
local opts = {
python_path = "python",
@@ -138,4 +163,8 @@ mp.add_key_binding("mbtn_right", "medios-menu-right-click", M.show_menu)
mp.add_key_binding("ctrl+i", "medios-info", M.get_file_info)
mp.add_key_binding("ctrl+del", "medios-delete", M.delete_current_file)
-- Lyrics toggle (requested: 'L')
mp.add_key_binding("l", "medeia-lyric-toggle", lyric_toggle)
mp.add_key_binding("L", "medeia-lyric-toggle-shift", lyric_toggle)
return M

1195
MPV/lyric.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -12,6 +12,7 @@ import os
import platform
import socket
import subprocess
import sys
import time as _time
from pathlib import Path
from typing import Any, Dict, Optional, List, BinaryIO, cast
@@ -24,6 +25,88 @@ FIXED_IPC_PIPE_NAME = "mpv-medeia-macina"
MPV_LUA_SCRIPT_PATH = str(Path(__file__).resolve().parent / "LUA" / "main.lua")
_LYRIC_PROCESS: Optional[subprocess.Popen] = None
_LYRIC_LOG_FH: Optional[Any] = None
def _windows_list_lyric_helper_pids(ipc_path: str) -> List[int]:
"""Return PIDs of `python -m MPV.lyric --ipc <ipc_path>` helpers (Windows only)."""
if platform.system() != "Windows":
return []
try:
ipc_path = str(ipc_path or "")
except Exception:
ipc_path = ""
if not ipc_path:
return []
# Use CIM to query command lines; output as JSON for robust parsing.
# Note: `ConvertTo-Json` returns a number for single item, array for many, or null.
ps_script = (
"$ipc = "
+ json.dumps(ipc_path)
+ "; "
"Get-CimInstance Win32_Process | "
"Where-Object { $_.CommandLine -and $_.CommandLine -match ' -m\\s+MPV\\.lyric(\\s|$)' -and $_.CommandLine -match ('--ipc\\s+' + [regex]::Escape($ipc)) } | "
"Select-Object -ExpandProperty ProcessId | ConvertTo-Json -Compress"
)
try:
out = subprocess.check_output(
["powershell", "-NoProfile", "-Command", ps_script],
stdin=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=2,
text=True,
)
except Exception:
return []
txt = (out or "").strip()
if not txt or txt == "null":
return []
try:
obj = json.loads(txt)
except Exception:
return []
pids: List[int] = []
if isinstance(obj, list):
for v in obj:
try:
pids.append(int(v))
except Exception:
pass
else:
try:
pids.append(int(obj))
except Exception:
pass
# De-dupe and filter obvious junk.
uniq: List[int] = []
for pid in pids:
if pid and pid > 0 and pid not in uniq:
uniq.append(pid)
return uniq
def _windows_kill_pids(pids: List[int]) -> None:
if platform.system() != "Windows":
return
for pid in pids or []:
try:
subprocess.run(
["taskkill", "/PID", str(int(pid)), "/F"],
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=2,
)
except Exception:
continue
class MPVIPCError(Exception):
"""Raised when MPV IPC communication fails."""
pass
@@ -38,7 +121,7 @@ class MPV:
- Query playlist and currently playing item via IPC
This class intentionally stays "dumb": it does not implement app logic.
App behavior is driven by cmdlets (e.g. `.pipe`) and the bundled Lua script.
App behavior is driven by cmdlet (e.g. `.pipe`) and the bundled Lua script.
"""
def __init__(
@@ -55,11 +138,11 @@ class MPV:
lua_path = Path(str(lua_script_path)).resolve()
self.lua_script_path = str(lua_path)
def client(self) -> "MPVIPCClient":
return MPVIPCClient(socket_path=self.ipc_path, timeout=self.timeout)
def client(self, silent: bool = False) -> "MPVIPCClient":
return MPVIPCClient(socket_path=self.ipc_path, timeout=self.timeout, silent=bool(silent))
def is_running(self) -> bool:
client = self.client()
client = self.client(silent=True)
try:
ok = client.connect()
return bool(ok)
@@ -67,7 +150,7 @@ class MPV:
client.disconnect()
def send(self, command: Dict[str, Any] | List[Any], silent: bool = False) -> Optional[Dict[str, Any]]:
client = self.client()
client = self.client(silent=bool(silent))
try:
if not client.connect():
return None
@@ -136,9 +219,109 @@ class MPV:
except Exception:
return
def ensure_lyric_loader_running(self) -> None:
"""Start (or keep) the Python lyric overlay helper.
Uses the fixed IPC pipe name so it can follow playback.
"""
global _LYRIC_PROCESS, _LYRIC_LOG_FH
# Cross-session guard (Windows): avoid spawning multiple helpers across separate CLI runs.
# Also clean up stale helpers when mpv isn't running anymore.
if platform.system() == "Windows":
try:
existing = _windows_list_lyric_helper_pids(str(self.ipc_path))
if existing:
if not self.is_running():
_windows_kill_pids(existing)
return
# If multiple exist, kill them and start fresh (prevents double overlays).
if len(existing) == 1:
return
_windows_kill_pids(existing)
except Exception:
pass
try:
if _LYRIC_PROCESS is not None and _LYRIC_PROCESS.poll() is None:
return
except Exception:
pass
try:
if _LYRIC_PROCESS is not None:
try:
_LYRIC_PROCESS.terminate()
except Exception:
pass
finally:
_LYRIC_PROCESS = None
try:
if _LYRIC_LOG_FH is not None:
_LYRIC_LOG_FH.close()
except Exception:
pass
_LYRIC_LOG_FH = None
try:
try:
tmp_dir = Path(os.environ.get("TEMP") or os.environ.get("TMP") or ".")
except Exception:
tmp_dir = Path(".")
log_path = str((tmp_dir / "medeia-mpv-lyric.log").resolve())
# Ensure the module can be imported even when the app is launched from a different cwd.
# Repo root = parent of the MPV package directory.
try:
repo_root = Path(__file__).resolve().parent.parent
except Exception:
repo_root = Path.cwd()
cmd: List[str] = [
sys.executable,
"-m",
"MPV.lyric",
"--ipc",
str(self.ipc_path),
"--log",
log_path,
]
# Redirect helper stdout/stderr to the log file so we can see crashes/import errors.
try:
_LYRIC_LOG_FH = open(log_path, "a", encoding="utf-8", errors="replace")
except Exception:
_LYRIC_LOG_FH = None
kwargs: Dict[str, Any] = {
"stdin": subprocess.DEVNULL,
"stdout": _LYRIC_LOG_FH or subprocess.DEVNULL,
"stderr": _LYRIC_LOG_FH or subprocess.DEVNULL,
}
# Ensure immediate flushing to the log file.
env = os.environ.copy()
env["PYTHONUNBUFFERED"] = "1"
try:
existing_pp = env.get("PYTHONPATH")
env["PYTHONPATH"] = str(repo_root) if not existing_pp else (str(repo_root) + os.pathsep + str(existing_pp))
except Exception:
pass
kwargs["env"] = env
# Make the current directory the repo root so `-m MPV.lyric` resolves reliably.
kwargs["cwd"] = str(repo_root)
if platform.system() == "Windows":
kwargs["creationflags"] = 0x00000008 # DETACHED_PROCESS
_LYRIC_PROCESS = subprocess.Popen(cmd, **kwargs)
debug(f"Lyric loader started (log={log_path})")
except Exception as exc:
debug(f"Lyric loader failed to start: {exc}")
def wait_for_ipc(self, retries: int = 20, delay_seconds: float = 0.2) -> bool:
for _ in range(max(1, retries)):
client = self.client()
client = self.client(silent=True)
try:
if client.connect():
return True
@@ -233,7 +416,7 @@ class MPVIPCClient:
It handles platform-specific differences (Windows named pipes vs Unix sockets).
"""
def __init__(self, socket_path: Optional[str] = None, timeout: float = 5.0):
def __init__(self, socket_path: Optional[str] = None, timeout: float = 5.0, silent: bool = False):
"""Initialize MPV IPC client.
Args:
@@ -244,6 +427,7 @@ class MPVIPCClient:
self.socket_path = socket_path or get_ipc_pipe_path()
self.sock: socket.socket | BinaryIO | None = None
self.is_windows = platform.system() == "Windows"
self.silent = bool(silent)
def connect(self) -> bool:
"""Connect to mpv IPC socket.
@@ -259,17 +443,20 @@ class MPVIPCClient:
self.sock = open(self.socket_path, 'r+b', buffering=0)
return True
except (OSError, IOError) as exc:
debug(f"Failed to connect to MPV named pipe: {exc}")
if not self.silent:
debug(f"Failed to connect to MPV named pipe: {exc}")
return False
else:
# Unix domain socket (Linux, macOS)
if not os.path.exists(self.socket_path):
debug(f"IPC socket not found: {self.socket_path}")
if not self.silent:
debug(f"IPC socket not found: {self.socket_path}")
return False
af_unix = getattr(socket, "AF_UNIX", None)
if af_unix is None:
debug("IPC AF_UNIX is not available on this platform")
if not self.silent:
debug("IPC AF_UNIX is not available on this platform")
return False
self.sock = socket.socket(af_unix, socket.SOCK_STREAM)
@@ -277,7 +464,8 @@ class MPVIPCClient:
self.sock.connect(self.socket_path)
return True
except Exception as exc:
debug(f"Failed to connect to MPV IPC: {exc}")
if not self.silent:
debug(f"Failed to connect to MPV IPC: {exc}")
self.sock = None
return False