Files
beefcarver e30ca09627 refactor!: rename somafm-tui to beefy-player
The app is now a general internet-radio player rather than a SomaFM client, so the package, console script, MPRIS identity, mpv namespaces, macOS tap names, XDG directories and docs all move to beefy-player.

Environment variables are now BEEFY_* (the old SOMAFM_* spellings are still honoured) and an existing ~/.config/somafm-tui/config.json is migrated on first run, so favourites and volume survive the rename.

The visible interface is untouched: same layout, same logo, same keys.
2026-11-03 16:10:25 -05:00

161 lines
4.8 KiB
Python

"""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:`beefy_player.mpris.MprisService`
exports ``org.mpris.MediaPlayer2.beefyplayer.instance<pid>`` 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:`beefy_player.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:`beefy_player.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)