diff --git a/main.js b/main.js index cba3707..c81e7e7 100644 --- a/main.js +++ b/main.js @@ -1,4 +1,4 @@ -const { app, BrowserWindow, screen, ipcMain, systemPreferences, shell } = require('electron'); +const { app, BrowserWindow, screen, ipcMain, systemPreferences, shell, dialog } = require('electron'); const path = require('path'); const fs = require('fs'); const { execFile } = require('child_process'); @@ -189,6 +189,27 @@ 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 { diff --git a/preload.js b/preload.js index 2a383ff..7cbc132 100644 --- a/preload.js +++ b/preload.js @@ -32,5 +32,7 @@ contextBridge.exposeInMainWorld('djv', { loadPads: () => ipcRenderer.invoke('pads:load'), savePads: (data) => ipcRenderer.invoke('pads:save', data), loadPlaylist: () => ipcRenderer.invoke('playlist:load'), - savePlaylist: (data) => ipcRenderer.invoke('playlist:save', data) + savePlaylist: (data) => ipcRenderer.invoke('playlist:save', data), + exportPlaylist: (data) => ipcRenderer.invoke('playlist:export', data), + importPlaylist: () => ipcRenderer.invoke('playlist:import') }); diff --git a/src/control.html b/src/control.html index 3d4d681..d1cec78 100644 --- a/src/control.html +++ b/src/control.html @@ -51,12 +51,35 @@ +
+ + +
    Clic sul nome = avvia/riavvia · ▶/⏸ pausa · ⠿ riordina · ⌨ tasto rapido · 🎬 scena (effetto+testo+immagine applicati quando parte il brano)
    +
    + + + + + + +
    Si applica al video riprodotto dalla playlist (per fonderlo con gli effetti).
    +
    +
    diff --git a/src/control.js b/src/control.js index 5639216..c6a5773 100644 --- a/src/control.js +++ b/src/control.js @@ -203,10 +203,14 @@ let sceneEditing = -1; // playlist index whose scene editor is open let sceneImgTarget = -1, sceneCueTarget = -1; // track/cue awaiting an image let activeCues = [], firedCue = -1; // cue schedule for the playing track +function serializePlaylist() { + return playlist.map(t => ({ path: t.path, name: t.name, key: t.key || null, cues: t.cues || [], isVideo: !!t.isVideo })); +} +function normalizeTrack(t) { + return { path: t.path, name: t.name || baseName(t.path), key: t.key || null, cues: Array.isArray(t.cues) ? t.cues : [], isVideo: !!t.isVideo }; +} function savePlaylistState() { - if (djv.savePlaylist) { - djv.savePlaylist(playlist.map(t => ({ path: t.path, name: t.name, key: t.key || null, cues: t.cues || [], isVideo: !!t.isVideo }))); - } + if (djv.savePlaylist) djv.savePlaylist(serializePlaylist()); } function hasScene(tr) { @@ -430,6 +434,30 @@ $('#btn-clear-playlist').addEventListener('click', () => { playlist = []; currentIndex = -1; sceneEditing = -1; renderPlaylist(); savePlaylistState(); }); $('#repeat-playlist').addEventListener('change', (e) => { repeat = e.target.checked; }); + +// Export / import the playlist to a file. +$('#btn-pl-save').addEventListener('click', async () => { + if (djv.exportPlaylist) await djv.exportPlaylist(serializePlaylist()); +}); +$('#btn-pl-load').addEventListener('click', async () => { + if (!djv.importPlaylist) return; + const data = await djv.importPlaylist(); + if (Array.isArray(data)) { + playlist = data.map(normalizeTrack); + currentIndex = -1; sceneEditing = -1; + renderPlaylist(); savePlaylistState(); + probePaths(playlist.map(t => t.path)); + } +}); + +// Playlist-video rendering (blend / opacity / fit). +$('#tvid-fit').addEventListener('change', (e) => send({ type: 'trackVideoFit', value: e.target.value })); +$('#tvid-blend').addEventListener('change', (e) => send({ type: 'trackVideoBlend', value: e.target.value })); +$('#tvid-op').addEventListener('input', (e) => { + const v = parseInt(e.target.value, 10); + $('#tvid-op-val').textContent = v + '%'; + send({ type: 'trackVideoOpacity', value: v / 100 }); +}); $('#btn-use-input').addEventListener('click', () => send({ type: 'useInput', deviceId: $('#device-select').value || null })); $('#output-select').addEventListener('change', (e) => @@ -670,10 +698,7 @@ if (padXfade) padXfade.addEventListener('input', (e) => { try { const saved = djv.loadPlaylist ? await djv.loadPlaylist() : null; if (Array.isArray(saved) && saved.length) { - playlist = saved.map(t => ({ - path: t.path, name: t.name || baseName(t.path), - key: t.key || null, cues: Array.isArray(t.cues) ? t.cues : [], isVideo: !!t.isVideo - })); + playlist = saved.map(normalizeTrack); renderPlaylist(); probePaths(playlist.map(t => t.path)); } diff --git a/src/output.js b/src/output.js index a2e27ad..c40d3d7 100644 --- a/src/output.js +++ b/src/output.js @@ -95,7 +95,12 @@ function composeFrame() { } if (trackVideo.classList.contains('show') && trackVideo.readyState >= 2 && trackVideo.videoWidth) { - try { drawMediaFit(recCtx, trackVideo, W, H, 'contain'); } catch (e) {} + recCtx.globalAlpha = parseFloat(getComputedStyle(trackVideo).opacity) || 1; + const bm = trackVideo.style.mixBlendMode; + recCtx.globalCompositeOperation = (bm && bm !== 'normal') ? bm : 'source-over'; + try { drawMediaFit(recCtx, trackVideo, W, H, trackVideo.style.objectFit || 'contain'); } catch (e) {} + recCtx.globalAlpha = 1; + recCtx.globalCompositeOperation = 'source-over'; } for (const im of images) { @@ -356,6 +361,9 @@ djv.onControl(async (m) => { case 'videoOpacity': bgVideo.style.opacity = m.value; break; case 'videoFit': bgVideo.style.objectFit = m.value; break; case 'videoBlend': bgVideo.style.mixBlendMode = m.value; break; + case 'trackVideoBlend': trackVideo.style.mixBlendMode = m.value; break; + case 'trackVideoOpacity': trackVideo.style.opacity = m.value; break; + case 'trackVideoFit': trackVideo.style.objectFit = m.value; break; case 'sceneImage': if (m.path) { sceneImage.src = toFileURL(m.path); sceneImage.classList.add('show'); hideHint(); }