Per-lane enable (replacing mute), feature boxes, set-list continue mode, external QR

- Each meter lane has an 'enable' checkbox right after its number (default on,
  green-dim row when off); replaces the right-side 'mute'. Renamed mute→enabled
  throughout (scheduler, snapshot, share '!' flag, 1–9 keys, now-playing). Old
  saved data still loads (back-compat).
- Features area redesigned into highlighting boxes (Gap trainer / Tempo ramp /
  Timers); trainer & ramp boxes light up + un-dim when enabled.
- Set list 'Continue' mode: per-item countdown (saved in each item, 'cd' token),
  and when a playing item's countdown hits 0 it auto-loads the next — so a list
  with countdowns plays straight through.
- Removed the in-app QR (vendored qrcode.js); 'QR ↗' now opens api.qrserver.com
  with the link, behind a banner warning it's a third party (verify it decodes).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Me Here 2026-05-24 18:47:16 -05:00
parent 285d78b499
commit ba752745b7
4 changed files with 101 additions and 2366 deletions

View file

@ -87,16 +87,16 @@ Tokens are joined with `;`. `tr` and `rmp` are omitted when off.
Opening such a link applies the settings (or imports the set list) on load, then
clears the hash so a refresh won't reimport.
## Sharing & QR
## Sharing
In the setlist panel's **⋯** menu:
- **Share settings link** / **Share setlist link** open a dialog with the link and
a **QR code** (scan to open on a phone). Copy or Open from there.
- **Share settings link** / **Share setlist link** open a dialog with the link to
**Copy** or **Open**.
- **QR ↗** opens a thirdparty QR service (api.qrserver.com) with the link in its
URL so you can scan it on a phone. A banner warns you it's external — confirm the
QR decodes to the shown link before trusting it. (No QR is generated locally.)
- **Export all / Import file** back up presets + set lists + logs as a JSON file.
QR codes are generated locally by the vendored `qrcode.js`; the link never leaves
your browser. Very long setlist links may exceed QR capacity — copy those instead.
## Keyboard shortcuts
| Key | Action |
@ -123,7 +123,7 @@ then push the tag and deploy.
## Deploy
`./deploy.sh` copies `index.html` (versionstamped) and `qrcode.js` into the Caddy
`./deploy.sh` copies `index.html` (versionstamped) into the Caddy
web root and smoketests the live URL. No restart needed (`file_server` picks up
changes immediately).
@ -132,12 +132,6 @@ changes immediately).
| File | Purpose |
|------|---------|
| `index.html` | the whole app |
| `qrcode.js` | vendored QR generator (Kazuhiko Arase, MIT) |
| `deploy.sh` | publish to the Caddy web root |
| `release.sh` | tag a formal version |
| `VERSION` | formal version string |
## Credits
QR generation by [qrcode-generator](https://github.com/kazuhikoarase/qrcode-generator)
(© Kazuhiko Arase, MIT).

View file

@ -37,9 +37,6 @@ fi
sed "s|const APP_VERSION = \"[^\"]*\";|const APP_VERSION = \"$BUILD\";|" "$SRC_DIR/index.html" > "$DEST_DIR/index.html"
echo "deployed v$BUILD ($(stat -c '%s' "$DEST_DIR/index.html") bytes) -> $DEST_DIR"
# vendored assets served alongside index.html
[[ -f "$SRC_DIR/qrcode.js" ]] && cp "$SRC_DIR/qrcode.js" "$DEST_DIR/qrcode.js" && echo "deployed qrcode.js"
# If real audio samples are added later (see the plan's GM-sample note),
# sync that directory too.
if [[ -d "$SRC_DIR/samples" ]]; then

View file

@ -130,6 +130,14 @@
}
.tray-head { display:flex; align-items:center; justify-content:space-between; margin-bottom:14px; }
.practice-col { border-left:1px solid var(--edge); padding-left:18px; }
.fbox { border:1px solid var(--edge); border-radius:10px; padding:9px 11px; margin-bottom:10px; transition:border-color .15s, background .15s; }
.fbox .fhead { display:flex; align-items:center; gap:8px; }
.fbox .ftitle { font-weight:600; font-size:12px; }
.fbox .fbody { margin-top:8px; }
.fbox.on { border-color:#2e7d32; background:rgba(46,125,50,.12); }
.fbox.toggleable:not(.on) .fbody { opacity:.5; }
.lane-enable { accent-color:#2e7d32; margin:0 2px; cursor:pointer; }
.lane-row.lane-off { opacity:.5; }
#themeBtn, #helpBtn { padding:4px 11px; }
/* --- responsive --- */
@media (max-width: 760px) {
@ -149,8 +157,8 @@
.overlay { position:fixed; inset:0; background:rgba(0,0,0,.55); display:flex; align-items:center; justify-content:center; z-index:80; }
.overlay[hidden] { display:none; }
.overlay-box { background:var(--panel-2); border:1px solid var(--edge); border-radius:14px; padding:16px 20px; width:380px; max-width:92vw; box-shadow:0 20px 60px rgba(0,0,0,.6); }
.qr { background:#fff; border-radius:8px; padding:10px; text-align:center; margin-bottom:12px; }
.qr img { display:block; margin:0 auto; image-rendering:pixelated; max-width:100%; }
.ext-banner { font-size:11px; color:#3a2f10; background:#ffe2a8; border:1px solid #d9a441; border-radius:8px; padding:8px 10px; margin-top:10px; line-height:1.35; }
.ext-banner[hidden] { display:none; }
#shareUrl { width:100%; resize:vertical; background:var(--panel); color:var(--txt); border:1px solid var(--edge); border-radius:8px; padding:8px; font-family:"Courier New",monospace; font-size:12px; }
.kbd-table { width:100%; border-collapse:collapse; font-size:13px; }
.kbd-table td { padding:6px 4px; border-bottom:1px solid var(--edge); }
@ -205,18 +213,24 @@
</div>
<div class="practice-col" style="flex:1; min-width:215px">
<div class="checkrow" style="margin-bottom:8px"><input type="checkbox" id="trainerOn"><label for="trainerOn">Gap / mute trainer</label></div>
<div class="row" style="gap:14px; align-items:center">
<div class="fbox toggleable" id="trainerBox">
<label class="fhead"><input type="checkbox" id="trainerOn"><span class="ftitle">Gap / mute trainer</span></label>
<div class="fbody row" style="gap:14px; align-items:center">
<label style="font-size:12px">Play <input type="number" class="num" id="playBars" min="1" max="16" value="2"></label>
<label style="font-size:12px">Mute <input type="number" class="num" id="muteBars" min="0" max="16" value="2"> bars</label>
</div>
<div class="checkrow" style="margin:10px 0 8px"><input type="checkbox" id="rampOn"><label for="rampOn">Tempo ramp</label></div>
<div class="row" style="gap:12px 14px; align-items:center; flex-wrap:wrap">
</div>
<div class="fbox toggleable" id="rampBox">
<label class="fhead"><input type="checkbox" id="rampOn"><span class="ftitle">Tempo ramp</span></label>
<div class="fbody row" style="gap:12px 14px; align-items:center; flex-wrap:wrap">
<label style="font-size:12px">from <input type="number" class="num" id="rampStart" min="30" max="300" value="80"> BPM</label>
<label style="font-size:12px" title="negative ramps down, positive ramps up"><input type="number" class="num" id="rampAmt" min="-30" max="30" value="5"> BPM</label>
<label style="font-size:12px">every <input type="number" class="num" id="rampEvery" min="1" max="16" value="4"> bars</label>
</div>
<div class="checkrow" style="margin:12px 0 6px; gap:8px"><b style="font-size:11px; text-transform:uppercase; letter-spacing:1px; color:var(--muted)">Timers</b><span class="hint" style="margin:0">run while playing</span></div>
</div>
<div class="fbox" id="timerBox">
<div class="fhead"><span class="ftitle">Timers</span><span class="hint" style="margin:0">run while playing</span></div>
<div class="fbody">
<div class="row" style="gap:10px; align-items:center">
<label style="font-size:12px">Elapsed (stopwatch)</label>
<button class="iconbtn" id="elapsedReset" title="reset elapsed"></button>
@ -229,6 +243,8 @@
</div>
</div>
</div>
</div>
</div>
<div class="row" style="align-items:center; gap:12px; margin:14px 0 8px">
<h2 style="font-size:11px; text-transform:uppercase; letter-spacing:1.4px; color:var(--muted); margin:0">Meter lanes</h2>
@ -273,8 +289,9 @@
<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">▶ loads &amp; starts an item · <kbd>N</kbd> advances · 💾 saves current settings back to an item.</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>
</aside>
@ -291,7 +308,7 @@
<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>1</kbd><kbd>9</kbd></td><td>Mute 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>Esc</kbd></td><td>Close tray / help</td></tr>
</table>
@ -302,14 +319,13 @@
<div id="shareOverlay" class="overlay" hidden>
<div class="overlay-box">
<div class="tray-head"><h2 id="shareTitle" style="margin:0">Share</h2><button class="x" id="shareClose" title="close" style="margin-left:0"></button></div>
<div id="shareQr" class="qr"></div>
<textarea id="shareUrl" readonly rows="3"></textarea>
<div class="btnrow" style="margin-top:8px"><button id="shareCopy">Copy link</button><button id="shareOpen">Open ↗</button></div>
<div class="btnrow" style="margin-top:8px"><button id="shareCopy">Copy link</button><button id="shareOpen">Open ↗</button><button id="shareQrExt">QR ↗</button></div>
<div class="hint" id="shareNote"></div>
<div class="ext-banner" id="shareExtBanner" hidden>⚠ “QR ↗” opens an external site (api.qrserver.com) with this link embedded in its URL. It is a third party — after scanning, confirm the QR decodes to the link above before trusting it.</div>
</div>
</div>
<script src="qrcode.js"></script>
<script>
"use strict";
@ -452,7 +468,7 @@ function scheduleMeterTick(m, time) {
const onBeat = (tickInBar % spb) === 0;
const beatIndex = Math.floor(tickInBar / spb);
if (onBeat) m.vq.push({ time, beat: beatIndex, bar: Math.floor(m.tick / barLen) }); // playhead + measure (advance even when muted)
if (m.mute || isMutedAt(time)) return;
if (!m.enabled || isMutedAt(time)) return;
if (!m.beatsOn[beatIndex]) return; // beat toggled off → rest (incl. its subdivisions)
if (onBeat) {
const groupStart = m.groupStarts.has(beatIndex);
@ -515,7 +531,7 @@ function addMeter(groupsStr = "4", stepsPerBeat = 4, sound = "beep", beatsOn = n
const p = parseGroups(groupsStr);
const m = {
id, groupsStr, groups: p.groups, beatsPerBar: p.beatsPerBar, groupStarts: p.groupStarts,
stepsPerBeat, sound, mute: false, poly: !!poly, color: laneColor(id),
stepsPerBeat, sound, enabled: true, poly: !!poly, color: laneColor(id),
beatsOn: beatsOn ? beatsOn.slice() : [], // per-beat on/off mask (rests)
tick: 0, nextTime: 0, vq: [], vqPtr: 0, currentBeat: -1, currentBar: 0,
el: null, stripEl: null, barEl: null,
@ -538,6 +554,11 @@ function removeMeter(id) {
// lane labels track position (1-based) so number-key shortcuts line up with what's shown
function renumberLanes() { meters.forEach((m, i) => { if (m.titleEl) m.titleEl.textContent = i + 1; }); }
function setLaneEnabled(m, on) {
m.enabled = on;
const cb = m.el && m.el.querySelector(`#m${m.id}_enable`); if (cb) cb.checked = on;
if (m.el) m.el.querySelector(".lane-row").classList.toggle("lane-off", !on);
}
function buildLaneCard(m) {
const card = document.createElement("div");
@ -545,6 +566,7 @@ function buildLaneCard(m) {
card.innerHTML = `
<div class="lane-row">
<span class="lane-title" id="m${m.id}_title" style="color:${m.color}">${m.id}</span>
<input type="checkbox" class="lane-enable" id="m${m.id}_enable" title="enable / silence this lane" checked>
<input type="text" class="txt grp" id="m${m.id}_group" value="${m.groupsStr}" spellcheck="false" title="grouping, e.g. 2+2+3">
<span class="sum" id="m${m.id}_sum"></span>
<select class="cmp" id="m${m.id}_preset" title="time-signature presets">
@ -560,7 +582,6 @@ function buildLaneCard(m) {
<div class="strip" id="m${m.id}_strip"></div>
<span class="bar" id="m${m.id}_bar"></span>
<label class="mini-check" title="polyrhythm: fit these beats evenly into lane 1's bar"><input type="checkbox" id="m${m.id}_poly"> poly</label>
<label class="mini-check"><input type="checkbox" id="m${m.id}_mute"> mute</label>
<button class="x" id="m${m.id}_remove" title="remove lane"></button>
</div>`;
document.getElementById("meters").appendChild(card);
@ -583,7 +604,9 @@ function buildLaneCard(m) {
sel.addEventListener("change", (e) => m.sound = e.target.value);
const polyCb = $c(`#m${m.id}_poly`); polyCb.checked = m.poly;
polyCb.addEventListener("change", (e) => m.poly = e.target.checked);
$c(`#m${m.id}_mute`).addEventListener("change", (e) => m.mute = e.target.checked);
const enCb = $c(`#m${m.id}_enable`); enCb.checked = m.enabled;
enCb.addEventListener("change", (e) => setLaneEnabled(m, e.target.checked));
card.querySelector(".lane-row").classList.toggle("lane-off", !m.enabled);
$c(`#m${m.id}_remove`).addEventListener("click", () => removeMeter(m.id));
recomputeLane(m);
@ -626,18 +649,17 @@ function renderLaneStrip(m) {
/* =========================================================================
PRESETS (localStorage)
========================================================================= */
const LS = { presets: "metronome.presets", setlists: "metronome.setlists", logs: "metronome.logs", seeded: "metronome.seeded" };
const LS = { presets: "metronome.presets", setlists: "metronome.setlists", logs: "metronome.logs", seeded: "metronome.seeded", continue: "metronome.continue" };
function lsGet(k, fb) { try { const v = localStorage.getItem(k); return v ? JSON.parse(v) : fb; } catch (e) { return fb; } }
function lsSet(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); return true; } catch (e) { console.warn("localStorage unavailable", e); return false; } }
function snapshotLanes() { return meters.map((m) => ({ groupsStr: m.groupsStr, stepsPerBeat: m.stepsPerBeat, sound: m.sound, mute: m.mute, poly: m.poly, beatsOn: m.beatsOn.slice() })); }
function snapshotLanes() { return meters.map((m) => ({ groupsStr: m.groupsStr, stepsPerBeat: m.stepsPerBeat, sound: m.sound, enabled: m.enabled, poly: m.poly, beatsOn: m.beatsOn.slice() })); }
function applyLanes(lanes) {
while (meters.length) removeMeter(meters[0].id);
for (const c of lanes) {
addMeter(c.groupsStr, c.stepsPerBeat, c.sound, c.beatsOn, c.poly);
const m = meters[meters.length - 1];
m.mute = !!c.mute;
const cb = m.el.querySelector(`#m${m.id}_mute`); if (cb) cb.checked = m.mute;
setLaneEnabled(m, c.enabled !== undefined ? !!c.enabled : (c.mute === undefined ? true : !c.mute)); // back-compat with old "mute"
}
}
// (Presets removed — set-list items are now the single "saved setup" mechanism.)
@ -653,17 +675,25 @@ let activeSL = 0; // selected set list
let activeItem = -1; // selected / loaded item in the active set list
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
function currentSetup() { return { bpm: state.bpm, lanes: snapshotLanes(), trainer: { ...trainer }, ramp: { ...ramp } }; }
function currentSetup() { return { bpm: state.bpm, lanes: snapshotLanes(), trainer: { ...trainer }, ramp: { ...ramp }, countMs: timers.totalMs }; }
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
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) : "";
refreshFeatureBoxes(); renderTimers();
}
function refreshFeatureBoxes() {
$("trainerBox").classList.toggle("on", trainer.on);
$("rampBox").classList.toggle("on", ramp.on);
}
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]; }
@ -730,7 +760,7 @@ function renderNowPlaying() {
return;
}
$("npName").textContent = (activeItem + 1) + ". " + it.name;
$("npSub").textContent = it.bpm + " BPM · " + it.lanes.map((l) => l.sound + " " + l.groupsStr + (l.poly ? "~" : "") + (l.mute ? " (muted)" : "")).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 : "");
}
@ -820,7 +850,7 @@ function importAll(file) {
/* =========================================================================
SHARE LANGUAGE (compact, human-readable; encodes settings/set lists in URLs)
Patch: v1;t<bpm>;vol<pct>;<lane>;…[;tr<play>/<mute>][;rmp<start>/<step>/<every>]
Lane: <sound>:<grouping>[/<sub>][=<pattern x/.>][~ poly][! mute]
Lane: <sound>:<grouping>[/<sub>][=<pattern x/.>][~ poly][! disabled]
========================================================================= */
function laneCfgToStr(c) {
const bpb = parseGroups(c.groupsStr).beatsPerBar;
@ -829,12 +859,12 @@ function laneCfgToStr(c) {
const on = (c.beatsOn || []).slice(0, bpb);
if (on.length && !on.every(Boolean)) s += "=" + Array.from({ length: bpb }, (_, i) => (on[i] ? "x" : ".")).join("");
if (c.poly) s += "~";
if (c.mute) s += "!";
if (c.enabled === false) s += "!"; // "!" = silenced / disabled
return s;
}
function laneStrToCfg(tok) {
let poly = false, mute = false;
while (/[~!]$/.test(tok)) { if (tok.endsWith("!")) mute = true; else poly = true; tok = tok.slice(0, -1); }
let poly = false, disabled = false;
while (/[~!]$/.test(tok)) { if (tok.endsWith("!")) disabled = true; else poly = true; tok = tok.slice(0, -1); }
const ci = tok.indexOf(":"); if (ci < 0) return null;
let sound = tok.slice(0, ci), rest = tok.slice(ci + 1), pattern = null;
const eq = rest.indexOf("="); if (eq >= 0) { pattern = rest.slice(eq + 1); rest = rest.slice(0, eq); }
@ -844,22 +874,24 @@ function laneStrToCfg(tok) {
const beatsOn = pattern ? pattern.split("").map((ch) => ch === "x" || ch === "X" || ch === "1")
: new Array(bpb).fill(true);
if (!DRUMS[sound]) sound = "beep";
return { groupsStr, stepsPerBeat: sub, sound, beatsOn, poly, mute };
return { groupsStr, stepsPerBeat: sub, sound, beatsOn, poly, enabled: !disabled };
}
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));
(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, 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, 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); }
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("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;
@ -884,23 +916,17 @@ function codeToSetlist(code) {
}
function shareLink(hashPart) { return location.origin + location.pathname + "#" + hashPart; }
function renderQR(el, text) {
el.innerHTML = "";
if (typeof qrcode !== "function") { el.textContent = "(QR library not loaded)"; return; }
try { const qr = qrcode(0, "M"); qr.addData(text); qr.make(); el.innerHTML = qr.createImgTag(4, 10); }
catch (e) { el.textContent = "Link too long to fit a QR — use Copy."; }
}
function openShare(title, url, note) {
$("shareTitle").textContent = title;
$("shareUrl").value = url;
$("shareNote").textContent = note || "";
renderQR($("shareQr"), url);
$("shareExtBanner").hidden = true; // reset the external-QR warning
$("shareOverlay").hidden = false;
}
function shareSettings() { openShare("Share settings", shareLink("p=" + currentPatch()), "Encodes tempo, lanes & practice settings. Scan the QR or copy the link."); }
function shareSettings() { openShare("Share settings", shareLink("p=" + currentPatch()), "Encodes tempo, lanes & practice settings."); }
function shareSetlist() {
const sl = getSL(); if (!sl) return alert("No set list selected to share.");
openShare("Share “" + (sl.title || "set list") + "”", shareLink("sl=" + setlistToCode(sl)), "Whole set list. Long links may not scan well — use Copy.");
openShare("Share “" + (sl.title || "set list") + "”", shareLink("sl=" + setlistToCode(sl)), "Shares the whole set list (each item's settings).");
}
// Apply a shared link on load. Returns true if it set the metronome state.
@ -972,7 +998,15 @@ function tickTimers() {
timers.last = now;
if (state.running) {
timers.elapsedMs += dt;
if (timers.totalMs > 0) timers.remainingMs -= dt; // counts past 0 into negative (overtime); never stops the metronome
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);
}
// otherwise it keeps counting past 0 into negative (overtime); never stops the metronome
}
}
renderTimers();
}
@ -1044,10 +1078,10 @@ $("vol").addEventListener("input", (e) => {
state.volume = +e.target.value / 100; volVal.textContent = e.target.value + "%";
if (masterGain) masterGain.gain.setTargetAtTime(state.volume, audioCtx.currentTime, 0.01);
});
$("trainerOn").addEventListener("change", (e) => trainer.on = e.target.checked);
$("trainerOn").addEventListener("change", (e) => { trainer.on = e.target.checked; refreshFeatureBoxes(); });
$("playBars").addEventListener("input", (e) => trainer.playBars = +e.target.value);
$("muteBars").addEventListener("input", (e) => trainer.muteBars = +e.target.value);
$("rampOn").addEventListener("change", (e) => ramp.on = e.target.checked);
$("rampOn").addEventListener("change", (e) => { ramp.on = e.target.checked; refreshFeatureBoxes(); });
$("rampStart").addEventListener("input", (e) => ramp.startBpm = +e.target.value);
$("rampAmt").addEventListener("input", (e) => ramp.amount = +e.target.value);
$("rampEvery").addEventListener("input", (e) => ramp.everyBars = +e.target.value);
@ -1055,6 +1089,7 @@ $("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(); });
$("continueMode").addEventListener("change", (e) => { continueMode = e.target.checked; lsSet(LS.continue, continueMode); });
$("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);
@ -1077,6 +1112,10 @@ $("shareClose").addEventListener("click", () => $("shareOverlay").hidden = true)
$("shareOverlay").addEventListener("click", (e) => { if (e.target.id === "shareOverlay") $("shareOverlay").hidden = true; });
$("shareCopy").addEventListener("click", async () => { try { await navigator.clipboard.writeText($("shareUrl").value); const b = $("shareCopy"); b.textContent = "Copied!"; setTimeout(() => b.textContent = "Copy link", 1200); } catch (e) { $("shareUrl").select(); } });
$("shareOpen").addEventListener("click", () => window.open($("shareUrl").value, "_blank"));
$("shareQrExt").addEventListener("click", () => {
$("shareExtBanner").hidden = false; // warn that we're handing the link to a third party
window.open("https://api.qrserver.com/v1/create-qr-code/?size=320x320&data=" + encodeURIComponent($("shareUrl").value), "_blank", "noopener");
});
window.addEventListener("keydown", (e) => {
const t = e.target;
if (t && (t.tagName === "INPUT" || t.tagName === "SELECT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
@ -1093,7 +1132,7 @@ window.addEventListener("keydown", (e) => {
else if (k === "Escape") { if (!$("shareOverlay").hidden) $("shareOverlay").hidden = true; else if (!$("shortcutsOverlay").hidden) toggleShortcuts(false); }
else if (k >= "1" && k <= "9") {
const m = meters[+k - 1];
if (m) { m.mute = !m.mute; const cb = m.el.querySelector(`#m${m.id}_mute`); if (cb) cb.checked = m.mute; }
if (m) setLaneEnabled(m, !m.enabled);
}
});
@ -1114,6 +1153,8 @@ if (!applyHashShare()) {
renderSetlists();
renderLog();
updateCtx();
refreshFeatureBoxes();
$("continueMode").checked = continueMode;
$("appVersion").textContent = "v" + APP_VERSION;
requestAnimationFrame(drawLoop);
</script>

2297
qrcode.js

File diff suppressed because it is too large Load diff