Files
beefy-player/beefy_player/widgets/schedule.py
T

145 lines
4.7 KiB
Python

"""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 RenderableType
from rich.text import Text
from textual.widget import Widget
from . import frame
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(3, self.size.height)
width, height = frame.inner_size(width, 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 frame.wrap(
lines[:height],
max(12, self.size.width),
max(3, self.size.height),
self.border_title,
)