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:
1 parent
d83a9ef03d
commit
9c722c5f61
8 files changed
+415
-37
No files matched your search
@@ -157,3 +157,23 @@ async def test_stage_shows_bandcamp_link_only_when_present() -> None:
|
||||
app.query_one(StagePanel).refresh_stage()
|
||||
await pilot.pause()
|
||||
assert "Bandcamp" not in rendered_text(app)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_media_session_commands_drive_the_app() -> None:
|
||||
app = SomaFMApp(autoplay=False, offline=True, audio=False)
|
||||
async with app.run_test(size=(100, 30)) as pilot:
|
||||
await pilot.pause()
|
||||
# The status bar names whichever backend this platform uses.
|
||||
assert app.media.label in {"mpris", "nowplaying"}
|
||||
assert app.media.status_label == "off" # audio disabled
|
||||
|
||||
# The ticker drains the queue, so give it a couple of ticks.
|
||||
before = app.current.id
|
||||
app.media_actions.put("next")
|
||||
await pilot.pause(0.2)
|
||||
assert app.current.id != before
|
||||
|
||||
app.media_actions.put("volume:25")
|
||||
await pilot.pause(0.2)
|
||||
assert app.volume == 25
|
||||
@@ -0,0 +1,56 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import queue
|
||||
import sys
|
||||
|
||||
from somafm_tui import mediasession
|
||||
from somafm_tui.mediasession import (
|
||||
MediaSession,
|
||||
NowPlayingSession,
|
||||
create_media_session,
|
||||
)
|
||||
|
||||
|
||||
def test_linux_uses_mpris() -> None:
|
||||
from somafm_tui.mpris import MprisService
|
||||
|
||||
session = create_media_session(queue.Queue(), enabled=False, platform="linux")
|
||||
assert isinstance(session, MprisService)
|
||||
assert session.label == "mpris"
|
||||
|
||||
|
||||
def test_macos_uses_the_system_now_playing_session() -> None:
|
||||
session = create_media_session(
|
||||
queue.Queue(), enabled=True, backend="mpv", platform="darwin"
|
||||
)
|
||||
assert isinstance(session, NowPlayingSession)
|
||||
assert session.label == "nowplaying"
|
||||
assert session.status_label == "on"
|
||||
|
||||
|
||||
def test_macos_without_mpv_reports_off() -> None:
|
||||
session = create_media_session(
|
||||
queue.Queue(), enabled=True, backend="ffplay", platform="darwin"
|
||||
)
|
||||
assert session.status_label == "off"
|
||||
|
||||
|
||||
def test_disabled_session_is_inert_but_keeps_state() -> None:
|
||||
actions: queue.Queue[str] = queue.Queue()
|
||||
session = MediaSession(actions, enabled=False)
|
||||
session.start()
|
||||
session.set_status("Playing")
|
||||
session.set_volume(1.7)
|
||||
session.set_track(station="Groove Salad", title="T", artist="A", album="B")
|
||||
session.stop()
|
||||
assert session.status_label == "off"
|
||||
assert session._status == "Playing"
|
||||
assert session._volume == 1.0
|
||||
assert session._metadata["album"] == "B"
|
||||
assert actions.empty()
|
||||
|
||||
|
||||
def test_platform_detection_is_available_for_tests() -> None:
|
||||
# The factory must never import D-Bus code on a platform without it.
|
||||
assert "linux" in sys.platform or sys.platform == "darwin"
|
||||
assert mediasession.media_actions_queue().empty()
|
||||
@@ -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"
|
||||
]
|
||||
Reference in new issue
Block a user