diff --git a/subwave_nowplaying.star b/subwave_nowplaying.star index 651c1a2..d615941 100644 --- a/subwave_nowplaying.star +++ b/subwave_nowplaying.star @@ -284,14 +284,19 @@ ART_SLOT = 32 ART_ZOOM_DURATION = 200 # 10s round-trip (5s in, 5s out) at the default 50ms/tick def _art_widget(art_bytes, zoom_scale, zoom_position, subsonic_id, show_static): - cropped = _zoomed_art(art_bytes, zoom_scale, zoom_position, subsonic_id) + zoom = _zoom_transformation(art_bytes, zoom_scale, zoom_position, subsonic_id) + cropped = render.Box(width = ART_SLOT, height = ART_SLOT, child = zoom) if not show_static: return cropped - return render.Stack(children = [cropped, _glitch_effect(cropped)]) + # Passing the raw (pre-crop) `zoom` down to the glitch effect, not the + # already-flattened `cropped`, so it can jitter the art's position + # using the same oversized margin the zoom animation itself relies on + # -- see _glitch_band and _glitch_base_jitter for why that matters. + return render.Stack(children = [cropped, _glitch_effect(zoom)]) -def _zoomed_art(art_bytes, zoom_scale, zoom_position, subsonic_id): +def _zoom_transformation(art_bytes, zoom_scale, zoom_position, subsonic_id): art = render.Image(src = art_bytes, width = ART_SIZE, height = ART_SIZE) # Always route through animation.Transformation, even when zoom is off @@ -338,7 +343,7 @@ def _zoomed_art(art_bytes, zoom_scale, zoom_position, subsonic_id): ), ], ) - return render.Box(width = ART_SLOT, height = ART_SLOT, child = zoom) + return zoom # Modeled on SUB/WAVE's own web UI, which applies a track-transition # effect to the cover art (found via its compiled CSS/JS, not written down @@ -421,20 +426,37 @@ def _glitch_states_for_cycle(cycle_index): start = (cycle_index * GLITCH_STATES_PER_CYCLE) % pool_size return [GLITCH_STATE_POOL[(start + i) % pool_size] for i in range(GLITCH_STATES_PER_CYCLE)] -def _glitch_band(zoomed_art, top, height, shift_x, hue_change): - tinted = filter.Hue(child = zoomed_art, change = hue_change) +def _glitch_shift(zoom, shift_x): + # Applied to the *raw* zoom Transformation (not an already-cropped + # ART_SLOT x ART_SLOT image) and only clipped to ART_SLOT afterward, + # so a horizontal nudge reveals more of the same oversized margin the + # zoom animation itself relies on, rather than exposing a blank edge + # with nothing behind it -- confirmed empirically (no black border at + # +-2px, at both rest and peak zoom) before building this in. + return render.Padding(pad = (max(0, shift_x), 0, max(0, -shift_x), 0), child = zoom) + +def _glitch_band(zoom, top, height, shift_x, hue_change): + tinted = filter.Hue(child = _glitch_shift(zoom, shift_x), change = hue_change) band = render.Padding( pad = (0, top, 0, max(0, ART_SLOT - top - height)), child = render.Box(width = ART_SLOT, height = height, child = tinted), ) - shifted = render.Padding(pad = (max(0, shift_x), 0, max(0, -shift_x), 0), child = band) # render.Padding *grows* its child's declared size by the pad amount # rather than nudging content within a fixed frame -- without this # outer Box to clip it back down to exactly ART_SLOT, the horizontal # shift above made this (and everything stacked with it) wider than # the 32px art slot, bleeding into the text column next to it. - return render.Box(width = ART_SLOT, height = ART_SLOT, child = shifted) + return render.Box(width = ART_SLOT, height = ART_SLOT, child = band) + +def _glitch_base_jitter(zoom, shift_x): + # The art itself jittering in sync with the RGB-split bands (not just + # the tinted bands moving over a static image underneath) -- modeled + # on the original SUB/WAVE effect, which shifts the whole cover frame + # along with the color split. Uses the same safe raw-zoom shift + # technique as the bands above, just without the hue tint or height + # crop, so it reads as the full image nudging rather than a band. + return render.Box(width = ART_SLOT, height = ART_SLOT, child = _glitch_shift(zoom, shift_x)) def _glitch_hold(child, duration): # A no-op Transformation purely to pin a Sequence child's FrameCount @@ -452,15 +474,21 @@ def _glitch_hold(child, duration): ], ) -def _glitch_jitter(zoomed_art): +def _glitch_jitter(zoom): pause = _glitch_hold(render.Box(width = ART_SLOT, height = ART_SLOT), GLITCH_PAUSE_TICKS) sequence = [] for cycle_index in range(GLITCH_REPEATS): for top1, h1, shift1, hue1, top2, h2, shift2, hue2 in _glitch_states_for_cycle(cycle_index): + # The art itself nudges along with the bands, not just the + # bands moving over a static image -- splitting the difference + # between the two bands' own shifts rather than adding a + # separate value to tune per state. + base_shift = int((shift1 + shift2) / 2.0) state = render.Stack(children = [ - _glitch_band(zoomed_art, top1, h1, shift1, hue1), - _glitch_band(zoomed_art, top2, h2, shift2, hue2), + _glitch_base_jitter(zoom, base_shift), + _glitch_band(zoom, top1, h1, shift1, hue1), + _glitch_band(zoom, top2, h2, shift2, hue2), ]) sequence.append(_glitch_hold(state, GLITCH_HOLD_TICKS)) sequence.append(pause) @@ -525,12 +553,12 @@ def _glitch_corner_ticks(color): ), ) -def _glitch_effect(zoomed_art): +def _glitch_effect(zoom): return render.Box( width = ART_SLOT, height = ART_SLOT, child = render.Stack(children = [ - _glitch_jitter(zoomed_art), + _glitch_jitter(zoom), _glitch_scanlines(), _glitch_corner_ticks("#33ff9980"), ]),