From 80a54bb1418b5ed2963b6c174a5765b5165fb245 Mon Sep 17 00:00:00 2001 From: Savsis Date: Thu, 2 Jul 2026 02:09:32 +0500 Subject: [PATCH] Initialize Electron app with core features Initial commit of the Electron application with main functionalities including settings management, file handling, and IPC communication. --- main.js | 518 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 518 insertions(+) create mode 100644 main.js diff --git a/main.js b/main.js new file mode 100644 index 0000000..22630f7 --- /dev/null +++ b/main.js @@ -0,0 +1,518 @@ +'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; });