Aux text = full ticker; scene effect cues get a target screen

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 <noreply@anthropic.com>
This commit is contained in:
lucianoandClaude Fable 5 committed 2026-08-05 15:48:08 +02:00
1 parent 5d4747f780
commit fe1cada045
4 files changed
+142 -54

No files matched your search

+10 -21
View File
@@ -5,33 +5,22 @@
<meta http-equiv="Content-Security-Policy" <meta http-equiv="Content-Security-Policy"
content="default-src 'self'; img-src 'self' blob: data: file:; style-src 'self' 'unsafe-inline'; script-src 'self'" /> content="default-src 'self'; img-src 'self' blob: data: file:; style-src 'self' 'unsafe-inline'; script-src 'self'" />
<title>DJ Visualizer — Uscita</title> <title>DJ Visualizer — Uscita</title>
<link rel="stylesheet" href="style.css" />
<style> <style>
html, body { margin: 0; padding: 0; width: 100%; height: 100%; background: #000; overflow: hidden; cursor: none; }
#gl { position: absolute; inset: 0; width: 100%; height: 100%; }
#aux-image { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; display: none; } #aux-image { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; display: none; }
/* Overlay text: static (centered) or scrolling marquee, over any mode */
#aux-text {
position: absolute; left: 0; width: 100%;
display: none; overflow: hidden; white-space: nowrap;
font-family: -apple-system, BlinkMacSystemFont, 'Arial Black', sans-serif;
text-shadow: 0 0 18px rgba(0,0,0,0.9), 0 2px 6px rgba(0,0,0,0.8);
pointer-events: none;
}
#aux-text.static { text-align: center; }
#aux-text.scroll #aux-text-inner {
display: inline-block; padding-left: 100vw;
animation: auxscroll var(--aux-scroll-dur, 14s) linear infinite;
}
@keyframes auxscroll {
from { transform: translateX(0); }
to { transform: translateX(-100%); }
}
</style> </style>
</head> </head>
<body> <body class="hide-cursor">
<canvas id="gl"></canvas> <canvas id="gl"></canvas>
<img id="aux-image" alt="" /> <img id="aux-image" alt="" />
<div id="aux-text"><span id="aux-text-inner"></span></div>
<!-- Same ticker structure (and CSS) as the main output -->
<div id="ticker"><div id="ticker-track"><span class="tick-copy"></span><span class="tick-copy"></span></div></div>
<div id="side-text">
<div class="side-col left"><div class="side-track"><span class="side-copy"></span><span class="side-copy"></span></div></div>
<div class="side-col right"><div class="side-track"><span class="side-copy"></span><span class="side-copy"></span></div></div>
</div>
<script src="shaders.js"></script> <script src="shaders.js"></script>
<script src="effects.js"></script> <script src="effects.js"></script>
<script src="fluid.js"></script> <script src="fluid.js"></script>
+56 -17
View File
@@ -36,25 +36,47 @@ function applyMode() {
if (mode === 'follow' && followEffect) viz.setEffect(safeEffect(followEffect)); if (mode === 'follow' && followEffect) viz.setEffect(safeEffect(followEffect));
} }
// Overlay text on top of whatever the mode shows (even plain black). // Overlay text on top of whatever the mode shows (even plain black): the SAME
const txtEl = document.getElementById('aux-text'); // ticker as the main output (structure + style.css), with the full option set
const txtInner = document.getElementById('aux-text-inner'); // 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, '&amp;').replace(/</g, '&lt;');
html += '<span class="tl" style="--i:' + i + '">' + ch + '</span>';
}
return html;
}
function styleBoth(prop, value) { tickerTrack.style[prop] = value; sideText.style[prop] = value; }
function applyText(c) { function applyText(c) {
const on = !!(c && c.on && c.value && c.value.trim()); 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; if (!on) return;
txtInner.textContent = c.value; const h = buildLetters(c.value);
txtEl.className = c.scroll ? 'scroll' : 'static'; tickCopies.forEach(el => el.innerHTML = h);
txtEl.style.fontSize = (c.size || 8) + 'vh'; sideCopies.forEach(el => el.innerHTML = h);
txtEl.style.color = c.color || '#ffffff'; ticker.classList.remove('dir-h', 'dir-vup', 'dir-vdown');
txtEl.style.fontWeight = c.weight === false ? 'normal' : '900'; if (dir !== 'sides') ticker.classList.add('dir-' + dir);
const pos = c.pos || 'middle'; ticker.classList.remove('pos-bottom', 'pos-top', 'pos-middle');
txtEl.style.top = pos === 'top' ? '6%' : pos === 'bottom' ? 'auto' : '50%'; ticker.classList.add('pos-' + (c.pos || 'bottom'));
txtEl.style.bottom = pos === 'bottom' ? '6%' : 'auto'; styleBoth('fontFamily', c.font || "-apple-system, BlinkMacSystemFont, sans-serif");
txtEl.style.transform = pos === 'middle' ? 'translateY(-50%)' : 'none'; styleBoth('fontSize', (c.size || 6) + 'vh');
// Longer texts scroll at the same apparent speed: duration scales with length. styleBoth('fontWeight', c.weight === false ? '400' : '800');
const base = 8 + c.value.length * 0.35; styleBoth('color', c.color || '#ffffff');
txtEl.style.setProperty('--aux-scroll-dur', (base / (c.speed || 1)).toFixed(1) + 's'); [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 ------------------------------------------------------- // ---- audio + clock -------------------------------------------------------
@@ -95,16 +117,33 @@ window.djv.onControl((m) => {
applyText(m.text); applyText(m.text);
break; 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() { function frame() {
if (mode === 'follow' || mode === 'effect') {
const now = performance.now(); const now = performance.now();
clockT += (now - clockAt) / 1000; clockT += (now - clockAt) / 1000;
clockAt = now; clockAt = now;
if (mode === 'follow' || mode === 'effect') {
viz.render(clockT, audioData || SILENT); 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);
} }
requestAnimationFrame(frame); requestAnimationFrame(frame);
+73 -15
View File
@@ -690,7 +690,7 @@ const IMG_POS_LABELS = { center: 'Centro', top: 'Alto', bottom: 'Basso', left: '
function optList(pairs, sel) { function optList(pairs, sel) {
return pairs.map(([v, l]) => '<option value="' + String(v).replace(/"/g, '&quot;') + '"' + (v === sel ? ' selected' : '') + '>' + l + '</option>').join(''); return pairs.map(([v, l]) => '<option value="' + String(v).replace(/"/g, '&quot;') + '"' + (v === sel ? ' selected' : '') + '>' + l + '</option>').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 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 }; } 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; 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) { function advanceCues(t) {
const ef = sceneActive('effect', t); const efMap = sceneActiveEffects(t);
if (ef !== lastScene.effect) { lastScene.effect = ef; if (ef && EFFECTS.list[ef.effectIndex]) applyEffect(EFFECTS.list[ef.effectIndex]); } 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 // 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 // ('screen') so the effect shows through underneath. Only when the user left
// the blend at the default 'normal' — a manual choice is always respected. // 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') { if (c.type === 'effect') {
const ce = (c.effectIndex != null && EFFECTS.list[c.effectIndex]) ? EFFECTS.list[c.effectIndex] : null; const ce = (c.effectIndex != null && EFFECTS.list[c.effectIndex]) ? EFFECTS.list[c.effectIndex] : null;
const cFam = ce ? ce.family : -1; 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 = '<option value="main"' + (tgt === 'main' ? ' selected' : '') + '>🖥 Principale</option>';
auxDisplays.forEach(ds => {
tgtOpts += '<option value="' + ds.id + '"' + (tgt === String(ds.id) ? ' selected' : '') + '>🖥 ' + ds.label + '</option>';
});
tgtOpts += '<option value="all"' + (tgt === 'all' ? ' selected' : '') + '>🖥🖥 Tutti gli schermi</option>';
if (tgt !== 'main' && tgt !== 'all' && !auxDisplays.some(ds => String(ds.id) === tgt)) {
tgtOpts += '<option value="' + tgt + '" selected>🖥 Schermo scollegato</option>';
}
html += html +=
'<div class="cue-body">' + '<div class="cue-body">' +
'<div class="cue-row">' + '<div class="cue-row">' +
'<select class="cue-eff-fam" title="Categoria">' + cueFamilyOptions(cFam) + '</select>' + '<select class="cue-eff-fam" title="Categoria">' + cueFamilyOptions(cFam) + '</select>' +
'<select class="cue-eff-pre" title="Preset"' + (cFam < 0 ? ' disabled' : '') + '>' + cuePresetOptions(cFam, c.effectIndex) + '</select>' + '<select class="cue-eff-pre" title="Preset"' + (cFam < 0 ? ' disabled' : '') + '>' + cuePresetOptions(cFam, c.effectIndex) + '</select>' +
'</div>' + '</div>' +
'<div class="cue-row">' +
'<span class="cue-dim">Su</span>' +
'<select class="cue-eff-target" title="Su quale schermo applicare l\'effetto">' + tgtOpts + '</select>' +
'</div>' +
'<div class="cue-row">' + '<div class="cue-row">' +
'<button class="cue-eff-cur" title="Usa l\'effetto visibile ora">🎯 Usa corrente</button>' + '<button class="cue-eff-cur" title="Usa l\'effetto visibile ora">🎯 Usa corrente</button>' +
'<span class="cue-eff-name">' + (ce ? ce.name : 'nessuno') + '</span>' + '<span class="cue-eff-name">' + (ce ? ce.name : 'nessuno') + '</span>' +
@@ -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(); } 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-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') { } else if (c.type === 'text') {
const ti = cueEl.querySelector('.cue-text'); ti.value = c.text || ''; const ti = cueEl.querySelector('.cue-text'); ti.value = c.text || '';
ti.addEventListener('input', (e) => { c.text = e.target.value; saveLive(); }); ti.addEventListener('input', (e) => { c.text = e.target.value; saveLive(); });
@@ -1678,22 +1731,25 @@ function renderAuxList() {
extra = '<button class="aux-img">Carica…</button><span class="aux-img-name">' + extra = '<button class="aux-img">Carica…</button><span class="aux-img-name">' +
(c.image ? c.image.split(/[\\/]/).pop() : 'nessuna') + '</span>'; (c.image ? c.image.split(/[\\/]/).pop() : 'nessuna') + '</span>';
} }
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 = ''; let textRow = '';
if (tx.on) { if (tx.on) {
textRow = textRow =
'<div class="aux-text-row">' + '<div class="aux-text-row">' +
'<input class="atx-val" type="text" placeholder="Testo da mostrare su questo schermo…" />' + '<input class="atx-val" type="text" placeholder="Testo da mostrare su questo schermo…" />' +
'<select class="atx-pos">' + '</div>' +
[['top', 'Alto'], ['middle', 'Centro'], ['bottom', 'Basso']].map(([v, l]) => '<div class="aux-text-row">' +
'<option value="' + v + '"' + (v === tx.pos ? ' selected' : '') + '>' + l + '</option>').join('') + '<select class="atx-dir" title="Direzione">' + optList(TXT_DIRS, tx.dir) + '</select>' +
'</select>' + '<select class="atx-fx" title="Effetto lettere">' + optList(TXT_FX, tx.fx) + '</select>' +
'<select class="atx-scroll">' + '<select class="atx-font" title="Font">' + optList(TXT_FONTS, tx.font) + '</select>' +
'<option value="1"' + (tx.scroll ? ' selected' : '') + '>Scorrevole</option>' + '<select class="atx-pos" title="Posizione">' + optList(TXT_POS, tx.pos) + '</select>' +
'<option value="0"' + (!tx.scroll ? ' selected' : '') + '>Fisso</option>' + '</div>' +
'</select>' + '<div class="aux-text-row">' +
'<span class="atx-lbl">Dim</span><input class="atx-size" type="range" min="3" max="20" step="0.5" value="' + tx.size + '" />' + '<span class="atx-lbl">Dim</span><input class="atx-size" type="range" min="2" max="18" step="0.5" value="' + tx.size + '" />' +
'<span class="atx-lbl">Vel</span><input class="atx-speed" type="range" min="0.2" max="3" step="0.1" value="' + tx.speed + '" />' + '<span class="atx-lbl">Vel</span><input class="atx-speed" type="range" min="0.2" max="4" step="0.1" value="' + tx.speed + '" />' +
'<input class="atx-color" type="color" value="' + tx.color + '" />' + '<input class="atx-color" type="color" value="' + tx.color + '" />' +
'<label class="atx-b"><input type="checkbox" class="atx-bold"' + (tx.weight ? ' checked' : '') + ' /> B</label>' + '<label class="atx-b"><input type="checkbox" class="atx-bold"' + (tx.weight ? ' checked' : '') + ' /> B</label>' +
'</div>'; '</div>';
@@ -1745,8 +1801,10 @@ function renderAuxList() {
txVal.value = tx.value || ''; txVal.value = tx.value || '';
const live = () => { auxSave(); auxSendCfg(d.id); }; const live = () => { auxSave(); auxSendCfg(d.id); };
txVal.addEventListener('input', (e) => { tx.value = e.target.value; live(); }); 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-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-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-speed').addEventListener('input', (e) => { tx.speed = parseFloat(e.target.value); live(); });
row.querySelector('.atx-color').addEventListener('input', (e) => { tx.color = e.target.value; live(); }); row.querySelector('.atx-color').addEventListener('input', (e) => { tx.color = e.target.value; live(); });
+3 -1
View File
@@ -974,7 +974,9 @@ kbd {
margin-top: 4px; padding-top: 6px; border-top: 1px dashed rgba(255,255,255,0.1); 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-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-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-size, .aux-row .atx-speed { flex: 0 0 70px; }
.aux-row .atx-color { flex: 0 0 30px; width: 30px; height: 22px; padding: 1px; } .aux-row .atx-color { flex: 0 0 30px; width: 30px; height: 22px; padding: 1px; }