Files
subwave-nowplaying/README.md
T
poprhythm 16ac004d06 Add a calibration test image/script for visually checking zoom cropping
dev/calibration.png is a synthetic grid (concentric color rings, corner
markers, a crosshair) and dev/calibration_test.star runs it through the
same zoom logic as the real _art_widget, so cropping can be checked
visually without depending on what a live station happens to be playing.
This is what was used to find and verify the centered-zoom fix.
2026-10-01 21:57:51 -04:00

152 lines
8.5 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.
## 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: eases from the full
frame in to a zoomed-in crop of the top-left corner, then back out, on a
continuous ~60s round-trip loop (not a one-shot reveal — see below).
Rendered oversized (45x45 vs. the 32x32 display slot) since
`animation.Scale` has no visible effect on a widget whose declared size
already matches its container, and scaled between 1.0 and 1.4 — only ever
scaling *up*, never down. That's a fix for a real on-device bug: an
earlier version zoomed *out* from a corner (scaling down from 1.0 toward
~0.71, reached via `animation.Translate`), which showed up on a real
device as a black border cutting into the art. Empirical testing (against
the actual Row layout, at native canvas size) showed the outer `Box`
auto-fits an oversized child to its own size at `Scale(1.0, 1.0)` — so
scaling *down* from there shrinks the already-fitted image further,
exposing background outside its bounds. Scaling *up* only ever crops
further into the image, which is always safe. `animation.Scale`'s pivot
defaults to the top-left corner (confirmed empirically — an explicit
`origin` parameter was tried and had no effect at all), so there's no
reliable way to zoom into any corner but that one; the effect always
zooms into the top-left. Verified this doesn't blow the payload budget:
measured ~280KB for a full render against the live station, comfortably
under the 460KB device limit.
**Why a loop, not a one-shot reveal:** a one-way version looked jumpy in
practice — since the app re-renders on `NOWPLAYING_TTL`, each fresh
render restarts its own animation from tick 0, so whatever the *previous*
pushed image happened to be showing gets abruptly replaced by a fresh
zoomed-in frame. Looping back-and-forth doesn't eliminate that (a
server-side re-render always restarts at tick 0 regardless of app logic),
but ending exactly where it started means the animation also loops
seamlessly *on-device* between refreshes, which reads as far less jarring
than a one-way reveal that snaps back. 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.
- 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.