diff options
Diffstat (limited to 'radio_tui/player.py')
| -rw-r--r-- | radio_tui/player.py | 163 |
1 files changed, 163 insertions, 0 deletions
diff --git a/radio_tui/player.py b/radio_tui/player.py new file mode 100644 index 0000000..7f87782 --- /dev/null +++ b/radio_tui/player.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +import json +import shutil +import subprocess +import threading +import time +from dataclasses import dataclass +from pathlib import Path + + +@dataclass +class PlayerState: + station_name: str = "" + stream_url: str = "" + media_title: str = "" + volume: int = 50 + muted: bool = False + playing: bool = False + offline: bool = False + + +class MpvPlayer: + def __init__(self, socket_path: Path | None = None) -> None: + self.socket_path = socket_path or Path("/tmp/radio_tui_mpvsocket") + self.state = PlayerState() + self._process: subprocess.Popen[bytes] | None = None + self._poll_thread: threading.Thread | None = None + self._stop_polling = threading.Event() + self._lock = threading.Lock() + + @property + def available(self) -> bool: + return shutil.which("mpv") is not None and shutil.which("socat") is not None + + def _ipc(self, command: list[str]) -> dict | None: + if not self.socket_path.exists(): + return None + payload = json.dumps({"command": command}) + "\n" + try: + result = subprocess.run( + ["socat", "-", str(self.socket_path)], + input=payload.encode(), + capture_output=True, + timeout=2, + check=False, + ) + except (OSError, subprocess.TimeoutExpired): + return None + if not result.stdout: + return None + try: + return json.loads(result.stdout.decode()) + except json.JSONDecodeError: + return None + + def _ensure_mpv(self, stream_url: str, volume: int) -> None: + if self._process and self._process.poll() is None: + return + self._stop_polling.set() + if self._poll_thread: + self._poll_thread.join(timeout=1) + self._stop_polling.clear() + if self.socket_path.exists(): + self.socket_path.unlink(missing_ok=True) + self._process = subprocess.Popen( + [ + "mpv", + f"--volume={volume}", + f"--input-ipc-server={self.socket_path}", + "--idle=yes", + "--no-terminal", + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + for _ in range(30): + if self.socket_path.exists(): + break + time.sleep(0.1) + + def play(self, station_name: str, stream_url: str) -> None: + with self._lock: + volume = self.state.volume + self._ensure_mpv(stream_url, volume) + if self._process and self._process.poll() is None and self.socket_path.exists(): + self._ipc(["loadfile", stream_url]) + self.state.station_name = station_name + self.state.stream_url = stream_url + self.state.media_title = "Connecting..." + self.state.playing = True + self.state.offline = False + self._start_poll() + + def stop(self) -> None: + with self._lock: + self._stop_polling.set() + if self._poll_thread: + self._poll_thread.join(timeout=1) + if self._process and self._process.poll() is None: + self._process.terminate() + try: + self._process.wait(timeout=2) + except subprocess.TimeoutExpired: + self._process.kill() + self._process = None + self.socket_path.unlink(missing_ok=True) + self.state = PlayerState(volume=self.state.volume) + + def adjust_volume(self, delta: int) -> None: + with self._lock: + self._ipc(["add", "volume", delta]) + response = self._ipc(["get_property", "volume"]) + if response and "data" in response: + self.state.volume = int(response["data"]) + + def toggle_mute(self) -> None: + with self._lock: + self._ipc(["cycle", "mute"]) + response = self._ipc(["get_property", "mute"]) + if response and "data" in response: + self.state.muted = bool(response["data"]) + + def _start_poll(self) -> None: + if self._poll_thread and self._poll_thread.is_alive(): + self._stop_polling.set() + self._poll_thread.join(timeout=1) + self._stop_polling.clear() + + def poll() -> None: + misses = 0 + while not self._stop_polling.is_set(): + response = self._ipc(["get_property", "media-title"]) + title = "" + if response and response.get("data"): + title = str(response["data"]) + if title: + misses = 0 + with self._lock: + self.state.media_title = title + self.state.offline = title == "Station Offline" + else: + misses += 1 + if misses >= 3: + with self._lock: + self.state.media_title = "Station Offline" + self.state.offline = True + self._stop_polling.wait(3) + + self._poll_thread = threading.Thread(target=poll, daemon=True) + self._poll_thread.start() + + def snapshot(self) -> PlayerState: + with self._lock: + return PlayerState( + station_name=self.state.station_name, + stream_url=self.state.stream_url, + media_title=self.state.media_title, + volume=self.state.volume, + muted=self.state.muted, + playing=self.state.playing, + offline=self.state.offline, + ) |
