metronome/micro.html
Me Here 76a94b629b Redesign PM-µ Micro as an inline practice bar
Reworks the Micro per the new brief: a long, narrow extruded-aluminium bar you
patch into your signal instead of a little box.
- Better display: amber 4-char 14-segment (Adafruit font) that shows BPM *and*
  short track names, replacing the 3-digit 7-segment. Off-segments kept very dim
  so the lit digits read clearly.
- Roller instead of knob: a recessed, clickable horizontal thumb-roller —
  roll = tempo, press = start/stop, hold + roll = switch track.
- New form/I-O: 1/4" TRS in on one end; USB-C + 1/4" TRS out on the other;
  USB-C or 2×AA power (battery gauge on the face). Click is summed into the
  signal in the analog domain (+ a small monitor speaker).
info-micro, concepts and the landing card updated to match; BOM reworked
(analog path + 2 jacks + 2×AA + 14-seg) → ≈ $38.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 07:42:15 -05:00

335 lines
21 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>VARASYS PMµ — micro (home practice)</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,@BUILD:favicon@">
<script>
/* ?embed=1 → strip site chrome (base.css [data-embed]) + auto-size to the host */
(function(){ if(!/[?&]embed=1/.test(location.search)) return;
document.documentElement.dataset.embed="1";
function ph(){ try{ parent.postMessage({type:"varasys-h",h:Math.ceil(document.documentElement.getBoundingClientRect().height)},"*"); }catch(e){} }
addEventListener("load",ph); addEventListener("resize",ph); setTimeout(ph,300); setTimeout(ph,1000);
})();
</script>
<!--
"Micro" — a long, narrow INLINE practice bar on the same RP2040 firmware.
Patch your instrument through it: 1/4" TRS in on one end; USB-C + 1/4" TRS out
on the other; powered from USB-C or 2×AA. The click is summed into your signal
in the ANALOG domain (and a small speaker). Display is a 4-char amber 14-segment
(shows BPM *and* short track names). One control — a clickable thumb-ROLLER:
• roll → tempo
• press (click) → start / stop
• hold + roll → switch track (the display shows the track name)
Built-in tracks are the editor's seed grooves, flattened. Shares src/engine.js.
-->
<script>
// Set theme before first paint (shared "metronome.theme" with the other pages).
(function(){ try{
var p = localStorage.getItem("metronome.theme");
if (p!=="light" && p!=="dark" && p!=="system") p = "system";
document.documentElement.dataset.theme = p==="system" ? (matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark") : p;
} catch(e){ document.documentElement.dataset.theme = "dark"; } })();
</script>
<style>
/*@BUILD:include:src/base.css@*/
:root{
--bg1:#12151c; --bg2:#05070a;
--txt:#c7d0db; --muted:#7f8b9a; --link:#6cb6ff;
--panel-bd:#2a313c;
--device-bd:#33363c; --silk:#aab2bc; --dmuted:#5a626c;
--cyan:#0AB3F7;
}
:root[data-theme="light"]{ --bg1:#f5f8fc; --bg2:#dde4ec; --txt:#1e2630; --muted:#5c6776; --link:#1769c4; --panel-bd:#d2dae4 }
body{ margin:0; min-height:100vh; padding:30px 14px 44px;
background:radial-gradient(circle at 50% -8%, var(--bg1), var(--bg2)); color:var(--txt);
display:flex; flex-direction:column; align-items:center; gap:16px }
a{ color:var(--link) }
.topbar{ width:100%; max-width:330px; display:flex; align-items:center; justify-content:space-between; gap:8px; font-size:12px; color:var(--muted); flex-wrap:wrap }
.topbar b{ color:var(--txt) }
.topbar-right{ display:flex; align-items:center; gap:10px }
.tbtn{ background:transparent; color:var(--muted); border:1px solid var(--panel-bd); border-radius:8px; padding:3px 9px; font-size:14px; line-height:1; cursor:pointer }
.tbtn:hover{ color:var(--txt) }
/* ---- the micro device: a long, narrow brushed-aluminium bar ---- */
.device{ width:100%; max-width:620px; display:flex; align-items:stretch; position:relative;
border-radius:18px; overflow:hidden; border:1px solid var(--device-bd);
background:
radial-gradient(rgba(255,255,255,.022) .5px, transparent .6px) 0 0/3px 3px, /* bead-blast micro-texture */
linear-gradient(180deg, #2b2d33, #161719); /* matte anodised graphite */
box-shadow:0 24px 50px rgba(0,0,0,.6), inset 0 1px 0 rgba(255,255,255,.05), inset 0 -2px 8px rgba(0,0,0,.5) }
/* end caps — the extrusion ends, where the jacks exit */
.endcap{ flex:0 0 auto; width:76px; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:11px;
padding:12px 8px; background:linear-gradient(180deg,#202227,#0d0e11) }
.endcap.left{ box-shadow:inset -7px 0 13px rgba(0,0,0,.55) }
.endcap.right{ box-shadow:inset 7px 0 13px rgba(0,0,0,.55) }
.endlbl{ font-size:7px; color:var(--silk); text-transform:uppercase; letter-spacing:.1em; text-align:center; line-height:1.35; opacity:.8 }
.jk{ display:flex; flex-direction:column; align-items:center; gap:4px }
.jk i{ width:23px; height:23px; border-radius:50%; background:radial-gradient(circle at 40% 34%, #333a44, #07090c 72%);
border:2px solid #5b6470; box-shadow:inset 0 0 5px #000 }
.jk.usb i{ width:25px; height:10px; border-radius:4px; border:2px solid #5b6470; background:#07090c }
.jk b{ font-size:7px; font-weight:700; color:var(--silk); letter-spacing:.05em; text-transform:uppercase; opacity:.9; text-align:center; line-height:1.25 }
/* the top face (between the end caps) */
.face{ flex:1; min-width:0; display:flex; flex-direction:column; padding:11px 16px; gap:8px }
.brandrow{ display:flex; align-items:center; justify-content:space-between; margin:0 }
.brand-logo{ height:13px; width:auto; display:block }
.silk{ display:flex; align-items:center; gap:7px; color:var(--silk) }
.silk .model{ font-size:8.5px; text-transform:uppercase; letter-spacing:.16em; opacity:.85 }
.meta{ display:flex; align-items:center; gap:12px }
.pwr{ display:flex; align-items:center; gap:5px; font-size:7.5px; color:var(--silk); text-transform:uppercase; letter-spacing:.12em; opacity:.85 }
.pwr .dot{ width:6px; height:6px; border-radius:50%; background:#2fe07a; box-shadow:0 0 6px #2fe07a }
/* battery gauge (2×AA) */
.batt{ display:flex; align-items:center; gap:5px; font-size:7.5px; color:var(--silk); text-transform:uppercase; letter-spacing:.1em; opacity:.85 }
.batt .cell{ width:21px; height:10px; border:1px solid var(--silk); border-radius:2px; position:relative }
.batt .cell::after{ content:""; position:absolute; right:-3px; top:2.5px; width:2px; height:5px; background:var(--silk); border-radius:0 1px 1px 0 }
.batt .cell::before{ content:""; position:absolute; left:1.5px; top:1.5px; bottom:1.5px; width:65%; background:#2fe07a; border-radius:1px }
.facemain{ display:flex; align-items:center; gap:14px }
/* ---- amber 14-segment alphanumeric display ---- */
.led-win{ flex:1; min-width:0; background:#140a02; border:2px solid #050100; border-radius:8px; padding:6px 12px;
box-shadow:inset 0 0 16px rgba(0,0,0,.85), inset 0 0 8px rgba(255,150,30,.10), 0 1px 0 rgba(255,255,255,.25) }
#led{ display:block; width:100%; max-width:236px; height:58px; margin:0 auto }
/* ---- recessed clickable thumb-roller (tempo) ---- */
.rollwrap{ display:flex; flex-direction:column; align-items:center; gap:3px }
.roller{ width:92px; height:46px; border-radius:9px; position:relative; cursor:ew-resize; overflow:hidden; touch-action:none;
background:#0a0d12; border:1px solid #04060a; box-shadow:inset 0 0 0 1px rgba(255,255,255,.03), 0 2px 5px rgba(0,0,0,.5) }
.roller::before{ content:""; position:absolute; inset:4px 3px; border-radius:5px; /* ribbed cylinder, scrolls via --rib */
background:repeating-linear-gradient(90deg, rgba(255,255,255,.12) 0 1px, rgba(0,0,0,.5) 1px 5px); background-position:var(--rib,0px) 0 }
.roller::after{ content:""; position:absolute; inset:0; border-radius:9px; pointer-events:none; /* cylinder sheen: bright centre, dark edges */
background:linear-gradient(90deg, rgba(0,0,0,.72) 0%, rgba(255,255,255,.13) 49%, rgba(255,255,255,.13) 51%, rgba(0,0,0,.72) 100%) }
.roller.press{ filter:brightness(.9); box-shadow:inset 0 0 0 1px rgba(255,255,255,.03), 0 1px 2px rgba(0,0,0,.6) }
.roll-cap{ font-size:7px; color:var(--silk); letter-spacing:.16em; text-transform:uppercase; opacity:.8 }
/* speaker grille + status indicators along the bottom of the face */
.facebot{ display:flex; align-items:center; gap:12px }
.grille{ flex:1; height:9px; border-radius:5px; background:radial-gradient(circle, #000 1px, transparent 1.3px) 0 0/7px 7px; opacity:.45 }
.inds{ display:flex; gap:11px }
.ind{ display:flex; align-items:center; gap:4px; font-size:7.5px; color:var(--silk); letter-spacing:.1em; text-transform:uppercase; opacity:.85 }
.ind .d{ width:6px; height:6px; border-radius:50%; background:#3a2306; box-shadow:inset 0 0 2px #000; transition:background .08s, box-shadow .08s }
.ind.on .d{ background:#ff8a1e; box-shadow:0 0 6px #ff8a1e }
.ind.play.on .d{ background:#2fe07a; box-shadow:0 0 6px #2fe07a }
.hint{ max-width:560px; text-align:center; font-size:11px; color:var(--muted); line-height:1.5 }
/* embed mode: just the device */
[data-embed] .hint { display:none !important; }
/* stack the bar's end caps under the face on very narrow screens */
@media (max-width:430px){ .device{ flex-wrap:wrap } .endcap{ width:50%; flex-direction:row; gap:18px; justify-content:center } .face{ flex-basis:100%; order:-1 } }
</style>
</head>
<body>
<header class="site-head">
<div class="head-left">
<a class="brand" href="/" title="VARASYS — Simplifying Complexity">
<img class="brand-logo brand-dark" src="data:image/png;base64,@BUILD:logo-dark@" alt="VARASYS — Simplifying Complexity" />
<img class="brand-logo brand-light" src="data:image/png;base64,@BUILD:logo-light@" alt="VARASYS — Simplifying Complexity" />
</a>
<span class="page-name"><b>PMµ</b> · Micro (inline practice bar)</span>
</div>
<nav class="site-nav">
<a href="/editor.html">Editor</a>
<a href="/concepts.html">Concepts</a>
<a href="/info-micro.html">Info</a>
<a href="/embed.html">Embed</a>
<button id="themeBtn" class="tbtn" title="toggle light / dark theme"></button>
</nav>
</header>
<div class="device">
<!-- LEFT END: instrument / aux in -->
<div class="endcap left">
<div class="jk" title="1/4&quot; TRS input — plug your instrument (or an aux source) in; the click is mixed into it"><i></i><b>TRS&nbsp;In</b></div>
<div class="endlbl">Inst /<br>aux in</div>
</div>
<!-- TOP FACE: display + roller + speaker -->
<div class="face">
<div class="brandrow">
<div class="silk"><img class="brand-logo" src="data:image/png;base64,@BUILD:logo-dark@" alt="VARASYS" /><span class="model">PMµ Micro</span></div>
<div class="meta">
<div class="batt" title="2×AA, or run from USBC"><span class="cell"></span>2×AA</div>
<div class="pwr"><span class="dot"></span>PWR</div>
</div>
</div>
<div class="facemain">
<div class="led-win"><canvas id="led" width="236" height="58" aria-label="14-segment tempo / track display"></canvas></div>
<div class="rollwrap">
<div class="roller" id="enc" title="Roll = tempo · press = start/stop · hold + roll = switch track"></div>
<div class="roll-cap">Tempo</div>
</div>
</div>
<div class="facebot">
<div class="grille" title="monitor speaker"></div>
<div class="inds">
<div class="ind on" id="indBpm"><span class="d"></span>BPM</div>
<div class="ind" id="indTrk"><span class="d"></span>Trk</div>
<div class="ind play" id="indPlay"><span class="d"></span></div>
</div>
</div>
</div>
<!-- RIGHT END: power + output -->
<div class="endcap right">
<div class="jk usb" title="USBC — power (5 V) &amp; set-list transfer"><i></i><b>USBC</b></div>
<div class="jk" title="1/4&quot; TRS output — instrument + click, to your amp, headphones or the desk"><i></i><b>TRS&nbsp;Out</b></div>
</div>
</div>
<div class="hint">Roll = <b>tempo</b> · press = <b>start / stop</b> · hold &amp; roll = <b>switch track</b>.
Instrument in one end, amp/headphones out the other — the click is mixed into your signal in the analog domain.</div>
<script>
const APP_VERSION = "v0.0.1-dev";
const $ = (id) => document.getElementById(id);
/* ========================= ENGINE (shared; synth voices only) ================= */
const SAMPLES = {}; // synth-only
/*@BUILD:include:src/engine.js@*/
/*@BUILD:include:src/setlists.js@*/
const state = { bpm:120, volume:0.85, running:false };
let meters = [], muteWindows = [];
function setBpm(v){ state.bpm = Math.max(30, Math.min(300, Math.round(v))); }
function scheduler(){
const ahead = audioCtx.currentTime + SCHEDULE_AHEAD;
for(const m of meters){ while(m.nextTime < ahead){ scheduleMeterTick(m, m.nextTime); m.nextTime += laneStepDur(m, m.tick); m.tick++; } }
}
function buildMeters(lanes){
return (lanes||[]).map(c=>{ const p=parseGroups(c.groupsStr);
return {groupsStr:c.groupsStr,groups:p.groups,beatsPerBar:p.beatsPerBar,groupStarts:p.groupStarts,
stepsPerBeat:c.stepsPerBeat||1,sound:c.sound,beatsOn:(c.beatsOn||[]).slice(),poly:!!c.poly,swing:!!c.swing,enabled:c.enabled!==false,
tick:0,nextTime:0,vq:[],vqPtr:0,currentStep:-1,currentBar:0}; });
}
function startAudio(){
ensureAudio(); audioCtx.resume(); state.running=true;
const t0=audioCtx.currentTime+0.08;
for(const m of meters){ m.tick=0; m.nextTime=t0; m.vq=[]; m.vqPtr=0; }
muteWindows=[]; schedulerTimer=setInterval(scheduler,LOOKAHEAD_MS); scheduler(); render();
}
function stopAudio(){ state.running=false; clearInterval(schedulerTimer); schedulerTimer=null; render(); }
function toggle(){ state.running ? stopAudio() : startAudio(); }
/* ========================= TRACKS (built-in seed grooves, flattened) ========= */
let tracks = SEED_SETLISTS.flatMap(sl => sl.items.map(([n,p]) => ({ name:n, ...patchToSetup(p) })));
let trackIdx=0, previewIdx=0;
// preload from a share link / embed config string (#p=<patch> or #sl=<set-list code>)
function tracksFromHash(){
const m=(location.hash||"").match(/[#&](p|sl)=([^&]+)/); if(!m) return null;
let payload=m[2]; try{ payload=decodeURIComponent(payload); }catch(e){}
try{
if(m[1]==="sl"){ const sl=codeToSetlist(payload); return sl.items.length ? sl.items.map(it=>({...it})) : null; }
const s=patchToSetup(payload); return s.lanes.length ? [{name:"Patch",...s}] : null;
}catch(e){ return null; }
}
function loadTrack(i){
const n=tracks.length; if(!n) return; trackIdx=((i%n)+n)%n; previewIdx=trackIdx;
const t=tracks[trackIdx]; setBpm(t.bpm||120); meters=buildMeters(t.lanes); // ramps/bars ignored — a steady practice loop
const was=state.running; if(was){ clearInterval(schedulerTimer); schedulerTimer=null; state.running=false; }
if(was) startAudio(); else render();
}
/* ========================= 14-SEGMENT DISPLAY ================================ */
const led=$("led"), lc=led.getContext("2d"), NCH=4, LW=236, LH=58;
(function(){ const dpr=Math.max(1,Math.min(3,window.devicePixelRatio||1)); led.width=LW*dpr; led.height=LH*dpr; lc.scale(dpr,dpr); })();
const LED_ON="#ff8a1e", LED_OFF="#1d1004", LED_BG="#120802"; // OFF kept very dim so the lit digits read clearly
// 14-seg font (Adafruit bit order): 0=A 1=B 2=C 3=D 4=E 5=F 6=G1 7=G2 8=H 9=I 10=J 11=K 12=L 13=M
const SEG14={ " ":0x0000,"-":0x00C0,
"0":0x0C3F,"1":0x0006,"2":0x00DB,"3":0x008F,"4":0x00E6,"5":0x2069,"6":0x00FD,"7":0x0007,"8":0x00FF,"9":0x00EF,
"A":0x00F7,"B":0x128F,"C":0x0039,"D":0x120F,"E":0x00F9,"F":0x0071,"G":0x00BD,"H":0x00F6,"I":0x1209,"J":0x001E,
"K":0x2470,"L":0x0038,"M":0x0536,"N":0x2136,"O":0x003F,"P":0x00F3,"Q":0x203F,"R":0x20F3,"S":0x00ED,"T":0x1201,
"U":0x003E,"V":0x0C30,"W":0x2836,"X":0x2D00,"Y":0x1500,"Z":0x0C09 };
let displayMode="bpm";
function trackName(i){ const raw=(tracks[i]&&tracks[i].name)||("TR"+(i+1));
return (raw.replace(/[^A-Za-z0-9]/g,"").toUpperCase().slice(0,NCH)) || ("T"+(i+1)); }
function ledText(){ return (displayMode==="track" ? trackName(previewIdx) : String(state.bpm)).padStart(NCH," "); }
function drawChar(dx,dy,w,h,ch){
const m=SEG14[ch]!=null?SEG14[ch]:0, t=Math.max(2.5,w*0.13), g=Math.max(1.5,t*0.5),
cx=dx+w/2, midY=dy+h/2, vH=h/2-t-g;
const bar=(b,x,y,ww,hh)=>{ if((m>>b)&1){ lc.fillStyle=LED_ON; lc.shadowColor=LED_ON; lc.shadowBlur=6; } else { lc.fillStyle=LED_OFF; lc.shadowBlur=0; }
lc.fillRect(x,y,ww,hh); lc.shadowBlur=0; };
const diag=(b,x1,y1,x2,y2)=>{ lc.lineCap="round"; lc.lineWidth=t*0.82;
if((m>>b)&1){ lc.strokeStyle=LED_ON; lc.shadowColor=LED_ON; lc.shadowBlur=6; } else { lc.strokeStyle=LED_OFF; lc.shadowBlur=0; }
lc.beginPath(); lc.moveTo(x1,y1); lc.lineTo(x2,y2); lc.stroke(); lc.shadowBlur=0; };
bar(0, dx+t, dy, w-2*t, t); // A top
bar(5, dx, dy+t, t, vH); // F upper-left
bar(1, dx+w-t, dy+t, t, vH); // B upper-right
bar(9, cx-t/2, dy+t, t, vH); // I centre-upper
bar(6, dx+t, midY-t/2, w/2-t-g, t); // G1 mid-left
bar(7, cx+g, midY-t/2, w/2-t-g, t); // G2 mid-right
bar(4, dx, midY+g, t, vH); // E lower-left
bar(2, dx+w-t, midY+g, t, vH); // C lower-right
bar(12, cx-t/2, midY+g, t, vH); // L centre-lower
bar(3, dx+t, dy+h-t, w-2*t, t); // D bottom
diag(8, dx+t+1, dy+t+1, cx-t*0.6, midY-t*0.6); // H top-left
diag(10, dx+w-t-1, dy+t+1, cx+t*0.6, midY-t*0.6); // J top-right
diag(11, dx+t+1, dy+h-t-1, cx-t*0.6, midY+t*0.6); // K bottom-left
diag(13, dx+w-t-1, dy+h-t-1, cx+t*0.6, midY+t*0.6); // M bottom-right
}
function drawLED(){
lc.fillStyle=LED_BG; lc.fillRect(0,0,LW,LH);
const txt=ledText(), pad=10, gap=9, n=NCH, dw=(LW-2*pad-(n-1)*gap)/n, dh=LH-2*pad;
for(let i=0;i<n;i++) drawChar(pad+i*(dw+gap), pad, dw, dh, txt[i]||" ");
}
function render(){
drawLED();
$("indBpm").classList.toggle("on", displayMode==="bpm");
$("indTrk").classList.toggle("on", displayMode==="track");
$("indPlay").classList.toggle("on", state.running);
}
/* ========================= ROLLER (the only control) ========================= */
let rollPos=0;
function nudge(d){ setBpm(state.bpm+d); displayMode="bpm"; rollPos+=d*5; $("enc").style.setProperty("--rib", rollPos+"px"); render(); }
let revertT=null;
function previewTrack(d){ previewIdx=((previewIdx+d)%tracks.length+tracks.length)%tracks.length; displayMode="track"; render(); }
function commitTrack(){ loadTrack(previewIdx); displayMode="track"; render(); clearTimeout(revertT); revertT=setTimeout(()=>{ displayMode="bpm"; render(); }, 1100); }
/* roll = tempo · quick press = start/stop · hold (~350ms) then roll = switch track */
(function(){
const k=$("enc"); let down=false, moved=false, held=false, lastX=0, acc=0, holdT=null;
k.addEventListener("wheel",(e)=>{ e.preventDefault(); nudge(e.deltaY<0 ? (e.shiftKey?5:1) : (e.shiftKey?-5:-1)); }, {passive:false});
k.addEventListener("pointerdown",(e)=>{ down=true; moved=false; held=false; lastX=e.clientX; acc=0; previewIdx=trackIdx;
k.classList.add("press"); k.setPointerCapture(e.pointerId);
holdT=setTimeout(()=>{ if(down && !moved){ held=true; displayMode="track"; render(); } }, 350); });
k.addEventListener("pointermove",(e)=>{ if(!down) return; acc += e.clientX - lastX; lastX=e.clientX;
if(held){ while(Math.abs(acc)>=12){ const d=acc>0?1:-1; acc-=d*12; moved=true; previewTrack(d); } } // hold + roll → track
else { while(Math.abs(acc)>=6){ const d=acc>0?1:-1; acc-=d*6; moved=true; clearTimeout(holdT); nudge(d); } } }); // roll → tempo
k.addEventListener("pointerup",()=>{ if(!down) return; down=false; clearTimeout(holdT); k.classList.remove("press");
if(held){ if(moved) commitTrack(); else { displayMode="bpm"; render(); } }
else if(!moved){ toggle(); } }); // quick press → start/stop
k.addEventListener("pointercancel",()=>{ down=false; clearTimeout(holdT); k.classList.remove("press"); });
})();
/* theme toggle — cycles system → light → dark; shares the "metronome.theme" key */
const THEMES=["system","light","dark"];
function effectiveTheme(p){ return p==="system" ? (matchMedia("(prefers-color-scheme: light)").matches?"light":"dark") : p; }
function themePref(){ try{ const p=localStorage.getItem("metronome.theme"); return (p==="light"||p==="dark"||p==="system")?p:"system"; }catch(e){ return "system"; } }
function applyTheme(p){ try{ localStorage.setItem("metronome.theme",p); }catch(e){}
document.documentElement.dataset.theme = effectiveTheme(p);
$("themeBtn").textContent = p==="system" ? "◐" : p==="light" ? "☀" : "☾"; $("themeBtn").title="Theme: "+p+" (system → light → dark)"; }
$("themeBtn").onclick = ()=> applyTheme(THEMES[(THEMES.indexOf(themePref())+1)%THEMES.length]);
matchMedia("(prefers-color-scheme: light)").addEventListener("change", ()=>{ if(themePref()==="system") applyTheme("system"); });
applyTheme(themePref());
addEventListener("keydown",(e)=>{
const tag=e.target?e.target.tagName:""; if(tag==="INPUT"||tag==="TEXTAREA"||tag==="SELECT") return;
const k=e.key;
if(k===" "||e.code==="Space"){ e.preventDefault(); toggle(); }
else if(k==="ArrowUp"){ e.preventDefault(); nudge(e.shiftKey?10:1); }
else if(k==="ArrowDown"){ e.preventDefault(); nudge(e.shiftKey?-10:-1); }
else if(k==="ArrowRight"){ e.preventDefault(); previewIdx=trackIdx+1; commitTrack(); }
else if(k==="ArrowLeft"){ e.preventDefault(); previewIdx=trackIdx-1; commitTrack(); }
});
/* ========================= INIT ============================================== */
{ const ht=tracksFromHash(); if(ht) tracks=ht; } // a #p=/#sl= link (or embed config) overrides the built-ins
loadTrack(0);
render();
</script>
</body>
</html>