Phone remote: show and fire the current track's scene elements

The remote now lists the current track's scene (time, type icon, readable
description incl. destination zone), flags the elements live right now and
fires any of them on tap, without seeking the music. Tracks with a scene get
a 🎬 count badge. Applying an element is factored into per-type functions
(applyTextTo/applyImageTo/applyVideoTo/applyMapEl/fireCue) shared by the
timeline and the remote; a hand-fired element stays flagged live until the
timeline moves its 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:45:16 +02:00
1 parent ab1ab89127
commit ea91fe35f4
4 files changed
+125 -17

No files matched your search

+87 -15
View File
@@ -855,31 +855,63 @@ function advanceCues(t) {
}
// Text / image / video: one channel per destination (main or a zone);
// when an element ends, its destination is cleared (unlike effects).
advanceTargeted('text', t, (k, el) => {
if (k === 'main') return applyTextEl(el);
send({ type: 'mapZoneSet', id: zoneIdOf(k), kind: 'text',
data: el && el.text && el.text.trim() ? { text: el.text, dir: el.dir, fx: el.fx, font: el.font,
size: el.size, weight: el.weight, color: el.color, pos: el.pos, speed: el.speed } : null });
});
advanceTargeted('image', t, (k, el) => {
if (k === 'main') return applyImageEl(el);
send({ type: 'mapZoneSet', id: zoneIdOf(k), kind: 'image', data: el && el.image ? { path: el.image } : null });
});
advanceTargeted('video', t, (k, el) => {
if (k === 'main') return; // videos are zone-only
send({ type: 'mapZoneSet', id: zoneIdOf(k), kind: 'video', data: el && el.path ? { path: el.path } : null });
});
advanceTargeted('text', t, applyTextTo);
advanceTargeted('image', t, applyImageTo);
advanceTargeted('video', t, applyVideoTo);
const mp = sceneActive('map', t);
if (mp !== lastScene.map) {
lastScene.map = mp;
// Apply-once like effects: when the element ends the state stays as is.
if (mp) {
if (mp) applyMapEl(mp);
}
}
// One function per element type, shared by the timeline and by the phone
// remote (which can fire any element of the current scene on demand).
function applyTextTo(k, el) {
if (k === 'main') return applyTextEl(el);
send({ type: 'mapZoneSet', id: zoneIdOf(k), kind: 'text',
data: el && el.text && el.text.trim() ? { text: el.text, dir: el.dir, fx: el.fx, font: el.font,
size: el.size, weight: el.weight, color: el.color, pos: el.pos, speed: el.speed } : null });
}
function applyImageTo(k, el) {
if (k === 'main') return applyImageEl(el);
send({ type: 'mapZoneSet', id: zoneIdOf(k), kind: 'image', data: el && el.image ? { path: el.image } : null });
}
function applyVideoTo(k, el) {
if (k === 'main') return; // videos are zone-only
send({ type: 'mapZoneSet', id: zoneIdOf(k), kind: 'video', data: el && el.path ? { path: el.path } : null });
}
function applyMapEl(mp) {
mapCfg.on = !!mp.on;
$('#map-on').checked = mapCfg.on;
send({ type: 'mapShow', ids: mp.on && Array.isArray(mp.zones) ? mp.zones : null });
send({ type: 'mapOn', on: mapCfg.on });
}
function fireCue(el) {
const k = String(el.target || 'main');
if (el.type === 'effect') applyEffectTo(k, el);
else if (el.type === 'text') applyTextTo(k, el);
else if (el.type === 'image') applyImageTo(k, el);
else if (el.type === 'video') applyVideoTo(k, el);
else if (el.type === 'map') applyMapEl(el);
}
// Human-readable line for a scene element (phone remote list).
function cueLabel(c) {
const tgt = String(c.target || 'main');
const z = tgt.startsWith('zone:') ? mapCfg.zones.find(x => 'zone:' + x.id === tgt) : null;
const where = z ? ' → ' + (z.name || 'Zona') : (tgt === 'all' ? ' → tutti' : '');
const fileName = (p) => p ? String(p).split(/[\\/]/).pop() : '—';
switch (c.type) {
case 'effect': return ((EFFECTS.list[c.effectIndex] || {}).name || 'nessun effetto') + where;
case 'text': return '"' + (c.text || '').trim() + '"' + where;
case 'image': return fileName(c.image) + where;
case 'video': return fileName(c.path) + where;
case 'map': return c.on ? 'Mappatura ON' + (Array.isArray(c.zones)
? ' · ' + c.zones.map(id => (mapCfg.zones.find(x => x.id === id) || {}).name || id).join(', ') : '') : 'Mappatura OFF';
}
return c.type;
}
function applyTextEl(el) {
const show = !!(el && el.text && el.text.trim());
@@ -2837,12 +2869,50 @@ function remoteCommand(m) {
break;
case 'blackout': setBlackout(!!m.on); break;
case 'freeze': setFreeze(!!m.on); break;
case 'cue': {
// fire one element of the CURRENT track's scene right now
const tr = playlist[currentIndex];
const el = tr && tr.cues && tr.cues[m.i];
if (el) {
firedByHand.set(cueChannel(el), { el, base: timelineByChannel(sceneTime()).get(cueChannel(el)) || null });
fireCue(el);
}
break;
}
}
pushRemoteState();
}
djv.onRemote(remoteCommand);
let remoteFamilies = null;
// Current track's scene for the phone: sorted by time, with the elements
// that are live right now flagged (same rules as the timeline).
// Elements fired by hand from the phone: channel -> { el, base } where base
// is what the timeline had on that channel at that moment. The fired element
// counts as live until the timeline moves that channel on.
const firedByHand = new Map();
const cueChannel = (c) => c.type === 'map' ? 'map' : c.type + '|' + String(c.target || 'main');
function timelineByChannel(t) {
const m = new Map();
if (!activeCues.length) return m;
Object.values(sceneActiveEffects(t)).forEach(e => m.set(cueChannel(e), e));
['text', 'image', 'video'].forEach(ty => Object.values(sceneActiveBy(ty, t)).forEach(e => m.set(cueChannel(e), e)));
const mp = sceneActive('map', t); if (mp) m.set('map', mp);
return m;
}
const sceneTime = () => segTimer ? (segBase + (playCur || 0)) : (playCur || 0);
function remoteScene(tr) {
if (!tr || !tr.cues || !tr.cues.length) return [];
const tl = 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);
}
const live = new Set(tl.values());
return tr.cues.map((c, i) => ({ i, type: c.type, time: c.time || 0, dur: c.dur || 0,
label: cueLabel(c), live: live.has(c) }))
.sort((a, b) => a.time - b.time);
}
function pushRemoteState() {
if (!remoteOn || !remoteClients) return;
const tr = playlist[currentIndex];
@@ -2852,6 +2922,8 @@ function pushRemoteState() {
djv.remoteState({
track: tr ? { i: currentIndex, name: tr.name, cur: playCur, dur: playDur, playing: isPlaying } : null,
tracks: playlist.slice(0, 60).map(t => (t.isInterlude ? '✨ ' : '') + t.name),
sceneCounts: playlist.slice(0, 60).map(t => (t.cues || []).length),
scene: remoteScene(tr),
effect: currentEffect ? currentEffect.name : '',
family: currentEffect ? currentEffect.family : -1,
families: remoteFamilies,
+15
View File
@@ -147,3 +147,18 @@ select { appearance: none; -webkit-appearance: none; width: 100%; background-col
}
#offline small { font-family: var(--ui); color: var(--dim); letter-spacing: 0; }
[hidden] { display: none !important; }
/* 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 {
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 .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; }
.cue.live { border-color: var(--lime); box-shadow: inset 3px 0 0 var(--lime), 0 0 14px rgba(125, 255, 138, 0.3); color: #fff; }
.cue.live .at { color: var(--lime); }
.badge { display: inline-block; margin-left: 6px; padding: 1px 7px; border-radius: 8px; font-size: 11px;
border: 1px solid rgba(255, 43, 214, 0.5); color: #ffc6f4; }
+6
View File
@@ -40,6 +40,12 @@
</div>
</section>
<!-- scene of the current track -->
<section class="card" id="scene-card" hidden>
<div class="cap">🎬 SCENA DEL BRANO <span class="cap-hint">tocca per lanciare ora</span></div>
<div id="scene" class="cues"></div>
</section>
<!-- effects -->
<section class="card">
<div class="cap">EFFETTO</div>
+17 -2
View File
@@ -58,6 +58,20 @@
$('#m-m').style.width = Math.min(100, s.meters.mid * 100) + '%';
$('#m-h').style.width = Math.min(100, s.meters.treble * 100) + '%';
// scene of the current track
const sc = s.scene || [];
$('#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 => '<button class="cue" data-i="' + c.i + '">' +
'<span class="ico">' + (ICO[c.type] || '•') + '</span>' +
'<span class="at">' + fmt(c.time) + '</span>' +
'<span class="lbl">' + esc(c.label) + '</span></button>').join('');
$('#scene').querySelectorAll('.cue').forEach(b => b.addEventListener('click', () => cmd('cue', { 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)));
// effects
$('#fx-name').textContent = s.effect || '—';
$('#btn-autovj').classList.toggle('on', !!s.autoVj);
@@ -107,8 +121,9 @@
});
// playlist
if (changed('tracks', s.tracks)) {
$('#tracks').innerHTML = s.tracks.map((n, i) => '<li data-i="' + i + '">' + (i + 1) + '. ' + esc(n) + '</li>').join('') ||
if (changed('tracks', [s.tracks, s.sceneCounts])) {
$('#tracks').innerHTML = s.tracks.map((n, i) => '<li data-i="' + i + '">' + (i + 1) + '. ' + esc(n) +
((s.sceneCounts || [])[i] ? '<span class="badge">🎬 ' + s.sceneCounts[i] + '</span>' : '') + '</li>').join('') ||
'<li style="opacity:.5">Playlist vuota</li>';
$('#tracks').querySelectorAll('li[data-i]').forEach(li => li.addEventListener('click', () => cmd('track', { i: +li.dataset.i })));
}