Files
subwave-nowplaying/subwave_nowplaying.star
T
poprhythm 91ecc8a75c Trim glitch active window to 18s to cut worst-case payload margin
Reports of some rendered segments coming out too large for the physical
Gen1 device, suspected (correctly) to be complex/busy album art pushing
the glitch segment's payload up. Confirmed with synthetic adversarial
testing: colored noise art (sharp per-pixel detail surviving the
downscale to decode resolution, not smoothed-away high-res noise) at
the full 22s window + strong zoom + random position measured 354KB --
uncomfortably close to the Gen1 460KB limit. Isolating glitch from
baseline on the same noisy art (216KB without glitch, 354KB with)
confirmed the glitch segment itself as the primary driver, exactly as
suspected: it builds many genuinely distinct frames (jitter shifts,
hue-tinted bands, scanline sweep) from the live art, and busy/high-
entropy art means those frames compress far worse than against typical
album art.

GLITCH_ACTIVE_SECONDS cut from the full STATIC_OVERLAY_END_SECONDS (22)
to a flat 18 -- re-tested against the same adversarial noise art: 333KB,
widening the safety margin from 106KB (23%) to 127KB (28%). Re-accepts
the tradeoff this was widened past once before (jitter/bands/scanlines
go static for the last few seconds of a very-long "ending" window)
deliberately this time, for the payload headroom.

Verified: normal live-art render still well under budget (345KB), 0
black-corner hits in isolation at worst-case settings -- the duration
change doesn't touch any of the margin-safety mechanics, just how many
cycles play out.
2026-10-11 13:20:27 -04:00

1306 lines
63 KiB
Python

