diff --git a/manifest.yaml b/manifest.yaml index 5e3a407..9674240 100644 --- a/manifest.yaml +++ b/manifest.yaml @@ -6,3 +6,4 @@ desc: Configure with your SUB/WAVE (github.com/perminder-klair/subwave) station' author: poprhythm fileName: subwave_nowplaying.star packageName: subwave-nowplaying +supports2x: true diff --git a/subwave_nowplaying.star b/subwave_nowplaying.star index ed44312..50d7640 100644 --- a/subwave_nowplaying.star +++ b/subwave_nowplaying.star @@ -5,7 +5,7 @@ Description: Configure with your SUB/WAVE (github.com/perminder-klair/subwave) s Author: poprhythm """ -load("render.star", "render") +load("render.star", "render", "canvas") load("schema.star", "schema") load("http.star", "http") load("cache.star", "cache") @@ -162,6 +162,21 @@ def _zoom_spot(zoom_position, subsonic_id): return ZOOM_RANDOM_SPOTS[(time_bucket + track_offset) % len(ZOOM_RANDOM_SPOTS)] def main(config): + # `scale` is the single source of truth for 2x support: 2 when the + # device/preview is rendering at 2x resolution (canvas.is2x()), else + # 1. Every size-based constant in this file is expressed as its 1x + # value and multiplied by `scale` at the point of use, rather than + # hardcoding separate 1x/2x numbers -- this keeps every hand-tuned + # relationship (black-border margins, scanline period divisibility, + # band overlap gaps) proportionally identical at both resolutions, + # since scaling every term in an already-verified-safe linear + # relationship by the same integer factor can't change its sign or + # introduce a new overlap/exposure that wasn't already ruled out at + # 1x. Tick counts/durations are deliberately *not* scaled -- those + # are real time, not space, and should take the same number of + # seconds regardless of resolution. + scale = 2 if canvas.is2x() else 1 + station_url = config.get("station_url", DEFAULT_STATION_URL) if station_url: station_url = station_url.rstrip("/") @@ -194,6 +209,8 @@ def main(config): (progress["elapsed"] < STATIC_OVERLAY_START_SECONDS or progress["remaining"] < STATIC_OVERLAY_END_SECONDS) ) + text_width = ART_SLOT * scale # the non-art half of the canvas is exactly as wide as the art slot + # The show/host and time/temp rows always render, even when empty, so # they hold a fixed vertical position regardless of which fields a given # track/moment actually has data for -- rather than the layout shifting @@ -203,20 +220,20 @@ def main(config): show_host_row.append(render.Text(" " + now["host"], font = "tom-thumb", color = colors["accent"])) top_lines = [ - render.Marquee(width = 32, child = render.Text(now["title"], color = colors["text"])), - render.Marquee(width = 32, child = render.Text(now["artist"], color = colors["text"])), - render.Box(width = 32, height = 1, color = colors["separator"]), - render.Marquee(width = 32, child = render.Row(children = show_host_row)), - render.Marquee(width = 32, child = render.Text(now["time_temp"], font = "tom-thumb", color = colors["meta"])), + render.Marquee(width = text_width, child = render.Text(now["title"], font = "tb-8", color = colors["text"])), + render.Marquee(width = text_width, child = render.Text(now["artist"], font = "tb-8", color = colors["text"])), + render.Box(width = text_width, height = scale, color = colors["separator"]), + render.Marquee(width = text_width, child = render.Row(children = show_host_row)), + render.Marquee(width = text_width, child = render.Text(now["time_temp"], font = "tom-thumb", color = colors["meta"])), ] children = [render.Column(children = top_lines)] if progress != None: - children.append(_progress_bar(progress["fraction"], colors, width = 32)) + children.append(_progress_bar(progress["fraction"], colors, width = text_width, height = 2 * scale)) text_column = render.Box( - width = 32, - height = 32, + width = text_width, + height = ART_SLOT * scale, child = render.Column( main_align = "space_between", cross_align = "start", @@ -226,15 +243,15 @@ def main(config): ) if art: - content = render.Row(children = [_art_widget(art, zoom_scale, zoom_position, now["subsonic_id"], show_static), text_column]) + content = render.Row(children = [_art_widget(art, zoom_scale, zoom_position, now["subsonic_id"], show_static, scale), text_column]) else: content = render.Column( main_align = "center", cross_align = "center", expanded = True, children = [ - render.Marquee(width = 64, child = render.Text(now["title"], color = colors["text"])), - render.Marquee(width = 64, child = render.Text(now["artist"], color = colors["text"])), + render.Marquee(width = canvas.width(), child = render.Text(now["title"], font = "tb-8", color = colors["text"])), + render.Marquee(width = canvas.width(), child = render.Text(now["artist"], font = "tb-8", color = colors["text"])), ], ) @@ -242,7 +259,7 @@ def main(config): def _with_background(child, colors): return render.Stack(children = [ - render.Box(width = 64, height = 32, color = colors["background"]), + render.Box(width = canvas.width(), height = canvas.height(), color = colors["background"]), child, ]) @@ -284,9 +301,9 @@ ART_SIZE = 36 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): - zoom = _zoom_transformation(art_bytes, zoom_scale, zoom_position, subsonic_id) - cropped = render.Box(width = ART_SLOT, height = ART_SLOT, child = zoom) +def _art_widget(art_bytes, zoom_scale, zoom_position, subsonic_id, show_static, scale): + zoom = _zoom_transformation(art_bytes, zoom_scale, zoom_position, subsonic_id, scale) + cropped = render.Box(width = ART_SLOT * scale, height = ART_SLOT * scale, child = zoom) if not show_static: return cropped @@ -298,11 +315,20 @@ def _art_widget(art_bytes, zoom_scale, zoom_position, subsonic_id, show_static): # _glitch_rest_art for why that was worth avoiding). The live zoom on # the base `cropped` layer underneath keeps running throughout; once # the glitch window ends, that's what's left showing. - rest_art = _glitch_rest_art(art_bytes, zoom_position, subsonic_id) - return render.Stack(children = [cropped, _glitch_effect(rest_art)]) + rest_art = _glitch_rest_art(art_bytes, zoom_position, subsonic_id, scale) + return render.Stack(children = [cropped, _glitch_effect(rest_art, scale)]) -def _zoom_transformation(art_bytes, zoom_scale, zoom_position, subsonic_id): - art = render.Image(src = art_bytes, width = ART_SIZE, height = ART_SIZE) +def _zoom_transformation(art_bytes, zoom_scale, zoom_position, subsonic_id, scale): + # Decoding at ART_SIZE * scale (not a fixed ART_SIZE) is what actually + # makes 2x worth anything for the art specifically: the source JPEG + # fetched live from the station is typically much higher resolution + # than even a doubled target, so a bigger decode genuinely pulls in + # more real detail rather than just stretching the same 36x36-logical- + # pixel result pixlet would otherwise produce by passively upscaling + # the final composited image (confirmed empirically: a non-2x-aware + # render, downscaled back with nearest-neighbor, matched the 1x render + # exactly -- no extra detail, just bigger blocks). + art = render.Image(src = art_bytes, width = ART_SIZE * scale, height = ART_SIZE * scale) # Always route through animation.Transformation, even when zoom is off # (a degenerate animation whose two keyframes are both Scale(1.0, 1.0), @@ -438,7 +464,7 @@ _GLITCH_CYCLE_TICKS = GLITCH_STATES_PER_CYCLE * GLITCH_HOLD_TICKS + GLITCH_PAUSE _GLITCH_TICKS_PER_SECOND = 20 # 1000ms / 50ms-per-tick GLITCH_REPEATS = int(GLITCH_ACTIVE_SECONDS * _GLITCH_TICKS_PER_SECOND / _GLITCH_CYCLE_TICKS) + 1 -def _glitch_rest_art(art_bytes, zoom_position, subsonic_id): +def _glitch_rest_art(art_bytes, zoom_position, subsonic_id, scale): # A separate, non-animated rendering of the art for the glitch segment # to jitter, instead of reusing the live `zoom` that's actually # mid-cycle on the base layer underneath. Simpler than keeping the @@ -458,7 +484,7 @@ def _glitch_rest_art(art_bytes, zoom_position, subsonic_id): # different attempts to fix it by bumping the scale (1.15 breathing, # then a constant 1.15) each made the border worse, not better, which # was the tell that scale was never the real problem. - return _zoom_transformation(art_bytes, 1.0, zoom_position, subsonic_id) + return _zoom_transformation(art_bytes, 1.0, zoom_position, subsonic_id, scale) def _glitch_states_for_cycle(cycle_index): # Rotate through GLITCH_STATE_POOL instead of slicing (which doesn't @@ -477,19 +503,26 @@ def _glitch_shift(zoom, shift_x): # +-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): +def _glitch_band(zoom, top, height, shift_x, hue_change, scale): + # top/height/shift_x arrive already multiplied by `scale` (see + # _glitch_jitter, which scales GLITCH_STATE_POOL's 1x values once at + # the point of use) -- doubling every term in an already-verified + # non-overlapping, safe-margin relationship can't change its sign or + # introduce a new overlap/exposure that wasn't already ruled out at + # 1x, so the pool itself doesn't need a separate 2x copy. + art_slot = ART_SLOT * scale 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), + pad = (0, top, 0, max(0, art_slot - top - height)), + child = render.Box(width = art_slot, height = height, child = tinted), ) # 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 + # 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 = band) + # the art slot, bleeding into the text column next to it. + 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 @@ -498,9 +531,14 @@ def _glitch_base_jitter(zoom, shift_x): # 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. + # + # Dead code (nothing currently calls this -- _glitch_jitter inlines + # the equivalent Box(_glitch_shift(...)) directly), kept at its + # original 1x-only ART_SLOT for whenever it's revived rather than + # scaled speculatively. return render.Box(width = ART_SLOT, height = ART_SLOT, child = _glitch_shift(zoom, shift_x)) -def _glitch_hold(child, duration): +def _glitch_hold(child, duration, scale): # A no-op Transformation purely to pin a Sequence child's FrameCount # to `duration` ticks, so render.Sequence holds on it for that long # before advancing to the next child -- Sequence has no other way to @@ -520,15 +558,15 @@ def _glitch_hold(child, duration): return animation.Transformation( child = child, duration = duration, - width = ART_SLOT, - height = ART_SLOT, + width = ART_SLOT * scale, + height = ART_SLOT * scale, keyframes = [ animation.Keyframe(percentage = 0.0, transforms = []), animation.Keyframe(percentage = 1.0, transforms = []), ], ) -def _glitch_jitter(zoom): +def _glitch_jitter(zoom, scale): # Built as a render.Sequence of discrete shifted states, each using # _glitch_shift (Padding-based), not animation.Transformation + # Translate (an approach tried in between). Translate seemed @@ -549,14 +587,22 @@ def _glitch_jitter(zoom): # frame-dependent zoom that version wrapped. That no longer applies: # `zoom` here is _glitch_rest_art's static, non-animated image, so # there's nothing frame-dependent left for Sequence to desync. - pause = _glitch_hold(render.Box(width = ART_SLOT, height = ART_SLOT, child = zoom), GLITCH_PAUSE_TICKS) + art_slot = ART_SLOT * scale + pause = _glitch_hold(render.Box(width = art_slot, height = art_slot, child = zoom), GLITCH_PAUSE_TICKS, scale) children = [] for cycle_index in range(GLITCH_REPEATS): for top1, h1, shift1, hue1, top2, h2, shift2, hue2 in _glitch_states_for_cycle(cycle_index): - # Capped to +-1px -- a full-frame shift reads very differently - # than a thin band shifting, so it stays subtle. Randomized - # rather than derived from shift1/shift2 (several + # GLITCH_STATE_POOL's top/height/shift values are 1x pixels, + # scaled here at the point of use -- see _glitch_band for why + # that's safe (doubling every term in an already-verified + # relationship preserves it). + top1, h1, shift1 = top1 * scale, h1 * scale, shift1 * scale + top2, h2, shift2 = top2 * scale, h2 * scale, shift2 * scale + + # Capped to +-1px (1x) -- a full-frame shift reads very + # differently than a thin band shifting, so it stays subtle. + # Randomized rather than derived from shift1/shift2 (several # GLITCH_STATE_POOL entries have those nearly or exactly # cancel out, e.g. -2 and 2, which silently zeroed out the # jitter in about half the states when this was tried before). @@ -570,13 +616,13 @@ def _glitch_jitter(zoom): # padded, or (once the margin was kept genuinely untinted) as # a real black border. Plain shift, no extra color tint on # the base jitter -- confirmed 0 black-corner hits. - shift = random.number(0, 2) - 1 # random.number requires min >= 0 + shift = (random.number(0, 2) - 1) * scale # random.number requires min >= 0 state = render.Stack(children = [ - render.Box(width = ART_SLOT, height = ART_SLOT, child = _glitch_shift(zoom, shift)), - _glitch_band(zoom, top1, h1, shift1, hue1), - _glitch_band(zoom, top2, h2, shift2, hue2), + render.Box(width = art_slot, height = art_slot, child = _glitch_shift(zoom, shift)), + _glitch_band(zoom, top1, h1, shift1, hue1, scale), + _glitch_band(zoom, top2, h2, shift2, hue2, scale), ]) - children.append(_glitch_hold(state, GLITCH_HOLD_TICKS)) + children.append(_glitch_hold(state, GLITCH_HOLD_TICKS, scale)) children.append(pause) return render.Sequence(children = children) @@ -600,7 +646,6 @@ def _glitch_jitter(zoom): # alternative to this small-sawtooth approach) isn't viable either; # small periodic shifts are the only mechanism that actually works here. SCANLINE_COUNT = 4 -SCANLINE_SPACING = ART_SLOT // SCANLINE_COUNT # Sized against the full show_static window (GLITCH_ACTIVE_SECONDS), not # a fixed 8s -- it used to be a flat 160 ticks while GLITCH_ACTIVE_SECONDS @@ -617,7 +662,7 @@ _SCANLINE_CYCLE_TICKS = 20 # 1s per sawtooth cycle at the default 50ms/tick SCANLINE_SWEEP_DURATION = GLITCH_ACTIVE_SECONDS * _GLITCH_TICKS_PER_SECOND SCANLINE_CYCLES = SCANLINE_SWEEP_DURATION // _SCANLINE_CYCLE_TICKS -def _glitch_scanlines(): +def _glitch_scanlines(scale): # Modeled on SUB/WAVE's own scanlines, which sweep continuously via # an animated `background-position-y` rather than sitting still. # @@ -642,13 +687,23 @@ def _glitch_scanlines(): # is invisible -- a standard infinite-scrolling-texture trick -- while # the translate distance never has to leave the small range already # confirmed safe. - tall = ART_SLOT + SCANLINE_SPACING + # + # SCANLINE_SPACING is computed from the *scaled* art_slot (not a + # fixed 1x constant) so it stays the true repeat period at either + # resolution -- SCANLINE_COUNT=4 still divides it evenly (32/4=8 at + # 1x, 64/4=16 at 2x), which is what the sweep's seamless loop depends + # on (see the note above SCANLINE_COUNT for why that divisibility + # matters). Each line is `scale` rows thick (not a fixed 1px), so the + # line's own weight stays proportional to the bigger canvas too. + art_slot = ART_SLOT * scale + scanline_spacing = art_slot // SCANLINE_COUNT + tall = art_slot + scanline_spacing lines = [] for y in range(tall): - if y % SCANLINE_SPACING == 0: - lines.append(render.Box(width = ART_SLOT, height = 1, color = "#00000080")) + if y % scanline_spacing < scale: + lines.append(render.Box(width = art_slot, height = 1, color = "#00000080")) else: - lines.append(render.Box(width = ART_SLOT, height = 1, color = "#00000000")) + lines.append(render.Box(width = art_slot, height = 1, color = "#00000000")) pattern = render.Column(children = lines) # The ramp's end and the next cycle's snap-back share the exact same @@ -667,15 +722,16 @@ def _glitch_scanlines(): )) keyframes.append(animation.Keyframe( percentage = float(cycle_end) / SCANLINE_SWEEP_DURATION, - transforms = [animation.Translate(0, SCANLINE_SPACING)], + transforms = [animation.Translate(0, scanline_spacing)], curve = "linear", )) sweeping = animation.Transformation(child = pattern, duration = SCANLINE_SWEEP_DURATION, keyframes = keyframes) - return render.Box(width = ART_SLOT, height = ART_SLOT, child = sweeping) + return render.Box(width = art_slot, height = art_slot, child = sweeping) -def _glitch_corner_ticks(color): - tick_size = 5 +def _glitch_corner_ticks(color, scale): + tick_size = 5 * scale + line_thickness = scale # was a fixed 1px; scaled so the bracket's own weight stays proportional # A single tick shape (two short perpendicular lines) always draws # the same way regardless of which corner it's placed in -- it has no @@ -688,19 +744,21 @@ def _glitch_corner_ticks(color): # child's declared size, so without this the tick would be wider/ # taller than tick_size. def tick(top_aligned, left_aligned): + far_edge = tick_size - line_thickness h_line = render.Padding( - pad = (0, 0 if top_aligned else tick_size - 1, 0, tick_size - 1 if top_aligned else 0), - child = render.Box(width = tick_size, height = 1, color = color), + pad = (0, 0 if top_aligned else far_edge, 0, far_edge if top_aligned else 0), + child = render.Box(width = tick_size, height = line_thickness, color = color), ) v_line = render.Padding( - pad = (0 if left_aligned else tick_size - 1, 0, tick_size - 1 if left_aligned else 0, 0), - child = render.Box(width = 1, height = tick_size, color = color), + pad = (0 if left_aligned else far_edge, 0, far_edge if left_aligned else 0, 0), + child = render.Box(width = line_thickness, height = tick_size, color = color), ) return render.Box(width = tick_size, height = tick_size, child = render.Stack(children = [h_line, v_line])) + art_slot = ART_SLOT * scale return render.Box( - width = ART_SLOT, - height = ART_SLOT, + width = art_slot, + height = art_slot, child = render.Column( main_align = "space_between", expanded = True, @@ -717,14 +775,15 @@ def _glitch_corner_ticks(color): ), ) -def _glitch_effect(zoom): +def _glitch_effect(zoom, scale): + art_slot = ART_SLOT * scale return render.Box( - width = ART_SLOT, - height = ART_SLOT, + width = art_slot, + height = art_slot, child = render.Stack(children = [ - _glitch_jitter(zoom), - _glitch_scanlines(), - _glitch_corner_ticks("#00000080"), + _glitch_jitter(zoom, scale), + _glitch_scanlines(scale), + _glitch_corner_ticks("#00000080", scale), ]), )