metronome/player.html
Me Here a86421eb5c Player: light/dark theme so the device stands out from the background
The device case was nearly the same tone as the room — the body's
radial-gradient even peaked *lighter* than the case — so the unit blended
into the background. Added a theme toggle mirroring the editor (◐/☀/☾,
cycles system → light → dark, shares the "metronome.theme" key, with a
pre-paint head script to avoid a flash) and reworked the palette around it:

- dark: a charcoal device sits a clear step lighter than a near-black room,
  with a rim highlight + drop shadow + faint cyan glow so it reads as an object;
- light: the dark device sits on a bright "desk" card (panel + fields go light).

Device internals (OLED, beat LEDs, buttons, knob, screws) keep fixed
dark-hardware colours in both themes via --dtxt/--dmuted, so only the
environment switches.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 19:16:13 -05:00

401 lines
23 KiB
HTML
Raw 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" />
<title>VARASYS PM1 — hardware player (mockup)</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,@BUILD:favicon@">
<!--
Hardware-device MOCKUP / simulator for the Pi Pico (RP2040) build of the
Stackable Metronome. The physical unit can't show the multi-lane editor or
manage set lists — it just *plays* a configuration expressed in the share
language. So this page is the device front panel: load a patch or set-list
(paste one, open a #p=/#sl= link, or pick a saved one) and it plays it,
driving the OLED + beat LEDs exactly as the firmware would.
Audio here is the synthesized voice set (the firmware uses the same scheduler;
on hardware the voices map to CC0 samples on the I2S DAC). One file, no deps.
-->
<script>
// Set theme before first paint (avoids a flash). Shares the editor's "metronome.theme".
(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{
/* environment (themed): room background + page/panel chrome */
--bg1:#12151c; --bg2:#05070a;
--txt:#c7d0db; --muted:#7f8b9a; --link:#6cb6ff;
--panel-bg:#171b22; --panel-bd:#2a313c; --field-bg:#0e1116; --field-bd:#222a36;
/* the device case — kept a clear step lighter than the room so it reads as an object */
--case:#232a36; --case2:#151a22; --device-bd:#39434f;
--device-shadow:0 0 0 1px rgba(120,150,190,.06), 0 30px 70px rgba(0,0,0,.62), 0 0 46px rgba(10,179,247,.05);
/* device internals — fixed dark-hardware colours in BOTH themes */
--dtxt:#c7d0db; --dmuted:#7f8b9a;
--cyan:#0AB3F7; --amber:#ffd166; --edge:#0b0d11; --bezel:#0a0c10;
--screen:#04140f; --phos:#34e0a0; --phos-dim:#1f6e54; --led-off:#1b2a23;
}
:root[data-theme="light"]{
/* light "desk": the dark device sits on a bright surface → strong contrast */
--bg1:#f5f8fc; --bg2:#dde4ec;
--txt:#1e2630; --muted:#5c6776; --link:#1769c4;
--panel-bg:#ffffff; --panel-bd:#d2dae4; --field-bg:#f1f4f8; --field-bd:#cdd6e0;
--case:#2b3340; --case2:#1b212b; --device-bd:#0f141b;
--device-shadow:0 0 0 1px rgba(0,0,0,.05), 0 26px 50px rgba(20,30,50,.30);
}
body{
margin:0; min-height:100vh; padding:28px 16px 48px;
background:radial-gradient(circle at 50% -8%, var(--bg1), var(--bg2));
color:var(--txt);
display:flex; flex-direction:column; align-items:center; gap:20px;
}
a{color:var(--link)}
.topbar{width:100%; max-width:560px; display:flex; align-items:center; justify-content:space-between; font-size:13px; color:var(--muted)}
.topbar b{color:var(--txt)}
.topbar-right{ display:flex; align-items:center; gap:12px }
.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 device ---- */
.device{
width:100%; max-width:560px; position:relative;
background:linear-gradient(180deg,var(--case),var(--case2));
border:1px solid var(--device-bd); border-radius:22px; padding:22px 22px 26px;
box-shadow:var(--device-shadow), inset 0 1px 0 rgba(255,255,255,.06), inset 0 -2px 8px rgba(0,0,0,.55);
}
.device::before, .device::after,
.device .screw{ content:""; position:absolute; width:9px; height:9px; border-radius:50%;
background:radial-gradient(circle at 35% 30%, #5a626e, #20252d 70%); box-shadow:inset 0 0 2px #000; }
.device::before{ top:11px; left:11px } .device::after{ top:11px; right:11px }
.screw.bl{ bottom:11px; left:11px } .screw.br{ bottom:11px; right:11px }
.brandrow{ display:flex; align-items:center; justify-content:space-between; margin:2px 6px 16px; }
.logo{ display:flex; align-items:baseline; gap:9px }
.logo .vk{ font-weight:800; letter-spacing:.22em; color:#fff; font-size:17px;
background:var(--cyan); padding:2px 8px; border-radius:4px; box-shadow:0 0 10px rgba(10,179,247,.5) }
.logo .model{ color:var(--dmuted); font-size:12px; letter-spacing:.04em }
.pwr{ display:flex; align-items:center; gap:7px; font-size:10px; color:var(--dmuted); text-transform:uppercase; letter-spacing:.12em }
.pwr .dot{ width:8px; height:8px; border-radius:50%; background:#2fe07a; box-shadow:0 0 8px #2fe07a }
/* ---- OLED ---- */
.screen{
background:linear-gradient(180deg,#06181244,var(--screen)); border:2px solid var(--bezel);
border-radius:10px; padding:14px 16px; margin:0 4px;
box-shadow:inset 0 0 24px rgba(0,0,0,.8), inset 0 0 6px rgba(52,224,160,.12), 0 1px 0 rgba(255,255,255,.04);
font-family:"Courier New",ui-monospace,monospace; color:var(--phos);
text-shadow:0 0 6px rgba(52,224,160,.55); position:relative; overflow:hidden;
}
.screen::after{ content:""; position:absolute; inset:0; pointer-events:none;
background:repeating-linear-gradient(0deg, rgba(0,0,0,.18) 0 1px, transparent 1px 3px); opacity:.5 }
.scr-top{ display:flex; justify-content:space-between; align-items:baseline; font-size:13px; color:var(--phos-dim) }
.scr-top .pos{ letter-spacing:.06em }
.scr-top .tempo{ color:var(--phos); font-size:16px }
.scr-top .tempo b{ font-size:22px; font-weight:700 }
.scr-name{ font-size:20px; margin:7px 0 8px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis }
.scr-bot{ display:flex; justify-content:space-between; align-items:baseline; font-size:13px; color:var(--phos-dim) }
.scr-bot .st{ color:var(--phos) }
.scr-bot .st.stopped{ color:var(--phos-dim) }
.scr-bot .bars{ color:var(--amber); text-shadow:0 0 6px rgba(255,209,102,.5) }
/* ---- beat LEDs ---- */
.leds{ display:flex; gap:8px; justify-content:center; flex-wrap:wrap; margin:16px 4px 6px; min-height:18px }
.led{ width:16px; height:16px; border-radius:50%; background:var(--led-off);
border:1px solid #000; box-shadow:inset 0 1px 2px rgba(0,0,0,.7); transition:background .04s, box-shadow .04s }
.led.group{ outline:1px solid #3a4754; outline-offset:2px }
.led.on{ background:var(--cyan); box-shadow:0 0 10px var(--cyan), 0 0 4px #fff inset }
.led.on.group{ background:var(--amber); box-shadow:0 0 12px var(--amber), 0 0 4px #fff inset }
/* ---- controls ---- */
.controls{ display:flex; align-items:center; justify-content:center; gap:12px; margin:14px 4px 4px; flex-wrap:wrap }
.btn{ background:linear-gradient(180deg,#2b323d,#1b212a); color:var(--dtxt); border:1px solid #39424f;
border-radius:11px; padding:12px 14px; font-size:15px; cursor:pointer; min-width:48px;
box-shadow:0 3px 0 #0c0f14, inset 0 1px 0 rgba(255,255,255,.06); user-select:none; transition:transform .04s, box-shadow .04s }
.btn:active{ transform:translateY(2px); box-shadow:0 1px 0 #0c0f14, inset 0 1px 0 rgba(255,255,255,.06) }
.btn small{ display:block; font-size:9px; color:var(--dmuted); letter-spacing:.08em; margin-top:2px }
.btn.play{ min-width:74px; font-size:20px; background:linear-gradient(180deg,#1f7a4d,#155f3b); border-color:#2e7d32; color:#eafff3 }
.btn.play.on{ background:linear-gradient(180deg,#b23b3b,#8f2d2d); border-color:#c0392b }
.knob{ width:52px; height:52px; border-radius:50%; background:radial-gradient(circle at 38% 32%,#3a424e,#171b22 72%);
border:1px solid #444c58; box-shadow:0 3px 8px rgba(0,0,0,.5), inset 0 1px 1px rgba(255,255,255,.08); position:relative; margin-left:4px }
.knob::after{ content:""; position:absolute; left:50%; top:6px; width:2px; height:13px; background:var(--cyan);
border-radius:2px; transform-origin:50% 20px; transform:rotate(var(--a,0deg)); box-shadow:0 0 5px var(--cyan) }
.knob-wrap{ display:flex; flex-direction:column; align-items:center; gap:4px; font-size:9px; color:var(--dmuted); letter-spacing:.1em }
/* ---- speaker grille ---- */
.grille{ height:14px; margin:18px 6px 2px; border-radius:6px;
background:radial-gradient(circle, #000 1.1px, transparent 1.4px) 0 0/9px 9px; opacity:.5 }
/* ---- load panel ---- */
.panel{ width:100%; max-width:560px; background:var(--panel-bg); border:1px solid var(--panel-bd); border-radius:14px; padding:16px }
.panel h2{ margin:0 0 4px; font-size:15px }
.panel p.sub{ margin:0 0 12px; font-size:12px; color:var(--muted); line-height:1.45 }
.panel label{ font-size:12px; color:var(--muted); display:block; margin:10px 0 5px }
textarea{ width:100%; background:var(--field-bg); color:var(--txt); border:1px solid var(--field-bd); border-radius:9px;
padding:10px; font-family:"Courier New",monospace; font-size:12px; resize:vertical; min-height:58px }
select, .ld{ border-radius:9px; padding:8px 10px; font-size:13px }
select{ background:var(--field-bg); color:var(--txt); border:1px solid var(--field-bd) }
.ld{ cursor:pointer; color:var(--dtxt); background:linear-gradient(180deg,#2b323d,#1b212a); border:1px solid #39424f }
.row{ display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-top:8px }
.status{ margin-top:10px; font-size:12px; min-height:1.2em; font-family:"Courier New",monospace }
.status.ok{ color:#5fd08a } .status.err{ color:#ff7a7a }
.hint{ font-size:11px; color:var(--muted) }
code{ background:var(--field-bg); border:1px solid var(--field-bd); border-radius:4px; padding:1px 5px; font-size:11px }
</style>
</head>
<body>
<div class="topbar">
<span><b>VARASYS PM1</b> · hardware player (mockup)</span>
<span class="topbar-right">
<button id="themeBtn" class="tbtn" title="toggle light / dark theme"></button>
<a href="/index.html">Open editor ↗</a>
</span>
</div>
<!-- ===================== THE DEVICE ===================== -->
<div class="device">
<span class="screw bl"></span><span class="screw br"></span>
<div class="brandrow">
<div class="logo"><span class="vk">VARASYS</span><span class="model">PM1 · Polymeter Player</span></div>
<div class="pwr"><span class="dot"></span>PWR</div>
</div>
<div class="screen">
<div class="scr-top"><span class="pos" id="sPos">/</span><span class="tempo">♩=<b id="sBpm">120</b></span></div>
<div class="scr-name" id="sName"></div>
<div class="scr-bot"><span class="st stopped" id="sState">⏸ STOP</span><span id="sBar">bar — · beat —</span><span class="bars" id="sBars"></span></div>
</div>
<div class="leds" id="leds"></div>
<div class="controls">
<button class="btn" id="bPrev" title="previous item"><small>PREV</small></button>
<button class="btn" id="bDown" title="tempo "><small>TEMPO</small></button>
<button class="btn play" id="bPlay" title="play / stop (Space)"><small>&nbsp;</small></button>
<button class="btn" id="bUp" title="tempo +">+<small>TEMPO</small></button>
<button class="btn" id="bNext" title="next item"><small>NEXT</small></button>
<button class="btn" id="bTap" title="tap tempo (T)">TAP<small>&nbsp;</small></button>
<div class="knob-wrap"><div class="knob" id="knob"></div>TEMPO</div>
</div>
<div class="grille"></div>
</div>
<!-- ===================== LOAD CONFIG ===================== -->
<div class="panel">
<h2>Load a configuration onto the device</h2>
<p class="sub">On the real unit you'd transfer this over USB / WiFi. Here, paste a <b>patch</b>
(e.g. <code>v1;t120;kick:4;snare:4=.X.X;hatClosed:4/2</code>), a <b>setlist code</b>, or a full
<code>#p=…</code>/<code>#sl=…</code> share link — it's validated before loading.</p>
<label for="cfg">Patch / setlist code / share link</label>
<textarea id="cfg" spellcheck="false" placeholder="v1;t120;kick:4;snare:4=.X.X;hatClosed:4/2/&#10;…or a #sl=… link / base64 set-list code"></textarea>
<div class="row">
<button class="ld" id="bLoad">Load onto device</button>
<span class="hint">or pick a built-in or saved set list:</span>
<select id="storedSel"><option value="">— choose a set list —</option></select>
</div>
<div class="status" id="status"></div>
</div>
<script>
const APP_VERSION = "v0.0.1-dev";
const $ = (id) => document.getElementById(id);
/* ========================= ENGINE (mirrors index.html; synth voices only) ===== */
const SAMPLES = {}; // synth-only device — no acoustic samples; the engine uses its synth voices
/*@BUILD:include:src/engine.js@*/
/*@BUILD:include:src/setlists.js@*/
const state={ bpm:120, volume:0.85, running:false };
let meters=[];
let ramp={on:false,startBpm:80,amount:5,everyBars:4}, trainer={on:false,playBars:2,muteBars:2};
let segBars=0, segBarCount=0, pendingAdvance=false;
let masterBeat=0, masterBeatTime=0, muteWindows=[];
function setBpm(v){ state.bpm=Math.max(30,Math.min(300,Math.round(v))); }
function advanceMaster(ahead){
const mbpb=masterBeatsPerBar();
while(masterBeatTime<ahead){
if(masterBeat%mbpb===0){
const barIndex=Math.floor(masterBeat/mbpb);
if(barIndex>0&&ramp.on&&(barIndex%ramp.everyBars===0)) setBpm(state.bpm+ramp.amount);
if(trainer.on){ const cyc=trainer.playBars+trainer.muteBars; if(cyc>0&&(barIndex%cyc)>=trainer.playBars) muteWindows.push({start:masterBeatTime,end:masterBeatTime+mbpb*(60/state.bpm)}); }
segBarCount=barIndex;
if(segBars>0&&barIndex>=segBars&&!pendingAdvance){ pendingAdvance=true; } // bar-count → auto-advance (player always continues)
}
masterBeat++; masterBeatTime+=60/state.bpm;
}
if(audioCtx) muteWindows=muteWindows.filter(w=>w.end>audioCtx.currentTime-1);
}
function scheduler(){
const ahead=audioCtx.currentTime+SCHEDULE_AHEAD;
advanceMaster(ahead);
for(const m of meters){ while(m.nextTime<ahead){ scheduleMeterTick(m,m.nextTime); m.nextTime+=laneStepDur(m,m.tick); m.tick++; } }
if(pendingAdvance){ pendingAdvance=false; setTimeout(()=>gotoItem(idx+1,true),0); } // loops at end (gotoItem wraps)
}
/* ========================= PLAYER ============================================= */
let setlist=null, idx=0;
// Built-in set lists = the editor's seed lists (shared via src/setlists.js).
const BUILTIN = SEED_SETLISTS.map((sl) => ({ title: sl.title, items: sl.items.map(([n, p]) => ({ name: n, ...patchToSetup(p) })) }));
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 loadSetup(s){
ramp=s.ramp?{...s.ramp}:{on:false,startBpm:80,amount:5,everyBars:4};
trainer=s.trainer?{...s.trainer}:{on:false,playBars:2,muteBars:2};
segBars=s.bars||0; segBarCount=0;
setBpm(s.bpm||120);
meters=buildMeters(s.lanes);
rebuildLeds();
}
function startAudio(){
ensureAudio(); audioCtx.resume(); state.running=true;
if(ramp.on) setBpm(ramp.startBpm);
const t0=audioCtx.currentTime+0.08;
for(const m of meters){ m.tick=0; m.nextTime=t0; m.vq=[]; m.vqPtr=0; m.currentStep=-1; m.currentBar=0; }
masterBeat=0; masterBeatTime=t0; muteWindows=[]; pendingAdvance=false;
schedulerTimer=setInterval(scheduler,LOOKAHEAD_MS); scheduler(); renderAll();
}
function stopAudio(){ state.running=false; clearInterval(schedulerTimer); schedulerTimer=null; pendingAdvance=false; for(const m of meters) m.currentStep=-1; renderAll(); }
function toggle(){ state.running?stopAudio():startAudio(); }
function gotoItem(i,keepPlaying){
if(!setlist||!setlist.items.length) return;
const n=setlist.items.length; idx=((i%n)+n)%n;
const wasRunning=state.running||keepPlaying;
if(state.running){ clearInterval(schedulerTimer); schedulerTimer=null; state.running=false; }
loadSetup(setlist.items[idx]);
if(wasRunning) startAudio(); else renderAll();
}
function loadSetlistObj(sl){ setlist=sl; idx=0; const wasRunning=state.running; if(wasRunning){clearInterval(schedulerTimer);schedulerTimer=null;state.running=false;} loadSetup(sl.items[0]); if(wasRunning) startAudio(); else renderAll(); }
let taps=[];
function tapTempo(){ const now=performance.now(); taps=taps.filter(t=>now-t<2000); taps.push(now);
if(taps.length>=2){ let s=0; for(let i=1;i<taps.length;i++) s+=taps[i]-taps[i-1]; setBpm(60000/(s/(taps.length-1))); ramp.on=false; renderAll(); } }
function nudge(d){ ramp.on=false; setBpm(state.bpm+d); renderAll(); }
/* ========================= RENDER ============================================ */
function rebuildLeds(){
const box=$("leds"); box.innerHTML="";
const m=meters[0]; const beats=m?m.beatsPerBar:0;
for(let i=0;i<beats;i++){ const d=document.createElement("div"); d.className="led"+(m.groupStarts.has(i)?" group":""); box.appendChild(d); }
}
function renderLeds(){
const m=meters[0]; if(!m) return;
const cur=state.running? Math.floor(m.currentStep/m.stepsPerBeat) : -1;
const els=$("leds").children;
for(let i=0;i<els.length;i++) els[i].classList.toggle("on", i===cur);
}
function fmtPos(){ return setlist? (idx+1)+"/"+setlist.items.length : "/"; }
function renderScreen(){
$("sPos").textContent="♪ "+fmtPos();
$("sBpm").textContent=state.bpm;
$("sName").textContent=setlist? (setlist.items[idx].name||"—") : "—";
const st=$("sState");
st.textContent=state.running?"▶ PLAY":"⏸ STOP"; st.classList.toggle("stopped",!state.running);
const m=meters[0];
if(state.running&&m){ const beat=Math.floor(m.currentStep/m.stepsPerBeat); $("sBar").textContent="bar "+(m.currentBar+1)+" · beat "+(beat>=0?beat+1:"—"); }
else $("sBar").textContent="bar — · beat —";
if(segBars>0){ const rem=Math.max(0,segBars-(m?m.currentBar:0)); $("sBars").textContent=(state.running?rem:segBars)+" bars"; }
else $("sBars").textContent="";
// knob angle ~ tempo (30..300 → -135..135deg)
const ang=-135+(Math.max(30,Math.min(300,state.bpm))-30)/270*270; $("knob").style.setProperty("--a",ang+"deg");
}
function renderAll(){ renderScreen(); renderLeds(); $("bPlay").textContent=state.running?"■":"▶"; $("bPlay").classList.toggle("on",state.running); }
function draw(){
if(audioCtx&&state.running){ const now=audioCtx.currentTime;
for(const m of meters){ while(m.vqPtr<m.vq.length&&m.vq[m.vqPtr].time<=now){ m.currentStep=m.vq[m.vqPtr].step; m.currentBar=m.vq[m.vqPtr].bar; m.vqPtr++; } } }
renderScreen(); renderLeds();
requestAnimationFrame(draw);
}
/* ========================= LOAD / VALIDATE =================================== */
function setStatus(msg,ok){ const s=$("status"); s.textContent=msg; s.className="status "+(ok?"ok":"err"); }
function loadConfig(text,quiet){
text=(text||"").trim(); if(!text){ if(!quiet) setStatus("Paste a patch or set-list code first.",false); return false; }
let payload=text, kind=null;
const m=text.match(/[#?&](p|sl)=([^&\s]+)/);
if(m){ kind=m[1]; payload=m[2]; }
try{ payload=decodeURIComponent(payload); }catch(e){}
try{
if(kind==="sl" || (kind!=="p" && !/[;:]/.test(payload))){
const sl=codeToSetlist(payload);
if(!sl.items.length) throw new Error("set list has no items");
loadSetlistObj(sl);
setStatus("✓ Loaded set list “"+sl.title+"” — "+sl.items.length+" item(s).",true); return true;
}
const setup=patchToSetup(payload);
if(!setup.lanes.length) throw new Error("no valid lanes (need e.g. kick:4)");
loadSetlistObj({title:"Pasted patch",items:[{name:"Patch",...setup}]});
setStatus("✓ Loaded patch — "+setup.lanes.length+" lane(s), "+setup.bpm+" BPM"+(setup.bars?", "+setup.bars+" bars":"")+(setup.ramp.on?", ramp":"")+".",true); return true;
}catch(e){ if(!quiet) setStatus("✗ Invalid configuration: "+e.message,false); return false; }
}
function loadStored(){
let lists=[]; try{ lists=JSON.parse(localStorage.getItem("metronome.setlists")||"[]"); }catch(e){}
const sel=$("storedSel"); sel.innerHTML='<option value="">— choose a set list —</option>';
const og1=document.createElement("optgroup"); og1.label="Built-in";
BUILTIN.forEach((sl,i)=>{ const o=document.createElement("option"); o.value="b"+i; o.textContent=sl.title+" ("+sl.items.length+")"; og1.appendChild(o); });
sel.appendChild(og1);
if(lists.length){ const og2=document.createElement("optgroup"); og2.label="Your saved set lists";
lists.forEach((sl,i)=>{ const o=document.createElement("option"); o.value="s"+i; o.textContent=(sl.title||("Set list "+(i+1)))+" ("+(sl.items?sl.items.length:0)+")"; og2.appendChild(o); });
sel.appendChild(og2); }
sel._lists=lists; sel._builtin=BUILTIN;
}
/* ========================= WIRING ============================================ */
$("bPlay").onclick=toggle;
$("bPrev").onclick=()=>gotoItem(idx-1,state.running);
$("bNext").onclick=()=>gotoItem(idx+1,state.running);
$("bUp").onclick=()=>nudge(+1); $("bDown").onclick=()=>nudge(-1);
$("bTap").onclick=tapTempo;
$("bLoad").onclick=()=>loadConfig($("cfg").value);
$("storedSel").onchange=(e)=>{ const v=e.target.value; if(!v) return;
const sl = v[0]==="b" ? e.target._builtin[+v.slice(1)] : e.target._lists[+v.slice(1)]; if(!sl) return;
loadSetlistObj({title:sl.title,items:(sl.items||[]).map(it=>({...it}))}); setStatus("✓ Loaded set list “"+(sl.title||"set list")+"”.",true); };
/* theme toggle — cycles system → light → dark; shares the editor's "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+" (click to cycle: 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 t=e.target, tag=t?t.tagName:""; if(tag==="TEXTAREA"||tag==="INPUT"||tag==="SELECT") return;
const k=e.key;
if(k===" "||e.code==="Space"){ e.preventDefault(); toggle(); }
else if(k==="ArrowRight"){ e.preventDefault(); gotoItem(idx+1,state.running); }
else if(k==="ArrowLeft"){ e.preventDefault(); gotoItem(idx-1,state.running); }
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==="t"||k==="T") tapTempo();
});
/* ========================= INIT ============================================== */
loadStored();
if(location.hash && /(p|sl)=/.test(location.hash)) loadConfig(location.hash, true);
if(!setlist){ setlist=BUILTIN[0]; idx=0; loadSetup(setlist.items[0]); }
renderAll();
requestAnimationFrame(draw);
</script>
</body>
</html>