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 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 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. 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 - **Theme-aware** — every accent is emitted as native ANSI (not RGB), and the
background is the terminal default (`SGR 49`), so transparent terminals stay background is the terminal default (`SGR 49`), so transparent terminals stay
transparent and the TUI adopts your shell/terminal colour scheme. 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 | | `/` | filter by title, genre, description |
| `esc` | clear filter / close search | | `esc` | clear filter / close search |
| `v` | visualiser: mirror → bars → wave → waterfall → radial → braille | | `v` | visualiser: mirror → bars → wave → waterfall → radial → braille |
| `c` | colour palette (16 of them, remembered) |
| `r` | refresh stations, schedule + song history | | `r` | refresh stations, schedule + song history |
| `p` | switch station catalogue (SomaFM / tilderadio)| | `p` | switch station catalogue (SomaFM / tilderadio)|
| `g` / `G` | top / bottom | | `g` / `G` | top / bottom |
+1 -1
View File
@@ -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.1.0"
+32 -1
View File
@@ -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
@@ -92,6 +94,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"),
@@ -147,6 +150,8 @@ class BeefyPlayerApp(App[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 = ""
@@ -956,6 +961,20 @@ 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 = ""
@@ -972,12 +991,23 @@ 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", "stations", "schedule", "bandcamp", "artwork"):
with contextlib.suppress(Exception): with contextlib.suppress(Exception):
@@ -1051,7 +1081,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()
+10
View File
@@ -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()
+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 { #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;
} }
+27 -2
View File
@@ -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
+12 -13
View File
@@ -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)
+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 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. 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
@@ -13,26 +16,30 @@ 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
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: def corner_fade(distance: int, stops: tuple[str, ...] = ()) -> str:
"""Colour ``distance`` cells away from a corner (0 is the corner itself).""" """Colour ``distance`` cells out from a corner; 0 is the corner glyph.
if 0 <= distance < len(CORNER_GRADIENT):
return CORNER_GRADIENT[distance]
return EDGE
The fade is short — four cells — and walks backwards down the palette from
def side_style(row: int, rows: int, side: str = "left") -> str: its hot end, so the corner glows in the palette's brightest colour and then
"""Colour of a side cell: only two corners fade, so only one end glows.""" hands over to the plain border grey.
if side == "right": """
return fade(row + 1) # fades down from the top-right corner if not stops:
return fade(rows - row) # fades up from the bottom-left corner 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]: 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) 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 +69,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,29 +82,38 @@ 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)
@@ -105,24 +121,29 @@ def wrap(
inner_width, inner_height = inner_size(width, height) inner_width, inner_height = inner_size(width, height)
rows = len(lines[:inner_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, rows, "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, rows, "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 +160,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 +179,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)]
) )
+11 -4
View File
@@ -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)
+82 -53
View File
@@ -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"),
@@ -35,6 +40,7 @@ HELP_ROWS: tuple[tuple[str, str], ...] = (
"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)
+29 -17
View File
@@ -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
+4 -2
View File
@@ -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,
) )
+13 -8
View File
@@ -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"
+4 -2
View File
@@ -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,6 +83,7 @@ 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
@@ -93,6 +94,7 @@ class StationList(Widget):
width, width,
height, height,
self.border_title, self.border_title,
stops,
) )
total = len(view) total = len(view)
@@ -167,4 +169,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)
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "beefy-player" 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)" 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"
+51
View File
@@ -9,6 +9,7 @@ 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
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
@@ -259,3 +260,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)
+57 -38
View File
@@ -4,8 +4,13 @@ from __future__ import annotations
from rich.text import Text from rich.text import Text
from beefy_player.palettes import PALETTES
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"]
HOT = STOPS[-1] # the corner colour
def styles_of(line: Text) -> list[tuple[str, str]]: def styles_of(line: Text) -> list[tuple[str, str]]:
return [ 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: 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, hot end last
assert [style for _text, style in spans[-5:-1]] == [ assert [style for _text, style in spans[-4:-1]] == [
"bright_black", STOPS[-4],
"cyan", STOPS[-3],
"bright_cyan", STOPS[-2],
"white",
] ]
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: 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]] == [
assert spans[-1] == ("╯", "bright_black") # plain grey corner 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: 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", STOPS[-4],
"bright_cyan", STOPS[-3],
"white", STOPS[-2],
] ]
# 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", STOPS[-2],
"bright_cyan", STOPS[-3],
"cyan", STOPS[-4],
"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
+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) 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)
Generated
+1 -1
View File
@@ -32,7 +32,7 @@ wheels = [
[[package]] [[package]]
name = "beefy-player" name = "beefy-player"
version = "1.0.0" version = "1.1.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "dbus-next", marker = "sys_platform == 'linux'" }, { name = "dbus-next", marker = "sys_platform == 'linux'" },