Use a show's real theme colors instead of guessing from its id

Replaces the id-based theme lookup (/api/state's theme.active mapped to
this app's own hardcoded COLOR_SCHEMES) with the real, currently-in-effect
token values from /api/themes. SUB/WAVE themes aren't a fixed set of 8
presets -- an operator can add their own theme JSON with arbitrary token
values (controller/src/themes.ts loads built-ins plus optional files from
${STATE_DIR}/themes/), so a theme id like "cyberpunk" on some station
was never guaranteed to match this app's own guess at its hex values.

Also fixes the precedence question directly: /api/themes exposes
activeSource ("show" or "station", per controller's own
theme-provenance.ts), so the override only applies when a show has
genuinely pinned its own theme -- when it's "station" (no show-level
pin), the user's own configured color_scheme is used, not the station's
default, which is what was actually wanted.

Token values aren't all plain hex, though: surveyed every token across
every theme on the live test station and found three formats in use --
"#rrggbb", "oklch(L C H)", and "color-mix(in oklab, COLOR1 P1%, COLOR2)"
(COLOR1/COLOR2 themselves hex or oklch, sometimes nested). Implements
the standard CSS Color 4 / Bjorn Ottosson OKLab conversion to handle all
three. Verified three ways before trusting it: hex -> OKLab -> hex
round-trips to the exact original hex; oklch(L C H) for pure red/green/
blue/white matches their well-known reference hex values exactly; and
the color-mix outputs were cross-checked against an independent Python
implementation of the same formulas and matched exactly.

