From 03a4c55d81ee313b666e38af570c417b4e4741ee Mon Sep 17 00:00:00 2001 From: jim kolpack Date: Mon, 12 Oct 2026 20:28:59 -0400 Subject: [PATCH] Fix remaining track-length clip (2px), add a blinking progress marker Track length was still clipping on the right even with the hard-clip Box from the last fix -- the 3.9px/char estimate is an average, and real strings apparently render a little wider than that specifically on the right side. Flat -2px correction to the gap, per request, rather than re-deriving per-character widths. Progress bar gets a small (2px) marker at the filled/empty boundary that blinks on/off (~500ms per state) to mark "here's where playback actually is right now", not just the static split. Built the same way the glitch jitter/scanlines already handle "hold a state for N ticks in a render.Sequence" (a no-op animation.Transformation per state, not repeating a widget reference -- see _glitch_hold's own comment for why that distinction matters), but as its own small helper sized to the marker's own width/height rather than reusing _glitch_hold directly, since that function's width/height are hardcoded to ART_SLOT*scale. Marker position is clamped so it can't poke outside the bar at 0% or 100% progress. Blinks via Stack-ing an opaque/transparent pair, not two opaque colors, so it reads as a clean on/off against whichever of accent/bar_empty it happens to sit on. Verified: marker blinks correctly (sampled frames 0/5 on, 10/15 off, 20 back on) and positions correctly at a forced 60% fraction (sits right at the pink/dark boundary). Payload impact negligible -- re-checked against the same adversarial busy album art used earlier this session: 315KB, consistent with the pre-marker baseline (309-316KB). --- subwave_nowplaying.star | 68 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 66 insertions(+), 2 deletions(-) diff --git a/subwave_nowplaying.star b/subwave_nowplaying.star index 9cae9f6..42b2c3a 100644 --- a/subwave_nowplaying.star +++ b/subwave_nowplaying.star @@ -377,7 +377,12 @@ def main(config): if scale == 2 and progress != None: bpm_width = int(len(list(now["bpm"].codepoints())) * 3.9) length_width = int(len(list(now["track_length"].codepoints())) * 3.9) - gap = max(0, text_width - bpm_width - length_width) + # -2px: still clipping on the right at the plain estimate, even + # with the hard-clip Box -- the 3.9px/char figure is an average, + # and real strings apparently render a little wider than that on + # the right side specifically. A flat 2px correction rather than + # re-deriving per-character widths, per request. + gap = max(0, text_width - bpm_width - length_width - 2) # Wrapped in a Box(width=text_width, height=6) to hard-clip it -- # every other line in this column is a Marquee, which enforces its @@ -1025,6 +1030,50 @@ def _message_screen(lines, colors): children = [render.Text(line, font = "tom-thumb", color = colors["text"]) for line in lines], ) +PROGRESS_MARKER_BLINK_TICKS = 10 # ~500ms per on/off state at the default 50ms/tick +PROGRESS_MARKER_BLINK_CYCLES = 20 # ~20s total -- covers the longest other animation in this file (GLITCH_ACTIVE_SECONDS=18s) with room to spare + +def _progress_marker(width, height, color): + # Blinks on/off at the filled/empty boundary -- a playhead marker for + # "here's where playback actually is right now", not just the static + # filled-vs-empty split. Built as a render.Sequence alternating an + # opaque Box with a fully transparent one (not alternating between + # two opaque colors), so it reads as a clean on/off blink regardless + # of what's underneath, via Stack rather than needing to match + # whichever of accent/bar_empty the marker happens to sit on. + # + # render.Sequence doesn't loop on its own (same fact this file + # already leans on for the glitch jitter/scanlines), so the on/off + # pattern is spelled out for PROGRESS_MARKER_BLINK_CYCLES repeats + # rather than relying on any runtime looping -- 20s was picked to + # comfortably cover this file's other longest-running animation + # (the glitch window) rather than visibly going static mid-render. + def _hold(child, duration): + # Same no-op-Transformation pattern as _glitch_hold (see its own + # comment for why this is needed, not just repeating a child + # widget N times in the Sequence's children list), but sized to + # this marker's own small width/height rather than _glitch_hold's + # hardcoded ART_SLOT*scale -- that function isn't generically + # reusable for arbitrary-sized content. + return animation.Transformation( + child = child, + duration = duration, + width = width, + height = height, + keyframes = [ + animation.Keyframe(percentage = 0.0, transforms = []), + animation.Keyframe(percentage = 1.0, transforms = []), + ], + ) + + on_state = _hold(render.Box(width = width, height = height, color = color), PROGRESS_MARKER_BLINK_TICKS) + off_state = _hold(render.Box(width = width, height = height, color = "#00000000"), PROGRESS_MARKER_BLINK_TICKS) + children = [] + for _ in range(PROGRESS_MARKER_BLINK_CYCLES): + children.append(on_state) + children.append(off_state) + return render.Sequence(children = children) + def _progress_bar(fraction, colors, width = 32, height = 2): filled = int(width * fraction + 0.5) filled = max(0, min(filled, width)) @@ -1033,7 +1082,22 @@ def _progress_bar(fraction, colors, width = 32, height = 2): children.append(render.Box(width = filled, height = height, color = colors["accent"])) if filled < width: children.append(render.Box(width = width - filled, height = height, color = colors["bar_empty"])) - return render.Row(children = children) + bar = render.Row(children = children) + + # Marker is centered on the fill boundary, clamped so it never pokes + # outside the bar's own width (e.g. at 0% or 100% progress, where a + # naively-centered marker would hang half off one edge). + marker_width = min(2, width) + marker_x = max(0, min(filled - marker_width // 2, width - marker_width)) + marker = render.Padding( + pad = (marker_x, 0, width - marker_x - marker_width, 0), + child = _progress_marker(marker_width, height, colors["text"]), + ) + + return render.Stack(children = [ + render.Box(width = width, height = height, child = bar), + render.Box(width = width, height = height, child = marker), + ]) def _fetch_now_playing(station_url): rep = http.get(station_url + "/api/now-playing", ttl_seconds = NOWPLAYING_TTL)