feat(ui): add catalogue picker, schedule pane and per-provider hero

This commit is contained in:
beefcarver committed 2026-11-03 17:51:27 -05:00
1 parent 058d336a2c
commit b31ad3f6fb
16 files changed
+1211 -65

No files matched your search

+57 -32
View File
@@ -1,4 +1,4 @@
"""Header: logo, clock and a live ticker."""
"""Header: the catalogue's hero, the clock and a live ticker."""
from __future__ import annotations
@@ -21,13 +21,19 @@ from .common import (
if TYPE_CHECKING:
from ..app import BeefyPlayerApp
LOGO = (
"▄▀▀▀ ▄▀▀▄ █▄ ▄█ ▄▀▀▄",
"▀▀▀▄ █ █ █ █ █ █▀▀█",
"▄▄▄▀ ▀▄▄▀ █ █ █ █",
)
MIDDLE = ("SOMA FM RADIO", "LISTENER SUPPORTED", "SAN FRANCISCO")
def _hero_line(markup: str, index: int, width: int, gradient_fill: bool) -> Text:
"""One row of the hero: spectrum-painted glyphs, or the art's own colours."""
if not gradient_fill:
line = Text.from_markup(markup)
line.no_wrap = True
return line
line = Text(no_wrap=True)
for column, char in enumerate(markup):
position = (column / max(1, width - 1) + index * 0.055) % 1.0
line.append(char, style=f"bold {gradient(SPECTRUM_STOPS, position)}")
return line
class HeaderBar(Widget):
@@ -35,54 +41,63 @@ class HeaderBar(Widget):
def render(self) -> RenderableType:
app = cast("BeefyPlayerApp", self.app)
provider = getattr(app, "provider", None)
hero = tuple(getattr(provider, "hero", ()) or ())
hero_lines = tuple(getattr(provider, "hero_lines", ()) or ())
gradient_fill = bool(getattr(provider, "hero_gradient", False))
width = max(24, self.size.width)
height = max(1, self.size.height)
state = app.playing_state
glyph, colour = state_glyph(state)
logo_width = max(cell_len(row) for row in LOGO)
middle_width = max(cell_len(row) for row in MIDDLE)
# Measure what the hero *shows*: the rows are rich markup, so their
# raw length says nothing about the columns they occupy.
hero_width = max(
(cell_len(Text.from_markup(row).plain) for row in hero), default=0
)
middle_width = max((cell_len(row) for row in hero_lines), default=0)
right = (clock_time(), date_label(), f"vol {app.volume}%")
right_width = max(cell_len(item) for item in right)
show_middle = width >= logo_width + middle_width + 8
show_right = width >= logo_width + right_width + 8
show_middle = bool(hero_lines) and width >= hero_width + middle_width + 8
show_right = width >= hero_width + right_width + 8
if show_middle and show_right:
show_middle = width >= logo_width + 4 + middle_width + 2 + right_width
show_middle = (
width >= hero_width + 4 + middle_width + 2 + right_width
)
# Centre the short text block against however tall the hero is: a
# three-line caption beside a seven-row picture sits in the middle.
text_offset = max(0, (len(hero) - len(hero_lines)) // 2)
lines: list[Text] = []
for index, row in enumerate(LOGO):
line = Text(no_wrap=True)
for column, char in enumerate(row):
position = (
column / max(1, logo_width - 1) + index * 0.055
) % 1.0
line.append(
char, style=f"bold {gradient(SPECTRUM_STOPS, position)}"
)
if show_middle:
for index, row in enumerate(hero):
line = _hero_line(row, index, hero_width, gradient_fill)
if show_middle and text_offset <= index < text_offset + len(hero_lines):
middle = hero_lines[index - text_offset]
line.append(" " * 4)
middle = MIDDLE[index]
line.append(middle, style="dim")
# Keep the right block on one column even though the middle
# lines differ in length ("SOMA FM RADIO" is shorter than
# "LISTENER SUPPORTED").
# lines differ in length ("TILDERADIO" is shorter than
# "STREAMED BY / FOR USERS OF THE").
line.append(" " * max(0, middle_width - cell_len(middle)))
if show_right:
used = logo_width
if show_right and text_offset <= index < text_offset + len(right):
used = hero_width
if show_middle:
used += 4 + middle_width
# Right-align the block *and* every item inside it: the clock,
# the date and the volume each keep their own length, so pad
# the shortest ones instead of left-aligning the whole block.
pad = max(1, width - used - right_width)
item = right[index]
item = right[index - text_offset]
line.append(" " * pad)
line.append(" " * max(0, right_width - cell_len(item)))
style = "bold" if index == 0 else "dim"
style = "bold" if index == text_offset else "dim"
line.append(item, style=style)
lines.append(line)
ticker: Text | None = None
if height >= 4:
ticker = Text(no_wrap=True)
prefix = f"{glyph} {state.upper():<8} "
@@ -94,14 +109,24 @@ class HeaderBar(Widget):
elif station is not None:
song = station.now_playing
if song is not None:
text = f"{station.title} · {song.display_artist} — {song.display_title}"
text = (
f"{station.title} · "
f"{song.display_artist} — {song.display_title}"
)
else:
text = f"{station.title} · {station.description}"
text_style = ""
else:
text = "Pick a station — press enter to tune in"
text_style = "italic dim"
ticker.append(marquee(text, max(4, width - cell_len(prefix))), style=text_style)
lines.append(ticker)
ticker.append(
marquee(text, max(4, width - cell_len(prefix))), style=text_style
)
# A short terminal loses the bottom of the hero before it loses the
# ticker: the ticker carries the state, the art can be cropped.
if ticker is not None:
if len(lines) >= height:
lines = lines[: height - 1]
lines.append(ticker)
return Group(*lines[:height])