Files
subwave-nowplaying/subwave_nowplaying.star
T
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

400 lines
16 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")
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 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"
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])
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"])
# 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), 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):
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.
#
# animation.Scale's pivot is fixed at the top-left corner (confirmed
# empirically; an explicit `origin` param has no effect at all). That
# means zooming in crops progressively more off the bottom and right
# than the top and left -- a real cosmetic asymmetry. Pairing Scale
# with a Translate to re-center it was tried (and visually worked),
# but introduced a worse problem: combining Scale + Translate causes
# pixlet to periodically re-rasterize the whole image with a visible
# "pop" (confirmed with both WebP and GIF output, and independent of
# zoom amplitude -- so it's pixlet's animation engine, not this app's
# math). Scale *alone*, with no Translate at all, has none of that
# popping. Given the choice between a subtle, constant crop asymmetry
# and a periodic visible pop, the asymmetry is the lesser issue, so
# this uses Scale only.
top_scale = max(zoom_scale, 1.0)
zoom = animation.Transformation(
child = art,
duration = ART_ZOOM_DURATION,
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 render.Box(width = ART_SLOT, height = ART_SLOT, child = zoom)
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"),
],
),
],
)