Add slow zoom/pan reveal to album art; fix JSON-null crash on activeShow

This commit is contained in:
poprhythm committed 2026-09-29 15:12:01 -04:00
1 parent 74be033a29
commit 27e29705c9
2 files changed
+92 -12

No files matched your search

+67 -6
View File
@@ -10,6 +10,7 @@ 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
@@ -71,7 +72,7 @@ def main(config):
)
if art:
content = render.Row(children = [render.Image(src = art, width = 32, height = 32), text_column])
content = render.Row(children = [_art_widget(art, now["subsonic_id"]), text_column])
else:
content = render.Column(
main_align = "center",
@@ -85,6 +86,62 @@ def main(config):
return render.Root(child = content)
# Album art gets a slow "Ken Burns" reveal: starts zoomed into one corner,
# eases out to the full frame over ~1 minute. Art is rendered oversized
# (45x45, vs. the 32x32 display slot) since animation.Scale above 1.0 has no
# visible effect on a widget whose declared size already matches its
# container (confirmed elsewhere this session) -- starting at native 1.0
# scale of the *oversized* image and easing down to ~32/45 achieves the
# "zoom out to reveal everything" effect while only ever using Scale <= 1.0.
# Combining Scale + Translate in one Transformation, and combining that with
# the app's 4 already-scrolling Marquee lines, was verified not to trigger
# any *additional* glitching beyond the already-accepted minor Marquee quirk
# (see README). Duration 1200 ticks x the default 50ms/tick = 60s; verified
# empirically that a slower zoom compresses *smaller*, not larger, since more
# adjacent ticks become pixel-identical at this resolution -- ~97KB alone,
# ~117KB combined with the full app, both far under the 460KB device limit.
ART_SIZE = 45
ART_SLOT = 32
ART_MAX_OFFSET = (ART_SIZE - ART_SLOT) / 2.0 # 6.5px: centered -> true corner
ART_END_SCALE = float(ART_SLOT) / float(ART_SIZE) # ~0.71: full art fits in the slot
ART_ZOOM_DURATION = 1200 # 60s at the default 50ms/tick
CORNER_OFFSETS = [
(-ART_MAX_OFFSET, -ART_MAX_OFFSET),
(ART_MAX_OFFSET, -ART_MAX_OFFSET),
(-ART_MAX_OFFSET, ART_MAX_OFFSET),
(ART_MAX_OFFSET, ART_MAX_OFFSET),
]
def _corner_for_track(subsonic_id):
# Pseudo-random but stable per track, so re-rendering the same
# still-playing track (every NOWPLAYING_TTL seconds) doesn't jarringly
# jump to a different corner -- only a genuinely new track does.
total = 0
for ch in subsonic_id.elems():
total += ord(ch)
return CORNER_OFFSETS[total % len(CORNER_OFFSETS)]
def _art_widget(art_bytes, subsonic_id):
corner_x, corner_y = _corner_for_track(subsonic_id)
big_art = render.Image(src = art_bytes, width = ART_SIZE, height = ART_SIZE)
zoom = animation.Transformation(
child = big_art,
duration = ART_ZOOM_DURATION,
keyframes = [
animation.Keyframe(
percentage = 0.0,
transforms = [animation.Scale(1.0, 1.0), animation.Translate(corner_x, corner_y)],
),
animation.Keyframe(
percentage = 1.0,
transforms = [animation.Scale(ART_END_SCALE, ART_END_SCALE), animation.Translate(0, 0)],
curve = "ease_in_out",
),
],
)
return render.Box(width = ART_SLOT, height = ART_SLOT, child = zoom)
def _message_screen(lines):
return render.Column(
main_align = "center",
@@ -109,10 +166,14 @@ def _fetch_now_playing(station_url):
return None
data = rep.json()
np = data.get("nowPlaying", {})
dj = data.get("dj", {})
context = data.get("context", {})
active_show = context.get("activeShow", {})
# `.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"),
@@ -122,7 +183,7 @@ def _fetch_now_playing(station_url):
"station": dj.get("station", "SUB/WAVE"),
"show": active_show.get("name", ""),
"host": dj.get("name", ""),
"time_temp": _format_time_temp(context.get("clock", {}), context.get("weather", {})),
"time_temp": _format_time_temp(context.get("clock", {}) or {}, context.get("weather", {}) or {}),
"online": bool(data.get("streamOnline", False)),
}
Reference in new issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.