feat(ui): numpad, audio readout, and sequence beat controls
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -14,49 +14,6 @@
|
||||
/** @type {Set<ReturnType<typeof setTimeout>>} */
|
||||
const pendingBeatPhaseTimers = new Set();
|
||||
|
||||
const STORAGE_KEY = "led-controller-audio-restore";
|
||||
const PHASE_MS_KEY = "led-controller-audio-beat-phase-ms";
|
||||
const STORAGE_VERSION = 1;
|
||||
|
||||
function readRestorePrefs() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const o = JSON.parse(raw);
|
||||
if (!o || o.v !== STORAGE_VERSION || !o.restore) return null;
|
||||
return {
|
||||
override: typeof o.override === "string" ? o.override : "",
|
||||
select: typeof o.select === "string" ? o.select : "",
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeRestorePrefs(override, select) {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
v: STORAGE_VERSION,
|
||||
restore: true,
|
||||
override: override || "",
|
||||
select: select || "",
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn("audio restore prefs save failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
function clearRestorePrefs() {
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
} catch (e) {
|
||||
console.warn("audio restore prefs clear failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
function el(id) {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
@@ -155,21 +112,91 @@
|
||||
node.textContent = `${label}${conf}`;
|
||||
}
|
||||
|
||||
/** @param {Record<string, unknown>} status */
|
||||
function updateBarPhaseDisplay(status) {
|
||||
const readout = String((status && status.bar_phase_readout) || "").trim();
|
||||
const phaseConf = Number((status && status.phase_confidence) || 0);
|
||||
const downbeat = !!(status && status.is_downbeat);
|
||||
let text = readout || "--";
|
||||
if (readout && Number.isFinite(phaseConf) && phaseConf > 0) {
|
||||
text = `${text} (${Math.round(phaseConf * 100)}%)`;
|
||||
}
|
||||
for (const id of ["audio-bar-phase-value", "audio-top-bar-phase"]) {
|
||||
const node = el(id);
|
||||
if (!node) continue;
|
||||
node.textContent = status && status.running ? text : "";
|
||||
node.classList.toggle("is-downbeat", downbeat && !!readout);
|
||||
}
|
||||
}
|
||||
|
||||
function setTopBpmVisible(on) {
|
||||
const top = el("audio-top-indicator");
|
||||
if (!top) return;
|
||||
top.classList.toggle("audio-running", !!on);
|
||||
}
|
||||
|
||||
function setNavResetVisible(on) {
|
||||
for (const id of ["audio-nav-reset-btn", "audio-nav-reset-mobile"]) {
|
||||
const node = el(id);
|
||||
if (node) node.hidden = !on;
|
||||
}
|
||||
}
|
||||
|
||||
async function resetAudioTracking() {
|
||||
try {
|
||||
const res = await fetch("/api/audio/reset", {
|
||||
method: "POST",
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
console.warn("audio reset failed", data.error || res.status);
|
||||
return;
|
||||
}
|
||||
await pollStatus();
|
||||
} catch (e) {
|
||||
console.warn("audio reset failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
function updateSequenceSyncControls(zoneSeqActive) {
|
||||
const topSync = el("audio-top-beat-sync");
|
||||
if (topSync) topSync.disabled = !zoneSeqActive;
|
||||
const modalBeat = el("audio-modal-beat-readout");
|
||||
if (modalBeat) modalBeat.disabled = !zoneSeqActive;
|
||||
const passBtn = el("audio-sync-pass-btn");
|
||||
if (passBtn) passBtn.disabled = !zoneSeqActive;
|
||||
}
|
||||
|
||||
async function syncSequenceBeatPhase(mode) {
|
||||
const res = await fetch("/sequences/sync-phase", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||
body: JSON.stringify({ mode: mode || "step" }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || `Sync failed (${res.status})`);
|
||||
}
|
||||
await pollStatus();
|
||||
}
|
||||
|
||||
function isTypingTarget(target) {
|
||||
if (!target || typeof target !== "object") return false;
|
||||
const tag = String(target.tagName || "").toLowerCase();
|
||||
return tag === "input" || tag === "textarea" || tag === "select" || target.isContentEditable;
|
||||
}
|
||||
|
||||
function flashBeat() {
|
||||
const node = el("audio-beat-flash");
|
||||
if (!node) return;
|
||||
node.classList.add("active");
|
||||
setTimeout(() => node.classList.remove("active"), 80);
|
||||
const syncBtn = el("audio-top-beat-sync");
|
||||
const top = el("audio-top-indicator");
|
||||
if (top && top.classList.contains("audio-running")) {
|
||||
top.classList.add("flash");
|
||||
setTimeout(() => top.classList.remove("flash"), 90);
|
||||
if (syncBtn && top && top.classList.contains("audio-running")) {
|
||||
syncBtn.classList.add("flash");
|
||||
setTimeout(() => syncBtn.classList.remove("flash"), 90);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,17 +211,17 @@
|
||||
const n = parseInt(String(inp.value).trim(), 10);
|
||||
if (Number.isFinite(n)) return Math.min(500, Math.max(0, n));
|
||||
}
|
||||
try {
|
||||
const v = parseInt(localStorage.getItem(PHASE_MS_KEY) || "0", 10);
|
||||
return Number.isFinite(v) ? Math.min(500, Math.max(0, v)) : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function persistBeatPhaseMs() {
|
||||
async function persistBeatPhaseMs() {
|
||||
const ms = getBeatPhaseDelayMs();
|
||||
try {
|
||||
localStorage.setItem(PHASE_MS_KEY, String(getBeatPhaseDelayMs()));
|
||||
await fetch("/settings", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||
body: JSON.stringify({ audio_beat_phase_ms: ms }),
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn("beat phase ms save failed", e);
|
||||
}
|
||||
@@ -224,6 +251,7 @@
|
||||
/** Stop detector and polling; does not clear “resume on load” prefs (used before restart). */
|
||||
async function stopAudioOnly() {
|
||||
setTopBpmVisible(false);
|
||||
setNavResetVisible(false);
|
||||
clearBeatPhaseTimers();
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
@@ -241,10 +269,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
/** User-initiated stop: also forget auto-restart on next page load. */
|
||||
/** User-initiated stop (run intent cleared on server). */
|
||||
async function stopAudio() {
|
||||
await stopAudioOnly();
|
||||
clearRestorePrefs();
|
||||
}
|
||||
|
||||
async function pollStatus() {
|
||||
@@ -260,22 +287,30 @@
|
||||
updateBeatReadoutDisplays({});
|
||||
updateBpmDisplay(null);
|
||||
setTopBpmVisible(!!status.running);
|
||||
setNavResetVisible(!!status.running);
|
||||
if (!status.running && pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
setTopBpmVisible(!!status.running);
|
||||
const zoneSeqActive = sequencePlaybackActiveFromStatus(status);
|
||||
setTopBpmVisible(!!status.running || zoneSeqActive);
|
||||
setNavResetVisible(!!status.running);
|
||||
updateSequenceSyncControls(zoneSeqActive);
|
||||
updateBpmDisplay(status.bpm);
|
||||
updateHitTypeDisplay(status.beat_type, Number(status.beat_type_confidence));
|
||||
updateBarPhaseDisplay(status);
|
||||
applyServerAudioUiFields(status);
|
||||
if (typeof window.applySequenceSwitchWaitFromServer === "function") {
|
||||
window.applySequenceSwitchWaitFromServer(status.sequence_switch_wait);
|
||||
}
|
||||
/*
|
||||
* `status.beat_seq` is cumulative since Audio Start — used only for flash / sticky idle
|
||||
* after sequence ends. Preset and sequence loop counts come from `manual_beat_stride` /
|
||||
* `sequence` on each poll.
|
||||
*/
|
||||
const beatSeq = Number(status.beat_seq || 0);
|
||||
const zoneSeqActive = sequencePlaybackActiveFromStatus(status);
|
||||
const endedSeq = prevZoneSequencePlaybackActive && !zoneSeqActive;
|
||||
const startedSeq = !prevZoneSequencePlaybackActive && zoneSeqActive;
|
||||
prevZoneSequencePlaybackActive = zoneSeqActive;
|
||||
@@ -320,7 +355,11 @@
|
||||
const selected = el("audio-device-select")?.value || "";
|
||||
const rawDevice = override !== "" ? override : selected;
|
||||
const numeric = rawDevice !== "" && /^-?\d+$/.test(rawDevice) ? Number(rawDevice) : rawDevice;
|
||||
const body = { device: rawDevice === "" ? null : numeric };
|
||||
const body = {
|
||||
device: rawDevice === "" ? null : numeric,
|
||||
device_override: override,
|
||||
device_select: selected,
|
||||
};
|
||||
const res = await fetch("/api/audio/start", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -330,7 +369,6 @@
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || "Failed to start audio detector");
|
||||
}
|
||||
writeRestorePrefs(override, selected);
|
||||
updateBpmDisplay(null);
|
||||
updateHitTypeDisplay("unknown", NaN);
|
||||
pollTimer = setInterval(pollStatus, 250);
|
||||
@@ -378,6 +416,7 @@
|
||||
const closeBtn = el("audio-close-btn");
|
||||
const startBtn = el("audio-start-btn");
|
||||
const stopBtn = el("audio-stop-btn");
|
||||
const navResetBtn = el("audio-nav-reset-btn");
|
||||
const refreshBtn = el("audio-refresh-btn");
|
||||
if (!modal || !openBtn) return;
|
||||
|
||||
@@ -410,6 +449,9 @@
|
||||
await stopAudio();
|
||||
});
|
||||
}
|
||||
if (navResetBtn) {
|
||||
navResetBtn.addEventListener("click", () => resetAudioTracking());
|
||||
}
|
||||
if (refreshBtn) {
|
||||
refreshBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
@@ -422,17 +464,36 @@
|
||||
|
||||
const phaseInp = el("audio-beat-phase-ms");
|
||||
if (phaseInp) {
|
||||
try {
|
||||
const stored = parseInt(localStorage.getItem(PHASE_MS_KEY) || "0", 10);
|
||||
if (Number.isFinite(stored)) {
|
||||
phaseInp.value = String(Math.min(500, Math.max(0, stored)));
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
phaseInp.addEventListener("change", () => persistBeatPhaseMs());
|
||||
phaseInp.addEventListener("input", () => persistBeatPhaseMs());
|
||||
phaseInp.addEventListener("change", () => {
|
||||
void persistBeatPhaseMs();
|
||||
});
|
||||
phaseInp.addEventListener("input", () => {
|
||||
void persistBeatPhaseMs();
|
||||
});
|
||||
}
|
||||
|
||||
const bindSync = (node, mode) => {
|
||||
if (!node) return;
|
||||
node.addEventListener("click", async () => {
|
||||
try {
|
||||
await syncSequenceBeatPhase(mode);
|
||||
} catch (e) {
|
||||
console.warn("sequence beat sync failed", e);
|
||||
}
|
||||
});
|
||||
};
|
||||
bindSync(el("audio-top-beat-sync"), "step");
|
||||
bindSync(el("audio-modal-beat-readout"), "step");
|
||||
bindSync(el("audio-sync-pass-btn"), "pass");
|
||||
|
||||
document.addEventListener("keydown", (ev) => {
|
||||
if (ev.defaultPrevented || ev.repeat || isTypingTarget(ev.target)) return;
|
||||
const k = String(ev.key || "").toLowerCase();
|
||||
if (k !== "s") return;
|
||||
ev.preventDefault();
|
||||
const mode = ev.shiftKey ? "pass" : "step";
|
||||
void syncSequenceBeatPhase(mode).catch((e) => console.warn("sequence beat sync failed", e));
|
||||
});
|
||||
}
|
||||
|
||||
async function resumePollingIfDetectorRunning() {
|
||||
@@ -451,27 +512,60 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply browser-stored device fields only (GET /devices list); does not start detection.
|
||||
* Beat detector run/stop is server-owned (`db/audio_run.json` + explicit Start/Stop in UI).
|
||||
*/
|
||||
async function applySavedAudioDeviceFormOnly() {
|
||||
const prefs = readRestorePrefs();
|
||||
if (!prefs) return;
|
||||
const ov = el("audio-device-override");
|
||||
const sel = el("audio-device-select");
|
||||
if (ov) ov.value = prefs.override || "";
|
||||
/** Apply server-owned audio UI fields from status (device form, beat phase delay). */
|
||||
function applyServerAudioUiFields(status) {
|
||||
if (!status || typeof status !== "object") return;
|
||||
const run = status.audio_run;
|
||||
if (run && typeof run === "object") {
|
||||
const ov = el("audio-device-override");
|
||||
const sel = el("audio-device-select");
|
||||
if (ov && run.device_override != null) ov.value = String(run.device_override);
|
||||
if (sel && run.device_select) sel.value = String(run.device_select);
|
||||
}
|
||||
const phaseInp = el("audio-beat-phase-ms");
|
||||
if (
|
||||
phaseInp &&
|
||||
status.beat_phase_ms != null &&
|
||||
document.activeElement !== phaseInp
|
||||
) {
|
||||
const ms = parseInt(String(status.beat_phase_ms), 10);
|
||||
if (Number.isFinite(ms)) {
|
||||
phaseInp.value = String(Math.min(500, Math.max(0, ms)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadServerAudioUiFields() {
|
||||
try {
|
||||
await refreshDevices();
|
||||
} catch (e) {
|
||||
console.warn("audio device list refresh failed", e);
|
||||
}
|
||||
if (sel && prefs.select) sel.value = prefs.select;
|
||||
try {
|
||||
const res = await fetch("/api/audio/status", { cache: "no-store" });
|
||||
const data = await res.json();
|
||||
applyServerAudioUiFields(data?.status || {});
|
||||
} catch (e) {
|
||||
console.warn("audio status load failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Called from sequences.js when server playback starts/stops without audio polling. */
|
||||
window.ledControllerSequencePlaybackChanged = (active) => {
|
||||
updateSequenceSyncControls(!!active);
|
||||
if (active) {
|
||||
setTopBpmVisible(true);
|
||||
return;
|
||||
}
|
||||
if (!pollTimer) {
|
||||
setTopBpmVisible(false);
|
||||
updateSequenceSyncControls(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
bind();
|
||||
await loadServerAudioUiFields();
|
||||
await resumePollingIfDetectorRunning();
|
||||
await applySavedAudioDeviceFormOnly();
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -131,7 +131,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch('/settings/settings', {
|
||||
const response = await fetch('/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
|
||||
117
src/static/numpad.js
Normal file
117
src/static/numpad.js
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Bluetooth / USB HID numpad shortcuts (browser focus required).
|
||||
*
|
||||
* Numpad1–9,0 → zone 1–10 (visible zone list order)
|
||||
* NumpadEnter → sequence beat sync (step), same as S
|
||||
* NumpadDecimal → sequence beat sync (pass), same as Shift+S
|
||||
* NumpadMultiply → reset audio detector
|
||||
* NumpadAdd → brightness +16
|
||||
* NumpadSubtract → brightness −16
|
||||
* NumpadDivide → stop zone sequence playback
|
||||
*/
|
||||
(() => {
|
||||
const BRIGHTNESS_STEP = 16;
|
||||
|
||||
function isTypingTarget(target) {
|
||||
if (!target || typeof target !== "object") return false;
|
||||
const tag = String(target.tagName || "").toLowerCase();
|
||||
return tag === "input" || tag === "textarea" || tag === "select" || target.isContentEditable;
|
||||
}
|
||||
|
||||
function zoneIdsInListOrder() {
|
||||
return [...document.querySelectorAll("#zones-list .zone-button[data-zone-id]")]
|
||||
.map((el) => el.getAttribute("data-zone-id"))
|
||||
.filter((id) => id != null && id !== "");
|
||||
}
|
||||
|
||||
async function selectZoneByListIndex(oneBased) {
|
||||
const order = zoneIdsInListOrder();
|
||||
if (oneBased < 1 || oneBased > order.length) return;
|
||||
const zoneId = order[oneBased - 1];
|
||||
if (window.tabsManager && typeof window.tabsManager.selectZone === "function") {
|
||||
await window.tabsManager.selectZone(zoneId);
|
||||
} else if (typeof selectZone === "function") {
|
||||
await selectZone(zoneId);
|
||||
}
|
||||
}
|
||||
|
||||
async function syncSequenceBeatPhase(mode) {
|
||||
const res = await fetch("/sequences/sync-phase", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||
body: JSON.stringify({ mode: mode || "step" }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || `Sync failed (${res.status})`);
|
||||
}
|
||||
}
|
||||
|
||||
async function resetAudioTracking() {
|
||||
const res = await fetch("/api/audio/reset", {
|
||||
method: "POST",
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || `Reset failed (${res.status})`);
|
||||
}
|
||||
}
|
||||
|
||||
function adjustZoneBrightness(delta) {
|
||||
const zoneId =
|
||||
(window.tabsManager && typeof window.tabsManager.getCurrentTabId === "function"
|
||||
? window.tabsManager.getCurrentTabId()
|
||||
: null) ||
|
||||
(window.tabsManager && typeof window.tabsManager.getCurrentZoneId === "function"
|
||||
? window.tabsManager.getCurrentZoneId()
|
||||
: null);
|
||||
if (!zoneId) return;
|
||||
const slider =
|
||||
document.getElementById("header-brightness-slider") ||
|
||||
document.getElementById("menu-brightness-slider");
|
||||
if (!slider) return;
|
||||
const cur = parseInt(slider.value, 10);
|
||||
const base = Number.isFinite(cur) ? cur : 127;
|
||||
const next = Math.max(0, Math.min(255, base + delta));
|
||||
if (String(slider.value) === String(next)) return;
|
||||
slider.value = String(next);
|
||||
slider.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
async function stopSequencePlayback() {
|
||||
if (typeof window.stopZoneSequencePlayback === "function") {
|
||||
await window.stopZoneSequencePlayback(true);
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {Record<string, () => void | Promise<void>>} */
|
||||
const actions = {
|
||||
NumpadEnter: () => syncSequenceBeatPhase("step"),
|
||||
NumpadDecimal: () => syncSequenceBeatPhase("pass"),
|
||||
NumpadMultiply: () => resetAudioTracking(),
|
||||
NumpadAdd: () => adjustZoneBrightness(BRIGHTNESS_STEP),
|
||||
NumpadSubtract: () => adjustZoneBrightness(-BRIGHTNESS_STEP),
|
||||
NumpadDivide: () => stopSequencePlayback(),
|
||||
Numpad1: () => selectZoneByListIndex(1),
|
||||
Numpad2: () => selectZoneByListIndex(2),
|
||||
Numpad3: () => selectZoneByListIndex(3),
|
||||
Numpad4: () => selectZoneByListIndex(4),
|
||||
Numpad5: () => selectZoneByListIndex(5),
|
||||
Numpad6: () => selectZoneByListIndex(6),
|
||||
Numpad7: () => selectZoneByListIndex(7),
|
||||
Numpad8: () => selectZoneByListIndex(8),
|
||||
Numpad9: () => selectZoneByListIndex(9),
|
||||
Numpad0: () => selectZoneByListIndex(10),
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", (ev) => {
|
||||
if (ev.defaultPrevented || ev.repeat || isTypingTarget(ev.target)) return;
|
||||
const code = ev.code;
|
||||
if (!code || !code.startsWith("Numpad")) return;
|
||||
const action = actions[code];
|
||||
if (!action) return;
|
||||
ev.preventDefault();
|
||||
Promise.resolve(action()).catch((e) => console.warn("numpad shortcut failed:", e));
|
||||
});
|
||||
})();
|
||||
@@ -264,6 +264,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const presetBackgroundFromPaletteButton = document.getElementById('preset-background-from-palette-btn');
|
||||
const presetModeInput = document.getElementById('preset-mode-input');
|
||||
const presetModeGroup = document.getElementById('preset-mode-group');
|
||||
const presetReverseInput = document.getElementById('preset-reverse-input');
|
||||
const presetReverseGroup = document.getElementById('preset-reverse-group');
|
||||
|
||||
if (!presetsButton || !presetsModal || !presetsList || !presetSaveButton) {
|
||||
return;
|
||||
@@ -350,6 +352,22 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
const patternSupportsModes = (patternName) => getPatternModeOptions(patternName) !== null;
|
||||
|
||||
const patternSupportsReverse = (patternName) => {
|
||||
const cfg = resolvePatternConfig(patternName);
|
||||
return !!(cfg && cfg.supports_reverse);
|
||||
};
|
||||
|
||||
const setPresetReverseFieldVisible = (show) => {
|
||||
if (!presetReverseGroup) {
|
||||
return;
|
||||
}
|
||||
presetReverseGroup.hidden = !show;
|
||||
presetReverseGroup.style.display = show ? '' : 'none';
|
||||
if (!show && presetReverseInput) {
|
||||
presetReverseInput.checked = false;
|
||||
}
|
||||
};
|
||||
|
||||
const setPresetModeFieldVisible = (show) => {
|
||||
if (!presetModeGroup) {
|
||||
return;
|
||||
@@ -773,6 +791,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
}
|
||||
|
||||
if (presetReverseInput) {
|
||||
const n5raw = preset.n5;
|
||||
const n5 = typeof n5raw === 'number' ? n5raw : parseInt(String(n5raw != null ? n5raw : '0'), 10);
|
||||
presetReverseInput.checked = Number.isFinite(n5) && n5 > 0;
|
||||
}
|
||||
|
||||
// Set n values, checking both n keys and descriptive names
|
||||
for (let i = 1; i <= 8; i++) {
|
||||
const nKey = `n${i}`;
|
||||
@@ -828,6 +852,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
if (presetManualModeInput) {
|
||||
presetManualModeInput.checked = false;
|
||||
}
|
||||
if (presetReverseInput) {
|
||||
presetReverseInput.checked = false;
|
||||
}
|
||||
setPresetReverseFieldVisible(false);
|
||||
if (presetManualBeatNInput) {
|
||||
presetManualBeatNInput.value = '1';
|
||||
}
|
||||
@@ -872,7 +900,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const tabData = await tabRes.json();
|
||||
const allowed =
|
||||
typeof window.zoneAllowsPresets === 'function'
|
||||
? window.zoneAllowsPresets(tabData)
|
||||
? window.zoneAllowsPresets(tabData, currentEditTabId)
|
||||
: true;
|
||||
presetRemoveFromTabButton.hidden = !allowed;
|
||||
} catch (e) {
|
||||
@@ -951,13 +979,20 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const modeEntries = patternSupportsModes(payload.pattern)
|
||||
? getPatternModeOptions(payload.pattern)
|
||||
: null;
|
||||
const reverseField = patternSupportsReverse(payload.pattern);
|
||||
for (let i = 1; i <= 8; i++) {
|
||||
const nKey = `n${i}`;
|
||||
if (modeEntries && nKey === 'n6') {
|
||||
continue;
|
||||
}
|
||||
if (reverseField && nKey === 'n5') {
|
||||
continue;
|
||||
}
|
||||
payload[nKey] = getNumberInput(`preset-${nKey}-input`);
|
||||
}
|
||||
if (reverseField) {
|
||||
payload.n5 = presetReverseInput && presetReverseInput.checked ? 1 : 0;
|
||||
}
|
||||
if (modeEntries && presetModeInput) {
|
||||
payload.mode = parseInt(presetModeInput.value, 10) || 0;
|
||||
}
|
||||
@@ -1065,9 +1100,19 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
|
||||
const modeEntries = patternSupportsModes(patternName) ? getPatternModeOptions(patternName) : null;
|
||||
const reverseField = patternSupportsReverse(patternName);
|
||||
if (modeEntries) {
|
||||
visibleNKeys.delete('n6');
|
||||
}
|
||||
if (reverseField) {
|
||||
visibleNKeys.delete('n5');
|
||||
}
|
||||
setPresetReverseFieldVisible(reverseField);
|
||||
if (reverseField && presetReverseInput) {
|
||||
const n5raw = presetForMode && presetForMode.n5 !== undefined ? presetForMode.n5 : 0;
|
||||
const n5 = typeof n5raw === 'number' ? n5raw : parseInt(String(n5raw), 10);
|
||||
presetReverseInput.checked = Number.isFinite(n5) && n5 > 0;
|
||||
}
|
||||
if (presetModeInput) {
|
||||
if (modeEntries) {
|
||||
setPresetModeFieldVisible(true);
|
||||
@@ -1355,7 +1400,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const zoneDoc = await zoneCheck.json();
|
||||
if (
|
||||
typeof window.zoneAllowsPresets === 'function' &&
|
||||
!window.zoneAllowsPresets(zoneDoc)
|
||||
!window.zoneAllowsPresets(zoneDoc, zoneId)
|
||||
) {
|
||||
alert('This zone is for sequences only. Add a sequence from the zone Edit menu instead.');
|
||||
return;
|
||||
@@ -1495,7 +1540,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const tabData = await tabResponse.json();
|
||||
if (
|
||||
typeof window.zoneAllowsPresets === 'function' &&
|
||||
!window.zoneAllowsPresets(tabData)
|
||||
!window.zoneAllowsPresets(tabData, zoneId)
|
||||
) {
|
||||
alert('This zone is for sequences only. Add a sequence from the zone Edit menu instead.');
|
||||
return;
|
||||
@@ -2214,7 +2259,7 @@ const savePresetGrid = async (zoneId, presetGrid) => {
|
||||
const tabData = await tabResponse.json();
|
||||
if (
|
||||
typeof window.zoneAllowsPresets === 'function' &&
|
||||
!window.zoneAllowsPresets(tabData)
|
||||
!window.zoneAllowsPresets(tabData, zoneId)
|
||||
) {
|
||||
throw new Error('This zone is for sequences only.');
|
||||
}
|
||||
@@ -2312,9 +2357,11 @@ const renderTabPresets = async (zoneId, options = {}) => {
|
||||
const tabData = await tabResponse.json();
|
||||
const groupsMapStrip = groupsStripRes.ok ? await groupsStripRes.json() : {};
|
||||
const ck =
|
||||
typeof window.normalizeZoneContentKind === 'function'
|
||||
? window.normalizeZoneContentKind(tabData)
|
||||
: null;
|
||||
typeof window.effectiveZoneContentKind === 'function'
|
||||
? window.effectiveZoneContentKind(tabData)
|
||||
: typeof window.normalizeZoneContentKind === 'function'
|
||||
? window.normalizeZoneContentKind(tabData)
|
||||
: 'presets';
|
||||
|
||||
// Get presets - support both 2D grid and flat array (for backward compatibility)
|
||||
let presetGrid = tabData.presets;
|
||||
@@ -2454,7 +2501,8 @@ const renderTabPresets = async (zoneId, options = {}) => {
|
||||
|
||||
if (
|
||||
typeof window.appendZoneSequenceTiles === 'function' &&
|
||||
(typeof window.zoneAllowsSequences !== 'function' || window.zoneAllowsSequences(tabData))
|
||||
(typeof window.zoneAllowsSequences !== 'function' ||
|
||||
window.zoneAllowsSequences(tabData, zoneId))
|
||||
) {
|
||||
await window.appendZoneSequenceTiles(zoneId, tabData, allPresets, paletteColors, presetsList);
|
||||
}
|
||||
@@ -2698,7 +2746,7 @@ const removePresetFromTab = async (zoneId, presetId) => {
|
||||
const tabData = await tabResponse.json();
|
||||
if (
|
||||
typeof window.zoneAllowsPresets === 'function' &&
|
||||
!window.zoneAllowsPresets(tabData)
|
||||
!window.zoneAllowsPresets(tabData, zoneId)
|
||||
) {
|
||||
alert('This zone is for sequences only.');
|
||||
return;
|
||||
|
||||
@@ -1,14 +1,98 @@
|
||||
// Sequences: lanes (parallel preset chains); advance is always by audio beats or simulated BPM.
|
||||
// Debug: in the browser console run setSequenceDebug(true) — toggling logs 1 (on) or 0 (off).
|
||||
// Debug: in the browser console run setSequenceDebug(true) — session only, not persisted.
|
||||
|
||||
const SEQ_DEBUG_STORAGE_KEY = 'led-controller-sequence-debug';
|
||||
/** @type {'beat'|'downbeat'} */
|
||||
let sequenceSwitchWaitFor = 'beat';
|
||||
|
||||
let sequenceDebugEnabled = false;
|
||||
let sequenceSwitchSaveInFlight = false;
|
||||
|
||||
async function loadSequenceSwitchWaitForFromServer() {
|
||||
try {
|
||||
const res = await fetch('/settings', {
|
||||
cache: 'no-store',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const raw = data && data.sequence_switch_wait;
|
||||
if (raw === 'downbeat' || raw === 'beat') {
|
||||
sequenceSwitchWaitFor = raw;
|
||||
} else if (raw === 'phrase') {
|
||||
sequenceSwitchWaitFor = 'beat';
|
||||
}
|
||||
} catch {
|
||||
/* keep default */
|
||||
}
|
||||
}
|
||||
|
||||
async function persistSequenceSwitchWaitFor() {
|
||||
sequenceSwitchSaveInFlight = true;
|
||||
try {
|
||||
const res = await fetch('/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ sequence_switch_wait: sequenceSwitchWaitFor }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.warn('[sequence] could not save switch wait to server', res.status);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[sequence] could not save switch wait to server', e);
|
||||
} finally {
|
||||
sequenceSwitchSaveInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getSequenceSwitchWaitFor() {
|
||||
return sequenceSwitchWaitFor === 'downbeat' ? 'downbeat' : 'beat';
|
||||
}
|
||||
|
||||
async function setSequenceSwitchWaitFor(waitFor) {
|
||||
sequenceSwitchWaitFor = waitFor === 'downbeat' ? 'downbeat' : 'beat';
|
||||
updateSequenceSwitchToggleUI();
|
||||
await persistSequenceSwitchWaitFor();
|
||||
}
|
||||
|
||||
function updateSequenceSwitchToggleUI() {
|
||||
const mode = getSequenceSwitchWaitFor();
|
||||
const ariaLabels = {
|
||||
beat: 'Switch sequence on beat',
|
||||
downbeat: 'Switch sequence on downbeat',
|
||||
};
|
||||
document.querySelectorAll('.seq-switch-toggle').forEach((btn) => {
|
||||
btn.setAttribute('aria-pressed', mode === 'beat' ? 'false' : 'true');
|
||||
btn.setAttribute('aria-label', ariaLabels[mode] || ariaLabels.beat);
|
||||
btn.classList.toggle('seq-switch-toggle--downbeat', mode === 'downbeat');
|
||||
});
|
||||
document.querySelectorAll('.seq-switch-toggle-wrap').forEach((wrap) => {
|
||||
wrap.classList.toggle('nav-slide-toggle-wrap--downbeat', mode === 'downbeat');
|
||||
});
|
||||
}
|
||||
|
||||
async function initSequenceSwitchToggle() {
|
||||
await loadSequenceSwitchWaitForFromServer();
|
||||
updateSequenceSwitchToggleUI();
|
||||
document.querySelectorAll('.seq-switch-toggle').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
void setSequenceSwitchWaitFor(getSequenceSwitchWaitFor() === 'beat' ? 'downbeat' : 'beat');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Sync toggle when settings changed elsewhere (e.g. another tab via audio status poll). */
|
||||
function applySequenceSwitchWaitFromServer(raw) {
|
||||
if (sequenceSwitchSaveInFlight) return;
|
||||
let mode = 'beat';
|
||||
if (raw === 'downbeat') mode = 'downbeat';
|
||||
else if (raw !== 'beat' && raw !== 'phrase') return;
|
||||
if (mode === getSequenceSwitchWaitFor()) return;
|
||||
sequenceSwitchWaitFor = mode;
|
||||
updateSequenceSwitchToggleUI();
|
||||
}
|
||||
|
||||
function seqDebugEnabled() {
|
||||
try {
|
||||
return localStorage.getItem(SEQ_DEBUG_STORAGE_KEY) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return sequenceDebugEnabled;
|
||||
}
|
||||
|
||||
/** @type {ReturnType<typeof setInterval> | null} */
|
||||
@@ -1120,15 +1204,11 @@ async function loadSequencesModalList() {
|
||||
});
|
||||
}
|
||||
|
||||
window.applySequenceSwitchWaitFromServer = applySequenceSwitchWaitFromServer;
|
||||
window.stopZoneSequencePlayback = stopZoneSequencePlayback;
|
||||
/** @param {boolean} on */
|
||||
window.setSequenceDebug = function setSequenceDebug(on) {
|
||||
try {
|
||||
if (on) localStorage.setItem(SEQ_DEBUG_STORAGE_KEY, '1');
|
||||
else localStorage.removeItem(SEQ_DEBUG_STORAGE_KEY);
|
||||
} catch (e) {
|
||||
console.warn('[sequence] could not persist debug flag', e);
|
||||
}
|
||||
sequenceDebugEnabled = !!on;
|
||||
console.log(seqDebugEnabled() ? 1 : 0);
|
||||
};
|
||||
window.appendZoneSequenceTiles = appendZoneSequenceTiles;
|
||||
@@ -1137,6 +1217,7 @@ window.addSequenceToTab = addSequenceToTab;
|
||||
window.removeSequenceFromTab = removeSequenceFromTab;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
void initSequenceSwitchToggle();
|
||||
const btn = document.getElementById('sequences-btn');
|
||||
const modal = document.getElementById('sequences-modal');
|
||||
const closeBtn = document.getElementById('sequences-close-btn');
|
||||
|
||||
@@ -106,7 +106,7 @@ header h1 {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Second header row: BPM, brightness, desktop buttons / mobile menu */
|
||||
/* Top header row: BPM, brightness, desktop buttons, mobile menu (above zone tabs) */
|
||||
.header-end {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -199,20 +199,43 @@ header h1 {
|
||||
|
||||
.audio-top-indicator {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.15rem;
|
||||
padding: 0.25rem 0.55rem;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 6px;
|
||||
background-color: #1a1a1a;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
min-width: 9rem;
|
||||
}
|
||||
|
||||
.audio-top-indicator-main {
|
||||
.audio-top-indicator.audio-running {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.audio-top-indicator .audio-top-beat-sync {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.audio-top-beat-sync {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
width: 100%;
|
||||
min-height: 2.25rem;
|
||||
padding: 0.3rem 0.55rem;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 6px;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.audio-top-beat-sync:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.audio-top-beat-sync:not(:disabled):hover {
|
||||
border-color: #6a6a6a;
|
||||
background-color: #2a2a2a;
|
||||
}
|
||||
|
||||
.audio-top-indicator-extra {
|
||||
@@ -226,10 +249,6 @@ header h1 {
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.audio-top-indicator.audio-running {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.audio-top-indicator-label {
|
||||
font-size: 0.72rem;
|
||||
color: #bdbdbd;
|
||||
@@ -245,16 +264,46 @@ header h1 {
|
||||
}
|
||||
|
||||
.audio-top-beat-readout {
|
||||
font-size: 0.62rem;
|
||||
font-size: 0.75rem;
|
||||
color: #b0bec5;
|
||||
line-height: 1.25;
|
||||
max-width: 12rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
min-width: 2rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.audio-top-beat-readout:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.audio-top-beat-readout:not(:empty)::before {
|
||||
content: "·";
|
||||
margin-right: 0.35rem;
|
||||
color: #757575;
|
||||
}
|
||||
|
||||
.audio-top-bar-phase {
|
||||
font-size: 0.7rem;
|
||||
color: #90a4ae;
|
||||
line-height: 1.25;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.audio-top-bar-phase:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.audio-top-bar-phase:not(:empty)::before {
|
||||
content: "·";
|
||||
margin-right: 0.35rem;
|
||||
color: #757575;
|
||||
}
|
||||
|
||||
.audio-top-bar-phase.is-downbeat {
|
||||
color: #ffab91;
|
||||
}
|
||||
|
||||
.audio-top-indicator-subvalue {
|
||||
@@ -264,16 +313,15 @@ header h1 {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.audio-top-indicator.flash {
|
||||
.audio-top-beat-sync.flash {
|
||||
background-color: #ff5252;
|
||||
border-color: #ff8a80;
|
||||
}
|
||||
|
||||
.audio-top-indicator.flash .audio-top-indicator-value,
|
||||
.audio-top-indicator.flash .audio-top-indicator-label,
|
||||
.audio-top-indicator.flash .audio-top-indicator-subvalue,
|
||||
.audio-top-indicator.flash .audio-top-indicator-extra,
|
||||
.audio-top-indicator.flash .audio-top-beat-readout {
|
||||
.audio-top-beat-sync.flash .audio-top-indicator-value,
|
||||
.audio-top-beat-sync.flash .audio-top-indicator-label,
|
||||
.audio-top-beat-sync.flash .audio-top-beat-readout,
|
||||
.audio-top-beat-sync.flash .audio-top-beat-readout::before {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@@ -333,7 +381,7 @@ body.preset-ui-run .edit-mode-only {
|
||||
|
||||
.zones-container {
|
||||
background-color: transparent;
|
||||
padding: 0.35rem 0 0;
|
||||
padding: 0;
|
||||
flex: 0 0 auto;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
@@ -863,12 +911,41 @@ body.preset-ui-run .edit-mode-only {
|
||||
min-width: 5rem;
|
||||
}
|
||||
|
||||
#audio-modal .audio-settings-section {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
#audio-modal .audio-settings-section .audio-modal-beat-readout {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.audio-modal-beat-readout {
|
||||
flex: 1;
|
||||
min-width: 10rem;
|
||||
min-height: 2.25rem;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.35;
|
||||
text-align: left;
|
||||
text-align: center;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 6px;
|
||||
background-color: #252525;
|
||||
padding: 0.35rem 0.65rem;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
color: #b0bec5;
|
||||
}
|
||||
|
||||
.audio-modal-beat-readout:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.audio-modal-beat-readout:not(:disabled):hover {
|
||||
border-color: #6a6a6a;
|
||||
background-color: #333;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.audio-hit-type-readout {
|
||||
@@ -1003,13 +1080,98 @@ body.preset-ui-run .edit-mode-only {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.ui-mode-toggle--edit {
|
||||
background-color: #4a3f8f;
|
||||
border: 1px solid #7b6fd6;
|
||||
.nav-slide-toggle-wrap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ui-mode-toggle--edit:hover {
|
||||
.nav-slide-toggle-side-label {
|
||||
font-size: 0.82rem;
|
||||
color: #888;
|
||||
user-select: none;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.nav-slide-toggle-wrap:not(.nav-slide-toggle-wrap--downbeat) .nav-slide-toggle-side-label--beat,
|
||||
.nav-slide-toggle-wrap--downbeat .nav-slide-toggle-side-label--downbeat {
|
||||
color: #e8e8e8;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.nav-slide-toggle-switch {
|
||||
position: relative;
|
||||
width: 2.75rem;
|
||||
height: 1.4rem;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
appearance: none;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 999px;
|
||||
background-color: #2a2a2a;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav-slide-toggle-switch:hover {
|
||||
border-color: #666;
|
||||
}
|
||||
|
||||
.nav-slide-toggle-switch:focus-visible {
|
||||
outline: 2px solid #7b6fd6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.nav-slide-toggle-track {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.nav-slide-toggle-thumb {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 2px;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: 50%;
|
||||
background-color: #bdbdbd;
|
||||
transform: translateY(-50%);
|
||||
transition: left 0.2s ease, background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.nav-slide-toggle-switch.seq-switch-toggle--downbeat {
|
||||
background-color: #4a3f8f;
|
||||
border-color: #7b6fd6;
|
||||
}
|
||||
|
||||
.nav-slide-toggle-switch.seq-switch-toggle--downbeat:hover {
|
||||
background-color: #5a4f9f;
|
||||
border-color: #8b7fe6;
|
||||
}
|
||||
|
||||
.nav-slide-toggle-switch.seq-switch-toggle--downbeat .nav-slide-toggle-thumb {
|
||||
left: calc(100% - 1rem - 2px);
|
||||
transform: translateY(-50%);
|
||||
background-color: #e8e4ff;
|
||||
}
|
||||
|
||||
.main-menu-dropdown .nav-slide-toggle-wrap--mobile {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0.45rem 0.5rem;
|
||||
border-bottom: 1px solid #333;
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Preset select buttons inside the zone grid */
|
||||
@@ -1261,13 +1423,43 @@ body.preset-ui-run .edit-mode-only {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Beat/downbeat toggle lives in the mobile menu only */
|
||||
#seq-switch-toggle-wrap {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.main-menu-dropdown {
|
||||
max-width: min(16rem, calc(100vw - 1rem));
|
||||
}
|
||||
|
||||
#seq-switch-toggle-wrap-mobile .nav-slide-toggle-side-label {
|
||||
font-size: 0.7rem;
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
#seq-switch-toggle-wrap-mobile .nav-slide-toggle-switch.seq-switch-toggle {
|
||||
width: 3.6rem;
|
||||
height: 1.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#seq-switch-toggle-wrap-mobile .nav-slide-toggle-switch .nav-slide-toggle-thumb {
|
||||
width: 0.9rem;
|
||||
height: 0.9rem;
|
||||
}
|
||||
|
||||
#seq-switch-toggle-wrap-mobile .nav-slide-toggle-switch.seq-switch-toggle--downbeat .nav-slide-toggle-thumb {
|
||||
left: calc(100% - 0.9rem - 2px);
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.header-menu-mobile {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
margin-top: 0;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.header-end {
|
||||
@@ -1277,10 +1469,15 @@ body.preset-ui-run .edit-mode-only {
|
||||
|
||||
.header-end .audio-top-indicator {
|
||||
min-width: 5rem;
|
||||
padding: 0.2rem 0.45rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-end .audio-top-beat-sync {
|
||||
padding: 0.2rem 0.4rem;
|
||||
min-height: 2rem;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.4rem 0.7rem;
|
||||
|
||||
@@ -515,22 +515,38 @@ function editModalContentKindSelected() {
|
||||
return radio && radio.value === 'sequences' ? 'sequences' : 'presets';
|
||||
}
|
||||
|
||||
function activeZoneContentKind(zoneDoc) {
|
||||
function activeZoneContentKind(zoneDoc, zoneId) {
|
||||
const modal = document.getElementById('edit-zone-modal');
|
||||
if (modal && modal.classList.contains('active')) {
|
||||
const editingId = document.getElementById('edit-zone-id')?.value;
|
||||
if (
|
||||
modal &&
|
||||
modal.classList.contains('active') &&
|
||||
zoneId != null &&
|
||||
zoneId !== '' &&
|
||||
String(editingId) === String(zoneId)
|
||||
) {
|
||||
return editModalContentKindSelected();
|
||||
}
|
||||
return effectiveZoneContentKind(zoneDoc);
|
||||
}
|
||||
|
||||
/** @returns {boolean} */
|
||||
function zoneAllowsPresets(zoneDoc) {
|
||||
return activeZoneContentKind(zoneDoc) === 'presets';
|
||||
/** True when the zone row has an explicit presets vs sequences type (not legacy inferred). */
|
||||
function zoneHasExplicitContentKind(zoneDoc) {
|
||||
return normalizeZoneContentKind(zoneDoc) !== null;
|
||||
}
|
||||
|
||||
/** @returns {boolean} */
|
||||
function zoneAllowsSequences(zoneDoc) {
|
||||
return activeZoneContentKind(zoneDoc) === 'sequences';
|
||||
function zoneAllowsPresets(zoneDoc, zoneId) {
|
||||
void zoneId;
|
||||
if (!zoneHasExplicitContentKind(zoneDoc)) return true;
|
||||
return effectiveZoneContentKind(zoneDoc) === 'presets';
|
||||
}
|
||||
|
||||
/** @returns {boolean} */
|
||||
function zoneAllowsSequences(zoneDoc, zoneId) {
|
||||
void zoneId;
|
||||
if (!zoneHasExplicitContentKind(zoneDoc)) return true;
|
||||
return effectiveZoneContentKind(zoneDoc) === 'sequences';
|
||||
}
|
||||
|
||||
function applyZoneContentKindEditModal(kind) {
|
||||
@@ -1028,7 +1044,7 @@ async function refreshEditTabPresetsUi(zoneId) {
|
||||
return;
|
||||
}
|
||||
const tabData = await tabRes.json();
|
||||
if (!zoneAllowsPresets(tabData)) {
|
||||
if (!zoneAllowsPresets(tabData, zoneId)) {
|
||||
currentEl.innerHTML =
|
||||
'<span class="muted-text">This zone is for sequences only. Presets are hidden.</span>';
|
||||
addEl.innerHTML = '<span class="muted-text">—</span>';
|
||||
@@ -1187,16 +1203,32 @@ async function updateZone(zoneId, name, groupRows, contentKind) {
|
||||
: [];
|
||||
const ck =
|
||||
contentKind === 'sequences' || contentKind === 'presets' ? contentKind : 'presets';
|
||||
let existing = {};
|
||||
try {
|
||||
const cur = await fetch(`/zones/${encodeURIComponent(zoneId)}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (cur.ok) {
|
||||
const j = await cur.json();
|
||||
if (j && typeof j === 'object') existing = j;
|
||||
}
|
||||
} catch (_) {
|
||||
/* use empty existing */
|
||||
}
|
||||
const response = await fetch(`/zones/${zoneId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...existing,
|
||||
name: name,
|
||||
names: [],
|
||||
group_ids: gids,
|
||||
preset_group_ids: {},
|
||||
preset_group_ids:
|
||||
existing.preset_group_ids && typeof existing.preset_group_ids === 'object'
|
||||
? existing.preset_group_ids
|
||||
: {},
|
||||
content_kind: ck,
|
||||
})
|
||||
});
|
||||
|
||||
@@ -9,18 +9,21 @@
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<header>
|
||||
<div class="zones-container">
|
||||
<div id="zones-list">
|
||||
Loading zones...
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-end">
|
||||
<div id="audio-top-indicator" class="audio-top-indicator" title="Live audio BPM">
|
||||
<div class="audio-top-indicator-main">
|
||||
<div class="nav-slide-toggle-wrap seq-switch-toggle-wrap edit-mode-only" id="seq-switch-toggle-wrap">
|
||||
<span class="nav-slide-toggle-side-label nav-slide-toggle-side-label--beat">Beat</span>
|
||||
<button type="button" role="switch" class="nav-slide-toggle-switch seq-switch-toggle" id="seq-switch-toggle" aria-pressed="false" aria-label="Switch sequence on beat" title="When starting a sequence: wait for beat or downbeat">
|
||||
<span class="nav-slide-toggle-track" aria-hidden="true"><span class="nav-slide-toggle-thumb"></span></span>
|
||||
</button>
|
||||
<span class="nav-slide-toggle-side-label nav-slide-toggle-side-label--downbeat">Downbeat</span>
|
||||
</div>
|
||||
<div id="audio-top-indicator" class="audio-top-indicator">
|
||||
<button type="button" id="audio-top-beat-sync" class="audio-top-beat-sync" disabled title="Sync step to music (S)">
|
||||
<span class="audio-top-indicator-label">BPM</span>
|
||||
<span id="audio-top-bpm-value" class="audio-top-indicator-value">--</span>
|
||||
<span id="audio-top-beat-readout" class="audio-top-beat-readout" aria-live="polite"></span>
|
||||
</div>
|
||||
<span id="audio-top-bar-phase" class="audio-top-bar-phase" aria-live="polite" title="Bar phase (beat in bar)"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<div class="header-brightness-control">
|
||||
@@ -38,12 +41,20 @@
|
||||
<button class="btn btn-secondary edit-mode-only" id="send-profile-presets-btn">Send Presets</button>
|
||||
<button class="btn btn-secondary edit-mode-only" id="led-tool-btn">LED Tool</button>
|
||||
<button class="btn btn-secondary" id="audio-btn">Audio</button>
|
||||
<button type="button" class="btn btn-secondary" id="audio-nav-reset-btn" hidden title="Clear stuck BPM / beat tracking">Reset detector</button>
|
||||
<button class="btn btn-secondary" id="help-btn">Help</button>
|
||||
<button type="button" class="btn btn-secondary ui-mode-toggle" id="ui-mode-toggle" aria-pressed="false" title="Switch preset strip mode — label is the mode you will switch to">Edit mode</button>
|
||||
</div>
|
||||
<div class="header-menu-mobile">
|
||||
<button class="btn btn-secondary" id="main-menu-btn">Menu</button>
|
||||
<div id="main-menu-dropdown" class="main-menu-dropdown">
|
||||
<div class="nav-slide-toggle-wrap nav-slide-toggle-wrap--mobile seq-switch-toggle-wrap" id="seq-switch-toggle-wrap-mobile">
|
||||
<span class="nav-slide-toggle-side-label nav-slide-toggle-side-label--beat">Beat</span>
|
||||
<button type="button" role="switch" class="nav-slide-toggle-switch seq-switch-toggle" id="seq-switch-toggle-mobile" aria-pressed="false" aria-label="Switch sequence on beat" title="Beat or downbeat">
|
||||
<span class="nav-slide-toggle-track" aria-hidden="true"><span class="nav-slide-toggle-thumb"></span></span>
|
||||
</button>
|
||||
<span class="nav-slide-toggle-side-label nav-slide-toggle-side-label--downbeat">Downbeat</span>
|
||||
</div>
|
||||
<button type="button" class="ui-mode-toggle" id="ui-mode-toggle-mobile" aria-pressed="false">Edit mode</button>
|
||||
<div class="menu-brightness-control">
|
||||
<label for="menu-brightness-slider">Brightness</label>
|
||||
@@ -60,10 +71,16 @@
|
||||
<button type="button" class="edit-mode-only" data-target="send-profile-presets-btn">Send Presets</button>
|
||||
<button type="button" class="edit-mode-only" data-target="led-tool-btn">LED Tool</button>
|
||||
<button type="button" data-target="audio-btn">Audio</button>
|
||||
<button type="button" id="audio-nav-reset-mobile" data-target="audio-nav-reset-btn" hidden>Reset detector</button>
|
||||
<button type="button" data-target="help-btn">Help</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="zones-container">
|
||||
<div id="zones-list">
|
||||
Loading zones...
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="main-content">
|
||||
@@ -348,6 +365,10 @@
|
||||
<label for="sequence-editor-simulated-bpm" style="display:block;margin-bottom:0.25rem;">Simulated BPM (when audio is off)</label>
|
||||
<input type="number" id="sequence-editor-simulated-bpm" min="30" max="300" value="120" style="width:6rem;" title="Used only while the audio detector is stopped">
|
||||
<p id="sequence-editor-bpm-live" class="muted-text" style="font-size:0.85em;margin:0.5rem 0 0 0;">—</p>
|
||||
<label style="display:block;margin-top:0.65rem;">
|
||||
<input type="checkbox" id="sequence-editor-loop" checked>
|
||||
Loop sequence (restart from the first step after the last)
|
||||
</label>
|
||||
</div>
|
||||
<div id="sequence-editor-lanes"></div>
|
||||
<div class="modal-actions preset-editor-modal-actions">
|
||||
@@ -405,6 +426,12 @@
|
||||
<span class="muted-text" style="font-size: 0.85em;">beats (this app only)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="preset-editor-field" id="preset-reverse-group" hidden>
|
||||
<label for="preset-reverse-input" style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0;">
|
||||
<input type="checkbox" id="preset-reverse-input">
|
||||
Reverse direction (strip installed upside down)
|
||||
</label>
|
||||
</div>
|
||||
<div class="preset-editor-field preset-mode-field" id="preset-mode-group" hidden>
|
||||
<label for="preset-mode-input" id="preset-mode-label">Mode</label>
|
||||
<select id="preset-mode-input" class="preset-mode-input"></select>
|
||||
@@ -619,22 +646,45 @@
|
||||
<label>Current BPM</label>
|
||||
<div class="audio-bpm-row">
|
||||
<div id="audio-bpm-value" class="audio-bpm-readout">--</div>
|
||||
<div id="audio-modal-beat-readout" class="audio-modal-beat-readout muted-text" aria-live="polite"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Detected hit type</label>
|
||||
<div id="audio-hit-type-value" class="audio-hit-type-readout">unknown</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Bar phase</label>
|
||||
<div class="audio-bpm-row">
|
||||
<div id="audio-bar-phase-value" class="audio-bpm-readout" title="Beat in bar (kick hints downbeat)">--</div>
|
||||
</div>
|
||||
<small class="muted-text">Bar uses kick-heavy hits (default 4/4). Tap <strong>Sync</strong> on a downbeat to lock bar phase.</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Flash on beat</label>
|
||||
<div id="audio-beat-flash" class="audio-beat-flash" aria-hidden="true"></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="audio-beat-phase-ms">Beat phase shift (ms)</label>
|
||||
<input type="number" id="audio-beat-phase-ms" min="0" max="500" step="5" value="0" style="width:6rem;">
|
||||
<small class="muted-text">Delays beat flashes and sequenced beats so they line up with what you hear (saved in this browser).</small>
|
||||
|
||||
<div class="settings-section audio-settings-section">
|
||||
<h3>Audio settings</h3>
|
||||
<div class="form-group">
|
||||
<label for="audio-beat-phase-ms">Beat phase shift (ms)</label>
|
||||
<input type="number" id="audio-beat-phase-ms" min="0" max="500" step="5" value="0" style="width:6rem;">
|
||||
<small class="muted-text">Delays beat flashes so they line up with what you hear (saved on the controller).</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Beat sync</label>
|
||||
<button type="button" id="audio-modal-beat-readout" class="audio-modal-beat-readout muted-text" disabled title="Sync step to music (S)" aria-live="polite"></button>
|
||||
<small class="muted-text">While a sequence is playing, tap the BPM/beat button in the header on a downbeat to align the step counter. Shortcut: <kbd>S</kbd>.</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Sequence alignment</label>
|
||||
<div class="profiles-actions" style="flex-wrap: wrap;">
|
||||
<button type="button" class="btn btn-secondary" id="audio-sync-pass-btn">Restart pass</button>
|
||||
</div>
|
||||
<small class="muted-text"><strong>Restart pass</strong> jumps to step 1 of the sequence (<kbd>Shift+S</kbd>). Use <strong>Reset detector</strong> in the header (while audio is running) to clear stuck BPM/beat tracking without stopping audio.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-primary" id="audio-start-btn">Start</button>
|
||||
<button type="button" class="btn btn-secondary" id="audio-stop-btn">Stop</button>
|
||||
@@ -805,5 +855,6 @@
|
||||
<script src="/static/sequences.js"></script>
|
||||
<script src="/static/devices.js"></script>
|
||||
<script src="/static/audio.js"></script>
|
||||
<script src="/static/numpad.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -261,7 +261,7 @@
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch('/settings/settings', {
|
||||
const response = await fetch('/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ wifi_channel: wifiChannel }),
|
||||
|
||||
Reference in New Issue
Block a user