864 lines
31 KiB
Python
864 lines
31 KiB
Python
"""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, bandcamp, 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._bandcamp_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_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 or self.offline or self._http is None:
|
|
return
|
|
song = station.now_playing
|
|
if song is None or not (song.artist.strip() or song.title.strip()):
|
|
return
|
|
|
|
track_key = f"{station.id}|{song.key()}"
|
|
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
|
|
|
|
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 _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._bandcamp_key = ""
|
|
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())
|