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
+200
-20
+200
-20
@@ -13,6 +13,7 @@ load("time.star", "time")
|
|||||||
load("animation.star", "animation")
|
load("animation.star", "animation")
|
||||||
load("filter.star", "filter")
|
load("filter.star", "filter")
|
||||||
load("random.star", "random")
|
load("random.star", "random")
|
||||||
|
load("math.star", "math")
|
||||||
|
|
||||||
DEFAULT_STATION_URL = ""
|
DEFAULT_STATION_URL = ""
|
||||||
NOWPLAYING_TTL = 20
|
NOWPLAYING_TTL = 20
|
||||||
@@ -198,11 +199,14 @@ def main(config):
|
|||||||
|
|
||||||
# A show's own theme override (if any) wins over the user's configured
|
# 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
|
# color_scheme, mirroring what SUB/WAVE's own web UI does -- see
|
||||||
# _fetch_show_theme. Falls back to the configured scheme on a fetch
|
# _fetch_show_colors. Only when activeSource is genuinely "show" (a
|
||||||
# failure or an id this app doesn't recognize, rather than erroring.
|
# show with its own themeId pinned, outranking the station default) --
|
||||||
show_theme_id = _fetch_show_theme(station_url)
|
# when it's "station" (no show-level override), the user's own
|
||||||
if show_theme_id != None and show_theme_id in COLOR_SCHEMES:
|
# configured color_scheme is respected instead, not the station's
|
||||||
colors = COLOR_SCHEMES[show_theme_id]
|
# 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"])
|
art = _fetch_cover(station_url, now["subsonic_id"])
|
||||||
progress = _track_progress(now["subsonic_id"], now["duration"])
|
progress = _track_progress(now["subsonic_id"], now["duration"])
|
||||||
@@ -987,27 +991,203 @@ def _format_time_temp(clock, weather):
|
|||||||
|
|
||||||
return " · ".join(parts)
|
return " · ".join(parts)
|
||||||
|
|
||||||
def _fetch_show_theme(station_url):
|
# SUB/WAVE themes are plain CSS custom properties, not just a fixed set of
|
||||||
# SUB/WAVE lets a show override the station's default color theme
|
# 8 preset ids: an operator can add their own theme JSON with arbitrary
|
||||||
# (controller/src/routes/public.ts resolves this server-side already:
|
# token values (controller/src/themes.ts loads built-ins from a bundled
|
||||||
# activeShow?.themeId || station default). That resolved value rides
|
# dir *plus* optional files from ${STATE_DIR}/themes/), so a theme named
|
||||||
# on /api/state's theme.active, not /api/now-playing -- a separate
|
# e.g. "cyberpunk" on one station isn't guaranteed to be *this app's*
|
||||||
# fetch, not a field on the response this app already pulls.
|
# 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.
|
||||||
#
|
#
|
||||||
# Theme ids there use hyphens (e.g. "classic-light"), this app's
|
# Token values aren't all plain hex, though -- surveyed every token across
|
||||||
# COLOR_SCHEMES keys use underscores ("classic_light") -- same 8
|
# every theme on the live test station and found three formats in use:
|
||||||
# names otherwise (confirmed against SUB/WAVE's own theme registry),
|
# "#rrggbb", "oklch(L C H)", and "color-mix(in oklab, COLOR1 P1%, COLOR2)"
|
||||||
# so swapping the separator is enough to look it up directly rather
|
# (where COLOR1/COLOR2 can themselves be hex or oklch, nested). The oklch
|
||||||
# than needing an explicit id-mapping table.
|
# <-> sRGB math below (and the inverse, hex -> OKLab, needed to mix a hex
|
||||||
rep = http.get(station_url + "/api/state", ttl_seconds = NOWPLAYING_TTL)
|
# 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:
|
if rep.status_code != 200:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
theme_id = (rep.json().get("theme", {}) or {}).get("active", "")
|
data = rep.json()
|
||||||
if not theme_id:
|
if data.get("activeSource", "") != "show":
|
||||||
return None
|
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):
|
def _fetch_cover(station_url, subsonic_id):
|
||||||
if not subsonic_id:
|
if not subsonic_id:
|
||||||
|
|||||||