From 6c9e06f33b639e3ac19d15e2190490a3fd9b2c15 Mon Sep 17 00:00:00 2001 From: Jimmy Date: Wed, 13 May 2026 01:58:00 +1200 Subject: [PATCH] feat(zones): profile-scoped groups, zone modes, sequence brightness - Optional profile_id on groups; UI and API for shared vs profile-only groups\n- Zone content_kind (presets vs sequences); edit modal shows matching sections; devices via groups only\n- Server sequence playback folds zone brightness into preset wire b (per MAC where needed)\n- Related preset/sequence/audio/beat-route and client updates Co-authored-by: Cursor --- src/controllers/group.py | 154 +++++++++++--- src/controllers/preset.py | 22 +- src/controllers/sequence.py | 2 +- src/controllers/zone.py | 5 +- src/main.py | 6 + src/models/group.py | 7 +- src/models/preset.py | 3 + src/models/sequence.py | 17 +- src/models/zone.py | 15 +- src/static/audio.js | 6 +- src/static/groups.js | 71 ++++++- src/static/presets.js | 187 ++++++++++++++--- src/static/sequences.js | 219 ++++++++++++-------- src/static/style.css | 8 + src/static/zones.js | 371 ++++++++++------------------------ src/templates/index.html | 56 ++--- src/util/audio_detector.py | 19 ++ src/util/beat_driver_route.py | 81 ++++++-- src/util/espnow_message.py | 55 +++-- src/util/sequence_playback.py | 329 ++++++++++++++++++------------ tests/models/test_sequence.py | 5 +- 21 files changed, 1034 insertions(+), 604 deletions(-) diff --git a/src/controllers/group.py b/src/controllers/group.py index e18b94f..b25aa68 100644 --- a/src/controllers/group.py +++ b/src/controllers/group.py @@ -1,4 +1,5 @@ from microdot import Microdot +from microdot.session import with_session import asyncio from models.group import Group from models.device import Device @@ -13,46 +14,127 @@ groups = Group() devices = Device() _pi_settings = Settings() -@controller.get('') -async def list_groups(request): - """List all groups.""" - return json.dumps(groups), 200, {'Content-Type': 'application/json'} -@controller.get('/') -async def get_group(request, id): - """Get a specific group by ID.""" +def _group_doc_visible_for_profile(doc, profile_id): + if not isinstance(doc, dict): + return False + scoped = doc.get("profile_id") + if scoped is None: + scoped = doc.get("profileId") + if scoped is None or str(scoped).strip() == "": + return True + if not profile_id: + return False + return str(scoped).strip() == str(profile_id).strip() + + +def _filtered_groups_dict(session): + from controllers.zone import get_current_profile_id + + pid = get_current_profile_id(session) + out = {} + for gid, doc in groups.items(): + if not isinstance(doc, dict): + continue + if _group_doc_visible_for_profile(doc, pid): + out[str(gid)] = doc + return out + + +@controller.get("") +@with_session +async def list_groups(request, session): + """List groups visible for the current profile (shared + profile-scoped).""" + return json.dumps(_filtered_groups_dict(session)), 200, {"Content-Type": "application/json"} + + +@controller.get("/") +@with_session +async def get_group(request, session, id): + """Get a specific group by ID (404 if scoped to another profile).""" group = groups.read(id) - if group: - return json.dumps(group), 200, {'Content-Type': 'application/json'} - return json.dumps({"error": "Group not found"}), 404 + if not group or not isinstance(group, dict): + return json.dumps({"error": "Group not found"}), 404 + from controllers.zone import get_current_profile_id -@controller.post('') -async def create_group(request): - """Create a new group.""" + if not _group_doc_visible_for_profile(group, get_current_profile_id(session)): + return json.dumps({"error": "Group not found"}), 404 + return json.dumps(group), 200, {"Content-Type": "application/json"} + + +def _sanitize_group_profile_id_write(data, session): + """Allow ``profile_id`` only for the active profile, or null to share across profiles.""" + if not isinstance(data, dict): + return + from controllers.zone import get_current_profile_id + + cur = get_current_profile_id(session) + if "profile_id" not in data and "profileId" not in data: + return + raw = data.get("profile_id") + if raw is None and "profileId" in data: + raw = data.get("profileId") + if raw is None or raw == "": + data.pop("profileId", None) + data["profile_id"] = None + return + if not cur or str(raw).strip() != str(cur).strip(): + data.pop("profileId", None) + data.pop("profile_id", None) + + +@controller.post("") +@with_session +async def create_group(request, session): + """Create a new group (omit ``profile_id`` for shared; or ``profile_scoped``: true for this profile only).""" try: - data = request.json or {} + data = dict(request.json or {}) name = data.get("name", "") + profile_scoped = bool(data.pop("profile_scoped", False)) + _sanitize_group_profile_id_write(data, session) group_id = groups.create(name) if data: groups.update(group_id, data) - return json.dumps(groups.read(group_id)), 201, {'Content-Type': 'application/json'} + if profile_scoped: + from controllers.zone import get_current_profile_id + + cur = get_current_profile_id(session) + if cur: + groups.update(group_id, {"profile_id": str(cur)}) + return json.dumps(groups.read(group_id)), 201, {"Content-Type": "application/json"} except Exception as e: return json.dumps({"error": str(e)}), 400 -@controller.put('/') -async def update_group(request, id): + +@controller.put("/") +@with_session +async def update_group(request, session, id): """Update an existing group.""" try: data = request.json + if not isinstance(data, dict): + return json.dumps({"error": "Invalid JSON"}), 400, {"Content-Type": "application/json"} + data = dict(data) + _sanitize_group_profile_id_write(data, session) if groups.update(id, data): - return json.dumps(groups.read(id)), 200, {'Content-Type': 'application/json'} + g = groups.read(id) + if g: + return json.dumps(g), 200, {"Content-Type": "application/json"} return json.dumps({"error": "Group not found"}), 404 except Exception as e: return json.dumps({"error": str(e)}), 400 -@controller.delete('/') -async def delete_group(request, id): - """Delete a group.""" +@controller.delete("/") +@with_session +async def delete_group(request, session, id): + """Delete a group (not allowed for another profile's scoped group).""" + g = groups.read(id) + if not g or not isinstance(g, dict): + return json.dumps({"error": "Group not found"}), 404 + from controllers.zone import get_current_profile_id + + if not _group_doc_visible_for_profile(g, get_current_profile_id(session)): + return json.dumps({"error": "Group not found"}), 404 if groups.delete(id): return json.dumps({"message": "Group deleted successfully"}), 200 return json.dumps({"error": "Group not found"}), 404 @@ -87,13 +169,25 @@ def _group_driver_config_payload(doc): return dc -@controller.post('//driver-config') -async def push_group_driver_config(request, id): +def _read_group_for_session(session, id): + g = groups.read(id) + if not g or not isinstance(g, dict): + return None + from controllers.zone import get_current_profile_id + + if not _group_doc_visible_for_profile(g, get_current_profile_id(session)): + return None + return g + + +@controller.post("//driver-config") +@with_session +async def push_group_driver_config(request, session, id): """ Push group Wi‑Fi defaults to every Wi‑Fi device listed in the group (TCP WebSocket). Uses stored ``wifi_*`` fields on the group; optional JSON body may override for this send only. """ - gdoc = groups.read(id) + gdoc = _read_group_for_session(session, id) if not gdoc: return json.dumps({"error": "Group not found"}), 404 @@ -158,12 +252,13 @@ def _brightness_save_message_json(b_val: int) -> str: return json.dumps({"v": "1", "b": b_val, "save": True}, separators=(",", ":")) -@controller.post('//brightness') -async def push_group_output_brightness(request, id): +@controller.post("//brightness") +@with_session +async def push_group_output_brightness(request, session, id): """ Push combined brightness (global × group(s) × device) to each member — one ``b`` per device. """ - gdoc = groups.read(id) + gdoc = _read_group_for_session(session, id) if not gdoc: return json.dumps({"error": "Group not found"}), 404 @@ -225,13 +320,14 @@ async def push_group_output_brightness(request, id): @controller.post("//identify") -async def identify_group_devices(request, id): +@with_session +async def identify_group_devices(request, session, id): """ Run the same identify blink as ``POST /devices//identify`` for every registry member in parallel so all drivers in the group blink together. """ _ = request - gdoc = groups.read(id) + gdoc = _read_group_for_session(session, id) if not gdoc: return json.dumps({"error": "Group not found"}), 404, {"Content-Type": "application/json"} diff --git a/src/controllers/preset.py b/src/controllers/preset.py index dd31dff..03d7265 100644 --- a/src/controllers/preset.py +++ b/src/controllers/preset.py @@ -2,6 +2,7 @@ from microdot import Microdot from microdot.session import with_session from models.preset import Preset from models.profile import Profile +from models.pallet import Palette from models.device import Device, normalize_mac from models.transport import get_current_sender from util.driver_delivery import deliver_json_messages, deliver_preset_broadcast_then_per_device @@ -12,6 +13,18 @@ controller = Microdot() presets = Preset() profiles = Profile() + +def _palette_colors_for_profile(profile_id): + prof = profiles.read(str(profile_id)) + if not isinstance(prof, dict): + return None + pid = prof.get("palette_id") or prof.get("paletteId") + if not pid: + return None + cols = Palette().read(str(pid)) + return cols if isinstance(cols, list) else None + + def get_current_profile_id(session=None): """Get the current active profile ID from session or fallback to first.""" profile_list = profiles.list() @@ -153,6 +166,7 @@ async def send_presets(request, session): # Build API-compliant preset map keyed by preset ID, include name current_profile_id = get_current_profile_id(session) + palette_colors = _palette_colors_for_profile(current_profile_id) presets_by_name = {} for pid in preset_ids: preset_data = presets.read(str(pid)) @@ -161,7 +175,7 @@ async def send_presets(request, session): if str(preset_data.get("profile_id")) != str(current_profile_id): continue preset_key = str(pid) - preset_payload = build_preset_dict(preset_data) + preset_payload = build_preset_dict(preset_data, palette_colors) preset_payload["name"] = preset_data.get("name", "") presets_by_name[preset_key] = preset_payload @@ -316,9 +330,13 @@ async def push_driver_messages(request, session): return json.dumps({"error": "Send failed"}), 503, {'Content-Type': 'application/json'} try: + from util import sequence_playback as seq_pb from util.beat_driver_route import sync_beat_route_from_push_sequence - sync_beat_route_from_push_sequence(seq, target_macs=target_list) + preserve = bool(seq_pb.playback_status().get("active")) + sync_beat_route_from_push_sequence( + seq, target_macs=target_list, preserve_parallel_lane_routes=preserve + ) except Exception: pass diff --git a/src/controllers/sequence.py b/src/controllers/sequence.py index d15162d..11c2a87 100644 --- a/src/controllers/sequence.py +++ b/src/controllers/sequence.py @@ -197,7 +197,7 @@ async def play_sequence(request, session, id): try: from util.sequence_playback import start - await start(zone_id, str(id), str(current_profile_id)) + await start(zone_id, str(id), str(current_profile_id), data if isinstance(data, dict) else None) return json.dumps({"ok": True}), 200, {"Content-Type": "application/json"} except ValueError as e: return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"} diff --git a/src/controllers/zone.py b/src/controllers/zone.py index 418a412..51d241b 100644 --- a/src/controllers/zone.py +++ b/src/controllers/zone.py @@ -291,6 +291,7 @@ async def create_zone(request, session): names = [i.strip() for i in ids_str.split(",") if i.strip()] preset_ids = None group_ids = [] + content_kind = None else: data = request.json or {} name = data.get("name", "") @@ -305,11 +306,13 @@ async def create_zone(request, session): group_ids = [str(x) for x in group_ids if x is not None] else: group_ids = [] + raw_kind = data.get("content_kind") + content_kind = raw_kind if raw_kind in ("presets", "sequences") else None if not name: return json.dumps({"error": "Zone name cannot be empty"}), 400 - zid = zones.create(name, names, preset_ids, group_ids) + zid = zones.create(name, names, preset_ids, group_ids, content_kind) profile_id = get_current_profile_id(session) if profile_id: diff --git a/src/main.py b/src/main.py index 9cf9c45..3134d10 100644 --- a/src/main.py +++ b/src/main.py @@ -254,6 +254,12 @@ async def main(port=80): app = Microdot() audio_detector = AudioBeatDetector() + try: + from util import audio_detector as audio_detector_module + + audio_detector_module.set_shared_beat_detector(audio_detector) + except Exception as e: + print(f"[startup] audio detector shared registration skipped: {e!r}") try: from util.audio_run_persist import coerce_audio_device, read_audio_run_state diff --git a/src/models/group.py b/src/models/group.py index b426847..baed051 100644 --- a/src/models/group.py +++ b/src/models/group.py @@ -2,7 +2,12 @@ from models.model import Model class Group(Model): - """Device groups (members + optional Wi‑Fi driver defaults); also pattern fields for sequences.""" + """Device groups (members + optional Wi‑Fi driver defaults); also pattern fields for sequences. + + Omit ``profile_id`` (or set it null) for a **shared** group: every profile can attach it to + zones and sequences. Set ``profile_id`` to a profile id to show the group only when that + profile is active (still one global record in ``group.json``). + """ def __init__(self): super().__init__() diff --git a/src/models/preset.py b/src/models/preset.py index df23f8c..20ae03b 100644 --- a/src/models/preset.py +++ b/src/models/preset.py @@ -15,6 +15,9 @@ class Preset(Model): if default_profile_id is not None: preset_data["profile_id"] = str(default_profile_id) changed = True + if isinstance(preset_data, dict) and "group_ids" in preset_data: + preset_data.pop("group_ids", None) + changed = True if changed: self.save() except Exception: diff --git a/src/models/sequence.py b/src/models/sequence.py index 726c158..bd35eb0 100644 --- a/src/models/sequence.py +++ b/src/models/sequence.py @@ -54,9 +54,19 @@ class Sequence(Model): if "group_ids" not in doc or not isinstance(doc.get("group_ids"), list): doc["group_ids"] = [] changed = True - if doc.get("advance_mode") not in ("time", "beats"): - doc["advance_mode"] = "time" + if doc.get("advance_mode") != "beats": + doc["advance_mode"] = "beats" changed = True + if "simulated_bpm" not in doc: + doc["simulated_bpm"] = 120 + changed = True + else: + try: + sb = int(float(doc["simulated_bpm"])) + doc["simulated_bpm"] = max(30, min(300, sb)) + except (TypeError, ValueError): + doc["simulated_bpm"] = 120 + changed = True if "sequence_transition" not in doc: doc["sequence_transition"] = 500 changed = True @@ -102,9 +112,10 @@ class Sequence(Model): "group_ids": [], "lanes": [[]], "lanes_group_ids": [[]], - "advance_mode": "time", + "advance_mode": "beats", "steps": [], "step_duration_ms": 3000, + "simulated_bpm": 120, "sequence_transition": 500, "loop": True, } diff --git a/src/models/zone.py b/src/models/zone.py index bb5beee..5c88968 100644 --- a/src/models/zone.py +++ b/src/models/zone.py @@ -19,7 +19,11 @@ def _maybe_migrate_tab_json_to_zone(): class Zone(Model): - """Preset layout row (stored in ``db/zone.json``); legacy storage was ``tab.json`` / Tab.""" + """Preset layout row (stored in ``db/zone.json``); legacy storage was ``tab.json`` / Tab. + + Optional ``content_kind`` on a row: ``\"presets\"`` (preset tiles only) or ``\"sequences\"`` + (sequence tiles only). Omitted or unknown => both (legacy behaviour). + """ def __init__(self): if not getattr(Zone, "_migration_checked", False): @@ -42,12 +46,12 @@ class Zone(Model): if changed: self.save() - def create(self, name="", names=None, presets=None, group_ids=None): + def create(self, name="", names=None, presets=None, group_ids=None, content_kind=None): next_id = self.get_next_id() gid_list = [] if isinstance(group_ids, list): - gid_list = [str(x) for x in group_ids if x is not None] - self[next_id] = { + gid_list = [str(x).strip() for x in group_ids if x is not None and str(x).strip()] + doc = { "name": name, "names": names if names else [], "group_ids": gid_list, @@ -56,6 +60,9 @@ class Zone(Model): "default_preset": None, "brightness": 255, } + if content_kind in ("presets", "sequences"): + doc["content_kind"] = content_kind + self[next_id] = doc self.save() return next_id diff --git a/src/static/audio.js b/src/static/audio.js index 7986ef8..da81dbf 100644 --- a/src/static/audio.js +++ b/src/static/audio.js @@ -83,10 +83,7 @@ lastBeatConsoleKey = key; if (!line) return; const seq = /** @type {Record|undefined} */ (status && status.sequence); - const seqBeats = - !!seq && - !!seq.active && - String(seq.advance_mode || "").toLowerCase() === "beats"; + const seqBeats = !!seq && !!seq.active; let out = line; if (seqBeats) { const nLanes = Number(seq && seq.num_lanes); @@ -122,7 +119,6 @@ function formatSequenceBeatFractionsForLog(status) { const seq = /** @type {Record|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); diff --git a/src/static/groups.js b/src/static/groups.js index 421288f..52f0e36 100644 --- a/src/static/groups.js +++ b/src/static/groups.js @@ -1,8 +1,27 @@ // Device groups: members (MAC ids) + Wi‑Fi driver defaults; persisted via /groups. +// Without ``profile_id``, a group is shared across all profiles; with ``profile_id`` it is listed only for that profile. + +async function getCurrentProfileIdForGroups() { + try { + const res = await fetch('/profiles/current', { + headers: { Accept: 'application/json' }, + credentials: 'same-origin', + }); + if (!res.ok) return null; + const data = await res.json(); + const id = data && (data.id || (data.profile && data.profile.id)); + return id != null ? String(id) : null; + } catch { + return null; + } +} async function fetchGroupsMap() { try { - const response = await fetch('/groups', { headers: { Accept: 'application/json' } }); + const response = await fetch('/groups', { + headers: { Accept: 'application/json' }, + credentials: 'same-origin', + }); if (!response.ok) return {}; const data = await response.json(); return data && typeof data === 'object' ? data : {}; @@ -137,6 +156,14 @@ function refreshEditGroupDebug() { } } +function syncGroupShareCheckboxFromDoc(g) { + const cb = document.getElementById('edit-group-share-all-profiles'); + if (!cb) return; + const raw = g && (g.profile_id != null ? g.profile_id : g.profileId); + const scoped = raw != null && String(raw).trim() !== ''; + cb.checked = !scoped; +} + function loadWifiFieldsFromGroup(g) { const wName = document.getElementById('edit-group-wifi-driver-name'); const wLeds = document.getElementById('edit-group-wifi-num-leds'); @@ -189,7 +216,10 @@ async function openEditGroupModal(groupId, groupDoc) { let g = groupDoc; if (!g || typeof g !== 'object') { try { - const response = await fetch(`/groups/${encodeURIComponent(groupId)}`); + const response = await fetch(`/groups/${encodeURIComponent(groupId)}`, { + credentials: 'same-origin', + headers: { Accept: 'application/json' }, + }); if (response.ok) g = await response.json(); } catch (e) { console.error(e); @@ -217,6 +247,7 @@ async function openEditGroupModal(groupId, groupDoc) { }); renderGroupDevicesEditor(editor, window.__editGroupDeviceRows, dm); loadWifiFieldsFromGroup(g); + syncGroupShareCheckboxFromDoc(g); refreshEditGroupDebug(); if (modal) modal.classList.add('active'); } @@ -259,8 +290,13 @@ function renderGroupsList(groups) { const label = document.createElement('span'); const devs = Array.isArray(g.devices) ? g.devices : []; label.textContent = `${g.name || gid} (${devs.length} device${devs.length === 1 ? '' : 's'})`; - label.style.flex = '1'; + const meta = document.createElement('div'); + meta.className = 'muted-text'; + meta.style.fontSize = '0.8em'; + const rawPid = g.profile_id != null ? g.profile_id : g.profileId; + const scoped = rawPid != null && String(rawPid).trim() !== ''; + meta.textContent = scoped ? `This profile only (${rawPid})` : 'Shared across profiles'; const editBtn = document.createElement('button'); editBtn.className = 'btn btn-secondary btn-small'; editBtn.textContent = 'Edit'; @@ -342,7 +378,10 @@ function renderGroupsList(groups) { delBtn.addEventListener('click', async () => { if (!confirm(`Delete group "${g.name || gid}"? Zones referencing it may need updating.`)) return; try { - const res = await fetch(`/groups/${encodeURIComponent(gid)}`, { method: 'DELETE' }); + const res = await fetch(`/groups/${encodeURIComponent(gid)}`, { + method: 'DELETE', + credentials: 'same-origin', + }); if (res.ok) await loadGroupsModal(); else { const data = await res.json().catch(() => ({})); @@ -354,7 +393,12 @@ function renderGroupsList(groups) { } }); - row.appendChild(label); + const left = document.createElement('div'); + left.style.flex = '1'; + left.style.minWidth = '0'; + left.appendChild(label); + left.appendChild(meta); + row.appendChild(left); row.appendChild(editBtn); row.appendChild(brightBtn); row.appendChild(applyBtn); @@ -433,11 +477,16 @@ document.addEventListener('DOMContentLoaded', () => { const createHandler = async () => { const name = newNameInput && newNameInput.value.trim(); if (!name) return; + const profileOnly = document.getElementById('new-group-profile-only'); try { const res = await fetch('/groups', { method: 'POST', + credentials: 'same-origin', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, - body: JSON.stringify({ name }), + body: JSON.stringify({ + name, + profile_scoped: !!(profileOnly && profileOnly.checked), + }), }); const data = await res.json().catch(() => ({})); if (!res.ok) { @@ -445,6 +494,7 @@ document.addEventListener('DOMContentLoaded', () => { return; } if (newNameInput) newNameInput.value = ''; + if (profileOnly) profileOnly.checked = false; await loadGroupsModal(); } catch (e) { console.error(e); @@ -466,9 +516,18 @@ document.addEventListener('DOMContentLoaded', () => { const { gid, payload } = collectGroupEditPayload(); if (!gid) return; + const shareCb = document.getElementById('edit-group-share-all-profiles'); + if (shareCb && shareCb.checked) { + payload.profile_id = null; + } else { + const pid = await getCurrentProfileIdForGroups(); + payload.profile_id = pid || null; + } + try { const res = await fetch(`/groups/${encodeURIComponent(gid)}`, { method: 'PUT', + credentials: 'same-origin', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify(payload), }); diff --git a/src/static/presets.js b/src/static/presets.js index b36105e..cd5c67c 100644 --- a/src/static/presets.js +++ b/src/static/presets.js @@ -157,7 +157,7 @@ function tabDeviceNamesFromSection(section) { : []; } -/** Device names for ``presetId`` on the current zone tab (per-preset groups or zone default). */ +/** Device names for ``presetId`` on the current zone tab (zone ``group_ids`` for presets, else tab devices). */ async function deviceNamesForPresetOnCurrentZone(presetId) { const section = document.querySelector('.presets-section[data-zone-id]'); const fallback = tabDeviceNamesFromSection(section); @@ -176,11 +176,11 @@ async function deviceNamesForPresetOnCurrentZone(presetId) { } } -function formatPresetTargetGroupsLine(zoneDoc, presetId, groupsMap) { +function formatPresetTargetGroupsLine(zoneDoc, groupsMap) { const zm = window.zonesManager; const gids = zm && typeof zm.effectiveGroupIdsForZonePreset === 'function' - ? zm.effectiveGroupIdsForZonePreset(zoneDoc, presetId) + ? zm.effectiveGroupIdsForZonePreset(zoneDoc || {}) : Array.isArray(zoneDoc && zoneDoc.group_ids) ? zoneDoc.group_ids.map((x) => String(x).trim()).filter((x) => x.length > 0) : []; @@ -242,6 +242,7 @@ document.addEventListener('DOMContentLoaded', () => { const presetRemoveFromTabButton = document.getElementById('preset-remove-from-zone-btn'); const presetSaveButton = document.getElementById('preset-save-btn'); const presetAddFromPaletteButton = document.getElementById('preset-add-from-palette-btn'); + const presetBackgroundFromPaletteButton = document.getElementById('preset-background-from-palette-btn'); if (!presetsButton || !presetsModal || !presetsList || !presetSaveButton) { return; @@ -253,6 +254,8 @@ document.addEventListener('DOMContentLoaded', () => { let cachedPatterns = {}; let currentPresetColors = []; // Track colors for the current preset let currentPresetPaletteRefs = []; // Palette index refs per color (null for direct colors) + let currentBackgroundPaletteRef = null; + let bgPaletteResolveGen = 0; // Function to get max colors for current pattern const getMaxColors = () => { @@ -326,6 +329,10 @@ document.addEventListener('DOMContentLoaded', () => { presetBackgroundButton.style.backgroundColor = color; presetBackgroundButton.style.color = '#fff'; presetBackgroundButton.style.borderColor = 'rgba(255, 255, 255, 0.6)'; + presetBackgroundButton.title = + currentBackgroundPaletteRef != null + ? `Background from profile palette (index ${currentBackgroundPaletteRef}); click to pick a custom colour` + : 'Choose background colour'; }; const updateDelayVisibilityForManualMode = () => { @@ -640,9 +647,28 @@ document.addEventListener('DOMContentLoaded', () => { presetBrightnessInput.value = preset.brightness || 0; presetDelayInput.value = preset.delay || 0; if (presetBackgroundInput) { + const rawBgRef = preset.background_palette_ref ?? preset.backgroundPaletteRef; + let bgRef = null; + if (rawBgRef != null && rawBgRef !== '') { + const n = typeof rawBgRef === 'number' ? rawBgRef : parseInt(String(rawBgRef), 10); + if (Number.isInteger(n) && n >= 0) { + bgRef = n; + } + } + currentBackgroundPaletteRef = bgRef; presetBackgroundInput.value = coercePresetBackground(preset); + updatePresetBackgroundButton(); + const gen = ++bgPaletteResolveGen; + void getCurrentProfilePaletteColors().then((pal) => { + if (gen !== bgPaletteResolveGen || !presetBackgroundInput) { + return; + } + presetBackgroundInput.value = resolvePresetBackgroundHex(preset, pal); + updatePresetBackgroundButton(); + }); + } else { + updatePresetBackgroundButton(); } - updatePresetBackgroundButton(); if (presetManualModeInput) { const autoVal = typeof preset.auto === 'boolean' ? preset.auto : true; presetManualModeInput.checked = !autoVal; @@ -714,6 +740,7 @@ document.addEventListener('DOMContentLoaded', () => { }; const clearForm = () => { + bgPaletteResolveGen += 1; currentEditId = null; currentEditTabId = null; currentPresetColors = []; @@ -742,9 +769,6 @@ document.addEventListener('DOMContentLoaded', () => { if (presetManualBeatNInput) { presetManualBeatNInput.value = '1'; } - if (presetBackgroundInput) { - presetBackgroundInput.value = '#000000'; - } updatePresetBackgroundButton(); updateManualModeAvailability(); // Re-enable name and pattern when clearing (for new preset) @@ -825,6 +849,7 @@ document.addEventListener('DOMContentLoaded', () => { brightness: presetBrightnessInput ? parseInt(presetBrightnessInput.value, 10) || 0 : 0, delay: presetDelayInput ? parseInt(presetDelayInput.value, 10) || 0 : 0, background: presetBackgroundInput ? presetBackgroundInput.value : '#000000', + background_palette_ref: currentBackgroundPaletteRef != null ? currentBackgroundPaletteRef : null, auto: presetManualModeInput ? !presetManualModeInput.checked : true, manual_beat_n: (() => { if (!presetManualBeatNInput) return 1; @@ -1302,7 +1327,15 @@ document.addEventListener('DOMContentLoaded', () => { throw new Error('Failed to load zone'); } const tabData = await tabResponse.json(); - + const kind = + typeof window.normalizeZoneContentKind === 'function' + ? window.normalizeZoneContentKind(tabData) + : null; + if (kind === 'sequences') { + alert('This zone is for sequences only. Add a sequence from the zone Edit menu instead.'); + return; + } + // Normalize to flat array to check and update usage let flat = []; if (Array.isArray(tabData.presets_flat)) { @@ -1324,9 +1357,6 @@ 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}`, { @@ -1383,6 +1413,7 @@ document.addEventListener('DOMContentLoaded', () => { presetBackgroundInput.click(); }); presetBackgroundInput.addEventListener('input', () => { + currentBackgroundPaletteRef = null; updatePresetBackgroundButton(); }); } @@ -1462,10 +1493,6 @@ document.addEventListener('DOMContentLoaded', () => { const ref = parseInt(row.dataset.paletteIndex, 10); if (!color || !Number.isInteger(ref)) return; - if (currentPresetColors.includes(color) && currentPresetPaletteRefs.includes(ref)) { - alert('That palette color is already linked.'); - return; - } const maxColors = getMaxColors(); if (currentPresetColors.length >= maxColors) { alert(`This pattern allows a maximum of ${maxColors} color${maxColors !== 1 ? 's' : ''}.`); @@ -1479,7 +1506,72 @@ document.addEventListener('DOMContentLoaded', () => { }); } catch (err) { console.error('Failed to add from palette:', err); - alert('Failed to load palette colors.'); + alert('Failed to load palette colours.'); + } + }); + } + + if (presetBackgroundFromPaletteButton) { + presetBackgroundFromPaletteButton.addEventListener('click', async () => { + try { + const paletteColors = await getCurrentProfilePaletteColors(); + if (!Array.isArray(paletteColors) || paletteColors.length === 0) { + alert('No profile palette colours available.'); + return; + } + + const modal = document.createElement('div'); + modal.className = 'modal active modal-child-overlay'; + modal.innerHTML = ` + + `; + document.body.appendChild(modal); + + const list = modal.querySelector('#pick-bg-palette-list'); + paletteColors.forEach((color, idx) => { + const row = document.createElement('div'); + row.className = 'profiles-row'; + row.style.display = 'flex'; + row.style.alignItems = 'center'; + row.style.gap = '0.75rem'; + row.dataset.paletteIndex = String(idx); + row.dataset.paletteColor = color; + row.innerHTML = ` +
+ ${color} + + `; + list.appendChild(row); + }); + + const close = () => modal.remove(); + modal.querySelector('#pick-bg-palette-close-btn').addEventListener('click', close); + + list.addEventListener('click', (e) => { + const btn = e.target.closest('button'); + if (!btn) return; + const row = e.target.closest('[data-palette-index]'); + if (!row) return; + const color = row.dataset.paletteColor; + const ref = parseInt(row.dataset.paletteIndex, 10); + if (!color || !Number.isInteger(ref)) return; + + currentBackgroundPaletteRef = ref; + if (presetBackgroundInput) { + presetBackgroundInput.value = color; + } + updatePresetBackgroundButton(); + close(); + }); + } catch (err) { + console.error('Failed to pick background from palette:', err); + alert('Failed to load palette colours.'); } }); } @@ -1663,6 +1755,26 @@ const coercePresetBackground = (preset) => { return '#000000'; }; +/** Resolved background hex; uses ``background_palette_ref`` when set and palette is available. */ +const resolvePresetBackgroundHex = (preset, paletteColors) => { + if (!preset || typeof preset !== 'object') { + return coercePresetBackground(preset); + } + const rawRef = + preset.background_palette_ref !== undefined && preset.background_palette_ref !== null + ? preset.background_palette_ref + : preset.backgroundPaletteRef; + const ref = typeof rawRef === 'number' ? rawRef : parseInt(String(rawRef != null ? rawRef : ''), 10); + const pal = Array.isArray(paletteColors) ? paletteColors : []; + if (Number.isInteger(ref) && ref >= 0 && ref < pal.length && pal[ref]) { + const c = String(pal[ref]).trim(); + if (/^#[0-9a-fA-F]{6}$/i.test(c)) { + return c.toUpperCase(); + } + } + return coercePresetBackground(preset); +}; + /** Audio beat stride for manual presets (led-controller only; firmware ignores this key). */ const coerceManualBeatN = (preset) => { if (!preset || typeof preset !== 'object') return 1; @@ -1695,7 +1807,7 @@ const sendPresetViaEspNow = async ( const wirePresetId = devicePresetId != null ? String(devicePresetId) : String(presetId); const presetAuto = coercePresetAuto(preset); - const presetBackground = coercePresetBackground(preset); + const presetBackground = resolvePresetBackgroundHex(preset, paletteColors); const presetMessage = { v: '1', presets: { @@ -2034,7 +2146,11 @@ const renderTabPresets = async (zoneId, options = {}) => { } const tabData = await tabResponse.json(); const groupsMapStrip = groupsStripRes.ok ? await groupsStripRes.json() : {}; - + const ck = + typeof window.normalizeZoneContentKind === 'function' + ? window.normalizeZoneContentKind(tabData) + : null; + // Get presets - support both 2D grid and flat array (for backward compatibility) let presetGrid = tabData.presets; if (!presetGrid || !Array.isArray(presetGrid)) { @@ -2045,6 +2161,9 @@ const renderTabPresets = async (zoneId, options = {}) => { // It's a flat array, convert to grid presetGrid = arrayToGrid(presetGrid, 3); } + if (ck === 'sequences') { + presetGrid = []; + } if (!presetsResponse.ok) { throw new Error('Failed to load presets'); @@ -2122,13 +2241,25 @@ const renderTabPresets = async (zoneId, options = {}) => { const validIdSet = new Set(flatPresets.map((id) => String(id))); pruneZonePresetSelection(zoneId, validIdSet); + const hasSeq = + Array.isArray(tabData.sequence_ids) && + tabData.sequence_ids.some((x) => x != null && String(x).trim()); + if (flatPresets.length === 0) { - // Show empty message if this zone has no presets const empty = document.createElement('p'); empty.className = 'muted-text'; empty.style.gridColumn = '1 / -1'; // Span all columns - empty.textContent = 'No presets added to this zone. Open the zone\'s Edit menu and click "Add Preset" to add one.'; - presetsList.appendChild(empty); + if (ck === 'sequences') { + if (!hasSeq) { + empty.textContent = + "No sequences on this zone yet. Open the zone's Edit menu to add one."; + presetsList.appendChild(empty); + } + } else { + empty.textContent = + 'No presets added to this zone. Open the zone\'s Edit menu and click "Add Preset" to add one.'; + presetsList.appendChild(empty); + } } else { flatPresets.forEach((presetId) => { const preset = allPresets[presetId]; @@ -2138,6 +2269,7 @@ const renderTabPresets = async (zoneId, options = {}) => { const displayPreset = { ...preset, colors: resolveColorsWithPaletteRefs(preset.colors, preset.palette_refs, paletteColors), + background: resolvePresetBackgroundHex(preset, paletteColors), }; const wrapper = createPresetButton( presetId, @@ -2153,7 +2285,7 @@ const renderTabPresets = async (zoneId, options = {}) => { }); } - if (typeof window.appendZoneSequenceTiles === 'function') { + if (typeof window.appendZoneSequenceTiles === 'function' && ck !== 'presets') { await window.appendZoneSequenceTiles(zoneId, tabData, allPresets, paletteColors, presetsList); } } catch (error) { @@ -2199,7 +2331,7 @@ const createPresetButton = (presetId, preset, zoneId, isSelected, tabData, group presetNameLabel.className = 'pattern-button-label'; button.appendChild(presetNameLabel); - const groupsText = formatPresetTargetGroupsLine(tabData || {}, presetId, groupsMap || {}); + const groupsText = formatPresetTargetGroupsLine(tabData || {}, groupsMap || {}); if (groupsText) { const groupsSpan = document.createElement('span'); groupsSpan.className = 'preset-tile-groups'; @@ -2253,9 +2385,6 @@ const createPresetButton = (presetId, preset, zoneId, isSelected, tabData, group button.addEventListener('click', () => { if (isDraggingPreset) return; console.info('Preset button pressed', { zoneId, presetId, name: (preset && preset.name) || presetId }); - if (typeof window.stopZoneSequencePlayback === 'function') { - window.stopZoneSequencePlayback(); - } const presetsListEl = document.getElementById('presets-list-zone'); ensureZonePresetSelection(zoneId); const z = String(zoneId); @@ -2421,12 +2550,6 @@ 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' }, diff --git a/src/static/sequences.js b/src/static/sequences.js index 72a8c66..d6ff8ee 100644 --- a/src/static/sequences.js +++ b/src/static/sequences.js @@ -1,4 +1,4 @@ -// Sequences: lanes (parallel preset chains), shared groups, time or beat advance. +// Sequences: lanes (parallel preset chains); advance is always by audio beats or simulated BPM. // Debug: in the browser console run setSequenceDebug(true) — toggling logs 1 (on) or 0 (off). const SEQ_DEBUG_STORAGE_KEY = 'led-controller-sequence-debug'; @@ -24,7 +24,7 @@ function stopSequenceEditorBpmPoll() { async function refreshSequenceEditorBpmDisplay() { const live = document.getElementById('sequence-editor-bpm-live'); const panel = document.getElementById('sequence-editor-beats-panel'); - if (!live || !panel || panel.style.display === 'none') return; + if (!live || !panel) return; try { const res = await fetch('/api/audio/status', { headers: { Accept: 'application/json' } }); const j = res.ok ? await res.json() : {}; @@ -39,7 +39,7 @@ async function refreshSequenceEditorBpmDisplay() { : NaN; if (!running) { live.textContent = - 'Audio detector is stopped — start it from the header to drive beat mode and show BPM.'; + 'Audio detector is stopped — the sequence uses simulated beats at the BPM you set above.'; return; } if (!Number.isFinite(bpm) || bpm <= 0) { @@ -97,15 +97,13 @@ function normalizeSequenceLanes(doc) { } /** - * Log each preset in the sequence with its step beat count (for Audio beats mode this is how - * many detector beats the step runs; in Time mode the value is still the stored step beats). + * Log each preset in the sequence with its step beat count (beats per step before advancing). * @param {string} sequenceId * @param {Record} sequenceDoc * @param {Record} presetsMap */ function logSequenceSelectionPresets(sequenceId, sequenceDoc, presetsMap) { if (!sequenceDoc || typeof sequenceDoc !== 'object') return; - const adv = sequenceDoc.advance_mode === 'beats' ? 'beats' : 'time'; const lanes = normalizeSequenceLanes(sequenceDoc); const nameFor = (pid) => { const p = presetsMap && presetsMap[pid]; @@ -117,8 +115,8 @@ function logSequenceSelectionPresets(sequenceId, sequenceDoc, presetsMap) { const nm = String(sequenceDoc.name || '').trim() || sequenceId; const multi = lanes.filter((lane) => lane.some((s) => s && s.preset_id)).length > 1; - let headerLine = `Sequence "${nm}" (${sequenceId}) — advance: ${adv}`; - if (adv === 'beats' && multi) { + let headerLine = `Sequence "${nm}" (${sequenceId}) — advance: beats`; + if (multi) { headerLine += ' — header/audio beat readout follows lane 1 only (other lanes run in parallel)'; } @@ -268,11 +266,18 @@ async function resolveSequenceSendDeviceNames(zoneId, zoneDoc, groupIds) { async function requestBackendSequencePlay(sequenceId, zoneId, sequenceDoc) { // Do not call stop here: server start() already stops any prior run. A fire-and-forget // client stop can reorder after play and clear the new session (same tile re-click bug). + let bodyBpm; + if (sequenceDoc && typeof sequenceDoc === 'object' && sequenceDoc.simulated_bpm != null) { + const n = parseInt(String(sequenceDoc.simulated_bpm), 10); + if (Number.isFinite(n)) bodyBpm = Math.min(300, Math.max(30, n)); + } + const body = { zone_id: String(zoneId) }; + if (bodyBpm != null) body.simulated_bpm = bodyBpm; const res = await fetch(`/sequences/${encodeURIComponent(sequenceId)}/play`, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, credentials: 'same-origin', - body: JSON.stringify({ zone_id: String(zoneId) }), + body: JSON.stringify(body), }); if (!res.ok) { const err = await res.json().catch(() => ({})); @@ -295,7 +300,10 @@ async function fetchSequencesMap() { async function fetchGroupsMapSeq() { try { - const res = await fetch('/groups', { headers: { Accept: 'application/json' } }); + const res = await fetch('/groups', { + headers: { Accept: 'application/json' }, + credentials: 'same-origin', + }); if (!res.ok) return {}; const data = await res.json(); return data && typeof data === 'object' ? data : {}; @@ -335,8 +343,11 @@ function createSequenceTileRow(sequenceId, sequenceDoc, zoneId, zoneDoc, allPres const lanes = normalizeSequenceLanes(sequenceDoc); const nLanes = lanes.filter((l) => l.length > 0).length || 1; const nSteps = lanes.reduce((a, l) => a + l.length, 0); - const adv = sequenceDoc.advance_mode === 'beats' ? 'beats' : 'time'; - sub.textContent = `${nLanes} lane${nLanes === 1 ? '' : 's'} · ${nSteps} step${nSteps === 1 ? '' : 's'} · ${adv}`; + const simRaw = sequenceDoc.simulated_bpm; + let sim = parseInt(String(simRaw != null ? simRaw : 120), 10); + if (!Number.isFinite(sim)) sim = 120; + sim = Math.min(300, Math.max(30, sim)); + sub.textContent = `${nLanes} lane${nLanes === 1 ? '' : 's'} · ${nSteps} step${nSteps === 1 ? '' : 's'} · beats · ${sim} BPM sim`; button.appendChild(sub); button.addEventListener('click', () => { @@ -443,6 +454,14 @@ async function addSequenceToTab(sequenceId, zoneId) { const tabResponse = await fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' } }); if (!tabResponse.ok) throw new Error('Failed to load zone'); const tabData = await tabResponse.json(); + const kind = + typeof window.normalizeZoneContentKind === 'function' + ? window.normalizeZoneContentKind(tabData) + : null; + if (kind === 'presets') { + alert('This zone is for presets only. Add presets from the zone Edit menu instead.'); + return; + } const list = Array.isArray(tabData.sequence_ids) ? tabData.sequence_ids.map(String) : []; if (list.includes(String(sequenceId))) { alert('Sequence is already on this zone.'); @@ -505,6 +524,16 @@ async function refreshEditTabSequencesUi(zoneId) { const zoneRes = await fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' } }); if (!zoneRes.ok) throw new Error('zone'); const zone = await zoneRes.json(); + const kind = + typeof window.normalizeZoneContentKind === 'function' + ? window.normalizeZoneContentKind(zone) + : null; + if (kind === 'presets') { + currentEl.innerHTML = + 'This zone is for presets only. Sequences are hidden.'; + addEl.innerHTML = ''; + return; + } const onZone = Array.isArray(zone.sequence_ids) ? zone.sequence_ids.map(String) : []; const seqMap = await fetchSequencesMap(); const onSet = new Set(onZone); @@ -586,6 +615,77 @@ async function refreshEditTabSequencesUi(zoneId) { let sequenceEditorId = null; +/** Insert point when dragging a step row vertically within a lane. */ +function getDragAfterSequenceStepRow(container, y) { + const draggableElements = [ + ...container.querySelectorAll(':scope > .sequence-step-row:not(.dragging)'), + ]; + return draggableElements.reduce( + (closest, child) => { + const box = child.getBoundingClientRect(); + const offset = y - box.top - box.height / 2; + if (offset < 0 && offset > closest.offset) { + return { offset, element: child }; + } + return closest; + }, + { offset: Number.NEGATIVE_INFINITY, element: null }, + ).element; +} + +/** Reorder step rows within one lane (DOM order = save order). */ +function wireSequenceLaneStepsDragReorder(stepsHost) { + if (!stepsHost || stepsHost.dataset.sequenceLaneDndWired === '1') return; + stepsHost.dataset.sequenceLaneDndWired = '1'; + let draggedRow = null; + + stepsHost.addEventListener('dragstart', (e) => { + const handle = e.target.closest('.sequence-step-drag-handle'); + if (!handle || !stepsHost.contains(handle)) return; + const row = handle.closest('.sequence-step-row'); + if (!row || !stepsHost.contains(row)) return; + draggedRow = row; + row.classList.add('dragging'); + try { + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', 'sequence-step'); + } catch (_) { + /* ignore */ + } + }); + + stepsHost.addEventListener('dragend', () => { + if (draggedRow) draggedRow.classList.remove('dragging'); + draggedRow = null; + }); + + stepsHost.addEventListener('dragenter', (e) => { + if (!draggedRow || !stepsHost.contains(draggedRow)) return; + e.preventDefault(); + }); + + stepsHost.addEventListener('dragover', (e) => { + if (!draggedRow || !stepsHost.contains(draggedRow)) return; + e.preventDefault(); + try { + e.dataTransfer.dropEffect = 'move'; + } catch (_) { + /* ignore */ + } + const afterElement = getDragAfterSequenceStepRow(stepsHost, e.clientY); + if (afterElement == null) { + stepsHost.appendChild(draggedRow); + } else if (afterElement !== draggedRow) { + stepsHost.insertBefore(draggedRow, afterElement); + } + }); + + stepsHost.addEventListener('drop', (e) => { + if (!draggedRow) return; + e.preventDefault(); + }); +} + function renderSequenceStepRow(presetsMap, step) { const row = document.createElement('div'); row.className = 'sequence-step-row profiles-row'; @@ -594,6 +694,15 @@ function renderSequenceStepRow(presetsMap, step) { const top = document.createElement('div'); top.style.cssText = 'display:flex;align-items:center;gap:0.75rem;flex-wrap:wrap;'; + + const dragHandle = document.createElement('span'); + dragHandle.className = 'sequence-step-drag-handle'; + dragHandle.draggable = true; + dragHandle.title = 'Drag to reorder'; + dragHandle.textContent = '⠿'; + dragHandle.style.cssText = + 'cursor:grab;user-select:none;flex-shrink:0;line-height:1;opacity:0.75;padding:0.15rem 0.25rem;'; + const presetWrap = document.createElement('div'); presetWrap.style.cssText = 'display:flex;align-items:center;gap:0.5rem;flex-wrap:wrap;'; const pl = document.createElement('label'); @@ -658,6 +767,7 @@ function renderSequenceStepRow(presetsMap, step) { ); }); + top.appendChild(dragHandle); top.appendChild(presetWrap); top.appendChild(beatWrap); top.appendChild(editPresetBtn); @@ -720,6 +830,7 @@ function renderSequenceLane(laneIndex, laneSteps, laneGroupIds, presetsMap, grou steps.forEach((s) => { stepsHost.appendChild(renderSequenceStepRow(presetsMap, s)); }); + wireSequenceLaneStepsDragReorder(stepsHost); wrap.appendChild(stepsHost); return wrap; } @@ -763,57 +874,22 @@ function collectLanesFromEditor() { return { lanes, lanes_group_ids }; } -function updateSequenceEditorTimeBpmHint() { - const hint = document.getElementById('sequence-editor-time-bpm-hint'); - const durInput = document.getElementById('sequence-editor-duration'); - const sel = document.getElementById('sequence-editor-advance-mode'); - if (!hint) return; - if (sel && sel.value === 'beats') { - hint.textContent = ''; - return; - } - const raw = durInput && durInput.value; - const parsed = parseInt(String(raw != null ? raw : '').trim(), 10); - if (!Number.isFinite(parsed) || parsed <= 0) { - hint.textContent = ''; - return; - } - const ms = Math.max(200, parsed); - const bpm = 60000 / ms; - let rounded; - if (bpm >= 100) rounded = Math.round(bpm * 10) / 10; - else if (bpm >= 10) rounded = Math.round(bpm * 100) / 100; - else rounded = Math.round(bpm * 1000) / 1000; - hint.textContent = `${rounded} BPM`; -} - -function syncSequenceAdvanceModeUi() { - const sel = document.getElementById('sequence-editor-advance-mode'); - const dw = document.getElementById('sequence-editor-duration-wrap'); - const tw = document.getElementById('sequence-editor-transition-wrap'); +function syncSequenceBeatsPanel() { const panel = document.getElementById('sequence-editor-beats-panel'); - const beatsMode = sel && sel.value === 'beats'; - if (dw) dw.style.display = beatsMode ? 'none' : 'block'; - if (tw) tw.style.display = beatsMode ? 'none' : 'block'; stopSequenceEditorBpmPoll(); - if (beatsMode && panel) { - panel.style.display = 'block'; + if (panel) { void refreshSequenceEditorBpmDisplay(); sequenceBpmPollTimer = setInterval(() => void refreshSequenceEditorBpmDisplay(), 1500); - } else if (panel) { - panel.style.display = 'none'; } - updateSequenceEditorTimeBpmHint(); } async function openSequenceEditor(sequenceId, existing) { sequenceEditorId = sequenceId != null && String(sequenceId).length ? String(sequenceId) : null; const modal = document.getElementById('sequence-editor-modal'); const nameInput = document.getElementById('sequence-editor-name'); - const durInput = document.getElementById('sequence-editor-duration'); - const advanceSel = document.getElementById('sequence-editor-advance-mode'); + const simBpmInput = document.getElementById('sequence-editor-simulated-bpm'); const lanesHost = document.getElementById('sequence-editor-lanes'); - if (!modal || !nameInput || !durInput || !lanesHost) return; + if (!modal || !nameInput || !lanesHost) return; const presetsRes = await fetch('/presets', { headers: { Accept: 'application/json' } }); const presetsMap = presetsRes.ok ? await presetsRes.json() : {}; @@ -841,16 +917,12 @@ async function openSequenceEditor(sequenceId, existing) { doc = {}; } nameInput.value = doc.name || ''; - durInput.value = doc.step_duration_ms != null ? String(doc.step_duration_ms) : '3000'; - const trInput = document.getElementById('sequence-editor-transition'); - if (trInput) { - const tr = doc.sequence_transition != null ? Number(doc.sequence_transition) : 500; - trInput.value = String(Number.isFinite(tr) ? Math.min(60000, Math.max(0, Math.floor(tr))) : 500); + if (simBpmInput) { + const v = parseInt(String(doc.simulated_bpm != null ? doc.simulated_bpm : 120), 10); + const clamped = Number.isFinite(v) ? Math.min(300, Math.max(30, v)) : 120; + simBpmInput.value = String(clamped); } - if (advanceSel) { - advanceSel.value = doc.advance_mode === 'beats' ? 'beats' : 'time'; - } - syncSequenceAdvanceModeUi(); + syncSequenceBeatsPanel(); const lanes = normalizeSequenceLanes(doc); lanesHost.innerHTML = ''; @@ -888,9 +960,7 @@ function resolveZoneIdForPresetStripRefresh() { async function saveSequenceEditor() { const nameInput = document.getElementById('sequence-editor-name'); - const durInput = document.getElementById('sequence-editor-duration'); - const trInput = document.getElementById('sequence-editor-transition'); - const advanceSel = document.getElementById('sequence-editor-advance-mode'); + const simBpmInput = document.getElementById('sequence-editor-simulated-bpm'); const { lanes, lanes_group_ids } = collectLanesFromEditor(); const idxs = []; lanes.forEach((l, i) => { @@ -902,17 +972,18 @@ async function saveSequenceEditor() { } const nonEmpty = idxs.map((i) => lanes[i].filter((s) => s && s.preset_id)); const nonEmptyLg = idxs.map((i) => (lanes_group_ids[i] ? [...lanes_group_ids[i]] : [])); - const advance_mode = advanceSel && advanceSel.value === 'beats' ? 'beats' : 'time'; - const trRaw = trInput && trInput.value ? parseInt(trInput.value, 10) : 500; - const sequence_transition = Math.min(60000, Math.max(0, Number.isFinite(trRaw) ? trRaw : 500)); + let simulated_bpm = 120; + if (simBpmInput && simBpmInput.value) { + const n = parseInt(String(simBpmInput.value).trim(), 10); + if (Number.isFinite(n)) simulated_bpm = Math.min(300, Math.max(30, n)); + } const payload = { name: nameInput ? nameInput.value.trim() : '', lanes: nonEmpty, lanes_group_ids: nonEmptyLg, group_ids: nonEmptyLg[0] ? [...nonEmptyLg[0]] : [], - advance_mode, - step_duration_ms: Math.max(200, parseInt(durInput && durInput.value ? durInput.value : '3000', 10) || 3000), - sequence_transition, + advance_mode: 'beats', + simulated_bpm, loop: true, steps: nonEmpty.length === 1 ? nonEmpty[0] : [], }; @@ -1089,16 +1160,6 @@ document.addEventListener('DOMContentLoaded', () => { if (edSave) edSave.addEventListener('click', () => saveSequenceEditor()); if (edDel) edDel.addEventListener('click', () => deleteCurrentSequence()); - const advanceSel = document.getElementById('sequence-editor-advance-mode'); - if (advanceSel) { - advanceSel.addEventListener('change', () => syncSequenceAdvanceModeUi()); - } - const durForBpmHint = document.getElementById('sequence-editor-duration'); - if (durForBpmHint) { - durForBpmHint.addEventListener('input', () => updateSequenceEditorTimeBpmHint()); - durForBpmHint.addEventListener('change', () => updateSequenceEditorTimeBpmHint()); - } - const edAddLane = document.getElementById('sequence-editor-add-lane-btn'); if (edAddLane) { edAddLane.addEventListener('click', async () => { diff --git a/src/static/style.css b/src/static/style.css index d0c762c..b9e7e8d 100644 --- a/src/static/style.css +++ b/src/static/style.css @@ -1620,6 +1620,14 @@ body.preset-ui-run .edit-mode-only { } } +.sequence-step-drag-handle:active { + cursor: grabbing; +} + +.sequence-step-row.dragging { + opacity: 0.65; +} + /* Settings modal */ #settings-modal .modal-content { max-width: 900px; diff --git a/src/static/zones.js b/src/static/zones.js index a46b71e..b95438c 100644 --- a/src/static/zones.js +++ b/src/static/zones.js @@ -156,7 +156,10 @@ async function fetchDevicesMap() { async function fetchGroupsMap() { try { - const response = await fetch("/groups", { headers: { Accept: "application/json" } }); + const response = await fetch("/groups", { + headers: { Accept: "application/json" }, + credentials: "same-origin", + }); if (!response.ok) return {}; const data = await response.json(); return data && typeof data === "object" ? data : {}; @@ -168,7 +171,7 @@ async function fetchGroupsMap() { /** * Resolve registry names + MACs for a zone document (``group_ids`` expands groups; - * otherwise legacy ``names``). + * otherwise ``names`` only). */ async function computeZoneTargets(zone) { const dm = await fetchDevicesMap(); @@ -208,6 +211,27 @@ async function computeZoneTargets(zone) { }; } +/** Tab device list for sequences: zone ``group_ids`` first, else legacy ``names`` only. */ +async function computeZoneNamesTargets(zone) { + const gids = Array.isArray(zone && zone.group_ids) + ? zone.group_ids.map((x) => String(x).trim()).filter((x) => x.length > 0) + : []; + if (gids.length > 0) { + const t = await resolveTargetsFromGroupIds(gids); + return { + names: Array.isArray(t.names) ? t.names : [], + macs: Array.isArray(t.macs) ? [...new Set(t.macs.filter(Boolean))] : [], + }; + } + const dm = await fetchDevicesMap(); + const zoneNames = Array.isArray(zone && zone.names) ? zone.names : []; + const rows = namesToRows(zoneNames, dm); + return { + names: rowsToNames(rows), + macs: [...new Set(rows.map((r) => r.mac).filter(Boolean))], + }; +} + function normalizeDeviceMac(raw) { return String(raw || "") .trim() @@ -231,13 +255,8 @@ function tabPresetIdsInZoneDoc(zoneDoc) { 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); - } +/** Group ids used for standalone presets on this zone: zone ``group_ids`` only. */ +function effectiveGroupIdsForZonePreset(zoneDoc) { return Array.isArray(zoneDoc && zoneDoc.group_ids) ? zoneDoc.group_ids.map((x) => String(x).trim()).filter((x) => x.length > 0) : []; @@ -273,9 +292,10 @@ async function resolveTargetsFromGroupIds(groupIds) { return { names, macs }; } -/** Device names for one zone preset slot (effective groups, or whole zone by name when no groups). */ +/** Device names for standalone presets: zone ``group_ids``, or all devices on the tab (``names``). */ async function resolveDeviceNamesForZonePreset(zoneDoc, presetId) { - const gids = effectiveGroupIdsForZonePreset(zoneDoc, presetId); + void presetId; + const gids = effectiveGroupIdsForZonePreset(zoneDoc); if (gids.length) { const t = await resolveTargetsFromGroupIds(gids); if (t.names.length) return t.names; @@ -284,45 +304,17 @@ async function resolveDeviceNamesForZonePreset(zoneDoc, presetId) { return Array.isArray(zt.names) ? zt.names.slice() : []; } -/** Union of all devices targeted by any preset on the zone (for tab strip + sequence scope). */ +/** Union of devices targeted by standalone presets on the zone (same as zone preset targeting). */ 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 }; + return await computeZoneTargets(zoneDoc); } /** - * Device names for one sequence step. Empty stepGroupIds => all zone names. - * Otherwise: devices in those groups intersected with the zone's target MACs. + * 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``). */ async function resolveSequenceStepDeviceNames(zone, stepGroupIds) { - const zoneT = await computeZonePresetUnionTargets(zone); + const zoneT = await computeZoneNamesTargets(zone); const names = Array.isArray(zoneT.names) ? zoneT.names : []; const macs = Array.isArray(zoneT.macs) ? zoneT.macs : []; const gids = Array.isArray(stepGroupIds) @@ -361,7 +353,7 @@ async function resolveSequenceStepDeviceNames(zone, stepGroupIds) { } async function resolveZoneDeviceMacsFromZoneData(zone) { - const t = await computeZonePresetUnionTargets(zone); + const t = await computeZoneTargets(zone); return t.macs; } @@ -408,67 +400,6 @@ function rowsToNames(rows) { return (rows || []).map((r) => String(r.name || "").trim()).filter((n) => n.length > 0); } -function renderZoneDevicesEditor(containerEl, rows, devicesMap) { - if (!containerEl) return; - containerEl.innerHTML = ""; - const entries = Object.entries(devicesMap || {}).sort(([a], [b]) => a.localeCompare(b)); - - rows.forEach((row, idx) => { - const div = document.createElement("div"); - div.className = "zone-device-row profiles-row"; - const label = document.createElement("span"); - label.className = "zone-device-row-label"; - const strong = document.createElement("strong"); - strong.textContent = row.name || "—"; - label.appendChild(strong); - label.appendChild(document.createTextNode(" ")); - const sub = document.createElement("span"); - sub.className = "muted-text"; - sub.textContent = row.mac ? row.mac : "(not in registry)"; - label.appendChild(sub); - - const rm = document.createElement("button"); - rm.type = "button"; - rm.className = "btn btn-danger btn-small"; - rm.textContent = "Remove"; - rm.addEventListener("click", () => { - rows.splice(idx, 1); - renderZoneDevicesEditor(containerEl, rows, devicesMap); - }); - div.appendChild(label); - div.appendChild(rm); - containerEl.appendChild(div); - }); - - const macsInRows = new Set(rows.map((r) => r.mac).filter(Boolean)); - const addWrap = document.createElement("div"); - addWrap.className = "zone-devices-add profiles-actions"; - const sel = document.createElement("select"); - sel.className = "zone-device-add-select"; - sel.appendChild(new Option("Add device…", "")); - entries.forEach(([mac, d]) => { - if (macsInRows.has(mac)) return; - const labelName = d && d.name ? String(d.name).trim() : ""; - const optLabel = labelName ? `${labelName} — ${mac}` : mac; - sel.appendChild(new Option(optLabel, mac)); - }); - const addBtn = document.createElement("button"); - addBtn.type = "button"; - addBtn.className = "btn btn-primary btn-small"; - addBtn.textContent = "Add"; - addBtn.addEventListener("click", () => { - const mac = sel.value; - if (!mac || !devicesMap[mac]) return; - const n = String((devicesMap[mac].name || "").trim() || mac); - rows.push({ mac, name: n }); - sel.value = ""; - renderZoneDevicesEditor(containerEl, rows, devicesMap); - }); - addWrap.appendChild(sel); - addWrap.appendChild(addBtn); - containerEl.appendChild(addWrap); -} - function renderZoneGroupsEditor(containerEl, rows, groupsMap) { if (!containerEl) return; containerEl.innerHTML = ""; @@ -530,13 +461,6 @@ function renderZoneGroupsEditor(containerEl, rows, groupsMap) { containerEl.appendChild(addWrap); } -/** Default group for a new zone (empty if no groups exist yet). */ -async function defaultGroupIdsForNewTab() { - const gm = await fetchGroupsMap(); - const ids = Object.keys(gm || {}).sort((a, b) => parseInt(a, 10) - parseInt(b, 10)); - return ids.length ? [ids[0]] : []; -} - /** Read zone device names from the presets section (JSON attr preferred; legacy comma list fallback). */ function parseTabDeviceNames(section) { if (!section) return []; @@ -566,6 +490,32 @@ function escapeHtmlAttr(s) { .replace(/ { + if (el) el.style.display = show ? '' : 'none'; + }; + vis(groupsBlock, true); + if (!kind) { + vis(presetsBlock, true); + vis(seqBlock, true); + return; + } + vis(presetsBlock, kind === 'presets'); + vis(seqBlock, kind === 'sequences'); +} + +window.normalizeZoneContentKind = normalizeZoneContentKind; + // Load tabs list async function loadZones() { try { @@ -623,13 +573,16 @@ function renderZonesList(tabs, tabOrder, currentZoneId) { const zone = tabs[zoneId]; if (zone) { const activeClass = zoneId === currentZoneId ? 'active' : ''; - const tabName = zone.name || `Zone ${zoneId}`; + let disp = zone.name || `Zone ${zoneId}`; + const kind = normalizeZoneContentKind(zone); + if (kind === 'presets') disp += ' · presets'; + else if (kind === 'sequences') disp += ' · sequences'; html += ` `; } @@ -669,9 +622,13 @@ function renderZonesListModal(tabs, tabOrder, currentZoneId) { row.dataset.zoneId = String(zoneId); const label = document.createElement("span"); - label.textContent = (zone && zone.name) || zoneId; + let disp = (zone && zone.name) || zoneId; + const kind = normalizeZoneContentKind(zone); + if (kind === 'presets') disp += ' · presets'; + else if (kind === 'sequences') disp += ' · sequences'; + label.textContent = disp; if (String(zoneId) === String(currentZoneId)) { - label.textContent = `✓ ${label.textContent}`; + label.textContent = `✓ ${disp}`; label.style.fontWeight = "bold"; label.style.color = "#FFD700"; } @@ -868,7 +825,7 @@ async function loadZoneContent(zoneId) { // Render zone content (presets section) const tabName = zone.name || `Zone ${zoneId}`; - const targets = await computeZonePresetUnionTargets(zone); + const targets = await computeZoneTargets(zone); const namesJsonAttr = encodeURIComponent(JSON.stringify(targets.names)); const macsJsonAttr = encodeURIComponent(JSON.stringify(targets.macs)); const legacyOk = @@ -1024,45 +981,6 @@ function tabPresetIdsInOrder(tabData) { 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; - } - 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). async function refreshEditTabPresetsUi(zoneId) { const currentEl = document.getElementById("edit-zone-presets-current"); @@ -1081,13 +999,17 @@ async function refreshEditTabPresetsUi(zoneId) { return; } const tabData = await tabRes.json(); + const kind = normalizeZoneContentKind(tabData); + if (kind === 'sequences') { + currentEl.innerHTML = + 'This zone is for sequences only. Presets are hidden.'; + addEl.innerHTML = ''; + return; + } const inTabIds = tabPresetIdsInOrder(tabData); const inTabSet = new Set(inTabIds.map((id) => String(id))); - const [presetsRes, groupsMapEdit] = await Promise.all([ - fetch("/presets", { headers: { Accept: "application/json" } }), - fetchGroupsMap(), - ]); + const presetsRes = await fetch("/presets", { headers: { Accept: "application/json" } }); const allPresets = presetsRes.ok ? await presetsRes.json() : {}; const makeRow = () => { @@ -1128,85 +1050,6 @@ async function refreshEditTabPresetsUi(zoneId) { 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); } } @@ -1268,7 +1111,6 @@ async function openEditZoneModal(zoneId, zone) { const modal = document.getElementById("edit-zone-modal"); const idInput = document.getElementById("edit-zone-id"); const nameInput = document.getElementById("edit-zone-name"); - const editor = document.getElementById("edit-zone-devices-editor"); let tabData = zone; if (!tabData || typeof tabData !== "object" || tabData.error) { @@ -1286,6 +1128,7 @@ async function openEditZoneModal(zoneId, zone) { if (idInput) idInput.value = zoneId; if (nameInput) nameInput.value = tabData.name || ""; + const groupsEditor = document.getElementById("edit-zone-groups-editor"); const groupsMap = await fetchGroupsMap(); const rawGids = Array.isArray(tabData.group_ids) ? tabData.group_ids : []; window.__editTabGroupRows = rawGids.map((gid) => { @@ -1293,20 +1136,21 @@ async function openEditZoneModal(zoneId, zone) { const g = groupsMap[id]; return { id, name: g && g.name ? String(g.name).trim() : id }; }); - renderZoneGroupsEditor(editor, window.__editTabGroupRows, groupsMap); + renderZoneGroupsEditor(groupsEditor, window.__editTabGroupRows, groupsMap); if (modal) modal.classList.add("active"); + applyZoneContentKindEditModal(normalizeZoneContentKind(tabData)); await refreshEditTabPresetsUi(zoneId); if (typeof window.refreshEditTabSequencesUi === "function") { await window.refreshEditTabSequencesUi(zoneId); } } -// Update an existing zone -async function updateZone(zoneId, name, groupIds) { +// Update an existing zone (name, group list; devices come from groups only). +async function updateZone(zoneId, name, groupRows) { try { - const gids = Array.isArray(groupIds) - ? groupIds.map((x) => String(x).trim()).filter((x) => x.length > 0) + const gids = Array.isArray(groupRows) + ? groupRows.map((r) => String(r.id || "").trim()).filter((x) => x.length > 0) : []; const response = await fetch(`/zones/${zoneId}`, { method: 'PUT', @@ -1315,8 +1159,9 @@ async function updateZone(zoneId, name, groupIds) { }, body: JSON.stringify({ name: name, - group_ids: gids, names: [], + group_ids: gids, + preset_group_ids: {}, }) }); @@ -1339,12 +1184,11 @@ async function updateZone(zoneId, name, groupIds) { } } -// Create a new zone -async function createZone(name, groupIds) { +// Create a new zone (add devices in Edit zone). ``contentKind`` is ``'presets'`` | ``'sequences'``. +async function createZone(name, contentKind) { try { - const gids = Array.isArray(groupIds) - ? groupIds.map((x) => String(x).trim()).filter((x) => x.length > 0) - : []; + const ck = + contentKind === 'sequences' || contentKind === 'presets' ? contentKind : 'presets'; const response = await fetch('/zones', { method: 'POST', headers: { @@ -1352,8 +1196,9 @@ async function createZone(name, groupIds) { }, body: JSON.stringify({ name: name, - group_ids: gids, names: [], + group_ids: [], + content_kind: ck, }) }); @@ -1434,8 +1279,12 @@ document.addEventListener('DOMContentLoaded', () => { const name = newTabNameInput.value.trim(); if (name) { - const groupIds = await defaultGroupIdsForNewTab(); - await createZone(name, groupIds); + const kindRadio = document.querySelector( + 'input[name="new-zone-content-kind"]:checked', + ); + const contentKind = + kindRadio && kindRadio.value === 'sequences' ? 'sequences' : 'presets'; + await createZone(name, contentKind); if (newTabNameInput) newTabNameInput.value = ""; } }; @@ -1462,15 +1311,10 @@ document.addEventListener('DOMContentLoaded', () => { const zoneId = idInput ? idInput.value : null; const name = nameInput ? nameInput.value.trim() : ""; - const rows = window.__editTabGroupRows || []; - const groupIds = rows.map((r) => r.id).filter(Boolean); + const groupRows = window.__editTabGroupRows || []; if (zoneId && name) { - if (groupIds.length === 0) { - alert("Add at least one device group."); - return; - } - await updateZone(zoneId, name, groupIds); + await updateZone(zoneId, name, groupRows); editZoneForm.reset(); } }); @@ -1530,10 +1374,13 @@ window.zonesManager = { resolveTabDeviceMacs: resolveZoneDeviceMacs, getCurrentZoneId: () => currentZoneId, computeZoneTargets, + computeZoneNamesTargets, computeZonePresetUnionTargets, effectiveGroupIdsForZonePreset, resolveDeviceNamesForZonePreset, resolveSequenceStepDeviceNames, + fetchGroupsMap, + renderZoneGroupsEditor, }; window.tabsManager = window.zonesManager; window.tabsManager.getCurrentTabId = () => currentZoneId; diff --git a/src/templates/index.html b/src/templates/index.html index c522253..916c62a 100644 --- a/src/templates/index.html +++ b/src/templates/index.html @@ -83,6 +83,11 @@ +
+ This zone is for + + +
- -
+
+ +
+
+
+
+
+
@@ -148,13 +159,16 @@ - +