"""
Applet: SUB/WAVE Now Playing
Summary: Shows what's playing on a SUB/WAVE station
Description: Configure with your SUB/WAVE (github.com/perminder-klair/subwave) station's base URL to display the current track, artist, show, host, album art, time/temp, and a progress bar.
Author: poprhythm
"""
load("render.star", "render", "canvas")
load("schema.star", "schema")
load("http.star", "http")
load("cache.star", "cache")
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
COVER_TTL = 300
# These match SUB/WAVE's own web UI theme picker (Blueprint, Classic Dark,
# Classic Light, Cyberpunk, Flare, Recon, Signal, Vinyl), so a station's own
# site theme can be mirrored on the device. Each scheme sets a background
# fill, the main text color (title/artist/show), an accent (host/DJ name +
# progress bar fill), a muted meta color (time/temp), a separator, and the
# progress bar's empty-track color.
COLOR_SCHEMES = {
"blueprint": {
"background": "#0a1128",
"text": "#8ecae6",
"accent": "#4a90e2",
"meta": "#3f5a7a",
"separator": "#1f3a5f",
"bar_empty": "#12223d",
},
"classic_dark": {
"background": "#1a1a1a",
"text": "#f0e6d2",
"accent": "#d32f2f",
"meta": "#8a8070",
"separator": "#3a3a3a",
"bar_empty": "#2b2b2b",
},
"classic_light": {
"background": "#f0e6d2",
"text": "#1a1a1a",
"accent": "#e63946",
"meta": "#7a7062",
"separator": "#d8cdb8",
"bar_empty": "#ded2ba",
},
"cyberpunk": {
"background": "#0d0d0d",
"text": "#22d3ee",
"accent": "#ec4899",
"meta": "#7a7a8a",
"separator": "#2a2a35",
"bar_empty": "#241f2e",
},
"flare": {
"background": "#120a00",
"text": "#ff8c1a",
"accent": "#ff6600",
"meta": "#8a5a2a",
"separator": "#3a2000",
"bar_empty": "#2a1800",
},
"recon": {
"background": "#4a4331",
"text": "#f2ecd9",
"accent": "#ff8c3a",
"meta": "#a89f85",
"separator": "#2a2519",
"bar_empty": "#332e20",
},
"signal": {
"background": "#2b2b2b",
"text": "#d9d9d9",
"accent": "#9a9a9a",
"meta": "#6e6e6e",
"separator": "#3a3a3a",
"bar_empty": "#1e1e1e",
},
"vinyl": {
"background": "#3e2f23",
"text": "#e8d5b5",
"accent": "#c97d3d",
"meta": "#8a7358",
"separator": "#2a1f16",
"bar_empty": "#241b13",
},
}
DEFAULT_COLOR_SCHEME = "cyberpunk" # matches the station's own site theme
# How many seconds into a track's start, or before its reported end, the
# glitch overlay shows over the album art. Only used when the "Static
# overlay" schema field is enabled.
#
# These aren't symmetric on purpose: SUB/WAVE's reported `duration` is a
# track's full metadata length, but in practice the station appears to
# cut/crossfade to the next track a bit before that duration actually
# elapses (observed via the progress bar consistently stalling a few
# segments short of full, never reaching the end) -- so `remaining` was
# rarely if ever dropping below a short end-window before the subsonic_id
# changed out from under it. Widening the end window compensates for that
# observed early cutoff; the start window doesn't have the same problem
# (elapsed genuinely starts at 0) so it stays short.
STATIC_OVERLAY_START_SECONDS = 5
STATIC_OVERLAY_END_SECONDS = 22
DEFAULT_STATIC_OVERLAY = "on"
# How far in the album art zoom goes, relative to its fully-zoomed-out rest
# state (see _art_widget below for why scale never goes below 1.0).
ZOOM_LEVELS = {
"off": 1.0,
"subtle": 1.15,
"moderate": 1.4,
"strong": 1.7,
}
DEFAULT_ZOOM_LEVEL = "moderate"
# Where the zoom centers on, as animation.Origin fractions of the image's
# own 36x36 bounds. Kept modest (0.3-0.7, not full 0.0-1.0) and verified
# safe (no black border, even at the strongest 1.7x zoom) via the
# calibration tool -- more extreme corners weren't tested and may not be.
# Only used when the "Zoom position" schema field is set to "random" --
# "center" always zooms on the image's own center, exposed as a config
# choice since some covers (e.g. ones with a centered logo or face) read
# better with a predictable center zoom than a random one.
#
# Rotates on a ~10s cadence (matching ART_ZOOM_DURATION below) based on
# wall-clock time, not just per-track: a time bucket picks the spot, offset
# by a per-track hash so different tracks don't all land on the same spot
# at the same moment. This means the spot can change on *every* refresh,
# including mid-playback of a single still-running track -- not just when
# the track itself changes -- so a long track gets to show off different
# sections of its art over time, which is the whole point of "random"
# rather than "center". One real limitation: a one-shot `pixlet render`
# CLI call reads the system clock same as the live server would, so this
# is still deterministic for a given moment -- it just varies *which*
# moment you rendered at, same as the live progress bar in _track_progress.
ZOOM_CENTER_SPOT = (0.5, 0.5)
ZOOM_RANDOM_SPOTS = [
(0.5, 0.5),
(0.3, 0.3),
(0.7, 0.7),
(0.3, 0.7),
(0.7, 0.3),
]
ZOOM_SPOT_ROTATE_SECONDS = 10
DEFAULT_ZOOM_POSITION = "center"
def _zoom_spot(zoom_position, subsonic_id):
if zoom_position != "random":
return ZOOM_CENTER_SPOT
track_offset = 0
for ch in subsonic_id.elems():
track_offset += ord(ch)
time_bucket = int(time.now().unix / ZOOM_SPOT_ROTATE_SECONDS)
return ZOOM_RANDOM_SPOTS[(time_bucket + track_offset) % len(ZOOM_RANDOM_SPOTS)]
def main(config):
# `scale` is the single source of truth for 2x support: 2 when the
# device/preview is rendering at 2x resolution (canvas.is2x()), else
# 1. Every size-based constant in this file is expressed as its 1x
# value and multiplied by `scale` at the point of use, rather than
# hardcoding separate 1x/2x numbers -- this keeps every hand-tuned
# relationship (black-border margins, scanline period divisibility,
# band overlap gaps) proportionally identical at both resolutions,
# since scaling every term in an already-verified-safe linear
# relationship by the same integer factor can't change its sign or
# introduce a new overlap/exposure that wasn't already ruled out at
# 1x. Tick counts/durations are deliberately *not* scaled -- those
# are real time, not space, and should take the same number of
# seconds regardless of resolution.
scale = 2 if canvas.is2x() else 1
station_url = config.get("station_url", DEFAULT_STATION_URL)
if station_url:
station_url = station_url.rstrip("/")
colors = COLOR_SCHEMES.get(config.get("color_scheme", DEFAULT_COLOR_SCHEME), COLOR_SCHEMES[DEFAULT_COLOR_SCHEME])
zoom_scale = ZOOM_LEVELS.get(config.get("zoom_level", DEFAULT_ZOOM_LEVEL), ZOOM_LEVELS[DEFAULT_ZOOM_LEVEL])
zoom_position = config.get("zoom_position", DEFAULT_ZOOM_POSITION)
static_overlay_enabled = config.get("static_overlay", DEFAULT_STATIC_OVERLAY) == "on"
if not station_url:
return render.Root(child = _with_background(_message_screen(["Set station URL", "in app settings"], colors), colors))
now = _fetch_now_playing(station_url)
if now == None:
return render.Root(child = _with_background(_message_screen(["Can't reach", "station"], colors), colors))
if not now["online"]:
return render.Root(child = _with_background(_message_screen([now["station"], "offline"], colors), colors))
# 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_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"])
# Only meaningful once _track_progress has real elapsed/remaining data
# (i.e. on a long-lived server -- see its own comment below for why a
# one-shot `pixlet render` CLI call always sees elapsed=0). On a local
# render this will always show the "starting" static, never "ending".
show_static = (
static_overlay_enabled and
progress != None and
(progress["elapsed"] < STATIC_OVERLAY_START_SECONDS or progress["remaining"] < STATIC_OVERLAY_END_SECONDS)
)
text_width = ART_SLOT * scale # the non-art half of the canvas is exactly as wide as the art slot
# The show/host and time/temp rows always render, even when empty, so
# they hold a fixed vertical position regardless of which fields a given
# track/moment actually has data for -- rather than the layout shifting
# up when e.g. there's a show but no DJ credited, or no show at all.
show_host_row = [render.Text(now["show"], font = "tom-thumb", color = colors["text"])]
if now["host"]:
show_host_row.append(render.Text(" " + now["host"], font = "tom-thumb", color = colors["accent"]))
# At 2x only (there's no spare vertical row for this at 1x): split
# show and host onto their own lines instead of sharing one. Show
# names can run up to ~60 characters -- nowhere near fitting even at
# 2x's doubled width, so that line stays a Marquee regardless. Host
# (DJ) names are assumed short enough to just fit, so that line is
# centered static text instead -- but guarded with the same
# codepoints()-based length check and Marquee fallback used for
# time_temp (see its own comment for why a bare len() would be
# wrong), rather than trusting "always fits" outright.
host_text = render.Text(now["host"], font = "tom-thumb", color = colors["accent"]) if now["host"] else None
host_char_count = len(list(now["host"].codepoints())) if now["host"] else 0
host_fits = host_char_count <= 15
if host_text == None:
host_line = render.Box(width = text_width, height = 6) # empty placeholder -- keeps the row count/position fixed even with no host credited
elif host_fits:
host_pad_left = max(0, (text_width - int(host_char_count * 3.9)) // 2)
host_line = render.Padding(pad = (host_pad_left, 0, 0, 0), child = host_text)
else:
host_line = render.Marquee(width = text_width, child = host_text)
time_temp_text = render.Text(now["time_temp"], font = "tom-thumb", color = colors["meta"])
# At 2x, the extra width usually fits e.g. "9:54 pm \xb7 65\xb0F" without
# scrolling -- tried as a first, contained experiment in using 2x's
# extra room rather than just stretching the same 1x layout. But
# tom-thumb is variable-width and pixlet has no way to measure
# rendered text width from within Starlark, so this is a length
# heuristic, not an exact fit check: measured via the CLI,
# "12:59 pm \xb7 100\xb0F" (16 chars, a realistic worst case -- 2-digit
# hour, 3-digit temp) comes out to 63px against a 64px budget, 1px of
# margin; "11:59 am \xb7 -5\xb0F" (15 chars) is a safer 59px. Capping at
# 15 chars keeps a real margin against overflowing into the art
# column instead of relying on a 1px coincidence. Always Marquee at
# 1x, where the available width is tighter regardless of length.
#
# Counted via codepoints, not a bare len(): Starlark's len() on a
# string counts UTF-8 *bytes*, and this string almost always has at
# least one multi-byte character (the \xb7 separator, \xb0 in the temp,
# if any) -- confirmed len() reports 16 for a real 14-character
# string here, which silently sent every normal-length string down
# the Marquee branch instead of the intended centered-static-text one
# (visually: text appeared "stuck" left-aligned with no error, since
# Marquee just shows already-fitting content statically rather than
# scrolling it -- easy to mistake for "the centering didn't work").
time_temp_char_count = len(list(now["time_temp"].codepoints()))
time_temp_fits = scale == 2 and time_temp_char_count <= 15
# Centered via a computed left Padding, not render.Row(expanded=True,
# main_align="center") -- that version centered the text correctly,
# but Row's `expanded` also grabbed extra *vertical* space from the
# enclosing Column (main_align="space_between"), which pushed the
# progress bar below it out of view. Padding only nudges this one
# line horizontally, so it can't affect the Column's vertical
# distribution the way a flexible/expanding child can.
#
# tom-thumb is variable-width, so this is an estimate (~3.9px/char,
# measured via the CLI against several real time/temp strings), not
# pixel-exact centering -- close enough that it isn't visibly
# off-center for strings in the length range this branch allows.
estimated_text_width = int(time_temp_char_count * 3.9)
pad_left = max(0, (text_width - estimated_text_width) // 2)
time_temp_line = (
render.Padding(pad = (pad_left, 0, 0, 0), child = time_temp_text)
if time_temp_fits else
render.Marquee(width = text_width, child = time_temp_text)
)
# terminus-16 is exactly double tb-8's height (8px -> 16px) -- also
# pixlet's own documented default font for 2x displays, so it's a
# natural "the title gets to be genuinely bigger, not just more
# pixels" choice for the one line this was asked for, rather than
# applying it everywhere (artist/show/time stay tb-8/tom-thumb, same
# as before).
title_font = "terminus-16" if scale == 2 else "tb-8"
show_lines = (
[
render.Marquee(width = text_width, child = render.Text(now["show"], font = "tom-thumb", color = colors["text"])),
host_line,
]
if scale == 2 else
[render.Marquee(width = text_width, child = render.Row(children = show_host_row))]
)
# Album/year only at 2x -- there's no spare row for it at 1x's tighter
# layout, same reasoning as the show/host split above. Marquee, not a
# centered static line like host/time_temp: album titles can run as
# long as track titles, so there's no "assumed short" case here to
# justify skipping it.
album_year_lines = (
[render.Marquee(width = text_width, child = render.Text(now["album_year"], font = "tom-thumb", color = colors["meta"]))]
if scale == 2 else
[]
)
top_lines = [
render.Marquee(width = text_width, child = render.Text(now["title"], font = title_font, color = colors["text"])),
render.Marquee(width = text_width, child = render.Text(now["artist"], font = "tb-8", color = colors["text"])),
] + album_year_lines + [
render.Box(width = text_width, height = 1, color = colors["separator"]), # kept at a crisp 1px at every scale, per request, rather than growing to match
] + show_lines + [time_temp_line]
children = [render.Column(children = top_lines)]
if progress != None:
children.append(_progress_bar(progress["fraction"], colors, width = text_width, height = 2 * scale))
text_column = render.Box(
width = text_width,
height = ART_SLOT * scale,
child = render.Column(
main_align = "space_between",
cross_align = "start",
expanded = True,
children = children,
),
)
if art:
content = render.Row(children = [_art_widget(art, zoom_scale, zoom_position, now["subsonic_id"], show_static, scale), text_column])
else:
content = render.Column(
main_align = "center",
cross_align = "center",
expanded = True,
children = [
render.Marquee(width = canvas.width(), child = render.Text(now["title"], font = title_font, color = colors["text"])),
render.Marquee(width = canvas.width(), child = render.Text(now["artist"], font = "tb-8", color = colors["text"])),
],
)
return render.Root(child = _with_background(content, colors))
def _with_background(child, colors):
return render.Stack(children = [
render.Box(width = canvas.width(), height = canvas.height(), color = colors["background"]),
child,
])
# Album art gets a slow "Ken Burns" breathing effect: starts and ends on
# the full frame, easing in to a zoomed-in crop at the midpoint, on a
# continuous loop -- not a one-shot reveal. A one-shot version (start
# zoomed in, end on the full frame, then hold) looked jumpy: since the app
# re-renders on NOWPLAYING_TTL
# and each fresh render restarts its own animation from tick 0, whatever the
# *previous* pushed image happened to be showing at that moment gets
# abruptly replaced by a fresh zoomed-in frame. The back-and-forth loop
# doesn't eliminate that (a server-side re-render always restarts at tick 0
# regardless), but ending exactly where it started means the animation also
# loops seamlessly on-device between refreshes.
#
# Duration is intentionally short (a handful of seconds each way), not the
# ~60s round-trip originally tried: on the real device/server, a fresh
# render replaces the still-playing animation well before a minute-long
# cycle can complete, so only the outbound half (zoom out) was ever visible
# and it never appeared to "zoom back in". A short loop guarantees at least
# one full out-and-back cycle plays before that happens.
#
# Art is rendered slightly oversized (36x36, vs. the 32x32 display slot).
# animation.Scale is only ever applied at 1.0 or above here -- empirically,
# scaling this *below* 1.0 (tried while chasing a "show the literal whole
# image at rest" design) reintroduces a real, on-device black-border bug:
# below roughly 0.9-0.95 a visible gap opens up along one edge, well before
# the math (scaled size vs. box size) would predict any gap at all, so it
# isn't a simple "runs out of image" overflow -- treat scale < 1.0 as
# unsafe regardless of the numbers. That rules out "native size at rest,
# shrink down to reveal everything" -- so instead ART_SIZE is kept just
# barely larger than ART_SLOT: at the safe floor of scale = 1.0, only
# ART_SLOT/ART_SIZE (~89%) of the image is visible, which reads as
# "fully zoomed out" (a sliver cropped off each edge, not noticeable) while
# leaving just enough headroom above 1.0 for a real zoom-in effect. The
# zoom-in amount itself (`zoom_scale`, from the ZOOM_LEVELS schema option)
# must stay >= 1.0 for the same reason.
ART_SIZE = 36
ART_SLOT = 32
ART_ZOOM_DURATION = 200 # 10s round-trip (5s in, 5s out) at the default 50ms/tick
def _art_widget(art_bytes, zoom_scale, zoom_position, subsonic_id, show_static, scale):
zoom = _zoom_transformation(art_bytes, zoom_scale, zoom_position, subsonic_id, scale)
cropped = render.Box(width = ART_SLOT * scale, height = ART_SLOT * scale, child = zoom)
if not show_static:
return cropped
# The glitch segment gets its own non-animated, full-resolution
# (scale locked at 1.0) rendering of the art, rather than the live
# `zoom` above -- simpler than keeping the jitter in sync with
# wherever the real zoom's 10s cycle currently is (see
# _glitch_rest_art for why that was worth avoiding). The live zoom on
# the base `cropped` layer underneath keeps running throughout; once
# the glitch window ends, that's what's left showing.
rest_art = _glitch_rest_art(art_bytes, zoom_position, subsonic_id, scale)
return render.Stack(children = [cropped, _glitch_effect(rest_art, scale)])
def _zoom_transformation(art_bytes, zoom_scale, zoom_position, subsonic_id, scale):
# Decoding at ART_SIZE * scale (not a fixed ART_SIZE) is what actually
# makes 2x worth anything for the art specifically: the source JPEG
# fetched live from the station is typically much higher resolution
# than even a doubled target, so a bigger decode genuinely pulls in
# more real detail rather than just stretching the same 36x36-logical-
# pixel result pixlet would otherwise produce by passively upscaling
# the final composited image (confirmed empirically: a non-2x-aware
# render, downscaled back with nearest-neighbor, matched the 1x render
# exactly -- no extra detail, just bigger blocks).
art = render.Image(src = art_bytes, width = ART_SIZE * scale, height = ART_SIZE * scale)
# Always route through animation.Transformation, even when zoom is off
# (a degenerate animation whose two keyframes are both Scale(1.0, 1.0),
# i.e. no visible movement). Wrapping the art in a Transformation shifts
# its crop by a few pixels versus a bare, unwrapped Image in the same
# Box -- confirmed empirically, even at an explicit Scale(1.0, 1.0) --
# so a plain-Image "off" path and a Transformation-wrapped zoom path
# don't actually agree on framing despite both nominally being "scale
# 1.0". Using the same wrapper unconditionally guarantees the "off"
# state and the rest state of every zoom level line up exactly.
#
# `origin` picks where the zoom centers on, which also gives a free
# "zoom to a different spot each time" effect (ZOOM_SPOTS above) by
# just varying it per track. An earlier version tried to recenter the
# zoom by pairing Scale with a *computed, animated* Translate, on the
# mistaken assumption that Scale's default pivot is the top-left
# corner -- it isn't; pixlet's own docs say the default origin is
# already the center (0.5, 0.5). That Translate was sized assuming
# Scale had no self-centering at all, so it overcorrected, visibly
# skewing the zoom toward the bottom-right -- the opposite of the bug
# it was meant to fix. Since `origin` already does directly and
# correctly what that Translate was clumsily approximating, this uses
# `origin` for the zoom-position pivot.
#
# Separately, though: `origin` only has any effect while Scale is
# actually changing frame to frame -- confirmed empirically (varying
# origin_x/origin_y produced byte-identical output for a Scale that's
# pinned to the same constant value across every keyframe, at *any*
# scale, not just 1.0). At the two instants where this animation's own
# Scale is momentarily constant (percentage 0.0 and 1.0, i.e. "rest"),
# there's nothing for origin to pivot, so the crop that's left falls
# back to render.Box's own default handling of an oversized
# Transformation child -- which isn't a centered crop at all: checked
# with the calibration tool (concentric rings plus a center crosshair),
# the crosshair sat several pixels down-and-right of where a centered
# crop would put it, i.e. the box was effectively showing more of the
# image's top-left and cropping away a disproportionate amount from
# the bottom-right -- matching exactly what was reported ("losing
# ~10% on bottom and right, not zoomed").
#
# Fixed with a *constant* Translate -- the same (tx, ty) at every
# keyframe, found empirically via the calibration tool's crosshair
# (the value that put it dead-center at rest also held up at peak
# zoom). This is not the same mistake as the earlier abandoned
# attempt: that Translate *varied* over the animation (fighting
# Origin's own pivot, and per pixlet's own source, interacting badly
# with Scale's continuous interpolation vs. Translate's per-frame
# rounding -- the "periodic pop" bug). A constant value changes
# nothing frame to frame, so there's no interpolation for it to
# mismatch against -- confirmed smooth across a full cycle, no pop.
# It's purely compensating for render.Box's own fixed crop bias, which
# doesn't depend on zoom_scale/zoom_position at all.
origin_x, origin_y = _zoom_spot(zoom_position, subsonic_id)
top_scale = max(zoom_scale, 1.0)
box_crop_bias = -2 * scale
zoom = animation.Transformation(
child = art,
duration = ART_ZOOM_DURATION,
origin = animation.Origin(origin_x, origin_y),
keyframes = [
animation.Keyframe(
percentage = 0.0,
transforms = [animation.Scale(1.0, 1.0), animation.Translate(box_crop_bias, box_crop_bias)],
),
animation.Keyframe(
percentage = 0.5,
transforms = [animation.Scale(top_scale, top_scale), animation.Translate(box_crop_bias, box_crop_bias)],
curve = "ease_in_out",
),
animation.Keyframe(
percentage = 1.0,
transforms = [animation.Scale(1.0, 1.0), animation.Translate(box_crop_bias, box_crop_bias)],
curve = "ease_in_out",
),
],
)
return zoom
# Modeled on SUB/WAVE's own web UI, which applies a track-transition
# effect to the cover art (found via its compiled CSS/JS, not written down
# anywhere as a spec): an RGB-split/chromatic-aberration glitch -- two
# duplicate, differently-tinted copies of the art, each cropped to a
# shifting horizontal band -- plus scanlines and viewfinder-style corner
# tick marks. Built here from scratch against the *live* art via
# filter.Hue, not a bundled asset -- pixlet's `filter` module (confirmed
# real and working, same as `animation.Origin`) can recolor an arbitrary
# widget, including an already-zoomed render.Image, so the glitch bands
# reflect whatever's actually on screen rather than a generic texture.
# This replaced an earlier version built as a bundled static.gif overlay
# (first sparse noise specks, briefly a VHS-footage-derived band) -- that
# approach is still viable for projects without filter.star, but once a
# live-recolor option exists it's a better match for what SUB/WAVE itself
# does, and it means this app goes back to a single-file upload (no
# bundled asset to zip alongside it).
#
# The exact keyframe sequence/timing SUB/WAVE's CSS uses past the first
# keyframe isn't visible in its compiled output (no source maps), so
# GLITCH_STATES below is an original sequence in the same spirit (shifting
# inset bands, step-end/snap timing via a Sequence rather than eased
# motion) rather than a reproduction of unseen code. Band proportions
# (top/height as a fraction of ART_SLOT) are loosely modeled on the one
# keyframe that was visible: roughly the top third and an overlapping
# middle band, each about 30% of the frame's height.
GLITCH_HOLD_TICKS = 2 # ~100ms per jitter state at the default 50ms/tick
GLITCH_PAUSE_TICKS = 3 # ~150ms of clean art between bursts
GLITCH_STATES_PER_CYCLE = 4
# A larger pool than any one burst needs, so repeated cycles (see
# GLITCH_REPEATS below) draw a different rotating window of states each
# time instead of showing the identical 4 band positions every loop.
# (top1, height1, shift1, hue1, top2, height2, shift2, hue2); proportions
# loosely modeled on the one SUB/WAVE keyframe that was visible (roughly
# the top third and an overlapping middle band, each ~30% of the frame's
# height) -- see the note above GLITCH_HOLD_TICKS for why that's the only
# part grounded in anything observed rather than invented.
GLITCH_STATE_POOL = [
# Capped to 4px tall -- thin RGB-split slivers rather than the
# original 10-13px bands. No longer tall enough to reliably show 2-3
# scanlines within a given band (that required ~9-13px against
# _glitch_scanlines' 4px repeat), so a band can land entirely between
# two scanline rows; accepted, since the shorter height was the ask.
#
# hue2 (-60.0, magenta) is placed 3-5px *above* hue1 (150.0, cyan) in
# every entry -- a fixed relative offset rather than
# independently-random positions, per request. The gap is measured
# edge-to-edge (top1 - (top2 + h2)), not top-to-top (top1 - top2):
# an earlier version used top-to-top, which let band2 (magenta)
# overlap band1 (cyan) whenever h2 exceeded the top-to-top gap (e.g.
# h2=4 with a 3px top-to-top gap put magenta's bottom edge 1px into
# cyan's territory). top2 = top1 - h2 - gap guarantees a real 3-5px
# of clean space between them regardless of h2.
(12, 3, -2, 150.0, 4, 4, 2, -60.0),
(16, 4, 2, 150.0, 10, 3, -3, -60.0),
(14, 2, -3, 150.0, 5, 4, 1, -60.0),
(22, 4, 1, 150.0, 16, 2, -2, -60.0),
(10, 3, -1, 150.0, 3, 3, 3, -60.0),
(26, 2, 3, 150.0, 19, 4, -1, -60.0),
(14, 3, -2, 150.0, 8, 2, 1, -60.0),
(20, 4, 1, 150.0, 14, 3, -3, -60.0),
]
# render.Sequence doesn't loop on its own (see _glitch_scanlines below), so
# to repeat the burst-then-pause pattern, the repeats have to be spelled
# out as one long children list up front rather than relying on any
# runtime looping. That matters here: with GLITCH_ACTIVE_SECONDS shorter
# than the actual show_static window, the sequence simply ran out and
# stopped painting partway through -- jitter played once near the start
# of the window and then silently went quiet for the rest of it, while
# the scanlines/corner ticks (which aren't Sequence-based) kept going,
# making it look like jitter wasn't working at all once past that
# opening burst.
#
# An earlier version capped this well below the full show_static window
# (which can be up to STATIC_OVERLAY_END_SECONDS = 22s) because the
# hue-shifted RGB-split bands this used to also drive were expensive
# enough to push a real render past the 460KB device limit if sized
# against the full window (measured 481KB at the time). Those bands were
# dropped for a while (jitter alone, measured ~54KB worst-case at the
# full 22s) and this was widened to match -- but the bands have since
# come back (see GLITCH_STATE_POOL above), and worst-case payload tracks
# the *album art's own complexity*, not just whether bands are on: a
# busy/high-entropy cover compresses far worse than typical art, since
# every jitter/band/hue-shift state built from it ends up genuinely
# distinct rather than mostly redundant. Measured against synthetic
# noise art at the full 22s + strong zoom + random position: 354KB,
# uncomfortably close to the Gen1 460KB limit for art this adversarial.
#
# Capped at 18s (not the full 22) as a deliberate safety margin against
# that -- shorter than the max show_static window, so jitter/bands (and
# the scanline sweep, which shares this duration -- see
# SCANLINE_SWEEP_DURATION below) go static for the last few seconds of
# an "ending" window on a very long remaining-time case, the same
# tradeoff this was widened past once already, just re-accepted on
# purpose this time for the payload margin.
GLITCH_ACTIVE_SECONDS = 18
_GLITCH_CYCLE_TICKS = GLITCH_STATES_PER_CYCLE * GLITCH_HOLD_TICKS + GLITCH_PAUSE_TICKS
_GLITCH_TICKS_PER_SECOND = 20 # 1000ms / 50ms-per-tick
GLITCH_REPEATS = int(GLITCH_ACTIVE_SECONDS * _GLITCH_TICKS_PER_SECOND / _GLITCH_CYCLE_TICKS) + 1
def _glitch_rest_art(art_bytes, zoom_position, subsonic_id, scale):
# A separate, non-animated rendering of the art for the glitch segment
# to jitter, instead of reusing the live `zoom` that's actually
# mid-cycle on the base layer underneath. Simpler than keeping the
# jitter synced to wherever that 10s cycle currently is -- there's
# nothing here that depends on frame position, so nothing to desync
# regardless of how the jitter's own timing is built. Same
# origin/zoom_position as the real zoom, so it doesn't visually jump
# to a different part of the image when the glitch starts or ends.
#
# Scale is locked at 1.0 (via _zoom_transformation's own top_scale =
# max(zoom_scale, 1.0), so passing 1.0 forces a constant Scale(1.0,
# 1.0) with no breathing at all) -- this is exactly the "rest" state
# _glitch_shift's own margin was already validated against (see its
# comment below). A black border was seen here earlier, but it traced
# to the *shift* mechanism (animation.Transformation + Translate,
# since replaced -- see _glitch_jitter), not to this scale; two
# different attempts to fix it by bumping the scale (1.15 breathing,
# then a constant 1.15) each made the border worse, not better, which
# was the tell that scale was never the real problem.
return _zoom_transformation(art_bytes, 1.0, zoom_position, subsonic_id, scale)
def _glitch_states_for_cycle(cycle_index):
# Rotate through GLITCH_STATE_POOL instead of slicing (which doesn't
# wrap around in Starlark), so each cycle's window of states can
# straddle the end of the pool and loop back to the start.
pool_size = len(GLITCH_STATE_POOL)
start = (cycle_index * GLITCH_STATES_PER_CYCLE) % pool_size
return [GLITCH_STATE_POOL[(start + i) % pool_size] for i in range(GLITCH_STATES_PER_CYCLE)]
def _glitch_shift(zoom, shift_x):
# Applied to the *raw* zoom Transformation (not an already-cropped
# ART_SLOT x ART_SLOT image) and only clipped to ART_SLOT afterward,
# so a horizontal nudge reveals more of the same oversized margin the
# zoom animation itself relies on, rather than exposing a blank edge
# with nothing behind it -- confirmed empirically (no black border at
# +-2px, at both rest and peak zoom) before building this in.
return render.Padding(pad = (max(0, shift_x), 0, max(0, -shift_x), 0), child = zoom)
def _glitch_band(zoom, top, height, shift_x, hue_change, scale):
# top/height/shift_x arrive already multiplied by `scale` (see
# _glitch_jitter, which scales GLITCH_STATE_POOL's 1x values once at
# the point of use) -- doubling every term in an already-verified
# non-overlapping, safe-margin relationship can't change its sign or
# introduce a new overlap/exposure that wasn't already ruled out at
# 1x, so the pool itself doesn't need a separate 2x copy.
art_slot = ART_SLOT * scale
tinted = filter.Hue(child = _glitch_shift(zoom, shift_x), change = hue_change)
band = render.Padding(
pad = (0, top, 0, max(0, art_slot - top - height)),
child = render.Box(width = art_slot, height = height, child = tinted),
)
# render.Padding *grows* its child's declared size by the pad amount
# rather than nudging content within a fixed frame -- without this
# outer Box to clip it back down to exactly art_slot, the horizontal
# shift above made this (and everything stacked with it) wider than
# the art slot, bleeding into the text column next to it.
return render.Box(width = art_slot, height = art_slot, child = band)
def _glitch_base_jitter(zoom, shift_x):
# The art itself jittering in sync with the RGB-split bands (not just
# the tinted bands moving over a static image underneath) -- modeled
# on the original SUB/WAVE effect, which shifts the whole cover frame
# along with the color split. Uses the same safe raw-zoom shift
# technique as the bands above, just without the hue tint or height
# crop, so it reads as the full image nudging rather than a band.
#
# Dead code (nothing currently calls this -- _glitch_jitter inlines
# the equivalent Box(_glitch_shift(...)) directly), kept at its
# original 1x-only ART_SLOT for whenever it's revived rather than
# scaled speculatively.
return render.Box(width = ART_SLOT, height = ART_SLOT, child = _glitch_shift(zoom, shift_x))
def _glitch_hold(child, duration, scale):
# A no-op Transformation purely to pin a Sequence child's FrameCount
# to `duration` ticks, so render.Sequence holds on it for that long
# before advancing to the next child -- Sequence has no other way to
# control how long a given child plays.
#
# This matters more than it looks: an earlier version of this
# function "held" a state by appending the same widget reference to
# the children list GLITCH_HOLD_TICKS times in a row instead of
# wrapping it once in a duration-N _glitch_hold. That rendered fine
# via plain `pixlet render` to a file (confirmed, pixel-diffed, a
# clean alternating shift) but was reported as showing no jitter at
# all in pixlet serve's live preview -- repeated-identical-children
# apparently doesn't advance the same way every consumer of a
# Sequence expects. Wrapping each state in its own _glitch_hold is
# the original, proven-working pattern this app used before that
# rewrite.
return animation.Transformation(
child = child,
duration = duration,
width = ART_SLOT * scale,
height = ART_SLOT * scale,
keyframes = [
animation.Keyframe(percentage = 0.0, transforms = []),
animation.Keyframe(percentage = 1.0, transforms = []),
],
)
def _glitch_jitter(zoom, scale):
# Built as a render.Sequence of discrete shifted states, each using
# _glitch_shift (Padding-based), not animation.Transformation +
# Translate (an approach tried in between). Translate seemed
# reasonable but produced a real, confirmed-on-device black border
# that no amount of scale adjustment on `zoom` could fix --
# root-caused via isolated, pixel-sampled test renders to a more
# fundamental pixlet limitation: Transformation + Translate can't
# reveal content beyond what's visible at rest, it only moves
# already-visible pixels around. Padding genuinely grows the declared
# canvas instead, which is exactly the mechanism _glitch_shift
# already uses.
#
# A much earlier version of this file *did* use render.Sequence for
# the jitter and was abandoned for a real bug -- but that bug was
# Sequence.Paint rebasing the frame index to 0 for whichever child is
# currently playing (confirmed in pixlet's own source --
# `c.Paint(dc, bounds, frameIdx-fc)`), which desynced the *live*,
# frame-dependent zoom that version wrapped. That no longer applies:
# `zoom` here is _glitch_rest_art's static, non-animated image, so
# there's nothing frame-dependent left for Sequence to desync.
art_slot = ART_SLOT * scale
pause = _glitch_hold(render.Box(width = art_slot, height = art_slot, child = zoom), GLITCH_PAUSE_TICKS, scale)
children = []
for cycle_index in range(GLITCH_REPEATS):
for top1, h1, shift1, hue1, top2, h2, shift2, hue2 in _glitch_states_for_cycle(cycle_index):
# GLITCH_STATE_POOL's top/height/shift values are 1x pixels,
# scaled here at the point of use -- see _glitch_band for why
# that's safe (doubling every term in an already-verified
# relationship preserves it).
top1, h1, shift1 = top1 * scale, h1 * scale, shift1 * scale
top2, h2, shift2 = top2 * scale, h2 * scale, shift2 * scale
# Capped to +-1px (1x) -- a full-frame shift reads very
# differently than a thin band shifting, so it stays subtle.
# Randomized rather than derived from shift1/shift2 (several
# GLITCH_STATE_POOL entries have those nearly or exactly
# cancel out, e.g. -2 and 2, which silently zeroed out the
# jitter in about half the states when this was tried before).
#
# A filter.Hue tint on the base jitter itself (not the bands
# below, which already carry their own) was also tried, to
# make the shift more visible without a bigger (riskier)
# shift -- reverted, it tinted _glitch_shift's own blank
# margin along with the real art in both orderings tried,
# either as a visible color strip on whichever edge got
# padded, or (once the margin was kept genuinely untinted) as
# a real black border. Plain shift, no extra color tint on
# the base jitter -- confirmed 0 black-corner hits.
shift = (random.number(0, 2) - 1) * scale # random.number requires min >= 0
state = render.Stack(children = [
render.Box(width = art_slot, height = art_slot, child = _glitch_shift(zoom, shift)),
_glitch_band(zoom, top1, h1, shift1, hue1, scale),
_glitch_band(zoom, top2, h2, shift2, hue2, scale),
])
children.append(_glitch_hold(state, GLITCH_HOLD_TICKS, scale))
children.append(pause)
return render.Sequence(children = children)
# Line count and spacing are derived from each other, not picked
# independently: the small-sawtooth sweep below only loops seamlessly
# when sweeping by exactly one full repeat-period of the line pattern,
# which only exists (as a *small* shift, not the full ART_SLOT) when
# SCANLINE_COUNT evenly divides ART_SLOT. 6 lines was tried first (an
# explicit skip-tuple dropping 2 of 8 period-4 positions) and looked
# uneven/inconsistent during the sweep -- traced to exactly this: with 2
# positions skipped, the pattern's true repeat period became the full 32
# (not 4), so shifting by 4 and snapping back no longer reproduced an
# identical-looking frame. 4 divides 32 cleanly (spacing/sweep = 8px);
# the only other clean option near the original density is 8 (spacing
# 4px, the original count). Confirmed separately (pixel-sampled test
# renders) that pixlet's Transformation+Translate can't reveal content
# beyond what's visible at rest -- shifting only moves already-visible
# pixels, it doesn't pull in new rows from an off-screen duplicate -- so
# a single large sweep (shifting by the full ART_SLOT, tried as an
# alternative to this small-sawtooth approach) isn't viable either;
# small periodic shifts are the only mechanism that actually works here.
SCANLINE_COUNT = 4
# Sized against the full show_static window (GLITCH_ACTIVE_SECONDS), not
# a fixed 8s -- it used to be a flat 160 ticks while GLITCH_ACTIVE_SECONDS
# only covered a short burst, which was fine since nothing animated past
# that burst anyway. Once GLITCH_ACTIVE_SECONDS was extended to cover the
# full window, this fell behind: animation.Transformation holds at its
# last keyframe once its own declared duration is up, so the scanline
# sweep visibly froze (while jitter/bands, sized against the full window,
# kept animating) for however much of the window was left after 8s.
# _SCANLINE_CYCLE_TICKS is kept fixed (same sweep speed as before) and
# SCANLINE_CYCLES derived from it, rather than the other way around, so
# extending the window doesn't also slow the sweep down.
_SCANLINE_CYCLE_TICKS = 20 # 1s per sawtooth cycle at the default 50ms/tick
SCANLINE_SWEEP_DURATION = GLITCH_ACTIVE_SECONDS * _GLITCH_TICKS_PER_SECOND
SCANLINE_CYCLES = SCANLINE_SWEEP_DURATION // _SCANLINE_CYCLE_TICKS
def _glitch_scanlines(scale):
# Modeled on SUB/WAVE's own scanlines, which sweep continuously via
# an animated `background-position-y` rather than sitting still.
#
# An earlier version tried a single large sweep across a buffer built
# twice as tall as the art slot, pre-shifted with a *negative*
# render.Padding so the pattern's middle 32 rows lined up with the
# window at rest. That didn't work how a positive Padding (which
# reliably *grows* a widget's declared size -- used elsewhere in this
# file, e.g. _glitch_band) does; empirically it went blank partway
# through instead of revealing new rows at the top, which is what
# "scanlines disappearing at the bottom without regenerating at the
# top" looked like.
#
# This avoids all of that: the buffer is only ONE extra
# SCANLINE_SPACING taller than the art slot (the same small, proven-
# safe oversized margin used everywhere else in this file -- e.g. the
# zoom's own ART_SIZE vs. ART_SLOT), and instead of one big sweep it's
# a repeating sawtooth -- ramp down by exactly one SCANLINE_SPACING,
# then snap back to 0 and repeat. Since the pattern repeats every
# SCANLINE_SPACING (see SCANLINE_COUNT above for why), a one-period
# displacement looks identical to the starting position, so the snap
# is invisible -- a standard infinite-scrolling-texture trick -- while
# the translate distance never has to leave the small range already
# confirmed safe.
#
# SCANLINE_SPACING is computed from the *scaled* art_slot (not a
# fixed 1x constant) so it stays the true repeat period at either
# resolution -- SCANLINE_COUNT=4 still divides it evenly (32/4=8 at
# 1x, 64/4=16 at 2x), which is what the sweep's seamless loop depends
# on (see the note above SCANLINE_COUNT for why that divisibility
# matters). Each line is `scale` rows thick (not a fixed 1px), so the
# line's own weight stays proportional to the bigger canvas too.
art_slot = ART_SLOT * scale
scanline_spacing = art_slot // SCANLINE_COUNT
tall = art_slot + scanline_spacing
lines = []
for y in range(tall):
if y % scanline_spacing < scale:
lines.append(render.Box(width = art_slot, height = 1, color = "#00000080"))
else:
lines.append(render.Box(width = art_slot, height = 1, color = "#00000000"))
pattern = render.Column(children = lines)
# The ramp's end and the next cycle's snap-back share the exact same
# percentage (a zero-duration jump), rather than the snap landing one
# tick later -- that one-tick gap got linearly interpolated, which
# for a single tick showed the pattern at a fractional, not-quite-0
# offset instead of cleanly snapping, producing a brief flicker right
# at the loop point.
keyframes = []
for cycle in range(SCANLINE_CYCLES):
cycle_start = cycle * _SCANLINE_CYCLE_TICKS
cycle_end = cycle_start + _SCANLINE_CYCLE_TICKS # == next cycle's cycle_start
keyframes.append(animation.Keyframe(
percentage = float(cycle_start) / SCANLINE_SWEEP_DURATION,
transforms = [animation.Translate(0, 0)],
))
keyframes.append(animation.Keyframe(
percentage = float(cycle_end) / SCANLINE_SWEEP_DURATION,
transforms = [animation.Translate(0, scanline_spacing)],
curve = "linear",
))
sweeping = animation.Transformation(child = pattern, duration = SCANLINE_SWEEP_DURATION, keyframes = keyframes)
return render.Box(width = art_slot, height = art_slot, child = sweeping)
def _glitch_corner_ticks(color, scale):
tick_size = 5 * scale
line_thickness = 1 # kept at a crisp 1px at every scale, per request, rather than growing to `scale`
# A single tick shape (two short perpendicular lines) always draws
# the same way regardless of which corner it's placed in -- it has no
# idea it's "the top-right one". To make the bracket actually open
# toward the image's center at every corner (not just top-left), each
# line has to be explicitly pushed to the correct edge of its own
# tick_size x tick_size box via Padding: the horizontal line to the
# top or bottom, the vertical line to the left or right. Wrapped in an
# outer Box for the same reason as _glitch_band -- Padding grows its
# child's declared size, so without this the tick would be wider/
# taller than tick_size.
def tick(top_aligned, left_aligned):
far_edge = tick_size - line_thickness
h_line = render.Padding(
pad = (0, 0 if top_aligned else far_edge, 0, far_edge if top_aligned else 0),
child = render.Box(width = tick_size, height = line_thickness, color = color),
)
v_line = render.Padding(
pad = (0 if left_aligned else far_edge, 0, far_edge if left_aligned else 0, 0),
child = render.Box(width = line_thickness, height = tick_size, color = color),
)
return render.Box(width = tick_size, height = tick_size, child = render.Stack(children = [h_line, v_line]))
art_slot = ART_SLOT * scale
return render.Box(
width = art_slot,
height = art_slot,
child = render.Column(
main_align = "space_between",
expanded = True,
children = [
render.Row(main_align = "space_between", expanded = True, children = [
tick(top_aligned = True, left_aligned = True),
tick(top_aligned = True, left_aligned = False),
]),
render.Row(main_align = "space_between", expanded = True, children = [
tick(top_aligned = False, left_aligned = True),
tick(top_aligned = False, left_aligned = False),
]),
],
),
)
def _glitch_effect(zoom, scale):
art_slot = ART_SLOT * scale
return render.Box(
width = art_slot,
height = art_slot,
child = render.Stack(children = [
_glitch_jitter(zoom, scale),
_glitch_scanlines(scale),
_glitch_corner_ticks("#00000080", scale),
]),
)
def _message_screen(lines, colors):
return render.Column(
main_align = "center",
cross_align = "center",
expanded = True,
children = [render.Text(line, font = "tom-thumb", color = colors["text"]) for line in lines],
)
def _progress_bar(fraction, colors, width = 32, height = 2):
filled = int(width * fraction + 0.5)
filled = max(0, min(filled, width))
children = []
if filled > 0:
children.append(render.Box(width = filled, height = height, color = colors["accent"]))
if filled < width:
children.append(render.Box(width = width - filled, height = height, color = colors["bar_empty"]))
return render.Row(children = children)
def _fetch_now_playing(station_url):
rep = http.get(station_url + "/api/now-playing", ttl_seconds = NOWPLAYING_TTL)
if rep.status_code != 200:
return None
data = rep.json()
# `.get(key, {})` only falls back to the default when the key is
# *absent* -- SUB/WAVE sends these as explicit JSON null (e.g.
# activeShow between scheduled shows), which decodes to Starlark None
# and passes right through .get(), so `or {}` is needed too.
np = data.get("nowPlaying", {}) or {}
dj = data.get("dj", {}) or {}
context = data.get("context", {}) or {}
active_show = context.get("activeShow", {}) or {}
return {
"title": np.get("title", "Unknown title"),
"artist": np.get("artist", "Unknown artist"),
"subsonic_id": np.get("subsonic_id", ""),
"duration": np.get("duration", 0),
"station": dj.get("station", "SUB/WAVE"),
"show": active_show.get("name", ""),
"host": dj.get("name", ""),
"time_temp": _format_time_temp(context.get("clock", {}) or {}, context.get("weather", {}) or {}),
"album_year": _format_album_year(np.get("album", ""), np.get("year", None)),
"online": bool(data.get("streamOnline", False)),
}
def _format_album_year(album, year):
parts = []
if album:
parts.append(album)
if year:
parts.append(str(int(year)))
return " · ".join(parts)
def _format_time_temp(clock, weather):
parts = []
display_time = clock.get("display", "")
if display_time:
parts.append(display_time)
temp = weather.get("temp", None)
unit = weather.get("tempUnit", "")
if temp != None:
parts.append("%d°%s" % (int(temp), unit))
return " · ".join(parts)
# 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
data = rep.json()
if data.get("activeSource", "") != "show":
return None
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:
return None
rep = http.get(station_url + "/api/cover/" + subsonic_id, ttl_seconds = COVER_TTL)
if rep.status_code != 200:
return None
return rep.body()
# The now-playing API reports track duration but not elapsed/remaining time,
# so we track playback position ourselves: the first time we see a given
# subsonic_id, we stamp the current wall-clock time in the cache; on later
# renders (while the cache entry is still alive) we diff against that stamp
# to get elapsed seconds. This only works on a long-lived server process
# with a persistent cache (confirmed true for tronbyt/server's in-memory
# cache) -- a one-shot `pixlet render` CLI call always starts a fresh
# process with an empty cache, so progress will always show 0% locally.
def _track_progress(subsonic_id, duration):
if not subsonic_id or not duration:
return None
key = "subwave_track_start_" + subsonic_id
now_unix = time.now().unix
cached = cache.get(key)
if cached == None:
cache.set(key, str(now_unix), ttl_seconds = int(duration) + 30)
start_unix = now_unix
else:
start_unix = int(cached)
elapsed = max(0, now_unix - start_unix)
remaining = max(0, duration - elapsed)
fraction = min(1.0, float(elapsed) / float(duration))
return {"elapsed": elapsed, "remaining": remaining, "fraction": fraction}
def get_schema():
return schema.Schema(
version = "1",
fields = [
schema.Text(
id = "station_url",
name = "Station URL",
desc = "Base URL of your SUB/WAVE station, e.g. https://radio.example.com",
icon = "server",
default = DEFAULT_STATION_URL,
),
schema.Dropdown(
id = "color_scheme",
name = "Color scheme",
desc = "Matches SUB/WAVE's own web UI themes -- pick the one your station uses.",
icon = "palette",
default = DEFAULT_COLOR_SCHEME,
options = [
schema.Option(display = "Cyberpunk", value = "cyberpunk"),
schema.Option(display = "Blueprint", value = "blueprint"),
schema.Option(display = "Classic Dark", value = "classic_dark"),
schema.Option(display = "Classic Light", value = "classic_light"),
schema.Option(display = "Flare", value = "flare"),
schema.Option(display = "Recon", value = "recon"),
schema.Option(display = "Signal", value = "signal"),
schema.Option(display = "Vinyl", value = "vinyl"),
],
),
schema.Dropdown(
id = "zoom_level",
name = "Album art zoom",
desc = "How far the album art zooms in during its breathing effect.",
icon = "magnifyingGlass",
default = DEFAULT_ZOOM_LEVEL,
options = [
schema.Option(display = "Off", value = "off"),
schema.Option(display = "Subtle", value = "subtle"),
schema.Option(display = "Moderate", value = "moderate"),
schema.Option(display = "Strong", value = "strong"),
],
),
schema.Dropdown(
id = "zoom_position",
name = "Zoom position",
desc = "Where the album art zoom centers on.",
icon = "crosshairs",
default = DEFAULT_ZOOM_POSITION,
options = [
schema.Option(display = "Center", value = "center"),
schema.Option(display = "Random", value = "random"),
],
),
schema.Dropdown(
id = "static_overlay",
name = "Static overlay",
desc = "Show a brief TV-static effect over the album art as a track starts and ends.",
icon = "tv",
default = DEFAULT_STATIC_OVERLAY,
options = [
schema.Option(display = "On", value = "on"),
schema.Option(display = "Off", value = "off"),
],
),
],
)