Registry lookup and its fallback for a stale slug, SomaFM branding and guessed mirrors, provider round-trip in the config, legacy-config migration and BEEFY_*/SOMAFM_* precedence - plus an end-to-end boot against a fake second provider, which is what actually proves the UI no longer knows about SomaFM.
The app now holds one RadioProvider and asks it for stations, song history and stream URLs instead of importing SomaFM's HTTP client directly.
- models.py: provider-neutral Song/Station plus the shared parsers every catalogue needs (Station.provider/.uid keep ids unique once there is more than one).
- providers/base.py: the RadioProvider interface and the toolkit it needs in practice - httpx client factory, .pls/.m3u resolution with an 8h disk cache, and a default stream_candidates() strategy.
- providers/somafm/: everything SomaFM-specific (channels.json, songs/<id>.json, PLS layout, guessed icecast mirrors) behind SomaFMProvider.
- providers/__init__.py: registry, get_provider() and --provider, remembered in the config file.
Widgets render branding, splash copy and the help credits from the active provider, so a second catalogue needs no UI changes; with SomaFM selected the screen is byte-identical to before.
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.
SYNC mode on macOS needed a virtual loopback driver (BlackHole, Loopback,
Soundflower) routed through a Multi-Output Device, and shelled out to
'ffmpeg -f avfoundation' to read it. macOS 14.2 lets a process tap the
system mix directly, so do that instead: CATapDescription ->
AudioHardwareCreateProcessTap -> private aggregate device -> IOProc,
straight through ctypes. No driver, no helper binary, no ffmpeg.
The tap hands out native-rate interleaved float32, so the analyser can
now retune its window and band edges when the capture rate differs from
44.1 kHz, and mono-downmixes the frames before the FFT.
A tap that was never granted System Audio Recording permission is
created happily but only ever delivers digital silence: treat that as a
failed capture instead of flat bars, explain it in the status bar and
fall back to the stream tap. A tap that renders nothing *yet* (playback
still starting, or mpv pointed at another device) is not a permanent
failure either - the stream tap covers for a minute, then the tap is
retried.
Drop the avfoundation device enumeration, the loopback-name heuristics
and the microphone safeguards' tests, and document the new permission
and its diagnostics instead.
iTerm2 images live in the cell grid, unlike Kitty placements: repainting
the mosaic behind them erases the picture. Re-emit the image whenever the
mosaic identity (station + track) changes, even if the artwork bytes are
the same.
Also send doNotMoveCursor=1 to iTerm2 itself so the terminal does not
scroll or jump the cursor out from under Textual's compositor; WezTerm
keeps the previous payload since it is pickier about parameters.
Some shells and wrappers export ZELLIJ=0 or TMUX=0 outside a multiplexer.
A plain truthiness check treated that as 'inside tmux/zellij' and
downgraded iTerm2/Kitty to the ANSI mosaic, so real artwork silently
disappeared. Only meaningful values now count.
SYNC mode was Linux-only: it shelled out to parec. On macOS there is no
PulseAudio, so the analyser always fell back to the drifting stream tap.
Capture with 'ffmpeg -f avfoundation' from a loopback input instead
(BlackHole, Loopback, Soundflower, Multi-Output/Aggregate devices are
auto-detected; SOMAFM_SPECTRUM_DEVICE accepts a name or index). Plain
microphones are never auto-selected.
A loopback that nothing is routed into delivers endless digital silence,
so treat ~6 s of all-zero blocks as a dead monitor and fall back to the
stream tap rather than showing frozen bars. parec handling is unchanged.
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.