metronome/mobile.html
Me Here 8b795d4107 pm-mobile: editable lane pads, guided help tour, persisted state
- Editable lanes (no notation/konnakol — just pads): each lane is a row of pads
  that blink on the beat; tap a pad to cycle rest → beat → accent → ghost. A
  lane's label opens a sheet to set sound, grouping (e.g. 2+2+3), subdivision,
  swing, mute and polymeter; plus "+ Add lane" / delete. Edits are live and feed
  straight into the scheduler. (Replaces the read-only lane chips; the global
  feature chips — bars/end/ramp/gaps — stay.)
- Help: a "?" runs a 7-step guided coachmark tour (spotlight + tooltip), shown
  once on first run and re-runnable anytime. Removed the instruction hint under
  the BPM (the tour covers it). Tour also frames tracks as named practice items.
- Persist + restore: the working state (set list / track / tempo / volume / lane
  edits) is saved to metronome.mobile.state and restored on reload.
- Dropped the separate beat-dot row — the pulse flash + per-lane pad playhead
  cover it, freeing room for the editable lanes.

Engine untouched; conformance suite unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 09:44:31 -05:00

620 lines
42 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, viewport-fit=cover, user-scalable=no" />
<title>VARASYS PolyMeter — Mobile</title>
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="theme-color" content="#eef3f9" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#05070a" media="(prefers-color-scheme: dark)" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="PolyMeter" />
<link rel="apple-touch-icon" href="/icon-180.png" />
<link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png" />
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,@BUILD:favicon@" />
<script>
window.EMBED = /[?&]embed=1/.test(location.search);
if (window.EMBED) document.documentElement.dataset.embed = "1";
</script>
<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:#e7edf5; --muted:#8b96a5; --link:#6cb6ff;
--panel-bg:#161b22; --panel-bd:#2a313c; --field-bg:#0e1116; --field-bd:#222a36;
--cyan:#0AB3F7; --amber:#ffd166;
--led-off:#1b2330; --ring:#2a3340; --glow:rgba(10,179,247,.55); --aglow:rgba(255,209,102,.55);
--btn1:#2b323d; --btn2:#1b212a; --btn-bd:#39424f; --chip-bg:#1b2230; --chip-bd:#2c3545;
}
:root[data-theme="light"]{
--bg1:#eef3f9; --bg2:#cfd9e6;
--txt:#10202f; --muted:#5c6776; --link:#1769c4;
--panel-bg:#ffffff; --panel-bd:#d2dae4; --field-bg:#f1f4f8; --field-bd:#cdd6e0;
--led-off:#c4cedb; --ring:#c9d4e1; --glow:rgba(10,179,247,.40); --aglow:rgba(230,160,30,.45);
--btn1:#ffffff; --btn2:#e7edf4; --btn-bd:#c8d2de; --chip-bg:#eef2f7; --chip-bd:#d3dbe5;
}
html,body{ height:100%; }
body{ margin:0; overflow:hidden; color:var(--txt);
background:radial-gradient(circle at 50% -12%, var(--bg1), var(--bg2));
font-family:"Segoe UI", Roboto, Helvetica, Arial, sans-serif;
-webkit-user-select:none; user-select:none; -webkit-tap-highlight-color:transparent;
touch-action:manipulation; overscroll-behavior:none; }
#app{ position:fixed; inset:0; overflow:hidden; display:flex; flex-direction:column;
padding:max(8px,env(safe-area-inset-top)) max(12px,env(safe-area-inset-right))
max(8px,env(safe-area-inset-bottom)) max(12px,env(safe-area-inset-left)); }
/* ---- top ---- */
#top{ flex:0 0 auto; display:flex; flex-direction:column; gap:8px; }
.sels{ display:flex; gap:8px; }
.sel{ flex:1 1 0; min-width:0; display:flex; flex-direction:column; gap:3px; }
.sel > span{ font-size:10px; letter-spacing:.14em; text-transform:uppercase; color:var(--muted); padding-left:3px; }
.sel select{ width:100%; background:var(--field-bg); color:var(--txt); border:1px solid var(--field-bd); border-radius:10px; padding:10px 8px; font-size:15px; }
.trow{ display:flex; align-items:center; gap:10px; }
.vol{ flex:1 1 auto; display:flex; align-items:center; gap:9px; color:var(--muted); font-size:15px; min-width:0; }
.vol input{ flex:1 1 auto; min-width:0; accent-color:var(--cyan); }
.icon{ flex:0 0 auto; width:42px; height:42px; border-radius:50%; display:flex; align-items:center; justify-content:center;
font-size:18px; line-height:1; cursor:pointer; color:var(--txt); background:rgba(127,139,154,.14); border:1px solid var(--panel-bd); }
.icon:active{ background:rgba(127,139,154,.30); }
/* ---- middle: stage (pulse) + right column (lanes/features + transport) ---- */
#mid{ flex:1 1 auto; min-height:0; display:flex; flex-direction:column; gap:6px; }
#stage{ flex:1 1 auto; min-height:0; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:clamp(8px,2vmin,22px); }
#pulse{ position:relative; width:clamp(150px,36vmin,340px); height:clamp(150px,36vmin,340px); border-radius:50%;
display:flex; flex-direction:column; align-items:center; justify-content:center; text-align:center;
border:2px solid var(--ring); background:radial-gradient(circle at 50% 40%, rgba(127,139,154,.07), transparent 70%);
transition:transform .12s ease-out, box-shadow .12s ease-out, border-color .12s ease-out; touch-action:none; cursor:pointer; }
#pulse.hit{ transform:scale(1.045); border-color:var(--cyan); box-shadow:0 0 60px var(--glow); }
#pulse.hit.acc{ border-color:var(--amber); box-shadow:0 0 72px var(--aglow); }
#bpm{ font-size:clamp(46px,15vmin,140px); font-weight:800; line-height:.85; font-variant-numeric:tabular-nums; letter-spacing:-.01em; }
#bpmlab{ font-size:clamp(10px,2vmin,16px); letter-spacing:.3em; color:var(--muted); margin-top:.6em; }
#bpmIn{ display:none; width:64%; text-align:center; font:inherit; font-size:clamp(42px,13vmin,120px); font-weight:800;
background:transparent; color:var(--txt); border:none; border-bottom:2px solid var(--cyan); outline:none; font-variant-numeric:tabular-nums; -moz-appearance:textfield; }
#bpmIn::-webkit-outer-spin-button, #bpmIn::-webkit-inner-spin-button{ -webkit-appearance:none; margin:0; }
#meterline{ font-size:clamp(12px,2.1vmin,16px); color:var(--muted); text-align:center; min-height:1.2em; letter-spacing:.02em; }
/* ---- editable lanes + feature chips ---- */
#detail{ flex:0 0 auto; max-height:30vh; overflow-y:auto; display:flex; flex-direction:column; gap:6px; padding:2px 0; }
#lanes{ display:flex; flex-direction:column; gap:6px; }
.lane{ display:flex; align-items:center; gap:8px; }
.lane.off{ opacity:.5; }
.lmeta{ flex:0 0 auto; width:30%; max-width:130px; min-width:64px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:left;
background:var(--chip-bg); border:1px solid var(--chip-bd); color:var(--txt); border-radius:8px; padding:6px 8px;
font-size:clamp(10px,1.8vmin,13px); font-family:"Courier New",monospace; cursor:pointer; }
.lmeta .lg{ color:var(--muted); }
.pads{ flex:1 1 auto; display:flex; gap:3px; overflow-x:auto; padding-bottom:2px; min-width:0; }
.pad{ flex:1 0 14px; min-width:14px; height:clamp(20px,3.6vmin,28px); border-radius:5px; border:1px solid var(--chip-bd); background:var(--led-off); cursor:pointer; padding:0; }
.pad.gs{ border-color:var(--amber); }
.pad.on{ background:var(--cyan); }
.pad.acc{ background:var(--amber); }
.pad.ghost{ background:var(--cyan); opacity:.42; }
.pad.cur{ outline:2px solid var(--txt); outline-offset:-1px; box-shadow:0 0 8px var(--glow); }
.addlane{ align-self:flex-start; background:transparent; border:1px dashed var(--chip-bd); color:var(--muted); border-radius:8px; padding:5px 11px; font-size:12px; cursor:pointer; }
.chips{ display:flex; flex-wrap:wrap; gap:6px; justify-content:center; }
.chip.feat{ font-size:clamp(10px,1.8vmin,13px); color:var(--muted); background:var(--chip-bg); border:1px solid var(--chip-bd); border-radius:7px; padding:3px 8px; white-space:nowrap; }
.chip.feat.r{ border-color:var(--cyan); color:var(--cyan); } .chip.feat.g{ border-color:var(--amber); color:var(--amber); }
/* ---- transport ---- */
#transport{ flex:0 0 auto; display:flex; justify-content:center; padding-top:2px; }
.tgrid{ display:grid; width:100%; max-width:560px; gap:clamp(6px,1.6vmin,13px);
grid-template-columns:1fr 1.5fr 1.5fr 1fr; grid-template-areas:"dn10 prev next up10" "dn play prac up"; }
.tbtn{ background:linear-gradient(180deg,var(--btn1),var(--btn2)); color:var(--txt); border:1px solid var(--btn-bd);
border-radius:14px; height:clamp(46px,11vmin,72px); font-size:clamp(18px,4.4vmin,28px); cursor:pointer;
box-shadow:0 3px 0 rgba(0,0,0,.28), inset 0 1px 0 rgba(255,255,255,.06); display:flex; flex-direction:column; align-items:center; justify-content:center; gap:2px; }
.tbtn small{ font-size:clamp(8px,1.5vmin,11px); letter-spacing:.1em; color:inherit; opacity:.85; }
.tbtn:active{ transform:translateY(2px); box-shadow:0 1px 0 rgba(0,0,0,.28), inset 0 1px 0 rgba(255,255,255,.06); }
#bDn10{grid-area:dn10} #bPrev{grid-area:prev} #bNext{grid-area:next} #bUp10{grid-area:up10}
#bDown{grid-area:dn} #bPlay{grid-area:play} #bPrac{grid-area:prac} #bUp{grid-area:up}
.tbtn.play{ background:linear-gradient(180deg,#1f7a4d,#155f3b); border-color:#2e7d32; color:#eafff3; }
.tbtn.play.on{ background:linear-gradient(180deg,#b23b3b,#8f2d2d); border-color:#c0392b; color:#fff; }
.tbtn.prac{ background:linear-gradient(180deg,#1c87b8,#136488); border-color:#1f9bd0; color:#eaf8ff; }
.tbtn.prac.on{ background:linear-gradient(180deg,#c8922a,#9c6f12); border-color:#e0a93a; color:#fff; }
@media (orientation:landscape) and (max-height:600px){
#mid{ flex-direction:row; align-items:stretch; gap:3vw; }
#stage{ flex:1 1 46%; gap:clamp(6px,1.6vmin,14px); }
#rightcol{ flex:1 1 54%; display:flex; flex-direction:column; justify-content:center; gap:6px; min-width:0; }
#pulse{ width:clamp(130px,34vmin,260px); height:clamp(130px,34vmin,260px); }
#bpmlab{ display:none; }
#detail{ max-height:42vh; }
.tbtn{ height:clamp(40px,12vmin,60px); }
}
#rightcol{ display:contents; }
/* ---- session bar ---- */
#sessbar{ flex:0 0 auto; display:flex; align-items:center; gap:8px; width:100%; margin-top:6px; padding:9px 12px; border-radius:11px;
cursor:pointer; text-decoration:none; background:rgba(127,139,154,.10); border:1px solid var(--panel-bd); color:var(--muted); font-size:13px; }
#sessbar.rec{ background:rgba(192,57,43,.12); border-color:#c0392b; color:var(--txt); cursor:default; }
#sessbar .dotrec{ width:9px; height:9px; border-radius:50%; background:#e0493a; box-shadow:0 0 8px #e0493a; flex:0 0 auto; display:none; }
#sessbar.rec .dotrec{ display:block; } #sessText{ flex:1 1 auto; }
/* ---- bottom sheet (lane editor) ---- */
#scrim{ position:fixed; inset:0; background:rgba(0,0,0,.55); opacity:0; pointer-events:none; transition:opacity .2s; z-index:40; }
#scrim.open{ opacity:1; pointer-events:auto; }
#laneSheet{ position:fixed; left:0; right:0; bottom:0; z-index:50; max-height:84vh; overflow-y:auto;
background:var(--panel-bg); border-top:1px solid var(--panel-bd); border-radius:18px 18px 0 0; transform:translateY(110%);
transition:transform .26s cubic-bezier(.2,.8,.2,1);
padding:12px max(16px,env(safe-area-inset-right)) max(18px,env(safe-area-inset-bottom)) max(16px,env(safe-area-inset-left)); }
#laneSheet.open{ transform:none; }
#laneSheet .grab{ width:42px; height:5px; border-radius:3px; background:var(--panel-bd); margin:0 auto 12px; }
#laneSheet h2{ margin:0 0 10px; font-size:16px; }
#laneSheet label{ display:block; font-size:12px; color:var(--muted); margin:10px 0 5px; }
#laneSheet select, #laneSheet input[type=text]{ width:100%; background:var(--field-bg); color:var(--txt); border:1px solid var(--field-bd); border-radius:10px; padding:11px; font-size:15px; }
.lrow{ display:flex; gap:14px; margin-top:10px; flex-wrap:wrap; }
.lrow .half{ flex:1 1 120px; margin:0; }
.chk{ display:flex; align-items:center; gap:8px; font-size:14px; color:var(--txt); margin-top:18px; }
.chk input{ width:20px; height:20px; accent-color:var(--cyan); }
.lfoot{ display:flex; justify-content:space-between; margin-top:18px; }
.lbtn{ cursor:pointer; color:var(--txt); background:linear-gradient(180deg,var(--btn1),var(--btn2)); border:1px solid var(--btn-bd); border-radius:10px; padding:10px 18px; font-size:14px; }
.lbtn.danger{ background:transparent; color:#ff7a7a; border-color:#ff7a7a; }
/* ---- help tour (coachmarks) ---- */
#tour{ position:fixed; inset:0; z-index:200; display:none; }
#tour.open{ display:block; }
#tourHole{ position:absolute; border-radius:12px; box-shadow:0 0 0 9999px rgba(0,0,0,.66); border:2px solid var(--cyan); transition:all .2s ease; pointer-events:none; }
#tourBox{ position:absolute; background:var(--panel-bg); border:1px solid var(--panel-bd); border-radius:12px; padding:14px; box-shadow:0 14px 44px rgba(0,0,0,.5); }
#tourBox h3{ margin:0 0 6px; font-size:15px; }
#tourBox p{ margin:0 0 12px; font-size:13px; color:var(--muted); line-height:1.45; }
#tourBox .trow{ display:flex; justify-content:space-between; align-items:center; gap:10px; }
.tdots{ font-size:12px; color:var(--muted); }
</style>
</head>
<body>
<div id="app">
<div id="top">
<div class="sels">
<label class="sel"><span>Set list</span><select id="slSel"></select></label>
<label class="sel"><span>Track</span><select id="trkSel"></select></label>
</div>
<div class="trow">
<div class="vol">🔈<input id="vol" type="range" min="0" max="100" value="85" />🔊</div>
<div class="icon" id="helpBtn" title="Help" aria-label="Help">?</div>
<div class="icon" id="themeBtn" title="Theme" aria-label="Theme"></div>
<div class="icon" id="fsBtn" title="Full screen" aria-label="Full screen"></div>
</div>
</div>
<div id="mid">
<div id="stage">
<div id="pulse">
<div id="bpm">120</div>
<input id="bpmIn" type="number" inputmode="numeric" min="30" max="300" />
<div id="bpmlab">BPM</div>
</div>
<div id="meterline"></div>
</div>
<div id="rightcol">
<div id="detail">
<div id="lanes"></div>
<div class="chips" id="feats"></div>
</div>
<div id="transport">
<div class="tgrid">
<button class="tbtn" id="bDn10" title="Tempo 10">10</button>
<button class="tbtn" id="bPrev" title="Previous track"></button>
<button class="tbtn" id="bNext" title="Next track"></button>
<button class="tbtn" id="bUp10" title="Tempo +10">+10</button>
<button class="tbtn" id="bDown" title="Tempo 1"></button>
<button class="tbtn play" id="bPlay" title="Play / Stop"><small>PLAY</small></button>
<button class="tbtn prac" id="bPrac" title="Practice (start/stop tracks within a session)">⦿<small>PRACTICE</small></button>
<button class="tbtn" id="bUp" title="Tempo +1">+</button>
</div>
</div>
</div>
</div>
<a id="sessbar" href="/mobile-sessions.html"><span class="dotrec"></span><span id="sessText">Practice sessions →</span></a>
</div>
<!-- lane editor sheet -->
<div id="scrim"></div>
<div id="laneSheet">
<div class="grab"></div>
<h2>Edit lane</h2>
<label for="lsSound">Sound</label><select id="lsSound"></select>
<label for="lsGroup">Grouping — beats per bar (e.g. 4, or 2+2+3)</label><input id="lsGroup" type="text" inputmode="text" autocomplete="off" />
<div class="lrow">
<label class="half">Subdivision<select id="lsSub"><option>1</option><option>2</option><option>3</option><option>4</option></select></label>
<label class="chk"><input type="checkbox" id="lsSwing" /> Swing</label>
</div>
<div class="lrow">
<label class="chk"><input type="checkbox" id="lsPoly" /> Polymeter</label>
<label class="chk"><input type="checkbox" id="lsMute" /> Mute lane</label>
</div>
<div class="lfoot"><button id="lsDel" class="lbtn danger">Delete lane</button><button id="lsDone" class="lbtn">Done</button></div>
</div>
<!-- guided help tour -->
<div id="tour">
<div id="tourHole"></div>
<div id="tourBox">
<h3 id="tourTitle"></h3><p id="tourText"></p>
<div class="trow"><span class="tdots" id="tourDots"></span>
<span><button id="tourSkip" class="lbtn" style="padding:8px 12px">Skip</button>
<button id="tourPrev" class="lbtn" style="padding:8px 12px">Back</button>
<button id="tourNext" class="lbtn" style="padding:8px 14px">Next</button></span></div>
</div>
</div>
<script>
const APP_VERSION = "v0.0.1-dev";
const $ = (id) => document.getElementById(id);
const LS_SESSIONS="metronome.sessions", LS_SETLISTS="metronome.setlists", LS_STATE="metronome.mobile.state", LS_TOURED="metronome.mobile.toured", LS_CURTRACK="metronome.curtrack";
function lsGet(k,fb){ try{ const v=localStorage.getItem(k); return v?JSON.parse(v):fb; }catch(e){ return fb; } }
function lsSet(k,v){ try{ localStorage.setItem(k,JSON.stringify(v)); }catch(e){} }
function esc(s){ return String(s).replace(/[&<>"]/g,(c)=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c])); }
function fmt(sec){ sec=Math.max(0,Math.round(sec)); const h=Math.floor(sec/3600), m=Math.floor((sec%3600)/60), s=sec%60;
return (h?h+":"+String(m).padStart(2,"0"):m)+":"+String(s).padStart(2,"0"); }
/* ========================= ENGINE ============================================ */
const SAMPLES = {};
/*@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, curEnd=null, curRep=null;
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; }
}
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); }
}
/* ========================= PLAYER ============================================= */
let setlist=null, idx=0, slKey="", transientTitle=null, savedLists=[];
const BUILTIN = SEED_SETLISTS.map((sl) => ({ title: sl.title, items: sl.items.map(([n, p]) => ({ name: n, ...patchToSetup(p) })) }));
function currentName(){ return setlist ? (setlist.items[idx].name||"") : ""; }
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(),orns:(c.orns||[]).slice(),poly:!!c.poly,swing:!!c.swing,enabled:c.enabled!==false,gainDb:c.gainDb||0,
tick:0,nextTime:0,vq:[],vqPtr:0,currentStep:-1,currentBar:0,_padEls:null,_lastPad:-1};
});
}
function snapshotLanes(){ return meters.map(m=>({groupsStr:m.groupsStr,stepsPerBeat:m.stepsPerBeat,sound:m.sound,beatsOn:m.beatsOn.slice(),orns:(m.orns||[]).slice(),poly:m.poly,swing:m.swing,enabled:m.enabled,gainDb:m.gainDb})); }
function currentPatch(){ return setupToPatch({bpm:state.bpm,volume:state.volume,lanes:snapshotLanes(),trainer,ramp,bars:segBars,end:curEnd,rep:curRep}); }
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; curEnd=s.end; curRep=s.rep;
setBpm(s.bpm||120);
meters=buildMeters(s.lanes); laneSig=null;
}
function unlockAudio(){
try{ if(navigator.audioSession) navigator.audioSession.type="playback"; }catch(e){}
try{ const b=audioCtx.createBuffer(1,1,audioCtx.sampleRate), s=audioCtx.createBufferSource(); s.buffer=b; s.connect(audioCtx.destination); s.start(0); }catch(e){}
}
let runStartAt=0;
function startAudio(){
ensureAudio(); unlockAudio(); audioCtx.resume(); state.running=true; runStartAt=Date.now();
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; lastBeatKey=-1;
schedulerTimer=setInterval(scheduler,LOOKAHEAD_MS); scheduler();
requestWake();
}
function stopMetronome(){ state.running=false; clearInterval(schedulerTimer); schedulerTimer=null; pendingAdvance=false; for(const m of meters){ m.currentStep=-1; } }
function stopAudio(){ stopMetronome(); releaseWake(); renderAll(); }
function startRun(){ startAudio(); renderAll(); }
/* sessions */
let sessionActive=false, session=null, trackSegStart=null, lastSaved=false;
function startTrack(){
if(!sessionActive){ sessionActive=true; session={at:Date.now(),segments:[]}; lastSaved=false; }
startAudio(); trackSegStart={name:currentName(),at:Date.now(),bpm:state.bpm}; renderAll(); renderSessionBar();
}
function recordSegment(){ if(!trackSegStart||!session) return; const sec=(Date.now()-trackSegStart.at)/1000;
if(sec>=3) session.segments.push({name:trackSegStart.name,at:trackSegStart.at,sec,bpm:state.bpm}); trackSegStart=null; }
function pauseTrack(){ recordSegment(); stopMetronome(); releaseWake(); renderAll(); renderSessionBar(); }
function endSession(){
if(state.running){ recordSegment(); stopMetronome(); releaseWake(); }
if(session){ const endedAt=Date.now(), clockSec=(endedAt-session.at)/1000;
if(session.segments.length && clockSec>=5){ const arr=lsGet(LS_SESSIONS,[]); arr.unshift({at:session.at,endedAt,clockSec,note:"",segments:session.segments}); lsSet(LS_SESSIONS,arr); lastSaved=true; } }
session=null; sessionActive=false; trackSegStart=null; renderAll(); renderSessionBar();
}
function play(){ if(sessionActive) endSession(); else if(state.running) stopAudio(); else startRun(); }
function practice(){ if(sessionActive&&state.running){ pauseTrack(); } else { if(state.running&&!sessionActive) stopMetronome(); startTrack(); } }
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){ if(sessionActive) recordSegment(); stopMetronome(); }
loadSetup(setlist.items[idx]);
if(wasRunning){ startAudio(); if(sessionActive) trackSegStart={name:currentName(),at:Date.now(),bpm:state.bpm}; }
buildSetlistOptions(); buildTrackOptions(); renderAll(); renderSessionBar();
}
function loadSetlistObj(sl){
if(state.running&&sessionActive) recordSegment();
const wasRunning=state.running; if(wasRunning) stopMetronome();
setlist=sl; idx=0; loadSetup(sl.items[0]);
if(wasRunning){ startAudio(); if(sessionActive) trackSegStart={name:currentName(),at:Date.now(),bpm:state.bpm}; }
buildSetlistOptions(); buildTrackOptions(); renderAll(); renderSessionBar();
}
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(); }
/* ========================= EDITABLE LANES ==================================== */
let laneSig=null, editLaneIdx=0;
function laneSignature(){ return meters.map(m=>m.sound+":"+m.groupsStr+"/"+m.stepsPerBeat+(m.swing?"s":"")+(m.enabled?"":"!")+(m.poly?"~":"")).join("|"); }
function lvlClass(l){ return l===2?"acc":l===3?"ghost":l===1?"on":""; }
function buildLanes(){
const box=$("lanes"); box.innerHTML="";
meters.forEach((m,i)=>{
const lane=document.createElement("div"); lane.className="lane"+(m.enabled?"":" off");
const meta=document.createElement("button"); meta.className="lmeta";
meta.innerHTML=esc(m.sound)+" <span class='lg'>"+esc(m.groupsStr)+(m.stepsPerBeat>1?"/"+m.stepsPerBeat:"")+(m.poly?"~":"")+"</span>";
meta.onclick=()=>openLaneSheet(i);
const pads=document.createElement("div"); pads.className="pads";
const steps=m.beatsPerBar*m.stepsPerBeat; m._padEls=[]; m._lastPad=-1;
for(let k=0;k<steps;k++){ const p=document.createElement("button"); p.className="pad"; p.onclick=()=>cyclePad(m,k,p); pads.appendChild(p); m._padEls.push(p); }
lane.appendChild(meta); lane.appendChild(pads); box.appendChild(lane);
});
const add=document.createElement("button"); add.className="addlane"; add.textContent="+ Add lane"; add.onclick=addLane; box.appendChild(add);
renderPadLevels();
}
function renderPadLevels(){ meters.forEach(m=>{ if(!m._padEls) return; const spb=m.stepsPerBeat;
m._padEls.forEach((p,k)=>{ const gs=(k%spb===0)&&m.groupStarts.has(k/spb); const lvl=m.beatsOn[k]|0; p.className="pad"+(gs?" gs":"")+(lvl?(" "+lvlClass(lvl)):""); }); }); }
function cyclePad(m,k,p){ m.beatsOn[k]=((m.beatsOn[k]|0)+1)%4; if(m.orns) m.orns[k]=0; const spb=m.stepsPerBeat; const gs=(k%spb===0)&&m.groupStarts.has(k/spb); const lvl=m.beatsOn[k];
p.className="pad"+(gs?" gs":"")+(lvl?(" "+lvlClass(lvl)):""); saveState(); }
function renderPadPlayheads(){ meters.forEach(m=>{ if(!m._padEls) return; const cur=state.running?m.currentStep:-1;
if(m._lastPad!==cur){ if(m._lastPad>=0&&m._padEls[m._lastPad]) m._padEls[m._lastPad].classList.remove("cur"); if(cur>=0&&m._padEls[cur]) m._padEls[cur].classList.add("cur"); m._lastPad=cur; } }); }
function rebuildLane(i,cfg){
const p=parseGroups(cfg.groupsStr), spb=Math.max(1,cfg.stepsPerBeat||1), steps=p.beatsPerBar*spb;
let on=cfg.beatsOn, orns=cfg.orns||[];
if(!on||on.length!==steps){ on=Array.from({length:steps},(_,k)=>((k%spb)===0&&p.groupStarts.has(k/spb))?2:1); orns=on.map(()=>0); }
const old=meters[i]||{};
meters[i]=Object.assign(old,{groupsStr:cfg.groupsStr,groups:p.groups,beatsPerBar:p.beatsPerBar,groupStarts:p.groupStarts,
stepsPerBeat:spb,sound:cfg.sound,beatsOn:on,orns:orns,poly:!!cfg.poly,swing:!!cfg.swing,enabled:cfg.enabled!==false,gainDb:cfg.gainDb||0,
currentStep:-1,_padEls:null,_lastPad:-1});
}
function addLane(){ meters.push(buildMeters([{groupsStr:"4",stepsPerBeat:1,sound:"beep",beatsOn:[2,1,1,1],orns:[0,0,0,0],poly:false,swing:false,enabled:true,gainDb:0}])[0]); laneSig=null; renderAll(); saveState(); }
/* lane settings sheet */
(function(){ const sel=$("lsSound"); VOICES.forEach(([k,lab])=>{ const o=document.createElement("option"); o.value=k; o.textContent=lab; sel.appendChild(o); }); })();
function openLaneSheet(i){ editLaneIdx=i; const m=meters[i]; if(!m) return;
$("lsSound").value=m.sound; $("lsGroup").value=m.groupsStr; $("lsSub").value=String(m.stepsPerBeat); $("lsSwing").checked=!!m.swing; $("lsPoly").checked=!!m.poly; $("lsMute").checked=!m.enabled;
$("scrim").classList.add("open"); $("laneSheet").classList.add("open"); }
function closeLaneSheet(){ $("scrim").classList.remove("open"); $("laneSheet").classList.remove("open"); }
function applyLane(){ const m=meters[editLaneIdx]; if(!m) return;
let grp=($("lsGroup").value||"").trim()||"4";
rebuildLane(editLaneIdx,{groupsStr:grp,stepsPerBeat:Math.max(1,Math.min(4,parseInt($("lsSub").value,10)||1)),swing:$("lsSwing").checked,
poly:$("lsPoly").checked,enabled:!$("lsMute").checked,sound:$("lsSound").value,gainDb:m.gainDb||0,beatsOn:m.beatsOn.slice(),orns:(m.orns||[]).slice()});
laneSig=null; renderAll(); saveState(); }
["lsSound","lsSub","lsSwing","lsPoly","lsMute"].forEach(id=>$(id).addEventListener("change",applyLane));
$("lsGroup").addEventListener("change",applyLane);
$("lsDone").onclick=closeLaneSheet;
$("lsDel").onclick=()=>{ if(meters.length<=1){ closeLaneSheet(); return; } meters.splice(editLaneIdx,1); laneSig=null; renderAll(); saveState(); closeLaneSheet(); };
$("scrim").onclick=closeLaneSheet;
/* ========================= SET-LIST / TRACK DROPDOWNS ======================== */
function opt(v,t){ const o=document.createElement("option"); o.value=v; o.textContent=t; return o; }
function og(label){ const g=document.createElement("optgroup"); g.label=label; return g; }
function buildSetlistOptions(){
savedLists=lsGet(LS_SETLISTS,[]);
const sel=$("slSel"); sel.innerHTML="";
if(slKey===""){ sel.appendChild(opt("", transientTitle||"Loaded")); }
const g1=og("Built-in"); BUILTIN.forEach((sl,i)=>g1.appendChild(opt("b"+i, sl.title+" ("+sl.items.length+")"))); sel.appendChild(g1);
if(savedLists.length){ const g2=og("Your set lists"); savedLists.forEach((sl,i)=>g2.appendChild(opt("s"+i,(sl.title||("Set list "+(i+1)))+" ("+(sl.items?sl.items.length:0)+")"))); sel.appendChild(g2); }
sel.value=slKey;
}
function buildTrackOptions(){ const sel=$("trkSel"); sel.innerHTML="";
if(setlist) setlist.items.forEach((it,i)=>sel.appendChild(opt(String(i),(i+1)+". "+(it.name||("Track "+(i+1)))))); sel.value=String(idx); }
$("slSel").onchange=(e)=>{ const v=e.target.value; let sl=null;
if(v[0]==="b") sl=BUILTIN[+v.slice(1)]; else if(v[0]==="s"){ const s=savedLists[+v.slice(1)]; if(s) sl={title:s.title,items:(s.items||[]).map(it=>({...it}))}; }
if(sl){ slKey=v; transientTitle=null; loadSetlistObj(sl); } };
$("trkSel").onchange=(e)=>{ gotoItem(+e.target.value, state.running); };
/* ========================= RENDER ============================================ */
let lastCur=null;
function renderInfo(){
if(!editingBpm) $("bpm").textContent=state.bpm;
const ts=$("trkSel"); if(ts && ts.value!==String(idx)) ts.value=String(idx);
const nm=currentName(); if(nm!==lastCur){ lastCur=nm; lsSet(LS_CURTRACK,nm); }
const sig=laneSignature(); if(sig!==laneSig){ laneSig=sig; buildLanes(); } else renderPadLevels();
buildFeats(); updateStatus();
}
function endLabel(){ if(curEnd==null) return "loop"; if(curEnd==="stop") return "stop"; if(curEnd===1) return "next";
if(typeof curEnd==="number"&&curEnd>0) return "+"+curEnd; return String(curEnd); }
function buildFeats(){ const ft=$("feats"); ft.innerHTML="";
const add=(t,cls)=>{ const c=document.createElement("span"); c.className="chip feat"+(cls?" "+cls:""); c.textContent=t; ft.appendChild(c); };
if(segBars>0) add(segBars+" bars"); add("→ "+endLabel()); if(curRep>1) add("× "+curRep);
if(ramp.on) add("ramp "+ramp.startBpm+"→ +"+ramp.amount+"/"+ramp.everyBars+"bar","r");
if(trainer.on) add("gap "+trainer.playBars+"/"+trainer.muteBars+" play/mute","g"); }
function updateStatus(){ const m=meters[0]; let s="";
if(m){ s=m.beatsPerBar+" beats"+(m.groups.length>1?" · "+m.groups.join("+"):"")+(m.swing?" · swing":""); }
if(state.running&&m){ s+=" · bar "+((m.currentBar|0)+1)+(segBars>0?(" / "+segBars):"")+" · "+fmt((Date.now()-runStartAt)/1000); }
else if(segBars>0){ s+=" · "+segBars+" bars"; }
$("meterline").textContent=s; }
function renderTransport(){
const onAny=sessionActive||state.running;
$("bPlay").innerHTML=(onAny?"■<small>STOP</small>":"▶<small>PLAY</small>"); $("bPlay").classList.toggle("on",onAny);
const pr=state.running&&sessionActive;
$("bPrac").innerHTML=(pr?"❚❚<small>PAUSE</small>":"⦿<small>PRACTICE</small>"); $("bPrac").classList.toggle("on",pr); }
function renderSessionBar(){ const bar=$("sessbar"), n=lsGet(LS_SESSIONS,[]).length;
if(sessionActive){ bar.classList.add("rec"); const segs=session.segments.length+(trackSegStart?1:0);
$("sessText").textContent="Recording · session "+fmt((Date.now()-session.at)/1000)+" · "+segs+" track"+(segs===1?"":"s"); }
else { bar.classList.remove("rec"); $("sessText").textContent=(lastSaved?"✓ Session saved · ":"")+"Practice sessions"+(n?(" ("+n+")"):"")+" →"; } }
function renderAll(){ renderInfo(); renderTransport(); saveState(); }
let lastBeatKey=-1, pulseTimer=null;
function pulseHit(acc){ const p=$("pulse"); p.classList.remove("hit","acc"); void p.offsetWidth; p.classList.add("hit"); if(acc) p.classList.add("acc");
clearTimeout(pulseTimer); pulseTimer=setTimeout(()=>p.classList.remove("hit","acc"),130); }
let lastTimeUpd=0;
function draw(){
if(audioCtx&&state.running){ const now=audioCtx.currentTime-(audioCtx.outputLatency||audioCtx.baseLatency||0);
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++; } } }
renderPadPlayheads();
const m=meters[0];
if(state.running&&m&&m.currentStep>=0){ const beat=Math.floor(m.currentStep/m.stepsPerBeat);
if(m.currentStep%m.stepsPerBeat===0){ const key=m.currentBar*1000+beat; if(key!==lastBeatKey){ lastBeatKey=key; pulseHit(m.groupStarts.has(beat)); } } }
const t=performance.now();
if(t-lastTimeUpd>200){ lastTimeUpd=t; if(state.running) updateStatus(); if(sessionActive) renderSessionBar(); }
requestAnimationFrame(draw);
}
/* ========================= BPM: tap=tap-tempo · hold=type · drag=scrub ======== */
let editingBpm=false;
function openBpmEdit(){ editingBpm=true; const i=$("bpmIn"); $("bpm").style.display="none"; i.style.display="block"; i.value=state.bpm; i.focus(); i.select(); }
function closeBpmEdit(commit){ const i=$("bpmIn"); if(commit){ const v=parseInt(i.value,10); if(v){ ramp.on=false; setBpm(v); } } editingBpm=false; i.style.display="none"; $("bpm").style.display="block"; renderAll(); }
$("bpmIn").addEventListener("keydown",(e)=>{ if(e.key==="Enter"){ closeBpmEdit(true); } else if(e.key==="Escape"){ closeBpmEdit(false); } });
$("bpmIn").addEventListener("blur",()=>{ if(editingBpm) closeBpmEdit(true); });
(function(){ const p=$("pulse"); let dragging=false, moved=false, lpFired=false, startY=0, startBpm=120, lpTimer=null;
p.addEventListener("pointerdown",(e)=>{ if(editingBpm) return; dragging=true; moved=false; lpFired=false; startY=e.clientY; startBpm=state.bpm;
p.setPointerCapture(e.pointerId); lpTimer=setTimeout(()=>{ lpFired=true; openBpmEdit(); },450); });
p.addEventListener("pointermove",(e)=>{ if(!dragging) return; const dy=startY-e.clientY; if(Math.abs(dy)>6){ moved=true; clearTimeout(lpTimer); ramp.on=false; setBpm(startBpm+dy*0.5); renderAll(); } });
p.addEventListener("pointerup",(e)=>{ if(!dragging) return; dragging=false; clearTimeout(lpTimer); try{p.releasePointerCapture(e.pointerId);}catch(_){} if(!moved && !lpFired) tapTempo(); });
p.addEventListener("pointercancel",()=>{ dragging=false; clearTimeout(lpTimer); });
})();
/* ========================= PERSIST / RESTORE STATE =========================== */
let saveTimer=null;
function saveState(){ clearTimeout(saveTimer); saveTimer=setTimeout(()=>{ try{
lsSet(LS_STATE,{slKey,transientTitle,idx,name:currentName(),patch:currentPatch(),volume:state.volume}); }catch(e){} },350); }
function restoreState(){
const st=lsGet(LS_STATE,null); if(!st||!st.patch) return false;
try{
if(st.volume!=null) state.volume=st.volume;
savedLists=lsGet(LS_SETLISTS,[]); let sl=null, key=st.slKey;
if(key&&key[0]==="b") sl=BUILTIN[+key.slice(1)];
else if(key&&key[0]==="s"){ const s=savedLists[+key.slice(1)]; if(s) sl={title:s.title,items:(s.items||[]).map(it=>({...it}))}; }
if(sl){ slKey=key; transientTitle=null; setlist=sl; idx=Math.max(0,Math.min(st.idx||0, sl.items.length-1)); }
else { slKey=""; transientTitle=st.transientTitle||st.name||"Restored"; setlist={title:transientTitle,items:[{name:st.name||"Track",...patchToSetup(st.patch)}]}; idx=0; }
loadSetup(patchToSetup(st.patch)); // active setup carries the user's edits + tempo
return true;
}catch(e){ return false; }
}
/* ========================= HASH SHARE-LINK LOADING =========================== */
function loadFromHash(text){
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 0;
slKey=""; transientTitle=sl.title||"Shared set list"; loadSetlistObj(sl); return true; }
const setup=patchToSetup(payload); if(!setup.lanes.length) throw 0;
slKey=""; transientTitle="Shared patch"; loadSetlistObj({title:"Shared patch",items:[{name:"Patch",...setup}]}); return true;
}catch(e){ return false; }
}
/* ========================= HELP TOUR ========================================= */
const TOUR=[
{sel:".sels", title:"Pick what to play", text:"Choose a set list and the track within it. Tracks are your practice items — name them for whatever you're working on, even if two share the same beat."},
{sel:"#pulse", title:"Tempo", text:"Tap the BPM to tap-tempo, press-and-hold to type an exact value, or drag up/down to scrub."},
{sel:"#bDn10", title:"Nudge tempo", text:"10 / +10 for big jumps, / + for fine adjustments."},
{sel:"#lanes", title:"Edit the beat", text:"Each lane is a row of pads that blink on the beat — tap a pad to cycle rest → beat → accent → ghost. Tap a lane's label to change its sound, grouping, subdivision, mute or polymeter. “+ Add lane” for more."},
{sel:"#bPlay", title:"Play", text:"Runs the metronome without recording anything."},
{sel:"#bPrac", title:"Practice = a timed session", text:"Practice starts a session clock and records what you play; Play turns into Stop. Use Practice to start/stop individual tracks, then hit Stop when you're done to save the whole session."},
{sel:"#sessbar", title:"Your practice log", text:"Review past sessions, add notes, and compare a track across days."},
];
let tstep=0;
function startTour(){ tstep=0; $("tour").classList.add("open"); showTour(); }
function endTour(){ $("tour").classList.remove("open"); lsSet(LS_TOURED,1); }
function showTour(){
while(tstep<TOUR.length && !document.querySelector(TOUR[tstep].sel)) tstep++;
if(tstep>=TOUR.length){ endTour(); return; }
const s=TOUR[tstep], el=document.querySelector(s.sel), r=el.getBoundingClientRect(), pad=6, hole=$("tourHole");
hole.style.left=(r.left-pad)+"px"; hole.style.top=(r.top-pad)+"px"; hole.style.width=(r.width+2*pad)+"px"; hole.style.height=(r.height+2*pad)+"px";
$("tourTitle").textContent=s.title; $("tourText").textContent=s.text; $("tourDots").textContent=(tstep+1)+" / "+TOUR.length;
$("tourPrev").style.visibility=tstep?"visible":"hidden"; $("tourNext").textContent=(tstep===TOUR.length-1)?"Done":"Next";
const box=$("tourBox"), bw=Math.min(290, innerWidth-24); box.style.width=bw+"px"; box.style.left="0px"; box.style.top="-9999px";
const bh=box.offsetHeight;
const left=Math.max(12, Math.min(r.left, innerWidth-bw-12));
const top=(r.bottom+12+bh < innerHeight) ? r.bottom+12 : Math.max(12, r.top-12-bh);
box.style.left=left+"px"; box.style.top=top+"px";
}
$("tourNext").onclick=()=>{ if(tstep>=TOUR.length-1) endTour(); else { tstep++; showTour(); } };
$("tourPrev").onclick=()=>{ if(tstep>0){ tstep--; showTour(); } };
$("tourSkip").onclick=endTour;
$("helpBtn").onclick=startTour;
addEventListener("resize",()=>{ if($("tour").classList.contains("open")) showTour(); });
/* ========================= WIRING ============================================ */
$("bPlay").onclick=play; $("bPrac").onclick=practice;
$("bPrev").onclick=()=>gotoItem(idx-1,state.running); $("bNext").onclick=()=>gotoItem(idx+1,state.running);
$("bUp").onclick=()=>nudge(+1); $("bDown").onclick=()=>nudge(-1);
$("bUp10").onclick=()=>nudge(+10); $("bDn10").onclick=()=>nudge(-10);
$("vol").oninput=(e)=>{ state.volume=(+e.target.value)/100; if(masterGain) masterGain.gain.value=state.volume; saveState(); };
$("sessbar").addEventListener("click",(e)=>{ if(sessionActive) e.preventDefault(); });
/*@BUILD:include:src/chrome.js@*/
/* ========================= FULLSCREEN + WAKE LOCK ============================ */
const docEl=document.documentElement;
const reqFS=docEl.requestFullscreen||docEl.webkitRequestFullscreen, exitFS=document.exitFullscreen||document.webkitExitFullscreen;
const fsEl=()=>document.fullscreenElement||document.webkitFullscreenElement;
let wakeLock=null;
async function requestWake(){ try{ if(navigator.wakeLock) wakeLock=await navigator.wakeLock.request("screen"); }catch(e){} }
function releaseWake(){ try{ wakeLock&&wakeLock.release(); }catch(e){} wakeLock=null; }
function toggleFS(){ if(fsEl()){ if(exitFS) try{ exitFS.call(document); }catch(e){} } else if(reqFS){ try{ reqFS.call(docEl); }catch(e){} } }
$("fsBtn").onclick=toggleFS;
if(window.EMBED) $("fsBtn").style.display="none";
document.addEventListener("visibilitychange",()=>{ if(document.visibilityState==="visible"&&state.running) requestWake(); });
/* PWA */
let deferredPrompt=null;
addEventListener("beforeinstallprompt",(e)=>{ e.preventDefault(); deferredPrompt=e; });
if(!window.EMBED && "serviceWorker" in navigator){ addEventListener("load",()=>{ navigator.serviceWorker.register("/mobile-sw.js").catch(()=>{}); }); }
addEventListener("beforeunload",(e)=>{ if(sessionActive){ e.preventDefault(); e.returnValue=""; } });
/* keyboard (desktop testing) */
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(); play(); }
else if(k==="p"||k==="P"){ e.preventDefault(); practice(); }
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==="f"||k==="F") toggleFS();
});
/* ========================= INIT ============================================== */
if(location.hash && /(p|sl)=/.test(location.hash)) loadFromHash(location.hash);
else restoreState();
if(!setlist){ slKey="b0"; setlist=BUILTIN[0]; idx=0; loadSetup(setlist.items[0]); }
buildSetlistOptions(); buildTrackOptions();
$("vol").value=Math.round(state.volume*100); if(masterGain) masterGain.gain.value=state.volume;
renderAll(); renderSessionBar();
requestAnimationFrame(draw);
if(!window.EMBED && !lsGet(LS_TOURED,0)) setTimeout(()=>{ if(!$("tour").classList.contains("open")) startTour(); }, 700);
</script>
</body>
</html>