Files
subwave-nowplaying/subwave_nowplaying.star
T
poprhythm 6cd5decdf5 Fix glitch effect bleeding into text column and wrong corner tick orientation
render.Padding grows its child's declared size by the pad amount rather
than nudging content within a fixed frame. _glitch_band's horizontal
shift_x relied on Padding without ever clipping the result back down to
ART_SLOT, so the RGB-split bands (and everything stacked with them) ended
up wider than the 32px art slot, bleeding into the text column next to
it. Fixed by wrapping each band (and the Sequence states, and the overall
effect) in an explicit Box(ART_SLOT, ART_SLOT) to clip back down.

_glitch_corner_ticks had the same missing-size issue (no explicit box
constraining it to the art slot at all), which is why the right-side
ticks were landing at the far edge of the full 64px canvas instead of the
32px art box's actual right edge.

Also fixed the ticks all being identical L-shapes (opening down-right)
regardless of which corner they were in, so only the true top-left one
looked correct. Each tick's two line segments are now explicitly pushed
to the correct edge of its own small box via Padding, so all four open
toward the image's center as intended.
2026-10-08 16:18:29 -04:00

634 lines
27 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")
load("filter.star", "filter")
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 many seconds into a track's start, or before its end, the "static"
# overlay shows over the album art (a bundled animated GIF of sparse noise
# specks over a transparent background -- images/static.gif). Only used
# when the "Static overlay" schema field is enabled.
STATIC_OVERLAY_SECONDS = 5
DEFAULT_STATIC_OVERLAY = "on"
# 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)
static_overlay_enabled = config.get("static_overlay", DEFAULT_STATIC_OVERLAY) == "on"
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"])
# Only meaningful once _track_progress has real elapsed/remaining data
# (i.e. on a long-lived server -- see its own comment below for why a
# one-shot `pixlet render` CLI call always sees elapsed=0). On a local
# render this will always show the "starting" static, never "ending".
show_static = (
static_overlay_enabled and
progress != None and
(progress["elapsed"] < STATIC_OVERLAY_SECONDS or progress["remaining"] < STATIC_OVERLAY_SECONDS)
)
# 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"], show_static), 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, show_static):
cropped = _zoomed_art(art_bytes, zoom_scale, zoom_position, subsonic_id)
if not show_static:
return cropped
return render.Stack(children = [cropped, _glitch_effect(cropped)])
def _zoomed_art(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)
# Modeled on SUB/WAVE's own web UI, which applies a track-transition
# effect to the cover art (found via its compiled CSS/JS, not written down
# anywhere as a spec): an RGB-split/chromatic-aberration glitch -- two
# duplicate, differently-tinted copies of the art, each cropped to a
# shifting horizontal band -- plus scanlines and viewfinder-style corner
# tick marks. Built here from scratch against the *live* art via
# filter.Hue, not a bundled asset -- pixlet's `filter` module (confirmed
# real and working, same as `animation.Origin`) can recolor an arbitrary
# widget, including an already-zoomed render.Image, so the glitch bands
# reflect whatever's actually on screen rather than a generic texture.
# This replaced an earlier version built as a bundled static.gif overlay
# (first sparse noise specks, briefly a VHS-footage-derived band) -- that
# approach is still viable for projects without filter.star, but once a
# live-recolor option exists it's a better match for what SUB/WAVE itself
# does, and it means this app goes back to a single-file upload (no
# bundled asset to zip alongside it).
#
# The exact keyframe sequence/timing SUB/WAVE's CSS uses past the first
# keyframe isn't visible in its compiled output (no source maps), so
# GLITCH_STATES below is an original sequence in the same spirit (shifting
# inset bands, step-end/snap timing via a Sequence rather than eased
# motion) rather than a reproduction of unseen code. Band proportions
# (top/height as a fraction of ART_SLOT) are loosely modeled on the one
# keyframe that was visible: roughly the top third and an overlapping
# middle band, each about 30% of the frame's height.
GLITCH_HOLD_TICKS = 5 # ~250ms per jitter state at the default 50ms/tick
GLITCH_STATES = [
# (top1, height1, shift1, hue1, top2, height2, shift2, hue2)
(3, 9, -2, 150.0, 19, 8, 2, -60.0),
(2, 8, 2, 150.0, 20, 9, -3, -60.0),
(5, 7, -3, 150.0, 17, 10, 1, -60.0),
(1, 10, 1, 150.0, 21, 7, -2, -60.0),
]
def _glitch_band(zoomed_art, top, height, shift_x, hue_change):
tinted = filter.Hue(child = zoomed_art, change = hue_change)
band = render.Padding(
pad = (0, top, 0, max(0, ART_SLOT - top - height)),
child = render.Box(width = ART_SLOT, height = height, child = tinted),
)
shifted = render.Padding(pad = (max(0, shift_x), 0, max(0, -shift_x), 0), child = band)
# render.Padding *grows* its child's declared size by the pad amount
# rather than nudging content within a fixed frame -- without this
# outer Box to clip it back down to exactly ART_SLOT, the horizontal
# shift above made this (and everything stacked with it) wider than
# the 32px art slot, bleeding into the text column next to it.
return render.Box(width = ART_SLOT, height = ART_SLOT, child = shifted)
def _glitch_jitter(zoomed_art):
states = []
for top1, h1, shift1, hue1, top2, h2, shift2, hue2 in GLITCH_STATES:
state = render.Stack(children = [
_glitch_band(zoomed_art, top1, h1, shift1, hue1),
_glitch_band(zoomed_art, top2, h2, shift2, hue2),
])
# Wrapping in a no-op Transformation just to pin this state's
# FrameCount to GLITCH_HOLD_TICKS, so render.Sequence holds on it
# for that many ticks before advancing to the next state --
# Sequence has no other way to control how long a child plays.
states.append(animation.Transformation(
child = state,
duration = GLITCH_HOLD_TICKS,
width = ART_SLOT,
height = ART_SLOT,
keyframes = [
animation.Keyframe(percentage = 0.0, transforms = []),
animation.Keyframe(percentage = 1.0, transforms = []),
],
))
return render.Sequence(children = states)
def _glitch_scanlines():
# render.Sequence doesn't loop (it just stops painting once its total
# frame count is exhausted -- confirmed in pixlet's own source), so
# the RGB-split jitter above plays once per show_static window and
# then goes quiet; these persist for the whole window as a constant
# texture rather than a one-shot burst, closer to how SUB/WAVE's
# scanlines sweep continuously while its live class is applied.
lines = []
for y in range(ART_SLOT):
if y % 4 == 0:
lines.append(render.Box(width = ART_SLOT, height = 1, color = "#00000080"))
else:
lines.append(render.Box(width = ART_SLOT, height = 1, color = "#00000000"))
return render.Column(children = lines)
def _glitch_corner_ticks(color):
tick_size = 5
# A single tick shape (two short perpendicular lines) always draws
# the same way regardless of which corner it's placed in -- it has no
# idea it's "the top-right one". To make the bracket actually open
# toward the image's center at every corner (not just top-left), each
# line has to be explicitly pushed to the correct edge of its own
# tick_size x tick_size box via Padding: the horizontal line to the
# top or bottom, the vertical line to the left or right. Wrapped in an
# outer Box for the same reason as _glitch_band -- Padding grows its
# child's declared size, so without this the tick would be wider/
# taller than tick_size.
def tick(top_aligned, left_aligned):
h_line = render.Padding(
pad = (0, 0 if top_aligned else tick_size - 1, 0, tick_size - 1 if top_aligned else 0),
child = render.Box(width = tick_size, height = 1, color = color),
)
v_line = render.Padding(
pad = (0 if left_aligned else tick_size - 1, 0, tick_size - 1 if left_aligned else 0, 0),
child = render.Box(width = 1, height = tick_size, color = color),
)
return render.Box(width = tick_size, height = tick_size, child = render.Stack(children = [h_line, v_line]))
return render.Box(
width = ART_SLOT,
height = ART_SLOT,
child = render.Column(
main_align = "space_between",
expanded = True,
children = [
render.Row(main_align = "space_between", expanded = True, children = [
tick(top_aligned = True, left_aligned = True),
tick(top_aligned = True, left_aligned = False),
]),
render.Row(main_align = "space_between", expanded = True, children = [
tick(top_aligned = False, left_aligned = True),
tick(top_aligned = False, left_aligned = False),
]),
],
),
)
def _glitch_effect(zoomed_art):
return render.Box(
width = ART_SLOT,
height = ART_SLOT,
child = render.Stack(children = [
_glitch_jitter(zoomed_art),
_glitch_scanlines(),
_glitch_corner_ticks("#33ff99"),
]),
)
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"),
],
),
schema.Dropdown(
id = "static_overlay",
name = "Static overlay",
desc = "Show a brief TV-static effect over the album art as a track starts and ends.",
icon = "tv",
default = DEFAULT_STATIC_OVERLAY,
options = [
schema.Option(display = "On", value = "on"),
schema.Option(display = "Off", value = "off"),
],
),
],
)