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 @@ + + + 🎬 SCENA DEL BRANO tocca per lanciare ora + + + 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 => '' + + '' + (ICO[c.type] || '•') + '' + + '' + fmt(c.time) + '' + + '' + esc(c.label) + '').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 }))); }