feat(tui): implement SomaFM terminal radio player
This commit is contained in:
1 parent
941e45a20f
commit
237de2efe9
21 files changed
+4937
No files matched your search
@@ -0,0 +1 @@
|
||||
"""Custom Textual widgets for somafm-tui."""
|
||||
@@ -0,0 +1,375 @@
|
||||
"""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 SomaFMApp
|
||||
|
||||
|
||||
_IMAGE_IDS = itertools.count(random.randrange(1, 2**31 - 1))
|
||||
|
||||
|
||||
def _tgp(data: str) -> str:
|
||||
return f"\x1b_G{data}\x1b\\"
|
||||
|
||||
|
||||
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``
|
||||
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")
|
||||
data = (
|
||||
"\x1b]1337;File=inline=1;"
|
||||
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)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# mosaic background
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
app = cast("SomaFMApp", self.app)
|
||||
station = app.current
|
||||
if station is None:
|
||||
return Text("")
|
||||
width = max(1, self.size.width)
|
||||
height = max(1, self.size.height)
|
||||
song = station.now_playing
|
||||
key = f"{station.id}|{song.artist if song else ''}|{song.title if song else ''}"
|
||||
return Group(*art_lines(key, 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("SomaFMApp", self.app)
|
||||
station = app.current
|
||||
data = app.artwork_bytes
|
||||
width = max(1, self.size.width)
|
||||
height = max(1, self.size.height)
|
||||
|
||||
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()
|
||||
@@ -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)
|
||||
@@ -0,0 +1,97 @@
|
||||
"""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 SomaFMApp
|
||||
|
||||
LOGO = (
|
||||
"▄▀▀▀ ▄▀▀▄ █▄ ▄█ ▄▀▀▄",
|
||||
"▀▀▀▄ █ █ █ █ █ █▀▀█",
|
||||
"▄▄▄▀ ▀▄▄▀ █ █ █ █",
|
||||
)
|
||||
|
||||
MIDDLE = ("SOMA FM RADIO", "LISTENER SUPPORTED", "SAN FRANCISCO")
|
||||
|
||||
|
||||
class HeaderBar(Widget):
|
||||
can_focus = False
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
app = cast("SomaFMApp", 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(len(row) for row in LOGO)
|
||||
middle_width = max(len(row) for row in MIDDLE)
|
||||
right = (clock_time(), date_label(), f"vol {app.volume}%")
|
||||
right_width = max(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)
|
||||
line.append(MIDDLE[index], style="dim")
|
||||
if show_right:
|
||||
used = logo_width
|
||||
if show_middle:
|
||||
used += 4 + middle_width
|
||||
pad = max(1, width - used - right_width)
|
||||
line.append(" " * pad)
|
||||
style = "bold" if index == 0 else "dim"
|
||||
line.append(right[index], 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])
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Full-screen keyboard help."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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: mirrored bars → bars → wave"),
|
||||
("r", "refresh station list and song history"),
|
||||
("g / G", "jump to the top / bottom of the list"),
|
||||
("? ", "this help"),
|
||||
("q", "quit"),
|
||||
)
|
||||
|
||||
ABOUT = (
|
||||
(
|
||||
"Stations and song history come from somafm.com "
|
||||
"(channels.json + songs/<station>.json)."
|
||||
),
|
||||
(
|
||||
"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`."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -0,0 +1,202 @@
|
||||
"""The flashy bit: an ANSI-colour spectrum analyser / oscilloscope."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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 SomaFMApp
|
||||
|
||||
|
||||
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]] = {}
|
||||
|
||||
# Called from the app ticker so the border title tracks the analyser mode.
|
||||
def sync(self, mode: str, status: str) -> None:
|
||||
label = {"both": "MIRROR", "bars": "BARS", "wave": "WAVE"}.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("SomaFMApp", 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"
|
||||
|
||||
lines: list[Text]
|
||||
if mode == "wave":
|
||||
lines = self._wave(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)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# drawing
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
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(
|
||||
self, frame: Frame, width: int, height: int, phase: int, dim: bool
|
||||
) -> list[Text]:
|
||||
wave = self._resample_mean(frame.wave, width)
|
||||
# The engine normalises each chunk, but smoothing and averaging make
|
||||
# the trace sit around the middle. A gentle display gain lets the
|
||||
# oscilloscope use the full height without amplifying silence into
|
||||
# noise.
|
||||
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]
|
||||
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
|
||||
@@ -0,0 +1,238 @@
|
||||
"""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")
|
||||
|
||||
return [
|
||||
heading,
|
||||
description,
|
||||
Text(""),
|
||||
label,
|
||||
artist,
|
||||
title,
|
||||
album,
|
||||
meta,
|
||||
notice,
|
||||
]
|
||||
|
||||
|
||||
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
|
||||
@@ -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 SomaFMApp
|
||||
|
||||
|
||||
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("SomaFMApp", self.app).action_cursor_down()
|
||||
|
||||
def on_mouse_scroll_up(self, event) -> None:
|
||||
cast("SomaFMApp", self.app).action_cursor_up()
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
app = cast("SomaFMApp", 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)
|
||||
@@ -0,0 +1,90 @@
|
||||
"""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 SomaFMApp
|
||||
|
||||
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("SomaFMApp", self.app)
|
||||
width = max(20, self.size.width)
|
||||
glyph, colour = state_glyph(app.playing_state)
|
||||
frame = app.spectrum.snapshot()
|
||||
mpris = app.mpris.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} mpris {mpris} {analyser}",
|
||||
f"{state} {volume} {analyser}",
|
||||
f"{state} {volume}",
|
||||
volume,
|
||||
)
|
||||
options = (
|
||||
(HINTS_LONG, right_variants[0]),
|
||||
(HINTS_LONG, right_variants[1]),
|
||||
(HINTS_MEDIUM, right_variants[0]),
|
||||
(HINTS_MEDIUM, right_variants[1]),
|
||||
(HINTS_SHORT, right_variants[1]),
|
||||
(HINTS_SHORT, right_variants[2]),
|
||||
(HINTS_SHORT, right_variants[3]),
|
||||
)
|
||||
|
||||
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
|
||||
Reference in new issue
Block a user