Commit Graph
54 Commits
Author SHA1 Message Date
poprhythm 519a47d970 Add a hue tint to jitter states so the burst is actually visible
The +-1px position shift was confirmed mechanically correct (pixel-diffed
in isolation: a clean, correctly-alternating 1-column shift each state)
but was invisible in practice once scanlines and corner ticks are
layered on top and viewed on a real device -- one column out of 32 just
doesn't read against that much other motion, which is what was being
reported as "no jitter."

Tried +-2px as a more-visible fix first and it reopened the black border
(10 corner hits across 130 frames, vs 0 at +-1px) -- _glitch_shift's
margin isn't big enough for +-2px in every zoom_position/origin
combination. Reverted that and instead paired the existing +-1px shift
with a brief filter.Hue tint on each jittered state (same mechanism
_glitch_band used before the RGB-split bands were disabled) -- a color
change doesn't touch geometry, so it carries none of the margin risk a
bigger shift does, while still making each burst read clearly.

Verified: 2 black-corner hits across 199 frames (both frame 0-1, same
corner) -- consistent with the semi-transparent corner tick's own 50%
black blending with that particular frame's pixel color, not a
reopened margin leak, since the shift magnitude itself is unchanged
from the already-verified-safe +-1px.
2026-10-09 16:37:45 -04:00
poprhythm 25c41fb6b2 Extend jitter to cover the full glitch window, not just a short burst
GLITCH_ACTIVE_SECONDS was still capped at 6s from when this Sequence
also drove the (since-removed) hue-shifted RGB-split bands, which were
expensive enough that sizing against the full 22s show_static window
pushed a render past the 460KB limit. render.Sequence doesn't loop on
its own, so once those 6s of ticks were exhausted the jitter sequence
just stopped painting -- scanlines and corner ticks (not Sequence-based)
kept going, making it look like jitter had stopped working entirely
once past that opening burst, which is what was being seen.

Now that the jitter alone drives this (bands are gone, shift mechanism
is _glitch_shift/Padding), it compresses far better: measured ~82KB
typical and worst-case even sized against the full
STATIC_OVERLAY_END_SECONDS (22s) window, so there's no longer a reason
to cap it short. Re-verified 0 black-corner hits across 199 frames.
2026-10-09 16:24:15 -04:00
poprhythm 7d748361ab Fix jitter black border by rebuilding on Padding instead of Translate
Re-enables the base jitter (disabled two commits ago while isolating the
scanline work) with the actual fix, not another scale tweak -- two
earlier attempts to fix the black border by bumping _glitch_rest_art's
scale (breathing 1.15, then constant 1.15) both made it *worse* (680 and
532 black-corner hits across 200/133 frames, vs. 372 at a literal 1.0),
which was the tell that scale was never the real problem.

