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:
1 parent
6e346a0cf8
commit
4a01de30de
2 files changed
+158
-34
No files matched your search
@@ -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
@@ -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"),
|
||||||
|
],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||