diff --git a/main.js b/main.js index 9f1d8b5..c7c3566 100644 --- a/main.js +++ b/main.js @@ -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() { const primary = screen.getPrimaryDisplay(); const ext = externalDisplay(); @@ -95,6 +154,8 @@ function createWindows() { controlWin.on('closed', () => { quitting = true; // closing the panel ends the show: let the output close controlWin = null; + for (const win of auxWins.values()) { if (!win.isDestroyed()) win.destroy(); } + auxWins.clear(); if (outputWin) outputWin.close(); app.quit(); }); @@ -190,6 +251,8 @@ app.whenReady().then(async () => { } await ensureMicAccess(); createWindows(); + screen.on('display-added', displaysChanged); + screen.on('display-removed', displaysChanged); app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindows(); }); @@ -202,6 +265,18 @@ app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit( // Control -> Output (commands). ipcMain.on('ctl', (_e, 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). @@ -378,17 +453,44 @@ ipcMain.handle('svg:listBuiltin', () => { } 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', () => { const primary = screen.getPrimaryDisplay(); + const outId = outputDisplayId(); return screen.getAllDisplays().map((d, i) => ({ id: d.id, index: i, label: d.label || `Display ${i + 1}`, 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* // fullscreen (a borderless window covering the screen) instead of native // fullscreen: native fullscreen creates a Space tied to whichever display the diff --git a/preload.js b/preload.js index 9c14c05..19f9480 100644 --- a/preload.js +++ b/preload.js @@ -1,11 +1,20 @@ 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 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', { 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. send: (msg) => ipcRenderer.send('ctl', msg), onControl: (cb) => ipcRenderer.on('ctl', (_e, m) => cb(m)), diff --git a/src/aux.html b/src/aux.html new file mode 100644 index 0000000..f50f75b --- /dev/null +++ b/src/aux.html @@ -0,0 +1,23 @@ + + + + + + DJ Visualizer — Uscita + + + + + + + + + + + + diff --git a/src/aux.js b/src/aux.js new file mode 100644 index 0000000..20a562c --- /dev/null +++ b/src/aux.js @@ -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 }); diff --git a/src/control.html b/src/control.html index 360c3f9..5cc9460 100644 --- a/src/control.html +++ b/src/control.html @@ -451,6 +451,12 @@ +
+ +
+
Gli schermi collegati/scollegati vengono rilevati automaticamente. + Lo schermo dell'output principale e quello dei Controlli non compaiono nell'elenco.
+
diff --git a/src/control.js b/src/control.js index 364d663..c2e2baf 100644 --- a/src/control.js +++ b/src/control.js @@ -1604,11 +1604,131 @@ async function refreshDisplays() { }); const ext = list.find(d => !d.isPrimary); 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); - 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 = '
Nessuno schermo aggiuntivo: qui compaiono i monitor oltre a Controlli e output principale.
'; + 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 += ''; + }); + 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 += ''; + }); + EFFECTS.list.forEach((e2, ei) => { + if (e2.family !== cur.family) return; + preOpts += ''; + }); + extra = '' + + ''; + } else if (c.mode === 'image') { + extra = '' + + (c.image ? c.image.split(/[\\/]/).pop() : 'nessuna') + ''; + } + row.innerHTML = + '' + + '' + 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()); // ---- LED wall active area (pixel-mapping processors) ---- @@ -1912,6 +2032,18 @@ djv.onReport((m) => { } 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': // corner edits made on the output: adopt and persist mapCfg.zones = m.zones || []; @@ -2041,6 +2173,6 @@ djv.onReport((m) => { renderLibrary(); renderSequence(); applyEffect(currentEffect); // push the starting effect to the output window -refreshDisplays(); +refreshDisplays().then(() => auxSyncAll()); // recreate saved aux outputs too window.addEventListener('focus', refreshDisplays); send({ type: 'refreshDevices' }); diff --git a/src/output.js b/src/output.js index 1be5992..5e0861f 100644 --- a/src/output.js +++ b/src/output.js @@ -346,6 +346,7 @@ djv.onControl(async (m) => { if (mapping) mapping.editOn = !!m.on; $('#map-canvas').classList.toggle('edit', !!m.on); break; + case 'auxActive': auxActive = !!m.on; break; case 'glbAnims': viz.setClipFilter(m.names || []); break; case 'modelBpm': viz.setManualBpm(m.bpm || 0); break; case 'modelSpread': viz.setArmSpread(m.deg || 0); break; @@ -541,6 +542,7 @@ djv.onControl(async (m) => { // ---------------------------------------------------------------- render loop const startTime = performance.now(); 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 ------ // 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. 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. if (now - lastProgress > 400) { lastProgress = now; diff --git a/src/style.css b/src/style.css index 22fbb63..d5b79c3 100644 --- a/src/style.css +++ b/src/style.css @@ -943,3 +943,22 @@ kbd { color: #ff8a8a; 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; }