# 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 " 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. `animation.Scale`'s pivot defaults to the *center* of the widget per pixlet's own docs, so Scale alone is already close to centered — just slightly skewed toward the top-left as the zoom amount increases. Paired with a computed `animation.Translate` to cancel out that residual skew. Combining Scale with Translate at all was tried once before and reverted after it produced a periodic rendering "pop" roughly every 0.7-1s — 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 is never rounded, so a continuously-changing Scale combined with a rounded Translate holds still for several frames and then jumps a whole pixel at once. Passing `rounding = "none"` on the `Transformation` disables that snapping and fixes it — confirmed via frame-by-frame pixel-delta analysis (the amount of change is now even from frame to frame, instead of long still stretches punctuated by sudden jumps). "strong" is capped at 1.45 (not pushed further): above ~1.46 the centered crop starts revealing a real black border in the bottom-right corner, verified with the calibration tool — this is the safe ceiling, not an arbitrary choice. Verified payload stays well under the 460KB device limit at every zoom level, measured against the live station (though with `rounding = "none"`, WebP can no longer dedupe as many near-identical frames, so payloads run larger than the "pop" version did — ~370KB vs. ~220-320KB — still comfortably within budget). **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. - 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.