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:
parent
61c933e44f
commit
7a6aa1d5ba
2 changed files with 283 additions and 91 deletions
35
README.md
35
README.md
|
|
@ -39,7 +39,7 @@ goes in a share link, and you can hand‑write or edit it.
|
|||
### 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 |
|
||||
|
|
@ -47,6 +47,8 @@ v1 ; t<bpm> [; vol<pct>] ; <lane> ; <lane> … [; tr<play>/<mute>] [; rmp<start>
|
|||
| `v1` | format version (always first) | `v1` |
|
||||
| `t<bpm>` | tempo | `t120` |
|
||||
| `vol<pct>` | master volume 0–100 | `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` |
|
||||
| `rmp<start>/<step>/<every>` | tempo ramp: start BPM, ±step, every N bars | `rmp80/5/4` |
|
||||
| `<lane>` | a meter lane (see below) | `kick:4` |
|
||||
|
|
@ -115,15 +117,36 @@ In the set‑list panel's **⋯** menu:
|
|||
|-----|--------|
|
||||
| `Space` | play / stop (works everywhere except while typing in a text field) |
|
||||
| `T` | tap tempo |
|
||||
| `↑` / `↓` | tempo ±1 (`Shift` = ±10) |
|
||||
| `←` / `→` | tempo ±1 (`Shift` = ±10) |
|
||||
| `A` | add meter lane |
|
||||
| `N` | load next set‑list item |
|
||||
| `Alt`+`↑` / `Alt`+`↓` | reorder the selected set‑list item |
|
||||
| `↑` / `↓` / `Home` / `End` | move the **cue** cursor (crosses set lists) |
|
||||
| `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 quick‑step) |
|
||||
| `Alt`+`↑` / `Alt`+`↓` | reorder the cued item |
|
||||
| `1`–`9` | enable / silence lane 1–9 |
|
||||
| `?` | 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 quick‑steps. `Esc` cancels an armed switch.
|
||||
- **Bar‑length 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
|
||||
auto‑advances 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
|
||||
|
||||
|
|
|
|||
331
index.html
331
index.html
|
|
@ -4,6 +4,7 @@
|
|||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Stackable Metronome — Mockup</title>
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAzMiAzMiI+PHJlY3Qgd2lkdGg9IjMyIiBoZWlnaHQ9IjMyIiByeD0iNyIgZmlsbD0iIzFDMjgzRiIvPjxwYXRoIGQ9Ik0xMiA2aDhsNCAyMUg4eiIgZmlsbD0iIzBBQjNGNyIvPjxwYXRoIGQ9Ik0xNiAyNEwxOS42IDkiIHN0cm9rZT0iIzFDMjgzRiIgc3Ryb2tlLXdpZHRoPSIxLjgiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgZmlsbD0ibm9uZSIvPjxyZWN0IHg9IjE2LjQiIHk9IjEzLjIiIHdpZHRoPSI0LjQiIGhlaWdodD0iMi44IiByeD0iMC42IiB0cmFuc2Zvcm09InJvdGF0ZSgtMTMgMTguNiAxNC42KSIgZmlsbD0iIzFDMjgzRiIvPjwvc3ZnPgo=">
|
||||
<script>
|
||||
// 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.
|
||||
|
|
@ -124,7 +125,8 @@
|
|||
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: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 .meta { color:var(--muted); font-family:"Courier New",monospace; font-size:11px; }
|
||||
.ex-item .row-actions { display:none; gap:4px; }
|
||||
|
|
@ -197,7 +199,7 @@
|
|||
.kbd-table tr:last-child td { border-bottom:none; }
|
||||
.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); }
|
||||
.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; }
|
||||
.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; }
|
||||
|
|
@ -222,7 +224,7 @@
|
|||
<button id="helpBtn" title="keyboard shortcuts (?)">?</button>
|
||||
</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 -->
|
||||
<div class="row">
|
||||
|
|
@ -233,7 +235,8 @@
|
|||
<div class="big" id="bpmDisplay">120</div>
|
||||
<div class="dtimers" id="dtimers">
|
||||
<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 class="ctx" id="ctxDisplay"> </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>
|
||||
<button class="iconbtn" id="countReset" title="reset countdown">⟲</button>
|
||||
</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>
|
||||
|
|
@ -314,23 +321,23 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lane-row" style="margin-bottom:8px">
|
||||
<select class="cmp" id="setlistSelect" style="flex:1"></select>
|
||||
<button id="newSetlistBtn">+ New</button>
|
||||
<!-- editable set-list selector: rename the active list in place; ▾ switches / creates -->
|
||||
<div class="lane-row" style="margin-bottom:8px; position:relative">
|
||||
<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>
|
||||
<div id="slMenu" class="menu" hidden style="top:38px; left:0; right:auto; max-height:240px; overflow:auto"></div>
|
||||
</div>
|
||||
<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"></textarea>
|
||||
<textarea id="slDesc" placeholder="description / notes" rows="1"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="lane-row" style="margin:12px 0 6px">
|
||||
<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 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">
|
||||
<button id="addItemBtn">+ Add current settings</button>
|
||||
</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 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 class="hint" style="margin-top:6px"><kbd>Alt+↑/↓</kbd> reorder · 💾 save settings to an item.</div>
|
||||
|
||||
<div id="logView" style="margin-top:18px"></div>
|
||||
</aside>
|
||||
|
|
@ -342,14 +349,17 @@
|
|||
<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>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 ±10 BPM</td></tr>
|
||||
<tr><td><kbd>←</kbd> <kbd>→</kbd></td><td>Tempo ±1 BPM (<kbd>⇧</kbd> ±10)</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></td><td>Reorder selected 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>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 1–9</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>
|
||||
<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>
|
||||
|
|
@ -496,6 +506,11 @@ function advanceMaster(ahead) {
|
|||
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++;
|
||||
masterBeatTime += 60 / state.bpm;
|
||||
|
|
@ -530,14 +545,19 @@ function laneStepDur(m) {
|
|||
|
||||
function scheduler() {
|
||||
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) {
|
||||
while (m.nextTime < ahead) {
|
||||
while (m.nextTime < cap) {
|
||||
scheduleMeterTick(m, m.nextTime);
|
||||
m.tick++;
|
||||
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() {
|
||||
state.running = false;
|
||||
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;
|
||||
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) {
|
||||
state.bpm = Math.max(30, Math.min(300, Math.round(v)));
|
||||
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).
|
||||
========================================================================= */
|
||||
let setlists = lsGet(LS.setlists, []);
|
||||
let activeSL = 0; // selected set list
|
||||
let activeItem = -1; // selected / loaded item in the active set list
|
||||
let activeSL = 0; // VIEWED set list (the one shown in the panel)
|
||||
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 historyName = null; // item whose past-session history is shown
|
||||
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
|
||||
|
||||
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) {
|
||||
setBpm(s.bpm); applyLanes(s.lanes);
|
||||
if (s.trainer) Object.assign(trainer, s.trainer);
|
||||
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();
|
||||
}
|
||||
function syncPracticeUI() {
|
||||
$("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;
|
||||
$("countTime").value = timers.totalMs > 0 ? fmtClock(timers.totalMs) : "";
|
||||
$("segBarsIn").value = segBars || 0;
|
||||
refreshFeatureBoxes(); renderTimers();
|
||||
}
|
||||
function refreshFeatureBoxes() {
|
||||
|
|
@ -760,92 +822,155 @@ function refreshFeatureBoxes() {
|
|||
$("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 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); }
|
||||
|
||||
// --- set list CRUD ---
|
||||
function newSetlist() {
|
||||
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() {
|
||||
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) {
|
||||
const sl = getSL(); if (!sl) return;
|
||||
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) {
|
||||
const sl = getSL(); if (!sl) return;
|
||||
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();
|
||||
}
|
||||
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+↑/↓)
|
||||
const sl = getSL(); if (!sl || activeItem < 0) return;
|
||||
const j = activeItem + d; if (j < 0 || j >= sl.items.length) return;
|
||||
moveItem(activeItem, d); activeItem = j; renderItems();
|
||||
function moveCuedItem(d) { // keyboard reorder of the cued item (Alt+↑/↓), within the viewed list
|
||||
if (cuedSL !== activeSL || cuedItem < 0) return;
|
||||
const sl = getSL(); const j = cuedItem + d; if (j < 0 || j >= sl.items.length) return;
|
||||
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 ---
|
||||
function loadItem(i) {
|
||||
const sl = getSL(); if (!sl || !sl.items[i]) return;
|
||||
const wasRunning = state.running;
|
||||
if (wasRunning) logFinalize(); // close out the previous segment
|
||||
applySetup(sl.items[i]);
|
||||
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();
|
||||
// Record the loaded item + sync the cue + history (state only; no audio, no applySetup).
|
||||
function setLoaded(sl, i) {
|
||||
loadedSL = sl; activeItem = i;
|
||||
const it = setlists[sl] && setlists[sl].items[i];
|
||||
if (it) historyName = it.name;
|
||||
cuedSL = sl; cuedItem = i; // the cue follows the loaded item
|
||||
}
|
||||
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)
|
||||
const sl = getSL(); if (!sl || !sl.items[i]) return;
|
||||
sl.items[i] = { name: sl.items[i].name, ...currentSetup() };
|
||||
|
||||
// --- load: clicking / N / P loads. While playing this is a gap-free RUDE (next-beat) cutover. ---
|
||||
function loadItem(i, sl = activeSL) {
|
||||
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();
|
||||
}
|
||||
|
||||
// Start/stop go through here so internal restarts don't create stray log entries.
|
||||
function toggleTransport() {
|
||||
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();
|
||||
}
|
||||
|
||||
// --- now-playing info on the main screen (replaces the old preset dropdown) ---
|
||||
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
|
||||
// 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
|
||||
? "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;
|
||||
if (!it) {
|
||||
const vsl = getSL();
|
||||
$("npName").textContent = "Free play";
|
||||
$("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;
|
||||
}
|
||||
const lsl = setlists[loadedSL];
|
||||
$("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(" · ");
|
||||
$("npDesc").textContent = (sl.title || "") + (sl.description ? " — " + sl.description : "");
|
||||
$("npDesc").textContent = ((lsl && lsl.title) || "") + (lsl && lsl.description ? " — " + lsl.description : "");
|
||||
}
|
||||
|
||||
// --- 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() {
|
||||
const sel = $("setlistSelect"); sel.innerHTML = "";
|
||||
const has = setlists.length > 0;
|
||||
$("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;
|
||||
setlists.forEach((sl, i) => { const o = document.createElement("option"); o.value = i; o.textContent = sl.title || ("Set list " + (i + 1)); sel.appendChild(o); });
|
||||
sel.value = activeSL;
|
||||
const sl = getSL(); $("slTitle").value = sl.title || ""; $("slDesc").value = sl.description || "";
|
||||
renderItems();
|
||||
const sl = getSL();
|
||||
$("slTitle").value = sl.title || "";
|
||||
$("slDesc").value = sl.description || ""; autoGrow($("slDesc"));
|
||||
buildSlMenu(); renderItems();
|
||||
}
|
||||
function renderItems() {
|
||||
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; }
|
||||
sl.items.forEach((it, i) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "ex-item" + (i === activeItem ? " active" : "");
|
||||
row.title = "Click to load into the player · Alt+↑/↓ to reorder";
|
||||
row.innerHTML = `<span class="nm">${i + 1}. ${it.name}</span>
|
||||
row.className = "ex-item"
|
||||
+ (activeSL === loadedSL && i === activeItem ? " active" : "") // loaded/playing (green)
|
||||
+ (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="row-actions">
|
||||
<button class="x iconbtn" data-act="del" title="remove this item">✕</button>
|
||||
</span>`;
|
||||
row.onclick = () => loadItem(i);
|
||||
row.onclick = () => loadItem(i, activeSL);
|
||||
row.querySelector('[data-act=del]').onclick = (e) => { e.stopPropagation(); removeItem(i); };
|
||||
box.appendChild(row);
|
||||
});
|
||||
|
|
@ -978,18 +1105,20 @@ function setupToPatch(s) {
|
|||
const parts = ["v1", "t" + s.bpm];
|
||||
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.bars > 0) parts.push("b" + s.bars);
|
||||
(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.ramp && s.ramp.on) parts.push("rmp" + s.ramp.startBpm + "/" + s.ramp.amount + "/" + s.ramp.everyBars);
|
||||
return parts.join(";");
|
||||
}
|
||||
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(";")) {
|
||||
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("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("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;
|
||||
|
|
@ -1099,9 +1228,11 @@ function tickTimers() {
|
|||
if (timers.totalMs > 0) {
|
||||
const before = timers.remainingMs;
|
||||
timers.remainingMs -= dt;
|
||||
if (before > 0 && timers.remainingMs <= 0 && continueMode) { // countdown hit 0 → auto-advance
|
||||
const sl = getSL();
|
||||
if (sl && activeItem >= 0 && activeItem + 1 < sl.items.length) loadItem(activeItem + 1);
|
||||
// time countdown hit 0 → auto-advance (smooth). Bar-length segments use the
|
||||
// bar counter instead (handled in advanceMaster), so only fire when segBars===0.
|
||||
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
|
||||
}
|
||||
|
|
@ -1113,12 +1244,23 @@ function renderTimers() {
|
|||
if (!timersOn) return;
|
||||
$("elapsedVal").textContent = fmtClock(timers.elapsedMs);
|
||||
const off = timers.totalMs <= 0;
|
||||
$("countWrap").hidden = off; // hide countdown when off
|
||||
if (off) return;
|
||||
$("countWrap").hidden = off; // hide time countdown when off
|
||||
if (!off) {
|
||||
const cd = $("countVal");
|
||||
cd.textContent = fmtClock(timers.remainingMs);
|
||||
cd.classList.toggle("over", timers.remainingMs <= 0); // overtime
|
||||
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 →
|
||||
|
|
@ -1135,8 +1277,12 @@ function updateStatus() {
|
|||
let s = "▶ bar " + (barIndex + 1);
|
||||
if (trainer.on) s += muted ? " · mute — count!" : " · play";
|
||||
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.classList.toggle("muted-cue", muted);
|
||||
ctxDisplay.classList.toggle("muted-cue", muted || !!pendingSwitch);
|
||||
}
|
||||
function updateCtx() { updateStatus(); }
|
||||
|
||||
|
|
@ -1175,7 +1321,7 @@ function tapTempo() {
|
|||
$("tapBtn").addEventListener("click", tapTempo);
|
||||
$("saveItemBtn").addEventListener("click", () => {
|
||||
if (activeItem < 0) return;
|
||||
updateItem(activeItem);
|
||||
updateItem();
|
||||
const b = $("saveItemBtn"), t = b.textContent; b.textContent = "✓ Saved"; setTimeout(() => { b.textContent = t; }, 900);
|
||||
});
|
||||
$("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(); });
|
||||
$("elapsedReset").addEventListener("click", () => { timers.elapsedMs = 0; 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); });
|
||||
$("timersOn").addEventListener("change", (e) => { timersOn = e.target.checked; lsSet(LS.timers, timersOn); refreshFeatureBoxes(); renderTimers(); });
|
||||
$("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; });
|
||||
$("newSetlistBtn").addEventListener("click", newSetlist);
|
||||
$("delSetlistBtn").addEventListener("click", deleteSetlist);
|
||||
$("setlistSelect").addEventListener("change", (e) => { activeSL = +e.target.value; activeItem = -1; renderSetlists(); });
|
||||
$("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)); } });
|
||||
$("slDesc").addEventListener("input", (e) => { const sl = getSL(); if (sl) { sl.description = e.target.value; saveSetlists(); } });
|
||||
$("slMenuBtn").addEventListener("click", (e) => { e.stopPropagation(); buildSlMenu(); $("slMenu").hidden = !$("slMenu").hidden; });
|
||||
document.addEventListener("click", (e) => { const m = $("slMenu"); if (m && !m.hidden && !m.contains(e.target) && e.target.id !== "slMenuBtn") m.hidden = true; });
|
||||
$("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 = ""; });
|
||||
$("helpBtn").addEventListener("click", () => toggleShortcuts());
|
||||
$("shortcutsClose").addEventListener("click", () => toggleShortcuts(false));
|
||||
|
|
@ -1228,20 +1375,42 @@ window.addEventListener("keydown", (e) => {
|
|||
if (t && (t.isContentEditable || tag === "TEXTAREA" ||
|
||||
(tag === "INPUT" && /^(text|number|search|email|url|tel|password)$/.test(type)))) return;
|
||||
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;
|
||||
// Transport: Space always = play/stop. preventDefault so it never scrolls the
|
||||
// page, toggles a focused checkbox, or re-fires a focused button.
|
||||
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");
|
||||
if (k === "ArrowUp") { if (arrowCtrl) return; e.preventDefault(); setBpm(state.bpm + (e.shiftKey ? 10 : 1)); return; }
|
||||
if (k === "ArrowDown") { if (arrowCtrl) return; e.preventDefault(); setBpm(state.bpm - (e.shiftKey ? 10 : 1)); return; }
|
||||
// ← / → : tempo (±1, Shift ±10).
|
||||
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 === "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 === "?") { 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); }
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue