Files
subwave-nowplaying/README.md
T
poprhythm 667899d258 Fix deploy instructions: zip upload now required, not a single file
Since the static-overlay feature bundled images/static.gif, the app needs
its asset uploaded alongside the .star file. A single-file upload (the
old instructions) will fail to resolve the load("images/static.gif", ...)
path. Updated Deploy section to match ormakers-tronbyt's zip-upload
instructions; also gitignored *.zip as a build artifact.
2026-10-02 22:37:20 -04:00

219 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
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
- `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 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 —
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
On the Tronbyt server, go to the device's **Add App** page → **Upload app**,
and upload a zip of this repo's contents (`subwave_nowplaying.star` +
`images/`, zipped at the root, no extra parent folder) — it bundles
`images/static.gif` for the static-overlay effect, so a single-file upload
of just the `.star` file will fail. Configure the Station URL field when
adding it to your device's schedule.
```
zip -r subwave-nowplaying.zip subwave_nowplaying.star images/
```