Files
subwave-nowplaying/subwave_nowplaying.star
T
poprhythm 2993e9e9a9 Replace bundled static.gif with a live RGB-split glitch effect
Rebuilt the track-transition "static overlay" as a chromatic-aberration
glitch on the *live* album art instead of a bundled, pre-baked animated
GIF: two duplicate, differently-tinted copies of the actual art (via
filter.Hue, confirmed real and working), each cropped to a shifting
horizontal band, plus scanlines and viewfinder-style corner tick marks.

Loosely modeled on SUB/WAVE's own web UI, which does something similar to
its cover art on track change (found via its compiled CSS/JS: a
.v3-cover-live class gating an RGB-split band glitch, sweeping scanlines,
and corner tick marks). The exact keyframe timing past the first step
wasn't visible in the minified output, so GLITCH_STATES is an original
jitter sequence in the same spirit, not a reproduction of unseen code.

This is the third design for this effect: sparse noise specks, then a
version derived from a real "VHS glitch" stock-footage clip (dropped
because recognizable footage content showed through at 32x32, and it
compressed worse than procedural noise), now this. Since it needs no
bundled asset, the app goes back to a single-file upload -- reverted the
Deploy section accordingly.

Verified: renders cleanly across all zoom-level/static-overlay
combinations, payload stays well under the 460KB limit, and
render.Sequence's lack of looping (confirmed in pixlet's own source)
means the RGB-split jitter plays once per show_static window and settles
to just scanlines/ticks over clean art for the remainder, not a
constant distraction for the whole window.
2026-10-08 15:58:15 -04:00

594 lines
25 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),
)
return render.Padding(pad = (max(0, shift_x), 0, max(0, -shift_x), 0), child = band)
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,
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):
def tick():
return render.Stack(children = [
render.Box(width = 5, height = 1, color = color),
render.Box(width = 1, height = 5, color = color),
])
return render.Column(
main_align = "space_between",
expanded = True,
children = [
render.Row(main_align = "space_between", expanded = True, children = [tick(), tick()]),
render.Row(main_align = "space_between", expanded = True, children = [tick(), tick()]),
],
)
def _glitch_effect(zoomed_art):
return 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"),
],
),
],
)