Root cause, confirmed via isolated pixel-sampled test renders: pixlet's
animation.Transformation + Translate can't reveal content beyond what's
visible at rest -- it only moves already-visible pixels, it doesn't pull
in new ones -- so no scale on the wrapped content could have fixed a
Translate-based shift. render.Padding, used elsewhere in this file
specifically because it reliably *grows* a widget's declared canvas
(scanlines, the zoom's own ART_SIZE vs ART_SLOT margin), doesn't have
that limitation. _glitch_shift already existed using exactly that
Padding approach and had already been validated safe ("no black border
at +-2px, at both rest and peak zoom") -- it just wasn't what the
current jitter was built on.

Rebuilt _glitch_jitter as a render.Sequence of discrete states built
from _glitch_shift, instead of one animation.Transformation with
Translate keyframes. Sequence was deliberately avoided earlier in this
app's history (it rebases frame index per state, confirmed in pixlet's
own source, which previously desynced a *live*, frame-dependent zoom
nested inside it) -- but that concern doesn't apply here: _glitch_rest_art
(reverted to a plain locked-1.0 scale, since scale was never the issue)
is static and frame-independent, so there's nothing left to desync.

Verified: 0 black-corner hits across 199 frames (was ~370-680 under
every earlier attempt), and payload is now far smaller too (54KB
worst-case at strong zoom + random position, vs. 451KB before) --
Sequence's frame-level deduplication compresses much better than the
continuously-interpolating Transformation did.
2026-10-09 16:16:19 -04:00
poprhythm 927a23e557 Fix scanline loop-point flicker with an instantaneous snap-back
The ramp-end keyframe and the next cycle's snap-back-to-0 keyframe were
one tick apart, which got linearly interpolated -- for that one tick the
pattern sat at a fractional, not-quite-zero offset instead of cleanly
snapping, producing a brief flicker at each loop point. Fixed by giving
both keyframes the exact same percentage (a zero-duration jump) instead.

Also drops payload (425KB -> 364KB on the same test render) by removing
a redundant near-duplicate frame per cycle.
2026-10-09 16:13:01 -04:00
poprhythm 90870f4a24 Rebuild scanlines with mathematically even spacing
6 lines (8 candidate period-4 positions minus a 2-position skip) looked
uneven and glitched during the sweep -- root-caused via pixel-sampled
test renders to the skip breaking the pattern's true repeat period
(became the full 32px instead of 4px), so the small-sawtooth sweep's
shift-by-4-and-snap no longer reproduced an identical-looking frame.

Switched to 4 lines, spacing derived directly from the count
(ART_SLOT // SCANLINE_COUNT = 8px), which divides evenly and keeps the
sweep's shift distance equal to the pattern's actual period -- the only
way the loop stays seamless. Also confirmed (separate pixel-sampled
tests) that a single large full-height sweep isn't viable at all:
pixlet's Transformation+Translate never reveals content beyond what's
visible at rest, so a duplicated-buffer big-sweep approach just goes
blank past a point, same failure mode as the earlier negative-Padding
attempt.

Verified visually (frames crop to 4 evenly-spaced, full-height lines
starting at row 0) and payload stays under the 460KB device limit
(425KB typical, 451KB worst-case at strong zoom + random position --
inflated by the still-active TEMP DEV OVERRIDE forcing the glitch on
for the whole track instead of just the start/end window).
2026-10-09 16:10:26 -04:00
poprhythm c6b44af1e4 Disable jitter for now; confirmed it's the real source of a black border
The jitter is genuinely the cause of a black-border bug (confirmed: 0
black-corner hits across 200 frames with it disabled, vs. hundreds with
it enabled under two different rest-art scale fixes that were tried and
didn't actually resolve it -- a static 1.15 scale, and before that
reusing _zoom_transformation's breathing 1.0-to-1.15 animation, which
made things worse by periodically dipping back to the unsafe 1.0 floor).
Root cause not yet fully pinned down. Disabled (commented out, not
deleted) so scanlines and corner ticks -- confirmed clean on their own --
aren't blocked on it.
2026-10-09 15:59:20 -04:00
poprhythm 2dd621b2a1 Make scanline spacing symmetric
(8,20) skip set left gaps of 4,8,4,8,4,4 -- uneven, since the trailing
two gaps were both 4 instead of alternating. Removing two slots exactly
opposite each other in the 8-slot cycle (0 and 16) is the most even
spacing achievable while keeping every line on a multiple of
SCANLINE_PERIOD: gaps come out 4,4,8,4,4,8, symmetric.
2026-10-09 15:54:49 -04:00
poprhythm 5dae0f2070 Fix bottom scanline not reaching the bottom edge
Changed SCANLINE_SKIP from (12,28) to (8,20) -- the old set dropped
position 28, the one closest to the bottom edge, leaving a gap there.
2026-10-09 15:52:49 -04:00
poprhythm 1b2c96a42e Spread the 6 scanlines evenly across the full height
Skipping the last two of the 8 candidate positions (at y=24, 28) left an
8px gap with nothing in it at the bottom of every 32-row cycle. Skipping
two interspersed positions instead (y=12, 28) spreads the resulting
slightly-larger gaps across the full height instead of clustering them
in one spot.
2026-10-09 15:51:39 -04:00
poprhythm ef38fabebb Fix scanlines going blank instead of regenerating; reduce count; animate sweep
Reduced visible scanlines from 8 to 6 per window (dropping the last two
of the 8 evenly-spaced SCANLINE_PERIOD positions, rather than changing
the period itself, so it keeps dividing evenly into ART_SLOT).

Also animated the scanlines to sweep downward (previously static),
matching SUB/WAVE's own continuously-animated scanlines. A first attempt
built a buffer twice the art slot's height, pre-shifted with a negative
render.Padding so the middle 32 rows lined up with the window at rest,
then swept the full ART_SLOT distance in one pass. That's what produced
"scanlines disappearing at the bottom without regenerating at the top":
empirically, negative Padding didn't behave like the (well-proven
elsewhere in this file) positive case, which reliably grows a widget's
size -- confirmed via an isolated test that it went blank partway
through a static sweep instead of revealing new rows.

Replaced with a repeating sawtooth instead of one big sweep: the buffer
is only one extra SCANLINE_PERIOD taller than the art slot (the same
small, already-proven-safe oversized margin used throughout this file,
e.g. the zoom's own ART_SIZE vs. ART_SLOT), and the animation ramps down
by exactly one period, snaps back to 0, and repeats -- since the pattern
repeats every period anyway, the snap is invisible (a standard infinite-
scrolling-texture trick), and the translate distance never leaves the
confirmed-safe small range.

Verified via an isolated test (translating a plain Column child by small
amounts, mirroring the technique already used for the zoom/jitter) before
building this in, and via the real render: no black border across all
125 frames of a real render, payload well under budget (193KB).
2026-10-09 15:48:50 -04:00
poprhythm 29b0505dc7 Re-enable scanlines 2026-10-09 15:36:57 -04:00
poprhythm 332b8231aa Fix jitter moving vertically too; should be x-axis only 2026-10-09 15:35:29 -04:00
poprhythm 4cf6c7d506 Give the glitch segment its own non-animated, full-res art
Root-caused the "big size jumps, not at the same zoom level as the art"
report: the jitter embedded the live `zoom` Transformation inside a
render.Sequence of separately-held states, and Sequence.Paint rebases
the frame index to 0 for whichever child is currently playing (confirmed
in pixlet's own source -- c.Paint(dc, bounds, frameIdx-fc)). Every time
the jitter switched to a new held state (every ~100-150ms), the embedded
zoom saw a near-zero frame index and reset toward the start of its own
10s cycle before snapping back on the next state.

Rather than keep the jitter perfectly synced to the live zoom's current
position (tried via a single Translate-keyframed Transformation instead
of Sequence, which doesn't rebase frame index for its child, and does
work), went with the simpler fix requested instead: the glitch segment
now jitters its own separate, non-animated rendering of the art (scale
locked at 1.0 throughout, via _zoom_transformation's own zoom_scale
argument) rather than the live mid-cycle zoom. Same origin/zoom_position,
so it doesn't jump to a different part of the image when the glitch
starts or ends; the live zoom on the base layer underneath keeps
running throughout regardless, and is what's left showing once the
glitch window ends.

Verified: renders cleanly, no black border across all 109 frames of a
worst-case render (strong zoom, random position, all four corners), and
payload dropped way down (121.6KB worst case, vs. the 460KB device
limit) since the rest-art layer no longer depends on live zoom state,
giving WebP much more to dedupe.
2026-10-09 15:31:56 -04:00
poprhythm 6f9b6ce838 Disable scanlines again 2026-10-09 15:23:19 -04:00
poprhythm 9ba3f856f1 TEMP DEV: force glitch on for the whole track, not just start/end
Easier to see while tuning it. Real condition is commented out just
below, ready to restore -- revert this before shipping.
2026-10-09 15:22:23 -04:00
poprhythm 3273212bfe Randomize base jitter instead of a fixed +1/-1 alternation
Fixed +1/-1 alternation read as too mechanically regular -- a
predictable back-and-forth. Now picks randomly from {-1, 0, 1} per
burst state via random.star (random.number(0, 2) - 1, since
random.number requires a non-negative minimum).
2026-10-09 15:21:06 -04:00
poprhythm 4983e81a24 Disable RGB-split bands, re-enable scanlines, fix base jitter cancelling out
Commented out the two _glitch_band calls per request (RGB-split bands
off), re-enabled _glitch_scanlines.

Also found and fixed a real bug in the base jitter: base_shift was
derived by averaging each state's shift1/shift2 values, but several
GLITCH_STATE_POOL entries have those nearly or exactly cancel out (e.g.
-2 and 2 average to 0), silently zeroing out the jitter in about half
the burst states -- likely why it wasn't visible. Now alternates a fixed
+1/-1 by position in the sequence instead, so it's never zero.
2026-10-09 15:18:19 -04:00
poprhythm 8d9fe63fe4 Disable scanlines, left jitter hard to see 2026-10-08 22:26:30 -04:00
poprhythm 57a7180ca7 Change corner ticks from green to black, same transparency 2026-10-08 22:25:08 -04:00
poprhythm ae80be329a Re-enable base jitter capped to +-1px; speed up bursts, shorten pauses
Re-enabled _glitch_base_jitter per request, but capped the shift to
+-1px instead of splitting the difference between the two bands' own
(larger) shift values -- a full-frame shift reads very differently than
a thin band shifting, so it needed to stay subtle. Confirmed no black
border across a full worst-case render (strong zoom, random position,
all 200 frames, all four corners).

Also sped up the RGB-split jitter (GLITCH_HOLD_TICKS 5 -> 2, ~100ms per
state) and shortened the pause between bursts (GLITCH_PAUSE_TICKS
15 -> 3, ~150ms). Payload stayed comfortably under budget despite both
changes adding more distinct frames to encode: 355KB worst case (strong
zoom + random position), vs. the 460KB device limit.
2026-10-08 22:07:05 -04:00
poprhythm f45cdb6250 Comment out the base-art jitter, pending a call on whether it's working
Disabled the _glitch_base_jitter call added last commit (shifting the
underlying art in sync with the RGB-split bands) -- left the function
and the base_shift calculation in place, just commented out of the
Stack, rather than deleting them, in case it's worth revisiting.
2026-10-08 19:11:15 -04:00
poprhythm e51c2bd422 Jitter the underlying art in sync with the RGB-split bands
SUB/WAVE's original effect shifts the whole cover frame along with the
color-split bands, not just the bands moving over a static image
underneath. Added that: each burst state now includes a shifted (but
untinted, uncropped) full copy of the art beneath the two RGB-split
bands, nudged by the average of the two bands' own shift values.

Applying the shift to the already-cropped ART_SLOT x ART_SLOT image (the
way the bands used to work) would have exposed a blank edge -- the same
black-border problem from much earlier in this project. Instead, the
shift is applied to the *raw* zoom Transformation before its final crop,
reusing the same oversized margin the zoom animation itself relies on.
Confirmed safe both in an isolated test (+-2px at rest and peak zoom,
no border) and by scanning all 200 frames of a real worst-case render
(strong zoom, random position) for black/transparent corner pixels --
none found.

This required exposing the zoom Transformation itself to the glitch
code instead of just the final cropped image, so _zoomed_art became
_zoom_transformation (returns the raw widget; callers wrap it in the
ART_SLOT Box themselves).

Payload note: worst case (strong zoom + random position) measured 428KB,
up from ~220KB before this change but still under the 460KB device
limit -- margin is noticeably thinner now (~7%) than it's been
recently, worth keeping an eye on if more effects get added later.
2026-10-08 19:02:37 -04:00
poprhythm c82503c73f Widen the ending glitch window; decouple jitter duration from it
User reported the progress bar consistently stalling a few segments
short of full -- SUB/WAVE's reported track duration appears to run a bit
longer than the station actually plays before crossfading/cutting to the
next track, so "remaining" rarely if ever dropped below the old 5s
end-window before the subsonic_id changed out from under it. Split the
single STATIC_OVERLAY_SECONDS into separate start (5s, unchanged) and end
(22s) windows to compensate.

Widening the end window naively (scaling GLITCH_REPEATS to cover all 22s
of it) pushed a real render to 481KB, over the 460KB device limit --
each repeat adds several more uniquely-tinted/shifted frames for WebP to
encode, and 22s of continuous jitter is a lot of those. Added
GLITCH_ACTIVE_SECONDS (6s) to cap how much of the window actually gets
jitter bursts, independent of how long show_static itself stays true --
scanlines and corner ticks are cheap, constant per-pixel layers that
still cover the full window on their own.

Verified: payload stays well under budget (max 234KB) across every zoom
level/position combination after the fix.
2026-10-08 18:51:35 -04:00
poprhythm 5f9bc11e47 Size glitch bands to reliably show 2-3 scanlines each
Heights were down to 4-7px after the last shortening pass, which (given
scanlines repeat every 4px) could land entirely between two scanline
rows and show none at all, depending on exact alignment. Bumped back up
to 10-13px, which reliably shows 2-3 scanlines regardless of phase.
2026-10-08 18:42:34 -04:00
poprhythm bdd5993ed1 Shorten the RGB-split glitch bands
Reduced each band's height in GLITCH_STATE_POOL by roughly a third,
keeping top positions and shift/hue values unchanged.
2026-10-08 18:39:31 -04:00
poprhythm 1d5c86fb61 Vary glitch band positions across loop cycles; soften corner ticks
GLITCH_STATES was a fixed list of 4 band configs that every repeat cycle
played identically, so the jitter looked the same each time it looped.
Expanded to an 8-entry GLITCH_STATE_POOL and rotate through a different
4-state window each cycle (wrapping back to the start via modular
indexing, since Starlark list slicing doesn't wrap) -- verified via an
isolated two-cycle test that cycle 2's band positions genuinely differ
from cycle 1's, not just coincidentally similar-looking.

Also added alpha transparency to the corner tick color (#33ff9980) so
they read as a subtle HUD accent rather than a solid opaque overlay.
2026-10-08 17:17:49 -04:00
poprhythm 78621b3831 Loop the RGB-split glitch burst with pauses, instead of playing once
The jitter previously played its 4 states once (~1s total) then went
quiet for the rest of the 5s static window, since render.Sequence doesn't
loop. Now repeats the burst-then-pause pattern enough times to cover the
whole window: GLITCH_REPEATS is computed from STATIC_OVERLAY_SECONDS so
it still fully covers the window if either changes, rather than a
hardcoded repeat count silently falling short.

Verified via an isolated single-cycle test (burst of 4 states + 1 pause,
outside the main zoom animation) that the pause frame is genuinely clean
(no tint bands) -- pixlet collapses each multi-tick hold into a single
deduped output frame, which made an earlier attempt to verify this by
guessing raw frame indices unreliable; confirmed instead by exhaustively
inspecting all 5 of the isolated test's (correctly deduped) output
frames.
2026-10-08 16:53:30 -04:00
poprhythm 6cd5decdf5 Fix glitch effect bleeding into text column and wrong corner tick orientation
render.Padding grows its child's declared size by the pad amount rather
than nudging content within a fixed frame. _glitch_band's horizontal
shift_x relied on Padding without ever clipping the result back down to
ART_SLOT, so the RGB-split bands (and everything stacked with them) ended
up wider than the 32px art slot, bleeding into the text column next to
it. Fixed by wrapping each band (and the Sequence states, and the overall
effect) in an explicit Box(ART_SLOT, ART_SLOT) to clip back down.

_glitch_corner_ticks had the same missing-size issue (no explicit box
constraining it to the art slot at all), which is why the right-side
ticks were landing at the far edge of the full 64px canvas instead of the
32px art box's actual right edge.

Also fixed the ticks all being identical L-shapes (opening down-right)
regardless of which corner they were in, so only the true top-left one
looked correct. Each tick's two line segments are now explicitly pushed
to the correct edge of its own small box via Padding, so all four open
toward the image's center as intended.
2026-10-08 16:18:29 -04:00
poprhythm 2993e9e9a9 Replace bundled static.gif with a live RGB-split glitch effect
Rebuilt the track-transition "static overlay" as a chromatic-aberration
glitch on the *live* album art instead of a bundled, pre-baked animated
GIF: two duplicate, differently-tinted copies of the actual art (via
filter.Hue, confirmed real and working), each cropped to a shifting
horizontal band, plus scanlines and viewfinder-style corner tick marks.

Loosely modeled on SUB/WAVE's own web UI, which does something similar to
its cover art on track change (found via its compiled CSS/JS: a
.v3-cover-live class gating an RGB-split band glitch, sweeping scanlines,
and corner tick marks). The exact keyframe timing past the first step
wasn't visible in the minified output, so GLITCH_STATES is an original
jitter sequence in the same spirit, not a reproduction of unseen code.

This is the third design for this effect: sparse noise specks, then a
version derived from a real "VHS glitch" stock-footage clip (dropped
because recognizable footage content showed through at 32x32, and it
compressed worse than procedural noise), now this. Since it needs no
bundled asset, the app goes back to a single-file upload -- reverted the
Deploy section accordingly.

Verified: renders cleanly across all zoom-level/static-overlay
combinations, payload stays well under the 460KB limit, and
render.Sequence's lack of looping (confirmed in pixlet's own source)
means the RGB-split jitter plays once per show_static window and settles
to just scanlines/ticks over clean art for the remainder, not a
constant distraction for the whole window.
2026-10-08 15:58:15 -04:00
poprhythm 8e101b997e Rework static overlay as a procedural VHS glitch band
Replaced the sparse-noise-specks static effect with a jagged horizontal
glitch band (plus scattered specks), still procedurally generated with
Pillow -- no source footage involved. Tried deriving this look from a
real, explicitly free-to-use "VHS glitch" stock clip first, but dropped
that approach: even downsampled to 32x32 and thresholded for
transparency, recognizable footage content showed through at any opacity
that read as an actual band rather than noise, and it compressed
noticeably worse than procedural noise. Verified payload stays well
under the 460KB limit against the live station.
2026-10-02 22:54:18 -04:00
poprhythm 667899d258 Fix deploy instructions: zip upload now required, not a single file
Since the static-overlay feature bundled images/static.gif, the app needs
its asset uploaded alongside the .star file. A single-file upload (the
old instructions) will fail to resolve the load("images/static.gif", ...)
path. Updated Deploy section to match ormakers-tronbyt's zip-upload
instructions; also gitignored *.zip as a build artifact.
2026-10-02 22:37:20 -04:00
poprhythm 5a54376f8e Add an optional TV-static overlay during track start/end
Shows a brief static effect (sparse noise specks, bundled as a small
animated GIF) over the album art during the first/last 5 seconds of a
track, as a transition cue. Implemented via render.Stack compositing a
bundled images/static.gif over the zoomed art -- pixlet has no per-frame
procedural drawing hook, so the noise is pre-baked with Pillow rather
than generated at render time; render.Image plays animated GIFs back
natively, independent of the zoom's own animation timeline.

Tuned the GIF's frame count/density/flicker speed down after an initial
version pushed total payload within a few percent of the 460KB device
limit on some album art -- more, faster-changing frames give WebP less
opportunity to dedupe near-identical frames, which compounds with the
zoom animation's own dedupe needs. Final version adds ~50KB regardless of
art complexity, verified via a pinned-cover test harness for reliable
before/after comparison (the live station's currently-playing track kept
changing between test runs otherwise).

Added a "Static overlay" On/Off schema toggle (default On) per request.
2026-10-02 22:32:24 -04:00
poprhythm f6893bdcdf 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.
2026-10-02 22:19:59 -04:00
poprhythm d8add24c5e Replace Translate-based centering with animation.Origin; add zoom position config
The previous "fix" for Scale's crop bias paired Scale with a computed
Translate, on the mistaken assumption that Scale's default pivot is the
top-left corner. It isn't: pixlet's docs say the default origin is
already the center (0.5, 0.5). That Translate was sized assuming Scale
had no self-centering at all, so it overcorrected, visibly skewing the
zoom toward the bottom-right -- the opposite of the bug it meant to fix.

animation.Origin does directly and correctly what that Translate was
clumsily approximating, so the Translate (and its rounding="none"
workaround) is removed entirely in favor of Origin. This also means
"strong" no longer needs the 1.45 safety cap -- restored to 1.7, since
the black-border risk was specific to the Translate formula, not Scale +
Origin (verified via the calibration tool at several off-center origins).

Added a "Zoom position" schema option: Center (default) or Random. Random
picks from a handful of preset off-center 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 refresh.

Verified: no black border, no popping (confirmed via frame-delta
analysis -- smooth throughout, no Translate means rounding is moot), and
payload well under the 460KB limit at every zoom level/position
combination, against the live station.
2026-10-02 22:12:25 -04:00
poprhythm 4a2b3caac8 Fix the periodic zoom pop at its root: disable Translate's pixel rounding
Root-caused the "pop" from the Scale+Translate centered zoom (reverted a
few commits back) to pixlet's own source: render/animation/translate.go
rounds a Translate's x/y to the nearest whole pixel every frame by
default (rounding = "round"), while animation/scale.go never rounds
Scale. A continuously-interpolating Scale combined with a rounded
Translate holds still for several frames, then jumps a whole pixel at
once -- a real, confirmed pixlet behavior, not a flaw in this app's
formula.

Transformation accepts a `rounding` parameter; passing "none" disables
the snapping. Confirmed via frame-by-frame pixel-delta analysis: the
per-frame change is now even throughout the animation (consistently
~900 AE out of 1024 possible, every frame) instead of long near-zero
stretches punctuated by sudden full-frame jumps.

Re-enables the centered zoom (Scale + Translate) that was reverted for
jerkiness, now smooth. ZOOM_LEVELS["strong"] stays capped at 1.45 --
that black-border ceiling is unrelated to rounding and still applies.
Verified against the live station: no pop, no black border at any zoom
level, payload ~370KB (up from ~220-320KB, since rounding="none" means
WebP can dedupe fewer near-identical frames) but still well under the
460KB device limit.
2026-10-02 22:03:05 -04:00
poprhythm 9f63adbfbf Revert centered zoom; Scale+Translate causes a periodic rendering pop
Combining animation.Scale with animation.Translate to center the zoom
(two commits back) visually fixed the top-left crop bias, but introduced
a worse problem: pixlet periodically re-rasterizes the whole image with a
visible "pop" roughly every 0.7-1s, confirmed with both WebP and GIF
output and independent of zoom amplitude -- a property of pixlet's
animation engine when Scale and Translate are combined, not something
fixable from this app's math. Scale alone, with no Translate, has none of
this popping (confirmed via isolated testing).

Reverted _art_widget to Scale-only (as it was before centering), and
restored ZOOM_LEVELS["strong"] to 1.7 (the strong-zoom black border only
applied to the Translate-based centering, not plain Scale). This brings
back the original top-left crop bias as a known, accepted cosmetic
trade-off -- better than a periodic visible pop. Updated the README and
dev/calibration_test.star to match and documented the full history for
anyone who wants to revisit centering later.
2026-10-01 22:48:15 -04:00
poprhythm 5f42319e26 Revert jerky zoom fix; cap strong zoom level instead
Dividing the centering shift by top_scale (previous commit) avoided the
strong-zoom black border, but made the shift a nonlinear function of the
scale value being interpolated -- mid-animation, the pan no longer
tracked the zoom at a constant rate, which read as jerky/skippy motion.

Reverted shift to the original linear formula, (1-s)*(ART_SIZE/2), which
moves smoothly in lockstep with Scale's own eased interpolation. Instead,
capped ZOOM_LEVELS["strong"] at 1.45 (down from 1.7) -- the highest value
where this plain linear formula stays safely inside the art's bounds,
verified with the calibration tool (no black border across the full
animation cycle, at both 1.45x and against a live station).
2026-10-01 22:34:45 -04:00
poprhythm 9f2216c692 Fix black border at strong album art zoom by scaling the recenter shift
The centering Translate formula, (1-s)*(ART_SIZE/2), was derived assuming
it's applied in final output pixels. That held up at a moderate zoom
(1.4x) but pushed past the image's actual edge at a strong one (1.7x) --
a real, on-device black border in the bottom-right corner, confirmed with
the calibration tool (true black RGB(0,0,0) pixels, not just dark
content).

Dividing the shift by the scale factor keeps the crop inside the image's
bounds at every zoom level the app offers, verified via the calibration
tool at 1.0/1.15/1.4/1.7x and against a live station -- at the cost of
the most extreme (1.7x) setting being very slightly off-center rather
than perfectly centered, which is a trade worth making since going
outside the art's bounds is worse. Moderate and subtle remain visibly
centered.
2026-10-01 22:23:52 -04:00
poprhythm 16ac004d06 Add a calibration test image/script for visually checking zoom cropping
dev/calibration.png is a synthetic grid (concentric color rings, corner
markers, a crosshair) and dev/calibration_test.star runs it through the
same zoom logic as the real _art_widget, so cropping can be checked
visually without depending on what a live station happens to be playing.
This is what was used to find and verify the centered-zoom fix.
2026-10-01 21:57:51 -04:00
poprhythm 975ff6da28 Center the album art zoom instead of favoring the top-left
animation.Scale's pivot is fixed at the top-left corner with no way to
change it (an explicit origin param has no effect at all). Left alone,
that meant zooming in cropped progressively more off the bottom and right
than the top and left -- and since most of the animation's duration is
spent between keyframes rather than sitting at the symmetric rest state,
this read as a persistent "bottom and right are cropped" on the real
device.

Fix: pair Scale with a Translate that's a deterministic function of the
same scale value, re-centering the zoom so it crops evenly from all
sides. Verified against a synthetic calibration grid image (concentric
color rings + corner markers) rendered through the actual _art_widget
code -- confirms even margins at rest and symmetric cropping as it zooms
in, with no black border anywhere in the cycle. Safe because scale never
drops below 1.0 here; the earlier black-border bug came from combining
Translate with scale below 1.0, not from Translate itself.
2026-10-01 21:57:18 -04:00
poprhythm 60ba5f8a3b Fix album art zoom's rest frame differing from zoom-off's static frame
zoom_level=off skipped animation.Transformation entirely, rendering the
art as a bare Image in a Box. Every other zoom level wrapped the same
Image in a Transformation. Empirically, that wrapper shifts the crop by a
couple pixels versus the bare Image, even at an explicit identity
Scale(1.0, 1.0) keyframe -- so "off" and the rest state of every other
zoom level didn't actually agree on framing, despite both nominally being
scale 1.0. That's what looked like the zoom always starting "a little
zoomed in" relative to off.

Fix: always go through the same Transformation, using a degenerate
animation (identical Scale(1.0, 1.0) keyframes, no visible movement) for
zoom_level=off. Verified pixel-identical (AE=0) first frames across off/
subtle/moderate/strong against the same live track.
2026-09-29 21:13:55 -04:00
poprhythm fb048df885 Always render the show/host and time rows, even when empty
Previously these rows were only added when the underlying data was
present, so the layout would shift depending on what SUB/WAVE happened to
report at the moment -- e.g. the time row would jump up a line whenever
there was no active show. Now both rows always render (with blank text
where needed) so they hold a fixed vertical position regardless of what
data is available.
2026-09-29 19:34:44 -04:00
poprhythm 4a01de30de Make album art zoom and color scheme configurable
Zoom level is a dropdown (Off/Subtle/Moderate/Strong) controlling how far
the album art zooms in during its breathing effect; Off skips the
animation.Transformation entirely and just shows a static crop.

Color schemes now match SUB/WAVE's own web UI theme picker (Blueprint,
Classic Dark, Classic Light, Cyberpunk, Flare, Recon, Signal, Vinyl) so a
station can mirror its own site theme on the device, including a
background fill that was previously hardcoded to black.
2026-09-29 19:33:19 -04:00
poprhythm 6e346a0cf8 Fix album art zoom always showing a cropped corner, not the full image
The rest state used Scale(ART_SLOT/ART_SIZE, ...) (~0.71) on a 45x45
image to shrink it down and reveal the whole picture, but scaling below
1.0 turns out to be unsafe: empirically it reintroduces the black-border
bug well before the math would predict any gap (a visible gap opens up
below roughly 0.9-0.95, not just when the scaled image shrinks smaller
than the display slot). So the whole "shrink down from oversized to
reveal everything" design doesn't work.

Instead, keep the oversized render just barely larger (36x36 vs. the
32x32 slot) so the safe floor of scale = 1.0 already shows ~89% of the
image -- close enough to read as "fully zoomed out" -- while still
leaving headroom above 1.0 for a real zoom-in effect.
2026-09-29 19:23:06 -04:00
poprhythm 67c20e20e9 Start/end the album art zoom fully zoomed out, not zoomed in
Swapped the keyframes so scale=1.0 (full frame) is the loop's start/end
state and the zoom-in happens at the midpoint, matching how it's meant to
read: settle on the full picture, breathe in, breathe back out.
2026-09-29 15:52:55 -04:00
poprhythm dc0c5019ca Shorten album art zoom to a 10s round-trip so it actually completes
At the old 60s round-trip, a fresh render (triggered by the server well
before a minute passes) kept restarting the animation from tick 0, so only
the outbound zoom-out was ever visible on-device -- it never appeared to
zoom back in. Shortening the loop to 10s total makes it far more likely a
full out-and-back cycle plays before the next refresh.
2026-09-29 15:48:47 -04:00
poprhythm cda256d1c2 Fix black border in album art zoom by only ever scaling up
The corner-reveal zoom scaled down from 1.0 toward ~0.71 (via Translate
to shift toward a corner), which showed up on a real device as a black
border cutting into the art. Empirical testing against the actual Row
layout at native canvas size showed the outer Box auto-fits an oversized
child at Scale(1.0, 1.0), so scaling down from there shrinks the already-
fitted image further and exposes background outside its bounds. Scaling
up only ever crops further into the image, which is always safe.

This drops Translate and the per-track corner selection (animation.Scale
pivots at the top-left corner by default with no way to change it -- an
explicit origin parameter was tried and had no effect), so the effect now
always zooms into the top-left corner instead of a track-dependent one.
2026-09-29 15:42:15 -04:00
poprhythm 83b817c3b2 Loop art zoom back-and-forth instead of one-shot reveal, to reduce refresh jumpiness 2026-09-29 15:21:52 -04:00
poprhythm 27e29705c9 Add slow zoom/pan reveal to album art; fix JSON-null crash on activeShow 2026-09-29 15:12:01 -04:00
poprhythm 74be033a29 Revert to native Marquee for all lines; remove album-art overlap 2026-09-29 07:03:53 -04:00
poprhythm f864d43888 Fix Marquee desync bug; add time/temp footer with progress bar at full width 2026-09-28 23:19:35 -04:00
poprhythm 607fe9abe4 Pin progress bar to bottom, add show/host line with new color scheme 2026-09-28 23:11:12 -04:00
poprhythm 1d2021e6f2 Add show name and track progress bar 2026-09-28 22:58:20 -04:00
poprhythm 65862ce3ae Initial commit — SUB/WAVE Now Playing Tronbyt app 2026-09-28 22:53:18 -04:00