Phone remote: stop a scene element or the whole scene

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XVMQ2JhsJ77pGKqcvvFHqc
This commit is contained in:
lucianoandClaude Opus 5.5 committed 2026-10-02 13:48:58 +02:00
1 parent ea91fe35f4
commit c832dc1647
4 files changed
+75 -6

No files matched your search

+55 -3
View File
@@ -777,6 +777,9 @@ function startCues(tr) {
// has nothing active at t=0. // has nothing active at t=0.
lastScene = { effect: undefined, text: undefined, image: undefined, map: undefined, video: undefined }; lastScene = { effect: undefined, text: undefined, image: undefined, map: undefined, video: undefined };
send({ type: 'mapZoneReset' }); // zones fall back to their Mappatura content 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); advanceCues(0);
} }
function sceneActive(type, t) { function sceneActive(type, t) {
@@ -832,7 +835,12 @@ function applyEffectTo(targetKey, el) {
send({ type: 'auxFx', displayId: id, effectIndex: el.effectIndex }); 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) { function advanceCues(t) {
if (sceneHalted) return;
const efMap = sceneActiveEffects(t); const efMap = sceneActiveEffects(t);
const force = lastScene.effect === undefined; const force = lastScene.effect === undefined;
const prevMap = (lastScene.effect && typeof lastScene.effect === 'object') ? lastScene.effect : {}; const prevMap = (lastScene.effect && typeof lastScene.effect === 'object') ? lastScene.effect : {};
@@ -897,6 +905,38 @@ function fireCue(el) {
else if (el.type === 'map') applyMapEl(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). // Human-readable line for a scene element (phone remote list).
function cueLabel(c) { function cueLabel(c) {
const tgt = String(c.target || 'main'); const tgt = String(c.target || 'main');
@@ -2879,6 +2919,17 @@ function remoteCommand(m) {
} }
break; 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(); pushRemoteState();
} }
@@ -2903,10 +2954,10 @@ function timelineByChannel(t) {
const sceneTime = () => segTimer ? (segBase + (playCur || 0)) : (playCur || 0); const sceneTime = () => segTimer ? (segBase + (playCur || 0)) : (playCur || 0);
function remoteScene(tr) { function remoteScene(tr) {
if (!tr || !tr.cues || !tr.cues.length) return []; 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]) { for (const [ch, f] of [...firedByHand]) {
if ((tl.get(ch) || null) !== f.base || !tr.cues.includes(f.el)) { firedByHand.delete(ch); continue; } if ((tl.get(ch) || null) !== f.base || (f.el && !tr.cues.includes(f.el))) { firedByHand.delete(ch); continue; }
tl.set(ch, f.el); if (f.el) tl.set(ch, f.el); else tl.delete(ch); // null = stopped by hand
} }
const live = new Set(tl.values()); const live = new Set(tl.values());
return tr.cues.map((c, i) => ({ i, type: c.type, time: c.time || 0, dur: c.dur || 0, 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), tracks: playlist.slice(0, 60).map(t => (t.isInterlude ? '✨ ' : '') + t.name),
sceneCounts: playlist.slice(0, 60).map(t => (t.cues || []).length), sceneCounts: playlist.slice(0, 60).map(t => (t.cues || []).length),
scene: remoteScene(tr), scene: remoteScene(tr),
sceneHalted,
effect: currentEffect ? currentEffect.name : '', effect: currentEffect ? currentEffect.name : '',
family: currentEffect ? currentEffect.family : -1, family: currentEffect ? currentEffect.family : -1,
families: remoteFamilies, families: remoteFamilies,
+7
View File
@@ -151,10 +151,17 @@ select { appearance: none; -webkit-appearance: none; width: 100%; background-col
/* scene elements of the current track */ /* scene elements of the current track */
.cap-hint { font-size: 10.5px; letter-spacing: 0.5px; color: var(--dim); font-weight: 600; } .cap-hint { font-size: 10.5px; letter-spacing: 0.5px; color: var(--dim); font-weight: 600; }
.cues { display: flex; flex-direction: column; gap: 7px; } .cues { display: flex; flex-direction: column; gap: 7px; }
.cue-wrap { display: flex; gap: 7px; }
.cue { .cue {
flex: 1; min-width: 0;
display: grid; grid-template-columns: 30px 52px 1fr; align-items: center; gap: 8px; 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; 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 .ico { font-size: 20px; text-align: center; }
.cue .at { font-family: var(--display); font-weight: 800; font-size: 11px; color: var(--cyan); } .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; } .cue .lbl { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+2 -1
View File
@@ -42,7 +42,8 @@
<!-- scene of the current track --> <!-- scene of the current track -->
<section class="card" id="scene-card" hidden> <section class="card" id="scene-card" hidden>
<div class="cap">🎬 SCENA DEL BRANO <span class="cap-hint">tocca per lanciare ora</span></div> <div class="cap">🎬 SCENA DEL BRANO <button class="mini" id="btn-scene-stop">■ FERMA SCENA</button></div>
<div class="cap-hint" id="scene-hint">tocca un elemento per lanciarlo ora · ■ per fermarlo</div>
<div id="scene" class="cues"></div> <div id="scene" class="cues"></div>
</section> </section>
+11 -2
View File
@@ -37,6 +37,7 @@
$('#btn-blackout').addEventListener('click', () => cmd('blackout', { on: !(st && st.blackout) })); $('#btn-blackout').addEventListener('click', () => cmd('blackout', { on: !(st && st.blackout) }));
$('#btn-freeze').addEventListener('click', () => cmd('freeze', { on: !(st && st.freeze) })); $('#btn-freeze').addEventListener('click', () => cmd('freeze', { on: !(st && st.freeze) }));
$('#btn-map').addEventListener('click', () => cmd('mapOn', { on: !(st && st.map.on) })); $('#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 })); $('#btn-allzones').addEventListener('click', () => cmd('mapShow', { ids: null }));
$('#fam').addEventListener('change', (e) => { if (e.target.value !== '') cmd('fxFamily', { fam: parseInt(e.target.value, 10) }); }); $('#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; $('#scene-card').hidden = !sc.length;
if (changed('scene', sc.map(c => [c.i, c.type, c.time, c.label]))) { if (changed('scene', sc.map(c => [c.i, c.type, c.time, c.label]))) {
const ICO = { effect: '🌀', text: '🔤', image: '🖼', video: '🎞', map: '🗺' }; const ICO = { effect: '🌀', text: '🔤', image: '🖼', video: '🎞', map: '🗺' };
$('#scene').innerHTML = sc.map(c => '<button class="cue" data-i="' + c.i + '">' + $('#scene').innerHTML = sc.map(c => '<div class="cue-wrap"><button class="cue" data-i="' + c.i + '">' +
'<span class="ico">' + (ICO[c.type] || '•') + '</span>' + '<span class="ico">' + (ICO[c.type] || '•') + '</span>' +
'<span class="at">' + fmt(c.time) + '</span>' + '<span class="at">' + fmt(c.time) + '</span>' +
'<span class="lbl">' + esc(c.label) + '</span></button>').join(''); '<span class="lbl">' + esc(c.label) + '</span></button>' +
'<button class="cue-stop" data-i="' + c.i + '" hidden aria-label="Ferma">■</button></div>').join('');
$('#scene').querySelectorAll('.cue').forEach(b => b.addEventListener('click', () => cmd('cue', { i: +b.dataset.i }))); $('#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)); 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').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 // effects
$('#fx-name').textContent = s.effect || '—'; $('#fx-name').textContent = s.effect || '—';