macOS has no D-Bus/MPRIS: the audio engine is what the OS media UI talks to. mpv's Cocoa backend registers with MPRemoteCommandCenter and MPNowPlayingInfoCenter, so it already publishes the live ICY title and receives the media keys. Add a platform-neutral media session (mediasession.py) so the app keeps a single interface: MprisService on D-Bus platforms, NowPlayingSession on macOS. Map mpv's NEXT/PREV/PLAY/PLAYONLY/PAUSEONLY/STOP keys onto script-messages and read them back as client-message IPC events, so the hardware media keys and Control Center drive the TUI (station changes, play/pause/stop) instead of fighting it. --no-mpris (now also --no-media) disables the media keys too. macOS-only mpv options are gated on sys.platform so the Linux command line is unchanged; mpv aborts on unknown flags.
879 lines
31 KiB
Python
879 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 .mediasession import create_media_session, media_actions_queue
|
|
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,
|
|
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()
|
|
# 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
|
|
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(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 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 = 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_media()
|
|
|
|
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_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
|
|
|
|
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_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 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_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"SomaFM — {song.display_artist} · {song.display_title}"
|
|
if song is not None
|
|
else "SomaFM"
|
|
)
|
|
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 "SomaFM",
|
|
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 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 _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)
|
|
|
|
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.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)
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# 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.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:
|
|
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.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())
|