diff --git a/subwave_nowplaying.star b/subwave_nowplaying.star index b99c910..ca4bb6e 100644 --- a/subwave_nowplaying.star +++ b/subwave_nowplaying.star @@ -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: