feat(ui): add catalogue picker, schedule pane and per-provider hero

This commit is contained in:
beefcarver committed 2026-11-03 17:51:27 -05:00
1 parent 058d336a2c
commit b31ad3f6fb
16 files changed
+1211 -65

No files matched your search

+99 -19
View File
@@ -8,10 +8,12 @@ own ANSI palette on the terminal's default background so it fits any theme
(including transparent terminals).
The station catalogue is pluggable. Beefy Player ships with
**[SomaFM](https://somafm.com)** (its first and, at the moment, only provider),
and the UI never talks to a station catalogue directly: it asks a
[provider](#providers) for stations, song history and stream URLs. Adding
another internet-radio catalogue is a self-contained module, not a rewrite.
**[SomaFM](https://somafm.com)** (≈50 curated channels) and
**[tilderadio](https://tilderadio.org)** (the tildeverse's single community
stream), and the UI never talks to a station catalogue directly: it asks a
[provider](#providers) for stations, song history and stream URLs. Press `p` to
switch catalogue without restarting. Adding another internet-radio catalogue is
a self-contained module, not a rewrite.
It runs the same way on **Linux** (PulseAudio/PipeWire) and **macOS**
(CoreAudio, iTerm2/Kitty/WezTerm): MPRIS on the desktop bus where that exists,
@@ -44,12 +46,23 @@ implemented here from public endpoints and open protocols.
"played N minutes ago", with procedural ANSI-shaded album art that changes
per track. When the current track can be found on Bandcamp, a clickable
**Bandcamp ↗** link appears below it; otherwise the line stays hidden.
Catalogues can opt out with `bandcamp = False` (tilderadio does), and a
provider can fill the `Station.presenter` line instead — tilderadio shows
`auto dj · library rotation` between live shows and `dj <name>` during one.
- **Last played** — the full recent history, newest first.
- **Every station** — with SomaFM that is the whole `channels.json` roster
(≈50 channels), live listener counts and genre search.
(≈50 channels), live listener counts and genre search; tilderadio is a
single-station catalogue, and the list simply holds one.
- **Favourites** that float to the top, remembered between runs.
- **Pluggable catalogues** — one `providers` module per radio network, selected
with `--provider` and remembered in the config file.
- **Pluggable catalogues** — one `providers` module per radio network, with a
`p` picker in the UI, `--provider` on the command line, and the choice
remembered per catalogue (including the last station you tuned).
- **A hero per catalogue** — the top-left art comes from the provider: SomaFM's
block wordmark, or tilderadio's radio icon converted from its logo PNG to
half-block ANSI by `tools/ansi_art.py`.
- **Schedule pane** — a single-stream catalogue gets a broadcast schedule
instead of a list of one station: who is on air now, since/until when, and
the upcoming shows grouped by day.
- **Desktop media integration** — on Linux an
`org.mpris.MediaPlayer2.beefyplayer.instance<pid>` player exposes
`PlaybackStatus`, `Metadata` (title/artist/album/artUrl), `Volume`, and
@@ -153,27 +166,69 @@ session — is provider-neutral.
class RadioProvider(ABC):
id, name, homepage, brand, tagline, station_source, history_source, user_agent
hero, hero_gradient, hero_lines # the header art and its caption
has_schedule # schedule pane instead of a list
bandcamp # look tracks up on Bandcamp?
async def fetch_stations(self, client) -> list[Station]: ...
def load_cached_stations(self) -> list[Station]: ...
def fallback_stations(self) -> list[Station]: ...
async def fetch_history(self, client, station_id) -> list[Song]: ...
def load_cached_history(self, station_id) -> list[Song]: ...
async def fetch_schedule(self, client) -> list[Broadcast]: ...
def load_cached_schedule(self) -> list[Broadcast]: ...
def fallback_urls(self, station) -> list[str]: ...
async def stream_candidates(self, client, station) -> list[str]: ...
```
`hero` rows are rich markup (half blocks); with `hero_gradient` the header
paints plain glyphs with the app's spectrum palette instead, which is how the
SomaFM wordmark stays theme-aware. `has_schedule` swaps the left pane for the
schedule view — the pane asks the provider for `Broadcast` objects (name, DJ,
start, end, live) and never learns where they came from.
SomaFM is the reference implementation, and it is quarantined in its own
package: the `channels.json` schema, the `songs/<station>.json` history, the PLS
layout and the guessed icecast mirrors all live in
`beefy_player/providers/somafm/`. Nothing outside that package imports it; the
app only ever holds a `RadioProvider`.
Two catalogues ship today:
| slug | catalogue | station list | history | streams |
| --- | --- | --- | --- | --- |
| `somafm` | [SomaFM](https://somafm.com) | `channels.json` (≈50) | `songs/<station>.json` | `.pls` → icecast mirrors |
| `tilderadio` | [tilderadio](https://tilderadio.org) | `/api/now/` (1) | `now_playing` + `history[]` | AzuraCast mounts (mp3/ogg, 320k) |
They are deliberately different shapes: one has fifty channels and PLS
playlists, the other has a single stream whose whole catalogue arrives in the
same response as its history. That is the seam working.
The tilderadio package also exercises the two optional capabilities: its left
pane is the broadcast schedule (`/api/schedule/`, with the live DJ coming from
`/api/now/`) rather than a list containing one station, and its header art is
the station's own radio icon converted to half blocks. The icon is generated,
not hand-drawn — regenerate it after the logo changes:
```bash
uv run python tools/ansi_art.py \
--url https://tilderadio.org/logos/tilderadio.png \
--cols 22 --rows 7 \
--module beefy_player/providers/tilderadio/hero.py
```
`tools/ansi_art.py` crops the transparent margin, resizes to 22×14 pixels (two
per text cell), quantises to the stock 16-colour ANSI palette and emits
run-length encoded rich markup — so the art follows the terminal's palette
instead of pinning RGB values. `--preview out.png` draws the result, which is
far easier to judge than ▀ and ▄.
Adding a catalogue:
1. create `beefy_player/providers/<slug>/` with a `RadioProvider` subclass,
2. register one instance in `beefy_player/providers/__init__.py`,
3. run it with `beefy-player --provider <slug>` (or set `"provider"` in
`~/.config/beefy-player/config.json`).
3. run it with `beefy-player --provider <slug>`, press `p` in the UI, or set
`"provider"` in `~/.config/beefy-player/config.json`.
`RadioProvider.stream_candidates()` already implements the common strategy —
resolve the station's playlists (with an on-disk cache), then try
@@ -211,6 +266,7 @@ Useful flags:
```bash
uv run beefy-player --provider somafm # pick a station catalogue
uv run beefy-player --provider tilderadio # one community stream, tildeverse
uv run beefy-player --play # start the last station immediately
uv run beefy-player --station defcon # pick a station
uv run beefy-player --volume 55
@@ -246,7 +302,8 @@ The UI wants roughly **84×24**; it also works smaller, just less pretty.
| `/` | filter by title, genre, description |
| `esc` | clear filter / close search |
| `v` | visualiser: mirror → bars → wave → waterfall → radial → braille |
| `r` | refresh stations + song history |
| `r` | refresh stations, schedule + song history |
| `p` | switch station catalogue (SomaFM / tilderadio)|
| `g` / `G` | top / bottom |
| `?` | help |
| `q` | quit |
@@ -290,7 +347,8 @@ beefy_player/
config.py XDG paths, settings, BEEFY_* env helpers
providers/
base.py RadioProvider interface + HTTP/playlist toolkit
somafm/ the SomaFM catalogue (API, bundled fallback list)
somafm/ the SomaFM catalogue (API, hero, bundled fallback list)
tilderadio/ the tilderadio catalogue (one station, hero, schedule)
player.py mpv over a Unix-socket JSON IPC channel
spectrum.py analyser (parec / Core Audio tap / ffmpeg / sim)
macos_tap.py macOS output capture (ctypes + Core Audio)
@@ -298,7 +356,10 @@ beefy_player/
mpris.py dbus-next MPRIS service
artwork.py Kitty TGP / iTerm2 images with an ANSI mosaic fallback
bandcamp.py "is this track on Bandcamp?" lookups
widgets/ the Textual widgets
widgets/ the Textual widgets (schedule pane, catalogue picker, …)
tools/
ansi_art.py logo PNG -> half-block ANSI hero (regenerates tilderadio's)
```
- `beefy_player/providers/base.py` — the provider interface and the shared
@@ -310,8 +371,20 @@ beefy_player/
- `songs/<station>.json` → last played tracks (cached on disk)
Playlist and channel caching means the UI opens instantly and still works
offline.
- `beefy_player/providers/tilderadio/api.py` — the same idea for a
single-station catalogue: `/api/now/` carries the station, the live DJ, the
listener count and both `now_playing` and `history[]`, while AzuraCast's
mount list contributes the bitrates (320k mp3, 192k mp3/ogg). Station and
history caches are per catalogue, so the two providers never overwrite each
other's files.
- `beefy_player/app.py` — holds one provider and one `Player`, ticks at 20 Hz,
and hands the widgets plain `Station`/`Song` objects.
and hands the widgets plain `Station`/`Song`/`Broadcast` objects. `p` swaps
the provider in place: the old stream is stopped, workers for the previous
catalogue are cancelled, the hero height and left pane follow the new
provider, and the station list (or schedule) reloads from cache first.
- `beefy_player/widgets/schedule.py` — the left pane for single-stream
catalogues: the live slot with its `since`/`until` window, then upcoming
shows grouped by local day, with `… +N more` when the pane runs out.
- `beefy_player/player.py` — mpv over a Unix-socket JSON IPC channel. Observes
`pause`/`volume`, handles mirror failover on `end-file`, auto-restarts a dead
engine, and uses `SIGSTOP`/`SIGCONT` when falling back to `ffplay`. On macOS
@@ -341,13 +414,18 @@ beefy_player/
### Files
```
~/.config/beefy-player/config.json favourites, volume, last station, mode
~/.cache/beefy-player/channels.json last good station list
~/.cache/beefy-player/playlists.json resolved stream URLs
~/.cache/beefy-player/songs/<id>.json song history snapshots
~/.cache/beefy-player/artwork/<sha1> cached album/channel artwork
~/.config/beefy-player/config.json favourites, volume, provider, last stations
~/.cache/beefy-player/channels.json last good SomaFM station list
~/.cache/beefy-player/channels-<id>.json last good list, other catalogues
~/.cache/beefy-player/schedule-<id>.json last good broadcast schedule
~/.cache/beefy-player/playlists.json resolved stream URLs
~/.cache/beefy-player/songs/<id>.json song history snapshots
~/.cache/beefy-player/artwork/<sha1> cached album/channel artwork
```
`last_stations` remembers which station each catalogue was tuned to, so
switching back with `p` lands on the same channel.
These stay in the XDG locations on every platform (macOS included), so the
same dotfiles/config work everywhere; `XDG_CONFIG_HOME`, `XDG_CACHE_HOME` and
`XDG_RUNTIME_DIR` are honoured when set. The app started life as
@@ -408,6 +486,8 @@ so favourites and volume survive the rename.
metered link, use `--spectrum-source monitor` (or `--no-audio` for a UI-only
demo).
- SomaFM is listener-supported: if you enjoy it, [donate](https://somafm.com/support/).
- tilderadio is a volunteer-run community station: [drop by](https://tilderadio.org),
or show up on the [schedule](https://tilderadio.org/schedule/) with a set.
## NixOS (optional aside)
@@ -435,6 +515,6 @@ uv run beefy-player
WTFPL — Do What The Fuck You Want To Public License, Version 2. See
[LICENSE](LICENSE). SomaFM and its channel names are trademarks of
SomaFM.com, LLC.
SomaFM.com, LLC. tilderadio belongs to the tildeverse community.
Terminal image rendering uses [Pillow](https://python-pillow.org/).