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])
+43 -3
View File
@@ -13,6 +13,7 @@ from textual.app import ComposeResult
from textual.screen import ModalScreen
from textual.widgets import Static
from ..providers import PROVIDERS, available_providers
from .common import SPECTRUM_STOPS, gradient
if TYPE_CHECKING:
@@ -33,7 +34,8 @@ HELP_ROWS: tuple[tuple[str, str], ...] = (
"v",
"cycle visualiser: mirror → bars → wave → waterfall → radial → braille",
),
("r", "refresh station list and song history"),
("r", "refresh stations, schedule and song history"),
("p", "switch station catalogue (SomaFM / tilderadio)"),
("g / G", "jump to the top / bottom of the list"),
("? ", "this help"),
("q", "quit"),
@@ -68,12 +70,40 @@ ABOUT_MACOS = (
class HelpScreen(ModalScreen[None]):
def compose(self) -> ComposeResult:
provider = cast("BeefyPlayerApp", self.app).provider
app = cast("BeefyPlayerApp", self.app)
provider = app.provider
about = (
f"Stations and song history come from {provider.name} "
f"({provider.station_source} + {provider.history_source}).",
) + (ABOUT_MACOS if sys.platform == "darwin" else ABOUT_LINUX)
catalogues = Table.grid(padding=(0, 2), expand=False)
catalogues.add_column(style="bold cyan", no_wrap=True, justify="right")
catalogues.add_column(style="bold", no_wrap=True)
catalogues.add_column(style="dim")
for slug in available_providers():
catalogue = PROVIDERS[slug]
marker = "●" if slug == provider.id else " "
pane = "schedule pane" if catalogue.has_schedule else "station list"
catalogues.add_row(
marker, slug, f"{catalogue.name} — {pane}"
)
pane_note = Text()
if provider.has_schedule:
pane_note.append(
f"{provider.name} is a single stream, so the left pane is its "
"broadcast schedule; the station filter and favourites only "
"apply to multi-station catalogues.",
style="dim italic",
)
catalogue_title = Text()
catalogue_title.append("CATALOGUES", style="bold cyan")
catalogue_title.append(
" switch with p or --provider at start", style="dim"
)
table = Table.grid(padding=(0, 2), expand=False)
table.add_column(style="bold cyan", no_wrap=True, justify="right")
table.add_column(style="")
@@ -90,7 +120,17 @@ class HelpScreen(ModalScreen[None]):
for line in textwrap.wrap(paragraph, width=70):
footer.append(line + "\n", style="dim")
body = Group(header, Text(""), table, Text(""), footer)
body = Group(
header,
Text(""),
table,
Text(""),
pane_note,
catalogue_title,
catalogues,
Text(""),
footer,
)
yield Static(body, id="help")
def on_key(self, event) -> None:
+87
View File
@@ -0,0 +1,87 @@
"""Catalogue picker: switch between SomaFM, tilderadio and whatever comes next."""
from __future__ import annotations
from rich.console import Group
from rich.text import Text
from textual.app import ComposeResult
from textual.screen import ModalScreen
from textual.widgets import Static
from ..providers import PROVIDERS, available_providers
from .common import SPECTRUM_STOPS, gradient, truncate
class ProviderScreen(ModalScreen[str | None]):
"""A list of catalogues; enter selects, esc leaves everything as it was."""
def __init__(self, current_id: str = "") -> None:
super().__init__()
self.slugs: tuple[str, ...] = available_providers()
self.current_id = current_id
self.index = self.slugs.index(current_id) if current_id in self.slugs else 0
def compose(self) -> ComposeResult:
yield Static(self._body(), id="providers")
def _body(self) -> Group:
header = Text()
header.append("◤ ", style=f"bold {gradient(SPECTRUM_STOPS, 0.9)}")
header.append("CATALOGUES", style=f"bold {gradient(SPECTRUM_STOPS, 0.55)}")
header.append(" pick a station catalogue", style="dim")
rows: list[Text] = []
for index, slug in enumerate(self.slugs):
provider = PROVIDERS[slug]
selected = index == self.index
row = Text(no_wrap=True)
row.append("▶ " if selected else " ", style="bold magenta" if selected else "")
row.append(f"{index + 1} ", style="dim")
name_style = "bold underline" if selected else "bold"
row.append(f"{provider.name:<12}", style=name_style)
row.append(f" {provider.brand}", style="dim")
if slug == self.current_id:
row.append(" ●", style="bold green")
rows.append(row)
detail = Text(no_wrap=True)
detail.append(" ", style="")
detail.append(truncate(provider.tagline, 54), style="italic dim")
rows.append(detail)
current = PROVIDERS.get(self.current_id)
footer = Text()
footer.append("\n")
if current is not None:
footer.append(
f"now: {current.name} — {current.station_source}\n", style="dim"
)
footer.append(
"↑ ↓ choose · 1–9 jump · enter switch · esc cancel",
style="dim",
)
return Group(header, Text(""), *rows, footer)
# -- interaction ----------------------------------------------------- #
def _move(self, delta: int) -> None:
self.index = (self.index + delta) % len(self.slugs)
self.query_one("#providers", Static).update(self._body())
def on_key(self, event) -> None:
key = event.key
if key in ("up", "k", "shift+tab"):
self._move(-1)
elif key in ("down", "j", "tab"):
self._move(1)
elif key in ("enter", "space"):
self.dismiss(self.slugs[self.index])
elif key == "escape" or key == "q":
self.dismiss(None)
elif key.isdigit() and key != "0":
choice = int(key) - 1
if 0 <= choice < len(self.slugs):
self.dismiss(self.slugs[choice])
else:
return
event.stop()
+137
View File
@@ -0,0 +1,137 @@
"""The left pane for single-stream catalogues: what is on, what is next.
A catalogue with one station does not need a station list, but it usually has
a broadcast schedule. This pane answers the two questions a listener actually
has — "who is on air?" and "when is the next show?" — in the space the list
would have used.
"""
from __future__ import annotations
from datetime import date, datetime, timedelta
from typing import TYPE_CHECKING, cast
from rich.console import Group, RenderableType
from rich.text import Text
from textual.widget import Widget
from .common import local_time, local_zone, state_glyph, truncate
if TYPE_CHECKING:
from ..app import BeefyPlayerApp
from ..models import Broadcast
def _window(broadcast: Broadcast | None) -> str:
"""``since 22:00 · until 23:00`` — whichever ends the feed actually sent."""
if broadcast is None:
return ""
start = local_time(broadcast.start) if broadcast.start else ""
end = local_time(broadcast.end) if broadcast.end else ""
if start and end:
return f"since {start} · until {end}"
if start:
return f"since {start}"
if end:
return f"until {end}"
return ""
def _day_label(day: date, today: date) -> str:
if day == today:
return "TODAY"
if day == today + timedelta(days=1):
return "TOMORROW"
return day.strftime("%a %d %b").upper()
def _upcoming_rows(
upcoming: list[Broadcast], width: int, budget: int
) -> list[Text]:
"""Upcoming shows, grouped by local day, clipped to the pane."""
zone = local_zone()
today = datetime.now(zone).date()
# (is a show, its line) so the "… +N more" count talks about shows and
# not about the day headings in between.
rows: list[tuple[bool, Text]] = []
current_day: date | None = None
for item in upcoming:
if item.start is None:
continue
day = item.start.astimezone(zone).date()
if day != current_day:
current_day = day
rows.append((False, Text(_day_label(day, today), style="dim")))
row = Text(no_wrap=True)
row.append(" ")
row.append(local_time(item.start), style="bold")
row.append(" ")
row.append(truncate(item.display_name, max(4, width - 7)))
rows.append((True, row))
if len(rows) > budget:
kept = rows[: max(0, budget - 1)]
while kept and not kept[-1][0]:
kept.pop()
dropped = sum(1 for is_show, _ in rows[len(kept) :] if is_show)
lines = [text for _, text in kept]
if dropped:
lines.append(Text(f"… +{dropped} more", style="dim italic"))
return lines
return [text for _, text in rows]
class SchedulePanel(Widget):
can_focus = False
def __init__(self) -> None:
super().__init__()
self.border_title = "SCHEDULE"
def render(self) -> RenderableType:
app = cast("BeefyPlayerApp", self.app)
width = max(12, self.size.width)
height = max(1, self.size.height)
broadcasts = list(getattr(app, "schedule", ()) or ())
loaded = bool(getattr(app, "schedule_loaded", False))
live = next((item for item in broadcasts if item.live), None)
upcoming = [item for item in broadcasts if not item.live]
lines: list[Text] = []
head = Text(no_wrap=True)
glyph, colour = state_glyph("playing" if live else "stopped")
head.append(f"{glyph} ", style=f"bold {colour}")
head.append("ON AIR" if live else "AUTO DJ", style="bold magenta")
if live is not None:
head.append(" ", style="")
head.append(
truncate(live.display_name, max(4, width - 12)),
style="bold",
)
lines.append(head)
window = _window(live)
lines.append(
Text(truncate(window, width), style="dim")
if window
else Text("library rotation", style="dim italic")
)
lines.append(Text(""))
lines.append(Text("UPCOMING", style="bold cyan"))
if not loaded:
lines.append(Text("loading schedule…", style="dim italic"))
elif not upcoming:
lines.append(Text("nothing scheduled", style="dim italic"))
else:
lines.extend(
_upcoming_rows(upcoming, width, max(0, height - 1 - len(lines)))
)
lines.append(Text(""))
while len(lines) < height:
lines.append(Text(""))
return Group(*lines[:height])