Compare commits

..
3 Commits
21 changed files with 776 additions and 184 deletions

No files matched your search

+6
View File
@@ -84,6 +84,11 @@ implemented here from public endpoints and open protocols.
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.
- **Sixteen palettes** — every colour comes from one ladder of ANSI names per
theme: the spectrum analyser, the album-art mosaic, the provider wordmarks
and the pane corners all draw from it. `c` cycles spectrum, rainbow, ocean,
ember, mint, sunset, violet, acid, neon, sand, blood, forest, ice, steel,
candy and mono; the choice is remembered.
- **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.
@@ -350,6 +355,7 @@ The UI wants roughly **84×24**; it also works smaller, just less pretty.
| `/` | filter by title, genre, description |
| `esc` | clear filter / close search |
| `v` | visualiser: mirror → bars → wave → waterfall → radial → braille |
| `c` | colour palette (16 of them, remembered) |
| `r` | refresh stations, schedule + song history |
| `p` | switch station catalogue (SomaFM / tilderadio)|
| `g` / `G` | top / bottom |
+1 -1
View File
@@ -1,3 +1,3 @@
"""Beefy Player — a flashy terminal player for internet radio."""
__version__ = "1.0.0"
__version__ = "1.1.0"
+32 -1
View File
@@ -26,10 +26,12 @@ from textual.widgets import Input
from . import artwork, bandcamp, config
from .mediasession import create_media_session, media_actions_queue
from .models import Broadcast, Station, parse_last_playing
from .palettes import PALETTE_ORDER
from .player import Player
from .providers import get_provider
from .providers.base import is_direct_stream, make_client
from .spectrum import SpectrumEngine
from .widgets.art import ArtPane
from .widgets.header import HeaderBar
from .widgets.help import HelpScreen
from .widgets.providers import ProviderScreen
@@ -92,6 +94,7 @@ class BeefyPlayerApp(App[None]):
Binding("slash", "search", "filter"),
Binding("escape", "escape", "close", show=False),
Binding("v", "visualizer", "visualiser"),
Binding("c", "palette", "palette"),
Binding("r", "refresh", "refresh"),
Binding("p", "provider", "catalogue"),
Binding("question_mark", "help", "help"),
@@ -147,6 +150,8 @@ class BeefyPlayerApp(App[None]):
self.playing_state = "stopped"
self.volume = self.config.volume
self.visualizer = self.config.visualizer
#: Colour palette name (see :mod:`beefy_player.palettes`).
self.palette = self.config.palette
self.tick_count = 0
self.notice = ""
@@ -956,6 +961,20 @@ class BeefyPlayerApp(App[None]):
self._set_notice(f"visualiser: {self.visualizer}", 2)
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:
self._last_history_fetch = 0.0
self._bandcamp_key = ""
@@ -972,12 +991,23 @@ class BeefyPlayerApp(App[None]):
self._fetch_history(self.current.id)
def action_provider(self) -> None:
self._suspend_artwork()
self.push_screen(ProviderScreen(self.provider.id), self._provider_chosen)
def _provider_chosen(self, provider_id: str | None) -> None:
self._resume_artwork()
if 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:
for group in ("stream", "history", "stations", "schedule", "bandcamp", "artwork"):
with contextlib.suppress(Exception):
@@ -1051,7 +1081,8 @@ class BeefyPlayerApp(App[None]):
self._refresh_schedule()
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:
self._cleanup()
+10
View File
@@ -26,6 +26,7 @@ from pathlib import Path
from typing import Any
from . import __version__
from .palettes import DEFAULT_PALETTE, PALETTES
#: Directory name for config, cache and runtime files.
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")
#: Colour palettes the UI can draw with (see :mod:`beefy_player.palettes`).
PALETTE_NAMES = tuple(PALETTES)
def channels_cache_path(provider_id: str) -> Path:
"""Where a catalogue caches its last good station list.
@@ -141,6 +145,8 @@ class Config:
last_stations: dict[str, str] = field(default_factory=dict)
volume: int = 70
visualizer: str = "both"
#: Colour palette name — spectrum, ocean, ember, …
palette: str = DEFAULT_PALETTE
autoplay: bool = False
#: Which catalogue to use by default (see :mod:`beefy_player.providers`).
provider: str = "somafm"
@@ -181,6 +187,10 @@ class Config:
if visualizer in VISUALIZERS:
config.visualizer = visualizer
palette = raw.get("palette")
if isinstance(palette, str) and palette in PALETTE_NAMES:
config.palette = palette
provider = raw.get("provider")
if isinstance(provider, str) and provider.strip():
config.provider = provider.strip().lower()
+88
View File
@@ -0,0 +1,88 @@
"""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"),
}
#: 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])
+18
View File
@@ -178,19 +178,37 @@ ModalScreen {
#providers {
color: ansi_default;
width: 66;
max-width: 100%;
height: auto;
max-height: 100%;
border: round ansi_bright_black;
border-title-color: ansi_default;
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;
}
#help {
color: ansi_default;
width: 78;
max-width: 100%;
height: auto;
max-height: 100%;
border: round ansi_bright_black;
border-title-color: ansi_default;
padding: 1 2;
overflow-y: auto;
scrollbar-size-vertical: 1;
scrollbar-color: ansi_bright_black;
scrollbar-background: ansi_default;
background: ansi_default;
}
+27 -2
View File
@@ -22,7 +22,7 @@ from textual.strip import Strip
from textual.widget import Widget
from .. import artwork
from .common import art_lines
from .common import active_stops, art_lines
_NULL_STYLE = Style()
_NULL_CONTROL = ((ControlType.CURSOR_FORWARD, 0),)
@@ -308,6 +308,8 @@ class ArtPane(Widget):
self._last_data: bytes | None = None
self._last_size: tuple[int, int] = (0, 0)
self._mosaic_key = ""
#: True while a modal is up: the inline image would sit over the menu.
self._suspended = False
# ------------------------------------------------------------------ #
# mosaic background
@@ -320,7 +322,12 @@ class ArtPane(Widget):
return Text("")
width = max(1, self.size.width)
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
@@ -337,9 +344,27 @@ class ArtPane(Widget):
def on_unmount(self) -> None:
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:
# Keep the mosaic behind the image in sync with the current track.
self.refresh()
if self._suspended:
return
app = cast("BeefyPlayerApp", self.app)
station = app.current
+12 -13
View File
@@ -19,23 +19,20 @@ from zoneinfo import ZoneInfo
from rich.cells import cell_len
from rich.text import Text
from ..palettes import (
DEFAULT_PALETTE,
palette_stops,
)
LOCALTIME_PATH = "/etc/localtime"
_UTC_NAMES = {"UTC", "UCT", "GMT", "ETC/UTC", "ETC/UCT", "ETC/GMT", "Z"}
LEVELS = " ▁▂▃▄▅▆▇█"
# Bottom-to-top spectrum colours, picked from the terminal's stock ANSI palette.
SPECTRUM_STOPS = (
"cyan",
"blue",
"magenta",
"bright_magenta",
"yellow",
"bright_yellow",
"red",
)
def active_stops(app: object) -> tuple[str, ...]:
"""The colour ladder the app is currently drawing with."""
return palette_stops(getattr(app, "palette", DEFAULT_PALETTE))
ART_STOPS = ("blue", "magenta", "bright_magenta", "yellow", "cyan", "green")
STATE_STYLE = {
"playing": ("●", "green"),
@@ -182,7 +179,9 @@ def state_glyph(state: str) -> tuple[str, str]:
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.
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)
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
colour = gradient(ART_STOPS, position)
colour = gradient(stops, position)
# Mostly solid blocks with a little texture, like a tiny cover.
glyph = "█" if ((seed + column * 7 + row * 11) % 17) not in (0, 5) else "▓"
line.append(glyph, style=colour)
+63 -41
View File
@@ -1,9 +1,12 @@
"""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
corners can be coloured individually. Two of them — the top right and the
bottom left — run white → light grey → bright cyan → cyan along both arms;
everything else, including the other two corners, is the border grey.
corners can be coloured. Two of them — the top right and the bottom left —
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 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
@@ -13,26 +16,30 @@ from rich.console import Group, RenderableType
from rich.text import Text
from textual.widget import Widget
from ..palettes import DEFAULT_PALETTE, PALETTES
from .common import active_stops
#: The frame itself: one grey, the same grey as the scrollbar thumb.
EDGE = "bright_black"
#: A faded corner runs white → light grey → bright cyan → cyan, then settles
#: back into the border grey.
CORNER_GRADIENT = ("bright_white", "white", "bright_cyan", "cyan")
#: How many cells a corner fade reaches into the edge.
FADE_CELLS = 4
def fade(distance: int) -> str:
"""Colour ``distance`` cells away from a corner (0 is the corner itself)."""
if 0 <= distance < len(CORNER_GRADIENT):
return CORNER_GRADIENT[distance]
return EDGE
def corner_fade(distance: int, stops: tuple[str, ...] = ()) -> str:
"""Colour ``distance`` cells out from a corner; 0 is the corner glyph.
def side_style(row: int, rows: int, side: str = "left") -> str:
"""Colour of a side cell: only two corners fade, so only one end glows."""
if side == "right":
return fade(row + 1) # fades down from the top-right corner
return fade(rows - row) # fades up from the bottom-left corner
The fade is short — four cells — and walks backwards down the palette from
its hot end, so the corner glows in the palette's brightest colour and then
hands over to the plain border grey.
"""
if not stops:
stops = PALETTES[DEFAULT_PALETTE]
distance = max(0, distance)
if distance >= FADE_CELLS:
return EDGE
index = len(stops) - 1 - distance
return stops[index] if index >= 0 else EDGE
def inner_size(width: int, height: int) -> tuple[int, int]:
@@ -40,12 +47,12 @@ def inner_size(width: int, height: int) -> tuple[int, int]:
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 = max(0, width - 2)
if faded_end == "left":
return [("─", fade(column + 1)) for column in range(inner)]
return [("─", fade(inner - column)) for column in range(inner)]
return [("─", corner_fade(column + 1, stops)) 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:
@@ -62,9 +69,9 @@ def _paint(line: Text, cells: list[tuple[str, str]]) -> None:
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)
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:
label = f" {title} "
start = 1
@@ -75,29 +82,38 @@ def top(width: int, title: str = "") -> Text:
line = Text(no_wrap=True)
line.append("╭", style=EDGE)
_paint(line, cells)
line.append("╮", style=f"bold {fade(0)}")
line.append("╮", style=f"bold {corner_fade(0, stops)}")
return line
def bottom(width: int) -> Text:
def bottom(width: int, stops: tuple[str, ...] = ()) -> Text:
width = max(2, width)
line = Text(no_wrap=True)
line.append("╰", style=f"bold {fade(0)}")
_paint(line, _edge_cells(width, "left")) # only the bottom-left fades
line.append("╰", style=f"bold {corner_fade(0, stops)}")
_paint(line, _edge_cells(width, "left", stops)) # only the bottom-left fades
line.append("╯", style=EDGE)
return line
def side() -> Text:
return Text("│", style=EDGE)
def side_style(row: int, rows: int, side: str = "left", stops: tuple[str, ...] = ()) -> str:
"""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:
return Text("│", style=side_style(row, rows, side))
def side_row(
row: int, rows: int, side: str = "left", stops: tuple[str, ...] = ()
) -> Text:
return Text("│", style=side_style(row, rows, side, stops))
def wrap(
lines: list[Text], width: int, height: int, title: str = ""
lines: list[Text],
width: int,
height: int,
title: str = "",
stops: tuple[str, ...] = (),
) -> RenderableType:
"""Frame ``lines`` (already sized for the content box) in a pane."""
width = max(4, width)
@@ -105,24 +121,29 @@ def wrap(
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]):
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.append_text(side_row(index, rows, "left"))
row.append_text(side_row(index, rows, "left", stops))
row.append_text(line)
pad = inner_width - cell_len(line.plain)
if pad > 0:
row.append(" " * pad)
row.append_text(side_row(index, rows, "right"))
row.append_text(side_row(index, rows, "right", stops))
framed.append(row)
while len(framed) < height - 1:
index = len(framed) - 1
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_text(side_row(index, inner_height, "right"))
row.append_text(side_row(index, inner_height, "right", stops))
framed.append(row)
framed.append(bottom(width))
framed.append(bottom(width, stops))
return Group(*framed[:height])
@@ -139,14 +160,14 @@ class FrameTop(Widget):
self.title = title
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):
can_focus = False
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):
@@ -158,6 +179,7 @@ class FrameSide(Widget):
def render(self) -> RenderableType:
rows = max(1, self.size.height)
stops = active_stops(self.app)
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)]
)
+11 -4
View File
@@ -10,7 +10,7 @@ from rich.text import Text
from textual.widget import Widget
from .common import (
SPECTRUM_STOPS,
active_stops,
clock_time,
date_label,
gradient,
@@ -22,7 +22,13 @@ if TYPE_CHECKING:
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."""
if not gradient_fill:
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)
for column, char in enumerate(markup):
position = (column / max(1, width - 1) + index * 0.055) % 1.0
line.append(char, style=f"bold {gradient(SPECTRUM_STOPS, position)}")
line.append(char, style=f"bold {gradient(stops, position)}")
return line
@@ -45,6 +51,7 @@ class HeaderBar(Widget):
hero = tuple(getattr(provider, "hero", ()) or ())
hero_lines = tuple(getattr(provider, "hero_lines", ()) or ())
gradient_fill = bool(getattr(provider, "hero_gradient", False))
stops = active_stops(app)
width = max(24, self.size.width)
height = max(1, self.size.height)
@@ -73,7 +80,7 @@ class HeaderBar(Widget):
lines: list[Text] = []
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
# column short; pad it back or the caption and clock drift left.
short = hero_width - cell_len(line.plain)
+82 -53
View File
@@ -2,23 +2,28 @@
from __future__ import annotations
import sys
import textwrap
from typing import TYPE_CHECKING, cast
import contextlib
from typing import TYPE_CHECKING, ClassVar, cast
from rich.console import Group
from rich.table import Table
from rich.text import Text
from textual.app import ComposeResult
from textual.containers import VerticalScroll
from textual.screen import ModalScreen
from textual.widgets import Static
from .. import __version__
from ..palettes import PALETTE_ORDER, PALETTES
from ..providers import PROVIDERS, available_providers
from .common import SPECTRUM_STOPS, gradient
from .common import active_stops, gradient
if TYPE_CHECKING:
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], ...] = (
("↑ / ↓ j / k", "move through the station list"),
("pgup / pgdn", "page through the station list"),
@@ -35,6 +40,7 @@ HELP_ROWS: tuple[tuple[str, str], ...] = (
"v",
"cycle visualiser: mirror → bars → wave → waterfall → radial → braille",
),
("c", "cycle the colour palette (listed below)"),
("r", "refresh stations, schedule and song history"),
("p", "switch station catalogue (SomaFM / tilderadio)"),
("g / G", "jump to the top / bottom of the list"),
@@ -42,42 +48,10 @@ HELP_ROWS: tuple[tuple[str, str], ...] = (
("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]):
def compose(self) -> ComposeResult:
app = cast("BeefyPlayerApp", self.app)
provider = app.provider
about = (
f"Stations and song history come from {provider.name} "
f"({provider.station_source} + {provider.history_source}).",
) + (ABOUT_MACOS if sys.platform == "darwin" else ABOUT_LINUX)
catalogues = Table.grid(padding=(0, 2), expand=False)
catalogues.add_column(style="bold cyan", no_wrap=True, justify="right")
catalogues.add_column(style="bold", no_wrap=True)
@@ -90,14 +64,39 @@ class HelpScreen(ModalScreen[None]):
marker, slug, f"{catalogue.name} — {pane}"
)
pane_note = Text()
if provider.has_schedule:
pane_note.append(
f"{provider.name} is a single stream, so the left pane is its "
"broadcast schedule; the station filter and favourites only "
"apply to multi-station catalogues.",
style="dim italic",
)
# -- palettes, two per row so sixteen still fit ------------------ #
names = list(PALETTE_ORDER)
name_width = max(len(name) for name in names)
def swatch(name: str) -> Text:
stops = PALETTES[name]
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.append("CATALOGUES", style="bold cyan")
@@ -112,28 +111,58 @@ class HelpScreen(ModalScreen[None]):
table.add_row(key, description)
header = Text()
header.append("◤ ", style=f"bold {gradient(SPECTRUM_STOPS, 0.9)}")
header.append("BEEFY PLAYER", style=f"bold {gradient(SPECTRUM_STOPS, 0.55)}")
header.append(" keyboard & credits", style="dim")
header.append("◤ ", style=f"bold {gradient(active_stops(self.app), 0.9)}")
header.append("BEEFY PLAYER", style=f"bold {gradient(active_stops(self.app), 0.55)}")
header.append(" keyboard shortcuts", style="dim")
header.append(" · ↑ ↓ / pgup pgdn scroll", style="dim")
footer = Text()
for paragraph in about:
for line in textwrap.wrap(paragraph, width=70):
footer.append(line + "\n", style="dim")
# One attribution line: version and where the project lives.
footer = Text(no_wrap=True)
footer.append(f"beefy player {__version__}", style="bold")
footer.append(" · ")
footer.append(
URL, style=f"dim link {URL}"
)
body = Group(
header,
Text(""),
table,
Text(""),
pane_note,
catalogue_title,
catalogues,
Text(""),
palette_title,
*palette_rows,
Text(""),
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:
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)
+29 -17
View File
@@ -5,11 +5,12 @@ from __future__ import annotations
from rich.console import Group
from rich.text import Text
from textual.app import ComposeResult
from textual.containers import VerticalScroll
from textual.screen import ModalScreen
from textual.widgets import Static
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]):
@@ -22,12 +23,19 @@ class ProviderScreen(ModalScreen[str | None]):
self.index = self.slugs.index(current_id) if current_id in self.slugs else 0
def compose(self) -> ComposeResult:
yield Static(self._body(), id="providers")
# 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:
header = Text()
header.append("◤ ", style=f"bold {gradient(SPECTRUM_STOPS, 0.9)}")
header.append("CATALOGUES", style=f"bold {gradient(SPECTRUM_STOPS, 0.55)}")
header.append("◤ ", style=f"bold {gradient(active_stops(self.app), 0.9)}")
header.append("CATALOGUES", style=f"bold {gradient(active_stops(self.app), 0.55)}")
header.append(" · pick a station catalogue", style="dim")
name_width = (
@@ -55,26 +63,20 @@ class ProviderScreen(ModalScreen[str | None]):
f"{truncate(provider.name, name_width - 2):<{name_width}}",
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:
row.append(" ●", style="bold green")
rows.append(row)
if selected:
rows.append(
Text(
" " + truncate(provider.tagline, 54),
style="italic dim",
no_wrap=True,
)
)
current = PROVIDERS.get(self.current_id)
footer = Text()
footer.append("\n")
if current is not None:
footer.append(
f"now: {current.name} — {current.station_source}\n", style="dim"
)
line = truncate(f"now: {current.name} — {current.station_source}", 58)
footer.append(line + "\n", style="dim")
footer.append(
"↑ ↓ choose · enter switch · esc cancel",
style="dim",
@@ -85,7 +87,17 @@ class ProviderScreen(ModalScreen[str | None]):
def _move(self, delta: int) -> None:
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:
key = event.key
+4 -2
View File
@@ -16,7 +16,7 @@ from rich.text import Text
from textual.widget import Widget
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:
from ..app import BeefyPlayerApp
@@ -68,7 +68,7 @@ def _upcoming_rows(
row.append(" ")
row.append(local_time(item.start), style="bold")
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))
if len(rows) > budget:
@@ -94,6 +94,7 @@ class SchedulePanel(Widget):
app = cast("BeefyPlayerApp", self.app)
width = max(12, self.size.width)
height = max(3, self.size.height)
stops = active_stops(app)
width, height = frame.inner_size(width, height)
broadcasts = list(getattr(app, "schedule", ()) or ())
@@ -141,4 +142,5 @@ class SchedulePanel(Widget):
max(12, self.size.width),
max(3, self.size.height),
self.border_title,
stops,
)
+13 -8
View File
@@ -11,9 +11,10 @@ from rich.console import RenderableType
from rich.text import Text
from textual.widget import Widget
from ..palettes import palette_stops
from ..spectrum import Frame, SpectrumEngine
from . import frame
from .common import LEVELS, SPECTRUM_STOPS, gradient
from .common import LEVELS, active_stops, gradient
if TYPE_CHECKING:
from ..app import BeefyPlayerApp
@@ -48,7 +49,9 @@ class SpectrumPanel(Widget):
super().__init__()
self.engine = engine
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
#: even while another mode is showing so switching to it has history.
self._history: deque[tuple[float, ...]] = deque(maxlen=WATERFALL_HISTORY)
@@ -65,6 +68,7 @@ class SpectrumPanel(Widget):
def render(self) -> RenderableType:
app = cast("BeefyPlayerApp", self.app)
snapshot = self.engine.snapshot()
self._stops = active_stops(app)
outer_width, outer_height = max(12, self.size.width), max(4, self.size.height)
width, height = frame.inner_size(outer_width, outer_height)
mode = getattr(app, "visualizer", "both")
@@ -96,7 +100,7 @@ class SpectrumPanel(Widget):
mirrored.reverse()
lines.extend(mirrored)
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
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)
if cached is not None:
return cached
palette: list[str] = []
for row in range(height):
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
if dim:
style = f"dim {style}"
@@ -331,7 +336,7 @@ class SpectrumPanel(Widget):
level = int(value * (len(WATERFALL_LEVELS) - 1))
if level <= 0:
continue
style = gradient(SPECTRUM_STOPS, value)
style = gradient(self._stops, value)
if faded:
style = f"dim {style}"
elif age == 1:
@@ -392,11 +397,11 @@ class SpectrumPanel(Widget):
if depth < 0.06:
grid[row][column] = "█"
styles[row][column] = ("dim " if dim else "") + gradient(
SPECTRUM_STOPS, edge
self._stops, edge
)
elif depth < 0.15:
grid[row][column] = "▓"
styles[row][column] = "dim " + gradient(SPECTRUM_STOPS, edge)
styles[row][column] = "dim " + gradient(self._stops, edge)
elif depth < 0.25:
grid[row][column] = "▒"
styles[row][column] = "dim"
+4 -2
View File
@@ -10,7 +10,7 @@ from rich.text import Text
from textual.widget import Widget
from . import frame
from .common import compact_count, truncate
from .common import active_stops, compact_count, truncate
if TYPE_CHECKING:
from ..app import BeefyPlayerApp
@@ -83,6 +83,7 @@ class StationList(Widget):
app = cast("BeefyPlayerApp", self.app)
width = max(12, self.size.width)
height = max(3, self.size.height)
stops = active_stops(app)
inner_width, inner_height = frame.inner_size(width, height)
view = app.view
@@ -93,6 +94,7 @@ class StationList(Widget):
width,
height,
self.border_title,
stops,
)
total = len(view)
@@ -167,4 +169,4 @@ class StationList(Widget):
)
lines.append(row)
return frame.wrap(lines, width, height, self.border_title)
return frame.wrap(lines, width, height, self.border_title, stops)
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "beefy-player"
version = "1.0.0"
version = "1.1.0"
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"
license = "WTFPL"
+51
View File
@@ -9,6 +9,7 @@ import pytest
from beefy_player import config
from beefy_player.app import BeefyPlayerApp
from beefy_player.models import Song
from beefy_player.widgets.art import ArtPane
from beefy_player.widgets.help import HelpScreen
from beefy_player.widgets.stage import StagePanel
from beefy_player.widgets.station_list import StationList
@@ -259,3 +260,53 @@ async def test_media_session_commands_drive_the_app() -> None:
app.media_actions.put("volume:25")
await pilot.pause(0.2)
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)
+57 -38
View File
@@ -4,8 +4,13 @@ from __future__ import annotations
from rich.text import Text
from beefy_player.palettes import PALETTES
from beefy_player.widgets import frame
#: The original ladder, used as a fixed reference in these tests.
STOPS = PALETTES["spectrum"]
HOT = STOPS[-1] # the corner colour
def styles_of(line: Text) -> list[tuple[str, str]]:
return [
@@ -14,68 +19,82 @@ def styles_of(line: Text) -> list[tuple[str, str]]:
def test_only_the_top_right_corner_fades_on_the_top_edge() -> None:
spans = styles_of(frame.top(30))
assert spans[0] == ("╭", "bright_black") # plain grey corner
# the fade runs into the top-right corner: cyan, bright cyan, light grey
assert [style for _text, style in spans[-5:-1]] == [
"bright_black",
"cyan",
"bright_cyan",
"white",
spans = styles_of(frame.top(30, stops=STOPS))
assert spans[0] == ("╭", frame.EDGE) # plain grey corner
# the fade runs into the top-right corner, hot end last
assert [style for _text, style in spans[-4:-1]] == [
STOPS[-4],
STOPS[-3],
STOPS[-2],
]
assert spans[-1] == ("╮", "bold bright_white")
assert spans[-1] == ("╮", f"bold {HOT}")
def test_only_the_bottom_left_corner_fades_on_the_bottom_edge() -> None:
spans = styles_of(frame.bottom(30))
assert spans[0] == ("╰", "bold bright_white")
assert [style for _text, style in spans[1:4]] == ["white", "bright_cyan", "cyan"]
assert spans[-1] == ("╯", "bright_black") # plain grey corner
spans = styles_of(frame.bottom(30, STOPS))
assert spans[0] == ("╰", f"bold {HOT}")
assert [style for _text, style in spans[1:4]] == [
STOPS[-2],
STOPS[-3],
STOPS[-4],
]
assert spans[-1] == ("╯", frame.EDGE) # plain grey corner
def test_the_sides_glow_towards_their_own_corner_only() -> None:
# left column fades up from the bottom-left corner
assert [frame.side_style(row, 10, "left") for row in range(10)] == [
"bright_black",
"bright_black",
"bright_black",
"bright_black",
"bright_black",
"bright_black",
"bright_black",
"cyan",
"bright_cyan",
"white",
assert [frame.side_style(row, 10, "left", STOPS) for row in range(10)] == [
frame.EDGE,
frame.EDGE,
frame.EDGE,
frame.EDGE,
frame.EDGE,
frame.EDGE,
frame.EDGE,
STOPS[-4],
STOPS[-3],
STOPS[-2],
]
# right column fades down from the top-right corner
assert [frame.side_style(row, 10, "right") for row in range(10)] == [
"white",
"bright_cyan",
"cyan",
"bright_black",
"bright_black",
"bright_black",
"bright_black",
"bright_black",
"bright_black",
"bright_black",
assert [frame.side_style(row, 10, "right", STOPS) for row in range(10)] == [
STOPS[-2],
STOPS[-3],
STOPS[-4],
frame.EDGE,
frame.EDGE,
frame.EDGE,
frame.EDGE,
frame.EDGE,
frame.EDGE,
frame.EDGE,
]
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 "STATIONS" in line.plain
# 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:
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]
assert len(lines) == 6
assert lines[0].startswith("╭") and lines[-1].startswith("╰")
assert all(len(line) == 20 for line in lines)
assert all(line.startswith("│") and line.endswith("│") for line in lines[1:-1])
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
+244
View File
@@ -0,0 +1,244 @@
"""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,
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_walks_back_down_the_ladder() -> None:
stops = PALETTES["spectrum"]
assert frame.corner_fade(0, stops) == stops[-1]
assert frame.corner_fade(1, stops) == stops[-2]
assert frame.corner_fade(3, stops) == stops[-4]
# the fade is short: past four cells it is just the border grey
assert frame.corner_fade(4, stops) == frame.EDGE
@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
+22
View File
@@ -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)
hidden = int(lines[-1].split("+")[1].split()[0])
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)
Generated
+1 -1
View File
@@ -32,7 +32,7 @@ wheels = [
[[package]]
name = "beefy-player"
version = "1.0.0"
version = "1.1.0"
source = { editable = "." }
dependencies = [
{ name = "dbus-next", marker = "sys_platform == 'linux'" },