'use strict'; const { app, BrowserWindow, ipcMain, shell, dialog, nativeTheme, Tray, Menu, nativeImage } = require('electron'); const path = require('path'); const fs = require('fs'); const { spawn } = require('child_process'); nativeTheme.themeSource = 'dark'; if (process.platform === 'win32') { app.setAppUserModelId('pw.nodex.media-editor'); } /* ── paths ───────────────────────────────────────────── */ const SETTINGS_PATH = path.join(app.getPath('userData'), 'settings.json'); const ASSET_IDX_PATH = path.join(app.getPath('userData'), 'assets-index.json'); const ICON_ICO = path.join(__dirname, '..', 'assets', 'icon.ico'); let settings = { outputDir: app.getPath('downloads'), projectsDir: path.join(app.getPath('documents'), 'nodex-projects'), defExportFmt: 'mp3', defBitrate: '320k', crossfade: 3, waveColor: '#ffffff', bpmGrid: true, firstRun: true, assetPaths: [], }; function loadSettings() { try { if (fs.existsSync(SETTINGS_PATH)) Object.assign(settings, JSON.parse(fs.readFileSync(SETTINGS_PATH, 'utf8'))); } catch {} } function saveSettings() { try { fs.writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2)); } catch {} } loadSettings(); try { fs.mkdirSync(settings.projectsDir, { recursive: true }); } catch {} /* ── ffmpeg ──────────────────────────────────────────── */ const FFMPEG_LOCAL = path.join(app.getPath('userData'), 'ffmpeg.exe'); function getFFmpegPath() { // 1. bundled in installer if (app.isPackaged) { const bundled = path.join(process.resourcesPath, 'ffmpeg.exe'); if (fs.existsSync(bundled)) return bundled; } // 2. previously downloaded to AppData if (fs.existsSync(FFMPEG_LOCAL)) return FFMPEG_LOCAL; // 3. ffmpeg-static (dev) try { const p = require('ffmpeg-static'); if (p && fs.existsSync(p)) return p; } catch {} // 4. system PATH return 'ffmpeg'; } async function ensureFFmpeg() { const current = getFFmpegPath(); // test if it works const works = await new Promise(resolve => { try { const p = spawn(current, ['-version'], { windowsHide: true }); p.on('close', c => resolve(c === 0)); p.on('error', () => resolve(false)); setTimeout(() => { try { p.kill(); } catch {} resolve(false); }, 5000); } catch { resolve(false); } }); if (works) return current; // download static build to AppData if (mainWin) mainWin.webContents.send('ffmpeg-downloading', true); const FFMPEG_URL = 'https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-lgpl.zip'; const zipPath = path.join(app.getPath('temp'), 'ffmpeg-dl.zip'); try { await downloadFile(FFMPEG_URL, zipPath); await extractFFmpegFromZip(zipPath, FFMPEG_LOCAL); fs.unlink(zipPath, () => {}); if (mainWin) mainWin.webContents.send('ffmpeg-downloading', false); return FFMPEG_LOCAL; } catch (e) { if (mainWin) mainWin.webContents.send('ffmpeg-downloading', false); console.error('FFmpeg download failed:', e.message); return null; } } function downloadFile(url, dest) { return new Promise((resolve, reject) => { const file = fs.createWriteStream(dest); const follow = (u) => { const mod = u.startsWith('https') ? require('https') : require('http'); mod.get(u, res => { if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { file.close(); follow(res.headers.location); return; } if (res.statusCode !== 200) { file.close(); reject(new Error(`HTTP ${res.statusCode}`)); return; } res.pipe(file); file.on('finish', () => file.close(resolve)); }).on('error', err => { file.close(); reject(err); }); }; follow(url); }); } async function extractFFmpegFromZip(zipPath, destBin) { // Use PowerShell to extract (avoids needing extra npm package) const extractDir = path.join(app.getPath('temp'), 'ffmpeg-extract'); await new Promise((resolve, reject) => { const ps = spawn('powershell.exe', [ '-NoProfile', '-Command', `Expand-Archive -LiteralPath "${zipPath}" -DestinationPath "${extractDir}" -Force`, ], { windowsHide: true }); ps.on('close', c => c === 0 ? resolve() : reject(new Error('extract failed'))); ps.on('error', reject); }); // find ffmpeg.exe inside extracted dir function findBin(dir) { for (const f of fs.readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, f.name); if (f.isDirectory()) { const r = findBin(full); if (r) return r; } else if (f.name.toLowerCase() === 'ffmpeg.exe') return full; } return null; } const found = findBin(extractDir); if (!found) throw new Error('ffmpeg.exe not found in archive'); fs.copyFileSync(found, destBin); fs.rm(extractDir, { recursive: true, force: true }, () => {}); } /* ── state ───────────────────────────────────────────── */ let mainWin = null; let tray = null; let unsavedProject = false; // renderer tells us when project is dirty /* ── tray ────────────────────────────────────────────── */ function buildTrayMenu() { return Menu.buildFromTemplate([ { label: 'nodex.pw Media Editor', enabled: false }, { type: 'separator' }, { label: 'Show', click: () => { mainWin?.show(); mainWin?.focus(); } }, { type: 'separator' }, { label: 'Quit', click: () => { app.isQuiting = true; app.quit(); } }, ]); } function createTray() { try { const img = nativeImage.createFromPath(ICON_ICO).resize({ width: 16, height: 16 }); tray = new Tray(img); tray.setToolTip('nodex.pw Media Editor'); tray.setContextMenu(buildTrayMenu()); tray.on('click', () => { mainWin?.show(); mainWin?.focus(); }); tray.on('double-click', () => { mainWin?.show(); mainWin?.focus(); }); } catch {} } /* ── window ──────────────────────────────────────────── */ function createWindow(openFile) { mainWin = new BrowserWindow({ width: 1320, height: 820, minWidth: 960, minHeight: 620, frame: false, transparent: false, backgroundColor: '#080808', titleBarStyle: 'hidden', webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, nodeIntegration: false, webSecurity: true, }, icon: ICON_ICO, show: false, }); mainWin.loadFile(path.join(__dirname, 'renderer', 'index.html')); mainWin.webContents.setWindowOpenHandler(({ url }) => { shell.openExternal(url); return { action: 'deny' }; }); mainWin.webContents.on('will-navigate', (e, url) => { if (!url.startsWith('file://')) { e.preventDefault(); shell.openExternal(url); } }); mainWin.once('ready-to-show', () => { mainWin.show(); if (openFile) mainWin.webContents.send('open-project-file', openFile); }); mainWin.on('maximize', () => mainWin?.webContents.send('win-maximized')); mainWin.on('unmaximize', () => mainWin?.webContents.send('win-restored')); mainWin.on('close', async (e) => { if (app.isQuiting) return; e.preventDefault(); if (unsavedProject) { // Show Windows balloon notification first tray?.displayBalloon({ iconType: 'warning', title: 'nodex.pw Media Editor', content: 'Проверьте — всё ли сохранено. made by savsis with <3', noSound: false, }); // Ask the user via native dialog const { response } = await dialog.showMessageBox(mainWin, { type: 'warning', buttons: ['Save & Close', 'Close without saving', 'Cancel'], defaultId: 0, cancelId: 2, title: 'Unsaved project', message: 'You have unsaved changes.', detail: 'Do you want to save the project before closing?', icon: ICON_ICO, }); if (response === 2) return; if (response === 0) { mainWin.webContents.send('save-project-then-close'); return; } } // Hide to tray instead of closing fully mainWin.hide(); tray?.displayBalloon({ iconType: 'none', title: 'nodex.pw Media Editor', content: 'Running in the background. made by savsis with <3', noSound: true, }); }); mainWin.on('closed', () => { mainWin = null; }); } /* ── .nodexproj file association (Windows CLI) ───────── */ let pendingOpenFile = null; const cliFile = process.argv.slice(app.isPackaged ? 1 : 2) .find(a => a.endsWith('.nodexproj')); if (cliFile && fs.existsSync(cliFile)) pendingOpenFile = cliFile; app.on('open-file', (event, filePath) => { event.preventDefault(); if (mainWin) mainWin.webContents.send('open-project-file', filePath); else pendingOpenFile = filePath; }); /* ── IPC: window controls ────────────────────────────── */ ipcMain.on('app-version', (e) => { e.returnValue = app.getVersion(); }); ipcMain.on('win-minimize', () => mainWin?.minimize()); ipcMain.on('win-maximize', () => mainWin?.isMaximized() ? mainWin.unmaximize() : mainWin.maximize()); ipcMain.on('win-close', () => mainWin?.close()); ipcMain.handle('open-external', (_, url) => shell.openExternal(url)); /* Renderer tells us when project dirty state changes */ ipcMain.on('project-dirty', (_, dirty) => { unsavedProject = dirty; }); /* Renderer calls this after save-then-close completes */ ipcMain.on('project-saved-close', () => { app.isQuiting = true; mainWin?.close(); }); /* ── IPC: settings ───────────────────────────────────── */ ipcMain.handle('get-settings', () => settings); ipcMain.handle('save-settings', (_, patch) => { Object.assign(settings, patch); saveSettings(); return settings; }); /* ── IPC: dialogs ────────────────────────────────────── */ ipcMain.handle('open-file', async (_, opts = {}) => { const r = await dialog.showOpenDialog(mainWin, { properties: opts.dir ? ['openDirectory'] : ['openFile', ...(opts.multi !== false ? ['multiSelections'] : [])], filters: opts.filters || [ { name: 'Audio / Video', extensions: ['mp3','flac','wav','ogg','m4a','aac','opus','webm','mp4','mkv','mov'] }, { name: 'Image', extensions: ['jpg','jpeg','png','gif','webp','bmp'] }, { name: 'nodex Project', extensions: ['nodexproj'] }, { name: 'All Files', extensions: ['*'] }, ], defaultPath: opts.defaultPath, title: opts.title || 'Select', }); return r.canceled ? null : r.filePaths; }); ipcMain.handle('save-file', async (_, opts = {}) => { const r = await dialog.showSaveDialog(mainWin, { defaultPath: opts.defaultPath || path.join(settings.outputDir, opts.name || 'output.mp3'), filters: opts.filters || [ { name: 'MP3', extensions: ['mp3'] }, { name: 'FLAC', extensions: ['flac'] }, { name: 'WAV', extensions: ['wav'] }, { name: 'nodex Project', extensions: ['nodexproj'] }, ], title: opts.title || 'Save', }); return r.canceled ? null : r.filePath; }); ipcMain.handle('read-file', async (_, p) => { const b = fs.readFileSync(p); return b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength); }); ipcMain.handle('write-file', async (_, p, buf) => { fs.mkdirSync(require('path').dirname(p), {recursive:true}); fs.writeFileSync(p, Buffer.from(buf)); return true; }); ipcMain.handle('read-text-file', async (_, p) => fs.readFileSync(p, 'utf8')); ipcMain.handle('write-text-file', async (_, p, t) => { fs.mkdirSync(require('path').dirname(p), {recursive:true}); fs.writeFileSync(p, t, 'utf8'); return true; }); ipcMain.handle('file-exists', (_, p) => fs.existsSync(p)); ipcMain.handle('get-file-stat', async (_, p) => { const s = fs.statSync(p); return { size: s.size, mtime: s.mtimeMs, name: require('path').basename(p) }; }); ipcMain.handle('get-temp-dir', () => { const t = require('path').join(app.getPath('temp'), 'nodex-me'); fs.mkdirSync(t, {recursive:true}); return t; }); ipcMain.handle('list-dir', (_, d) => { try { return fs.readdirSync(d).map(f => require('path').join(d, f)); } catch { return []; } }); /* ── IPC: projects ───────────────────────────────────── */ ipcMain.handle('get-projects-dir', () => settings.projectsDir); ipcMain.handle('set-projects-dir', async () => { const r = await dialog.showOpenDialog(mainWin, { properties: ['openDirectory'] }); if (!r.canceled) { settings.projectsDir = r.filePaths[0]; fs.mkdirSync(settings.projectsDir, {recursive:true}); saveSettings(); } return settings.projectsDir; }); ipcMain.handle('list-projects', () => { try { return fs.readdirSync(settings.projectsDir) .filter(f => f.endsWith('.nodexproj')) .map(f => { const fp = path.join(settings.projectsDir, f); const st = fs.statSync(fp); let name = f.replace('.nodexproj',''); try { const d = JSON.parse(fs.readFileSync(fp,'utf8')); name = d.name || name; } catch {} return { file: fp, name, mtime: st.mtimeMs }; }) .sort((a,b) => b.mtime - a.mtime); } catch { return []; } }); ipcMain.handle('save-project', async (_, { project, saveAs }) => { let dest; if (saveAs || !project.filePath) { const r = await dialog.showSaveDialog(mainWin, { defaultPath: path.join(settings.projectsDir, (project.name || 'Untitled') + '.nodexproj'), filters: [{ name: 'nodex Project', extensions: ['nodexproj'] }], title: 'Save Project', }); if (r.canceled) return null; dest = r.filePath; } else { dest = project.filePath; } project.filePath = dest; project.modified = new Date().toISOString(); fs.writeFileSync(dest, JSON.stringify(project, null, 2), 'utf8'); return dest; }); ipcMain.handle('load-project', async (_, filePath) => { if (!filePath) { const r = await dialog.showOpenDialog(mainWin, { defaultPath: settings.projectsDir, filters: [{ name: 'nodex Project', extensions: ['nodexproj'] }], title: 'Open Project', }); if (r.canceled) return null; filePath = r.filePaths[0]; } const data = JSON.parse(fs.readFileSync(filePath, 'utf8')); data.filePath = filePath; return data; }); /* ── IPC: asset scanning ─────────────────────────────── */ const AUDIO_EXTS = new Set(['mp3','flac','wav','ogg','m4a','aac','opus','webm','wma','aiff']); const IMAGE_EXTS = new Set(['jpg','jpeg','png','gif','webp','bmp']); const VIDEO_EXTS = new Set(['mp4','mkv','mov','avi','wmv','m4v']); const getExt = f => path.extname(f).slice(1).toLowerCase(); function countAssets(dir) { let n = 0; try { for (const e of fs.readdirSync(dir, {withFileTypes:true})) { if (e.name.startsWith('.')) continue; const full = path.join(dir, e.name); if (e.isDirectory()) n += countAssets(full); else { const x = getExt(e.name); if (AUDIO_EXTS.has(x)||IMAGE_EXTS.has(x)||VIDEO_EXTS.has(x)) n++; } } } catch {} return n; } ipcMain.handle('asset-estimate', async (_, dirs) => { let total = 0; for (const d of dirs) total += countAssets(d); return total; }); ipcMain.handle('asset-scan', async (event, dirs) => { const res = { audio: [], image: [], video: [] }; function walk(dir) { try { for (const e of fs.readdirSync(dir, {withFileTypes:true})) { if (e.name.startsWith('.')) continue; const full = path.join(dir, e.name); if (e.isDirectory()) { walk(full); continue; } const x = getExt(e.name); if (AUDIO_EXTS.has(x)) res.audio.push(full); else if (IMAGE_EXTS.has(x)) res.image.push(full); else if (VIDEO_EXTS.has(x)) res.video.push(full); const total = res.audio.length + res.image.length + res.video.length; if (total % 100 === 0) event.sender.send('asset-scan-progress', { audio: res.audio.length, image: res.image.length, video: res.video.length }); } } catch {} } for (const d of dirs) walk(d); fs.writeFileSync(ASSET_IDX_PATH, JSON.stringify({ dirs, ...res, scannedAt: Date.now() })); settings.assetPaths = dirs; saveSettings(); return res; }); ipcMain.handle('asset-load-index', () => { try { if (fs.existsSync(ASSET_IDX_PATH)) return JSON.parse(fs.readFileSync(ASSET_IDX_PATH,'utf8')); } catch {} return null; }); ipcMain.handle('asset-pick-folder', async () => { const r = await dialog.showOpenDialog(mainWin, {properties:['openDirectory','multiSelections']}); return r.canceled ? null : r.filePaths; }); ipcMain.handle('get-default-asset-dirs', () => [app.getPath('music'), app.getPath('pictures'), app.getPath('downloads')]); /* ── IPC: ffmpeg ─────────────────────────────────────── */ ipcMain.handle('ffmpeg-run', (event, args) => { return new Promise((resolve, reject) => { const proc = spawn(getFFmpegPath(), args, { windowsHide: true }); let stderr = ''; proc.stderr.on('data', d => { stderr += d.toString(); const m = stderr.match(/time=(\d{2}:\d{2}:\d{2}\.\d{2})/g); if (m) event.sender.send('ffmpeg-progress', { time: m[m.length-1].replace('time=','') }); }); proc.on('close', c => c === 0 ? resolve({ ok: true }) : reject(new Error(stderr.slice(-800)))); proc.on('error', reject); }); }); /* ── system notifications via tray ──────────────────── */ ipcMain.on('tray-notify', (_, { title, content }) => { tray?.displayBalloon({ iconType: 'info', title, content, noSound: true }); }); /* ── yt-dlp ──────────────────────────────────────────── */ const YTDLP_LOCAL = path.join(app.getPath('userData'), 'yt-dlp.exe'); const YTDLP_URL = 'https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe'; function getYtdlpPath() { if (app.isPackaged) { const bundled = path.join(process.resourcesPath, 'yt-dlp.exe'); if (fs.existsSync(bundled)) return bundled; } if (fs.existsSync(YTDLP_LOCAL)) return YTDLP_LOCAL; return null; } async function ensureYtdlp() { const cur = getYtdlpPath(); if (cur) { const ok = await new Promise(resolve => { try { const p = spawn(cur, ['--version'], { windowsHide: true }); p.on('close', c => resolve(c === 0)); p.on('error', () => resolve(false)); setTimeout(() => { try { p.kill(); } catch {} resolve(false); }, 5000); } catch { resolve(false); } }); if (ok) return cur; } if (mainWin) mainWin.webContents.send('ytdlp-downloading', true); try { await downloadFile(YTDLP_URL, YTDLP_LOCAL); if (mainWin) mainWin.webContents.send('ytdlp-downloading', false); return YTDLP_LOCAL; } catch (e) { if (mainWin) mainWin.webContents.send('ytdlp-downloading', false); console.error('yt-dlp download failed:', e.message); return null; } } ipcMain.handle('ensure-ytdlp', () => ensureYtdlp()); ipcMain.handle('get-ytdlp-path', () => getYtdlpPath()); ipcMain.handle('ytdlp-run', (event, { args, outputDir }) => { return new Promise((resolve, reject) => { const bin = getYtdlpPath(); if (!bin) { reject(new Error('yt-dlp not found')); return; } const proc = spawn(bin, args, { windowsHide: true, cwd: outputDir || app.getPath('downloads') }); let stdout = '', stderr = ''; proc.stdout.on('data', d => { stdout += d.toString(); const lines = stdout.split('\n'); const last = lines[lines.length - 2] || ''; if (last.includes('%')) event.sender.send('ytdlp-progress', { line: last.trim() }); }); proc.stderr.on('data', d => { stderr += d.toString(); }); proc.on('close', c => c === 0 ? resolve({ ok: true, stdout }) : reject(new Error(stderr.slice(-600) || stdout.slice(-600)))); proc.on('error', reject); }); }); /* ── IPC: ffmpeg status ──────────────────────────────── */ ipcMain.handle('get-ffmpeg-status', () => { const p = getFFmpegPath(); return { path: p, exists: p !== 'ffmpeg' && fs.existsSync(p) }; }); ipcMain.handle('ensure-ffmpeg', () => ensureFFmpeg()); /* ── lifecycle ───────────────────────────────────────── */ app.whenReady().then(async () => { createWindow(pendingOpenFile); createTray(); ensureFFmpeg().catch(() => {}); ensureYtdlp().catch(() => {}); app.on('activate', () => { if (!mainWin) createWindow(); }); }); app.on('window-all-closed', () => {}); app.on('before-quit', () => { app.isQuiting = true; });