From 5a54376f8e70c5b996771ac6073ea92b116d6cae Mon Sep 17 00:00:00 2001 From: jim kolpack Date: Fri, 2 Oct 2026 22:32:24 -0400 Subject: [PATCH] Add an optional TV-static overlay during track start/end Shows a brief static effect (sparse noise specks, bundled as a small animated GIF) over the album art during the first/last 5 seconds of a track, as a transition cue. Implemented via render.Stack compositing a bundled images/static.gif over the zoomed art -- pixlet has no per-frame procedural drawing hook, so the noise is pre-baked with Pillow rather than generated at render time; render.Image plays animated GIFs back natively, independent of the zoom's own animation timeline. Tuned the GIF's frame count/density/flicker speed down after an initial version pushed total payload within a few percent of the 460KB device limit on some album art -- more, faster-changing frames give WebP less opportunity to dedupe near-identical frames, which compounds with the zoom animation's own dedupe needs. Final version adds ~50KB regardless of art complexity, verified via a pinned-cover test harness for reliable before/after comparison (the live station's currently-playing track kept changing between test runs otherwise). Added a "Static overlay" On/Off schema toggle (default On) per request. --- README.md | 22 ++++++++++++++++++ images/static.gif | Bin 0 -> 1928 bytes subwave_nowplaying.star | 50 +++++++++++++++++++++++++++++++++++++--- 3 files changed, 69 insertions(+), 3 deletions(-) create mode 100644 images/static.gif diff --git a/README.md b/README.md index 09615ec..ba8fdff 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,9 @@ app's config when adding it to a device. *every* refresh, not just when the track changes, so a long track gets to show off different sections of its art over time instead of being stuck on one spot for its whole runtime. +- **Static overlay** — On (default) or Off. When on, shows a brief + TV-static effect (sparse noise specks) over the album art during the + first/last few seconds of a track, as a transition cue. ## How it works @@ -112,6 +115,25 @@ app's config when adding it to a device. its full module list) — so there's no way to pre-bake or crop-at-render- time from a source we don't have until the request hits the live station. +- When enabled (Static overlay config), shows a brief TV-static effect + over the album art during the first/last `STATIC_OVERLAY_SECONDS` (5s) + of a track, as a transition cue. Implemented as a small bundled animated + GIF (`images/static.gif` — sparse noise specks over a transparent + background, built with Pillow) composited over the zoomed art via + `render.Stack`, not generated at render time: pixlet's Starlark sandbox + has no per-frame procedural drawing hook, and `animation.Transformation` + only interpolates between fixed keyframes, so there's no way to get + genuine random noise out of it. `render.Image` plays back animated GIFs + natively, looping independently of the zoom's own animation timeline. + Like the progress bar, this only works correctly on a long-lived server + with a persistent cache — a one-shot `pixlet render` CLI call always + sees `elapsed=0`, so it'll always show the "starting" static, never the + "ending" one. Tuned the GIF's frame count/density/flicker speed down + (fewer, sparser, slower-changing frames) after an initial version came + within a few percent of the 460KB device limit on some album art — more + distinct frames changing more often give WebP less opportunity to + dedupe near-identical frames, and that cost compounds with the zoom + animation already using up dedupe headroom of its own. - Shows the current show name (`context.activeShow.name`) and host name (`dj.name`) below the artist, separated by a thin divider line, and the local time/temperature (`context.clock`/`context.weather`) below that — diff --git a/images/static.gif b/images/static.gif new file mode 100644 index 0000000000000000000000000000000000000000..4265a21c44988fe0e43a404a3c6d48346b3c35d0 GIT binary patch literal 1928 zcmZ?wbhEHbRA5kGXkh?>jEs!j+}xs~qVn?cnwpx%#>Upx)~>Fu{{H?cQ>M(EIdjpX zMa!2jU$bV-#*G`dZQHhc_wFM{j+{Js^4z&|moHzwapT6_yLTTxeE96yv)8X*fB5j> z>({Tpe*OCY|3A=h#h)yk8X)>Vx1VcBu(M-;tC5}oGb2z)2Z(`=P+(x-SkJ)7A>&cN zw*_;YdWNYLz;9{6?Aj5}^S^SSqgCXOo z#(2&yn^RYlZZZfp>-F3eS?;-*H*4G6h{APun~hELc6d))gY(7u2{spiVb0Og>09*hjCE2gM2voNz)7`#YW(z=3g zaZrj?X-XgizlBgj=hXzpM$RKl0t%mWPjeXt_N~#{zW&}mRz(d1j;RT^CYFWs0Yo`Q{ zlE#hV#1oC|MiDky25Mdmx}{PPk&Mc$48nOrH48ScKDkguQh=vnqNXDwQ@!1nsGv7Y z-Tv%uXDdWi+_vZm&f>9}m>tSs%H7AJAojSmN$Q5wk?apyOvR%y^Wbt|Xz1bLH4vSV`1I%kKS`#yGa6F;ds*H28Zs0Pc<~ub-89E?s^W=l{M!%7 z-aPQ&+=86j_j)bF4zzEpoLST&VX=^fNsnJCAky(MYe)A=yF0TD-Z-9O)8*#jV6X-N Di*prA literal 0 HcmV?d00001 diff --git a/subwave_nowplaying.star b/subwave_nowplaying.star index 35dae7e..4e3739e 100644 --- a/subwave_nowplaying.star +++ b/subwave_nowplaying.star @@ -11,6 +11,7 @@ load("http.star", "http") load("cache.star", "cache") load("time.star", "time") load("animation.star", "animation") +load("images/static.gif", STATIC_ASSET = "file") DEFAULT_STATION_URL = "" NOWPLAYING_TTL = 20 @@ -90,6 +91,13 @@ COLOR_SCHEMES = { } DEFAULT_COLOR_SCHEME = "cyberpunk" # matches the station's own site theme +# How many seconds into a track's start, or before its end, the "static" +# overlay shows over the album art (a bundled animated GIF of sparse noise +# specks over a transparent background -- images/static.gif). Only used +# when the "Static overlay" schema field is enabled. +STATIC_OVERLAY_SECONDS = 5 +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 = { @@ -150,6 +158,7 @@ def main(config): 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)) @@ -164,6 +173,16 @@ def main(config): 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_SECONDS or progress["remaining"] < STATIC_OVERLAY_SECONDS) + ) + # 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 @@ -196,7 +215,7 @@ def main(config): ) if art: - content = render.Row(children = [_art_widget(art, zoom_scale, zoom_position, now["subsonic_id"]), text_column]) + content = render.Row(children = [_art_widget(art, zoom_scale, zoom_position, now["subsonic_id"], show_static), text_column]) else: content = render.Column( main_align = "center", @@ -254,7 +273,7 @@ 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): +def _art_widget(art_bytes, zoom_scale, zoom_position, subsonic_id, show_static): art = render.Image(src = art_bytes, width = ART_SIZE, height = ART_SIZE) # Always route through animation.Transformation, even when zoom is off @@ -301,7 +320,21 @@ def _art_widget(art_bytes, zoom_scale, zoom_position, subsonic_id): ), ], ) - return render.Box(width = ART_SLOT, height = ART_SLOT, child = zoom) + cropped = render.Box(width = ART_SLOT, height = ART_SLOT, child = zoom) + + if not show_static: + return cropped + + # images/static.gif is a bundled, pre-baked animated GIF (sparse noise + # specks over a transparent background) -- not something generated at + # render time. pixlet's Starlark sandbox has no per-frame procedural + # drawing hook; animated widgets work by either interpolating + # animation.Transformation keyframes (no good way to get true random + # noise out of that) or, as here, by playing back a real animated + # image asset, which render.Image supports natively for GIFs. The GIF + # loops independently of ART_ZOOM_DURATION at its own frame rate. + static = render.Image(src = STATIC_ASSET.readall(), width = ART_SLOT, height = ART_SLOT) + return render.Stack(children = [cropped, static]) def _message_screen(lines, colors): return render.Column( @@ -449,5 +482,16 @@ def get_schema(): 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"), + ], + ), ], )