"""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])