feat(macos): drive the system Now Playing session through mpv

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.
This commit is contained in:
beefcarver committed 2026-10-30 18:43:03 -04:00
1 parent d83a9ef03d
commit 9c722c5f61
8 files changed
+415 -37

No files matched your search

+73 -3
View File
@@ -20,6 +20,7 @@ import shlex
import shutil
import signal
import subprocess
import sys
import threading
import time
from collections.abc import Callable
@@ -28,12 +29,35 @@ from pathlib import Path
from typing import Any
from .api import USER_AGENT
from .config import RUNTIME_DIR
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"
kind: str = "state" # "state" | "volume" | "notice" | "media"
state: str = ""
detail: str = ""
volume: int | None = None
@@ -41,8 +65,9 @@ class PlayerEvent:
class Player:
def __init__(self) -> None:
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
@@ -295,6 +320,7 @@ class Player:
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))
@@ -330,6 +356,32 @@ class Player:
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:
@@ -367,6 +419,10 @@ class Player:
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")
@@ -409,6 +465,20 @@ class Player:
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