Compare commits
18
Commits
253b462df1
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6684ddbbd | ||
|
|
d06a94f388 | ||
|
|
1f686ce7ba | ||
|
|
cbcdfae8bf | ||
|
|
ffd67fc90d | ||
|
|
941c744638 | ||
|
|
2753a7b6ba | ||
|
|
dc6ed00abe | ||
|
|
12792b1265 | ||
|
|
4346f75eac | ||
|
|
e0e1ad5df3 | ||
|
|
ec2ca72efb | ||
|
|
81116282cb | ||
|
|
03297e8751 | ||
|
|
638cc9eddb | ||
|
|
f96923b5b7 | ||
|
|
7ba2d50503 | ||
|
|
43ddd8666b |
No files matched your search
@@ -3,364 +3,208 @@
|
|||||||

|

|
||||||
|
|
||||||
A flashy terminal player for **internet radio** — a live spectrum analyser,
|
A flashy terminal player for **internet radio** — a live spectrum analyser,
|
||||||
song history, and full desktop media integration, drawn with your terminal's
|
song history and album art, with desktop media integration on Linux and macOS.
|
||||||
own ANSI palette on the terminal's default background so it fits any theme
|
It draws with your terminal's own ANSI palette on the terminal's default
|
||||||
(including transparent terminals).
|
background, so it fits any theme, including transparent terminals.
|
||||||
|
|
||||||
The station catalogue is pluggable. Beefy Player ships with thirteen catalogues
|
Thirteen station catalogues ship today, from SomaFM's curated channels to the
|
||||||
— **[SomaFM](https://somafm.com)** (≈50 curated channels),
|
62,000-station Radio Browser directory, loaded as you scroll and searched live
|
||||||
**[Radio Paradise](https://radioparadise.com)** (five mixes), the community
|
as you type. Press `p` to switch catalogue without restarting.
|
||||||
stations **[tilderadio](https://tilderadio.org)**,
|
|
||||||
**[Bassdrive](https://bassdrive.com)**, **[dublab](https://dublab.com)**,
|
|
||||||
**[Rinse FM](https://rinse.fm)**, **[Refuge Worldwide](https://refugeworldwide.com)**,
|
|
||||||
**[LYL Radio](https://lyl.live)**, **[The Lot Radio](https://thelotradio.com)**,
|
|
||||||
**[Cashmere Radio](https://cashmereradio.com)**,
|
|
||||||
**[Datafruits](https://datafruits.fm)** and **[aNONradio](https://anonradio.net)**,
|
|
||||||
plus the **[Radio Browser](https://www.radio-browser.info)** directory
|
|
||||||
(300 stations) — 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,
|
|
||||||
macOS Now Playing (Control Center and the media keys) where it does not.
|
|
||||||
|
|
||||||
No existing radio client is used: the station list, playlists, song history,
|
|
||||||
audio control, spectrum visualiser and media-session integration are all
|
|
||||||
implemented here from public endpoints and open protocols.
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Live spectrum analyser** — 72 log-spaced bands with peak caps, a moving
|
- **Live spectrum analyser** — 72 log-spaced bands with peak caps, a palette
|
||||||
gradient taken from your terminal's ANSI palette and a symmetric mirrored
|
gradient and a mirrored spectrum. Six modes (`v`): mirrored bars, plain bars,
|
||||||
spectrum. Six modes (`v`): mirrored bars, plain bars, oscilloscope,
|
oscilloscope, scrolling waterfall, radial spectrum and a high-resolution
|
||||||
scrolling waterfall spectrogram, a radial spectrum wrapped around the
|
braille oscilloscope.
|
||||||
centre, and a high-resolution braille oscilloscope (2×4 dots per cell).
|
- **Synced to what you hear** — the analyser listens to your actual audio
|
||||||
- **Synced spectrum, not a guess** — by default the analyser records your
|
output by default, so the bars follow the music. If that is unavailable it
|
||||||
actual audio output, so the bars follow what you hear (`SYNC`): the
|
falls back to decoding the stream, then to a simulated display.
|
||||||
PulseAudio/PipeWire monitor via `parec` on Linux, and a native Core Audio
|
- **Now playing and history** — artist, title, album, DJ, listeners, bitrate
|
||||||
process tap on macOS (no driver, no BlackHole, nothing to install). Without
|
and "played N minutes ago", plus the full recent history. A clickable
|
||||||
an output capture it falls back to a muted second `ffmpeg` decode of the
|
**Bandcamp ↗** link appears when the current track can be found there.
|
||||||
stream (`FFT`, can drift), then to a synthesized display (`SIM`).
|
- **Real album art** — native images in Kitty, iTerm2 and WezTerm; an
|
||||||
- **Real artwork on iTerm2, Kitty and WezTerm** — Kitty uses the kitty
|
ANSI-shaded mosaic in every other terminal.
|
||||||
graphics protocol; iTerm2 (including on macOS) and WezTerm use their native
|
- **Thirteen station catalogues** — SomaFM (≈50 channels), Radio Paradise,
|
||||||
iTerm2 inline-image protocol. Images occupy exactly the ON AIR art cell box
|
tilderadio, Bassdrive, dublab, Rinse FM, Refuge Worldwide, LYL Radio, The Lot
|
||||||
and fall back to the procedural ANSI mosaic for other terminals, failed
|
Radio, Cashmere Radio, Datafruits, aNONradio and the Radio Browser directory
|
||||||
loads, or blocked graphics passthrough. Images are cached in
|
(loaded as you scroll in popularity order, with live search across all
|
||||||
`~/.cache/beefy-player/artwork`, letterboxed rather than cropped, and an
|
62,000).
|
||||||
image that carries an alpha channel is composited onto black — a transparent
|
- **Favourites, search and schedules** — favourites float to the top and are
|
||||||
PNG drawn over the mosaic reads as noise behind a station logo.
|
remembered between runs, including directory stations outside the loaded
|
||||||
- **Now playing** — artist, title, album, DJ, listeners, format/bitrate and
|
page; search by title, genre or description, with live server-side search
|
||||||
"played N minutes ago", with procedural ANSI-shaded album art that changes
|
across the Radio Browser directory; single-stream stations show their
|
||||||
per track. When the current track can be found on Bandcamp, a clickable
|
broadcast schedule instead of a one-item list.
|
||||||
**Bandcamp ↗** link appears below it; otherwise the line stays hidden.
|
- **Desktop media integration** — MPRIS on Linux (GNOME/KDE widgets, lock
|
||||||
Catalogues can opt out with `bandcamp = False` (tilderadio does), and a
|
screens, `playerctl` and the media keys) and Now Playing on macOS (Control
|
||||||
provider can fill the `Station.presenter` line instead — tilderadio shows
|
Center and the media keys).
|
||||||
`auto dj · library rotation` between live shows and `dj <name>` during one.
|
- **Sixteen palettes, theme-aware** — every colour is native ANSI against the
|
||||||
- **Last played** — the full recent history, newest first.
|
terminal's default background, so transparent terminals stay transparent and
|
||||||
- **Every station** — with SomaFM that is the whole `channels.json` roster
|
the TUI follows your colour scheme. Press `c` to cycle palettes.
|
||||||
(≈50 channels), live listener counts and genre search; tilderadio is a
|
- **Graceful offline** — cached stations and schedules, a bundled fallback
|
||||||
single-station catalogue, and the list simply holds one. Long lists get a grey
|
list, automatic mirror failover and `ffplay` when `mpv` is missing.
|
||||||
thumb down the right edge (no rail) and a `12/51` position in the pane title;
|
|
||||||
`pgup`/`pgdn` move a screen at a time.
|
|
||||||
- **Favourites** that float to the top, remembered between runs.
|
|
||||||
- **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). Switching to
|
|
||||||
a single-station catalogue tunes it straight away; a multi-station one waits
|
|
||||||
for `enter`.
|
|
||||||
- **A hero per catalogue** — the top-left art is the catalogue's own name in a
|
|
||||||
shared half-block font: SomaFM in the spectrum gradient, tilderadio in the
|
|
||||||
terminal's default colour, Bassdrive in its two blues. Every hero is three
|
|
||||||
rows tall, so the header keeps one height.
|
|
||||||
- **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
|
|
||||||
`Play`/`Pause`/`PlayPause`/`Stop`/`Next`/`Previous`, so GNOME/KDE widgets,
|
|
||||||
lock screens, `playerctl` and the media keys pick it up. On macOS the audio
|
|
||||||
engine (mpv) publishes to the system **Now Playing** session, so Control
|
|
||||||
Center, the menu bar and the keyboard media keys drive the app the same way.
|
|
||||||
- **Theme-aware** — every accent is emitted as native ANSI (not RGB), and the
|
|
||||||
background is the terminal default (`SGR 49`), so transparent terminals stay
|
|
||||||
transparent and the TUI adopts your shell/terminal colour scheme.
|
|
||||||
- **Graceful degradation** — works with cached data offline, falls back to a
|
|
||||||
bundled station list, reconnects across ICE mirrors automatically, and uses
|
|
||||||
`ffplay` if `mpv` is missing.
|
|
||||||
|
|
||||||
## Requirements
|
## Installation
|
||||||
|
|
||||||
`beefy-player` is a normal Python application: there is nothing to compile and
|
### Nix
|
||||||
no distro-specific packaging. It runs on any Linux distribution or macOS with
|
|
||||||
Python **3.10+** and a few command-line audio tools on `$PATH`.
|
|
||||||
|
|
||||||
| tool | why |
|
The flake builds the package, runs the test suite and wraps everything the app
|
||||||
| ---- | --- |
|
calls at runtime (`mpv`, `ffmpeg`, and PulseAudio's `parec`/`pactl` — PipeWire
|
||||||
| `python3` | runtime (3.10 or newer; no exact version pin) |
|
speaks the same protocol):
|
||||||
| [`uv`](https://docs.astral.sh/uv/) | creates the venv and installs every Python dependency from `uv.lock` |
|
|
||||||
| `mpv` | playback and JSON IPC control |
|
|
||||||
| `ffmpeg` | muted stream decode for the fallback analyser |
|
|
||||||
| `parec` / `pactl` *(Linux)* | synced monitor capture for the spectrum (`pulseaudio-utils`; works with PipeWire) |
|
|
||||||
| D-Bus session bus *(Linux)* | MPRIS desktop media integration (`playerctl` is handy for testing) |
|
|
||||||
|
|
||||||
On macOS the spectrum capture is part of the app: it uses the Core Audio
|
|
||||||
process-tap API (macOS 14.2 or newer), so there is no loopback driver to
|
|
||||||
install.
|
|
||||||
|
|
||||||
On macOS the media integration needs nothing extra: mpv is what the system's
|
|
||||||
Now Playing session (Control Center, media keys) talks to.
|
|
||||||
|
|
||||||
Install `uv` once (skip if it is already there):
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
nix run git+https://tildeforge.dev/beefcarver/beefy-player # try it
|
||||||
|
nix profile install git+https://tildeforge.dev/beefcarver/beefy-player # install it
|
||||||
```
|
```
|
||||||
|
|
||||||
`brew install uv`, `pipx install uv`, or a distro package also work. If your
|
In a NixOS configuration:
|
||||||
system Python is older than 3.10, let uv manage one: `uv python install 3.12`.
|
|
||||||
|
|
||||||
### Linux
|
```nix
|
||||||
|
{
|
||||||
|
inputs.beefy-player.url = "git+https://tildeforge.dev/beefcarver/beefy-player";
|
||||||
|
|
||||||
Install these with your package manager. Names are usually:
|
outputs = { nixpkgs, beefy-player, ... }: {
|
||||||
|
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
|
||||||
|
modules = [
|
||||||
|
beefy-player.nixosModules.default
|
||||||
|
{
|
||||||
|
programs.beefy-player.enable = true;
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Or, for a single user with Home Manager:
|
||||||
|
|
||||||
|
```nix
|
||||||
|
{
|
||||||
|
inputs.beefy-player.url = "git+https://tildeforge.dev/beefcarver/beefy-player";
|
||||||
|
|
||||||
|
outputs = { home-manager, beefy-player, ... }: {
|
||||||
|
homeConfigurations.me = home-manager.lib.homeManagerConfiguration {
|
||||||
|
modules = [
|
||||||
|
beefy-player.homeModules.default
|
||||||
|
{
|
||||||
|
programs.beefy-player.enable = true;
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Both modules add one option, `programs.beefy-player.package`, so a pinned or
|
||||||
|
overridden build can be substituted for the flake's own. The raw package
|
||||||
|
(`packages.${system}.default`) and an overlay (`overlays.default`) are exported
|
||||||
|
too. From a checkout, `nix develop` gives you `uv`, `ruff` and the audio tools
|
||||||
|
for the usual `uv sync` / `uv run pytest` workflow; `nix build` builds and
|
||||||
|
tests the package.
|
||||||
|
|
||||||
|
### Arch Linux
|
||||||
|
|
||||||
|
The `aur/PKGBUILD` here is AUR-ready, but the package is not published in the
|
||||||
|
AUR: the AUR has closed new sign-ups, so there is no account to push it to.
|
||||||
|
Build and install it from a checkout instead:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Debian / Ubuntu
|
git clone https://tildeforge.dev/beefcarver/beefy-player
|
||||||
sudo apt install python3 mpv ffmpeg pulseaudio-utils dbus playerctl
|
cd beefy-player/aur
|
||||||
|
makepkg -si # needs base-devel; builds, runs the test suite, installs
|
||||||
# Fedora (enable RPM Fusion for ffmpeg if needed)
|
|
||||||
sudo dnf install python3 mpv ffmpeg pulseaudio-utils dbus-tools playerctl
|
|
||||||
|
|
||||||
# Arch / Manjaro
|
|
||||||
sudo pacman -S python mpv ffmpeg libpulse dbus playerctl
|
|
||||||
|
|
||||||
# openSUSE
|
|
||||||
sudo zypper install python3 mpv ffmpeg pulseaudio-utils dbus-1-tools playerctl
|
|
||||||
```
|
```
|
||||||
|
|
||||||
For other distributions, install the equivalents. On a headless machine you
|
Once the AUR accepts new accounts again, the package can be published and
|
||||||
can skip the audio/D-Bus tools and use `--no-audio` for a UI-only demo, though
|
`paru -S beefy-player` / `yay -S beefy-player` will work; until then `makepkg`
|
||||||
the synced spectrum needs `parec`/`pactl`.
|
is the only route. The `aur/PKGBUILD` is tracked and kept in sync with every
|
||||||
|
release either way.
|
||||||
|
|
||||||
### macOS
|
The Python side is pulled in automatically. For audio, the output-synced
|
||||||
|
spectrum and image passthrough through tmux, install the optional tools too:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
brew install mpv ffmpeg uv # ffplay ships with ffmpeg
|
sudo pacman -S mpv ffmpeg libpulse tmux
|
||||||
```
|
```
|
||||||
|
|
||||||
That is enough to run: playback, artwork in iTerm2/Kitty/WezTerm, the synced
|
### From source
|
||||||
Core Audio spectrum tap and the macOS Now Playing integration all work out of
|
|
||||||
the box.
|
|
||||||
|
|
||||||
The first time the analyser starts it asks macOS for **System Audio
|
|
||||||
Recording** permission — a one-off prompt for your terminal (iTerm2, Kitty,
|
|
||||||
WezTerm, …). Approve it under *System Settings → Privacy & Security → System
|
|
||||||
Audio Recording*; no loopback driver, Multi-Output Device or audio routing is
|
|
||||||
needed. If the permission is denied the tap only ever hands out silence, the
|
|
||||||
analyser notices and falls back to `FFT` instead of showing dead bars.
|
|
||||||
|
|
||||||
The tap records the **default output device**, which is also what mpv uses; if
|
|
||||||
you point mpv elsewhere with `BEEFY_MPV_ARGS='--audio-device=...'` the
|
|
||||||
analyser falls back to `FFT`.
|
|
||||||
|
|
||||||
Terminal image rendering uses [Pillow](https://python-pillow.org/) and is
|
|
||||||
implemented directly (Kitty TGP + iTerm2 OSC 1337) — no terminal probing.
|
|
||||||
|
|
||||||
## Providers
|
|
||||||
|
|
||||||
A *provider* is one station catalogue. It knows how to fetch the station list,
|
|
||||||
how to fetch a station's recently played songs, and how to turn a station into
|
|
||||||
a stream URL. Everything else — the UI, the player, the spectrum, the media
|
|
||||||
session — is provider-neutral.
|
|
||||||
|
|
||||||
```python
|
|
||||||
# beefy_player/providers/base.py
|
|
||||||
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`.
|
|
||||||
|
|
||||||
Thirteen catalogues ship today:
|
|
||||||
|
|
||||||
| slug | catalogue | stations | what is on | relays |
|
|
||||||
| --- | --- | --- | --- | --- |
|
|
||||||
| `somafm` | [SomaFM](https://somafm.com) | `channels.json` (≈50) | `songs/<station>.json` | `.pls` → icecast mirrors |
|
|
||||||
| `radioparadise` | [Radio Paradise](https://radioparadise.com) | five mixes | `api/now_playing` (artist, album, cover) | FLAC / AAC / MP3 icecast |
|
|
||||||
| `tilderadio` | [tilderadio](https://tilderadio.org) | 1 + schedule | `/api/now/` | AzuraCast mounts |
|
|
||||||
| `bassdrive` | [Bassdrive](https://bassdrive.com) | 1 + schedule | icecast `display-title` (the show) | three mounts + mirrors |
|
|
||||||
| `dublab` | [dublab](https://dublab.com) | 1 + schedule | dublab's calendar | Airtime mp3 |
|
|
||||||
| `rinse` | [Rinse FM](https://rinse.fm) | 3 | — | proxy mounts (aac+) |
|
|
||||||
| `refuge` | [Refuge Worldwide](https://refugeworldwide.com) | 1 | `public.radio.co` status | radio.co mp3 |
|
|
||||||
| `lyl` | [LYL Radio](https://lyl.live) | 1 | — | icecast mp3 |
|
|
||||||
| `lot` | [The Lot Radio](https://thelotradio.com) | 1 | — | HLS |
|
|
||||||
| `cashmere` | [Cashmere Radio](https://cashmereradio.com) | 1 | — | Airtime ogg/mp3 |
|
|
||||||
| `datafruits` | [Datafruits](https://datafruits.fm) | 1 | streampusher icecast | relay mp3 |
|
|
||||||
| `anonradio` | [aNONradio](https://anonradio.net) | 1 | icecast title | icecast mp3 |
|
|
||||||
| `radiobrowser` | [Radio Browser](https://www.radio-browser.info) | 300 (directory) | — | whatever each station publishes |
|
|
||||||
|
|
||||||
They are deliberately different shapes: fifty channels with PLS playlists, a
|
|
||||||
single AzuraCast stream that carries its own history, and an Icecast relay whose
|
|
||||||
"now playing" is the *show* rather than the track. That is the seam working.
|
|
||||||
|
|
||||||
The single-stream catalogues also use the optional capabilities: where a
|
|
||||||
schedule exists (`tilderadio`, `bassdrive`, `dublab`) the left pane is that
|
|
||||||
schedule rather than a list containing one station, and none of them do
|
|
||||||
Bandcamp lookups. Bassdrive is the extreme case — Icecast reports one title and
|
|
||||||
keeps no history, so the provider maps the title to `Station.dj`/`presenter`
|
|
||||||
and keeps a rolling record of the shows it has heard.
|
|
||||||
|
|
||||||
The community stations share one base class,
|
|
||||||
`providers/community/base.py`, which knows how to read an Icecast
|
|
||||||
`status-json.xsl`, a `public.radio.co` status, a scheduled show, or nothing at
|
|
||||||
all; each station is then ten lines of data (relays, wordmark, tagline). Where
|
|
||||||
a relay publishes no metadata (Rinse, LYL, The Lot, Cashmere) the app simply
|
|
||||||
plays it and says so, rather than inventing a track.
|
|
||||||
|
|
||||||
`radiobrowser` is the directory case: three hundred of the most-clicked working
|
|
||||||
stations from the community database, keyless and public-domain, with no
|
|
||||||
per-station history at all — breadth rather than depth, and the one catalogue
|
|
||||||
where ON AIR legitimately has nothing to say.
|
|
||||||
|
|
||||||
### Header wordmarks
|
|
||||||
|
|
||||||
All three heroes come from one font, `beefy_player/providers/wordmark.py`:
|
|
||||||
four-column capitals six pixels tall, emitted as three rows of half blocks
|
|
||||||
(`▀`/`▄`/`█`) — the shape of the original SomaFM logo. A provider's `hero.py`
|
|
||||||
is one line of data:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# beefy_player/providers/bassdrive/hero.py
|
|
||||||
HERO = render("BASSDRIVE", ["bright_cyan"] * 4 + ["cyan"] * 5)
|
|
||||||
```
|
|
||||||
|
|
||||||
`render()` takes one optional style per character and groups equal neighbours
|
|
||||||
into markup spans; with no styles the wordmark inherits the terminal's default
|
|
||||||
foreground (tilderadio). Add a glyph to `GLYPHS` when a catalogue needs a letter
|
|
||||||
the font does not have yet — `▀█▀` is the I, one pixel thick on purpose.
|
|
||||||
|
|
||||||
Image logos are a different problem: `tools/ansi_art.py` converts a PNG into
|
|
||||||
half-block art (`--key-white` for logos on white, `--preview out.png` to see the
|
|
||||||
result). No shipped catalogue needs it today, but it is the tool to reach for
|
|
||||||
when a station's identity is a picture rather than a word.
|
|
||||||
|
|
||||||
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>`, 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
|
|
||||||
`fallback_urls()` — so most providers only have to supply the catalogue,
|
|
||||||
history and mirror URLs.
|
|
||||||
|
|
||||||
## Run
|
|
||||||
|
|
||||||
From the repository root, sync the environment and start the app:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv sync # create .venv and install exactly what uv.lock pins
|
uv sync
|
||||||
uv run beefy-player # run inside that environment
|
uv run beefy-player
|
||||||
```
|
```
|
||||||
|
|
||||||
You do **not** need to activate the venv. `uv run` uses `.venv` automatically,
|
## Usage
|
||||||
and re-syncs it first if `pyproject.toml` or `uv.lock` changed. If you prefer a
|
|
||||||
traditional shell, run `source .venv/bin/activate` and then `beefy-player`.
|
|
||||||
|
|
||||||
### Keeping the uv environment in sync
|
|
||||||
|
|
||||||
`uv sync` is the command to remember. It is incremental, safe to re-run, and
|
|
||||||
should be run after pulling changes, switching branches, or editing
|
|
||||||
`pyproject.toml`:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv sync # make .venv match uv.lock
|
beefy-player
|
||||||
uv sync --frozen # strict: fail if uv.lock is out of date, never rewrite it
|
|
||||||
uv lock --upgrade # refresh uv.lock to the newest allowed versions
|
|
||||||
uv sync --upgrade # upgrade dependencies and sync .venv in one step
|
|
||||||
rm -rf .venv && uv sync # last resort if the venv was built by an older Python
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
|
||||||
uv run beefy-player --offline # no network; cached/bundled stations
|
|
||||||
uv run beefy-player --no-mpris # no MPRIS / Now Playing, no media keys
|
|
||||||
uv run beefy-player --list # print station ids
|
|
||||||
uv run beefy-player --no-audio # UI demo without mpv/ffmpeg
|
|
||||||
uv run beefy-player --spectrum-source monitor # force synced output capture
|
|
||||||
uv run beefy-player --spectrum-source stream # force the muted ffmpeg tap
|
|
||||||
BEEFY_IMAGES=0 uv run beefy-player # force the ANSI artwork mosaic
|
|
||||||
BEEFY_IMAGES=tgp uv run beefy-player # force Kitty/TGP artwork
|
|
||||||
BEEFY_IMAGES=iterm2 uv run beefy-player # force iTerm2/WezTerm artwork
|
|
||||||
```
|
|
||||||
|
|
||||||
Environment variables are prefixed `BEEFY_`; the pre-rename `SOMAFM_` spellings
|
|
||||||
(`SOMAFM_IMAGES`, `SOMAFM_MPV_ARGS`, `SOMAFM_SPECTRUM_DEVICE`) are still
|
|
||||||
honoured, so an existing shell profile keeps working.
|
|
||||||
|
|
||||||
The UI wants roughly **84×24**; it also works smaller, just less pretty.
|
The UI wants roughly **84×24**; it also works smaller, just less pretty.
|
||||||
|
|
||||||
## Keys
|
| flag | what it does |
|
||||||
|
| --- | --- |
|
||||||
|
| `--provider <slug>` | pick a station catalogue |
|
||||||
|
| `--station <id>` | pick a station (see `--list`) |
|
||||||
|
| `--play` | tune the last station right away |
|
||||||
|
| `--list` | print station ids for the current catalogue |
|
||||||
|
| `--volume <0-100>` | set the initial volume |
|
||||||
|
| `--offline` | no network; use cached and bundled stations |
|
||||||
|
| `--no-audio` | UI demo without `mpv` / `ffmpeg` |
|
||||||
|
| `--no-mpris` | no MPRIS / Now Playing, no media keys |
|
||||||
|
| `--spectrum-source monitor\|stream` | force synced output capture or the stream tap |
|
||||||
|
|
||||||
| key | action |
|
Environment variables are prefixed `BEEFY_`: `BEEFY_IMAGES` (`tgp`, `iterm2`
|
||||||
| -------------- | --------------------------------------------- |
|
or `0`), `BEEFY_MPV_ARGS` and `BEEFY_SPECTRUM_DEVICE`.
|
||||||
| `↑` / `↓`, `j` / `k` | move through stations |
|
|
||||||
| `pgup` / `pgdn` | page through stations |
|
|
||||||
| `enter` | tune in to the selected station |
|
|
||||||
| `space` | play / pause |
|
|
||||||
| `s` | stop |
|
|
||||||
| `]` / `[` | next / previous station (tunes immediately) |
|
|
||||||
| `+` / `-` | volume |
|
|
||||||
| `m` | mute |
|
|
||||||
| `f` | favourite (favourites sort to the top) |
|
|
||||||
| `/` | filter by title, genre, description |
|
|
||||||
| `esc` | clear filter / close search |
|
|
||||||
| `v` | visualiser: mirror → bars → wave → waterfall → radial → braille |
|
|
||||||
| `r` | refresh stations, schedule + song history |
|
|
||||||
| `p` | switch station catalogue (SomaFM / tilderadio)|
|
|
||||||
| `g` / `G` | top / bottom |
|
|
||||||
| `?` | help |
|
|
||||||
| `q` | quit |
|
|
||||||
|
|
||||||
## Desktop media widgets (MPRIS / Now Playing)
|
### Keys
|
||||||
|
|
||||||
**Linux** — while playing, the app exports
|
| key | action |
|
||||||
`org.mpris.MediaPlayer2.beefyplayer.instance<pid>` on the **session bus**. Check
|
| --- | --- |
|
||||||
it:
|
| `↑` / `↓`, `j` / `k` | move through stations |
|
||||||
|
| `pgup` / `pgdn` | page through stations |
|
||||||
|
| `enter` | tune in to the selected station |
|
||||||
|
| `space` | play / pause |
|
||||||
|
| `s` | stop |
|
||||||
|
| `]` / `[` | next / previous station (tunes immediately) |
|
||||||
|
| `+` / `-` | volume |
|
||||||
|
| `m` | mute |
|
||||||
|
| `f` | favourite (favourites sort to the top) |
|
||||||
|
| `/` | filter by title, genre, description |
|
||||||
|
| `esc` | close the search box; press again to clear the filter |
|
||||||
|
| `v` | visualiser: mirror → bars → wave → waterfall → radial → braille |
|
||||||
|
| `c` | colour palette (16 of them, remembered) |
|
||||||
|
| `r` | refresh stations, schedule and song history |
|
||||||
|
| `p` | switch station catalogue |
|
||||||
|
| `g` / `G` | top / bottom |
|
||||||
|
| `?` | help |
|
||||||
|
| `q` | quit |
|
||||||
|
|
||||||
|
## Station catalogues
|
||||||
|
|
||||||
|
| catalogue | slug | stations |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| [SomaFM](https://somafm.com) | `somafm` | ≈50 curated channels |
|
||||||
|
| [Radio Paradise](https://radioparadise.com) | `radioparadise` | 5 mixes |
|
||||||
|
| [tilderadio](https://tilderadio.org) | `tilderadio` | 1 + schedule |
|
||||||
|
| [Bassdrive](https://bassdrive.com) | `bassdrive` | 1 + schedule |
|
||||||
|
| [dublab](https://dublab.com) | `dublab` | 1 + schedule |
|
||||||
|
| [Rinse FM](https://rinse.fm) | `rinse` | 3 |
|
||||||
|
| [Refuge Worldwide](https://refugeworldwide.com) | `refuge` | 1 |
|
||||||
|
| [LYL Radio](https://lyl.live) | `lyl` | 1 |
|
||||||
|
| [The Lot Radio](https://thelotradio.com) | `lot` | 1 |
|
||||||
|
| [Cashmere Radio](https://cashmereradio.com) | `cashmere` | 1 |
|
||||||
|
| [Datafruits](https://datafruits.fm) | `datafruits` | 1 |
|
||||||
|
| [aNONradio](https://anonradio.net) | `anonradio` | 1 |
|
||||||
|
| [Radio Browser](https://www.radio-browser.info) | `radiobrowser` | 62k · paged + live search |
|
||||||
|
|
||||||
|
Pick one with `p` in the UI, `--provider <slug>` on the command line, or by
|
||||||
|
setting `"provider"` in the config file.
|
||||||
|
|
||||||
|
## Desktop media integration
|
||||||
|
|
||||||
|
**Linux** — while playing, the app exposes an
|
||||||
|
`org.mpris.MediaPlayer2.beefyplayer.instance<pid>` player on the session bus,
|
||||||
|
so GNOME/KDE widgets, lock screens, `playerctl` and the media keys pick it up:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
playerctl -l
|
playerctl -l
|
||||||
@@ -368,221 +212,64 @@ playerctl metadata --format '{{artist}} — {{title}} ({{album}})'
|
|||||||
playerctl play-pause
|
playerctl play-pause
|
||||||
```
|
```
|
||||||
|
|
||||||
`mpris:artUrl` is the track's album art when the provider supplies it,
|
If there is no session bus (SSH without `dbus-run-session`, containers, …)
|
||||||
otherwise the channel logo. If there is no session bus (SSH without
|
MPRIS is skipped and the status bar shows `mpris off`.
|
||||||
`dbus-run-session`, containers, …) MPRIS is skipped and the status bar shows
|
|
||||||
`mpris off`.
|
|
||||||
|
|
||||||
**macOS** — mpv owns the system Now Playing session, so Control Center, the
|
**macOS** — mpv publishes to the system Now Playing session, so Control Center,
|
||||||
menu-bar widget and the keyboard media keys (▶︎/⏸, ⏭, ⏮) all target this app
|
the menu bar and the media keys drive the app while it plays. The status bar
|
||||||
while it is playing. mpv publishes the live stream title (the same
|
shows `nowplaying on`. `--no-mpris` (also spelled `--no-media`) turns the media
|
||||||
“Artist - Title” that the station sends with the audio), and the media keys are
|
keys off.
|
||||||
routed back into the TUI, so `⏭`/`⏮` really change station and the panel, the
|
|
||||||
spectrum and the station list stay in sync. The status bar shows
|
|
||||||
`nowplaying on`. There is no D-Bus and no extra dependency involved, and
|
|
||||||
`--no-mpris` (also spelled `--no-media`) turns the media keys off again.
|
|
||||||
|
|
||||||
> macOS shows one Now Playing app at a time. If Music/Safari/another mpv is
|
> macOS shows one Now Playing app at a time. If Music, Safari or another mpv is
|
||||||
> playing, the media keys go there instead — quit it or pause it to hand the
|
> playing, the media keys go there instead.
|
||||||
> keys back.
|
|
||||||
|
|
||||||
## How it works
|
## Configuration
|
||||||
|
|
||||||
```
|
Settings live in `~/.config/beefy-player/config.json` (favourites, volume,
|
||||||
beefy_player/
|
provider and the last station tuned in each catalogue). Cached stations,
|
||||||
app.py Textual app: layout, keys, orchestration
|
schedules and artwork live in `~/.cache/beefy-player/`.
|
||||||
models.py Song + Station, provider-neutral
|
|
||||||
config.py XDG paths, settings, BEEFY_* env helpers
|
|
||||||
providers/
|
|
||||||
base.py RadioProvider interface + HTTP/playlist toolkit
|
|
||||||
wordmark.py the half-block font every hero is drawn with
|
|
||||||
somafm/ the SomaFM catalogue (API, hero, bundled fallback list)
|
|
||||||
radiobrowser/ the community directory (300 stations, no metadata)
|
|
||||||
radioparadise/ five curated mixes with real track metadata
|
|
||||||
tilderadio/ the tilderadio catalogue (one station, hero, schedule)
|
|
||||||
bassdrive/ Bassdrive (Icecast status, show schedule, rolling history)
|
|
||||||
community/ one module per community station, on a shared base
|
|
||||||
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)
|
|
||||||
mediasession.py platform-neutral desktop media face
|
|
||||||
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 (schedule pane, catalogue picker, …)
|
|
||||||
|
|
||||||
tools/
|
These stay in the usual XDG locations on every platform, macOS included;
|
||||||
ansi_art.py logo PNG -> half-block ANSI hero (regenerates tilderadio's)
|
`XDG_CONFIG_HOME` and `XDG_CACHE_HOME` are honoured when set.
|
||||||
```
|
|
||||||
|
|
||||||
- `beefy_player/providers/base.py` — the provider interface and the shared
|
## Troubleshooting
|
||||||
toolkit: `httpx` client factory plus `.pls`/`.m3u` parsing and resolution
|
|
||||||
(cached for 8 hours), so providers only describe their own catalogue.
|
|
||||||
- `beefy_player/providers/somafm/api.py` — async `httpx` client for:
|
|
||||||
- `channels.json` → stations, genres, listeners, `.pls` playlist URLs
|
|
||||||
- `<station>.pls` → resolved direct icecast URLs (cached 8h)
|
|
||||||
- `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`/`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/providers/bassdrive/api.py` — Icecast's keyless
|
|
||||||
`status-json.xsl` (mounts, bitrates, listeners, `display-title`) plus the
|
|
||||||
homepage schedule grid, whose slots carry absolute `<local-time datetime>`
|
|
||||||
values. A memoised fetch and a lock keep a cold start to one homepage
|
|
||||||
download; the display-title's show is matched against the running slot for
|
|
||||||
"live from <city>".
|
|
||||||
- `beefy_player/providers/community/` — the community stations: `base.py` reads
|
|
||||||
Icecast or radio.co status, keeps the rolling history and turns a running
|
|
||||||
schedule slot into "what is on"; each station module is data. `rinse.py`
|
|
||||||
shows the multi-station case and `dublab.py` the schedule case.
|
|
||||||
- `beefy_player/providers/wordmark.py` — the half-block capitals behind every
|
|
||||||
hero, with per-character style spans.
|
|
||||||
- `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
|
|
||||||
it also asks mpv to publish Now Playing and maps the media keys onto
|
|
||||||
`script-message`s that come back over the same IPC socket.
|
|
||||||
- `beefy_player/spectrum.py` — analyser (44.1 kHz mono s16le from `parec`, or
|
|
||||||
the device's native float32 rate from the macOS tap). Prefers capturing the
|
|
||||||
real output: `parec` (PulseAudio/PipeWire monitor) on Linux, or a native
|
|
||||||
Core Audio process tap on macOS (SYNC, see `macos_tap.py`). Otherwise a
|
|
||||||
muted low-latency `ffmpeg` decode of the stream (FFT). Hann-windowed
|
|
||||||
2048-point FFT, log band grouping, auto-gain, fast attack/decay smoothing,
|
|
||||||
peak hold and waveform downsampling.
|
|
||||||
- `beefy_player/macos_tap.py` — the macOS output capture, in pure `ctypes`
|
|
||||||
against Core Audio: `CATapDescription` → `AudioHardwareCreateProcessTap` →
|
|
||||||
private aggregate device → IOProc. No driver, no helper binary, no ffmpeg.
|
|
||||||
- `beefy_player/mediasession.py` — picks the desktop media backend (MPRIS on
|
|
||||||
Linux, Now Playing on macOS) so the app has one platform-neutral interface.
|
|
||||||
- `beefy_player/mpris.py` — `dbus-next` MPRIS service in its own thread with a
|
|
||||||
thread-safe command queue back into the UI (only loaded on D-Bus platforms).
|
|
||||||
- `beefy_player/bandcamp.py` — searches Bandcamp's public autocomplete API for
|
|
||||||
the now-playing track (artist + track first, then artist + album, then the
|
|
||||||
album page) and returns a track URL when there is a match.
|
|
||||||
- `beefy_player/widgets/` — Textual widgets rendered with native ANSI colours;
|
|
||||||
backgrounds are `ansi_default` (`SGR 49`), so transparent terminals stay
|
|
||||||
transparent.
|
|
||||||
|
|
||||||
### Files
|
- **Artwork shows the mosaic?** Real images are auto-enabled for Kitty, iTerm2
|
||||||
|
and WezTerm. Force a protocol with `BEEFY_IMAGES=tgp` (Kitty) or
|
||||||
```
|
`BEEFY_IMAGES=iterm2` (iTerm2/WezTerm); `BEEFY_IMAGES=0` forces the mosaic.
|
||||||
~/.config/beefy-player/config.json favourites, volume, provider, last stations
|
`--diagnose-images` prints what was detected.
|
||||||
~/.cache/beefy-player/channels.json last good SomaFM station list
|
- **No audio?** `mpv` is preferred, `ffplay` works as a fallback. Without
|
||||||
~/.cache/beefy-player/channels-<id>.json last good list, other catalogues
|
either, the UI still runs and tells you. Extra mpv arguments can be injected
|
||||||
~/.cache/beefy-player/schedule-<id>.json last good broadcast schedule
|
with `BEEFY_MPV_ARGS`, e.g. `BEEFY_MPV_ARGS='--audio-device=...'`.
|
||||||
~/.cache/beefy-player/playlists.json resolved stream URLs
|
- **Spectrum says `FFT` instead of `SYNC`?** Output capture is unavailable or
|
||||||
~/.cache/beefy-player/songs/<id>.json SomaFM song history snapshots
|
failed, so the analyser is decoding the stream instead; the bars are still
|
||||||
~/.cache/beefy-player/songs/<p>-<id>.json history for every other catalogue
|
real audio but can drift. Force sync with `--spectrum-source monitor`, or
|
||||||
~/.cache/beefy-player/artwork/<sha1> cached album/channel artwork
|
pick a specific monitor with `BEEFY_SPECTRUM_DEVICE` on Linux. `SIM` means the
|
||||||
```
|
bars are synthesized.
|
||||||
|
|
||||||
`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
|
|
||||||
`somafm-tui`: on first run a `~/.config/somafm-tui/config.json` is copied over
|
|
||||||
so favourites and volume survive the rename.
|
|
||||||
|
|
||||||
## Notes / troubleshooting
|
|
||||||
|
|
||||||
- **Artwork shows the mosaic?** Real images are auto-enabled for Kitty,
|
|
||||||
iTerm2 and WezTerm. Force a protocol with `BEEFY_IMAGES=tgp` (Kitty/TGP) or
|
|
||||||
`BEEFY_IMAGES=iterm2` (iTerm2/WezTerm); `BEEFY_IMAGES=0` forces the ANSI
|
|
||||||
mosaic. iTerm2 on macOS is detected from `TERM_PROGRAM`/`LC_TERMINAL`, and
|
|
||||||
empty or `0` values in `TMUX`/`ZELLIJ`/`STY` (exported by some wrappers) are
|
|
||||||
ignored so they cannot downgrade it. Kitty inside tmux is attempted through
|
|
||||||
tmux passthrough (enable `set -g allow-passthrough on`); iTerm2/WezTerm
|
|
||||||
inside tmux default to the mosaic. Images are cached under
|
|
||||||
`~/.cache/beefy-player/artwork`.
|
|
||||||
- **Clock shows UTC?** The clock and song history use the system zone from
|
|
||||||
`/etc/localtime`; a non-UTC `TZ` overrides it. A container or dev shell can
|
|
||||||
inherit a build-time `TZ=UTC`, which the app deliberately ignores; bind the
|
|
||||||
host's `/etc/localtime` or set e.g. `TZ=Europe/Berlin` in the shell that
|
|
||||||
launches the app.
|
|
||||||
- **MPRIS says off/error?** No session bus. Run from your desktop session
|
|
||||||
(the shell profile picks up `$XDG_RUNTIME_DIR/bus`). `dbus-run-session -- uv
|
|
||||||
run beefy-player` starts a private bus for testing, but desktop widgets won't
|
|
||||||
see it. macOS does not use MPRIS at all — see “Now Playing” above.
|
|
||||||
- **No audio?** `beefy-player` needs `mpv` (preferred) or `ffplay`. Without
|
|
||||||
either, the UI still runs and tells you. Extra mpv args can be injected with
|
|
||||||
`BEEFY_MPV_ARGS` (e.g. `BEEFY_MPV_ARGS='--audio-device=...'`, which is the
|
|
||||||
macOS way to choose an output device).
|
|
||||||
- **Spectrum says `FFT` instead of `SYNC`?** The monitor is unavailable or
|
|
||||||
failed, or you forced the stream tap. The bars are still real audio, but the
|
|
||||||
second stream can drift relative to playback. Use `--spectrum-source
|
|
||||||
monitor` to force sync, or `BEEFY_SPECTRUM_DEVICE=<source>` on Linux to pick
|
|
||||||
a specific monitor. `SIM` means the bars are synthesized.
|
|
||||||
- **Diagnose artwork:** `uv run beefy-player --diagnose-images` prints the
|
|
||||||
detected protocol, widget class, terminal env, dependency versions and cache
|
|
||||||
path. If `protocol` is `None`, force it with `BEEFY_IMAGES=tgp` (Kitty) or
|
|
||||||
`BEEFY_IMAGES=iterm2` (iTerm2/WezTerm).
|
|
||||||
- **`SYNC` but the bars are flat?** mpv may be playing to a different sink (or
|
|
||||||
directly to ALSA). Point `BEEFY_SPECTRUM_DEVICE` at that sink's monitor, or
|
|
||||||
use `--spectrum-source stream`.
|
|
||||||
- **macOS: spectrum stays `FFT`?** Grant your terminal **System Audio
|
- **macOS: spectrum stays `FFT`?** Grant your terminal **System Audio
|
||||||
Recording** permission (*System Settings → Privacy & Security → System Audio
|
Recording** permission under *System Settings → Privacy & Security → System
|
||||||
Recording*), then restart the app; the status bar explains when the tap is
|
Audio Recording*, then restart the app. Also check that mpv is playing to the
|
||||||
silent because of this. Also check that mpv is playing to the default output
|
default output device, since that is what the analyser records.
|
||||||
device, since that is what the tap records.
|
- **macOS: media keys do nothing?** Another app owns the Now Playing session,
|
||||||
- **macOS: media keys do nothing?** Another app currently owns the Now Playing
|
the app was started with `--no-mpris`, or audio fell back to `ffplay` (mpv is
|
||||||
session, or the app was started with `--no-mpris`, or the audio backend fell
|
required for Now Playing).
|
||||||
back to `ffplay` (mpv is required for Now Playing). The status bar shows
|
- **MPRIS says off?** There is no session bus. Run from your desktop session,
|
||||||
`nowplaying on` when the integration is armed.
|
or use `dbus-run-session` for a private bus. macOS does not use MPRIS.
|
||||||
- **macOS: Control Center shows only a title?** mpv publishes the stream's live
|
|
||||||
“Artist - Title” as the Now Playing title; artist/album fields and artwork are
|
|
||||||
not filled in by macOS for plain audio streams. The TUI itself always shows
|
|
||||||
the full metadata and album art.
|
|
||||||
- **Bandwidth** is unchanged in SYNC mode (the monitor capture creates no
|
|
||||||
second stream). The FFT fallback opens one extra stream; if you are on a
|
|
||||||
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.
|
|
||||||
- Bassdrive is free and listener-supported too: [bassdrive.com](https://bassdrive.com)
|
|
||||||
has the shows, archives and the shop that keeps the relays running.
|
|
||||||
|
|
||||||
## NixOS (optional aside)
|
## Development
|
||||||
|
|
||||||
None of the above needs Nix. If you *are* on NixOS, `shell.nix` wraps the same
|
|
||||||
`uv sync` / `uv run` workflow in an FHS environment, because uv installs
|
|
||||||
manylinux wheels such as NumPy that expect `libstdc++.so.6` in the usual
|
|
||||||
`/usr/lib` paths:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nix-shell # enter the FHS dev shell
|
uv sync # create the environment
|
||||||
uv sync
|
uv run pytest # test suite
|
||||||
uv run beefy-player
|
uvx ruff check . # lint
|
||||||
```
|
```
|
||||||
|
|
||||||
- If you see `libstdc++.so.6: cannot open shared object file`, make sure you
|
Each catalogue is a self-contained provider module under
|
||||||
are inside `nix-shell`, then recreate the venv once with
|
`beefy_player/providers/`; `base.py` documents the interface. `tools/ansi_art.py`
|
||||||
`rm -rf .venv && uv sync`.
|
converts a PNG logo into half-block ANSI art. On NixOS, `nix develop` (or the
|
||||||
- `nix-shell --run "..."` does not work with FHS `shell.nix` files; enter the
|
older `shell.nix`) provides the same `uv sync` / `uv run` workflow.
|
||||||
shell interactively and run commands there.
|
|
||||||
- FHS shells need unprivileged user namespaces. `bwrap: No permissions to
|
|
||||||
create a new namespace` means they are disabled (for example in a locked-down
|
|
||||||
container); run the app on the host instead.
|
|
||||||
|
|
||||||
## Licence
|
## Licence
|
||||||
|
|
||||||
WTFPL — Do What The Fuck You Want To Public License, Version 2. See
|
WTFPL — Do What The Fuck You Want To Public License, Version 2.
|
||||||
[LICENSE](LICENSE). SomaFM and its channel names are trademarks of
|
See [LICENSE](LICENSE).
|
||||||
SomaFM.com, LLC. tilderadio belongs to the tildeverse community.
|
|
||||||
|
|
||||||
Terminal image rendering uses [Pillow](https://python-pillow.org/).
|
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
pkgbase = beefy-player
|
||||||
|
pkgdesc = Flashy terminal player for internet radio with a live spectrum analyser
|
||||||
|
pkgver = 1.2.2
|
||||||
|
pkgrel = 1
|
||||||
|
url = https://tildeforge.dev/beefcarver/beefy-player
|
||||||
|
arch = any
|
||||||
|
license = WTFPL
|
||||||
|
depends = python
|
||||||
|
depends = python-dbus-next
|
||||||
|
depends = python-httpx
|
||||||
|
depends = python-numpy
|
||||||
|
depends = python-pillow
|
||||||
|
depends = python-rich
|
||||||
|
depends = python-textual
|
||||||
|
makedepends = python-build
|
||||||
|
makedepends = python-hatchling
|
||||||
|
makedepends = python-installer
|
||||||
|
makedepends = python-wheel
|
||||||
|
checkdepends = python-pytest
|
||||||
|
checkdepends = python-pytest-asyncio
|
||||||
|
optdepends = ffmpeg: decode the stream for the spectrum analyser, and ffplay as the audio fallback
|
||||||
|
optdepends = libpulse: capture the output monitor so the analyser follows what you hear (parec/pactl)
|
||||||
|
optdepends = mpv: preferred audio backend
|
||||||
|
optdepends = tmux: detect the outer terminal for kitty graphics passthrough inside tmux
|
||||||
|
source = beefy-player-1.2.2.tar.gz::https://tildeforge.dev/beefcarver/beefy-player/archive/941c7446384bf346e40d1438a7ce67939d9615df.tar.gz
|
||||||
|
sha256sums = 8ac568676ef5ef8345441c28e4f8f5d61fb06d5225e566ac578acae18672c778
|
||||||
|
|
||||||
|
pkgname = beefy-player
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Maintainer: beefy-player contributors <beefcarver@tilde.club>
|
||||||
|
|
||||||
|
pkgname=beefy-player
|
||||||
|
pkgver=1.2.2
|
||||||
|
pkgrel=1
|
||||||
|
pkgdesc="Flashy terminal player for internet radio with a live spectrum analyser"
|
||||||
|
arch=('any')
|
||||||
|
url="https://tildeforge.dev/beefcarver/beefy-player"
|
||||||
|
license=('WTFPL')
|
||||||
|
depends=(
|
||||||
|
'python'
|
||||||
|
'python-dbus-next'
|
||||||
|
'python-httpx'
|
||||||
|
'python-numpy'
|
||||||
|
'python-pillow'
|
||||||
|
'python-rich'
|
||||||
|
'python-textual'
|
||||||
|
)
|
||||||
|
makedepends=(
|
||||||
|
'python-build'
|
||||||
|
'python-hatchling'
|
||||||
|
'python-installer'
|
||||||
|
'python-wheel'
|
||||||
|
)
|
||||||
|
checkdepends=(
|
||||||
|
'python-pytest'
|
||||||
|
'python-pytest-asyncio'
|
||||||
|
)
|
||||||
|
optdepends=(
|
||||||
|
'ffmpeg: decode the stream for the spectrum analyser, and ffplay as the audio fallback'
|
||||||
|
'libpulse: capture the output monitor so the analyser follows what you hear (parec/pactl)'
|
||||||
|
'mpv: preferred audio backend'
|
||||||
|
'tmux: detect the outer terminal for kitty graphics passthrough inside tmux'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Upstream does not tag releases yet, so the source is the archive of the
|
||||||
|
# commit that carries this version. Once a `v$pkgver` tag exists, switch to
|
||||||
|
#
|
||||||
|
# source=("$pkgname-$pkgver.tar.gz::$url/archive/v$pkgver.tar.gz")
|
||||||
|
#
|
||||||
|
_commit=941c7446384bf346e40d1438a7ce67939d9615df
|
||||||
|
source=("$pkgname-$pkgver.tar.gz::$url/archive/$_commit.tar.gz")
|
||||||
|
sha256sums=('8ac568676ef5ef8345441c28e4f8f5d61fb06d5225e566ac578acae18672c778')
|
||||||
|
|
||||||
|
build() {
|
||||||
|
cd "$pkgname"
|
||||||
|
python -m build --wheel --no-isolation
|
||||||
|
}
|
||||||
|
|
||||||
|
check() {
|
||||||
|
cd "$pkgname"
|
||||||
|
# The suite is offline: catalogues are fixtures, mpv/parec are faked.
|
||||||
|
python -m pytest -q
|
||||||
|
}
|
||||||
|
|
||||||
|
package() {
|
||||||
|
cd "$pkgname"
|
||||||
|
python -m installer --destdir="$pkgdir" dist/*.whl
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# AUR packaging for beefy-player
|
||||||
|
|
||||||
|
An AUR repository holds exactly two tracked files, `PKGBUILD` and `.SRCINFO`.
|
||||||
|
This directory is the staging area for both; the rest of the project is
|
||||||
|
irrelevant to the AUR.
|
||||||
|
|
||||||
|
## Build it locally
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd aur
|
||||||
|
makepkg -si # build, run the test suite, install
|
||||||
|
```
|
||||||
|
|
||||||
|
`makepkg -o && makepkg -e` skips the download/checksum phase, and
|
||||||
|
`extra-x86_64-build` (devtools) builds in a clean chroot. The package builds
|
||||||
|
with `base-devel` plus the `makedepends`/`checkdepends` listed in the
|
||||||
|
`PKGBUILD`; every dependency comes from the official repositories, none from
|
||||||
|
the AUR.
|
||||||
|
|
||||||
|
## Publish to the AUR
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone ssh://aur@aur.archlinux.org/beefy-player.git /tmp/aur-beefy-player
|
||||||
|
cp PKGBUILD .SRCINFO /tmp/aur-beefy-player/
|
||||||
|
cd /tmp/aur-beefy-player
|
||||||
|
git add PKGBUILD .SRCINFO
|
||||||
|
git commit -m "Initial import: beefy-player 1.2.2-1"
|
||||||
|
git push
|
||||||
|
```
|
||||||
|
|
||||||
|
## Releasing a new version
|
||||||
|
|
||||||
|
1. Set `pkgver` in `PKGBUILD` and `pyproject.toml`, and reset `pkgrel=1`.
|
||||||
|
2. Point `_commit` at the commit carrying that version:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git rev-parse HEAD # or the tag, once one exists
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Refresh the checksum:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL "https://tildeforge.dev/beefcarver/beefy-player/archive/$_commit.tar.gz" | sha256sum
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Regenerate the metadata (on Arch, or inside a chroot):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
makepkg --printsrcinfo > .SRCINFO
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Push to the AUR with the conventional commit message:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit -am "upgpkg: beefy-player <pkgver>-<pkgrel>"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Why the source is a commit archive
|
||||||
|
|
||||||
|
Upstream publishes no tags or releases, so the checksum in `sha256sums`
|
||||||
|
belongs to the archive of a specific commit rather than of a `v$pkgver` tag.
|
||||||
|
Forge archives of a commit are deterministic, so the checksum stays stable.
|
||||||
|
Once `v$pkgver` is tagged, replace the `source` line with
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source=("$pkgname-$pkgver.tar.gz::$url/archive/v$pkgver.tar.gz")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Packaging notes
|
||||||
|
|
||||||
|
- The wheel is built with `hatchling`; `theme.tcss` is force-included by
|
||||||
|
`pyproject.toml`, so it ends up in `site-packages/beefy_player/`.
|
||||||
|
- `check()` runs the real test suite. It is offline by design (station
|
||||||
|
catalogues are fixtures and `mpv`/`parec` are faked), which makes it safe in
|
||||||
|
a build chroot with no network.
|
||||||
|
- `mpv`, `ffmpeg`, `libpulse` (`parec`/`pactl`) and `tmux` are `optdepends`,
|
||||||
|
not `depends`: the player degrades gracefully without them and says so in
|
||||||
|
the UI. Users who want sound need `mpv` (or `ffmpeg` for `ffplay`) and, for
|
||||||
|
the output-synced spectrum, `libpulse`.
|
||||||
|
- `license=('WTFPL')` is an SPDX identifier shipped by Arch's `licenses`
|
||||||
|
package.
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
"""Beefy Player — a flashy terminal player for internet radio."""
|
"""Beefy Player — a flashy terminal player for internet radio."""
|
||||||
|
|
||||||
__version__ = "1.0.0"
|
__version__ = "1.2.2"
|
||||||
+386
-12
@@ -26,10 +26,12 @@ from textual.widgets import Input
|
|||||||
from . import artwork, bandcamp, config
|
from . import artwork, bandcamp, config
|
||||||
from .mediasession import create_media_session, media_actions_queue
|
from .mediasession import create_media_session, media_actions_queue
|
||||||
from .models import Broadcast, Station, parse_last_playing
|
from .models import Broadcast, Station, parse_last_playing
|
||||||
|
from .palettes import PALETTE_ORDER
|
||||||
from .player import Player
|
from .player import Player
|
||||||
from .providers import get_provider
|
from .providers import get_provider
|
||||||
from .providers.base import is_direct_stream, make_client
|
from .providers.base import is_direct_stream, make_client
|
||||||
from .spectrum import SpectrumEngine
|
from .spectrum import SpectrumEngine
|
||||||
|
from .widgets.art import ArtPane
|
||||||
from .widgets.header import HeaderBar
|
from .widgets.header import HeaderBar
|
||||||
from .widgets.help import HelpScreen
|
from .widgets.help import HelpScreen
|
||||||
from .widgets.providers import ProviderScreen
|
from .widgets.providers import ProviderScreen
|
||||||
@@ -42,6 +44,10 @@ from .widgets.status import StatusBar
|
|||||||
|
|
||||||
VOLUME_STEP = 5
|
VOLUME_STEP = 5
|
||||||
|
|
||||||
|
#: How close the cursor may get to the end of a paginated list before the next
|
||||||
|
#: directory page is fetched.
|
||||||
|
LOAD_MORE_MARGIN = 20
|
||||||
|
|
||||||
# A theme made only of terminal defaults / ANSI palette entries. Textual's
|
# A theme made only of terminal defaults / ANSI palette entries. Textual's
|
||||||
# built-in themes paint an RGB background and map ANSI names to their own RGB
|
# built-in themes paint an RGB background and map ANSI names to their own RGB
|
||||||
# palette, which breaks transparency and ignores the user's colours.
|
# palette, which breaks transparency and ignores the user's colours.
|
||||||
@@ -92,6 +98,7 @@ class BeefyPlayerApp(App[None]):
|
|||||||
Binding("slash", "search", "filter"),
|
Binding("slash", "search", "filter"),
|
||||||
Binding("escape", "escape", "close", show=False),
|
Binding("escape", "escape", "close", show=False),
|
||||||
Binding("v", "visualizer", "visualiser"),
|
Binding("v", "visualizer", "visualiser"),
|
||||||
|
Binding("c", "palette", "palette"),
|
||||||
Binding("r", "refresh", "refresh"),
|
Binding("r", "refresh", "refresh"),
|
||||||
Binding("p", "provider", "catalogue"),
|
Binding("p", "provider", "catalogue"),
|
||||||
Binding("question_mark", "help", "help"),
|
Binding("question_mark", "help", "help"),
|
||||||
@@ -143,10 +150,26 @@ class BeefyPlayerApp(App[None]):
|
|||||||
self.schedule_loaded = False
|
self.schedule_loaded = False
|
||||||
self.index = 0
|
self.index = 0
|
||||||
self.query = ""
|
self.query = ""
|
||||||
|
#: Server-side directory results for ``query``; ``None`` means the
|
||||||
|
#: view is filtered locally from :attr:`stations`.
|
||||||
|
self.search_results: list[Station] | None = None
|
||||||
|
self._search_results_query = ""
|
||||||
|
#: A server-side search is in flight; the list shows a searching hint.
|
||||||
|
self.search_pending = False
|
||||||
|
#: Stations reached through a search or a later directory page. They
|
||||||
|
#: are kept when a refresh only fetches the first page again.
|
||||||
|
self._discovered_station_ids: set[str] = set()
|
||||||
|
#: Directory paging: next offset, whether there are more pages, and
|
||||||
|
#: whether a page request is already in flight.
|
||||||
|
self._directory_offset = 0
|
||||||
|
self._directory_exhausted = True
|
||||||
|
self._loading_more = False
|
||||||
self.current: Station | None = None
|
self.current: Station | None = None
|
||||||
self.playing_state = "stopped"
|
self.playing_state = "stopped"
|
||||||
self.volume = self.config.volume
|
self.volume = self.config.volume
|
||||||
self.visualizer = self.config.visualizer
|
self.visualizer = self.config.visualizer
|
||||||
|
#: Colour palette name (see :mod:`beefy_player.palettes`).
|
||||||
|
self.palette = self.config.palette
|
||||||
self.tick_count = 0
|
self.tick_count = 0
|
||||||
self.notice = ""
|
self.notice = ""
|
||||||
|
|
||||||
@@ -204,9 +227,11 @@ class BeefyPlayerApp(App[None]):
|
|||||||
self._apply_catalogue_chrome()
|
self._apply_catalogue_chrome()
|
||||||
|
|
||||||
stations = self.provider.load_cached_stations()
|
stations = self.provider.load_cached_stations()
|
||||||
|
cached = bool(stations)
|
||||||
if not stations:
|
if not stations:
|
||||||
stations = self.provider.fallback_stations()
|
stations = self.provider.fallback_stations()
|
||||||
self._set_stations(stations)
|
self._set_stations(stations)
|
||||||
|
self._reset_directory_paging(stations, has_more=cached)
|
||||||
self._select_initial_station()
|
self._select_initial_station()
|
||||||
|
|
||||||
if self.current is not None:
|
if self.current is not None:
|
||||||
@@ -254,10 +279,35 @@ class BeefyPlayerApp(App[None]):
|
|||||||
self.config.visualizer = self.visualizer
|
self.config.visualizer = self.visualizer
|
||||||
self.config.save()
|
self.config.save()
|
||||||
|
|
||||||
|
def _find_station(self, station_id: str) -> Station | None:
|
||||||
|
"""Find a station by id: on air, in the catalogue, or in the search."""
|
||||||
|
if self.current is not None and self.current.id == station_id:
|
||||||
|
return self.current
|
||||||
|
for station in self.stations:
|
||||||
|
if station.id == station_id:
|
||||||
|
return station
|
||||||
|
for station in self.search_results or ():
|
||||||
|
if station.id == station_id:
|
||||||
|
return station
|
||||||
|
return None
|
||||||
|
|
||||||
def _set_stations(self, stations: list[Station]) -> None:
|
def _set_stations(self, stations: list[Station]) -> None:
|
||||||
if not stations:
|
if not stations:
|
||||||
return
|
return
|
||||||
|
stations = list(stations)
|
||||||
|
known = {station.id for station in stations}
|
||||||
|
for station in self.stations:
|
||||||
|
if (
|
||||||
|
station.id in self._discovered_station_ids
|
||||||
|
and station.id not in known
|
||||||
|
):
|
||||||
|
# Keep stations found through search or a later page when a
|
||||||
|
# refresh only knows the first page.
|
||||||
|
stations.append(station)
|
||||||
|
known.add(station.id)
|
||||||
existing = {station.id: station for station in self.stations}
|
existing = {station.id: station for station in self.stations}
|
||||||
|
if self.current is not None:
|
||||||
|
existing[self.current.id] = self.current
|
||||||
for station in stations:
|
for station in stations:
|
||||||
previous = existing.get(station.id)
|
previous = existing.get(station.id)
|
||||||
if previous is not None:
|
if previous is not None:
|
||||||
@@ -279,6 +329,33 @@ class BeefyPlayerApp(App[None]):
|
|||||||
self._update_artwork()
|
self._update_artwork()
|
||||||
self.query_one(HeaderBar).refresh()
|
self.query_one(HeaderBar).refresh()
|
||||||
|
|
||||||
|
def _reset_directory_paging(
|
||||||
|
self, stations: list[Station], *, has_more: bool = True
|
||||||
|
) -> None:
|
||||||
|
self._loading_more = False
|
||||||
|
if not self.provider.paginated or not has_more:
|
||||||
|
self._directory_offset = 0
|
||||||
|
self._directory_exhausted = True
|
||||||
|
return
|
||||||
|
# The cached list is in catalogue order, so continue after it.
|
||||||
|
self._directory_offset = len(stations)
|
||||||
|
self._directory_exhausted = False
|
||||||
|
|
||||||
|
def _append_stations(self, stations: list[Station]) -> int:
|
||||||
|
"""Add a page to the catalogue, preserving existing station state."""
|
||||||
|
known = {station.id for station in self.stations}
|
||||||
|
added = 0
|
||||||
|
for station in stations:
|
||||||
|
if station.id in known:
|
||||||
|
continue
|
||||||
|
station.favorite = self.config.is_favorite(station.id)
|
||||||
|
if station.now_playing is None and station.last_playing:
|
||||||
|
station.now_playing = parse_last_playing(station.last_playing)
|
||||||
|
self.stations.append(station)
|
||||||
|
known.add(station.id)
|
||||||
|
added += 1
|
||||||
|
return added
|
||||||
|
|
||||||
def _select_initial_station(self) -> None:
|
def _select_initial_station(self) -> None:
|
||||||
if not self.view:
|
if not self.view:
|
||||||
return
|
return
|
||||||
@@ -302,7 +379,8 @@ class BeefyPlayerApp(App[None]):
|
|||||||
break
|
break
|
||||||
|
|
||||||
def _rebuild_view(self, keep_id: str | None = None) -> None:
|
def _rebuild_view(self, keep_id: str | None = None) -> None:
|
||||||
terms = self.query.casefold().split()
|
query = self.query.strip()
|
||||||
|
terms = query.casefold().split()
|
||||||
|
|
||||||
def matches(station: Station) -> bool:
|
def matches(station: Station) -> bool:
|
||||||
if not terms:
|
if not terms:
|
||||||
@@ -316,8 +394,27 @@ class BeefyPlayerApp(App[None]):
|
|||||||
if keep_id is None and self.view and 0 <= self.index < len(self.view):
|
if keep_id is None and self.view and 0 <= self.index < len(self.view):
|
||||||
keep_id = self.view[self.index].id
|
keep_id = self.view[self.index].id
|
||||||
|
|
||||||
view = [station for station in self.stations if matches(station)]
|
if (
|
||||||
view.sort(key=lambda station: (0 if station.favorite else 1, station.title.casefold()))
|
terms
|
||||||
|
and self.search_results is not None
|
||||||
|
and self._search_results_query == query
|
||||||
|
):
|
||||||
|
# The directory matched fields that are not all part of a Station's
|
||||||
|
# local search text; trust its answer instead of filtering again.
|
||||||
|
view = list(self.search_results)
|
||||||
|
else:
|
||||||
|
view = [station for station in self.stations if matches(station)]
|
||||||
|
if self.provider.preserve_order:
|
||||||
|
# Stable sort: keep the catalogue's popularity order within the
|
||||||
|
# favourites and everything else.
|
||||||
|
view.sort(key=lambda station: 0 if station.favorite else 1)
|
||||||
|
else:
|
||||||
|
view.sort(
|
||||||
|
key=lambda station: (
|
||||||
|
0 if station.favorite else 1,
|
||||||
|
station.title.casefold(),
|
||||||
|
)
|
||||||
|
)
|
||||||
self.view = view
|
self.view = view
|
||||||
|
|
||||||
if keep_id is not None:
|
if keep_id is not None:
|
||||||
@@ -333,6 +430,39 @@ class BeefyPlayerApp(App[None]):
|
|||||||
if self.current is None and view:
|
if self.current is None and view:
|
||||||
self.current = view[self.index]
|
self.current = view[self.index]
|
||||||
|
|
||||||
|
def _set_search_results(self, query: str, stations: list[Station]) -> None:
|
||||||
|
"""Show a server-side directory search in place of the loaded list."""
|
||||||
|
known = {station.id: station for station in self.stations}
|
||||||
|
if self.current is not None:
|
||||||
|
known[self.current.id] = self.current
|
||||||
|
results: list[Station] = []
|
||||||
|
for station in stations:
|
||||||
|
previous = known.get(station.id)
|
||||||
|
if previous is not None:
|
||||||
|
# Reuse the catalogue's object so favourite/history changes
|
||||||
|
# made in the search results show up in the regular list too.
|
||||||
|
station = previous
|
||||||
|
else:
|
||||||
|
station.favorite = self.config.is_favorite(station.id)
|
||||||
|
if station.now_playing is None and station.last_playing:
|
||||||
|
station.now_playing = parse_last_playing(station.last_playing)
|
||||||
|
results.append(station)
|
||||||
|
|
||||||
|
self.search_results = results
|
||||||
|
self._search_results_query = query
|
||||||
|
self.search_pending = False
|
||||||
|
self._rebuild_view(
|
||||||
|
keep_id=self.current.id if self.current is not None else None
|
||||||
|
)
|
||||||
|
if self.current is None or not any(
|
||||||
|
station.id == self.current.id for station in self.view
|
||||||
|
):
|
||||||
|
self.index = 0
|
||||||
|
with contextlib.suppress(NoMatches):
|
||||||
|
pane = self.query_one(StationList)
|
||||||
|
pane.set_filter(self.query)
|
||||||
|
pane.refresh()
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# the catalogue's chrome
|
# the catalogue's chrome
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@@ -357,6 +487,9 @@ class BeefyPlayerApp(App[None]):
|
|||||||
def _reset_filter(self) -> None:
|
def _reset_filter(self) -> None:
|
||||||
"""Forget the station filter and put the search box away."""
|
"""Forget the station filter and put the search box away."""
|
||||||
self.query = ""
|
self.query = ""
|
||||||
|
self.search_results = None
|
||||||
|
self._search_results_query = ""
|
||||||
|
self.search_pending = False
|
||||||
search = self.query_one(SearchInput)
|
search = self.query_one(SearchInput)
|
||||||
search.value = ""
|
search.value = ""
|
||||||
search.remove_class("visible")
|
search.remove_class("visible")
|
||||||
@@ -380,6 +513,12 @@ class BeefyPlayerApp(App[None]):
|
|||||||
def _tune(self, station: Station) -> None:
|
def _tune(self, station: Station) -> None:
|
||||||
self.current = station
|
self.current = station
|
||||||
self.start_station_id = station.id
|
self.start_station_id = station.id
|
||||||
|
if not any(existing.id == station.id for existing in self.stations):
|
||||||
|
# A directory search can reach a station outside the loaded pages;
|
||||||
|
# keep it in the catalogue so clearing the filter still shows it.
|
||||||
|
self.stations.append(station)
|
||||||
|
# Remember it so a refresh that only fetches the first page keeps it.
|
||||||
|
self._discovered_station_ids.add(station.id)
|
||||||
self.playing_state = "loading"
|
self.playing_state = "loading"
|
||||||
self._set_notice(f"tuning in to {station.title}…", 5)
|
self._set_notice(f"tuning in to {station.title}…", 5)
|
||||||
self._save_config()
|
self._save_config()
|
||||||
@@ -400,7 +539,7 @@ class BeefyPlayerApp(App[None]):
|
|||||||
|
|
||||||
@work(exclusive=True, group="stream", exit_on_error=False)
|
@work(exclusive=True, group="stream", exit_on_error=False)
|
||||||
async def _resolve_and_play(self, station_id: str) -> None:
|
async def _resolve_and_play(self, station_id: str) -> None:
|
||||||
station = next((s for s in self.stations if s.id == station_id), None)
|
station = self._find_station(station_id)
|
||||||
if station is None or self.current is None or self.current.id != station_id:
|
if station is None or self.current is None or self.current.id != station_id:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -435,7 +574,7 @@ class BeefyPlayerApp(App[None]):
|
|||||||
@work(exclusive=True, group="history", exit_on_error=False)
|
@work(exclusive=True, group="history", exit_on_error=False)
|
||||||
async def _fetch_history(self, station_id: str) -> None:
|
async def _fetch_history(self, station_id: str) -> None:
|
||||||
self._last_history_fetch = time.monotonic()
|
self._last_history_fetch = time.monotonic()
|
||||||
station = next((s for s in self.stations if s.id == station_id), None)
|
station = self._find_station(station_id)
|
||||||
if station is None or self._http is None:
|
if station is None or self._http is None:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
@@ -554,7 +693,7 @@ class BeefyPlayerApp(App[None]):
|
|||||||
return
|
return
|
||||||
|
|
||||||
self._bandcamp_urls[f"{station_id}|{song_key}"] = url
|
self._bandcamp_urls[f"{station_id}|{song_key}"] = url
|
||||||
station = next((s for s in self.stations if s.id == station_id), None)
|
station = self._find_station(station_id)
|
||||||
if station is None or self.current is None or self.current.id != station_id:
|
if station is None or self.current is None or self.current.id != station_id:
|
||||||
return
|
return
|
||||||
song = station.now_playing
|
song = station.now_playing
|
||||||
@@ -788,27 +927,179 @@ class BeefyPlayerApp(App[None]):
|
|||||||
# workers
|
# workers
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
@work(exclusive=True, group="stations-page", exit_on_error=False)
|
||||||
|
async def _load_more_stations(self) -> None:
|
||||||
|
try:
|
||||||
|
if (
|
||||||
|
not self.provider.paginated
|
||||||
|
or self._directory_exhausted
|
||||||
|
or self.offline
|
||||||
|
or self._http is None
|
||||||
|
):
|
||||||
|
return
|
||||||
|
page = await self.provider.fetch_station_page(
|
||||||
|
self._http, self._directory_offset
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
self._set_notice("could not load more stations", 4)
|
||||||
|
return
|
||||||
|
finally:
|
||||||
|
self._loading_more = False
|
||||||
|
|
||||||
|
self._directory_offset = page.next_offset or 0
|
||||||
|
self._directory_exhausted = page.next_offset is None
|
||||||
|
if not self._append_stations(page.stations):
|
||||||
|
return
|
||||||
|
|
||||||
|
keep_id = (
|
||||||
|
self.view[self.index].id
|
||||||
|
if self.view and 0 <= self.index < len(self.view)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
self._rebuild_view(keep_id=keep_id)
|
||||||
|
with contextlib.suppress(NoMatches):
|
||||||
|
pane = self.query_one(StationList)
|
||||||
|
pane.set_filter(self.query)
|
||||||
|
pane.refresh()
|
||||||
|
|
||||||
|
def _maybe_load_more_stations(self) -> None:
|
||||||
|
"""Queue the next directory page when the cursor nears the end."""
|
||||||
|
if (
|
||||||
|
not self.provider.paginated
|
||||||
|
or self._loading_more
|
||||||
|
or self._directory_exhausted
|
||||||
|
or self.offline
|
||||||
|
or not self.view
|
||||||
|
or self.query.strip()
|
||||||
|
):
|
||||||
|
return
|
||||||
|
if self.index < len(self.view) - LOAD_MORE_MARGIN:
|
||||||
|
return
|
||||||
|
self._loading_more = True
|
||||||
|
self._set_notice("loading more stations…", 3)
|
||||||
|
self._load_more_stations()
|
||||||
|
|
||||||
|
@work(exclusive=True, group="search", exit_on_error=False)
|
||||||
|
async def _search_directory(self, query: str) -> None:
|
||||||
|
"""Debounced server-side search, for catalogues that support it."""
|
||||||
|
text = query.strip()
|
||||||
|
if (
|
||||||
|
not text
|
||||||
|
or not self.provider.server_search
|
||||||
|
or self.offline
|
||||||
|
or self._http is None
|
||||||
|
):
|
||||||
|
self.search_pending = False
|
||||||
|
return
|
||||||
|
# A new keystroke starts a new exclusive worker and cancels this sleep.
|
||||||
|
await asyncio.sleep(0.25)
|
||||||
|
if text != self.query.strip():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
stations = await self.provider.search_stations(self._http, text)
|
||||||
|
except Exception:
|
||||||
|
if text == self.query.strip():
|
||||||
|
self.search_pending = False
|
||||||
|
self._set_notice(
|
||||||
|
"directory search failed — filtering the loaded stations", 4
|
||||||
|
)
|
||||||
|
self._refresh_pane()
|
||||||
|
return
|
||||||
|
if text != self.query.strip():
|
||||||
|
return
|
||||||
|
self._set_search_results(text, stations)
|
||||||
|
|
||||||
|
async def _load_favorite_stations(self) -> None:
|
||||||
|
"""Fetch favourites that are not on the currently loaded page."""
|
||||||
|
if self.offline or self._http is None or not self.provider.paginated:
|
||||||
|
return
|
||||||
|
known = {station.id for station in self.stations}
|
||||||
|
missing = [
|
||||||
|
station_id
|
||||||
|
for station_id in self.config.favorites
|
||||||
|
if station_id not in known
|
||||||
|
]
|
||||||
|
if not missing:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
stations = await self.provider.fetch_stations_by_ids(
|
||||||
|
self._http, missing
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
added = 0
|
||||||
|
for station in stations:
|
||||||
|
if any(existing.id == station.id for existing in self.stations):
|
||||||
|
continue
|
||||||
|
if not self.config.is_favorite(station.id):
|
||||||
|
continue
|
||||||
|
previous = next(
|
||||||
|
(
|
||||||
|
searched
|
||||||
|
for searched in (self.search_results or ())
|
||||||
|
if searched.id == station.id
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if previous is not None:
|
||||||
|
# A search result and a favourite lookup can race; keep one
|
||||||
|
# object so favourite/history changes are shared.
|
||||||
|
station = previous
|
||||||
|
station.favorite = True
|
||||||
|
self.stations.append(station)
|
||||||
|
self._discovered_station_ids.add(station.id)
|
||||||
|
added += 1
|
||||||
|
if not added:
|
||||||
|
return
|
||||||
|
|
||||||
|
keep_id = (
|
||||||
|
self.view[self.index].id
|
||||||
|
if self.view and 0 <= self.index < len(self.view)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
self._rebuild_view(keep_id=keep_id)
|
||||||
|
with contextlib.suppress(NoMatches):
|
||||||
|
pane = self.query_one(StationList)
|
||||||
|
pane.set_filter(self.query)
|
||||||
|
pane.refresh()
|
||||||
|
|
||||||
@work(exclusive=True, group="stations", exit_on_error=False)
|
@work(exclusive=True, group="stations", exit_on_error=False)
|
||||||
async def _refresh_stations(self) -> None:
|
async def _refresh_stations(self) -> None:
|
||||||
if self.offline or self._http is None:
|
if self.offline or self._http is None:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
stations = await self.provider.fetch_stations(self._http)
|
if self.provider.paginated:
|
||||||
|
page = await self.provider.fetch_station_page(self._http, 0)
|
||||||
|
stations = page.stations
|
||||||
|
next_offset = page.next_offset
|
||||||
|
else:
|
||||||
|
stations = await self.provider.fetch_stations(self._http)
|
||||||
|
next_offset = None
|
||||||
|
if not stations:
|
||||||
|
raise RuntimeError("the catalogue returned no stations")
|
||||||
except Exception:
|
except Exception:
|
||||||
if not self.stations:
|
if not self.stations:
|
||||||
self._set_stations(self.provider.fallback_stations())
|
self._set_stations(self.provider.fallback_stations())
|
||||||
self._set_notice("offline — using the built-in station list", 8)
|
self._set_notice("offline — using the built-in station list", 8)
|
||||||
else:
|
else:
|
||||||
self._set_notice("station refresh failed — using the cached list", 6)
|
self._set_notice("station refresh failed — using the cached list", 6)
|
||||||
|
# The full directory may be unreachable while a favourite lookup
|
||||||
|
# still works; show those rather than hiding them until a restart.
|
||||||
|
await self._load_favorite_stations()
|
||||||
return
|
return
|
||||||
before = self.current.id if self.current is not None else None
|
before = self.current.id if self.current is not None else None
|
||||||
self._set_stations(stations)
|
self._set_stations(stations)
|
||||||
if before is not None:
|
if before is not None:
|
||||||
self._select_station_id(before)
|
self._select_station_id(before)
|
||||||
|
self._directory_offset = next_offset or 0
|
||||||
|
self._directory_exhausted = next_offset is None
|
||||||
|
self._loading_more = False
|
||||||
self._set_notice(
|
self._set_notice(
|
||||||
"1 station" if len(stations) == 1 else f"{len(stations)} stations", 3
|
"1 station" if len(stations) == 1 else f"{len(stations)} stations", 3
|
||||||
)
|
)
|
||||||
self.query_one(StationList).refresh()
|
self.query_one(StationList).refresh()
|
||||||
|
await self._load_favorite_stations()
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# actions
|
# actions
|
||||||
@@ -825,6 +1116,7 @@ class BeefyPlayerApp(App[None]):
|
|||||||
return
|
return
|
||||||
self.index = min(len(self.view) - 1, self.index + 1)
|
self.index = min(len(self.view) - 1, self.index + 1)
|
||||||
self.query_one(StationList).refresh()
|
self.query_one(StationList).refresh()
|
||||||
|
self._maybe_load_more_stations()
|
||||||
|
|
||||||
def _page(self, delta: int) -> None:
|
def _page(self, delta: int) -> None:
|
||||||
if not self.view:
|
if not self.view:
|
||||||
@@ -832,6 +1124,7 @@ class BeefyPlayerApp(App[None]):
|
|||||||
page = max(1, self.query_one(StationList).size.height - 1)
|
page = max(1, self.query_one(StationList).size.height - 1)
|
||||||
self.index = max(0, min(len(self.view) - 1, self.index + delta * page))
|
self.index = max(0, min(len(self.view) - 1, self.index + delta * page))
|
||||||
self.query_one(StationList).refresh()
|
self.query_one(StationList).refresh()
|
||||||
|
self._maybe_load_more_stations()
|
||||||
|
|
||||||
def action_page_up(self) -> None:
|
def action_page_up(self) -> None:
|
||||||
self._page(-1)
|
self._page(-1)
|
||||||
@@ -850,6 +1143,7 @@ class BeefyPlayerApp(App[None]):
|
|||||||
return
|
return
|
||||||
self.index = len(self.view) - 1
|
self.index = len(self.view) - 1
|
||||||
self.query_one(StationList).refresh()
|
self.query_one(StationList).refresh()
|
||||||
|
self._maybe_load_more_stations()
|
||||||
|
|
||||||
def action_tune(self) -> None:
|
def action_tune(self) -> None:
|
||||||
if not self.view:
|
if not self.view:
|
||||||
@@ -909,6 +1203,21 @@ class BeefyPlayerApp(App[None]):
|
|||||||
return
|
return
|
||||||
added = self.config.toggle_favorite(station.id)
|
added = self.config.toggle_favorite(station.id)
|
||||||
station.favorite = added
|
station.favorite = added
|
||||||
|
known = False
|
||||||
|
for existing in self.stations:
|
||||||
|
if existing.id == station.id:
|
||||||
|
# A search copy and the catalogue copy can briefly coexist;
|
||||||
|
# keep both in step so clearing the filter cannot resurrect
|
||||||
|
# the old favourite state.
|
||||||
|
existing.favorite = added
|
||||||
|
known = True
|
||||||
|
break
|
||||||
|
if added:
|
||||||
|
# A favourite reached through a directory search must join the
|
||||||
|
# catalogue, or clearing the filter would hide it again.
|
||||||
|
self._discovered_station_ids.add(station.id)
|
||||||
|
if not known:
|
||||||
|
self.stations.append(station)
|
||||||
self._save_config()
|
self._save_config()
|
||||||
self._rebuild_view(keep_id=station.id)
|
self._rebuild_view(keep_id=station.id)
|
||||||
self._set_notice(("★ added " if added else "☆ removed ") + station.title, 3)
|
self._set_notice(("★ added " if added else "☆ removed ") + station.title, 3)
|
||||||
@@ -927,9 +1236,12 @@ class BeefyPlayerApp(App[None]):
|
|||||||
search.cursor_position = len(search.value)
|
search.cursor_position = len(search.value)
|
||||||
|
|
||||||
def action_escape(self) -> None:
|
def action_escape(self) -> None:
|
||||||
if self.query:
|
search = self.query_one(SearchInput)
|
||||||
|
if self.query and not search.has_class("visible"):
|
||||||
|
# The filter is already sticky: a second escape clears it.
|
||||||
self._close_search(clear=True)
|
self._close_search(clear=True)
|
||||||
else:
|
else:
|
||||||
|
# Put the search box away but keep the filter on the station list.
|
||||||
self._close_search(clear=False)
|
self._close_search(clear=False)
|
||||||
|
|
||||||
def _close_search(self, clear: bool = False) -> None:
|
def _close_search(self, clear: bool = False) -> None:
|
||||||
@@ -937,6 +1249,9 @@ class BeefyPlayerApp(App[None]):
|
|||||||
search.remove_class("visible")
|
search.remove_class("visible")
|
||||||
if clear:
|
if clear:
|
||||||
self.query = ""
|
self.query = ""
|
||||||
|
self.search_results = None
|
||||||
|
self._search_results_query = ""
|
||||||
|
self.search_pending = False
|
||||||
search.value = ""
|
search.value = ""
|
||||||
search.disabled = True
|
search.disabled = True
|
||||||
self.set_focus(None)
|
self.set_focus(None)
|
||||||
@@ -944,6 +1259,7 @@ class BeefyPlayerApp(App[None]):
|
|||||||
self.query_one(StationList).set_filter(self.query)
|
self.query_one(StationList).set_filter(self.query)
|
||||||
self.query_one(StationList).refresh()
|
self.query_one(StationList).refresh()
|
||||||
self.query_one(StagePanel).refresh_stage()
|
self.query_one(StagePanel).refresh_stage()
|
||||||
|
self._maybe_load_more_stations()
|
||||||
|
|
||||||
def action_visualizer(self) -> None:
|
def action_visualizer(self) -> None:
|
||||||
modes = list(config.VISUALIZERS)
|
modes = list(config.VISUALIZERS)
|
||||||
@@ -956,10 +1272,27 @@ class BeefyPlayerApp(App[None]):
|
|||||||
self._set_notice(f"visualiser: {self.visualizer}", 2)
|
self._set_notice(f"visualiser: {self.visualizer}", 2)
|
||||||
self.query_one(SpectrumPanel).refresh()
|
self.query_one(SpectrumPanel).refresh()
|
||||||
|
|
||||||
|
def action_palette(self) -> None:
|
||||||
|
order = list(PALETTE_ORDER)
|
||||||
|
index = order.index(self.palette) if self.palette in order else 0
|
||||||
|
self.palette = order[(index + 1) % len(order)]
|
||||||
|
self.config.palette = self.palette
|
||||||
|
self._save_config()
|
||||||
|
self._set_notice(f"palette: {self.palette}", 2)
|
||||||
|
# Frames, spectrum, mosaic and wordmarks all read the palette live, so
|
||||||
|
# a repaint of every widget is all it takes. (``self.query`` is the
|
||||||
|
# station filter here, hence ``self.screen.query``.)
|
||||||
|
for widget in self.screen.query("*"):
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
widget.refresh()
|
||||||
|
|
||||||
def action_refresh(self) -> None:
|
def action_refresh(self) -> None:
|
||||||
self._last_history_fetch = 0.0
|
self._last_history_fetch = 0.0
|
||||||
self._bandcamp_key = ""
|
self._bandcamp_key = ""
|
||||||
self._bandcamp_urls.clear()
|
self._bandcamp_urls.clear()
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
self.workers.cancel_group(self, "stations-page")
|
||||||
|
self._loading_more = False
|
||||||
self._set_notice("refreshing…", 2)
|
self._set_notice("refreshing…", 2)
|
||||||
if self.offline:
|
if self.offline:
|
||||||
self._set_notice("offline mode — nothing to refresh", 3)
|
self._set_notice("offline mode — nothing to refresh", 3)
|
||||||
@@ -972,14 +1305,34 @@ class BeefyPlayerApp(App[None]):
|
|||||||
self._fetch_history(self.current.id)
|
self._fetch_history(self.current.id)
|
||||||
|
|
||||||
def action_provider(self) -> None:
|
def action_provider(self) -> None:
|
||||||
|
self._suspend_artwork()
|
||||||
self.push_screen(ProviderScreen(self.provider.id), self._provider_chosen)
|
self.push_screen(ProviderScreen(self.provider.id), self._provider_chosen)
|
||||||
|
|
||||||
def _provider_chosen(self, provider_id: str | None) -> None:
|
def _provider_chosen(self, provider_id: str | None) -> None:
|
||||||
|
self._resume_artwork()
|
||||||
if provider_id:
|
if provider_id:
|
||||||
self._switch_catalogue(provider_id)
|
self._switch_catalogue(provider_id)
|
||||||
|
|
||||||
|
def _suspend_artwork(self) -> None:
|
||||||
|
"""Hide the inline artwork: terminal images would sit over a menu."""
|
||||||
|
with contextlib.suppress(NoMatches):
|
||||||
|
self.query_one(ArtPane).suspend()
|
||||||
|
|
||||||
|
def _resume_artwork(self) -> None:
|
||||||
|
with contextlib.suppress(NoMatches):
|
||||||
|
self.query_one(ArtPane).resume()
|
||||||
|
|
||||||
def _cancel_workers(self) -> None:
|
def _cancel_workers(self) -> None:
|
||||||
for group in ("stream", "history", "stations", "schedule", "bandcamp", "artwork"):
|
for group in (
|
||||||
|
"stream",
|
||||||
|
"history",
|
||||||
|
"search",
|
||||||
|
"stations",
|
||||||
|
"stations-page",
|
||||||
|
"schedule",
|
||||||
|
"bandcamp",
|
||||||
|
"artwork",
|
||||||
|
):
|
||||||
with contextlib.suppress(Exception):
|
with contextlib.suppress(Exception):
|
||||||
self.workers.cancel_group(self, group)
|
self.workers.cancel_group(self, group)
|
||||||
|
|
||||||
@@ -1009,6 +1362,10 @@ class BeefyPlayerApp(App[None]):
|
|||||||
self.stations = []
|
self.stations = []
|
||||||
self.view = []
|
self.view = []
|
||||||
self.index = 0
|
self.index = 0
|
||||||
|
self.search_results = None
|
||||||
|
self._search_results_query = ""
|
||||||
|
self.search_pending = False
|
||||||
|
self._discovered_station_ids.clear()
|
||||||
self.current = None
|
self.current = None
|
||||||
self.schedule = []
|
self.schedule = []
|
||||||
self.schedule_loaded = False
|
self.schedule_loaded = False
|
||||||
@@ -1025,8 +1382,12 @@ class BeefyPlayerApp(App[None]):
|
|||||||
self._apply_catalogue_chrome()
|
self._apply_catalogue_chrome()
|
||||||
with contextlib.suppress(NoMatches):
|
with contextlib.suppress(NoMatches):
|
||||||
self._reset_filter()
|
self._reset_filter()
|
||||||
stations = provider.load_cached_stations() or provider.fallback_stations()
|
stations = provider.load_cached_stations()
|
||||||
|
cached = bool(stations)
|
||||||
|
if not stations:
|
||||||
|
stations = provider.fallback_stations()
|
||||||
self._set_stations(stations)
|
self._set_stations(stations)
|
||||||
|
self._reset_directory_paging(stations, has_more=cached)
|
||||||
self._select_initial_station()
|
self._select_initial_station()
|
||||||
self._update_media()
|
self._update_media()
|
||||||
self._update_artwork()
|
self._update_artwork()
|
||||||
@@ -1051,7 +1412,8 @@ class BeefyPlayerApp(App[None]):
|
|||||||
self._refresh_schedule()
|
self._refresh_schedule()
|
||||||
|
|
||||||
def action_help(self) -> None:
|
def action_help(self) -> None:
|
||||||
self.push_screen(HelpScreen())
|
self._suspend_artwork()
|
||||||
|
self.push_screen(HelpScreen(), lambda _result: self._resume_artwork())
|
||||||
|
|
||||||
def action_quit(self) -> None:
|
def action_quit(self) -> None:
|
||||||
self._cleanup()
|
self._cleanup()
|
||||||
@@ -1065,15 +1427,27 @@ class BeefyPlayerApp(App[None]):
|
|||||||
if event.input.id != "search":
|
if event.input.id != "search":
|
||||||
return
|
return
|
||||||
self.query = event.value
|
self.query = event.value
|
||||||
|
self.search_results = None
|
||||||
|
self._search_results_query = ""
|
||||||
|
self.search_pending = (
|
||||||
|
self.provider.server_search
|
||||||
|
and not self.offline
|
||||||
|
and bool(event.value.strip())
|
||||||
|
)
|
||||||
self._rebuild_view()
|
self._rebuild_view()
|
||||||
self.query_one(StationList).set_filter(self.query)
|
self.query_one(StationList).set_filter(self.query)
|
||||||
self.query_one(StationList).refresh()
|
self.query_one(StationList).refresh()
|
||||||
|
if self.provider.server_search:
|
||||||
|
self._search_directory(event.value)
|
||||||
|
self._maybe_load_more_stations()
|
||||||
|
|
||||||
def on_input_submitted(self, event: Input.Submitted) -> None:
|
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||||
if event.input.id != "search":
|
if event.input.id != "search":
|
||||||
return
|
return
|
||||||
station = self.view[self.index] if self.view else None
|
station = self.view[self.index] if self.view else None
|
||||||
self._close_search(clear=True)
|
# Enter tunes, and leaves the filter in place exactly like the first
|
||||||
|
# escape does; a second escape returns to the regular station list.
|
||||||
|
self._close_search(clear=False)
|
||||||
if station is not None:
|
if station is not None:
|
||||||
self._tune(station)
|
self._tune(station)
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from . import __version__
|
from . import __version__
|
||||||
|
from .palettes import DEFAULT_PALETTE, PALETTES
|
||||||
|
|
||||||
#: Directory name for config, cache and runtime files.
|
#: Directory name for config, cache and runtime files.
|
||||||
APP_NAME = "beefy-player"
|
APP_NAME = "beefy-player"
|
||||||
@@ -72,6 +73,9 @@ LEGACY_CONFIG_PATH = _xdg("XDG_CONFIG_HOME", ".config") / LEGACY_APP_NAME / "con
|
|||||||
|
|
||||||
VISUALIZERS = ("both", "bars", "wave", "waterfall", "radial", "braille")
|
VISUALIZERS = ("both", "bars", "wave", "waterfall", "radial", "braille")
|
||||||
|
|
||||||
|
#: Colour palettes the UI can draw with (see :mod:`beefy_player.palettes`).
|
||||||
|
PALETTE_NAMES = tuple(PALETTES)
|
||||||
|
|
||||||
|
|
||||||
def channels_cache_path(provider_id: str) -> Path:
|
def channels_cache_path(provider_id: str) -> Path:
|
||||||
"""Where a catalogue caches its last good station list.
|
"""Where a catalogue caches its last good station list.
|
||||||
@@ -141,6 +145,8 @@ class Config:
|
|||||||
last_stations: dict[str, str] = field(default_factory=dict)
|
last_stations: dict[str, str] = field(default_factory=dict)
|
||||||
volume: int = 70
|
volume: int = 70
|
||||||
visualizer: str = "both"
|
visualizer: str = "both"
|
||||||
|
#: Colour palette name — spectrum, ocean, ember, …
|
||||||
|
palette: str = DEFAULT_PALETTE
|
||||||
autoplay: bool = False
|
autoplay: bool = False
|
||||||
#: Which catalogue to use by default (see :mod:`beefy_player.providers`).
|
#: Which catalogue to use by default (see :mod:`beefy_player.providers`).
|
||||||
provider: str = "somafm"
|
provider: str = "somafm"
|
||||||
@@ -181,6 +187,10 @@ class Config:
|
|||||||
if visualizer in VISUALIZERS:
|
if visualizer in VISUALIZERS:
|
||||||
config.visualizer = visualizer
|
config.visualizer = visualizer
|
||||||
|
|
||||||
|
palette = raw.get("palette")
|
||||||
|
if isinstance(palette, str) and palette in PALETTE_NAMES:
|
||||||
|
config.palette = palette
|
||||||
|
|
||||||
provider = raw.get("provider")
|
provider = raw.get("provider")
|
||||||
if isinstance(provider, str) and provider.strip():
|
if isinstance(provider, str) and provider.strip():
|
||||||
config.provider = provider.strip().lower()
|
config.provider = provider.strip().lower()
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
"""The colour palettes everything in the UI draws from.
|
||||||
|
|
||||||
|
Every colour in Beefy Player is a stock ANSI palette *name*, never RGB, so the
|
||||||
|
TUI adopts whatever colours the terminal is configured with. A palette here is
|
||||||
|
therefore a short ladder of those names — the "gradient" is a quantised walk
|
||||||
|
from the quiet end to the hot end, not an interpolation.
|
||||||
|
|
||||||
|
The active palette drives the spectrum analyser, the album-art mosaic, the
|
||||||
|
provider wordmarks and the pane corners; `c` cycles through them and the
|
||||||
|
choice is remembered in the config file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
#: Dark → hot ladders of ANSI names. Sixteen of them: seven hue gradients, a
|
||||||
|
#: few near-monochromes, and the original spectrum.
|
||||||
|
PALETTES: dict[str, tuple[str, ...]] = {
|
||||||
|
"spectrum": (
|
||||||
|
"cyan",
|
||||||
|
"blue",
|
||||||
|
"magenta",
|
||||||
|
"bright_magenta",
|
||||||
|
"yellow",
|
||||||
|
"bright_yellow",
|
||||||
|
"red",
|
||||||
|
),
|
||||||
|
"rainbow": (
|
||||||
|
"red",
|
||||||
|
"bright_yellow",
|
||||||
|
"bright_green",
|
||||||
|
"bright_cyan",
|
||||||
|
"bright_blue",
|
||||||
|
"bright_magenta",
|
||||||
|
),
|
||||||
|
"ocean": (
|
||||||
|
"bright_black",
|
||||||
|
"blue",
|
||||||
|
"bright_blue",
|
||||||
|
"cyan",
|
||||||
|
"bright_cyan",
|
||||||
|
"bright_white",
|
||||||
|
),
|
||||||
|
"ember": (
|
||||||
|
"bright_black",
|
||||||
|
"red",
|
||||||
|
"bright_red",
|
||||||
|
"yellow",
|
||||||
|
"bright_yellow",
|
||||||
|
"bright_white",
|
||||||
|
),
|
||||||
|
"mint": (
|
||||||
|
"bright_black",
|
||||||
|
"green",
|
||||||
|
"bright_green",
|
||||||
|
"cyan",
|
||||||
|
"bright_cyan",
|
||||||
|
"bright_white",
|
||||||
|
),
|
||||||
|
"sunset": ("magenta", "bright_magenta", "red", "bright_red", "yellow"),
|
||||||
|
"violet": ("blue", "bright_blue", "magenta", "bright_magenta", "bright_white"),
|
||||||
|
"acid": ("bright_black", "green", "bright_green", "yellow", "bright_yellow"),
|
||||||
|
"neon": (
|
||||||
|
"bright_magenta",
|
||||||
|
"bright_red",
|
||||||
|
"bright_yellow",
|
||||||
|
"bright_green",
|
||||||
|
"bright_cyan",
|
||||||
|
),
|
||||||
|
"sand": ("bright_black", "yellow", "bright_yellow", "white", "bright_white"),
|
||||||
|
"blood": ("bright_black", "red", "bright_red", "bright_magenta"),
|
||||||
|
"forest": ("bright_black", "green", "bright_green", "yellow", "bright_white"),
|
||||||
|
"ice": ("bright_white", "bright_cyan", "cyan", "bright_blue", "blue"),
|
||||||
|
"steel": ("bright_black", "white", "bright_white", "bright_cyan"),
|
||||||
|
"candy": ("magenta", "bright_magenta", "bright_cyan", "bright_yellow"),
|
||||||
|
"mono": ("bright_black", "white", "bright_white"),
|
||||||
|
}
|
||||||
|
|
||||||
|
#: The stock names from dark to bright. These are xterm's default values in
|
||||||
|
#: Rec. 601 luma order — the closest thing to a stock answer. A theme may
|
||||||
|
#: disagree about the exact numbers, but the ordering is what the frame fades
|
||||||
|
#: need: a ladder is a *hue* walk, so where it ends says nothing about which
|
||||||
|
#: end is brighter.
|
||||||
|
BRIGHTNESS: tuple[str, ...] = (
|
||||||
|
"black",
|
||||||
|
"blue",
|
||||||
|
"red",
|
||||||
|
"bright_red",
|
||||||
|
"magenta",
|
||||||
|
"bright_magenta",
|
||||||
|
"bright_blue",
|
||||||
|
"green",
|
||||||
|
"bright_black",
|
||||||
|
"cyan",
|
||||||
|
"bright_green",
|
||||||
|
"bright_cyan",
|
||||||
|
"yellow",
|
||||||
|
"white",
|
||||||
|
"bright_yellow",
|
||||||
|
"bright_white",
|
||||||
|
)
|
||||||
|
|
||||||
|
_RANK = {name: rank for rank, name in enumerate(BRIGHTNESS)}
|
||||||
|
|
||||||
|
#: Cycle order for the palette key.
|
||||||
|
PALETTE_ORDER: tuple[str, ...] = tuple(PALETTES)
|
||||||
|
|
||||||
|
#: What you get until you press `c`.
|
||||||
|
DEFAULT_PALETTE = "spectrum"
|
||||||
|
|
||||||
|
|
||||||
|
def palette_stops(name: str | None) -> tuple[str, ...]:
|
||||||
|
"""The ladder for ``name``, falling back to the default palette."""
|
||||||
|
key = (name or "").strip().lower()
|
||||||
|
return PALETTES.get(key, PALETTES[DEFAULT_PALETTE])
|
||||||
|
|
||||||
|
|
||||||
|
def brightness(colour: str) -> int:
|
||||||
|
"""How bright a stock name is: 0 (black) to 15 (bright white)."""
|
||||||
|
return _RANK.get(colour.strip().lower(), 0)
|
||||||
@@ -18,6 +18,7 @@ import asyncio
|
|||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ from ..models import Broadcast, Song, Station
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"PLAYLIST_TTL",
|
"PLAYLIST_TTL",
|
||||||
"RadioProvider",
|
"RadioProvider",
|
||||||
|
"StationPage",
|
||||||
"fetch_json",
|
"fetch_json",
|
||||||
"is_direct_stream",
|
"is_direct_stream",
|
||||||
"is_playlist",
|
"is_playlist",
|
||||||
@@ -62,6 +64,15 @@ def reset_playlist_cache() -> None:
|
|||||||
_PLAYLIST_CACHE = None
|
_PLAYLIST_CACHE = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class StationPage:
|
||||||
|
"""One page of a large catalogue, in the catalogue's own order."""
|
||||||
|
|
||||||
|
stations: list[Station]
|
||||||
|
#: Offset to request next, or ``None`` when the catalogue is exhausted.
|
||||||
|
next_offset: int | None = None
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# the interface
|
# the interface
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -114,6 +125,20 @@ class RadioProvider(ABC):
|
|||||||
#: stations that would rather not be scraped).
|
#: stations that would rather not be scraped).
|
||||||
bandcamp: bool = True
|
bandcamp: bool = True
|
||||||
|
|
||||||
|
#: The catalogue can search its whole directory on the server. When true,
|
||||||
|
#: the app calls :meth:`search_stations` as the filter changes instead of
|
||||||
|
#: only filtering the (possibly small) list from :meth:`fetch_stations`.
|
||||||
|
server_search: bool = False
|
||||||
|
|
||||||
|
#: The catalogue is larger than one page. When true, the app fetches the
|
||||||
|
#: first page with :meth:`fetch_station_page` and asks for the next one as
|
||||||
|
#: the station list nears its end.
|
||||||
|
paginated: bool = False
|
||||||
|
|
||||||
|
#: Keep the catalogue's own ordering (popularity, schedule, ...) instead of
|
||||||
|
#: sorting the station list alphabetically.
|
||||||
|
preserve_order: bool = False
|
||||||
|
|
||||||
# -- catalogue ------------------------------------------------------- #
|
# -- catalogue ------------------------------------------------------- #
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
@@ -128,6 +153,37 @@ class RadioProvider(ABC):
|
|||||||
def fallback_stations(self) -> list[Station]:
|
def fallback_stations(self) -> list[Station]:
|
||||||
"""A bundled list, so a fresh offline start still works."""
|
"""A bundled list, so a fresh offline start still works."""
|
||||||
|
|
||||||
|
async def search_stations(
|
||||||
|
self, client: httpx.AsyncClient, query: str
|
||||||
|
) -> list[Station]:
|
||||||
|
"""Search the catalogue for *query*.
|
||||||
|
|
||||||
|
Only called when :attr:`server_search` is true. Providers that search
|
||||||
|
locally leave this alone; the app filters the loaded list itself.
|
||||||
|
"""
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def fetch_station_page(
|
||||||
|
self, client: httpx.AsyncClient, offset: int = 0
|
||||||
|
) -> StationPage:
|
||||||
|
"""One page of a paginated catalogue.
|
||||||
|
|
||||||
|
Only called when :attr:`paginated` is true. ``offset`` is the value
|
||||||
|
from the previous page's :attr:`StationPage.next_offset`.
|
||||||
|
"""
|
||||||
|
return StationPage(stations=[], next_offset=None)
|
||||||
|
|
||||||
|
async def fetch_stations_by_ids(
|
||||||
|
self, client: httpx.AsyncClient, station_ids: list[str]
|
||||||
|
) -> list[Station]:
|
||||||
|
"""Fetch specific stations by id.
|
||||||
|
|
||||||
|
Used for favourites that are not on the first page of a paginated
|
||||||
|
catalogue. Providers that return their whole catalogue at once can
|
||||||
|
leave this alone.
|
||||||
|
"""
|
||||||
|
return []
|
||||||
|
|
||||||
# -- metadata -------------------------------------------------------- #
|
# -- metadata -------------------------------------------------------- #
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"""Radio Browser: a directory of the community's stations rather than a station.
|
"""Radio Browser: a directory of the community's stations rather than a station.
|
||||||
|
|
||||||
Three hundred of the most-clicked working relays, keyless and public-domain.
|
The most-clicked working relays are loaded a page at a time, and the rest of
|
||||||
There is no now-playing API, so this catalogue offers breadth, not history: pick
|
the 60,000-station directory is searched on the server as you type. There is no
|
||||||
a station and the stream speaks for itself.
|
now-playing API, so this catalogue offers breadth, not history: pick a station
|
||||||
|
and the stream speaks for itself.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -11,7 +12,7 @@ import httpx
|
|||||||
|
|
||||||
from ... import config
|
from ... import config
|
||||||
from ...models import Song, Station
|
from ...models import Song, Station
|
||||||
from ..base import RadioProvider
|
from ..base import RadioProvider, StationPage
|
||||||
from . import api, fallback
|
from . import api, fallback
|
||||||
from . import hero as hero_art
|
from . import hero as hero_art
|
||||||
|
|
||||||
@@ -23,8 +24,8 @@ class RadioBrowserProvider(RadioProvider):
|
|||||||
name = "Radio Browser"
|
name = "Radio Browser"
|
||||||
homepage = "radio-browser.info"
|
homepage = "radio-browser.info"
|
||||||
brand = "RADIO BROWSER"
|
brand = "RADIO BROWSER"
|
||||||
tagline = "community directory · 50,000 stations"
|
tagline = "community directory · live search"
|
||||||
station_source = "radio-browser.info (top 300)"
|
station_source = "radio-browser.info (paged + search)"
|
||||||
history_source = ""
|
history_source = ""
|
||||||
|
|
||||||
hero = hero_art.HERO
|
hero = hero_art.HERO
|
||||||
@@ -33,6 +34,14 @@ class RadioBrowserProvider(RadioProvider):
|
|||||||
# A directory has no show schedule and no Bandcamp worth scraping.
|
# A directory has no show schedule and no Bandcamp worth scraping.
|
||||||
bandcamp = False
|
bandcamp = False
|
||||||
|
|
||||||
|
# The full directory is much larger than the list fetched at startup; the
|
||||||
|
# app asks the API when the filter changes.
|
||||||
|
server_search = True
|
||||||
|
|
||||||
|
# The directory is loaded a page at a time, in click-count order.
|
||||||
|
paginated = True
|
||||||
|
preserve_order = True
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def user_agent(self) -> str:
|
def user_agent(self) -> str:
|
||||||
return f"{config.USER_AGENT}; +https://www.radio-browser.info"
|
return f"{config.USER_AGENT}; +https://www.radio-browser.info"
|
||||||
@@ -40,6 +49,21 @@ class RadioBrowserProvider(RadioProvider):
|
|||||||
async def fetch_stations(self, client: httpx.AsyncClient) -> list[Station]:
|
async def fetch_stations(self, client: httpx.AsyncClient) -> list[Station]:
|
||||||
return await api.fetch_stations(client)
|
return await api.fetch_stations(client)
|
||||||
|
|
||||||
|
async def fetch_station_page(
|
||||||
|
self, client: httpx.AsyncClient, offset: int = 0
|
||||||
|
) -> StationPage:
|
||||||
|
return await api.fetch_station_page(client, offset)
|
||||||
|
|
||||||
|
async def fetch_stations_by_ids(
|
||||||
|
self, client: httpx.AsyncClient, station_ids: list[str]
|
||||||
|
) -> list[Station]:
|
||||||
|
return await api.fetch_stations_by_ids(client, station_ids)
|
||||||
|
|
||||||
|
async def search_stations(
|
||||||
|
self, client: httpx.AsyncClient, query: str
|
||||||
|
) -> list[Station]:
|
||||||
|
return await api.search_stations(client, query)
|
||||||
|
|
||||||
def load_cached_stations(self) -> list[Station]:
|
def load_cached_stations(self) -> list[Station]:
|
||||||
return api.load_cached_stations()
|
return api.load_cached_stations()
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,15 @@
|
|||||||
|
|
||||||
``https://de1.api.radio-browser.info/json/stations/search`` is keyless, CORS-open
|
``https://de1.api.radio-browser.info/json/stations/search`` is keyless, CORS-open
|
||||||
and public-domain data. It knows nothing about what a station is playing —
|
and public-domain data. It knows nothing about what a station is playing —
|
||||||
there is no per-station history — so this catalogue is a *directory*: three
|
there is no per-station history — so this catalogue is a *directory*: the most
|
||||||
hundred of the most-clicked working stations, ready to tune into. Whatever the
|
clicked working stations, a page at a time. Whatever the relay puts in its
|
||||||
relay puts in its stream is what you hear; the app has no metadata to show.
|
stream is what you hear; the app has no metadata to show.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import re
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
@@ -16,18 +18,34 @@ import httpx
|
|||||||
|
|
||||||
from ... import config
|
from ... import config
|
||||||
from ...models import Station, to_int
|
from ...models import Station, to_int
|
||||||
from ..base import fetch_json
|
from ..base import StationPage, fetch_json
|
||||||
|
|
||||||
API_URL = "https://de1.api.radio-browser.info/json/stations/search"
|
API_URL = "https://de1.api.radio-browser.info/json/stations/search"
|
||||||
|
BY_UUID_URL = "https://de1.api.radio-browser.info/json/stations/byuuid"
|
||||||
|
|
||||||
#: Three hundred is a comfortable directory: enough to browse, quick to fetch.
|
#: Stations per directory page. The app loads the first page at startup and
|
||||||
STATION_LIMIT = 300
|
#: the next ones as the station list nears its end.
|
||||||
|
PAGE_SIZE = 100
|
||||||
|
|
||||||
|
#: Results per field for a directory search. The four fields are merged.
|
||||||
|
SEARCH_LIMIT = 100
|
||||||
|
|
||||||
|
#: The search endpoint has no full-text parameter, so a free-text query is
|
||||||
|
#: sent once per field and the answers are merged.
|
||||||
|
_SEARCH_FIELDS = ("name", "tag", "country", "language")
|
||||||
|
|
||||||
|
#: Radio Browser ids are UUIDs; anything else is another catalogue's favourite.
|
||||||
|
_UUID_PATTERN = re.compile(
|
||||||
|
r"^[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$"
|
||||||
|
)
|
||||||
|
|
||||||
|
#: How many UUIDs to ask for in one ``byuuid`` request.
|
||||||
|
LOOKUP_BATCH = 50
|
||||||
|
|
||||||
_PARAMS = {
|
_PARAMS = {
|
||||||
"hidebroken": "true",
|
"hidebroken": "true",
|
||||||
"order": "clickcount",
|
"order": "clickcount",
|
||||||
"reverse": "true",
|
"reverse": "true",
|
||||||
"limit": str(STATION_LIMIT),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_CODECS = {"mp3": "mp3", "aac": "aac", "aac+": "aacp", "ogg": "ogg", "opus": "opus"}
|
_CODECS = {"mp3": "mp3", "aac": "aac", "aac+": "aacp", "ogg": "ogg", "opus": "opus"}
|
||||||
@@ -81,7 +99,7 @@ def station_from_entry(entry: dict[str, Any]) -> Station | None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def parse_stations(payload: Any) -> list[Station]:
|
def parse_stations(payload: Any, *, sort: bool = True) -> list[Station]:
|
||||||
if not isinstance(payload, list):
|
if not isinstance(payload, list):
|
||||||
return []
|
return []
|
||||||
stations: list[Station] = []
|
stations: list[Station] = []
|
||||||
@@ -92,22 +110,129 @@ def parse_stations(payload: Any) -> list[Station]:
|
|||||||
continue
|
continue
|
||||||
seen.add(station.id)
|
seen.add(station.id)
|
||||||
stations.append(station)
|
stations.append(station)
|
||||||
stations.sort(key=lambda station: station.title.casefold())
|
if sort:
|
||||||
|
stations.sort(key=lambda station: station.title.casefold())
|
||||||
return stations
|
return stations
|
||||||
|
|
||||||
|
|
||||||
|
def _page_url(offset: int) -> str:
|
||||||
|
params = {**_PARAMS, "limit": str(PAGE_SIZE), "offset": str(offset)}
|
||||||
|
return f"{API_URL}?{urlencode(params)}"
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_station_page(
|
||||||
|
client: httpx.AsyncClient, offset: int = 0
|
||||||
|
) -> StationPage:
|
||||||
|
"""One page of the directory, most-clicked first."""
|
||||||
|
try:
|
||||||
|
payload = await fetch_json(client, _page_url(offset))
|
||||||
|
except RuntimeError as exc:
|
||||||
|
raise RuntimeError(f"could not fetch the Radio Browser directory: {exc}") from None
|
||||||
|
|
||||||
|
stations = parse_stations(payload, sort=False)
|
||||||
|
rows = len(payload) if isinstance(payload, list) else 0
|
||||||
|
next_offset = offset + PAGE_SIZE if rows >= PAGE_SIZE else None
|
||||||
|
if offset == 0 and stations and isinstance(payload, list):
|
||||||
|
config.write_json(config.channels_cache_path("radiobrowser"), payload)
|
||||||
|
return StationPage(stations=stations, next_offset=next_offset)
|
||||||
|
|
||||||
|
|
||||||
async def fetch_stations(client: httpx.AsyncClient) -> list[Station]:
|
async def fetch_stations(client: httpx.AsyncClient) -> list[Station]:
|
||||||
url = f"{API_URL}?{urlencode(_PARAMS)}"
|
page = await fetch_station_page(client, 0)
|
||||||
try:
|
if not page.stations:
|
||||||
payload = await fetch_json(client, url)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
raise RuntimeError(f"could not fetch the Radio Browser directory: {exc}") from None
|
|
||||||
stations = parse_stations(payload)
|
|
||||||
if not stations:
|
|
||||||
raise RuntimeError("Radio Browser returned no stations")
|
raise RuntimeError("Radio Browser returned no stations")
|
||||||
config.write_json(config.channels_cache_path("radiobrowser"), payload)
|
return page.stations
|
||||||
return stations
|
|
||||||
|
|
||||||
|
|
||||||
def load_cached_stations() -> list[Station]:
|
def load_cached_stations() -> list[Station]:
|
||||||
return parse_stations(config.read_json(config.channels_cache_path("radiobrowser")))
|
return parse_stations(
|
||||||
|
config.read_json(config.channels_cache_path("radiobrowser")), sort=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _search_field(
|
||||||
|
client: httpx.AsyncClient, query: str, field: str
|
||||||
|
) -> list[Station]:
|
||||||
|
params = {**_PARAMS, "limit": str(SEARCH_LIMIT), field: query}
|
||||||
|
payload = await fetch_json(client, f"{API_URL}?{urlencode(params)}")
|
||||||
|
return parse_stations(payload, sort=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def search_stations(client: httpx.AsyncClient, query: str) -> list[Station]:
|
||||||
|
"""Search the directory by name, tag, country and language.
|
||||||
|
|
||||||
|
The API has no single full-text search, so the query is sent once per field
|
||||||
|
and the results are merged. A station that matches a genre rarely has that
|
||||||
|
genre in its name, and vice versa. Fields that fail are ignored as long as
|
||||||
|
one of them still answers; only a complete failure is raised.
|
||||||
|
"""
|
||||||
|
text = query.strip()
|
||||||
|
if not text:
|
||||||
|
return []
|
||||||
|
|
||||||
|
batches = await asyncio.gather(
|
||||||
|
*(_search_field(client, text, field) for field in _SEARCH_FIELDS),
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
stations: list[Station] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
failures = 0
|
||||||
|
for batch in batches:
|
||||||
|
if isinstance(batch, BaseException):
|
||||||
|
failures += 1
|
||||||
|
continue
|
||||||
|
for station in batch:
|
||||||
|
if station.id in seen:
|
||||||
|
continue
|
||||||
|
seen.add(station.id)
|
||||||
|
stations.append(station)
|
||||||
|
|
||||||
|
if failures == len(batches):
|
||||||
|
raise RuntimeError("could not search the Radio Browser directory")
|
||||||
|
|
||||||
|
return stations
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_by_uuids(
|
||||||
|
client: httpx.AsyncClient, station_ids: list[str]
|
||||||
|
) -> list[Station]:
|
||||||
|
params = urlencode({"uuids": ",".join(station_ids)}, safe=",")
|
||||||
|
payload = await fetch_json(client, f"{BY_UUID_URL}?{params}")
|
||||||
|
return parse_stations(payload, sort=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_stations_by_ids(
|
||||||
|
client: httpx.AsyncClient, station_ids: list[str]
|
||||||
|
) -> list[Station]:
|
||||||
|
"""Look up specific stations, for favourites outside the first page."""
|
||||||
|
ids = list(
|
||||||
|
dict.fromkeys(
|
||||||
|
station_id.strip()
|
||||||
|
for station_id in station_ids
|
||||||
|
if _UUID_PATTERN.match(station_id.strip())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not ids:
|
||||||
|
return []
|
||||||
|
|
||||||
|
batches = [
|
||||||
|
ids[index : index + LOOKUP_BATCH]
|
||||||
|
for index in range(0, len(ids), LOOKUP_BATCH)
|
||||||
|
]
|
||||||
|
results = await asyncio.gather(
|
||||||
|
*(_fetch_by_uuids(client, batch) for batch in batches),
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
stations: list[Station] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for batch in results:
|
||||||
|
if isinstance(batch, BaseException):
|
||||||
|
continue
|
||||||
|
for station in batch:
|
||||||
|
if station.id in seen:
|
||||||
|
continue
|
||||||
|
seen.add(station.id)
|
||||||
|
stations.append(station)
|
||||||
|
return stations
|
||||||
@@ -178,19 +178,37 @@ ModalScreen {
|
|||||||
#providers {
|
#providers {
|
||||||
color: ansi_default;
|
color: ansi_default;
|
||||||
width: 66;
|
width: 66;
|
||||||
|
max-width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
|
max-height: 100%;
|
||||||
border: round ansi_bright_black;
|
border: round ansi_bright_black;
|
||||||
border-title-color: ansi_default;
|
border-title-color: ansi_default;
|
||||||
padding: 1 2;
|
padding: 1 2;
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-size-vertical: 1;
|
||||||
|
scrollbar-color: ansi_bright_black;
|
||||||
|
scrollbar-background: ansi_default;
|
||||||
|
background: ansi_default;
|
||||||
|
}
|
||||||
|
|
||||||
|
#providers-body {
|
||||||
|
color: ansi_default;
|
||||||
|
height: auto;
|
||||||
background: ansi_default;
|
background: ansi_default;
|
||||||
}
|
}
|
||||||
|
|
||||||
#help {
|
#help {
|
||||||
color: ansi_default;
|
color: ansi_default;
|
||||||
width: 78;
|
width: 78;
|
||||||
|
max-width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
|
max-height: 100%;
|
||||||
border: round ansi_bright_black;
|
border: round ansi_bright_black;
|
||||||
border-title-color: ansi_default;
|
border-title-color: ansi_default;
|
||||||
padding: 1 2;
|
padding: 1 2;
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-size-vertical: 1;
|
||||||
|
scrollbar-color: ansi_bright_black;
|
||||||
|
scrollbar-background: ansi_default;
|
||||||
background: ansi_default;
|
background: ansi_default;
|
||||||
}
|
}
|
||||||
@@ -22,7 +22,7 @@ from textual.strip import Strip
|
|||||||
from textual.widget import Widget
|
from textual.widget import Widget
|
||||||
|
|
||||||
from .. import artwork
|
from .. import artwork
|
||||||
from .common import art_lines
|
from .common import active_stops, art_lines
|
||||||
|
|
||||||
_NULL_STYLE = Style()
|
_NULL_STYLE = Style()
|
||||||
_NULL_CONTROL = ((ControlType.CURSOR_FORWARD, 0),)
|
_NULL_CONTROL = ((ControlType.CURSOR_FORWARD, 0),)
|
||||||
@@ -308,6 +308,8 @@ class ArtPane(Widget):
|
|||||||
self._last_data: bytes | None = None
|
self._last_data: bytes | None = None
|
||||||
self._last_size: tuple[int, int] = (0, 0)
|
self._last_size: tuple[int, int] = (0, 0)
|
||||||
self._mosaic_key = ""
|
self._mosaic_key = ""
|
||||||
|
#: True while a modal is up: the inline image would sit over the menu.
|
||||||
|
self._suspended = False
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# mosaic background
|
# mosaic background
|
||||||
@@ -320,7 +322,12 @@ class ArtPane(Widget):
|
|||||||
return Text("")
|
return Text("")
|
||||||
width = max(1, self.size.width)
|
width = max(1, self.size.width)
|
||||||
height = max(1, self.size.height)
|
height = max(1, self.size.height)
|
||||||
return Group(*art_lines(_art_key(app), width, height))
|
if artwork.supported() and app.artwork_bytes is not None:
|
||||||
|
# A real image occupies this box (letterboxed, so not all of it).
|
||||||
|
# Leave the space blank: the patterned mosaic behind a scaled
|
||||||
|
# picture reads as noise around its edges.
|
||||||
|
return Group(*[Text(" " * width) for _ in range(height)])
|
||||||
|
return Group(*art_lines(_art_key(app), width, height, active_stops(app)))
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# image overlay
|
# image overlay
|
||||||
@@ -337,9 +344,27 @@ class ArtPane(Widget):
|
|||||||
def on_unmount(self) -> None:
|
def on_unmount(self) -> None:
|
||||||
self._drop_image_widget()
|
self._drop_image_widget()
|
||||||
|
|
||||||
|
def suspend(self) -> None:
|
||||||
|
"""Take the inline image down so a modal is not drawn underneath it.
|
||||||
|
|
||||||
|
Kitty and iTerm2 paint images into cells the terminal owns; Textual
|
||||||
|
repainting the menu over them does not remove them, so the artwork has
|
||||||
|
to be deleted and re-emitted rather than covered.
|
||||||
|
"""
|
||||||
|
self._suspended = True
|
||||||
|
self._drop_image_widget()
|
||||||
|
self.refresh(layout=True)
|
||||||
|
|
||||||
|
def resume(self) -> None:
|
||||||
|
"""Put the artwork back after the modal closes."""
|
||||||
|
self._suspended = False
|
||||||
|
self.sync_image(force=True)
|
||||||
|
|
||||||
def sync_image(self, *, force: bool = False) -> None:
|
def sync_image(self, *, force: bool = False) -> None:
|
||||||
# Keep the mosaic behind the image in sync with the current track.
|
# Keep the mosaic behind the image in sync with the current track.
|
||||||
self.refresh()
|
self.refresh()
|
||||||
|
if self._suspended:
|
||||||
|
return
|
||||||
|
|
||||||
app = cast("BeefyPlayerApp", self.app)
|
app = cast("BeefyPlayerApp", self.app)
|
||||||
station = app.current
|
station = app.current
|
||||||
|
|||||||
@@ -19,23 +19,20 @@ from zoneinfo import ZoneInfo
|
|||||||
from rich.cells import cell_len
|
from rich.cells import cell_len
|
||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
|
|
||||||
|
from ..palettes import (
|
||||||
|
DEFAULT_PALETTE,
|
||||||
|
palette_stops,
|
||||||
|
)
|
||||||
|
|
||||||
LOCALTIME_PATH = "/etc/localtime"
|
LOCALTIME_PATH = "/etc/localtime"
|
||||||
_UTC_NAMES = {"UTC", "UCT", "GMT", "ETC/UTC", "ETC/UCT", "ETC/GMT", "Z"}
|
_UTC_NAMES = {"UTC", "UCT", "GMT", "ETC/UTC", "ETC/UCT", "ETC/GMT", "Z"}
|
||||||
|
|
||||||
LEVELS = " ▁▂▃▄▅▆▇█"
|
LEVELS = " ▁▂▃▄▅▆▇█"
|
||||||
|
|
||||||
# Bottom-to-top spectrum colours, picked from the terminal's stock ANSI palette.
|
def active_stops(app: object) -> tuple[str, ...]:
|
||||||
SPECTRUM_STOPS = (
|
"""The colour ladder the app is currently drawing with."""
|
||||||
"cyan",
|
return palette_stops(getattr(app, "palette", DEFAULT_PALETTE))
|
||||||
"blue",
|
|
||||||
"magenta",
|
|
||||||
"bright_magenta",
|
|
||||||
"yellow",
|
|
||||||
"bright_yellow",
|
|
||||||
"red",
|
|
||||||
)
|
|
||||||
|
|
||||||
ART_STOPS = ("blue", "magenta", "bright_magenta", "yellow", "cyan", "green")
|
|
||||||
|
|
||||||
STATE_STYLE = {
|
STATE_STYLE = {
|
||||||
"playing": ("●", "green"),
|
"playing": ("●", "green"),
|
||||||
@@ -182,7 +179,9 @@ def state_glyph(state: str) -> tuple[str, str]:
|
|||||||
return STATE_STYLE.get(state, STATE_STYLE["stopped"])
|
return STATE_STYLE.get(state, STATE_STYLE["stopped"])
|
||||||
|
|
||||||
|
|
||||||
def art_lines(key: str, width: int, height: int) -> list[Text]:
|
def art_lines(
|
||||||
|
key: str, width: int, height: int, stops: tuple[str, ...]
|
||||||
|
) -> list[Text]:
|
||||||
"""Procedural album art as a solid ANSI-colour mosaic.
|
"""Procedural album art as a solid ANSI-colour mosaic.
|
||||||
|
|
||||||
Filled block glyphs are used (never cell backgrounds), so the cover is
|
Filled block glyphs are used (never cell backgrounds), so the cover is
|
||||||
@@ -200,7 +199,7 @@ def art_lines(key: str, width: int, height: int) -> list[Text]:
|
|||||||
nx = column / max(1, width - 1)
|
nx = column / max(1, width - 1)
|
||||||
wave = 0.5 + 0.5 * math.sin(nx * 2.9 + ny * 2.1 + (seed % 11))
|
wave = 0.5 + 0.5 * math.sin(nx * 2.9 + ny * 2.1 + (seed % 11))
|
||||||
position = (nx * 0.55 + (1.0 - ny) * 0.30 + wave * 0.15) % 1.0
|
position = (nx * 0.55 + (1.0 - ny) * 0.30 + wave * 0.15) % 1.0
|
||||||
colour = gradient(ART_STOPS, position)
|
colour = gradient(stops, position)
|
||||||
# Mostly solid blocks with a little texture, like a tiny cover.
|
# Mostly solid blocks with a little texture, like a tiny cover.
|
||||||
glyph = "█" if ((seed + column * 7 + row * 11) % 17) not in (0, 5) else "▓"
|
glyph = "█" if ((seed + column * 7 + row * 11) % 17) not in (0, 5) else "▓"
|
||||||
line.append(glyph, style=colour)
|
line.append(glyph, style=colour)
|
||||||
|
|||||||
@@ -1,38 +1,60 @@
|
|||||||
"""Pane frames: one grey box, with two corners fading out of it.
|
"""Pane frames: one grey box whose corners glow in the active palette.
|
||||||
|
|
||||||
The panes draw their own frames instead of using Textual's ``border`` so the
|
The panes draw their own frames instead of using Textual's ``border`` so the
|
||||||
corners can be coloured individually. Two of them — the top right and the
|
corners can be coloured. Two of them — the top right and the bottom left —
|
||||||
bottom left — run white → light grey → bright cyan → cyan along both arms;
|
run a short fade out of the palette's hot end and back into the border grey,
|
||||||
everything else, including the other two corners, is the border grey.
|
brightest colour at the corner; everything else, including the other two
|
||||||
|
corners, is that grey.
|
||||||
|
|
||||||
|
The ladder is whatever palette the app is on (``c`` cycles them), which is why
|
||||||
|
every function here takes ``stops`` rather than reading a module constant.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from functools import cache
|
||||||
|
|
||||||
from rich.cells import cell_len
|
from rich.cells import cell_len
|
||||||
from rich.console import Group, RenderableType
|
from rich.console import Group, RenderableType
|
||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
from textual.widget import Widget
|
from textual.widget import Widget
|
||||||
|
|
||||||
|
from ..palettes import DEFAULT_PALETTE, PALETTES, brightness
|
||||||
|
from .common import active_stops
|
||||||
|
|
||||||
#: The frame itself: one grey, the same grey as the scrollbar thumb.
|
#: The frame itself: one grey, the same grey as the scrollbar thumb.
|
||||||
EDGE = "bright_black"
|
EDGE = "bright_black"
|
||||||
|
|
||||||
#: A faded corner runs white → light grey → bright cyan → cyan, then settles
|
#: How many cells a corner fade reaches into the edge.
|
||||||
#: back into the border grey.
|
FADE_CELLS = 4
|
||||||
CORNER_GRADIENT = ("bright_white", "white", "bright_cyan", "cyan")
|
|
||||||
|
|
||||||
|
|
||||||
def fade(distance: int) -> str:
|
@cache
|
||||||
"""Colour ``distance`` cells away from a corner (0 is the corner itself)."""
|
def fade_ramp(stops: tuple[str, ...]) -> tuple[str, ...]:
|
||||||
if 0 <= distance < len(CORNER_GRADIENT):
|
"""The corner ramp: brightest cell first, border grey past its end.
|
||||||
return CORNER_GRADIENT[distance]
|
|
||||||
return EDGE
|
The colours come from the palette's hot end, but a ladder is a hue walk,
|
||||||
|
not a brightness walk: ``spectrum`` ends on ``red``, which is dimmer than
|
||||||
|
the ``yellow`` just before it, so painting the ladder backwards put the
|
||||||
|
dim colour at the corner and the glow out on the edge. Sorting the hot
|
||||||
|
end by brightness keeps the corner the brightest cell and dims outwards,
|
||||||
|
whatever theme sits behind the names.
|
||||||
|
"""
|
||||||
|
hot = [name for name in stops if name != EDGE][-FADE_CELLS:]
|
||||||
|
return tuple(sorted(hot, key=brightness, reverse=True))
|
||||||
|
|
||||||
|
|
||||||
def side_style(row: int, rows: int, side: str = "left") -> str:
|
def corner_fade(distance: int, stops: tuple[str, ...] = ()) -> str:
|
||||||
"""Colour of a side cell: only two corners fade, so only one end glows."""
|
"""Colour ``distance`` cells out from a corner; 0 is the corner glyph.
|
||||||
if side == "right":
|
|
||||||
return fade(row + 1) # fades down from the top-right corner
|
The fade is short — four cells — and runs from the brightest colour the
|
||||||
return fade(rows - row) # fades up from the bottom-left corner
|
palette's hot end offers back into the plain border grey.
|
||||||
|
"""
|
||||||
|
if not stops:
|
||||||
|
stops = PALETTES[DEFAULT_PALETTE]
|
||||||
|
ramp = fade_ramp(tuple(stops))
|
||||||
|
distance = max(0, distance)
|
||||||
|
return ramp[distance] if distance < len(ramp) else EDGE
|
||||||
|
|
||||||
|
|
||||||
def inner_size(width: int, height: int) -> tuple[int, int]:
|
def inner_size(width: int, height: int) -> tuple[int, int]:
|
||||||
@@ -40,12 +62,12 @@ def inner_size(width: int, height: int) -> tuple[int, int]:
|
|||||||
return max(1, width - 2), max(1, height - 2)
|
return max(1, width - 2), max(1, height - 2)
|
||||||
|
|
||||||
|
|
||||||
def _edge_cells(width: int, faded_end: str = "right") -> list[tuple[str, str]]:
|
def _edge_cells(width: int, faded_end: str, stops: tuple[str, ...]) -> list[tuple[str, str]]:
|
||||||
"""Inner cells of an edge; only one end of it fades into a corner."""
|
"""Inner cells of an edge; only one end of it fades into a corner."""
|
||||||
inner = max(0, width - 2)
|
inner = max(0, width - 2)
|
||||||
if faded_end == "left":
|
if faded_end == "left":
|
||||||
return [("─", fade(column + 1)) for column in range(inner)]
|
return [("─", corner_fade(column + 1, stops)) for column in range(inner)]
|
||||||
return [("─", fade(inner - column)) for column in range(inner)]
|
return [("─", corner_fade(inner - column, stops)) for column in range(inner)]
|
||||||
|
|
||||||
|
|
||||||
def _paint(line: Text, cells: list[tuple[str, str]]) -> None:
|
def _paint(line: Text, cells: list[tuple[str, str]]) -> None:
|
||||||
@@ -62,9 +84,9 @@ def _paint(line: Text, cells: list[tuple[str, str]]) -> None:
|
|||||||
line.append("".join(run), style=style)
|
line.append("".join(run), style=style)
|
||||||
|
|
||||||
|
|
||||||
def top(width: int, title: str = "") -> Text:
|
def top(width: int, title: str = "", stops: tuple[str, ...] = ()) -> Text:
|
||||||
width = max(2, width)
|
width = max(2, width)
|
||||||
cells = _edge_cells(width, "right") # only the top-right corner fades
|
cells = _edge_cells(width, "right", stops) # only the top-right corner fades
|
||||||
if title:
|
if title:
|
||||||
label = f" {title} "
|
label = f" {title} "
|
||||||
start = 1
|
start = 1
|
||||||
@@ -75,54 +97,67 @@ def top(width: int, title: str = "") -> Text:
|
|||||||
line = Text(no_wrap=True)
|
line = Text(no_wrap=True)
|
||||||
line.append("╭", style=EDGE)
|
line.append("╭", style=EDGE)
|
||||||
_paint(line, cells)
|
_paint(line, cells)
|
||||||
line.append("╮", style=f"bold {fade(0)}")
|
line.append("╮", style=f"bold {corner_fade(0, stops)}")
|
||||||
return line
|
return line
|
||||||
|
|
||||||
|
|
||||||
def bottom(width: int) -> Text:
|
def bottom(width: int, stops: tuple[str, ...] = ()) -> Text:
|
||||||
width = max(2, width)
|
width = max(2, width)
|
||||||
line = Text(no_wrap=True)
|
line = Text(no_wrap=True)
|
||||||
line.append("╰", style=f"bold {fade(0)}")
|
line.append("╰", style=f"bold {corner_fade(0, stops)}")
|
||||||
_paint(line, _edge_cells(width, "left")) # only the bottom-left fades
|
_paint(line, _edge_cells(width, "left", stops)) # only the bottom-left fades
|
||||||
line.append("╯", style=EDGE)
|
line.append("╯", style=EDGE)
|
||||||
return line
|
return line
|
||||||
|
|
||||||
|
|
||||||
def side() -> Text:
|
def side_style(row: int, rows: int, side: str = "left", stops: tuple[str, ...] = ()) -> str:
|
||||||
return Text("│", style=EDGE)
|
"""Colour of a side cell: only two corners fade, so only one end glows."""
|
||||||
|
if side == "right":
|
||||||
|
return corner_fade(row + 1, stops) # fades down from the top-right corner
|
||||||
|
return corner_fade(rows - row, stops) # fades up from the bottom-left corner
|
||||||
|
|
||||||
|
|
||||||
def side_row(row: int, rows: int, side: str = "left") -> Text:
|
def side_row(
|
||||||
return Text("│", style=side_style(row, rows, side))
|
row: int, rows: int, side: str = "left", stops: tuple[str, ...] = ()
|
||||||
|
) -> Text:
|
||||||
|
return Text("│", style=side_style(row, rows, side, stops))
|
||||||
|
|
||||||
|
|
||||||
def wrap(
|
def wrap(
|
||||||
lines: list[Text], width: int, height: int, title: str = ""
|
lines: list[Text],
|
||||||
|
width: int,
|
||||||
|
height: int,
|
||||||
|
title: str = "",
|
||||||
|
stops: tuple[str, ...] = (),
|
||||||
) -> RenderableType:
|
) -> RenderableType:
|
||||||
"""Frame ``lines`` (already sized for the content box) in a pane."""
|
"""Frame ``lines`` (already sized for the content box) in a pane."""
|
||||||
width = max(4, width)
|
width = max(4, width)
|
||||||
height = max(2, height)
|
height = max(2, height)
|
||||||
inner_width, inner_height = inner_size(width, height)
|
inner_width, inner_height = inner_size(width, height)
|
||||||
rows = len(lines[:inner_height])
|
|
||||||
|
|
||||||
framed: list[Text] = [top(width, title)]
|
framed: list[Text] = [top(width, title, stops)]
|
||||||
for index, line in enumerate(lines[:inner_height]):
|
for index, line in enumerate(lines[:inner_height]):
|
||||||
|
if cell_len(line.plain) > inner_width:
|
||||||
|
# A pane must never spill over its neighbour: trim, whatever the
|
||||||
|
# caller asked for.
|
||||||
|
line = line.copy()
|
||||||
|
line.truncate(inner_width, overflow="ellipsis")
|
||||||
row = Text(no_wrap=True)
|
row = Text(no_wrap=True)
|
||||||
row.append_text(side_row(index, rows, "left"))
|
row.append_text(side_row(index, inner_height, "left", stops))
|
||||||
row.append_text(line)
|
row.append_text(line)
|
||||||
pad = inner_width - cell_len(line.plain)
|
pad = inner_width - cell_len(line.plain)
|
||||||
if pad > 0:
|
if pad > 0:
|
||||||
row.append(" " * pad)
|
row.append(" " * pad)
|
||||||
row.append_text(side_row(index, rows, "right"))
|
row.append_text(side_row(index, inner_height, "right", stops))
|
||||||
framed.append(row)
|
framed.append(row)
|
||||||
while len(framed) < height - 1:
|
while len(framed) < height - 1:
|
||||||
index = len(framed) - 1
|
index = len(framed) - 1
|
||||||
row = Text(no_wrap=True)
|
row = Text(no_wrap=True)
|
||||||
row.append_text(side_row(index, inner_height, "left"))
|
row.append_text(side_row(index, inner_height, "left", stops))
|
||||||
row.append(" " * inner_width)
|
row.append(" " * inner_width)
|
||||||
row.append_text(side_row(index, inner_height, "right"))
|
row.append_text(side_row(index, inner_height, "right", stops))
|
||||||
framed.append(row)
|
framed.append(row)
|
||||||
framed.append(bottom(width))
|
framed.append(bottom(width, stops))
|
||||||
return Group(*framed[:height])
|
return Group(*framed[:height])
|
||||||
|
|
||||||
|
|
||||||
@@ -139,14 +174,14 @@ class FrameTop(Widget):
|
|||||||
self.title = title
|
self.title = title
|
||||||
|
|
||||||
def render(self) -> RenderableType:
|
def render(self) -> RenderableType:
|
||||||
return top(max(2, self.size.width), self.title)
|
return top(max(2, self.size.width), self.title, active_stops(self.app))
|
||||||
|
|
||||||
|
|
||||||
class FrameBottom(Widget):
|
class FrameBottom(Widget):
|
||||||
can_focus = False
|
can_focus = False
|
||||||
|
|
||||||
def render(self) -> RenderableType:
|
def render(self) -> RenderableType:
|
||||||
return bottom(max(2, self.size.width))
|
return bottom(max(2, self.size.width), active_stops(self.app))
|
||||||
|
|
||||||
|
|
||||||
class FrameSide(Widget):
|
class FrameSide(Widget):
|
||||||
@@ -158,6 +193,7 @@ class FrameSide(Widget):
|
|||||||
|
|
||||||
def render(self) -> RenderableType:
|
def render(self) -> RenderableType:
|
||||||
rows = max(1, self.size.height)
|
rows = max(1, self.size.height)
|
||||||
|
stops = active_stops(self.app)
|
||||||
return Group(
|
return Group(
|
||||||
*[side_row(row, rows, self.side) for row in range(rows)]
|
*[side_row(row, rows, self.side, stops) for row in range(rows)]
|
||||||
)
|
)
|
||||||
@@ -10,7 +10,7 @@ from rich.text import Text
|
|||||||
from textual.widget import Widget
|
from textual.widget import Widget
|
||||||
|
|
||||||
from .common import (
|
from .common import (
|
||||||
SPECTRUM_STOPS,
|
active_stops,
|
||||||
clock_time,
|
clock_time,
|
||||||
date_label,
|
date_label,
|
||||||
gradient,
|
gradient,
|
||||||
@@ -22,7 +22,13 @@ if TYPE_CHECKING:
|
|||||||
from ..app import BeefyPlayerApp
|
from ..app import BeefyPlayerApp
|
||||||
|
|
||||||
|
|
||||||
def _hero_line(markup: str, index: int, width: int, gradient_fill: bool) -> Text:
|
def _hero_line(
|
||||||
|
markup: str,
|
||||||
|
index: int,
|
||||||
|
width: int,
|
||||||
|
gradient_fill: bool,
|
||||||
|
stops: tuple[str, ...],
|
||||||
|
) -> Text:
|
||||||
"""One row of the hero: spectrum-painted glyphs, or the art's own colours."""
|
"""One row of the hero: spectrum-painted glyphs, or the art's own colours."""
|
||||||
if not gradient_fill:
|
if not gradient_fill:
|
||||||
line = Text.from_markup(markup)
|
line = Text.from_markup(markup)
|
||||||
@@ -32,7 +38,7 @@ def _hero_line(markup: str, index: int, width: int, gradient_fill: bool) -> Text
|
|||||||
line = Text(no_wrap=True)
|
line = Text(no_wrap=True)
|
||||||
for column, char in enumerate(markup):
|
for column, char in enumerate(markup):
|
||||||
position = (column / max(1, width - 1) + index * 0.055) % 1.0
|
position = (column / max(1, width - 1) + index * 0.055) % 1.0
|
||||||
line.append(char, style=f"bold {gradient(SPECTRUM_STOPS, position)}")
|
line.append(char, style=f"bold {gradient(stops, position)}")
|
||||||
return line
|
return line
|
||||||
|
|
||||||
|
|
||||||
@@ -45,6 +51,7 @@ class HeaderBar(Widget):
|
|||||||
hero = tuple(getattr(provider, "hero", ()) or ())
|
hero = tuple(getattr(provider, "hero", ()) or ())
|
||||||
hero_lines = tuple(getattr(provider, "hero_lines", ()) or ())
|
hero_lines = tuple(getattr(provider, "hero_lines", ()) or ())
|
||||||
gradient_fill = bool(getattr(provider, "hero_gradient", False))
|
gradient_fill = bool(getattr(provider, "hero_gradient", False))
|
||||||
|
stops = active_stops(app)
|
||||||
|
|
||||||
width = max(24, self.size.width)
|
width = max(24, self.size.width)
|
||||||
height = max(1, self.size.height)
|
height = max(1, self.size.height)
|
||||||
@@ -73,7 +80,7 @@ class HeaderBar(Widget):
|
|||||||
|
|
||||||
lines: list[Text] = []
|
lines: list[Text] = []
|
||||||
for index, row in enumerate(hero):
|
for index, row in enumerate(hero):
|
||||||
line = _hero_line(row, index, hero_width, gradient_fill)
|
line = _hero_line(row, index, hero_width, gradient_fill, stops)
|
||||||
# Art rows are right-trimmed, so a row ending in a blank cell is a
|
# Art rows are right-trimmed, so a row ending in a blank cell is a
|
||||||
# column short; pad it back or the caption and clock drift left.
|
# column short; pad it back or the caption and clock drift left.
|
||||||
short = hero_width - cell_len(line.plain)
|
short = hero_width - cell_len(line.plain)
|
||||||
|
|||||||
@@ -2,23 +2,28 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import sys
|
import contextlib
|
||||||
import textwrap
|
from typing import TYPE_CHECKING, ClassVar, cast
|
||||||
from typing import TYPE_CHECKING, cast
|
|
||||||
|
|
||||||
from rich.console import Group
|
from rich.console import Group
|
||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
from textual.app import ComposeResult
|
from textual.app import ComposeResult
|
||||||
|
from textual.containers import VerticalScroll
|
||||||
from textual.screen import ModalScreen
|
from textual.screen import ModalScreen
|
||||||
from textual.widgets import Static
|
from textual.widgets import Static
|
||||||
|
|
||||||
|
from .. import __version__
|
||||||
|
from ..palettes import PALETTE_ORDER, PALETTES
|
||||||
from ..providers import PROVIDERS, available_providers
|
from ..providers import PROVIDERS, available_providers
|
||||||
from .common import SPECTRUM_STOPS, gradient
|
from .common import active_stops, gradient
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ..app import BeefyPlayerApp
|
from ..app import BeefyPlayerApp
|
||||||
|
|
||||||
|
#: Where the project lives (shown at the foot of the help screen).
|
||||||
|
URL = "https://tildeforge.dev/beefcarver/beefy-player"
|
||||||
|
|
||||||
HELP_ROWS: tuple[tuple[str, str], ...] = (
|
HELP_ROWS: tuple[tuple[str, str], ...] = (
|
||||||
("↑ / ↓ j / k", "move through the station list"),
|
("↑ / ↓ j / k", "move through the station list"),
|
||||||
("pgup / pgdn", "page through the station list"),
|
("pgup / pgdn", "page through the station list"),
|
||||||
@@ -30,11 +35,12 @@ HELP_ROWS: tuple[tuple[str, str], ...] = (
|
|||||||
("m", "mute / unmute"),
|
("m", "mute / unmute"),
|
||||||
("f", "favourite — favourites float to the top"),
|
("f", "favourite — favourites float to the top"),
|
||||||
("/", "filter stations by title, genre or description"),
|
("/", "filter stations by title, genre or description"),
|
||||||
("esc", "clear the filter / close overlays"),
|
("esc", "close the search box; esc again clears the filter"),
|
||||||
(
|
(
|
||||||
"v",
|
"v",
|
||||||
"cycle visualiser: mirror → bars → wave → waterfall → radial → braille",
|
"cycle visualiser: mirror → bars → wave → waterfall → radial → braille",
|
||||||
),
|
),
|
||||||
|
("c", "cycle the colour palette (listed below)"),
|
||||||
("r", "refresh stations, schedule and song history"),
|
("r", "refresh stations, schedule and song history"),
|
||||||
("p", "switch station catalogue (SomaFM / tilderadio)"),
|
("p", "switch station catalogue (SomaFM / tilderadio)"),
|
||||||
("g / G", "jump to the top / bottom of the list"),
|
("g / G", "jump to the top / bottom of the list"),
|
||||||
@@ -42,42 +48,10 @@ HELP_ROWS: tuple[tuple[str, str], ...] = (
|
|||||||
("q", "quit"),
|
("q", "quit"),
|
||||||
)
|
)
|
||||||
|
|
||||||
ABOUT_LINUX = (
|
|
||||||
(
|
|
||||||
"The spectrum captures your actual PulseAudio/PipeWire output with "
|
|
||||||
"parec (SYNC); otherwise it falls back to a muted ffmpeg stream tap "
|
|
||||||
"(FFT, can drift) or a synthesized display (SIM)."
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"The desktop picks us up over MPRIS as a "
|
|
||||||
"beefyplayer media player — try `playerctl -l` / `playerctl metadata`."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
ABOUT_MACOS = (
|
|
||||||
(
|
|
||||||
"The spectrum captures your Mac's real audio output (SYNC) with a "
|
|
||||||
"native Core Audio process tap — nothing to install. macOS asks for "
|
|
||||||
"System Audio Recording permission the first time; without it the "
|
|
||||||
"analyser falls back to a muted ffmpeg stream tap (FFT, can drift) "
|
|
||||||
"or a synthesized display (SIM)."
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"mpv publishes what is playing to the macOS Now Playing session, so "
|
|
||||||
"Control Center and the keyboard media keys control this app."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class HelpScreen(ModalScreen[None]):
|
class HelpScreen(ModalScreen[None]):
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
app = cast("BeefyPlayerApp", self.app)
|
app = cast("BeefyPlayerApp", self.app)
|
||||||
provider = app.provider
|
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 = Table.grid(padding=(0, 2), expand=False)
|
||||||
catalogues.add_column(style="bold cyan", no_wrap=True, justify="right")
|
catalogues.add_column(style="bold cyan", no_wrap=True, justify="right")
|
||||||
catalogues.add_column(style="bold", no_wrap=True)
|
catalogues.add_column(style="bold", no_wrap=True)
|
||||||
@@ -90,14 +64,39 @@ class HelpScreen(ModalScreen[None]):
|
|||||||
marker, slug, f"{catalogue.name} — {pane}"
|
marker, slug, f"{catalogue.name} — {pane}"
|
||||||
)
|
)
|
||||||
|
|
||||||
pane_note = Text()
|
# -- palettes, two per row so sixteen still fit ------------------ #
|
||||||
if provider.has_schedule:
|
names = list(PALETTE_ORDER)
|
||||||
pane_note.append(
|
name_width = max(len(name) for name in names)
|
||||||
f"{provider.name} is a single stream, so the left pane is its "
|
|
||||||
"broadcast schedule; the station filter and favourites only "
|
def swatch(name: str) -> Text:
|
||||||
"apply to multi-station catalogues.",
|
stops = PALETTES[name]
|
||||||
style="dim italic",
|
block = Text(no_wrap=True)
|
||||||
)
|
for step in range(8):
|
||||||
|
block.append("█", style=gradient(stops, step / 7))
|
||||||
|
return block
|
||||||
|
|
||||||
|
def entry(name: str, first: bool) -> Text:
|
||||||
|
line = Text(no_wrap=True)
|
||||||
|
if not first:
|
||||||
|
line.append(" ")
|
||||||
|
line.append("● " if name == app.palette else " ", style="bold cyan")
|
||||||
|
line.append(f"{name:<{name_width}}", style="bold")
|
||||||
|
line.append(" ")
|
||||||
|
line.append_text(swatch(name))
|
||||||
|
return line
|
||||||
|
|
||||||
|
palette_rows: list[Text] = []
|
||||||
|
for index in range(0, len(names), 2):
|
||||||
|
row = entry(names[index], True)
|
||||||
|
if index + 1 < len(names):
|
||||||
|
row.append_text(entry(names[index + 1], False))
|
||||||
|
palette_rows.append(row)
|
||||||
|
|
||||||
|
palette_title = Text()
|
||||||
|
palette_title.append("PALETTES", style="bold cyan")
|
||||||
|
palette_title.append(
|
||||||
|
" c cycles them · remembered between runs", style="dim"
|
||||||
|
)
|
||||||
|
|
||||||
catalogue_title = Text()
|
catalogue_title = Text()
|
||||||
catalogue_title.append("CATALOGUES", style="bold cyan")
|
catalogue_title.append("CATALOGUES", style="bold cyan")
|
||||||
@@ -112,28 +111,58 @@ class HelpScreen(ModalScreen[None]):
|
|||||||
table.add_row(key, description)
|
table.add_row(key, description)
|
||||||
|
|
||||||
header = Text()
|
header = Text()
|
||||||
header.append("◤ ", style=f"bold {gradient(SPECTRUM_STOPS, 0.9)}")
|
header.append("◤ ", style=f"bold {gradient(active_stops(self.app), 0.9)}")
|
||||||
header.append("BEEFY PLAYER", style=f"bold {gradient(SPECTRUM_STOPS, 0.55)}")
|
header.append("BEEFY PLAYER", style=f"bold {gradient(active_stops(self.app), 0.55)}")
|
||||||
header.append(" keyboard & credits", style="dim")
|
header.append(" keyboard shortcuts", style="dim")
|
||||||
|
header.append(" · ↑ ↓ / pgup pgdn scroll", style="dim")
|
||||||
|
|
||||||
footer = Text()
|
# One attribution line: version and where the project lives.
|
||||||
for paragraph in about:
|
footer = Text(no_wrap=True)
|
||||||
for line in textwrap.wrap(paragraph, width=70):
|
footer.append(f"beefy player {__version__}", style="bold")
|
||||||
footer.append(line + "\n", style="dim")
|
footer.append(" · ")
|
||||||
|
footer.append(
|
||||||
|
URL, style=f"dim link {URL}"
|
||||||
|
)
|
||||||
|
|
||||||
body = Group(
|
body = Group(
|
||||||
header,
|
header,
|
||||||
Text(""),
|
Text(""),
|
||||||
table,
|
table,
|
||||||
Text(""),
|
Text(""),
|
||||||
pane_note,
|
|
||||||
catalogue_title,
|
catalogue_title,
|
||||||
catalogues,
|
catalogues,
|
||||||
Text(""),
|
Text(""),
|
||||||
|
palette_title,
|
||||||
|
*palette_rows,
|
||||||
|
Text(""),
|
||||||
footer,
|
footer,
|
||||||
)
|
)
|
||||||
yield Static(body, id="help")
|
# The body outgrows a small terminal; the scroll container keeps it
|
||||||
|
# readable (and gives the box its border).
|
||||||
|
with VerticalScroll(id="help"):
|
||||||
|
yield Static(body, id="help-body")
|
||||||
|
|
||||||
|
#: Keys that scroll the page instead of closing it (help outgrows a 24-row
|
||||||
|
#: terminal, so it has to be readable there).
|
||||||
|
SCROLL_KEYS: ClassVar[dict[str, str]] = {
|
||||||
|
"up": "scroll_up",
|
||||||
|
"k": "scroll_up",
|
||||||
|
"down": "scroll_down",
|
||||||
|
"j": "scroll_down",
|
||||||
|
"pageup": "scroll_page_up",
|
||||||
|
"pagedown": "scroll_page_down",
|
||||||
|
"home": "scroll_home",
|
||||||
|
"g": "scroll_home",
|
||||||
|
"end": "scroll_end",
|
||||||
|
"shift+g": "scroll_end",
|
||||||
|
}
|
||||||
|
|
||||||
def on_key(self, event) -> None:
|
def on_key(self, event) -> None:
|
||||||
event.stop()
|
event.stop()
|
||||||
|
method = self.SCROLL_KEYS.get(event.key)
|
||||||
|
if method is not None:
|
||||||
|
body = self.query_one("#help")
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
getattr(body, method)(animate=False)
|
||||||
|
return
|
||||||
self.dismiss(None)
|
self.dismiss(None)
|
||||||
@@ -5,11 +5,12 @@ from __future__ import annotations
|
|||||||
from rich.console import Group
|
from rich.console import Group
|
||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
from textual.app import ComposeResult
|
from textual.app import ComposeResult
|
||||||
|
from textual.containers import VerticalScroll
|
||||||
from textual.screen import ModalScreen
|
from textual.screen import ModalScreen
|
||||||
from textual.widgets import Static
|
from textual.widgets import Static
|
||||||
|
|
||||||
from ..providers import PROVIDERS, available_providers
|
from ..providers import PROVIDERS, available_providers
|
||||||
from .common import SPECTRUM_STOPS, gradient, truncate
|
from .common import active_stops, gradient, truncate
|
||||||
|
|
||||||
|
|
||||||
class ProviderScreen(ModalScreen[str | None]):
|
class ProviderScreen(ModalScreen[str | None]):
|
||||||
@@ -22,12 +23,19 @@ class ProviderScreen(ModalScreen[str | None]):
|
|||||||
self.index = self.slugs.index(current_id) if current_id in self.slugs else 0
|
self.index = self.slugs.index(current_id) if current_id in self.slugs else 0
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
yield Static(self._body(), id="providers")
|
# A scroll container keeps thirteen catalogues navigable on a short
|
||||||
|
# terminal; the body is given its exact line count so it measures.
|
||||||
|
with VerticalScroll(id="providers"):
|
||||||
|
yield Static(self._body(), id="providers-body")
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
body = self.query_one("#providers-body", Static)
|
||||||
|
body.styles.height = len(self.slugs) + 5 # heading, blank, rows, footer
|
||||||
|
|
||||||
def _body(self) -> Group:
|
def _body(self) -> Group:
|
||||||
header = Text()
|
header = Text()
|
||||||
header.append("◤ ", style=f"bold {gradient(SPECTRUM_STOPS, 0.9)}")
|
header.append("◤ ", style=f"bold {gradient(active_stops(self.app), 0.9)}")
|
||||||
header.append("CATALOGUES", style=f"bold {gradient(SPECTRUM_STOPS, 0.55)}")
|
header.append("CATALOGUES", style=f"bold {gradient(active_stops(self.app), 0.55)}")
|
||||||
header.append(" · pick a station catalogue", style="dim")
|
header.append(" · pick a station catalogue", style="dim")
|
||||||
|
|
||||||
name_width = (
|
name_width = (
|
||||||
@@ -55,26 +63,20 @@ class ProviderScreen(ModalScreen[str | None]):
|
|||||||
f"{truncate(provider.name, name_width - 2):<{name_width}}",
|
f"{truncate(provider.name, name_width - 2):<{name_width}}",
|
||||||
style="bold underline" if selected else "bold",
|
style="bold underline" if selected else "bold",
|
||||||
)
|
)
|
||||||
row.append(truncate(provider.brand, 14), style="dim")
|
row.append(" ")
|
||||||
|
# marker(2) + number(5) + name + gap(2) + tagline + current dot(3)
|
||||||
|
room = max(8, 58 - 2 - 5 - name_width - 2 - 3)
|
||||||
|
row.append(truncate(provider.tagline, room), style="italic dim")
|
||||||
if slug == self.current_id:
|
if slug == self.current_id:
|
||||||
row.append(" ●", style="bold green")
|
row.append(" ●", style="bold green")
|
||||||
rows.append(row)
|
rows.append(row)
|
||||||
if selected:
|
|
||||||
rows.append(
|
|
||||||
Text(
|
|
||||||
" " + truncate(provider.tagline, 54),
|
|
||||||
style="italic dim",
|
|
||||||
no_wrap=True,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
current = PROVIDERS.get(self.current_id)
|
current = PROVIDERS.get(self.current_id)
|
||||||
footer = Text()
|
footer = Text()
|
||||||
footer.append("\n")
|
footer.append("\n")
|
||||||
if current is not None:
|
if current is not None:
|
||||||
footer.append(
|
line = truncate(f"now: {current.name} — {current.station_source}", 58)
|
||||||
f"now: {current.name} — {current.station_source}\n", style="dim"
|
footer.append(line + "\n", style="dim")
|
||||||
)
|
|
||||||
footer.append(
|
footer.append(
|
||||||
"↑ ↓ choose · enter switch · esc cancel",
|
"↑ ↓ choose · enter switch · esc cancel",
|
||||||
style="dim",
|
style="dim",
|
||||||
@@ -85,7 +87,17 @@ class ProviderScreen(ModalScreen[str | None]):
|
|||||||
|
|
||||||
def _move(self, delta: int) -> None:
|
def _move(self, delta: int) -> None:
|
||||||
self.index = (self.index + delta) % len(self.slugs)
|
self.index = (self.index + delta) % len(self.slugs)
|
||||||
self.query_one("#providers", Static).update(self._body())
|
body = self.query_one("#providers-body", Static)
|
||||||
|
body.update(self._body())
|
||||||
|
|
||||||
|
box = self.query_one("#providers", VerticalScroll)
|
||||||
|
|
||||||
|
def follow() -> None:
|
||||||
|
visible = max(1, box.size.height)
|
||||||
|
target = max(0, 2 + self.index - (visible - 3) // 2)
|
||||||
|
box.scroll_to(y=target, animate=False)
|
||||||
|
|
||||||
|
box.call_after_refresh(follow)
|
||||||
|
|
||||||
def on_key(self, event) -> None:
|
def on_key(self, event) -> None:
|
||||||
key = event.key
|
key = event.key
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from rich.text import Text
|
|||||||
from textual.widget import Widget
|
from textual.widget import Widget
|
||||||
|
|
||||||
from . import frame
|
from . import frame
|
||||||
from .common import local_time, local_zone, state_glyph, truncate
|
from .common import active_stops, local_time, local_zone, state_glyph, truncate
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ..app import BeefyPlayerApp
|
from ..app import BeefyPlayerApp
|
||||||
@@ -68,7 +68,7 @@ def _upcoming_rows(
|
|||||||
row.append(" ")
|
row.append(" ")
|
||||||
row.append(local_time(item.start), style="bold")
|
row.append(local_time(item.start), style="bold")
|
||||||
row.append(" ")
|
row.append(" ")
|
||||||
row.append(truncate(item.display_name, max(4, width - 7)))
|
row.append(truncate(item.display_name, max(4, width - 9)))
|
||||||
rows.append((True, row))
|
rows.append((True, row))
|
||||||
|
|
||||||
if len(rows) > budget:
|
if len(rows) > budget:
|
||||||
@@ -94,6 +94,7 @@ class SchedulePanel(Widget):
|
|||||||
app = cast("BeefyPlayerApp", self.app)
|
app = cast("BeefyPlayerApp", self.app)
|
||||||
width = max(12, self.size.width)
|
width = max(12, self.size.width)
|
||||||
height = max(3, self.size.height)
|
height = max(3, self.size.height)
|
||||||
|
stops = active_stops(app)
|
||||||
width, height = frame.inner_size(width, height)
|
width, height = frame.inner_size(width, height)
|
||||||
|
|
||||||
broadcasts = list(getattr(app, "schedule", ()) or ())
|
broadcasts = list(getattr(app, "schedule", ()) or ())
|
||||||
@@ -141,4 +142,5 @@ class SchedulePanel(Widget):
|
|||||||
max(12, self.size.width),
|
max(12, self.size.width),
|
||||||
max(3, self.size.height),
|
max(3, self.size.height),
|
||||||
self.border_title,
|
self.border_title,
|
||||||
|
stops,
|
||||||
)
|
)
|
||||||
@@ -11,9 +11,10 @@ from rich.console import RenderableType
|
|||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
from textual.widget import Widget
|
from textual.widget import Widget
|
||||||
|
|
||||||
|
from ..palettes import palette_stops
|
||||||
from ..spectrum import Frame, SpectrumEngine
|
from ..spectrum import Frame, SpectrumEngine
|
||||||
from . import frame
|
from . import frame
|
||||||
from .common import LEVELS, SPECTRUM_STOPS, gradient
|
from .common import LEVELS, active_stops, gradient
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ..app import BeefyPlayerApp
|
from ..app import BeefyPlayerApp
|
||||||
@@ -48,7 +49,9 @@ class SpectrumPanel(Widget):
|
|||||||
super().__init__()
|
super().__init__()
|
||||||
self.engine = engine
|
self.engine = engine
|
||||||
self.border_title = "SPECTRUM"
|
self.border_title = "SPECTRUM"
|
||||||
self._palettes: dict[tuple[int, int, bool], list[str]] = {}
|
#: The active colour ladder; ``render()`` refreshes it each frame.
|
||||||
|
self._stops: tuple[str, ...] = palette_stops(None)
|
||||||
|
self._palettes: dict[tuple, list[str]] = {}
|
||||||
#: Recent band frames, newest last, for the waterfall display. Filled
|
#: Recent band frames, newest last, for the waterfall display. Filled
|
||||||
#: even while another mode is showing so switching to it has history.
|
#: even while another mode is showing so switching to it has history.
|
||||||
self._history: deque[tuple[float, ...]] = deque(maxlen=WATERFALL_HISTORY)
|
self._history: deque[tuple[float, ...]] = deque(maxlen=WATERFALL_HISTORY)
|
||||||
@@ -65,6 +68,7 @@ class SpectrumPanel(Widget):
|
|||||||
def render(self) -> RenderableType:
|
def render(self) -> RenderableType:
|
||||||
app = cast("BeefyPlayerApp", self.app)
|
app = cast("BeefyPlayerApp", self.app)
|
||||||
snapshot = self.engine.snapshot()
|
snapshot = self.engine.snapshot()
|
||||||
|
self._stops = active_stops(app)
|
||||||
outer_width, outer_height = max(12, self.size.width), max(4, self.size.height)
|
outer_width, outer_height = max(12, self.size.width), max(4, self.size.height)
|
||||||
width, height = frame.inner_size(outer_width, outer_height)
|
width, height = frame.inner_size(outer_width, outer_height)
|
||||||
mode = getattr(app, "visualizer", "both")
|
mode = getattr(app, "visualizer", "both")
|
||||||
@@ -96,7 +100,7 @@ class SpectrumPanel(Widget):
|
|||||||
mirrored.reverse()
|
mirrored.reverse()
|
||||||
lines.extend(mirrored)
|
lines.extend(mirrored)
|
||||||
return frame.wrap(
|
return frame.wrap(
|
||||||
lines, outer_width, outer_height, self.border_title
|
lines, outer_width, outer_height, self.border_title, self._stops
|
||||||
)
|
)
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@@ -127,14 +131,15 @@ class SpectrumPanel(Widget):
|
|||||||
return float(values[low]) * (1.0 - fraction) + float(values[high]) * fraction
|
return float(values[low]) * (1.0 - fraction) + float(values[high]) * fraction
|
||||||
|
|
||||||
def _row_palette(self, height: int, phase: int, dim: bool) -> list[str]:
|
def _row_palette(self, height: int, phase: int, dim: bool) -> list[str]:
|
||||||
key = (height, phase % 12, dim)
|
stops = self._stops
|
||||||
|
key = (height, phase % 12, dim, stops)
|
||||||
cached = self._palettes.get(key)
|
cached = self._palettes.get(key)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
return cached
|
return cached
|
||||||
palette: list[str] = []
|
palette: list[str] = []
|
||||||
for row in range(height):
|
for row in range(height):
|
||||||
position = (height - 1 - row) / max(1, height - 1)
|
position = (height - 1 - row) / max(1, height - 1)
|
||||||
colour = gradient(SPECTRUM_STOPS, position)
|
colour = gradient(stops, position)
|
||||||
style = f"bold {colour}" if (row + phase) % 7 == 0 else colour
|
style = f"bold {colour}" if (row + phase) % 7 == 0 else colour
|
||||||
if dim:
|
if dim:
|
||||||
style = f"dim {style}"
|
style = f"dim {style}"
|
||||||
@@ -331,7 +336,7 @@ class SpectrumPanel(Widget):
|
|||||||
level = int(value * (len(WATERFALL_LEVELS) - 1))
|
level = int(value * (len(WATERFALL_LEVELS) - 1))
|
||||||
if level <= 0:
|
if level <= 0:
|
||||||
continue
|
continue
|
||||||
style = gradient(SPECTRUM_STOPS, value)
|
style = gradient(self._stops, value)
|
||||||
if faded:
|
if faded:
|
||||||
style = f"dim {style}"
|
style = f"dim {style}"
|
||||||
elif age == 1:
|
elif age == 1:
|
||||||
@@ -392,11 +397,11 @@ class SpectrumPanel(Widget):
|
|||||||
if depth < 0.06:
|
if depth < 0.06:
|
||||||
grid[row][column] = "█"
|
grid[row][column] = "█"
|
||||||
styles[row][column] = ("dim " if dim else "") + gradient(
|
styles[row][column] = ("dim " if dim else "") + gradient(
|
||||||
SPECTRUM_STOPS, edge
|
self._stops, edge
|
||||||
)
|
)
|
||||||
elif depth < 0.15:
|
elif depth < 0.15:
|
||||||
grid[row][column] = "▓"
|
grid[row][column] = "▓"
|
||||||
styles[row][column] = "dim " + gradient(SPECTRUM_STOPS, edge)
|
styles[row][column] = "dim " + gradient(self._stops, edge)
|
||||||
elif depth < 0.25:
|
elif depth < 0.25:
|
||||||
grid[row][column] = "▒"
|
grid[row][column] = "▒"
|
||||||
styles[row][column] = "dim"
|
styles[row][column] = "dim"
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from rich.text import Text
|
|||||||
from textual.widget import Widget
|
from textual.widget import Widget
|
||||||
|
|
||||||
from . import frame
|
from . import frame
|
||||||
from .common import compact_count, truncate
|
from .common import active_stops, compact_count, truncate
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ..app import BeefyPlayerApp
|
from ..app import BeefyPlayerApp
|
||||||
@@ -83,16 +83,21 @@ class StationList(Widget):
|
|||||||
app = cast("BeefyPlayerApp", self.app)
|
app = cast("BeefyPlayerApp", self.app)
|
||||||
width = max(12, self.size.width)
|
width = max(12, self.size.width)
|
||||||
height = max(3, self.size.height)
|
height = max(3, self.size.height)
|
||||||
|
stops = active_stops(app)
|
||||||
inner_width, inner_height = frame.inner_size(width, height)
|
inner_width, inner_height = frame.inner_size(width, height)
|
||||||
view = app.view
|
view = app.view
|
||||||
|
|
||||||
if not view:
|
if not view:
|
||||||
message = "no stations match" if app.query else "loading stations…"
|
if getattr(app, "search_pending", False):
|
||||||
|
message = "searching the directory…"
|
||||||
|
else:
|
||||||
|
message = "no stations match" if app.query else "loading stations…"
|
||||||
return frame.wrap(
|
return frame.wrap(
|
||||||
[Text(truncate(message, inner_width), style="dim italic")],
|
[Text(truncate(message, inner_width), style="dim italic")],
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
self.border_title,
|
self.border_title,
|
||||||
|
stops,
|
||||||
)
|
)
|
||||||
|
|
||||||
total = len(view)
|
total = len(view)
|
||||||
@@ -167,4 +172,4 @@ class StationList(Widget):
|
|||||||
)
|
)
|
||||||
lines.append(row)
|
lines.append(row)
|
||||||
|
|
||||||
return frame.wrap(lines, width, height, self.border_title)
|
return frame.wrap(lines, width, height, self.border_title, stops)
|
||||||
Generated
+27
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1793726008,
|
||||||
|
"narHash": "sha256-jQp1wKvbwWdLBASTXkSi/VYmDJ5AbBi1WvcMt9HWJC8=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "1d060086d60cb76a40cf37109f979d88a5bd6396",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
{
|
||||||
|
description = "beefy-player — a flashy terminal player for internet radio";
|
||||||
|
|
||||||
|
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
|
|
||||||
|
outputs =
|
||||||
|
{ self, nixpkgs }:
|
||||||
|
let
|
||||||
|
systems = [
|
||||||
|
"x86_64-linux"
|
||||||
|
"aarch64-linux"
|
||||||
|
"x86_64-darwin"
|
||||||
|
"aarch64-darwin"
|
||||||
|
];
|
||||||
|
|
||||||
|
forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
|
||||||
|
|
||||||
|
# Things the app shells out to at runtime: mpv for audio, ffmpeg for the
|
||||||
|
# spectrum capture, and parec/pactl from PulseAudio for the monitor source
|
||||||
|
# (PipeWire speaks the same protocol).
|
||||||
|
runtimeDeps =
|
||||||
|
pkgs:
|
||||||
|
with pkgs;
|
||||||
|
[
|
||||||
|
mpv
|
||||||
|
ffmpeg
|
||||||
|
]
|
||||||
|
++ lib.optionals stdenv.hostPlatform.isLinux [ pulseaudio ];
|
||||||
|
|
||||||
|
beefy-player =
|
||||||
|
pkgs:
|
||||||
|
let
|
||||||
|
py = pkgs.python3Packages;
|
||||||
|
in
|
||||||
|
py.buildPythonApplication {
|
||||||
|
pname = "beefy-player";
|
||||||
|
version = "1.2.2";
|
||||||
|
pyproject = true;
|
||||||
|
src = self;
|
||||||
|
|
||||||
|
build-system = [ py.hatchling ];
|
||||||
|
|
||||||
|
dependencies =
|
||||||
|
with py;
|
||||||
|
[
|
||||||
|
textual
|
||||||
|
rich
|
||||||
|
httpx
|
||||||
|
numpy
|
||||||
|
pillow
|
||||||
|
]
|
||||||
|
++ pkgs.lib.optionals pkgs.stdenv.hostPlatform.isLinux [ dbus-next ];
|
||||||
|
|
||||||
|
nativeBuildInputs = [ pkgs.makeWrapper ];
|
||||||
|
nativeCheckInputs = with py; [
|
||||||
|
pytestCheckHook
|
||||||
|
pytest-asyncio
|
||||||
|
];
|
||||||
|
|
||||||
|
# The suite is offline (catalogues are cached fixtures).
|
||||||
|
doCheck = true;
|
||||||
|
|
||||||
|
pythonImportsCheck = [ "beefy_player" ];
|
||||||
|
|
||||||
|
makeWrapperArgs = [
|
||||||
|
"--prefix"
|
||||||
|
"PATH"
|
||||||
|
":"
|
||||||
|
(pkgs.lib.makeBinPath (runtimeDeps pkgs))
|
||||||
|
];
|
||||||
|
|
||||||
|
meta = {
|
||||||
|
description = "Flashy terminal player for internet radio, with a live spectrum analyser";
|
||||||
|
homepage = "https://tildeforge.dev/beefcarver/beefy-player";
|
||||||
|
license = pkgs.lib.licenses.wtfpl;
|
||||||
|
mainProgram = "beefy-player";
|
||||||
|
platforms = pkgs.lib.platforms.unix;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
# One module body, two install targets: a NixOS system adds the package
|
||||||
|
# to environment.systemPackages, Home Manager to home.packages.
|
||||||
|
mkModule =
|
||||||
|
install:
|
||||||
|
{
|
||||||
|
config,
|
||||||
|
lib,
|
||||||
|
pkgs,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
cfg = config.programs.beefy-player;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
options.programs.beefy-player = {
|
||||||
|
enable = lib.mkEnableOption "beefy-player, a terminal radio player";
|
||||||
|
|
||||||
|
package = lib.mkOption {
|
||||||
|
type = lib.types.package;
|
||||||
|
default = self.packages.${pkgs.stdenv.hostPlatform.system}.default;
|
||||||
|
defaultText = lib.literalMD "the `beefy-player` package from this flake";
|
||||||
|
description = "The beefy-player package to use.";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
config = lib.mkIf cfg.enable (lib.setAttrByPath install [ cfg.package ]);
|
||||||
|
};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
packages = forAllSystems (
|
||||||
|
pkgs:
|
||||||
|
let
|
||||||
|
pkg = beefy-player pkgs;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
default = pkg;
|
||||||
|
beefy-player = pkg;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
apps = forAllSystems (pkgs: {
|
||||||
|
default = {
|
||||||
|
type = "app";
|
||||||
|
program = "${beefy-player pkgs}/bin/beefy-player";
|
||||||
|
meta.description = "Flashy terminal player for internet radio";
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
checks = forAllSystems (pkgs: {
|
||||||
|
beefy-player = beefy-player pkgs;
|
||||||
|
});
|
||||||
|
|
||||||
|
devShells = forAllSystems (
|
||||||
|
pkgs:
|
||||||
|
let
|
||||||
|
inherit (pkgs) lib;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
# No FHS needed here: `nix develop` puts libstdc++ on LD_LIBRARY_PATH
|
||||||
|
# so uv's manylinux wheels load, unlike the plain `nix-shell` case in
|
||||||
|
# shell.nix.
|
||||||
|
default = pkgs.mkShell {
|
||||||
|
packages = runtimeDeps pkgs ++ [
|
||||||
|
pkgs.python3
|
||||||
|
pkgs.uv
|
||||||
|
pkgs.ruff
|
||||||
|
pkgs.git
|
||||||
|
pkgs.cacert
|
||||||
|
];
|
||||||
|
|
||||||
|
env = {
|
||||||
|
UV_PYTHON = "${pkgs.python3}/bin/python3";
|
||||||
|
UV_PYTHON_DOWNLOADS = "never";
|
||||||
|
UV_PROJECT_ENVIRONMENT = "${toString ./.}/.venv";
|
||||||
|
UV_LINK_MODE = "copy";
|
||||||
|
SSL_CERT_FILE = "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt";
|
||||||
|
};
|
||||||
|
|
||||||
|
LD_LIBRARY_PATH = lib.makeLibraryPath [
|
||||||
|
pkgs.stdenv.cc.cc.lib
|
||||||
|
pkgs.zlib
|
||||||
|
];
|
||||||
|
|
||||||
|
shellHook = ''
|
||||||
|
echo "beefy-player dev shell — uv sync, uv run pytest, uv run beefy-player"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
nixosModules.default = mkModule [
|
||||||
|
"environment"
|
||||||
|
"systemPackages"
|
||||||
|
];
|
||||||
|
nixosModules.beefy-player = self.nixosModules.default;
|
||||||
|
|
||||||
|
homeModules.default = mkModule [
|
||||||
|
"home"
|
||||||
|
"packages"
|
||||||
|
];
|
||||||
|
homeModules.beefy-player = self.homeModules.default;
|
||||||
|
|
||||||
|
formatter = forAllSystems (pkgs: pkgs.nixfmt);
|
||||||
|
|
||||||
|
overlays.default = final: _prev: {
|
||||||
|
beefy-player = beefy-player final;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
class BeefyPlayer < Formula
|
||||||
|
include Language::Python::Virtualenv
|
||||||
|
|
||||||
|
desc "Terminal player for internet radio with a live spectrum analyser"
|
||||||
|
homepage "https://tildeforge.dev/beefcarver/beefy-player"
|
||||||
|
url "https://tildeforge.dev/beefcarver/beefy-player/releases/download/v1.2.2/beefy-player-1.2.2.tar.gz"
|
||||||
|
sha256 "0000000000000000000000000000000000000000000000000000000000000000" # TODO: release asset
|
||||||
|
license "WTFPL"
|
||||||
|
head "https://tildeforge.dev/beefcarver/beefy-player.git", branch: "main"
|
||||||
|
|
||||||
|
depends_on "python@3.14"
|
||||||
|
|
||||||
|
# numpy and pillow are large native builds. Depending on Homebrew's own
|
||||||
|
# formulae (which have bottles) keeps them out of the app's virtualenv build;
|
||||||
|
# the venv sees them through system site-packages.
|
||||||
|
depends_on "numpy"
|
||||||
|
depends_on "pillow"
|
||||||
|
|
||||||
|
# Runtime tools the app shells out to: mpv for audio, ffmpeg for the stream
|
||||||
|
# decode fallback of the spectrum analyser. Both are optional at runtime --
|
||||||
|
# the UI degrades gracefully -- but a radio player without them is not much fun.
|
||||||
|
depends_on "ffmpeg"
|
||||||
|
depends_on "mpv"
|
||||||
|
|
||||||
|
# Tells `brew update-python-resources` what to resolve: numpy/pillow come from
|
||||||
|
# the formulae above, and dbus-next is marker-gated to Linux upstream so it
|
||||||
|
# has to be requested explicitly for the Linux side of the tap.
|
||||||
|
pypi_packages exclude_packages: "numpy,pillow", extra_packages: "dbus-next"
|
||||||
|
|
||||||
|
def install
|
||||||
|
# system_site_packages is the default, so the excluded numpy/pillow from the
|
||||||
|
# formulae are importable from the virtualenv.
|
||||||
|
virtualenv_install_with_resources
|
||||||
|
end
|
||||||
|
|
||||||
|
def caveats
|
||||||
|
<<~EOS
|
||||||
|
The spectrum analyser follows what you hear. On macOS 14.2+ grant your
|
||||||
|
terminal permission under System Settings > Privacy & Security > System
|
||||||
|
Audio Recording, otherwise it falls back to decoding the stream.
|
||||||
|
|
||||||
|
Inside tmux, install `tmux` for image passthrough.
|
||||||
|
EOS
|
||||||
|
end
|
||||||
|
|
||||||
|
test do
|
||||||
|
assert_match version.to_s, shell_output("#{bin}/beefy-player --version")
|
||||||
|
# --offline keeps the test hermetic: cached or bundled stations only.
|
||||||
|
assert_match(/^# /, shell_output("#{bin}/beefy-player --offline --list"))
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# Homebrew packaging for beefy-player
|
||||||
|
|
||||||
|
A Homebrew **tap** is just a second git repository that Homebrew clones next to
|
||||||
|
homebrew/core. There is no central submission, no review and no binary host:
|
||||||
|
`brew` reads the Ruby formula from the tap, downloads the pinned source tarball
|
||||||
|
and installs it on the user's machine.
|
||||||
|
|
||||||
|
This directory is the staging area for the tap repository, which lives at
|
||||||
|
**https://tildeforge.dev/beefcarver/homebrew-tap** (repo name `homebrew-tap`, so
|
||||||
|
the tap name is `beefcarver/tap`).
|
||||||
|
|
||||||
|
Because Homebrew's one-argument `brew tap` assumes GitHub, a Forgejo tap has to
|
||||||
|
be tapped with its URL, and Homebrew 6+ also wants an explicit trust.
|
||||||
|
|
||||||
|
## Install (user side)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew tap beefcarver/tap https://tildeforge.dev/beefcarver/homebrew-tap
|
||||||
|
brew trust --formula beefcarver/tap/beefy-player
|
||||||
|
brew install beefcarver/tap/beefy-player
|
||||||
|
```
|
||||||
|
|
||||||
|
`brew update` refreshes the tap from then on; `brew upgrade` picks up new
|
||||||
|
versions like any core formula. If the tap is mirrored to GitHub later,
|
||||||
|
`brew install beefcarver/tap/beefy-player` becomes a one-liner, because that
|
||||||
|
form auto-taps from GitHub and trusts only the formula it installs.
|
||||||
|
|
||||||
|
## Publishing the tap (maintainer side)
|
||||||
|
|
||||||
|
### 1. One tag per release, with a release asset
|
||||||
|
|
||||||
|
The formula pins a stable URL plus a SHA-256, so it needs an immutable tarball.
|
||||||
|
Do **not** point it at Forgejo's `/archive/v$VERSION.tar.gz` URL: those archives
|
||||||
|
are generated on demand and are not guaranteed byte-stable. Attach a tarball to
|
||||||
|
the release instead (Forgejo serves it at `/releases/download/<tag>/<file>`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
VERSION=$(uv run python -c 'import beefy_player; print(beefy_player.__version__)')
|
||||||
|
git archive --format=tar.gz --prefix="beefy-player-$VERSION/" \
|
||||||
|
-o "beefy-player-$VERSION.tar.gz" "v$VERSION"
|
||||||
|
sha256sum "beefy-player-$VERSION.tar.gz"
|
||||||
|
```
|
||||||
|
|
||||||
|
Create the tag on the mirror (`git push tildeforge "v$VERSION"`), upload the
|
||||||
|
tarball to the `v$VERSION` release, then paste the URL and checksum into
|
||||||
|
`Formula/b/beefy-player.rb`.
|
||||||
|
|
||||||
|
### 2. Resolve the Python resources
|
||||||
|
|
||||||
|
Every Python dependency (and its recursive dependencies) that is not provided by
|
||||||
|
another formula must be declared as a `resource` so Homebrew installs with
|
||||||
|
dependency resolution disabled. Never write these by hand:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew tap beefcarver/tap /path/to/homebrew-tap # local clone, or the URL
|
||||||
|
brew update-python-resources --print-only beefcarver/tap/beefy-player
|
||||||
|
brew update-python-resources beefcarver/tap/beefy-player
|
||||||
|
```
|
||||||
|
|
||||||
|
`pypi_packages exclude_packages: "numpy,pillow"` keeps the two native builds out
|
||||||
|
of the resource list — they come from the `numpy`/`pillow` formulae, which ship
|
||||||
|
bottles, and the virtualenv picks them up through system site-packages.
|
||||||
|
`extra_packages: "dbus-next"` makes sure the Linux-only dependency is recorded
|
||||||
|
even though the resolver runs on macOS.
|
||||||
|
|
||||||
|
In a third-party tap, `--ignore-errors` records anything that cannot be resolved
|
||||||
|
and leaves a `RESOURCE-ERROR` comment; the official taps do not allow that, a
|
||||||
|
personal one does.
|
||||||
|
|
||||||
|
### 3. Build, test and audit locally
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew install --build-from-source beefcarver/tap/beefy-player
|
||||||
|
brew test beefcarver/tap/beefy-player
|
||||||
|
brew audit --strict beefcarver/tap/beefy-player
|
||||||
|
brew style beefcarver/tap/beefy-player
|
||||||
|
```
|
||||||
|
|
||||||
|
The formula's `test do` block runs `--version` and `--offline --list`, so it
|
||||||
|
needs no network and no audio device.
|
||||||
|
|
||||||
|
### 4. Push
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add Formula/b/beefy-player.rb && git commit -m 'beefy-player 1.2.2' && git push
|
||||||
|
```
|
||||||
|
|
||||||
|
## Version bumps
|
||||||
|
|
||||||
|
For each release: tag, upload the tarball, update `url`/`sha256`, rerun
|
||||||
|
`brew update-python-resources` (only needed when the dependency set or pins
|
||||||
|
changed), install from source, test, push. `brew bump-formula-pr` only works
|
||||||
|
against GitHub-hosted taps, so on Forgejo the bump is a `sed` plus a re-test.
|
||||||
|
|
||||||
|
Optional `livecheck` support, so `brew livecheck --tap beefcarver/tap` can spot
|
||||||
|
new tags:
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
livecheck do
|
||||||
|
url "https://tildeforge.dev/beefcarver/beefy-player/tags"
|
||||||
|
regex(/^v?(\d+(?:\.\d+)+)$/i)
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
## Bottles (optional)
|
||||||
|
|
||||||
|
Without a `bottle do` block users build from source — for this app that means
|
||||||
|
filling a virtualenv with pure-Python resources while `numpy`, `pillow`, `mpv`
|
||||||
|
and `ffmpeg` arrive as bottles, so it is quick. Prebuilt bottles need Homebrew's
|
||||||
|
GitHub Actions workflows, which cannot run on Forgejo; skip them unless the tap
|
||||||
|
is also mirrored to GitHub.
|
||||||
|
|
||||||
|
## Why a tap and not homebrew-core
|
||||||
|
|
||||||
|
homebrew-core requires a tagged, immutable source URL and enough notability to
|
||||||
|
be accepted at all (roughly 75+ stars / 30+ forks, or coverage that makes a
|
||||||
|
project notable). A tap has none of those constraints, which is why upstream
|
||||||
|
taps are the normal route for a project this size. The formula here follows
|
||||||
|
core's conventions, so it could be submitted there later unchanged if the
|
||||||
|
project qualifies.
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "beefy-player"
|
name = "beefy-player"
|
||||||
version = "1.0.0"
|
version = "1.2.2"
|
||||||
description = "A flashy terminal player for internet radio — live spectrum analyser, MPRIS / macOS Now Playing support, song history and pluggable station catalogues (SomaFM, Radio Paradise, dublab, Rinse FM and more)"
|
description = "A flashy terminal player for internet radio — live spectrum analyser, MPRIS / macOS Now Playing support, song history and pluggable station catalogues (SomaFM, Radio Paradise, dublab, Rinse FM and more)"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "WTFPL"
|
license = "WTFPL"
|
||||||
|
|||||||
+365
-3
@@ -8,7 +8,9 @@ import pytest
|
|||||||
|
|
||||||
from beefy_player import config
|
from beefy_player import config
|
||||||
from beefy_player.app import BeefyPlayerApp
|
from beefy_player.app import BeefyPlayerApp
|
||||||
from beefy_player.models import Song
|
from beefy_player.models import Song, Station
|
||||||
|
from beefy_player.providers.base import StationPage
|
||||||
|
from beefy_player.widgets.art import ArtPane
|
||||||
from beefy_player.widgets.help import HelpScreen
|
from beefy_player.widgets.help import HelpScreen
|
||||||
from beefy_player.widgets.stage import StagePanel
|
from beefy_player.widgets.stage import StagePanel
|
||||||
from beefy_player.widgets.station_list import StationList
|
from beefy_player.widgets.station_list import StationList
|
||||||
@@ -69,8 +71,10 @@ async def test_boot_navigation_filter_favourite_visualizer_help() -> None:
|
|||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
assert app.current is not None
|
assert app.current is not None
|
||||||
assert app.current.id == "defcon"
|
assert app.current.id == "defcon"
|
||||||
assert app.query == ""
|
assert app.query == "defcon"
|
||||||
assert len(app.view) == len(app.stations)
|
assert [station.id for station in app.view] == ["defcon"]
|
||||||
|
assert not search.has_class("visible")
|
||||||
|
assert app.focused is None
|
||||||
assert app.playing_state in ("stopped", "error")
|
assert app.playing_state in ("stopped", "error")
|
||||||
|
|
||||||
app.action_favorite()
|
app.action_favorite()
|
||||||
@@ -99,6 +103,314 @@ async def test_boot_navigation_filter_favourite_visualizer_help() -> None:
|
|||||||
assert "DEF CON" in text.upper()
|
assert "DEF CON" in text.upper()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_escape_leaves_the_filter_sticky_and_a_second_escape_clears_it() -> None:
|
||||||
|
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
|
||||||
|
async with app.run_test(size=(110, 36)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
await pilot.press("slash")
|
||||||
|
for char in "defcon":
|
||||||
|
await pilot.press(char)
|
||||||
|
await pilot.pause()
|
||||||
|
search = app.query_one("#search")
|
||||||
|
assert search.has_class("visible")
|
||||||
|
assert app.query == "defcon"
|
||||||
|
assert [station.id for station in app.view] == ["defcon"]
|
||||||
|
|
||||||
|
await pilot.press("escape")
|
||||||
|
await pilot.pause()
|
||||||
|
assert app.query == "defcon"
|
||||||
|
assert search.value == "defcon"
|
||||||
|
assert not search.has_class("visible")
|
||||||
|
assert app.focused is None
|
||||||
|
assert [station.id for station in app.view] == ["defcon"]
|
||||||
|
assert "defcon" in app.query_one(StationList).border_title
|
||||||
|
|
||||||
|
app.action_favorite()
|
||||||
|
await pilot.pause()
|
||||||
|
assert "defcon" in app.config.favorites
|
||||||
|
assert app.query == "defcon"
|
||||||
|
assert [station.id for station in app.view] == ["defcon"]
|
||||||
|
|
||||||
|
await pilot.press("escape")
|
||||||
|
await pilot.pause()
|
||||||
|
assert app.query == ""
|
||||||
|
assert search.value == ""
|
||||||
|
assert len(app.view) == len(app.stations)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_enter_keeps_the_filter_sticky_like_the_first_escape() -> None:
|
||||||
|
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
|
||||||
|
async with app.run_test(size=(110, 36)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
await pilot.press("slash")
|
||||||
|
for char in "defcon":
|
||||||
|
await pilot.press(char)
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
await pilot.press("enter")
|
||||||
|
await pilot.pause()
|
||||||
|
search = app.query_one("#search")
|
||||||
|
assert app.current is not None
|
||||||
|
assert app.current.id == "defcon"
|
||||||
|
assert app.query == "defcon"
|
||||||
|
assert [station.id for station in app.view] == ["defcon"]
|
||||||
|
assert not search.has_class("visible")
|
||||||
|
|
||||||
|
await pilot.press("escape")
|
||||||
|
await pilot.pause()
|
||||||
|
assert app.query == ""
|
||||||
|
assert len(app.view) == len(app.stations)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_directory_search_uses_the_server_not_the_loaded_list(monkeypatch) -> None:
|
||||||
|
app = BeefyPlayerApp(
|
||||||
|
provider="radiobrowser", autoplay=False, offline=True, audio=False
|
||||||
|
)
|
||||||
|
async with app.run_test(size=(110, 36)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
app.offline = False
|
||||||
|
|
||||||
|
found = Station(
|
||||||
|
id="deep-jazz",
|
||||||
|
title="Deep Jazz",
|
||||||
|
description="France · french",
|
||||||
|
genre="jazz",
|
||||||
|
provider="radiobrowser",
|
||||||
|
)
|
||||||
|
queries: list[str] = []
|
||||||
|
|
||||||
|
async def fake_search(self, client, query):
|
||||||
|
queries.append(query)
|
||||||
|
return [found]
|
||||||
|
|
||||||
|
monkeypatch.setattr(type(app.provider), "search_stations", fake_search)
|
||||||
|
|
||||||
|
app.query = "jazz"
|
||||||
|
app._rebuild_view()
|
||||||
|
assert not any(station.id == "deep-jazz" for station in app.view)
|
||||||
|
|
||||||
|
worker = app._search_directory("jazz")
|
||||||
|
await worker.wait()
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
assert queries == ["jazz"]
|
||||||
|
assert [station.id for station in app.view] == ["deep-jazz"]
|
||||||
|
assert app._find_station("deep-jazz") is found
|
||||||
|
|
||||||
|
app._tune(found)
|
||||||
|
assert any(station.id == "deep-jazz" for station in app.stations)
|
||||||
|
app._set_stations(
|
||||||
|
[Station(id="other", title="Other", provider="radiobrowser")]
|
||||||
|
)
|
||||||
|
assert app.current.id == "deep-jazz"
|
||||||
|
assert any(station.id == "deep-jazz" for station in app.stations)
|
||||||
|
|
||||||
|
app.query = ""
|
||||||
|
app.search_results = None
|
||||||
|
app._search_results_query = ""
|
||||||
|
app._rebuild_view()
|
||||||
|
assert len(app.view) == len(app.stations)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_refresh_replaces_a_fallback_station() -> None:
|
||||||
|
app = BeefyPlayerApp(
|
||||||
|
provider="radiobrowser", autoplay=False, offline=True, audio=False
|
||||||
|
)
|
||||||
|
async with app.run_test(size=(110, 36)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
assert app.current is not None
|
||||||
|
assert app.current.id.startswith("fallback-")
|
||||||
|
|
||||||
|
app._set_stations(
|
||||||
|
[Station(id="real", title="Real Station", provider="radiobrowser")]
|
||||||
|
)
|
||||||
|
assert app.current.id == "real"
|
||||||
|
assert [station.id for station in app.stations] == ["real"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_favoriting_a_loaded_station_from_search_updates_it_in_place() -> None:
|
||||||
|
app = BeefyPlayerApp(
|
||||||
|
provider="radiobrowser", autoplay=False, offline=True, audio=False
|
||||||
|
)
|
||||||
|
async with app.run_test(size=(110, 36)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
existing = app.stations[0]
|
||||||
|
search_copy = Station(
|
||||||
|
id=existing.id, title=existing.title, provider="radiobrowser"
|
||||||
|
)
|
||||||
|
|
||||||
|
app.query = "jazz"
|
||||||
|
app._set_search_results("jazz", [search_copy])
|
||||||
|
assert app.view[0] is existing
|
||||||
|
|
||||||
|
app.index = 0
|
||||||
|
app.action_favorite()
|
||||||
|
await pilot.pause()
|
||||||
|
assert existing.favorite is True
|
||||||
|
assert app.config.is_favorite(existing.id)
|
||||||
|
|
||||||
|
app._close_search(clear=True)
|
||||||
|
await pilot.pause()
|
||||||
|
assert app.view[0].id == existing.id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_favoriting_a_directory_result_keeps_it_after_clearing_the_filter(
|
||||||
|
monkeypatch,
|
||||||
|
) -> None:
|
||||||
|
app = BeefyPlayerApp(
|
||||||
|
provider="radiobrowser", autoplay=False, offline=True, audio=False
|
||||||
|
)
|
||||||
|
async with app.run_test(size=(110, 36)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
app.offline = False
|
||||||
|
|
||||||
|
found = Station(
|
||||||
|
id="deep-jazz", title="Deep Jazz", provider="radiobrowser"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def fake_search(self, client, query):
|
||||||
|
return [found]
|
||||||
|
|
||||||
|
monkeypatch.setattr(type(app.provider), "search_stations", fake_search)
|
||||||
|
|
||||||
|
await pilot.press("slash")
|
||||||
|
for char in "jazz":
|
||||||
|
await pilot.press(char)
|
||||||
|
await pilot.pause(0.4)
|
||||||
|
assert [station.id for station in app.view] == ["deep-jazz"]
|
||||||
|
|
||||||
|
await pilot.press("escape")
|
||||||
|
await pilot.pause()
|
||||||
|
app.action_favorite()
|
||||||
|
await pilot.pause()
|
||||||
|
assert found.favorite is True
|
||||||
|
assert "deep-jazz" in app.config.favorites
|
||||||
|
|
||||||
|
await pilot.press("escape")
|
||||||
|
await pilot.pause()
|
||||||
|
assert app.query == ""
|
||||||
|
assert app.view[0].id == "deep-jazz"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_missing_favorites_load_with_the_first_directory_page(
|
||||||
|
monkeypatch,
|
||||||
|
) -> None:
|
||||||
|
app = BeefyPlayerApp(
|
||||||
|
provider="radiobrowser", autoplay=False, offline=True, audio=False
|
||||||
|
)
|
||||||
|
async with app.run_test(size=(110, 36)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
app.offline = False
|
||||||
|
|
||||||
|
favourite = Station(
|
||||||
|
id="12345678-1234-1234-1234-123456789abc",
|
||||||
|
title="Favourite",
|
||||||
|
provider="radiobrowser",
|
||||||
|
)
|
||||||
|
app.config.favorites = [favourite.id]
|
||||||
|
page = StationPage(
|
||||||
|
stations=[
|
||||||
|
Station(id="page-0", title="Page Zero", provider="radiobrowser")
|
||||||
|
],
|
||||||
|
next_offset=100,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def fake_page(self, client, offset=0):
|
||||||
|
return page
|
||||||
|
|
||||||
|
async def fake_by_ids(self, client, station_ids):
|
||||||
|
assert station_ids == [favourite.id]
|
||||||
|
return [favourite]
|
||||||
|
|
||||||
|
monkeypatch.setattr(type(app.provider), "fetch_station_page", fake_page)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
type(app.provider), "fetch_stations_by_ids", fake_by_ids
|
||||||
|
)
|
||||||
|
|
||||||
|
worker = app._refresh_stations()
|
||||||
|
await worker.wait()
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
assert any(
|
||||||
|
station.id == favourite.id and station.favorite
|
||||||
|
for station in app.stations
|
||||||
|
)
|
||||||
|
assert app.view[0].id == favourite.id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_paginated_catalogue_loads_the_next_page_near_the_end(
|
||||||
|
monkeypatch,
|
||||||
|
) -> None:
|
||||||
|
app = BeefyPlayerApp(
|
||||||
|
provider="radiobrowser", autoplay=False, offline=True, audio=False
|
||||||
|
)
|
||||||
|
async with app.run_test(size=(110, 36)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
app.offline = False
|
||||||
|
|
||||||
|
first = [
|
||||||
|
Station(
|
||||||
|
id=f"s{index}", title=f"Station {index}", provider="radiobrowser"
|
||||||
|
)
|
||||||
|
for index in range(100)
|
||||||
|
]
|
||||||
|
app._set_stations(first)
|
||||||
|
app._reset_directory_paging(first, has_more=True)
|
||||||
|
assert app._directory_offset == 100
|
||||||
|
assert app._directory_exhausted is False
|
||||||
|
|
||||||
|
app.index = 0
|
||||||
|
app._maybe_load_more_stations()
|
||||||
|
assert app._loading_more is False
|
||||||
|
|
||||||
|
seen: list[int] = []
|
||||||
|
|
||||||
|
async def fake_page(self, client, offset=0):
|
||||||
|
seen.append(offset)
|
||||||
|
return StationPage(
|
||||||
|
stations=[
|
||||||
|
Station(id="extra", title="Extra", provider="radiobrowser")
|
||||||
|
],
|
||||||
|
next_offset=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(type(app.provider), "fetch_station_page", fake_page)
|
||||||
|
app.index = 99
|
||||||
|
worker = app._load_more_stations()
|
||||||
|
await worker.wait()
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
assert seen == [100]
|
||||||
|
assert any(station.id == "extra" for station in app.stations)
|
||||||
|
assert app._directory_exhausted is True
|
||||||
|
assert app.view[app.index].id == "s99"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_popularity_ordered_catalogue_keeps_its_order() -> None:
|
||||||
|
app = BeefyPlayerApp(
|
||||||
|
provider="radiobrowser", autoplay=False, offline=True, audio=False
|
||||||
|
)
|
||||||
|
async with app.run_test(size=(110, 36)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
app._set_stations(
|
||||||
|
[
|
||||||
|
Station(id="zulu", title="Zulu", provider="radiobrowser"),
|
||||||
|
Station(id="alpha", title="Alpha", provider="radiobrowser"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert [station.title for station in app.view] == ["Zulu", "Alpha"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_stage_renders_history_and_listener_count() -> None:
|
async def test_stage_renders_history_and_listener_count() -> None:
|
||||||
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
|
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
|
||||||
@@ -259,3 +571,53 @@ async def test_media_session_commands_drive_the_app() -> None:
|
|||||||
app.media_actions.put("volume:25")
|
app.media_actions.put("volume:25")
|
||||||
await pilot.pause(0.2)
|
await pilot.pause(0.2)
|
||||||
assert app.volume == 25
|
assert app.volume == 25
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_menus_take_the_inline_artwork_down_and_put_it_back(monkeypatch) -> None:
|
||||||
|
"""Terminal images sit over a modal unless the image is deleted first."""
|
||||||
|
calls: list[str] = []
|
||||||
|
monkeypatch.setattr(ArtPane, "suspend", lambda self: calls.append("suspend"))
|
||||||
|
monkeypatch.setattr(ArtPane, "resume", lambda self: calls.append("resume"))
|
||||||
|
|
||||||
|
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
|
||||||
|
async with app.run_test(size=(100, 30)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
await pilot.press("question_mark")
|
||||||
|
await pilot.pause()
|
||||||
|
assert calls == ["suspend"]
|
||||||
|
await pilot.press("x")
|
||||||
|
await pilot.pause()
|
||||||
|
assert calls == ["suspend", "resume"]
|
||||||
|
|
||||||
|
await pilot.press("p")
|
||||||
|
await pilot.pause()
|
||||||
|
assert calls[-1] == "suspend"
|
||||||
|
await pilot.press("escape")
|
||||||
|
await pilot.pause()
|
||||||
|
assert calls[-1] == "resume"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_the_mosaic_gives_way_to_a_real_image(monkeypatch) -> None:
|
||||||
|
"""The pattern must not peek out around a letterboxed picture."""
|
||||||
|
from beefy_player.widgets import art as art_module
|
||||||
|
|
||||||
|
monkeypatch.setattr(art_module.artwork, "supported", lambda: True)
|
||||||
|
|
||||||
|
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
|
||||||
|
async with app.run_test(size=(110, 34)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
pane = app.query_one(ArtPane)
|
||||||
|
assert pane.display, "the artwork pane should be on screen at this size"
|
||||||
|
|
||||||
|
app.artwork_bytes = None
|
||||||
|
patterned = [line.plain for line in pane.render().renderables]
|
||||||
|
assert patterned and any(line.strip() for line in patterned)
|
||||||
|
|
||||||
|
app.artwork_bytes = b"pretend this is a png"
|
||||||
|
blank = [line.plain for line in pane.render().renderables]
|
||||||
|
assert len(blank) == len(patterned)
|
||||||
|
assert len(blank[0]) == len(patterned[0])
|
||||||
|
assert all(not line.strip() for line in blank)
|
||||||
+77
-40
@@ -1,11 +1,18 @@
|
|||||||
"""The BBS frames: grey edges, corners fading white → grey → cyan."""
|
"""The BBS frames: grey edges, and a short palette glow in two corners."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
|
|
||||||
|
from beefy_player.palettes import PALETTES, brightness
|
||||||
from beefy_player.widgets import frame
|
from beefy_player.widgets import frame
|
||||||
|
|
||||||
|
#: The original ladder, used as a fixed reference in these tests.
|
||||||
|
STOPS = PALETTES["spectrum"]
|
||||||
|
#: Its glow: the brightest cell first, dimmer cells towards the edge.
|
||||||
|
RAMP = frame.fade_ramp(STOPS)
|
||||||
|
HOT = RAMP[0] # the corner colour
|
||||||
|
|
||||||
|
|
||||||
def styles_of(line: Text) -> list[tuple[str, str]]:
|
def styles_of(line: Text) -> list[tuple[str, str]]:
|
||||||
return [
|
return [
|
||||||
@@ -13,69 +20,99 @@ def styles_of(line: Text) -> list[tuple[str, str]]:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_corner_gets_the_brightest_colour_of_the_fade() -> None:
|
||||||
|
ranks = [brightness(colour) for colour in RAMP]
|
||||||
|
assert ranks == sorted(ranks, reverse=True)
|
||||||
|
# spectrum's hot end is red, which is dimmer than the yellow before it:
|
||||||
|
# the corner must still be the bright one, not the ladder's last stop
|
||||||
|
assert STOPS[-1] != HOT
|
||||||
|
assert brightness(HOT) > brightness(STOPS[-1])
|
||||||
|
assert set(RAMP) <= set(STOPS)
|
||||||
|
assert frame.corner_fade(len(RAMP), STOPS) == frame.EDGE
|
||||||
|
|
||||||
|
|
||||||
def test_only_the_top_right_corner_fades_on_the_top_edge() -> None:
|
def test_only_the_top_right_corner_fades_on_the_top_edge() -> None:
|
||||||
spans = styles_of(frame.top(30))
|
spans = styles_of(frame.top(30, stops=STOPS))
|
||||||
assert spans[0] == ("╭", "bright_black") # plain grey corner
|
assert spans[0] == ("╭", frame.EDGE) # plain grey corner
|
||||||
# the fade runs into the top-right corner: cyan, bright cyan, light grey
|
# the fade runs into the top-right corner, brightest cell last
|
||||||
assert [style for _text, style in spans[-5:-1]] == [
|
assert [style for _text, style in spans[-4:-1]] == [RAMP[3], RAMP[2], RAMP[1]]
|
||||||
"bright_black",
|
assert spans[-1] == ("╮", f"bold {HOT}")
|
||||||
"cyan",
|
|
||||||
"bright_cyan",
|
|
||||||
"white",
|
|
||||||
]
|
|
||||||
assert spans[-1] == ("╮", "bold bright_white")
|
|
||||||
|
|
||||||
|
|
||||||
def test_only_the_bottom_left_corner_fades_on_the_bottom_edge() -> None:
|
def test_only_the_bottom_left_corner_fades_on_the_bottom_edge() -> None:
|
||||||
spans = styles_of(frame.bottom(30))
|
spans = styles_of(frame.bottom(30, STOPS))
|
||||||
assert spans[0] == ("╰", "bold bright_white")
|
assert spans[0] == ("╰", f"bold {HOT}")
|
||||||
assert [style for _text, style in spans[1:4]] == ["white", "bright_cyan", "cyan"]
|
assert [style for _text, style in spans[1:4]] == [RAMP[1], RAMP[2], RAMP[3]]
|
||||||
assert spans[-1] == ("╯", "bright_black") # plain grey corner
|
assert spans[-1] == ("╯", frame.EDGE) # plain grey corner
|
||||||
|
|
||||||
|
|
||||||
def test_the_sides_glow_towards_their_own_corner_only() -> None:
|
def test_the_sides_glow_towards_their_own_corner_only() -> None:
|
||||||
# left column fades up from the bottom-left corner
|
# left column fades up from the bottom-left corner
|
||||||
assert [frame.side_style(row, 10, "left") for row in range(10)] == [
|
assert [frame.side_style(row, 10, "left", STOPS) for row in range(10)] == [
|
||||||
"bright_black",
|
frame.EDGE,
|
||||||
"bright_black",
|
frame.EDGE,
|
||||||
"bright_black",
|
frame.EDGE,
|
||||||
"bright_black",
|
frame.EDGE,
|
||||||
"bright_black",
|
frame.EDGE,
|
||||||
"bright_black",
|
frame.EDGE,
|
||||||
"bright_black",
|
frame.EDGE,
|
||||||
"cyan",
|
RAMP[3],
|
||||||
"bright_cyan",
|
RAMP[2],
|
||||||
"white",
|
RAMP[1],
|
||||||
]
|
]
|
||||||
# right column fades down from the top-right corner
|
# right column fades down from the top-right corner
|
||||||
assert [frame.side_style(row, 10, "right") for row in range(10)] == [
|
assert [frame.side_style(row, 10, "right", STOPS) for row in range(10)] == [
|
||||||
"white",
|
RAMP[1],
|
||||||
"bright_cyan",
|
RAMP[2],
|
||||||
"cyan",
|
RAMP[3],
|
||||||
"bright_black",
|
frame.EDGE,
|
||||||
"bright_black",
|
frame.EDGE,
|
||||||
"bright_black",
|
frame.EDGE,
|
||||||
"bright_black",
|
frame.EDGE,
|
||||||
"bright_black",
|
frame.EDGE,
|
||||||
"bright_black",
|
frame.EDGE,
|
||||||
"bright_black",
|
frame.EDGE,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_the_title_sits_after_the_left_fade() -> None:
|
def test_the_title_sits_after_the_left_fade() -> None:
|
||||||
line = frame.top(30, "STATIONS")
|
line = frame.top(30, "STATIONS", STOPS)
|
||||||
assert line.plain.startswith("╭─ STATIONS ─")
|
assert line.plain.startswith("╭─ STATIONS ─")
|
||||||
assert "STATIONS" in line.plain
|
assert "STATIONS" in line.plain
|
||||||
# a pane too narrow for the title drops it rather than truncating
|
# a pane too narrow for the title drops it rather than truncating
|
||||||
assert "STATIONS" not in frame.top(10, "STATIONS").plain
|
assert "STATIONS" not in frame.top(10, "STATIONS", STOPS).plain
|
||||||
|
|
||||||
|
|
||||||
def test_wrap_frames_the_content_on_every_row() -> None:
|
def test_wrap_frames_the_content_on_every_row() -> None:
|
||||||
content = [Text("hello"), Text("world")]
|
content = [Text("hello"), Text("world")]
|
||||||
group = frame.wrap(content, 20, 6, "PANE")
|
group = frame.wrap(content, 20, 6, "PANE", STOPS)
|
||||||
lines = [line.plain for line in group.renderables]
|
lines = [line.plain for line in group.renderables]
|
||||||
assert len(lines) == 6
|
assert len(lines) == 6
|
||||||
assert lines[0].startswith("╭") and lines[-1].startswith("╰")
|
assert lines[0].startswith("╭") and lines[-1].startswith("╰")
|
||||||
assert all(len(line) == 20 for line in lines)
|
assert all(len(line) == 20 for line in lines)
|
||||||
assert all(line.startswith("│") and line.endswith("│") for line in lines[1:-1])
|
assert all(line.startswith("│") and line.endswith("│") for line in lines[1:-1])
|
||||||
assert lines[1].startswith("│hello")
|
assert lines[1].startswith("│hello")
|
||||||
|
|
||||||
|
|
||||||
|
def test_content_wider_than_the_pane_is_trimmed_not_spilled() -> None:
|
||||||
|
"""A pane must never draw over its neighbour."""
|
||||||
|
long_line = Text("x" * 40)
|
||||||
|
group = frame.wrap([long_line], 20, 4, "PANE", STOPS)
|
||||||
|
lines = [line.plain for line in group.renderables]
|
||||||
|
assert all(len(line) == 20 for line in lines)
|
||||||
|
assert lines[1].endswith("│")
|
||||||
|
# the original renderable is left untouched
|
||||||
|
assert long_line.plain == "x" * 40
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_short_pane_still_glows_in_its_bottom_left_corner() -> None:
|
||||||
|
"""The fade follows the pane's height, not the number of content rows."""
|
||||||
|
group = frame.wrap([Text("one line")], 20, 6, stops=STOPS)
|
||||||
|
lines = list(group.renderables)
|
||||||
|
# rows 1-4 are the content box; the fade climbs its last three rows
|
||||||
|
assert str(lines[1].spans[0].style) == frame.EDGE
|
||||||
|
assert [str(line.spans[0].style) for line in lines[2:5]] == [
|
||||||
|
RAMP[3],
|
||||||
|
RAMP[2],
|
||||||
|
RAMP[1],
|
||||||
|
]
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
"""The colour palettes: sixteen ladders of ANSI names, and the `c` key."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import html
|
||||||
|
import re
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from beefy_player import config
|
||||||
|
from beefy_player.app import BeefyPlayerApp
|
||||||
|
from beefy_player.palettes import (
|
||||||
|
DEFAULT_PALETTE,
|
||||||
|
PALETTE_ORDER,
|
||||||
|
PALETTES,
|
||||||
|
brightness,
|
||||||
|
palette_stops,
|
||||||
|
)
|
||||||
|
from beefy_player.widgets import frame
|
||||||
|
from beefy_player.widgets.help import HelpScreen
|
||||||
|
from beefy_player.widgets.station_list import StationList
|
||||||
|
|
||||||
|
ANSI_NAMES = {
|
||||||
|
"black",
|
||||||
|
"red",
|
||||||
|
"green",
|
||||||
|
"yellow",
|
||||||
|
"blue",
|
||||||
|
"magenta",
|
||||||
|
"cyan",
|
||||||
|
"white",
|
||||||
|
"bright_black",
|
||||||
|
"bright_red",
|
||||||
|
"bright_green",
|
||||||
|
"bright_yellow",
|
||||||
|
"bright_blue",
|
||||||
|
"bright_magenta",
|
||||||
|
"bright_cyan",
|
||||||
|
"bright_white",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_there_are_sixteen_palettes_of_ansi_names() -> None:
|
||||||
|
assert len(PALETTES) == 16
|
||||||
|
assert len(PALETTE_ORDER) == 16
|
||||||
|
assert len(set(PALETTE_ORDER)) == 16
|
||||||
|
assert DEFAULT_PALETTE in PALETTES
|
||||||
|
|
||||||
|
for name, stops in PALETTES.items():
|
||||||
|
assert len(stops) >= 3, name
|
||||||
|
assert set(stops) <= ANSI_NAMES, (name, set(stops) - ANSI_NAMES)
|
||||||
|
# a ladder, not a list: the quiet end must differ from the hot end
|
||||||
|
assert stops[0] != stops[-1], name
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_broken_palette_name_falls_back_to_the_default() -> None:
|
||||||
|
assert palette_stops("mono") == PALETTES["mono"]
|
||||||
|
assert palette_stops(" MONO ") == PALETTES["mono"]
|
||||||
|
assert palette_stops("no-such-palette") == PALETTES[DEFAULT_PALETTE]
|
||||||
|
assert palette_stops(None) == PALETTES[DEFAULT_PALETTE]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_config_remembers_the_palette() -> None:
|
||||||
|
assert config.Config.load().palette == DEFAULT_PALETTE
|
||||||
|
config.Config(palette="ember").save()
|
||||||
|
assert config.Config.load().palette == "ember"
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unknown_palette_in_the_config_is_ignored() -> None:
|
||||||
|
config.CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
config.CONFIG_PATH.write_text('{"palette": "chartreuse"}')
|
||||||
|
assert config.Config.load().palette == DEFAULT_PALETTE
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_the_c_key_cycles_and_remembers_the_palette() -> None:
|
||||||
|
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
|
||||||
|
async with app.run_test(size=(100, 30)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
start = app.palette
|
||||||
|
assert start == DEFAULT_PALETTE
|
||||||
|
|
||||||
|
await pilot.press("c")
|
||||||
|
await pilot.pause()
|
||||||
|
expected = PALETTE_ORDER[(PALETTE_ORDER.index(start) + 1) % len(PALETTE_ORDER)]
|
||||||
|
assert app.palette == expected
|
||||||
|
assert app.config.palette == expected
|
||||||
|
assert "palette" in app.notice
|
||||||
|
|
||||||
|
# the choice survives a restart
|
||||||
|
config.Config(palette=app.palette).save()
|
||||||
|
assert config.Config.load().palette == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_the_pane_corners_follow_the_palette() -> None:
|
||||||
|
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
|
||||||
|
async with app.run_test(size=(100, 30)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
pane = app.query_one(StationList)
|
||||||
|
|
||||||
|
app.palette = "mono"
|
||||||
|
mono = [str(span.style) for span in pane.render().renderables[0].spans]
|
||||||
|
app.palette = "ember"
|
||||||
|
ember = [str(span.style) for span in pane.render().renderables[0].spans]
|
||||||
|
|
||||||
|
assert mono != ember
|
||||||
|
assert any("bright_white" in style for style in mono)
|
||||||
|
# ember's hot end is white too, but its quiet end is not
|
||||||
|
assert any(PALETTES["ember"][-2] in style for style in ember)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_the_spectrum_rows_use_the_active_palette() -> None:
|
||||||
|
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
|
||||||
|
async with app.run_test(size=(100, 30)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
spectrum = app.query_one("SpectrumPanel")
|
||||||
|
|
||||||
|
def colours(styles: list[str]) -> set[str]:
|
||||||
|
return {
|
||||||
|
style.replace("dim ", "").replace("bold ", "").strip()
|
||||||
|
for style in styles
|
||||||
|
}
|
||||||
|
|
||||||
|
app.palette = "mono"
|
||||||
|
spectrum.refresh()
|
||||||
|
await pilot.pause()
|
||||||
|
assert colours(spectrum._row_palette(12, 0, False)) <= set(PALETTES["mono"])
|
||||||
|
|
||||||
|
app.palette = "neon"
|
||||||
|
spectrum.refresh()
|
||||||
|
await pilot.pause()
|
||||||
|
assert colours(spectrum._row_palette(12, 0, False)) <= set(PALETTES["neon"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_corner_fade_starts_at_the_brightest_stop() -> None:
|
||||||
|
stops = PALETTES["spectrum"]
|
||||||
|
ramp = frame.fade_ramp(stops)
|
||||||
|
assert frame.corner_fade(0, stops) == ramp[0]
|
||||||
|
assert frame.corner_fade(1, stops) == ramp[1]
|
||||||
|
# the fade is short: past the ramp it is just the border grey
|
||||||
|
assert frame.corner_fade(len(ramp), stops) == frame.EDGE
|
||||||
|
# spectrum's hot end is red, which is dimmer than the yellow before it;
|
||||||
|
# the corner still gets the bright colour
|
||||||
|
assert ramp[0] != stops[-1]
|
||||||
|
assert ramp[-1] == stops[-1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_palette_fades_from_bright_to_dim() -> None:
|
||||||
|
"""No palette may put a brighter cell further from the corner."""
|
||||||
|
for name, stops in PALETTES.items():
|
||||||
|
ramp = frame.fade_ramp(stops)
|
||||||
|
assert ramp, name
|
||||||
|
ranks = [brightness(colour) for colour in ramp]
|
||||||
|
assert ranks == sorted(ranks, reverse=True), (name, ramp)
|
||||||
|
assert set(ramp) <= set(stops), name
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_the_help_scrolls_when_the_terminal_is_short() -> None:
|
||||||
|
"""The help page is ~56 rows: it must be readable on a 24-row screen."""
|
||||||
|
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
|
||||||
|
async with app.run_test(size=(80, 24)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
await pilot.press("question_mark")
|
||||||
|
await pilot.pause()
|
||||||
|
body = app.screen.query_one("#help")
|
||||||
|
assert body.size.height < body.virtual_size.height
|
||||||
|
assert body.max_scroll_y > 0
|
||||||
|
assert body.scroll_y == 0
|
||||||
|
|
||||||
|
await pilot.press("pagedown")
|
||||||
|
await pilot.pause()
|
||||||
|
assert body.scroll_y > 0
|
||||||
|
|
||||||
|
await pilot.press("end")
|
||||||
|
await pilot.pause()
|
||||||
|
assert body.scroll_y == body.max_scroll_y
|
||||||
|
|
||||||
|
await pilot.press("home")
|
||||||
|
await pilot.pause()
|
||||||
|
assert body.scroll_y == 0
|
||||||
|
|
||||||
|
await pilot.press("x")
|
||||||
|
await pilot.pause()
|
||||||
|
assert not isinstance(app.screen, HelpScreen)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_tall_terminal_needs_no_scrolling() -> None:
|
||||||
|
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
|
||||||
|
async with app.run_test(size=(120, 70)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
await pilot.press("question_mark")
|
||||||
|
await pilot.pause()
|
||||||
|
body = app.screen.query_one("#help")
|
||||||
|
assert body.max_scroll_y == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_the_help_lists_every_palette_and_marks_the_active_one() -> None:
|
||||||
|
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
|
||||||
|
async with app.run_test(size=(120, 64)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
app.palette = "ocean"
|
||||||
|
await pilot.press("question_mark")
|
||||||
|
await pilot.pause()
|
||||||
|
text = html.unescape(app.export_screenshot()).replace("\xa0", " ")
|
||||||
|
text = re.sub(r"<[^>]+>", "", text)
|
||||||
|
|
||||||
|
assert "PALETTES" in text
|
||||||
|
assert "c cycles them" in text
|
||||||
|
for name in PALETTE_ORDER:
|
||||||
|
assert name in text, name
|
||||||
|
assert "● ocean" in text
|
||||||
|
# each palette shows a swatch of eight blocks
|
||||||
|
assert "████████" in text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_the_help_footer_attributes_the_build() -> None:
|
||||||
|
"""One line: the version and the project URL — no credits essay."""
|
||||||
|
from beefy_player import __version__
|
||||||
|
|
||||||
|
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
|
||||||
|
async with app.run_test(size=(110, 70)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
await pilot.press("question_mark")
|
||||||
|
await pilot.pause()
|
||||||
|
body = app.screen.query_one("#help")
|
||||||
|
body.scroll_end(animate=False)
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
text = html.unescape(app.export_screenshot()).replace("\xa0", " ")
|
||||||
|
text = re.sub(r"<[^>]+>", "", text)
|
||||||
|
|
||||||
|
assert f"beefy player {__version__}" in text
|
||||||
|
assert "https://tildeforge.dev/beefcarver/beefy-player" in text
|
||||||
|
# the old explanatory blurb is gone
|
||||||
|
assert "playerctl" not in text
|
||||||
|
assert "parec" not in text
|
||||||
|
assert "Stations and song history come from" not in text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("slug", ["bassdrive", "somafm"])
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_the_help_has_no_pane_explainer(slug: str) -> None:
|
||||||
|
"""No catalogue gets a paragraph about its left pane."""
|
||||||
|
app = BeefyPlayerApp(provider=slug, autoplay=False, offline=True, audio=False)
|
||||||
|
async with app.run_test(size=(110, 60)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
await pilot.press("question_mark")
|
||||||
|
await pilot.pause()
|
||||||
|
text = html.unescape(app.export_screenshot()).replace("\xa0", " ")
|
||||||
|
text = re.sub(r"<[^>]+>", "", text)
|
||||||
|
assert "is a single stream" not in text
|
||||||
|
assert "multi-station catalogues" not in text
|
||||||
|
assert "CATALOGUES" in text
|
||||||
@@ -2,13 +2,13 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import queue
|
import queue
|
||||||
|
import sys
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from beefy_player.player import Player
|
from beefy_player.player import Player
|
||||||
|
|
||||||
FAKE_MPV = '''#!/usr/bin/env python3
|
FAKE_MPV = f"#!{sys.executable}\n" + '''import json, os, socket, sys, time
|
||||||
import json, os, socket, sys, time
|
|
||||||
|
|
||||||
sock_path = None
|
sock_path = None
|
||||||
volume = 70
|
volume = 70
|
||||||
|
|||||||
+115
-4
@@ -66,25 +66,136 @@ def test_hls_streams_survive_as_direct_urls() -> None:
|
|||||||
assert station.format_label == "aac · 64k"
|
assert station.format_label == "aac · 64k"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_stations_queries_all_searchable_fields() -> None:
|
||||||
|
entries = {
|
||||||
|
"name": {**ENTRY, "stationuuid": "name", "name": "Jazz Name"},
|
||||||
|
"tag": {**ENTRY, "stationuuid": "tag", "name": "Zed Jazz Tag"},
|
||||||
|
"country": {**ENTRY, "stationuuid": "country", "name": "Country Jazz"},
|
||||||
|
"language": {**ENTRY, "stationuuid": "language", "name": "Language Jazz"},
|
||||||
|
}
|
||||||
|
seen: list[dict[str, str]] = []
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
params = dict(request.url.params)
|
||||||
|
seen.append(params)
|
||||||
|
field = next(name for name in api._SEARCH_FIELDS if name in params)
|
||||||
|
payload = [entries[field]]
|
||||||
|
if field in ("name", "tag"):
|
||||||
|
payload.append(ENTRY) # the same station may match twice
|
||||||
|
return httpx.Response(200, json=payload)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||||
|
stations = await api.search_stations(client, "jazz")
|
||||||
|
|
||||||
|
assert {station.id for station in stations} == {
|
||||||
|
ENTRY["stationuuid"],
|
||||||
|
"name",
|
||||||
|
"tag",
|
||||||
|
"country",
|
||||||
|
"language",
|
||||||
|
}
|
||||||
|
assert [params["limit"] for params in seen] == [
|
||||||
|
str(api.SEARCH_LIMIT)
|
||||||
|
] * len(api._SEARCH_FIELDS)
|
||||||
|
for params in seen:
|
||||||
|
assert params["hidebroken"] == "true"
|
||||||
|
assert params["order"] == "clickcount"
|
||||||
|
assert params["reverse"] == "true"
|
||||||
|
assert {next(name for name in api._SEARCH_FIELDS if name in params) for params in seen} == set(
|
||||||
|
api._SEARCH_FIELDS
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_stations_ignores_a_failed_field(monkeypatch) -> None:
|
||||||
|
async def fake_fetch_json(client, url):
|
||||||
|
if "name=jazz" in url:
|
||||||
|
raise RuntimeError("name search is down")
|
||||||
|
return [ENTRY]
|
||||||
|
|
||||||
|
monkeypatch.setattr(api, "fetch_json", fake_fetch_json)
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
stations = await api.search_stations(client, "jazz")
|
||||||
|
assert [station.id for station in stations] == [ENTRY["stationuuid"]]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_an_empty_search_does_not_hit_the_api() -> None:
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
raise AssertionError("empty queries must not make a request")
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||||
|
assert await api.search_stations(client, " ") == []
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_fetch_stations_caches_the_directory() -> None:
|
async def test_fetch_stations_caches_the_directory() -> None:
|
||||||
payload = [ENTRY, {**ENTRY, "stationuuid": "x", "name": "Another"}]
|
payload = [ENTRY, {**ENTRY, "stationuuid": "x", "name": "Another"}]
|
||||||
|
|
||||||
def handler(request: httpx.Request) -> httpx.Response:
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
assert "hidebroken=true" in str(request.url)
|
params = dict(request.url.params)
|
||||||
assert "limit=300" in str(request.url)
|
assert params["hidebroken"] == "true"
|
||||||
|
assert params["limit"] == str(api.PAGE_SIZE)
|
||||||
|
assert params["offset"] == "0"
|
||||||
return httpx.Response(200, json=payload)
|
return httpx.Response(200, json=payload)
|
||||||
|
|
||||||
provider = RadioBrowserProvider()
|
provider = RadioBrowserProvider()
|
||||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||||
stations = await provider.fetch_stations(client)
|
stations = await provider.fetch_stations(client)
|
||||||
assert [station.title for station in stations] == ["Another", "France Info"]
|
assert [station.title for station in stations] == ["France Info", "Another"]
|
||||||
assert [station.title for station in provider.load_cached_stations()] == [
|
assert [station.title for station in provider.load_cached_stations()] == [
|
||||||
"Another",
|
|
||||||
"France Info",
|
"France Info",
|
||||||
|
"Another",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_station_page_continues_in_catalogue_order() -> None:
|
||||||
|
first = [
|
||||||
|
{**ENTRY, "stationuuid": f"id-{index}", "name": f"Station {index}"}
|
||||||
|
for index in range(api.PAGE_SIZE)
|
||||||
|
]
|
||||||
|
last = [{**ENTRY, "stationuuid": "last", "name": "Last Station"}]
|
||||||
|
seen: list[dict[str, str]] = []
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
params = dict(request.url.params)
|
||||||
|
seen.append(params)
|
||||||
|
return httpx.Response(200, json=first if params["offset"] == "100" else last)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||||
|
page = await api.fetch_station_page(client, 100)
|
||||||
|
end = await api.fetch_station_page(client, 200)
|
||||||
|
|
||||||
|
assert [station.id for station in page.stations] == [
|
||||||
|
f"id-{index}" for index in range(api.PAGE_SIZE)
|
||||||
|
]
|
||||||
|
assert page.next_offset == 200
|
||||||
|
assert end.next_offset is None
|
||||||
|
assert [params["offset"] for params in seen] == ["100", "200"]
|
||||||
|
assert [params["limit"] for params in seen] == [str(api.PAGE_SIZE)] * 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_stations_by_ids_uses_the_uuid_endpoint() -> None:
|
||||||
|
seen: list[dict[str, str]] = []
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
seen.append(dict(request.url.params))
|
||||||
|
return httpx.Response(200, json=[ENTRY])
|
||||||
|
|
||||||
|
station_id = ENTRY["stationuuid"]
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||||
|
stations = await api.fetch_stations_by_ids(
|
||||||
|
client, ["defcon", station_id, station_id]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [station.id for station in stations] == [station_id]
|
||||||
|
assert len(seen) == 1
|
||||||
|
assert seen[0]["uuids"] == station_id
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_a_failed_fetch_does_not_kill_the_catalogue() -> None:
|
async def test_a_failed_fetch_does_not_kill_the_catalogue() -> None:
|
||||||
def handler(request: httpx.Request) -> httpx.Response:
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
|||||||
@@ -164,3 +164,25 @@ def test_upcoming_rows_count_the_shows_they_had_to_hide() -> None:
|
|||||||
listed = sum(1 for line in lines if "show " in line)
|
listed = sum(1 for line in lines if "show " in line)
|
||||||
hidden = int(lines[-1].split("+")[1].split()[0])
|
hidden = int(lines[-1].split("+")[1].split()[0])
|
||||||
assert listed + hidden == 30
|
assert listed + hidden == 30
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_long_show_names_stay_inside_the_pane() -> None:
|
||||||
|
"""Regression: the schedule's row maths used to overshoot by two cells."""
|
||||||
|
app = BeefyPlayerApp(provider="tilderadio", offline=True, audio=False)
|
||||||
|
async with app.run_test(size=(80, 30)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
app.schedule = [
|
||||||
|
Broadcast(
|
||||||
|
name="A Very Long Show Name That Would Never Fit In The Pane",
|
||||||
|
start=now + timedelta(hours=index + 1),
|
||||||
|
end=now + timedelta(hours=index + 2),
|
||||||
|
)
|
||||||
|
for index in range(6)
|
||||||
|
]
|
||||||
|
app.schedule_loaded = True
|
||||||
|
panel = app.query_one(SchedulePanel)
|
||||||
|
rows = [line.plain for line in panel.render().renderables]
|
||||||
|
assert all(len(row) == panel.size.width for row in rows)
|
||||||
|
assert all(row.startswith(("│", "╭", "╰")) for row in rows)
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -8,8 +9,7 @@ import numpy as np
|
|||||||
from beefy_player import spectrum as spectrum_module
|
from beefy_player import spectrum as spectrum_module
|
||||||
from beefy_player.spectrum import Frame, SpectrumEngine
|
from beefy_player.spectrum import Frame, SpectrumEngine
|
||||||
|
|
||||||
PCM_SCRIPT = """#!/usr/bin/env python3
|
PCM_SCRIPT = f"#!{sys.executable}\n" + """import math, struct, sys, time
|
||||||
import math, struct, sys, time
|
|
||||||
sr = 44100
|
sr = 44100
|
||||||
samples = [int(0.6 * 32767 * math.sin(2 * math.pi * 1000 * i / sr)) for i in range(4096)]
|
samples = [int(0.6 * 32767 * math.sin(2 * math.pi * 1000 * i / sr)) for i in range(4096)]
|
||||||
chunk = struct.pack("<%dh" % len(samples), *samples)
|
chunk = struct.pack("<%dh" % len(samples), *samples)
|
||||||
@@ -303,7 +303,7 @@ def test_tap_audio_is_downmixed_and_retuned() -> None:
|
|||||||
def test_dead_monitor_falls_back_instead_of_flat_bars(tmp_path) -> None:
|
def test_dead_monitor_falls_back_instead_of_flat_bars(tmp_path) -> None:
|
||||||
script = tmp_path / "silent-parec"
|
script = tmp_path / "silent-parec"
|
||||||
script.write_text(
|
script.write_text(
|
||||||
"#!/usr/bin/env python3\n"
|
f"#!{sys.executable}\n"
|
||||||
"import sys\n"
|
"import sys\n"
|
||||||
"block = b'\\0' * 4096\n"
|
"block = b'\\0' * 4096\n"
|
||||||
"for _ in range(1000):\n"
|
"for _ in range(1000):\n"
|
||||||
@@ -333,7 +333,7 @@ def test_monitor_survives_silence_after_audio(tmp_path, monkeypatch) -> None:
|
|||||||
# replaced by the drifting stream tap) for the rest of the session.
|
# replaced by the drifting stream tap) for the rest of the session.
|
||||||
script = tmp_path / "parec"
|
script = tmp_path / "parec"
|
||||||
script.write_text(
|
script.write_text(
|
||||||
"#!/usr/bin/env python3\n"
|
f"#!{sys.executable}\n"
|
||||||
"import math, struct, sys, time\n"
|
"import math, struct, sys, time\n"
|
||||||
"sr = 44100\n"
|
"sr = 44100\n"
|
||||||
"n = 2048\n"
|
"n = 2048\n"
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "beefy-player"
|
name = "beefy-player"
|
||||||
version = "1.0.0"
|
version = "1.2.2"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "dbus-next", marker = "sys_platform == 'linux'" },
|
{ name = "dbus-next", marker = "sys_platform == 'linux'" },
|
||||||
|
|||||||
Reference in new issue
Block a user