Playlist: scrub/seek playback by clicking-dragging the waveform

Pointer on the waveform now seeks: scrub the playhead on the playing track
(new 'seek' output message setting currentTime) or start a stopped track from
the clicked point (playIndex(i, fromTime)). Grabbing within ~10px of the green/
red handles still adjusts the trim in/out instead of seeking.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
lucianoandClaude Opus 4.8 committed 2026-06-22 16:45:00 +02:00
1 parent 882dd4643a
commit 26a5000b04
3 files changed
+52 -17

No files matched your search

+1 -1
View File
@@ -80,7 +80,7 @@
<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, ognuno col proprio tempo) · ✨ <b>Intermezzo</b> = voce solo-effetti (niente audio) tra un brano e l'altro</div> <div class="hint-mini">Clic sul <b>nome</b> = avvia/riavvia · ▶/⏸ pausa · ⠿ riordina · ⌨ tasto rapido · 🎬 <b>scena</b> · ✨ <b>Intermezzo</b> = solo-effetti tra i brani · sulla <b>forma d'onda</b>: clic/trascina = sposta la riproduzione, maniglie 🟢🔴 = inizio/fine</div>
</section> </section>
<section class="span2"> <section class="span2">
+43 -16
View File
@@ -276,31 +276,56 @@ function wireTrim(li, canvas, tr, i) {
li.querySelector('.trim-here-e').addEventListener('click', () => { if (i === currentIndex) { tr.end = Math.max(0.5, playCur); commitTrim(tr, i, true); } }); li.querySelector('.trim-here-e').addEventListener('click', () => { if (i === currentIndex) { tr.end = Math.max(0.5, playCur); commitTrim(tr, i, true); } });
li.querySelector('.trim-reset').addEventListener('click', () => { tr.start = 0; tr.end = 0; commitTrim(tr, i, true); }); li.querySelector('.trim-reset').addEventListener('click', () => { tr.start = 0; tr.end = 0; commitTrim(tr, i, true); });
let dragH = null; // Pointer on the waveform: grab a trim handle if near one, otherwise SEEK —
// scrub the playhead on the current track, or start a stopped track from there.
const HANDLE_PX = 10;
let mode = null;
const dur = () => durations[tr.path] || (i === currentIndex ? playDur : 0) || 0; const dur = () => durations[tr.path] || (i === currentIndex ? playDur : 0) || 0;
const timeAt = (e) => { const xToTime = (e) => {
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
const f = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); return Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)) * dur();
return f * dur(); };
const scrub = (t) => {
if (i !== currentIndex) return; // preview seek only for the playing track
playCur = t;
send({ type: 'seek', time: t });
drawWave(canvas, tr, true);
}; };
canvas.addEventListener('pointerdown', (e) => { canvas.addEventListener('pointerdown', (e) => {
if (!dur()) return; const d = dur();
const t = timeAt(e), s = tr.start || 0, en = tr.end > 0 ? tr.end : dur(); const rect = canvas.getBoundingClientRect();
dragH = Math.abs(t - s) <= Math.abs(t - en) ? 'start' : 'end'; const x = e.clientX - rect.left;
const sx = d ? (tr.start || 0) / d * rect.width : 0;
const ex = d ? (tr.end > 0 ? tr.end : d) / d * rect.width : rect.width;
if (d && Math.abs(x - sx) <= HANDLE_PX) mode = 'start';
else if (d && Math.abs(x - ex) <= HANDLE_PX) mode = 'end';
else mode = 'seek';
canvas.setPointerCapture(e.pointerId); e.preventDefault(); canvas.setPointerCapture(e.pointerId); e.preventDefault();
if (mode === 'seek') scrub(xToTime(e));
}); });
canvas.addEventListener('pointermove', (e) => { canvas.addEventListener('pointermove', (e) => {
if (!dragH) return; if (!mode) return;
const t = timeAt(e); const t = xToTime(e);
if (dragH === 'start') tr.start = Math.max(0, Math.min(t, (tr.end > 0 ? tr.end : dur()) - 0.5)); if (mode === 'start') { tr.start = Math.max(0, Math.min(t, (tr.end > 0 ? tr.end : dur()) - 0.5)); }
else tr.end = Math.min(dur(), Math.max(t, (tr.start || 0) + 0.5)); else if (mode === 'end') { tr.end = Math.min(dur(), Math.max(t, (tr.start || 0) + 0.5)); }
else { scrub(t); return; }
drawWave(canvas, tr, i === currentIndex); drawWave(canvas, tr, i === currentIndex);
sIn.value = fmtTime(Math.floor(tr.start || 0)); sIn.value = fmtTime(Math.floor(tr.start || 0));
eIn.value = tr.end > 0 ? fmtTime(Math.floor(tr.end)) : ''; eIn.value = tr.end > 0 ? fmtTime(Math.floor(tr.end)) : '';
}); });
const endDrag = () => { if (dragH) { dragH = null; commitTrim(tr, i, true); } }; const endDrag = (e) => {
if (!mode) return;
if (mode === 'seek') {
const t = xToTime(e);
if (i === currentIndex) scrub(t);
else playIndex(i, t); // start a stopped track from the clicked point
} else {
commitTrim(tr, i, true);
}
mode = null;
};
canvas.addEventListener('pointerup', endDrag); canvas.addEventListener('pointerup', endDrag);
canvas.addEventListener('pointercancel', endDrag); canvas.addEventListener('pointercancel', () => { mode = null; });
} }
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
@@ -477,7 +502,7 @@ function addInterlude() {
savePlaylistState(); savePlaylistState();
} }
function playIndex(i) { function playIndex(i, fromTime) {
if (i < 0 || i >= playlist.length) return; if (i < 0 || i >= playlist.length) return;
clearGap(); clearGap();
const tr = playlist[i]; const tr = playlist[i];
@@ -492,7 +517,9 @@ function playIndex(i) {
playInterlude(tr); playInterlude(tr);
return; return;
} }
send({ type: tr.isVideo ? 'playVideoTrack' : 'playTrack', path: tr.path, start: tr.start || 0, end: tr.end || 0 }); // fromTime (set by clicking the waveform) overrides the trim start for this run.
const startAt = (fromTime != null) ? Math.max(0, fromTime) : (tr.start || 0);
send({ type: tr.isVideo ? 'playVideoTrack' : 'playTrack', path: tr.path, start: startAt, end: tr.end || 0 });
startCues(tr); startCues(tr);
renderPlaylist(); renderPlaylist();
} }
@@ -783,7 +810,7 @@ function renderPlaylist() {
'<span class="tt-tot" title="Durata totale">⏱ ' + fmtTime(Math.floor(total)) + '</span>' + '<span class="tt-tot" title="Durata totale">⏱ ' + fmtTime(Math.floor(total)) + '</span>' +
'<span class="tt-rem" title="Rimasto">⧗ -' + fmtTime(Math.ceil(remaining)) + '</span>' + '<span class="tt-rem" title="Rimasto">⧗ -' + fmtTime(Math.ceil(remaining)) + '</span>' +
'</div>' + '</div>' +
'<canvas class="wave" width="' + WAVE_BUCKETS + '" height="46"></canvas>' + '<canvas class="wave" width="' + WAVE_BUCKETS + '" height="46" title="Clic/trascina = sposta il punto di riproduzione · maniglie verde/rossa = inizio/fine"></canvas>' +
'<div class="track-trim">' + '<div class="track-trim">' +
'<span class="trim-lbl trim-lbl-s">Inizio</span>' + '<span class="trim-lbl trim-lbl-s">Inizio</span>' +
'<input class="trim-start" type="text" value="' + fmtTime(Math.floor(tStart)) + '" title="Punto di inizio (mm:ss)" />' + '<input class="trim-start" type="text" value="' + fmtTime(Math.floor(tStart)) + '" title="Punto di inizio (mm:ss)" />' +
+8
View File
@@ -359,6 +359,14 @@ djv.onControl(async (m) => {
} catch (e) { djv.report({ type: 'error', message: e.message }); } } catch (e) { djv.report({ type: 'error', message: e.message }); }
break; break;
case 'setTrim': audio.setTrim(m.start || 0, m.end || 0); break; case 'setTrim': audio.setTrim(m.start || 0, m.end || 0); break;
case 'seek': {
const el = audio.mediaEl;
if (el && isFinite(el.duration) && el.duration > 0) {
el.currentTime = Math.max(0, Math.min(el.duration - 0.05, m.time || 0));
audio._trimFired = false; // re-arm the trim-end check after seeking
}
break;
}
case 'probeDurations': probeDurations(m.paths || []); break; case 'probeDurations': probeDurations(m.paths || []); break;
case 'togglePlay': { case 'togglePlay': {
const playing = audio.togglePlay(); const playing = audio.togglePlay();