nodex.pw-Media-Editor/main.js

519 lines
21 KiB
JavaScript
Raw Permalink Normal View History

'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; });