Token-to-role mapping: background/text/accent/meta/separator/bar_empty
<- --bg/--ink/--accent/--muted/--line/--surface, confirmed present on
every theme on the live station. --line is usually a translucent rgba
in SUB/WAVE's own themes -- preserved as an 8-digit #rrggbbaa hex
(alpha carried through the OKLab math too) rather than flattened to
opaque, matching the alpha-hex format already used elsewhere in this
file (e.g. the corner ticks' "#00000080").

Verified end-to-end: a test render with the real "blueprint" theme's
tokens forced through activeSource="show" produced the exact expected
background pixel (10,58,146 = #0a3a92); the live station (currently no
show-level override, activeSource="station") correctly falls back to
an explicitly configured color_scheme=vinyl instead of being overridden.
This commit is contained in:
poprhythm committed 2026-10-10 17:15:22 -04:00
1 parent 887dbc7183
commit 676b80ad97
1 file changed
+201 -21
+201 -21
View File
@@ -13,6 +13,7 @@ load("time.star", "time")
load("animation.star", "animation")
load("filter.star", "filter")
load("random.star", "random")
load("math.star", "math")
DEFAULT_STATION_URL = ""
NOWPLAYING_TTL = 20
@@ -198,11 +199,14 @@ def main(config):
# A show's own theme override (if any) wins over the user's configured
# color_scheme, mirroring what SUB/WAVE's own web UI does -- see
# _fetch_show_theme. Falls back to the configured scheme on a fetch
# failure or an id this app doesn't recognize, rather than erroring.
show_theme_id = _fetch_show_theme(station_url)
if show_theme_id != None and show_theme_id in COLOR_SCHEMES:
colors = COLOR_SCHEMES[show_theme_id]
# _fetch_show_colors. Only when activeSource is genuinely "show" (a
# show with its own themeId pinned, outranking the station default) --
# when it's "station" (no show-level override), the user's own
# configured color_scheme is respected instead, not the station's
# default. Falls back to the configured scheme on any fetch failure.
show_colors = _fetch_show_colors(station_url)
if show_colors != None:
colors = show_colors
art = _fetch_cover(station_url, now["subsonic_id"])
progress = _track_progress(now["subsonic_id"], now["duration"])
@@ -987,27 +991,203 @@ def _format_time_temp(clock, weather):
return " · ".join(parts)
def _fetch_show_theme(station_url):
# SUB/WAVE lets a show override the station's default color theme
# (controller/src/routes/public.ts resolves this server-side already:
# activeShow?.themeId || station default). That resolved value rides
# on /api/state's theme.active, not /api/now-playing -- a separate
# fetch, not a field on the response this app already pulls.
#
# Theme ids there use hyphens (e.g. "classic-light"), this app's
# COLOR_SCHEMES keys use underscores ("classic_light") -- same 8
# names otherwise (confirmed against SUB/WAVE's own theme registry),
# so swapping the separator is enough to look it up directly rather
# than needing an explicit id-mapping table.
rep = http.get(station_url + "/api/state", ttl_seconds = NOWPLAYING_TTL)
# SUB/WAVE themes are plain CSS custom properties, not just a fixed set of
# 8 preset ids: an operator can add their own theme JSON with arbitrary
# token values (controller/src/themes.ts loads built-ins from a bundled
# dir *plus* optional files from ${STATE_DIR}/themes/), so a theme named
# e.g. "cyberpunk" on one station isn't guaranteed to be *this app's*
# hardcoded COLOR_SCHEMES["cyberpunk"] hex values. /api/themes exposes
# the real, currently-in-effect token values directly, so _fetch_show_colors
# below reads those instead of mapping a theme id to our own guess.
#
# Token values aren't all plain hex, though -- surveyed every token across
# every theme on the live test station and found three formats in use:
# "#rrggbb", "oklch(L C H)", and "color-mix(in oklab, COLOR1 P1%, COLOR2)"
# (where COLOR1/COLOR2 can themselves be hex or oklch, nested). The oklch
# <-> sRGB math below (and the inverse, hex -> OKLab, needed to mix a hex
# color against an oklch one) is the standard CSS Color 4 / Bjorn Ottosson
# OKLab conversion -- verified two ways before trusting it: round-tripping
# hex -> OKLab -> hex returned the exact original hex for several colors,
# and converting oklch(L C H) for pure red/green/blue/white against their
# well-known reference hex values matched exactly. The color-mix outputs
# were cross-checked against an independent Python implementation of the
# same formulas and matched exactly too.
PI = 3.14159265358979
def _clamp01(x):
return max(0.0, min(1.0, x))
def _hex2(n):
s = "%x" % n
return "0" + s if len(s) < 2 else s
def _srgb_to_linear(c):
if c <= 0.04045:
return c / 12.92
return math.pow((c + 0.055) / 1.055, 2.4)
def _linear_to_srgb(c):
c = _clamp01(c)
if c <= 0.0031308:
return c * 12.92
return 1.055 * math.pow(c, 1.0 / 2.4) - 0.055
def _hex_to_rgb01(hex_str):
hex_str = hex_str.lstrip("#")
return (int(hex_str[0:2], 16) / 255.0, int(hex_str[2:4], 16) / 255.0, int(hex_str[4:6], 16) / 255.0)
def _rgb01_to_hex(r, g, b):
ri = int(_clamp01(r) * 255 + 0.5)
gi = int(_clamp01(g) * 255 + 0.5)
bi = int(_clamp01(b) * 255 + 0.5)
return "#" + _hex2(ri) + _hex2(gi) + _hex2(bi)
def _linear_rgb_to_oklab(r, g, b):
l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b
m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b
s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b
l_ = math.pow(l, 1.0 / 3.0) if l >= 0 else -math.pow(-l, 1.0 / 3.0)
m_ = math.pow(m, 1.0 / 3.0) if m >= 0 else -math.pow(-m, 1.0 / 3.0)
s_ = math.pow(s, 1.0 / 3.0) if s >= 0 else -math.pow(-s, 1.0 / 3.0)
L = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_
A = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_
B = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_
return (L, A, B)
def _oklab_to_linear_rgb(L, A, B):
l_ = L + 0.3963377774 * A + 0.2158037573 * B
m_ = L - 0.1055613458 * A - 0.0638541728 * B
s_ = L - 0.0894841775 * A - 1.2914855480 * B
l = l_ * l_ * l_
m = m_ * m_ * m_
s = s_ * s_ * s_
r = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s
g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s
bb = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s
return (r, g, bb)
def _hex_to_oklab(hex_str):
r, g, b = _hex_to_rgb01(hex_str)
return _linear_rgb_to_oklab(_srgb_to_linear(r), _srgb_to_linear(g), _srgb_to_linear(b))
def _oklab_to_hex(L, A, B):
r, g, b = _oklab_to_linear_rgb(L, A, B)
return _rgb01_to_hex(_linear_to_srgb(r), _linear_to_srgb(g), _linear_to_srgb(b))
def _oklch_to_oklab(l, c, h_deg):
h_rad = h_deg * PI / 180.0
return (l, c * math.cos(h_rad), c * math.sin(h_rad))
def _rgb255_to_oklab(r, g, b):
return _linear_rgb_to_oklab(_srgb_to_linear(r / 255.0), _srgb_to_linear(g / 255.0), _srgb_to_linear(b / 255.0))
def _find_top_level_comma(s):
# First comma not nested inside parens -- splits color-mix's two
# "COLOR PERCENT%" segments. A nested oklch(...) has no commas
# (space-separated), but rgb(...)/rgba(...) does, so this has to
# track paren depth rather than just split(",").
depth = 0
for i in range(len(s)):
ch = s[i]
if ch == "(":
depth += 1
elif ch == ")":
depth -= 1
elif ch == "," and depth == 0:
return i
return -1
def _parse_color_token(s):
# Returns (L, A, B, alpha) in OKLab + a 0-1 alpha (1.0 unless the
# token is rgba(...) with a 4th value, or mixes one in).
s = s.strip()
if s.startswith("#"):
L, A, B = _hex_to_oklab(s)
return (L, A, B, 1.0)
if s.startswith("oklch("):
inner = s[s.index("(") + 1:s.rindex(")")]
parts = [p for p in inner.split(" ") if p != ""]
L, A, B = _oklch_to_oklab(float(parts[0]), float(parts[1]), float(parts[2]))
return (L, A, B, 1.0)
if s.startswith("rgba(") or s.startswith("rgb("):
inner = s[s.index("(") + 1:s.rindex(")")]
parts = [p.strip() for p in inner.split(",")]
L, A, B = _rgb255_to_oklab(float(parts[0]), float(parts[1]), float(parts[2]))
alpha = float(parts[3]) if len(parts) > 3 else 1.0
return (L, A, B, alpha)
if s.startswith("color-mix("):
inner = s[s.index("(") + 1:s.rindex(")")]
after_space = inner[len("in oklab,"):].strip() if inner.startswith("in oklab,") else inner
split_at = _find_top_level_comma(after_space)
seg1 = after_space[:split_at].strip()
seg2 = after_space[split_at + 1:].strip()
def _split_color_pct(seg):
if seg.endswith("%"):
last_space = seg.rindex(" ")
return seg[:last_space].strip(), float(seg[last_space + 1:-1])
return seg, None
color1, pct1 = _split_color_pct(seg1)
color2, pct2 = _split_color_pct(seg2)
if pct1 == None and pct2 == None:
pct1 = 50.0
elif pct1 == None:
pct1 = 100.0 - pct2
L1, A1, B1, Alpha1 = _parse_color_token(color1)
L2, A2, B2, Alpha2 = _parse_color_token(color2)
t = pct1 / 100.0
return (
L1 * t + L2 * (1 - t),
A1 * t + A2 * (1 - t),
B1 * t + B2 * (1 - t),
Alpha1 * t + Alpha2 * (1 - t),
)
fail("unrecognized color token: " + s)
def _token_to_hex(s):
# 8-digit #rrggbbaa when the token isn't fully opaque (e.g. --line is
# usually a translucent rgba in SUB/WAVE's own themes), matching the
# alpha-hex format already used elsewhere in this file (e.g. the
# corner ticks' "#00000080") rather than flattening semi-transparent
# tokens to an opaque color that wouldn't match the station's own site.
L, A, B, alpha = _parse_color_token(s)
hex6 = _oklab_to_hex(L, A, B)
if alpha >= 1.0:
return hex6
return hex6 + _hex2(int(_clamp01(alpha) * 255 + 0.5))
def _fetch_show_colors(station_url):
rep = http.get(station_url + "/api/themes", ttl_seconds = NOWPLAYING_TTL)
if rep.status_code != 200:
return None
theme_id = (rep.json().get("theme", {}) or {}).get("active", "")
if not theme_id:
data = rep.json()
if data.get("activeSource", "") != "show":
return None
return theme_id.replace("-", "_")
active_id = data.get("active", "")
theme = None
for t in data.get("themes", []):
if t.get("id", "") == active_id:
theme = t
break
if theme == None:
return None
tokens = theme.get("tokens", {}) or {}
required = ("--bg", "--ink", "--accent", "--muted", "--line", "--surface")
if not all([k in tokens for k in required]):
return None
return {
"background": _token_to_hex(tokens["--bg"]),
"text": _token_to_hex(tokens["--ink"]),
"accent": _token_to_hex(tokens["--accent"]),
"meta": _token_to_hex(tokens["--muted"]),
"separator": _token_to_hex(tokens["--line"]),
"bar_empty": _token_to_hex(tokens["--surface"]),
}
def _fetch_cover(station_url, subsonic_id):
if not subsonic_id:
Reference in new issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.