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,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)
|
||||
Reference in new issue
Block a user