Bigger rework of the mobile player around a new "practice session" concept,
plus a second page to review sessions.
Transport / sessions:
- Practice now starts a continuous SESSION clock and begins practicing the
current track. While practicing, the Play button becomes Stop and Practice
becomes Pause, so Practice starts/stops individual tracks while the session
clock keeps running. Stop (the Play button) ends the session and records it.
- Plain Play still runs the metronome with no session/recording.
- Each track-practice is one segment {name, at, sec, bpm}; sub-3s blips are
skipped. A session = {at, endedAt, clockSec, note, segments[]} stored under
metronome.sessions (replaces the old per-track metronome.logs sheet).
- Switching track / set list mid-session rolls the current segment over.
Display:
- Removed the Tap Tempo button; the BPM display now does it: tap = tap tempo,
hold = type an exact value, vertical drag = scrub.
- Detail panel shows every lane (canonical share-token chips, disabled lanes
struck through) and the active features: bar count, end behavior, ramp, and
gaps (trainer play/mute).
- Meter line shows live bar count with total (e.g. "bar 4 / 16") and elapsed
play time; the bottom bar shows live session time + track count while
recording, and links to the sessions page otherwise.
New page mobile-sessions.html: lists saved sessions, each with an editable note
(autosaved) and an aggregate table of tracks practiced in that session
(track - time - plays - bpm range), with per-session delete. PWA scope widened
to /mobile so both pages stay in the installed app + offline (SW v2).
Engine untouched; conformance suite unaffected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
518 lines
30 KiB
HTML
518 lines
30 KiB
HTML
<!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");
|
||
}
|
||
function renderInfo(){
|
||
if(!editingBpm) $("bpm").textContent=state.bpm;
|
||
const ts=$("trkSel"); if(ts && ts.value!==String(idx)) ts.value=String(idx);
|
||
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>
|