feat(ui): numpad, audio readout, and sequence beat controls

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-17 18:32:12 +12:00
parent 964cfc6d91
commit c286e504eb
9 changed files with 779 additions and 159 deletions

117
src/static/numpad.js Normal file
View File

@@ -0,0 +1,117 @@
/**
* Bluetooth / USB HID numpad shortcuts (browser focus required).
*
* Numpad19,0 → zone 110 (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));
});
})();