From 27e29705c9776423d914d7eb9e456813e6d102a4 Mon Sep 17 00:00:00 2001 From: jim kolpack Date: Tue, 29 Sep 2026 15:12:01 -0400 Subject: [PATCH] Add slow zoom/pan reveal to album art; fix JSON-null crash on activeShow --- README.md | 31 +++++++++++++---- subwave_nowplaying.star | 73 +++++++++++++++++++++++++++++++++++++---- 2 files changed, 92 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index cfd15a4..cff5745 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # subwave-nowplaying Tronbyt/Tidbyt app showing the current track on a [SUB/WAVE](https://github.com/perminder-klair/subwave) -internet radio station: album art, 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. Color scheme (cyan title/artist, white show name, -magenta host/progress bar) is loosely modeled on SUB/WAVE's own web -now-playing display. +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. Color scheme (cyan title/artist, +white show name, magenta host/progress bar) is loosely modeled on SUB/WAVE's +own web now-playing display. Not tied to any specific station — set your own station's base URL in the app's config when adding it to a device. @@ -28,6 +28,18 @@ app's config when adding it to a device. 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" reveal: starts zoomed into one corner of + the image, eases out to show the full frame over ~60s. Rendered oversized + (45x45 vs. the 32x32 display slot) and scaled down from 1.0 to ~0.71, + since `animation.Scale` above 1.0 has no visible effect on a widget whose + declared size already matches its container. Which corner it starts in is + derived from the track's `subsonic_id` (not true randomness) so a + still-playing track re-rendered on the `NOWPLAYING_TTL` cache doesn't + jarringly jump corners — only a genuinely new track does. Verified this + doesn't blow the payload budget: counterintuitively, a *slower* zoom + compresses *smaller*, since more adjacent ticks become pixel-identical at + this resolution and WebP dedupes them — measured 97-264KB depending on the + art's own visual complexity, all well under the 460KB device limit. - 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 — @@ -62,6 +74,13 @@ long-lived server process with a persistent cache (true for 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/ diff --git a/subwave_nowplaying.star b/subwave_nowplaying.star index f695d53..527c220 100644 --- a/subwave_nowplaying.star +++ b/subwave_nowplaying.star @@ -10,6 +10,7 @@ load("schema.star", "schema") load("http.star", "http") load("cache.star", "cache") load("time.star", "time") +load("animation.star", "animation") DEFAULT_STATION_URL = "" NOWPLAYING_TTL = 20 @@ -71,7 +72,7 @@ def main(config): ) if art: - content = render.Row(children = [render.Image(src = art, width = 32, height = 32), text_column]) + content = render.Row(children = [_art_widget(art, now["subsonic_id"]), text_column]) else: content = render.Column( main_align = "center", @@ -85,6 +86,62 @@ def main(config): return render.Root(child = content) +# Album art gets a slow "Ken Burns" reveal: starts zoomed into one corner, +# eases out to the full frame over ~1 minute. Art is rendered oversized +# (45x45, vs. the 32x32 display slot) since animation.Scale above 1.0 has no +# visible effect on a widget whose declared size already matches its +# container (confirmed elsewhere this session) -- starting at native 1.0 +# scale of the *oversized* image and easing down to ~32/45 achieves the +# "zoom out to reveal everything" effect while only ever using Scale <= 1.0. +# Combining Scale + Translate in one Transformation, and combining that with +# the app's 4 already-scrolling Marquee lines, was verified not to trigger +# any *additional* glitching beyond the already-accepted minor Marquee quirk +# (see README). Duration 1200 ticks x the default 50ms/tick = 60s; verified +# empirically that a slower zoom compresses *smaller*, not larger, since more +# adjacent ticks become pixel-identical at this resolution -- ~97KB alone, +# ~117KB combined with the full app, both far under the 460KB device limit. +ART_SIZE = 45 +ART_SLOT = 32 +ART_MAX_OFFSET = (ART_SIZE - ART_SLOT) / 2.0 # 6.5px: centered -> true corner +ART_END_SCALE = float(ART_SLOT) / float(ART_SIZE) # ~0.71: full art fits in the slot +ART_ZOOM_DURATION = 1200 # 60s at the default 50ms/tick + +CORNER_OFFSETS = [ + (-ART_MAX_OFFSET, -ART_MAX_OFFSET), + (ART_MAX_OFFSET, -ART_MAX_OFFSET), + (-ART_MAX_OFFSET, ART_MAX_OFFSET), + (ART_MAX_OFFSET, ART_MAX_OFFSET), +] + +def _corner_for_track(subsonic_id): + # Pseudo-random but stable per track, so re-rendering the same + # still-playing track (every NOWPLAYING_TTL seconds) doesn't jarringly + # jump to a different corner -- only a genuinely new track does. + total = 0 + for ch in subsonic_id.elems(): + total += ord(ch) + return CORNER_OFFSETS[total % len(CORNER_OFFSETS)] + +def _art_widget(art_bytes, subsonic_id): + corner_x, corner_y = _corner_for_track(subsonic_id) + big_art = render.Image(src = art_bytes, width = ART_SIZE, height = ART_SIZE) + zoom = animation.Transformation( + child = big_art, + duration = ART_ZOOM_DURATION, + keyframes = [ + animation.Keyframe( + percentage = 0.0, + transforms = [animation.Scale(1.0, 1.0), animation.Translate(corner_x, corner_y)], + ), + animation.Keyframe( + percentage = 1.0, + transforms = [animation.Scale(ART_END_SCALE, ART_END_SCALE), animation.Translate(0, 0)], + curve = "ease_in_out", + ), + ], + ) + return render.Box(width = ART_SLOT, height = ART_SLOT, child = zoom) + def _message_screen(lines): return render.Column( main_align = "center", @@ -109,10 +166,14 @@ def _fetch_now_playing(station_url): return None data = rep.json() - np = data.get("nowPlaying", {}) - dj = data.get("dj", {}) - context = data.get("context", {}) - active_show = context.get("activeShow", {}) + # `.get(key, {})` only falls back to the default when the key is + # *absent* -- SUB/WAVE sends these as explicit JSON null (e.g. + # activeShow between scheduled shows), which decodes to Starlark None + # and passes right through .get(), so `or {}` is needed too. + np = data.get("nowPlaying", {}) or {} + dj = data.get("dj", {}) or {} + context = data.get("context", {}) or {} + active_show = context.get("activeShow", {}) or {} return { "title": np.get("title", "Unknown title"), @@ -122,7 +183,7 @@ def _fetch_now_playing(station_url): "station": dj.get("station", "SUB/WAVE"), "show": active_show.get("name", ""), "host": dj.get("name", ""), - "time_temp": _format_time_temp(context.get("clock", {}), context.get("weather", {})), + "time_temp": _format_time_temp(context.get("clock", {}) or {}, context.get("weather", {}) or {}), "online": bool(data.get("streamOnline", False)), }