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.
753 lines
34 KiB
Python
753 lines
34 KiB
Python
"""
|
|
Applet: SUB/WAVE Now Playing
|
|
Summary: Shows what's playing on a SUB/WAVE station
|
|
Description: Configure with your SUB/WAVE (github.com/perminder-klair/subwave) station's base URL to display the current track, artist, show, host, album art, time/temp, and a progress bar.
|
|
Author: poprhythm
|
|
"""
|
|
|
|
load("render.star", "render")
|
|
load("schema.star", "schema")
|
|
load("http.star", "http")
|
|
load("cache.star", "cache")
|
|
load("time.star", "time")
|
|
load("animation.star", "animation")
|
|
load("filter.star", "filter")
|
|
load("random.star", "random")
|
|
|
|
DEFAULT_STATION_URL = ""
|
|
NOWPLAYING_TTL = 20
|
|
COVER_TTL = 300
|
|
|
|
# These match SUB/WAVE's own web UI theme picker (Blueprint, Classic Dark,
|
|
# Classic Light, Cyberpunk, Flare, Recon, Signal, Vinyl), so a station's own
|
|
# site theme can be mirrored on the device. Each scheme sets a background
|
|
# fill, the main text color (title/artist/show), an accent (host/DJ name +
|
|
# progress bar fill), a muted meta color (time/temp), a separator, and the
|
|
# progress bar's empty-track color.
|
|
COLOR_SCHEMES = {
|
|
"blueprint": {
|
|
"background": "#0a1128",
|
|
"text": "#8ecae6",
|
|
"accent": "#4a90e2",
|
|
"meta": "#3f5a7a",
|
|
"separator": "#1f3a5f",
|
|
"bar_empty": "#12223d",
|
|
},
|
|
"classic_dark": {
|
|
"background": "#1a1a1a",
|
|
"text": "#f0e6d2",
|
|
"accent": "#d32f2f",
|
|
"meta": "#8a8070",
|
|
"separator": "#3a3a3a",
|
|
"bar_empty": "#2b2b2b",
|
|
},
|
|
"classic_light": {
|
|
"background": "#f0e6d2",
|
|
"text": "#1a1a1a",
|
|
"accent": "#e63946",
|
|
"meta": "#7a7062",
|
|
"separator": "#d8cdb8",
|
|
"bar_empty": "#ded2ba",
|
|
},
|
|
"cyberpunk": {
|
|
"background": "#0d0d0d",
|
|
"text": "#22d3ee",
|
|
"accent": "#ec4899",
|
|
"meta": "#7a7a8a",
|
|
"separator": "#2a2a35",
|
|
"bar_empty": "#241f2e",
|
|
},
|
|
"flare": {
|
|
"background": "#120a00",
|
|
"text": "#ff8c1a",
|
|
"accent": "#ff6600",
|
|
"meta": "#8a5a2a",
|
|
"separator": "#3a2000",
|
|
"bar_empty": "#2a1800",
|
|
},
|
|
"recon": {
|
|
"background": "#4a4331",
|
|
"text": "#f2ecd9",
|
|
"accent": "#ff8c3a",
|
|
"meta": "#a89f85",
|
|
"separator": "#2a2519",
|
|
"bar_empty": "#332e20",
|
|
},
|
|
"signal": {
|
|
"background": "#2b2b2b",
|
|
"text": "#d9d9d9",
|
|
"accent": "#9a9a9a",
|
|
"meta": "#6e6e6e",
|
|
"separator": "#3a3a3a",
|
|
"bar_empty": "#1e1e1e",
|
|
},
|
|
"vinyl": {
|
|
"background": "#3e2f23",
|
|
"text": "#e8d5b5",
|
|
"accent": "#c97d3d",
|
|
"meta": "#8a7358",
|
|
"separator": "#2a1f16",
|
|
"bar_empty": "#241b13",
|
|
},
|
|
}
|
|
DEFAULT_COLOR_SCHEME = "cyberpunk" # matches the station's own site theme
|
|
|
|
# How many seconds into a track's start, or before its reported end, the
|
|
# glitch overlay shows over the album art. Only used when the "Static
|
|
# overlay" schema field is enabled.
|
|
#
|
|
# These aren't symmetric on purpose: SUB/WAVE's reported `duration` is a
|
|
# track's full metadata length, but in practice the station appears to
|
|
# cut/crossfade to the next track a bit before that duration actually
|
|
# elapses (observed via the progress bar consistently stalling a few
|
|
# segments short of full, never reaching the end) -- so `remaining` was
|
|
# rarely if ever dropping below a short end-window before the subsonic_id
|
|
# changed out from under it. Widening the end window compensates for that
|
|
# observed early cutoff; the start window doesn't have the same problem
|
|
# (elapsed genuinely starts at 0) so it stays short.
|
|
STATIC_OVERLAY_START_SECONDS = 5
|
|
STATIC_OVERLAY_END_SECONDS = 22
|
|
DEFAULT_STATIC_OVERLAY = "on"
|
|
|
|
# How far in the album art zoom goes, relative to its fully-zoomed-out rest
|
|
# state (see _art_widget below for why scale never goes below 1.0).
|
|
ZOOM_LEVELS = {
|
|
"off": 1.0,
|
|
"subtle": 1.15,
|
|
"moderate": 1.4,
|
|
"strong": 1.7,
|
|
}
|
|
DEFAULT_ZOOM_LEVEL = "moderate"
|
|
|
|
# Where the zoom centers on, as animation.Origin fractions of the image's
|
|
# 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.
|
|
# 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),
|
|
(0.3, 0.3),
|
|
(0.7, 0.7),
|
|
(0.3, 0.7),
|
|
(0.7, 0.3),
|
|
]
|
|
ZOOM_SPOT_ROTATE_SECONDS = 10
|
|
DEFAULT_ZOOM_POSITION = "center"
|
|
|
|
def _zoom_spot(zoom_position, subsonic_id):
|
|
if zoom_position != "random":
|
|
return ZOOM_CENTER_SPOT
|
|
|
|
track_offset = 0
|
|
for ch in subsonic_id.elems():
|
|
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)
|
|
if station_url:
|
|
station_url = station_url.rstrip("/")
|
|
|
|
colors = COLOR_SCHEMES.get(config.get("color_scheme", DEFAULT_COLOR_SCHEME), COLOR_SCHEMES[DEFAULT_COLOR_SCHEME])
|
|
zoom_scale = ZOOM_LEVELS.get(config.get("zoom_level", DEFAULT_ZOOM_LEVEL), ZOOM_LEVELS[DEFAULT_ZOOM_LEVEL])
|
|
zoom_position = config.get("zoom_position", DEFAULT_ZOOM_POSITION)
|
|
static_overlay_enabled = config.get("static_overlay", DEFAULT_STATIC_OVERLAY) == "on"
|
|
|
|
if not station_url:
|
|
return render.Root(child = _with_background(_message_screen(["Set station URL", "in app settings"], colors), colors))
|
|
|
|
now = _fetch_now_playing(station_url)
|
|
if now == None:
|
|
return render.Root(child = _with_background(_message_screen(["Can't reach", "station"], colors), colors))
|
|
|
|
if not now["online"]:
|
|
return render.Root(child = _with_background(_message_screen([now["station"], "offline"], colors), colors))
|
|
|
|
art = _fetch_cover(station_url, now["subsonic_id"])
|
|
progress = _track_progress(now["subsonic_id"], now["duration"])
|
|
|
|
# Only meaningful once _track_progress has real elapsed/remaining data
|
|
# (i.e. on a long-lived server -- see its own comment below for why a
|
|
# one-shot `pixlet render` CLI call always sees elapsed=0). On a local
|
|
# render this will always show the "starting" static, never "ending".
|
|
# TEMP DEV OVERRIDE: forces the glitch on for the entire track instead
|
|
# of just the start/end windows, to make it easier to see while
|
|
# tuning it. Remove this line (and uncomment the real condition below)
|
|
# before shipping.
|
|
show_static = static_overlay_enabled
|
|
# show_static = (
|
|
# static_overlay_enabled and
|
|
# progress != None and
|
|
# (progress["elapsed"] < STATIC_OVERLAY_START_SECONDS or progress["remaining"] < STATIC_OVERLAY_END_SECONDS)
|
|
# )
|
|
|
|
# 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
|
|
# up when e.g. there's a show but no DJ credited, or no show at all.
|
|
show_host_row = [render.Text(now["show"], font = "tom-thumb", color = colors["text"])]
|
|
if now["host"]:
|
|
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"])),
|
|
]
|
|
|
|
children = [render.Column(children = top_lines)]
|
|
if progress != None:
|
|
children.append(_progress_bar(progress["fraction"], colors, width = 32))
|
|
|
|
text_column = render.Box(
|
|
width = 32,
|
|
height = 32,
|
|
child = render.Column(
|
|
main_align = "space_between",
|
|
cross_align = "start",
|
|
expanded = True,
|
|
children = children,
|
|
),
|
|
)
|
|
|
|
if art:
|
|
content = render.Row(children = [_art_widget(art, zoom_scale, zoom_position, now["subsonic_id"], show_static), 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"])),
|
|
],
|
|
)
|
|
|
|
return render.Root(child = _with_background(content, colors))
|
|
|
|
def _with_background(child, colors):
|
|
return render.Stack(children = [
|
|
render.Box(width = 64, height = 32, color = colors["background"]),
|
|
child,
|
|
])
|
|
|
|
# Album art gets a slow "Ken Burns" breathing effect: starts and ends on
|
|
# the full frame, easing in to a zoomed-in crop at the midpoint, on a
|
|
# continuous loop -- not a one-shot reveal. A one-shot version (start
|
|
# zoomed in, end on the full frame, then hold) looked jumpy: since the app
|
|
# re-renders on NOWPLAYING_TTL
|
|
# and each fresh render restarts its own animation from tick 0, whatever the
|
|
# *previous* pushed image happened to be showing at that moment gets
|
|
# abruptly replaced by a fresh zoomed-in frame. The back-and-forth loop
|
|
# doesn't eliminate that (a server-side re-render always restarts at tick 0
|
|
# regardless), but ending exactly where it started means the animation also
|
|
# loops seamlessly on-device between refreshes.
|
|
#
|
|
# Duration is intentionally short (a handful of seconds each way), not the
|
|
# ~60s round-trip originally tried: on the real device/server, a fresh
|
|
# render replaces the still-playing animation well before a minute-long
|
|
# cycle can complete, so only the outbound half (zoom out) was ever visible
|
|
# and it never appeared to "zoom back in". A short loop guarantees at least
|
|
# one full out-and-back cycle plays before that happens.
|
|
#
|
|
# Art is rendered slightly oversized (36x36, vs. the 32x32 display slot).
|
|
# animation.Scale is only ever applied at 1.0 or above here -- empirically,
|
|
# scaling this *below* 1.0 (tried while chasing a "show the literal whole
|
|
# image at rest" design) reintroduces a real, on-device black-border bug:
|
|
# below roughly 0.9-0.95 a visible gap opens up along one edge, well before
|
|
# the math (scaled size vs. box size) would predict any gap at all, so it
|
|
# isn't a simple "runs out of image" overflow -- treat scale < 1.0 as
|
|
# unsafe regardless of the numbers. That rules out "native size at rest,
|
|
# shrink down to reveal everything" -- so instead ART_SIZE is kept just
|
|
# barely larger than ART_SLOT: at the safe floor of scale = 1.0, only
|
|
# ART_SLOT/ART_SIZE (~89%) of the image is visible, which reads as
|
|
# "fully zoomed out" (a sliver cropped off each edge, not noticeable) while
|
|
# leaving just enough headroom above 1.0 for a real zoom-in effect. The
|
|
# zoom-in amount itself (`zoom_scale`, from the ZOOM_LEVELS schema option)
|
|
# must stay >= 1.0 for the same reason.
|
|
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)
|
|
|
|
if not show_static:
|
|
return cropped
|
|
|
|
# The glitch segment gets its own non-animated, full-resolution
|
|
# (scale locked at 1.0) rendering of the art, rather than the live
|
|
# `zoom` above -- simpler than keeping the jitter in sync with
|
|
# wherever the real zoom's 10s cycle currently is (see
|
|
# _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)])
|
|
|
|
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
|
|
# (a degenerate animation whose two keyframes are both Scale(1.0, 1.0),
|
|
# i.e. no visible movement). Wrapping the art in a Transformation shifts
|
|
# its crop by a few pixels versus a bare, unwrapped Image in the same
|
|
# Box -- confirmed empirically, even at an explicit Scale(1.0, 1.0) --
|
|
# so a plain-Image "off" path and a Transformation-wrapped zoom path
|
|
# don't actually agree on framing despite both nominally being "scale
|
|
# 1.0". Using the same wrapper unconditionally guarantees the "off"
|
|
# state and the rest state of every zoom level line up exactly.
|
|
#
|
|
# `origin` picks where the zoom centers on, which also gives a free
|
|
# "zoom to a different spot each time" effect (ZOOM_SPOTS above) by
|
|
# just varying it per track. An earlier version tried to recenter the
|
|
# zoom by pairing Scale with a *computed* Translate, on the mistaken
|
|
# assumption that Scale's default pivot is the top-left corner -- it
|
|
# isn't; pixlet's own 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 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(zoom_position, subsonic_id)
|
|
top_scale = max(zoom_scale, 1.0)
|
|
zoom = animation.Transformation(
|
|
child = art,
|
|
duration = ART_ZOOM_DURATION,
|
|
origin = animation.Origin(origin_x, origin_y),
|
|
keyframes = [
|
|
animation.Keyframe(
|
|
percentage = 0.0,
|
|
transforms = [animation.Scale(1.0, 1.0)],
|
|
),
|
|
animation.Keyframe(
|
|
percentage = 0.5,
|
|
transforms = [animation.Scale(top_scale, top_scale)],
|
|
curve = "ease_in_out",
|
|
),
|
|
animation.Keyframe(
|
|
percentage = 1.0,
|
|
transforms = [animation.Scale(1.0, 1.0)],
|
|
curve = "ease_in_out",
|
|
),
|
|
],
|
|
)
|
|
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
|
|
# anywhere as a spec): an RGB-split/chromatic-aberration glitch -- two
|
|
# duplicate, differently-tinted copies of the art, each cropped to a
|
|
# shifting horizontal band -- plus scanlines and viewfinder-style corner
|
|
# tick marks. Built here from scratch against the *live* art via
|
|
# filter.Hue, not a bundled asset -- pixlet's `filter` module (confirmed
|
|
# real and working, same as `animation.Origin`) can recolor an arbitrary
|
|
# widget, including an already-zoomed render.Image, so the glitch bands
|
|
# reflect whatever's actually on screen rather than a generic texture.
|
|
# This replaced an earlier version built as a bundled static.gif overlay
|
|
# (first sparse noise specks, briefly a VHS-footage-derived band) -- that
|
|
# approach is still viable for projects without filter.star, but once a
|
|
# live-recolor option exists it's a better match for what SUB/WAVE itself
|
|
# does, and it means this app goes back to a single-file upload (no
|
|
# bundled asset to zip alongside it).
|
|
#
|
|
# The exact keyframe sequence/timing SUB/WAVE's CSS uses past the first
|
|
# keyframe isn't visible in its compiled output (no source maps), so
|
|
# GLITCH_STATES below is an original sequence in the same spirit (shifting
|
|
# inset bands, step-end/snap timing via a Sequence rather than eased
|
|
# motion) rather than a reproduction of unseen code. Band proportions
|
|
# (top/height as a fraction of ART_SLOT) are loosely modeled on the one
|
|
# keyframe that was visible: roughly the top third and an overlapping
|
|
# middle band, each about 30% of the frame's height.
|
|
GLITCH_HOLD_TICKS = 2 # ~100ms per jitter state at the default 50ms/tick
|
|
GLITCH_PAUSE_TICKS = 3 # ~150ms of clean art between bursts
|
|
GLITCH_STATES_PER_CYCLE = 4
|
|
|
|
# A larger pool than any one burst needs, so repeated cycles (see
|
|
# GLITCH_REPEATS below) draw a different rotating window of states each
|
|
# time instead of showing the identical 4 band positions every loop.
|
|
# (top1, height1, shift1, hue1, top2, height2, shift2, hue2); proportions
|
|
# loosely modeled on the one SUB/WAVE keyframe that was visible (roughly
|
|
# the top third and an overlapping middle band, each ~30% of the frame's
|
|
# height) -- see the note above GLITCH_HOLD_TICKS for why that's the only
|
|
# part grounded in anything observed rather than invented.
|
|
GLITCH_STATE_POOL = [
|
|
# Scanlines (see _glitch_scanlines below) repeat every 4px, so each
|
|
# band's height needs to be at least ~9-13px to reliably show 2-3 of
|
|
# them, regardless of the band's exact vertical alignment against the
|
|
# scanline grid -- much shorter and a band can land entirely between
|
|
# two scanline rows and show none at all.
|
|
(3, 12, -2, 150.0, 19, 11, 2, -60.0),
|
|
(2, 11, 2, 150.0, 20, 12, -3, -60.0),
|
|
(5, 10, -3, 150.0, 17, 13, 1, -60.0),
|
|
(1, 13, 1, 150.0, 21, 10, -2, -60.0),
|
|
(6, 10, 1, 150.0, 14, 12, 3, -60.0),
|
|
(0, 11, 3, 150.0, 20, 10, -1, -60.0),
|
|
(4, 10, -1, 150.0, 12, 13, 2, -60.0),
|
|
(2, 12, -3, 150.0, 22, 10, 1, -60.0),
|
|
]
|
|
|
|
# render.Sequence doesn't loop on its own (see _glitch_scanlines below), so
|
|
# to repeat the burst-then-pause pattern, the repeats have to be spelled
|
|
# out as one long children list up front rather than relying on any
|
|
# runtime looping.
|
|
#
|
|
# Deliberately *not* sized against the full show_static window (which can
|
|
# now be up to STATIC_OVERLAY_END_SECONDS = 22s): each repeat adds several
|
|
# more uniquely-tinted/shifted frames for WebP to encode, and scaling that
|
|
# up to cover 22s of jitter pushed a real render past the 460KB device
|
|
# limit (measured 481KB). Scanlines and corner ticks (see _glitch_effect)
|
|
# are cheap, constant per-pixel layers that can cover the full window for
|
|
# free, but the hue-shifted bands don't need to keep jittering the whole
|
|
# time to read as "something's happening" -- GLITCH_ACTIVE_SECONDS caps
|
|
# how much of the window actually gets jitter, independent of how long
|
|
# the window itself is.
|
|
GLITCH_ACTIVE_SECONDS = 6
|
|
_GLITCH_CYCLE_TICKS = GLITCH_STATES_PER_CYCLE * GLITCH_HOLD_TICKS + GLITCH_PAUSE_TICKS
|
|
_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):
|
|
# A separate, non-animated rendering of the art (scale locked at 1.0
|
|
# throughout, via _zoom_transformation's own zoom_scale argument) 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 jitter synced to wherever that 10s cycle currently is --
|
|
# there's nothing here that depends on frame position, so nothing to
|
|
# desync regardless of how the jitter's own timing is built. Same
|
|
# origin/zoom_position as the real zoom, so it doesn't visually jump
|
|
# to a different part of the image when the glitch starts or ends.
|
|
return _zoom_transformation(art_bytes, 1.0, zoom_position, subsonic_id)
|
|
|
|
def _glitch_states_for_cycle(cycle_index):
|
|
# Rotate through GLITCH_STATE_POOL instead of slicing (which doesn't
|
|
# wrap around in Starlark), so each cycle's window of states can
|
|
# straddle the end of the pool and loop back to the start.
|
|
pool_size = len(GLITCH_STATE_POOL)
|
|
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_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),
|
|
)
|
|
|
|
# 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 = 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_jitter(zoom):
|
|
# Built as ONE animation.Transformation (Translate-only keyframes)
|
|
# wrapping the real `zoom`, not a render.Sequence of separately-held
|
|
# states (the previous approach). That turned out to be a real bug,
|
|
# not just a tuning issue: 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)`), so every time the
|
|
# jitter switched to a new held state, the *embedded* zoom saw a
|
|
# near-zero frame index and reset toward the start of its own
|
|
# ART_ZOOM_DURATION cycle before snapping back on the next state --
|
|
# exactly the "big size jumps, not at the same zoom level as the art"
|
|
# that were reported. animation.Transformation does not rebase frame
|
|
# index for its child (`t.Child.Paint(dc, bounds, frameIdx)` -- same
|
|
# frameIdx in and out), so nesting it around `zoom` instead keeps the
|
|
# zoom perfectly in sync with the real art alongside it.
|
|
total_ticks = GLITCH_REPEATS * _GLITCH_CYCLE_TICKS
|
|
keyframes = []
|
|
tick = 0
|
|
for cycle_index in range(GLITCH_REPEATS):
|
|
for _ in range(GLITCH_STATES_PER_CYCLE):
|
|
# Capped to +-1px -- a full-frame shift reads very differently
|
|
# than a thin band shifting, so it stays subtle. Randomized
|
|
# rather than a fixed alternating +1/-1 (too mechanically
|
|
# regular) or 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).
|
|
shift = random.number(0, 2) - 1 # random.number requires min >= 0
|
|
keyframes.append(animation.Keyframe(
|
|
percentage = float(tick) / total_ticks,
|
|
transforms = [animation.Translate(shift, shift)],
|
|
))
|
|
tick += GLITCH_HOLD_TICKS
|
|
keyframes.append(animation.Keyframe(
|
|
percentage = float(tick) / total_ticks,
|
|
transforms = [animation.Translate(0, 0)],
|
|
))
|
|
tick += GLITCH_PAUSE_TICKS
|
|
|
|
jittered = animation.Transformation(child = zoom, duration = total_ticks, keyframes = keyframes)
|
|
return render.Box(width = ART_SLOT, height = ART_SLOT, child = jittered)
|
|
|
|
def _glitch_scanlines():
|
|
# render.Sequence doesn't loop (it just stops painting once its total
|
|
# frame count is exhausted -- confirmed in pixlet's own source), so
|
|
# the RGB-split jitter above plays once per show_static window and
|
|
# then goes quiet; these persist for the whole window as a constant
|
|
# texture rather than a one-shot burst, closer to how SUB/WAVE's
|
|
# scanlines sweep continuously while its live class is applied.
|
|
lines = []
|
|
for y in range(ART_SLOT):
|
|
if y % 4 == 0:
|
|
lines.append(render.Box(width = ART_SLOT, height = 1, color = "#00000080"))
|
|
else:
|
|
lines.append(render.Box(width = ART_SLOT, height = 1, color = "#00000000"))
|
|
return render.Column(children = lines)
|
|
|
|
def _glitch_corner_ticks(color):
|
|
tick_size = 5
|
|
|
|
# A single tick shape (two short perpendicular lines) always draws
|
|
# the same way regardless of which corner it's placed in -- it has no
|
|
# idea it's "the top-right one". To make the bracket actually open
|
|
# toward the image's center at every corner (not just top-left), each
|
|
# line has to be explicitly pushed to the correct edge of its own
|
|
# tick_size x tick_size box via Padding: the horizontal line to the
|
|
# top or bottom, the vertical line to the left or right. Wrapped in an
|
|
# outer Box for the same reason as _glitch_band -- Padding grows its
|
|
# child's declared size, so without this the tick would be wider/
|
|
# taller than tick_size.
|
|
def tick(top_aligned, left_aligned):
|
|
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),
|
|
)
|
|
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),
|
|
)
|
|
return render.Box(width = tick_size, height = tick_size, child = render.Stack(children = [h_line, v_line]))
|
|
|
|
return render.Box(
|
|
width = ART_SLOT,
|
|
height = ART_SLOT,
|
|
child = render.Column(
|
|
main_align = "space_between",
|
|
expanded = True,
|
|
children = [
|
|
render.Row(main_align = "space_between", expanded = True, children = [
|
|
tick(top_aligned = True, left_aligned = True),
|
|
tick(top_aligned = True, left_aligned = False),
|
|
]),
|
|
render.Row(main_align = "space_between", expanded = True, children = [
|
|
tick(top_aligned = False, left_aligned = True),
|
|
tick(top_aligned = False, left_aligned = False),
|
|
]),
|
|
],
|
|
),
|
|
)
|
|
|
|
def _glitch_effect(zoom):
|
|
return render.Box(
|
|
width = ART_SLOT,
|
|
height = ART_SLOT,
|
|
child = render.Stack(children = [
|
|
_glitch_jitter(zoom),
|
|
# _glitch_scanlines(),
|
|
_glitch_corner_ticks("#00000080"),
|
|
]),
|
|
)
|
|
|
|
def _message_screen(lines, colors):
|
|
return render.Column(
|
|
main_align = "center",
|
|
cross_align = "center",
|
|
expanded = True,
|
|
children = [render.Text(line, font = "tom-thumb", color = colors["text"]) for line in lines],
|
|
)
|
|
|
|
def _progress_bar(fraction, colors, width = 32, height = 2):
|
|
filled = int(width * fraction + 0.5)
|
|
filled = max(0, min(filled, width))
|
|
children = []
|
|
if filled > 0:
|
|
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)
|
|
|
|
def _fetch_now_playing(station_url):
|
|
rep = http.get(station_url + "/api/now-playing", ttl_seconds = NOWPLAYING_TTL)
|
|
if rep.status_code != 200:
|
|
return None
|
|
|
|
data = rep.json()
|
|
# `.get(key, {})` only falls back to the default when the key is
|
|
# *absent* -- SUB/WAVE sends these as explicit JSON null (e.g.
|
|
# activeShow between scheduled shows), which decodes to Starlark None
|
|
# and passes right through .get(), so `or {}` is needed too.
|
|
np = data.get("nowPlaying", {}) or {}
|
|
dj = data.get("dj", {}) or {}
|
|
context = data.get("context", {}) or {}
|
|
active_show = context.get("activeShow", {}) or {}
|
|
|
|
return {
|
|
"title": np.get("title", "Unknown title"),
|
|
"artist": np.get("artist", "Unknown artist"),
|
|
"subsonic_id": np.get("subsonic_id", ""),
|
|
"duration": np.get("duration", 0),
|
|
"station": dj.get("station", "SUB/WAVE"),
|
|
"show": active_show.get("name", ""),
|
|
"host": dj.get("name", ""),
|
|
"time_temp": _format_time_temp(context.get("clock", {}) or {}, context.get("weather", {}) or {}),
|
|
"online": bool(data.get("streamOnline", False)),
|
|
}
|
|
|
|
def _format_time_temp(clock, weather):
|
|
parts = []
|
|
display_time = clock.get("display", "")
|
|
if display_time:
|
|
parts.append(display_time)
|
|
|
|
temp = weather.get("temp", None)
|
|
unit = weather.get("tempUnit", "")
|
|
if temp != None:
|
|
parts.append("%d°%s" % (int(temp), unit))
|
|
|
|
return " · ".join(parts)
|
|
|
|
def _fetch_cover(station_url, subsonic_id):
|
|
if not subsonic_id:
|
|
return None
|
|
rep = http.get(station_url + "/api/cover/" + subsonic_id, ttl_seconds = COVER_TTL)
|
|
if rep.status_code != 200:
|
|
return None
|
|
return rep.body()
|
|
|
|
# The now-playing API reports track duration but not elapsed/remaining time,
|
|
# so we track playback position ourselves: the first time we see a given
|
|
# subsonic_id, we stamp the current wall-clock time in the cache; on later
|
|
# renders (while the cache entry is still alive) we diff against that stamp
|
|
# to get elapsed seconds. This only works on a long-lived server process
|
|
# with a persistent cache (confirmed true for tronbyt/server's in-memory
|
|
# cache) -- a one-shot `pixlet render` CLI call always starts a fresh
|
|
# process with an empty cache, so progress will always show 0% locally.
|
|
def _track_progress(subsonic_id, duration):
|
|
if not subsonic_id or not duration:
|
|
return None
|
|
|
|
key = "subwave_track_start_" + subsonic_id
|
|
now_unix = time.now().unix
|
|
cached = cache.get(key)
|
|
|
|
if cached == None:
|
|
cache.set(key, str(now_unix), ttl_seconds = int(duration) + 30)
|
|
start_unix = now_unix
|
|
else:
|
|
start_unix = int(cached)
|
|
|
|
elapsed = max(0, now_unix - start_unix)
|
|
remaining = max(0, duration - elapsed)
|
|
fraction = min(1.0, float(elapsed) / float(duration))
|
|
|
|
return {"elapsed": elapsed, "remaining": remaining, "fraction": fraction}
|
|
|
|
def get_schema():
|
|
return schema.Schema(
|
|
version = "1",
|
|
fields = [
|
|
schema.Text(
|
|
id = "station_url",
|
|
name = "Station URL",
|
|
desc = "Base URL of your SUB/WAVE station, e.g. https://radio.example.com",
|
|
icon = "server",
|
|
default = DEFAULT_STATION_URL,
|
|
),
|
|
schema.Dropdown(
|
|
id = "color_scheme",
|
|
name = "Color scheme",
|
|
desc = "Matches SUB/WAVE's own web UI themes -- pick the one your station uses.",
|
|
icon = "palette",
|
|
default = DEFAULT_COLOR_SCHEME,
|
|
options = [
|
|
schema.Option(display = "Cyberpunk", value = "cyberpunk"),
|
|
schema.Option(display = "Blueprint", value = "blueprint"),
|
|
schema.Option(display = "Classic Dark", value = "classic_dark"),
|
|
schema.Option(display = "Classic Light", value = "classic_light"),
|
|
schema.Option(display = "Flare", value = "flare"),
|
|
schema.Option(display = "Recon", value = "recon"),
|
|
schema.Option(display = "Signal", value = "signal"),
|
|
schema.Option(display = "Vinyl", value = "vinyl"),
|
|
],
|
|
),
|
|
schema.Dropdown(
|
|
id = "zoom_level",
|
|
name = "Album art zoom",
|
|
desc = "How far the album art zooms in during its breathing effect.",
|
|
icon = "magnifyingGlass",
|
|
default = DEFAULT_ZOOM_LEVEL,
|
|
options = [
|
|
schema.Option(display = "Off", value = "off"),
|
|
schema.Option(display = "Subtle", value = "subtle"),
|
|
schema.Option(display = "Moderate", value = "moderate"),
|
|
schema.Option(display = "Strong", value = "strong"),
|
|
],
|
|
),
|
|
schema.Dropdown(
|
|
id = "zoom_position",
|
|
name = "Zoom position",
|
|
desc = "Where the album art zoom centers on.",
|
|
icon = "crosshairs",
|
|
default = DEFAULT_ZOOM_POSITION,
|
|
options = [
|
|
schema.Option(display = "Center", value = "center"),
|
|
schema.Option(display = "Random", value = "random"),
|
|
],
|
|
),
|
|
schema.Dropdown(
|
|
id = "static_overlay",
|
|
name = "Static overlay",
|
|
desc = "Show a brief TV-static effect over the album art as a track starts and ends.",
|
|
icon = "tv",
|
|
default = DEFAULT_STATIC_OVERLAY,
|
|
options = [
|
|
schema.Option(display = "On", value = "on"),
|
|
schema.Option(display = "Off", value = "off"),
|
|
],
|
|
),
|
|
],
|
|
)
|