True multi-output: one aux window per extra display, assignable content
New 'Uscite multiple' section in the Schermo tab: every display that hosts neither the panel nor the main output gets a row with an on/off toggle and a content mode — follow the main effect, a dedicated effect (family+preset, camera/3D families excluded), a fullscreen image, or black. Aux windows are lightweight visual-only renderers (aux.html/aux.js) fed with the audio analysis relayed from the main output at ~30 Hz over the new 'afr' channel, with a drift-corrected clock. Display hot-plug is now detected (list refresh + stale aux windows dropped); config persists per display id in localStorage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b5dc0275c8
commit
203efe3333
8 files changed
+398
-5
No files matched your search
@@ -63,6 +63,65 @@ function createOutputWindow() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Aux output windows (true multi-output: one extra window per display) ---
|
||||||
|
// Keyed by display id. Each loads aux.html: a lightweight visual-only renderer
|
||||||
|
// (own effect / follow the main effect / image / black) fed with the audio
|
||||||
|
// analysis relayed from the main output at ~30 Hz on the 'afr' channel.
|
||||||
|
const auxWins = new Map();
|
||||||
|
|
||||||
|
function tellOutputAuxActive() {
|
||||||
|
if (outputWin && !outputWin.isDestroyed()) {
|
||||||
|
outputWin.webContents.send('ctl', { type: 'auxActive', on: auxWins.size > 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAuxWindow(display) {
|
||||||
|
const win = new BrowserWindow({
|
||||||
|
x: display.bounds.x, y: display.bounds.y,
|
||||||
|
width: display.bounds.width, height: display.bounds.height,
|
||||||
|
title: 'DJ Visualizer — Uscita ' + display.id,
|
||||||
|
backgroundColor: '#000000',
|
||||||
|
webPreferences: {
|
||||||
|
preload: path.join(__dirname, 'preload.js'),
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
additionalArguments: ['--role=aux', '--display=' + display.id]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
win.loadFile(path.join(__dirname, 'src', 'aux.html'));
|
||||||
|
win.once('ready-to-show', () => {
|
||||||
|
win.setBounds({ ...display.bounds });
|
||||||
|
if (process.platform === 'darwin') win.setSimpleFullScreen(true);
|
||||||
|
else win.setFullScreen(true);
|
||||||
|
});
|
||||||
|
win.on('closed', () => {
|
||||||
|
if (auxWins.get(display.id) === win) auxWins.delete(display.id);
|
||||||
|
tellOutputAuxActive();
|
||||||
|
if (controlWin && !controlWin.isDestroyed()) {
|
||||||
|
controlWin.webContents.send('rpt', { type: 'auxClosed', displayId: display.id });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
auxWins.set(display.id, win);
|
||||||
|
tellOutputAuxActive();
|
||||||
|
return win;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconcile the aux window set with the wanted list of display ids.
|
||||||
|
ipcMain.handle('aux:sync', (_e, wantedIds) => {
|
||||||
|
const wanted = new Set(wantedIds || []);
|
||||||
|
for (const [id, win] of [...auxWins]) {
|
||||||
|
if (!wanted.has(id)) { auxWins.delete(id); if (!win.isDestroyed()) win.destroy(); }
|
||||||
|
}
|
||||||
|
tellOutputAuxActive();
|
||||||
|
const displays = screen.getAllDisplays();
|
||||||
|
for (const id of wanted) {
|
||||||
|
if (auxWins.has(id)) continue;
|
||||||
|
const d = displays.find(x => x.id === id);
|
||||||
|
if (d) createAuxWindow(d);
|
||||||
|
}
|
||||||
|
return [...auxWins.keys()];
|
||||||
|
});
|
||||||
|
|
||||||
function createWindows() {
|
function createWindows() {
|
||||||
const primary = screen.getPrimaryDisplay();
|
const primary = screen.getPrimaryDisplay();
|
||||||
const ext = externalDisplay();
|
const ext = externalDisplay();
|
||||||
@@ -95,6 +154,8 @@ function createWindows() {
|
|||||||
controlWin.on('closed', () => {
|
controlWin.on('closed', () => {
|
||||||
quitting = true; // closing the panel ends the show: let the output close
|
quitting = true; // closing the panel ends the show: let the output close
|
||||||
controlWin = null;
|
controlWin = null;
|
||||||
|
for (const win of auxWins.values()) { if (!win.isDestroyed()) win.destroy(); }
|
||||||
|
auxWins.clear();
|
||||||
if (outputWin) outputWin.close();
|
if (outputWin) outputWin.close();
|
||||||
app.quit();
|
app.quit();
|
||||||
});
|
});
|
||||||
@@ -190,6 +251,8 @@ app.whenReady().then(async () => {
|
|||||||
}
|
}
|
||||||
await ensureMicAccess();
|
await ensureMicAccess();
|
||||||
createWindows();
|
createWindows();
|
||||||
|
screen.on('display-added', displaysChanged);
|
||||||
|
screen.on('display-removed', displaysChanged);
|
||||||
app.on('activate', () => {
|
app.on('activate', () => {
|
||||||
if (BrowserWindow.getAllWindows().length === 0) createWindows();
|
if (BrowserWindow.getAllWindows().length === 0) createWindows();
|
||||||
});
|
});
|
||||||
@@ -202,6 +265,18 @@ app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(
|
|||||||
// Control -> Output (commands).
|
// Control -> Output (commands).
|
||||||
ipcMain.on('ctl', (_e, msg) => {
|
ipcMain.on('ctl', (_e, msg) => {
|
||||||
if (outputWin && !outputWin.isDestroyed()) outputWin.webContents.send('ctl', msg);
|
if (outputWin && !outputWin.isDestroyed()) outputWin.webContents.send('ctl', msg);
|
||||||
|
// Aux outputs listen to the same channel (they pick what they care about:
|
||||||
|
// their own 'auxCfg' and the broadcast 'effect' for follow mode).
|
||||||
|
for (const win of auxWins.values()) {
|
||||||
|
if (!win.isDestroyed()) win.webContents.send('ctl', msg);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Audio analysis frames: main output -> every aux output (never the control).
|
||||||
|
ipcMain.on('afr', (_e, data) => {
|
||||||
|
for (const win of auxWins.values()) {
|
||||||
|
if (!win.isDestroyed()) win.webContents.send('afr', data);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Output -> Control (reports: meters, device list, play state).
|
// Output -> Control (reports: meters, device list, play state).
|
||||||
@@ -378,17 +453,44 @@ ipcMain.handle('svg:listBuiltin', () => {
|
|||||||
} catch (e) { return []; }
|
} catch (e) { return []; }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function outputDisplayId() {
|
||||||
|
if (!outputWin || outputWin.isDestroyed()) return -1;
|
||||||
|
try {
|
||||||
|
const b = outputWin.getBounds();
|
||||||
|
return screen.getDisplayNearestPoint({
|
||||||
|
x: b.x + Math.floor(b.width / 2), y: b.y + Math.floor(b.height / 2)
|
||||||
|
}).id;
|
||||||
|
} catch (e) { return -1; }
|
||||||
|
}
|
||||||
|
|
||||||
ipcMain.handle('displays:list', () => {
|
ipcMain.handle('displays:list', () => {
|
||||||
const primary = screen.getPrimaryDisplay();
|
const primary = screen.getPrimaryDisplay();
|
||||||
|
const outId = outputDisplayId();
|
||||||
return screen.getAllDisplays().map((d, i) => ({
|
return screen.getAllDisplays().map((d, i) => ({
|
||||||
id: d.id,
|
id: d.id,
|
||||||
index: i,
|
index: i,
|
||||||
label: d.label || `Display ${i + 1}`,
|
label: d.label || `Display ${i + 1}`,
|
||||||
bounds: d.bounds,
|
bounds: d.bounds,
|
||||||
isPrimary: d.id === primary.id
|
isPrimary: d.id === primary.id,
|
||||||
|
hasOutput: d.id === outId,
|
||||||
|
hasAux: auxWins.has(d.id)
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Hot-plug: keep the panel's display lists fresh and drop aux windows whose
|
||||||
|
// display disappeared (their fullscreen window would otherwise pile up on
|
||||||
|
// another screen).
|
||||||
|
function displaysChanged() {
|
||||||
|
const ids = new Set(screen.getAllDisplays().map(d => d.id));
|
||||||
|
for (const [id, win] of [...auxWins]) {
|
||||||
|
if (!ids.has(id)) { auxWins.delete(id); if (!win.isDestroyed()) win.destroy(); }
|
||||||
|
}
|
||||||
|
tellOutputAuxActive();
|
||||||
|
if (controlWin && !controlWin.isDestroyed()) {
|
||||||
|
controlWin.webContents.send('rpt', { type: 'displaysChanged' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Place the output window on a given display. On macOS we use *simple*
|
// Place the output window on a given display. On macOS we use *simple*
|
||||||
// fullscreen (a borderless window covering the screen) instead of native
|
// fullscreen (a borderless window covering the screen) instead of native
|
||||||
// fullscreen: native fullscreen creates a Space tied to whichever display the
|
// fullscreen: native fullscreen creates a Space tied to whichever display the
|
||||||
|
|||||||
+10
-1
@@ -1,11 +1,20 @@
|
|||||||
const { contextBridge, ipcRenderer, webUtils } = require('electron');
|
const { contextBridge, ipcRenderer, webUtils } = require('electron');
|
||||||
|
|
||||||
// Each window is launched with --role=control or --role=output.
|
// Each window is launched with --role=control, --role=output or --role=aux.
|
||||||
const roleArg = process.argv.find(a => a.startsWith('--role='));
|
const roleArg = process.argv.find(a => a.startsWith('--role='));
|
||||||
const role = roleArg ? roleArg.split('=')[1] : 'control';
|
const role = roleArg ? roleArg.split('=')[1] : 'control';
|
||||||
|
// Aux outputs also get the id of the display they live on.
|
||||||
|
const dispArg = process.argv.find(a => a.startsWith('--display='));
|
||||||
|
const displayId = dispArg ? parseInt(dispArg.split('=')[1], 10) : -1;
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('djv', {
|
contextBridge.exposeInMainWorld('djv', {
|
||||||
role,
|
role,
|
||||||
|
displayId,
|
||||||
|
// Audio analysis relay: main output -> aux outputs (~30 Hz).
|
||||||
|
sendAudio: (data) => ipcRenderer.send('afr', data),
|
||||||
|
onAudio: (cb) => ipcRenderer.on('afr', (_e, d) => cb(d)),
|
||||||
|
// Create/close aux output windows to match the given display-id list.
|
||||||
|
auxSync: (ids) => ipcRenderer.invoke('aux:sync', ids),
|
||||||
// Control -> Output commands.
|
// Control -> Output commands.
|
||||||
send: (msg) => ipcRenderer.send('ctl', msg),
|
send: (msg) => ipcRenderer.send('ctl', msg),
|
||||||
onControl: (cb) => ipcRenderer.on('ctl', (_e, m) => cb(m)),
|
onControl: (cb) => ipcRenderer.on('ctl', (_e, m) => cb(m)),
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="it">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta http-equiv="Content-Security-Policy"
|
||||||
|
content="default-src 'self'; img-src 'self' blob: data: file:; style-src 'self' 'unsafe-inline'; script-src 'self'" />
|
||||||
|
<title>DJ Visualizer — Uscita</title>
|
||||||
|
<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; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<canvas id="gl"></canvas>
|
||||||
|
<img id="aux-image" alt="" />
|
||||||
|
<script src="shaders.js"></script>
|
||||||
|
<script src="effects.js"></script>
|
||||||
|
<script src="fluid.js"></script>
|
||||||
|
<script src="visualizer.js"></script>
|
||||||
|
<script src="aux.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+91
@@ -0,0 +1,91 @@
|
|||||||
|
// Aux output window: a lightweight, visual-only renderer for one extra
|
||||||
|
// physical display. It has no audio engine of its own — the main output relays
|
||||||
|
// its analysis (~30 Hz on the 'afr' channel) and this window renders with it.
|
||||||
|
// Modes: 'follow' (same effect as the main output), 'effect' (own preset),
|
||||||
|
// 'image' (fullscreen picture), 'black'.
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const cv = document.getElementById('gl');
|
||||||
|
const imgEl = document.getElementById('aux-image');
|
||||||
|
const viz = new Visualizer(cv);
|
||||||
|
|
||||||
|
const MY_ID = window.djv.displayId;
|
||||||
|
|
||||||
|
let mode = 'follow';
|
||||||
|
let ownEffect = null; // effect object for mode 'effect'
|
||||||
|
let followEffect = null; // last effect broadcast by the control panel
|
||||||
|
|
||||||
|
function toFileURL(p) {
|
||||||
|
let n = String(p).replace(/\\/g, '/');
|
||||||
|
if (n[0] !== '/') n = '/' + n;
|
||||||
|
return encodeURI('file://' + n).replace(/#/g, '%23').replace(/\?/g, '%3F');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Camera/3D families run hardware or heavy engines that must stay exclusive
|
||||||
|
// to the main output; an aux window falls back to a plain shader instead.
|
||||||
|
function safeEffect(e) {
|
||||||
|
if (!e || e.isInteractive || e.isModel3d) return EFFECTS.defaults();
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyMode() {
|
||||||
|
const showCanvas = mode === 'follow' || mode === 'effect';
|
||||||
|
cv.style.display = showCanvas ? 'block' : 'none';
|
||||||
|
imgEl.style.display = mode === 'image' && imgEl.src ? 'block' : 'none';
|
||||||
|
if (mode === 'effect' && ownEffect) viz.setEffect(safeEffect(ownEffect));
|
||||||
|
if (mode === 'follow' && followEffect) viz.setEffect(safeEffect(followEffect));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- audio + clock -------------------------------------------------------
|
||||||
|
// The relay carries the primary's clock (already speed-scaled). Between frames
|
||||||
|
// we extrapolate with the local rAF delta so motion stays 60fps-smooth, and a
|
||||||
|
// gentle correction keeps us glued to the primary without visible jumps.
|
||||||
|
let audioData = null;
|
||||||
|
let clockT = 0, clockAt = performance.now();
|
||||||
|
|
||||||
|
const SILENT = {
|
||||||
|
bass: 0, mid: 0, treble: 0, level: 0, beat: 0,
|
||||||
|
spectrum: new Float32Array(32), wave: new Float32Array(256), waveHist: new Float32Array(256)
|
||||||
|
};
|
||||||
|
|
||||||
|
window.djv.onAudio((d) => {
|
||||||
|
audioData = d;
|
||||||
|
const now = performance.now();
|
||||||
|
const local = clockT + (now - clockAt) / 1000;
|
||||||
|
clockAt = now;
|
||||||
|
// Adopt the primary's time, but smoothly when the gap is small.
|
||||||
|
clockT = Math.abs(d.t - local) > 0.5 ? d.t : local + (d.t - local) * 0.15;
|
||||||
|
});
|
||||||
|
|
||||||
|
window.djv.onControl((m) => {
|
||||||
|
switch (m.type) {
|
||||||
|
case 'effect':
|
||||||
|
followEffect = m.effect;
|
||||||
|
if (mode === 'follow') viz.setEffect(safeEffect(followEffect));
|
||||||
|
break;
|
||||||
|
case 'auxCfg': {
|
||||||
|
if (m.displayId !== MY_ID) break;
|
||||||
|
mode = m.mode || 'follow';
|
||||||
|
if (m.effect) followEffect = m.effect;
|
||||||
|
if (m.effectIndex != null && EFFECTS.list[m.effectIndex]) ownEffect = EFFECTS.list[m.effectIndex];
|
||||||
|
if (m.image) imgEl.src = toFileURL(m.image);
|
||||||
|
else if (m.mode !== 'image') imgEl.removeAttribute('src');
|
||||||
|
applyMode();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function frame() {
|
||||||
|
if (mode === 'follow' || mode === 'effect') {
|
||||||
|
const now = performance.now();
|
||||||
|
clockT += (now - clockAt) / 1000;
|
||||||
|
clockAt = now;
|
||||||
|
viz.render(clockT, audioData || SILENT);
|
||||||
|
}
|
||||||
|
requestAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
requestAnimationFrame(frame);
|
||||||
|
|
||||||
|
// Announce ourselves so the panel can (re)send this display's configuration.
|
||||||
|
window.djv.report({ type: 'auxReady', displayId: MY_ID });
|
||||||
@@ -451,6 +451,12 @@
|
|||||||
<button id="btn-fullscreen">⛶ Fullscreen</button>
|
<button id="btn-fullscreen">⛶ Fullscreen</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<section class="span2">
|
||||||
|
<label>Uscite multiple <small>— una finestra indipendente per ogni schermo in più</small></label>
|
||||||
|
<div id="aux-list" class="aux-list"></div>
|
||||||
|
<div class="hint-mini">Gli schermi collegati/scollegati vengono rilevati automaticamente.
|
||||||
|
Lo schermo dell'output principale e quello dei Controlli non compaiono nell'elenco.</div>
|
||||||
|
</section>
|
||||||
<section class="span2">
|
<section class="span2">
|
||||||
<label>Area LED (pixel mapping)</label>
|
<label>Area LED (pixel mapping)</label>
|
||||||
<div class="row led-row">
|
<div class="row led-row">
|
||||||
|
|||||||
+135
-3
@@ -1604,11 +1604,131 @@ async function refreshDisplays() {
|
|||||||
});
|
});
|
||||||
const ext = list.find(d => !d.isPrimary);
|
const ext = list.find(d => !d.isPrimary);
|
||||||
if (ext) sel.value = ext.id;
|
if (ext) sel.value = ext.id;
|
||||||
|
// Aux candidates: every display that hosts neither the panel nor the output.
|
||||||
|
auxDisplays = list.filter(d => !d.isPrimary && !d.hasOutput);
|
||||||
|
renderAuxList();
|
||||||
}
|
}
|
||||||
$('#btn-display').addEventListener('click', () => {
|
$('#btn-display').addEventListener('click', async () => {
|
||||||
const id = parseInt($('#display-select').value, 10);
|
const id = parseInt($('#display-select').value, 10);
|
||||||
if (id) djv.moveOutputTo(id);
|
if (!id) return;
|
||||||
|
// If an aux window occupies the chosen display, turn it off first.
|
||||||
|
if (auxCfg[id] && auxCfg[id].on) { auxCfg[id].on = false; auxSave(); await auxSyncAll(); }
|
||||||
|
await djv.moveOutputTo(id);
|
||||||
|
refreshDisplays(); // the aux candidate list depends on where the output sits
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- Aux outputs: one independent window per extra display ----
|
||||||
|
let auxCfg = {}; // displayId -> { on, mode, effectIndex, image }
|
||||||
|
try { auxCfg = JSON.parse(localStorage.getItem('auxout') || '{}'); } catch (e) {}
|
||||||
|
let auxDisplays = []; // current candidate displays (neither panel nor output)
|
||||||
|
function auxSave() { localStorage.setItem('auxout', JSON.stringify(auxCfg)); }
|
||||||
|
|
||||||
|
function auxSendCfg(displayId) {
|
||||||
|
const c = auxCfg[displayId];
|
||||||
|
if (!c || !c.on) return;
|
||||||
|
send({ type: 'auxCfg', displayId: parseInt(displayId, 10),
|
||||||
|
mode: c.mode || 'follow', effectIndex: c.effectIndex, image: c.image || null,
|
||||||
|
effect: currentEffect }); // so a fresh window in 'follow' mode starts aligned
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create/close aux windows so they match the enabled displays, then push each
|
||||||
|
// one's content config (late loaders are covered by their 'auxReady' report).
|
||||||
|
async function auxSyncAll() {
|
||||||
|
const ids = auxDisplays.filter(d => auxCfg[d.id] && auxCfg[d.id].on).map(d => d.id);
|
||||||
|
const live = await djv.auxSync(ids);
|
||||||
|
live.forEach(id => auxSendCfg(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
const AUX_MODES = [['follow', '🔁 Segui il principale'], ['effect', '✨ Effetto dedicato'],
|
||||||
|
['image', '🖼 Immagine'], ['black', '⬛ Nero']];
|
||||||
|
|
||||||
|
function renderAuxList() {
|
||||||
|
const box = $('#aux-list');
|
||||||
|
if (!box) return;
|
||||||
|
box.innerHTML = '';
|
||||||
|
if (!auxDisplays.length) {
|
||||||
|
box.innerHTML = '<div class="hint-mini">Nessuno schermo aggiuntivo: qui compaiono i monitor oltre a Controlli e output principale.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
auxDisplays.forEach(d => {
|
||||||
|
const c = auxCfg[d.id] = auxCfg[d.id] || { on: false, mode: 'follow', effectIndex: null, image: null };
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'aux-row';
|
||||||
|
let modeOpts = '';
|
||||||
|
AUX_MODES.forEach(([v, l]) => {
|
||||||
|
modeOpts += '<option value="' + v + '"' + (v === (c.mode || 'follow') ? ' selected' : '') + '>' + l + '</option>';
|
||||||
|
});
|
||||||
|
let extra = '';
|
||||||
|
if (c.mode === 'effect') {
|
||||||
|
const cur = EFFECTS.list[c.effectIndex] || EFFECTS.defaults();
|
||||||
|
let famOpts = '', preOpts = '';
|
||||||
|
EFFECTS.families.forEach((name, fi) => {
|
||||||
|
if (/^Interattivo|^Modello 3D/.test(name)) return;
|
||||||
|
famOpts += '<option value="' + fi + '"' + (fi === cur.family ? ' selected' : '') + '>' + name + '</option>';
|
||||||
|
});
|
||||||
|
EFFECTS.list.forEach((e2, ei) => {
|
||||||
|
if (e2.family !== cur.family) return;
|
||||||
|
preOpts += '<option value="' + ei + '"' + (ei === c.effectIndex ? ' selected' : '') + '>' +
|
||||||
|
e2.name.replace(EFFECTS.families[e2.family] + ' · ', '') + '</option>';
|
||||||
|
});
|
||||||
|
extra = '<span class="aux-fx"><select class="aux-fam">' + famOpts + '</select>' +
|
||||||
|
'<select class="aux-pre">' + preOpts + '</select></span>';
|
||||||
|
} else if (c.mode === 'image') {
|
||||||
|
extra = '<button class="aux-img">Carica…</button><span class="aux-img-name">' +
|
||||||
|
(c.image ? c.image.split(/[\\/]/).pop() : 'nessuna') + '</span>';
|
||||||
|
}
|
||||||
|
row.innerHTML =
|
||||||
|
'<label class="aux-chk"><input type="checkbox" class="aux-on"' + (c.on ? ' checked' : '') + ' /> ' +
|
||||||
|
d.label + ' <small>' + d.bounds.width + '×' + d.bounds.height + '</small></label>' +
|
||||||
|
'<select class="aux-mode">' + modeOpts + '</select>' + extra;
|
||||||
|
|
||||||
|
row.querySelector('.aux-on').addEventListener('change', (e) => {
|
||||||
|
c.on = e.target.checked; auxSave(); auxSyncAll();
|
||||||
|
});
|
||||||
|
row.querySelector('.aux-mode').addEventListener('change', (e) => {
|
||||||
|
c.mode = e.target.value;
|
||||||
|
if (c.mode === 'effect' && (c.effectIndex == null || c.effectIndex < 0)) {
|
||||||
|
const di = EFFECTS.list.indexOf(EFFECTS.defaults());
|
||||||
|
c.effectIndex = di < 0 ? 12 : di; // defaults() is not in the list
|
||||||
|
}
|
||||||
|
auxSave(); auxSendCfg(d.id); renderAuxList();
|
||||||
|
});
|
||||||
|
const famSel = row.querySelector('.aux-fam');
|
||||||
|
if (famSel) {
|
||||||
|
const preSel = row.querySelector('.aux-pre');
|
||||||
|
famSel.addEventListener('change', () => {
|
||||||
|
const fam = parseInt(famSel.value, 10);
|
||||||
|
preSel.innerHTML = '';
|
||||||
|
EFFECTS.list.forEach((e2, ei) => {
|
||||||
|
if (e2.family !== fam) return;
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = ei;
|
||||||
|
o.textContent = e2.name.replace(EFFECTS.families[fam] + ' · ', '');
|
||||||
|
preSel.appendChild(o);
|
||||||
|
});
|
||||||
|
c.effectIndex = parseInt(preSel.value, 10);
|
||||||
|
auxSave(); auxSendCfg(d.id);
|
||||||
|
});
|
||||||
|
preSel.addEventListener('change', () => {
|
||||||
|
c.effectIndex = parseInt(preSel.value, 10);
|
||||||
|
auxSave(); auxSendCfg(d.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const imgBtn = row.querySelector('.aux-img');
|
||||||
|
if (imgBtn) imgBtn.addEventListener('click', () => {
|
||||||
|
const inp = document.createElement('input');
|
||||||
|
inp.type = 'file';
|
||||||
|
inp.accept = 'image/*';
|
||||||
|
inp.addEventListener('change', () => {
|
||||||
|
const f = inp.files[0];
|
||||||
|
const p = f ? djv.pathForFile(f) : null;
|
||||||
|
if (p) { c.image = p; auxSave(); auxSendCfg(d.id); renderAuxList(); }
|
||||||
|
});
|
||||||
|
inp.click();
|
||||||
|
});
|
||||||
|
box.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
$('#btn-fullscreen').addEventListener('click', () => djv.toggleOutputFullscreen());
|
$('#btn-fullscreen').addEventListener('click', () => djv.toggleOutputFullscreen());
|
||||||
|
|
||||||
// ---- LED wall active area (pixel-mapping processors) ----
|
// ---- LED wall active area (pixel-mapping processors) ----
|
||||||
@@ -1912,6 +2032,18 @@ djv.onReport((m) => {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'glbClips': renderGlbClips(m.names); break;
|
case 'glbClips': renderGlbClips(m.names); break;
|
||||||
|
case 'auxReady':
|
||||||
|
// An aux window finished loading: (re)send its configuration.
|
||||||
|
auxSendCfg(m.displayId);
|
||||||
|
break;
|
||||||
|
case 'auxClosed':
|
||||||
|
if (auxCfg[m.displayId]) { auxCfg[m.displayId].on = false; auxSave(); }
|
||||||
|
refreshDisplays();
|
||||||
|
break;
|
||||||
|
case 'displaysChanged':
|
||||||
|
// Monitor plugged/unplugged: refresh pickers and drop stale aux flags.
|
||||||
|
refreshDisplays().then(() => auxSyncAll());
|
||||||
|
break;
|
||||||
case 'mapZones':
|
case 'mapZones':
|
||||||
// corner edits made on the output: adopt and persist
|
// corner edits made on the output: adopt and persist
|
||||||
mapCfg.zones = m.zones || [];
|
mapCfg.zones = m.zones || [];
|
||||||
@@ -2041,6 +2173,6 @@ djv.onReport((m) => {
|
|||||||
renderLibrary();
|
renderLibrary();
|
||||||
renderSequence();
|
renderSequence();
|
||||||
applyEffect(currentEffect); // push the starting effect to the output window
|
applyEffect(currentEffect); // push the starting effect to the output window
|
||||||
refreshDisplays();
|
refreshDisplays().then(() => auxSyncAll()); // recreate saved aux outputs too
|
||||||
window.addEventListener('focus', refreshDisplays);
|
window.addEventListener('focus', refreshDisplays);
|
||||||
send({ type: 'refreshDevices' });
|
send({ type: 'refreshDevices' });
|
||||||
@@ -346,6 +346,7 @@ djv.onControl(async (m) => {
|
|||||||
if (mapping) mapping.editOn = !!m.on;
|
if (mapping) mapping.editOn = !!m.on;
|
||||||
$('#map-canvas').classList.toggle('edit', !!m.on);
|
$('#map-canvas').classList.toggle('edit', !!m.on);
|
||||||
break;
|
break;
|
||||||
|
case 'auxActive': auxActive = !!m.on; break;
|
||||||
case 'glbAnims': viz.setClipFilter(m.names || []); break;
|
case 'glbAnims': viz.setClipFilter(m.names || []); break;
|
||||||
case 'modelBpm': viz.setManualBpm(m.bpm || 0); break;
|
case 'modelBpm': viz.setManualBpm(m.bpm || 0); break;
|
||||||
case 'modelSpread': viz.setArmSpread(m.deg || 0); break;
|
case 'modelSpread': viz.setArmSpread(m.deg || 0); break;
|
||||||
@@ -541,6 +542,7 @@ djv.onControl(async (m) => {
|
|||||||
// ---------------------------------------------------------------- render loop
|
// ---------------------------------------------------------------- render loop
|
||||||
const startTime = performance.now();
|
const startTime = performance.now();
|
||||||
let frames = 0, fpsT = performance.now(), fps = 0, lastReport = 0, prevBeat = 0, lastProgress = 0;
|
let frames = 0, fpsT = performance.now(), fps = 0, lastReport = 0, prevBeat = 0, lastProgress = 0;
|
||||||
|
let auxActive = false, lastAfr = 0; // aux outputs: relay audio only when some exist
|
||||||
|
|
||||||
// ---- Auto VJ: a director that adapts the visuals to the playing track ------
|
// ---- Auto VJ: a director that adapts the visuals to the playing track ------
|
||||||
// Uses the engine's musical features (mood/bpm/drop/beatCount): switches preset
|
// Uses the engine's musical features (mood/bpm/drop/beatCount): switches preset
|
||||||
@@ -643,6 +645,15 @@ function frame() {
|
|||||||
}
|
}
|
||||||
// Stop at the trim end point if one is set.
|
// Stop at the trim end point if one is set.
|
||||||
audio.checkTrim();
|
audio.checkTrim();
|
||||||
|
// Relay the audio analysis + clock to the aux output windows (~30 Hz).
|
||||||
|
if (auxActive && now - lastAfr > 33) {
|
||||||
|
lastAfr = now;
|
||||||
|
djv.sendAudio({
|
||||||
|
t: t * speed,
|
||||||
|
bass: a.bass, mid: a.mid, treble: a.treble, level: a.level, beat: a.beat,
|
||||||
|
spectrum: a.spectrum, wave: a.wave, waveHist: a.waveHist
|
||||||
|
});
|
||||||
|
}
|
||||||
// Playback progress for the currently playing file.
|
// Playback progress for the currently playing file.
|
||||||
if (now - lastProgress > 400) {
|
if (now - lastProgress > 400) {
|
||||||
lastProgress = now;
|
lastProgress = now;
|
||||||
|
|||||||
@@ -943,3 +943,22 @@ kbd {
|
|||||||
color: #ff8a8a;
|
color: #ff8a8a;
|
||||||
padding: 0 3px;
|
padding: 0 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Multi-output rows (tab Schermo) */
|
||||||
|
.aux-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
margin: 4px 0;
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
border-radius: 9px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
}
|
||||||
|
.aux-row .aux-chk { flex: 1 1 auto; display: flex; align-items: center; gap: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.aux-row .aux-chk small { opacity: 0.6; }
|
||||||
|
.aux-row .aux-mode { flex: 0 0 auto; padding: 4px 6px; font-size: 11px; }
|
||||||
|
.aux-row .aux-fx { flex: 1 1 auto; display: flex; gap: 4px; min-width: 0; }
|
||||||
|
.aux-row .aux-fx select { flex: 1; min-width: 0; padding: 4px 6px; font-size: 11px; }
|
||||||
|
.aux-row .aux-img { flex: 0 0 auto; width: auto; padding: 4px 8px; font-size: 11px; }
|
||||||
|
.aux-row .aux-img-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; opacity: 0.75; }
|
||||||
Reference in new issue
Block a user