UX: select-to-load set-list items; now-playing info block; drop preset dropdown
- Set-list rows are now click-to-load (the transport is the only play/stop); the
per-item ▶/⏹ and ↑/↓ buttons are gone. Reorder via Alt+↑/↓ (tooltip + help).
Selected row is highlighted and reveals compact 💾 (save-back) / ✕ (remove).
- Replaced the main-screen preset dropdown with a 'Now loaded' info block showing
the item name, config summary, and the set list's title + description.
- Presets consolidated into set-list items (removed preset functions/UI). N loads
the next item; shortcuts help + demo description updated.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7207ffe1c7
commit
3f04383e2b
1 changed files with 84 additions and 78 deletions
162
index.html
162
index.html
|
|
@ -97,9 +97,18 @@
|
|||
.x:hover { color:#ff9a8a; border-color:#c0392b; }
|
||||
.hint { font-size:11px; color:var(--muted); margin-top:8px; }
|
||||
code { background:#0d1014; padding:1px 5px; border-radius:4px; color:#cfe3ff; }
|
||||
.ex-item { display:flex; gap:8px; align-items:center; padding:7px 9px; border:1px solid var(--edge); border-radius:8px; margin-bottom:6px; font-size:13px; background:var(--panel); }
|
||||
.ex-item { display:flex; gap:8px; align-items:center; padding:7px 9px; border:1px solid var(--edge); border-radius:8px; margin-bottom:6px; font-size:13px; background:var(--panel); cursor:pointer; }
|
||||
.ex-item:hover { border-color:var(--muted); }
|
||||
.ex-item.active { border-color:#2e7d32; box-shadow:inset 3px 0 0 #2e7d32; }
|
||||
.ex-item .nm { flex:1; }
|
||||
.ex-item .meta { color:var(--muted); font-family:"Courier New",monospace; font-size:11px; }
|
||||
.ex-item .row-actions { display:none; gap:4px; }
|
||||
.ex-item.active .row-actions, .ex-item:hover .row-actions { display:inline-flex; }
|
||||
.nowplaying { background:var(--panel); border:1px solid var(--edge); border-radius:10px; padding:10px 12px; margin-bottom:12px; }
|
||||
.np-label { font-size:10px; letter-spacing:1.4px; color:var(--muted); text-transform:uppercase; }
|
||||
.np-name { font-size:16px; font-weight:600; margin:2px 0; }
|
||||
.np-sub { font-size:12px; color:var(--muted); font-family:"Courier New",monospace; word-break:break-word; }
|
||||
.np-desc { font-size:12px; color:var(--muted); margin-top:4px; }
|
||||
.iconbtn { padding:3px 8px; font-size:12px; }
|
||||
.log-item { padding:8px 10px; border:1px solid var(--edge); border-radius:8px; margin-bottom:6px; background:var(--panel); }
|
||||
.log-head { font-weight:600; font-size:13px; margin-bottom:3px; }
|
||||
|
|
@ -180,12 +189,11 @@
|
|||
</div>
|
||||
|
||||
<div style="flex:1; min-width:200px">
|
||||
<div class="knob" style="margin-bottom:10px">
|
||||
<label>Preset</label>
|
||||
<div style="display:flex; gap:6px">
|
||||
<select class="cmp" id="presetSelect" style="flex:1"></select>
|
||||
<button class="x" id="delPresetBtn" title="delete selected preset" style="margin-left:0">✕</button>
|
||||
</div>
|
||||
<div class="nowplaying">
|
||||
<div class="np-label">Now loaded</div>
|
||||
<div class="np-name" id="npName">Free play</div>
|
||||
<div class="np-sub" id="npSub"></div>
|
||||
<div class="np-desc" id="npDesc"></div>
|
||||
</div>
|
||||
<div class="knob"><label>Tempo (BPM) <b id="bpmVal">120</b></label><input type="range" id="bpm" min="30" max="300" value="120"></div>
|
||||
<div class="knob" style="margin-bottom:0"><label>Master Volume <b id="volVal">70%</b></label><input type="range" id="vol" min="0" max="100" value="70"></div>
|
||||
|
|
@ -271,7 +279,8 @@
|
|||
<tr><td><kbd>⇧↑</kbd> <kbd>⇧↓</kbd></td><td>Tempo ±10 BPM</td></tr>
|
||||
<tr><td><kbd>A</kbd></td><td>Add meter lane</td></tr>
|
||||
<tr><td><kbd>R</kbd></td><td>Set lists tray</td></tr>
|
||||
<tr><td><kbd>N</kbd></td><td>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>1</kbd>–<kbd>9</kbd></td><td>Mute 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>
|
||||
|
|
@ -621,24 +630,7 @@ function applyLanes(lanes) {
|
|||
const cb = m.el.querySelector(`#m${m.id}_mute`); if (cb) cb.checked = m.mute;
|
||||
}
|
||||
}
|
||||
function savePreset(name) {
|
||||
const all = lsGet(LS.presets, {});
|
||||
all[name] = currentSetup();
|
||||
lsSet(LS.presets, all); refreshPresetList(name);
|
||||
}
|
||||
function loadPreset(name) {
|
||||
const all = lsGet(LS.presets, {}); const p = all[name]; if (!p) return;
|
||||
const wasRunning = state.running; if (wasRunning) stop();
|
||||
applySetup(p);
|
||||
if (wasRunning) start();
|
||||
}
|
||||
function deletePreset(name) { const all = lsGet(LS.presets, {}); delete all[name]; lsSet(LS.presets, all); refreshPresetList(); }
|
||||
function refreshPresetList(sel) {
|
||||
const all = lsGet(LS.presets, {}); const list = $("presetSelect");
|
||||
list.innerHTML = '<option value="">— preset —</option><option value="__save__">+ Save current as…</option>';
|
||||
Object.keys(all).sort().forEach((n) => { const o = document.createElement("option"); o.value = n; o.textContent = n; list.appendChild(o); });
|
||||
list.value = sel || "";
|
||||
}
|
||||
// (Presets removed — set-list items are now the single "saved setup" mechanism.)
|
||||
|
||||
/* =========================================================================
|
||||
SET LISTS + PRACTICE LOG
|
||||
|
|
@ -647,10 +639,10 @@ function refreshPresetList(sel) {
|
|||
Each played item is logged (timestamp, name, duration, BPM, conditions).
|
||||
========================================================================= */
|
||||
let setlists = lsGet(LS.setlists, []);
|
||||
let activeSL = 0; // index of the selected set list
|
||||
let playingItem = -1; // index of the item currently playing in the active set list
|
||||
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; // name of the item whose past-session history is shown
|
||||
let historyName = null; // item whose past-session history is shown
|
||||
|
||||
function currentSetup() { return { bpm: state.bpm, lanes: snapshotLanes(), trainer: { ...trainer }, ramp: { ...ramp } }; }
|
||||
function applySetup(s) {
|
||||
|
|
@ -670,44 +662,66 @@ function saveSetlists() { lsSet(LS.setlists, setlists); }
|
|||
// --- set list CRUD ---
|
||||
function newSetlist() {
|
||||
setlists.push({ title: "Set list " + (setlists.length + 1), description: "", items: [] });
|
||||
activeSL = setlists.length - 1; playingItem = -1; saveSetlists(); renderSetlists();
|
||||
activeSL = setlists.length - 1; activeItem = -1; saveSetlists(); renderSetlists();
|
||||
}
|
||||
function deleteSetlist() {
|
||||
if (!setlists.length || !confirm("Delete this set list?")) return;
|
||||
setlists.splice(activeSL, 1); activeSL = Math.max(0, activeSL - 1); playingItem = -1; saveSetlists(); renderSetlists();
|
||||
setlists.splice(activeSL, 1); activeSL = Math.max(0, activeSL - 1); activeItem = -1; saveSetlists(); renderSetlists();
|
||||
}
|
||||
function addItem(name) {
|
||||
const sl = getSL(); if (!sl) return;
|
||||
sl.items.push({ name: name || ("Item " + (sl.items.length + 1)), ...currentSetup() });
|
||||
activeItem = sl.items.length - 1; saveSetlists(); renderItems();
|
||||
}
|
||||
function removeItem(i) {
|
||||
const sl = getSL(); if (!sl) return;
|
||||
sl.items.splice(i, 1);
|
||||
if (activeItem === i) activeItem = -1; else if (activeItem > i) activeItem--;
|
||||
saveSetlists(); renderItems();
|
||||
}
|
||||
function removeItem(i) { const sl = getSL(); sl.items.splice(i, 1); if (playingItem >= sl.items.length) playingItem = -1; saveSetlists(); renderItems(); }
|
||||
function moveItem(i, d) { const sl = getSL(); const j = i + d; if (j < 0 || j >= sl.items.length) return; [sl.items[i], sl.items[j]] = [sl.items[j], sl.items[i]]; saveSetlists(); renderItems(); }
|
||||
function moveItem(i, d) { const sl = getSL(); const j = i + d; if (j < 0 || j >= sl.items.length) return; [sl.items[i], sl.items[j]] = [sl.items[j], sl.items[i]]; saveSetlists(); }
|
||||
function moveActiveItem(d) { // keyboard reorder of the selected item (Alt+↑/↓)
|
||||
const sl = getSL(); if (!sl || activeItem < 0) return;
|
||||
const j = activeItem + d; if (j < 0 || j >= sl.items.length) return;
|
||||
moveItem(activeItem, d); activeItem = j; renderItems();
|
||||
}
|
||||
|
||||
// --- play / advance ---
|
||||
function playItem(i) {
|
||||
// --- select / advance: clicking an item LOADS it; the transport is the only play/stop ---
|
||||
function loadItem(i) {
|
||||
const sl = getSL(); if (!sl || !sl.items[i]) return;
|
||||
logFinalize(); // close out the previously-playing item
|
||||
const wasRunning = state.running;
|
||||
if (wasRunning) logFinalize(); // close out the previous segment
|
||||
applySetup(sl.items[i]);
|
||||
if (state.running) stop(); // clean restart so ramp start-BPM + bar counter reset
|
||||
start();
|
||||
playingItem = i;
|
||||
historyName = sl.items[i].name;
|
||||
nowPlaying = { at: Date.now(), name: sl.items[i].name };
|
||||
activeItem = i; historyName = sl.items[i].name;
|
||||
if (wasRunning) { stop(); start(); nowPlaying = { at: Date.now(), name: sl.items[i].name }; } // keep playing the new item
|
||||
renderItems(); renderLog();
|
||||
}
|
||||
function nextItem() { const sl = getSL(); if (sl && playingItem + 1 < sl.items.length) playItem(playingItem + 1); }
|
||||
function updateItem(i) { // load → adjust → save back to the item
|
||||
function nextItem() { const sl = getSL(); if (sl && activeItem + 1 < sl.items.length) loadItem(activeItem + 1); }
|
||||
function updateItem(i) { // overwrite item with current settings (keeps its name)
|
||||
const sl = getSL(); if (!sl || !sl.items[i]) return;
|
||||
const nm = sl.items[i].name;
|
||||
sl.items[i] = { name: nm, ...currentSetup() };
|
||||
sl.items[i] = { name: sl.items[i].name, ...currentSetup() };
|
||||
saveSetlists(); renderItems();
|
||||
}
|
||||
|
||||
// Start/stop button + Space route through here so internal restarts don't log.
|
||||
// Start/stop go through here so internal restarts don't create stray log entries.
|
||||
function toggleTransport() {
|
||||
if (state.running) { logFinalize(); playingItem = -1; renderItems(); stop(); }
|
||||
else start();
|
||||
if (state.running) { logFinalize(); stop(); }
|
||||
else { start(); const sl = getSL(); if (activeItem >= 0 && sl && sl.items[activeItem]) nowPlaying = { at: Date.now(), name: sl.items[activeItem].name }; }
|
||||
renderItems();
|
||||
}
|
||||
|
||||
// --- now-playing info on the main screen (replaces the old preset dropdown) ---
|
||||
function renderNowPlaying() {
|
||||
const sl = getSL(); const it = (sl && activeItem >= 0) ? sl.items[activeItem] : null;
|
||||
if (!it) {
|
||||
$("npName").textContent = "Free play";
|
||||
$("npSub").textContent = "No set-list item loaded — edit the lanes freely.";
|
||||
$("npDesc").textContent = (sl && sl.description) ? "“" + sl.title + "” — " + sl.description : "";
|
||||
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(" · ");
|
||||
$("npDesc").textContent = (sl.title || "") + (sl.description ? " — " + sl.description : "");
|
||||
}
|
||||
|
||||
// --- render ---
|
||||
|
|
@ -724,26 +738,24 @@ function renderSetlists() {
|
|||
}
|
||||
function renderItems() {
|
||||
const box = $("itemList"); box.innerHTML = ""; const sl = getSL();
|
||||
if (!sl) { box.innerHTML = '<div class="hint">Create a set list, then add the current settings as items.</div>'; return; }
|
||||
if (!sl.items.length) { box.innerHTML = '<div class="hint">No items yet — set up the metronome and “Add current settings”.</div>'; return; }
|
||||
if (!sl) { box.innerHTML = '<div class="hint">Create a set list, then “Add current settings” to capture items.</div>'; renderNowPlaying(); return; }
|
||||
if (!sl.items.length) { box.innerHTML = '<div class="hint">No items yet — set up the metronome and “Add current settings”.</div>'; renderNowPlaying(); return; }
|
||||
sl.items.forEach((it, i) => {
|
||||
const row = document.createElement("div"); row.className = "ex-item";
|
||||
const playing = (i === playingItem && state.running);
|
||||
if (i === playingItem) row.style.borderColor = "#2e7d32";
|
||||
row.innerHTML = `<button class="${playing ? "stop" : "play"} iconbtn" data-act="play" title="${playing ? "stop" : "load & start"}">${playing ? "⏹" : "▶"}</button>
|
||||
<span class="nm">${i + 1}. ${it.name}</span>
|
||||
<span class="meta">${it.bpm}bpm · ${it.lanes.map((l) => l.groupsStr).join("/")}</span>
|
||||
<button class="iconbtn" data-act="save" title="save current settings to this item">💾</button>
|
||||
<button class="iconbtn" data-act="up" title="up">↑</button>
|
||||
<button class="iconbtn" data-act="down" title="down">↓</button>
|
||||
<button class="x iconbtn" data-act="del" title="remove">✕</button>`;
|
||||
row.querySelector('[data-act=play]').onclick = () => (i === playingItem && state.running) ? toggleTransport() : playItem(i);
|
||||
row.querySelector('[data-act=save]').onclick = () => updateItem(i);
|
||||
row.querySelector('[data-act=up]').onclick = () => moveItem(i, -1);
|
||||
row.querySelector('[data-act=down]').onclick = () => moveItem(i, 1);
|
||||
row.querySelector('[data-act=del]').onclick = () => removeItem(i);
|
||||
const row = document.createElement("div");
|
||||
row.className = "ex-item" + (i === activeItem ? " active" : "");
|
||||
row.title = "Click to load into the player · Alt+↑/↓ to reorder";
|
||||
row.innerHTML = `<span class="nm">${i + 1}. ${it.name}</span>
|
||||
<span class="meta">${it.bpm} · ${it.lanes.map((l) => l.groupsStr).join("/")}</span>
|
||||
<span class="row-actions">
|
||||
<button class="iconbtn" data-act="save" title="overwrite this item with the current settings">💾</button>
|
||||
<button class="x iconbtn" data-act="del" title="remove this item">✕</button>
|
||||
</span>`;
|
||||
row.onclick = () => loadItem(i);
|
||||
row.querySelector('[data-act=save]').onclick = (e) => { e.stopPropagation(); updateItem(i); };
|
||||
row.querySelector('[data-act=del]').onclick = (e) => { e.stopPropagation(); removeItem(i); };
|
||||
box.appendChild(row);
|
||||
});
|
||||
renderNowPlaying();
|
||||
}
|
||||
|
||||
// --- practice log (flat entries, one per played item) ---
|
||||
|
|
@ -786,9 +798,9 @@ function importAll(file) {
|
|||
try {
|
||||
const d = JSON.parse(reader.result);
|
||||
if (d.presets) lsSet(LS.presets, d.presets);
|
||||
if (d.setlists) { lsSet(LS.setlists, d.setlists); setlists = d.setlists; activeSL = 0; playingItem = -1; }
|
||||
if (d.setlists) { lsSet(LS.setlists, d.setlists); setlists = d.setlists; activeSL = 0; activeItem = -1; }
|
||||
if (d.logs) lsSet(LS.logs, d.logs);
|
||||
refreshPresetList(); renderSetlists(); renderLog();
|
||||
renderSetlists(); renderLog();
|
||||
alert("Imported " + Object.keys(d.presets || {}).length + " presets, " + (d.setlists || []).length + " set lists, " + (d.logs || []).length + " log entries.");
|
||||
} catch (e) { alert("Import failed: " + e.message); }
|
||||
};
|
||||
|
|
@ -889,7 +901,7 @@ function applyHashShare() {
|
|||
if (h.startsWith("#sl=")) {
|
||||
const sl = codeToSetlist(decodeURIComponent(h.slice(4)));
|
||||
setlists.push(sl); activeSL = setlists.length - 1; saveSetlists(); renderSetlists();
|
||||
if (sl.items[0]) applySetup(sl.items[0]);
|
||||
if (sl.items[0]) { applySetup(sl.items[0]); activeItem = 0; historyName = sl.items[0].name; }
|
||||
history.replaceState(null, "", location.pathname);
|
||||
alert("Imported set list: " + sl.title + " (" + sl.items.length + " items)");
|
||||
return true;
|
||||
|
|
@ -990,19 +1002,13 @@ $("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);
|
||||
$("addMeterBtn").addEventListener("click", () => addMeter("4", 1, "claves"));
|
||||
$("presetSelect").addEventListener("change", (e) => {
|
||||
const v = e.target.value;
|
||||
if (v === "__save__") { const n = (prompt("Save current settings as preset:") || "").trim(); if (n) savePreset(n); else refreshPresetList(); }
|
||||
else if (v) loadPreset(v);
|
||||
});
|
||||
$("delPresetBtn").addEventListener("click", () => { const n = $("presetSelect").value; if (n && n !== "__save__" && confirm('Delete preset "' + n + '"?')) deletePreset(n); });
|
||||
$("routineToggle").addEventListener("click", () => $("routineTray").classList.toggle("open"));
|
||||
$("routineClose").addEventListener("click", () => $("routineTray").classList.remove("open"));
|
||||
$("trayMenuBtn").addEventListener("click", (e) => { e.stopPropagation(); $("trayMenu").hidden = !$("trayMenu").hidden; });
|
||||
document.addEventListener("click", (e) => { const m = $("trayMenu"); if (m && !m.hidden && !m.contains(e.target) && e.target.id !== "trayMenuBtn") m.hidden = true; });
|
||||
$("newSetlistBtn").addEventListener("click", newSetlist);
|
||||
$("delSetlistBtn").addEventListener("click", deleteSetlist);
|
||||
$("setlistSelect").addEventListener("change", (e) => { activeSL = +e.target.value; playingItem = -1; renderSetlists(); });
|
||||
$("setlistSelect").addEventListener("change", (e) => { activeSL = +e.target.value; activeItem = -1; renderSetlists(); });
|
||||
$("slTitle").addEventListener("input", (e) => { const sl = getSL(); if (sl) { sl.title = e.target.value; saveSetlists(); const o = $("setlistSelect").options[activeSL]; if (o) o.textContent = sl.title || ("Set list " + (activeSL + 1)); } });
|
||||
$("slDesc").addEventListener("input", (e) => { const sl = getSL(); if (sl) { sl.description = e.target.value; saveSetlists(); } });
|
||||
$("addItemBtn").addEventListener("click", () => { addItem($("itemName").value.trim()); $("itemName").value = ""; });
|
||||
|
|
@ -1023,8 +1029,9 @@ $("shareOpen").addEventListener("click", () => window.open($("shareUrl").value,
|
|||
window.addEventListener("keydown", (e) => {
|
||||
const t = e.target;
|
||||
if (t && (t.tagName === "INPUT" || t.tagName === "SELECT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
const k = e.key;
|
||||
if (e.altKey && (k === "ArrowUp" || k === "ArrowDown")) { e.preventDefault(); moveActiveItem(k === "ArrowUp" ? -1 : 1); return; } // reorder selected item
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
if (e.code === "Space") { e.preventDefault(); toggleTransport(); }
|
||||
else if (k === "t" || k === "T") { tapTempo(); }
|
||||
else if (k === "ArrowUp") { e.preventDefault(); setBpm(state.bpm + (e.shiftKey ? 10 : 1)); }
|
||||
|
|
@ -1044,7 +1051,7 @@ window.addEventListener("keydown", (e) => {
|
|||
// seed the demo set list once (first run only; not re-added if the user deletes it)
|
||||
if (!lsGet(LS.seeded, false)) {
|
||||
if (!setlists.length) {
|
||||
setlists.push({ title: "✨ Demos", description: "Tap ▶ on an item to hear it — meters, polyrhythms, odd time, subdivisions & practice tools.", items: DEMOS.map(([n, p]) => ({ name: n, ...patchToSetup(p) })) });
|
||||
setlists.push({ title: "✨ Demos", description: "Click an item to load it, then press Space — meters, polyrhythms, odd time, subdivisions & practice tools.", items: DEMOS.map(([n, p]) => ({ name: n, ...patchToSetup(p) })) });
|
||||
activeSL = 0; saveSetlists();
|
||||
}
|
||||
lsSet(LS.seeded, true);
|
||||
|
|
@ -1054,7 +1061,6 @@ if (!applyHashShare()) {
|
|||
addMeter("4", 1, "kick"); // reference bar
|
||||
addMeter("5", 1, "claves", null, true); // 5 fit into the bar → true 5:4 polyrhythm
|
||||
}
|
||||
refreshPresetList();
|
||||
renderSetlists();
|
||||
renderLog();
|
||||
updateCtx();
|
||||
|
|
|
|||
Loading…
Reference in a new issue