refactor!: rename somafm-tui to beefy-player

The app is now a general internet-radio player rather than a SomaFM client, so the package, console script, MPRIS identity, mpv namespaces, macOS tap names, XDG directories and docs all move to beefy-player.

Environment variables are now BEEFY_* (the old SOMAFM_* spellings are still honoured) and an existing ~/.config/somafm-tui/config.json is migrated on first run, so favourites and volume survive the rename.

The visible interface is untouched: same layout, same logo, same keys.
This commit is contained in:
beefcarver committed 2026-11-03 16:10:25 -05:00
1 parent 4bed90b489
commit e30ca09627
44 files changed
+329 -192

No files matched your search

+1
View File
@@ -0,0 +1 @@
"""Custom Textual widgets for Beefy Player."""
+409
View File
@@ -0,0 +1,409 @@
"""ON AIR artwork: real terminal images over an always-present ANSI mosaic."""
from __future__ import annotations
import base64
import contextlib
import itertools
import os
import random
import sys
from io import BytesIO
from typing import TYPE_CHECKING, cast
from rich.console import Group, RenderableType
from rich.control import Control
from rich.segment import ControlType, Segment
from rich.style import Style
from rich.text import Text
from textual.dom import NoScreen
from textual.geometry import Region
from textual.strip import Strip
from textual.widget import Widget
from .. import artwork
from .common import art_lines
_NULL_STYLE = Style()
_NULL_CONTROL = ((ControlType.CURSOR_FORWARD, 0),)
if TYPE_CHECKING:
from ..app import BeefyPlayerApp
_IMAGE_IDS = itertools.count(random.randrange(1, 2**31 - 1))
def _tgp(data: str) -> str:
return f"\x1b_G{data}\x1b\\"
def _iterm2_native() -> bool:
"""Are we talking to iTerm2 itself (rather than a compatible terminal)?
WezTerm understands the same OSC 1337 payload but is fussier about
parameters it does not know, so the iTerm2-only ones stay gated.
"""
env = os.environ
return env.get("LC_TERMINAL", "").lower() == "iterm2" or env.get(
"TERM_PROGRAM", ""
).lower() in {"iterm.app", "iterm2"}
def _art_key(app) -> str:
"""Identity of what the mosaic is drawing (station + current track)."""
station = app.current
if station is None:
return ""
song = station.now_playing
artist = song.artist if song is not None else ""
title = song.title if song is not None else ""
return f"{station.id}|{artist}|{title}"
def _terminal_wrap(sequence: str) -> str:
"""Wrap Kitty sequences for tmux DCS passthrough."""
term = os.environ.get("TERM", "")
if os.environ.get("TMUX") or term.startswith(("screen", "tmux")):
return (
"\x1bPtmux;\x1b"
+ sequence.replace("\x1b", "\x1b\x1b")
+ "\x1b\\"
)
return sequence
class KittyImage(Widget):
"""Direct Kitty graphics placement (no Unicode placeholders or probe)."""
DEFAULT_CSS = """
KittyImage {
width: 100%;
height: 100%;
}
"""
can_focus = False
def __init__(self, image) -> None:
super().__init__()
self._image = image
self._image_id: int | None = None
self._delete_ids: list[int] = []
self._placed_key: tuple[int, int, int] | None = None
@property
def image(self):
return self._image
@image.setter
def image(self, value) -> None:
if self._image_id is not None and value is not self._image:
self._delete_ids.append(self._image_id)
self._image_id = None
self._image = value
self._placed_key = None
self.refresh(layout=True)
def _sequences(self, width: int, height: int) -> list[str]:
key = (id(self._image), width, height)
if self._image_id is not None and self._placed_key == key:
return []
buffer = BytesIO()
self._image.save(buffer, format="PNG")
payload = base64.b64encode(buffer.getvalue()).decode("ascii")
chunks = [payload[i : i + 4096] for i in range(0, len(payload), 4096)]
commands: list[str] = []
for old_id in self._delete_ids:
commands.append(_terminal_wrap(_tgp(f"a=d,i={old_id},q=2")))
self._delete_ids.clear()
image_id = next(_IMAGE_IDS)
for index, chunk in enumerate(chunks):
more = 1 if index < len(chunks) - 1 else 0
commands.append(
_terminal_wrap(
_tgp(f"a=t,i={image_id},f=100,m={more},q=2;{chunk}")
)
)
commands.append(
_terminal_wrap(
_tgp(f"a=p,i={image_id},c={width},r={height},C=1,q=2")
)
)
self._image_id = image_id
self._placed_key = key
return commands
def cleanup(self) -> None:
ids = list(self._delete_ids)
if self._image_id is not None:
ids.append(self._image_id)
self._image_id = None
self._delete_ids.clear()
self._placed_key = None
stdout = sys.__stdout__
if stdout is None:
return
for image_id in ids:
with contextlib.suppress(Exception):
stdout.write(
_terminal_wrap(_tgp(f"a=d,i={image_id},q=2"))
)
with contextlib.suppress(Exception):
stdout.flush()
def render_lines(self, crop: Region) -> list[Strip]:
empty = [Strip([]) for _ in range(max(0, crop.height))]
if self._image is None:
return empty
try:
if not self.screen.is_active:
return empty
except NoScreen:
return empty
width = self.content_size.width
height = self.content_size.height
if width <= 0 or height <= 0:
return empty
commands = self._sequences(width, height)
if not commands:
return empty
try:
visible = self.screen.find_widget(self).visible_region
except NoScreen:
return empty
segments: list[Segment] = [
Segment(
Control.move_to(visible.x, visible.y).segment.text,
style=_NULL_STYLE,
)
]
for command in commands:
segments.append(
Segment(command, style=_NULL_STYLE, control=_NULL_CONTROL)
)
segments.append(
Segment(
Control.move_to(visible.right, visible.bottom).segment.text,
style=_NULL_STYLE,
)
)
last = Strip(segments, cell_length=max(1, crop.width))
return [Strip([]) for _ in range(max(0, crop.height - 1))] + [last]
class ITerm2Image(Widget):
"""Inline image via OSC 1337, injected through Textual's render_lines().
WezTerm and iTerm2 both implement this natively. ``doNotMoveCursor=1``
(iTerm2 only) keeps the terminal cursor sane; the widget then restores it
to the bottom-right of the region.
"""
DEFAULT_CSS = """
ITerm2Image {
width: 100%;
height: 100%;
}
"""
can_focus = False
def __init__(self, image) -> None:
super().__init__()
self._image = image
self._cache_key: tuple[int, int, int] | None = None
self._cache_data = ""
@property
def image(self):
return self._image
@image.setter
def image(self, value) -> None:
self._image = value
self._cache_key = None
self._cache_data = ""
self.refresh(layout=True)
def _sequence(self, width: int, height: int) -> str:
key = (id(self._image), width, height)
if key == self._cache_key:
return self._cache_data
buffer = BytesIO()
self._image.save(buffer, format="PNG")
payload = base64.b64encode(buffer.getvalue()).decode("ascii")
# ``doNotMoveCursor`` keeps iTerm2 from scrolling/jumping the cursor
# out from under Textual's compositor; we place the cursor ourselves.
keep_cursor = "doNotMoveCursor=1;" if _iterm2_native() else ""
data = (
"\x1b]1337;File=inline=1;"
f"{keep_cursor}"
f"width={width};height={height};"
f"preserveAspectRatio=0:{payload}\x07"
)
self._cache_key = key
self._cache_data = data
return data
def render_lines(self, crop: Region) -> list[Strip]:
empty = [Strip([]) for _ in range(max(0, crop.height))]
if self._image is None:
return empty
try:
if not self.screen.is_active:
return empty
except NoScreen:
return empty
width = self.content_size.width
height = self.content_size.height
if width <= 0 or height <= 0:
return empty
try:
visible = self.screen.find_widget(self).visible_region
except NoScreen:
return empty
segments = [
Segment(
Control.move_to(visible.x, visible.y).segment.text,
style=_NULL_STYLE,
),
Segment(
self._sequence(width, height),
style=_NULL_STYLE,
control=_NULL_CONTROL,
),
Segment(
Control.move_to(visible.right, visible.bottom).segment.text,
style=_NULL_STYLE,
),
]
last = Strip(segments, cell_length=max(1, crop.width))
return [Strip([]) for _ in range(max(0, crop.height - 1))] + [last]
class ArtPane(Widget):
"""Mosaic background plus an optional protocol-backed image overlay.
The mosaic is always rendered by this widget. If the terminal image
protocol silently fails (or an image is transparent), the artwork area is
still not blank.
"""
can_focus = False
def __init__(self) -> None:
super().__init__()
self._image_widget: Widget | None = None
self._last_data: bytes | None = None
self._last_size: tuple[int, int] = (0, 0)
self._mosaic_key = ""
# ------------------------------------------------------------------ #
# mosaic background
# ------------------------------------------------------------------ #
def render(self) -> RenderableType:
app = cast("BeefyPlayerApp", self.app)
station = app.current
if station is None:
return Text("")
width = max(1, self.size.width)
height = max(1, self.size.height)
return Group(*art_lines(_art_key(app), width, height))
# ------------------------------------------------------------------ #
# image overlay
# ------------------------------------------------------------------ #
def on_mount(self) -> None:
self.sync_image()
def on_resize(self, event) -> None:
self.refresh()
if self.display:
self.sync_image()
def on_unmount(self) -> None:
self._drop_image_widget()
def sync_image(self, *, force: bool = False) -> None:
# Keep the mosaic behind the image in sync with the current track.
self.refresh()
app = cast("BeefyPlayerApp", self.app)
station = app.current
data = app.artwork_bytes
width = max(1, self.size.width)
height = max(1, self.size.height)
# Repainting the mosaic overwrites the cells an inline (iTerm2) image
# occupies, so re-emit the image whenever what is drawn underneath
# changes -- even if the artwork bytes happen to be the same.
mosaic_key = _art_key(app)
if mosaic_key != self._mosaic_key:
self._mosaic_key = mosaic_key
force = True
if station is not None and data and artwork.supported():
if (
not force
and self._image_widget is not None
and self._last_data is data
and self._last_size == (width, height)
):
return
fitted = artwork.fit_image(data, width, height)
if fitted is not None:
self._show_image(fitted, data, width, height)
return
self._show_fallback()
def _show_image(self, image, data: bytes, width: int, height: int) -> None:
if self._image_widget is None:
if artwork.PROTOCOL == "tgp":
widget_class = KittyImage
elif artwork.PROTOCOL == "iterm2":
widget_class = ITerm2Image
else:
self._show_fallback()
return
try:
widget = widget_class(image)
except Exception:
self._show_fallback()
return
widget.add_class("art-image")
self._image_widget = widget
self.mount(widget)
else:
self._image_widget.image = image
self._last_data = data
self._last_size = (width, height)
def _show_fallback(self) -> None:
self._drop_image_widget()
def _drop_image_widget(self) -> None:
widget = self._image_widget
self._image_widget = None
self._last_data = None
self._last_size = (0, 0)
if widget is None:
return
with contextlib.suppress(Exception):
widget.cleanup()
with contextlib.suppress(Exception):
widget.image = None
widget.remove()
+212
View File
@@ -0,0 +1,212 @@
"""Small rendering helpers shared by the widgets.
Every colour in the UI is an ANSI palette name (``cyan``, ``bright_magenta``,
``dim`` …), never an RGB value. That way the TUI adopts whatever colours the
terminal is configured with, and nothing sets a background colour, so
transparent terminals stay transparent.
"""
from __future__ import annotations
import hashlib
import math
import os
import time
from datetime import datetime, timezone, tzinfo
from functools import lru_cache
from zoneinfo import ZoneInfo
from rich.cells import cell_len
from rich.text import Text
LOCALTIME_PATH = "/etc/localtime"
_UTC_NAMES = {"UTC", "UCT", "GMT", "ETC/UTC", "ETC/UCT", "ETC/GMT", "Z"}
LEVELS = " ▁▂▃▄▅▆▇█"
# Bottom-to-top spectrum colours, picked from the terminal's stock ANSI palette.
SPECTRUM_STOPS = (
"cyan",
"blue",
"magenta",
"bright_magenta",
"yellow",
"bright_yellow",
"red",
)
ART_STOPS = ("blue", "magenta", "bright_magenta", "yellow", "cyan", "green")
STATE_STYLE = {
"playing": ("●", "green"),
"loading": ("◌", "yellow"),
"buffering": ("◌", "yellow"),
"paused": ("❚❚", "magenta"),
"stopped": ("○", "bright_black"),
"error": ("✕", "red"),
}
def truncate(text: str, width: int) -> str:
if width <= 0:
return ""
if cell_len(text) <= width:
return text
if width == 1:
return "…"
kept: list[str] = []
used = 0
for char in text:
char_width = cell_len(char)
if used + char_width > width - 1:
break
kept.append(char)
used += char_width
return "".join(kept) + "…"
def fit(text: str, width: int) -> str:
clipped = truncate(text, width)
return clipped + " " * max(0, width - cell_len(clipped))
def compact_count(value: int) -> str:
if value <= 0:
return "—"
if value >= 1_000_000:
return f"{value / 1_000_000:.1f}M"
if value >= 1_000:
return f"{value / 1_000:.1f}k"
return str(value)
def time_ago(moment: datetime | None, now: datetime | None = None) -> str:
if moment is None:
return ""
now = now or datetime.now(timezone.utc)
if moment.tzinfo is None:
moment = moment.replace(tzinfo=timezone.utc)
seconds = max(0, int((now - moment).total_seconds()))
if seconds < 60:
return f"{seconds}s ago"
if seconds < 3600:
return f"{seconds // 60}m ago"
if seconds < 86_400:
return f"{seconds // 3600}h ago"
return f"{seconds // 86_400}d ago"
def _zone_from_file(path: str) -> tzinfo | None:
try:
with open(path, "rb") as handle:
return ZoneInfo.from_file(handle)
except (OSError, ValueError):
return None
@lru_cache(maxsize=1)
def local_zone() -> tzinfo:
"""Return the user's local timezone.
A nix-shell (or an FHS environment) can inherit a build-time ``TZ=UTC``,
which would otherwise make Python show UTC. Prefer an explicit non-UTC
``TZ``, then the system's ``/etc/localtime`` (the host's configured zone),
and only then Python's idea of local time.
"""
tz_name = (os.environ.get("TZ") or "").strip()
if tz_name and tz_name.upper() not in _UTC_NAMES:
try:
return ZoneInfo(tz_name)
except Exception:
pass
zone = _zone_from_file(LOCALTIME_PATH)
if zone is not None:
return zone
if tz_name:
try:
return ZoneInfo(tz_name)
except Exception:
pass
return datetime.now().astimezone().tzinfo or timezone.utc
def _local(moment: datetime | None) -> datetime:
zone = local_zone()
if moment is None:
return datetime.now(zone)
if moment.tzinfo is None:
return moment.replace(tzinfo=zone)
return moment.astimezone(zone)
def clock_time(moment: datetime | None = None) -> str:
return _local(moment).strftime("%H:%M:%S")
def date_label(moment: datetime | None = None) -> str:
return _local(moment).strftime("%a %d %b")
def local_time(moment: datetime | None) -> str:
if moment is None:
return "--:--"
return _local(moment).strftime("%H:%M")
def gradient(stops: tuple[str, ...], position: float) -> str:
"""Pick a colour from a stock palette (no RGB interpolation)."""
if not stops:
return "white"
position = max(0.0, min(1.0, position))
index = min(len(stops) - 1, int(position * len(stops)))
return stops[index]
def marquee(
text: str, width: int, now: float | None = None, speed: float = 7.0, gap: int = 5
) -> str:
if width <= 0:
return ""
if cell_len(text) <= width:
return text
now = time.monotonic() if now is None else now
cycle = text + " " * gap
length = max(1, cell_len(cycle))
offset = int(now * speed) % length
return truncate(cycle[offset:] + cycle[:offset], width)
def state_glyph(state: str) -> tuple[str, str]:
return STATE_STYLE.get(state, STATE_STYLE["stopped"])
def art_lines(key: str, width: int, height: int) -> list[Text]:
"""Procedural album art as a solid ANSI-colour mosaic.
Filled block glyphs are used (never cell backgrounds), so the cover is
opaque while everything around it stays transparent.
"""
width = max(1, width)
height = max(1, height)
seed = int(hashlib.sha1(key.encode("utf-8")).hexdigest()[:12], 16)
lines: list[Text] = []
for row in range(height):
line = Text(no_wrap=True)
ny = row / max(1, height - 1)
for column in range(width):
nx = column / max(1, width - 1)
wave = 0.5 + 0.5 * math.sin(nx * 2.9 + ny * 2.1 + (seed % 11))
position = (nx * 0.55 + (1.0 - ny) * 0.30 + wave * 0.15) % 1.0
colour = gradient(ART_STOPS, position)
# Mostly solid blocks with a little texture, like a tiny cover.
glyph = "█" if ((seed + column * 7 + row * 11) % 17) not in (0, 5) else "▓"
line.append(glyph, style=colour)
lines.append(line)
return lines
def rule(width: int, color: str = "bright_black", char: str = "─") -> Text:
return Text(char * max(0, width), style=color)
+107
View File
@@ -0,0 +1,107 @@
"""Header: logo, clock and a live ticker."""
from __future__ import annotations
from typing import TYPE_CHECKING, cast
from rich.cells import cell_len
from rich.console import Group, RenderableType
from rich.text import Text
from textual.widget import Widget
from .common import (
SPECTRUM_STOPS,
clock_time,
date_label,
gradient,
marquee,
state_glyph,
)
if TYPE_CHECKING:
from ..app import BeefyPlayerApp
LOGO = (
"▄▀▀▀ ▄▀▀▄ █▄ ▄█ ▄▀▀▄",
"▀▀▀▄ █ █ █ █ █ █▀▀█",
"▄▄▄▀ ▀▄▄▀ █ █ █ █",
)
MIDDLE = ("SOMA FM RADIO", "LISTENER SUPPORTED", "SAN FRANCISCO")
class HeaderBar(Widget):
can_focus = False
def render(self) -> RenderableType:
app = cast("BeefyPlayerApp", self.app)
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)
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
if show_middle and show_right:
show_middle = width >= logo_width + 4 + middle_width + 2 + right_width
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:
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").
line.append(" " * max(0, middle_width - cell_len(middle)))
if show_right:
used = logo_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]
line.append(" " * pad)
line.append(" " * max(0, right_width - cell_len(item)))
style = "bold" if index == 0 else "dim"
line.append(item, style=style)
lines.append(line)
if height >= 4:
ticker = Text(no_wrap=True)
prefix = f"{glyph} {state.upper():<8} "
ticker.append(prefix, style=f"bold {colour}")
station = app.current
if app.notice:
text = app.notice
text_style = "italic yellow"
elif station is not None:
song = station.now_playing
if song is not None:
text = f"{station.title} · {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)
return Group(*lines[:height])
+97
View File
@@ -0,0 +1,97 @@
"""Full-screen keyboard help."""
from __future__ import annotations
import sys
import textwrap
from rich.console import Group
from rich.table import Table
from rich.text import Text
from textual.app import ComposeResult
from textual.screen import ModalScreen
from textual.widgets import Static
from .common import SPECTRUM_STOPS, gradient
HELP_ROWS: tuple[tuple[str, str], ...] = (
("↑ / ↓ j / k", "move through the station list"),
("enter", "tune in to the selected station"),
("space", "play / pause the current station"),
("s", "stop playback"),
("] / [", "next / previous station (tunes immediately)"),
("+ / -", "volume up / down"),
("m", "mute / unmute"),
("f", "favourite — favourites float to the top"),
("/", "filter stations by title, genre or description"),
("esc", "clear the filter / close overlays"),
(
"v",
"cycle visualiser: mirror → bars → wave → waterfall → radial → braille",
),
("r", "refresh station list and song history"),
("g / G", "jump to the top / bottom of the list"),
("? ", "this help"),
("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 "
"parec (SYNC); otherwise it falls back to a muted ffmpeg stream tap "
"(FFT, can drift) or a synthesized display (SIM)."
),
(
"The desktop picks us up over MPRIS as a "
"somafmtui media player — try `playerctl -l` / `playerctl metadata`."
),
)
ABOUT_MACOS = (
(
"The spectrum captures your Mac's real audio output (SYNC) with a "
"native Core Audio process tap — nothing to install. macOS asks for "
"System Audio Recording permission the first time; without it the "
"analyser falls back to a muted ffmpeg stream tap (FFT, can drift) "
"or a synthesized display (SIM)."
),
(
"mpv publishes what is playing to the macOS Now Playing session, so "
"Control Center and the keyboard media keys control this app."
),
)
ABOUT = ABOUT_COMMON + (ABOUT_MACOS if sys.platform == "darwin" else ABOUT_LINUX)
class HelpScreen(ModalScreen[None]):
def compose(self) -> ComposeResult:
table = Table.grid(padding=(0, 2), expand=False)
table.add_column(style="bold cyan", no_wrap=True, justify="right")
table.add_column(style="")
for key, description in HELP_ROWS:
table.add_row(key, description)
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(" keyboard & credits", style="dim")
footer = Text()
for paragraph in ABOUT:
for line in textwrap.wrap(paragraph, width=70):
footer.append(line + "\n", style="dim")
body = Group(header, Text(""), table, Text(""), footer)
yield Static(body, id="help")
def on_key(self, event) -> None:
event.stop()
self.dismiss(None)
+14
View File
@@ -0,0 +1,14 @@
"""Filter input (hidden until you press /)."""
from __future__ import annotations
from textual.widgets import Input
class SearchInput(Input):
def __init__(self) -> None:
super().__init__(
placeholder="filter stations… (enter tune · esc close)",
id="search",
disabled=True,
)
+408
View File
@@ -0,0 +1,408 @@
"""The flashy bit: an ANSI-colour spectrum analyser / oscilloscope."""
from __future__ import annotations
import math
from collections import deque
from collections.abc import Sequence
from typing import TYPE_CHECKING, cast
from rich.console import Group, RenderableType
from rich.text import Text
from textual.widget import Widget
from ..spectrum import Frame, SpectrumEngine
from .common import LEVELS, SPECTRUM_STOPS, gradient
if TYPE_CHECKING:
from ..app import BeefyPlayerApp
#: One waterfall column per analyser tick; a deque this long covers ~25 s of
#: history at 20 Hz, plenty for even a very wide terminal.
WATERFALL_HISTORY = 512
WATERFALL_LEVELS = " ░▒▓█"
#: Braille dot bit value, indexed by sub-row and sub-column inside a cell.
BRAILLE_BITS = (
(0x01, 0x08),
(0x02, 0x10),
(0x04, 0x20),
(0x40, 0x80),
)
VISUALIZER_LABELS = {
"both": "MIRROR",
"bars": "BARS",
"wave": "WAVE",
"waterfall": "WATERFALL",
"radial": "RADIAL",
"braille": "BRAILLE",
}
class SpectrumPanel(Widget):
can_focus = False
def __init__(self, engine: SpectrumEngine) -> None:
super().__init__()
self.engine = engine
self.border_title = "SPECTRUM"
self._palettes: dict[tuple[int, int, bool], list[str]] = {}
#: Recent band frames, newest last, for the waterfall display. Filled
#: even while another mode is showing so switching to it has history.
self._history: deque[tuple[float, ...]] = deque(maxlen=WATERFALL_HISTORY)
self._history_tick: int | None = None
# Called from the app ticker so the border title tracks the analyser mode.
def sync(self, mode: str, status: str) -> None:
label = VISUALIZER_LABELS.get(mode, mode.upper())
title = f"SPECTRUM · {label} · {status}"
if self.border_title != title:
self.border_title = title
self.refresh()
def render(self) -> RenderableType:
app = cast("BeefyPlayerApp", self.app)
frame = self.engine.snapshot()
width = max(8, self.size.width)
height = max(2, self.size.height)
mode = getattr(app, "visualizer", "both")
phase = int(getattr(app, "tick_count", 0) // 2)
dim = frame.status == "idle"
self._record(frame, int(getattr(app, "tick_count", 0)))
lines: list[Text]
if mode == "wave":
lines = self._wave(frame, width, height, phase, dim)
elif mode == "braille":
lines = self._braille(frame, width, height, phase, dim)
elif mode == "waterfall":
lines = self._waterfall(width, height, dim)
elif mode == "radial":
lines = self._radial(frame, width, height, phase, dim)
elif mode == "bars" or height < 8:
lines = self._bars(frame, width, height, phase, dim)
else:
# Default mode: a full-height mirrored spectrum. The top half
# grows up from the centre line, the bottom half grows down from
# it (same values, flipped rows, dimmed like a reflection).
top_height = height // 2
bottom_height = height - top_height
lines = self._bars(frame, width, top_height, phase, dim)
mirrored = self._bars(
frame, width, bottom_height, phase + 5, True, peaks=False
)
mirrored.reverse()
lines.extend(mirrored)
return Group(*lines)
# ------------------------------------------------------------------ #
# history
# ------------------------------------------------------------------ #
def _record(self, frame: Frame, tick: int) -> None:
"""Remember one column per analyser tick (not one per render)."""
if tick == self._history_tick:
return
self._history_tick = tick
self._history.append(frame.bands)
# ------------------------------------------------------------------ #
# drawing
# ------------------------------------------------------------------ #
@staticmethod
def _sample(values: Sequence[float], position: float) -> float:
"""Linearly interpolate ``values`` at a fractional index."""
if not values:
return 0.0
last = len(values) - 1
position = max(0.0, min(float(last), position))
low = int(position)
high = min(low + 1, last)
fraction = position - low
return float(values[low]) * (1.0 - fraction) + float(values[high]) * fraction
def _row_palette(self, height: int, phase: int, dim: bool) -> list[str]:
key = (height, phase % 12, dim)
cached = self._palettes.get(key)
if cached is not None:
return cached
palette: list[str] = []
for row in range(height):
position = (height - 1 - row) / max(1, height - 1)
colour = gradient(SPECTRUM_STOPS, position)
style = f"bold {colour}" if (row + phase) % 7 == 0 else colour
if dim:
style = f"dim {style}"
palette.append(style)
if len(self._palettes) > 96:
self._palettes.clear()
self._palettes[key] = palette
return palette
@staticmethod
def _resample(values: Sequence[float], width: int) -> list[float]:
count = len(values)
if count == 0:
return [0.0] * width
if width <= 1 or count == 1:
return [float(values[0])] * width
last = count - 1
output: list[float] = []
for column in range(width):
position = column * last / (width - 1)
low = int(position)
high = min(low + 1, last)
fraction = position - low
output.append(values[low] * (1.0 - fraction) + values[high] * fraction)
return output
@staticmethod
def _resample_mean(values: Sequence[float], width: int) -> list[float]:
count = len(values)
if count == 0:
return [0.0] * width
output: list[float] = []
for column in range(width):
low = int(column * count / width)
high = max(low + 1, int((column + 1) * count / width))
high = min(high, count)
chunk = values[low:high]
output.append(sum(chunk) / len(chunk) if chunk else 0.0)
return output
def _bars(
self,
frame: Frame,
width: int,
height: int,
phase: int,
dim: bool,
peaks: bool = True,
) -> list[Text]:
values = self._resample(frame.bands, width)
peaks = self._resample(frame.peaks, width)
palette = self._row_palette(height, phase, dim)
scale = height * 8.0
lines: list[Text] = []
for row in range(height):
line = Text(no_wrap=True)
row_top = (height - row - 1) * 8.0
style = palette[row]
for column in range(width):
units = min(values[column], 1.0) * scale - row_top
peak_units = min(peaks[column], 1.0) * scale - row_top
if units >= 8.0:
line.append("█", style=style)
elif units > 0.0:
level = max(1, min(7, int(units)))
line.append(LEVELS[level], style=style)
elif (
peaks
and 0.0 <= peak_units < 8.0
and peak_units > units + 1.0
):
line.append("▁", style="dim" if dim else "bold")
else:
line.append(" ", style="")
lines.append(line)
return lines
def _wave_values(self, frame: Frame, width: int) -> list[float]:
"""The waveform resampled to ``width`` points, with display gain.
The engine normalises each chunk, but smoothing and averaging make the
trace sit around the middle. A gentle display gain lets an oscilloscope
use the full height without amplifying silence into noise.
"""
wave = self._resample_mean(frame.wave, width)
peak = max((abs(value) for value in wave), default=0.0)
if peak > 0.02:
gain = min(2.5, 0.9 / peak)
wave = [value * gain for value in wave]
return wave
def _wave(
self, frame: Frame, width: int, height: int, phase: int, dim: bool
) -> list[Text]:
wave = self._wave_values(frame, width)
palette = self._row_palette(height, phase, dim)
centre = (height - 1) / 2.0
amplitude = max(0.75, (height - 1) / 2.0)
grid = [[" " for _ in range(width)] for _ in range(height)]
previous_row: int | None = None
for column, value in enumerate(wave):
clamped = max(-1.0, min(1.0, float(value)))
position = centre - clamped * amplitude
row = max(0, min(height - 1, round(position)))
grid[row][column] = "█"
delta = position - row
if delta > 0.35 and row + 1 < height:
grid[row + 1][column] = "▄"
elif delta < -0.35 and row - 1 >= 0:
grid[row - 1][column] = "▀"
if previous_row is not None and previous_row != row:
low, high = sorted((previous_row, row))
for between in range(low + 1, high):
if grid[between][column] == " ":
grid[between][column] = "│"
previous_row = row
lines: list[Text] = []
for row in range(height):
line = Text(no_wrap=True)
style = palette[row]
for char in grid[row]:
if char == " ":
line.append(" ", style="")
else:
line.append(char, style=style)
lines.append(line)
return lines
def _braille(
self, frame: Frame, width: int, height: int, phase: int, dim: bool
) -> list[Text]:
"""Oscilloscope drawn with braille dots: 2x4 sub-cells per character."""
dots_width = width * 2
dots_height = height * 4
values = self._wave_values(frame, dots_width)
centre = (dots_height - 1) / 2.0
amplitude = max(1.0, (dots_height - 1) / 2.0)
cells = [[0] * width for _ in range(height)]
def set_dot(x: int, y: int) -> None:
cells[y // 4][x // 2] |= BRAILLE_BITS[y % 4][x % 2]
previous: int | None = None
for column, value in enumerate(values):
clamped = max(-1.0, min(1.0, float(value)))
row = max(0, min(dots_height - 1, round(centre - clamped * amplitude)))
set_dot(column, row)
# Join steep segments so the trace stays continuous.
if previous is not None and previous != row:
low, high = sorted((previous, row))
for between in range(low + 1, high):
set_dot(column, between)
previous = row
palette = self._row_palette(height, phase, dim)
lines: list[Text] = []
for row in range(height):
line = Text(no_wrap=True)
style = palette[row]
for bits in cells[row]:
if bits:
line.append(chr(0x2800 | bits), style=style)
else:
line.append(" ", style="")
lines.append(line)
return lines
def _waterfall(self, width: int, height: int, dim: bool) -> list[Text]:
"""Scrolling spectrogram: history left-to-right, frequency bottom-up."""
columns = list(self._history)[-width:]
if not columns:
columns = [()]
last = max(1, len(columns[-1]) - 1)
# Row 0 is the treble end; the bass sits on the bottom row.
positions = [
(height - 1 - row) / max(1, height - 1) * last for row in range(height)
]
offset = width - len(columns)
fade_after = max(3, int(len(columns) * 0.75))
grid = [[" "] * width for _ in range(height)]
styles = [[""] * width for _ in range(height)]
for index, column in enumerate(columns):
if not column:
continue
x = offset + index
age = len(columns) - index
faded = dim or age > fade_after
for row, position in enumerate(positions):
value = self._sample(column, position)
level = int(value * (len(WATERFALL_LEVELS) - 1))
if level <= 0:
continue
style = gradient(SPECTRUM_STOPS, value)
if faded:
style = f"dim {style}"
elif age == 1:
# The newest column is the leading edge of the scan.
style = f"bold {style}"
grid[row][x] = WATERFALL_LEVELS[level]
styles[row][x] = style
lines: list[Text] = []
for row in range(height):
line = Text(no_wrap=True)
for x in range(width):
char = grid[row][x]
line.append(char, style=styles[row][x] if char != " " else "")
lines.append(line)
return lines
def _radial(
self, frame: Frame, width: int, height: int, phase: int, dim: bool
) -> list[Text]:
"""The spectrum wrapped into a glowing circle around the centre."""
centre_x = (width - 1) / 2.0
centre_y = (height - 1) / 2.0
# Terminal cells are about twice as tall as they are wide, so the
# horizontal radius is twice the vertical one to draw a round circle.
radius_y = min(centre_y, centre_x / 2.0)
if radius_y < 2.5:
return self._bars(frame, width, height, phase, dim)
radius_x = radius_y * 2.0
bands = list(frame.bands)
count = max(1, len(bands))
hole = 0.18
grid = [[" "] * width for _ in range(height)]
styles = [[""] * width for _ in range(height)]
for row in range(height):
dy = (row - centre_y) / radius_y
if abs(dy) > 1.0:
continue
for column in range(width):
dx = (column - centre_x) / radius_x
distance = math.hypot(dx, dy)
if distance > 1.0:
continue
angle = (math.atan2(dy, dx) + math.pi / 2.0) % (2.0 * math.pi)
band = bands[int(angle / (2.0 * math.pi) * count) % count]
edge = hole + max(0.0, min(1.0, float(band))) * (1.0 - hole)
if distance <= hole + 0.02:
# A faint inner ring gives silent stations a focus.
if distance > hole * 0.35:
grid[row][column] = "·"
styles[row][column] = "dim"
continue
if distance > edge:
continue
depth = edge - distance
if depth < 0.06:
grid[row][column] = "█"
styles[row][column] = ("dim " if dim else "") + gradient(
SPECTRUM_STOPS, edge
)
elif depth < 0.15:
grid[row][column] = "▓"
styles[row][column] = "dim " + gradient(SPECTRUM_STOPS, edge)
elif depth < 0.25:
grid[row][column] = "▒"
styles[row][column] = "dim"
lines: list[Text] = []
for row in range(height):
line = Text(no_wrap=True)
for column in range(width):
char = grid[row][column]
line.append(char, style=styles[row][column] if char != " " else "")
lines.append(line)
return lines
+246
View File
@@ -0,0 +1,246 @@
"""The stage: artwork, now playing details and song history."""
from __future__ import annotations
import contextlib
from typing import TYPE_CHECKING, cast
from rich.console import Group, RenderableType
from rich.text import Text
from textual.containers import Horizontal
from textual.css.query import NoMatches
from textual.widget import Widget
from ..api import Song, Station
from .art import ArtPane
from .common import (
compact_count,
local_time,
marquee,
state_glyph,
time_ago,
truncate,
)
if TYPE_CHECKING:
from ..app import SomaFMApp
class StagePanel(Widget):
"""Container for the artwork, track details and history."""
can_focus = False
def __init__(self) -> None:
super().__init__()
self.border_title = "ON AIR"
def compose(self):
with Horizontal(id="stage_top"):
yield ArtPane()
yield StageInfo()
yield HistoryPane()
def on_mount(self) -> None:
self.refresh_art()
def on_resize(self, event) -> None:
self.refresh_art()
def refresh_stage(self) -> None:
"""Refresh the text parts (details + history) without touching images."""
with contextlib.suppress(NoMatches):
self.query_one(StageInfo).refresh()
self.query_one(HistoryPane).refresh()
def refresh_art(self) -> None:
"""Show/hide and update the artwork pane for the current layout/data."""
with contextlib.suppress(NoMatches):
art = self.query_one(ArtPane)
visible = (
self.app.current is not None
and self.size.width >= 56
and self.size.height >= 8
)
force = visible and not art.display
if art.display != visible:
art.display = visible
if visible:
art.sync_image(force=force)
class StageInfo(Widget):
"""Station heading, now-playing metadata and notices."""
can_focus = False
def render(self) -> RenderableType:
app = cast("SomaFMApp", 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))
details = self._details(app, station, station.now_playing, width)
return Group(*details[:height])
def _splash(self, width: int, height: int) -> list[Text]:
lines = [
Text("◤ SOMA FM ◢", style="bold magenta"),
Text("listener-supported radio · san francisco", 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"),
]
return [
Text(truncate(line.plain, width), style=line.style)
for line in lines[:height]
]
def _details(
self,
app: SomaFMApp,
station: Station,
song: Song | None,
width: int,
) -> list[Text]:
width = max(8, width)
glyph, colour = state_glyph(app.playing_state)
heading = Text(no_wrap=True)
heading.append(
truncate(station.title.upper(), max(8, width - 20)),
style="bold magenta",
)
if station.favorite:
heading.append(" ★", style="bold yellow")
heading.append(" ")
heading.append(
f"{glyph} {app.playing_state.upper()}", style=f"bold {colour}"
)
description = Text(truncate(station.description, width), style="dim")
label = Text(no_wrap=True)
label.append("NOW PLAYING", style="bold cyan")
if song is not None and song.played_at is not None:
label.append(f" · {time_ago(song.played_at)}", style="dim")
if song is not None:
artist = Text(truncate(song.display_artist, width), style="bold yellow")
title = Text(marquee(song.display_title, width), style="bold")
album = Text(no_wrap=True)
if song.album:
album.append(truncate(song.album, max(8, width - 22)), style="dim")
if station.dj:
if song.album:
album.append(" · ", style="dim")
album.append(f"dj {truncate(station.dj, 18)}", style="dim")
if song.art_url:
album.append(" ▣", style="cyan")
else:
artist = Text("waiting for track data…", style="italic yellow")
fallback = station.last_playing or "tuning in"
title = Text(marquee(fallback, width), style="bold")
album = Text(truncate(station.genre_label, width), style="dim")
meta = Text(no_wrap=True)
if station.listeners:
meta.append(
f"♪ {compact_count(station.listeners)} listeners", style="dim"
)
meta.append(" · ", style="dim")
meta.append(station.format_label, style="dim")
meta.append(" · ", style="dim")
meta.append(station.genre_label, style="dim")
notice = Text(truncate(app.notice, width), style="italic yellow")
details = [
heading,
description,
Text(""),
label,
artist,
title,
album,
meta,
]
if song is not None and song.bandcamp_url:
link = Text(no_wrap=True)
link.append(
"Bandcamp ↗",
style=f"bold cyan link {song.bandcamp_url}",
)
details.append(link)
details.append(notice)
return details
class HistoryPane(Widget):
"""Recently played songs."""
can_focus = False
def render(self) -> RenderableType:
app = cast("SomaFMApp", self.app)
width = max(8, self.size.width)
height = max(1, self.size.height)
station = app.current
if station is None:
return Text("")
song = station.now_playing
history = [
entry
for entry in station.history
if not (song is not None and entry.key() == song.key())
]
if not history:
return Text("")
lines: list[Text] = []
if height >= 3:
lines.append(Text(""))
lines.append(self._header(width))
for entry in history:
if len(lines) >= height:
break
lines.append(self._history_line(entry, width))
elif height == 2:
lines.append(self._header(width))
lines.append(self._history_line(history[0], width))
else:
lines.append(self._history_line(history[0], width))
return Group(*lines[:height])
@staticmethod
def _header(width: int) -> Text:
header = Text(no_wrap=True)
header.append("LAST PLAYED", style="bold cyan")
header.append(" ")
header.append("─" * max(0, width - 13), style="bright_black")
return header
@staticmethod
def _history_line(song: Song, width: int) -> Text:
line = Text(no_wrap=True)
line.append(local_time(song.played_at), style="dim")
line.append(" ")
album_width = min(24, max(0, width // 3)) if width >= 56 else 0
label_width = max(
6, width - 5 - 2 - (album_width + 2 if album_width else 0)
)
line.append(
truncate(f"{song.display_artist} — {song.display_title}", label_width),
style="",
)
if album_width and song.album:
line.append(" ")
line.append(truncate(song.album, album_width), style="italic dim")
return line
+106
View File
@@ -0,0 +1,106 @@
"""Station list with favourites, live markers and a filter."""
from __future__ import annotations
from typing import TYPE_CHECKING, cast
from rich.cells import cell_len
from rich.console import Group, RenderableType
from rich.text import Text
from textual.widget import Widget
from .common import compact_count, truncate
if TYPE_CHECKING:
from ..app import BeefyPlayerApp
class StationList(Widget):
can_focus = False
def __init__(self) -> None:
super().__init__()
self.border_title = "STATIONS"
def set_filter(self, query: str) -> None:
safe_query = query.replace("[", "(").replace("]", ")") if query else ""
title = f"STATIONS · /{safe_query}" if safe_query else "STATIONS"
if self.border_title != title:
self.border_title = title
def on_mouse_scroll_down(self, event) -> None:
cast("BeefyPlayerApp", self.app).action_cursor_down()
def on_mouse_scroll_up(self, event) -> None:
cast("BeefyPlayerApp", self.app).action_cursor_up()
def render(self) -> RenderableType:
app = cast("BeefyPlayerApp", self.app)
width = max(12, self.size.width)
height = max(1, self.size.height)
view = app.view
if not view:
message = "no stations match" if app.query else "loading stations…"
return Group(Text(truncate(message, width), style="dim italic"))
first = 0
if len(view) > height:
first = max(0, min(app.index - height // 2, len(view) - height))
lines: list[Text] = []
for offset in range(min(height, len(view))):
index = first + offset
station = view[index]
selected = index == app.index
current = app.current is not None and app.current.id == station.id
playing = current and app.playing_state not in ("stopped", "error")
marker = "▌" if selected else " "
star = "★" if station.favorite else " "
play = (
("‖" if app.playing_state == "paused" else "▶") if current else " "
)
count_text = ""
count_width = 0
if width >= 26 and station.listeners > 0:
count_text = f"{compact_count(station.listeners)} ♪"
count_width = cell_len(count_text) + 1
prefix = marker + star + play + " "
available = width - cell_len(prefix) - count_width
title = truncate(station.title, max(4, available))
gap = max(0, width - cell_len(prefix) - cell_len(title) - count_width)
if selected:
base = ""
marker_style = "magenta"
title_style = "bold underline"
count_style = "bold"
elif current:
base = ""
marker_style = "cyan"
title_style = "cyan"
count_style = "dim"
else:
base = ""
marker_style = ""
title_style = ""
count_style = "dim"
row = Text(no_wrap=True, style=base)
row.append(marker, style=marker_style)
row.append(star, style="bold yellow" if station.favorite else "")
row.append(play, style="cyan" if playing else "")
row.append(" ")
row.append(title, style=title_style)
row.append(" " * gap)
if count_text:
row.append(count_text, style=count_style)
used = cell_len(prefix) + cell_len(title) + gap + cell_len(count_text)
if used < width:
row.append(" " * (width - used))
lines.append(row)
return Group(*lines)
+93
View File
@@ -0,0 +1,93 @@
"""One-line status / key hint bar."""
from __future__ import annotations
from typing import TYPE_CHECKING, cast
from rich.cells import cell_len
from rich.console import RenderableType
from rich.text import Text
from textual.widget import Widget
from .common import compact_count, state_glyph, truncate
if TYPE_CHECKING:
from ..app import BeefyPlayerApp
HINTS_LONG = (
"SPACE pause ↑↓ station ENTER tune +/- vol f ★ / filter "
"v visual r refresh ? help q quit"
)
HINTS_MEDIUM = (
"space pause · ↑↓ station · enter tune · +/- vol · f ★ · / filter · ? help · q quit"
)
HINTS_SHORT = "↑↓ station · space play · ? help · q quit"
class StatusBar(Widget):
can_focus = False
def render(self) -> RenderableType:
app = cast("BeefyPlayerApp", self.app)
width = max(20, self.size.width)
glyph, colour = state_glyph(app.playing_state)
frame = app.spectrum.snapshot()
session = app.media.label
status = app.media.status_label
backend = app.player.backend or "no audio"
analyser = frame.label
listeners = ""
if app.current is not None and app.current.listeners:
listeners = f"♪ {compact_count(app.current.listeners)} "
state = app.playing_state
volume = f"vol {app.volume:>3}%"
# From most to least informative; pick the first combination that fits.
right_variants = (
f"{state} {listeners}{volume} {backend} {session} {status} {analyser}",
f"{state} {volume} {session} {status} {analyser}",
f"{state} {volume} {analyser}",
f"{state} {volume}",
volume,
)
options = (
(HINTS_LONG, right_variants[0]),
(HINTS_MEDIUM, right_variants[0]),
(HINTS_LONG, right_variants[1]),
(HINTS_MEDIUM, right_variants[1]),
(HINTS_SHORT, right_variants[1]),
(HINTS_SHORT, right_variants[2]),
(HINTS_SHORT, right_variants[3]),
(HINTS_SHORT, right_variants[4]),
)
chosen: tuple[int, str, str] | None = None
for hint, right_text in options:
right_width = cell_len(glyph) + 1 + cell_len(right_text)
if cell_len(hint) + right_width + 2 <= width:
chosen = (right_width, right_text, hint)
break
if chosen is None:
right_text = right_variants[-1]
right_width = cell_len(glyph) + 1 + cell_len(right_text)
if width <= right_width + 2:
line = Text(no_wrap=True)
line.append(
truncate(f"{glyph} {right_text}", width), style="dim"
)
return line
chosen = (right_width, right_text, "")
right_width, right_text, hint = chosen
left_width = max(0, width - right_width - 2)
left = app.notice or hint
left_style = "italic yellow" if app.notice else "dim"
line = Text(no_wrap=True)
line.append(truncate(left, left_width), style=left_style)
line.append(" " * max(0, width - left_width - right_width))
line.append(f"{glyph} ", style=f"bold {colour}")
line.append(right_text, style="dim")
return line