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>
This commit is contained in:
Me Here 2026-06-07 08:52:41 -05:00
parent a3a09bc77d
commit dca2a405f7

View file

@ -59,21 +59,27 @@
#app{ position:fixed; inset:0; overflow:hidden; display:flex; flex-direction:column; #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)) 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)); } max(10px,env(safe-area-inset-bottom)) max(12px,env(safe-area-inset-left)); }
/* ---- top bar ---- */ /* ---- top: set-list + track dropdowns, volume, theme/fullscreen ---- */
#bar{ flex:0 0 auto; display:flex; align-items:center; gap:10px; min-height:44px; } #top{ flex:0 0 auto; display:flex; flex-direction:column; gap:8px; }
#bar .id{ flex:1 1 auto; min-width:0; line-height:1.15; } .sels{ display:flex; gap:8px; }
#bar .nm{ font-size:clamp(15px,2.7vmin,22px); font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } .sel{ flex:1 1 0; min-width:0; display:flex; flex-direction:column; gap:3px; }
#bar .sub{ font-size:clamp(11px,1.9vmin,14px); color:var(--muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } .sel > span{ font-size:10px; letter-spacing:.14em; text-transform:uppercase; color:var(--muted); padding-left:3px; }
.icon{ flex:0 0 auto; width:44px; height:44px; border-radius:50%; display:flex; align-items:center; justify-content:center; .sel select{ width:100%; background:var(--field-bg); color:var(--txt); border:1px solid var(--field-bd);
font-size:19px; line-height:1; cursor:pointer; color:var(--txt); 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); } background:rgba(127,139,154,.14); border:1px solid var(--panel-bd); }
.icon:active{ background:rgba(127,139,154,.30); } .icon:active{ background:rgba(127,139,154,.30); }
/* ---- stage: beats + pulse ---- */ /* ---- 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; #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); } 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%; } #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%; .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; } background:var(--led-off); border:1px solid rgba(0,0,0,.35); transition:background .05s, box-shadow .05s, transform .05s; }
@ -81,89 +87,101 @@
.dot.on{ background:var(--cyan); box-shadow:0 0 12px var(--glow); transform:scale(1.12); } .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); } .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%; #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; 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%); 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; transition:transform .12s ease-out, box-shadow .12s ease-out, border-color .12s ease-out;
touch-action:none; cursor:ns-resize; } 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{ 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); } #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; } #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; } #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; #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; background:transparent; color:var(--txt); border:none; border-bottom:2px solid var(--cyan); outline:none;
font-variant-numeric:tabular-nums; -moz-appearance:textfield; } font-variant-numeric:tabular-nums; -moz-appearance:textfield; }
#bpmIn::-webkit-outer-spin-button, #bpmIn::-webkit-inner-spin-button{ -webkit-appearance:none; margin:0; } #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; } #meterline{ font-size:clamp(12px,2.1vmin,17px); color:var(--muted); min-height:1.2em; letter-spacing:.02em; }
/* ---- transport ---- */ /* ---- 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(8px,1.6vmin,14px); #transport{ flex:0 0 auto; display:flex; flex-direction:column; align-items:center; gap:clamp(7px,1.4vmin,12px);
padding-top:clamp(8px,1.6vmin,16px); width:100%; } padding-top:clamp(6px,1.2vmin,12px); width:100%; }
/* buttons SHARE the row width (flex:1) so 5 of them never overflow a narrow phone — they .tgrid{ display:grid; width:100%; max-width:560px; gap:clamp(6px,1.7vmin,13px);
just get narrower; capped by max-width so they don't sprawl on a tablet. */ grid-template-columns:1fr 1.5fr 1.5fr 1fr;
.row{ display:flex; align-items:center; justify-content:center; gap:clamp(6px,2vmin,16px); grid-template-areas:"dn10 prev next up10" "dn play prac up"; }
width:100%; max-width:560px; } .tbtn{ background:linear-gradient(180deg,var(--btn1),var(--btn2)); color:var(--txt); border:1px solid var(--btn-bd);
.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(50px,12.5vmin,76px); font-size:clamp(18px,4.6vmin,28px); cursor:pointer;
border-radius:14px; height:clamp(56px,15vmin,84px); font-size:clamp(20px,5vmin,30px); box-shadow:0 3px 0 rgba(0,0,0,.28), inset 0 1px 0 rgba(255,255,255,.06);
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; }
display:flex; align-items:center; justify-content:center; } .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: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: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.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; .tbtn.prac{ background:linear-gradient(180deg,#1c87b8,#136488); border-color:#1f9bd0; color:#eaf8ff; }
padding:9px 22px; font-size:clamp(12px,2.2vmin,15px); letter-spacing:.16em; cursor:pointer; } .tbtn.prac.on{ background:linear-gradient(180deg,#b23b3b,#8f2d2d); border-color:#c0392b; color:#fff; }
.tap:active{ color:var(--txt); background:rgba(127,139,154,.14); } .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); }
/* shorter viewports (landscape phones): tighten so it all fits */ /* landscape phones: pulse on the left, transport on the right (use width, not height) */
@media (max-height:540px){ @media (orientation:landscape) and (max-height:600px){
#stage{ gap:clamp(8px,2vmin,18px); } #mid{ flex-direction:row; align-items:center; gap:3vw; }
#bpmlab{ margin-top:.4em; } #stage{ flex:1 1 52%; gap:clamp(8px,2vmin,18px); }
.tbtn{ height:clamp(48px,18vmin,70px); } #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); }
} }
/* ---- menu sheet ---- */ /* ---- 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{ 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; } #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; #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; 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); 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)); } padding:14px max(16px,env(safe-area-inset-right)) max(18px,env(safe-area-inset-bottom)) max(16px,env(safe-area-inset-left)); }
#sheet.open{ transform:none; } #logsheet.open{ transform:none; }
#sheet .grab{ width:42px; height:5px; border-radius:3px; background:var(--panel-bd); margin:0 auto 12px; } #logsheet .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; } #logsheet .lhead{ display:flex; align-items:baseline; justify-content:space-between; gap:10px; margin-bottom:4px; }
#sheet label{ display:block; font-size:12px; color:var(--muted); margin:14px 0 5px; } #logsheet h2{ margin:0; font-size:15px; }
#sheet select, #sheet textarea, #sheet input[type=range]{ width:100%; } #logsheet .sub{ font-size:12px; color:var(--muted); }
#sheet select{ background:var(--field-bg); color:var(--txt); border:1px solid var(--field-bd); border-radius:10px; padding:11px; font-size:15px; } .hist-row{ display:flex; align-items:center; gap:10px; padding:9px 2px; border-bottom:1px solid var(--panel-bd); font-size:13px; }
#sheet textarea{ background:var(--field-bg); color:var(--txt); border:1px solid var(--field-bd); border-radius:10px; padding:11px; .hist-row .t{ flex:1 1 auto; font-variant-numeric:tabular-nums; }
font-family:"Courier New",monospace; font-size:13px; resize:vertical; min-height:56px; } .hist-del{ flex:0 0 auto; background:transparent; border:1px solid var(--panel-bd); color:var(--muted);
.srow{ display:flex; gap:10px; align-items:center; margin-top:10px; } border-radius:8px; width:30px; height:30px; cursor:pointer; }
.ld{ flex:0 0 auto; cursor:pointer; color:#eafff3; background:linear-gradient(180deg,#1f7a4d,#155f3b); border:1px solid #2e7d32; .lempty{ font-size:13px; color:var(--muted); padding:10px 2px; }
border-radius:10px; padding:11px 18px; font-size:15px; } .lfoot{ display:flex; align-items:center; justify-content:space-between; margin-top:14px; padding-top:12px;
#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); } border-top:1px solid var(--panel-bd); font-size:12px; color:var(--muted); }
.sheet-foot a{ color:var(--link); text-decoration:none; } .lbtn{ cursor:pointer; color:var(--txt); background:transparent; border:1px solid var(--panel-bd); border-radius:9px; padding:7px 13px; font-size:13px; }
#installBtn{ display:none; cursor:pointer; color:var(--txt); background:transparent; border:1px solid var(--panel-bd); .dev-logo{ height:16px; opacity:.85; vertical-align:middle; }
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; } [data-theme="light"] .logo-dark{ display:none; } [data-theme="dark"] .logo-light{ display:none; }
</style> </style>
</head> </head>
<body> <body>
<div id="app"> <div id="app">
<div id="bar"> <div id="top">
<div class="icon" id="menuBtn" title="Menu" aria-label="Menu"></div> <div class="sels">
<div class="id"> <label class="sel"><span>Set list</span><select id="slSel"></select></label>
<div class="nm" id="mName"></div> <label class="sel"><span>Track</span><select id="trkSel"></select></label>
<div class="sub"><span id="mPos">/</span></div>
</div> </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="themeBtn" title="Theme" aria-label="Theme"></div>
<div class="icon" id="fsBtn" title="Full screen" aria-label="Full screen"></div> <div class="icon" id="fsBtn" title="Full screen" aria-label="Full screen"></div>
</div> </div>
</div>
<div id="mid">
<div id="stage"> <div id="stage">
<div id="beats"></div> <div id="beats"></div>
<div id="pulse"> <div id="pulse">
@ -175,41 +193,41 @@
</div> </div>
<div id="transport"> <div id="transport">
<div class="row"> <div class="tgrid">
<button class="tbtn" id="bDown" title="Tempo "></button> <button class="tbtn" id="bDn10" title="Tempo 10">10</button>
<button class="tbtn" id="bPrev" title="Previous"></button> <button class="tbtn" id="bPrev" title="Previous track"></button>
<button class="tbtn play" id="bPlay" title="Play / Stop"></button> <button class="tbtn" id="bNext" title="Next track"></button>
<button class="tbtn" id="bNext" title="Next"></button> <button class="tbtn" id="bUp10" title="Tempo +10">+10</button>
<button class="tbtn" id="bUp" title="Tempo +">+</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> </div>
<button class="tap" id="bTap" title="Tap tempo">TAP TEMPO</button> <button class="tap" id="bTap" title="Tap tempo">TAP TEMPO</button>
</div> </div>
</div> </div>
<!-- bottom sheet: load / set lists / volume / install --> <div id="logbar"><span class="grow">▴ Practice log <span id="logCount"></span></span></div>
<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> </div>
<label for="vol">Volume</label>
<div class="vol">🔈<input id="vol" type="range" min="0" max="100" value="85" />🔊</div> <!-- practice-log bottom sheet -->
<div class="sheet-foot"> <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> <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> <button class="lbtn" id="logClear">Clear this track</button>
</div> </div>
</div> </div>
<script> <script>
const APP_VERSION = "v0.0.1-dev"; const APP_VERSION = "v0.0.1-dev";
const $ = (id) => document.getElementById(id); 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) ======= */ /* ========================= ENGINE (synth voices only; shared scheduler) ======= */
const SAMPLES = {}; const SAMPLES = {};
@ -245,7 +263,9 @@ function scheduler(){
/* ========================= PLAYER ============================================= */ /* ========================= PLAYER ============================================= */
let setlist=null, idx=0; 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) })) })); 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){ function buildMeters(lanes){
return (lanes||[]).map(c=>{ return (lanes||[]).map(c=>{
@ -278,23 +298,89 @@ function startAudio(){
schedulerTimer=setInterval(scheduler,LOOKAHEAD_MS); scheduler(); renderAll(); schedulerTimer=setInterval(scheduler,LOOKAHEAD_MS); scheduler(); renderAll();
requestWake(); requestWake();
} }
function stopAudio(){ state.running=false; clearInterval(schedulerTimer); schedulerTimer=null; pendingAdvance=false; for(const m of meters) m.currentStep=-1; renderAll(); releaseWake(); } function stopAudio(){ logFinalize(); 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(); }
/* 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){ function gotoItem(i,keepPlaying){
if(!setlist||!setlist.items.length) return; if(!setlist||!setlist.items.length) return;
const n=setlist.items.length; idx=((i%n)+n)%n; const n=setlist.items.length; idx=((i%n)+n)%n;
const wasRunning=state.running||keepPlaying; const wasRunning=state.running||keepPlaying;
if(state.running){ clearInterval(schedulerTimer); schedulerTimer=null; state.running=false; } if(state.running){ if(practicing) logFinalize(); clearInterval(schedulerTimer); schedulerTimer=null; state.running=false; }
loadSetup(setlist.items[idx]); loadSetup(setlist.items[idx]);
if(wasRunning) startAudio(); else renderAll(); 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();
} }
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=[]; let taps=[];
function tapTempo(){ const now=performance.now(); taps=taps.filter(t=>now-t<2000); taps.push(now); 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(); } } 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(); } 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 ============================================ */ /* ========================= RENDER ============================================ */
function rebuildBeats(){ function rebuildBeats(){
const box=$("beats"); box.innerHTML=""; const box=$("beats"); box.innerHTML="";
@ -308,13 +394,21 @@ function renderBeats(){
for(let i=0;i<els.length;i++) els[i].classList.toggle("on", i===cur); for(let i=0;i<els.length;i++) els[i].classList.toggle("on", i===cur);
} }
function renderInfo(){ function renderInfo(){
if(editingBpm) { /* leave the input alone */ } else $("bpm").textContent=state.bpm; if(!editingBpm) $("bpm").textContent=state.bpm;
$("mName").textContent=setlist? (setlist.items[idx].name||"—") : "—";
$("mPos").textContent=setlist? "♪ "+(idx+1)+"/"+setlist.items.length : "/";
const m=meters[0]; const m=meters[0];
$("meterline").textContent = m ? (m.beatsPerBar + " beats" + (m.groups.length>1 ? " · " + m.groups.join("+") : "") + (m.swing?" · swing":"")) : ""; $("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 renderAll(){ renderInfo(); renderBeats(); $("bPlay").textContent=state.running?"■":"▶"; $("bPlay").classList.toggle("on",state.running); } 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; 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"); function pulseHit(acc){ const p=$("pulse"); p.classList.remove("hit","acc"); void p.offsetWidth; p.classList.add("hit"); if(acc) p.classList.add("acc");
@ -336,66 +430,37 @@ function draw(){
/* ========================= BPM tap-to-edit + drag-to-scrub ==================== */ /* ========================= BPM tap-to-edit + drag-to-scrub ==================== */
let editingBpm=false; 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 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(); } 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("keydown",(e)=>{ if(e.key==="Enter"){ closeBpmEdit(true); } else if(e.key==="Escape"){ closeBpmEdit(false); } });
$("bpmIn").addEventListener("blur",()=>{ if(editingBpm) closeBpmEdit(true); }); $("bpmIn").addEventListener("blur",()=>{ if(editingBpm) closeBpmEdit(true); });
(function(){ // pointer drag on the pulse = scrub tempo; a clean tap (no drag) = type a value (function(){ const p=$("pulse"); let dragging=false, moved=false, startY=0, startBpm=120;
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("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("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("pointerup",(e)=>{ if(!dragging) return; dragging=false; try{p.releasePointerCapture(e.pointerId);}catch(_){} if(!moved) openBpmEdit(); });
p.addEventListener("pointercancel",()=>{ dragging=false; }); p.addEventListener("pointercancel",()=>{ dragging=false; });
})(); })();
/* ========================= LOAD / SET LISTS ================================== */ /* ========================= HASH SHARE-LINK LOADING =========================== */
function setStatus(msg,ok){ const s=$("status"); s.textContent=msg||""; s.className=ok===undefined?"":(ok?"ok":"err"); } function loadFromHash(text){
function loadConfig(text,quiet){ let payload=text, kind=null; const m=text.match(/[#?&](p|sl)=([^&\s]+)/);
text=(text||"").trim(); if(!text){ if(!quiet) setStatus("Paste a patch or set-list code first.",false); return false; } if(m){ kind=m[1]; payload=m[2]; } try{ payload=decodeURIComponent(payload); }catch(e){}
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{ try{
if(kind==="sl" || (kind!=="p" && !/[;:]/.test(payload))){ if(kind==="sl" || (kind!=="p" && !/[;:]/.test(payload))){
const sl=codeToSetlist(payload); const sl=codeToSetlist(payload); if(!sl.items.length) throw 0;
if(!sl.items.length) throw new Error("set list has no items"); slKey=""; transientTitle=sl.title||"Shared set list"; loadSetlistObj(sl); return true;
loadSetlistObj(sl);
setStatus("✓ Loaded “"+sl.title+"” — "+sl.items.length+" item(s).",true); return true;
} }
const setup=patchToSetup(payload); const setup=patchToSetup(payload); if(!setup.lanes.length) throw 0;
if(!setup.lanes.length) throw new Error("no valid lanes (need e.g. kick:4)"); slKey=""; transientTitle="Shared patch"; loadSetlistObj({title:"Shared patch",items:[{name:"Patch",...setup}]}); return true;
loadSetlistObj({title:"Pasted patch",items:[{name:"Patch",...setup}]}); }catch(e){ return false; }
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 ======================================== */ /* ========================= WIRING ============================================ */
function openSheet(){ loadStored(); $("scrim").classList.add("open"); $("sheet").classList.add("open"); } $("bPlay").onclick=playBtn; $("bPrac").onclick=practiceBtn;
function closeSheet(){ $("scrim").classList.remove("open"); $("sheet").classList.remove("open"); } $("bPrev").onclick=()=>gotoItem(idx-1,state.running); $("bNext").onclick=()=>gotoItem(idx+1,state.running);
$("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); $("bUp").onclick=()=>nudge(+1); $("bDown").onclick=()=>nudge(-1);
$("bUp10").onclick=()=>nudge(+10); $("bDn10").onclick=()=>nudge(-10);
$("bTap").onclick=tapTempo; $("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 */ /* theme toggle (shared "metronome.theme") + version stamp */
/*@BUILD:include:src/chrome.js@*/ /*@BUILD:include:src/chrome.js@*/
@ -408,19 +473,14 @@ const fsEl=()=>document.fullscreenElement||document.webkitFullscreenElement;
let wakeLock=null; let wakeLock=null;
async function requestWake(){ try{ if(navigator.wakeLock) wakeLock=await navigator.wakeLock.request("screen"); }catch(e){} } 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 releaseWake(){ try{ wakeLock&&wakeLock.release(); }catch(e){} wakeLock=null; }
function toggleFS(){ function toggleFS(){ if(fsEl()){ if(exitFS) try{ exitFS.call(document); }catch(e){} } else if(reqFS){ try{ reqFS.call(docEl); }catch(e){} } }
if(fsEl()){ if(exitFS) try{ exitFS.call(document); }catch(e){} }
else if(reqFS){ try{ reqFS.call(docEl); }catch(e){} }
}
$("fsBtn").onclick=toggleFS; $("fsBtn").onclick=toggleFS;
if(window.EMBED) $("fsBtn").style.display="none"; // pointless inside the gallery iframe if(window.EMBED) $("fsBtn").style.display="none";
document.addEventListener("visibilitychange",()=>{ if(document.visibilityState==="visible"&&state.running) requestWake(); }); document.addEventListener("visibilitychange",()=>{ if(document.visibilityState==="visible"&&state.running) requestWake(); });
/* ========================= PWA: install + service worker ===================== */ /* ========================= PWA: install + service worker ===================== */
let deferredPrompt=null; let deferredPrompt=null;
addEventListener("beforeinstallprompt",(e)=>{ e.preventDefault(); deferredPrompt=e; if(!window.EMBED) $("installBtn").style.display="inline-block"; }); addEventListener("beforeinstallprompt",(e)=>{ e.preventDefault(); deferredPrompt=e; });
$("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(()=>{}); }); } if(!window.EMBED && "serviceWorker" in navigator){ addEventListener("load",()=>{ navigator.serviceWorker.register("/mobile-sw.js").catch(()=>{}); }); }
/* ========================= KEYBOARD (desktop testing) ======================== */ /* ========================= KEYBOARD (desktop testing) ======================== */
@ -437,9 +497,9 @@ addEventListener("keydown",(e)=>{
}); });
/* ========================= INIT ============================================== */ /* ========================= INIT ============================================== */
loadStored(); let loadedFromHash=false;
if(location.hash && /(p|sl)=/.test(location.hash)) loadConfig(location.hash, true); if(location.hash && /(p|sl)=/.test(location.hash)) loadedFromHash=loadFromHash(location.hash);
if(!setlist){ setlist=BUILTIN[0]; idx=0; loadSetup(setlist.items[0]); } if(!setlist){ slKey="b0"; setlist=BUILTIN[0]; idx=0; loadSetup(setlist.items[0]); buildSetlistOptions(); buildTrackOptions(); }
$("vol").value=Math.round(state.volume*100); $("vol").value=Math.round(state.volume*100);
renderAll(); renderAll();
requestAnimationFrame(draw); requestAnimationFrame(draw);