"""The Textual application: layout, key bindings and orchestration. The app is deliberately catalogue-agnostic: it holds one :class:`~beefy_player.providers.base.RadioProvider` and asks it for stations, song history and stream URLs. SomaFM is one such provider; see :mod:`beefy_player.providers`. """ from __future__ import annotations import asyncio import contextlib import queue import time from typing import ClassVar import httpx from textual import work from textual.app import App, ComposeResult from textual.binding import Binding from textual.containers import Horizontal, Vertical from textual.css.query import NoMatches from textual.theme import Theme from textual.widgets import Input from . import artwork, bandcamp, config from .mediasession import create_media_session, media_actions_queue 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 from .widgets.station_list import StationList from .widgets.status import StatusBar VOLUME_STEP = 5 # A theme made only of terminal defaults / ANSI palette entries. Textual's # built-in themes paint an RGB background and map ANSI names to their own RGB # palette, which breaks transparency and ignores the user's colours. TERMINAL_THEME = Theme( name="terminal", primary="ansi_cyan", secondary="ansi_blue", accent="ansi_magenta", warning="ansi_yellow", error="ansi_red", success="ansi_green", # Leave foreground/background unset: Textual's ANSI theme generator then # resolves them to "ansi_default" (SGR 49) instead of RGB black. dark=True, ansi=True, text_alpha=1.0, variables={ "ansi-background": "ansi_default", "ansi-foreground": "ansi_default", }, ) class BeefyPlayerApp(App[None]): CSS_PATH = "theme.tcss" ENABLE_COMMAND_PALETTE = False TITLE = "Beefy Player" SUB_TITLE = "terminal internet radio" BINDINGS: ClassVar[list[Binding]] = [ Binding("up,k", "cursor_up", "up", show=False), Binding("down,j", "cursor_down", "down", show=False), Binding("g", "cursor_top", "top", show=False), Binding("shift+g", "cursor_bottom", "bottom", show=False), Binding("enter", "tune", "tune in"), Binding("space", "toggle_play", "play/pause"), Binding("s", "stop", "stop", show=False), Binding("right,bracketright", "next_station", "next", show=False), Binding("left,bracketleft", "prev_station", "previous", show=False), Binding("plus", "volume_up", "vol +", show=False), Binding("equals_sign", "volume_up", "vol +", show=False), Binding("minus", "volume_down", "vol -", show=False), Binding("underscore", "volume_down", "vol -", show=False), Binding("m", "mute", "mute", show=False), Binding("f", "favorite", "favourite"), Binding("slash", "search", "filter"), 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"), ] def __init__( self, *, provider: str | None = None, autoplay: bool | None = None, station_id: str | None = None, volume: int | None = None, media: bool = True, mpris: bool | None = None, offline: bool = False, audio: bool = True, spectrum_source: str = "auto", ) -> None: # ansi_color=True stops Textual from converting ANSI palette entries # to RGB; the terminal then chooses the actual colours. super().__init__(ansi_color=True) self.register_theme(TERMINAL_THEME) self.theme = TERMINAL_THEME.name self.config = config.Config.load() #: The station catalogue in use (see :mod:`beefy_player.providers`). self.provider = get_provider(provider or self.config.provider) # One-off command line overrides are deliberately *not* written back # into the config file (only an explicit --volume is remembered). self.autoplay = self.config.autoplay if autoplay is None else autoplay # ``mpris`` is the historical keyword; desktop media integration is # MPRIS on Linux and Now Playing (via mpv) on macOS. if mpris is not None: media = mpris self.media_enabled = self.config.mpris and media 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_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 self.playing_state = "stopped" self.volume = self.config.volume self.visualizer = self.config.visualizer self.tick_count = 0 self.notice = "" self._notice_until = 0.0 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 self._art_track_key = "" self._bandcamp_key = "" self._bandcamp_urls: dict[str, str] = {} self._http: httpx.AsyncClient | None = None self._closed = False self._tick_timer = None self.player = Player(media_keys=self.media_enabled) self.spectrum = SpectrumEngine(source_mode=spectrum_source) self.media_actions: queue.Queue[str] = media_actions_queue() self.media = create_media_session( self.media_actions, enabled=self.media_enabled, # No audio engine means nothing to publish from. backend=Player.detect_backend() if audio else None, ) # ------------------------------------------------------------------ # # layout # ------------------------------------------------------------------ # def compose(self) -> ComposeResult: yield HeaderBar() with Horizontal(id="main"): with Vertical(id="left"): yield SearchInput() yield StationList() yield SchedulePanel() yield StagePanel() yield SpectrumPanel(self.spectrum) yield StatusBar() def on_mount(self) -> None: config.ensure_dirs() if self.audio_enabled: self.player.start() self.player.set_volume(self.volume) self.spectrum.start() self.media.start() self._http = make_client(self.provider.user_agent) self._apply_catalogue_chrome() stations = self.provider.load_cached_stations() if not stations: stations = self.provider.fallback_stations() self._set_stations(stations) self._select_initial_station() if self.current is not None: cached = self.provider.load_cached_history(self.current.id) if cached: self.current.history = cached self.current.now_playing = cached[0] self._update_media() self._tick_timer = self.set_interval(0.05, self._tick) if not self.offline: 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) if self.autoplay and self.current is not None and self.audio_enabled: self._tune(self.current) elif not self.audio_enabled: self._set_notice("headless mode — audio disabled", 6) else: self._set_notice("press enter to tune in", 5) def on_unmount(self) -> None: self._cleanup() # ------------------------------------------------------------------ # # state helpers # ------------------------------------------------------------------ # def _set_notice(self, message: str, seconds: float = 4.0) -> None: self.notice = message self._notice_until = time.monotonic() + seconds def _save_config(self) -> 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() def _set_stations(self, stations: list[Station]) -> None: if not stations: return existing = {station.id: station for station in self.stations} for station in stations: previous = existing.get(station.id) if previous is not None: station.history = previous.history station.now_playing = previous.now_playing station.favorite = previous.favorite station.favorite = self.config.is_favorite(station.id) if station.now_playing is None and station.last_playing: station.now_playing = parse_last_playing(station.last_playing) self.stations = stations wanted = self.current.id if self.current is not None else self.start_station_id self.current = next((s for s in stations if s.id == wanted), None) if self.current is None: self.current = stations[0] self._rebuild_view(keep_id=wanted) self.query_one(StationList).refresh() self.query_one(StagePanel).refresh_stage() self._update_artwork() self.query_one(HeaderBar).refresh() def _select_initial_station(self) -> None: if not self.view: return target = self.start_station_id for index, station in enumerate(self.view): if station.id == target: self.index = index self.current = station break else: self.index = 0 self.current = self.view[0] self.config.last_station = self.current.id self.query_one(StationList).refresh() def _select_station_id(self, station_id: str) -> None: self._rebuild_view(keep_id=station_id) for index, station in enumerate(self.view): if station.id == station_id: self.index = index break def _rebuild_view(self, keep_id: str | None = None) -> None: terms = self.query.casefold().split() def matches(station: Station) -> bool: if not terms: return True haystack = ( f"{station.title} {station.genre} {station.description} " f"{station.dj} {station.id}" ).casefold() return all(term in haystack for term in terms) if keep_id is None and self.view and 0 <= self.index < len(self.view): keep_id = self.view[self.index].id view = [station for station in self.stations if matches(station)] view.sort(key=lambda station: (0 if station.favorite else 1, station.title.casefold())) self.view = view if keep_id is not None: for index, station in enumerate(view): if station.id == keep_id: self.index = index break else: self.index = min(self.index, max(0, len(view) - 1)) else: self.index = min(self.index, max(0, len(view) - 1)) 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 # ------------------------------------------------------------------ # def _tune(self, station: Station) -> None: self.current = station self.start_station_id = station.id self.playing_state = "loading" self._set_notice(f"tuning in to {station.title}…", 5) self._save_config() self._update_media() self.query_one(StagePanel).refresh_stage() self.query_one(HeaderBar).refresh() if not self.audio_enabled: self.playing_state = "stopped" self._set_notice("audio disabled (--no-audio)", 5) return # A tune gets a clean analyser: forget any previous capture failure and # clear the old station's bars before the new stream starts. self.spectrum.reset() self._resolve_and_play(station.id) self._fetch_history(station.id) @work(exclusive=True, group="stream", exit_on_error=False) async def _resolve_and_play(self, station_id: str) -> None: station = next((s for s in self.stations if s.id == station_id), None) if station is None or self.current is None or self.current.id != station_id: return if self.offline or self._http is None: urls = self.provider.fallback_urls(station) else: try: urls = await self.provider.stream_candidates(self._http, station) except Exception as exc: self.playing_state = "error" self._set_notice(f"could not resolve stream: {exc}", 8) self._update_media() self.query_one(StagePanel).refresh_stage() return if self.current is None or self.current.id != station_id: return if not urls: self.playing_state = "error" self._set_notice("no stream mirrors available", 8) self._update_media() self.query_one(StagePanel).refresh_stage() return direct = next((url for url in urls if is_direct_stream(url)), None) self._stream_url = direct or urls[0] if direct is not None: self.spectrum.set_source(direct) self.player.set_volume(self.volume) self.player.play(urls) @work(exclusive=True, group="history", exit_on_error=False) async def _fetch_history(self, station_id: str) -> None: self._last_history_fetch = time.monotonic() station = next((s for s in self.stations if s.id == station_id), None) if station is None or self._http is None: return try: songs = await self.provider.fetch_history(self._http, station_id) except Exception: songs = self.provider.load_cached_history(station_id) if not songs: return previous = station.now_playing station.history = songs station.now_playing = songs[0] if previous is not None and previous.key() == songs[0].key(): # History refreshes replace the Song object even when the track has # not changed. Keep the asynchronously discovered Bandcamp link. songs[0].bandcamp_url = previous.bandcamp_url changed = previous is None or previous.key() != songs[0].key() if self.current is not None and self.current.id == station_id: if changed: self._set_notice( f"♪ {songs[0].display_artist} — {songs[0].display_title}", 5 ) self._update_media() self.query_one(StagePanel).refresh_stage() self.query_one(HeaderBar).refresh() def _maybe_refresh_history(self) -> None: if self.offline or self.current is None: return if self.playing_state not in ("playing", "buffering", "loading"): return if time.monotonic() - self._last_history_fetch >= self._history_refresh_seconds: self._fetch_history(self.current.id) def _update_media(self) -> None: station = self.current if station is None: return song = station.now_playing self.title = ( f"{self.provider.name} — {song.display_artist} · {song.display_title}" if song is not None else self.provider.name ) if song is not None: self.media.set_track( station=station.title, title=song.display_title, artist=song.display_artist, album=song.album, art_url=song.art_url or station.image, stream_url=self._stream_url, track_key=f"{station.id}|{song.key()}", ) else: self.media.set_track( station=station.title, title=station.last_playing or station.title, artist=station.dj or self.provider.name, album=station.description, art_url=station.image, stream_url=self._stream_url, track_key=station.id, ) self.media.set_status(self._media_status()) self.media.set_volume(self.volume / 100.0) self._update_bandcamp() self._update_artwork() def _update_bandcamp(self) -> None: """Find a Bandcamp track link once per now-playing track.""" station = self.current if station is None: return 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) if cached_url: self._bandcamp_key = track_key if song.bandcamp_url != cached_url: song.bandcamp_url = cached_url with contextlib.suppress(NoMatches): self.query_one(StagePanel).refresh_stage() return if self.offline or self._http is None: return if track_key == self._bandcamp_key: return self._bandcamp_key = track_key self._fetch_bandcamp( station.id, song.key(), song.artist, song.album, song.title ) @work(exclusive=True, group="bandcamp", exit_on_error=False) async def _fetch_bandcamp( self, station_id: str, song_key: str, artist: str, album: str, title: str, ) -> None: if self._http is None or self.offline: return url = await bandcamp.find_track(self._http, artist, album, title) if not url: return self._bandcamp_urls[f"{station_id}|{song_key}"] = url station = next((s for s in self.stations if s.id == station_id), None) if station is None or self.current is None or self.current.id != station_id: return song = station.now_playing if song is None or song.key() != song_key: return song.bandcamp_url = url with contextlib.suppress(NoMatches): self.query_one(StagePanel).refresh_stage() def _refresh_art(self) -> None: with contextlib.suppress(NoMatches): self.query_one(StagePanel).refresh_art() def _update_artwork(self) -> None: """Pick the album art / station logo and fetch it when needed.""" station = self.current if station is None: changed = bool(self.artwork_url or self.artwork_bytes) self.artwork_url = "" self.artwork_bytes = None self._art_track_key = "" if changed: self._refresh_art() return song = station.now_playing track_key = f"{station.id}|{song.key() if song else ''}" track_changed = track_key != self._art_track_key self._art_track_key = track_key url = ( song.art_url if song is not None and song.art_url else station.image ) or "" if not artwork.supported() or not url: changed = bool(self.artwork_url or self.artwork_bytes) self.artwork_url = "" self.artwork_bytes = None if track_changed or changed: self._refresh_art() return if url != self.artwork_url: self.artwork_url = url self.artwork_bytes = artwork.load_cached(url) self._refresh_art() if self.artwork_bytes is None and not self.offline: self._fetch_artwork(url) elif track_changed: # The image can stay, but the ANSI fallback changes per track. self._refresh_art() @work(exclusive=True, group="artwork", exit_on_error=False) async def _fetch_artwork(self, url: str) -> None: if self._http is None or not artwork.supported(): return data = await artwork.fetch(self._http, url) if self.artwork_url != url: return self.artwork_bytes = data if data is None: self._set_notice("artwork unavailable — using mosaic", 5) self._refresh_art() def _media_status(self) -> str: if self.playing_state == "paused": return "Paused" if self.playing_state in ("playing", "loading", "buffering"): return "Playing" return "Stopped" def _set_volume(self, value: int) -> None: value = max(0, min(100, int(value))) if value == self.volume: return self.volume = value self.config.volume = value if value > 0: self._volume_before_mute = value self.player.set_volume(value) self.media.set_volume(value / 100.0) self._save_config() self.query_one(StatusBar).refresh() self.query_one(HeaderBar).refresh() # ------------------------------------------------------------------ # # event loops # ------------------------------------------------------------------ # def _tick(self) -> None: if self._closed: return try: self._tick_body() except NoMatches: # A timer can fire in the tiny window while the screen is being # unmounted; nothing to update any more. return def _tick_body(self) -> None: self.tick_count += 1 self._drain_player() self._drain_media() if self.notice and time.monotonic() >= self._notice_until: self.notice = "" self.query_one(StatusBar).refresh() self.query_one(HeaderBar).refresh() frame = self.spectrum.snapshot() self.query_one(SpectrumPanel).sync(self.visualizer, frame.label) notice = self.spectrum.take_notice() if notice: self._set_notice(notice, 10) if self.tick_count % 4 == 0: self.query_one(StagePanel).refresh_stage() self.query_one(HeaderBar).refresh() if self.tick_count % 12 == 0: self.query_one(StatusBar).refresh() self._refresh_pane() self._maybe_refresh_history() self._maybe_refresh_schedule() def _drain_player(self) -> None: while True: try: event = self.player.events.get_nowait() except queue.Empty: return if event.kind == "volume": if event.volume is not None and event.volume != self.volume: self.volume = event.volume self.config.volume = event.volume self.media.set_volume(self.volume / 100.0) self._save_config() continue if event.kind == "notice": if event.detail: self._set_notice(event.detail, 6) continue if event.kind == "media": # Media keys / Control Center on macOS, routed via mpv. if event.detail: self.media_actions.put(event.detail) continue self.playing_state = event.state or self.playing_state if event.url: self._stream_url = event.url if event.state in ("playing", "loading", "buffering"): self.media.set_status("Playing") if event.url: self.spectrum.set_source(event.url) elif event.state == "paused": self.media.set_status("Paused") self.spectrum.set_source(None) elif event.state in ("stopped", "error"): self.media.set_status("Stopped") self.spectrum.set_source(None) if event.detail: self._set_notice(event.detail, 6) self.query_one(StagePanel).refresh_stage() def _drain_media(self) -> None: while True: try: action = self.media_actions.get_nowait() except queue.Empty: return if action == "play": if self.playing_state == "paused": self.player.resume() elif self.current is not None and self.playing_state in ("stopped", "error"): self._tune(self.current) elif self.current is None: self.action_tune() elif action == "pause": self.player.pause() elif action == "toggle": self.action_toggle_play() elif action == "stop": self.action_stop() elif action == "next": self.action_next_station() elif action == "prev": self.action_prev_station() elif action == "quit": self.action_quit() elif action.startswith("volume:"): try: value = int(action.split(":", 1)[1]) except ValueError: 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 # ------------------------------------------------------------------ # @work(exclusive=True, group="stations", exit_on_error=False) async def _refresh_stations(self) -> None: if self.offline or self._http is None: return try: stations = await self.provider.fetch_stations(self._http) except Exception: if not self.stations: self._set_stations(self.provider.fallback_stations()) self._set_notice("offline — using the built-in station list", 8) else: self._set_notice("station refresh failed — using the cached list", 6) return before = self.current.id if self.current is not None else None self._set_stations(stations) if before is not None: self._select_station_id(before) self._set_notice( "1 station" if len(stations) == 1 else f"{len(stations)} stations", 3 ) self.query_one(StationList).refresh() # ------------------------------------------------------------------ # # actions # ------------------------------------------------------------------ # def action_cursor_up(self) -> None: if not self.view: return self.index = max(0, self.index - 1) self.query_one(StationList).refresh() def action_cursor_down(self) -> None: if not self.view: return self.index = min(len(self.view) - 1, self.index + 1) self.query_one(StationList).refresh() def action_cursor_top(self) -> None: if not self.view: return self.index = 0 self.query_one(StationList).refresh() def action_cursor_bottom(self) -> None: if not self.view: return self.index = len(self.view) - 1 self.query_one(StationList).refresh() def action_tune(self) -> None: if not self.view: return self.index = max(0, min(self.index, len(self.view) - 1)) self._tune(self.view[self.index]) def action_toggle_play(self) -> None: if self.current is None: self.action_tune() return if self.playing_state in ("stopped", "error"): self._tune(self.current) elif not self.audio_enabled: self._set_notice("audio disabled (--no-audio)", 4) else: self.player.toggle_pause() def action_stop(self) -> None: self.player.stop() self.spectrum.set_source(None) self.playing_state = "stopped" self.media.set_status("Stopped") self._set_notice("stopped", 2) self.query_one(StagePanel).refresh_stage() self.query_one(HeaderBar).refresh() def _step_station(self, delta: int) -> None: if not self.view: return self.index = (self.index + delta) % len(self.view) self.query_one(StationList).refresh() self._tune(self.view[self.index]) def action_next_station(self) -> None: self._step_station(1) def action_prev_station(self) -> None: self._step_station(-1) def action_volume_up(self) -> None: self._set_volume(self.volume + VOLUME_STEP) def action_volume_down(self) -> None: self._set_volume(self.volume - VOLUME_STEP) def action_mute(self) -> None: if self.volume > 0: self._volume_before_mute = self.volume self._set_volume(0) else: self._set_volume(self._volume_before_mute or 70) def action_favorite(self) -> None: station = self.view[self.index] if self.view else self.current if station is None: return added = self.config.toggle_favorite(station.id) station.favorite = added self._save_config() self._rebuild_view(keep_id=station.id) self._set_notice(("★ added " if added else "☆ removed ") + station.title, 3) self.query_one(StationList).refresh() 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") search.focus() with contextlib.suppress(Exception): search.cursor_position = len(search.value) def action_escape(self) -> None: if self.query: self._close_search(clear=True) else: self._close_search(clear=False) def _close_search(self, clear: bool = False) -> None: search = self.query_one(SearchInput) search.remove_class("visible") if clear: self.query = "" search.value = "" search.disabled = True self.set_focus(None) self._rebuild_view() self.query_one(StationList).set_filter(self.query) self.query_one(StationList).refresh() self.query_one(StagePanel).refresh_stage() def action_visualizer(self) -> None: modes = list(config.VISUALIZERS) if self.visualizer in modes: self.visualizer = modes[(modes.index(self.visualizer) + 1) % len(modes)] else: self.visualizer = "both" self.config.visualizer = self.visualizer self._save_config() self._set_notice(f"visualiser: {self.visualizer}", 2) self.query_one(SpectrumPanel).refresh() def action_refresh(self) -> None: self._last_history_fetch = 0.0 self._bandcamp_key = "" self._bandcamp_urls.clear() self._set_notice("refreshing…", 2) if self.offline: self._set_notice("offline mode — nothing to refresh", 3) 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()) def action_quit(self) -> None: self._cleanup() self.exit() # ------------------------------------------------------------------ # # input widget events # ------------------------------------------------------------------ # def on_input_changed(self, event: Input.Changed) -> None: if event.input.id != "search": return self.query = event.value self._rebuild_view() self.query_one(StationList).set_filter(self.query) self.query_one(StationList).refresh() def on_input_submitted(self, event: Input.Submitted) -> None: if event.input.id != "search": return station = self.view[self.index] if self.view else None self._close_search(clear=True) if station is not None: self._tune(station) # ------------------------------------------------------------------ # # shutdown # ------------------------------------------------------------------ # def _cleanup(self) -> None: if self._closed: return self._closed = True if self._tick_timer is not None: with contextlib.suppress(Exception): self._tick_timer.stop() self._save_config() with contextlib.suppress(Exception): self.media.stop() with contextlib.suppress(Exception): self.spectrum.stop() with contextlib.suppress(Exception): self.player.shutdown() if self._http is not None: with contextlib.suppress(RuntimeError): asyncio.get_running_loop().create_task(self._http.aclose())