From b31ad3f6fbb9bdffbb8976c8c0ee63ecda4469b2 Mon Sep 17 00:00:00 2001 From: beefcarver Date: Tue, 3 Nov 2026 17:51:27 -0500 Subject: [PATCH] feat(ui): add catalogue picker, schedule pane and per-provider hero --- README.md | 118 ++++++++++-- beefy_player/app.py | 176 +++++++++++++++++- beefy_player/providers/__init__.py | 3 +- beefy_player/providers/base.py | 34 +++- beefy_player/providers/somafm/__init__.py | 5 + beefy_player/providers/somafm/hero.py | 20 +++ beefy_player/theme.tcss | 21 +++ beefy_player/widgets/header.py | 89 +++++---- beefy_player/widgets/help.py | 46 ++++- beefy_player/widgets/providers.py | 87 +++++++++ beefy_player/widgets/schedule.py | 137 ++++++++++++++ tests/test_header.py | 55 +++++- tests/test_providers.py | 107 ++++++++++- tests/test_schedule.py | 166 +++++++++++++++++ tests/test_theme.py | 2 + tools/ansi_art.py | 210 ++++++++++++++++++++++ 16 files changed, 1211 insertions(+), 65 deletions(-) create mode 100644 beefy_player/providers/somafm/hero.py create mode 100644 beefy_player/widgets/providers.py create mode 100644 beefy_player/widgets/schedule.py create mode 100644 tests/test_schedule.py create mode 100644 tools/ansi_art.py diff --git a/README.md b/README.md index 652925f..9850a5b 100644 --- a/README.md +++ b/README.md @@ -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 ` 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` 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/.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/.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//` with a `RadioProvider` subclass, 2. register one instance in `beefy_player/providers/__init__.py`, -3. run it with `beefy-player --provider ` (or set `"provider"` in - `~/.config/beefy-player/config.json`). +3. run it with `beefy-player --provider `, 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/.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/.json song history snapshots -~/.cache/beefy-player/artwork/ 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-.json last good list, other catalogues +~/.cache/beefy-player/schedule-.json last good broadcast schedule +~/.cache/beefy-player/playlists.json resolved stream URLs +~/.cache/beefy-player/songs/.json song history snapshots +~/.cache/beefy-player/artwork/ 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/). diff --git a/beefy_player/app.py b/beefy_player/app.py index 92ed34b..5886f2a 100644 --- a/beefy_player/app.py +++ b/beefy_player/app.py @@ -25,13 +25,15 @@ from textual.widgets import Input from . import artwork, bandcamp, config from .mediasession import create_media_session, media_actions_queue -from .models import Station, parse_last_playing +from .models import Broadcast, Station, parse_last_playing from .player import Player from .providers import get_provider from .providers.base import is_direct_stream, make_client from .spectrum import SpectrumEngine from .widgets.header import HeaderBar from .widgets.help import HelpScreen +from .widgets.providers import ProviderScreen +from .widgets.schedule import SchedulePanel from .widgets.search import SearchInput from .widgets.spectrum import SpectrumPanel from .widgets.stage import StagePanel @@ -89,6 +91,7 @@ class BeefyPlayerApp(App[None]): Binding("escape", "escape", "close", show=False), Binding("v", "visualizer", "visualiser"), Binding("r", "refresh", "refresh"), + Binding("p", "provider", "catalogue"), Binding("question_mark", "help", "help"), Binding("q", "quit", "quit"), ] @@ -125,12 +128,17 @@ class BeefyPlayerApp(App[None]): if volume is not None: self.config.volume = max(0, min(100, int(volume))) - self.start_station_id = station_id or self.config.last_station + self.start_station_id = station_id or self.config.last_station_for( + self.provider.id + ) self.offline = offline self.audio_enabled = audio self.stations: list[Station] = [] self.view: list[Station] = [] + #: The current catalogue's broadcast schedule, when it has one. + self.schedule: list[Broadcast] = [] + self.schedule_loaded = False self.index = 0 self.query = "" self.current: Station | None = None @@ -144,6 +152,8 @@ class BeefyPlayerApp(App[None]): self._volume_before_mute = self.volume or 70 self._last_history_fetch = 0.0 self._history_refresh_seconds = 25.0 + self._schedule_refresh_seconds = 300.0 + self._last_schedule_fetch = 0.0 self._stream_url = "" self.artwork_url = "" self.artwork_bytes: bytes | None = None @@ -174,6 +184,7 @@ class BeefyPlayerApp(App[None]): with Vertical(id="left"): yield SearchInput() yield StationList() + yield SchedulePanel() yield StagePanel() yield SpectrumPanel(self.spectrum) yield StatusBar() @@ -188,6 +199,7 @@ class BeefyPlayerApp(App[None]): self.media.start() self._http = make_client(self.provider.user_agent) + self._apply_catalogue_chrome() stations = self.provider.load_cached_stations() if not stations: @@ -208,6 +220,9 @@ class BeefyPlayerApp(App[None]): self._refresh_stations() if self.current is not None: self._fetch_history(self.current.id) + if self.provider.has_schedule: + # Offline still shows the cached schedule — the worker decides. + self._refresh_schedule() self.set_focus(None) @@ -233,6 +248,7 @@ class BeefyPlayerApp(App[None]): self.config.volume = self.volume if self.current is not None: self.config.last_station = self.current.id + self.config.last_stations[self.provider.id] = self.current.id self.config.visualizer = self.visualizer self.config.save() @@ -315,6 +331,46 @@ class BeefyPlayerApp(App[None]): if self.current is None and view: self.current = view[self.index] + # ------------------------------------------------------------------ # + # the catalogue's chrome + # ------------------------------------------------------------------ # + + def _apply_catalogue_chrome(self) -> None: + """Point the chrome at the catalogue: hero height and the left pane.""" + provider = self.provider + hero_rows = len(getattr(provider, "hero", ()) or ()) + # Give the hero its rows, but never more than a quarter of the screen: + # on a short terminal the spectrum and the stage matter more. + screen_height = getattr(self, "size", None) + cap = max(4, (screen_height.height // 4) if screen_height else 10) + with contextlib.suppress(NoMatches): + header = self.query_one(HeaderBar) + header.styles.height = min(hero_rows + 1, cap) if hero_rows else 4 + header.refresh() + self.query_one(StationList).display = not provider.has_schedule + self.query_one(SchedulePanel).display = provider.has_schedule + if provider.has_schedule: + self._reset_filter() + + def _reset_filter(self) -> None: + """Forget the station filter and put the search box away.""" + self.query = "" + search = self.query_one(SearchInput) + search.value = "" + search.remove_class("visible") + search.disabled = True + self.query_one(StationList).set_filter("") + + def on_resize(self, event) -> None: + self._apply_catalogue_chrome() + + def _refresh_pane(self) -> None: + with contextlib.suppress(NoMatches): + if self.provider.has_schedule: + self.query_one(SchedulePanel).refresh() + else: + self.query_one(StationList).refresh() + # ------------------------------------------------------------------ # # playback # ------------------------------------------------------------------ # @@ -455,6 +511,11 @@ class BeefyPlayerApp(App[None]): song = station.now_playing if song is None or not (song.artist.strip() or song.title.strip()): return + if not getattr(self.provider, "bandcamp", True): + # Not a Bandcamp catalogue: never look the track up, and do not + # leave a stale link behind from the previous station. + song.bandcamp_url = "" + return track_key = f"{station.id}|{song.key()}" cached_url = self._bandcamp_urls.get(track_key) @@ -615,8 +676,9 @@ class BeefyPlayerApp(App[None]): self.query_one(HeaderBar).refresh() if self.tick_count % 12 == 0: self.query_one(StatusBar).refresh() - self.query_one(StationList).refresh() + self._refresh_pane() self._maybe_refresh_history() + self._maybe_refresh_schedule() def _drain_player(self) -> None: while True: @@ -693,6 +755,33 @@ class BeefyPlayerApp(App[None]): continue self._set_volume(value) + def _maybe_refresh_schedule(self) -> None: + if not self.provider.has_schedule or self.offline: + return + if ( + time.monotonic() - self._last_schedule_fetch + >= self._schedule_refresh_seconds + ): + self._refresh_schedule() + + @work(exclusive=True, group="schedule", exit_on_error=False) + async def _refresh_schedule(self) -> None: + if not self.provider.has_schedule: + return + self._last_schedule_fetch = time.monotonic() + broadcasts: list[Broadcast] = [] + if self.offline or self._http is None: + broadcasts = self.provider.load_cached_schedule() + else: + try: + broadcasts = await self.provider.fetch_schedule(self._http) + except Exception: + broadcasts = self.provider.load_cached_schedule() + self.schedule = broadcasts + self.schedule_loaded = True + with contextlib.suppress(NoMatches): + self.query_one(SchedulePanel).refresh() + # ------------------------------------------------------------------ # # workers # ------------------------------------------------------------------ # @@ -714,7 +803,9 @@ class BeefyPlayerApp(App[None]): self._set_stations(stations) if before is not None: self._select_station_id(before) - self._set_notice(f"{len(stations)} stations", 3) + self._set_notice( + "1 station" if len(stations) == 1 else f"{len(stations)} stations", 3 + ) self.query_one(StationList).refresh() # ------------------------------------------------------------------ # @@ -810,6 +901,9 @@ class BeefyPlayerApp(App[None]): self.query_one(StagePanel).refresh_stage() def action_search(self) -> None: + if self.provider.has_schedule: + self._set_notice("this catalogue shows a schedule, not a station list", 3) + return search = self.query_one(SearchInput) search.disabled = False search.add_class("visible") @@ -857,10 +951,84 @@ class BeefyPlayerApp(App[None]): return self.artwork_url = "" self._refresh_stations() + self._refresh_schedule() if self.current is not None: self._update_artwork() self._fetch_history(self.current.id) + def action_provider(self) -> None: + self.push_screen(ProviderScreen(self.provider.id), self._provider_chosen) + + def _provider_chosen(self, provider_id: str | None) -> None: + if provider_id: + self._switch_catalogue(provider_id) + + def _cancel_workers(self) -> None: + for group in ("stream", "history", "stations", "schedule", "bandcamp", "artwork"): + with contextlib.suppress(Exception): + self.workers.cancel_group(self, group) + + def _switch_catalogue(self, provider_id: str) -> None: + """Move to another station catalogue without restarting the app.""" + provider = get_provider(provider_id) + if provider is self.provider: + return + + self._cancel_workers() + with contextlib.suppress(Exception): + self.player.stop() + self.spectrum.set_source(None) + self.playing_state = "stopped" + self.media.set_status("Stopped") + + old_client = self._http + self.provider = provider + self._http = make_client(provider.user_agent) + if old_client is not None: + with contextlib.suppress(RuntimeError): + asyncio.get_running_loop().create_task(old_client.aclose()) + + self.config.provider = provider.id + self.config.save() + + self.stations = [] + self.view = [] + self.index = 0 + self.current = None + self.schedule = [] + self.schedule_loaded = False + self._last_schedule_fetch = 0.0 + self._last_history_fetch = 0.0 + self._bandcamp_key = "" + self._bandcamp_urls.clear() + self._stream_url = "" + self._art_track_key = "" + self.artwork_url = "" + self.artwork_bytes = None + self.start_station_id = self.config.last_station_for(provider.id) + + self._apply_catalogue_chrome() + with contextlib.suppress(NoMatches): + self._reset_filter() + stations = provider.load_cached_stations() or provider.fallback_stations() + self._set_stations(stations) + self._select_initial_station() + self._update_media() + self._update_artwork() + self._refresh_art() + with contextlib.suppress(NoMatches): + self.query_one(HeaderBar).refresh() + self.query_one(StagePanel).refresh_stage() + self.query_one(StatusBar).refresh() + self._set_notice(f"catalogue: {provider.name}", 3) + + if not self.offline: + self._refresh_stations() + if self.current is not None: + self._fetch_history(self.current.id) + if provider.has_schedule: + self._refresh_schedule() + def action_help(self) -> None: self.push_screen(HelpScreen()) diff --git a/beefy_player/providers/__init__.py b/beefy_player/providers/__init__.py index e25de24..cda0b84 100644 --- a/beefy_player/providers/__init__.py +++ b/beefy_player/providers/__init__.py @@ -18,6 +18,7 @@ from __future__ import annotations from .base import RadioProvider from .somafm import SomaFMProvider +from .tilderadio import TilderadioProvider __all__ = [ "DEFAULT_PROVIDER", @@ -29,7 +30,7 @@ __all__ = [ #: Every catalogue the app can talk to, keyed by its slug. PROVIDERS: dict[str, RadioProvider] = { - provider.id: provider for provider in (SomaFMProvider(),) + provider.id: provider for provider in (SomaFMProvider(), TilderadioProvider()) } #: Provider used when nothing else is configured. diff --git a/beefy_player/providers/base.py b/beefy_player/providers/base.py index a8e991e..58055a5 100644 --- a/beefy_player/providers/base.py +++ b/beefy_player/providers/base.py @@ -24,7 +24,7 @@ from urllib.parse import quote import httpx from .. import config -from ..models import Song, Station +from ..models import Broadcast, Song, Station __all__ = [ "PLAYLIST_TTL", @@ -92,6 +92,28 @@ class RadioProvider(ABC): #: ``User-Agent`` for this catalogue's requests (SomaFM likes to know). user_agent: str = "" + # -- header hero ----------------------------------------------------- # + + #: Rows of header art drawn at the top left (rich markup is allowed). + hero: tuple[str, ...] = () + #: Paint :attr:`hero` with the app's spectrum gradient (a wordmark) instead + #: of the colours the art carries itself (a picture). + hero_gradient: bool = False + #: Short lines printed beside the hero, top to bottom. + hero_lines: tuple[str, ...] = () + + # -- left pane ------------------------------------------------------- # + + #: Show a broadcast schedule in the left pane instead of a station list. + #: Set by single-stream catalogues, where a list of one station is a waste + #: of the pane and "what is on, what is next" is the useful view. + has_schedule: bool = False + + #: Look the current track up on Bandcamp and offer a link when it is found. + #: Off for catalogues whose rotation is unlikely to be there (and for + #: stations that would rather not be scraped). + bandcamp: bool = True + # -- catalogue ------------------------------------------------------- # @abstractmethod @@ -118,6 +140,16 @@ class RadioProvider(ABC): def load_cached_history(self, station_id: str) -> list[Song]: """The last good song history from disk, or ``[]``.""" + # -- schedule -------------------------------------------------------- # + + async def fetch_schedule(self, client: httpx.AsyncClient) -> list[Broadcast]: + """Upcoming broadcasts, soonest first (the live one, if any, first).""" + return [] + + def load_cached_schedule(self) -> list[Broadcast]: + """The last good schedule from disk, or ``[]``.""" + return [] + # -- streams --------------------------------------------------------- # def fallback_urls(self, station: Station) -> list[str]: diff --git a/beefy_player/providers/somafm/__init__.py b/beefy_player/providers/somafm/__init__.py index cdf1d48..20c09f9 100644 --- a/beefy_player/providers/somafm/__init__.py +++ b/beefy_player/providers/somafm/__init__.py @@ -15,6 +15,7 @@ from ... import config from ...models import Song, Station from ..base import RadioProvider from . import api, fallback +from . import hero as hero_art __all__ = ["SomaFMProvider"] @@ -28,6 +29,10 @@ class SomaFMProvider(RadioProvider): station_source = "somafm.com/channels.json" history_source = "songs/.json" + hero = hero_art.LOGO + hero_gradient = True + hero_lines = hero_art.TAGLINES + @property def user_agent(self) -> str: # SomaFM asks clients to identify themselves, so say who we are and diff --git a/beefy_player/providers/somafm/hero.py b/beefy_player/providers/somafm/hero.py new file mode 100644 index 0000000..d2ee4b1 --- /dev/null +++ b/beefy_player/providers/somafm/hero.py @@ -0,0 +1,20 @@ +"""SomaFM's header hero: the block-letter wordmark and the three lines beside it. + +The glyphs are plain half-blocks; the header paints them with the app's +spectrum gradient (``hero_gradient``), so the logo takes on the terminal's own +colours instead of carrying RGB values around. +""" + +from __future__ import annotations + +LOGO: tuple[str, ...] = ( + "▄▀▀▀ ▄▀▀▄ █▄ ▄█ ▄▀▀▄", + "▀▀▀▄ █ █ █ █ █ █▀▀█", + "▄▄▄▀ ▀▄▄▀ █ █ █ █", +) + +TAGLINES: tuple[str, ...] = ( + "SOMA FM RADIO", + "LISTENER SUPPORTED", + "SAN FRANCISCO", +) diff --git a/beefy_player/theme.tcss b/beefy_player/theme.tcss index a4d1076..7fe3d21 100644 --- a/beefy_player/theme.tcss +++ b/beefy_player/theme.tcss @@ -82,6 +82,17 @@ StationList { background: ansi_default; } +SchedulePanel { + color: ansi_default; + width: 1fr; + height: 1fr; + display: none; + border: round ansi_bright_black; + border-title-color: ansi_default; + padding: 0 1; + background: ansi_default; +} + StagePanel { color: ansi_default; layout: vertical; @@ -151,6 +162,16 @@ ModalScreen { background: ansi_default; } +#providers { + color: ansi_default; + width: 66; + height: auto; + border: round ansi_bright_black; + border-title-color: ansi_default; + padding: 1 2; + background: ansi_default; +} + #help { color: ansi_default; width: 78; diff --git a/beefy_player/widgets/header.py b/beefy_player/widgets/header.py index b051ba8..66c7b80 100644 --- a/beefy_player/widgets/header.py +++ b/beefy_player/widgets/header.py @@ -1,4 +1,4 @@ -"""Header: logo, clock and a live ticker.""" +"""Header: the catalogue's hero, the clock and a live ticker.""" from __future__ import annotations @@ -21,13 +21,19 @@ from .common import ( if TYPE_CHECKING: from ..app import BeefyPlayerApp -LOGO = ( - "▄▀▀▀ ▄▀▀▄ █▄ ▄█ ▄▀▀▄", - "▀▀▀▄ █ █ █ █ █ █▀▀█", - "▄▄▄▀ ▀▄▄▀ █ █ █ █", -) -MIDDLE = ("SOMA FM RADIO", "LISTENER SUPPORTED", "SAN FRANCISCO") +def _hero_line(markup: str, index: int, width: int, gradient_fill: bool) -> Text: + """One row of the hero: spectrum-painted glyphs, or the art's own colours.""" + if not gradient_fill: + line = Text.from_markup(markup) + line.no_wrap = True + return line + + line = Text(no_wrap=True) + for column, char in enumerate(markup): + position = (column / max(1, width - 1) + index * 0.055) % 1.0 + line.append(char, style=f"bold {gradient(SPECTRUM_STOPS, position)}") + return line class HeaderBar(Widget): @@ -35,54 +41,63 @@ class HeaderBar(Widget): def render(self) -> RenderableType: app = cast("BeefyPlayerApp", self.app) + provider = getattr(app, "provider", None) + hero = tuple(getattr(provider, "hero", ()) or ()) + hero_lines = tuple(getattr(provider, "hero_lines", ()) or ()) + gradient_fill = bool(getattr(provider, "hero_gradient", False)) + width = max(24, self.size.width) height = max(1, self.size.height) state = app.playing_state glyph, colour = state_glyph(state) - logo_width = max(cell_len(row) for row in LOGO) - middle_width = max(cell_len(row) for row in MIDDLE) + # Measure what the hero *shows*: the rows are rich markup, so their + # raw length says nothing about the columns they occupy. + hero_width = max( + (cell_len(Text.from_markup(row).plain) for row in hero), default=0 + ) + middle_width = max((cell_len(row) for row in hero_lines), default=0) right = (clock_time(), date_label(), f"vol {app.volume}%") right_width = max(cell_len(item) for item in right) - show_middle = width >= logo_width + middle_width + 8 - show_right = width >= logo_width + right_width + 8 + show_middle = bool(hero_lines) and width >= hero_width + middle_width + 8 + show_right = width >= hero_width + right_width + 8 if show_middle and show_right: - show_middle = width >= logo_width + 4 + middle_width + 2 + right_width + show_middle = ( + width >= hero_width + 4 + middle_width + 2 + right_width + ) + + # Centre the short text block against however tall the hero is: a + # three-line caption beside a seven-row picture sits in the middle. + text_offset = max(0, (len(hero) - len(hero_lines)) // 2) lines: list[Text] = [] - for index, row in enumerate(LOGO): - line = Text(no_wrap=True) - for column, char in enumerate(row): - position = ( - column / max(1, logo_width - 1) + index * 0.055 - ) % 1.0 - line.append( - char, style=f"bold {gradient(SPECTRUM_STOPS, position)}" - ) - if show_middle: + for index, row in enumerate(hero): + line = _hero_line(row, index, hero_width, gradient_fill) + if show_middle and text_offset <= index < text_offset + len(hero_lines): + middle = hero_lines[index - text_offset] line.append(" " * 4) - middle = MIDDLE[index] line.append(middle, style="dim") # Keep the right block on one column even though the middle - # lines differ in length ("SOMA FM RADIO" is shorter than - # "LISTENER SUPPORTED"). + # lines differ in length ("TILDERADIO" is shorter than + # "STREAMED BY / FOR USERS OF THE"). line.append(" " * max(0, middle_width - cell_len(middle))) - if show_right: - used = logo_width + if show_right and text_offset <= index < text_offset + len(right): + used = hero_width if show_middle: used += 4 + middle_width # Right-align the block *and* every item inside it: the clock, # the date and the volume each keep their own length, so pad # the shortest ones instead of left-aligning the whole block. pad = max(1, width - used - right_width) - item = right[index] + item = right[index - text_offset] line.append(" " * pad) line.append(" " * max(0, right_width - cell_len(item))) - style = "bold" if index == 0 else "dim" + style = "bold" if index == text_offset else "dim" line.append(item, style=style) lines.append(line) + ticker: Text | None = None if height >= 4: ticker = Text(no_wrap=True) prefix = f"{glyph} {state.upper():<8} " @@ -94,14 +109,24 @@ class HeaderBar(Widget): elif station is not None: song = station.now_playing if song is not None: - text = f"{station.title} · {song.display_artist} — {song.display_title}" + text = ( + f"{station.title} · " + f"{song.display_artist} — {song.display_title}" + ) else: text = f"{station.title} · {station.description}" text_style = "" else: text = "Pick a station — press enter to tune in" text_style = "italic dim" - ticker.append(marquee(text, max(4, width - cell_len(prefix))), style=text_style) - lines.append(ticker) + ticker.append( + marquee(text, max(4, width - cell_len(prefix))), style=text_style + ) + # A short terminal loses the bottom of the hero before it loses the + # ticker: the ticker carries the state, the art can be cropped. + if ticker is not None: + if len(lines) >= height: + lines = lines[: height - 1] + lines.append(ticker) return Group(*lines[:height]) diff --git a/beefy_player/widgets/help.py b/beefy_player/widgets/help.py index febbff8..fa2d642 100644 --- a/beefy_player/widgets/help.py +++ b/beefy_player/widgets/help.py @@ -13,6 +13,7 @@ from textual.app import ComposeResult from textual.screen import ModalScreen from textual.widgets import Static +from ..providers import PROVIDERS, available_providers from .common import SPECTRUM_STOPS, gradient if TYPE_CHECKING: @@ -33,7 +34,8 @@ HELP_ROWS: tuple[tuple[str, str], ...] = ( "v", "cycle visualiser: mirror → bars → wave → waterfall → radial → braille", ), - ("r", "refresh station list and song history"), + ("r", "refresh stations, schedule and song history"), + ("p", "switch station catalogue (SomaFM / tilderadio)"), ("g / G", "jump to the top / bottom of the list"), ("? ", "this help"), ("q", "quit"), @@ -68,12 +70,40 @@ ABOUT_MACOS = ( class HelpScreen(ModalScreen[None]): def compose(self) -> ComposeResult: - provider = cast("BeefyPlayerApp", self.app).provider + app = cast("BeefyPlayerApp", self.app) + provider = app.provider about = ( f"Stations and song history come from {provider.name} " f"({provider.station_source} + {provider.history_source}).", ) + (ABOUT_MACOS if sys.platform == "darwin" else ABOUT_LINUX) + catalogues = Table.grid(padding=(0, 2), expand=False) + catalogues.add_column(style="bold cyan", no_wrap=True, justify="right") + catalogues.add_column(style="bold", no_wrap=True) + catalogues.add_column(style="dim") + for slug in available_providers(): + catalogue = PROVIDERS[slug] + marker = "●" if slug == provider.id else " " + pane = "schedule pane" if catalogue.has_schedule else "station list" + catalogues.add_row( + marker, slug, f"{catalogue.name} — {pane}" + ) + + pane_note = Text() + if provider.has_schedule: + pane_note.append( + f"{provider.name} is a single stream, so the left pane is its " + "broadcast schedule; the station filter and favourites only " + "apply to multi-station catalogues.", + style="dim italic", + ) + + catalogue_title = Text() + catalogue_title.append("CATALOGUES", style="bold cyan") + catalogue_title.append( + " switch with p or --provider at start", style="dim" + ) + table = Table.grid(padding=(0, 2), expand=False) table.add_column(style="bold cyan", no_wrap=True, justify="right") table.add_column(style="") @@ -90,7 +120,17 @@ class HelpScreen(ModalScreen[None]): for line in textwrap.wrap(paragraph, width=70): footer.append(line + "\n", style="dim") - body = Group(header, Text(""), table, Text(""), footer) + body = Group( + header, + Text(""), + table, + Text(""), + pane_note, + catalogue_title, + catalogues, + Text(""), + footer, + ) yield Static(body, id="help") def on_key(self, event) -> None: diff --git a/beefy_player/widgets/providers.py b/beefy_player/widgets/providers.py new file mode 100644 index 0000000..fd05705 --- /dev/null +++ b/beefy_player/widgets/providers.py @@ -0,0 +1,87 @@ +"""Catalogue picker: switch between SomaFM, tilderadio and whatever comes next.""" + +from __future__ import annotations + +from rich.console import Group +from rich.text import Text +from textual.app import ComposeResult +from textual.screen import ModalScreen +from textual.widgets import Static + +from ..providers import PROVIDERS, available_providers +from .common import SPECTRUM_STOPS, gradient, truncate + + +class ProviderScreen(ModalScreen[str | None]): + """A list of catalogues; enter selects, esc leaves everything as it was.""" + + def __init__(self, current_id: str = "") -> None: + super().__init__() + self.slugs: tuple[str, ...] = available_providers() + self.current_id = current_id + self.index = self.slugs.index(current_id) if current_id in self.slugs else 0 + + def compose(self) -> ComposeResult: + yield Static(self._body(), id="providers") + + def _body(self) -> Group: + header = Text() + header.append("◤ ", style=f"bold {gradient(SPECTRUM_STOPS, 0.9)}") + header.append("CATALOGUES", style=f"bold {gradient(SPECTRUM_STOPS, 0.55)}") + header.append(" pick a station catalogue", style="dim") + + rows: list[Text] = [] + for index, slug in enumerate(self.slugs): + provider = PROVIDERS[slug] + selected = index == self.index + row = Text(no_wrap=True) + row.append("▶ " if selected else " ", style="bold magenta" if selected else "") + row.append(f"{index + 1} ", style="dim") + name_style = "bold underline" if selected else "bold" + row.append(f"{provider.name:<12}", style=name_style) + row.append(f" {provider.brand}", style="dim") + if slug == self.current_id: + row.append(" ●", style="bold green") + rows.append(row) + + detail = Text(no_wrap=True) + detail.append(" ", style="") + detail.append(truncate(provider.tagline, 54), style="italic dim") + rows.append(detail) + + current = PROVIDERS.get(self.current_id) + footer = Text() + footer.append("\n") + if current is not None: + footer.append( + f"now: {current.name} — {current.station_source}\n", style="dim" + ) + footer.append( + "↑ ↓ choose · 1–9 jump · enter switch · esc cancel", + style="dim", + ) + return Group(header, Text(""), *rows, footer) + + # -- interaction ----------------------------------------------------- # + + def _move(self, delta: int) -> None: + self.index = (self.index + delta) % len(self.slugs) + self.query_one("#providers", Static).update(self._body()) + + def on_key(self, event) -> None: + key = event.key + if key in ("up", "k", "shift+tab"): + self._move(-1) + elif key in ("down", "j", "tab"): + self._move(1) + elif key in ("enter", "space"): + self.dismiss(self.slugs[self.index]) + elif key == "escape" or key == "q": + self.dismiss(None) + elif key.isdigit() and key != "0": + choice = int(key) - 1 + if 0 <= choice < len(self.slugs): + self.dismiss(self.slugs[choice]) + else: + return + event.stop() diff --git a/beefy_player/widgets/schedule.py b/beefy_player/widgets/schedule.py new file mode 100644 index 0000000..b1639ce --- /dev/null +++ b/beefy_player/widgets/schedule.py @@ -0,0 +1,137 @@ +"""The left pane for single-stream catalogues: what is on, what is next. + +A catalogue with one station does not need a station list, but it usually has +a broadcast schedule. This pane answers the two questions a listener actually +has — "who is on air?" and "when is the next show?" — in the space the list +would have used. +""" + +from __future__ import annotations + +from datetime import date, datetime, timedelta +from typing import TYPE_CHECKING, cast + +from rich.console import Group, RenderableType +from rich.text import Text +from textual.widget import Widget + +from .common import local_time, local_zone, state_glyph, truncate + +if TYPE_CHECKING: + from ..app import BeefyPlayerApp + from ..models import Broadcast + + +def _window(broadcast: Broadcast | None) -> str: + """``since 22:00 · until 23:00`` — whichever ends the feed actually sent.""" + if broadcast is None: + return "" + start = local_time(broadcast.start) if broadcast.start else "" + end = local_time(broadcast.end) if broadcast.end else "" + if start and end: + return f"since {start} · until {end}" + if start: + return f"since {start}" + if end: + return f"until {end}" + return "" + + +def _day_label(day: date, today: date) -> str: + if day == today: + return "TODAY" + if day == today + timedelta(days=1): + return "TOMORROW" + return day.strftime("%a %d %b").upper() + + +def _upcoming_rows( + upcoming: list[Broadcast], width: int, budget: int +) -> list[Text]: + """Upcoming shows, grouped by local day, clipped to the pane.""" + zone = local_zone() + today = datetime.now(zone).date() + + # (is a show, its line) so the "… +N more" count talks about shows and + # not about the day headings in between. + rows: list[tuple[bool, Text]] = [] + current_day: date | None = None + for item in upcoming: + if item.start is None: + continue + day = item.start.astimezone(zone).date() + if day != current_day: + current_day = day + rows.append((False, Text(_day_label(day, today), style="dim"))) + row = Text(no_wrap=True) + row.append(" ") + row.append(local_time(item.start), style="bold") + row.append(" ") + row.append(truncate(item.display_name, max(4, width - 7))) + rows.append((True, row)) + + if len(rows) > budget: + kept = rows[: max(0, budget - 1)] + while kept and not kept[-1][0]: + kept.pop() + dropped = sum(1 for is_show, _ in rows[len(kept) :] if is_show) + lines = [text for _, text in kept] + if dropped: + lines.append(Text(f"… +{dropped} more", style="dim italic")) + return lines + return [text for _, text in rows] + + +class SchedulePanel(Widget): + can_focus = False + + def __init__(self) -> None: + super().__init__() + self.border_title = "SCHEDULE" + + def render(self) -> RenderableType: + app = cast("BeefyPlayerApp", self.app) + width = max(12, self.size.width) + height = max(1, self.size.height) + + broadcasts = list(getattr(app, "schedule", ()) or ()) + loaded = bool(getattr(app, "schedule_loaded", False)) + live = next((item for item in broadcasts if item.live), None) + upcoming = [item for item in broadcasts if not item.live] + + lines: list[Text] = [] + + head = Text(no_wrap=True) + glyph, colour = state_glyph("playing" if live else "stopped") + head.append(f"{glyph} ", style=f"bold {colour}") + head.append("ON AIR" if live else "AUTO DJ", style="bold magenta") + if live is not None: + head.append(" ", style="") + head.append( + truncate(live.display_name, max(4, width - 12)), + style="bold", + ) + lines.append(head) + + window = _window(live) + lines.append( + Text(truncate(window, width), style="dim") + if window + else Text("library rotation", style="dim italic") + ) + lines.append(Text("")) + lines.append(Text("UPCOMING", style="bold cyan")) + + if not loaded: + lines.append(Text("loading schedule…", style="dim italic")) + elif not upcoming: + lines.append(Text("nothing scheduled", style="dim italic")) + else: + lines.extend( + _upcoming_rows(upcoming, width, max(0, height - 1 - len(lines))) + ) + + lines.append(Text("")) + while len(lines) < height: + lines.append(Text("")) + return Group(*lines[:height]) diff --git a/tests/test_header.py b/tests/test_header.py index 0a3d124..5edc3ef 100644 --- a/tests/test_header.py +++ b/tests/test_header.py @@ -6,6 +6,8 @@ import types import pytest from rich.cells import cell_len +from beefy_player.providers.somafm import SomaFMProvider +from beefy_player.providers.tilderadio import TilderadioProvider from beefy_player.widgets.header import HeaderBar TIME = re.compile(r"\d\d:\d\d:\d\d$") @@ -13,11 +15,17 @@ DATE = re.compile(r"[A-Z][a-z]{2} \d\d [A-Z][a-z]{2}$") VOLUME = re.compile(r"vol \d+%$") -def render_header(width: int, height: int = 3) -> list[str]: +def render_header( + width: int, height: int = 3, provider=None +) -> list[str]: """Render the header without a running app, as plain text per line.""" fake = type("Fake", (), {})() fake.app = types.SimpleNamespace( - playing_state="playing", volume=70, current=None, notice=None + playing_state="playing", + volume=70, + current=None, + notice=None, + provider=provider or SomaFMProvider(), ) fake.size = types.SimpleNamespace(width=width, height=height) return [line.plain.rstrip() for line in HeaderBar.render(fake).renderables] @@ -34,8 +42,8 @@ def test_right_block_is_flush_right(width: int) -> None: def test_ragged_middle_does_not_shift_the_right_block() -> None: - """"SOMA FM RADIO" and "SAN FRANCISCO" are shorter than - "LISTENER SUPPORTED"; the right block must not follow their length.""" + """"TILDERADIO" is shorter than "STREAMED BY / FOR USERS OF THE"; the + right block must not follow the length of the middle lines.""" clock, date, volume = render_header(143) assert cell_len(clock) == cell_len(date) == cell_len(volume) == 143 @@ -44,3 +52,42 @@ def test_right_block_hidden_on_narrow_terminals() -> None: """Below the threshold the right block is dropped, not squashed.""" lines = render_header(30) assert all(":" not in line for line in lines) + + +def test_somafm_hero_is_the_wordmark_and_its_taglines() -> None: + lines = render_header(143) + assert lines[0].startswith("▄▀▀▀ ▄▀▀▄ █▄ ▄█ ▄▀▀▄") + assert "SOMA FM RADIO" in lines[0] + assert "LISTENER SUPPORTED" in lines[1] + assert "SAN FRANCISCO" in lines[2] + + +def test_tilderadio_hero_is_its_own_art_and_motto() -> None: + provider = TilderadioProvider() + lines = render_header(143, height=len(provider.hero), provider=provider) + assert len(lines) == len(provider.hero) + # the caption block is centred against the seven-row radio icon + assert "TILDERADIO" in lines[2] + assert "STREAMED BY / FOR USERS OF THE" in lines[3] + assert "TILDEVERSE" in lines[4] + # the radio icon's signature green wave survives the conversion + styles = [ + str(span.style) + for line in HeaderBar.render(_fake(provider)).renderables + for span in line.spans + ] + assert any("green" in style for style in styles) + assert all("SOMA" not in line for line in lines) + + +def _fake(provider): + fake = type("Fake", (), {})() + fake.app = types.SimpleNamespace( + playing_state="playing", + volume=70, + current=None, + notice=None, + provider=provider, + ) + fake.size = types.SimpleNamespace(width=143, height=len(provider.hero) or 3) + return fake diff --git a/tests/test_providers.py b/tests/test_providers.py index 7ad5799..569ba61 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -11,6 +11,8 @@ from beefy_player.models import Song, Station from beefy_player.providers import PROVIDERS, available_providers, get_provider from beefy_player.providers.base import RadioProvider from beefy_player.providers.somafm import SomaFMProvider +from beefy_player.providers.tilderadio import TilderadioProvider +from beefy_player.widgets.header import HeaderBar def rendered_text(app) -> str: @@ -19,9 +21,10 @@ def rendered_text(app) -> str: def test_registry_lists_somafm_as_the_default() -> None: - assert available_providers() == ("somafm",) + assert available_providers() == ("somafm", "tilderadio") assert isinstance(get_provider(), SomaFMProvider) assert get_provider("somafm") is get_provider() + assert isinstance(get_provider("tilderadio"), TilderadioProvider) # a stale slug in the config file must not stop the app from starting assert get_provider("no-such-catalogue") is get_provider() @@ -128,3 +131,105 @@ def test_environment_variables_prefer_the_beefy_prefix(monkeypatch) -> None: monkeypatch.delenv("SOMAFM_IMAGES") assert config.env_var("IMAGES") == "" + + +def test_tilderadio_replaces_the_station_list_with_a_schedule() -> None: + provider = TilderadioProvider() + assert provider.has_schedule is True + # the SomaFM wordmark's slot now holds tilderadio's own art and motto + assert provider.hero and not provider.hero_gradient + assert provider.hero_lines == ( + "TILDERADIO", + "STREAMED BY / FOR USERS OF THE", + "TILDEVERSE", + ) + assert SomaFMProvider().has_schedule is False + + +class _CatalogueApp: + """A headless app on a fixed catalogue, for the picker tests.""" + + def __init__(self, provider: str = "somafm") -> None: + from beefy_player.app import BeefyPlayerApp + + self.app = BeefyPlayerApp( + provider=provider, autoplay=False, offline=True, audio=False + ) + + +@pytest.mark.asyncio +async def test_p_opens_the_picker_and_switches_catalogue() -> None: + from beefy_player.widgets.providers import ProviderScreen + from beefy_player.widgets.schedule import SchedulePanel + from beefy_player.widgets.station_list import StationList + + app = _CatalogueApp().app + async with app.run_test(size=(110, 40)) as pilot: + await pilot.pause() + assert app.provider.id == "somafm" + assert app.query_one(StationList).display is True + assert app.query_one(SchedulePanel).display is False + assert app.query_one(HeaderBar).styles.height.value == 4 + + await pilot.press("p") + await pilot.pause() + assert isinstance(app.screen, ProviderScreen) + + await pilot.press("2") # jump straight to tilderadio + await pilot.pause() + await pilot.pause() + + assert app.provider.id == "tilderadio" + assert app.config.provider == "tilderadio" + assert [station.id for station in app.stations] == ["tilderadio"] + # the left pane becomes the schedule, and the hero grows + assert app.query_one(StationList).display is False + assert app.query_one(SchedulePanel).display is True + assert app.query_one(HeaderBar).styles.height.value == 8 + assert "tilderadio" in app.title + + +@pytest.mark.asyncio +async def test_escape_closes_the_picker_without_switching() -> None: + app = _CatalogueApp().app + async with app.run_test(size=(110, 40)) as pilot: + await pilot.pause() + await pilot.press("p") + await pilot.pause() + await pilot.press("escape") + await pilot.pause() + assert app.provider.id == "somafm" + assert app.config.provider != "tilderadio" + + +@pytest.mark.asyncio +async def test_help_names_both_catalogues_and_the_switch_key() -> None: + app = _CatalogueApp().app + async with app.run_test(size=(120, 60)) as pilot: + await pilot.pause() + await pilot.press("question_mark") + await pilot.pause() + text = rendered_text(app) + assert "CATALOGUES" in text + assert "somafm" in text and "tilderadio" in text + assert "switch station catalogue" in text + + +def test_config_remembers_a_station_per_catalogue() -> None: + config.Config(last_stations={"somafm": "defcon", "tilderadio": "tilderadio"}).save() + loaded = config.Config.load() + assert loaded.last_station_for("somafm") == "defcon" + assert loaded.last_station_for("tilderadio") == "tilderadio" + # a catalogue nobody has tuned yet falls back to the legacy single field + assert loaded.last_station_for("no-such-catalogue") == loaded.last_station + + +@pytest.mark.asyncio +async def test_short_terminals_crop_the_hero_but_keep_the_ticker() -> None: + app = _CatalogueApp("tilderadio").app + async with app.run_test(size=(90, 24)) as pilot: + await pilot.pause() + header = app.query_one(HeaderBar) + assert header.styles.height.value <= 6 + lines = [line.plain for line in header.render().renderables] + assert any("STOPPED" in line or "PLAYING" in line for line in lines) diff --git a/tests/test_schedule.py b/tests/test_schedule.py new file mode 100644 index 0000000..e2b1811 --- /dev/null +++ b/tests/test_schedule.py @@ -0,0 +1,166 @@ +"""tilderadio's schedule: parsing, the live slot, caching and the pane.""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +import httpx +import pytest + +from beefy_player.app import BeefyPlayerApp +from beefy_player.models import Broadcast +from beefy_player.providers.tilderadio import api +from beefy_player.widgets.schedule import SchedulePanel, _upcoming_rows + + +def iso(moment: datetime) -> str: + return moment.isoformat() + + +def test_parse_schedule_reads_the_documented_feed() -> None: + payload = { + "schedule": [ + { + "name": "controlfreak", + "slug": "controlfreak", + "start": "2026-11-04T02:00:00+00:00", + "end": "2026-11-04T03:00:00+00:00", + "start_ts": 1793757600, + "end_ts": 1793761200, + }, + { + "name": "pa2", + "slug": "pa2", + "start": "2026-11-04T14:00:00+00:00", + "end": "2026-11-04T15:00:00+00:00", + "start_ts": 1793800800, + "end_ts": 1793804400, + }, + ] + } + shows = api.parse_schedule(payload) + assert [show.name for show in shows] == ["controlfreak", "pa2"] + assert shows[0].start == datetime(2026, 11, 4, 2, tzinfo=timezone.utc) + assert shows[0].end == datetime(2026, 11, 4, 3, tzinfo=timezone.utc) + assert not shows[0].live + # junk is dropped rather than crashing the pane + assert api.parse_schedule({"schedule": [None, {}, {"name": "no start"}]}) == [] + assert api.parse_schedule(None) == [] + + +def test_live_broadcast_only_when_someone_is_on_air() -> None: + payload = { + "is_live": False, + "dj": None, + "broadcast_start": None, + } + assert api.live_broadcast(payload) is None + + payload = { + "is_live": True, + "dj": "dokuja", + "broadcast_start": 1793599228, + } + live = api.live_broadcast(payload) + assert live is not None + assert (live.name, live.dj, live.live) == ("dokuja", "dokuja", True) + assert live.start is not None + + +def client_for(now_payload, schedule_payload, status: int = 200) -> httpx.AsyncClient: + def handler(request: httpx.Request) -> httpx.Response: + if str(request.url).endswith("/api/now/"): + return httpx.Response(200, json=now_payload) + if str(request.url).endswith("/api/schedule/"): + return httpx.Response(status, json=schedule_payload) + return httpx.Response(404, json={}) + + return httpx.AsyncClient(transport=httpx.MockTransport(handler)) + + +@pytest.mark.asyncio +async def test_fetch_schedule_puts_the_live_show_first_and_drops_the_past() -> None: + now = datetime.now(timezone.utc) + now_payload = { + "is_live": True, + "dj": "dokuja", + "broadcast_start": iso(now - timedelta(minutes=25)), + } + schedule_payload = { + "schedule": [ + { + "name": "yesterday", + "start": iso(now - timedelta(days=1)), + "end": iso(now - timedelta(days=1) + timedelta(hours=1)), + }, + { + "name": "controlfreak", + "start": iso(now + timedelta(hours=2)), + "end": iso(now + timedelta(hours=3)), + }, + ] + } + async with client_for(now_payload, schedule_payload) as client: + broadcasts = await api.fetch_schedule(client) + + assert broadcasts[0].live and broadcasts[0].name == "dokuja" + assert [item.name for item in broadcasts[1:]] == ["controlfreak"] + + # ... and the schedule survives a dead endpoint + async with client_for({"is_live": False}, {}, status=500) as client: + offline = await api.fetch_schedule(client) + assert [item.name for item in offline] == ["controlfreak"] + + +def broadcast(name: str, minutes: int, *, live: bool = False) -> Broadcast: + start = datetime.now(timezone.utc) + timedelta(minutes=minutes) + return Broadcast( + name=name, dj=name, start=start, end=start + timedelta(hours=1), live=live + ) + + +async def panel_lines(broadcasts: list[Broadcast], size=(100, 40)) -> list[str]: + app = BeefyPlayerApp(provider="tilderadio", offline=True, audio=False) + async with app.run_test(size=size) as pilot: + await pilot.pause() + app.schedule = broadcasts + app.schedule_loaded = True + panel = app.query_one(SchedulePanel) + return [line.plain for line in panel.render().renderables] + + +@pytest.mark.asyncio +async def test_panel_shows_the_transmitter_and_what_is_next() -> None: + lines = await panel_lines( + [broadcast("dokuja", -20, live=True), broadcast("controlfreak", 90)] + ) + text = "\n".join(lines) + assert "ON AIR" in text and "dokuja" in text + assert "since " in text and "until " in text + assert "UPCOMING" in text + assert "controlfreak" in text + + +@pytest.mark.asyncio +async def test_panel_says_auto_dj_when_nothing_is_live() -> None: + lines = await panel_lines([broadcast("pa2", 120)]) + text = "\n".join(lines) + assert "AUTO DJ" in text + assert "library rotation" in text + assert "ON AIR" not in text + + +@pytest.mark.asyncio +async def test_panel_handles_an_empty_schedule() -> None: + assert "nothing scheduled" in "\n".join(await panel_lines([])) + + +def test_upcoming_rows_count_the_shows_they_had_to_hide() -> None: + many = [broadcast(f"show {index}", 60 * (index + 1)) for index in range(30)] + lines = [line.plain for line in _upcoming_rows(many, 34, 10)] + assert len(lines) <= 10 + assert any("more" in line for line in lines) + # every show is either listed or counted in the "… +N more" line + listed = sum(1 for line in lines if "show " in line) + hidden = int(lines[-1].split("+")[1].split()[0]) + assert listed + hidden == 30 diff --git a/tests/test_theme.py b/tests/test_theme.py index 3490f33..29f8cc3 100644 --- a/tests/test_theme.py +++ b/tests/test_theme.py @@ -7,6 +7,7 @@ import pytest from beefy_player.app import BeefyPlayerApp from beefy_player.widgets.header import HeaderBar +from beefy_player.widgets.schedule import SchedulePanel from beefy_player.widgets.search import SearchInput from beefy_player.widgets.spectrum import SpectrumPanel from beefy_player.widgets.stage import StagePanel @@ -42,6 +43,7 @@ async def test_backgrounds_use_terminal_default_and_borders_are_ansi() -> None: HeaderBar, SearchInput, StationList, + SchedulePanel, StagePanel, SpectrumPanel, StatusBar, diff --git a/tools/ansi_art.py b/tools/ansi_art.py new file mode 100644 index 0000000..ec97c30 --- /dev/null +++ b/tools/ansi_art.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +"""Turn a logo into half-block ANSI art for the header hero. + +The header hero is a handful of text rows, so an image has to survive a brutal +downsample: two pixels per cell (upper/lower half block), one ANSI colour per +half. Colours are quantised to the stock 16-colour ANSI palette on purpose — +the rest of the UI never emits RGB, and a hero built from palette names still +reads as the station's art in any terminal theme. + +Usage: + + python tools/ansi_art.py logo.png --cols 22 --rows 7 + python tools/ansi_art.py --url https://tilderadio.org/logos/tilderadio.png \\ + --cols 22 --rows 7 --module beefy_player/providers/tilderadio/hero.py + +``--preview out.png`` draws what the cells will look like, which is much easier +to judge than a wall of ▀ and ▄. +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +from PIL import Image, ImageDraw + +#: The stock xterm colours the names below resolve to in a terminal. +ANSI16: dict[str, tuple[int, int, int]] = { + "black": (0, 0, 0), + "red": (170, 0, 0), + "green": (0, 170, 0), + "yellow": (170, 85, 0), + "blue": (0, 0, 170), + "magenta": (170, 0, 170), + "cyan": (0, 170, 170), + "white": (170, 170, 170), + "bright_black": (85, 85, 85), + "bright_red": (255, 85, 85), + "bright_green": (85, 255, 85), + "bright_yellow": (255, 255, 85), + "bright_blue": (85, 85, 255), + "bright_magenta": (255, 85, 255), + "bright_cyan": (85, 255, 255), + "bright_white": (255, 255, 255), +} + +#: Below this alpha a half-cell counts as empty (terminal background). +ALPHA_CUTOFF = 110 + + +def nearest_colour(rgb: tuple[int, int, int]) -> str: + best, best_distance = "white", float("inf") + for name, reference in ANSI16.items(): + distance = sum((a - b) ** 2 for a, b in zip(rgb, reference, strict=True)) + if distance < best_distance: + best, best_distance = name, distance + return best + + +def load_image(path: Path | None, url: str | None) -> Image.Image: + if path is None and url: + import httpx + + response = httpx.get(url, follow_redirects=True, timeout=30.0) + response.raise_for_status() + from io import BytesIO + + image = Image.open(BytesIO(response.content)) + elif path is not None: + image = Image.open(path) + else: # pragma: no cover - argparse enforces one of the two + raise SystemExit("pass an image path or --url") + + image = image.convert("RGBA") + # Trim the transparent margin: the art should fill the box it is given. + opaque = image.getchannel("A").point(lambda value: 255 if value > 24 else 0) + box = opaque.getbbox() + return image.crop(box) if box else image + + +def art_rows(image: Image.Image, cols: int, rows: int) -> list[list[tuple[str, str, str]]]: + """(glyph, foreground, background) per cell, two source pixels per cell.""" + small = image.resize((cols, rows * 2), Image.LANCZOS) + pixels = small.load() + grid: list[list[tuple[str, str, str]]] = [] + for row in range(rows): + cells: list[tuple[str, str, str]] = [] + for column in range(cols): + top = pixels[column, row * 2] + bottom = pixels[column, row * 2 + 1] + top_ink = top[3] >= ALPHA_CUTOFF + bottom_ink = bottom[3] >= ALPHA_CUTOFF + if not top_ink and not bottom_ink: + cells.append((" ", "", "")) + elif top_ink and bottom_ink: + cells.append(("▀", nearest_colour(top[:3]), nearest_colour(bottom[:3]))) + elif top_ink: + cells.append(("▀", nearest_colour(top[:3]), "")) + else: + cells.append(("▄", nearest_colour(bottom[:3]), "")) + grid.append(cells) + return grid + + +def markup(grid: list[list[tuple[str, str, str]]]) -> list[str]: + """One rich-markup string per row, run-length encoded by style.""" + lines: list[str] = [] + for cells in grid: + line: list[str] = [] + index = 0 + while index < len(cells): + glyph, foreground, background = cells[index] + if not background and glyph == " ": + line.append(" ") + index += 1 + continue + run = [glyph] + index += 1 + while index < len(cells) and cells[index] == (glyph, foreground, background): + run.append(cells[index][0]) + index += 1 + style = " ".join( + part for part in (foreground, f"on {background}" if background else "") if part + ) + body = "".join(run) + line.append(f"[{style}]{body}[/]" if style else body) + lines.append("".join(line).rstrip()) + return lines + + +def preview(grid: list[list[tuple[str, str, str]]], path: Path, cell: tuple[int, int]) -> None: + width, height = cell + image = Image.new("RGB", (len(grid[0]) * width, len(grid) * height), (24, 24, 24)) + draw = ImageDraw.Draw(image) + for row, cells in enumerate(grid): + for column, (glyph, foreground, background) in enumerate(cells): + x, y = column * width, row * height + if background: + draw.rectangle([x, y, x + width - 1, y + height - 1], fill=ANSI16[background]) + if glyph == " ": + continue + colour = ANSI16.get(foreground, (140, 140, 140)) + if glyph == "▀": + box = [x, y, x + width - 1, y + height // 2 - 1] + else: + box = [x, y + height // 2, x + width - 1, y + height - 1] + draw.rectangle(box, fill=colour) + image.resize((image.width * 3, image.height * 3), Image.NEAREST).save(path) + + +MODULE_TEMPLATE = '''"""Generated header art — do not edit by hand. + +{provenance} + +Regenerate with:: + + {command} + +Each row is rich markup; ``▀`` is a half block whose foreground is the top +pixel and whose background is the pixel below it. Colours are stock ANSI +palette names, so the art keeps the terminal's own palette. +""" + +from __future__ import annotations + +HERO: tuple[str, ...] = ( +{rows} +) +''' + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("image", nargs="?", type=Path, help="local image file") + parser.add_argument("--url", help="fetch the image from this URL instead") + parser.add_argument("--cols", type=int, default=22, help="terminal columns (default 22)") + parser.add_argument("--rows", type=int, default=7, help="terminal rows (default 7)") + parser.add_argument("--module", type=Path, help="write a Python module with HERO") + parser.add_argument("--preview", type=Path, help="write a PNG preview of the cells") + parser.add_argument("--provenance", default="", help="source note for the module header") + args = parser.parse_args(argv) + + image = load_image(args.image, args.url) + grid = art_rows(image, args.cols, args.rows) + rows = markup(grid) + + if args.preview: + preview(grid, args.preview, (10, 20)) + + if args.module: + source = args.provenance or args.url or str(args.image) + command = "uv run python tools/ansi_art.py " + " ".join(sys.argv[1:]) + body = "\n".join(f" {row!r}," for row in rows) + args.module.write_text( + MODULE_TEMPLATE.format( + provenance=f"Source: {source}", + command=command, + rows=body, + ), + encoding="utf-8", + ) + print(f"wrote {args.module} ({len(rows)} rows x {args.cols} cols)") + else: + print("\n".join(rows)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())