feat(ui): add catalogue picker, schedule pane and per-provider hero
This commit is contained in:
1 parent
058d336a2c
commit
b31ad3f6fb
16 files changed
+1211
-65
No files matched your search
@@ -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])
|
||||
Reference in new issue
Block a user