From c832dc1647bf3afb9b2cd57299b1606e46ba7949 Mon Sep 17 00:00:00 2001 From: luciano Date: Fri, 2 Oct 2026 13:48:58 +0200 Subject: [PATCH] Phone remote: stop a scene element or the whole scene MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live elements get a ■ button on the phone: stopping clears text/image/video from their destination, gives a zone back its own content, returns the main effect to the one active before the scene (aux displays back to their own setting) and switches a mapping element off. '■ Ferma scena' clears everything the scene shows and halts the rest of the track's timeline (advanceCues no-ops) until '▶ Riprendi', which re-applies the scene at the current position, or the next track. Hand-stopped elements stay un-flagged until the timeline moves their channel on. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XVMQ2JhsJ77pGKqcvvFHqc --- src/control.js | 58 ++++++++++++++++++++++++++++++++++++++++++++++--- src/remote.css | 7 ++++++ src/remote.html | 3 ++- src/remote.js | 13 +++++++++-- 4 files changed, 75 insertions(+), 6 deletions(-) diff --git a/src/control.js b/src/control.js index 6d67e83..c0d2b0d 100644 --- a/src/control.js +++ b/src/control.js @@ -777,6 +777,9 @@ function startCues(tr) { // has nothing active at t=0. lastScene = { effect: undefined, text: undefined, image: undefined, map: undefined, video: undefined }; send({ type: 'mapZoneReset' }); // zones fall back to their Mappatura content + sceneHalted = false; + firedByHand.clear(); + sceneBaseEffect = currentEffect; // what 'stop' on the main effect returns to advanceCues(0); } function sceneActive(type, t) { @@ -832,7 +835,12 @@ function applyEffectTo(targetKey, el) { send({ type: 'auxFx', displayId: id, effectIndex: el.effectIndex }); } } +// 'Ferma scena' (phone remote): the rest of this track's timeline is skipped +// until 'Riprendi' or the next track. +let sceneHalted = false; +let sceneBaseEffect = null; function advanceCues(t) { + if (sceneHalted) return; const efMap = sceneActiveEffects(t); const force = lastScene.effect === undefined; const prevMap = (lastScene.effect && typeof lastScene.effect === 'object') ? lastScene.effect : {}; @@ -897,6 +905,38 @@ function fireCue(el) { else if (el.type === 'map') applyMapEl(el); } +// Undo what one element put on screen. +function stopCue(el) { + const k = String(el.target || 'main'); + if (el.type === 'effect') { + if (k === 'main' || k === 'all') { if (sceneBaseEffect) applyEffect(sceneBaseEffect); } + if (k === 'all') Object.keys(auxCfg).forEach(id => auxSendCfg(id)); + else if (k.startsWith('zone:')) send({ type: 'mapZoneSet', id: zoneIdOf(k), kind: 'effect', data: null }); + else if (k !== 'main') auxSendCfg(k); // aux display back to its own setting + } else if (el.type === 'text') applyTextTo(k, null); + else if (el.type === 'image') applyImageTo(k, null); + else if (el.type === 'video') applyVideoTo(k, null); + else if (el.type === 'map') applyMapEl({ on: false }); +} +// Stop the whole scene: clear everything it shows and freeze its timeline. +function stopScene() { + sceneHalted = true; + firedByHand.clear(); + applyTextEl(null); + applyImageEl(null); + send({ type: 'mapZoneReset' }); + send({ type: 'mapShow', ids: null }); + if (sceneBaseEffect) applyEffect(sceneBaseEffect); + Object.keys(auxCfg).forEach(id => auxSendCfg(id)); +} +function resumeScene() { + sceneHalted = false; + firedByHand.clear(); + lastScene = { effect: undefined, text: undefined, image: undefined, map: undefined, video: undefined }; + send({ type: 'mapZoneReset' }); + advanceCues(sceneTime()); +} + // Human-readable line for a scene element (phone remote list). function cueLabel(c) { const tgt = String(c.target || 'main'); @@ -2879,6 +2919,17 @@ function remoteCommand(m) { } break; } + case 'cueStop': { + const tr = playlist[currentIndex]; + const el = tr && tr.cues && tr.cues[m.i]; + if (el) { + firedByHand.set(cueChannel(el), { el: null, base: timelineByChannel(sceneTime()).get(cueChannel(el)) || null }); + stopCue(el); + } + break; + } + case 'sceneStop': stopScene(); break; + case 'sceneResume': resumeScene(); break; } pushRemoteState(); } @@ -2903,10 +2954,10 @@ function timelineByChannel(t) { const sceneTime = () => segTimer ? (segBase + (playCur || 0)) : (playCur || 0); function remoteScene(tr) { if (!tr || !tr.cues || !tr.cues.length) return []; - const tl = timelineByChannel(sceneTime()); + const tl = sceneHalted ? new Map() : timelineByChannel(sceneTime()); for (const [ch, f] of [...firedByHand]) { - if ((tl.get(ch) || null) !== f.base || !tr.cues.includes(f.el)) { firedByHand.delete(ch); continue; } - tl.set(ch, f.el); + if ((tl.get(ch) || null) !== f.base || (f.el && !tr.cues.includes(f.el))) { firedByHand.delete(ch); continue; } + if (f.el) tl.set(ch, f.el); else tl.delete(ch); // null = stopped by hand } const live = new Set(tl.values()); return tr.cues.map((c, i) => ({ i, type: c.type, time: c.time || 0, dur: c.dur || 0, @@ -2924,6 +2975,7 @@ function pushRemoteState() { tracks: playlist.slice(0, 60).map(t => (t.isInterlude ? '✨ ' : '') + t.name), sceneCounts: playlist.slice(0, 60).map(t => (t.cues || []).length), scene: remoteScene(tr), + sceneHalted, effect: currentEffect ? currentEffect.name : '', family: currentEffect ? currentEffect.family : -1, families: remoteFamilies, diff --git a/src/remote.css b/src/remote.css index d5aafd9..309baa2 100644 --- a/src/remote.css +++ b/src/remote.css @@ -151,10 +151,17 @@ select { appearance: none; -webkit-appearance: none; width: 100%; background-col /* scene elements of the current track */ .cap-hint { font-size: 10.5px; letter-spacing: 0.5px; color: var(--dim); font-weight: 600; } .cues { display: flex; flex-direction: column; gap: 7px; } +.cue-wrap { display: flex; gap: 7px; } .cue { + flex: 1; min-width: 0; display: grid; grid-template-columns: 30px 52px 1fr; align-items: center; gap: 8px; text-align: left; min-height: 52px; padding: 8px 12px; font-size: 14px; } +.cue-stop { flex: 0 0 52px; font-size: 18px; padding: 0; color: var(--red); border-color: rgba(255, 61, 110, 0.5); } +.cue-stop:active { box-shadow: 0 0 18px rgba(255, 61, 110, 0.7); } +#scene-hint { margin: -4px 0 10px; } +#scene-card.halted .cues { opacity: 0.45; } +#btn-scene-stop.halted { color: var(--lime); border-color: var(--lime); } .cue .ico { font-size: 20px; text-align: center; } .cue .at { font-family: var(--display); font-weight: 800; font-size: 11px; color: var(--cyan); } .cue .lbl { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } diff --git a/src/remote.html b/src/remote.html index 2658bd0..62e030a 100644 --- a/src/remote.html +++ b/src/remote.html @@ -42,7 +42,8 @@ diff --git a/src/remote.js b/src/remote.js index 505add4..86f0b1f 100644 --- a/src/remote.js +++ b/src/remote.js @@ -37,6 +37,7 @@ $('#btn-blackout').addEventListener('click', () => cmd('blackout', { on: !(st && st.blackout) })); $('#btn-freeze').addEventListener('click', () => cmd('freeze', { on: !(st && st.freeze) })); $('#btn-map').addEventListener('click', () => cmd('mapOn', { on: !(st && st.map.on) })); + $('#btn-scene-stop').addEventListener('click', () => cmd(st && st.sceneHalted ? 'sceneResume' : 'sceneStop')); $('#btn-allzones').addEventListener('click', () => cmd('mapShow', { ids: null })); $('#fam').addEventListener('change', (e) => { if (e.target.value !== '') cmd('fxFamily', { fam: parseInt(e.target.value, 10) }); }); @@ -63,14 +64,22 @@ $('#scene-card').hidden = !sc.length; if (changed('scene', sc.map(c => [c.i, c.type, c.time, c.label]))) { const ICO = { effect: '🌀', text: '🔤', image: '🖼', video: '🎞', map: '🗺' }; - $('#scene').innerHTML = sc.map(c => '').join(''); + '' + esc(c.label) + '' + + '').join(''); $('#scene').querySelectorAll('.cue').forEach(b => b.addEventListener('click', () => cmd('cue', { i: +b.dataset.i }))); + $('#scene').querySelectorAll('.cue-stop').forEach(b => b.addEventListener('click', () => cmd('cueStop', { i: +b.dataset.i }))); } const liveSet = new Set(sc.filter(c => c.live).map(c => c.i)); $('#scene').querySelectorAll('.cue').forEach(b => b.classList.toggle('live', liveSet.has(+b.dataset.i))); + $('#scene').querySelectorAll('.cue-stop').forEach(b => { b.hidden = !liveSet.has(+b.dataset.i); }); + $('#scene-card').classList.toggle('halted', !!s.sceneHalted); + $('#btn-scene-stop').classList.toggle('halted', !!s.sceneHalted); + $('#btn-scene-stop').textContent = s.sceneHalted ? '▶ RIPRENDI' : '■ FERMA SCENA'; + $('#scene-hint').textContent = s.sceneHalted ? 'scena ferma: nessun elemento parte finché non riprendi' + : 'tocca un elemento per lanciarlo ora · ■ per fermarlo'; // effects $('#fx-name').textContent = s.effect || '—';