metronome/mobile.html
Me Here dca2a405f7 pm-mobile: top dropdowns, ±10 tempo, Play/Practice split, collapsible log
Reworks the mobile player's controls per use on a phone:

- Set list + track are now two dropdowns at the top (with the volume slider +
  theme/fullscreen beside them); drops the hamburger/bottom-sheet menu. The
  track dropdown stays in sync with prev/next and set-list auto-advance.
- Tempo grid adds coarse -10/+10 buttons above the fine -/+ buttons, laid out
  as a 4-col grid with prev/next and play/practice in the centre columns.
- Separate Play and Practice transports: Play runs the metronome without
  touching the practice log; Practice runs AND records a session
  (metronome.logs, same format/key as the editor: {at,name,durationSec,bpm,
  lanes}, per-track history, sub-3s blips skipped).
- Tap Tempo restyled as a real button.
- Collapsible practice log: a thin bar at the bottom opens a bottom-sheet
  showing past sessions for the current track (date - duration @ bpm), with
  per-entry delete and clear-this-track.

Landscape phones switch to a two-column layout (pulse left, transport right) so
everything fits without vertical overflow. Engine untouched.

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

508 lines
31 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: makes it launch 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>
/* ?embed=1 → running inside the form-factor gallery iframe: skip PWA/fullscreen/wake-lock. */
window.EMBED = /[?&]embed=1/.test(location.search);
if (window.EMBED) document.documentElement.dataset.embed = "1";
</script>
<script>
// Set theme before first paint (no flash). Shares the editor's "metronome.theme" key.
(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;
}
: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;
}
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(10px,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: beats + pulse, then transport (row in landscape) ---- */
#mid{ flex:1 1 auto; min-height:0; display:flex; flex-direction:column; }
#stage{ flex:1 1 auto; min-height:0; display:flex; flex-direction:column; align-items:center; justify-content:center;
gap:clamp(12px,3vmin,36px); }
#beats{ display:flex; flex-wrap:wrap; justify-content:center; align-items:center; gap:clamp(8px,1.7vmin,16px); max-width:92%; }
.dot{ width:clamp(13px,3vmin,30px); height:clamp(13px,3vmin,30px); 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(180px,46vmin,440px); height:clamp(180px,46vmin,440px); 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:ns-resize; }
#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(54px,18vmin,170px); font-weight:800; line-height:.85; font-variant-numeric:tabular-nums; letter-spacing:-.01em; }
#bpmlab{ font-size:clamp(11px,2.3vmin,18px); letter-spacing:.34em; color:var(--muted); margin-top:.7em; }
#bpmIn{ display:none; width:62%; text-align:center; font:inherit; font-size:clamp(48px,15vmin,140px); 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,17px); color:var(--muted); min-height:1.2em; letter-spacing:.02em; }
/* ---- transport: tempo grid (10/ +/+10) + prev/next + play/practice + tap ---- */
#transport{ flex:0 0 auto; display:flex; flex-direction:column; align-items:center; gap:clamp(7px,1.4vmin,12px);
padding-top:clamp(6px,1.2vmin,12px); width:100%; }
.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(50px,12.5vmin,76px); 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:.8; }
.tbtn:active{ transform:translateY(2px); box-shadow:0 1px 0 rgba(0,0,0,.28), inset 0 1px 0 rgba(255,255,255,.06); }
.tbtn:disabled{ opacity:.42; pointer-events:none; }
#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,#b23b3b,#8f2d2d); border-color:#c0392b; color:#fff; }
.tap{ width:100%; max-width:560px; height:clamp(44px,9vmin,58px); border-radius:13px;
background:linear-gradient(180deg,var(--btn1),var(--btn2)); color:var(--txt); border:1px solid var(--btn-bd);
font-size:clamp(13px,2.4vmin,16px); letter-spacing:.16em; cursor:pointer;
box-shadow:0 3px 0 rgba(0,0,0,.28), inset 0 1px 0 rgba(255,255,255,.06); }
.tap:active{ transform:translateY(2px); box-shadow:0 1px 0 rgba(0,0,0,.28), inset 0 1px 0 rgba(255,255,255,.06); }
/* landscape phones: pulse on the left, transport on the right (use width, not height) */
@media (orientation:landscape) and (max-height:600px){
#mid{ flex-direction:row; align-items:center; gap:3vw; }
#stage{ flex:1 1 52%; gap:clamp(8px,2vmin,18px); }
#transport{ flex:1 1 48%; max-width:560px; }
#pulse{ width:clamp(150px,40vmin,300px); height:clamp(150px,40vmin,300px); }
#bpmlab{ display:none; }
.tgrid > .tbtn, .tap{ height:clamp(42px,13vmin,62px); }
}
/* ---- collapsible practice log (thin bar → bottom-sheet overlay) ---- */
#logbar{ flex:0 0 auto; display:flex; align-items:center; gap:8px; width:100%;
margin-top:6px; padding:9px 12px; border-radius:11px; cursor:pointer;
background:rgba(127,139,154,.10); border:1px solid var(--panel-bd); color:var(--muted); font-size:13px; }
#logbar .grow{ flex:1 1 auto; }
#logbar b{ color:var(--txt); }
#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; }
#logsheet{ position:fixed; left:0; right:0; bottom:0; z-index:50; max-height:74vh; 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:14px max(16px,env(safe-area-inset-right)) max(18px,env(safe-area-inset-bottom)) max(16px,env(safe-area-inset-left)); }
#logsheet.open{ transform:none; }
#logsheet .grab{ width:42px; height:5px; border-radius:3px; background:var(--panel-bd); margin:0 auto 12px; }
#logsheet .lhead{ display:flex; align-items:baseline; justify-content:space-between; gap:10px; margin-bottom:4px; }
#logsheet h2{ margin:0; font-size:15px; }
#logsheet .sub{ font-size:12px; color:var(--muted); }
.hist-row{ display:flex; align-items:center; gap:10px; padding:9px 2px; border-bottom:1px solid var(--panel-bd); font-size:13px; }
.hist-row .t{ flex:1 1 auto; font-variant-numeric:tabular-nums; }
.hist-del{ flex:0 0 auto; background:transparent; border:1px solid var(--panel-bd); color:var(--muted);
border-radius:8px; width:30px; height:30px; cursor:pointer; }
.lempty{ font-size:13px; color:var(--muted); padding:10px 2px; }
.lfoot{ display:flex; align-items:center; justify-content:space-between; margin-top:14px; padding-top:12px;
border-top:1px solid var(--panel-bd); font-size:12px; color:var(--muted); }
.lbtn{ cursor:pointer; color:var(--txt); background:transparent; border:1px solid var(--panel-bd); border-radius:9px; padding:7px 13px; font-size:13px; }
.dev-logo{ height:16px; opacity:.85; vertical-align:middle; }
[data-theme="light"] .logo-dark{ display:none; } [data-theme="dark"] .logo-light{ display:none; }
</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>
<div id="meterline"></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 (no logging)"><small>PLAY</small></button>
<button class="tbtn prac" id="bPrac" title="Practice (logs the session)">⦿<small>PRACTICE</small></button>
<button class="tbtn" id="bUp" title="Tempo +1">+</button>
</div>
<button class="tap" id="bTap" title="Tap tempo">TAP TEMPO</button>
</div>
</div>
<div id="logbar"><span class="grow">▴ Practice log <span id="logCount"></span></span></div>
</div>
<!-- practice-log bottom sheet -->
<div id="scrim"></div>
<div id="logsheet">
<div class="grab"></div>
<div class="lhead"><h2>Practice log</h2><span class="sub" id="logFor"></span></div>
<div id="logBody"></div>
<div class="lfoot">
<span><img class="dev-logo logo-dark" src="data:image/png;base64,@BUILD:logo-dark@" alt="VARASYS" /><img class="dev-logo logo-light" src="data:image/png;base64,@BUILD:logo-light@" alt="VARASYS" /> &nbsp;PolyMeter <span id="appVersion"></span></span>
<button class="lbtn" id="logClear">Clear this track</button>
</div>
</div>
<script>
const APP_VERSION = "v0.0.1-dev";
const $ = (id) => document.getElementById(id);
const LS_LOGS = "metronome.logs", 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){} }
/* ========================= 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;
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(),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;
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){}
}
function startAudio(){
ensureAudio(); unlockAudio(); audioCtx.resume(); state.running=true;
if(ramp.on) setBpm(ramp.startBpm);
const t0=audioCtx.currentTime+0.08;
for(const m of meters){ m.tick=0; m.nextTime=t0; m.vq=[]; m.vqPtr=0; m.currentStep=-1; m.currentBar=0; }
masterBeat=0; masterBeatTime=t0; muteWindows=[]; pendingAdvance=false; lastBeatKey=-1;
schedulerTimer=setInterval(scheduler,LOOKAHEAD_MS); scheduler(); renderAll();
requestWake();
}
function stopAudio(){ logFinalize(); state.running=false; clearInterval(schedulerTimer); schedulerTimer=null; pendingAdvance=false; for(const m of meters) m.currentStep=-1; renderAll(); releaseWake(); }
/* separate transports: Play = run only · Practice = run AND record to the practice log */
let practicing=false, nowPlaying=null;
function startRun(log){ practicing=log; startAudio(); if(log) nowPlaying={at:Date.now(),name:currentName()}; renderAll(); }
function playBtn(){ if(state.running){ if(!practicing) stopAudio(); } else startRun(false); }
function practiceBtn(){ if(state.running){ if(practicing) stopAudio(); } else startRun(true); }
function toggle(){ state.running ? stopAudio() : startRun(false); } // keyboard/space = plain play
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(practicing) logFinalize(); clearInterval(schedulerTimer); schedulerTimer=null; state.running=false; }
loadSetup(setlist.items[idx]);
if(wasRunning){ startAudio(); if(practicing) nowPlaying={at:Date.now(),name:currentName()}; } else renderAll();
}
function loadSetlistObj(sl){
if(state.running&&practicing) logFinalize();
const wasRunning=state.running;
if(wasRunning){ clearInterval(schedulerTimer); schedulerTimer=null; state.running=false; }
setlist=sl; idx=0; loadSetup(sl.items[0]);
buildSetlistOptions(); buildTrackOptions();
if(wasRunning){ startAudio(); if(practicing) nowPlaying={at:Date.now(),name:currentName()}; } else renderAll();
}
let taps=[];
function tapTempo(){ const now=performance.now(); taps=taps.filter(t=>now-t<2000); taps.push(now);
if(taps.length>=2){ let s=0; for(let i=1;i<taps.length;i++) s+=taps[i]-taps[i-1]; setBpm(60000/(s/(taps.length-1))); ramp.on=false; renderAll(); } }
function nudge(d){ ramp.on=false; setBpm(state.bpm+d); renderAll(); }
/* ========================= PRACTICE LOG ====================================== */
let historyName=null;
function snapshotLanes(){ return meters.map(m=>({groupsStr:m.groupsStr,stepsPerBeat:m.stepsPerBeat,sound:m.sound,enabled:m.enabled,poly:m.poly,swing:!!m.swing,gainDb:m.gainDb||0,beatsOn:m.beatsOn.slice()})); }
function fmtDur(sec){ sec=Math.round(sec); const m=Math.floor(sec/60); return m+":"+String(sec%60).padStart(2,"0"); }
function logFinalize(){
if(!nowPlaying) return;
const dur=(Date.now()-nowPlaying.at)/1000;
if(dur>=3){ const logs=lsGet(LS_LOGS,[]); logs.unshift({at:nowPlaying.at,name:nowPlaying.name,durationSec:dur,bpm:state.bpm,lanes:snapshotLanes()}); lsSet(LS_LOGS,logs); } // skip sub-3s blips
nowPlaying=null; renderLog();
}
function renderLog(){
const entries=lsGet(LS_LOGS,[]).filter(e=>e.name===historyName);
$("logCount").innerHTML = entries.length ? ("· <b>"+entries.length+"</b> for this track") : "";
$("logFor").textContent = historyName ? ("“"+historyName+"”") : "";
const box=$("logBody"); box.innerHTML="";
if(!entries.length){ box.innerHTML='<div class="lempty">No sessions for this track yet. Hit <b>Practice</b> to record one — past sessions show here so you can compare BPM &amp; duration over time.</div>'; return; }
entries.forEach(e=>{
const row=document.createElement("div"); row.className="hist-row";
const t=document.createElement("span"); t.className="t"; t.textContent=new Date(e.at).toLocaleString()+" · "+fmtDur(e.durationSec)+" @ "+e.bpm+" bpm";
const del=document.createElement("button"); del.className="hist-del"; del.textContent="✕"; del.title="delete this entry";
del.onclick=()=>{ const logs=lsGet(LS_LOGS,[]).filter(x=>!(x.at===e.at&&x.name===e.name)); lsSet(LS_LOGS,logs); renderLog(); };
row.appendChild(t); row.appendChild(del); box.appendChild(row);
});
}
$("logClear").onclick=()=>{ if(!historyName) return; if(!confirm("Clear all practice-log sessions for “"+historyName+"”?")) return;
lsSet(LS_LOGS, lsGet(LS_LOGS,[]).filter(e=>e.name!==historyName)); renderLog(); };
function openLog(){ renderLog(); $("scrim").classList.add("open"); $("logsheet").classList.add("open"); }
function closeLog(){ $("scrim").classList.remove("open"); $("logsheet").classList.remove("open"); }
$("logbar").onclick=openLog; $("scrim").onclick=closeLog;
/* ========================= 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")); } // a hash/share-loaded list not in the menus
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 renderInfo(){
if(!editingBpm) $("bpm").textContent=state.bpm;
const m=meters[0];
$("meterline").textContent = m ? (m.beatsPerBar + " beats" + (m.groups.length>1 ? " · " + m.groups.join("+") : "") + (m.swing?" · swing":"")) : "";
const ts=$("trkSel"); if(ts && ts.value!==String(idx)) ts.value=String(idx);
const name=currentName();
if(name!==historyName){ historyName=name; renderLog(); }
}
function renderTransport(){
const r=state.running;
$("bPlay").classList.toggle("on", r&&!practicing); $("bPrac").classList.toggle("on", r&&practicing);
$("bPlay").innerHTML = (r&&!practicing) ? "■<small>STOP</small>" : "▶<small>PLAY</small>";
$("bPrac").innerHTML = (r&&practicing) ? "■<small>STOP</small>" : "⦿<small>PRACTICE</small>";
$("bPlay").disabled = r&&practicing; $("bPrac").disabled = r&&!practicing;
}
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); }
function draw(){
// latency-compensated playhead so visuals land when the click is HEARD (see engine note)
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)); } }
}
requestAnimationFrame(draw);
}
/* ========================= BPM tap-to-edit + drag-to-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, startY=0, startBpm=120;
p.addEventListener("pointerdown",(e)=>{ if(editingBpm) return; dragging=true; moved=false; startY=e.clientY; startBpm=state.bpm; p.setPointerCapture(e.pointerId); });
p.addEventListener("pointermove",(e)=>{ if(!dragging) return; const dy=startY-e.clientY; if(Math.abs(dy)>6) moved=true; if(moved){ ramp.on=false; setBpm(startBpm+dy*0.5); renderAll(); } });
p.addEventListener("pointerup",(e)=>{ if(!dragging) return; dragging=false; try{p.releasePointerCapture(e.pointerId);}catch(_){} if(!moved) openBpmEdit(); });
p.addEventListener("pointercancel",()=>{ dragging=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; }
}
/* ========================= WIRING ============================================ */
$("bPlay").onclick=playBtn; $("bPrac").onclick=practiceBtn;
$("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);
$("bTap").onclick=tapTempo;
$("vol").oninput=(e)=>{ state.volume=(+e.target.value)/100; if(masterGain) masterGain.gain.value=state.volume; };
/* 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(()=>{}); }); }
/* ========================= 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(); toggle(); }
else if(k==="ArrowRight"){ e.preventDefault(); gotoItem(idx+1,state.running); }
else if(k==="ArrowLeft"){ e.preventDefault(); gotoItem(idx-1,state.running); }
else if(k==="ArrowUp"){ e.preventDefault(); nudge(e.shiftKey?10:1); }
else if(k==="ArrowDown"){ e.preventDefault(); nudge(e.shiftKey?-10:-1); }
else if(k==="t"||k==="T") tapTempo();
else if(k==="f"||k==="F") toggleFS();
});
/* ========================= INIT ============================================== */
let loadedFromHash=false;
if(location.hash && /(p|sl)=/.test(location.hash)) loadedFromHash=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();
requestAnimationFrame(draw);
</script>
</body>
</html>