Per-track scenes: auto-apply effect + text + image when a playlist track starts
Each playlist track gets an optional scene (🎬) bound to it: pick the current effect, a ticker text and an overlay image. When the track plays, the scene is applied automatically — a cue/queue per song. Scene image is a dedicated output layer (also captured in recordings). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
67a4a32dea
commit
8925fa50f9
5 files changed
+113
-1
No files matched your search
+2
-1
@@ -52,8 +52,9 @@
|
|||||||
<label class="chk"><input type="checkbox" id="repeat-playlist" /> Ripeti</label>
|
<label class="chk"><input type="checkbox" id="repeat-playlist" /> Ripeti</label>
|
||||||
</div>
|
</div>
|
||||||
<input type="file" id="tracks-input" accept="audio/*" multiple hidden />
|
<input type="file" id="tracks-input" accept="audio/*" multiple 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 dall'inizio · ▶/⏸ pausa/riprendi · ⠿ trascina per riordinare · ⌨ tasto rapido</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>
|
<section>
|
||||||
|
|||||||
@@ -199,6 +199,26 @@ const durations = {}; // path -> seconds (probed from the output window)
|
|||||||
let playCur = 0, playDur = 0; // live progress of the currently playing track
|
let playCur = 0, playDur = 0; // live progress of the currently playing track
|
||||||
let padCrossfadeMs = 0; // crossfade duration for pad triggers
|
let padCrossfadeMs = 0; // crossfade duration for pad triggers
|
||||||
let capturingFor = -1; // index of the track awaiting a hotkey, or -1
|
let capturingFor = -1; // index of the track awaiting a hotkey, or -1
|
||||||
|
let sceneEditing = -1; // playlist index whose scene editor is open
|
||||||
|
let sceneImgTarget = -1; // playlist index awaiting a scene image
|
||||||
|
|
||||||
|
function hasScene(tr) {
|
||||||
|
const s = tr.scene;
|
||||||
|
return !!(s && (s.effectIndex != null || (s.text && s.text.trim()) || s.image));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply a track's scene (effect + text + image) when it starts.
|
||||||
|
function applyScene(tr) {
|
||||||
|
const s = tr.scene;
|
||||||
|
if (!s) return;
|
||||||
|
if (s.effectIndex != null && EFFECTS.list[s.effectIndex]) applyEffect(EFFECTS.list[s.effectIndex]);
|
||||||
|
const showText = !!(s.text && s.text.trim());
|
||||||
|
send({ type: 'tickerText', text: s.text || '' });
|
||||||
|
send({ type: 'tickerOn', on: showText });
|
||||||
|
$('#ticker-text').value = s.text || '';
|
||||||
|
$('#ticker-on').checked = showText;
|
||||||
|
send({ type: 'sceneImage', path: s.image || null });
|
||||||
|
}
|
||||||
|
|
||||||
const baseName = (p) => p.split('/').pop();
|
const baseName = (p) => p.split('/').pop();
|
||||||
|
|
||||||
@@ -225,6 +245,7 @@ function playIndex(i) {
|
|||||||
playbackOwner = 'playlist';
|
playbackOwner = 'playlist';
|
||||||
activePad = -1; padPlaying = false; renderPads();
|
activePad = -1; padPlaying = false; renderPads();
|
||||||
send({ type: 'playTrack', path: playlist[i].path });
|
send({ type: 'playTrack', path: playlist[i].path });
|
||||||
|
if (playlist[i].scene) applyScene(playlist[i]);
|
||||||
$('#btn-play').disabled = false;
|
$('#btn-play').disabled = false;
|
||||||
$('#btn-play').textContent = '⏸ Pausa';
|
$('#btn-play').textContent = '⏸ Pausa';
|
||||||
renderPlaylist();
|
renderPlaylist();
|
||||||
@@ -279,6 +300,7 @@ function renderPlaylist() {
|
|||||||
'<button class="key-btn" title="Assegna tasto rapido">' +
|
'<button class="key-btn" title="Assegna tasto rapido">' +
|
||||||
(capturingFor === i ? '…' : (tr.key ? tr.key.toUpperCase() : '⌨')) + '</button>' +
|
(capturingFor === i ? '…' : (tr.key ? tr.key.toUpperCase() : '⌨')) + '</button>' +
|
||||||
'<button class="play-btn" title="' + (isCur && isPlaying ? 'Pausa' : 'Avvia') + '">' + playIcon + '</button>' +
|
'<button class="play-btn" title="' + (isCur && isPlaying ? 'Pausa' : 'Avvia') + '">' + playIcon + '</button>' +
|
||||||
|
'<button class="scene-btn' + (hasScene(tr) ? ' active' : '') + '" title="Scena del brano (effetto/testo/immagine)">🎬</button>' +
|
||||||
'<button class="del-btn" title="Rimuovi">✕</button>' +
|
'<button class="del-btn" title="Rimuovi">✕</button>' +
|
||||||
(isCur ? '<i class="tprog" style="width:' + prog.toFixed(1) + '%"></i>' : '');
|
(isCur ? '<i class="tprog" style="width:' + prog.toFixed(1) + '%"></i>' : '');
|
||||||
|
|
||||||
@@ -294,6 +316,10 @@ function renderPlaylist() {
|
|||||||
capturingFor = capturingFor === i ? -1 : i;
|
capturingFor = capturingFor === i ? -1 : i;
|
||||||
renderPlaylist();
|
renderPlaylist();
|
||||||
});
|
});
|
||||||
|
li.querySelector('.scene-btn').addEventListener('click', () => {
|
||||||
|
sceneEditing = sceneEditing === i ? -1 : i;
|
||||||
|
renderPlaylist();
|
||||||
|
});
|
||||||
|
|
||||||
// Drag to reorder.
|
// Drag to reorder.
|
||||||
li.addEventListener('dragstart', () => { dragFrom = i; li.classList.add('dragging'); });
|
li.addEventListener('dragstart', () => { dragFrom = i; li.classList.add('dragging'); });
|
||||||
@@ -305,6 +331,35 @@ function renderPlaylist() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ol.appendChild(li);
|
ol.appendChild(li);
|
||||||
|
|
||||||
|
if (sceneEditing === i) {
|
||||||
|
const s = tr.scene || (tr.scene = { effectIndex: null, effectName: '', text: '', image: null });
|
||||||
|
const ed = document.createElement('li');
|
||||||
|
ed.className = 'scene-editor';
|
||||||
|
ed.innerHTML =
|
||||||
|
'<div class="se-row"><button class="se-effect">🌀 Usa effetto corrente</button>' +
|
||||||
|
'<span class="se-name">' + (s.effectName || '— nessun effetto —') + '</span></div>' +
|
||||||
|
'<input class="se-text textfield" placeholder="Testo da mostrare (vuoto = nessuno)" />' +
|
||||||
|
'<div class="se-row"><button class="se-img">🖼 Immagine…</button>' +
|
||||||
|
'<button class="se-img-clear">✕</button>' +
|
||||||
|
'<span class="se-name">' + (s.image ? baseName(s.image) : 'nessuna immagine') + '</span></div>' +
|
||||||
|
'<div class="se-row"><button class="se-clear">Rimuovi scena</button>' +
|
||||||
|
'<span class="hint-mini" style="margin:0">Applicata quando parte il brano</span></div>';
|
||||||
|
ed.querySelector('.se-text').value = s.text || '';
|
||||||
|
ed.querySelector('.se-effect').addEventListener('click', () => {
|
||||||
|
s.effectIndex = EFFECTS.list.indexOf(currentEffect);
|
||||||
|
s.effectName = currentEffect.name;
|
||||||
|
renderPlaylist();
|
||||||
|
});
|
||||||
|
ed.querySelector('.se-text').addEventListener('input', (e) => {
|
||||||
|
s.text = e.target.value;
|
||||||
|
li.querySelector('.scene-btn').classList.toggle('active', hasScene(tr));
|
||||||
|
});
|
||||||
|
ed.querySelector('.se-img').addEventListener('click', () => { sceneImgTarget = i; $('#scene-img-input').click(); });
|
||||||
|
ed.querySelector('.se-img-clear').addEventListener('click', () => { s.image = null; renderPlaylist(); });
|
||||||
|
ed.querySelector('.se-clear').addEventListener('click', () => { tr.scene = null; sceneEditing = -1; renderPlaylist(); });
|
||||||
|
ol.appendChild(ed);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
$('#playlist-count').textContent = playlist.length + (playlist.length === 1 ? ' brano' : ' brani');
|
$('#playlist-count').textContent = playlist.length + (playlist.length === 1 ? ' brano' : ' brani');
|
||||||
}
|
}
|
||||||
@@ -315,6 +370,16 @@ $('#tracks-input').addEventListener('change', (e) => {
|
|||||||
if (items.length) addTracks(items);
|
if (items.length) addTracks(items);
|
||||||
e.target.value = ''; // allow re-adding the same file
|
e.target.value = ''; // allow re-adding the same file
|
||||||
});
|
});
|
||||||
|
$('#scene-img-input').addEventListener('change', (e) => {
|
||||||
|
const f = e.target.files[0];
|
||||||
|
const p = f && filePath(f);
|
||||||
|
if (p && sceneImgTarget >= 0 && playlist[sceneImgTarget]) {
|
||||||
|
const tr = playlist[sceneImgTarget];
|
||||||
|
(tr.scene = tr.scene || { effectIndex: null, effectName: '', text: '', image: null }).image = p;
|
||||||
|
renderPlaylist();
|
||||||
|
}
|
||||||
|
sceneImgTarget = -1; e.target.value = '';
|
||||||
|
});
|
||||||
$('#btn-clear-playlist').addEventListener('click', () => {
|
$('#btn-clear-playlist').addEventListener('click', () => {
|
||||||
playlist = []; currentIndex = -1; renderPlaylist();
|
playlist = []; currentIndex = -1; renderPlaylist();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,6 +13,9 @@
|
|||||||
<!-- Image overlay layer -->
|
<!-- Image overlay layer -->
|
||||||
<div id="overlay-images"></div>
|
<div id="overlay-images"></div>
|
||||||
|
|
||||||
|
<!-- Per-track scene image -->
|
||||||
|
<img id="scene-image" class="scene-image" />
|
||||||
|
|
||||||
<!-- Two independent, freely-positioned & resizable logos -->
|
<!-- Two independent, freely-positioned & resizable logos -->
|
||||||
<img id="logo-0" class="logo" />
|
<img id="logo-0" class="logo" />
|
||||||
<img id="logo-1" class="logo" />
|
<img id="logo-1" class="logo" />
|
||||||
|
|||||||
@@ -88,6 +88,11 @@ function composeFrame() {
|
|||||||
recCtx.globalAlpha = 1;
|
recCtx.globalAlpha = 1;
|
||||||
recCtx.globalCompositeOperation = 'source-over';
|
recCtx.globalCompositeOperation = 'source-over';
|
||||||
|
|
||||||
|
if (sceneImage.classList.contains('show') && sceneImage.src) {
|
||||||
|
const r = sceneImage.getBoundingClientRect();
|
||||||
|
try { recCtx.drawImage(sceneImage, r.x * sx, r.y * sy, r.width * sx, r.height * sy); } catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
for (const l of logos) {
|
for (const l of logos) {
|
||||||
if (!l.classList.contains('show')) continue;
|
if (!l.classList.contains('show')) continue;
|
||||||
const r = l.getBoundingClientRect();
|
const r = l.getBoundingClientRect();
|
||||||
@@ -187,6 +192,8 @@ function loadCustomTexture(dataUrl) {
|
|||||||
img.src = dataUrl;
|
img.src = dataUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sceneImage = $('#scene-image');
|
||||||
|
|
||||||
// Two independent logos.
|
// Two independent logos.
|
||||||
const logos = [$('#logo-0'), $('#logo-1')];
|
const logos = [$('#logo-0'), $('#logo-1')];
|
||||||
function setLogo(i, url) {
|
function setLogo(i, url) {
|
||||||
@@ -294,6 +301,10 @@ djv.onControl(async (m) => {
|
|||||||
case 'imgNext': showImage(imgIndex + 1); break;
|
case 'imgNext': showImage(imgIndex + 1); break;
|
||||||
case 'imgPrev': showImage(imgIndex - 1); break;
|
case 'imgPrev': showImage(imgIndex - 1); break;
|
||||||
|
|
||||||
|
case 'sceneImage':
|
||||||
|
if (m.path) { sceneImage.src = toFileURL(m.path); sceneImage.classList.add('show'); hideHint(); }
|
||||||
|
else { sceneImage.classList.remove('show'); sceneImage.removeAttribute('src'); }
|
||||||
|
break;
|
||||||
case 'logo': setLogo(m.index, m.path ? toFileURL(m.path) : ''); break;
|
case 'logo': setLogo(m.index, m.path ? toFileURL(m.path) : ''); break;
|
||||||
case 'logoX': logos[m.index].style.left = m.value + '%'; break;
|
case 'logoX': logos[m.index].style.left = m.value + '%'; break;
|
||||||
case 'logoY': logos[m.index].style.top = m.value + '%'; break;
|
case 'logoY': logos[m.index].style.top = m.value + '%'; break;
|
||||||
|
|||||||
@@ -347,6 +347,23 @@ textarea.textarea-lg {
|
|||||||
}
|
}
|
||||||
.track .play-btn { color: #8ef0b0; }
|
.track .play-btn { color: #8ef0b0; }
|
||||||
.track .del-btn { color: #ff8a8a; }
|
.track .del-btn { color: #ff8a8a; }
|
||||||
|
.track .scene-btn { filter: grayscale(1) opacity(0.5); }
|
||||||
|
.track .scene-btn.active { filter: none; }
|
||||||
|
|
||||||
|
/* Per-track scene editor */
|
||||||
|
.scene-editor {
|
||||||
|
list-style: none;
|
||||||
|
margin: -2px 0 7px;
|
||||||
|
padding: 10px;
|
||||||
|
background: rgba(110,150,255,0.10);
|
||||||
|
border: 1px solid rgba(120,150,255,0.3);
|
||||||
|
border-radius: 9px;
|
||||||
|
}
|
||||||
|
.scene-editor .se-row { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; }
|
||||||
|
.scene-editor .se-row:last-child { margin-bottom: 0; }
|
||||||
|
.scene-editor .se-name { font-size: 10.5px; opacity: 0.7; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.scene-editor .se-text { margin: 0 0 6px; }
|
||||||
|
.scene-editor .se-img-clear, .scene-editor .se-clear { flex: 0 0 auto; }
|
||||||
|
|
||||||
/* ============ OUTPUT WINDOW ============ */
|
/* ============ OUTPUT WINDOW ============ */
|
||||||
body.output { cursor: none; background: #000; }
|
body.output { cursor: none; background: #000; }
|
||||||
@@ -375,6 +392,21 @@ body.output { cursor: none; background: #000; }
|
|||||||
}
|
}
|
||||||
#overlay-images img.show { opacity: 1; }
|
#overlay-images img.show { opacity: 1; }
|
||||||
|
|
||||||
|
/* ---- Per-track scene image ---- */
|
||||||
|
.scene-image {
|
||||||
|
position: fixed;
|
||||||
|
left: 50%; top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
max-width: 62vw;
|
||||||
|
max-height: 62vh;
|
||||||
|
object-fit: contain;
|
||||||
|
z-index: 4;
|
||||||
|
pointer-events: none;
|
||||||
|
display: none;
|
||||||
|
filter: drop-shadow(0 0 24px rgba(0,0,0,0.5));
|
||||||
|
}
|
||||||
|
.scene-image.show { display: block; }
|
||||||
|
|
||||||
/* ---- Independent logos ---- */
|
/* ---- Independent logos ---- */
|
||||||
.logo {
|
.logo {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
Reference in new issue
Block a user