Playlist-video blend/opacity/fit controls + export/import playlist to file
- Blend mode, opacity and fit for videos played from the playlist (mix with visuals). - Save/Load buttons to export/import a playlist (with scenes/cues) to a JSON file via native dialogs; the playlist already auto-persists between sessions. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
0f0accc67d
commit
50b4223100
5 files changed
+89
-10
No files matched your search
@@ -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 path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const { execFile } = require('child_process');
|
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; }
|
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.
|
// Read the bundled svg/ folder and return each SVG as a data: URL.
|
||||||
ipcMain.handle('svg:listBuiltin', () => {
|
ipcMain.handle('svg:listBuiltin', () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
+3
-1
@@ -32,5 +32,7 @@ contextBridge.exposeInMainWorld('djv', {
|
|||||||
loadPads: () => ipcRenderer.invoke('pads:load'),
|
loadPads: () => ipcRenderer.invoke('pads:load'),
|
||||||
savePads: (data) => ipcRenderer.invoke('pads:save', data),
|
savePads: (data) => ipcRenderer.invoke('pads:save', data),
|
||||||
loadPlaylist: () => ipcRenderer.invoke('playlist:load'),
|
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')
|
||||||
});
|
});
|
||||||
@@ -51,12 +51,35 @@
|
|||||||
<button id="btn-clear-playlist">🗑 Svuota</button>
|
<button id="btn-clear-playlist">🗑 Svuota</button>
|
||||||
<label class="chk"><input type="checkbox" id="repeat-playlist" /> Ripeti</label>
|
<label class="chk"><input type="checkbox" id="repeat-playlist" /> Ripeti</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<button id="btn-pl-save">💾 Salva playlist…</button>
|
||||||
|
<button id="btn-pl-load">📂 Carica playlist…</button>
|
||||||
|
</div>
|
||||||
<input type="file" id="tracks-input" accept="audio/*,video/*" multiple hidden />
|
<input type="file" id="tracks-input" accept="audio/*,video/*" multiple hidden />
|
||||||
<input type="file" id="scene-img-input" accept="image/*" hidden />
|
<input type="file" id="scene-img-input" accept="image/*" hidden />
|
||||||
<ol id="playlist" class="playlist"></ol>
|
<ol id="playlist" class="playlist"></ol>
|
||||||
<div class="hint-mini">Clic sul <b>nome</b> = avvia/riavvia · ▶/⏸ pausa · ⠿ riordina · ⌨ tasto rapido · 🎬 <b>scena</b> (effetto+testo+immagine applicati quando parte il brano)</div>
|
<div class="hint-mini">Clic sul <b>nome</b> = avvia/riavvia · ▶/⏸ pausa · ⠿ riordina · ⌨ tasto rapido · 🎬 <b>scena</b> (effetto+testo+immagine applicati quando parte il brano)</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<label>Resa video in playlist</label>
|
||||||
|
<select id="tvid-fit">
|
||||||
|
<option value="contain">Adatta (bordi neri)</option>
|
||||||
|
<option value="cover">Riempie schermo</option>
|
||||||
|
</select>
|
||||||
|
<label>Fusione</label>
|
||||||
|
<select id="tvid-blend">
|
||||||
|
<option value="normal">normale</option>
|
||||||
|
<option value="screen">screen</option>
|
||||||
|
<option value="overlay">overlay</option>
|
||||||
|
<option value="multiply">multiply</option>
|
||||||
|
<option value="lighten">lighten</option>
|
||||||
|
</select>
|
||||||
|
<label>Opacità <span id="tvid-op-val">100%</span></label>
|
||||||
|
<input type="range" id="tvid-op" min="0" max="100" step="1" value="100" />
|
||||||
|
<div class="hint-mini">Si applica al video riprodotto dalla playlist (per fonderlo con gli effetti).</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<label>Reattività <span id="gain-val">1.0×</span></label>
|
<label>Reattività <span id="gain-val">1.0×</span></label>
|
||||||
<input type="range" id="gain" min="0" max="3" step="0.05" value="1" />
|
<input type="range" id="gain" min="0" max="3" step="0.05" value="1" />
|
||||||
|
|||||||
+32
-7
@@ -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 sceneImgTarget = -1, sceneCueTarget = -1; // track/cue awaiting an image
|
||||||
let activeCues = [], firedCue = -1; // cue schedule for the playing track
|
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() {
|
function savePlaylistState() {
|
||||||
if (djv.savePlaylist) {
|
if (djv.savePlaylist) djv.savePlaylist(serializePlaylist());
|
||||||
djv.savePlaylist(playlist.map(t => ({ path: t.path, name: t.name, key: t.key || null, cues: t.cues || [], isVideo: !!t.isVideo })));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasScene(tr) {
|
function hasScene(tr) {
|
||||||
@@ -430,6 +434,30 @@ $('#btn-clear-playlist').addEventListener('click', () => {
|
|||||||
playlist = []; currentIndex = -1; sceneEditing = -1; renderPlaylist(); savePlaylistState();
|
playlist = []; currentIndex = -1; sceneEditing = -1; renderPlaylist(); savePlaylistState();
|
||||||
});
|
});
|
||||||
$('#repeat-playlist').addEventListener('change', (e) => { repeat = e.target.checked; });
|
$('#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', () =>
|
$('#btn-use-input').addEventListener('click', () =>
|
||||||
send({ type: 'useInput', deviceId: $('#device-select').value || null }));
|
send({ type: 'useInput', deviceId: $('#device-select').value || null }));
|
||||||
$('#output-select').addEventListener('change', (e) =>
|
$('#output-select').addEventListener('change', (e) =>
|
||||||
@@ -670,10 +698,7 @@ if (padXfade) padXfade.addEventListener('input', (e) => {
|
|||||||
try {
|
try {
|
||||||
const saved = djv.loadPlaylist ? await djv.loadPlaylist() : null;
|
const saved = djv.loadPlaylist ? await djv.loadPlaylist() : null;
|
||||||
if (Array.isArray(saved) && saved.length) {
|
if (Array.isArray(saved) && saved.length) {
|
||||||
playlist = saved.map(t => ({
|
playlist = saved.map(normalizeTrack);
|
||||||
path: t.path, name: t.name || baseName(t.path),
|
|
||||||
key: t.key || null, cues: Array.isArray(t.cues) ? t.cues : [], isVideo: !!t.isVideo
|
|
||||||
}));
|
|
||||||
renderPlaylist();
|
renderPlaylist();
|
||||||
probePaths(playlist.map(t => t.path));
|
probePaths(playlist.map(t => t.path));
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-1
@@ -95,7 +95,12 @@ function composeFrame() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (trackVideo.classList.contains('show') && trackVideo.readyState >= 2 && trackVideo.videoWidth) {
|
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) {
|
for (const im of images) {
|
||||||
@@ -356,6 +361,9 @@ djv.onControl(async (m) => {
|
|||||||
case 'videoOpacity': bgVideo.style.opacity = m.value; break;
|
case 'videoOpacity': bgVideo.style.opacity = m.value; break;
|
||||||
case 'videoFit': bgVideo.style.objectFit = m.value; break;
|
case 'videoFit': bgVideo.style.objectFit = m.value; break;
|
||||||
case 'videoBlend': bgVideo.style.mixBlendMode = 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':
|
case 'sceneImage':
|
||||||
if (m.path) { sceneImage.src = toFileURL(m.path); sceneImage.classList.add('show'); hideHint(); }
|
if (m.path) { sceneImage.src = toFileURL(m.path); sceneImage.classList.add('show'); hideHint(); }
|
||||||
|
|||||||
Reference in new issue
Block a user