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
+43
-28
@@ -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):
|
||||
|
||||
Reference in new issue
Block a user