Make album art zoom and color scheme configurable

Zoom level is a dropdown (Off/Subtle/Moderate/Strong) controlling how far
the album art zooms in during its breathing effect; Off skips the
animation.Transformation entirely and just shows a static crop.

Color schemes now match SUB/WAVE's own web UI theme picker (Blueprint,
Classic Dark, Classic Light, Cyberpunk, Flare, Recon, Signal, Vinyl) so a
station can mirror its own site theme on the device, including a
background fill that was previously hardcoded to black.
This commit is contained in:
poprhythm committed 2026-09-29 19:33:19 -04:00
1 parent 6e346a0cf8
commit 4a01de30de
2 files changed
+158 -34

No files matched your search

+9 -3
View File
@@ -4,9 +4,7 @@ Tronbyt/Tidbyt app showing the current track on a [SUB/WAVE](https://github.com/
internet radio station: album art with a slow zoom/pan reveal, and four internet radio station: album art with a slow zoom/pan reveal, and four
scrolling lines — track title/artist, a thin separator, the current show scrolling lines — track title/artist, a thin separator, the current show
and host, and local time/temperature — plus a track progress bar pinned to and host, and local time/temperature — plus a track progress bar pinned to
the bottom of the 32px-wide text column. Color scheme (cyan title/artist, the bottom of the 32px-wide text column.
white show name, magenta host/progress bar) is loosely modeled on SUB/WAVE's
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.
@@ -17,6 +15,14 @@ app's config when adding it to a device.
`https://radio.example.com`. No trailing slash needed (stripped `https://radio.example.com`. No trailing slash needed (stripped
automatically). No auth required — SUB/WAVE's now-playing and cover-art automatically). No auth required — SUB/WAVE's now-playing and cover-art
endpoints are public. endpoints are public.
- **Color scheme** — matches SUB/WAVE's own web UI theme picker (Blueprint,
Classic Dark, Classic Light, Cyberpunk, Flare, Recon, Signal, Vinyl), so
the device can mirror your station's own site theme. Defaults to
Cyberpunk. Each theme sets a background fill, the main text color
(title/artist/show), an accent (host/DJ name + progress bar fill), and a
muted color for the time/temp line.
- **Album art zoom** — how far the album art's breathing zoom effect goes
in: Off, Subtle, Moderate (default), or Strong.
## How it works ## How it works
+149 -31
View File
@@ -16,49 +16,127 @@ DEFAULT_STATION_URL = ""
NOWPLAYING_TTL = 20 NOWPLAYING_TTL = 20
COVER_TTL = 300 COVER_TTL = 300
COLOR_TITLE = "#22d3ee" # cyan # These match SUB/WAVE's own web UI theme picker (Blueprint, Classic Dark,
COLOR_ARTIST = "#22d3ee" # cyan # Classic Light, Cyberpunk, Flare, Recon, Signal, Vinyl), so a station's own
COLOR_SEPARATOR = "#444444" # thin divider between artist and show/host # site theme can be mirrored on the device. Each scheme sets a background
COLOR_SHOW = "#e0e0e0" # near-white # fill, the main text color (title/artist/show), an accent (host/DJ name +
COLOR_HOST = "#ec4899" # magenta/pink # progress bar fill), a muted meta color (time/temp), a separator, and the
COLOR_META = "#888888" # muted gray, for the time/temp line # progress bar's empty-track color.
COLOR_BAR_FILLED = "#c026d3" # magenta/purple COLOR_SCHEMES = {
COLOR_BAR_EMPTY = "#333333" "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): def main(config):
station_url = config.get("station_url", DEFAULT_STATION_URL) station_url = config.get("station_url", DEFAULT_STATION_URL)
if station_url: if station_url:
station_url = station_url.rstrip("/") 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: if not station_url:
return render.Root(child = _message_screen(["Set station URL", "in app settings"])) return render.Root(child = _with_background(_message_screen(["Set station URL", "in app settings"], colors), colors))
now = _fetch_now_playing(station_url) now = _fetch_now_playing(station_url)
if now == None: if now == None:
return render.Root(child = _message_screen(["Can't reach", "station"])) return render.Root(child = _with_background(_message_screen(["Can't reach", "station"], colors), colors))
if not now["online"]: if not now["online"]:
return render.Root(child = _message_screen([now["station"], "offline"])) return render.Root(child = _with_background(_message_screen([now["station"], "offline"], colors), colors))
art = _fetch_cover(station_url, now["subsonic_id"]) art = _fetch_cover(station_url, now["subsonic_id"])
progress = _track_progress(now["subsonic_id"], now["duration"]) progress = _track_progress(now["subsonic_id"], now["duration"])
top_lines = [ top_lines = [
render.Marquee(width = 32, child = render.Text(now["title"], color = COLOR_TITLE)), render.Marquee(width = 32, child = render.Text(now["title"], color = colors["text"])),
render.Marquee(width = 32, child = render.Text(now["artist"], color = COLOR_ARTIST)), render.Marquee(width = 32, child = render.Text(now["artist"], color = colors["text"])),
] ]
if now["show"]: if now["show"]:
top_lines.append(render.Box(width = 32, height = 1, color = COLOR_SEPARATOR)) top_lines.append(render.Box(width = 32, height = 1, color = colors["separator"]))
show_host_row = [render.Text(now["show"], font = "tom-thumb", color = COLOR_SHOW)] show_host_row = [render.Text(now["show"], font = "tom-thumb", color = colors["text"])]
if now["host"]: if now["host"]:
show_host_row.append(render.Text(" " + now["host"], font = "tom-thumb", color = COLOR_HOST)) show_host_row.append(render.Text(" " + now["host"], font = "tom-thumb", color = colors["accent"]))
top_lines.append(render.Marquee(width = 32, child = render.Row(children = show_host_row))) top_lines.append(render.Marquee(width = 32, child = render.Row(children = show_host_row)))
if now["time_temp"]: if now["time_temp"]:
top_lines.append(render.Marquee(width = 32, child = render.Text(now["time_temp"], font = "tom-thumb", color = COLOR_META))) top_lines.append(render.Marquee(width = 32, child = render.Text(now["time_temp"], font = "tom-thumb", color = colors["meta"])))
children = [render.Column(children = top_lines)] children = [render.Column(children = top_lines)]
if progress != None: if progress != None:
children.append(_progress_bar(progress["fraction"], width = 32)) children.append(_progress_bar(progress["fraction"], colors, width = 32))
text_column = render.Box( text_column = render.Box(
width = 32, width = 32,
@@ -72,19 +150,25 @@ def main(config):
) )
if art: if art:
content = render.Row(children = [_art_widget(art), text_column]) content = render.Row(children = [_art_widget(art, zoom_scale), text_column])
else: else:
content = render.Column( content = render.Column(
main_align = "center", main_align = "center",
cross_align = "center", cross_align = "center",
expanded = True, expanded = True,
children = [ children = [
render.Marquee(width = 64, child = render.Text(now["title"], color = COLOR_TITLE)), render.Marquee(width = 64, child = render.Text(now["title"], color = colors["text"])),
render.Marquee(width = 64, child = render.Text(now["artist"], color = COLOR_ARTIST)), render.Marquee(width = 64, child = render.Text(now["artist"], color = colors["text"])),
], ],
) )
return render.Root(child = content) 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 # 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 # the full frame, easing in to a zoomed-in crop at the midpoint, on a
@@ -117,14 +201,18 @@ def main(config):
# barely larger than ART_SLOT: at the safe floor of scale = 1.0, only # 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 # 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 # "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. # 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_SIZE = 36
ART_SLOT = 32 ART_SLOT = 32
ART_ZOOM_SCALE = 1.4 # how far in to zoom, relative to the rest state
ART_ZOOM_DURATION = 200 # 10s round-trip (5s in, 5s out) at the default 50ms/tick ART_ZOOM_DURATION = 200 # 10s round-trip (5s in, 5s out) at the default 50ms/tick
def _art_widget(art_bytes): def _art_widget(art_bytes, zoom_scale):
art = render.Image(src = art_bytes, width = ART_SIZE, height = ART_SIZE) art = render.Image(src = art_bytes, width = ART_SIZE, height = ART_SIZE)
if zoom_scale <= 1.0:
return render.Box(width = ART_SLOT, height = ART_SLOT, child = art)
zoom = animation.Transformation( zoom = animation.Transformation(
child = art, child = art,
duration = ART_ZOOM_DURATION, duration = ART_ZOOM_DURATION,
@@ -135,7 +223,7 @@ def _art_widget(art_bytes):
), ),
animation.Keyframe( animation.Keyframe(
percentage = 0.5, percentage = 0.5,
transforms = [animation.Scale(ART_ZOOM_SCALE, ART_ZOOM_SCALE)], transforms = [animation.Scale(zoom_scale, zoom_scale)],
curve = "ease_in_out", curve = "ease_in_out",
), ),
animation.Keyframe( animation.Keyframe(
@@ -147,22 +235,22 @@ def _art_widget(art_bytes):
) )
return render.Box(width = ART_SLOT, height = ART_SLOT, child = zoom) return render.Box(width = ART_SLOT, height = ART_SLOT, child = zoom)
def _message_screen(lines): def _message_screen(lines, colors):
return render.Column( return render.Column(
main_align = "center", main_align = "center",
cross_align = "center", cross_align = "center",
expanded = True, expanded = True,
children = [render.Text(line, font = "tom-thumb") for line in lines], children = [render.Text(line, font = "tom-thumb", color = colors["text"]) for line in lines],
) )
def _progress_bar(fraction, width = 32, height = 2): def _progress_bar(fraction, colors, width = 32, height = 2):
filled = int(width * fraction + 0.5) filled = int(width * fraction + 0.5)
filled = max(0, min(filled, width)) filled = max(0, min(filled, width))
children = [] children = []
if filled > 0: if filled > 0:
children.append(render.Box(width = filled, height = height, color = COLOR_BAR_FILLED)) children.append(render.Box(width = filled, height = height, color = colors["accent"]))
if filled < width: if filled < width:
children.append(render.Box(width = width - filled, height = height, color = COLOR_BAR_EMPTY)) children.append(render.Box(width = width - filled, height = height, color = colors["bar_empty"]))
return render.Row(children = children) return render.Row(children = children)
def _fetch_now_playing(station_url): def _fetch_now_playing(station_url):
@@ -252,5 +340,35 @@ def get_schema():
icon = "server", icon = "server",
default = DEFAULT_STATION_URL, 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"),
],
),
], ],
) )
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.