feat(ui): add catalogue picker, schedule pane and per-provider hero
This commit is contained in:
1 parent
058d336a2c
commit
b31ad3f6fb
16 files changed
+1211
-65
No files matched your search
@@ -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/).
|
||||
Reference in new issue
Block a user