Rebuilt the track-transition "static overlay" as a chromatic-aberration glitch on the *live* album art instead of a bundled, pre-baked animated GIF: two duplicate, differently-tinted copies of the actual art (via filter.Hue, confirmed real and working), each cropped to a shifting horizontal band, plus scanlines and viewfinder-style corner tick marks. Loosely modeled on SUB/WAVE's own web UI, which does something similar to its cover art on track change (found via its compiled CSS/JS: a .v3-cover-live class gating an RGB-split band glitch, sweeping scanlines, and corner tick marks). The exact keyframe timing past the first step wasn't visible in the minified output, so GLITCH_STATES is an original jitter sequence in the same spirit, not a reproduction of unseen code. This is the third design for this effect: sparse noise specks, then a version derived from a real "VHS glitch" stock-footage clip (dropped because recognizable footage content showed through at 32x32, and it compressed worse than procedural noise), now this. Since it needs no bundled asset, the app goes back to a single-file upload -- reverted the Deploy section accordingly. Verified: renders cleanly across all zoom-level/static-overlay combinations, payload stays well under the 460KB limit, and render.Sequence's lack of looping (confirmed in pixlet's own source) means the RGB-split jitter plays once per show_static window and settles to just scanlines/ticks over clean art for the remainder, not a constant distraction for the whole window.
232 lines
13 KiB
Markdown
232 lines
13 KiB
Markdown
# subwave-nowplaying
|
|
|
|
Tronbyt/Tidbyt app showing the current track on a [SUB/WAVE](https://github.com/perminder-klair/subwave)
|
|
internet radio station: album art with a slow zoom/pan reveal, and four
|
|
scrolling lines — track title/artist, a thin separator, the current show
|
|
and host, and local time/temperature — plus a track progress bar pinned to
|
|
the bottom of the 32px-wide text column.
|
|
|
|
Not tied to any specific station — set your own station's base URL in the
|
|
app's config when adding it to a device.
|
|
|
|
## Configuration
|
|
|
|
- **Station URL** — the base URL of your SUB/WAVE instance, e.g.
|
|
`https://radio.example.com`. No trailing slash needed (stripped
|
|
automatically). No auth required — SUB/WAVE's now-playing and cover-art
|
|
endpoints are public.
|
|
- **Color scheme** — matches SUB/WAVE's own web UI theme picker (Blueprint,
|
|
Classic Dark, Classic Light, Cyberpunk, Flare, Recon, Signal, Vinyl), so
|
|
the device can mirror your station's own site theme. Defaults to
|
|
Cyberpunk. Each theme sets a background fill, the main text color
|
|
(title/artist/show), an accent (host/DJ name + progress bar fill), and a
|
|
muted color for the time/temp line.
|
|
- **Album art zoom** — how far the album art's breathing zoom effect goes
|
|
in: Off, Subtle, Moderate (default), or Strong.
|
|
- **Zoom position** — where the zoom centers on: Center (default), or
|
|
Random (a handful of preset off-center spots). Rotates on a ~10s cadence
|
|
based on wall-clock time, offset per-track so different tracks don't all
|
|
land on the same spot at the same moment — meaning it can change on
|
|
*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
|
|
chromatic-aberration glitch (RGB-split bands, scanlines, and corner tick
|
|
marks) over the album art during the first/last few seconds of a
|
|
track, as a transition cue.
|
|
|
|
## How it works
|
|
|
|
- `GET {station_url}/api/now-playing` — track title, artist, `subsonic_id`,
|
|
DJ/station name, and stream online status. Cached 20s.
|
|
- `GET {station_url}/api/cover/{subsonic_id}` — album art JPEG, proxied by
|
|
SUB/WAVE itself (no Navidrome credentials needed). Cached 300s.
|
|
- If the station reports `streamOnline: false`, shows "<station> offline"
|
|
instead of stale track info.
|
|
- If a track has no `subsonic_id`, or the cover fetch fails, falls back to a
|
|
full-width layout with no art rather than a broken image.
|
|
- 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 ~10s round-trip loop (not a one-shot reveal — see below). How
|
|
far it zooms in is configurable (Off/Subtle/Moderate/Strong — see
|
|
Configuration). Rendered oversized (36x36 vs. the 32x32 display slot,
|
|
just enough headroom for `animation.Scale` to have a visible effect)
|
|
and scaled *up only*, from 1.0 to the configured zoom amount, never
|
|
below 1.0 — scaling down was tried (to make the rest state reveal the
|
|
literal whole image, or to re-center an off-center crop) and both
|
|
attempts produced a real, on-device black border; see the fuller
|
|
history below.
|
|
|
|
Where the zoom centers on is controlled via `animation.Origin`, which
|
|
works correctly and does real work here — contrary to an earlier,
|
|
mistaken belief in this app's own history that it "had no effect" (that
|
|
came from testing it set to its own default value and comparing the
|
|
result against itself, which is of course identical either way). The
|
|
"Random" zoom position picks from a handful of preset off-center spots
|
|
(`ZOOM_RANDOM_SPOTS`), kept modest (0.3-0.7 of the image's own bounds,
|
|
not the full 0.0-1.0 range) 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 safe. Which spot is picked rotates on a
|
|
`ZOOM_SPOT_ROTATE_SECONDS` (10s) cadence based on wall-clock time
|
|
(`time.now().unix`), offset by a hash of `subsonic_id` so different
|
|
tracks don't all land on the same spot in the same 10s window. This was
|
|
deliberately *not* made purely per-track-stable (i.e. the same spot for
|
|
a track's entire runtime, the way the old corner-selection logic for
|
|
the since-removed Translate approach worked): the ask was to see
|
|
different sections of a single piece of art over time, which requires
|
|
the spot to be able to change mid-playback, not just when the track
|
|
changes.
|
|
|
|
An earlier version tried to fix a *different* problem — `animation.Scale`
|
|
zooming in asymmetrically toward one corner — by pairing Scale with a
|
|
*computed* `animation.Translate`, reasoning (wrongly) 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. That Translate, sized
|
|
assuming Scale had no self-centering at all, overcorrected and visibly
|
|
skewed the zoom toward the *opposite* corner instead. It also introduced
|
|
a periodic rendering "pop" (confirmed with both WebP and GIF output,
|
|
independent of zoom amplitude) root-caused to pixlet's own source
|
|
(`render/animation/translate.go`): a Translate's x/y are rounded to the
|
|
nearest whole pixel every frame by default, while Scale never rounds,
|
|
so a continuously-changing Scale combined with a rounded Translate
|
|
holds still for several frames and then jumps a whole pixel at once.
|
|
Since `animation.Origin` already does directly and correctly what that
|
|
Translate was clumsily approximating, the Translate was removed
|
|
entirely rather than patched further (e.g. with `rounding = "none"`,
|
|
which does fix the popping but wasn't needed once Origin replaced it).
|
|
Verified payload stays well under the 460KB device limit at every zoom
|
|
level and position, measured against the live station.
|
|
|
|
**Why a loop, not a one-shot reveal, and why it's short:** a one-way
|
|
version looked jumpy in practice — since the app re-renders on
|
|
`NOWPLAYING_TTL` (and the server appears to refresh pushed apps well
|
|
under a minute regardless), a fresh render restarts its own animation
|
|
from tick 0, so whatever the *previous* pushed image happened to be
|
|
showing gets abruptly replaced. A loop that starts and ends in the same
|
|
state doesn't eliminate that restart, but makes it invisible — and
|
|
keeping the round-trip short (~10s) makes it far more likely a full
|
|
cycle plays before the next refresh cuts it off; a 60s version tried
|
|
earlier only ever showed its outbound half. Considered using genuinely
|
|
random per-frame content (like `ormakers-tronbyt`'s LoRa segment, which
|
|
pre-crops 60 frames directly from a high-resolution *static* source
|
|
photo via offline Pillow processing) for a sharper zoom, but that
|
|
technique fundamentally doesn't transfer here: album art is fetched live
|
|
per-track at render time, and pixlet's Starlark sandbox has no
|
|
image-cropping/pixel-analysis capability at all (confirmed by checking
|
|
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 chromatic-aberration
|
|
glitch over the album art during the first/last `STATIC_OVERLAY_SECONDS`
|
|
(5s) of a track, as a transition cue: two duplicate, differently-tinted
|
|
copies of the *actual* art, each cropped to a shifting horizontal band
|
|
(RGB-split look), plus scanlines and viewfinder-style corner tick marks.
|
|
Loosely modeled on SUB/WAVE's own web UI, which does something similar
|
|
to its cover art on track change (found via its compiled CSS/JS — not
|
|
published anywhere as a spec, and the exact keyframe timing past the
|
|
first step wasn't visible in the minified output, so the specific
|
|
jitter sequence here, `GLITCH_STATES`, is an original sequence in the
|
|
same spirit rather than a reproduction of unseen code).
|
|
|
|
Built from the live, already-zoomed art via `filter.Hue`, not a bundled
|
|
asset — pixlet's `filter` module can recolor an arbitrary widget, so the
|
|
glitch bands reflect whatever's actually on screen. The RGB-split jitter
|
|
plays once per `show_static` window via `render.Sequence` (which doesn't
|
|
loop — confirmed in pixlet's own source, it just stops painting once its
|
|
total frame count is used up) and then goes quiet, while the scanlines
|
|
and corner ticks persist for the whole window as a constant texture.
|
|
|
|
Went through two earlier, discarded designs before landing here, both
|
|
bundled pre-baked animated GIFs rather than working from the live art:
|
|
first sparse noise specks, then a version derived from a real "VHS
|
|
glitch" stock-footage clip (downloaded, downsampled, and thresholded for
|
|
transparency) — dropped because, even licensed as free-to-use, cropping
|
|
it down to 32x32 let recognizable footage content show through at any
|
|
opacity that read as an actual "band" rather than noise, and it
|
|
compressed noticeably worse than procedural noise. The current
|
|
live-`filter.Hue` version needs no bundled asset at all, so this app is
|
|
back to a single-file upload. 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.
|
|
- 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 —
|
|
all as independently scrolling Marquee lines, confined to the 32px-wide
|
|
text column next to the album art (no overlay/overlap with the art).
|
|
|
|
**Known quirk — occasional Marquee glitch:** pixlet's `render.Marquee` runs
|
|
its own internal scroll-cycle animation, and stacking several Marquees of
|
|
different text lengths in one Root can occasionally desync them — a line
|
|
may briefly blank or show a garbled frame while another Marquee is
|
|
mid-cycle. This is a real, currently-unfixed pixlet bug (there's an
|
|
open `FIXME: Behaviour when children themselves are animated is a bit
|
|
weird` in pixlet's own widget docs), not something introduced by this app.
|
|
It's also not unique to this app: `tronbyt/apps/apps/goosefm/goose_fm.star`
|
|
(an accepted official community app) stacks 3 Marquees for the same kind of
|
|
now-playing info and exhibits the identical occasional glitch on close
|
|
frame-by-frame inspection. In practice it's subtle enough to be easy to
|
|
miss on a real, continuously-scrolling display. A custom hand-rolled scroll
|
|
(via `animation.Transformation` + `Translate`, avoiding `Marquee` entirely)
|
|
was prototyped and could fully eliminate this, but only by consolidating
|
|
down to 2 scrolling lines total (title alone, everything else combined into
|
|
one line) — full independent lines for every field was preferred instead,
|
|
accepting the same minor quirk real Tidbyt/Tronbyt apps already ship with.
|
|
|
|
**Progress bar caveat:** the now-playing API reports track `duration` but
|
|
not elapsed/remaining time, so the app tracks playback position itself —
|
|
the first time it sees a given `subsonic_id`, it stamps the current
|
|
wall-clock time in pixlet's cache, then diffs against that stamp on later
|
|
renders to compute elapsed seconds. This only works correctly on a
|
|
long-lived server process with a persistent cache (true for
|
|
`tronbyt/server`'s in-memory cache) — a one-shot `pixlet render` CLI
|
|
invocation always starts a fresh process with an empty cache, so the bar
|
|
will always show 0% when testing locally. This is expected, not a bug.
|
|
|
|
**Note on JSON nulls:** SUB/WAVE sends some fields as explicit JSON `null`
|
|
(e.g. `context.activeShow` between scheduled shows) rather than omitting the
|
|
key. Starlark's `dict.get(key, default)` only falls back to `default` when
|
|
the key is *absent* — an explicit `null` (decoded to Starlark `None`) passes
|
|
straight through, so this app also does `... or {}` on those lookups before
|
|
chaining another `.get()` off them, to avoid a `NoneType has no .get` crash.
|
|
|
|
**Note on unreachable hosts:** if the station URL is wrong or the host is
|
|
completely unreachable (DNS failure, connection refused), `http.get` raises
|
|
a hard error and the render fails for that cycle — this is normal pixlet/
|
|
Starlark behavior (no try/except in the language), not a bug. Only
|
|
HTTP-level errors (404, 500, etc.) are something the app can catch and
|
|
display a message for, which it does.
|
|
|
|
## Dev/test
|
|
|
|
```
|
|
pixlet render subwave_nowplaying.star station_url=https://your-station.example.com
|
|
pixlet serve subwave_nowplaying.star
|
|
```
|
|
|
|
`dev/calibration_test.star` renders a synthetic test grid (`dev/calibration.png`
|
|
— concentric color rings, corner markers, a crosshair) through the exact same
|
|
zoom logic as the real `_art_widget`, to visually check cropping without
|
|
depending on what a live station happens to be playing:
|
|
|
|
```
|
|
pixlet render dev/calibration_test.star -o calib.gif
|
|
```
|
|
|
|
At rest you should see a thin, *even* sliver of magenta on all four edges;
|
|
as it zooms in, each corner marker should get clipped by the same amount.
|
|
If one side loses noticeably more than the others, the zoom isn't
|
|
centered. Keep its copy of the zoom math in sync with `_art_widget` if that
|
|
ever changes.
|
|
|
|
## Deploy
|
|
|
|
Upload `subwave_nowplaying.star` directly through your Tronbyt server's
|
|
"Upload app" button on a device's Add App page (single file, no bundled
|
|
assets — no need to zip). Configure the Station URL field when adding it to
|
|
your device's schedule.
|
|
|
|
```
|
|
zip -r subwave-nowplaying.zip subwave_nowplaying.star images/
|
|
```
|