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)
|
id_str = str(id)
|
||||||
if id_str not in self:
|
if id_str not in self:
|
||||||
return False
|
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)
|
self[id_str].update(patch)
|
||||||
if "content_kind" in patch:
|
if "content_kind" in patch:
|
||||||
self._enforce_content_kind_invariants(self[id_str])
|
self._enforce_content_kind_invariants(self[id_str])
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
(() => {
|
(() => {
|
||||||
let pollTimer = null;
|
let pollTimer = null;
|
||||||
|
let audioDetectorRunning = false;
|
||||||
let lastBeatSeq = 0;
|
let lastBeatSeq = 0;
|
||||||
let lastLoggedSequenceBeatFractions = "";
|
let lastLoggedSequenceBeatFractions = "";
|
||||||
/** Prior poll had server zone sequence playback active (`status.sequence.active === true`). */
|
/** Prior poll had server zone sequence playback active (`status.sequence.active === true`). */
|
||||||
@@ -161,13 +162,37 @@
|
|||||||
|
|
||||||
function updateSequenceSyncControls(zoneSeqActive) {
|
function updateSequenceSyncControls(zoneSeqActive) {
|
||||||
const topSync = el("audio-top-beat-sync");
|
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");
|
const modalBeat = el("audio-modal-beat-readout");
|
||||||
if (modalBeat) modalBeat.disabled = !zoneSeqActive;
|
if (modalBeat) modalBeat.disabled = !zoneSeqActive;
|
||||||
const passBtn = el("audio-sync-pass-btn");
|
const passBtn = el("audio-sync-pass-btn");
|
||||||
if (passBtn) passBtn.disabled = !zoneSeqActive;
|
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) {
|
async function syncSequenceBeatPhase(mode) {
|
||||||
const res = await fetch("/sequences/sync-phase", {
|
const res = await fetch("/sequences/sync-phase", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -250,6 +275,7 @@
|
|||||||
|
|
||||||
/** Stop detector and polling; does not clear “resume on load” prefs (used before restart). */
|
/** Stop detector and polling; does not clear “resume on load” prefs (used before restart). */
|
||||||
async function stopAudioOnly() {
|
async function stopAudioOnly() {
|
||||||
|
audioDetectorRunning = false;
|
||||||
setTopBpmVisible(false);
|
setTopBpmVisible(false);
|
||||||
setNavResetVisible(false);
|
setNavResetVisible(false);
|
||||||
clearBeatPhaseTimers();
|
clearBeatPhaseTimers();
|
||||||
@@ -285,6 +311,7 @@
|
|||||||
node.textContent = String(status.error).trim().slice(0, 120);
|
node.textContent = String(status.error).trim().slice(0, 120);
|
||||||
}
|
}
|
||||||
updateBeatReadoutDisplays({});
|
updateBeatReadoutDisplays({});
|
||||||
|
audioDetectorRunning = !!status.running;
|
||||||
updateBpmDisplay(null);
|
updateBpmDisplay(null);
|
||||||
setTopBpmVisible(!!status.running);
|
setTopBpmVisible(!!status.running);
|
||||||
setNavResetVisible(!!status.running);
|
setNavResetVisible(!!status.running);
|
||||||
@@ -294,6 +321,7 @@
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
audioDetectorRunning = !!status.running;
|
||||||
const zoneSeqActive = sequencePlaybackActiveFromStatus(status);
|
const zoneSeqActive = sequencePlaybackActiveFromStatus(status);
|
||||||
setTopBpmVisible(!!status.running || zoneSeqActive);
|
setTopBpmVisible(!!status.running || zoneSeqActive);
|
||||||
setNavResetVisible(!!status.running);
|
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-modal-beat-readout"), "step");
|
||||||
bindSync(el("audio-sync-pass-btn"), "pass");
|
bindSync(el("audio-sync-pass-btn"), "pass");
|
||||||
|
|
||||||
@@ -501,11 +534,14 @@
|
|||||||
const res = await fetch("/api/audio/status", { cache: "no-store" });
|
const res = await fetch("/api/audio/status", { cache: "no-store" });
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const status = data?.status || {};
|
const status = data?.status || {};
|
||||||
|
audioDetectorRunning = !!status.running;
|
||||||
if (status.running && !pollTimer) {
|
if (status.running && !pollTimer) {
|
||||||
pollTimer = setInterval(pollStatus, 250);
|
pollTimer = setInterval(pollStatus, 250);
|
||||||
lastBeatSeq = Number(status.beat_seq || 0);
|
lastBeatSeq = Number(status.beat_seq || 0);
|
||||||
prevZoneSequencePlaybackActive = sequencePlaybackActiveFromStatus(status);
|
prevZoneSequencePlaybackActive = sequencePlaybackActiveFromStatus(status);
|
||||||
await pollStatus();
|
await pollStatus();
|
||||||
|
} else {
|
||||||
|
updateSequenceSyncControls(sequencePlaybackActiveFromStatus(status));
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("audio resume poll check failed", 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).
|
* Device names for one sequence step. Only devices in checked lane groups (within the zone tab).
|
||||||
* Otherwise: lane groups intersected with that tab device list (not zone ``group_ids``).
|
|
||||||
*/
|
*/
|
||||||
async function resolveSequenceStepDeviceNames(zone, stepGroupIds) {
|
async function resolveSequenceStepDeviceNames(zone, stepGroupIds) {
|
||||||
const zoneT = await computeZoneNamesTargets(zone);
|
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)
|
? stepGroupIds.map((x) => String(x).trim()).filter((x) => x.length > 0)
|
||||||
: [];
|
: [];
|
||||||
if (!gids.length) {
|
if (!gids.length) {
|
||||||
return names.slice();
|
return [];
|
||||||
}
|
}
|
||||||
const zoneMacSet = new Set(
|
const zoneMacSet = new Set(
|
||||||
macs.map((m) => normalizeDeviceMac(m)).filter((m) => m.length === 12),
|
macs.map((m) => normalizeDeviceMac(m)).filter((m) => m.length === 12),
|
||||||
@@ -509,43 +508,15 @@ function effectiveZoneContentKind(zoneDoc) {
|
|||||||
return 'presets';
|
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} */
|
/** @returns {boolean} */
|
||||||
function zoneAllowsPresets(zoneDoc, zoneId) {
|
function zoneAllowsPresets(zoneDoc, zoneId) {
|
||||||
void zoneId;
|
void zoneId;
|
||||||
if (!zoneHasExplicitContentKind(zoneDoc)) return true;
|
|
||||||
return effectiveZoneContentKind(zoneDoc) === 'presets';
|
return effectiveZoneContentKind(zoneDoc) === 'presets';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @returns {boolean} */
|
/** @returns {boolean} */
|
||||||
function zoneAllowsSequences(zoneDoc, zoneId) {
|
function zoneAllowsSequences(zoneDoc, zoneId) {
|
||||||
void zoneId;
|
void zoneId;
|
||||||
if (!zoneHasExplicitContentKind(zoneDoc)) return true;
|
|
||||||
return effectiveZoneContentKind(zoneDoc) === 'sequences';
|
return effectiveZoneContentKind(zoneDoc) === 'sequences';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -623,7 +594,7 @@ function renderZonesList(tabs, tabOrder, currentZoneId) {
|
|||||||
for (const zoneId of tabOrder) {
|
for (const zoneId of tabOrder) {
|
||||||
const zone = tabs[zoneId];
|
const zone = tabs[zoneId];
|
||||||
if (zone) {
|
if (zone) {
|
||||||
const activeClass = zoneId === currentZoneId ? 'active' : '';
|
const activeClass = String(zoneId) === String(currentZoneId) ? 'active' : '';
|
||||||
const disp = zone.name || `Zone ${zoneId}`;
|
const disp = zone.name || `Zone ${zoneId}`;
|
||||||
html += `
|
html += `
|
||||||
<button class="zone-button ${activeClass}"
|
<button class="zone-button ${activeClass}"
|
||||||
@@ -1183,9 +1154,13 @@ async function openEditZoneModal(zoneId, zone) {
|
|||||||
renderZoneGroupsEditor(groupsEditor, window.__editTabGroupRows, groupsMap);
|
renderZoneGroupsEditor(groupsEditor, window.__editTabGroupRows, groupsMap);
|
||||||
|
|
||||||
const kind = effectiveZoneContentKind(tabData);
|
const kind = effectiveZoneContentKind(tabData);
|
||||||
document.querySelectorAll('input[name="edit-zone-content-kind"]').forEach((radio) => {
|
const typeLabel = document.getElementById('edit-zone-type-label');
|
||||||
radio.checked = radio.value === kind;
|
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");
|
if (modal) modal.classList.add("active");
|
||||||
applyZoneContentKindEditModal(kind);
|
applyZoneContentKindEditModal(kind);
|
||||||
@@ -1196,13 +1171,11 @@ async function openEditZoneModal(zoneId, zone) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update an existing zone (name, group list; devices come from groups only).
|
// 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 {
|
try {
|
||||||
const gids = Array.isArray(groupRows)
|
const gids = Array.isArray(groupRows)
|
||||||
? groupRows.map((r) => String(r.id || "").trim()).filter((x) => x.length > 0)
|
? groupRows.map((r) => String(r.id || "").trim()).filter((x) => x.length > 0)
|
||||||
: [];
|
: [];
|
||||||
const ck =
|
|
||||||
contentKind === 'sequences' || contentKind === 'presets' ? contentKind : 'presets';
|
|
||||||
let existing = {};
|
let existing = {};
|
||||||
try {
|
try {
|
||||||
const cur = await fetch(`/zones/${encodeURIComponent(zoneId)}`, {
|
const cur = await fetch(`/zones/${encodeURIComponent(zoneId)}`, {
|
||||||
@@ -1215,6 +1188,7 @@ async function updateZone(zoneId, name, groupRows, contentKind) {
|
|||||||
} catch (_) {
|
} catch (_) {
|
||||||
/* use empty existing */
|
/* use empty existing */
|
||||||
}
|
}
|
||||||
|
const lockedKind = effectiveZoneContentKind(existing);
|
||||||
const response = await fetch(`/zones/${zoneId}`, {
|
const response = await fetch(`/zones/${zoneId}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
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 && typeof existing.preset_group_ids === 'object'
|
||||||
? existing.preset_group_ids
|
? existing.preset_group_ids
|
||||||
: {},
|
: {},
|
||||||
content_kind: ck,
|
content_kind: lockedKind,
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1238,6 +1212,9 @@ async function updateZone(zoneId, name, groupRows, contentKind) {
|
|||||||
// Reload tabs list
|
// Reload tabs list
|
||||||
await loadZonesModal();
|
await loadZonesModal();
|
||||||
await loadZones();
|
await loadZones();
|
||||||
|
if (String(currentZoneId) === String(zoneId)) {
|
||||||
|
await loadZoneContent(zoneId);
|
||||||
|
}
|
||||||
// Close modal
|
// Close modal
|
||||||
document.getElementById('edit-zone-modal').classList.remove('active');
|
document.getElementById('edit-zone-modal').classList.remove('active');
|
||||||
return true;
|
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
|
// Set up edit zone form
|
||||||
const editZoneForm = document.getElementById('edit-zone-form');
|
const editZoneForm = document.getElementById('edit-zone-form');
|
||||||
if (editZoneForm) {
|
if (editZoneForm) {
|
||||||
@@ -1394,7 +1359,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const groupRows = window.__editTabGroupRows || [];
|
const groupRows = window.__editTabGroupRows || [];
|
||||||
|
|
||||||
if (zoneId && name) {
|
if (zoneId && name) {
|
||||||
await updateZone(zoneId, name, groupRows, editModalContentKindSelected());
|
await updateZone(zoneId, name, groupRows);
|
||||||
editZoneForm.reset();
|
editZoneForm.reset();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1441,6 +1406,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
window.selectZone = selectZone;
|
||||||
|
|
||||||
// Export for use in other scripts
|
// Export for use in other scripts
|
||||||
window.zonesManager = {
|
window.zonesManager = {
|
||||||
loadZones,
|
loadZones,
|
||||||
|
|||||||
@@ -119,10 +119,7 @@
|
|||||||
<input type="hidden" id="edit-zone-id">
|
<input type="hidden" id="edit-zone-id">
|
||||||
<label>Zone Name:</label>
|
<label>Zone Name:</label>
|
||||||
<input type="text" id="edit-zone-name" placeholder="Enter zone name" required>
|
<input type="text" id="edit-zone-name" placeholder="Enter zone name" required>
|
||||||
<div class="zone-content-kind-row muted-text">
|
<p id="edit-zone-type-label" class="zone-content-kind-row muted-text" aria-live="polite"></p>
|
||||||
<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>
|
|
||||||
<div id="edit-zone-block-groups">
|
<div id="edit-zone-block-groups">
|
||||||
<label class="zone-devices-label">Device groups on this zone</label>
|
<label class="zone-devices-label">Device groups on this zone</label>
|
||||||
<div id="edit-zone-groups-editor" class="zone-devices-editor"></div>
|
<div id="edit-zone-groups-editor" class="zone-devices-editor"></div>
|
||||||
|
|||||||
Reference in New Issue
Block a user