Random zoom position was previously picked once per track (stable via subsonic_id) and held for that track's entire runtime. Changed it to rotate on a ~10s cadence based on wall-clock time, still offset by a per-track hash so different tracks don't all land on the same spot in the same window. This lets a single long track show off different sections of its own artwork over time, rather than being stuck on one spot until the next track starts.
454 lines
19 KiB
Python
454 lines
19 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"
|
|
|
|
# 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)
|
|
|
|
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, zoom_position, now["subsonic_id"]), 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):
|
|
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 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"),
|
|
],
|
|
),
|
|
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"),
|
|
],
|
|
),
|
|
],
|
|
)
|