diff --git a/README.md b/README.md index cff5745..b0d2fcc 100644 --- a/README.md +++ b/README.md @@ -28,18 +28,38 @@ 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 +- Album art gets a slow "Ken Burns" breathing effect: eases from one corner + of the image out to the full frame, then back into that same corner, on a + continuous ~60s round-trip loop (not a one-shot reveal — see below). + Rendered oversized (45x45 vs. the 32x32 display slot) and scaled between + 1.0 and ~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 uses 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. + + **Why a loop, not a one-shot reveal:** a one-way "start in corner, end on + full frame, then hold" 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 into a corner" 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 — diff --git a/subwave_nowplaying.star b/subwave_nowplaying.star index 527c220..a388269 100644 --- a/subwave_nowplaying.star +++ b/subwave_nowplaying.star @@ -86,20 +86,31 @@ 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 +# Album art gets a slow "Ken Burns" breathing effect: eases from one corner +# out to the full frame, then back into that same corner, on a continuous +# loop -- not a one-shot reveal. A one-shot version (start in a corner, end +# on the full frame, then hold) looked jumpy: since the app re-renders on +# NOWPLAYING_TTL and each fresh render restarts its own animation from tick +# 0, whatever the *previous* pushed image happened to be showing at that +# moment gets abruptly replaced by a fresh "zoomed into a corner" frame. The +# back-and-forth loop doesn't eliminate that (a server-side re-render always +# restarts at tick 0 regardless), but ending exactly where it started means +# the animation also loops seamlessly on-device between refreshes, which in +# practice reads as far less jarring than a one-way reveal that snaps back. +# +# 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 "zoom out to reveal everything" 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 round-trip (30s out, 30s back); 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. +# adjacent ticks become pixel-identical at this resolution -- ~97-264KB +# depending on the art's own complexity, well 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 @@ -134,10 +145,15 @@ def _art_widget(art_bytes, subsonic_id): transforms = [animation.Scale(1.0, 1.0), animation.Translate(corner_x, corner_y)], ), animation.Keyframe( - percentage = 1.0, + percentage = 0.5, transforms = [animation.Scale(ART_END_SCALE, ART_END_SCALE), animation.Translate(0, 0)], curve = "ease_in_out", ), + animation.Keyframe( + percentage = 1.0, + transforms = [animation.Scale(1.0, 1.0), animation.Translate(corner_x, corner_y)], + curve = "ease_in_out", + ), ], ) return render.Box(width = ART_SLOT, height = ART_SLOT, child = zoom)