diff --git a/src/control.html b/src/control.html
index e613037..0b559cc 100644
--- a/src/control.html
+++ b/src/control.html
@@ -52,8 +52,9 @@
+
- Clic sul nome = avvia/riavvia dall'inizio · ▶/⏸ pausa/riprendi · ⠿ trascina per riordinare · ⌨ tasto rapido
+ Clic sul nome = avvia/riavvia · ▶/⏸ pausa · ⠿ riordina · ⌨ tasto rapido · 🎬 scena (effetto+testo+immagine applicati quando parte il brano)
diff --git a/src/control.js b/src/control.js
index 9fa51e8..0b11ffc 100644
--- a/src/control.js
+++ b/src/control.js
@@ -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 padCrossfadeMs = 0; // crossfade duration for pad triggers
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();
@@ -225,6 +245,7 @@ function playIndex(i) {
playbackOwner = 'playlist';
activePad = -1; padPlaying = false; renderPads();
send({ type: 'playTrack', path: playlist[i].path });
+ if (playlist[i].scene) applyScene(playlist[i]);
$('#btn-play').disabled = false;
$('#btn-play').textContent = '⏸ Pausa';
renderPlaylist();
@@ -279,6 +300,7 @@ function renderPlaylist() {
'' +
'' +
+ '' +
'' +
(isCur ? '' : '');
@@ -294,6 +316,10 @@ function renderPlaylist() {
capturingFor = capturingFor === i ? -1 : i;
renderPlaylist();
});
+ li.querySelector('.scene-btn').addEventListener('click', () => {
+ sceneEditing = sceneEditing === i ? -1 : i;
+ renderPlaylist();
+ });
// Drag to reorder.
li.addEventListener('dragstart', () => { dragFrom = i; li.classList.add('dragging'); });
@@ -305,6 +331,35 @@ function renderPlaylist() {
});
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 =
+ '' +
+ '' + (s.effectName || '— nessun effetto —') + '
' +
+ '' +
+ '' +
+ '' +
+ '' + (s.image ? baseName(s.image) : 'nessuna immagine') + '
' +
+ '' +
+ 'Applicata quando parte il brano
';
+ 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');
}
@@ -315,6 +370,16 @@ $('#tracks-input').addEventListener('change', (e) => {
if (items.length) addTracks(items);
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', () => {
playlist = []; currentIndex = -1; renderPlaylist();
});
diff --git a/src/output.html b/src/output.html
index bbaaaea..10b7371 100644
--- a/src/output.html
+++ b/src/output.html
@@ -13,6 +13,9 @@
+
+
+
diff --git a/src/output.js b/src/output.js
index fa39740..afc60af 100644
--- a/src/output.js
+++ b/src/output.js
@@ -88,6 +88,11 @@ function composeFrame() {
recCtx.globalAlpha = 1;
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) {
if (!l.classList.contains('show')) continue;
const r = l.getBoundingClientRect();
@@ -187,6 +192,8 @@ function loadCustomTexture(dataUrl) {
img.src = dataUrl;
}
+const sceneImage = $('#scene-image');
+
// Two independent logos.
const logos = [$('#logo-0'), $('#logo-1')];
function setLogo(i, url) {
@@ -294,6 +301,10 @@ djv.onControl(async (m) => {
case 'imgNext': 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 'logoX': logos[m.index].style.left = m.value + '%'; break;
case 'logoY': logos[m.index].style.top = m.value + '%'; break;
diff --git a/src/style.css b/src/style.css
index 71fcf2e..9079b20 100644
--- a/src/style.css
+++ b/src/style.css
@@ -347,6 +347,23 @@ textarea.textarea-lg {
}
.track .play-btn { color: #8ef0b0; }
.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 ============ */
body.output { cursor: none; background: #000; }
@@ -375,6 +392,21 @@ body.output { cursor: none; background: #000; }
}
#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 ---- */
.logo {
position: fixed;