From 8925fa50f9efcc5e0d5f890b63be5a991535245e Mon Sep 17 00:00:00 2001 From: luciano Date: Fri, 19 Jun 2026 16:08:50 +0200 Subject: [PATCH] Per-track scenes: auto-apply effect + text + image when a playlist track starts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/control.html | 3 ++- src/control.js | 65 ++++++++++++++++++++++++++++++++++++++++++++++++ src/output.html | 3 +++ src/output.js | 11 ++++++++ src/style.css | 32 ++++++++++++++++++++++++ 5 files changed, 113 insertions(+), 1 deletion(-) 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;