metronome/mobile.html
Me Here 812a69942f pm-mobile: sessions page — per-track comparison + collapsible sessions
- Top section aggregates the CURRENT track across all sessions (track picker
  defaults to the metronome's current track, persisted via metronome.curtrack):
  total time / plays / bpm range, plus a per-session comparison table so you can
  watch a single track progress across days.
- Each session is now a collapsible <details>: the summary shows a friendly
  timestamp ("Fri Jun 16 at 2:46 PM") with total/practiced/track-count; the note
  + per-track aggregate table + delete live in the expanded body.

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

520 lines
30 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" />
<!-- viewport-fit=cover → draw under the notch/home-indicator; no user zoom (app, not a document) -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no" />
<title>VARASYS PolyMeter — Mobile</title>
<!-- PWA / Add-to-Home-Screen: launches full-screen & chrome-less from the home screen -->
<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: set-list + track dropdowns, volume, theme/fullscreen ---- */
#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 (beats+pulse) + right column (detail + 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(10px,2.4vmin,30px); }
#beats{ display:flex; flex-wrap:wrap; justify-content:center; align-items:center; gap:clamp(8px,1.7vmin,16px); max-width:92%; }
.dot{ width:clamp(12px,2.8vmin,28px); height:clamp(12px,2.8vmin,28px); border-radius:50%;
background:var(--led-off); border:1px solid rgba(0,0,0,.35); transition:background .05s, box-shadow .05s, transform .05s; }
.dot.group{ outline:1.5px solid var(--amber); outline-offset:3px; opacity:.95; }
.dot.on{ background:var(--cyan); box-shadow:0 0 12px var(--glow); transform:scale(1.12); }
.dot.on.group{ background:var(--amber); box-shadow:0 0 14px var(--aglow); }
#pulse{ position:relative; width:clamp(160px,40vmin,380px); height:clamp(160px,40vmin,380px); 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(50px,16vmin,150px); 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; }
#bpmhint{ font-size:clamp(9px,1.6vmin,12px); color:var(--muted); opacity:.7; margin-top:.5em; }
#bpmIn{ display:none; width:64%; text-align:center; font:inherit; font-size:clamp(46px,14vmin,130px); 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; }
/* ---- detail: lanes + features as chips ---- */
#detail{ flex:0 0 auto; max-height:18vh; overflow-y:auto; display:flex; flex-direction:column; gap:5px; padding:2px 0; }
.chips{ display:flex; flex-wrap:wrap; gap:6px; justify-content:center; }
.chip{ font-size:clamp(10px,1.8vmin,13px); font-family:"Courier New",monospace; background:var(--chip-bg); border:1px solid var(--chip-bd);
color:var(--txt); border-radius:7px; padding:3px 8px; white-space:nowrap; }
.chip.off{ opacity:.45; text-decoration:line-through; }
.chip.feat{ font-family:inherit; color:var(--muted); }
.chip.feat.r{ border-color:var(--cyan); color:var(--cyan); }
.chip.feat.g{ border-color:var(--amber); color:var(--amber); }
/* ---- transport: tempo grid (10/ +/+10) + prev/next + play/practice ---- */
#transport{ flex:0 0 auto; display:flex; justify-content:center; padding-top:4px; }
.tgrid{ display:grid; width:100%; max-width:560px; gap:clamp(6px,1.7vmin,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(48px,12vmin,74px); font-size:clamp(18px,4.6vmin,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; }
/* landscape phones: pulse left, right column = detail + transport */
@media (orientation:landscape) and (max-height:600px){
#mid{ flex-direction:row; align-items:stretch; gap:3vw; }
#stage{ flex:1 1 50%; gap:clamp(6px,1.6vmin,14px); }
#rightcol{ flex:1 1 50%; display:flex; flex-direction:column; justify-content:center; gap:6px; min-width:0; }
#pulse{ width:clamp(140px,38vmin,280px); height:clamp(140px,38vmin,280px); }
#bpmlab,#bpmhint{ display:none; }
#detail{ max-height:30vh; }
.tbtn{ height:clamp(40px,12vmin,60px); }
}
#rightcol{ display:contents; } /* portrait: detail + transport flow normally in #mid */
/* ---- session bar (bottom) ---- */
#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; }
</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="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="beats"></div>
<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 id="bpmhint">tap = tap tempo · hold = type · drag = scrub</div>
</div>
<div id="meterline"></div>
</div>
<div id="rightcol">
<div id="detail">
<div class="chips" 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 a track 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>
<script>
const APP_VERSION = "v0.0.1-dev";
const $ = (id) => document.getElementById(id);
const LS_SESSIONS = "metronome.sessions", LS_SETLISTS = "metronome.setlists";
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 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 (synth voices only; shared scheduler) ======= */
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); } // loop set list at end
}
/* ========================= PLAYER ============================================= */
let setlist=null, idx=0;
let 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};
});
}
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);
rebuildBeats();
}
/* iOS: ignore the ring/silent hardware switch + warm up the context inside the play gesture. */
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(); } // plain stop (no session)
function startRun(){ startAudio(); renderAll(); } // plain play (no session)
/* ---- sessions: Practice records track segments under one continuous session clock ---- */
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}); // skip sub-3s blips
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 toggle(){ play(); } // keyboard space
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(); }
/* ========================= 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 ============================================ */
function rebuildBeats(){
const box=$("beats"); 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="dot"+(m.groupStarts.has(i)?" group":""); box.appendChild(d); }
}
function renderBeats(){
const m=meters[0]; if(!m) return;
const cur=state.running? Math.floor(m.currentStep/m.stepsPerBeat) : -1;
const els=$("beats").children;
for(let i=0;i<els.length;i++) els[i].classList.toggle("on", i===cur);
}
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 buildDetail(){
const ln=$("lanes"); ln.innerHTML="";
meters.forEach(m=>{ const c=document.createElement("span"); c.className="chip"+(m.enabled?"":" off"); c.textContent=laneCfgToStr(m); ln.appendChild(c); });
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");
}
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("metronome.curtrack",nm); } // sessions page reads this
buildDetail(); updateStatus();
}
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(); renderBeats(); renderTransport(); }
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++; } } }
renderBeats();
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); }); // hold → type
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(); }); // clean tap → tap tempo
p.addEventListener("pointercancel",()=>{ dragging=false; clearTimeout(lpTimer); });
})();
/* ========================= 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; }
}
/* ========================= 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; };
$("sessbar").addEventListener("click",(e)=>{ if(sessionActive) e.preventDefault(); }); // don't lose an in-progress session
/* theme toggle (shared "metronome.theme") + version stamp */
/*@BUILD:include:src/chrome.js@*/
/* ========================= FULLSCREEN + WAKE LOCK ============================ */
const docEl=document.documentElement;
const reqFS=docEl.requestFullscreen||docEl.webkitRequestFullscreen;
const 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: install + service worker ===================== */
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(()=>{}); }); }
/* warn if the user tries to leave mid-session (in-memory session would be lost) */
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);
if(!setlist){ slKey="b0"; setlist=BUILTIN[0]; idx=0; loadSetup(setlist.items[0]); }
buildSetlistOptions(); buildTrackOptions();
$("vol").value=Math.round(state.volume*100);
renderAll(); renderSessionBar();
requestAnimationFrame(draw);
</script>
</body>
</html>