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

+43 -28
View File
@@ -20,7 +20,7 @@ from textual.widgets import Input
from . import api, artwork, bandcamp, config
from .api import Station
from .fallback import fallback_channels
from .mpris import MprisService
from .mediasession import create_media_session, media_actions_queue
from .player import Player
from .spectrum import SpectrumEngine
from .widgets.header import HeaderBar
@@ -92,7 +92,8 @@ class SomaFMApp(App[None]):
autoplay: bool | None = None,
station_id: str | None = None,
volume: int | None = None,
mpris: bool = True,
media: bool = True,
mpris: bool | None = None,
offline: bool = False,
audio: bool = True,
spectrum_source: str = "auto",
@@ -106,7 +107,11 @@ class SomaFMApp(App[None]):
# One-off command line overrides are deliberately *not* written back
# into the config file (only an explicit --volume is remembered).
self.autoplay = self.config.autoplay if autoplay is None else autoplay
self.mpris_enabled = self.config.mpris and mpris
# ``mpris`` is the historical keyword; desktop media integration is
# MPRIS on Linux and Now Playing (via mpv) on macOS.
if mpris is not None:
media = mpris
self.media_enabled = self.config.mpris and media
if volume is not None:
self.config.volume = max(0, min(100, int(volume)))
@@ -138,10 +143,15 @@ class SomaFMApp(App[None]):
self._closed = False
self._tick_timer = None
self.player = Player()
self.player = Player(media_keys=self.media_enabled)
self.spectrum = SpectrumEngine(source_mode=spectrum_source)
self.mpris_actions: queue.Queue[str] = queue.Queue()
self.mpris = MprisService(self.mpris_actions, enabled=self.mpris_enabled)
self.media_actions: queue.Queue[str] = media_actions_queue()
self.media = create_media_session(
self.media_actions,
enabled=self.media_enabled,
# No audio engine means nothing to publish from.
backend=Player.detect_backend() if audio else None,
)
# ------------------------------------------------------------------ #
# layout
@@ -164,7 +174,7 @@ class SomaFMApp(App[None]):
self.player.start()
self.player.set_volume(self.volume)
self.spectrum.start()
self.mpris.start()
self.media.start()
self._http = api.make_client()
@@ -179,7 +189,7 @@ class SomaFMApp(App[None]):
if cached:
self.current.history = cached
self.current.now_playing = cached[0]
self._update_mpris()
self._update_media()
self._tick_timer = self.set_interval(0.05, self._tick)
@@ -304,7 +314,7 @@ class SomaFMApp(App[None]):
self.playing_state = "loading"
self._set_notice(f"tuning in to {station.title}…", 5)
self._save_config()
self._update_mpris()
self._update_media()
self.query_one(StagePanel).refresh_stage()
self.query_one(HeaderBar).refresh()
@@ -330,7 +340,7 @@ class SomaFMApp(App[None]):
except Exception as exc:
self.playing_state = "error"
self._set_notice(f"could not resolve stream: {exc}", 8)
self._update_mpris()
self._update_media()
self.query_one(StagePanel).refresh_stage()
return
@@ -339,7 +349,7 @@ class SomaFMApp(App[None]):
if not urls:
self.playing_state = "error"
self._set_notice("no stream mirrors available", 8)
self._update_mpris()
self._update_media()
self.query_one(StagePanel).refresh_stage()
return
@@ -372,7 +382,7 @@ class SomaFMApp(App[None]):
self._set_notice(
f"♪ {songs[0].display_artist} — {songs[0].display_title}", 5
)
self._update_mpris()
self._update_media()
self.query_one(StagePanel).refresh_stage()
self.query_one(HeaderBar).refresh()
@@ -384,7 +394,7 @@ class SomaFMApp(App[None]):
if time.monotonic() - self._last_history_fetch >= self._history_refresh_seconds:
self._fetch_history(self.current.id)
def _update_mpris(self) -> None:
def _update_media(self) -> None:
station = self.current
if station is None:
return
@@ -395,7 +405,7 @@ class SomaFMApp(App[None]):
else "SomaFM"
)
if song is not None:
self.mpris.set_track(
self.media.set_track(
station=station.title,
title=song.display_title,
artist=song.display_artist,
@@ -405,7 +415,7 @@ class SomaFMApp(App[None]):
track_key=f"{station.id}|{song.key()}",
)
else:
self.mpris.set_track(
self.media.set_track(
station=station.title,
title=station.last_playing or station.title,
artist=station.dj or "SomaFM",
@@ -414,8 +424,8 @@ class SomaFMApp(App[None]):
stream_url=self._stream_url,
track_key=station.id,
)
self.mpris.set_status(self._mpris_status())
self.mpris.set_volume(self.volume / 100.0)
self.media.set_status(self._media_status())
self.media.set_volume(self.volume / 100.0)
self._update_bandcamp()
self._update_artwork()
@@ -518,7 +528,7 @@ class SomaFMApp(App[None]):
self._set_notice("artwork unavailable — using mosaic", 5)
self._refresh_art()
def _mpris_status(self) -> str:
def _media_status(self) -> str:
if self.playing_state == "paused":
return "Paused"
if self.playing_state in ("playing", "loading", "buffering"):
@@ -534,7 +544,7 @@ class SomaFMApp(App[None]):
if value > 0:
self._volume_before_mute = value
self.player.set_volume(value)
self.mpris.set_volume(value / 100.0)
self.media.set_volume(value / 100.0)
self._save_config()
self.query_one(StatusBar).refresh()
self.query_one(HeaderBar).refresh()
@@ -556,7 +566,7 @@ class SomaFMApp(App[None]):
def _tick_body(self) -> None:
self.tick_count += 1
self._drain_player()
self._drain_mpris()
self._drain_media()
if self.notice and time.monotonic() >= self._notice_until:
self.notice = ""
@@ -585,37 +595,42 @@ class SomaFMApp(App[None]):
if event.volume is not None and event.volume != self.volume:
self.volume = event.volume
self.config.volume = event.volume
self.mpris.set_volume(self.volume / 100.0)
self.media.set_volume(self.volume / 100.0)
self._save_config()
continue
if event.kind == "notice":
if event.detail:
self._set_notice(event.detail, 6)
continue
if event.kind == "media":
# Media keys / Control Center on macOS, routed via mpv.
if event.detail:
self.media_actions.put(event.detail)
continue
self.playing_state = event.state or self.playing_state
if event.url:
self._stream_url = event.url
if event.state in ("playing", "loading", "buffering"):
self.mpris.set_status("Playing")
self.media.set_status("Playing")
if event.url:
self.spectrum.set_source(event.url)
elif event.state == "paused":
self.mpris.set_status("Paused")
self.media.set_status("Paused")
self.spectrum.set_source(None)
elif event.state in ("stopped", "error"):
self.mpris.set_status("Stopped")
self.media.set_status("Stopped")
self.spectrum.set_source(None)
if event.detail:
self._set_notice(event.detail, 6)
self.query_one(StagePanel).refresh_stage()
def _drain_mpris(self) -> None:
def _drain_media(self) -> None:
while True:
try:
action = self.mpris_actions.get_nowait()
action = self.media_actions.get_nowait()
except queue.Empty:
return
if action == "play":
@@ -719,7 +734,7 @@ class SomaFMApp(App[None]):
self.player.stop()
self.spectrum.set_source(None)
self.playing_state = "stopped"
self.mpris.set_status("Stopped")
self.media.set_status("Stopped")
self._set_notice("stopped", 2)
self.query_one(StagePanel).refresh_stage()
self.query_one(HeaderBar).refresh()
@@ -853,7 +868,7 @@ class SomaFMApp(App[None]):
self._tick_timer.stop()
self._save_config()
with contextlib.suppress(Exception):
self.mpris.stop()
self.media.stop()
with contextlib.suppress(Exception):
self.spectrum.stop()
with contextlib.suppress(Exception):