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.
This commit is contained in:
poprhythm committed 2026-10-02 22:32:24 -04:00
1 parent f6893bdcdf
commit 5a54376f8e
3 files changed
+69 -3

No files matched your search

+22
View File
@@ -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 *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 to show off different sections of its art over time instead of being
stuck on one spot for its whole runtime. 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 ## 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- 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 time from a source we don't have until the request hits the live
station. 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 - Shows the current show name (`context.activeShow.name`) and host name
(`dj.name`) below the artist, separated by a thin divider line, and the (`dj.name`) below the artist, separated by a thin divider line, and the
local time/temperature (`context.clock`/`context.weather`) below that — local time/temperature (`context.clock`/`context.weather`) below that —
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

+47 -3
View File
@@ -11,6 +11,7 @@ load("http.star", "http")
load("cache.star", "cache") load("cache.star", "cache")
load("time.star", "time") load("time.star", "time")
load("animation.star", "animation") load("animation.star", "animation")
load("images/static.gif", STATIC_ASSET = "file")
DEFAULT_STATION_URL = "" DEFAULT_STATION_URL = ""
NOWPLAYING_TTL = 20 NOWPLAYING_TTL = 20
@@ -90,6 +91,13 @@ COLOR_SCHEMES = {
} }
DEFAULT_COLOR_SCHEME = "cyberpunk" # matches the station's own site theme 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 # 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). # state (see _art_widget below for why scale never goes below 1.0).
ZOOM_LEVELS = { 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]) 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_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) zoom_position = config.get("zoom_position", DEFAULT_ZOOM_POSITION)
static_overlay_enabled = config.get("static_overlay", DEFAULT_STATIC_OVERLAY) == "on"
if not station_url: if not station_url:
return render.Root(child = _with_background(_message_screen(["Set station URL", "in app settings"], colors), colors)) 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"]) art = _fetch_cover(station_url, now["subsonic_id"])
progress = _track_progress(now["subsonic_id"], now["duration"]) 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 # 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 # they hold a fixed vertical position regardless of which fields a given
# track/moment actually has data for -- rather than the layout shifting # track/moment actually has data for -- rather than the layout shifting
@@ -196,7 +215,7 @@ def main(config):
) )
if art: 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: else:
content = render.Column( content = render.Column(
main_align = "center", main_align = "center",
@@ -254,7 +273,7 @@ ART_SIZE = 36
ART_SLOT = 32 ART_SLOT = 32
ART_ZOOM_DURATION = 200 # 10s round-trip (5s in, 5s out) at the default 50ms/tick 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) art = render.Image(src = art_bytes, width = ART_SIZE, height = ART_SIZE)
# Always route through animation.Transformation, even when zoom is off # 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): def _message_screen(lines, colors):
return render.Column( return render.Column(
@@ -449,5 +482,16 @@ def get_schema():
schema.Option(display = "Random", value = "random"), 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"),
],
),
], ],
) )
Reference in new issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.