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 @@
đź—‘ Svuota
Ripeti
+
+ 💾 Salva playlist…
+ 📂 Carica playlist…
+
Clic sul nome = avvia/riavvia · ▶/⏸ pausa · ⠿ riordina · ⌨ tasto rapido · 🎬 scena (effetto+testo+immagine applicati quando parte il brano)
+
+
ReattivitĂ 1.0Ă—
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(); }