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

+172 -4
View File
@@ -25,13 +25,15 @@ from textual.widgets import Input
from . import artwork, bandcamp, config
from .mediasession import create_media_session, media_actions_queue
from .models import Station, parse_last_playing
from .models import Broadcast, Station, parse_last_playing
from .player import Player
from .providers import get_provider
from .providers.base import is_direct_stream, make_client
from .spectrum import SpectrumEngine
from .widgets.header import HeaderBar
from .widgets.help import HelpScreen
from .widgets.providers import ProviderScreen
from .widgets.schedule import SchedulePanel
from .widgets.search import SearchInput
from .widgets.spectrum import SpectrumPanel
from .widgets.stage import StagePanel
@@ -89,6 +91,7 @@ class BeefyPlayerApp(App[None]):
Binding("escape", "escape", "close", show=False),
Binding("v", "visualizer", "visualiser"),
Binding("r", "refresh", "refresh"),
Binding("p", "provider", "catalogue"),
Binding("question_mark", "help", "help"),
Binding("q", "quit", "quit"),
]
@@ -125,12 +128,17 @@ class BeefyPlayerApp(App[None]):
if volume is not None:
self.config.volume = max(0, min(100, int(volume)))
self.start_station_id = station_id or self.config.last_station
self.start_station_id = station_id or self.config.last_station_for(
self.provider.id
)
self.offline = offline
self.audio_enabled = audio
self.stations: list[Station] = []
self.view: list[Station] = []
#: The current catalogue's broadcast schedule, when it has one.
self.schedule: list[Broadcast] = []
self.schedule_loaded = False
self.index = 0
self.query = ""
self.current: Station | None = None
@@ -144,6 +152,8 @@ class BeefyPlayerApp(App[None]):
self._volume_before_mute = self.volume or 70
self._last_history_fetch = 0.0
self._history_refresh_seconds = 25.0
self._schedule_refresh_seconds = 300.0
self._last_schedule_fetch = 0.0
self._stream_url = ""
self.artwork_url = ""
self.artwork_bytes: bytes | None = None
@@ -174,6 +184,7 @@ class BeefyPlayerApp(App[None]):
with Vertical(id="left"):
yield SearchInput()
yield StationList()
yield SchedulePanel()
yield StagePanel()
yield SpectrumPanel(self.spectrum)
yield StatusBar()
@@ -188,6 +199,7 @@ class BeefyPlayerApp(App[None]):
self.media.start()
self._http = make_client(self.provider.user_agent)
self._apply_catalogue_chrome()
stations = self.provider.load_cached_stations()
if not stations:
@@ -208,6 +220,9 @@ class BeefyPlayerApp(App[None]):
self._refresh_stations()
if self.current is not None:
self._fetch_history(self.current.id)
if self.provider.has_schedule:
# Offline still shows the cached schedule — the worker decides.
self._refresh_schedule()
self.set_focus(None)
@@ -233,6 +248,7 @@ class BeefyPlayerApp(App[None]):
self.config.volume = self.volume
if self.current is not None:
self.config.last_station = self.current.id
self.config.last_stations[self.provider.id] = self.current.id
self.config.visualizer = self.visualizer
self.config.save()
@@ -315,6 +331,46 @@ class BeefyPlayerApp(App[None]):
if self.current is None and view:
self.current = view[self.index]
# ------------------------------------------------------------------ #
# the catalogue's chrome
# ------------------------------------------------------------------ #
def _apply_catalogue_chrome(self) -> None:
"""Point the chrome at the catalogue: hero height and the left pane."""
provider = self.provider
hero_rows = len(getattr(provider, "hero", ()) or ())
# Give the hero its rows, but never more than a quarter of the screen:
# on a short terminal the spectrum and the stage matter more.
screen_height = getattr(self, "size", None)
cap = max(4, (screen_height.height // 4) if screen_height else 10)
with contextlib.suppress(NoMatches):
header = self.query_one(HeaderBar)
header.styles.height = min(hero_rows + 1, cap) if hero_rows else 4
header.refresh()
self.query_one(StationList).display = not provider.has_schedule
self.query_one(SchedulePanel).display = provider.has_schedule
if provider.has_schedule:
self._reset_filter()
def _reset_filter(self) -> None:
"""Forget the station filter and put the search box away."""
self.query = ""
search = self.query_one(SearchInput)
search.value = ""
search.remove_class("visible")
search.disabled = True
self.query_one(StationList).set_filter("")
def on_resize(self, event) -> None:
self._apply_catalogue_chrome()
def _refresh_pane(self) -> None:
with contextlib.suppress(NoMatches):
if self.provider.has_schedule:
self.query_one(SchedulePanel).refresh()
else:
self.query_one(StationList).refresh()
# ------------------------------------------------------------------ #
# playback
# ------------------------------------------------------------------ #
@@ -455,6 +511,11 @@ class BeefyPlayerApp(App[None]):
song = station.now_playing
if song is None or not (song.artist.strip() or song.title.strip()):
return
if not getattr(self.provider, "bandcamp", True):
# Not a Bandcamp catalogue: never look the track up, and do not
# leave a stale link behind from the previous station.
song.bandcamp_url = ""
return
track_key = f"{station.id}|{song.key()}"
cached_url = self._bandcamp_urls.get(track_key)
@@ -615,8 +676,9 @@ class BeefyPlayerApp(App[None]):
self.query_one(HeaderBar).refresh()
if self.tick_count % 12 == 0:
self.query_one(StatusBar).refresh()
self.query_one(StationList).refresh()
self._refresh_pane()
self._maybe_refresh_history()
self._maybe_refresh_schedule()
def _drain_player(self) -> None:
while True:
@@ -693,6 +755,33 @@ class BeefyPlayerApp(App[None]):
continue
self._set_volume(value)
def _maybe_refresh_schedule(self) -> None:
if not self.provider.has_schedule or self.offline:
return
if (
time.monotonic() - self._last_schedule_fetch
>= self._schedule_refresh_seconds
):
self._refresh_schedule()
@work(exclusive=True, group="schedule", exit_on_error=False)
async def _refresh_schedule(self) -> None:
if not self.provider.has_schedule:
return
self._last_schedule_fetch = time.monotonic()
broadcasts: list[Broadcast] = []
if self.offline or self._http is None:
broadcasts = self.provider.load_cached_schedule()
else:
try:
broadcasts = await self.provider.fetch_schedule(self._http)
except Exception:
broadcasts = self.provider.load_cached_schedule()
self.schedule = broadcasts
self.schedule_loaded = True
with contextlib.suppress(NoMatches):
self.query_one(SchedulePanel).refresh()
# ------------------------------------------------------------------ #
# workers
# ------------------------------------------------------------------ #
@@ -714,7 +803,9 @@ class BeefyPlayerApp(App[None]):
self._set_stations(stations)
if before is not None:
self._select_station_id(before)
self._set_notice(f"{len(stations)} stations", 3)
self._set_notice(
"1 station" if len(stations) == 1 else f"{len(stations)} stations", 3
)
self.query_one(StationList).refresh()
# ------------------------------------------------------------------ #
@@ -810,6 +901,9 @@ class BeefyPlayerApp(App[None]):
self.query_one(StagePanel).refresh_stage()
def action_search(self) -> None:
if self.provider.has_schedule:
self._set_notice("this catalogue shows a schedule, not a station list", 3)
return
search = self.query_one(SearchInput)
search.disabled = False
search.add_class("visible")
@@ -857,10 +951,84 @@ class BeefyPlayerApp(App[None]):
return
self.artwork_url = ""
self._refresh_stations()
self._refresh_schedule()
if self.current is not None:
self._update_artwork()
self._fetch_history(self.current.id)
def action_provider(self) -> None:
self.push_screen(ProviderScreen(self.provider.id), self._provider_chosen)
def _provider_chosen(self, provider_id: str | None) -> None:
if provider_id:
self._switch_catalogue(provider_id)
def _cancel_workers(self) -> None:
for group in ("stream", "history", "stations", "schedule", "bandcamp", "artwork"):
with contextlib.suppress(Exception):
self.workers.cancel_group(self, group)
def _switch_catalogue(self, provider_id: str) -> None:
"""Move to another station catalogue without restarting the app."""
provider = get_provider(provider_id)
if provider is self.provider:
return
self._cancel_workers()
with contextlib.suppress(Exception):
self.player.stop()
self.spectrum.set_source(None)
self.playing_state = "stopped"
self.media.set_status("Stopped")
old_client = self._http
self.provider = provider
self._http = make_client(provider.user_agent)
if old_client is not None:
with contextlib.suppress(RuntimeError):
asyncio.get_running_loop().create_task(old_client.aclose())
self.config.provider = provider.id
self.config.save()
self.stations = []
self.view = []
self.index = 0
self.current = None
self.schedule = []
self.schedule_loaded = False
self._last_schedule_fetch = 0.0
self._last_history_fetch = 0.0
self._bandcamp_key = ""
self._bandcamp_urls.clear()
self._stream_url = ""
self._art_track_key = ""
self.artwork_url = ""
self.artwork_bytes = None
self.start_station_id = self.config.last_station_for(provider.id)
self._apply_catalogue_chrome()
with contextlib.suppress(NoMatches):
self._reset_filter()
stations = provider.load_cached_stations() or provider.fallback_stations()
self._set_stations(stations)
self._select_initial_station()
self._update_media()
self._update_artwork()
self._refresh_art()
with contextlib.suppress(NoMatches):
self.query_one(HeaderBar).refresh()
self.query_one(StagePanel).refresh_stage()
self.query_one(StatusBar).refresh()
self._set_notice(f"catalogue: {provider.name}", 3)
if not self.offline:
self._refresh_stations()
if self.current is not None:
self._fetch_history(self.current.id)
if provider.has_schedule:
self._refresh_schedule()
def action_help(self) -> None:
self.push_screen(HelpScreen())