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:
1 parent
887dbc7183
commit
676b80ad97
1 file changed
+201
-21
+201
-21
@@ -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:
|
||||
|
||||