Live-performance set switching, bar-length segments, favicon

Cue/commit model for moving through a set without audible gaps:
- Arrows/Home/End/PgUp/PgDn move an amber cue cursor across set lists.
  Enter commits with a SMOOTH cutover at the next bar; Shift+Enter a
  RUDE cutover at the next beat. N/P are rude quick-steps; Esc cancels.
- One gap-free cutover (armSwitch → scheduler horizon-cap →
  performCutover) replaces the old stop()/start() switch — the audio
  clock stays continuous across every transition (manual or auto).
- Per-item bar length (b<n> patch token + Bars input) with a bar
  countdown; Continue auto-advances at the boundary (cross-list aware).
  Each segment owns its tempo/ramp and resets at the cut.
- Decouple loaded vs viewed list (loadedSL) so the playing item can
  live in a list you're not currently viewing.

Set-list panel: editable name combobox (rename in place; the ▾ menu
lists the set lists with "+ New" last), auto-growing description,
add-item row moved below the list, trimmed hint.

Add an inline SVG favicon (brand-cyan metronome on navy).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Me Here 2026-05-25 10:31:36 -05:00
parent 61c933e44f
commit 7a6aa1d5ba
2 changed files with 283 additions and 91 deletions

View file

@ -39,7 +39,7 @@ goes in a share link, and you can handwrite or edit it.
### Patch grammar ### Patch grammar
``` ```
v1 ; t<bpm> [; vol<pct>] ; <lane> ; <lane> … [; tr<play>/<mute>] [; rmp<start>/<step>/<every>] v1 ; t<bpm> [; vol<pct>] [; cd<sec>] [; b<bars>] ; <lane> … [; tr<play>/<mute>] [; rmp<start>/<step>/<every>]
``` ```
| Token | Meaning | Example | | Token | Meaning | Example |
@ -47,6 +47,8 @@ v1 ; t<bpm> [; vol<pct>] ; <lane> ; <lane> … [; tr<play>/<mute>] [; rmp<start>
| `v1` | format version (always first) | `v1` | | `v1` | format version (always first) | `v1` |
| `t<bpm>` | tempo | `t120` | | `t<bpm>` | tempo | `t120` |
| `vol<pct>` | master volume 0100 | `vol70` | | `vol<pct>` | master volume 0100 | `vol70` |
| `cd<sec>` | time countdown, seconds (auto-advance with Continue) | `cd60` |
| `b<bars>` | segment length in bars (auto-advance with Continue) | `b16` |
| `tr<play>/<mute>` | gap trainer: play N bars, mute M | `tr2/2` | | `tr<play>/<mute>` | gap trainer: play N bars, mute M | `tr2/2` |
| `rmp<start>/<step>/<every>` | tempo ramp: start BPM, ±step, every N bars | `rmp80/5/4` | | `rmp<start>/<step>/<every>` | tempo ramp: start BPM, ±step, every N bars | `rmp80/5/4` |
| `<lane>` | a meter lane (see below) | `kick:4` | | `<lane>` | a meter lane (see below) | `kick:4` |
@ -115,15 +117,36 @@ In the setlist panel's **⋯** menu:
|-----|--------| |-----|--------|
| `Space` | play / stop (works everywhere except while typing in a text field) | | `Space` | play / stop (works everywhere except while typing in a text field) |
| `T` | tap tempo | | `T` | tap tempo |
| `↑` / `↓` | tempo ±1 (`Shift` = ±10) | | `←` / `→` | tempo ±1 (`Shift` = ±10) |
| `A` | add meter lane | | `A` | add meter lane |
| `N` | load next setlist item | | `↑` / `↓` / `Home` / `End` | move the **cue** cursor (crosses set lists) |
| `Alt`+`↑` / `Alt`+`↓` | reorder the selected setlist item | | `PgUp` / `PgDn` | cue the previous / next set list |
| `Enter` | commit the cued item — switches on the next **bar** (smooth) |
| `Shift`+`Enter` | commit now — switches on the next **beat** (rude) |
| `N` / `P` | load next / previous immediately (rude quickstep) |
| `Alt`+`↑` / `Alt`+`↓` | reorder the cued item |
| `1``9` | enable / silence lane 19 | | `1``9` | enable / silence lane 19 |
| `?` | shortcuts help | | `?` | shortcuts help |
| `Esc` | close the help / share dialog | | `Esc` | close the help / share dialog · cancel an armed switch |
(Arrow keys are left alone while a slider or dropdown is focused, so they still adjust it.) (Arrow / navigation keys are left alone while a slider or dropdown is focused, so they still adjust it.)
## Live performance
The set list is performance-ready: you can line up where you're going next without
disturbing what's playing, then commit on a musical boundary — no audible gap.
- **Cue, then commit.** The arrows / `Home` / `End` / `PgUp` / `PgDn` move a *cue
cursor* (amber outline) through items — across set lists, without loading anything.
**`Enter`** commits the cued item with a **smooth** cutover at the next **bar**;
**`Shift`+`Enter`** is a **rude** cutover at the next **beat** ("wrong thing playing,
fix it now"). `N` / `P` are immediate rude quicksteps. `Esc` cancels an armed switch.
- **Barlength segments.** Give an item a **bar** count (Timers box, or the `b<n>`
patch token) and a bar countdown (▦) shows bars remaining. With **Continue** on, it
autoadvances to the next item at the bar boundary — so a *song* is just a set list
of segments (each with its own tempo, ramp and bar length) that hand off seamlessly.
- All transitions — manual or auto, beat or bar — keep the clock continuous; the loaded
item can even live in a set list you're not currently viewing (the player names it).
## Versioning ## Versioning

View file

@ -4,6 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Stackable Metronome — Mockup</title> <title>Stackable Metronome — Mockup</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAzMiAzMiI+PHJlY3Qgd2lkdGg9IjMyIiBoZWlnaHQ9IjMyIiByeD0iNyIgZmlsbD0iIzFDMjgzRiIvPjxwYXRoIGQ9Ik0xMiA2aDhsNCAyMUg4eiIgZmlsbD0iIzBBQjNGNyIvPjxwYXRoIGQ9Ik0xNiAyNEwxOS42IDkiIHN0cm9rZT0iIzFDMjgzRiIgc3Ryb2tlLXdpZHRoPSIxLjgiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgZmlsbD0ibm9uZSIvPjxyZWN0IHg9IjE2LjQiIHk9IjEzLjIiIHdpZHRoPSI0LjQiIGhlaWdodD0iMi44IiByeD0iMC42IiB0cmFuc2Zvcm09InJvdGF0ZSgtMTMgMTguNiAxNC42KSIgZmlsbD0iIzFDMjgzRiIvPjwvc3ZnPgo=">
<script> <script>
// Set theme before first paint (avoids a flash). Preference is system|light|dark // Set theme before first paint (avoids a flash). Preference is system|light|dark
// (default system → follows the OS); "system" resolves to the OS scheme here. // (default system → follows the OS); "system" resolves to the OS scheme here.
@ -124,7 +125,8 @@
code { background:#0d1014; padding:1px 5px; border-radius:4px; color:#cfe3ff; } code { background:#0d1014; padding:1px 5px; border-radius:4px; color:#cfe3ff; }
.ex-item { display:flex; gap:8px; align-items:center; padding:7px 9px; border:1px solid var(--edge); border-radius:8px; margin-bottom:6px; font-size:13px; background:var(--panel); cursor:pointer; } .ex-item { display:flex; gap:8px; align-items:center; padding:7px 9px; border:1px solid var(--edge); border-radius:8px; margin-bottom:6px; font-size:13px; background:var(--panel); cursor:pointer; }
.ex-item:hover { border-color:var(--muted); } .ex-item:hover { border-color:var(--muted); }
.ex-item.active { border-color:#2e7d32; box-shadow:inset 3px 0 0 #2e7d32; } .ex-item.active { border-color:#2e7d32; box-shadow:inset 3px 0 0 #2e7d32; } /* loaded / playing */
.ex-item.cued { outline:2px solid #ffb454; outline-offset:-2px; } /* cue cursor (coexists with .active) */
.ex-item .nm { flex:1; } .ex-item .nm { flex:1; }
.ex-item .meta { color:var(--muted); font-family:"Courier New",monospace; font-size:11px; } .ex-item .meta { color:var(--muted); font-family:"Courier New",monospace; font-size:11px; }
.ex-item .row-actions { display:none; gap:4px; } .ex-item .row-actions { display:none; gap:4px; }
@ -197,7 +199,7 @@
.kbd-table tr:last-child td { border-bottom:none; } .kbd-table tr:last-child td { border-bottom:none; }
.kbd-table td:first-child { width:100px; white-space:nowrap; } .kbd-table td:first-child { width:100px; white-space:nowrap; }
kbd { background:var(--panel); border:1px solid var(--edge); border-bottom-width:2px; border-radius:5px; padding:2px 7px; font-family:"Courier New",monospace; font-size:12px; color:var(--txt); } kbd { background:var(--panel); border:1px solid var(--edge); border-bottom-width:2px; border-radius:5px; padding:2px 7px; font-family:"Courier New",monospace; font-size:12px; color:var(--txt); }
.setlist-fields textarea { width:100%; background:var(--panel); color:var(--txt); border:1px solid var(--edge); border-radius:8px; padding:8px; font-size:12px; resize:vertical; min-height:40px; } .setlist-fields textarea { width:100%; background:var(--panel); color:var(--txt); border:1px solid var(--edge); border-radius:8px; padding:8px; font-size:12px; resize:none; overflow:hidden; min-height:34px; box-sizing:border-box; }
.play { background:#2e7d32; border-color:#2e7d32; color:#fff; padding:3px 10px; } .play { background:#2e7d32; border-color:#2e7d32; color:#fff; padding:3px 10px; }
.stop { background:#c0392b; border-color:#c0392b; color:#fff; padding:3px 10px; } .stop { background:#c0392b; border-color:#c0392b; color:#fff; padding:3px 10px; }
.menu { position:absolute; top:36px; right:0; background:var(--panel-2); border:1px solid var(--edge); border-radius:10px; padding:6px; display:flex; flex-direction:column; gap:4px; box-shadow:0 12px 30px rgba(0,0,0,.5); z-index:70; min-width:150px; } .menu { position:absolute; top:36px; right:0; background:var(--panel-2); border:1px solid var(--edge); border-radius:10px; padding:6px; display:flex; flex-direction:column; gap:4px; box-shadow:0 12px 30px rgba(0,0,0,.5); z-index:70; min-width:150px; }
@ -222,7 +224,7 @@
<button id="helpBtn" title="keyboard shortcuts (?)">?</button> <button id="helpBtn" title="keyboard shortcuts (?)">?</button>
</div> </div>
</div> </div>
<div class="kbd-legend" style="margin-bottom:12px">Space play · T tap · ↑↓ tempo (⇧×10) · A add · N next · ? help</div> <div class="kbd-legend" style="margin-bottom:12px">Space play · T tap · ←→ tempo · ↑↓ cue · ⏎ commit · N/P step · A add · ? help</div>
<!-- Transport: display + preset/tempo/volume + practice, in three columns --> <!-- Transport: display + preset/tempo/volume + practice, in three columns -->
<div class="row"> <div class="row">
@ -233,7 +235,8 @@
<div class="big" id="bpmDisplay">120</div> <div class="big" id="bpmDisplay">120</div>
<div class="dtimers" id="dtimers"> <div class="dtimers" id="dtimers">
<span title="elapsed (stopwatch)"><span id="elapsedVal">0:00</span></span> <span title="elapsed (stopwatch)"><span id="elapsedVal">0:00</span></span>
<span id="countWrap" title="countdown" hidden><span id="countVal" class="tval">0:00</span></span> <span id="countWrap" title="time countdown" hidden><span id="countVal" class="tval">0:00</span></span>
<span id="barWrap" title="bars remaining in this segment" hidden><span id="barVal" class="tval">0</span></span>
</div> </div>
<div class="ctx" id="ctxDisplay">&nbsp;</div> <div class="ctx" id="ctxDisplay">&nbsp;</div>
</div> </div>
@ -278,6 +281,10 @@
<label style="font-size:12px">Countdown <input type="text" class="txt" id="countTime" placeholder="m:ss" title="blank = off · h:mm:ss, m:ss, or plain minutes" style="width:80px; text-align:center"></label> <label style="font-size:12px">Countdown <input type="text" class="txt" id="countTime" placeholder="m:ss" title="blank = off · h:mm:ss, m:ss, or plain minutes" style="width:80px; text-align:center"></label>
<button class="iconbtn" id="countReset" title="reset countdown"></button> <button class="iconbtn" id="countReset" title="reset countdown"></button>
</div> </div>
<div class="row" style="gap:10px; align-items:center; margin-top:6px">
<label style="font-size:12px">Bars <input type="number" class="num" id="segBarsIn" min="0" max="999" value="0" title="segment length in bars (0 = manual). With Continue on, auto-advances to the next item at the bar boundary." style="width:64px"></label>
<span class="hint" style="margin:0">0 = manual</span>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -314,23 +321,23 @@
</div> </div>
</div> </div>
<div class="lane-row" style="margin-bottom:8px"> <!-- editable set-list selector: rename the active list in place; ▾ switches / creates -->
<select class="cmp" id="setlistSelect" style="flex:1"></select> <div class="lane-row" style="margin-bottom:8px; position:relative">
<button id="newSetlistBtn">+ New</button> <input type="text" class="txt" id="slTitle" placeholder="set list title" style="flex:1; text-align:left">
<button id="slMenuBtn" title="switch or create a set list" aria-haspopup="true"></button>
<button class="x" id="delSetlistBtn" title="delete set list" style="margin-left:0"></button> <button class="x" id="delSetlistBtn" title="delete set list" style="margin-left:0"></button>
<div id="slMenu" class="menu" hidden style="top:38px; left:0; right:auto; max-height:240px; overflow:auto"></div>
</div> </div>
<div class="setlist-fields"> <div class="setlist-fields">
<input type="text" class="txt" id="slTitle" placeholder="set list title" style="width:100%; text-align:left; margin-bottom:6px"> <textarea id="slDesc" placeholder="description / notes" rows="1"></textarea>
<textarea id="slDesc" placeholder="description / notes"></textarea>
</div> </div>
<label class="mini-check" title="when a playing item reaches the end of its countdown or its bar-length, auto-advance to the next item (smooth cutover at the next bar) — give items a countdown or bar count to auto-play a whole song/set" style="margin:8px 0 6px"><input type="checkbox" id="continueMode"> Continue — auto-advance (countdown / bars)</label>
<div class="lane-row" style="margin:12px 0 6px"> <div id="itemList"></div>
<div class="lane-row" style="margin:10px 0 6px">
<input type="text" class="txt" id="itemName" placeholder="item name" style="flex:1; min-width:110px; text-align:left"> <input type="text" class="txt" id="itemName" placeholder="item name" style="flex:1; min-width:110px; text-align:left">
<button id="addItemBtn">+ Add current settings</button> <button id="addItemBtn">+ Add current settings</button>
</div> </div>
<label class="mini-check" title="when a playing item's countdown reaches 0, auto-load the next item — give every item a countdown to auto-play the whole list" style="margin-bottom:6px"><input type="checkbox" id="continueMode"> Continue — auto-advance on countdown</label> <div class="hint" style="margin-top:6px"><kbd>Alt+↑/↓</kbd> reorder · 💾 save settings to an item.</div>
<div id="itemList"></div>
<div class="hint" style="margin-top:6px">Click to load · <kbd>N</kbd> next · <kbd>Alt+↑/↓</kbd> reorder · 💾 save settings to an item.</div>
<div id="logView" style="margin-top:18px"></div> <div id="logView" style="margin-top:18px"></div>
</aside> </aside>
@ -342,14 +349,17 @@
<table class="kbd-table"> <table class="kbd-table">
<tr><td><kbd>Space</kbd></td><td>Play / stop (works everywhere except while typing in a text field)</td></tr> <tr><td><kbd>Space</kbd></td><td>Play / stop (works everywhere except while typing in a text field)</td></tr>
<tr><td><kbd>T</kbd></td><td>Tap tempo</td></tr> <tr><td><kbd>T</kbd></td><td>Tap tempo</td></tr>
<tr><td><kbd></kbd> <kbd></kbd></td><td>Tempo ±1 BPM</td></tr> <tr><td><kbd></kbd> <kbd></kbd></td><td>Tempo ±1 BPM (<kbd></kbd> ±10)</td></tr>
<tr><td><kbd>⇧↑</kbd> <kbd>⇧↓</kbd></td><td>Tempo ±10 BPM</td></tr>
<tr><td><kbd>A</kbd></td><td>Add meter lane</td></tr> <tr><td><kbd>A</kbd></td><td>Add meter lane</td></tr>
<tr><td><kbd>N</kbd></td><td>Load next set-list item</td></tr> <tr><td><kbd></kbd> <kbd></kbd> <kbd>Home</kbd> <kbd>End</kbd></td><td>Move the cue cursor (crosses set lists)</td></tr>
<tr><td><kbd>⌥↑</kbd> <kbd>⌥↓</kbd></td><td>Reorder selected item</td></tr> <tr><td><kbd>PgUp</kbd> <kbd>PgDn</kbd></td><td>Cue the previous / next set list</td></tr>
<tr><td><kbd>Enter</kbd></td><td>Commit the cued item — switches on the next <b>bar</b> (smooth)</td></tr>
<tr><td><kbd>⇧Enter</kbd></td><td>Commit now — switches on the next <b>beat</b> (rude)</td></tr>
<tr><td><kbd>N</kbd> / <kbd>P</kbd></td><td>Load next / previous immediately (rude quick-step)</td></tr>
<tr><td><kbd>⌥↑</kbd> <kbd>⌥↓</kbd></td><td>Reorder the cued item</td></tr>
<tr><td><kbd>1</kbd><kbd>9</kbd></td><td>Enable / silence lane 19</td></tr> <tr><td><kbd>1</kbd><kbd>9</kbd></td><td>Enable / silence lane 19</td></tr>
<tr><td><kbd>?</kbd></td><td>This help</td></tr> <tr><td><kbd>?</kbd></td><td>This help</td></tr>
<tr><td><kbd>Esc</kbd></td><td>Close tray / help</td></tr> <tr><td><kbd>Esc</kbd></td><td>Close tray / help · cancel an armed switch</td></tr>
</table> </table>
<div class="help-about"> <div class="help-about">
<p>Source: <a href="https://git.varasys.io/VARASYS/metronome" target="_blank" rel="noopener">git.varasys.io/VARASYS/metronome</a></p> <p>Source: <a href="https://git.varasys.io/VARASYS/metronome" target="_blank" rel="noopener">git.varasys.io/VARASYS/metronome</a></p>
@ -496,6 +506,11 @@ function advanceMaster(ahead) {
muteWindows.push({ start: masterBeatTime, end: masterBeatTime + mbpb * (60 / state.bpm) }); muteWindows.push({ start: masterBeatTime, end: masterBeatTime + mbpb * (60 / state.bpm) });
} }
} }
segBarCount = barIndex; // whole bars elapsed in this segment
if (segBars > 0 && barIndex >= segBars && !pendingSwitch && state.running) { // bar-count auto-advance
const nx = nextLoadedTarget();
if (nx) { pendingSwitch = { sl: nx.sl, item: nx.item, atTime: masterBeatTime, reason: "auto" }; break; } // cut at this downbeat
}
} }
masterBeat++; masterBeat++;
masterBeatTime += 60 / state.bpm; masterBeatTime += 60 / state.bpm;
@ -530,14 +545,19 @@ function laneStepDur(m) {
function scheduler() { function scheduler() {
const ahead = audioCtx.currentTime + SCHEDULE_AHEAD; const ahead = audioCtx.currentTime + SCHEDULE_AHEAD;
advanceMaster(ahead); // While a switch is armed, never advance/schedule past its boundary — so no audio
// is committed beyond it. advanceMaster may also arm an auto-switch and stop at the boundary.
advanceMaster(pendingSwitch ? Math.min(ahead, pendingSwitch.atTime) : ahead);
const cap = pendingSwitch ? Math.min(ahead, pendingSwitch.atTime) : ahead;
for (const m of meters) { for (const m of meters) {
while (m.nextTime < ahead) { while (m.nextTime < cap) {
scheduleMeterTick(m, m.nextTime); scheduleMeterTick(m, m.nextTime);
m.tick++; m.tick++;
m.nextTime += laneStepDur(m); m.nextTime += laneStepDur(m);
} }
} }
// Boundary reached → swap to the new segment seamlessly (scheduler keeps running).
if (pendingSwitch && masterBeatTime >= pendingSwitch.atTime - 1e-9) performCutover(pendingSwitch);
} }
/* ========================================================================= /* =========================================================================
@ -556,9 +576,44 @@ function start() {
function stop() { function stop() {
state.running = false; state.running = false;
clearInterval(schedulerTimer); schedulerTimer = null; clearInterval(schedulerTimer); schedulerTimer = null;
pendingSwitch = null; segBarCount = 0; // drop any armed switch so it can't fire on next start
for (const m of meters) m.currentStep = -1; for (const m of meters) m.currentStep = -1;
syncStartBtn(); syncStartBtn();
} }
/* ----- gap-free cutover -----------------------------------------------------
One mechanism, two quantize targets: "beat" (rude/now) and "bar" (smooth).
Arming records a future boundary time; the scheduler caps outgoing audio at it
and rebuilds the meters there — schedulerTimer never stops, so the downbeat is
continuous (this replaces the old gappy stop()+start() switch). */
function nextBeatBoundaryTime() { return masterBeatTime; } // time of the next (unscheduled) beat
function nextBarBoundaryTime() {
const mbpb = masterBeatsPerBar();
const toNext = ((mbpb - (masterBeat % mbpb)) % mbpb) || mbpb; // beats until the next downbeat (≥ 1 bar away if on it)
return masterBeatTime + toNext * (60 / state.bpm);
}
function armSwitch(sl, item, reason, quantize) {
if (!setlists[sl] || !setlists[sl].items[item]) return;
if (!state.running) { loadItem(item, sl); return; } // not playing → load immediately
let bt = quantize === "bar" ? nextBarBoundaryTime() : nextBeatBoundaryTime();
const unit = (quantize === "bar" ? masterBeatsPerBar() : 1) * (60 / state.bpm);
while (bt <= audioCtx.currentTime + SCHEDULE_AHEAD) bt += unit; // defer past already-committed audio
pendingSwitch = { sl, item, atTime: bt, reason: reason || "commit" };
updateCtx();
}
function performCutover(ps) {
const bt = ps.atTime;
logFinalize(); // close out the outgoing segment's log entry
pendingSwitch = null;
applySetup(setlists[ps.sl].items[ps.item]); // rebuilds meters, sets bpm/ramp/trainer/segBars, resets segBarCount
if (ramp.on) setBpm(ramp.startBpm); // each segment's ramp starts fresh (like start())
setLoaded(ps.sl, ps.item);
for (const m of meters) { m.tick = 0; m.nextTime = bt; m.vq = []; m.vqPtr = 0; m.currentStep = -1; m.currentBar = 0; } // first tick on the boundary
masterBeat = 0; masterBeatTime = bt; muteWindows = [];
nowPlaying = { at: Date.now(), name: setlists[ps.sl].items[ps.item].name };
if (activeSL !== loadedSL) { activeSL = loadedSL; renderSetlists(); } else renderItems();
renderLog(); updateCtx();
}
function setBpm(v) { function setBpm(v) {
state.bpm = Math.max(30, Math.min(300, Math.round(v))); state.bpm = Math.max(30, Math.min(300, Math.round(v)));
bpm.value = state.bpm; bpmVal.textContent = state.bpm; bpmDisplay.textContent = state.bpm; bpm.value = state.bpm; bpmVal.textContent = state.bpm; bpmDisplay.textContent = state.bpm;
@ -733,25 +788,32 @@ function applyLanes(lanes) {
Each played item is logged (timestamp, name, duration, BPM, conditions). Each played item is logged (timestamp, name, duration, BPM, conditions).
========================================================================= */ ========================================================================= */
let setlists = lsGet(LS.setlists, []); let setlists = lsGet(LS.setlists, []);
let activeSL = 0; // selected set list let activeSL = 0; // VIEWED set list (the one shown in the panel)
let activeItem = -1; // selected / loaded item in the active set list let activeItem = -1; // loaded item index within loadedSL (-1 = none / free play)
let loadedSL = 0; // set list the loaded/playing item lives in (may differ from the viewed one)
let cuedSL = -1, cuedItem = -1; // cue cursor — non-destructive browse pointer (-1 = none)
let pendingSwitch = null; // armed cutover: { sl, item, atTime, reason }
let segBars = 0; // bar-length of the loaded segment (0 = manual, no auto-advance)
let segBarCount = 0; // whole bars elapsed in the current segment
let nowPlaying = null; // { at, name } for duration logging let nowPlaying = null; // { at, name } for duration logging
let historyName = null; // item whose past-session history is shown let historyName = null; // item whose past-session history is shown
let continueMode = lsGet(LS.continue, false); // auto-advance to next item when countdown ends let continueMode = lsGet(LS.continue, false); // auto-advance to next item when countdown ends
let timersOn = lsGet(LS.timers, true); // master switch for the elapsed/countdown timers let timersOn = lsGet(LS.timers, true); // master switch for the elapsed/countdown timers
function currentSetup() { return { bpm: state.bpm, lanes: snapshotLanes(), trainer: { ...trainer }, ramp: { ...ramp }, countMs: timers.totalMs }; } function currentSetup() { return { bpm: state.bpm, lanes: snapshotLanes(), trainer: { ...trainer }, ramp: { ...ramp }, countMs: timers.totalMs, bars: segBars }; }
function applySetup(s) { function applySetup(s) {
setBpm(s.bpm); applyLanes(s.lanes); setBpm(s.bpm); applyLanes(s.lanes);
if (s.trainer) Object.assign(trainer, s.trainer); if (s.trainer) Object.assign(trainer, s.trainer);
if (s.ramp) Object.assign(ramp, s.ramp); if (s.ramp) Object.assign(ramp, s.ramp);
timers.totalMs = s.countMs || 0; timers.remainingMs = timers.totalMs; // per-item countdown timers.totalMs = s.countMs || 0; timers.remainingMs = timers.totalMs; // per-item time countdown
segBars = s.bars || 0; segBarCount = 0; // per-item bar-length + counter
syncPracticeUI(); updateCtx(); syncPracticeUI(); updateCtx();
} }
function syncPracticeUI() { function syncPracticeUI() {
$("trainerOn").checked = trainer.on; $("playBars").value = trainer.playBars; $("muteBars").value = trainer.muteBars; $("trainerOn").checked = trainer.on; $("playBars").value = trainer.playBars; $("muteBars").value = trainer.muteBars;
$("rampOn").checked = ramp.on; $("rampStart").value = ramp.startBpm; $("rampAmt").value = ramp.amount; $("rampEvery").value = ramp.everyBars; $("rampOn").checked = ramp.on; $("rampStart").value = ramp.startBpm; $("rampAmt").value = ramp.amount; $("rampEvery").value = ramp.everyBars;
$("countTime").value = timers.totalMs > 0 ? fmtClock(timers.totalMs) : ""; $("countTime").value = timers.totalMs > 0 ? fmtClock(timers.totalMs) : "";
$("segBarsIn").value = segBars || 0;
refreshFeatureBoxes(); renderTimers(); refreshFeatureBoxes(); renderTimers();
} }
function refreshFeatureBoxes() { function refreshFeatureBoxes() {
@ -760,92 +822,155 @@ function refreshFeatureBoxes() {
$("timerBox").classList.toggle("on", timersOn); $("timerBox").classList.toggle("on", timersOn);
} }
function fmtDur(sec) { sec = Math.round(sec); const m = Math.floor(sec / 60); return m + ":" + String(sec % 60).padStart(2, "0"); } function fmtDur(sec) { sec = Math.round(sec); const m = Math.floor(sec / 60); return m + ":" + String(sec % 60).padStart(2, "0"); }
function getSL() { return setlists[activeSL]; } function getSL() { return setlists[activeSL]; } // the VIEWED list
function loadedItem() { const sl = setlists[loadedSL]; return (sl && activeItem >= 0) ? sl.items[activeItem] : null; }
function saveSetlists() { lsSet(LS.setlists, setlists); } function saveSetlists() { lsSet(LS.setlists, setlists); }
// --- set list CRUD --- // --- set list CRUD ---
function newSetlist() { function newSetlist() {
setlists.push({ title: "Set list " + (setlists.length + 1), description: "", items: [] }); setlists.push({ title: "Set list " + (setlists.length + 1), description: "", items: [] });
activeSL = setlists.length - 1; activeItem = -1; saveSetlists(); renderSetlists(); activeSL = setlists.length - 1; saveSetlists(); renderSetlists(); // view the new list; loaded item keeps playing in its own
} }
function deleteSetlist() { function deleteSetlist() {
if (!setlists.length || !confirm("Delete this set list?")) return; if (!setlists.length || !confirm("Delete this set list?")) return;
setlists.splice(activeSL, 1); activeSL = Math.max(0, activeSL - 1); activeItem = -1; saveSetlists(); renderSetlists(); const removed = activeSL;
setlists.splice(removed, 1);
const adj = (n) => n > removed ? n - 1 : n;
if (loadedSL === removed) { activeItem = -1; loadedSL = Math.max(0, removed - 1); } else loadedSL = adj(loadedSL);
if (cuedSL === removed) { cuedSL = -1; cuedItem = -1; } else cuedSL = adj(cuedSL);
if (pendingSwitch) { if (pendingSwitch.sl === removed) pendingSwitch = null; else pendingSwitch.sl = adj(pendingSwitch.sl); }
activeSL = Math.max(0, removed - 1); saveSetlists(); renderSetlists();
} }
function addItem(name) { function addItem(name) {
const sl = getSL(); if (!sl) return; const sl = getSL(); if (!sl) return;
sl.items.push({ name: name || ("Item " + (sl.items.length + 1)), ...currentSetup() }); sl.items.push({ name: name || ("Item " + (sl.items.length + 1)), ...currentSetup() });
activeItem = sl.items.length - 1; saveSetlists(); renderItems(); setLoaded(activeSL, sl.items.length - 1); // the captured item becomes the loaded one
saveSetlists(); renderItems();
} }
function removeItem(i) { function removeItem(i) {
const sl = getSL(); if (!sl) return; const sl = getSL(); if (!sl) return;
sl.items.splice(i, 1); sl.items.splice(i, 1);
if (activeItem === i) activeItem = -1; else if (activeItem > i) activeItem--; if (activeSL === loadedSL) { if (activeItem === i) activeItem = -1; else if (activeItem > i) activeItem--; }
if (activeSL === cuedSL) { if (cuedItem === i) cuedItem = Math.min(cuedItem, sl.items.length - 1); else if (cuedItem > i) cuedItem--; }
saveSetlists(); renderItems(); saveSetlists(); renderItems();
} }
function moveItem(i, d) { const sl = getSL(); const j = i + d; if (j < 0 || j >= sl.items.length) return; [sl.items[i], sl.items[j]] = [sl.items[j], sl.items[i]]; saveSetlists(); } function moveItem(i, d) { const sl = getSL(); const j = i + d; if (j < 0 || j >= sl.items.length) return; [sl.items[i], sl.items[j]] = [sl.items[j], sl.items[i]]; saveSetlists(); }
function moveActiveItem(d) { // keyboard reorder of the selected item (Alt+↑/↓) function moveCuedItem(d) { // keyboard reorder of the cued item (Alt+↑/↓), within the viewed list
const sl = getSL(); if (!sl || activeItem < 0) return; if (cuedSL !== activeSL || cuedItem < 0) return;
const j = activeItem + d; if (j < 0 || j >= sl.items.length) return; const sl = getSL(); const j = cuedItem + d; if (j < 0 || j >= sl.items.length) return;
moveItem(activeItem, d); activeItem = j; renderItems(); moveItem(cuedItem, d);
if (loadedSL === activeSL) { if (activeItem === cuedItem) activeItem = j; else if (activeItem === j) activeItem = cuedItem; }
cuedItem = j; renderItems();
} }
// --- select / advance: clicking an item LOADS it; the transport is the only play/stop --- // Record the loaded item + sync the cue + history (state only; no audio, no applySetup).
function loadItem(i) { function setLoaded(sl, i) {
const sl = getSL(); if (!sl || !sl.items[i]) return; loadedSL = sl; activeItem = i;
const wasRunning = state.running; const it = setlists[sl] && setlists[sl].items[i];
if (wasRunning) logFinalize(); // close out the previous segment if (it) historyName = it.name;
applySetup(sl.items[i]); cuedSL = sl; cuedItem = i; // the cue follows the loaded item
activeItem = i; historyName = sl.items[i].name;
if (wasRunning) { stop(); start(); nowPlaying = { at: Date.now(), name: sl.items[i].name }; } // keep playing the new item
renderItems(); renderLog();
} }
function nextItem() { const sl = getSL(); if (sl && activeItem + 1 < sl.items.length) loadItem(activeItem + 1); }
function updateItem(i) { // overwrite item with current settings (keeps its name) // --- load: clicking / N / P loads. While playing this is a gap-free RUDE (next-beat) cutover. ---
const sl = getSL(); if (!sl || !sl.items[i]) return; function loadItem(i, sl = activeSL) {
sl.items[i] = { name: sl.items[i].name, ...currentSetup() }; if (!setlists[sl] || !setlists[sl].items[i]) return;
if (state.running) { armSwitch(sl, i, "load", "beat"); return; } // playing → next beat, no gap
applySetup(setlists[sl].items[i]);
setLoaded(sl, i);
if (activeSL !== sl) { activeSL = sl; renderSetlists(); } else renderItems();
renderLog();
}
function nextItem() { // N — quick-step within the loaded list (rude when playing)
if (activeItem < 0) { loadItem(0, activeSL); return; }
const sl = setlists[loadedSL]; if (sl && activeItem + 1 < sl.items.length) loadItem(activeItem + 1, loadedSL);
}
function prevItem() { const sl = setlists[loadedSL]; if (sl && activeItem - 1 >= 0) loadItem(activeItem - 1, loadedSL); }
// --- cue cursor (browse without loading); commits via Enter/Shift+Enter ---
function setCue(sl, item) {
if (sl < 0 || sl >= setlists.length || !setlists[sl].items.length) return;
cuedSL = sl; cuedItem = Math.max(0, Math.min(item, setlists[sl].items.length - 1));
if (activeSL !== sl) { activeSL = sl; renderSetlists(); } else renderItems(); // viewed list follows the cue
}
function ensureCue() { // seed the cue on first nav (from the loaded item, else the viewed list)
if (cuedSL >= 0 && cuedItem >= 0 && setlists[cuedSL] && setlists[cuedSL].items[cuedItem]) return;
if (activeItem >= 0 && setlists[loadedSL]) { cuedSL = loadedSL; cuedItem = activeItem; } else { cuedSL = activeSL; cuedItem = 0; }
}
function cueNext() { ensureCue(); if (cuedItem + 1 < setlists[cuedSL].items.length) setCue(cuedSL, cuedItem + 1); else for (let j = cuedSL + 1; j < setlists.length; j++) if (setlists[j].items.length) return setCue(j, 0); }
function cuePrev() { ensureCue(); if (cuedItem - 1 >= 0) setCue(cuedSL, cuedItem - 1); else for (let j = cuedSL - 1; j >= 0; j--) if (setlists[j].items.length) return setCue(j, setlists[j].items.length - 1); }
function cueFirst() { for (let j = 0; j < setlists.length; j++) if (setlists[j].items.length) return setCue(j, 0); }
function cueLast() { for (let j = setlists.length - 1; j >= 0; j--) if (setlists[j].items.length) return setCue(j, setlists[j].items.length - 1); }
function cueSetlist(d) { ensureCue(); for (let j = cuedSL + d; j >= 0 && j < setlists.length; j += d) if (setlists[j].items.length) return setCue(j, 0); }
// The item after the loaded one, crossing into the next non-empty list (for auto-advance). null = end.
function nextLoadedTarget() {
const sl = setlists[loadedSL]; if (!sl || activeItem < 0) return null;
if (activeItem + 1 < sl.items.length) return { sl: loadedSL, item: activeItem + 1 };
for (let j = loadedSL + 1; j < setlists.length; j++) if (setlists[j].items.length) return { sl: j, item: 0 };
return null;
}
function updateItem() { // Save — overwrite the LOADED item with current settings (keeps its name)
const sl = setlists[loadedSL]; if (!sl || activeItem < 0 || !sl.items[activeItem]) return;
sl.items[activeItem] = { name: sl.items[activeItem].name, ...currentSetup() };
saveSetlists(); renderItems(); saveSetlists(); renderItems();
} }
// Start/stop go through here so internal restarts don't create stray log entries. // Start/stop go through here so internal restarts don't create stray log entries.
function toggleTransport() { function toggleTransport() {
if (state.running) { logFinalize(); stop(); } if (state.running) { logFinalize(); stop(); }
else { start(); const sl = getSL(); if (activeItem >= 0 && sl && sl.items[activeItem]) nowPlaying = { at: Date.now(), name: sl.items[activeItem].name }; } else { start(); const it = loadedItem(); if (it) nowPlaying = { at: Date.now(), name: it.name }; }
renderItems(); renderItems();
} }
// --- now-playing info on the main screen (replaces the old preset dropdown) --- // --- now-playing info on the main screen (replaces the old preset dropdown) ---
function renderNowPlaying() { function renderNowPlaying() {
const sl = getSL(); const it = (sl && activeItem >= 0) ? sl.items[activeItem] : null; const it = loadedItem(); // the LOADED item (may live in a list you're not viewing)
$("saveItemBtn").disabled = !it; // single save button targets the loaded set-list item $("saveItemBtn").disabled = !it; // single save button targets the loaded set-list item
// A disabled <button> swallows hover, so its title never shows — set it on the wrapper // A disabled <button> swallows hover, so its title never shows — set it on the wrapper
// span too, and explain *why* it's disabled when no item is selected. // span too, and explain *why* it's disabled when no item is loaded.
const saveTip = it const saveTip = it
? "Save the current settings to “" + it.name + "” (set-list item " + (activeItem + 1) + ")" ? "Save the current settings to “" + it.name + "” (set-list item " + (activeItem + 1) + ")"
: "Select a set-list item to enable Save — it overwrites that item with your current settings"; : "Load a set-list item to enable Save — it overwrites that item with your current settings";
$("saveItemBtn").title = $("saveItemWrap").title = saveTip; $("saveItemBtn").title = $("saveItemWrap").title = saveTip;
if (!it) { if (!it) {
const vsl = getSL();
$("npName").textContent = "Free play"; $("npName").textContent = "Free play";
$("npSub").textContent = "No set-list item loaded — edit the lanes freely."; $("npSub").textContent = "No set-list item loaded — edit the lanes freely.";
$("npDesc").textContent = (sl && sl.description) ? "“" + sl.title + "” — " + sl.description : ""; $("npDesc").textContent = (vsl && vsl.description) ? "“" + vsl.title + "” — " + vsl.description : "";
return; return;
} }
const lsl = setlists[loadedSL];
$("npName").textContent = (activeItem + 1) + ". " + it.name; $("npName").textContent = (activeItem + 1) + ". " + it.name;
$("npSub").textContent = it.bpm + " BPM · " + it.lanes.map((l) => l.sound + " " + l.groupsStr + (l.poly ? "~" : "") + (l.enabled === false ? " (off)" : "")).join(" · "); $("npSub").textContent = it.bpm + " BPM · " + it.lanes.map((l) => l.sound + " " + l.groupsStr + (l.poly ? "~" : "") + (l.enabled === false ? " (off)" : "")).join(" · ");
$("npDesc").textContent = (sl.title || "") + (sl.description ? " — " + sl.description : ""); $("npDesc").textContent = ((lsl && lsl.title) || "") + (lsl && lsl.description ? " — " + lsl.description : "");
} }
// --- render --- // --- render ---
function autoGrow(el) { if (!el) return; el.style.height = "auto"; el.style.height = (el.scrollHeight || 0) + "px"; }
function buildSlMenu() { // the ▾ dropdown: every list + "+ New" as the last item
const menu = $("slMenu"); if (!menu) return;
menu.innerHTML = "";
setlists.forEach((sl, i) => {
const b = document.createElement("button");
b.textContent = (i === activeSL ? "● " : "") + (sl.title || ("Set list " + (i + 1)));
b.onclick = () => { $("slMenu").hidden = true; activeSL = i; renderSetlists(); }; // view only
menu.appendChild(b);
});
const nb = document.createElement("button");
nb.textContent = "+ New set list";
nb.style.cssText = "border-top:1px solid var(--edge); margin-top:2px; padding-top:6px;";
nb.onclick = () => { $("slMenu").hidden = true; newSetlist(); };
menu.appendChild(nb);
}
function renderSetlists() { function renderSetlists() {
const sel = $("setlistSelect"); sel.innerHTML = "";
const has = setlists.length > 0; const has = setlists.length > 0;
$("slTitle").disabled = $("slDesc").disabled = $("addItemBtn").disabled = $("delSetlistBtn").disabled = !has; $("slTitle").disabled = $("slDesc").disabled = $("addItemBtn").disabled = $("delSetlistBtn").disabled = !has;
if (!has) { sel.innerHTML = '<option>— no set lists —</option>'; $("slTitle").value = ""; $("slDesc").value = ""; renderItems(); return; } if (!has) { $("slTitle").value = ""; $("slDesc").value = ""; autoGrow($("slDesc")); buildSlMenu(); renderItems(); return; }
if (activeSL >= setlists.length) activeSL = setlists.length - 1; if (activeSL >= setlists.length) activeSL = setlists.length - 1;
setlists.forEach((sl, i) => { const o = document.createElement("option"); o.value = i; o.textContent = sl.title || ("Set list " + (i + 1)); sel.appendChild(o); }); const sl = getSL();
sel.value = activeSL; $("slTitle").value = sl.title || "";
const sl = getSL(); $("slTitle").value = sl.title || ""; $("slDesc").value = sl.description || ""; $("slDesc").value = sl.description || ""; autoGrow($("slDesc"));
renderItems(); buildSlMenu(); renderItems();
} }
function renderItems() { function renderItems() {
const box = $("itemList"); box.innerHTML = ""; const sl = getSL(); const box = $("itemList"); box.innerHTML = ""; const sl = getSL();
@ -853,14 +978,16 @@ function renderItems() {
if (!sl.items.length) { box.innerHTML = '<div class="hint">No items yet — set up the metronome and “Add current settings”.</div>'; renderNowPlaying(); return; } if (!sl.items.length) { box.innerHTML = '<div class="hint">No items yet — set up the metronome and “Add current settings”.</div>'; renderNowPlaying(); return; }
sl.items.forEach((it, i) => { sl.items.forEach((it, i) => {
const row = document.createElement("div"); const row = document.createElement("div");
row.className = "ex-item" + (i === activeItem ? " active" : ""); row.className = "ex-item"
row.title = "Click to load into the player · Alt+↑/↓ to reorder"; + (activeSL === loadedSL && i === activeItem ? " active" : "") // loaded/playing (green)
row.innerHTML = `<span class="nm">${i + 1}. ${it.name}</span> + (activeSL === cuedSL && i === cuedItem ? " cued" : ""); // cue cursor (amber)
row.title = "Click to load · ↑↓ to cue · Enter to commit · Alt+↑/↓ to reorder";
row.innerHTML = `<span class="nm">${i + 1}. ${it.name}${it.bars ? ` <span class="lane-meta">${it.bars} bars</span>` : ""}</span>
<span class="meta">${it.bpm} · ${it.lanes.map((l) => l.groupsStr).join("/")}</span> <span class="meta">${it.bpm} · ${it.lanes.map((l) => l.groupsStr).join("/")}</span>
<span class="row-actions"> <span class="row-actions">
<button class="x iconbtn" data-act="del" title="remove this item"></button> <button class="x iconbtn" data-act="del" title="remove this item"></button>
</span>`; </span>`;
row.onclick = () => loadItem(i); row.onclick = () => loadItem(i, activeSL);
row.querySelector('[data-act=del]').onclick = (e) => { e.stopPropagation(); removeItem(i); }; row.querySelector('[data-act=del]').onclick = (e) => { e.stopPropagation(); removeItem(i); };
box.appendChild(row); box.appendChild(row);
}); });
@ -978,18 +1105,20 @@ function setupToPatch(s) {
const parts = ["v1", "t" + s.bpm]; const parts = ["v1", "t" + s.bpm];
if (s.volume != null) parts.push("vol" + Math.round(s.volume * 100)); if (s.volume != null) parts.push("vol" + Math.round(s.volume * 100));
if (s.countMs > 0) parts.push("cd" + Math.round(s.countMs / 1000)); if (s.countMs > 0) parts.push("cd" + Math.round(s.countMs / 1000));
if (s.bars > 0) parts.push("b" + s.bars);
(s.lanes || []).forEach((c) => parts.push(laneCfgToStr(c))); (s.lanes || []).forEach((c) => parts.push(laneCfgToStr(c)));
if (s.trainer && s.trainer.on) parts.push("tr" + s.trainer.playBars + "/" + s.trainer.muteBars); if (s.trainer && s.trainer.on) parts.push("tr" + s.trainer.playBars + "/" + s.trainer.muteBars);
if (s.ramp && s.ramp.on) parts.push("rmp" + s.ramp.startBpm + "/" + s.ramp.amount + "/" + s.ramp.everyBars); if (s.ramp && s.ramp.on) parts.push("rmp" + s.ramp.startBpm + "/" + s.ramp.amount + "/" + s.ramp.everyBars);
return parts.join(";"); return parts.join(";");
} }
function patchToSetup(str) { function patchToSetup(str) {
const s = { bpm: 120, volume: null, countMs: 0, lanes: [], trainer: { on: false, playBars: 2, muteBars: 2 }, ramp: { on: false, startBpm: 80, amount: 5, everyBars: 4 } }; const s = { bpm: 120, volume: null, countMs: 0, bars: 0, lanes: [], trainer: { on: false, playBars: 2, muteBars: 2 }, ramp: { on: false, startBpm: 80, amount: 5, everyBars: 4 } };
for (let tok of String(str).split(";")) { for (let tok of String(str).split(";")) {
tok = tok.trim(); if (!tok || tok === "v1") continue; tok = tok.trim(); if (!tok || tok === "v1") continue;
if (tok.includes(":")) { const c = laneStrToCfg(tok); if (c) s.lanes.push(c); } if (tok.includes(":")) { const c = laneStrToCfg(tok); if (c) s.lanes.push(c); } // lanes contain ":" → matched first
else if (tok.startsWith("vol")) s.volume = (parseInt(tok.slice(3), 10) || 0) / 100; else if (tok.startsWith("vol")) s.volume = (parseInt(tok.slice(3), 10) || 0) / 100;
else if (tok.startsWith("cd")) s.countMs = (parseInt(tok.slice(2), 10) || 0) * 1000; else if (tok.startsWith("cd")) s.countMs = (parseInt(tok.slice(2), 10) || 0) * 1000;
else if (tok.startsWith("b")) s.bars = parseInt(tok.slice(1), 10) || 0; // segment bar-length
else if (tok.startsWith("tr")) { const [p, m] = tok.slice(2).split("/"); s.trainer = { on: true, playBars: +p || 1, muteBars: +m || 0 }; } else if (tok.startsWith("tr")) { const [p, m] = tok.slice(2).split("/"); s.trainer = { on: true, playBars: +p || 1, muteBars: +m || 0 }; }
else if (tok.startsWith("rmp")) { const [a, b, c] = tok.slice(3).split("/"); s.ramp = { on: true, startBpm: +a || 80, amount: +b || 0, everyBars: +c || 1 }; } else if (tok.startsWith("rmp")) { const [a, b, c] = tok.slice(3).split("/"); s.ramp = { on: true, startBpm: +a || 80, amount: +b || 0, everyBars: +c || 1 }; }
else if (tok.startsWith("t")) s.bpm = parseInt(tok.slice(1), 10) || 120; else if (tok.startsWith("t")) s.bpm = parseInt(tok.slice(1), 10) || 120;
@ -1099,9 +1228,11 @@ function tickTimers() {
if (timers.totalMs > 0) { if (timers.totalMs > 0) {
const before = timers.remainingMs; const before = timers.remainingMs;
timers.remainingMs -= dt; timers.remainingMs -= dt;
if (before > 0 && timers.remainingMs <= 0 && continueMode) { // countdown hit 0 → auto-advance // time countdown hit 0 → auto-advance (smooth). Bar-length segments use the
const sl = getSL(); // bar counter instead (handled in advanceMaster), so only fire when segBars===0.
if (sl && activeItem >= 0 && activeItem + 1 < sl.items.length) loadItem(activeItem + 1); if (before > 0 && timers.remainingMs <= 0 && continueMode && segBars === 0 && !pendingSwitch) {
const nx = nextLoadedTarget();
if (nx) armSwitch(nx.sl, nx.item, "auto", "bar");
} }
// otherwise it keeps counting past 0 into negative (overtime); never stops the metronome // otherwise it keeps counting past 0 into negative (overtime); never stops the metronome
} }
@ -1113,13 +1244,24 @@ function renderTimers() {
if (!timersOn) return; if (!timersOn) return;
$("elapsedVal").textContent = fmtClock(timers.elapsedMs); $("elapsedVal").textContent = fmtClock(timers.elapsedMs);
const off = timers.totalMs <= 0; const off = timers.totalMs <= 0;
$("countWrap").hidden = off; // hide countdown when off $("countWrap").hidden = off; // hide time countdown when off
if (off) return; if (!off) {
const cd = $("countVal"); const cd = $("countVal");
cd.textContent = fmtClock(timers.remainingMs); cd.textContent = fmtClock(timers.remainingMs);
cd.classList.toggle("over", timers.remainingMs <= 0); // overtime cd.classList.toggle("over", timers.remainingMs <= 0); // overtime
cd.classList.toggle("low", timers.remainingMs > 0 && timers.remainingMs <= 10000); // almost up cd.classList.toggle("low", timers.remainingMs > 0 && timers.remainingMs <= 10000); // almost up
} }
// bar countdown — bars remaining in the current segment (audible bar from lane 1, not the look-ahead master clock)
const showBars = state.running && segBars > 0;
$("barWrap").hidden = !showBars;
if (showBars) {
const elapsed = meters.length ? meters[0].currentBar : segBarCount;
const remaining = Math.max(0, segBars - elapsed);
const bv = $("barVal");
bv.textContent = remaining;
bv.classList.toggle("low", remaining <= 1);
}
}
// Status shows in the display, under the BPM. Stopped → meter count; running → // Status shows in the display, under the BPM. Stopped → meter count; running →
// bar + trainer/ramp flags (kept short for the narrow display column). // bar + trainer/ramp flags (kept short for the narrow display column).
@ -1135,8 +1277,12 @@ function updateStatus() {
let s = "▶ bar " + (barIndex + 1); let s = "▶ bar " + (barIndex + 1);
if (trainer.on) s += muted ? " · mute — count!" : " · play"; if (trainer.on) s += muted ? " · mute — count!" : " · play";
if (ramp.on) s += " · ramp"; if (ramp.on) s += " · ramp";
if (pendingSwitch) { // a switch is armed → show the target
const it = setlists[pendingSwitch.sl] && setlists[pendingSwitch.sl].items[pendingSwitch.item];
s += " · → " + (it ? it.name : "next");
}
ctxDisplay.textContent = s; ctxDisplay.textContent = s;
ctxDisplay.classList.toggle("muted-cue", muted); ctxDisplay.classList.toggle("muted-cue", muted || !!pendingSwitch);
} }
function updateCtx() { updateStatus(); } function updateCtx() { updateStatus(); }
@ -1175,7 +1321,7 @@ function tapTempo() {
$("tapBtn").addEventListener("click", tapTempo); $("tapBtn").addEventListener("click", tapTempo);
$("saveItemBtn").addEventListener("click", () => { $("saveItemBtn").addEventListener("click", () => {
if (activeItem < 0) return; if (activeItem < 0) return;
updateItem(activeItem); updateItem();
const b = $("saveItemBtn"), t = b.textContent; b.textContent = "✓ Saved"; setTimeout(() => { b.textContent = t; }, 900); const b = $("saveItemBtn"), t = b.textContent; b.textContent = "✓ Saved"; setTimeout(() => { b.textContent = t; }, 900);
}); });
$("bpm").addEventListener("input", (e) => setBpm(+e.target.value)); $("bpm").addEventListener("input", (e) => setBpm(+e.target.value));
@ -1194,15 +1340,16 @@ $("addMeterBtn").addEventListener("click", () => addMeter("4", 1, "claves"));
$("countTime").addEventListener("input", (e) => { timers.totalMs = parseTime(e.target.value); timers.remainingMs = timers.totalMs; renderTimers(); }); $("countTime").addEventListener("input", (e) => { timers.totalMs = parseTime(e.target.value); timers.remainingMs = timers.totalMs; renderTimers(); });
$("elapsedReset").addEventListener("click", () => { timers.elapsedMs = 0; renderTimers(); }); $("elapsedReset").addEventListener("click", () => { timers.elapsedMs = 0; renderTimers(); });
$("countReset").addEventListener("click", () => { timers.remainingMs = timers.totalMs; renderTimers(); }); $("countReset").addEventListener("click", () => { timers.remainingMs = timers.totalMs; renderTimers(); });
$("segBarsIn").addEventListener("input", (e) => { segBars = Math.max(0, parseInt(e.target.value, 10) || 0); renderTimers(); });
$("continueMode").addEventListener("change", (e) => { continueMode = e.target.checked; lsSet(LS.continue, continueMode); }); $("continueMode").addEventListener("change", (e) => { continueMode = e.target.checked; lsSet(LS.continue, continueMode); });
$("timersOn").addEventListener("change", (e) => { timersOn = e.target.checked; lsSet(LS.timers, timersOn); refreshFeatureBoxes(); renderTimers(); }); $("timersOn").addEventListener("change", (e) => { timersOn = e.target.checked; lsSet(LS.timers, timersOn); refreshFeatureBoxes(); renderTimers(); });
$("trayMenuBtn").addEventListener("click", (e) => { e.stopPropagation(); $("trayMenu").hidden = !$("trayMenu").hidden; }); $("trayMenuBtn").addEventListener("click", (e) => { e.stopPropagation(); $("trayMenu").hidden = !$("trayMenu").hidden; });
document.addEventListener("click", (e) => { const m = $("trayMenu"); if (m && !m.hidden && !m.contains(e.target) && e.target.id !== "trayMenuBtn") m.hidden = true; }); document.addEventListener("click", (e) => { const m = $("trayMenu"); if (m && !m.hidden && !m.contains(e.target) && e.target.id !== "trayMenuBtn") m.hidden = true; });
$("newSetlistBtn").addEventListener("click", newSetlist);
$("delSetlistBtn").addEventListener("click", deleteSetlist); $("delSetlistBtn").addEventListener("click", deleteSetlist);
$("setlistSelect").addEventListener("change", (e) => { activeSL = +e.target.value; activeItem = -1; renderSetlists(); }); $("slMenuBtn").addEventListener("click", (e) => { e.stopPropagation(); buildSlMenu(); $("slMenu").hidden = !$("slMenu").hidden; });
$("slTitle").addEventListener("input", (e) => { const sl = getSL(); if (sl) { sl.title = e.target.value; saveSetlists(); const o = $("setlistSelect").options[activeSL]; if (o) o.textContent = sl.title || ("Set list " + (activeSL + 1)); } }); document.addEventListener("click", (e) => { const m = $("slMenu"); if (m && !m.hidden && !m.contains(e.target) && e.target.id !== "slMenuBtn") m.hidden = true; });
$("slDesc").addEventListener("input", (e) => { const sl = getSL(); if (sl) { sl.description = e.target.value; saveSetlists(); } }); $("slTitle").addEventListener("input", (e) => { const sl = getSL(); if (sl) { sl.title = e.target.value; saveSetlists(); } }); // rename the active list in place
$("slDesc").addEventListener("input", (e) => { const sl = getSL(); if (sl) { sl.description = e.target.value; saveSetlists(); } autoGrow(e.target); });
$("addItemBtn").addEventListener("click", () => { addItem($("itemName").value.trim()); $("itemName").value = ""; }); $("addItemBtn").addEventListener("click", () => { addItem($("itemName").value.trim()); $("itemName").value = ""; });
$("helpBtn").addEventListener("click", () => toggleShortcuts()); $("helpBtn").addEventListener("click", () => toggleShortcuts());
$("shortcutsClose").addEventListener("click", () => toggleShortcuts(false)); $("shortcutsClose").addEventListener("click", () => toggleShortcuts(false));
@ -1228,20 +1375,42 @@ window.addEventListener("keydown", (e) => {
if (t && (t.isContentEditable || tag === "TEXTAREA" || if (t && (t.isContentEditable || tag === "TEXTAREA" ||
(tag === "INPUT" && /^(text|number|search|email|url|tel|password)$/.test(type)))) return; (tag === "INPUT" && /^(text|number|search|email|url|tel|password)$/.test(type)))) return;
const k = e.key; const k = e.key;
if (e.altKey && (k === "ArrowUp" || k === "ArrowDown")) { e.preventDefault(); moveActiveItem(k === "ArrowUp" ? -1 : 1); return; } // reorder selected item if (e.altKey && (k === "ArrowUp" || k === "ArrowDown")) { e.preventDefault(); moveCuedItem(k === "ArrowUp" ? -1 : 1); return; } // reorder cued item
// Enter = commit the cued item. Smooth (next bar) by default; Shift+Enter = rude (next beat).
// (shiftKey is NOT in the modifier guard below, so Shift+Enter reaches here.)
if (k === "Enter") {
if (tag === "BUTTON" || tag === "A" || tag === "SELECT") return; // let focused controls keep Enter
e.preventDefault();
if (cuedSL >= 0 && cuedItem >= 0) armSwitch(cuedSL, cuedItem, "commit", e.shiftKey ? "beat" : "bar");
return;
}
if (e.metaKey || e.ctrlKey || e.altKey) return; if (e.metaKey || e.ctrlKey || e.altKey) return;
// Transport: Space always = play/stop. preventDefault so it never scrolls the // Transport: Space always = play/stop. preventDefault so it never scrolls the
// page, toggles a focused checkbox, or re-fires a focused button. // page, toggles a focused checkbox, or re-fires a focused button.
if (k === " " || e.code === "Space") { e.preventDefault(); toggleTransport(); return; } if (k === " " || e.code === "Space") { e.preventDefault(); toggleTransport(); return; }
// Leave arrow keys to a focused slider / menu so they still adjust it. // A focused slider / dropdown uses these keys natively — leave it alone.
const arrowCtrl = tag === "SELECT" || (tag === "INPUT" && type === "range"); const arrowCtrl = tag === "SELECT" || (tag === "INPUT" && type === "range");
if (k === "ArrowUp") { if (arrowCtrl) return; e.preventDefault(); setBpm(state.bpm + (e.shiftKey ? 10 : 1)); return; } // ← / → : tempo (±1, Shift ±10).
if (k === "ArrowDown") { if (arrowCtrl) return; e.preventDefault(); setBpm(state.bpm - (e.shiftKey ? 10 : 1)); return; } if (k === "ArrowRight") { if (arrowCtrl) return; e.preventDefault(); setBpm(state.bpm + (e.shiftKey ? 10 : 1)); return; }
if (k === "ArrowLeft") { if (arrowCtrl) return; e.preventDefault(); setBpm(state.bpm - (e.shiftKey ? 10 : 1)); return; }
// ↑ ↓ Home End : move the cue cursor; PgUp/PgDn : cue across set lists. (Enter commits.)
if (k === "ArrowUp") { if (arrowCtrl) return; e.preventDefault(); cuePrev(); return; }
if (k === "ArrowDown") { if (arrowCtrl) return; e.preventDefault(); cueNext(); return; }
if (k === "Home") { if (arrowCtrl) return; e.preventDefault(); cueFirst(); return; }
if (k === "End") { if (arrowCtrl) return; e.preventDefault(); cueLast(); return; }
if (k === "PageUp") { if (arrowCtrl) return; e.preventDefault(); cueSetlist(-1); return; }
if (k === "PageDown") { if (arrowCtrl) return; e.preventDefault(); cueSetlist(1); return; }
if (k === "t" || k === "T") { tapTempo(); return; } if (k === "t" || k === "T") { tapTempo(); return; }
if (k === "a" || k === "A") { addMeter("4", 1, "claves"); return; } if (k === "a" || k === "A") { addMeter("4", 1, "claves"); return; }
if (k === "p" || k === "P") { prevItem(); return; } // rude quick-step (next beat while playing)
if (k === "n" || k === "N") { nextItem(); return; } if (k === "n" || k === "N") { nextItem(); return; }
if (k === "?") { toggleShortcuts(true); return; } if (k === "?") { toggleShortcuts(true); return; }
if (k === "Escape") { if (!$("shareOverlay").hidden) $("shareOverlay").hidden = true; else if (!$("shortcutsOverlay").hidden) toggleShortcuts(false); return; } if (k === "Escape") {
if (!$("shareOverlay").hidden) $("shareOverlay").hidden = true;
else if (!$("shortcutsOverlay").hidden) toggleShortcuts(false);
else if (pendingSwitch) { pendingSwitch = null; updateCtx(); } // cancel an armed switch
return;
}
if (k >= "1" && k <= "9") { const m = meters[+k - 1]; if (m) setLaneEnabled(m, !m.enabled); } if (k >= "1" && k <= "9") { const m = meters[+k - 1]; if (m) setLaneEnabled(m, !m.enabled); }
}); });