From f6893bdcdfb4e28b298b4f8833cbb11089295e20 Mon Sep 17 00:00:00 2001 From: jim kolpack Date: Fri, 2 Oct 2026 22:19:59 -0400 Subject: [PATCH] Rotate the random zoom spot over time, not just per-track Random zoom position was previously picked once per track (stable via subsonic_id) and held for that track's entire runtime. Changed it to rotate on a ~10s cadence based on wall-clock time, still offset by a per-track hash so different tracks don't all land on the same spot in the same window. This lets a single long track show off different sections of its own artwork over time, rather than being stuck on one spot until the next track starts. --- README.md | 28 +++++++++++++++++++--------- subwave_nowplaying.star | 36 ++++++++++++++++++++++++------------ 2 files changed, 43 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 15f92a6..09615ec 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,12 @@ app's config when adding it to a device. - **Album art zoom** — how far the album art's breathing zoom effect goes in: Off, Subtle, Moderate (default), or Strong. - **Zoom position** — where the zoom centers on: Center (default), or - Random (a handful of preset off-center spots, picked per-track so a - still-playing track doesn't jump between spots on every refresh). + Random (a handful of preset off-center spots). Rotates on a ~10s cadence + based on wall-clock time, offset per-track so different tracks don't all + land on the same spot at the same moment — meaning it can change on + *every* refresh, not just when the track changes, so a long track gets + to show off different sections of its art over time instead of being + stuck on one spot for its whole runtime. ## How it works @@ -55,13 +59,19 @@ app's config when adding it to a device. came from testing it set to its own default value and comparing the result against itself, which is of course identical either way). The "Random" zoom position picks from a handful of preset off-center spots - (`ZOOM_RANDOM_SPOTS`), chosen per-track from `subsonic_id` the same way - the old corner-selection logic worked, so a still-playing track doesn't - jump between spots on every `NOWPLAYING_TTL` refresh. Those spots are - kept modest (0.3-0.7 of the image's own bounds, not the full 0.0-1.0 - range) and verified safe (no black border, even at the strongest 1.7x - zoom) via the calibration tool; more extreme corners weren't tested and - may not be safe. + (`ZOOM_RANDOM_SPOTS`), kept modest (0.3-0.7 of the image's own bounds, + not the full 0.0-1.0 range) and verified safe (no black border, even at + the strongest 1.7x zoom) via the calibration tool; more extreme corners + weren't tested and may not be safe. Which spot is picked rotates on a + `ZOOM_SPOT_ROTATE_SECONDS` (10s) cadence based on wall-clock time + (`time.now().unix`), offset by a hash of `subsonic_id` so different + tracks don't all land on the same spot in the same 10s window. This was + deliberately *not* made purely per-track-stable (i.e. the same spot for + a track's entire runtime, the way the old corner-selection logic for + the since-removed Translate approach worked): the ask was to see + different sections of a single piece of art over time, which requires + the spot to be able to change mid-playback, not just when the track + changes. An earlier version tried to fix a *different* problem — `animation.Scale` zooming in asymmetrically toward one corner — by pairing Scale with a diff --git a/subwave_nowplaying.star b/subwave_nowplaying.star index a1ce8b4..35dae7e 100644 --- a/subwave_nowplaying.star +++ b/subwave_nowplaying.star @@ -104,13 +104,22 @@ DEFAULT_ZOOM_LEVEL = "moderate" # own 36x36 bounds. Kept modest (0.3-0.7, not full 0.0-1.0) and verified # safe (no black border, even at the strongest 1.7x zoom) via the # calibration tool -- more extreme corners weren't tested and may not be. -# Picked per-track (stable via subsonic_id, like the old corner-selection -# logic) rather than per-render, so a still-playing track doesn't jump -# between spots on every NOWPLAYING_TTL refresh. Only used when the -# "Zoom position" schema field is set to "random" -- "center" always zooms -# on the image's own center, exposed as a config choice since some covers -# (e.g. ones with a centered logo or face) read better with a predictable -# center zoom than a per-track random one. +# Only used when the "Zoom position" schema field is set to "random" -- +# "center" always zooms on the image's own center, exposed as a config +# choice since some covers (e.g. ones with a centered logo or face) read +# better with a predictable center zoom than a random one. +# +# Rotates on a ~10s cadence (matching ART_ZOOM_DURATION below) based on +# wall-clock time, not just per-track: a time bucket picks the spot, offset +# by a per-track hash so different tracks don't all land on the same spot +# at the same moment. This means the spot can change on *every* refresh, +# including mid-playback of a single still-running track -- not just when +# the track itself changes -- so a long track gets to show off different +# sections of its art over time, which is the whole point of "random" +# rather than "center". One real limitation: a one-shot `pixlet render` +# CLI call reads the system clock same as the live server would, so this +# is still deterministic for a given moment -- it just varies *which* +# moment you rendered at, same as the live progress bar in _track_progress. ZOOM_CENTER_SPOT = (0.5, 0.5) ZOOM_RANDOM_SPOTS = [ (0.5, 0.5), @@ -119,16 +128,19 @@ ZOOM_RANDOM_SPOTS = [ (0.3, 0.7), (0.7, 0.3), ] +ZOOM_SPOT_ROTATE_SECONDS = 10 DEFAULT_ZOOM_POSITION = "center" -def _zoom_spot_for_track(zoom_position, subsonic_id): +def _zoom_spot(zoom_position, subsonic_id): if zoom_position != "random": return ZOOM_CENTER_SPOT - total = 0 + track_offset = 0 for ch in subsonic_id.elems(): - total += ord(ch) - return ZOOM_RANDOM_SPOTS[total % len(ZOOM_RANDOM_SPOTS)] + track_offset += ord(ch) + + time_bucket = int(time.now().unix / ZOOM_SPOT_ROTATE_SECONDS) + return ZOOM_RANDOM_SPOTS[(time_bucket + track_offset) % len(ZOOM_RANDOM_SPOTS)] def main(config): station_url = config.get("station_url", DEFAULT_STATION_URL) @@ -266,7 +278,7 @@ def _art_widget(art_bytes, zoom_scale, zoom_position, subsonic_id): # toward the bottom-right -- the opposite of the bug it was meant to # fix. Since `origin` already does directly and correctly what that # Translate was clumsily approximating, this uses `origin` alone. - origin_x, origin_y = _zoom_spot_for_track(zoom_position, subsonic_id) + origin_x, origin_y = _zoom_spot(zoom_position, subsonic_id) top_scale = max(zoom_scale, 1.0) zoom = animation.Transformation( child = art,