macOS has no D-Bus/MPRIS: the audio engine is what the OS media UI talks to. mpv's Cocoa backend registers with MPRemoteCommandCenter and MPNowPlayingInfoCenter, so it already publishes the live ICY title and receives the media keys. Add a platform-neutral media session (mediasession.py) so the app keeps a single interface: MprisService on D-Bus platforms, NowPlayingSession on macOS. Map mpv's NEXT/PREV/PLAY/PLAYONLY/PAUSEONLY/STOP keys onto script-messages and read them back as client-message IPC events, so the hardware media keys and Control Center drive the TUI (station changes, play/pause/stop) instead of fighting it. --no-mpris (now also --no-media) disables the media keys too. macOS-only mpv options are gated on sys.platform so the Linux command line is unchanged; mpv aborts on unknown flags.
622 lines
21 KiB
Python
622 lines
21 KiB
Python
"""Audio playback.
|
|
|
|
Primary backend is **mpv** driven over its JSON IPC socket (play/pause/volume/
|
|
mirror failover/stop). If mpv is missing we fall back to **ffplay**, which can
|
|
still play but offers no live control beyond SIGSTOP/SIGCONT.
|
|
|
|
The player runs in its own thread with a private asyncio loop. The UI talks to
|
|
it through :meth:`Player.play` / :meth:`Player.pause` / ... (thread safe) and
|
|
reads :attr:`Player.events` (a plain ``queue.Queue``) from its ticker.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import json
|
|
import os
|
|
import queue
|
|
import shlex
|
|
import shutil
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from .api import USER_AGENT
|
|
from .config import RUNTIME_DIR, atomic_write_text
|
|
|
|
#: mpv input bindings that turn the system media keys into TUI commands.
|
|
#:
|
|
#: On macOS mpv's Cocoa backend implements MPRemoteCommandCenter, so the
|
|
#: hardware/keyboard media keys arrive as these special key names. Instead of
|
|
#: letting mpv handle them (which would fight the TUI and desync the station
|
|
#: list, the spectrum and the desktop "now playing" state) we bounce them back
|
|
#: over the IPC socket as ``client-message`` events.
|
|
MEDIA_KEY_BINDINGS = """\
|
|
# Generated by somafm-tui -- do not edit; it is rewritten on every launch.
|
|
PLAY script-message somafm toggle
|
|
PLAYONLY script-message somafm play
|
|
PAUSEONLY script-message somafm pause
|
|
STOP script-message somafm stop
|
|
NEXT script-message somafm next
|
|
PREV script-message somafm prev
|
|
"""
|
|
|
|
#: Commands a media key may ask for; everything else is ignored.
|
|
MEDIA_COMMANDS = frozenset({"play", "pause", "toggle", "stop", "next", "prev"})
|
|
|
|
#: Prefix of the ``client-message`` namespace we listen on.
|
|
MEDIA_MESSAGE_PREFIX = "somafm"
|
|
|
|
|
|
@dataclass
|
|
class PlayerEvent:
|
|
kind: str = "state" # "state" | "volume" | "notice" | "media"
|
|
state: str = ""
|
|
detail: str = ""
|
|
volume: int | None = None
|
|
url: str = ""
|
|
|
|
|
|
class Player:
|
|
def __init__(self, media_keys: bool = True) -> None:
|
|
self.events: queue.Queue[PlayerEvent] = queue.Queue()
|
|
self.media_keys = media_keys
|
|
self.backend: str | None = None
|
|
self._loop: asyncio.AbstractEventLoop | None = None
|
|
self._thread: threading.Thread | None = None
|
|
self._ready = threading.Event()
|
|
self._stopping = threading.Event()
|
|
self._proc: subprocess.Popen[bytes] | None = None
|
|
self._reader: asyncio.StreamReader | None = None
|
|
self._writer: asyncio.StreamWriter | None = None
|
|
self._urls: list[str] = []
|
|
self._url_index = 0
|
|
self._volume = 70
|
|
self._paused = False
|
|
self._has_file = False
|
|
self._state = "stopped"
|
|
self._sock_path: Path | None = None
|
|
self._ffplay_proc: subprocess.Popen[bytes] | None = None
|
|
self._retry_scheduled = False
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# setup
|
|
# ------------------------------------------------------------------ #
|
|
|
|
@staticmethod
|
|
def detect_backend() -> str | None:
|
|
if shutil.which("mpv"):
|
|
return "mpv"
|
|
if shutil.which("ffplay"):
|
|
return "ffplay"
|
|
return None
|
|
|
|
def start(self) -> None:
|
|
if self._thread is not None:
|
|
return
|
|
self.backend = self.detect_backend()
|
|
if self.backend is None:
|
|
self._state = "error"
|
|
self.events.put(
|
|
PlayerEvent(
|
|
kind="state",
|
|
state="error",
|
|
detail="no audio backend found — install mpv or ffmpeg/ffplay",
|
|
)
|
|
)
|
|
return
|
|
self._thread = threading.Thread(
|
|
target=self._thread_main, name="somafm-player", daemon=True
|
|
)
|
|
self._thread.start()
|
|
self._ready.wait(10)
|
|
|
|
def _thread_main(self) -> None:
|
|
loop = asyncio.new_event_loop()
|
|
asyncio.set_event_loop(loop)
|
|
self._loop = loop
|
|
self._ready.set()
|
|
try:
|
|
loop.run_until_complete(self._run())
|
|
except Exception as exc: # pragma: no cover - defensive
|
|
self.events.put(
|
|
PlayerEvent(kind="state", state="error", detail=f"player stopped: {exc}")
|
|
)
|
|
finally:
|
|
try:
|
|
pending = asyncio.all_tasks(loop)
|
|
for task in pending:
|
|
task.cancel()
|
|
if pending:
|
|
loop.run_until_complete(
|
|
asyncio.gather(*pending, return_exceptions=True)
|
|
)
|
|
except Exception:
|
|
pass
|
|
loop.close()
|
|
self._loop = None
|
|
|
|
async def _run(self) -> None:
|
|
if self.backend == "mpv":
|
|
await self._run_mpv()
|
|
else:
|
|
await self._run_ffplay()
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# public, thread safe API
|
|
# ------------------------------------------------------------------ #
|
|
|
|
def play(self, urls: list[str]) -> None:
|
|
if not urls:
|
|
self._set_state("error", "no playable stream found")
|
|
return
|
|
self._submit(self._do_play, list(urls))
|
|
|
|
def pause(self) -> None:
|
|
self._submit(self._do_pause)
|
|
|
|
def resume(self) -> None:
|
|
self._submit(self._do_resume)
|
|
|
|
def toggle_pause(self) -> None:
|
|
self._submit(self._do_toggle)
|
|
|
|
def stop(self) -> None:
|
|
self._submit(self._do_stop)
|
|
|
|
def set_volume(self, value: int) -> None:
|
|
self._submit(self._do_volume, int(value))
|
|
|
|
def shutdown(self, timeout: float = 3.0) -> None:
|
|
if self._stopping.is_set():
|
|
return
|
|
self._stopping.set()
|
|
loop = self._loop
|
|
if loop is None or loop.is_closed():
|
|
return
|
|
try:
|
|
future = asyncio.run_coroutine_threadsafe(self._do_shutdown(), loop)
|
|
future.result(timeout)
|
|
except Exception:
|
|
pass
|
|
|
|
@property
|
|
def state(self) -> str:
|
|
return self._state
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# internals
|
|
# ------------------------------------------------------------------ #
|
|
|
|
def _submit(self, factory: Callable[..., Any], *args: Any) -> None:
|
|
loop = self._loop
|
|
if loop is None or loop.is_closed() or self._stopping.is_set():
|
|
return
|
|
with contextlib.suppress(RuntimeError):
|
|
loop.call_soon_threadsafe(lambda: asyncio.ensure_future(factory(*args)))
|
|
|
|
def _set_state(self, state: str, detail: str = "", url: str = "") -> None:
|
|
self._state = state
|
|
self.events.put(
|
|
PlayerEvent(kind="state", state=state, detail=detail, url=url)
|
|
)
|
|
|
|
def _current_url(self) -> str:
|
|
if 0 <= self._url_index < len(self._urls):
|
|
return self._urls[self._url_index]
|
|
return ""
|
|
|
|
# ----- commands ---------------------------------------------------- #
|
|
|
|
async def _do_play(self, urls: list[str]) -> None:
|
|
self._urls = urls
|
|
self._url_index = 0
|
|
self._paused = False
|
|
self._has_file = False
|
|
self._retry_scheduled = False
|
|
if self.backend == "mpv":
|
|
await self._mpv_load()
|
|
else:
|
|
await self._kill_ffplay()
|
|
|
|
async def _do_pause(self) -> None:
|
|
if self.backend == "mpv":
|
|
await self._mpv_send(["set_property", "pause", True])
|
|
elif self.backend == "ffplay" and self._ffplay_proc is not None:
|
|
self._ffplay_signal(signal.SIGSTOP)
|
|
self._paused = True
|
|
self._set_state("paused", "", self._current_url())
|
|
|
|
async def _do_resume(self) -> None:
|
|
if self.backend == "mpv":
|
|
await self._mpv_send(["set_property", "pause", False])
|
|
elif self.backend == "ffplay" and self._ffplay_proc is not None:
|
|
self._ffplay_signal(signal.SIGCONT)
|
|
self._paused = False
|
|
self._set_state("playing", "", self._current_url())
|
|
|
|
async def _do_toggle(self) -> None:
|
|
if self._paused:
|
|
await self._do_resume()
|
|
else:
|
|
await self._do_pause()
|
|
|
|
async def _do_stop(self) -> None:
|
|
self._urls = []
|
|
self._url_index = 0
|
|
self._paused = False
|
|
self._has_file = False
|
|
if self.backend == "mpv":
|
|
await self._mpv_send(["stop"])
|
|
else:
|
|
await self._kill_ffplay()
|
|
self._set_state("stopped")
|
|
|
|
async def _do_volume(self, value: int) -> None:
|
|
value = max(0, min(100, value))
|
|
self._volume = value
|
|
if self.backend == "mpv":
|
|
await self._mpv_send(["set_property", "volume", value])
|
|
self.events.put(PlayerEvent(kind="volume", volume=value))
|
|
|
|
async def _do_shutdown(self) -> None:
|
|
if self.backend == "mpv":
|
|
await self._mpv_send(["quit"])
|
|
await self._kill_ffplay()
|
|
await self._stop_mpv()
|
|
|
|
# ----- mpv --------------------------------------------------------- #
|
|
|
|
async def _run_mpv(self) -> None:
|
|
while not self._stopping.is_set():
|
|
try:
|
|
await self._start_mpv()
|
|
except Exception as exc:
|
|
if self._stopping.is_set():
|
|
return
|
|
self._set_state("error", f"mpv failed to start: {exc}")
|
|
await asyncio.sleep(3.0)
|
|
continue
|
|
|
|
try:
|
|
await self._read_mpv()
|
|
except (OSError, ValueError, ConnectionError, asyncio.IncompleteReadError):
|
|
pass
|
|
finally:
|
|
await self._stop_mpv()
|
|
|
|
if self._stopping.is_set():
|
|
return
|
|
self._set_state("buffering", "restarting audio engine…")
|
|
await asyncio.sleep(1.5)
|
|
|
|
async def _start_mpv(self) -> None:
|
|
RUNTIME_DIR.mkdir(parents=True, exist_ok=True)
|
|
self._sock_path = RUNTIME_DIR / f"mpv-{os.getpid()}.sock"
|
|
with contextlib.suppress(OSError):
|
|
self._sock_path.unlink()
|
|
|
|
command = [
|
|
"mpv",
|
|
"--no-config",
|
|
"--no-video",
|
|
"--really-quiet",
|
|
"--no-terminal",
|
|
"--idle=yes",
|
|
"--force-window=no",
|
|
f"--input-ipc-server={self._sock_path}",
|
|
f"--volume={self._volume}",
|
|
"--cache=yes",
|
|
"--cache-secs=20",
|
|
"--demuxer-max-bytes=32MiB",
|
|
"--audio-client-name=somafm-tui",
|
|
f"--user-agent={USER_AGENT}",
|
|
"--network-timeout=20",
|
|
]
|
|
command.extend(self._platform_options())
|
|
extra = os.environ.get("SOMAFM_MPV_ARGS")
|
|
if extra:
|
|
command.extend(shlex.split(extra))
|
|
|
|
self._proc = subprocess.Popen(
|
|
command,
|
|
stdin=subprocess.DEVNULL,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
start_new_session=True,
|
|
)
|
|
|
|
deadline = time.monotonic() + 6.0
|
|
while True:
|
|
if self._stopping.is_set():
|
|
raise RuntimeError("shutting down")
|
|
if self._proc.poll() is not None:
|
|
raise RuntimeError(f"mpv exited with code {self._proc.returncode}")
|
|
try:
|
|
self._reader, self._writer = await asyncio.open_unix_connection(
|
|
str(self._sock_path)
|
|
)
|
|
break
|
|
except (FileNotFoundError, ConnectionRefusedError, OSError):
|
|
if time.monotonic() > deadline:
|
|
raise RuntimeError(
|
|
"timed out waiting for mpv IPC socket"
|
|
) from None
|
|
await asyncio.sleep(0.05)
|
|
|
|
await self._mpv_send(["observe_property", 1, "pause"])
|
|
await self._mpv_send(["observe_property", 2, "volume"])
|
|
if self._urls:
|
|
await self._mpv_load()
|
|
|
|
def _platform_options(self, platform: str | None = None) -> list[str]:
|
|
"""mpv options that only exist (or only make sense) on some platforms.
|
|
|
|
Keeping them here means the Linux command line stays exactly what it
|
|
always was -- mpv aborts on unknown options, so never pass a macOS-only
|
|
flag unconditionally.
|
|
"""
|
|
system = sys.platform if platform is None else platform
|
|
if system != "darwin":
|
|
return []
|
|
return self._macos_media_key_options()
|
|
|
|
def _macos_media_key_options(self) -> list[str]:
|
|
"""Let mpv publish Now Playing, and route its media keys into the TUI."""
|
|
if not self.media_keys:
|
|
return ["--input-media-keys=no"]
|
|
options = ["--input-media-keys=yes"]
|
|
try:
|
|
RUNTIME_DIR.mkdir(parents=True, exist_ok=True)
|
|
conf = RUNTIME_DIR / "mpv-input.conf"
|
|
atomic_write_text(conf, MEDIA_KEY_BINDINGS)
|
|
except OSError:
|
|
# Now Playing still works; only next/prev/stop stay inside mpv.
|
|
return options
|
|
return [*options, f"--input-conf={conf}"]
|
|
|
|
async def _mpv_send(self, command: list[Any]) -> None:
|
|
writer = self._writer
|
|
if writer is None:
|
|
return
|
|
try:
|
|
writer.write((json.dumps({"command": command}) + "\n").encode("utf-8"))
|
|
await writer.drain()
|
|
except (OSError, RuntimeError):
|
|
pass
|
|
|
|
async def _mpv_load(self) -> None:
|
|
url = self._current_url()
|
|
if not url:
|
|
return
|
|
self._has_file = False
|
|
await self._mpv_send(["loadfile", url, "replace"])
|
|
await self._mpv_send(["set_property", "pause", False])
|
|
self._paused = False
|
|
self._set_state("loading", "tuning in…", url)
|
|
|
|
async def _read_mpv(self) -> None:
|
|
reader = self._reader
|
|
if reader is None:
|
|
return
|
|
while not self._stopping.is_set():
|
|
line = await reader.readline()
|
|
if not line:
|
|
return
|
|
try:
|
|
message = json.loads(line)
|
|
except ValueError:
|
|
continue
|
|
if isinstance(message, dict):
|
|
self._handle_mpv_message(message)
|
|
|
|
def _handle_mpv_message(self, message: dict[str, Any]) -> None:
|
|
event = message.get("event")
|
|
if event == "client-message":
|
|
self._handle_client_message(message.get("args"))
|
|
return
|
|
|
|
if event == "property-change":
|
|
name = message.get("name")
|
|
value = message.get("data")
|
|
if name == "pause":
|
|
self._paused = bool(value)
|
|
if self._has_file:
|
|
state = "paused" if self._paused else "playing"
|
|
self._set_state(state, "", self._current_url())
|
|
elif name == "volume" and isinstance(value, (int, float)):
|
|
self._volume = max(0, min(100, round(value)))
|
|
self.events.put(PlayerEvent(kind="volume", volume=self._volume))
|
|
return
|
|
|
|
if event == "start-file":
|
|
self._has_file = True
|
|
self._set_state("loading", "buffering stream…", self._current_url())
|
|
return
|
|
|
|
if event == "file-loaded":
|
|
self._has_file = True
|
|
state = "paused" if self._paused else "playing"
|
|
self._set_state(state, "", self._current_url())
|
|
return
|
|
|
|
if event == "end-file":
|
|
self._has_file = False
|
|
reason = str(message.get("reason") or "")
|
|
if reason == "stop" or self._stopping.is_set():
|
|
self._set_state("stopped")
|
|
elif reason in ("eof", "error"):
|
|
self._schedule_retry(reason)
|
|
return
|
|
|
|
if event == "idle":
|
|
if not self._urls:
|
|
self._set_state("stopped")
|
|
return
|
|
|
|
error = message.get("error")
|
|
if error and error != "success":
|
|
self.events.put(PlayerEvent(kind="notice", detail=str(error)))
|
|
|
|
def _handle_client_message(self, args: Any) -> None:
|
|
"""Turn ``script-message somafm <command>`` into a player event.
|
|
|
|
mpv's macOS backend feeds the system media keys (and Control Center
|
|
buttons) into its input system; ``MEDIA_KEY_BINDINGS`` maps them onto
|
|
these messages, which arrive over the JSON IPC socket.
|
|
"""
|
|
if not isinstance(args, list) or len(args) < 2:
|
|
return
|
|
namespace, command = str(args[0]), str(args[1])
|
|
if namespace != MEDIA_MESSAGE_PREFIX or command not in MEDIA_COMMANDS:
|
|
return
|
|
self.events.put(PlayerEvent(kind="media", detail=command))
|
|
|
|
def _schedule_retry(self, reason: str) -> None:
|
|
if self._retry_scheduled or not self._urls or self._stopping.is_set():
|
|
return
|
|
self._retry_scheduled = True
|
|
self._submit(self._retry_stream, reason)
|
|
|
|
async def _retry_stream(self, reason: str) -> None:
|
|
self._retry_scheduled = False
|
|
if not self._urls or self._stopping.is_set():
|
|
return
|
|
if self._url_index + 1 >= len(self._urls):
|
|
self._set_state(
|
|
"error", f"stream failed ({reason}) — press enter to retry"
|
|
)
|
|
return
|
|
self._url_index += 1
|
|
self._set_state(
|
|
"buffering",
|
|
f"reconnecting via mirror {self._url_index + 1}/{len(self._urls)}…",
|
|
self._current_url(),
|
|
)
|
|
await asyncio.sleep(1.0)
|
|
if self.backend == "mpv":
|
|
await self._mpv_load()
|
|
else:
|
|
await self._kill_ffplay()
|
|
|
|
async def _stop_mpv(self) -> None:
|
|
writer = self._writer
|
|
self._writer = None
|
|
self._reader = None
|
|
if writer is not None:
|
|
try:
|
|
writer.close()
|
|
await writer.wait_closed()
|
|
except Exception:
|
|
pass
|
|
|
|
proc = self._proc
|
|
self._proc = None
|
|
if proc is not None:
|
|
try:
|
|
if proc.poll() is None:
|
|
proc.terminate()
|
|
try:
|
|
await asyncio.to_thread(proc.wait, 2)
|
|
except Exception:
|
|
proc.kill()
|
|
except Exception:
|
|
pass
|
|
|
|
if self._sock_path is not None:
|
|
with contextlib.suppress(OSError):
|
|
self._sock_path.unlink()
|
|
self._sock_path = None
|
|
|
|
# ----- ffplay fallback --------------------------------------------- #
|
|
|
|
async def _run_ffplay(self) -> None:
|
|
while not self._stopping.is_set():
|
|
if not self._urls:
|
|
if self._ffplay_proc is not None:
|
|
await self._kill_ffplay()
|
|
if self._state != "stopped":
|
|
self._set_state("stopped")
|
|
await asyncio.sleep(0.2)
|
|
continue
|
|
|
|
if self._ffplay_proc is None:
|
|
self._start_ffplay()
|
|
elif self._ffplay_proc.poll() is not None:
|
|
self._ffplay_proc = None
|
|
if not self._paused and not self._stopping.is_set():
|
|
if self._url_index + 1 < len(self._urls):
|
|
self._url_index += 1
|
|
self._set_state(
|
|
"buffering",
|
|
f"reconnecting via mirror {self._url_index + 1}/{len(self._urls)}…",
|
|
self._current_url(),
|
|
)
|
|
else:
|
|
self._set_state(
|
|
"error", "stream ended — press enter to retry"
|
|
)
|
|
self._urls = []
|
|
await asyncio.sleep(0.2)
|
|
|
|
def _start_ffplay(self) -> None:
|
|
url = self._current_url()
|
|
if not url:
|
|
return
|
|
self._set_state("loading", "tuning in…", url)
|
|
command = [
|
|
"ffplay",
|
|
"-nodisp",
|
|
"-autoexit",
|
|
"-loglevel",
|
|
"quiet",
|
|
"-volume",
|
|
str(self._volume),
|
|
url,
|
|
]
|
|
try:
|
|
self._ffplay_proc = subprocess.Popen(
|
|
command,
|
|
stdin=subprocess.DEVNULL,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
start_new_session=True,
|
|
)
|
|
except OSError as exc:
|
|
self._set_state("error", f"ffplay failed: {exc}")
|
|
self._urls = []
|
|
return
|
|
self._paused = False
|
|
self._set_state(
|
|
"playing", "ffplay fallback (volume changes apply on next tune-in)", url
|
|
)
|
|
|
|
def _ffplay_signal(self, sig: int) -> None:
|
|
proc = self._ffplay_proc
|
|
if proc is not None and proc.poll() is None:
|
|
with contextlib.suppress(OSError):
|
|
proc.send_signal(sig)
|
|
|
|
async def _kill_ffplay(self) -> None:
|
|
proc = self._ffplay_proc
|
|
self._ffplay_proc = None
|
|
if proc is None:
|
|
return
|
|
try:
|
|
if proc.poll() is None:
|
|
proc.send_signal(signal.SIGCONT)
|
|
proc.terminate()
|
|
try:
|
|
await asyncio.to_thread(proc.wait, 1)
|
|
except Exception:
|
|
proc.kill()
|
|
except OSError:
|
|
pass
|