feat(ui): lock zone type and start audio from BPM
Zone preset vs sequence is fixed at create; edit shows read-only type. Header BPM button starts beat detection when the detector is stopped. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -134,7 +134,11 @@ class Zone(Model):
|
||||
id_str = str(id)
|
||||
if id_str not in self:
|
||||
return False
|
||||
patch = data if isinstance(data, dict) else {}
|
||||
patch = dict(data) if isinstance(data, dict) else {}
|
||||
doc = self[id_str]
|
||||
locked_kind = self._normalized_content_kind(doc) or self._infer_content_kind(doc)
|
||||
if "content_kind" in patch:
|
||||
patch["content_kind"] = locked_kind
|
||||
self[id_str].update(patch)
|
||||
if "content_kind" in patch:
|
||||
self._enforce_content_kind_invariants(self[id_str])
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
(() => {
|
||||
let pollTimer = null;
|
||||
let audioDetectorRunning = false;
|
||||
let lastBeatSeq = 0;
|
||||
let lastLoggedSequenceBeatFractions = "";
|
||||
/** Prior poll had server zone sequence playback active (`status.sequence.active === true`). */
|
||||
@@ -161,13 +162,37 @@
|
||||
|
||||
function updateSequenceSyncControls(zoneSeqActive) {
|
||||
const topSync = el("audio-top-beat-sync");
|
||||
if (topSync) topSync.disabled = !zoneSeqActive;
|
||||
if (topSync) {
|
||||
topSync.disabled = audioDetectorRunning && !zoneSeqActive;
|
||||
topSync.title = !audioDetectorRunning
|
||||
? "Start beat detection"
|
||||
: zoneSeqActive
|
||||
? "Sync step to music (S)"
|
||||
: "Beat detection running";
|
||||
}
|
||||
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 handleTopBpmButtonClick() {
|
||||
if (!audioDetectorRunning) {
|
||||
try {
|
||||
await startAudio();
|
||||
} catch (e) {
|
||||
console.error("audio start failed", e);
|
||||
alert("Failed to start audio input. Check mic permissions.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await syncSequenceBeatPhase("step");
|
||||
} catch (e) {
|
||||
console.warn("sequence beat sync failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function syncSequenceBeatPhase(mode) {
|
||||
const res = await fetch("/sequences/sync-phase", {
|
||||
method: "POST",
|
||||
@@ -250,6 +275,7 @@
|
||||
|
||||
/** Stop detector and polling; does not clear “resume on load” prefs (used before restart). */
|
||||
async function stopAudioOnly() {
|
||||
audioDetectorRunning = false;
|
||||
setTopBpmVisible(false);
|
||||
setNavResetVisible(false);
|
||||
clearBeatPhaseTimers();
|
||||
@@ -285,6 +311,7 @@
|
||||
node.textContent = String(status.error).trim().slice(0, 120);
|
||||
}
|
||||
updateBeatReadoutDisplays({});
|
||||
audioDetectorRunning = !!status.running;
|
||||
updateBpmDisplay(null);
|
||||
setTopBpmVisible(!!status.running);
|
||||
setNavResetVisible(!!status.running);
|
||||
@@ -294,6 +321,7 @@
|
||||
}
|
||||
return;
|
||||
}
|
||||
audioDetectorRunning = !!status.running;
|
||||
const zoneSeqActive = sequencePlaybackActiveFromStatus(status);
|
||||
setTopBpmVisible(!!status.running || zoneSeqActive);
|
||||
setNavResetVisible(!!status.running);
|
||||
@@ -482,7 +510,12 @@
|
||||
}
|
||||
});
|
||||
};
|
||||
bindSync(el("audio-top-beat-sync"), "step");
|
||||
const topBpm = el("audio-top-beat-sync");
|
||||
if (topBpm) {
|
||||
topBpm.addEventListener("click", () => {
|
||||
void handleTopBpmButtonClick();
|
||||
});
|
||||
}
|
||||
bindSync(el("audio-modal-beat-readout"), "step");
|
||||
bindSync(el("audio-sync-pass-btn"), "pass");
|
||||
|
||||
@@ -501,11 +534,14 @@
|
||||
const res = await fetch("/api/audio/status", { cache: "no-store" });
|
||||
const data = await res.json();
|
||||
const status = data?.status || {};
|
||||
audioDetectorRunning = !!status.running;
|
||||
if (status.running && !pollTimer) {
|
||||
pollTimer = setInterval(pollStatus, 250);
|
||||
lastBeatSeq = Number(status.beat_seq || 0);
|
||||
prevZoneSequencePlaybackActive = sequencePlaybackActiveFromStatus(status);
|
||||
await pollStatus();
|
||||
} else {
|
||||
updateSequenceSyncControls(sequencePlaybackActiveFromStatus(status));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("audio resume poll check failed", e);
|
||||
|
||||
@@ -310,8 +310,7 @@ async function computeZonePresetUnionTargets(zoneDoc) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Device names for one sequence step. Empty stepGroupIds => all zone tab devices (``names`` only).
|
||||
* Otherwise: lane groups intersected with that tab device list (not zone ``group_ids``).
|
||||
* Device names for one sequence step. Only devices in checked lane groups (within the zone tab).
|
||||
*/
|
||||
async function resolveSequenceStepDeviceNames(zone, stepGroupIds) {
|
||||
const zoneT = await computeZoneNamesTargets(zone);
|
||||
@@ -321,7 +320,7 @@ async function resolveSequenceStepDeviceNames(zone, stepGroupIds) {
|
||||
? stepGroupIds.map((x) => String(x).trim()).filter((x) => x.length > 0)
|
||||
: [];
|
||||
if (!gids.length) {
|
||||
return names.slice();
|
||||
return [];
|
||||
}
|
||||
const zoneMacSet = new Set(
|
||||
macs.map((m) => normalizeDeviceMac(m)).filter((m) => m.length === 12),
|
||||
@@ -509,43 +508,15 @@ function effectiveZoneContentKind(zoneDoc) {
|
||||
return 'presets';
|
||||
}
|
||||
|
||||
/** @returns {'presets' | 'sequences'} */
|
||||
function editModalContentKindSelected() {
|
||||
const radio = document.querySelector('input[name="edit-zone-content-kind"]:checked');
|
||||
return radio && radio.value === 'sequences' ? 'sequences' : 'presets';
|
||||
}
|
||||
|
||||
function activeZoneContentKind(zoneDoc, zoneId) {
|
||||
const modal = document.getElementById('edit-zone-modal');
|
||||
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);
|
||||
}
|
||||
|
||||
/** 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 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';
|
||||
}
|
||||
|
||||
@@ -623,7 +594,7 @@ function renderZonesList(tabs, tabOrder, currentZoneId) {
|
||||
for (const zoneId of tabOrder) {
|
||||
const zone = tabs[zoneId];
|
||||
if (zone) {
|
||||
const activeClass = zoneId === currentZoneId ? 'active' : '';
|
||||
const activeClass = String(zoneId) === String(currentZoneId) ? 'active' : '';
|
||||
const disp = zone.name || `Zone ${zoneId}`;
|
||||
html += `
|
||||
<button class="zone-button ${activeClass}"
|
||||
@@ -1183,9 +1154,13 @@ async function openEditZoneModal(zoneId, zone) {
|
||||
renderZoneGroupsEditor(groupsEditor, window.__editTabGroupRows, groupsMap);
|
||||
|
||||
const kind = effectiveZoneContentKind(tabData);
|
||||
document.querySelectorAll('input[name="edit-zone-content-kind"]').forEach((radio) => {
|
||||
radio.checked = radio.value === kind;
|
||||
});
|
||||
const typeLabel = document.getElementById('edit-zone-type-label');
|
||||
if (typeLabel) {
|
||||
typeLabel.textContent =
|
||||
kind === 'sequences'
|
||||
? 'Zone type: Sequences (set when the zone was created)'
|
||||
: 'Zone type: Presets (set when the zone was created)';
|
||||
}
|
||||
|
||||
if (modal) modal.classList.add("active");
|
||||
applyZoneContentKindEditModal(kind);
|
||||
@@ -1196,13 +1171,11 @@ async function openEditZoneModal(zoneId, zone) {
|
||||
}
|
||||
|
||||
// Update an existing zone (name, group list; devices come from groups only).
|
||||
async function updateZone(zoneId, name, groupRows, contentKind) {
|
||||
async function updateZone(zoneId, name, groupRows) {
|
||||
try {
|
||||
const gids = Array.isArray(groupRows)
|
||||
? groupRows.map((r) => String(r.id || "").trim()).filter((x) => x.length > 0)
|
||||
: [];
|
||||
const ck =
|
||||
contentKind === 'sequences' || contentKind === 'presets' ? contentKind : 'presets';
|
||||
let existing = {};
|
||||
try {
|
||||
const cur = await fetch(`/zones/${encodeURIComponent(zoneId)}`, {
|
||||
@@ -1215,6 +1188,7 @@ async function updateZone(zoneId, name, groupRows, contentKind) {
|
||||
} catch (_) {
|
||||
/* use empty existing */
|
||||
}
|
||||
const lockedKind = effectiveZoneContentKind(existing);
|
||||
const response = await fetch(`/zones/${zoneId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
@@ -1229,7 +1203,7 @@ async function updateZone(zoneId, name, groupRows, contentKind) {
|
||||
existing.preset_group_ids && typeof existing.preset_group_ids === 'object'
|
||||
? existing.preset_group_ids
|
||||
: {},
|
||||
content_kind: ck,
|
||||
content_kind: lockedKind,
|
||||
})
|
||||
});
|
||||
|
||||
@@ -1238,6 +1212,9 @@ async function updateZone(zoneId, name, groupRows, contentKind) {
|
||||
// Reload tabs list
|
||||
await loadZonesModal();
|
||||
await loadZones();
|
||||
if (String(currentZoneId) === String(zoneId)) {
|
||||
await loadZoneContent(zoneId);
|
||||
}
|
||||
// Close modal
|
||||
document.getElementById('edit-zone-modal').classList.remove('active');
|
||||
return true;
|
||||
@@ -1369,18 +1346,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll('input[name="edit-zone-content-kind"]').forEach((radio) => {
|
||||
radio.addEventListener('change', async () => {
|
||||
applyZoneContentKindEditModal(editModalContentKindSelected());
|
||||
const zoneId = document.getElementById('edit-zone-id')?.value;
|
||||
if (!zoneId) return;
|
||||
await refreshEditTabPresetsUi(zoneId);
|
||||
if (typeof window.refreshEditTabSequencesUi === 'function') {
|
||||
await window.refreshEditTabSequencesUi(zoneId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Set up edit zone form
|
||||
const editZoneForm = document.getElementById('edit-zone-form');
|
||||
if (editZoneForm) {
|
||||
@@ -1394,7 +1359,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const groupRows = window.__editTabGroupRows || [];
|
||||
|
||||
if (zoneId && name) {
|
||||
await updateZone(zoneId, name, groupRows, editModalContentKindSelected());
|
||||
await updateZone(zoneId, name, groupRows);
|
||||
editZoneForm.reset();
|
||||
}
|
||||
});
|
||||
@@ -1441,6 +1406,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
});
|
||||
|
||||
window.selectZone = selectZone;
|
||||
|
||||
// Export for use in other scripts
|
||||
window.zonesManager = {
|
||||
loadZones,
|
||||
|
||||
@@ -119,10 +119,7 @@
|
||||
<input type="hidden" id="edit-zone-id">
|
||||
<label>Zone Name:</label>
|
||||
<input type="text" id="edit-zone-name" placeholder="Enter zone name" required>
|
||||
<div class="zone-content-kind-row muted-text">
|
||||
<label><input type="radio" name="edit-zone-content-kind" value="presets" checked> Presets</label>
|
||||
<label><input type="radio" name="edit-zone-content-kind" value="sequences"> Sequences</label>
|
||||
</div>
|
||||
<p id="edit-zone-type-label" class="zone-content-kind-row muted-text" aria-live="polite"></p>
|
||||
<div id="edit-zone-block-groups">
|
||||
<label class="zone-devices-label">Device groups on this zone</label>
|
||||
<div id="edit-zone-groups-editor" class="zone-devices-editor"></div>
|
||||
|
||||
Reference in New Issue
Block a user