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
+172
-4
@@ -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())
|
||||
|
||||
|
||||
Reference in new issue
Block a user