feat(ui): add catalogue picker, schedule pane and per-provider hero
This commit is contained in:
1 parent
058d336a2c
commit
b31ad3f6fb
16 files changed
+1211
-65
No files matched your search
+172
-4
@@ -25,13 +25,15 @@ from textual.widgets import Input
|
||||
|
||||
from . import artwork, bandcamp, config
|
||||
from .mediasession import create_media_session, media_actions_queue
|
||||
from .models import Station, parse_last_playing
|
||||
from .models import Broadcast, Station, parse_last_playing
|
||||
from .player import Player
|
||||
from .providers import get_provider
|
||||
from .providers.base import is_direct_stream, make_client
|
||||
from .spectrum import SpectrumEngine
|
||||
from .widgets.header import HeaderBar
|
||||
from .widgets.help import HelpScreen
|
||||
from .widgets.providers import ProviderScreen
|
||||
from .widgets.schedule import SchedulePanel
|
||||
from .widgets.search import SearchInput
|
||||
from .widgets.spectrum import SpectrumPanel
|
||||
from .widgets.stage import StagePanel
|
||||
@@ -89,6 +91,7 @@ class BeefyPlayerApp(App[None]):
|
||||
Binding("escape", "escape", "close", show=False),
|
||||
Binding("v", "visualizer", "visualiser"),
|
||||
Binding("r", "refresh", "refresh"),
|
||||
Binding("p", "provider", "catalogue"),
|
||||
Binding("question_mark", "help", "help"),
|
||||
Binding("q", "quit", "quit"),
|
||||
]
|
||||
@@ -125,12 +128,17 @@ class BeefyPlayerApp(App[None]):
|
||||
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.start_station_id = station_id or self.config.last_station_for(
|
||||
self.provider.id
|
||||
)
|
||||
self.offline = offline
|
||||
self.audio_enabled = audio
|
||||
|
||||
self.stations: list[Station] = []
|
||||
self.view: list[Station] = []
|
||||
#: The current catalogue's broadcast schedule, when it has one.
|
||||
self.schedule: list[Broadcast] = []
|
||||
self.schedule_loaded = False
|
||||
self.index = 0
|
||||
self.query = ""
|
||||
self.current: Station | None = None
|
||||
@@ -144,6 +152,8 @@ class BeefyPlayerApp(App[None]):
|
||||
self._volume_before_mute = self.volume or 70
|
||||
self._last_history_fetch = 0.0
|
||||
self._history_refresh_seconds = 25.0
|
||||
self._schedule_refresh_seconds = 300.0
|
||||
self._last_schedule_fetch = 0.0
|
||||
self._stream_url = ""
|
||||
self.artwork_url = ""
|
||||
self.artwork_bytes: bytes | None = None
|
||||
@@ -174,6 +184,7 @@ class BeefyPlayerApp(App[None]):
|
||||
with Vertical(id="left"):
|
||||
yield SearchInput()
|
||||
yield StationList()
|
||||
yield SchedulePanel()
|
||||
yield StagePanel()
|
||||
yield SpectrumPanel(self.spectrum)
|
||||
yield StatusBar()
|
||||
@@ -188,6 +199,7 @@ class BeefyPlayerApp(App[None]):
|
||||
self.media.start()
|
||||
|
||||
self._http = make_client(self.provider.user_agent)
|
||||
self._apply_catalogue_chrome()
|
||||
|
||||
stations = self.provider.load_cached_stations()
|
||||
if not stations:
|
||||
@@ -208,6 +220,9 @@ class BeefyPlayerApp(App[None]):
|
||||
self._refresh_stations()
|
||||
if self.current is not None:
|
||||
self._fetch_history(self.current.id)
|
||||
if self.provider.has_schedule:
|
||||
# Offline still shows the cached schedule — the worker decides.
|
||||
self._refresh_schedule()
|
||||
|
||||
self.set_focus(None)
|
||||
|
||||
@@ -233,6 +248,7 @@ class BeefyPlayerApp(App[None]):
|
||||
self.config.volume = self.volume
|
||||
if self.current is not None:
|
||||
self.config.last_station = self.current.id
|
||||
self.config.last_stations[self.provider.id] = self.current.id
|
||||
self.config.visualizer = self.visualizer
|
||||
self.config.save()
|
||||
|
||||
@@ -315,6 +331,46 @@ class BeefyPlayerApp(App[None]):
|
||||
if self.current is None and view:
|
||||
self.current = view[self.index]
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# the catalogue's chrome
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _apply_catalogue_chrome(self) -> None:
|
||||
"""Point the chrome at the catalogue: hero height and the left pane."""
|
||||
provider = self.provider
|
||||
hero_rows = len(getattr(provider, "hero", ()) or ())
|
||||
# Give the hero its rows, but never more than a quarter of the screen:
|
||||
# on a short terminal the spectrum and the stage matter more.
|
||||
screen_height = getattr(self, "size", None)
|
||||
cap = max(4, (screen_height.height // 4) if screen_height else 10)
|
||||
with contextlib.suppress(NoMatches):
|
||||
header = self.query_one(HeaderBar)
|
||||
header.styles.height = min(hero_rows + 1, cap) if hero_rows else 4
|
||||
header.refresh()
|
||||
self.query_one(StationList).display = not provider.has_schedule
|
||||
self.query_one(SchedulePanel).display = provider.has_schedule
|
||||
if provider.has_schedule:
|
||||
self._reset_filter()
|
||||
|
||||
def _reset_filter(self) -> None:
|
||||
"""Forget the station filter and put the search box away."""
|
||||
self.query = ""
|
||||
search = self.query_one(SearchInput)
|
||||
search.value = ""
|
||||
search.remove_class("visible")
|
||||
search.disabled = True
|
||||
self.query_one(StationList).set_filter("")
|
||||
|
||||
def on_resize(self, event) -> None:
|
||||
self._apply_catalogue_chrome()
|
||||
|
||||
def _refresh_pane(self) -> None:
|
||||
with contextlib.suppress(NoMatches):
|
||||
if self.provider.has_schedule:
|
||||
self.query_one(SchedulePanel).refresh()
|
||||
else:
|
||||
self.query_one(StationList).refresh()
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# playback
|
||||
# ------------------------------------------------------------------ #
|
||||
@@ -455,6 +511,11 @@ class BeefyPlayerApp(App[None]):
|
||||
song = station.now_playing
|
||||
if song is None or not (song.artist.strip() or song.title.strip()):
|
||||
return
|
||||
if not getattr(self.provider, "bandcamp", True):
|
||||
# Not a Bandcamp catalogue: never look the track up, and do not
|
||||
# leave a stale link behind from the previous station.
|
||||
song.bandcamp_url = ""
|
||||
return
|
||||
|
||||
track_key = f"{station.id}|{song.key()}"
|
||||
cached_url = self._bandcamp_urls.get(track_key)
|
||||
@@ -615,8 +676,9 @@ class BeefyPlayerApp(App[None]):
|
||||
self.query_one(HeaderBar).refresh()
|
||||
if self.tick_count % 12 == 0:
|
||||
self.query_one(StatusBar).refresh()
|
||||
self.query_one(StationList).refresh()
|
||||
self._refresh_pane()
|
||||
self._maybe_refresh_history()
|
||||
self._maybe_refresh_schedule()
|
||||
|
||||
def _drain_player(self) -> None:
|
||||
while True:
|
||||
@@ -693,6 +755,33 @@ class BeefyPlayerApp(App[None]):
|
||||
continue
|
||||
self._set_volume(value)
|
||||
|
||||
def _maybe_refresh_schedule(self) -> None:
|
||||
if not self.provider.has_schedule or self.offline:
|
||||
return
|
||||
if (
|
||||
time.monotonic() - self._last_schedule_fetch
|
||||
>= self._schedule_refresh_seconds
|
||||
):
|
||||
self._refresh_schedule()
|
||||
|
||||
@work(exclusive=True, group="schedule", exit_on_error=False)
|
||||
async def _refresh_schedule(self) -> None:
|
||||
if not self.provider.has_schedule:
|
||||
return
|
||||
self._last_schedule_fetch = time.monotonic()
|
||||
broadcasts: list[Broadcast] = []
|
||||
if self.offline or self._http is None:
|
||||
broadcasts = self.provider.load_cached_schedule()
|
||||
else:
|
||||
try:
|
||||
broadcasts = await self.provider.fetch_schedule(self._http)
|
||||
except Exception:
|
||||
broadcasts = self.provider.load_cached_schedule()
|
||||
self.schedule = broadcasts
|
||||
self.schedule_loaded = True
|
||||
with contextlib.suppress(NoMatches):
|
||||
self.query_one(SchedulePanel).refresh()
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# workers
|
||||
# ------------------------------------------------------------------ #
|
||||
@@ -714,7 +803,9 @@ class BeefyPlayerApp(App[None]):
|
||||
self._set_stations(stations)
|
||||
if before is not None:
|
||||
self._select_station_id(before)
|
||||
self._set_notice(f"{len(stations)} stations", 3)
|
||||
self._set_notice(
|
||||
"1 station" if len(stations) == 1 else f"{len(stations)} stations", 3
|
||||
)
|
||||
self.query_one(StationList).refresh()
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
@@ -810,6 +901,9 @@ class BeefyPlayerApp(App[None]):
|
||||
self.query_one(StagePanel).refresh_stage()
|
||||
|
||||
def action_search(self) -> None:
|
||||
if self.provider.has_schedule:
|
||||
self._set_notice("this catalogue shows a schedule, not a station list", 3)
|
||||
return
|
||||
search = self.query_one(SearchInput)
|
||||
search.disabled = False
|
||||
search.add_class("visible")
|
||||
@@ -857,10 +951,84 @@ class BeefyPlayerApp(App[None]):
|
||||
return
|
||||
self.artwork_url = ""
|
||||
self._refresh_stations()
|
||||
self._refresh_schedule()
|
||||
if self.current is not None:
|
||||
self._update_artwork()
|
||||
self._fetch_history(self.current.id)
|
||||
|
||||
def action_provider(self) -> None:
|
||||
self.push_screen(ProviderScreen(self.provider.id), self._provider_chosen)
|
||||
|
||||
def _provider_chosen(self, provider_id: str | None) -> None:
|
||||
if provider_id:
|
||||
self._switch_catalogue(provider_id)
|
||||
|
||||
def _cancel_workers(self) -> None:
|
||||
for group in ("stream", "history", "stations", "schedule", "bandcamp", "artwork"):
|
||||
with contextlib.suppress(Exception):
|
||||
self.workers.cancel_group(self, group)
|
||||
|
||||
def _switch_catalogue(self, provider_id: str) -> None:
|
||||
"""Move to another station catalogue without restarting the app."""
|
||||
provider = get_provider(provider_id)
|
||||
if provider is self.provider:
|
||||
return
|
||||
|
||||
self._cancel_workers()
|
||||
with contextlib.suppress(Exception):
|
||||
self.player.stop()
|
||||
self.spectrum.set_source(None)
|
||||
self.playing_state = "stopped"
|
||||
self.media.set_status("Stopped")
|
||||
|
||||
old_client = self._http
|
||||
self.provider = provider
|
||||
self._http = make_client(provider.user_agent)
|
||||
if old_client is not None:
|
||||
with contextlib.suppress(RuntimeError):
|
||||
asyncio.get_running_loop().create_task(old_client.aclose())
|
||||
|
||||
self.config.provider = provider.id
|
||||
self.config.save()
|
||||
|
||||
self.stations = []
|
||||
self.view = []
|
||||
self.index = 0
|
||||
self.current = None
|
||||
self.schedule = []
|
||||
self.schedule_loaded = False
|
||||
self._last_schedule_fetch = 0.0
|
||||
self._last_history_fetch = 0.0
|
||||
self._bandcamp_key = ""
|
||||
self._bandcamp_urls.clear()
|
||||
self._stream_url = ""
|
||||
self._art_track_key = ""
|
||||
self.artwork_url = ""
|
||||
self.artwork_bytes = None
|
||||
self.start_station_id = self.config.last_station_for(provider.id)
|
||||
|
||||
self._apply_catalogue_chrome()
|
||||
with contextlib.suppress(NoMatches):
|
||||
self._reset_filter()
|
||||
stations = provider.load_cached_stations() or provider.fallback_stations()
|
||||
self._set_stations(stations)
|
||||
self._select_initial_station()
|
||||
self._update_media()
|
||||
self._update_artwork()
|
||||
self._refresh_art()
|
||||
with contextlib.suppress(NoMatches):
|
||||
self.query_one(HeaderBar).refresh()
|
||||
self.query_one(StagePanel).refresh_stage()
|
||||
self.query_one(StatusBar).refresh()
|
||||
self._set_notice(f"catalogue: {provider.name}", 3)
|
||||
|
||||
if not self.offline:
|
||||
self._refresh_stations()
|
||||
if self.current is not None:
|
||||
self._fetch_history(self.current.id)
|
||||
if provider.has_schedule:
|
||||
self._refresh_schedule()
|
||||
|
||||
def action_help(self) -> None:
|
||||
self.push_screen(HelpScreen())
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ from __future__ import annotations
|
||||
|
||||
from .base import RadioProvider
|
||||
from .somafm import SomaFMProvider
|
||||
from .tilderadio import TilderadioProvider
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_PROVIDER",
|
||||
@@ -29,7 +30,7 @@ __all__ = [
|
||||
|
||||
#: Every catalogue the app can talk to, keyed by its slug.
|
||||
PROVIDERS: dict[str, RadioProvider] = {
|
||||
provider.id: provider for provider in (SomaFMProvider(),)
|
||||
provider.id: provider for provider in (SomaFMProvider(), TilderadioProvider())
|
||||
}
|
||||
|
||||
#: Provider used when nothing else is configured.
|
||||
|
||||
@@ -24,7 +24,7 @@ from urllib.parse import quote
|
||||
import httpx
|
||||
|
||||
from .. import config
|
||||
from ..models import Song, Station
|
||||
from ..models import Broadcast, Song, Station
|
||||
|
||||
__all__ = [
|
||||
"PLAYLIST_TTL",
|
||||
@@ -92,6 +92,28 @@ class RadioProvider(ABC):
|
||||
#: ``User-Agent`` for this catalogue's requests (SomaFM likes to know).
|
||||
user_agent: str = ""
|
||||
|
||||
# -- header hero ----------------------------------------------------- #
|
||||
|
||||
#: Rows of header art drawn at the top left (rich markup is allowed).
|
||||
hero: tuple[str, ...] = ()
|
||||
#: Paint :attr:`hero` with the app's spectrum gradient (a wordmark) instead
|
||||
#: of the colours the art carries itself (a picture).
|
||||
hero_gradient: bool = False
|
||||
#: Short lines printed beside the hero, top to bottom.
|
||||
hero_lines: tuple[str, ...] = ()
|
||||
|
||||
# -- left pane ------------------------------------------------------- #
|
||||
|
||||
#: Show a broadcast schedule in the left pane instead of a station list.
|
||||
#: Set by single-stream catalogues, where a list of one station is a waste
|
||||
#: of the pane and "what is on, what is next" is the useful view.
|
||||
has_schedule: bool = False
|
||||
|
||||
#: Look the current track up on Bandcamp and offer a link when it is found.
|
||||
#: Off for catalogues whose rotation is unlikely to be there (and for
|
||||
#: stations that would rather not be scraped).
|
||||
bandcamp: bool = True
|
||||
|
||||
# -- catalogue ------------------------------------------------------- #
|
||||
|
||||
@abstractmethod
|
||||
@@ -118,6 +140,16 @@ class RadioProvider(ABC):
|
||||
def load_cached_history(self, station_id: str) -> list[Song]:
|
||||
"""The last good song history from disk, or ``[]``."""
|
||||
|
||||
# -- schedule -------------------------------------------------------- #
|
||||
|
||||
async def fetch_schedule(self, client: httpx.AsyncClient) -> list[Broadcast]:
|
||||
"""Upcoming broadcasts, soonest first (the live one, if any, first)."""
|
||||
return []
|
||||
|
||||
def load_cached_schedule(self) -> list[Broadcast]:
|
||||
"""The last good schedule from disk, or ``[]``."""
|
||||
return []
|
||||
|
||||
# -- streams --------------------------------------------------------- #
|
||||
|
||||
def fallback_urls(self, station: Station) -> list[str]:
|
||||
|
||||
@@ -15,6 +15,7 @@ from ... import config
|
||||
from ...models import Song, Station
|
||||
from ..base import RadioProvider
|
||||
from . import api, fallback
|
||||
from . import hero as hero_art
|
||||
|
||||
__all__ = ["SomaFMProvider"]
|
||||
|
||||
@@ -28,6 +29,10 @@ class SomaFMProvider(RadioProvider):
|
||||
station_source = "somafm.com/channels.json"
|
||||
history_source = "songs/<station>.json"
|
||||
|
||||
hero = hero_art.LOGO
|
||||
hero_gradient = True
|
||||
hero_lines = hero_art.TAGLINES
|
||||
|
||||
@property
|
||||
def user_agent(self) -> str:
|
||||
# SomaFM asks clients to identify themselves, so say who we are and
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
"""SomaFM's header hero: the block-letter wordmark and the three lines beside it.
|
||||
|
||||
The glyphs are plain half-blocks; the header paints them with the app's
|
||||
spectrum gradient (``hero_gradient``), so the logo takes on the terminal's own
|
||||
colours instead of carrying RGB values around.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
LOGO: tuple[str, ...] = (
|
||||
"▄▀▀▀ ▄▀▀▄ █▄ ▄█ ▄▀▀▄",
|
||||
"▀▀▀▄ █ █ █ █ █ █▀▀█",
|
||||
"▄▄▄▀ ▀▄▄▀ █ █ █ █",
|
||||
)
|
||||
|
||||
TAGLINES: tuple[str, ...] = (
|
||||
"SOMA FM RADIO",
|
||||
"LISTENER SUPPORTED",
|
||||
"SAN FRANCISCO",
|
||||
)
|
||||
@@ -82,6 +82,17 @@ StationList {
|
||||
background: ansi_default;
|
||||
}
|
||||
|
||||
SchedulePanel {
|
||||
color: ansi_default;
|
||||
width: 1fr;
|
||||
height: 1fr;
|
||||
display: none;
|
||||
border: round ansi_bright_black;
|
||||
border-title-color: ansi_default;
|
||||
padding: 0 1;
|
||||
background: ansi_default;
|
||||
}
|
||||
|
||||
StagePanel {
|
||||
color: ansi_default;
|
||||
layout: vertical;
|
||||
@@ -151,6 +162,16 @@ ModalScreen {
|
||||
background: ansi_default;
|
||||
}
|
||||
|
||||
#providers {
|
||||
color: ansi_default;
|
||||
width: 66;
|
||||
height: auto;
|
||||
border: round ansi_bright_black;
|
||||
border-title-color: ansi_default;
|
||||
padding: 1 2;
|
||||
background: ansi_default;
|
||||
}
|
||||
|
||||
#help {
|
||||
color: ansi_default;
|
||||
width: 78;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Header: logo, clock and a live ticker."""
|
||||
"""Header: the catalogue's hero, the clock and a live ticker."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -21,13 +21,19 @@ from .common import (
|
||||
if TYPE_CHECKING:
|
||||
from ..app import BeefyPlayerApp
|
||||
|
||||
LOGO = (
|
||||
"▄▀▀▀ ▄▀▀▄ █▄ ▄█ ▄▀▀▄",
|
||||
"▀▀▀▄ █ █ █ █ █ █▀▀█",
|
||||
"▄▄▄▀ ▀▄▄▀ █ █ █ █",
|
||||
)
|
||||
|
||||
MIDDLE = ("SOMA FM RADIO", "LISTENER SUPPORTED", "SAN FRANCISCO")
|
||||
def _hero_line(markup: str, index: int, width: int, gradient_fill: bool) -> Text:
|
||||
"""One row of the hero: spectrum-painted glyphs, or the art's own colours."""
|
||||
if not gradient_fill:
|
||||
line = Text.from_markup(markup)
|
||||
line.no_wrap = True
|
||||
return line
|
||||
|
||||
line = Text(no_wrap=True)
|
||||
for column, char in enumerate(markup):
|
||||
position = (column / max(1, width - 1) + index * 0.055) % 1.0
|
||||
line.append(char, style=f"bold {gradient(SPECTRUM_STOPS, position)}")
|
||||
return line
|
||||
|
||||
|
||||
class HeaderBar(Widget):
|
||||
@@ -35,54 +41,63 @@ class HeaderBar(Widget):
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
app = cast("BeefyPlayerApp", self.app)
|
||||
provider = getattr(app, "provider", None)
|
||||
hero = tuple(getattr(provider, "hero", ()) or ())
|
||||
hero_lines = tuple(getattr(provider, "hero_lines", ()) or ())
|
||||
gradient_fill = bool(getattr(provider, "hero_gradient", False))
|
||||
|
||||
width = max(24, self.size.width)
|
||||
height = max(1, self.size.height)
|
||||
state = app.playing_state
|
||||
glyph, colour = state_glyph(state)
|
||||
|
||||
logo_width = max(cell_len(row) for row in LOGO)
|
||||
middle_width = max(cell_len(row) for row in MIDDLE)
|
||||
# Measure what the hero *shows*: the rows are rich markup, so their
|
||||
# raw length says nothing about the columns they occupy.
|
||||
hero_width = max(
|
||||
(cell_len(Text.from_markup(row).plain) for row in hero), default=0
|
||||
)
|
||||
middle_width = max((cell_len(row) for row in hero_lines), default=0)
|
||||
right = (clock_time(), date_label(), f"vol {app.volume}%")
|
||||
right_width = max(cell_len(item) for item in right)
|
||||
|
||||
show_middle = width >= logo_width + middle_width + 8
|
||||
show_right = width >= logo_width + right_width + 8
|
||||
show_middle = bool(hero_lines) and width >= hero_width + middle_width + 8
|
||||
show_right = width >= hero_width + right_width + 8
|
||||
if show_middle and show_right:
|
||||
show_middle = width >= logo_width + 4 + middle_width + 2 + right_width
|
||||
show_middle = (
|
||||
width >= hero_width + 4 + middle_width + 2 + right_width
|
||||
)
|
||||
|
||||
# Centre the short text block against however tall the hero is: a
|
||||
# three-line caption beside a seven-row picture sits in the middle.
|
||||
text_offset = max(0, (len(hero) - len(hero_lines)) // 2)
|
||||
|
||||
lines: list[Text] = []
|
||||
for index, row in enumerate(LOGO):
|
||||
line = Text(no_wrap=True)
|
||||
for column, char in enumerate(row):
|
||||
position = (
|
||||
column / max(1, logo_width - 1) + index * 0.055
|
||||
) % 1.0
|
||||
line.append(
|
||||
char, style=f"bold {gradient(SPECTRUM_STOPS, position)}"
|
||||
)
|
||||
if show_middle:
|
||||
for index, row in enumerate(hero):
|
||||
line = _hero_line(row, index, hero_width, gradient_fill)
|
||||
if show_middle and text_offset <= index < text_offset + len(hero_lines):
|
||||
middle = hero_lines[index - text_offset]
|
||||
line.append(" " * 4)
|
||||
middle = MIDDLE[index]
|
||||
line.append(middle, style="dim")
|
||||
# Keep the right block on one column even though the middle
|
||||
# lines differ in length ("SOMA FM RADIO" is shorter than
|
||||
# "LISTENER SUPPORTED").
|
||||
# lines differ in length ("TILDERADIO" is shorter than
|
||||
# "STREAMED BY / FOR USERS OF THE").
|
||||
line.append(" " * max(0, middle_width - cell_len(middle)))
|
||||
if show_right:
|
||||
used = logo_width
|
||||
if show_right and text_offset <= index < text_offset + len(right):
|
||||
used = hero_width
|
||||
if show_middle:
|
||||
used += 4 + middle_width
|
||||
# Right-align the block *and* every item inside it: the clock,
|
||||
# the date and the volume each keep their own length, so pad
|
||||
# the shortest ones instead of left-aligning the whole block.
|
||||
pad = max(1, width - used - right_width)
|
||||
item = right[index]
|
||||
item = right[index - text_offset]
|
||||
line.append(" " * pad)
|
||||
line.append(" " * max(0, right_width - cell_len(item)))
|
||||
style = "bold" if index == 0 else "dim"
|
||||
style = "bold" if index == text_offset else "dim"
|
||||
line.append(item, style=style)
|
||||
lines.append(line)
|
||||
|
||||
ticker: Text | None = None
|
||||
if height >= 4:
|
||||
ticker = Text(no_wrap=True)
|
||||
prefix = f"{glyph} {state.upper():<8} "
|
||||
@@ -94,14 +109,24 @@ class HeaderBar(Widget):
|
||||
elif station is not None:
|
||||
song = station.now_playing
|
||||
if song is not None:
|
||||
text = f"{station.title} · {song.display_artist} — {song.display_title}"
|
||||
text = (
|
||||
f"{station.title} · "
|
||||
f"{song.display_artist} — {song.display_title}"
|
||||
)
|
||||
else:
|
||||
text = f"{station.title} · {station.description}"
|
||||
text_style = ""
|
||||
else:
|
||||
text = "Pick a station — press enter to tune in"
|
||||
text_style = "italic dim"
|
||||
ticker.append(marquee(text, max(4, width - cell_len(prefix))), style=text_style)
|
||||
lines.append(ticker)
|
||||
ticker.append(
|
||||
marquee(text, max(4, width - cell_len(prefix))), style=text_style
|
||||
)
|
||||
|
||||
# A short terminal loses the bottom of the hero before it loses the
|
||||
# ticker: the ticker carries the state, the art can be cropped.
|
||||
if ticker is not None:
|
||||
if len(lines) >= height:
|
||||
lines = lines[: height - 1]
|
||||
lines.append(ticker)
|
||||
return Group(*lines[:height])
|
||||
@@ -13,6 +13,7 @@ from textual.app import ComposeResult
|
||||
from textual.screen import ModalScreen
|
||||
from textual.widgets import Static
|
||||
|
||||
from ..providers import PROVIDERS, available_providers
|
||||
from .common import SPECTRUM_STOPS, gradient
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -33,7 +34,8 @@ HELP_ROWS: tuple[tuple[str, str], ...] = (
|
||||
"v",
|
||||
"cycle visualiser: mirror → bars → wave → waterfall → radial → braille",
|
||||
),
|
||||
("r", "refresh station list and song history"),
|
||||
("r", "refresh stations, schedule and song history"),
|
||||
("p", "switch station catalogue (SomaFM / tilderadio)"),
|
||||
("g / G", "jump to the top / bottom of the list"),
|
||||
("? ", "this help"),
|
||||
("q", "quit"),
|
||||
@@ -68,12 +70,40 @@ ABOUT_MACOS = (
|
||||
|
||||
class HelpScreen(ModalScreen[None]):
|
||||
def compose(self) -> ComposeResult:
|
||||
provider = cast("BeefyPlayerApp", self.app).provider
|
||||
app = cast("BeefyPlayerApp", self.app)
|
||||
provider = app.provider
|
||||
about = (
|
||||
f"Stations and song history come from {provider.name} "
|
||||
f"({provider.station_source} + {provider.history_source}).",
|
||||
) + (ABOUT_MACOS if sys.platform == "darwin" else ABOUT_LINUX)
|
||||
|
||||
catalogues = Table.grid(padding=(0, 2), expand=False)
|
||||
catalogues.add_column(style="bold cyan", no_wrap=True, justify="right")
|
||||
catalogues.add_column(style="bold", no_wrap=True)
|
||||
catalogues.add_column(style="dim")
|
||||
for slug in available_providers():
|
||||
catalogue = PROVIDERS[slug]
|
||||
marker = "●" if slug == provider.id else " "
|
||||
pane = "schedule pane" if catalogue.has_schedule else "station list"
|
||||
catalogues.add_row(
|
||||
marker, slug, f"{catalogue.name} — {pane}"
|
||||
)
|
||||
|
||||
pane_note = Text()
|
||||
if provider.has_schedule:
|
||||
pane_note.append(
|
||||
f"{provider.name} is a single stream, so the left pane is its "
|
||||
"broadcast schedule; the station filter and favourites only "
|
||||
"apply to multi-station catalogues.",
|
||||
style="dim italic",
|
||||
)
|
||||
|
||||
catalogue_title = Text()
|
||||
catalogue_title.append("CATALOGUES", style="bold cyan")
|
||||
catalogue_title.append(
|
||||
" switch with p or --provider at start", style="dim"
|
||||
)
|
||||
|
||||
table = Table.grid(padding=(0, 2), expand=False)
|
||||
table.add_column(style="bold cyan", no_wrap=True, justify="right")
|
||||
table.add_column(style="")
|
||||
@@ -90,7 +120,17 @@ class HelpScreen(ModalScreen[None]):
|
||||
for line in textwrap.wrap(paragraph, width=70):
|
||||
footer.append(line + "\n", style="dim")
|
||||
|
||||
body = Group(header, Text(""), table, Text(""), footer)
|
||||
body = Group(
|
||||
header,
|
||||
Text(""),
|
||||
table,
|
||||
Text(""),
|
||||
pane_note,
|
||||
catalogue_title,
|
||||
catalogues,
|
||||
Text(""),
|
||||
footer,
|
||||
)
|
||||
yield Static(body, id="help")
|
||||
|
||||
def on_key(self, event) -> None:
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Catalogue picker: switch between SomaFM, tilderadio and whatever comes next."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from rich.console import Group
|
||||
from rich.text import Text
|
||||
from textual.app import ComposeResult
|
||||
from textual.screen import ModalScreen
|
||||
from textual.widgets import Static
|
||||
|
||||
from ..providers import PROVIDERS, available_providers
|
||||
from .common import SPECTRUM_STOPS, gradient, truncate
|
||||
|
||||
|
||||
class ProviderScreen(ModalScreen[str | None]):
|
||||
"""A list of catalogues; enter selects, esc leaves everything as it was."""
|
||||
|
||||
def __init__(self, current_id: str = "") -> None:
|
||||
super().__init__()
|
||||
self.slugs: tuple[str, ...] = available_providers()
|
||||
self.current_id = current_id
|
||||
self.index = self.slugs.index(current_id) if current_id in self.slugs else 0
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Static(self._body(), id="providers")
|
||||
|
||||
def _body(self) -> Group:
|
||||
header = Text()
|
||||
header.append("◤ ", style=f"bold {gradient(SPECTRUM_STOPS, 0.9)}")
|
||||
header.append("CATALOGUES", style=f"bold {gradient(SPECTRUM_STOPS, 0.55)}")
|
||||
header.append(" pick a station catalogue", style="dim")
|
||||
|
||||
rows: list[Text] = []
|
||||
for index, slug in enumerate(self.slugs):
|
||||
provider = PROVIDERS[slug]
|
||||
selected = index == self.index
|
||||
row = Text(no_wrap=True)
|
||||
row.append("▶ " if selected else " ", style="bold magenta" if selected else "")
|
||||
row.append(f"{index + 1} ", style="dim")
|
||||
name_style = "bold underline" if selected else "bold"
|
||||
row.append(f"{provider.name:<12}", style=name_style)
|
||||
row.append(f" {provider.brand}", style="dim")
|
||||
if slug == self.current_id:
|
||||
row.append(" ●", style="bold green")
|
||||
rows.append(row)
|
||||
|
||||
detail = Text(no_wrap=True)
|
||||
detail.append(" ", style="")
|
||||
detail.append(truncate(provider.tagline, 54), style="italic dim")
|
||||
rows.append(detail)
|
||||
|
||||
current = PROVIDERS.get(self.current_id)
|
||||
footer = Text()
|
||||
footer.append("\n")
|
||||
if current is not None:
|
||||
footer.append(
|
||||
f"now: {current.name} — {current.station_source}\n", style="dim"
|
||||
)
|
||||
footer.append(
|
||||
"↑ ↓ choose · 1–9 jump · enter switch · esc cancel",
|
||||
style="dim",
|
||||
)
|
||||
return Group(header, Text(""), *rows, footer)
|
||||
|
||||
# -- interaction ----------------------------------------------------- #
|
||||
|
||||
def _move(self, delta: int) -> None:
|
||||
self.index = (self.index + delta) % len(self.slugs)
|
||||
self.query_one("#providers", Static).update(self._body())
|
||||
|
||||
def on_key(self, event) -> None:
|
||||
key = event.key
|
||||
if key in ("up", "k", "shift+tab"):
|
||||
self._move(-1)
|
||||
elif key in ("down", "j", "tab"):
|
||||
self._move(1)
|
||||
elif key in ("enter", "space"):
|
||||
self.dismiss(self.slugs[self.index])
|
||||
elif key == "escape" or key == "q":
|
||||
self.dismiss(None)
|
||||
elif key.isdigit() and key != "0":
|
||||
choice = int(key) - 1
|
||||
if 0 <= choice < len(self.slugs):
|
||||
self.dismiss(self.slugs[choice])
|
||||
else:
|
||||
return
|
||||
event.stop()
|
||||
@@ -0,0 +1,137 @@
|
||||
"""The left pane for single-stream catalogues: what is on, what is next.
|
||||
|
||||
A catalogue with one station does not need a station list, but it usually has
|
||||
a broadcast schedule. This pane answers the two questions a listener actually
|
||||
has — "who is on air?" and "when is the next show?" — in the space the list
|
||||
would have used.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
from rich.console import Group, RenderableType
|
||||
from rich.text import Text
|
||||
from textual.widget import Widget
|
||||
|
||||
from .common import local_time, local_zone, state_glyph, truncate
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..app import BeefyPlayerApp
|
||||
from ..models import Broadcast
|
||||
|
||||
|
||||
def _window(broadcast: Broadcast | None) -> str:
|
||||
"""``since 22:00 · until 23:00`` — whichever ends the feed actually sent."""
|
||||
if broadcast is None:
|
||||
return ""
|
||||
start = local_time(broadcast.start) if broadcast.start else ""
|
||||
end = local_time(broadcast.end) if broadcast.end else ""
|
||||
if start and end:
|
||||
return f"since {start} · until {end}"
|
||||
if start:
|
||||
return f"since {start}"
|
||||
if end:
|
||||
return f"until {end}"
|
||||
return ""
|
||||
|
||||
|
||||
def _day_label(day: date, today: date) -> str:
|
||||
if day == today:
|
||||
return "TODAY"
|
||||
if day == today + timedelta(days=1):
|
||||
return "TOMORROW"
|
||||
return day.strftime("%a %d %b").upper()
|
||||
|
||||
|
||||
def _upcoming_rows(
|
||||
upcoming: list[Broadcast], width: int, budget: int
|
||||
) -> list[Text]:
|
||||
"""Upcoming shows, grouped by local day, clipped to the pane."""
|
||||
zone = local_zone()
|
||||
today = datetime.now(zone).date()
|
||||
|
||||
# (is a show, its line) so the "… +N more" count talks about shows and
|
||||
# not about the day headings in between.
|
||||
rows: list[tuple[bool, Text]] = []
|
||||
current_day: date | None = None
|
||||
for item in upcoming:
|
||||
if item.start is None:
|
||||
continue
|
||||
day = item.start.astimezone(zone).date()
|
||||
if day != current_day:
|
||||
current_day = day
|
||||
rows.append((False, Text(_day_label(day, today), style="dim")))
|
||||
row = Text(no_wrap=True)
|
||||
row.append(" ")
|
||||
row.append(local_time(item.start), style="bold")
|
||||
row.append(" ")
|
||||
row.append(truncate(item.display_name, max(4, width - 7)))
|
||||
rows.append((True, row))
|
||||
|
||||
if len(rows) > budget:
|
||||
kept = rows[: max(0, budget - 1)]
|
||||
while kept and not kept[-1][0]:
|
||||
kept.pop()
|
||||
dropped = sum(1 for is_show, _ in rows[len(kept) :] if is_show)
|
||||
lines = [text for _, text in kept]
|
||||
if dropped:
|
||||
lines.append(Text(f"… +{dropped} more", style="dim italic"))
|
||||
return lines
|
||||
return [text for _, text in rows]
|
||||
|
||||
|
||||
class SchedulePanel(Widget):
|
||||
can_focus = False
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.border_title = "SCHEDULE"
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
app = cast("BeefyPlayerApp", self.app)
|
||||
width = max(12, self.size.width)
|
||||
height = max(1, self.size.height)
|
||||
|
||||
broadcasts = list(getattr(app, "schedule", ()) or ())
|
||||
loaded = bool(getattr(app, "schedule_loaded", False))
|
||||
live = next((item for item in broadcasts if item.live), None)
|
||||
upcoming = [item for item in broadcasts if not item.live]
|
||||
|
||||
lines: list[Text] = []
|
||||
|
||||
head = Text(no_wrap=True)
|
||||
glyph, colour = state_glyph("playing" if live else "stopped")
|
||||
head.append(f"{glyph} ", style=f"bold {colour}")
|
||||
head.append("ON AIR" if live else "AUTO DJ", style="bold magenta")
|
||||
if live is not None:
|
||||
head.append(" ", style="")
|
||||
head.append(
|
||||
truncate(live.display_name, max(4, width - 12)),
|
||||
style="bold",
|
||||
)
|
||||
lines.append(head)
|
||||
|
||||
window = _window(live)
|
||||
lines.append(
|
||||
Text(truncate(window, width), style="dim")
|
||||
if window
|
||||
else Text("library rotation", style="dim italic")
|
||||
)
|
||||
lines.append(Text(""))
|
||||
lines.append(Text("UPCOMING", style="bold cyan"))
|
||||
|
||||
if not loaded:
|
||||
lines.append(Text("loading schedule…", style="dim italic"))
|
||||
elif not upcoming:
|
||||
lines.append(Text("nothing scheduled", style="dim italic"))
|
||||
else:
|
||||
lines.extend(
|
||||
_upcoming_rows(upcoming, width, max(0, height - 1 - len(lines)))
|
||||
)
|
||||
|
||||
lines.append(Text(""))
|
||||
while len(lines) < height:
|
||||
lines.append(Text(""))
|
||||
return Group(*lines[:height])
|
||||
Reference in new issue
Block a user