diff --git a/dashcaddy-api/src/docker/app-templates.js b/dashcaddy-api/src/docker/app-templates.js index fdf57ae..48f8002 100644 --- a/dashcaddy-api/src/docker/app-templates.js +++ b/dashcaddy-api/src/docker/app-templates.js @@ -1764,6 +1764,47 @@ const APP_TEMPLATES = { ] }, + "vintage-radio": { + name: "Vintage Stereo", + description: "Glass-front console stereo that tunes curated real internet stations (SomaFM, KEXP, Radio Paradise, and more) through a beautiful analog UI", + icon: "📻", + category: "Media", + popularity: 72, + difficulty: "Easy", + docker: { + image: "nginx:alpine", + ports: ["{{PORT}}:80"], + volumes: [ + "/opt/vintage-radio/web:/usr/share/nginx/html:ro" + ], + environment: {} + }, + subdomain: "radio", + defaultPort: 8090, + healthCheck: "/", + subpathSupport: 'none', + preInstall: { + description: "Materialize the bundled static assets into /opt/vintage-radio/web before starting the container.", + script: "vintage-radio-install.sh" + }, + features: [ + "Glass-front console stereo UI with wooden end caps and brushed-metal faceplate", + "Tunable analog slide-rule dial with click-stop detents and red cursor flag", + "Twin glowing VU meters with smooth needle animation while powered", + "Power / Mode / Mute knobs, vertical volume slider, signal-strength LED", + "MODE knob filters stations by genre (ALL / AMBIENT / ROCK / MIXED)", + "18 curated real internet-radio streams (SomaFM, KEXP, Radio Paradise, Space Station Soma, Mission Control, and more)" + ], + setupInstructions: [ + "Run `bash /usr/local/bin/vintage-radio-install.sh` once before starting the container — copies the bundled web assets (index.html, radio.css, radio.js, stations.json) from the DashCaddy repo (dashcaddy-api/static-sites/vintage-radio/web) into /opt/vintage-radio/web", + "Open radio.sami (or your configured subdomain)", + "Press the PWR knob, drag the dial or click a station card", + "Cycle the MODE knob to filter by genre (ALL / AMBIENT / ROCK / MIXED)", + "To add stations, edit /opt/vintage-radio/web/stations.json on the host and restart the container" + ], + tags: ["radio", "music", "streaming", "audio", "vintage", "retro", "media"] + }, + "airsonic": { name: "Airsonic Advanced", description: "Free web-based media streamer", diff --git a/dashcaddy-api/static-sites/vintage-radio/install-installer.sh b/dashcaddy-api/static-sites/vintage-radio/install-installer.sh new file mode 100755 index 0000000..540cd14 --- /dev/null +++ b/dashcaddy-api/static-sites/vintage-radio/install-installer.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# install-installer.sh — Installs vintage-radio-install.sh into /usr/local/bin. +# +# Run this once on a host to make `bash /usr/local/bin/vintage-radio-install.sh` +# available as a system command. Idempotent. + +set -euo pipefail + +SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SRC="${SELF_DIR}/install.sh" +DEST="/usr/local/bin/vintage-radio-install.sh" + +if [[ ! -f "$SRC" ]]; then + echo "FATAL: $SRC not found" >&2 + exit 1 +fi + +install -m 0755 "$SRC" "$DEST" +echo "Installed: $SRC -> $DEST" +echo "Run it with: bash $DEST" diff --git a/dashcaddy-api/static-sites/vintage-radio/install.sh b/dashcaddy-api/static-sites/vintage-radio/install.sh new file mode 100755 index 0000000..775c8dd --- /dev/null +++ b/dashcaddy-api/static-sites/vintage-radio/install.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# vintage-radio-install.sh — Materializes the Vintage Stereo bundled web assets. +# +# The Vintage Stereo radio template serves its UI through an nginx:alpine +# container that mounts /opt/vintage-radio/web as /usr/share/nginx/html. This +# script copies the assets (index.html, radio.css, radio.js, stations.json) +# from the DashCaddy source tree into that mount target. +# +# Usage: +# bash /usr/local/bin/vintage-radio-install.sh +# +# Environment overrides: +# DASHCADDY_ROOT — Path to the DashCaddy install root (defaults to /opt/dashcaddy). +# TARGET_DIR — Mount target directory (defaults to /opt/vintage-radio/web). +# +# Idempotent: safe to re-run; overwrites the target files each time. + +set -euo pipefail + +DASHCADDY_ROOT="${DASHCADDY_ROOT:-/opt/dashcaddy}" +TARGET_DIR="${TARGET_DIR:-/opt/vintage-radio/web}" +SOURCE_DIR="${DASHCADDY_ROOT}/dashcaddy-api/static-sites/vintage-radio/web" + +if [[ ! -d "$SOURCE_DIR" ]]; then + echo "FATAL: source assets not found at $SOURCE_DIR" >&2 + echo " Install DashCaddy, or set DASHCADDY_ROOT to its location." >&2 + exit 1 +fi + +if [[ ! -f "$SOURCE_DIR/index.html" || ! -f "$SOURCE_DIR/radio.css" \ + || ! -f "$SOURCE_DIR/radio.js" || ! -f "$SOURCE_DIR/stations.json" ]]; then + echo "FATAL: incomplete assets in $SOURCE_DIR" >&2 + ls -la "$SOURCE_DIR" >&2 + exit 1 +fi + +mkdir -p "$TARGET_DIR" + +install -m 0644 "$SOURCE_DIR/index.html" "$TARGET_DIR/index.html" +install -m 0644 "$SOURCE_DIR/radio.css" "$TARGET_DIR/radio.css" +install -m 0644 "$SOURCE_DIR/radio.js" "$TARGET_DIR/radio.js" +install -m 0644 "$SOURCE_DIR/stations.json" "$TARGET_DIR/stations.json" + +chmod 0755 "$TARGET_DIR" + +echo "Vintage Stereo assets installed:" +echo " Source: $SOURCE_DIR" +echo " Target: $TARGET_DIR" +ls -la "$TARGET_DIR" diff --git a/dashcaddy-api/static-sites/vintage-radio/web/index.html b/dashcaddy-api/static-sites/vintage-radio/web/index.html new file mode 100644 index 0000000..9b5b0e3 --- /dev/null +++ b/dashcaddy-api/static-sites/vintage-radio/web/index.html @@ -0,0 +1,143 @@ + + + + + + Vintage Stereo + + + +
+
+ + + + + +
+
+ + +
+
--.-
+
VINTAGE STEREO
+
+ + +
+ +
+ 889296100104 +
+
+ + +
+ + +
+ + +
+ + Standby + + Signal +
+
+ + + + + + + +
+ + + +
+ + + + + + diff --git a/dashcaddy-api/static-sites/vintage-radio/web/radio.css b/dashcaddy-api/static-sites/vintage-radio/web/radio.css new file mode 100644 index 0000000..2b86a73 --- /dev/null +++ b/dashcaddy-api/static-sites/vintage-radio/web/radio.css @@ -0,0 +1,682 @@ +/* Vintage Stereo — glass-front console stereo styling */ + +:root { + --wood-light: #c89466; + --wood-mid: #8a5326; + --wood-dark: #3e2110; + --wood-cap: #2a160a; + --brushed: #d4cfc2; + --brushed-dk: #807a6e; + --face: #b8b2a3; + --face-dk: #615d54; + --led-off: #341a10; + --led-on: #ff5733; + --dial-glow: #ffa84a; + --vu-glow: #f1c40f; + --knob-cap: #1d1814; + --ink: #14110a; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + padding: 0; + min-height: 100%; + background: + radial-gradient(ellipse at center, #1f140a 0%, #0a0604 80%); + color: var(--ink); + font-family: 'Inter', 'Helvetica Neue', Helvetica, Arial, sans-serif; + overflow: hidden; +} + +.room { + display: grid; + grid-template-columns: minmax(640px, 1fr) 340px; + gap: 24px; + padding: 28px; + align-items: stretch; + min-height: 100vh; +} + +@media (max-width: 1000px) { + .room { + grid-template-columns: 1fr; + overflow-y: auto; + height: auto; + min-height: 100vh; + } + .room > .console { justify-self: center; } + .room > .panel { min-height: 60vh; } +} + +/* Very narrow phones: zoom the console down to fit the viewport. + Note: `zoom` is supported in Chrome/Edge/Safari and Firefox 126+. Older Firefox + falls back to the unzoomed layout (with mild horizontal overflow). */ +@media (max-width: 760px) { + html, body { overflow: auto; } + .room { padding: 12px; } + .room > .console { zoom: 0.92; } +} + +@media (max-width: 600px) { + .room > .console { zoom: 0.78; } +} + +@media (max-width: 480px) { + .room > .console { zoom: 0.62; } +} + +/* ====== Console ====== */ + +.console { + position: relative; + background: + repeating-linear-gradient(90deg, + rgba(255,255,255,0.05) 0 2px, + transparent 2px 5px), + linear-gradient(180deg, var(--wood-light) 0%, var(--wood-mid) 50%, var(--wood-dark) 100%); + border-radius: 24px; + padding: 0; + box-shadow: + inset 0 1px 0 rgba(255,255,255,0.25), + inset 0 -30px 80px rgba(0,0,0,0.55), + 0 30px 80px rgba(0,0,0,0.6), + 0 0 0 8px var(--wood-cap); + display: grid; + grid-template-columns: 130px 1fr 200px; + grid-template-rows: 360px 1fr; + grid-template-areas: + "left face right" + "grille grille grille"; + min-height: 720px; + overflow: hidden; +} + +.console::before { + content: ""; + position: absolute; + inset: 6px; + border-radius: 20px; + border: 2px solid rgba(0,0,0,0.35); + pointer-events: none; + z-index: 6; +} + +/* ====== End caps (left & right wooden panels with knobs) ====== */ + +.endcap { + background: linear-gradient(180deg, var(--wood-mid) 0%, var(--wood-dark) 100%); + padding: 22px 12px; + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + box-shadow: inset 8px 0 18px rgba(0,0,0,0.45); + position: relative; +} + +.endcap-left { grid-area: left; border-right: 2px solid rgba(0,0,0,0.4); } +.endcap-right { grid-area: right; border-left: 2px solid rgba(0,0,0,0.4); box-shadow: inset -8px 0 18px rgba(0,0,0,0.45); } + +/* ====== Knobs ====== */ + +.knob { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + padding: 0; + background: transparent; + border: 0; + cursor: pointer; + font-family: inherit; + color: #f4ead0; + font-size: 9px; + letter-spacing: 2px; +} + +.knob-face { + width: 56px; + height: 56px; + border-radius: 50%; + background: + radial-gradient(circle at 30% 25%, #f0e8d4 0%, #8a7e5e 60%, #1c1610 100%); + border: 2px solid #0a0805; + box-shadow: + 0 3px 6px rgba(0,0,0,0.5), + inset 0 -1px 2px rgba(255,255,255,0.18), + inset 0 2px 4px rgba(255,255,255,0.15); + position: relative; + transition: transform 0.05s; +} + +.knob:active .knob-face { transform: translateY(1px); } + +.knob-indicator { + position: absolute; + top: 6px; + left: 50%; + width: 3px; + height: 14px; + background: var(--led-on); + border-radius: 1px; + transform: translateX(-50%); + box-shadow: 0 0 4px var(--led-on); +} + +.knob-power[aria-pressed="true"] .knob-indicator { + box-shadow: 0 0 10px var(--led-on), 0 0 16px rgba(255,87,51,0.4); +} + +.knob-label { + font-weight: bold; + color: var(--brushed); + text-shadow: 0 1px 0 rgba(0,0,0,0.5); +} + +.knob-mode-name { + font-size: 8px; + letter-spacing: 1.5px; + color: var(--dial-glow); + background: #1a0d05; + padding: 2px 6px; + border-radius: 3px; + border: 1px solid #0a0805; + margin-top: -2px; + text-shadow: 0 0 3px var(--dial-glow); +} + +/* ====== Glass face ====== */ + +.glass-face { + grid-area: face; + position: relative; + background: + linear-gradient(180deg, #c4beae 0%, #a39c8b 50%, #7a7363 100%); + padding: 28px 32px 22px; + display: grid; + grid-template-rows: auto 1fr auto auto; + gap: 16px; + overflow: hidden; +} + +/* The smoked-glass overlay that sits ON TOP of all face contents */ +.glass-overlay { + position: absolute; + inset: 0; + background: + linear-gradient(180deg, rgba(20, 14, 6, 0.18) 0%, rgba(20, 14, 6, 0.35) 100%), + repeating-linear-gradient(135deg, + rgba(255,255,255,0.04) 0 1px, + transparent 1px 4px); + box-shadow: + inset 0 1px 0 rgba(255,255,255,0.45), + inset 0 0 30px rgba(0,0,0,0.35); + border-left: 2px solid rgba(0,0,0,0.4); + border-right: 2px solid rgba(0,0,0,0.4); + pointer-events: none; + z-index: 4; +} + +.glass-face > *:not(.glass-overlay) { position: relative; z-index: 2; } + +/* Faint streaks like a polished-glass reflection */ +.glass-face::after { + content: ""; + position: absolute; + inset: 0; + background: + linear-gradient(120deg, + transparent 30%, + rgba(255,255,255,0.18) 38%, + transparent 46%, + rgba(255,255,255,0.08) 60%, + transparent 70%); + pointer-events: none; + z-index: 5; + mix-blend-mode: screen; +} + +/* ====== Dial window: backlit section behind glass ====== */ + +.dial-window { + background: + linear-gradient(180deg, #1a0d05 0%, #2b1608 100%); + padding: 16px 24px; + border-radius: 8px; + border: 2px solid #0a0805; + text-align: center; + box-shadow: + inset 0 2px 6px rgba(0,0,0,0.7), + 0 0 12px rgba(0,0,0,0.4); +} + +.dial-frequency { + font-family: 'Courier New', monospace; + font-size: 56px; + font-weight: bold; + color: var(--dial-glow); + letter-spacing: 4px; + line-height: 1; + text-shadow: + 0 0 8px var(--dial-glow), + 0 0 18px rgba(255,168,74,0.4); + font-variant-numeric: tabular-nums; +} + +.console[data-power="off"] .dial-frequency { color: #4a2b14; text-shadow: none; } + +.dial-station { + margin-top: 8px; + font-size: 16px; + letter-spacing: 5px; + color: #f6e6c8; + text-shadow: 0 0 6px rgba(255,176,102,0.4); +} + +.console[data-power="off"] .dial-station { color: #4a2b14; text-shadow: none; } + +/* ====== Tuning rail ====== */ + +.dial-rail-wrap { + position: relative; +} + +.dial-rail { + position: relative; + width: 100%; + height: 72px; + background: + linear-gradient(180deg, #161109 0%, #2a1c0b 100%); + border-radius: 6px; + border: 2px solid #0a0805; + cursor: ew-resize; + touch-action: none; + user-select: none; + padding: 0; + overflow: visible; +} + +.dial-ticks { + position: absolute; + inset: 0; + background: + repeating-linear-gradient(90deg, + rgba(255,168,74,0.25) 0 1px, + transparent 1px 2px, + rgba(255,168,74,0.5) 8px 9px, + rgba(255,168,74,0.15) 9px 14px); +} + +.dial-stop { + position: absolute; + top: 4px; + bottom: 4px; + width: 3px; + background: var(--dial-glow); + border-radius: 2px; + box-shadow: 0 0 4px var(--dial-glow); + pointer-events: none; + opacity: 0.7; +} + +.dial-cursor { + position: absolute; + top: -6px; + bottom: -6px; + left: 50%; + width: 0; + pointer-events: none; + transition: left 0.18s ease-out; +} + +.cursor-line { + position: absolute; + top: 0; + bottom: 0; + left: -1px; + width: 2px; + background: var(--led-on); + box-shadow: 0 0 6px var(--led-on), 0 0 12px rgba(255,87,51,0.5); +} + +.cursor-flag { + position: absolute; + top: -10px; + left: -7px; + width: 0; + height: 0; + border-left: 7px solid transparent; + border-right: 7px solid transparent; + border-bottom: 8px solid var(--led-on); + filter: drop-shadow(0 0 4px var(--led-on)); +} + +.dial-scale { + display: flex; + justify-content: space-between; + margin-top: 4px; + font-family: 'Courier New', monospace; + font-size: 10px; + color: var(--face-dk); + letter-spacing: 1px; + padding: 0 6px; +} + +/* ====== Twin VU meters ====== */ + +.vu-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 18px; + padding: 0 6px; +} + +.vu-meter { + position: relative; + height: 80px; + background: + linear-gradient(180deg, #f7f0d8 0%, #d8cfb5 100%); + border-radius: 6px; + border: 2px solid #0a0805; + overflow: hidden; + box-shadow: inset 0 2px 4px rgba(0,0,0,0.25); +} + +.vu-needle { + position: absolute; + bottom: 0; + left: 50%; + width: 1.5px; + height: 100%; + background: #c0392b; + transform-origin: bottom center; + transition: transform 0.12s ease-out; +} + +.vu-falloff { + position: absolute; + inset: 0; + background: linear-gradient(90deg, transparent 49%, rgba(0,0,0,0.15) 50%, transparent 51%); + pointer-events: none; +} + +.vu-label { + position: absolute; + top: 4px; + left: 6px; + font-size: 11px; + font-weight: bold; + color: #c0392b; +} + +.vu-bg-marks { + position: absolute; + bottom: 4px; + left: 4px; + right: 4px; + display: flex; + justify-content: space-around; +} + +.vu-bg-marks span { + width: 1px; + height: 6px; + background: rgba(60, 40, 25, 0.6); + display: block; +} + +.vu-bg-marks span.red { background: #c0392b; } + +/* ====== Status row under glass ====== */ + +.status-row { + display: flex; + align-items: center; + gap: 10px; + padding: 0 8px; + font-size: 11px; + letter-spacing: 2px; + color: var(--face-dk); + font-family: 'Courier New', monospace; +} + +.led { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--led-off); + box-shadow: inset 0 1px 1px rgba(255,255,255,0.2); + transition: background 0.2s, box-shadow 0.2s; +} + +.console[data-power="on"] .led { background: var(--led-on); box-shadow: 0 0 8px var(--led-on), inset 0 1px 1px rgba(255,255,255,0.3); } + +.led-signal { background: #2a1608; } + +.console[data-power="on"][data-streaming="true"] .led-signal { + background: #2ecc71; + box-shadow: 0 0 6px #2ecc71, inset 0 1px 1px rgba(255,255,255,0.3); + animation: signal-pulse 1.6s infinite ease-in-out; +} + +@keyframes signal-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.55; } +} + +.status-text { font-weight: bold; text-transform: uppercase; } + +/* ====== Right end cap: volume + presets ====== */ + +.volume-block, .preset-block { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + width: 100%; +} + +.block-label { + font-size: 9px; + letter-spacing: 3px; + color: var(--brushed); + font-weight: bold; +} + +.volume-slider { + writing-mode: vertical-lr; + direction: rtl; + width: 28px; + height: 100px; + accent-color: var(--led-on); + cursor: pointer; +} + +.volume-readout { + font-family: 'Courier New', monospace; + font-size: 18px; + font-weight: bold; + color: var(--dial-glow); + text-shadow: 0 0 6px var(--dial-glow); + background: #1a0d05; + padding: 4px 10px; + border-radius: 4px; + border: 1px solid #0a0805; + min-width: 48px; + text-align: center; + font-variant-numeric: tabular-nums; +} + +.preset-buttons { display: flex; gap: 6px; } + +.preset { + background: var(--brushed); + border: 2px solid var(--brushed-dk); + border-radius: 4px; + padding: 8px 12px; + cursor: pointer; + font-family: inherit; + color: var(--ink); + font-size: 12px; + font-weight: bold; + letter-spacing: 1px; + box-shadow: inset 0 -2px 3px rgba(0,0,0,0.25), 0 2px 3px rgba(0,0,0,0.4); +} + +.preset:active { transform: translateY(1px); box-shadow: inset 0 2px 3px rgba(0,0,0,0.25), 0 0 0 transparent; } + +.preset:disabled { opacity: 0.3; cursor: not-allowed; } + +.preset-label { + font-family: 'Courier New', monospace; + font-size: 11px; + color: var(--dial-glow); + text-shadow: 0 0 4px var(--dial-glow); + background: #1a0d05; + padding: 3px 8px; + border-radius: 3px; + border: 1px solid #0a0805; +} + +/* ====== Speaker grille (spans full bottom) ====== */ + +.grille { + grid-area: grille; + background: + repeating-linear-gradient(90deg, + rgba(0,0,0,0.85) 0 2px, + rgba(255,255,255,0.04) 2px 6px); + border-top: 4px solid rgba(0,0,0,0.5); + box-shadow: inset 0 4px 12px rgba(0,0,0,0.6); + position: relative; + min-height: 120px; +} + +.grille-fabric { + position: absolute; + inset: 12px; + background: + repeating-linear-gradient(90deg, + rgba(0,0,0,0.4) 0 3px, + rgba(120, 80, 40, 0.2) 3px 6px), + radial-gradient(ellipse at center, rgba(0,0,0,0.4) 0%, transparent 70%); + border-radius: 4px; +} + +/* ====== Side panel ====== */ + +.panel { + background: linear-gradient(180deg, #1a120a 0%, #0d0805 100%); + color: #d4c9a8; + border-radius: 22px; + padding: 22px; + border: 2px solid var(--wood-dark); + box-shadow: + inset 0 0 30px rgba(0,0,0,0.6), + 0 12px 30px rgba(0,0,0,0.4); + overflow: hidden; + display: flex; + flex-direction: column; +} + +.panel-head h1 { + margin: 0; + font-size: 16px; + letter-spacing: 4px; + color: var(--dial-glow); + text-shadow: 0 0 8px var(--dial-glow); +} + +.panel-sub { + margin: 4px 0 18px; + font-size: 11px; + letter-spacing: 1.5px; + opacity: 0.6; +} + +.station-list { + list-style: none; + margin: 0; + padding: 0; + flex: 1; + overflow-y: auto; +} + +.station-list li { + padding: 10px 12px; + margin-bottom: 4px; + border-radius: 6px; + cursor: pointer; + display: grid; + grid-template-columns: 56px 1fr; + gap: 12px; + align-items: center; + border: 1px solid transparent; + transition: background 0.15s, border-color 0.15s, transform 0.05s; +} + +.station-list li:hover { background: rgba(255,176,102,0.08); border-color: rgba(255,176,102,0.3); } + +.station-list li[aria-selected="true"] { + background: rgba(255,176,102,0.15); + border-color: var(--dial-glow); +} + +.station-list li:active { transform: translateX(2px); } + +.station-freq { + font-size: 16px; + font-weight: bold; + text-align: right; + font-variant-numeric: tabular-nums; + color: var(--dial-glow); + font-family: 'Courier New', monospace; +} + +.station-info { display: flex; flex-direction: column; gap: 2px; min-width: 0; } + +.station-name { + font-size: 14px; + color: #f4ead0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.station-genre { + font-size: 10px; + letter-spacing: 1px; + opacity: 0.6; + text-transform: uppercase; +} + +.panel-foot { + margin-top: 16px; + padding-top: 12px; + border-top: 1px solid rgba(255,176,102,0.2); + font-size: 11px; + display: flex; + gap: 8px; + align-items: center; + letter-spacing: 1px; +} + +.panel-foot .sep { opacity: 0.4; } + +#streamInfo.live::before { + content: "\25CF"; + color: var(--led-on); + margin-right: 4px; + animation: blink 1.2s infinite; +} + +@keyframes blink { + 0%, 60%, 100% { opacity: 1; } + 30% { opacity: 0.2; } +} + +.station-list::-webkit-scrollbar { width: 6px; } +.station-list::-webkit-scrollbar-track { background: rgba(0,0,0,0.3); } +.station-list::-webkit-scrollbar-thumb { background: var(--wood-mid); border-radius: 3px; } diff --git a/dashcaddy-api/static-sites/vintage-radio/web/radio.js b/dashcaddy-api/static-sites/vintage-radio/web/radio.js new file mode 100644 index 0000000..74c2cbe --- /dev/null +++ b/dashcaddy-api/static-sites/vintage-radio/web/radio.js @@ -0,0 +1,474 @@ +// Vintage Stereo — tuner logic for the glass-front console stereo +// Loads stations from /stations.json, manages playback through an