Add slow zoom/pan reveal to album art; fix JSON-null crash on activeShow
This commit is contained in:
1 parent
74be033a29
commit
27e29705c9
2 files changed
+92
-12
No files matched your search
@@ -1,12 +1,12 @@
|
|||||||
# subwave-nowplaying
|
# subwave-nowplaying
|
||||||
|
|
||||||
Tronbyt/Tidbyt app showing the current track on a [SUB/WAVE](https://github.com/perminder-klair/subwave)
|
Tronbyt/Tidbyt app showing the current track on a [SUB/WAVE](https://github.com/perminder-klair/subwave)
|
||||||
internet radio station: album art, and four scrolling lines — track
|
internet radio station: album art with a slow zoom/pan reveal, and four
|
||||||
title/artist, a thin separator, the current show and host, and local
|
scrolling lines — track title/artist, a thin separator, the current show
|
||||||
time/temperature — plus a track progress bar pinned to the bottom of the
|
and host, and local time/temperature — plus a track progress bar pinned to
|
||||||
32px-wide text column. Color scheme (cyan title/artist, white show name,
|
the bottom of the 32px-wide text column. Color scheme (cyan title/artist,
|
||||||
magenta host/progress bar) is loosely modeled on SUB/WAVE's own web
|
white show name, magenta host/progress bar) is loosely modeled on SUB/WAVE's
|
||||||
now-playing display.
|
own web now-playing display.
|
||||||
|
|
||||||
Not tied to any specific station — set your own station's base URL in the
|
Not tied to any specific station — set your own station's base URL in the
|
||||||
app's config when adding it to a device.
|
app's config when adding it to a device.
|
||||||
@@ -28,6 +28,18 @@ app's config when adding it to a device.
|
|||||||
instead of stale track info.
|
instead of stale track info.
|
||||||
- If a track has no `subsonic_id`, or the cover fetch fails, falls back to a
|
- If a track has no `subsonic_id`, or the cover fetch fails, falls back to a
|
||||||
full-width layout with no art rather than a broken image.
|
full-width layout with no art rather than a broken image.
|
||||||
|
- Album art gets a slow "Ken Burns" reveal: starts zoomed into one corner of
|
||||||
|
the image, eases out to show the full frame over ~60s. Rendered oversized
|
||||||
|
(45x45 vs. the 32x32 display slot) and scaled down from 1.0 to ~0.71,
|
||||||
|
since `animation.Scale` above 1.0 has no visible effect on a widget whose
|
||||||
|
declared size already matches its container. Which corner it starts in is
|
||||||
|
derived from the track's `subsonic_id` (not true randomness) so a
|
||||||
|
still-playing track re-rendered on the `NOWPLAYING_TTL` cache doesn't
|
||||||
|
jarringly jump corners — only a genuinely new track does. Verified this
|
||||||
|
doesn't blow the payload budget: counterintuitively, a *slower* zoom
|
||||||
|
compresses *smaller*, since more adjacent ticks become pixel-identical at
|
||||||
|
this resolution and WebP dedupes them — measured 97-264KB depending on the
|
||||||
|
art's own visual complexity, all well under the 460KB device limit.
|
||||||
- Shows the current show name (`context.activeShow.name`) and host name
|
- Shows the current show name (`context.activeShow.name`) and host name
|
||||||
(`dj.name`) below the artist, separated by a thin divider line, and the
|
(`dj.name`) below the artist, separated by a thin divider line, and the
|
||||||
local time/temperature (`context.clock`/`context.weather`) below that —
|
local time/temperature (`context.clock`/`context.weather`) below that —
|
||||||
@@ -62,6 +74,13 @@ long-lived server process with a persistent cache (true for
|
|||||||
invocation always starts a fresh process with an empty cache, so the bar
|
invocation always starts a fresh process with an empty cache, so the bar
|
||||||
will always show 0% when testing locally. This is expected, not a bug.
|
will always show 0% when testing locally. This is expected, not a bug.
|
||||||
|
|
||||||
|
**Note on JSON nulls:** SUB/WAVE sends some fields as explicit JSON `null`
|
||||||
|
(e.g. `context.activeShow` between scheduled shows) rather than omitting the
|
||||||
|
key. Starlark's `dict.get(key, default)` only falls back to `default` when
|
||||||
|
the key is *absent* — an explicit `null` (decoded to Starlark `None`) passes
|
||||||
|
straight through, so this app also does `... or {}` on those lookups before
|
||||||
|
chaining another `.get()` off them, to avoid a `NoneType has no .get` crash.
|
||||||
|
|
||||||
**Note on unreachable hosts:** if the station URL is wrong or the host is
|
**Note on unreachable hosts:** if the station URL is wrong or the host is
|
||||||
completely unreachable (DNS failure, connection refused), `http.get` raises
|
completely unreachable (DNS failure, connection refused), `http.get` raises
|
||||||
a hard error and the render fails for that cycle — this is normal pixlet/
|
a hard error and the render fails for that cycle — this is normal pixlet/
|
||||||
|
|||||||
+67
-6
@@ -10,6 +10,7 @@ load("schema.star", "schema")
|
|||||||
load("http.star", "http")
|
load("http.star", "http")
|
||||||
load("cache.star", "cache")
|
load("cache.star", "cache")
|
||||||
load("time.star", "time")
|
load("time.star", "time")
|
||||||
|
load("animation.star", "animation")
|
||||||
|
|
||||||
DEFAULT_STATION_URL = ""
|
DEFAULT_STATION_URL = ""
|
||||||
NOWPLAYING_TTL = 20
|
NOWPLAYING_TTL = 20
|
||||||
@@ -71,7 +72,7 @@ def main(config):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if art:
|
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:
|
else:
|
||||||
content = render.Column(
|
content = render.Column(
|
||||||
main_align = "center",
|
main_align = "center",
|
||||||
@@ -85,6 +86,62 @@ def main(config):
|
|||||||
|
|
||||||
return render.Root(child = content)
|
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):
|
def _message_screen(lines):
|
||||||
return render.Column(
|
return render.Column(
|
||||||
main_align = "center",
|
main_align = "center",
|
||||||
@@ -109,10 +166,14 @@ def _fetch_now_playing(station_url):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
data = rep.json()
|
data = rep.json()
|
||||||
np = data.get("nowPlaying", {})
|
# `.get(key, {})` only falls back to the default when the key is
|
||||||
dj = data.get("dj", {})
|
# *absent* -- SUB/WAVE sends these as explicit JSON null (e.g.
|
||||||
context = data.get("context", {})
|
# activeShow between scheduled shows), which decodes to Starlark None
|
||||||
active_show = context.get("activeShow", {})
|
# 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 {
|
return {
|
||||||
"title": np.get("title", "Unknown title"),
|
"title": np.get("title", "Unknown title"),
|
||||||
@@ -122,7 +183,7 @@ def _fetch_now_playing(station_url):
|
|||||||
"station": dj.get("station", "SUB/WAVE"),
|
"station": dj.get("station", "SUB/WAVE"),
|
||||||
"show": active_show.get("name", ""),
|
"show": active_show.get("name", ""),
|
||||||
"host": dj.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)),
|
"online": bool(data.get("streamOnline", False)),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||