const { app, BrowserWindow, screen, ipcMain, systemPreferences, shell, dialog, protocol, net } = require('electron'); const path = require('path'); const fs = require('fs'); const { execFile } = require('child_process'); const { pathToFileURL } = require('url'); const crypto = require('crypto'); const { RemoteServer } = require('./remote-server'); // djvres:// serves the MediaPipe WASM runtime and the bundled pose model to // the renderer: fetch() does not work on file:// URLs, and the pose tracker // loads its assets via fetch. Registered before app.ready as required. protocol.registerSchemesAsPrivileged([{ scheme: 'djvres', privileges: { standard: true, secure: true, supportFetchAPI: true, stream: true, corsEnabled: true } }]); 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; } let quitting = false; function createOutputWindow() { const primary = screen.getPrimaryDisplay(); const ext = externalDisplay(); const hasExternal = ext.id !== primary.id; 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)); } // The output window hosts the audio engine and all visual state: closing it // by accident (Cmd+W, red button) would kill the show. Ignore close requests // while the control panel is open; only app quit may close it. outputWin.on('close', (e) => { if (!quitting && controlWin && !controlWin.isDestroyed()) e.preventDefault(); }); // Safety net: if it dies anyway (e.g. a renderer crash), bring it back. outputWin.on('closed', () => { outputWin = null; if (!quitting && controlWin && !controlWin.isDestroyed()) createOutputWindow(); }); outputWin.webContents.on('render-process-gone', () => { if (quitting) return; if (outputWin && !outputWin.isDestroyed()) outputWin.destroy(); }); } // --- 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(); 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: 980, height: 1180, 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) --- createOutputWindow(); 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(); remote.stop(); if (outputWin) outputWin.close(); app.quit(); }); } 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 */ } } // Convert a Mixamo FBX animation into a skeleton-only GLB clip via Blender // (when installed): lets the user add moves to the dance library from the // panel without any manual conversion step. function blenderPath() { const cands = ['/Applications/Blender.app/Contents/MacOS/Blender', '/opt/homebrew/bin/blender', '/usr/local/bin/blender']; if (process.platform === 'win32') { // Blender installs versioned folders under Program Files for (const root of ['C:\\Program Files\\Blender Foundation', 'C:\\Program Files (x86)\\Blender Foundation']) { try { for (const d of fs.readdirSync(root)) cands.push(path.join(root, d, 'blender.exe')); } catch (e) { /* root missing */ } } } for (const c of cands) if (fs.existsSync(c)) return c; return null; } function convertFbx(fbxPath, scriptName, outSub) { const blender = blenderPath(); if (!blender) return Promise.resolve({ ok: false, error: 'Blender non trovato: serve per convertire gli FBX (oppure carica un .glb)' }); const outDir = path.join(app.getPath('userData'), outSub); fs.mkdirSync(outDir, { recursive: true }); const out = path.join(outDir, path.basename(fbxPath).replace(/\.fbx$/i, '') + '.glb'); // packaged app: the .py must live outside the asar for Blender to read it const script = path.join(__dirname, 'scripts', scriptName) .replace('app.asar' + path.sep, 'app.asar.unpacked' + path.sep); return new Promise((resolve) => { execFile(blender, ['-b', '-P', script, '--', fbxPath, out], { maxBuffer: 1 << 24, timeout: 300000 }, (err, _so, se) => { if (err || !fs.existsSync(out)) resolve({ ok: false, error: 'conversione fallita: ' + String(se || (err && err.message) || '').slice(-300) }); else resolve({ ok: true, path: out }); }); }); } ipcMain.handle('anim:convert', (_e, p) => convertFbx(p, 'fbx2animglb.py', 'anims')); ipcMain.handle('model:convert', (_e, p) => convertFbx(p, 'fbx2modelglb.py', 'models3d')); // Camera permission is requested lazily (only when an interactive preset is // activated), not at startup: no camera prompt for users who never use it. ipcMain.handle('cam:ensure', async () => { if (process.platform !== 'darwin') return true; try { const status = systemPreferences.getMediaAccessStatus('camera'); if (status !== 'granted') return await systemPreferences.askForMediaAccess('camera'); return true; } catch (e) { return true; } }); app.on('before-quit', () => { quitting = true; }); app.whenReady().then(async () => { // djvres://mp/ -> node_modules/@mediapipe/tasks-vision/ // djvres://models/ -> models/ const RES_ROOTS = { mp: path.join(__dirname, 'node_modules', '@mediapipe', 'tasks-vision'), models: path.join(__dirname, 'models') }; protocol.handle('djvres', async (req) => { try { const u = new URL(req.url); const root = RES_ROOTS[u.host]; const rel = decodeURIComponent(u.pathname).replace(/^\//, ''); if (!root || rel.includes('..')) return new Response('forbidden', { status: 403 }); const res = await net.fetch(pathToFileURL(path.join(root, rel)).toString()); const headers = new Headers(); headers.set('Access-Control-Allow-Origin', '*'); if (rel.endsWith('.wasm')) headers.set('Content-Type', 'application/wasm'); else if (rel.endsWith('.js') || rel.endsWith('.mjs')) headers.set('Content-Type', 'text/javascript'); return new Response(res.body, { status: res.status, headers }); } catch (e) { return new Response('not found', { status: 404 }); } }); if (process.platform === 'darwin' && app.dock) { try { app.dock.setIcon(path.join(__dirname, 'build', 'icon_1024.png')); } catch (e) { /* non-fatal */ } } await ensureMicAccess(); createWindows(); screen.on('display-added', displaysChanged); screen.on('display-removed', displaysChanged); 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); // 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). ipcMain.on('rpt', (_e, msg) => { if (controlWin && !controlWin.isDestroyed()) controlWin.webContents.send('rpt', msg); }); // --- Display management -------------------------------------------------- // --- Recording: receive WebM chunks, mux to MP4 at a chosen aspect ratio --- let recStream = null, recTempPath = null; function ffmpegPath() { const cands = []; try { cands.push(require('ffmpeg-static')); } catch (e) { /* optional */ } // Absolute candidates FIRST: the packaged app launched from Finder has a // minimal PATH (no /opt/homebrew/bin), so a bare 'ffmpeg' spawns ENOENT // there even though the same call works when started from a terminal. cands.push(process.env.FFMPEG, '/opt/homebrew/bin/ffmpeg', '/usr/local/bin/ffmpeg', '/usr/bin/ffmpeg', 'C:\\ffmpeg\\bin\\ffmpeg.exe', 'C:\\Program Files\\ffmpeg\\bin\\ffmpeg.exe'); for (const c of cands) { if (c && fs.existsSync(c)) return c; } return 'ffmpeg'; // last resort: hope PATH has it } function transcodeToMp4(input, output, opts) { const w = (opts && opts.w) || 1920; const h = (opts && opts.h) || 1080; // Cover the target frame then centre-crop, so the chosen aspect is filled // without distortion. const args = ['-y', '-i', input]; if (opts && opts.srcH264 && opts.srcW === w && opts.srcH === h) { // Source already matches the requested frame and is H.264: REMUX without // re-encoding — the hardware realtime pass stays the only encode in the // final file (zero generational loss). args.push('-c', 'copy'); } else { // Rescale/crop needed (e.g. vertical reels) or non-H264 fallback source. // Post-processing, not realtime: spend encode time on quality. -r 60 // turns the recorder's variable frame timing into constant fps. // cropX 0..1 picks WHICH strip survives the cover-crop (0.5 = centre). const p = Math.max(0, Math.min(1, (opts && opts.cropX != null) ? opts.cropX : 0.5)); const vf = `scale=${w}:${h}:force_original_aspect_ratio=increase,` + `crop=${w}:${h}:(iw-ow)*${p.toFixed(3)}:(ih-oh)*${p.toFixed(3)}`; args.push('-vf', vf, '-r', '60', '-c:v', 'libx264', '-preset', 'slow', '-crf', '16', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '256k'); } args.push('-movflags', '+faststart', output); return new Promise((resolve, reject) => { execFile(ffmpegPath(), args, { maxBuffer: 1 << 24 }, (err, stdout, stderr) => { if (err) reject(new Error('ffmpeg: ' + String(stderr || err.message).slice(-500))); else resolve(); }); }); } // Read raw file bytes for the renderer (used to decode waveforms via Web Audio, // which needs no external ffmpeg). Returns null if unreadable. ipcMain.handle('file:read', async (_e, filePath) => { try { return await fs.promises.readFile(filePath); } catch (e) { return null; } }); // Decode a media file to mono 8 kHz PCM via ffmpeg and reduce it to `buckets` // peak amplitudes (0..1) for drawing a waveform. Returns null on failure. const peaksCache = new Map(); ipcMain.handle('audio:peaks', async (_e, filePath, buckets) => { buckets = buckets || 400; const key = filePath + '|' + buckets; if (peaksCache.has(key)) return peaksCache.get(key); const args = ['-v', 'quiet', '-i', filePath, '-ac', '1', '-ar', '8000', '-f', 's16le', '-']; return new Promise((resolve) => { execFile(ffmpegPath(), args, { encoding: 'buffer', maxBuffer: 1 << 28 }, (err, stdout) => { if (err || !stdout || stdout.length < 4) { resolve(null); return; } const u8 = Uint8Array.from(stdout); const samples = new Int16Array(u8.buffer, 0, u8.length >> 1); const n = samples.length; const peaks = new Array(buckets).fill(0); const per = Math.max(1, Math.floor(n / buckets)); for (let b = 0; b < buckets; b++) { let max = 0; const s = b * per, e = Math.min(n, s + per); for (let i = s; i < e; i++) { const v = samples[i] < 0 ? -samples[i] : samples[i]; if (v > max) max = v; } peaks[b] = max / 32768; } // Normalize so the loudest peak fills the height. const top = Math.max(0.0001, ...peaks); for (let b = 0; b < buckets; b++) peaks[b] = Math.min(1, peaks[b] / top); peaksCache.set(key, peaks); resolve(peaks); }); }); }); function recordingsDir() { const dir = path.join(app.getPath('videos'), 'DJ Visualizer'); fs.mkdirSync(dir, { recursive: true }); return dir; } ipcMain.handle('rec:start', () => { recTempPath = path.join(app.getPath('temp'), 'djv-rec-' + Date.now() + '.webm'); recStream = fs.createWriteStream(recTempPath); return true; }); ipcMain.on('rec:chunk', (_e, bytes) => { if (recStream && bytes) recStream.write(Buffer.from(bytes)); }); ipcMain.handle('rec:stop', async (_e, opts) => { if (!recStream) return { ok: false, error: 'Nessuna registrazione attiva' }; await new Promise(r => recStream.end(r)); const temp = recTempPath; recStream = null; recTempPath = null; try { const stamp = new Date().toISOString().replace(/[:T]/g, '-').slice(0, 19); const outPath = path.join(recordingsDir(), 'rec-' + stamp + '.mp4'); await transcodeToMp4(temp, outPath, opts); try { fs.unlinkSync(temp); } catch (e) { /* ignore */ } return { ok: true, path: outPath }; } catch (e) { return { ok: false, error: e.message }; } }); ipcMain.handle('rec:openFolder', () => { const dir = recordingsDir(); shell.openPath(dir); return dir; }); // --- Phone remote (LAN web page + WebSocket) --- // Commands from phones go to the panel, which runs them through its own code // paths; the panel pushes a compact state back for the phones to display. const remoteCfgFile = () => path.join(app.getPath('userData'), 'remote.json'); function remoteCfg() { try { return JSON.parse(fs.readFileSync(remoteCfgFile(), 'utf8')); } catch (e) { return {}; } } function saveRemoteCfg(c) { try { fs.writeFileSync(remoteCfgFile(), JSON.stringify(c)); } catch (e) {} } const remote = new RemoteServer({ srcDir: path.join(__dirname, 'src'), onCommand: (m) => { if (controlWin && !controlWin.isDestroyed()) controlWin.webContents.send('remote', m); } }); ipcMain.handle('remote:start', async () => { const c = remoteCfg(); // the key survives restarts so a phone's home-screen shortcut keeps working if (!c.key) c.key = crypto.randomBytes(6).toString('hex'); c.on = true; saveRemoteCfg(c); try { return await remote.start(8787, c.key); } catch (e) { return { on: false, error: e.message }; } }); ipcMain.handle('remote:stop', () => { remote.stop(); const c = remoteCfg(); c.on = false; saveRemoteCfg(c); return { on: false }; }); ipcMain.handle('remote:newKey', async () => { // revoke: every phone that had the old code is disconnected and locked out remote.stop(); const c = remoteCfg(); c.key = crypto.randomBytes(6).toString('hex'); c.on = true; saveRemoteCfg(c); try { return await remote.start(8787, c.key); } catch (e) { return { on: false, error: e.message }; } }); ipcMain.handle('remote:info', async () => (remote.running ? remote.info() : { on: false, wanted: !!remoteCfg().on })); ipcMain.on('remote:state', (_e, state) => remote.broadcast(state)); // --- Pad bank persistence --- const padsFile = () => path.join(app.getPath('userData'), 'pads.json'); ipcMain.handle('pads:load', () => { try { return JSON.parse(fs.readFileSync(padsFile(), 'utf8')); } catch (e) { return null; } }); ipcMain.handle('pads:save', (_e, data) => { try { fs.writeFileSync(padsFile(), JSON.stringify(data)); return true; } catch (e) { return false; } }); const playlistFile = () => path.join(app.getPath('userData'), 'playlist.json'); ipcMain.handle('playlist:load', () => { try { return JSON.parse(fs.readFileSync(playlistFile(), 'utf8')); } catch (e) { return null; } }); ipcMain.handle('playlist:save', (_e, data) => { try { fs.writeFileSync(playlistFile(), JSON.stringify(data)); return true; } catch (e) { return false; } }); // Export/import a playlist to a user-chosen file. ipcMain.handle('playlist:export', async (_e, data) => { const res = await dialog.showSaveDialog(controlWin, { title: 'Salva playlist', defaultPath: 'set.djvplaylist.json', filters: [{ name: 'Playlist DJ Visualizer', extensions: ['json'] }] }); if (res.canceled || !res.filePath) return { ok: false }; try { fs.writeFileSync(res.filePath, JSON.stringify(data, null, 2)); return { ok: true, path: res.filePath }; } catch (e) { return { ok: false, error: e.message }; } }); ipcMain.handle('playlist:import', async () => { const res = await dialog.showOpenDialog(controlWin, { title: 'Carica playlist', properties: ['openFile'], filters: [{ name: 'Playlist', extensions: ['json'] }] }); if (res.canceled || !res.filePaths.length) return null; try { return JSON.parse(fs.readFileSync(res.filePaths[0], 'utf8')); } catch (e) { return null; } }); // Read the bundled svg/ folder and return each SVG as a data: URL. ipcMain.handle('svg:listBuiltin', () => { try { const dir = path.join(__dirname, 'svg'); return fs.readdirSync(dir) .filter(f => f.toLowerCase().endsWith('.svg')) .map(f => { const txt = fs.readFileSync(path.join(dir, f), 'utf8'); const dataUrl = 'data:image/svg+xml;base64,' + Buffer.from(txt, 'utf8').toString('base64'); const name = f .replace(/\.svg$/i, '') .replace(/-svgrepo-com.*$/i, '') .replace(/^freesvg[\s-]+/i, '') .replace(/[-_]+/g, ' ') .trim(); return { name: name || f, dataUrl }; }); } 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, 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 // 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(); });