Loop art zoom back-and-forth instead of one-shot reveal, to reduce refresh jumpiness
This commit is contained in:
1 parent
27e29705c9
commit
83b817c3b2
2 files changed
+57
-21
No files matched your search
@@ -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 —
|
||||
|
||||
+30
-14
@@ -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)
|
||||
|
||||