From fe1cada0459f1459b7c078296cc4b7f1308a591d Mon Sep 17 00:00:00 2001 From: luciano Date: Wed, 5 Aug 2026 15:48:08 +0200 Subject: [PATCH] Aux text = full ticker; scene effect cues get a target screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-monitor overlay text now reuses the main output's ticker wholesale (same DOM structure and style.css classes): direction h/vertical/side-edges, letter effects (wave/zoom/flash/rotate/updown), the 8 fonts, position, size, weight, color, speed, and the audio-reactive glow — the complete Testo tab option set. Scene 'effect' elements gain a 'Su' selector (main output / one aux display / all screens); each target is an independent cue channel so same-time cues aimed at different monitors all fire, and aux windows in follow mode ignore the all-screens broadcast (they already follow). Co-Authored-By: Claude Fable 5 --- src/aux.html | 31 ++++++------------ src/aux.js | 77 ++++++++++++++++++++++++++++++++----------- src/control.js | 88 +++++++++++++++++++++++++++++++++++++++++--------- src/style.css | 4 ++- 4 files changed, 144 insertions(+), 56 deletions(-) diff --git a/src/aux.html b/src/aux.html index 553cba0..0eeda3d 100644 --- a/src/aux.html +++ b/src/aux.html @@ -5,33 +5,22 @@ DJ Visualizer — Uscita + - + -
+ + +
+
+
+
+
+ diff --git a/src/aux.js b/src/aux.js index 6396242..845e1f0 100644 --- a/src/aux.js +++ b/src/aux.js @@ -36,25 +36,47 @@ function applyMode() { if (mode === 'follow' && followEffect) viz.setEffect(safeEffect(followEffect)); } -// Overlay text on top of whatever the mode shows (even plain black). -const txtEl = document.getElementById('aux-text'); -const txtInner = document.getElementById('aux-text-inner'); +// Overlay text on top of whatever the mode shows (even plain black): the SAME +// ticker as the main output (structure + style.css), with the full option set +// of the Testo tab — direction, letter effect, font, position, size, speed. +const ticker = document.getElementById('ticker'); +const tickerTrack = document.getElementById('ticker-track'); +const tickCopies = ticker.querySelectorAll('.tick-copy'); +const sideText = document.getElementById('side-text'); +const sideCopies = sideText.querySelectorAll('.side-copy'); +function buildLetters(txt) { + const full = (txt || '') + ' • '; + let html = ''; + for (let i = 0; i < full.length; i++) { + const c = full[i]; + const ch = c === ' ' ? ' ' : c.replace(/&/g, '&').replace(/' + ch + ''; + } + return html; +} +function styleBoth(prop, value) { tickerTrack.style[prop] = value; sideText.style[prop] = value; } function applyText(c) { const on = !!(c && c.on && c.value && c.value.trim()); - txtEl.style.display = on ? 'block' : 'none'; + const dir = (c && c.dir) || 'h'; + ticker.classList.toggle('show', on && dir !== 'sides'); + sideText.classList.toggle('show', on && dir === 'sides'); if (!on) return; - txtInner.textContent = c.value; - txtEl.className = c.scroll ? 'scroll' : 'static'; - txtEl.style.fontSize = (c.size || 8) + 'vh'; - txtEl.style.color = c.color || '#ffffff'; - txtEl.style.fontWeight = c.weight === false ? 'normal' : '900'; - const pos = c.pos || 'middle'; - txtEl.style.top = pos === 'top' ? '6%' : pos === 'bottom' ? 'auto' : '50%'; - txtEl.style.bottom = pos === 'bottom' ? '6%' : 'auto'; - txtEl.style.transform = pos === 'middle' ? 'translateY(-50%)' : 'none'; - // Longer texts scroll at the same apparent speed: duration scales with length. - const base = 8 + c.value.length * 0.35; - txtEl.style.setProperty('--aux-scroll-dur', (base / (c.speed || 1)).toFixed(1) + 's'); + const h = buildLetters(c.value); + tickCopies.forEach(el => el.innerHTML = h); + sideCopies.forEach(el => el.innerHTML = h); + ticker.classList.remove('dir-h', 'dir-vup', 'dir-vdown'); + if (dir !== 'sides') ticker.classList.add('dir-' + dir); + ticker.classList.remove('pos-bottom', 'pos-top', 'pos-middle'); + ticker.classList.add('pos-' + (c.pos || 'bottom')); + styleBoth('fontFamily', c.font || "-apple-system, BlinkMacSystemFont, sans-serif"); + styleBoth('fontSize', (c.size || 6) + 'vh'); + styleBoth('fontWeight', c.weight === false ? '400' : '800'); + styleBoth('color', c.color || '#ffffff'); + [tickerTrack, sideText].forEach(el => { + el.classList.remove('fx-updown', 'fx-wave', 'fx-zoom', 'fx-flash', 'fx-rotate'); + if (c.fx && c.fx !== 'none') el.classList.add('fx-' + c.fx); + }); + document.documentElement.style.setProperty('--ticker-dur', (18 / (c.speed || 1)) + 's'); } // ---- audio + clock ------------------------------------------------------- @@ -95,16 +117,33 @@ window.djv.onControl((m) => { applyText(m.text); break; } + case 'auxFx': { + // Scene cue targeted at aux outputs. displayId -1 = every aux window, + // but follow-mode windows ignore the broadcast form: they receive the + // same effect through the normal 'effect' broadcast and switching them + // to 'effect' mode would silently stop them from following afterwards. + if (m.displayId !== MY_ID && !(m.displayId === -1 && mode !== 'follow')) break; + const e = EFFECTS.list[m.effectIndex]; + if (e) { mode = 'effect'; ownEffect = e; applyMode(); } + break; + } } }); function frame() { + const now = performance.now(); + clockT += (now - clockAt) / 1000; + clockAt = now; if (mode === 'follow' || mode === 'effect') { - const now = performance.now(); - clockT += (now - clockAt) / 1000; - clockAt = now; viz.render(clockT, audioData || SILENT); } + // Ticker letters glow with the music, same formula as the main output. + if (audioData) { + tickerTrack.style.setProperty('--ticker-glow', + (audioData.level * 24 + audioData.beat * 20).toFixed(0) + 'px'); + sideText.style.setProperty('--ticker-glow', + (audioData.level * 24 + audioData.beat * 20).toFixed(0) + 'px'); + } requestAnimationFrame(frame); } requestAnimationFrame(frame); diff --git a/src/control.js b/src/control.js index bff3e68..24165e5 100644 --- a/src/control.js +++ b/src/control.js @@ -690,7 +690,7 @@ const IMG_POS_LABELS = { center: 'Centro', top: 'Alto', bottom: 'Basso', left: ' function optList(pairs, sel) { return pairs.map(([v, l]) => '').join(''); } -function newEffectEl(time) { return { type: 'effect', time, effectIndex: EFFECTS.list.indexOf(currentEffect), effectName: currentEffect.name, dur: 0 }; } +function newEffectEl(time) { return { type: 'effect', time, effectIndex: EFFECTS.list.indexOf(currentEffect), effectName: currentEffect.name, dur: 0, target: 'main' }; } function newTextEl(time) { return { type: 'text', time, text: '', dir: 'h', fx: 'none', font: TXT_FONTS[0][0], size: 6, weight: true, color: '#ffffff', pos: 'bottom', speed: 1, dur: 0 }; } function newImageEl(time) { return { type: 'image', time, image: null, imageSize: 60, imagePos: 'center', dur: 0 }; } @@ -736,9 +736,43 @@ function sceneActive(type, t) { } return best; } +// Effect cues carry a target (main output, one aux display, or all screens): +// each target is its own channel so cues aimed at different monitors never +// supersede one another. +function sceneActiveEffects(t) { + const best = {}; + for (const el of activeCues) { + if (el.type !== 'effect' || el.time > t) continue; + if (el.dur && el.time + el.dur <= t) continue; + const k = String(el.target || 'main'); + if (!best[k] || el.time >= best[k].time) best[k] = el; + } + return best; +} +function applyEffectTo(targetKey, el) { + const e = EFFECTS.list[el.effectIndex]; + if (!e) return; + if (targetKey === 'main') applyEffect(e); + else if (targetKey === 'all') { + applyEffect(e); + // -1 = every aux window (follow-mode ones get it via the effect broadcast) + send({ type: 'auxFx', displayId: -1, effectIndex: el.effectIndex }); + } else { + send({ type: 'auxFx', displayId: parseInt(targetKey, 10), effectIndex: el.effectIndex }); + } +} function advanceCues(t) { - const ef = sceneActive('effect', t); - if (ef !== lastScene.effect) { lastScene.effect = ef; if (ef && EFFECTS.list[ef.effectIndex]) applyEffect(EFFECTS.list[ef.effectIndex]); } + const efMap = sceneActiveEffects(t); + const force = lastScene.effect === undefined; + const prevMap = (lastScene.effect && typeof lastScene.effect === 'object') ? lastScene.effect : {}; + const keys = new Set([...Object.keys(efMap), ...Object.keys(prevMap)]); + keys.forEach(k => { + const el = efMap[k] || null; + if (!force && prevMap[k] === el) return; + if (el) applyEffectTo(k, el); // apply-once semantics: no revert on expiry + }); + lastScene.effect = efMap; + const ef = efMap['main'] || efMap['all'] || null; // Auto-blend: on a video track, when a scene effect is active, blend the video // ('screen') so the effect shows through underneath. Only when the user left // the blend at the default 'normal' — a manual choice is always respected. @@ -971,12 +1005,26 @@ function buildSceneEditor(tr, i, li) { if (c.type === 'effect') { const ce = (c.effectIndex != null && EFFECTS.list[c.effectIndex]) ? EFFECTS.list[c.effectIndex] : null; const cFam = ce ? ce.family : -1; + // Destination screen: main output, one aux display, or all of them. + const tgt = String(c.target || 'main'); + let tgtOpts = ''; + auxDisplays.forEach(ds => { + tgtOpts += ''; + }); + tgtOpts += ''; + if (tgt !== 'main' && tgt !== 'all' && !auxDisplays.some(ds => String(ds.id) === tgt)) { + tgtOpts += ''; + } html += '
' + '
' + '' + '' + '
' + + '
' + + 'Su' + + '' + + '
' + '
' + '' + '' + (ce ? ce.name : 'nessuno') + '' + @@ -1114,6 +1162,11 @@ function buildSceneEditor(tr, i, li) { if (idx >= 0 && EFFECTS.list[idx]) { c.effectIndex = idx; c.effectName = EFFECTS.list[idx].name; save(); } }); cueEl.querySelector('.cue-eff-cur').addEventListener('click', () => { c.effectIndex = EFFECTS.list.indexOf(currentEffect); c.effectName = currentEffect.name; save(); }); + cueEl.querySelector('.cue-eff-target').addEventListener('change', (e) => { + const v = e.target.value; + c.target = (v === 'main' || v === 'all') ? v : parseInt(v, 10); + saveLive(); + }); } else if (c.type === 'text') { const ti = cueEl.querySelector('.cue-text'); ti.value = c.text || ''; ti.addEventListener('input', (e) => { c.text = e.target.value; saveLive(); }); @@ -1678,22 +1731,25 @@ function renderAuxList() { extra = '' + (c.image ? c.image.split(/[\\/]/).pop() : 'nessuna') + ''; } - const tx = c.text = c.text || { on: false, value: '', pos: 'middle', size: 8, color: '#ffffff', weight: true, scroll: true, speed: 1 }; + const tx = c.text = c.text || { on: false, value: '', dir: 'h', fx: 'none', font: TXT_FONTS[0][0], pos: 'bottom', size: 6, color: '#ffffff', weight: true, speed: 1 }; + if (tx.dir === undefined) { // migrate pre-ticker configs (scroll-flag era) + tx.dir = 'h'; tx.fx = 'none'; tx.font = TXT_FONTS[0][0]; delete tx.scroll; + } let textRow = ''; if (tx.on) { textRow = '
' + '' + - '' + - '' + - 'Dim' + - 'Vel' + + '
' + + '
' + + '' + + '' + + '' + + '' + + '
' + + '
' + + 'Dim' + + 'Vel' + '' + '' + '
'; @@ -1745,8 +1801,10 @@ function renderAuxList() { txVal.value = tx.value || ''; const live = () => { auxSave(); auxSendCfg(d.id); }; txVal.addEventListener('input', (e) => { tx.value = e.target.value; live(); }); + row.querySelector('.atx-dir').addEventListener('change', (e) => { tx.dir = e.target.value; live(); }); + row.querySelector('.atx-fx').addEventListener('change', (e) => { tx.fx = e.target.value; live(); }); + row.querySelector('.atx-font').addEventListener('change', (e) => { tx.font = e.target.value; live(); }); row.querySelector('.atx-pos').addEventListener('change', (e) => { tx.pos = e.target.value; live(); }); - row.querySelector('.atx-scroll').addEventListener('change', (e) => { tx.scroll = e.target.value === '1'; live(); }); row.querySelector('.atx-size').addEventListener('input', (e) => { tx.size = parseFloat(e.target.value); live(); }); row.querySelector('.atx-speed').addEventListener('input', (e) => { tx.speed = parseFloat(e.target.value); live(); }); row.querySelector('.atx-color').addEventListener('input', (e) => { tx.color = e.target.value; live(); }); diff --git a/src/style.css b/src/style.css index f9bb355..4149b7c 100644 --- a/src/style.css +++ b/src/style.css @@ -974,7 +974,9 @@ kbd { margin-top: 4px; padding-top: 6px; border-top: 1px dashed rgba(255,255,255,0.1); } .aux-row .atx-val { flex: 1 1 160px; min-width: 0; width: auto; padding: 4px 6px; font-size: 11px; } -.aux-row .atx-pos, .aux-row .atx-scroll { flex: 0 0 auto; width: 92px; padding: 4px 6px; font-size: 11px; } +.aux-row .atx-dir, .aux-row .atx-fx, .aux-row .atx-font, .aux-row .atx-pos { + flex: 1 1 0; min-width: 0; width: auto; padding: 4px 6px; font-size: 11px; +} .aux-row .atx-lbl { flex: 0 0 auto; font-size: 10px; opacity: 0.6; } .aux-row .atx-size, .aux-row .atx-speed { flex: 0 0 70px; } .aux-row .atx-color { flex: 0 0 30px; width: 30px; height: 22px; padding: 1px; }