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:
parent
285d78b499
commit
ba752745b7
4 changed files with 101 additions and 2366 deletions
20
README.md
20
README.md
|
|
@ -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 re‑import.
|
||||
|
||||
## Sharing & QR
|
||||
## Sharing
|
||||
|
||||
In the set‑list panel's **⋯** menu:
|
||||
- **Share settings link** / **Share set‑list 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 set‑list link** open a dialog with the link to
|
||||
**Copy** or **Open**.
|
||||
- **QR ↗** opens a third‑party 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 set‑list 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` (version‑stamped) and `qrcode.js` into the Caddy
|
||||
`./deploy.sh` copies `index.html` (version‑stamped) into the Caddy
|
||||
web root and smoke‑tests 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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
147
index.html
147
index.html
|
|
@ -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,25 +213,33 @@
|
|||
</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">
|
||||
<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 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>
|
||||
<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">
|
||||
<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 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>
|
||||
<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 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>
|
||||
</div>
|
||||
<div class="row" style="gap:10px; align-items:center; margin-top:6px">
|
||||
<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 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>
|
||||
</div>
|
||||
<div class="row" style="gap:10px; align-items:center; margin-top:6px">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -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 & 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 1–9</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>
|
||||
</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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue