feat(providers): add a pluggable station-catalogue seam
The app now holds one RadioProvider and asks it for stations, song history and stream URLs instead of importing SomaFM's HTTP client directly. - models.py: provider-neutral Song/Station plus the shared parsers every catalogue needs (Station.provider/.uid keep ids unique once there is more than one). - providers/base.py: the RadioProvider interface and the toolkit it needs in practice - httpx client factory, .pls/.m3u resolution with an 8h disk cache, and a default stream_candidates() strategy. - providers/somafm/: everything SomaFM-specific (channels.json, songs/<id>.json, PLS layout, guessed icecast mirrors) behind SomaFMProvider. - providers/__init__.py: registry, get_provider() and --provider, remembered in the config file. Widgets render branding, splash copy and the help credits from the active provider, so a second catalogue needs no UI changes; with SomaFM selected the screen is byte-identical to before.
This commit is contained in:
1 parent
e30ca09627
commit
c4cc91ee1a
13 files changed
+708
-372
No files matched your search
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import textwrap
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
from rich.console import Group
|
||||
from rich.table import Table
|
||||
@@ -14,6 +15,9 @@ from textual.widgets import Static
|
||||
|
||||
from .common import SPECTRUM_STOPS, gradient
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..app import BeefyPlayerApp
|
||||
|
||||
HELP_ROWS: tuple[tuple[str, str], ...] = (
|
||||
("↑ / ↓ j / k", "move through the station list"),
|
||||
("enter", "tune in to the selected station"),
|
||||
@@ -35,13 +39,6 @@ HELP_ROWS: tuple[tuple[str, str], ...] = (
|
||||
("q", "quit"),
|
||||
)
|
||||
|
||||
ABOUT_COMMON = (
|
||||
(
|
||||
"Stations and song history come from somafm.com "
|
||||
"(channels.json + songs/<station>.json)."
|
||||
),
|
||||
)
|
||||
|
||||
ABOUT_LINUX = (
|
||||
(
|
||||
"The spectrum captures your actual PulseAudio/PipeWire output with "
|
||||
@@ -50,7 +47,7 @@ ABOUT_LINUX = (
|
||||
),
|
||||
(
|
||||
"The desktop picks us up over MPRIS as a "
|
||||
"somafmtui media player — try `playerctl -l` / `playerctl metadata`."
|
||||
"beefyplayer media player — try `playerctl -l` / `playerctl metadata`."
|
||||
),
|
||||
)
|
||||
|
||||
@@ -68,11 +65,15 @@ ABOUT_MACOS = (
|
||||
),
|
||||
)
|
||||
|
||||
ABOUT = ABOUT_COMMON + (ABOUT_MACOS if sys.platform == "darwin" else ABOUT_LINUX)
|
||||
|
||||
|
||||
class HelpScreen(ModalScreen[None]):
|
||||
def compose(self) -> ComposeResult:
|
||||
provider = cast("BeefyPlayerApp", self.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)
|
||||
|
||||
table = Table.grid(padding=(0, 2), expand=False)
|
||||
table.add_column(style="bold cyan", no_wrap=True, justify="right")
|
||||
table.add_column(style="")
|
||||
@@ -81,11 +82,11 @@ class HelpScreen(ModalScreen[None]):
|
||||
|
||||
header = Text()
|
||||
header.append("◤ ", style=f"bold {gradient(SPECTRUM_STOPS, 0.9)}")
|
||||
header.append("SOMAFM TUI", style=f"bold {gradient(SPECTRUM_STOPS, 0.55)}")
|
||||
header.append("BEEFY PLAYER", style=f"bold {gradient(SPECTRUM_STOPS, 0.55)}")
|
||||
header.append(" keyboard & credits", style="dim")
|
||||
|
||||
footer = Text()
|
||||
for paragraph in ABOUT:
|
||||
for paragraph in about:
|
||||
for line in textwrap.wrap(paragraph, width=70):
|
||||
footer.append(line + "\n", style="dim")
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ from textual.containers import Horizontal
|
||||
from textual.css.query import NoMatches
|
||||
from textual.widget import Widget
|
||||
|
||||
from ..api import Song, Station
|
||||
from ..models import Song, Station
|
||||
from .art import ArtPane
|
||||
from .common import (
|
||||
compact_count,
|
||||
@@ -23,7 +23,7 @@ from .common import (
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..app import SomaFMApp
|
||||
from ..app import BeefyPlayerApp
|
||||
|
||||
|
||||
class StagePanel(Widget):
|
||||
@@ -75,27 +75,30 @@ class StageInfo(Widget):
|
||||
can_focus = False
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
app = cast("SomaFMApp", self.app)
|
||||
app = cast("BeefyPlayerApp", self.app)
|
||||
width = max(8, self.size.width)
|
||||
height = max(1, self.size.height)
|
||||
station = app.current
|
||||
|
||||
if station is None:
|
||||
return Group(*self._splash(width, height))
|
||||
return Group(*self._splash(app, width, height))
|
||||
|
||||
details = self._details(app, station, station.now_playing, width)
|
||||
return Group(*details[:height])
|
||||
|
||||
def _splash(self, width: int, height: int) -> list[Text]:
|
||||
def _splash(
|
||||
self, app: BeefyPlayerApp, width: int, height: int
|
||||
) -> list[Text]:
|
||||
provider = app.provider
|
||||
lines = [
|
||||
Text("◤ SOMA FM ◢", style="bold magenta"),
|
||||
Text("listener-supported radio · san francisco", style="italic dim"),
|
||||
Text(f"◤ {provider.brand} ◢", style="bold magenta"),
|
||||
Text(provider.tagline, style="italic dim"),
|
||||
Text(""),
|
||||
Text("↑ ↓ choose a station", style=""),
|
||||
Text("⏎ tune in", style=""),
|
||||
Text("? all keyboard shortcuts", style=""),
|
||||
Text(""),
|
||||
Text("Stations load from somafm.com/channels.json", style="dim"),
|
||||
Text(f"Stations load from {provider.station_source}", style="dim"),
|
||||
]
|
||||
return [
|
||||
Text(truncate(line.plain, width), style=line.style)
|
||||
@@ -104,7 +107,7 @@ class StageInfo(Widget):
|
||||
|
||||
def _details(
|
||||
self,
|
||||
app: SomaFMApp,
|
||||
app: BeefyPlayerApp,
|
||||
station: Station,
|
||||
song: Song | None,
|
||||
width: int,
|
||||
@@ -188,7 +191,7 @@ class HistoryPane(Widget):
|
||||
can_focus = False
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
app = cast("SomaFMApp", self.app)
|
||||
app = cast("BeefyPlayerApp", self.app)
|
||||
width = max(8, self.size.width)
|
||||
height = max(1, self.size.height)
|
||||
station = app.current
|
||||
|
||||
Reference in new issue
Block a user