diff --git a/README.md b/README.md index af6e6ef..5472ae4 100644 --- a/README.md +++ b/README.md @@ -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 scrolling lines — track title/artist, a thin separator, the current show 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, -white show name, magenta host/progress bar) is loosely modeled on SUB/WAVE's -own web now-playing display. +the bottom of the 32px-wide text column. Not tied to any specific station — set your own station's base URL in the 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 automatically). No auth required — SUB/WAVE's now-playing and cover-art 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 diff --git a/subwave_nowplaying.star b/subwave_nowplaying.star index d613733..9315c76 100644 --- a/subwave_nowplaying.star +++ b/subwave_nowplaying.star @@ -16,49 +16,127 @@ DEFAULT_STATION_URL = "" NOWPLAYING_TTL = 20 COVER_TTL = 300 -COLOR_TITLE = "#22d3ee" # cyan -COLOR_ARTIST = "#22d3ee" # cyan -COLOR_SEPARATOR = "#444444" # thin divider between artist and show/host -COLOR_SHOW = "#e0e0e0" # near-white -COLOR_HOST = "#ec4899" # magenta/pink -COLOR_META = "#888888" # muted gray, for the time/temp line -COLOR_BAR_FILLED = "#c026d3" # magenta/purple -COLOR_BAR_EMPTY = "#333333" +# 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" 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]) + 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) 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"]: - 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"]) progress = _track_progress(now["subsonic_id"], now["duration"]) top_lines = [ - render.Marquee(width = 32, child = render.Text(now["title"], color = COLOR_TITLE)), - render.Marquee(width = 32, child = render.Text(now["artist"], color = COLOR_ARTIST)), + render.Marquee(width = 32, child = render.Text(now["title"], color = colors["text"])), + render.Marquee(width = 32, child = render.Text(now["artist"], color = colors["text"])), ] if now["show"]: - top_lines.append(render.Box(width = 32, height = 1, color = COLOR_SEPARATOR)) - show_host_row = [render.Text(now["show"], font = "tom-thumb", color = COLOR_SHOW)] + top_lines.append(render.Box(width = 32, height = 1, color = colors["separator"])) + 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 = 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))) 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)] if progress != None: - children.append(_progress_bar(progress["fraction"], width = 32)) + children.append(_progress_bar(progress["fraction"], colors, width = 32)) text_column = render.Box( width = 32, @@ -72,19 +150,25 @@ def main(config): ) if art: - content = render.Row(children = [_art_widget(art), text_column]) + content = render.Row(children = [_art_widget(art, zoom_scale), 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 = COLOR_TITLE)), - render.Marquee(width = 64, child = render.Text(now["artist"], color = COLOR_ARTIST)), + 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 = 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 # 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 # 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. +# 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_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 -def _art_widget(art_bytes): +def _art_widget(art_bytes, zoom_scale): 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( child = art, duration = ART_ZOOM_DURATION, @@ -135,7 +223,7 @@ def _art_widget(art_bytes): ), animation.Keyframe( percentage = 0.5, - transforms = [animation.Scale(ART_ZOOM_SCALE, ART_ZOOM_SCALE)], + transforms = [animation.Scale(zoom_scale, zoom_scale)], curve = "ease_in_out", ), animation.Keyframe( @@ -147,22 +235,22 @@ def _art_widget(art_bytes): ) return render.Box(width = ART_SLOT, height = ART_SLOT, child = zoom) -def _message_screen(lines): +def _message_screen(lines, colors): return render.Column( main_align = "center", cross_align = "center", 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 = max(0, min(filled, width)) children = [] 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: - 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) def _fetch_now_playing(station_url): @@ -252,5 +340,35 @@ def get_schema(): 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"), + ], + ), ], )