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
|
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.
|
clears the hash so a refresh won't re‑import.
|
||||||
|
|
||||||
## Sharing & QR
|
## Sharing
|
||||||
|
|
||||||
In the set‑list panel's **⋯** menu:
|
In the set‑list panel's **⋯** menu:
|
||||||
- **Share settings link** / **Share set‑list link** open a dialog with the link and
|
- **Share settings link** / **Share set‑list link** open a dialog with the link to
|
||||||
a **QR code** (scan to open on a phone). Copy or Open from there.
|
**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.
|
- **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
|
## Keyboard shortcuts
|
||||||
|
|
||||||
| Key | Action |
|
| Key | Action |
|
||||||
|
|
@ -123,7 +123,7 @@ then push the tag and deploy.
|
||||||
|
|
||||||
## 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
|
web root and smoke‑tests the live URL. No restart needed (`file_server` picks up
|
||||||
changes immediately).
|
changes immediately).
|
||||||
|
|
||||||
|
|
@ -132,12 +132,6 @@ changes immediately).
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `index.html` | the whole app |
|
| `index.html` | the whole app |
|
||||||
| `qrcode.js` | vendored QR generator (Kazuhiko Arase, MIT) |
|
|
||||||
| `deploy.sh` | publish to the Caddy web root |
|
| `deploy.sh` | publish to the Caddy web root |
|
||||||
| `release.sh` | tag a formal version |
|
| `release.sh` | tag a formal version |
|
||||||
| `VERSION` | formal version string |
|
| `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"
|
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"
|
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),
|
# If real audio samples are added later (see the plan's GM-sample note),
|
||||||
# sync that directory too.
|
# sync that directory too.
|
||||||
if [[ -d "$SRC_DIR/samples" ]]; then
|
if [[ -d "$SRC_DIR/samples" ]]; then
|
||||||
|
|
|
||||||
123
index.html
123
index.html
|
|
@ -130,6 +130,14 @@
|
||||||
}
|
}
|
||||||
.tray-head { display:flex; align-items:center; justify-content:space-between; margin-bottom:14px; }
|
.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; }
|
.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; }
|
#themeBtn, #helpBtn { padding:4px 11px; }
|
||||||
/* --- responsive --- */
|
/* --- responsive --- */
|
||||||
@media (max-width: 760px) {
|
@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 { 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[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); }
|
.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; }
|
.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; }
|
||||||
.qr img { display:block; margin:0 auto; image-rendering:pixelated; max-width:100%; }
|
.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; }
|
#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 { width:100%; border-collapse:collapse; font-size:13px; }
|
||||||
.kbd-table td { padding:6px 4px; border-bottom:1px solid var(--edge); }
|
.kbd-table td { padding:6px 4px; border-bottom:1px solid var(--edge); }
|
||||||
|
|
@ -205,18 +213,24 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="practice-col" style="flex:1; min-width:215px">
|
<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="fbox toggleable" id="trainerBox">
|
||||||
<div class="row" style="gap:14px; align-items:center">
|
<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">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>
|
<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>
|
||||||
<div class="row" style="gap:12px 14px; align-items:center; flex-wrap:wrap">
|
<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">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" 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>
|
<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>
|
||||||
|
<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">
|
<div class="row" style="gap:10px; align-items:center">
|
||||||
<label style="font-size:12px">Elapsed (stopwatch)</label>
|
<label style="font-size:12px">Elapsed (stopwatch)</label>
|
||||||
<button class="iconbtn" id="elapsedReset" title="reset elapsed">⟲</button>
|
<button class="iconbtn" id="elapsedReset" title="reset elapsed">⟲</button>
|
||||||
|
|
@ -229,6 +243,8 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="row" style="align-items:center; gap:12px; margin:14px 0 8px">
|
<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>
|
<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">
|
<input type="text" class="txt" id="itemName" placeholder="item name" style="flex:1; min-width:110px; text-align:left">
|
||||||
<button id="addItemBtn">+ Add current settings</button>
|
<button id="addItemBtn">+ Add current settings</button>
|
||||||
</div>
|
</div>
|
||||||
|
<label class="mini-check" title="when a playing item's countdown reaches 0, auto-load the next item — give every item a countdown to auto-play the whole list" style="margin-bottom:6px"><input type="checkbox" id="continueMode"> Continue — auto-advance on countdown</label>
|
||||||
<div id="itemList"></div>
|
<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>
|
<div id="logView" style="margin-top:18px"></div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
@ -291,7 +308,7 @@
|
||||||
<tr><td><kbd>A</kbd></td><td>Add meter lane</td></tr>
|
<tr><td><kbd>A</kbd></td><td>Add meter lane</td></tr>
|
||||||
<tr><td><kbd>N</kbd></td><td>Load next set-list item</td></tr>
|
<tr><td><kbd>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></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>?</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</td></tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
@ -302,14 +319,13 @@
|
||||||
<div id="shareOverlay" class="overlay" hidden>
|
<div id="shareOverlay" class="overlay" hidden>
|
||||||
<div class="overlay-box">
|
<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 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>
|
<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="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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="qrcode.js"></script>
|
|
||||||
<script>
|
<script>
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
|
|
@ -452,7 +468,7 @@ function scheduleMeterTick(m, time) {
|
||||||
const onBeat = (tickInBar % spb) === 0;
|
const onBeat = (tickInBar % spb) === 0;
|
||||||
const beatIndex = Math.floor(tickInBar / spb);
|
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 (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 (!m.beatsOn[beatIndex]) return; // beat toggled off → rest (incl. its subdivisions)
|
||||||
if (onBeat) {
|
if (onBeat) {
|
||||||
const groupStart = m.groupStarts.has(beatIndex);
|
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 p = parseGroups(groupsStr);
|
||||||
const m = {
|
const m = {
|
||||||
id, groupsStr, groups: p.groups, beatsPerBar: p.beatsPerBar, groupStarts: p.groupStarts,
|
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)
|
beatsOn: beatsOn ? beatsOn.slice() : [], // per-beat on/off mask (rests)
|
||||||
tick: 0, nextTime: 0, vq: [], vqPtr: 0, currentBeat: -1, currentBar: 0,
|
tick: 0, nextTime: 0, vq: [], vqPtr: 0, currentBeat: -1, currentBar: 0,
|
||||||
el: null, stripEl: null, barEl: null,
|
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
|
// 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 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) {
|
function buildLaneCard(m) {
|
||||||
const card = document.createElement("div");
|
const card = document.createElement("div");
|
||||||
|
|
@ -545,6 +566,7 @@ function buildLaneCard(m) {
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<div class="lane-row">
|
<div class="lane-row">
|
||||||
<span class="lane-title" id="m${m.id}_title" style="color:${m.color}">${m.id}</span>
|
<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">
|
<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>
|
<span class="sum" id="m${m.id}_sum"></span>
|
||||||
<select class="cmp" id="m${m.id}_preset" title="time-signature presets">
|
<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>
|
<div class="strip" id="m${m.id}_strip"></div>
|
||||||
<span class="bar" id="m${m.id}_bar">—</span>
|
<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" 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>
|
<button class="x" id="m${m.id}_remove" title="remove lane">✕</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
document.getElementById("meters").appendChild(card);
|
document.getElementById("meters").appendChild(card);
|
||||||
|
|
@ -583,7 +604,9 @@ function buildLaneCard(m) {
|
||||||
sel.addEventListener("change", (e) => m.sound = e.target.value);
|
sel.addEventListener("change", (e) => m.sound = e.target.value);
|
||||||
const polyCb = $c(`#m${m.id}_poly`); polyCb.checked = m.poly;
|
const polyCb = $c(`#m${m.id}_poly`); polyCb.checked = m.poly;
|
||||||
polyCb.addEventListener("change", (e) => m.poly = e.target.checked);
|
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));
|
$c(`#m${m.id}_remove`).addEventListener("click", () => removeMeter(m.id));
|
||||||
|
|
||||||
recomputeLane(m);
|
recomputeLane(m);
|
||||||
|
|
@ -626,18 +649,17 @@ function renderLaneStrip(m) {
|
||||||
/* =========================================================================
|
/* =========================================================================
|
||||||
PRESETS (localStorage)
|
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 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 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) {
|
function applyLanes(lanes) {
|
||||||
while (meters.length) removeMeter(meters[0].id);
|
while (meters.length) removeMeter(meters[0].id);
|
||||||
for (const c of lanes) {
|
for (const c of lanes) {
|
||||||
addMeter(c.groupsStr, c.stepsPerBeat, c.sound, c.beatsOn, c.poly);
|
addMeter(c.groupsStr, c.stepsPerBeat, c.sound, c.beatsOn, c.poly);
|
||||||
const m = meters[meters.length - 1];
|
const m = meters[meters.length - 1];
|
||||||
m.mute = !!c.mute;
|
setLaneEnabled(m, c.enabled !== undefined ? !!c.enabled : (c.mute === undefined ? true : !c.mute)); // back-compat with old "mute"
|
||||||
const cb = m.el.querySelector(`#m${m.id}_mute`); if (cb) cb.checked = m.mute;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// (Presets removed — set-list items are now the single "saved setup" mechanism.)
|
// (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 activeItem = -1; // selected / loaded item in the active set list
|
||||||
let nowPlaying = null; // { at, name } for duration logging
|
let nowPlaying = null; // { at, name } for duration logging
|
||||||
let historyName = null; // item whose past-session history is shown
|
let historyName = null; // item whose past-session history is shown
|
||||||
|
let continueMode = lsGet(LS.continue, false); // auto-advance to next item when countdown ends
|
||||||
|
|
||||||
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) {
|
function applySetup(s) {
|
||||||
setBpm(s.bpm); applyLanes(s.lanes);
|
setBpm(s.bpm); applyLanes(s.lanes);
|
||||||
if (s.trainer) Object.assign(trainer, s.trainer);
|
if (s.trainer) Object.assign(trainer, s.trainer);
|
||||||
if (s.ramp) Object.assign(ramp, s.ramp);
|
if (s.ramp) Object.assign(ramp, s.ramp);
|
||||||
|
timers.totalMs = s.countMs || 0; timers.remainingMs = timers.totalMs; // per-item countdown
|
||||||
syncPracticeUI(); updateCtx();
|
syncPracticeUI(); updateCtx();
|
||||||
}
|
}
|
||||||
function syncPracticeUI() {
|
function syncPracticeUI() {
|
||||||
$("trainerOn").checked = trainer.on; $("playBars").value = trainer.playBars; $("muteBars").value = trainer.muteBars;
|
$("trainerOn").checked = trainer.on; $("playBars").value = trainer.playBars; $("muteBars").value = trainer.muteBars;
|
||||||
$("rampOn").checked = ramp.on; $("rampStart").value = ramp.startBpm; $("rampAmt").value = ramp.amount; $("rampEvery").value = ramp.everyBars;
|
$("rampOn").checked = ramp.on; $("rampStart").value = ramp.startBpm; $("rampAmt").value = ramp.amount; $("rampEvery").value = ramp.everyBars;
|
||||||
|
$("countTime").value = timers.totalMs > 0 ? fmtClock(timers.totalMs) : "";
|
||||||
|
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 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]; }
|
||||||
|
|
@ -730,7 +760,7 @@ function renderNowPlaying() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$("npName").textContent = (activeItem + 1) + ". " + it.name;
|
$("npName").textContent = (activeItem + 1) + ". " + it.name;
|
||||||
$("npSub").textContent = it.bpm + " BPM · " + it.lanes.map((l) => l.sound + " " + l.groupsStr + (l.poly ? "~" : "") + (l.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 : "");
|
$("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)
|
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>]
|
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) {
|
function laneCfgToStr(c) {
|
||||||
const bpb = parseGroups(c.groupsStr).beatsPerBar;
|
const bpb = parseGroups(c.groupsStr).beatsPerBar;
|
||||||
|
|
@ -829,12 +859,12 @@ function laneCfgToStr(c) {
|
||||||
const on = (c.beatsOn || []).slice(0, bpb);
|
const on = (c.beatsOn || []).slice(0, bpb);
|
||||||
if (on.length && !on.every(Boolean)) s += "=" + Array.from({ length: bpb }, (_, i) => (on[i] ? "x" : ".")).join("");
|
if (on.length && !on.every(Boolean)) s += "=" + Array.from({ length: bpb }, (_, i) => (on[i] ? "x" : ".")).join("");
|
||||||
if (c.poly) s += "~";
|
if (c.poly) s += "~";
|
||||||
if (c.mute) s += "!";
|
if (c.enabled === false) s += "!"; // "!" = silenced / disabled
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
function laneStrToCfg(tok) {
|
function laneStrToCfg(tok) {
|
||||||
let poly = false, mute = false;
|
let poly = false, disabled = false;
|
||||||
while (/[~!]$/.test(tok)) { if (tok.endsWith("!")) mute = true; else poly = true; tok = tok.slice(0, -1); }
|
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;
|
const ci = tok.indexOf(":"); if (ci < 0) return null;
|
||||||
let sound = tok.slice(0, ci), rest = tok.slice(ci + 1), pattern = 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); }
|
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")
|
const beatsOn = pattern ? pattern.split("").map((ch) => ch === "x" || ch === "X" || ch === "1")
|
||||||
: new Array(bpb).fill(true);
|
: new Array(bpb).fill(true);
|
||||||
if (!DRUMS[sound]) sound = "beep";
|
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) {
|
function setupToPatch(s) {
|
||||||
const parts = ["v1", "t" + s.bpm];
|
const parts = ["v1", "t" + s.bpm];
|
||||||
if (s.volume != null) parts.push("vol" + Math.round(s.volume * 100));
|
if (s.volume != null) parts.push("vol" + Math.round(s.volume * 100));
|
||||||
|
if (s.countMs > 0) parts.push("cd" + Math.round(s.countMs / 1000));
|
||||||
(s.lanes || []).forEach((c) => parts.push(laneCfgToStr(c)));
|
(s.lanes || []).forEach((c) => parts.push(laneCfgToStr(c)));
|
||||||
if (s.trainer && s.trainer.on) parts.push("tr" + s.trainer.playBars + "/" + s.trainer.muteBars);
|
if (s.trainer && s.trainer.on) parts.push("tr" + s.trainer.playBars + "/" + s.trainer.muteBars);
|
||||||
if (s.ramp && s.ramp.on) parts.push("rmp" + s.ramp.startBpm + "/" + s.ramp.amount + "/" + s.ramp.everyBars);
|
if (s.ramp && s.ramp.on) parts.push("rmp" + s.ramp.startBpm + "/" + s.ramp.amount + "/" + s.ramp.everyBars);
|
||||||
return parts.join(";");
|
return parts.join(";");
|
||||||
}
|
}
|
||||||
function patchToSetup(str) {
|
function patchToSetup(str) {
|
||||||
const s = { bpm: 120, volume: null, 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(";")) {
|
for (let tok of String(str).split(";")) {
|
||||||
tok = tok.trim(); if (!tok || tok === "v1") continue;
|
tok = tok.trim(); if (!tok || tok === "v1") continue;
|
||||||
if (tok.includes(":")) { const c = laneStrToCfg(tok); if (c) s.lanes.push(c); }
|
if (tok.includes(":")) { const c = laneStrToCfg(tok); if (c) s.lanes.push(c); }
|
||||||
else if (tok.startsWith("vol")) s.volume = (parseInt(tok.slice(3), 10) || 0) / 100;
|
else if (tok.startsWith("vol")) s.volume = (parseInt(tok.slice(3), 10) || 0) / 100;
|
||||||
|
else if (tok.startsWith("cd")) s.countMs = (parseInt(tok.slice(2), 10) || 0) * 1000;
|
||||||
else if (tok.startsWith("tr")) { const [p, m] = tok.slice(2).split("/"); s.trainer = { on: true, playBars: +p || 1, muteBars: +m || 0 }; }
|
else if (tok.startsWith("tr")) { const [p, m] = tok.slice(2).split("/"); s.trainer = { on: true, playBars: +p || 1, muteBars: +m || 0 }; }
|
||||||
else if (tok.startsWith("rmp")) { const [a, b, c] = tok.slice(3).split("/"); s.ramp = { on: true, startBpm: +a || 80, amount: +b || 0, everyBars: +c || 1 }; }
|
else if (tok.startsWith("rmp")) { const [a, b, c] = tok.slice(3).split("/"); s.ramp = { on: true, startBpm: +a || 80, amount: +b || 0, everyBars: +c || 1 }; }
|
||||||
else if (tok.startsWith("t")) s.bpm = parseInt(tok.slice(1), 10) || 120;
|
else if (tok.startsWith("t")) s.bpm = parseInt(tok.slice(1), 10) || 120;
|
||||||
|
|
@ -884,23 +916,17 @@ function codeToSetlist(code) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function shareLink(hashPart) { return location.origin + location.pathname + "#" + hashPart; }
|
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) {
|
function openShare(title, url, note) {
|
||||||
$("shareTitle").textContent = title;
|
$("shareTitle").textContent = title;
|
||||||
$("shareUrl").value = url;
|
$("shareUrl").value = url;
|
||||||
$("shareNote").textContent = note || "";
|
$("shareNote").textContent = note || "";
|
||||||
renderQR($("shareQr"), url);
|
$("shareExtBanner").hidden = true; // reset the external-QR warning
|
||||||
$("shareOverlay").hidden = false;
|
$("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() {
|
function shareSetlist() {
|
||||||
const sl = getSL(); if (!sl) return alert("No set list selected to share.");
|
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.
|
// Apply a shared link on load. Returns true if it set the metronome state.
|
||||||
|
|
@ -972,7 +998,15 @@ function tickTimers() {
|
||||||
timers.last = now;
|
timers.last = now;
|
||||||
if (state.running) {
|
if (state.running) {
|
||||||
timers.elapsedMs += dt;
|
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();
|
renderTimers();
|
||||||
}
|
}
|
||||||
|
|
@ -1044,10 +1078,10 @@ $("vol").addEventListener("input", (e) => {
|
||||||
state.volume = +e.target.value / 100; volVal.textContent = e.target.value + "%";
|
state.volume = +e.target.value / 100; volVal.textContent = e.target.value + "%";
|
||||||
if (masterGain) masterGain.gain.setTargetAtTime(state.volume, audioCtx.currentTime, 0.01);
|
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);
|
$("playBars").addEventListener("input", (e) => trainer.playBars = +e.target.value);
|
||||||
$("muteBars").addEventListener("input", (e) => trainer.muteBars = +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);
|
$("rampStart").addEventListener("input", (e) => ramp.startBpm = +e.target.value);
|
||||||
$("rampAmt").addEventListener("input", (e) => ramp.amount = +e.target.value);
|
$("rampAmt").addEventListener("input", (e) => ramp.amount = +e.target.value);
|
||||||
$("rampEvery").addEventListener("input", (e) => ramp.everyBars = +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(); });
|
$("countTime").addEventListener("input", (e) => { timers.totalMs = parseTime(e.target.value); timers.remainingMs = timers.totalMs; renderTimers(); });
|
||||||
$("elapsedReset").addEventListener("click", () => { timers.elapsedMs = 0; renderTimers(); });
|
$("elapsedReset").addEventListener("click", () => { timers.elapsedMs = 0; renderTimers(); });
|
||||||
$("countReset").addEventListener("click", () => { timers.remainingMs = timers.totalMs; renderTimers(); });
|
$("countReset").addEventListener("click", () => { timers.remainingMs = timers.totalMs; renderTimers(); });
|
||||||
|
$("continueMode").addEventListener("change", (e) => { continueMode = e.target.checked; lsSet(LS.continue, continueMode); });
|
||||||
$("trayMenuBtn").addEventListener("click", (e) => { e.stopPropagation(); $("trayMenu").hidden = !$("trayMenu").hidden; });
|
$("trayMenuBtn").addEventListener("click", (e) => { e.stopPropagation(); $("trayMenu").hidden = !$("trayMenu").hidden; });
|
||||||
document.addEventListener("click", (e) => { const m = $("trayMenu"); if (m && !m.hidden && !m.contains(e.target) && e.target.id !== "trayMenuBtn") m.hidden = true; });
|
document.addEventListener("click", (e) => { const m = $("trayMenu"); if (m && !m.hidden && !m.contains(e.target) && e.target.id !== "trayMenuBtn") m.hidden = true; });
|
||||||
$("newSetlistBtn").addEventListener("click", newSetlist);
|
$("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; });
|
$("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(); } });
|
$("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"));
|
$("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) => {
|
window.addEventListener("keydown", (e) => {
|
||||||
const t = e.target;
|
const t = e.target;
|
||||||
if (t && (t.tagName === "INPUT" || t.tagName === "SELECT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
|
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 === "Escape") { if (!$("shareOverlay").hidden) $("shareOverlay").hidden = true; else if (!$("shortcutsOverlay").hidden) toggleShortcuts(false); }
|
||||||
else if (k >= "1" && k <= "9") {
|
else if (k >= "1" && k <= "9") {
|
||||||
const m = meters[+k - 1];
|
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();
|
renderSetlists();
|
||||||
renderLog();
|
renderLog();
|
||||||
updateCtx();
|
updateCtx();
|
||||||
|
refreshFeatureBoxes();
|
||||||
|
$("continueMode").checked = continueMode;
|
||||||
$("appVersion").textContent = "v" + APP_VERSION;
|
$("appVersion").textContent = "v" + APP_VERSION;
|
||||||
requestAnimationFrame(drawLoop);
|
requestAnimationFrame(drawLoop);
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue