Files
beefy-player/somafm_tui/app.py
T

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())