metronome/mobile.html
Me Here a3a09bc77d pm-mobile: touch-first phone/tablet PWA player (mobile.html)
A new full-screen, touch-first edition of the player aimed at phones through
tablets - no native app, just a web page you can "Add to Home Screen".

Reuses the shared engine + look-ahead scheduler (same player loop as
player.html); new UI is a big pulsing beat display, beat-dot row with accent
grouping, huge BPM (tap to type, vertical drag to scrub), prev/play/next +/-
and tap-tempo, and a bottom sheet for set lists / patch+link loading / volume.

Mobile concerns handled:
- iOS ring/silent switch: navigator.audioSession.type="playback" + a silent
  buffer warmup inside the play gesture, so audio isn't muted by the switch.
- Screen Wake Lock while running (re-acquired on visibilitychange).
- PWA: manifest.webmanifest + apple-touch meta + mobile-sw.js (network-first
  app shell, passthrough for everything else) -> installable + offline.
  Multi-file is fine here since it targets mobile (waives the single-file rule).
- viewport-fit=cover + safe-area insets, no user zoom, touch-action:manipulation,
  overscroll-behavior:none; transport buttons flex-share the row so they never
  overflow a narrow phone; responsive portrait/landscape, phone->tablet.
- Fullscreen toggle where supported (Android/desktop; iOS uses home-screen PWA).

Wired into build.sh + deploy.sh (page + PWA assets) and added to the index
gallery as PM_M-1 Mobile. New metronome app icons generated in assets/.
Conformance suite unaffected (engine untouched): 47 pass, 1 known.

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

