From ea91fe35f40fa9e3f35b8fc2de70e4a20c4d19ff Mon Sep 17 00:00:00 2001 From: luciano Date: Fri, 2 Oct 2026 13:45:16 +0200 Subject: [PATCH] Phone remote: show and fire the current track's scene elements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01XVMQ2JhsJ77pGKqcvvFHqc --- src/control.js | 112 +++++++++++++++++++++++++++++++++++++++--------- src/remote.css | 15 +++++++ src/remote.html | 6 +++ src/remote.js | 19 +++++++- 4 files changed, 130 insertions(+), 22 deletions(-) diff --git a/src/control.js b/src/control.js index c5cf724..6d67e83 100644 --- a/src/control.js +++ b/src/control.js @@ -855,32 +855,64 @@ 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) { - 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 }); - } + 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()); send({ type: 'tickerText', text: show ? el.text : '' }); @@ -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, diff --git a/src/remote.css b/src/remote.css index 572f3ed..d5aafd9 100644 --- a/src/remote.css +++ b/src/remote.css @@ -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; } diff --git a/src/remote.html b/src/remote.html index 25296e4..2658bd0 100644 --- a/src/remote.html +++ b/src/remote.html @@ -40,6 +40,12 @@ + + +
EFFETTO
diff --git a/src/remote.js b/src/remote.js index ef3ae2f..505add4 100644 --- a/src/remote.js +++ b/src/remote.js @@ -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 => '').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) => '
  • ' + (i + 1) + '. ' + esc(n) + '
  • ').join('') || + if (changed('tracks', [s.tracks, s.sceneCounts])) { + $('#tracks').innerHTML = s.tracks.map((n, i) => '
  • ' + (i + 1) + '. ' + esc(n) + + ((s.sceneCounts || [])[i] ? '๐ŸŽฌ ' + s.sceneCounts[i] + '' : '') + '
  • ').join('') || '
  • Playlist vuota
  • '; $('#tracks').querySelectorAll('li[data-i]').forEach(li => li.addEventListener('click', () => cmd('track', { i: +li.dataset.i }))); }