diff --git a/somafm_tui/__main__.py b/somafm_tui/__main__.py index ddf1433..8b211c7 100644 --- a/somafm_tui/__main__.py +++ b/somafm_tui/__main__.py @@ -34,8 +34,11 @@ def _build_parser() -> argparse.ArgumentParser: parser.add_argument("--volume", type=int, metavar="0-100", help="initial volume") parser.add_argument( "--no-mpris", + "--no-media", + dest="no_mpris", action="store_true", - help="do not announce playback over MPRIS", + help="do not publish to the desktop media session (MPRIS on Linux, " + "Now Playing on macOS) and ignore the media keys", ) parser.add_argument( "--offline", @@ -51,7 +54,8 @@ def _build_parser() -> argparse.ArgumentParser: "--spectrum-source", choices=SOURCE_MODES, default="auto", - help="spectrum audio source: auto (monitor→stream→sim), monitor, " + help="spectrum audio source: auto (monitor→stream→sim), monitor " + "(parec on Linux, an avfoundation loopback device on macOS), " "stream (muted second ffmpeg decode), or sim", ) parser.add_argument( @@ -109,7 +113,7 @@ def main(argv: Sequence[str] | None = None) -> int: autoplay=args.autoplay, station_id=args.station, volume=args.volume, - mpris=not args.no_mpris, + media=not args.no_mpris, offline=args.offline, audio=not args.no_audio, spectrum_source=args.spectrum_source, diff --git a/somafm_tui/app.py b/somafm_tui/app.py index 98fbd88..4ac5627 100644 --- a/somafm_tui/app.py +++ b/somafm_tui/app.py @@ -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): diff --git a/somafm_tui/mediasession.py b/somafm_tui/mediasession.py new file mode 100644 index 0000000..9e38f21 --- /dev/null +++ b/somafm_tui/mediasession.py @@ -0,0 +1,160 @@ +"""Desktop media integration, with a platform-neutral face. + +The rest of the app only ever talks to this small surface:: + + session = create_media_session(actions, enabled=True) + session.start() + session.set_track(station=…, title=…, artist=…, album=…, art_url=…) + session.set_status("Playing") + session.set_volume(0.7) + +which keeps the platform differences in one place: + +* **Linux / BSD / any D-Bus desktop** — :class:`somafm_tui.mpris.MprisService` + exports ``org.mpris.MediaPlayer2.somafmtui.instance`` on the session + bus, so GNOME/KDE widgets, lock screens and ``playerctl`` show the station + and can drive playback. +* **macOS** — mpv *is* the app the system knows about: its Cocoa backend + registers with ``MPRemoteCommandCenter``/``MPNowPlayingInfoCenter``, which + is what Control Center, the menu bar and the media keys talk to. So there + is nothing to publish here, and the media keys are routed back into the TUI + by mpv (see :mod:`somafm_tui.player`). :class:`NowPlayingSession` only + keeps the state the status bar renders and stays out of the way. + +Anything else (or a disabled/absent backend) degrades to an inert session +whose status label is ``off`` — the TUI carries on regardless. +""" + +from __future__ import annotations + +import sys +from queue import Queue +from typing import Any + +__all__ = [ + "MediaSession", + "NowPlayingSession", + "create_media_session", + "media_actions_queue", +] + + +def media_actions_queue() -> Queue[str]: + """Queue of commands coming back from the desktop media integration.""" + return Queue() + + +class MediaSession: + """Inert media session: keeps state, publishes nothing. + + Used as the base for backends that have no IPC service of their own and as + the fallback when desktop integration is unavailable. + """ + + #: Short name for the status bar ("mpris", "nowplaying", …). + label = "media" + + def __init__(self, actions: Queue[str], *, enabled: bool = True) -> None: + self.actions = actions + self.enabled = bool(enabled) + self.available = False + self.error = "" + self._status = "Stopped" + self._volume = 0.7 + self._metadata: dict[str, Any] = {} + + # ----- lifecycle ---------------------------------------------------- # + + def start(self) -> None: + return None + + def stop(self) -> None: + return None + + # ----- state -------------------------------------------------------- # + + @property + def status_label(self) -> str: + if not self.enabled: + return "off" + if self.available: + return "on" + return "error" if self.error else "off" + + def set_status(self, status: str) -> None: + if status in ("Playing", "Paused", "Stopped"): + self._status = status + + def set_volume(self, ratio: float) -> None: + self._volume = max(0.0, min(1.0, float(ratio))) + + def set_track( + self, + *, + station: str, + title: str, + artist: str, + album: str = "", + art_url: str = "", + stream_url: str = "", + track_key: str = "", + ) -> None: + self._metadata = { + "station": station, + "title": title, + "artist": artist, + "album": album, + "art_url": art_url, + "stream_url": stream_url, + "track_key": track_key, + } + + +class NowPlayingSession(MediaSession): + """macOS: mpv owns the system Now Playing session, we just mirror state. + + mpv's Cocoa backend publishes the live ICY title (and play/pause state) to + Control Center and receives the media keys; :mod:`somafm_tui.player` routes + those key presses back into this app. ``available`` is false when the audio + backend is not mpv, because then nobody publishes anything. + """ + + label = "nowplaying" + + def __init__( + self, + actions: Queue[str], + *, + enabled: bool = True, + backend: str | None = "mpv", + ) -> None: + super().__init__(actions, enabled=enabled) + self.backend = backend + self.available = bool(enabled) and backend == "mpv" + + @property + def status_label(self) -> str: + if not self.enabled: + return "off" + return "on" if self.available else "off" + + +def create_media_session( + actions: Queue[str], + *, + enabled: bool = True, + backend: str | None = None, + platform: str | None = None, +) -> MediaSession: + """Pick the right desktop media backend for this platform. + + ``platform``/``backend`` exist so the choice is testable without touching + the real machine. + """ + system = sys.platform if platform is None else platform + if system == "darwin": + return NowPlayingSession(actions, enabled=enabled, backend=backend) + + from .mpris import MprisService + + return MprisService(actions, enabled=enabled) diff --git a/somafm_tui/player.py b/somafm_tui/player.py index a391855..9da7a31 100644 --- a/somafm_tui/player.py +++ b/somafm_tui/player.py @@ -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 `` 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 diff --git a/somafm_tui/widgets/status.py b/somafm_tui/widgets/status.py index 2530394..8fa9ab1 100644 --- a/somafm_tui/widgets/status.py +++ b/somafm_tui/widgets/status.py @@ -32,7 +32,8 @@ class StatusBar(Widget): width = max(20, self.size.width) glyph, colour = state_glyph(app.playing_state) frame = app.spectrum.snapshot() - mpris = app.mpris.status_label + session = app.media.label + status = app.media.status_label backend = app.player.backend or "no audio" analyser = frame.label @@ -44,19 +45,21 @@ class StatusBar(Widget): # From most to least informative; pick the first combination that fits. right_variants = ( - f"{state} {listeners}{volume} {backend} mpris {mpris} {analyser}", + f"{state} {listeners}{volume} {backend} {session} {status} {analyser}", + f"{state} {volume} {session} {status} {analyser}", f"{state} {volume} {analyser}", f"{state} {volume}", volume, ) options = ( (HINTS_LONG, right_variants[0]), - (HINTS_LONG, right_variants[1]), (HINTS_MEDIUM, right_variants[0]), + (HINTS_LONG, right_variants[1]), (HINTS_MEDIUM, right_variants[1]), (HINTS_SHORT, right_variants[1]), (HINTS_SHORT, right_variants[2]), (HINTS_SHORT, right_variants[3]), + (HINTS_SHORT, right_variants[4]), ) chosen: tuple[int, str, str] | None = None diff --git a/tests/test_app.py b/tests/test_app.py index e30c038..16a7253 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -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 diff --git a/tests/test_mediasession.py b/tests/test_mediasession.py new file mode 100644 index 0000000..f5fede1 --- /dev/null +++ b/tests/test_mediasession.py @@ -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() diff --git a/tests/test_player.py b/tests/test_player.py index 369d54d..b6a749e 100644 --- a/tests/test_player.py +++ b/tests/test_player.py @@ -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" + ]