feat(tui): implement SomaFM terminal radio player
This commit is contained in:
1 parent
941e45a20f
commit
237de2efe9
21 files changed
+4937
No files matched your search
@@ -0,0 +1,818 @@
|
||||
"""The Textual application: layout, key bindings and orchestration."""
|
||||
|
||||
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 api, artwork, config
|
||||
from .api import Station
|
||||
from .fallback import fallback_channels
|
||||
from .mpris import MprisService
|
||||
from .player import Player
|
||||
from .spectrum import SpectrumEngine
|
||||
from .widgets.header import HeaderBar
|
||||
from .widgets.help import HelpScreen
|
||||
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 SomaFMApp(App[None]):
|
||||
CSS_PATH = "theme.tcss"
|
||||
ENABLE_COMMAND_PALETTE = False
|
||||
TITLE = "SomaFM"
|
||||
SUB_TITLE = "terminal 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("question_mark", "help", "help"),
|
||||
Binding("q", "quit", "quit"),
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
autoplay: bool | None = None,
|
||||
station_id: str | None = None,
|
||||
volume: int | None = None,
|
||||
mpris: bool = True,
|
||||
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()
|
||||
# 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
|
||||
self.mpris_enabled = self.config.mpris and mpris
|
||||
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.offline = offline
|
||||
self.audio_enabled = audio
|
||||
|
||||
self.stations: list[Station] = []
|
||||
self.view: list[Station] = []
|
||||
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._stream_url = ""
|
||||
self.artwork_url = ""
|
||||
self.artwork_bytes: bytes | None = None
|
||||
self._art_track_key = ""
|
||||
self._http: httpx.AsyncClient | None = None
|
||||
self._closed = False
|
||||
self._tick_timer = None
|
||||
|
||||
self.player = Player()
|
||||
self.spectrum = SpectrumEngine(source_mode=spectrum_source)
|
||||
self.mpris_actions: queue.Queue[str] = queue.Queue()
|
||||
self.mpris = MprisService(self.mpris_actions, enabled=self.mpris_enabled)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# layout
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield HeaderBar()
|
||||
with Horizontal(id="main"):
|
||||
with Vertical(id="left"):
|
||||
yield SearchInput()
|
||||
yield StationList()
|
||||
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.mpris.start()
|
||||
|
||||
self._http = api.make_client()
|
||||
|
||||
stations = api.load_cached_channels()
|
||||
if not stations:
|
||||
stations = api.parse_channels({"channels": fallback_channels()})
|
||||
self._set_stations(stations)
|
||||
self._select_initial_station()
|
||||
|
||||
if self.current is not None:
|
||||
cached = api.load_cached_songs(self.current.id)
|
||||
if cached:
|
||||
self.current.history = cached
|
||||
self.current.now_playing = cached[0]
|
||||
self._update_mpris()
|
||||
|
||||
self._tick_timer = self.set_interval(0.05, self._tick)
|
||||
|
||||
if not self.offline:
|
||||
self._refresh_channels()
|
||||
if self.current is not None:
|
||||
self._fetch_history(self.current.id)
|
||||
|
||||
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.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 = api.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]
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 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_mpris()
|
||||
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
|
||||
|
||||
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 = station.synthetic_mirrors()
|
||||
else:
|
||||
try:
|
||||
urls = await api.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_mpris()
|
||||
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_mpris()
|
||||
self.query_one(StagePanel).refresh_stage()
|
||||
return
|
||||
|
||||
direct = next((url for url in urls if api.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 api.fetch_history(self._http, station_id)
|
||||
except Exception:
|
||||
songs = api.load_cached_songs(station_id)
|
||||
if not songs:
|
||||
return
|
||||
|
||||
previous = station.now_playing
|
||||
station.history = songs
|
||||
station.now_playing = songs[0]
|
||||
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_mpris()
|
||||
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_mpris(self) -> None:
|
||||
station = self.current
|
||||
if station is None:
|
||||
return
|
||||
song = station.now_playing
|
||||
self.title = (
|
||||
f"SomaFM — {song.display_artist} · {song.display_title}"
|
||||
if song is not None
|
||||
else "SomaFM"
|
||||
)
|
||||
if song is not None:
|
||||
self.mpris.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.mpris.set_track(
|
||||
station=station.title,
|
||||
title=station.last_playing or station.title,
|
||||
artist=station.dj or "SomaFM",
|
||||
album=station.description,
|
||||
art_url=station.image,
|
||||
stream_url=self._stream_url,
|
||||
track_key=station.id,
|
||||
)
|
||||
self.mpris.set_status(self._mpris_status())
|
||||
self.mpris.set_volume(self.volume / 100.0)
|
||||
self._update_artwork()
|
||||
|
||||
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 _mpris_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.mpris.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_mpris()
|
||||
|
||||
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)
|
||||
|
||||
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.query_one(StationList).refresh()
|
||||
self._maybe_refresh_history()
|
||||
|
||||
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.mpris.set_volume(self.volume / 100.0)
|
||||
self._save_config()
|
||||
continue
|
||||
if event.kind == "notice":
|
||||
if event.detail:
|
||||
self._set_notice(event.detail, 6)
|
||||
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.mpris.set_status("Playing")
|
||||
if event.url:
|
||||
self.spectrum.set_source(event.url)
|
||||
elif event.state == "paused":
|
||||
self.mpris.set_status("Paused")
|
||||
self.spectrum.set_source(None)
|
||||
elif event.state in ("stopped", "error"):
|
||||
self.mpris.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_mpris(self) -> None:
|
||||
while True:
|
||||
try:
|
||||
action = self.mpris_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)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# workers
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@work(exclusive=True, group="channels", exit_on_error=False)
|
||||
async def _refresh_channels(self) -> None:
|
||||
if self.offline or self._http is None:
|
||||
return
|
||||
try:
|
||||
stations = await api.fetch_channels(self._http)
|
||||
except Exception:
|
||||
if not self.stations:
|
||||
self._set_stations(
|
||||
api.parse_channels({"channels": fallback_channels()})
|
||||
)
|
||||
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(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.mpris.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:
|
||||
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._set_notice("refreshing…", 2)
|
||||
if self.offline:
|
||||
self._set_notice("offline mode — nothing to refresh", 3)
|
||||
return
|
||||
self.artwork_url = ""
|
||||
self._refresh_channels()
|
||||
if self.current is not None:
|
||||
self._update_artwork()
|
||||
self._fetch_history(self.current.id)
|
||||
|
||||
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.mpris.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())
|
||||
Reference in new issue
Block a user