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

+50
View File
@@ -166,3 +166,53 @@ def test_backend_detection_without_tools(tmp_path, monkeypatch) -> None:
event, _ = wait_for(player, lambda ev: ev.kind == "state", timeout=1)
assert event.state == "error"
player.shutdown()
def test_media_key_messages_become_player_events() -> None:
player = Player()
player._handle_mpv_message(
{"event": "client-message", "args": ["somafm", "next"]}
)
event = player.events.get_nowait()
assert event.kind == "media"
assert event.detail == "next"
player._handle_mpv_message(
{"event": "client-message", "args": ["somafm", "toggle"]}
)
assert player.events.get_nowait().detail == "toggle"
# Foreign namespaces, unknown commands and malformed payloads are ignored.
player._handle_mpv_message({"event": "client-message", "args": ["other", "next"]})
player._handle_mpv_message({"event": "client-message", "args": ["somafm", "seek"]})
player._handle_mpv_message({"event": "client-message", "args": None})
player._handle_mpv_message({"event": "client-message"})
assert player.events.empty()
def test_platform_options_never_leak_to_linux(tmp_path, monkeypatch) -> None:
import somafm_tui.player as player_module
monkeypatch.setattr(player_module, "RUNTIME_DIR", tmp_path)
assert Player()._platform_options("linux") == []
assert Player()._platform_options("win32") == []
options = Player()._platform_options("darwin")
assert "--input-media-keys=yes" in options
conf = next(option for option in options if option.startswith("--input-conf="))
assert conf.endswith("mpv-input.conf")
bindings = (tmp_path / "mpv-input.conf").read_text()
for line in (
"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",
):
assert line in bindings
# --no-mpris / --no-media must silence the media keys as well.
assert Player(media_keys=False)._platform_options("darwin") == [
"--input-media-keys=no"
]