448 lines
26 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(12px,env(safe-area-inset-bottom)) max(12px,env(safe-area-inset-left)); }
/* ---- top bar ---- */
#bar{ flex:0 0 auto; display:flex; align-items:center; gap:10px; min-height:44px; }
#bar .id{ flex:1 1 auto; min-width:0; line-height:1.15; }
#bar .nm{ font-size:clamp(15px,2.7vmin,22px); font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
#bar .sub{ font-size:clamp(11px,1.9vmin,14px); color:var(--muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.icon{ flex:0 0 auto; width:44px; height:44px; border-radius:50%; display:flex; align-items:center; justify-content:center;
font-size:19px; 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); }
/* ---- stage: beats + pulse ---- */
#stage{ flex:1 1 auto; min-height:0; display:flex; flex-direction:column; align-items:center; justify-content:center;
gap:clamp(14px,3.4vmin,40px); }
#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(190px,48vmin,460px); height:clamp(190px,48vmin,460px); 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(58px,19vmin,180px); 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(50px,16vmin,150px); 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 ---- */
#transport{ flex:0 0 auto; display:flex; flex-direction:column; align-items:center; gap:clamp(8px,1.6vmin,14px);
padding-top:clamp(8px,1.6vmin,16px); width:100%; }
/* buttons SHARE the row width (flex:1) so 5 of them never overflow a narrow phone — they
just get narrower; capped by max-width so they don't sprawl on a tablet. */
.row{ display:flex; align-items:center; justify-content:center; gap:clamp(6px,2vmin,16px);
width:100%; max-width:560px; }
.tbtn{ flex:1 1 0; min-width:0; background:linear-gradient(180deg,var(--btn1),var(--btn2)); color:var(--txt); border:1px solid var(--btn-bd);
border-radius:14px; height:clamp(56px,15vmin,84px); font-size:clamp(20px,5vmin,30px);
cursor:pointer; box-shadow:0 3px 0 rgba(0,0,0,.28), inset 0 1px 0 rgba(255,255,255,.06);
display:flex; align-items:center; justify-content:center; }
.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.play{ flex:1.6 1 0; 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; }
.tap{ background:transparent; color:var(--muted); border:1px solid var(--panel-bd); border-radius:12px;
padding:9px 22px; font-size:clamp(12px,2.2vmin,15px); letter-spacing:.16em; cursor:pointer; }
.tap:active{ color:var(--txt); background:rgba(127,139,154,.14); }
/* shorter viewports (landscape phones): tighten so it all fits */
@media (max-height:540px){
#stage{ gap:clamp(8px,2vmin,18px); }
#bpmlab{ margin-top:.4em; }
.tbtn{ height:clamp(48px,18vmin,70px); }
}
/* ---- menu sheet ---- */
#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; }
#sheet{ position:fixed; left:0; right:0; bottom:0; z-index:50; max-height:86vh; 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(20px,env(safe-area-inset-bottom)) max(16px,env(safe-area-inset-left)); }
#sheet.open{ transform:none; }
#sheet .grab{ width:42px; height:5px; border-radius:3px; background:var(--panel-bd); margin:0 auto 12px; }
#sheet h2{ margin:2px 0 4px; font-size:15px; }
#sheet label{ display:block; font-size:12px; color:var(--muted); margin:14px 0 5px; }
#sheet select, #sheet textarea, #sheet input[type=range]{ width:100%; }
#sheet select{ background:var(--field-bg); color:var(--txt); border:1px solid var(--field-bd); border-radius:10px; padding:11px; font-size:15px; }
#sheet textarea{ background:var(--field-bg); color:var(--txt); border:1px solid var(--field-bd); border-radius:10px; padding:11px;
font-family:"Courier New",monospace; font-size:13px; resize:vertical; min-height:56px; }
.srow{ display:flex; gap:10px; align-items:center; margin-top:10px; }
.ld{ flex:0 0 auto; cursor:pointer; color:#eafff3; background:linear-gradient(180deg,#1f7a4d,#155f3b); border:1px solid #2e7d32;
border-radius:10px; padding:11px 18px; font-size:15px; }
#status{ margin-top:10px; font-size:12px; min-height:1.2em; font-family:"Courier New",monospace; color:var(--muted); }
#status.ok{ color:#5fd08a; } #status.err{ color:#ff7a7a; }
.vol{ display:flex; align-items:center; gap:12px; }
.sheet-foot{ display:flex; align-items:center; justify-content:space-between; margin-top:18px; padding-top:12px;
border-top:1px solid var(--panel-bd); font-size:12px; color:var(--muted); }
.sheet-foot a{ color:var(--link); text-decoration:none; }
#installBtn{ display:none; cursor:pointer; color:var(--txt); background:transparent; border:1px solid var(--panel-bd);
border-radius:9px; padding:7px 14px; font-size:13px; }
.dev-logo{ height:18px; opacity:.85; }
[data-theme="light"] .logo-dark{ display:none; } [data-theme="dark"] .logo-light{ display:none; }
</style>
</head>
<body>
<div id="app">
<div id="bar">
<div class="icon" id="menuBtn" title="Menu" aria-label="Menu"></div>
<div class="id">
<div class="nm" id="mName"></div>
<div class="sub"><span id="mPos">/</span></div>
</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 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="row">
<button class="tbtn" id="bDown" title="Tempo "></button>
<button class="tbtn" id="bPrev" title="Previous"></button>
<button class="tbtn play" id="bPlay" title="Play / Stop"></button>
<button class="tbtn" id="bNext" title="Next"></button>
<button class="tbtn" id="bUp" title="Tempo +">+</button>
</div>
<button class="tap" id="bTap" title="Tap tempo">TAP TEMPO</button>
</div>
</div>
<!-- bottom sheet: load / set lists / volume / install -->
<div id="scrim"></div>
<div id="sheet">
<div class="grab"></div>
<h2>Load a groove</h2>
<label for="storedSel">Set list</label>
<select id="storedSel"><option value="">— choose a set list —</option></select>
<label for="cfg">…or paste a patch / share link</label>
<textarea id="cfg" spellcheck="false" placeholder="v1;t120;kick:4;snare:4=.X.X;hatClosed:4/2 — or a #p=… / #sl=… link"></textarea>
<div class="srow">
<button class="ld" id="bLoad">Load</button>
<span id="status"></span>
</div>
<label for="vol">Volume</label>
<div class="vol">🔈<input id="vol" type="range" min="0" max="100" value="85" />🔊</div>
<div class="sheet-foot">
<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 id="installBtn">Install app</button>
</div>
</div>
<script>
const APP_VERSION = "v0.0.1-dev";
const $ = (id) => document.getElementById(id);
/* ========================= 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;
const BUILTIN = SEED_SETLISTS.map((sl) => ({ title: sl.title, items: sl.items.map(([n, p]) => ({ name: n, ...patchToSetup(p) })) }));
function buildMeters(lanes){
return (lanes||[]).map(c=>{
const p=parseGroups(c.groupsStr);
return {groupsStr:c.groupsStr,groups:p.groups,beatsPerBar:p.beatsPerBar,groupStarts:p.groupStarts,
stepsPerBeat:c.stepsPerBeat||1,sound:c.sound,beatsOn:(c.beatsOn||[]).slice(),poly:!!c.poly,swing:!!c.swing,enabled:c.enabled!==false,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(){ state.running=false; clearInterval(schedulerTimer); schedulerTimer=null; pendingAdvance=false; for(const m of meters) m.currentStep=-1; renderAll(); releaseWake(); }
function toggle(){ state.running?stopAudio():startAudio(); }
function gotoItem(i,keepPlaying){
if(!setlist||!setlist.items.length) return;
const n=setlist.items.length; idx=((i%n)+n)%n;
const wasRunning=state.running||keepPlaying;
if(state.running){ clearInterval(schedulerTimer); schedulerTimer=null; state.running=false; }
loadSetup(setlist.items[idx]);
if(wasRunning) startAudio(); else renderAll();
}
function loadSetlistObj(sl){ setlist=sl; idx=0; const wasRunning=state.running; if(wasRunning){clearInterval(schedulerTimer);schedulerTimer=null;state.running=false;} loadSetup(sl.items[0]); if(wasRunning) startAudio(); else renderAll(); }
let taps=[];
function tapTempo(){ const now=performance.now(); taps=taps.filter(t=>now-t<2000); taps.push(now);
if(taps.length>=2){ let s=0; for(let i=1;i<taps.length;i++) s+=taps[i]-taps[i-1]; setBpm(60000/(s/(taps.length-1))); ramp.on=false; renderAll(); } }
function nudge(d){ ramp.on=false; setBpm(state.bpm+d); renderAll(); }
/* ========================= RENDER ============================================ */
function 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) { /* leave the input alone */ } else $("bpm").textContent=state.bpm;
$("mName").textContent=setlist? (setlist.items[idx].name||"—") : "—";
$("mPos").textContent=setlist? "♪ "+(idx+1)+"/"+setlist.items.length : "/";
const m=meters[0];
$("meterline").textContent = m ? (m.beatsPerBar + " beats" + (m.groups.length>1 ? " · " + m.groups.join("+") : "") + (m.swing?" · swing":"")) : "";
}
function renderAll(){ renderInfo(); renderBeats(); $("bPlay").textContent=state.running?"■":"▶"; $("bPlay").classList.toggle("on",state.running); }
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){ nudge(0); 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(){ // pointer drag on the pulse = scrub tempo; a clean tap (no drag) = type a value
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; });
})();
/* ========================= LOAD / SET LISTS ================================== */
function setStatus(msg,ok){ const s=$("status"); s.textContent=msg||""; s.className=ok===undefined?"":(ok?"ok":"err"); }
function loadConfig(text,quiet){
text=(text||"").trim(); if(!text){ if(!quiet) setStatus("Paste a patch or set-list code first.",false); return false; }
let payload=text, kind=null;
const m=text.match(/[#?&](p|sl)=([^&\s]+)/);
if(m){ kind=m[1]; payload=m[2]; }
try{ payload=decodeURIComponent(payload); }catch(e){}
try{
if(kind==="sl" || (kind!=="p" && !/[;:]/.test(payload))){
const sl=codeToSetlist(payload);
if(!sl.items.length) throw new Error("set list has no items");
loadSetlistObj(sl);
setStatus("✓ Loaded “"+sl.title+"” — "+sl.items.length+" item(s).",true); return true;
}
const setup=patchToSetup(payload);
if(!setup.lanes.length) throw new Error("no valid lanes (need e.g. kick:4)");
loadSetlistObj({title:"Pasted patch",items:[{name:"Patch",...setup}]});
setStatus("✓ Loaded patch — "+setup.lanes.length+" lane(s), "+setup.bpm+" BPM.",true); return true;
}catch(e){ if(!quiet) setStatus("✗ Invalid: "+e.message,false); return false; }
}
function loadStored(){
let lists=[]; try{ lists=JSON.parse(localStorage.getItem("metronome.setlists")||"[]"); }catch(e){}
const sel=$("storedSel"); sel.innerHTML='<option value="">— choose a set list —</option>';
const og1=document.createElement("optgroup"); og1.label="Built-in";
BUILTIN.forEach((sl,i)=>{ const o=document.createElement("option"); o.value="b"+i; o.textContent=sl.title+" ("+sl.items.length+")"; og1.appendChild(o); });
sel.appendChild(og1);
if(lists.length){ const og2=document.createElement("optgroup"); og2.label="Your saved set lists";
lists.forEach((sl,i)=>{ const o=document.createElement("option"); o.value="s"+i; o.textContent=(sl.title||("Set list "+(i+1)))+" ("+(sl.items?sl.items.length:0)+")"; og2.appendChild(o); });
sel.appendChild(og2); }
sel._lists=lists; sel._builtin=BUILTIN;
}
/* ========================= MENU SHEET ======================================== */
function openSheet(){ loadStored(); $("scrim").classList.add("open"); $("sheet").classList.add("open"); }
function closeSheet(){ $("scrim").classList.remove("open"); $("sheet").classList.remove("open"); }
$("menuBtn").onclick=openSheet; $("scrim").onclick=closeSheet;
$("bLoad").onclick=()=>{ if(loadConfig($("cfg").value)) closeSheet(); };
$("storedSel").onchange=(e)=>{ const v=e.target.value; if(!v) return;
const sl = v[0]==="b" ? e.target._builtin[+v.slice(1)] : e.target._lists[+v.slice(1)]; if(!sl) return;
loadSetlistObj({title:sl.title,items:(sl.items||[]).map(it=>({...it}))}); setStatus("✓ Loaded “"+(sl.title||"set list")+"”.",true); closeSheet(); };
$("vol").oninput=(e)=>{ state.volume=(+e.target.value)/100; if(masterGain) masterGain.gain.value=state.volume; };
/* ========================= TRANSPORT WIRING ================================== */
$("bPlay").onclick=toggle;
$("bPrev").onclick=()=>gotoItem(idx-1,state.running);
$("bNext").onclick=()=>gotoItem(idx+1,state.running);
$("bUp").onclick=()=>nudge(+1); $("bDown").onclick=()=>nudge(-1);
$("bTap").onclick=tapTempo;
/* 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"; // pointless inside the gallery iframe
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) $("installBtn").style.display="inline-block"; });
$("installBtn").onclick=async()=>{ if(!deferredPrompt) return; deferredPrompt.prompt(); await deferredPrompt.userChoice; deferredPrompt=null; $("installBtn").style.display="none"; };
addEventListener("appinstalled",()=>{ $("installBtn").style.display="none"; });
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 ============================================== */
loadStored();
if(location.hash && /(p|sl)=/.test(location.hash)) loadConfig(location.hash, true);
if(!setlist){ setlist=BUILTIN[0]; idx=0; loadSetup(setlist.items[0]); }
$("vol").value=Math.round(state.volume*100);
renderAll();
requestAnimationFrame(draw);
</script>
</body>
</html>