aboutsummaryrefslogtreecommitdiff
path: root/radio_tui/player.py
diff options
context:
space:
mode:
authorveradec <glitchmill.dyj3d@simplelogin.co>2026-10-25 22:06:42 +0530
committerveradec <glitchmill.dyj3d@simplelogin.co>2026-10-25 22:06:42 +0530
commitb518cd156166b16d8c7acca8e63efc65ebe13383 (patch)
tree1ebc1fa6367f47e738162a048c246de12cb96862 /radio_tui/player.py
init: init radio_tuiHEADmaster
Diffstat (limited to 'radio_tui/player.py')
-rw-r--r--radio_tui/player.py163
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,
+ )