Electron app with audio-reactive fractal/abstract visuals on an external display. Dual-window setup (control panel + fullscreen output), 8 WebGL scenes, audio from file/live input/system audio, image and scrolling-text overlays. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
157 lines
5.3 KiB
JavaScript
157 lines
5.3 KiB
JavaScript
const { app, BrowserWindow, screen, ipcMain, systemPreferences } = require('electron');
|
|
const path = require('path');
|
|
|
|
let controlWin = null; // panel with all the controls (primary screen)
|
|
let outputWin = null; // pure visualization (external screen, fullscreen)
|
|
|
|
function externalDisplay() {
|
|
const primary = screen.getPrimaryDisplay();
|
|
return screen.getAllDisplays().find(d => d.id !== primary.id) || primary;
|
|
}
|
|
|
|
function createWindows() {
|
|
const primary = screen.getPrimaryDisplay();
|
|
const ext = externalDisplay();
|
|
const hasExternal = ext.id !== primary.id;
|
|
|
|
console.log(`[DJV] Schermi rilevati: ${screen.getAllDisplays().length}` +
|
|
` — esterno ${hasExternal ? 'SÌ' : 'NO'} (output su ${hasExternal ? 'monitor esterno' : 'principale, finestra'})`);
|
|
|
|
// --- Control window (the panel) ---
|
|
controlWin = new BrowserWindow({
|
|
width: 580,
|
|
height: 880,
|
|
minWidth: 420,
|
|
x: primary.bounds.x + 60,
|
|
y: primary.bounds.y + 60,
|
|
title: 'DJ Visualizer — Controlli',
|
|
backgroundColor: '#0e0e16',
|
|
webPreferences: {
|
|
preload: path.join(__dirname, 'preload.js'),
|
|
contextIsolation: true,
|
|
nodeIntegration: false,
|
|
additionalArguments: ['--role=control']
|
|
}
|
|
});
|
|
controlWin.loadFile(path.join(__dirname, 'src', 'control.html'));
|
|
|
|
// --- Output window (the visuals) ---
|
|
outputWin = new BrowserWindow({
|
|
width: hasExternal ? ext.bounds.width : 960,
|
|
height: hasExternal ? ext.bounds.height : 600,
|
|
x: hasExternal ? ext.bounds.x : primary.bounds.x + 520,
|
|
y: hasExternal ? ext.bounds.y : primary.bounds.y + 60,
|
|
title: 'DJ Visualizer — Output',
|
|
backgroundColor: '#000000',
|
|
webPreferences: {
|
|
preload: path.join(__dirname, 'preload.js'),
|
|
contextIsolation: true,
|
|
nodeIntegration: false,
|
|
additionalArguments: ['--role=output'],
|
|
// Let audio loaded via IPC play without a click inside the output window.
|
|
autoplayPolicy: 'no-user-gesture-required'
|
|
}
|
|
});
|
|
outputWin.loadFile(path.join(__dirname, 'src', 'output.html'));
|
|
if (hasExternal) {
|
|
outputWin.once('ready-to-show', () => placeOutputOnDisplay(ext, true));
|
|
}
|
|
|
|
controlWin.on('closed', () => {
|
|
controlWin = null;
|
|
if (outputWin) outputWin.close();
|
|
app.quit();
|
|
});
|
|
outputWin.on('closed', () => { outputWin = null; });
|
|
}
|
|
|
|
async function ensureMicAccess() {
|
|
if (process.platform !== 'darwin') return;
|
|
try {
|
|
const status = systemPreferences.getMediaAccessStatus('microphone');
|
|
if (status !== 'granted') await systemPreferences.askForMediaAccess('microphone');
|
|
} catch (e) { /* getUserMedia will prompt again if needed */ }
|
|
}
|
|
|
|
app.whenReady().then(async () => {
|
|
await ensureMicAccess();
|
|
createWindows();
|
|
app.on('activate', () => {
|
|
if (BrowserWindow.getAllWindows().length === 0) createWindows();
|
|
});
|
|
});
|
|
|
|
app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); });
|
|
|
|
// --- IPC routing between the two windows --------------------------------
|
|
|
|
// Control -> Output (commands).
|
|
ipcMain.on('ctl', (_e, msg) => {
|
|
if (outputWin && !outputWin.isDestroyed()) outputWin.webContents.send('ctl', msg);
|
|
});
|
|
|
|
// Output -> Control (reports: meters, device list, play state).
|
|
ipcMain.on('rpt', (_e, msg) => {
|
|
if (controlWin && !controlWin.isDestroyed()) controlWin.webContents.send('rpt', msg);
|
|
});
|
|
|
|
// --- Display management --------------------------------------------------
|
|
|
|
ipcMain.handle('displays:list', () => {
|
|
const primary = screen.getPrimaryDisplay();
|
|
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
|
|
}));
|
|
});
|
|
|
|
// 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
|
|
// window happens to be on and is unreliable when moving between monitors.
|
|
function placeOutputOnDisplay(target, fullscreen) {
|
|
if (!outputWin || !target) return;
|
|
// Leave any current fullscreen first so the window can be repositioned.
|
|
if (process.platform === 'darwin') {
|
|
if (outputWin.isSimpleFullScreen()) outputWin.setSimpleFullScreen(false);
|
|
} else if (outputWin.isFullScreen()) {
|
|
outputWin.setFullScreen(false);
|
|
}
|
|
|
|
// Move onto the target display.
|
|
outputWin.setBounds({ ...target.bounds });
|
|
outputWin.show();
|
|
outputWin.focus();
|
|
|
|
if (!fullscreen) return;
|
|
// Cover the target display once the move has settled.
|
|
setTimeout(() => {
|
|
if (!outputWin) return;
|
|
outputWin.setBounds({ ...target.bounds });
|
|
if (process.platform === 'darwin') outputWin.setSimpleFullScreen(true);
|
|
else outputWin.setFullScreen(true);
|
|
}, 100);
|
|
}
|
|
|
|
// Move the OUTPUT window to a display and go fullscreen there.
|
|
ipcMain.handle('output:moveTo', (_e, displayId) => {
|
|
const target = screen.getAllDisplays().find(d => d.id === displayId);
|
|
if (!target) return false;
|
|
placeOutputOnDisplay(target, true);
|
|
return true;
|
|
});
|
|
|
|
ipcMain.handle('output:toggleFullscreen', () => {
|
|
if (!outputWin) return false;
|
|
if (process.platform === 'darwin') {
|
|
const next = !outputWin.isSimpleFullScreen();
|
|
outputWin.setSimpleFullScreen(next);
|
|
return next;
|
|
}
|
|
outputWin.setFullScreen(!outputWin.isFullScreen());
|
|
return outputWin.isFullScreen();
|
|
});
|