feat(ui): edit tab zones, audio readout, live reload
- Zones/presets/sequence strip and Pipfile dev command fix - Optional live reload and beat test audio asset + generator Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
94
src/main.py
94
src/main.py
@@ -2,6 +2,7 @@ import asyncio
|
||||
import errno
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import signal
|
||||
import socket
|
||||
import threading
|
||||
@@ -38,6 +39,11 @@ _tcp_device_lock = threading.Lock()
|
||||
DISCOVERY_UDP_PORT = 8766
|
||||
|
||||
|
||||
def _live_reload_enabled() -> bool:
|
||||
v = os.environ.get("LED_CONTROLLER_LIVE_RELOAD", "").strip().lower()
|
||||
return v not in ("", "0", "false", "no")
|
||||
|
||||
|
||||
def _register_udp_device_sync(
|
||||
device_name: str, peer_ip: str, mac, device_type=None
|
||||
) -> None:
|
||||
@@ -248,9 +254,22 @@ async def main(port=80):
|
||||
|
||||
app = Microdot()
|
||||
audio_detector = AudioBeatDetector()
|
||||
try:
|
||||
from util.audio_run_persist import coerce_audio_device, read_audio_run_state
|
||||
|
||||
persisted = read_audio_run_state()
|
||||
if persisted.get("enabled"):
|
||||
dev = coerce_audio_device(persisted.get("device"))
|
||||
audio_detector.start(device=dev)
|
||||
print("[startup] audio beat detector started from saved run state")
|
||||
except Exception as e:
|
||||
print(f"[startup] audio auto-start skipped: {e!r}")
|
||||
from util import beat_driver_route
|
||||
|
||||
beat_driver_route.set_beat_route_main_loop(asyncio.get_running_loop())
|
||||
from util import sequence_playback as seq_pb
|
||||
|
||||
seq_pb.ensure_beat_consumer_started()
|
||||
|
||||
# Initialize sessions with a secret key from settings
|
||||
secret_key = settings.get('session_secret_key', 'led-controller-secret-key-change-in-production')
|
||||
@@ -284,11 +303,42 @@ async def main(port=80):
|
||||
tcp_client_registry.set_settings(settings)
|
||||
tcp_client_registry.set_tcp_status_broadcaster(broadcast_device_tcp_status)
|
||||
|
||||
live_reload = _live_reload_enabled()
|
||||
dev_build_id = secrets.token_hex(12) if live_reload else None
|
||||
if live_reload:
|
||||
print(
|
||||
"[dev] LED_CONTROLLER_LIVE_RELOAD: browser refreshes when the server process restarts"
|
||||
)
|
||||
|
||||
if dev_build_id:
|
||||
|
||||
@app.route("/__dev/build-id")
|
||||
def dev_build_id_route(request):
|
||||
_ = request
|
||||
return (
|
||||
dev_build_id,
|
||||
200,
|
||||
{
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
)
|
||||
|
||||
# Serve index.html at root (cwd is src/ when run via pipenv run run)
|
||||
@app.route('/')
|
||||
@app.route("/")
|
||||
def index(request):
|
||||
"""Serve the main web UI."""
|
||||
return send_file('templates/index.html')
|
||||
if dev_build_id:
|
||||
try:
|
||||
with open("templates/index.html", encoding="utf-8") as f:
|
||||
html = f.read()
|
||||
tag = '<script src="/static/dev-live-reload.js" defer></script>'
|
||||
if "</body>" in html:
|
||||
html = html.replace("</body>", tag + "\n</body>", 1)
|
||||
return html, 200, {"Content-Type": "text/html; charset=utf-8"}
|
||||
except OSError:
|
||||
pass
|
||||
return send_file("templates/index.html")
|
||||
|
||||
# Favicon: avoid 404 in browser console (no file needed)
|
||||
@app.route('/favicon.ico')
|
||||
@@ -319,6 +369,9 @@ async def main(port=80):
|
||||
pass
|
||||
try:
|
||||
audio_detector.start(device=device)
|
||||
from util.audio_run_persist import write_audio_run_state
|
||||
|
||||
write_audio_run_state(enabled=True, device=device)
|
||||
return {"ok": True, "status": audio_detector.status()}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}, 500
|
||||
@@ -327,12 +380,47 @@ async def main(port=80):
|
||||
async def audio_stop(request):
|
||||
_ = request
|
||||
audio_detector.stop()
|
||||
from util.audio_run_persist import write_audio_run_state
|
||||
|
||||
write_audio_run_state(enabled=False)
|
||||
return {"ok": True, "status": audio_detector.status()}
|
||||
|
||||
@app.route('/api/audio/status')
|
||||
async def audio_status(request):
|
||||
_ = request
|
||||
return {"status": audio_detector.status()}
|
||||
from util import beat_driver_route
|
||||
from util import sequence_playback
|
||||
|
||||
st = audio_detector.status()
|
||||
st["sequence"] = sequence_playback.playback_status()
|
||||
st["manual_beat_stride"] = beat_driver_route.manual_beat_stride_status()
|
||||
seq = st.get("sequence")
|
||||
beat_readout = ""
|
||||
if isinstance(seq, dict) and str(seq.get("beat_readout") or "").strip():
|
||||
beat_readout = str(seq.get("beat_readout") or "").strip()
|
||||
elif st.get("running"):
|
||||
mb = st.get("manual_beat_stride")
|
||||
if isinstance(mb, dict) and mb.get("active"):
|
||||
try:
|
||||
n = int(mb.get("stride_n") or 1)
|
||||
except (TypeError, ValueError):
|
||||
n = 1
|
||||
n = max(1, min(64, n))
|
||||
try:
|
||||
bi = int(mb.get("beat_in_stride") or 1)
|
||||
except (TypeError, ValueError):
|
||||
bi = 1
|
||||
pos = min(n, max(1, bi))
|
||||
beat_readout = f"{pos}/{n}"
|
||||
else:
|
||||
try:
|
||||
bs = int(st.get("beat_seq") or 0)
|
||||
except (TypeError, ValueError):
|
||||
bs = 0
|
||||
if bs > 0:
|
||||
beat_readout = str(bs)
|
||||
st["beat_readout"] = beat_readout
|
||||
return {"status": st}
|
||||
|
||||
# Static file route
|
||||
@app.route("/static/<path:path>")
|
||||
|
||||
@@ -36,6 +36,9 @@ class Zone(Model):
|
||||
if "group_ids" not in doc:
|
||||
doc["group_ids"] = []
|
||||
changed = True
|
||||
if "preset_group_ids" not in doc or not isinstance(doc.get("preset_group_ids"), dict):
|
||||
doc["preset_group_ids"] = {}
|
||||
changed = True
|
||||
if changed:
|
||||
self.save()
|
||||
|
||||
@@ -48,6 +51,7 @@ class Zone(Model):
|
||||
"name": name,
|
||||
"names": names if names else [],
|
||||
"group_ids": gid_list,
|
||||
"preset_group_ids": {},
|
||||
"presets": presets if presets else [],
|
||||
"default_preset": None,
|
||||
"brightness": 255,
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
(() => {
|
||||
let pollTimer = null;
|
||||
let lastBeatSeq = 0;
|
||||
let lastLoggedSequenceBeatFractions = "";
|
||||
/** Prior poll had server zone sequence playback active (`status.sequence.active === true`). */
|
||||
let prevZoneSequencePlaybackActive = false;
|
||||
/**
|
||||
* After sequence playback ends/stops while audio keeps running, keep header # idle until the
|
||||
* next beat bumps `beat_seq` (avoids the stuck final cumulative value vs sequence readout).
|
||||
*/
|
||||
let headerBeatStickyIdleAfterSeq = false;
|
||||
/** Suppresses duplicate `console.log` when the same `beat_seq` + server `beat_readout` repeats. */
|
||||
let lastBeatConsoleKey = "";
|
||||
/** @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() {
|
||||
@@ -48,6 +61,45 @@
|
||||
return document.getElementById(id);
|
||||
}
|
||||
|
||||
/** @param {Record<string, unknown>} status */
|
||||
function updateBeatReadoutDisplays(status) {
|
||||
const text = String((status && status.beat_readout) || "").trim();
|
||||
for (const id of ["audio-top-beat-readout", "audio-modal-beat-readout"]) {
|
||||
const n = el(id);
|
||||
if (n) n.textContent = text;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* On each new audio `beat_seq`, log server `beat_readout` once (deduped when poll repeats the
|
||||
* same `beat_seq` + line).
|
||||
* @param {Record<string, unknown>} status
|
||||
*/
|
||||
function logServerBeatConsoleOnPollEdge(status) {
|
||||
const beatSeq = Number((status && status.beat_seq) || 0);
|
||||
const line = String((status && status.beat_readout) || "").trim();
|
||||
const key = `${beatSeq}\t${line}`;
|
||||
if (key !== lastBeatConsoleKey) {
|
||||
lastBeatConsoleKey = key;
|
||||
if (!line) return;
|
||||
const seq = /** @type {Record<string, unknown>|undefined} */ (status && status.sequence);
|
||||
const seqBeats =
|
||||
!!seq &&
|
||||
!!seq.active &&
|
||||
String(seq.advance_mode || "").toLowerCase() === "beats";
|
||||
let out = line;
|
||||
if (seqBeats) {
|
||||
const nLanes = Number(seq && seq.num_lanes);
|
||||
const lanesNote =
|
||||
Number.isFinite(nLanes) && nLanes > 1
|
||||
? `lane 1 of ${nLanes} (readout is for this lane only)`
|
||||
: "lane 1";
|
||||
out = `${line} — ${lanesNote}`;
|
||||
}
|
||||
console.log(out);
|
||||
}
|
||||
}
|
||||
|
||||
function updateBpmDisplay(bpm) {
|
||||
const node = el("audio-bpm-value");
|
||||
if (!node) return;
|
||||
@@ -58,11 +110,45 @@
|
||||
}
|
||||
}
|
||||
|
||||
function updateBeatCounter(seq) {
|
||||
const topNode = el("audio-top-beat-count");
|
||||
if (!topNode) return;
|
||||
const n = Number(seq);
|
||||
topNode.textContent = Number.isFinite(n) && n >= 0 ? `#${Math.floor(n)}` : "#0";
|
||||
/** Zone sequence playback (server); only when `active === true` is beat X/Y meaningful. */
|
||||
function sequencePlaybackActiveFromStatus(status) {
|
||||
const seq = /** @type {Record<string, unknown>|undefined} */ (
|
||||
status && status.sequence
|
||||
);
|
||||
return !!(seq && seq.active);
|
||||
}
|
||||
|
||||
/** Build sequence beat fractions for debug logging (browser console only). */
|
||||
function formatSequenceBeatFractionsForLog(status) {
|
||||
const seq = /** @type {Record<string, unknown>|undefined} */ (status && status.sequence);
|
||||
if (!seq || !seq.active) return null;
|
||||
if (seq.advance_mode !== "beats") return null;
|
||||
|
||||
const laneBeatAt = Number(seq.lane0_beat_in_step);
|
||||
const laneBeatsPerStep = Number(seq.lane0_beats_per_step);
|
||||
if (
|
||||
!Number.isFinite(laneBeatAt) ||
|
||||
laneBeatAt <= 0 ||
|
||||
!Number.isFinite(laneBeatsPerStep) ||
|
||||
laneBeatsPerStep <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const presetFraction = `${Math.floor(laneBeatAt)}/${Math.floor(laneBeatsPerStep)}`;
|
||||
|
||||
const sequenceBeatAt = Number(seq.sequence_beat_at);
|
||||
const sequenceBeatsPerPass = Number(seq.sequence_beats_per_pass);
|
||||
if (
|
||||
!Number.isFinite(sequenceBeatAt) ||
|
||||
sequenceBeatAt <= 0 ||
|
||||
!Number.isFinite(sequenceBeatsPerPass) ||
|
||||
sequenceBeatsPerPass <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const sequenceFraction = `${Math.floor(sequenceBeatAt)}/${Math.floor(sequenceBeatsPerPass)}`;
|
||||
|
||||
return `${presetFraction} ${sequenceFraction}`;
|
||||
}
|
||||
|
||||
function updateHitTypeDisplay(hitType, confidence) {
|
||||
@@ -91,15 +177,67 @@
|
||||
}
|
||||
}
|
||||
|
||||
function clearBeatPhaseTimers() {
|
||||
pendingBeatPhaseTimers.forEach((t) => clearTimeout(t));
|
||||
pendingBeatPhaseTimers.clear();
|
||||
}
|
||||
|
||||
function getBeatPhaseDelayMs() {
|
||||
const inp = el("audio-beat-phase-ms");
|
||||
if (inp && String(inp.value).trim() !== "") {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
function persistBeatPhaseMs() {
|
||||
try {
|
||||
localStorage.setItem(PHASE_MS_KEY, String(getBeatPhaseDelayMs()));
|
||||
} catch (e) {
|
||||
console.warn("beat phase ms save failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleBeatPhaseFire(seq, delayMs) {
|
||||
let tid = null;
|
||||
const run = () => {
|
||||
if (tid != null) pendingBeatPhaseTimers.delete(tid);
|
||||
flashBeat();
|
||||
try {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("ledControllerAudioBeat", { detail: { beatSeq: seq } }),
|
||||
);
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
if (delayMs <= 0) {
|
||||
run();
|
||||
return;
|
||||
}
|
||||
tid = setTimeout(run, delayMs);
|
||||
pendingBeatPhaseTimers.add(tid);
|
||||
}
|
||||
|
||||
/** Stop detector and polling; does not clear “resume on load” prefs (used before restart). */
|
||||
async function stopAudioOnly() {
|
||||
setTopBpmVisible(false);
|
||||
clearBeatPhaseTimers();
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
lastBeatSeq = 0;
|
||||
updateBeatCounter(0);
|
||||
prevZoneSequencePlaybackActive = false;
|
||||
headerBeatStickyIdleAfterSeq = false;
|
||||
lastBeatConsoleKey = "";
|
||||
updateBeatReadoutDisplays({});
|
||||
try {
|
||||
await fetch("/api/audio/stop", { method: "POST" });
|
||||
} catch (e) {
|
||||
@@ -115,7 +253,7 @@
|
||||
|
||||
async function pollStatus() {
|
||||
try {
|
||||
const res = await fetch("/api/audio/status");
|
||||
const res = await fetch("/api/audio/status", { cache: "no-store" });
|
||||
const data = await res.json();
|
||||
const status = data?.status || {};
|
||||
if (status.error && String(status.error).trim()) {
|
||||
@@ -123,6 +261,7 @@
|
||||
if (node) {
|
||||
node.textContent = String(status.error).trim().slice(0, 120);
|
||||
}
|
||||
updateBeatReadoutDisplays({});
|
||||
updateBpmDisplay(null);
|
||||
setTopBpmVisible(!!status.running);
|
||||
if (!status.running && pollTimer) {
|
||||
@@ -134,12 +273,46 @@
|
||||
setTopBpmVisible(!!status.running);
|
||||
updateBpmDisplay(status.bpm);
|
||||
updateHitTypeDisplay(status.beat_type, Number(status.beat_type_confidence));
|
||||
const seq = Number(status.beat_seq || 0);
|
||||
updateBeatCounter(seq);
|
||||
if (seq > lastBeatSeq) {
|
||||
lastBeatSeq = seq;
|
||||
flashBeat();
|
||||
/*
|
||||
* `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;
|
||||
if (startedSeq) {
|
||||
headerBeatStickyIdleAfterSeq = false;
|
||||
lastLoggedSequenceBeatFractions = "";
|
||||
}
|
||||
if (endedSeq) {
|
||||
headerBeatStickyIdleAfterSeq = true;
|
||||
clearBeatPhaseTimers();
|
||||
lastBeatSeq = beatSeq;
|
||||
}
|
||||
if (!zoneSeqActive && headerBeatStickyIdleAfterSeq) {
|
||||
if (beatSeq > lastBeatSeq) {
|
||||
lastBeatSeq = beatSeq;
|
||||
logServerBeatConsoleOnPollEdge(status);
|
||||
scheduleBeatPhaseFire(beatSeq, getBeatPhaseDelayMs());
|
||||
headerBeatStickyIdleAfterSeq = false;
|
||||
}
|
||||
} else if (beatSeq > lastBeatSeq) {
|
||||
lastBeatSeq = beatSeq;
|
||||
logServerBeatConsoleOnPollEdge(status);
|
||||
scheduleBeatPhaseFire(beatSeq, getBeatPhaseDelayMs());
|
||||
}
|
||||
const beatFractions = formatSequenceBeatFractionsForLog(status);
|
||||
if (beatFractions) {
|
||||
if (beatFractions !== lastLoggedSequenceBeatFractions) {
|
||||
lastLoggedSequenceBeatFractions = beatFractions;
|
||||
}
|
||||
} else {
|
||||
lastLoggedSequenceBeatFractions = "";
|
||||
}
|
||||
updateBeatReadoutDisplays(status);
|
||||
} catch (e) {
|
||||
console.warn("audio status poll failed", e);
|
||||
}
|
||||
@@ -164,7 +337,6 @@
|
||||
writeRestorePrefs(override, selected);
|
||||
updateBpmDisplay(null);
|
||||
updateHitTypeDisplay("unknown", NaN);
|
||||
updateBeatCounter(0);
|
||||
pollTimer = setInterval(pollStatus, 250);
|
||||
await pollStatus();
|
||||
}
|
||||
@@ -252,17 +424,30 @@
|
||||
});
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
async function resumePollingIfDetectorRunning() {
|
||||
try {
|
||||
const res = await fetch("/api/audio/status");
|
||||
const res = await fetch("/api/audio/status", { cache: "no-store" });
|
||||
const data = await res.json();
|
||||
const status = data?.status || {};
|
||||
if (status.running && !pollTimer) {
|
||||
pollTimer = setInterval(pollStatus, 250);
|
||||
lastBeatSeq = Number(status.beat_seq || 0);
|
||||
updateBeatCounter(lastBeatSeq);
|
||||
prevZoneSequencePlaybackActive = sequencePlaybackActiveFromStatus(status);
|
||||
await pollStatus();
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -270,8 +455,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreAudioIfNeeded() {
|
||||
if (pollTimer) return;
|
||||
/**
|
||||
* 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");
|
||||
@@ -280,20 +468,14 @@
|
||||
try {
|
||||
await refreshDevices();
|
||||
} catch (e) {
|
||||
console.warn("audio restore refresh devices failed", e);
|
||||
console.warn("audio device list refresh failed", e);
|
||||
}
|
||||
if (sel && prefs.select) sel.value = prefs.select;
|
||||
try {
|
||||
await startAudio();
|
||||
} catch (e) {
|
||||
console.warn("audio auto-restart failed", e);
|
||||
clearRestorePrefs();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
bind();
|
||||
await resumePollingIfDetectorRunning();
|
||||
await restoreAudioIfNeeded();
|
||||
await applySavedAudioDeviceFormOnly();
|
||||
});
|
||||
})();
|
||||
|
||||
25
src/static/dev-live-reload.js
Normal file
25
src/static/dev-live-reload.js
Normal file
@@ -0,0 +1,25 @@
|
||||
/* Polls server build id; full reload when watchfiles restarts Python (new process = new id). */
|
||||
(function () {
|
||||
var prev = null;
|
||||
function tick() {
|
||||
fetch('/__dev/build-id', { cache: 'no-store', credentials: 'same-origin' })
|
||||
.then(function (r) {
|
||||
return r.ok ? r.text() : '';
|
||||
})
|
||||
.then(function (id) {
|
||||
id = (id || '').trim();
|
||||
if (!id) return;
|
||||
if (prev === null) {
|
||||
prev = id;
|
||||
return;
|
||||
}
|
||||
if (id !== prev) {
|
||||
prev = id;
|
||||
window.location.reload();
|
||||
}
|
||||
})
|
||||
.catch(function () {});
|
||||
}
|
||||
setInterval(tick, 750);
|
||||
tick();
|
||||
})();
|
||||
@@ -29,6 +29,9 @@ const filterPresetsForCurrentProfile = async (presetsObj) => {
|
||||
}),
|
||||
);
|
||||
};
|
||||
try {
|
||||
window.filterPresetsForCurrentProfile = filterPresetsForCurrentProfile;
|
||||
} catch (e) {}
|
||||
|
||||
const getCurrentProfileData = async () => {
|
||||
try {
|
||||
@@ -154,7 +157,44 @@ function tabDeviceNamesFromSection(section) {
|
||||
: [];
|
||||
}
|
||||
|
||||
async function postDriverSequence(sequence, targetMacs, delayS) {
|
||||
/** Device names for ``presetId`` on the current zone tab (per-preset groups or zone default). */
|
||||
async function deviceNamesForPresetOnCurrentZone(presetId) {
|
||||
const section = document.querySelector('.presets-section[data-zone-id]');
|
||||
const fallback = tabDeviceNamesFromSection(section);
|
||||
if (!section || !presetId) return fallback;
|
||||
const zm = window.zonesManager;
|
||||
if (!zm || typeof zm.resolveDeviceNamesForZonePreset !== 'function') return fallback;
|
||||
const zoneId = section.dataset.zoneId;
|
||||
try {
|
||||
const res = await fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' } });
|
||||
if (!res.ok) return fallback;
|
||||
const zd = await res.json();
|
||||
const names = await zm.resolveDeviceNamesForZonePreset(zd, String(presetId));
|
||||
return names.length ? names : fallback;
|
||||
} catch (_) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function formatPresetTargetGroupsLine(zoneDoc, presetId, groupsMap) {
|
||||
const zm = window.zonesManager;
|
||||
const gids =
|
||||
zm && typeof zm.effectiveGroupIdsForZonePreset === 'function'
|
||||
? zm.effectiveGroupIdsForZonePreset(zoneDoc, presetId)
|
||||
: Array.isArray(zoneDoc && zoneDoc.group_ids)
|
||||
? zoneDoc.group_ids.map((x) => String(x).trim()).filter((x) => x.length > 0)
|
||||
: [];
|
||||
const parts = (gids || [])
|
||||
.map((id) => {
|
||||
const g = groupsMap && groupsMap[id];
|
||||
const gn = g && g.name ? String(g.name).trim() : '';
|
||||
return gn;
|
||||
})
|
||||
.filter(Boolean);
|
||||
return parts.length ? parts.join(', ') : '';
|
||||
}
|
||||
|
||||
async function postDriverSequence(sequence, targetMacs, delayS, pushOptions) {
|
||||
const body = {
|
||||
sequence,
|
||||
targets: Array.isArray(targetMacs) && targetMacs.length ? [...new Set(targetMacs)] : undefined,
|
||||
@@ -1169,7 +1209,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
// Create modal
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'modal active';
|
||||
modal.className = 'modal active modal-child-overlay';
|
||||
modal.id = 'add-preset-to-zone-modal';
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content">
|
||||
@@ -1284,7 +1324,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const newGrid = arrayToGrid(flat, 3);
|
||||
tabData.presets = newGrid;
|
||||
tabData.presets_flat = flat;
|
||||
|
||||
if (!tabData.preset_group_ids || typeof tabData.preset_group_ids !== 'object') {
|
||||
tabData.preset_group_ids = {};
|
||||
}
|
||||
|
||||
// Update zone
|
||||
const updateResponse = await fetch(`/zones/${zoneId}`, {
|
||||
method: 'PUT',
|
||||
@@ -1378,7 +1421,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'modal active';
|
||||
modal.className = 'modal active modal-child-overlay';
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content">
|
||||
<h2>Pick Palette Color</h2>
|
||||
@@ -1449,12 +1492,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
alert('Preset name is required to send.');
|
||||
return;
|
||||
}
|
||||
// Send current editor values and then select on all devices in the current zone (if any)
|
||||
const section = document.querySelector('.presets-section[data-zone-id]');
|
||||
const deviceNames = tabDeviceNamesFromSection(section);
|
||||
// Work out the preset ID: for existing presets use currentEditId, otherwise fall back to name
|
||||
// Send current editor values to zone devices (if any); never persist on device.
|
||||
const presetId = currentEditId || payload.name;
|
||||
// Try sends preset first, then select; never persist on device.
|
||||
const deviceNames = await deviceNamesForPresetOnCurrentZone(presetId);
|
||||
// Auto: load + immediate select. Manual: load only; first advance on the next audio beat.
|
||||
await sendPresetViaEspNow(presetId, payload, deviceNames, false, false, '2');
|
||||
});
|
||||
}
|
||||
@@ -1466,9 +1507,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
alert('Preset name is required.');
|
||||
return;
|
||||
}
|
||||
const section = document.querySelector('.presets-section[data-zone-id]');
|
||||
const deviceNames = tabDeviceNamesFromSection(section);
|
||||
const presetId = currentEditId || payload.name;
|
||||
const deviceNames = await deviceNamesForPresetOnCurrentZone(presetId);
|
||||
await sendPresetViaEspNow(presetId, payload, deviceNames, true, true, '1');
|
||||
await updateTabDefaultPreset(presetId);
|
||||
await sendDefaultPreset('1', deviceNames);
|
||||
@@ -1503,9 +1543,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
throw new Error('Failed to save preset');
|
||||
}
|
||||
|
||||
// Same device targeting as Try: zone tab supplies names and selection without persistence.
|
||||
const section = document.querySelector('.presets-section[data-zone-id]');
|
||||
const deviceNames = tabDeviceNamesFromSection(section);
|
||||
// Same device targeting as Try: per-preset zone groups when in a zone tab.
|
||||
const presetIdForSend = currentEditId || payload.name;
|
||||
const deviceNames = await deviceNamesForPresetOnCurrentZone(presetIdForSend);
|
||||
|
||||
// Use saved preset from server response for sending
|
||||
const saved = await response.json().catch(() => null);
|
||||
@@ -1644,6 +1684,7 @@ const sendPresetViaEspNow = async (
|
||||
saveToDevice = true,
|
||||
setDefault = false,
|
||||
devicePresetId = null,
|
||||
pushOptions = null,
|
||||
) => {
|
||||
try {
|
||||
const baseColors = Array.isArray(preset.colors) && preset.colors.length
|
||||
@@ -1707,7 +1748,7 @@ const sendPresetViaEspNow = async (
|
||||
}
|
||||
}
|
||||
|
||||
await postDriverSequence(sequence, targetMacs, 0.05);
|
||||
await postDriverSequence(sequence, targetMacs, 0.05, pushOptions);
|
||||
} catch (error) {
|
||||
console.error('Failed to send preset to devices:', error);
|
||||
alert('Failed to send preset to devices.');
|
||||
@@ -1776,6 +1817,48 @@ try {
|
||||
// window may not exist in some environments; ignore.
|
||||
}
|
||||
|
||||
// Store selected preset(s) per zone (multi-select; merge send order = click order, last wins on device).
|
||||
const zoneSelectedPresetIds = {};
|
||||
const zonePresetSelectionOrder = {};
|
||||
|
||||
function ensureZonePresetSelection(zoneId) {
|
||||
const z = String(zoneId);
|
||||
if (!zoneSelectedPresetIds[z]) zoneSelectedPresetIds[z] = new Set();
|
||||
if (!zonePresetSelectionOrder[z]) zonePresetSelectionOrder[z] = [];
|
||||
}
|
||||
|
||||
function pruneZonePresetSelection(zoneId, validIdSet) {
|
||||
const z = String(zoneId);
|
||||
ensureZonePresetSelection(z);
|
||||
const set = zoneSelectedPresetIds[z];
|
||||
for (const id of [...set]) {
|
||||
if (!validIdSet.has(String(id))) set.delete(id);
|
||||
}
|
||||
zonePresetSelectionOrder[z] = (zonePresetSelectionOrder[z] || []).filter((id) => set.has(String(id)));
|
||||
}
|
||||
|
||||
function getOrderedZonePresetSelection(zoneId) {
|
||||
const z = String(zoneId);
|
||||
ensureZonePresetSelection(z);
|
||||
const set = zoneSelectedPresetIds[z];
|
||||
return (zonePresetSelectionOrder[z] || []).filter((id) => set.has(String(id)));
|
||||
}
|
||||
|
||||
async function sendMergedZonePresetSelection(zoneId, tabData, allPresets) {
|
||||
const ids = getOrderedZonePresetSelection(zoneId);
|
||||
if (!ids.length) return;
|
||||
for (let i = 0; i < ids.length; i += 1) {
|
||||
const pid = ids[i];
|
||||
const preset = allPresets[pid];
|
||||
if (!preset) continue;
|
||||
const names =
|
||||
window.zonesManager && typeof window.zonesManager.resolveDeviceNamesForZonePreset === 'function'
|
||||
? await window.zonesManager.resolveDeviceNamesForZonePreset(tabData, pid)
|
||||
: [];
|
||||
await sendPresetViaEspNow(pid, preset, names, false, false, '2');
|
||||
}
|
||||
}
|
||||
|
||||
// Store selected preset per zone
|
||||
const selectedPresets = {};
|
||||
// Store selected preset payload per zone for beat-trigger reliability.
|
||||
@@ -1920,19 +2003,37 @@ const insertDraggingOntoTarget = (presetsList, dragging, dropTarget) => {
|
||||
};
|
||||
|
||||
// Function to render presets for a specific zone in 2D grid
|
||||
const renderTabPresets = async (zoneId) => {
|
||||
/**
|
||||
* @param {string} zoneId
|
||||
* @param {{ stopSequencePlayback?: boolean }} [options] - pass `{ stopSequencePlayback: true }` only when
|
||||
* the UI action should stop server zone sequence playback (default: do not POST /sequences/stop).
|
||||
*/
|
||||
const renderTabPresets = async (zoneId, options = {}) => {
|
||||
const presetsList = document.getElementById('presets-list-zone');
|
||||
if (!presetsList) return;
|
||||
|
||||
|
||||
const stopSeq = options.stopSequencePlayback === true;
|
||||
if (stopSeq && typeof window.stopZoneSequencePlayback === 'function') {
|
||||
// Pass false: an earlier render's stop() can finish after this pass rebuilds the DOM and
|
||||
// would otherwise clear .active from new sequence tiles (breaks edit/run selection).
|
||||
await window.stopZoneSequencePlayback(false);
|
||||
}
|
||||
|
||||
try {
|
||||
// Get zone data to see which presets are associated
|
||||
const tabResponse = await fetch(`/zones/${zoneId}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
const [tabResponse, groupsStripRes, presetsResponse] = await Promise.all([
|
||||
fetch(`/zones/${zoneId}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
}),
|
||||
fetch('/groups', { headers: { Accept: 'application/json' } }),
|
||||
fetch('/presets', {
|
||||
headers: { Accept: 'application/json' },
|
||||
}),
|
||||
]);
|
||||
if (!tabResponse.ok) {
|
||||
throw new Error('Failed to load zone');
|
||||
}
|
||||
const tabData = await tabResponse.json();
|
||||
const groupsMapStrip = groupsStripRes.ok ? await groupsStripRes.json() : {};
|
||||
|
||||
// Get presets - support both 2D grid and flat array (for backward compatibility)
|
||||
let presetGrid = tabData.presets;
|
||||
@@ -1945,10 +2046,6 @@ const renderTabPresets = async (zoneId) => {
|
||||
presetGrid = arrayToGrid(presetGrid, 3);
|
||||
}
|
||||
|
||||
// Get all presets
|
||||
const presetsResponse = await fetch('/presets', {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!presetsResponse.ok) {
|
||||
throw new Error('Failed to load presets');
|
||||
}
|
||||
@@ -2021,13 +2118,10 @@ const renderTabPresets = async (zoneId) => {
|
||||
});
|
||||
}
|
||||
|
||||
// Get the currently selected preset for this zone
|
||||
const selectedPresetId = selectedPresets[zoneId];
|
||||
|
||||
// Render presets in grid layout
|
||||
// Flatten the grid and render all presets (grid CSS will handle layout)
|
||||
const flatPresets = presetGrid.flat().filter(id => id);
|
||||
|
||||
const validIdSet = new Set(flatPresets.map((id) => String(id)));
|
||||
pruneZonePresetSelection(zoneId, validIdSet);
|
||||
|
||||
if (flatPresets.length === 0) {
|
||||
// Show empty message if this zone has no presets
|
||||
const empty = document.createElement('p');
|
||||
@@ -2039,23 +2133,36 @@ const renderTabPresets = async (zoneId) => {
|
||||
flatPresets.forEach((presetId) => {
|
||||
const preset = allPresets[presetId];
|
||||
if (preset) {
|
||||
const isSelected = presetId === selectedPresetId;
|
||||
ensureZonePresetSelection(zoneId);
|
||||
const isSelected = zoneSelectedPresetIds[String(zoneId)].has(String(presetId));
|
||||
const displayPreset = {
|
||||
...preset,
|
||||
colors: resolveColorsWithPaletteRefs(preset.colors, preset.palette_refs, paletteColors),
|
||||
};
|
||||
const wrapper = createPresetButton(presetId, displayPreset, zoneId, isSelected);
|
||||
const wrapper = createPresetButton(
|
||||
presetId,
|
||||
displayPreset,
|
||||
zoneId,
|
||||
isSelected,
|
||||
tabData,
|
||||
groupsMapStrip,
|
||||
allPresets,
|
||||
);
|
||||
presetsList.appendChild(wrapper);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof window.appendZoneSequenceTiles === 'function') {
|
||||
await window.appendZoneSequenceTiles(zoneId, tabData, allPresets, paletteColors, presetsList);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to render zone presets:', error);
|
||||
presetsList.innerHTML = '<p class="muted-text">Failed to load presets.</p>';
|
||||
}
|
||||
};
|
||||
|
||||
const createPresetButton = (presetId, preset, zoneId, isSelected = false) => {
|
||||
const createPresetButton = (presetId, preset, zoneId, isSelected, tabData, groupsMap, allPresets) => {
|
||||
const uiMode = getPresetUiMode();
|
||||
|
||||
const row = document.createElement('div');
|
||||
@@ -2069,7 +2176,6 @@ const createPresetButton = (presetId, preset, zoneId, isSelected = false) => {
|
||||
button.className = 'pattern-button preset-tile-main';
|
||||
if (isSelected) {
|
||||
button.classList.add('active');
|
||||
selectedPresetPayloads[zoneId] = preset;
|
||||
}
|
||||
|
||||
const colors = Array.isArray(preset.colors) ? preset.colors.filter((c) => c) : [];
|
||||
@@ -2093,6 +2199,14 @@ const createPresetButton = (presetId, preset, zoneId, isSelected = false) => {
|
||||
presetNameLabel.className = 'pattern-button-label';
|
||||
button.appendChild(presetNameLabel);
|
||||
|
||||
const groupsText = formatPresetTargetGroupsLine(tabData || {}, presetId, groupsMap || {});
|
||||
if (groupsText) {
|
||||
const groupsSpan = document.createElement('span');
|
||||
groupsSpan.className = 'preset-tile-groups';
|
||||
groupsSpan.textContent = groupsText;
|
||||
button.appendChild(groupsSpan);
|
||||
}
|
||||
|
||||
const bgSwatch = document.createElement('span');
|
||||
const bgColor = coercePresetBackground(preset);
|
||||
bgSwatch.title = `Background: ${bgColor}`;
|
||||
@@ -2111,7 +2225,7 @@ const createPresetButton = (presetId, preset, zoneId, isSelected = false) => {
|
||||
`;
|
||||
button.appendChild(bgSwatch);
|
||||
|
||||
const isManualPreset = preset && typeof preset.auto === 'boolean' ? !preset.auto : false;
|
||||
const isManualPreset = preset && !coercePresetAuto(preset);
|
||||
if (isManualPreset) {
|
||||
const manualBadge = document.createElement('span');
|
||||
manualBadge.textContent = '1';
|
||||
@@ -2138,18 +2252,42 @@ const createPresetButton = (presetId, preset, zoneId, isSelected = false) => {
|
||||
|
||||
button.addEventListener('click', () => {
|
||||
if (isDraggingPreset) return;
|
||||
const presetsListEl = document.getElementById('presets-list-zone');
|
||||
if (presetsListEl) {
|
||||
presetsListEl.querySelectorAll('.pattern-button').forEach((btn) => btn.classList.remove('active'));
|
||||
console.info('Preset button pressed', { zoneId, presetId, name: (preset && preset.name) || presetId });
|
||||
if (typeof window.stopZoneSequencePlayback === 'function') {
|
||||
window.stopZoneSequencePlayback();
|
||||
}
|
||||
button.classList.add('active');
|
||||
selectedPresets[zoneId] = presetId;
|
||||
selectedPresetPayloads[zoneId] = preset;
|
||||
const section = row.closest('.presets-section');
|
||||
const deviceNames = tabDeviceNamesFromSection(section);
|
||||
sendPresetViaEspNow(presetId, preset, deviceNames, false, false, '2').catch((err) => {
|
||||
console.error(err);
|
||||
});
|
||||
const presetsListEl = document.getElementById('presets-list-zone');
|
||||
ensureZonePresetSelection(zoneId);
|
||||
const z = String(zoneId);
|
||||
const set = zoneSelectedPresetIds[z];
|
||||
const order = zonePresetSelectionOrder[z];
|
||||
const idStr = String(presetId);
|
||||
if (set.has(idStr)) {
|
||||
set.delete(idStr);
|
||||
zonePresetSelectionOrder[z] = order.filter((x) => String(x) !== idStr);
|
||||
} else {
|
||||
set.add(idStr);
|
||||
order.push(idStr);
|
||||
}
|
||||
if (presetsListEl) {
|
||||
presetsListEl.querySelectorAll('.preset-tile-row:not(.sequence-tile-row)').forEach((rw) => {
|
||||
const pid = rw.dataset.presetId;
|
||||
const btnEl = rw.querySelector('.preset-tile-main');
|
||||
if (!btnEl || !pid) return;
|
||||
if (set.has(String(pid))) btnEl.classList.add('active');
|
||||
else btnEl.classList.remove('active');
|
||||
});
|
||||
}
|
||||
const orderList = getOrderedZonePresetSelection(zoneId);
|
||||
if (orderList.length) {
|
||||
const lastPid = orderList[orderList.length - 1];
|
||||
selectedPresets[zoneId] = lastPid;
|
||||
selectedPresetPayloads[zoneId] = (allPresets && allPresets[lastPid]) || preset;
|
||||
} else {
|
||||
delete selectedPresets[zoneId];
|
||||
delete selectedPresetPayloads[zoneId];
|
||||
}
|
||||
void sendMergedZonePresetSelection(zoneId, tabData, allPresets);
|
||||
});
|
||||
|
||||
if (canDrag) {
|
||||
@@ -2173,7 +2311,9 @@ const createPresetButton = (presetId, preset, zoneId, isSelected = false) => {
|
||||
});
|
||||
}
|
||||
|
||||
row.appendChild(button);
|
||||
const top = document.createElement('div');
|
||||
top.className = 'preset-tile-row-top';
|
||||
top.appendChild(button);
|
||||
|
||||
if (uiMode === 'edit') {
|
||||
const actions = document.createElement('div');
|
||||
@@ -2192,9 +2332,11 @@ const createPresetButton = (presetId, preset, zoneId, isSelected = false) => {
|
||||
});
|
||||
|
||||
actions.appendChild(editBtn);
|
||||
row.appendChild(actions);
|
||||
top.appendChild(actions);
|
||||
}
|
||||
|
||||
row.appendChild(top);
|
||||
|
||||
return row;
|
||||
};
|
||||
|
||||
@@ -2279,6 +2421,12 @@ const removePresetFromTab = async (zoneId, presetId) => {
|
||||
tabData.presets = newGrid;
|
||||
tabData.presets_flat = flat;
|
||||
|
||||
if (tabData.preset_group_ids && typeof tabData.preset_group_ids === 'object') {
|
||||
const pg = { ...tabData.preset_group_ids };
|
||||
delete pg[String(presetId)];
|
||||
tabData.preset_group_ids = pg;
|
||||
}
|
||||
|
||||
const updateResponse = await fetch(`/zones/${zoneId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -2297,6 +2445,10 @@ const removePresetFromTab = async (zoneId, presetId) => {
|
||||
try {
|
||||
window.removePresetFromTab = removePresetFromTab;
|
||||
} catch (e) {}
|
||||
try {
|
||||
window.renderTabPresets = renderTabPresets;
|
||||
window.getPresetUiMode = getPresetUiMode;
|
||||
} catch (e) {}
|
||||
|
||||
// Listen for HTMX swaps to render presets
|
||||
document.body.addEventListener('htmx:afterSwap', (event) => {
|
||||
@@ -2327,10 +2479,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
const mainMenu = document.getElementById('main-menu-dropdown');
|
||||
if (mainMenu) mainMenu.classList.remove('open');
|
||||
const leftPanel = document.querySelector('.presets-section[data-zone-id]');
|
||||
if (leftPanel) {
|
||||
renderTabPresets(leftPanel.dataset.zoneId);
|
||||
}
|
||||
// Preset strip re-renders from `zones.js` after `loadZones()` (no driver/playback side effects).
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -199,13 +199,31 @@ header h1 {
|
||||
|
||||
.audio-top-indicator {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.15rem;
|
||||
padding: 0.25rem 0.55rem;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 6px;
|
||||
background-color: #1a1a1a;
|
||||
min-width: 6.5rem;
|
||||
min-width: 9rem;
|
||||
}
|
||||
|
||||
.audio-top-indicator-main {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.audio-top-indicator-extra {
|
||||
font-size: 0.62rem;
|
||||
color: #9e9e9e;
|
||||
line-height: 1.25;
|
||||
text-align: right;
|
||||
max-width: 16rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.audio-top-indicator.audio-running {
|
||||
@@ -226,6 +244,19 @@ header h1 {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.audio-top-beat-readout {
|
||||
font-size: 0.62rem;
|
||||
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;
|
||||
}
|
||||
|
||||
.audio-top-indicator-subvalue {
|
||||
font-size: 0.75rem;
|
||||
color: #9e9e9e;
|
||||
@@ -240,7 +271,9 @@ header h1 {
|
||||
|
||||
.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-subvalue,
|
||||
.audio-top-indicator.flash .audio-top-indicator-extra,
|
||||
.audio-top-indicator.flash .audio-top-beat-readout {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@@ -620,7 +653,8 @@ body.preset-ui-run .edit-mode-only {
|
||||
overflow-x: hidden;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, minmax(0, 1fr));
|
||||
grid-auto-rows: 5rem;
|
||||
/* min-content height prevents taller tiles (edit actions, wrapping) from overlapping the next row and stealing clicks */
|
||||
grid-auto-rows: minmax(5rem, auto);
|
||||
column-gap: 0.3rem;
|
||||
row-gap: 0.3rem;
|
||||
align-content: start;
|
||||
@@ -784,6 +818,26 @@ body.preset-ui-run .edit-mode-only {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.audio-bpm-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.audio-bpm-row .audio-bpm-readout {
|
||||
flex: 0 0 auto;
|
||||
min-width: 5rem;
|
||||
}
|
||||
|
||||
.audio-modal-beat-readout {
|
||||
flex: 1;
|
||||
min-width: 10rem;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.35;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.audio-hit-type-readout {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
@@ -851,17 +905,43 @@ body.preset-ui-run .edit-mode-only {
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
min-height: 5rem;
|
||||
}
|
||||
|
||||
.preset-tile-row--run .preset-tile-actions {
|
||||
display: none;
|
||||
.preset-tile-row-top {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 5rem;
|
||||
}
|
||||
|
||||
.preset-tile-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 0.12rem;
|
||||
}
|
||||
|
||||
.preset-tile-main .preset-tile-groups {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.15;
|
||||
opacity: 0.88;
|
||||
text-align: center;
|
||||
max-width: 100%;
|
||||
padding: 0 0.35rem;
|
||||
box-sizing: border-box;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.preset-tile-row--run .preset-tile-actions {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Edit only beside the preset tile in edit mode. */
|
||||
@@ -1030,6 +1110,46 @@ body.preset-ui-run .edit-mode-only {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Stack sequence modals below groups / preset editor so in-modal actions stay visible */
|
||||
#sequence-editor-modal.active,
|
||||
#sequences-modal.active {
|
||||
z-index: 1040;
|
||||
}
|
||||
#groups-modal.active,
|
||||
#edit-group-modal.active,
|
||||
#presets-modal.active {
|
||||
z-index: 1050;
|
||||
}
|
||||
#preset-editor-modal.active {
|
||||
z-index: 1060;
|
||||
}
|
||||
|
||||
/* Child / overlay modals: must paint above preset editor (1060) and list modals (1050). */
|
||||
#color-palette-modal.active,
|
||||
#pattern-editor-modal.active,
|
||||
#edit-device-modal.active,
|
||||
#edit-zone-modal.active {
|
||||
z-index: 1070;
|
||||
}
|
||||
|
||||
/* Patterns library (often used next to presets); below preset editor, above sequences. */
|
||||
#patterns-modal.active {
|
||||
z-index: 1055;
|
||||
}
|
||||
|
||||
/* Header / global dialogs */
|
||||
#help-modal.active,
|
||||
#audio-modal.active,
|
||||
#settings-modal.active,
|
||||
#led-tool-modal.active {
|
||||
z-index: 1080;
|
||||
}
|
||||
|
||||
/* JS-appended overlays (e.g. preset “From Palette”, add-preset-to-zone) — must sit above #preset-editor-modal */
|
||||
.modal.modal-child-overlay.active {
|
||||
z-index: 1080;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: #2e2e2e;
|
||||
padding: 2rem;
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
// Zone management JavaScript
|
||||
let currentZoneId = null;
|
||||
let brightnessSendTimeout = null;
|
||||
/**
|
||||
* When true, the next `loadZoneContent` skips `sendZoneBrightness` (run/edit toggle: same zone, UI only).
|
||||
*/
|
||||
let suppressZoneContentDriverSideEffects = false;
|
||||
/** First successful `loadZoneContent` after open: skip hardware brightness push (read-only hydration). */
|
||||
let isFirstZoneContentHydration = true;
|
||||
|
||||
function clamp255(n) {
|
||||
const v = parseInt(n, 10);
|
||||
@@ -202,8 +208,160 @@ async function computeZoneTargets(zone) {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDeviceMac(raw) {
|
||||
return String(raw || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/:/g, "")
|
||||
.replace(/-/g, "");
|
||||
}
|
||||
|
||||
/** Flat preset ids on a zone document (grid or flat). */
|
||||
function tabPresetIdsInZoneDoc(zoneDoc) {
|
||||
let ids = [];
|
||||
if (Array.isArray(zoneDoc && zoneDoc.presets_flat)) {
|
||||
ids = zoneDoc.presets_flat.slice();
|
||||
} else if (Array.isArray(zoneDoc && zoneDoc.presets)) {
|
||||
if (zoneDoc.presets.length && typeof zoneDoc.presets[0] === "string") {
|
||||
ids = zoneDoc.presets.slice();
|
||||
} else if (zoneDoc.presets.length && Array.isArray(zoneDoc.presets[0])) {
|
||||
ids = zoneDoc.presets.flat();
|
||||
}
|
||||
}
|
||||
return (ids || []).filter(Boolean);
|
||||
}
|
||||
|
||||
/** Group ids for a preset: explicit ``preset_group_ids[presetId]`` when non-empty, else zone ``group_ids``. */
|
||||
function effectiveGroupIdsForZonePreset(zoneDoc, presetId) {
|
||||
const pid = String(presetId);
|
||||
const raw = zoneDoc && zoneDoc.preset_group_ids && zoneDoc.preset_group_ids[pid];
|
||||
if (Array.isArray(raw) && raw.length > 0) {
|
||||
return raw.map((x) => String(x).trim()).filter((x) => x.length > 0);
|
||||
}
|
||||
return Array.isArray(zoneDoc && zoneDoc.group_ids)
|
||||
? zoneDoc.group_ids.map((x) => String(x).trim()).filter((x) => x.length > 0)
|
||||
: [];
|
||||
}
|
||||
|
||||
/** Resolve device names + MACs from a list of group ids (same rules as zone group expansion). */
|
||||
async function resolveTargetsFromGroupIds(groupIds) {
|
||||
const dm = await fetchDevicesMap();
|
||||
const gids = Array.isArray(groupIds)
|
||||
? groupIds.map((x) => String(x).trim()).filter((x) => x.length > 0)
|
||||
: [];
|
||||
if (!gids.length) {
|
||||
return { names: [], macs: [] };
|
||||
}
|
||||
const gm = await fetchGroupsMap();
|
||||
const seen = new Set();
|
||||
const names = [];
|
||||
const macs = [];
|
||||
for (const gid of gids) {
|
||||
const g = gm[gid];
|
||||
if (!g || !Array.isArray(g.devices)) continue;
|
||||
for (const raw of g.devices) {
|
||||
const m = normalizeDeviceMac(raw);
|
||||
if (m.length !== 12) continue;
|
||||
if (seen.has(m)) continue;
|
||||
seen.add(m);
|
||||
const d = dm[m];
|
||||
const n = d && String((d.name || "").trim()) ? String(d.name).trim() : m;
|
||||
names.push(n);
|
||||
macs.push(m);
|
||||
}
|
||||
}
|
||||
return { names, macs };
|
||||
}
|
||||
|
||||
/** Device names for one zone preset slot (effective groups, or whole zone by name when no groups). */
|
||||
async function resolveDeviceNamesForZonePreset(zoneDoc, presetId) {
|
||||
const gids = effectiveGroupIdsForZonePreset(zoneDoc, presetId);
|
||||
if (gids.length) {
|
||||
const t = await resolveTargetsFromGroupIds(gids);
|
||||
if (t.names.length) return t.names;
|
||||
}
|
||||
const zt = await computeZoneTargets(zoneDoc);
|
||||
return Array.isArray(zt.names) ? zt.names.slice() : [];
|
||||
}
|
||||
|
||||
/** Union of all devices targeted by any preset on the zone (for tab strip + sequence scope). */
|
||||
async function computeZonePresetUnionTargets(zoneDoc) {
|
||||
const ids = tabPresetIdsInZoneDoc(zoneDoc);
|
||||
if (!ids.length) {
|
||||
return await computeZoneTargets(zoneDoc);
|
||||
}
|
||||
const seen = new Set();
|
||||
const names = [];
|
||||
const macs = [];
|
||||
for (const pid of ids) {
|
||||
const gids = effectiveGroupIdsForZonePreset(zoneDoc, pid);
|
||||
let t;
|
||||
if (gids.length) {
|
||||
t = await resolveTargetsFromGroupIds(gids);
|
||||
} else {
|
||||
t = await computeZoneTargets(zoneDoc);
|
||||
}
|
||||
const tn = Array.isArray(t.names) ? t.names : [];
|
||||
const tm = Array.isArray(t.macs) ? t.macs : [];
|
||||
for (let i = 0; i < tm.length; i++) {
|
||||
const m = normalizeDeviceMac(tm[i]);
|
||||
if (m.length !== 12 || seen.has(m)) continue;
|
||||
seen.add(m);
|
||||
macs.push(tm[i]);
|
||||
names.push(tn[i] || m);
|
||||
}
|
||||
}
|
||||
if (!names.length) {
|
||||
return await computeZoneTargets(zoneDoc);
|
||||
}
|
||||
return { names, macs };
|
||||
}
|
||||
|
||||
/**
|
||||
* Device names for one sequence step. Empty stepGroupIds => all zone names.
|
||||
* Otherwise: devices in those groups intersected with the zone's target MACs.
|
||||
*/
|
||||
async function resolveSequenceStepDeviceNames(zone, stepGroupIds) {
|
||||
const zoneT = await computeZonePresetUnionTargets(zone);
|
||||
const names = Array.isArray(zoneT.names) ? zoneT.names : [];
|
||||
const macs = Array.isArray(zoneT.macs) ? zoneT.macs : [];
|
||||
const gids = Array.isArray(stepGroupIds)
|
||||
? stepGroupIds.map((x) => String(x).trim()).filter((x) => x.length > 0)
|
||||
: [];
|
||||
if (!gids.length) {
|
||||
return names.slice();
|
||||
}
|
||||
const zoneMacSet = new Set(
|
||||
macs.map((m) => normalizeDeviceMac(m)).filter((m) => m.length === 12),
|
||||
);
|
||||
const zoneNameByMac = new Map();
|
||||
for (let i = 0; i < macs.length; i++) {
|
||||
const m = normalizeDeviceMac(macs[i]);
|
||||
if (m.length === 12 && !zoneNameByMac.has(m)) {
|
||||
zoneNameByMac.set(m, names[i] || m);
|
||||
}
|
||||
}
|
||||
const gm = await fetchGroupsMap();
|
||||
const stepMacs = new Set();
|
||||
for (const gid of gids) {
|
||||
const g = gm[gid];
|
||||
if (!g || !Array.isArray(g.devices)) continue;
|
||||
for (const raw of g.devices) {
|
||||
const m = normalizeDeviceMac(raw);
|
||||
if (m.length !== 12 || !zoneMacSet.has(m)) continue;
|
||||
stepMacs.add(m);
|
||||
}
|
||||
}
|
||||
const out = [];
|
||||
for (const m of stepMacs) {
|
||||
const n = zoneNameByMac.get(m);
|
||||
if (n) out.push(n);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function resolveZoneDeviceMacsFromZoneData(zone) {
|
||||
const t = await computeZoneTargets(zone);
|
||||
const t = await computeZonePresetUnionTargets(zone);
|
||||
return t.macs;
|
||||
}
|
||||
|
||||
@@ -710,7 +868,7 @@ async function loadZoneContent(zoneId) {
|
||||
|
||||
// Render zone content (presets section)
|
||||
const tabName = zone.name || `Zone ${zoneId}`;
|
||||
const targets = await computeZoneTargets(zone);
|
||||
const targets = await computeZonePresetUnionTargets(zone);
|
||||
const namesJsonAttr = encodeURIComponent(JSON.stringify(targets.names));
|
||||
const macsJsonAttr = encodeURIComponent(JSON.stringify(targets.macs));
|
||||
const legacyOk =
|
||||
@@ -735,8 +893,14 @@ async function loadZoneContent(zoneId) {
|
||||
? Math.max(0, Math.min(255, Math.round(zoneBrightness)))
|
||||
: 255;
|
||||
applyBrightnessSliders(normalizedBrightness);
|
||||
// Apply this zone's saved brightness when switching zones.
|
||||
sendZoneBrightness(zoneId, normalizedBrightness);
|
||||
const initialHydration = isFirstZoneContentHydration;
|
||||
if (isFirstZoneContentHydration) {
|
||||
isFirstZoneContentHydration = false;
|
||||
}
|
||||
if (!suppressZoneContentDriverSideEffects && !initialHydration) {
|
||||
// Apply this zone's saved brightness when switching zones (not initial page load or UI-only strip refresh).
|
||||
sendZoneBrightness(zoneId, normalizedBrightness);
|
||||
}
|
||||
|
||||
// Trigger presets loading if the function exists
|
||||
if (typeof renderTabPresets === 'function') {
|
||||
@@ -857,17 +1021,46 @@ async function sendProfilePresets() {
|
||||
}
|
||||
|
||||
function tabPresetIdsInOrder(tabData) {
|
||||
let ids = [];
|
||||
if (Array.isArray(tabData.presets_flat)) {
|
||||
ids = tabData.presets_flat.slice();
|
||||
} else if (Array.isArray(tabData.presets)) {
|
||||
if (tabData.presets.length && typeof tabData.presets[0] === "string") {
|
||||
ids = tabData.presets.slice();
|
||||
} else if (tabData.presets.length && Array.isArray(tabData.presets[0])) {
|
||||
ids = tabData.presets.flat();
|
||||
}
|
||||
return tabPresetIdsInZoneDoc(tabData);
|
||||
}
|
||||
|
||||
async function saveZonePresetGroupOverride(zoneId, presetId, useDefault, selectedGids) {
|
||||
const tabRes = await fetch(`/zones/${zoneId}`, { headers: { Accept: "application/json" } });
|
||||
if (!tabRes.ok) {
|
||||
alert("Failed to load zone.");
|
||||
return false;
|
||||
}
|
||||
return (ids || []).filter(Boolean);
|
||||
const tabData = await tabRes.json();
|
||||
const pg =
|
||||
tabData.preset_group_ids && typeof tabData.preset_group_ids === "object"
|
||||
? { ...tabData.preset_group_ids }
|
||||
: {};
|
||||
if (useDefault) {
|
||||
delete pg[String(presetId)];
|
||||
} else {
|
||||
const gids = Array.isArray(selectedGids)
|
||||
? selectedGids.map((x) => String(x).trim()).filter((x) => x.length > 0)
|
||||
: [];
|
||||
if (!gids.length) {
|
||||
alert("Select at least one group, or use zone default.");
|
||||
return false;
|
||||
}
|
||||
pg[String(presetId)] = gids;
|
||||
}
|
||||
tabData.preset_group_ids = pg;
|
||||
const up = await fetch(`/zones/${zoneId}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(tabData),
|
||||
});
|
||||
if (!up.ok) {
|
||||
alert("Failed to save preset groups.");
|
||||
return false;
|
||||
}
|
||||
if (typeof window.renderTabPresets === "function") {
|
||||
await window.renderTabPresets(zoneId);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Presets already on the zone (remove) and presets available to add (select).
|
||||
@@ -891,7 +1084,10 @@ async function refreshEditTabPresetsUi(zoneId) {
|
||||
const inTabIds = tabPresetIdsInOrder(tabData);
|
||||
const inTabSet = new Set(inTabIds.map((id) => String(id)));
|
||||
|
||||
const presetsRes = await fetch("/presets", { headers: { Accept: "application/json" } });
|
||||
const [presetsRes, groupsMapEdit] = await Promise.all([
|
||||
fetch("/presets", { headers: { Accept: "application/json" } }),
|
||||
fetchGroupsMap(),
|
||||
]);
|
||||
const allPresets = presetsRes.ok ? await presetsRes.json() : {};
|
||||
|
||||
const makeRow = () => {
|
||||
@@ -911,8 +1107,12 @@ async function refreshEditTabPresetsUi(zoneId) {
|
||||
for (const presetId of inTabIds) {
|
||||
const preset = allPresets[presetId] || {};
|
||||
const name = preset.name || presetId;
|
||||
const row = makeRow();
|
||||
const block = document.createElement("div");
|
||||
block.style.cssText =
|
||||
"border:1px solid rgba(255,255,255,0.12);border-radius:6px;padding:0.5rem 0.65rem;margin-bottom:0.65rem;";
|
||||
const top = makeRow();
|
||||
const label = document.createElement("span");
|
||||
label.style.fontWeight = "600";
|
||||
label.textContent = name;
|
||||
const removeBtn = document.createElement("button");
|
||||
removeBtn.type = "button";
|
||||
@@ -924,9 +1124,90 @@ async function refreshEditTabPresetsUi(zoneId) {
|
||||
await window.removePresetFromTab(zoneId, presetId);
|
||||
await refreshEditTabPresetsUi(zoneId);
|
||||
});
|
||||
row.appendChild(label);
|
||||
row.appendChild(removeBtn);
|
||||
currentEl.appendChild(row);
|
||||
top.appendChild(label);
|
||||
top.appendChild(removeBtn);
|
||||
block.appendChild(top);
|
||||
|
||||
const hasExplicit =
|
||||
tabData.preset_group_ids &&
|
||||
typeof tabData.preset_group_ids === "object" &&
|
||||
Array.isArray(tabData.preset_group_ids[presetId]) &&
|
||||
tabData.preset_group_ids[presetId].length > 0;
|
||||
const zoneG = Array.isArray(tabData.group_ids)
|
||||
? tabData.group_ids.map((x) => String(x).trim()).filter((x) => x.length > 0)
|
||||
: [];
|
||||
const initialChecked = new Set(
|
||||
hasExplicit
|
||||
? tabData.preset_group_ids[presetId].map((x) => String(x).trim())
|
||||
: zoneG,
|
||||
);
|
||||
|
||||
const useRow = document.createElement("div");
|
||||
useRow.className = "profiles-row";
|
||||
useRow.style.marginTop = "0.35rem";
|
||||
const useDefCb = document.createElement("input");
|
||||
useDefCb.type = "checkbox";
|
||||
useDefCb.id = `edit-zone-preset-use-def-${presetId}`;
|
||||
useDefCb.checked = !hasExplicit;
|
||||
const useDefLbl = document.createElement("label");
|
||||
useDefLbl.htmlFor = useDefCb.id;
|
||||
useDefLbl.style.marginLeft = "0.25rem";
|
||||
useDefLbl.style.fontSize = "0.9em";
|
||||
useDefLbl.textContent = "Use zone default groups";
|
||||
useRow.appendChild(useDefCb);
|
||||
useRow.appendChild(useDefLbl);
|
||||
block.appendChild(useRow);
|
||||
|
||||
const boxHost = document.createElement("div");
|
||||
boxHost.style.cssText = `display:${hasExplicit ? "flex" : "none"};flex-wrap:wrap;gap:0.4rem;margin-top:0.35rem;align-items:center;`;
|
||||
const entries = Object.keys(groupsMapEdit || {})
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.map((gid) => {
|
||||
const g = groupsMapEdit[gid];
|
||||
const gn = g && g.name ? String(g.name).trim() : "";
|
||||
return { gid, label: gn ? `${gn} (${gid})` : `Group ${gid}` };
|
||||
});
|
||||
entries.forEach(({ gid, label: glabel }) => {
|
||||
const id = `zpg-${zoneId}-${presetId}-${gid}`;
|
||||
const lbl = document.createElement("label");
|
||||
lbl.style.cssText = "display:inline-flex;align-items:center;gap:0.2rem;font-size:0.85em;";
|
||||
const cb = document.createElement("input");
|
||||
cb.type = "checkbox";
|
||||
cb.className = "edit-zone-preset-group-cb";
|
||||
cb.value = gid;
|
||||
cb.id = id;
|
||||
cb.checked = initialChecked.has(String(gid));
|
||||
const sp = document.createElement("span");
|
||||
sp.textContent = glabel;
|
||||
lbl.appendChild(cb);
|
||||
lbl.appendChild(sp);
|
||||
boxHost.appendChild(lbl);
|
||||
});
|
||||
block.appendChild(boxHost);
|
||||
|
||||
useDefCb.addEventListener("change", () => {
|
||||
boxHost.style.display = useDefCb.checked ? "none" : "flex";
|
||||
});
|
||||
|
||||
const applyBtn = document.createElement("button");
|
||||
applyBtn.type = "button";
|
||||
applyBtn.className = "btn btn-primary btn-small";
|
||||
applyBtn.style.marginTop = "0.4rem";
|
||||
applyBtn.textContent = "Apply preset groups";
|
||||
applyBtn.addEventListener("click", async () => {
|
||||
const useD = !!useDefCb.checked;
|
||||
const sel = [];
|
||||
if (!useD) {
|
||||
boxHost.querySelectorAll(".edit-zone-preset-group-cb:checked").forEach((c) => {
|
||||
if (c.value) sel.push(String(c.value));
|
||||
});
|
||||
}
|
||||
const ok = await saveZonePresetGroupOverride(zoneId, presetId, useD, sel);
|
||||
if (ok) await refreshEditTabPresetsUi(zoneId);
|
||||
});
|
||||
block.appendChild(applyBtn);
|
||||
|
||||
currentEl.appendChild(block);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1016,6 +1297,9 @@ async function openEditZoneModal(zoneId, zone) {
|
||||
|
||||
if (modal) modal.classList.add("active");
|
||||
await refreshEditTabPresetsUi(zoneId);
|
||||
if (typeof window.refreshEditTabSequencesUi === "function") {
|
||||
await window.refreshEditTabSequencesUi(zoneId);
|
||||
}
|
||||
}
|
||||
|
||||
// Update an existing zone
|
||||
@@ -1220,9 +1504,14 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
// When run/edit mode toggles, refresh tabs UI so edit actions show/hide immediately.
|
||||
document.querySelectorAll('.ui-mode-toggle').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
await loadZones();
|
||||
if (zonesModal && zonesModal.classList.contains("active")) {
|
||||
await loadZonesModal();
|
||||
suppressZoneContentDriverSideEffects = true;
|
||||
try {
|
||||
await loadZones();
|
||||
if (zonesModal && zonesModal.classList.contains("active")) {
|
||||
await loadZonesModal();
|
||||
}
|
||||
} finally {
|
||||
suppressZoneContentDriverSideEffects = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1240,6 +1529,11 @@ window.zonesManager = {
|
||||
resolveZoneDeviceMacsFromZoneData,
|
||||
resolveTabDeviceMacs: resolveZoneDeviceMacs,
|
||||
getCurrentZoneId: () => currentZoneId,
|
||||
computeZoneTargets,
|
||||
computeZonePresetUnionTargets,
|
||||
effectiveGroupIdsForZonePreset,
|
||||
resolveDeviceNamesForZonePreset,
|
||||
resolveSequenceStepDeviceNames,
|
||||
};
|
||||
window.tabsManager = window.zonesManager;
|
||||
window.tabsManager.getCurrentTabId = () => currentZoneId;
|
||||
|
||||
@@ -16,9 +16,11 @@
|
||||
</div>
|
||||
<div class="header-end">
|
||||
<div id="audio-top-indicator" class="audio-top-indicator" title="Live audio BPM">
|
||||
<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-count" class="audio-top-indicator-subvalue">#0</span>
|
||||
<div class="audio-top-indicator-main">
|
||||
<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>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<div class="header-brightness-control">
|
||||
@@ -30,6 +32,7 @@
|
||||
<button class="btn btn-secondary edit-mode-only" id="groups-btn">Groups</button>
|
||||
<button class="btn btn-secondary edit-mode-only" id="zones-btn">Zones</button>
|
||||
<button class="btn btn-secondary edit-mode-only" id="presets-btn">Presets</button>
|
||||
<button class="btn btn-secondary edit-mode-only" id="sequences-btn">Sequences</button>
|
||||
<button class="btn btn-secondary edit-mode-only" id="patterns-btn">Patterns</button>
|
||||
<button class="btn btn-secondary edit-mode-only" id="color-palette-btn">Colour Palette</button>
|
||||
<button class="btn btn-secondary edit-mode-only" id="send-profile-presets-btn">Send Presets</button>
|
||||
@@ -51,6 +54,7 @@
|
||||
<button type="button" class="edit-mode-only" data-target="groups-btn">Groups</button>
|
||||
<button type="button" class="edit-mode-only" data-target="zones-btn">Tabs</button>
|
||||
<button type="button" class="edit-mode-only" data-target="presets-btn">Presets</button>
|
||||
<button type="button" class="edit-mode-only" data-target="sequences-btn">Sequences</button>
|
||||
<button type="button" class="edit-mode-only" data-target="patterns-btn">Patterns</button>
|
||||
<button type="button" class="edit-mode-only" data-target="color-palette-btn">Colour Palette</button>
|
||||
<button type="button" class="edit-mode-only" data-target="send-profile-presets-btn">Send Presets</button>
|
||||
@@ -104,6 +108,10 @@
|
||||
<div id="edit-zone-presets-current" class="profiles-list edit-zone-presets-scroll"></div>
|
||||
<label class="zone-presets-section-label">Add presets to this zone</label>
|
||||
<div id="edit-zone-presets-list" class="profiles-list edit-zone-presets-scroll"></div>
|
||||
<label class="zone-presets-section-label">Sequences on this zone</label>
|
||||
<div id="edit-zone-sequences-current" class="profiles-list edit-zone-presets-scroll"></div>
|
||||
<label class="zone-presets-section-label">Add a sequence to this zone</label>
|
||||
<div id="edit-zone-sequences-list" class="profiles-list edit-zone-presets-scroll"></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -169,6 +177,10 @@
|
||||
<input type="text" id="edit-group-name" required autocomplete="off">
|
||||
<label class="zone-devices-label">Devices in this group</label>
|
||||
<div id="edit-group-devices-editor" class="zone-devices-editor"></div>
|
||||
<div class="profiles-actions" style="margin-top: 0.5rem;">
|
||||
<button type="button" class="btn btn-secondary btn-small" id="edit-group-identify-btn">Identify devices in group</button>
|
||||
</div>
|
||||
<p class="muted-text" style="margin-top:0.25rem;">Runs identify on every driver in the group at the same time so they blink together.</p>
|
||||
<label for="edit-group-output-brightness" style="margin-top:0.75rem;display:block;">Group output brightness (0–255)</label>
|
||||
<div class="profiles-actions" style="align-items: center; gap: 0.75rem;">
|
||||
<input type="range" id="edit-group-output-brightness" min="0" max="255" value="255" style="flex:1;">
|
||||
@@ -280,6 +292,62 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sequences Modal -->
|
||||
<div id="sequences-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h2>Sequences</h2>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-primary" id="sequence-add-btn">Add</button>
|
||||
<button type="button" class="btn btn-secondary" id="sequences-open-presets-btn">Presets</button>
|
||||
</div>
|
||||
<div id="sequences-list" class="profiles-list"></div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-secondary" id="sequences-close-btn">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sequence Editor Modal -->
|
||||
<div id="sequence-editor-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h2>Sequence</h2>
|
||||
<div class="preset-editor-field">
|
||||
<label for="sequence-editor-name">Name</label>
|
||||
<input type="text" id="sequence-editor-name" placeholder="Sequence name" style="width:100%;max-width:24rem;">
|
||||
</div>
|
||||
<div class="preset-editor-field">
|
||||
<label for="sequence-editor-advance-mode">Advance</label>
|
||||
<select id="sequence-editor-advance-mode" style="max-width:16rem;">
|
||||
<option value="time">Time (ms between steps)</option>
|
||||
<option value="beats">Audio beats (requires Audio detector)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="preset-editor-field" id="sequence-editor-duration-wrap">
|
||||
<label for="sequence-editor-duration">Step duration (ms), all lanes together</label>
|
||||
<div style="display:flex;align-items:center;gap:0.6rem;flex-wrap:wrap;">
|
||||
<input type="number" id="sequence-editor-duration" min="200" max="600000" value="3000" style="width:8rem;">
|
||||
<span id="sequence-editor-time-bpm-hint" class="muted-text" style="font-size:0.9em;"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="preset-editor-field" id="sequence-editor-transition-wrap">
|
||||
<label for="sequence-editor-transition">Pause before next step (ms)</label>
|
||||
<input type="number" id="sequence-editor-transition" min="0" max="60000" value="500" style="width:8rem;">
|
||||
</div>
|
||||
<div id="sequence-editor-beats-panel" style="display:none;margin:0 0 0.75rem 0;">
|
||||
<p id="sequence-editor-bpm-live" class="muted-text" style="font-size:0.85em;margin:0;">—</p>
|
||||
</div>
|
||||
<div id="sequence-editor-lanes"></div>
|
||||
<div class="modal-actions" style="margin-top:0.75rem;">
|
||||
<button type="button" class="btn btn-secondary btn-small" id="sequence-editor-add-lane-btn">Add lane</button>
|
||||
</div>
|
||||
<div class="modal-actions preset-editor-modal-actions">
|
||||
<button type="button" class="btn btn-danger" id="sequence-editor-delete-btn">Delete</button>
|
||||
<button type="button" class="btn btn-primary" id="sequence-editor-save-btn">Save</button>
|
||||
<button type="button" class="btn btn-secondary" id="sequence-editor-close-btn">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preset Editor Modal -->
|
||||
<div id="preset-editor-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
@@ -321,7 +389,7 @@
|
||||
<p id="preset-manual-mode-hint" class="muted-text" style="display: none; margin-top: 0.35rem; font-size: 0.85em;"></p>
|
||||
<div id="preset-manual-beat-n-wrap" class="preset-editor-field" style="display: none; margin-top: 0.5rem;">
|
||||
<label for="preset-manual-beat-n-input">Audio beat: every</label>
|
||||
<input type="number" id="preset-manual-beat-n-input" min="1" max="64" value="1" style="width: 4rem;" title="Controller only; not sent to pattern logic">
|
||||
<input type="number" id="preset-manual-beat-n-input" min="1" max="64" value="1" style="width: 4rem;" title="Controller only; not sent to pattern logic" autocomplete="off">
|
||||
<span class="muted-text" style="font-size: 0.85em;">beats (this app only)</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -533,7 +601,10 @@
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Current BPM</label>
|
||||
<div id="audio-bpm-value" class="audio-bpm-readout">--</div>
|
||||
<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>
|
||||
@@ -543,6 +614,11 @@
|
||||
<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>
|
||||
<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>
|
||||
@@ -709,6 +785,7 @@
|
||||
<script src="/static/zone_palette.js"></script>
|
||||
<script src="/static/patterns.js"></script>
|
||||
<script src="/static/presets.js"></script>
|
||||
<script src="/static/sequences.js"></script>
|
||||
<script src="/static/devices.js"></script>
|
||||
<script src="/static/audio.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -161,11 +161,11 @@ class AudioBeatDetector:
|
||||
self._status["beat_type_confidence"] = float(beat_type_confidence or 0.0)
|
||||
self._status["beat_seq"] = int(self._status.get("beat_seq", 0)) + 1
|
||||
try:
|
||||
from util.beat_driver_route import notify_beat_detected
|
||||
from util import sequence_playback as seq_pb
|
||||
|
||||
notify_beat_detected()
|
||||
seq_pb.push_thread_beat()
|
||||
except Exception as e:
|
||||
print(f"[audio] beat driver route: {e}")
|
||||
print(f"[audio] sequence beat queue: {e}")
|
||||
|
||||
def _run_loop(self, device):
|
||||
try:
|
||||
|
||||
52
src/util/audio_run_persist.py
Normal file
52
src/util/audio_run_persist.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""Persist whether the audio beat detector should be running (survives process restarts)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
def _db_path() -> str:
|
||||
base = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
return os.path.join(base, "db", "audio_run.json")
|
||||
|
||||
|
||||
def coerce_audio_device(device: Any) -> Optional[Any]:
|
||||
"""Match ``/api/audio/start`` body coercion (None = host default input)."""
|
||||
if device in ("", None):
|
||||
return None
|
||||
try:
|
||||
return int(device)
|
||||
except (TypeError, ValueError):
|
||||
return device
|
||||
|
||||
|
||||
def read_audio_run_state() -> Dict[str, Any]:
|
||||
path = _db_path()
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
raw = json.load(f)
|
||||
except (OSError, json.JSONDecodeError, TypeError):
|
||||
return {"enabled": False, "device": None}
|
||||
if not isinstance(raw, dict):
|
||||
return {"enabled": False, "device": None}
|
||||
enabled = bool(raw.get("enabled"))
|
||||
dev = raw.get("device", None)
|
||||
return {"enabled": enabled, "device": dev}
|
||||
|
||||
|
||||
def write_audio_run_state(*, enabled: bool, device: Any = None) -> None:
|
||||
"""Write run intent. When ``enabled`` is false, keep ``device`` from the previous file for next start."""
|
||||
path = _db_path()
|
||||
prev = read_audio_run_state()
|
||||
if enabled:
|
||||
data = {"enabled": True, "device": device}
|
||||
else:
|
||||
data = {"enabled": False, "device": prev.get("device")}
|
||||
try:
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
except OSError as e:
|
||||
print(f"[audio_run_persist] save failed: {e!r}")
|
||||
Reference in New Issue
Block a user