Sync: the visual playhead now advances on a latency-compensated clock (currentTime − outputLatency||baseLatency) so the on-screen pulse lands when the click is HEARD, not when it's queued — previously the visual could lead the audio by the output buffer / Bluetooth latency (up to ~a subdivision). Applied to editor, player, teacher, and the new pages; also bound the visual queue (vq trim). No data races: single-threaded; only the rAF draw touches vqPtr/currentStep, and each vq entry carries the exact scheduled time of its sound. stage.html — foot-pedal stompbox: two heavy footswitches (Tap=tempo / hold=start- stop, Next=item / hold=prev), 1/4" expression-pedal input → tempo sweep, big floor-readable RGB beat light + angled TFT, analog instrument pass-through. showcase.html — pyramid display piece: an RGB-light pendulum easing to each beat plus per-lane segment rows showing subdivisions/accents/mutes (canvas). Both: dual USB-C (data+power and power-thru) to daisy-chain off one source. Wired into embed.js (stage, showcase variants), build.sh, deploy.sh, the concepts gallery + landing cards, info-stage.html (~$52) + info-showcase.html (~$39) with BOMs, and the README. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
324 lines
18 KiB
HTML
324 lines
18 KiB
HTML
<!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‑1 — Stage (foot‑pedal stompbox)</title>
|
||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,@BUILD:favicon@">
|
||
<script>
|
||
/* ?embed=1 → strip site chrome + 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>
|
||
<!--
|
||
PM-1 "Stage" — the foot-operated live stompbox (the hands-free sibling of the
|
||
desktop /teacher.html). Same RP2040 firmware/engine. Floor-driven controls:
|
||
• LEFT footswitch = TAP tempo (tap to set BPM) ; hold = start / stop
|
||
• RIGHT footswitch = NEXT set-list item ; hold = previous
|
||
• 1/4" expression-pedal input = sweep tempo with your foot
|
||
A big floor-readable RGB BEAT light + a small angled TFT (BPM, item, beats).
|
||
Analog click injection (Inst in -> summed -> balanced TRS out) like the Teacher.
|
||
Power: TWO USB-C ports — one data+power, one power-thru, so pedals daisy-chain
|
||
off a single charger / power bank. Shares src/engine.js.
|
||
-->
|
||
<script>
|
||
(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; --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:26px 14px 46px;
|
||
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) }
|
||
|
||
/* ---- the stompbox ---- */
|
||
.device{ width:100%; max-width:340px; position:relative; border-radius:16px; padding:0 0 20px;
|
||
background:
|
||
radial-gradient(rgba(255,255,255,.022) .5px, transparent .6px) 0 0/3px 3px,
|
||
linear-gradient(180deg, #2b2d33, #141518);
|
||
border:1px solid var(--device-bd);
|
||
box-shadow:0 26px 52px rgba(0,0,0,.62), inset 0 1px 0 rgba(255,255,255,.05), inset 0 -3px 10px rgba(0,0,0,.55) }
|
||
|
||
/* top edge — all the jacks (cables exit upward off the board) */
|
||
.edge{ display:flex; align-items:flex-start; justify-content:space-between; gap:4px;
|
||
padding:11px 12px 12px; border-radius:16px 16px 0 0; background:linear-gradient(180deg,#1c1e22,#0d0e11);
|
||
border-bottom:1px solid #04060a; box-shadow:inset 0 -6px 12px rgba(0,0,0,.5) }
|
||
.jk{ flex:1; display:flex; flex-direction:column; align-items:center; gap:4px }
|
||
.jk i{ width:17px; height:17px; border-radius:50%; background:radial-gradient(circle at 40% 34%, #333a44, #07090c 72%);
|
||
border:2px solid #5b6470; box-shadow:inset 0 0 4px #000 }
|
||
.jk.usb i{ width:19px; height:8px; border-radius:3px; border:2px solid #5b6470; background:#07090c }
|
||
.jk b{ font-size:6.5px; font-weight:700; color:var(--silk); letter-spacing:.03em; text-transform:uppercase; opacity:.9; text-align:center; line-height:1.2 }
|
||
|
||
.brandrow{ display:flex; align-items:center; justify-content:space-between; margin:13px 16px 10px }
|
||
.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 }
|
||
.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 }
|
||
|
||
/* small angled TFT */
|
||
.tft-wrap{ margin:0 16px; padding:7px; border-radius:9px; background:linear-gradient(180deg,#0b0d11,#05070a);
|
||
border:1px solid #04060a; box-shadow:inset 0 2px 8px rgba(0,0,0,.7);
|
||
transform:perspective(440px) rotateX(7deg) }
|
||
#tft{ display:block; width:100%; height:96px; border-radius:5px; background:#06080c }
|
||
|
||
/* big floor-readable RGB beat light (dome LED) */
|
||
.beat-wrap{ display:flex; flex-direction:column; align-items:center; gap:5px; margin:16px 0 4px }
|
||
.beatlight{ width:74px; height:74px; border-radius:50%; position:relative;
|
||
background:radial-gradient(circle at 42% 36%, #20242b, #0a0c10 72%);
|
||
border:3px solid #2a2f37; box-shadow:0 3px 8px rgba(0,0,0,.55), inset 0 2px 5px rgba(255,255,255,.06) }
|
||
.beatlight::after{ content:""; position:absolute; inset:11px; border-radius:50%;
|
||
background:var(--bc,#0c0f14); box-shadow:0 0 var(--bg-glow,0) var(--bc,#0c0f14); transition:none }
|
||
.beat-cap{ font-size:7.5px; color:var(--silk); letter-spacing:.16em; text-transform:uppercase; opacity:.8 }
|
||
|
||
/* expression-pedal stand-in: a rocker that sweeps tempo */
|
||
.exp{ margin:14px 18px 4px; display:flex; align-items:center; gap:10px }
|
||
.exp label{ font-size:7.5px; color:var(--silk); letter-spacing:.1em; text-transform:uppercase; opacity:.85; white-space:nowrap }
|
||
.exp input[type=range]{ flex:1; -webkit-appearance:none; appearance:none; height:8px; border-radius:5px; outline:none;
|
||
background:linear-gradient(90deg,#1b2733,#33424f); border:1px solid #04060a }
|
||
.exp input[type=range]::-webkit-slider-thumb{ -webkit-appearance:none; width:26px; height:18px; border-radius:4px; cursor:ew-resize;
|
||
background:linear-gradient(180deg,#444c57,#222730); border:1px solid #565f6c; box-shadow:0 2px 4px rgba(0,0,0,.5) }
|
||
.exp input[type=range]::-moz-range-thumb{ width:26px; height:18px; border-radius:4px; cursor:ew-resize;
|
||
background:linear-gradient(180deg,#444c57,#222730); border:1px solid #565f6c }
|
||
|
||
/* two heavy footswitches */
|
||
.switches{ display:flex; justify-content:space-around; gap:18px; margin:14px 14px 2px }
|
||
.fsw{ flex:1; display:flex; flex-direction:column; align-items:center; gap:8px }
|
||
.stomp{ width:78px; height:78px; border-radius:50%; cursor:pointer; position:relative; border:0; padding:0; touch-action:none;
|
||
background:radial-gradient(circle at 38% 30%, #eef2f6, #aab2bc 40%, #6c7480 70%, #3b424c 100%);
|
||
box-shadow:0 6px 10px rgba(0,0,0,.55), inset 0 -3px 6px rgba(0,0,0,.4), inset 0 3px 5px rgba(255,255,255,.5) }
|
||
.stomp::after{ content:""; position:absolute; inset:18px; border-radius:50%;
|
||
background:radial-gradient(circle at 40% 34%, #d7dde3, #8b939e 70%, #5a626c 100%);
|
||
box-shadow:inset 0 2px 4px rgba(255,255,255,.5), inset 0 -3px 6px rgba(0,0,0,.4) }
|
||
.stomp.down{ transform:translateY(3px); box-shadow:0 2px 4px rgba(0,0,0,.55), inset 0 -2px 4px rgba(0,0,0,.4), inset 0 2px 4px rgba(255,255,255,.4) }
|
||
.fsw b{ font-size:9px; font-weight:800; color:var(--silk); letter-spacing:.12em; text-transform:uppercase }
|
||
.fsw small{ font-size:7px; color:var(--muted); letter-spacing:.04em; text-align:center; line-height:1.3 }
|
||
|
||
.hint{ max-width:340px; text-align:center; font-size:11px; color:var(--muted); line-height:1.5 }
|
||
[data-embed] .hint{ display:none !important }
|
||
</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‑1</b> · Stage (foot‑pedal stompbox)</span>
|
||
</div>
|
||
<nav class="site-nav">
|
||
<a href="/editor.html">Editor</a>
|
||
<a href="/concepts.html">Concepts</a>
|
||
<a href="/info-stage.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">
|
||
<!-- top edge: all jacks, including dual USB-C daisy-chain power -->
|
||
<div class="edge">
|
||
<div class="jk" title="External trigger / aux footswitch in"><i></i><b>Trig</b></div>
|
||
<div class="jk" title="Instrument in — 1/4" pass-through; the click is mixed into your signal"><i></i><b>Inst In</b></div>
|
||
<div class="jk" title="Main out — 1/4" balanced TRS (instrument + click)"><i></i><b>Out TRS</b></div>
|
||
<div class="jk" title="Expression-pedal input — sweep tempo with your foot"><i></i><b>Exp</b></div>
|
||
<div class="jk usb" title="USB-C — power + data (config / firmware)"><i></i><b>USB‑C</b></div>
|
||
<div class="jk usb" title="USB-C — power thru (daisy-chain the next pedal)"><i></i><b>USB‑C thru</b></div>
|
||
</div>
|
||
|
||
<div class="brandrow">
|
||
<div class="silk"><img class="brand-logo" src="data:image/png;base64,@BUILD:logo-dark@" alt="VARASYS" /><span class="model">PM‑1 Stage</span></div>
|
||
<div class="pwr"><span class="dot"></span>USB‑C PWR</div>
|
||
</div>
|
||
|
||
<div class="tft-wrap"><canvas id="tft" width="300" height="96" aria-label="tempo / item display"></canvas></div>
|
||
|
||
<div class="beat-wrap">
|
||
<div class="beatlight" id="beat"></div>
|
||
<div class="beat-cap">Beat</div>
|
||
</div>
|
||
|
||
<div class="exp">
|
||
<label for="expPedal">Exp pedal<br>(tempo)</label>
|
||
<input type="range" id="expPedal" min="40" max="240" value="120" title="External expression pedal → tempo sweep">
|
||
</div>
|
||
|
||
<div class="switches">
|
||
<div class="fsw">
|
||
<button class="stomp" id="swTap" title="Tap to set tempo · hold to start/stop"></button>
|
||
<b>Tap</b><small>tap = tempo<br>hold = start/stop</small>
|
||
</div>
|
||
<div class="fsw">
|
||
<button class="stomp" id="swNext" title="Next item · hold for previous"></button>
|
||
<b>Next</b><small>tap = next<br>hold = previous</small>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="hint">Stomp <b>Tap</b> to set tempo (hold = start/stop) · <b>Next</b> to change item (hold = previous) ·
|
||
an expression pedal sweeps tempo. Instrument passes through with the click mixed in (analog).</div>
|
||
|
||
<script>
|
||
const APP_VERSION = "v0.0.1-dev";
|
||
const $ = (id) => document.getElementById(id);
|
||
|
||
/* ========================= ENGINE (shared; synth voices only) ================= */
|
||
const SAMPLES = {};
|
||
/*@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; m.currentStep=-1; }
|
||
beatCount=-1; lastBeatTime=t0; muteWindows=[];
|
||
schedulerTimer=setInterval(scheduler,LOOKAHEAD_MS); scheduler();
|
||
}
|
||
function stopAudio(){ state.running=false; clearInterval(schedulerTimer); schedulerTimer=null; }
|
||
function toggle(){ state.running ? stopAudio() : startAudio(); }
|
||
|
||
/* ========================= TRACKS (seed grooves, flattened) =================== */
|
||
let tracks = SEED_SETLISTS.flatMap(sl => sl.items.map(([n,p]) => ({ name:n, ...patchToSetup(p) })));
|
||
let trackIdx = 0;
|
||
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;
|
||
const t=tracks[trackIdx]; setBpm(t.bpm||120); $("expPedal").value=state.bpm; meters=buildMeters(t.lanes);
|
||
const was=state.running; if(was){ clearInterval(schedulerTimer); schedulerTimer=null; state.running=false; }
|
||
if(was) startAudio();
|
||
flashName=performance.now();
|
||
}
|
||
|
||
/* ========================= DISPLAY (TFT) + BEAT LIGHT ======================== */
|
||
const tft=$("tft"), tc=tft.getContext("2d"), TW=300, TH=96;
|
||
(function(){ const dpr=Math.max(1,Math.min(3,window.devicePixelRatio||1)); tft.width=TW*dpr; tft.height=TH*dpr; tc.scale(dpr,dpr); })();
|
||
let beatCount=-1, lastBeatTime=0, flash=0, flashAccent=false, flashName=0;
|
||
|
||
function masterLane(){ return meters[0]; }
|
||
function drawTFT(){
|
||
tc.fillStyle="#06080c"; tc.fillRect(0,0,TW,TH);
|
||
// tempo
|
||
tc.fillStyle="#eaf6ff"; tc.font="700 40px 'Segoe UI',Roboto,Arial,sans-serif"; tc.textBaseline="alphabetic";
|
||
tc.fillText(String(state.bpm), 12, 50);
|
||
tc.fillStyle="#5b86a3"; tc.font="600 11px 'Segoe UI',Roboto,Arial,sans-serif"; tc.fillText("BPM", 14, 64);
|
||
// running state
|
||
tc.fillStyle=state.running?"#2fe07a":"#7f8b9a"; tc.font="700 11px 'Segoe UI',Roboto,Arial,sans-serif";
|
||
tc.textAlign="right"; tc.fillText(state.running?"▶ RUN":"■ STOP", TW-12, 22); tc.textAlign="left";
|
||
// item name
|
||
const nm=(tracks[trackIdx]&&tracks[trackIdx].name)||"—";
|
||
tc.fillStyle="#c7d0db"; tc.font="600 13px 'Segoe UI',Roboto,Arial,sans-serif";
|
||
tc.fillText(nm.length>22?nm.slice(0,21)+"…":nm, 12, 84);
|
||
// beat dots (master lane)
|
||
const m=masterLane();
|
||
if(m){ const bpb=m.beatsPerBar, r=4, gap=13, x0=TW-12-(bpb-1)*gap, y=46;
|
||
const curBeat = m.currentStep>=0 ? Math.floor(m.currentStep/m.stepsPerBeat) : -1;
|
||
for(let i=0;i<bpb;i++){ const on = state.running && i===curBeat;
|
||
tc.beginPath(); tc.arc(x0+i*gap, y, r, 0, 7);
|
||
tc.fillStyle = on ? (m.groupStarts.has(i)?"#ff9b2e":"#33d0ff") : "#243240"; tc.fill(); } }
|
||
}
|
||
function setBeatLight(){
|
||
const el=$("beat"), c=flashAccent?"#ff9b2e":"#33d0ff";
|
||
const lit = Math.max(0, flash);
|
||
el.style.setProperty("--bc", lit>0.02 ? c : "#0c0f14");
|
||
el.style.setProperty("--bg-glow", (10+lit*34).toFixed(0)+"px");
|
||
el.style.filter = "brightness("+(1+lit*1.3)+")";
|
||
}
|
||
// Visuals follow the SAME clock the audio is scheduled on, but compensated for
|
||
// output latency so the on-screen pulse lands when the click is *heard* (not when
|
||
// it's queued). Without this the visual leads the sound by the output buffer /
|
||
// Bluetooth latency — up to a full subdivision on high-latency outputs.
|
||
function audioLatency(){ return audioCtx ? (audioCtx.outputLatency || audioCtx.baseLatency || 0) : 0; }
|
||
function draw(){
|
||
const now = audioCtx ? audioCtx.currentTime - audioLatency() : 0;
|
||
if(audioCtx && state.running){
|
||
for(const m of meters){ while(m.vqPtr<m.vq.length && m.vq[m.vqPtr].time<=now){
|
||
const e=m.vq[m.vqPtr]; m.currentStep=e.step; m.currentBar=e.bar;
|
||
if(m===meters[0] && e.step % m.stepsPerBeat === 0){ // a beat on the master lane
|
||
beatCount++; lastBeatTime=e.time;
|
||
const lvl=m.beatsOn[e.step]|0; flashAccent = lvl>=2 || m.groupStarts.has(e.step/m.stepsPerBeat);
|
||
if(lvl!==0) flash=1; // don't flash on a muted beat
|
||
}
|
||
m.vqPtr++;
|
||
}
|
||
if(m.vqPtr>512){ m.vq=m.vq.slice(m.vqPtr); m.vqPtr=0; } // bound the visual queue
|
||
}
|
||
}
|
||
flash = Math.max(0, flash - 0.085); // decay
|
||
drawTFT(); setBeatLight();
|
||
requestAnimationFrame(draw);
|
||
}
|
||
|
||
/* ========================= FOOTSWITCHES + EXP PEDAL ========================== */
|
||
function holdSwitch(el, onTap, onHold){
|
||
let t=null, held=false;
|
||
el.addEventListener("pointerdown",(e)=>{ e.preventDefault(); try{el.setPointerCapture(e.pointerId);}catch(_){}
|
||
held=false; el.classList.add("down"); t=setTimeout(()=>{ held=true; onHold&&onHold(); }, 480); });
|
||
const end=(fire)=>{ if(t){clearTimeout(t); t=null;} el.classList.remove("down"); if(fire && !held) onTap&&onTap(); held=false; };
|
||
el.addEventListener("pointerup",()=>end(true));
|
||
el.addEventListener("pointercancel",()=>end(false));
|
||
}
|
||
let taps=[];
|
||
function tapTempo(){ const now=performance.now(); taps.push(now); taps=taps.filter(x=>now-x<2400);
|
||
if(taps.length>=2){ let s=0; for(let i=1;i<taps.length;i++) s+=taps[i]-taps[i-1];
|
||
const bpm=Math.round(60000/(s/(taps.length-1))); if(bpm>=30&&bpm<=300){ setBpm(bpm); $("expPedal").value=state.bpm; } } }
|
||
holdSwitch($("swTap"), ()=>tapTempo(), ()=>toggle());
|
||
holdSwitch($("swNext"), ()=>loadTrack(trackIdx+1), ()=>loadTrack(trackIdx-1));
|
||
$("expPedal").addEventListener("input", (e)=>{ setBpm(+e.target.value); });
|
||
|
||
/* theme toggle (shared "metronome.theme") */
|
||
const THEMES=["system","light","dark"];
|
||
function effTheme(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 = effTheme(p);
|
||
$("themeBtn").textContent = p==="system" ? "◐" : p==="light" ? "☀" : "☾"; $("themeBtn").title="Theme: "+p; }
|
||
$("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;
|
||
if(e.key===" "){ e.preventDefault(); toggle(); }
|
||
else if(e.key==="t"||e.key==="T"){ tapTempo(); }
|
||
else if(e.key==="ArrowRight"){ loadTrack(trackIdx+1); }
|
||
else if(e.key==="ArrowLeft"){ loadTrack(trackIdx-1); }
|
||
});
|
||
|
||
/* ========================= INIT ============================================== */
|
||
{ const ht=tracksFromHash(); if(ht) tracks=ht; }
|
||
loadTrack(0);
|
||
requestAnimationFrame(draw);
|
||
</script>
|
||
</body>
|
||
</html>
|