diff options
| author | skal <pascal.massimino@gmail.com> | 2026-02-15 11:09:01 +0100 |
|---|---|---|
| committer | skal <pascal.massimino@gmail.com> | 2026-02-15 11:09:01 +0100 |
| commit | b54d15620032d2c03d528478f2a6742747125097 (patch) | |
| tree | 7daffc347744015e0c5cee8c91e5e1eba40a2398 /tools/timeline_editor | |
| parent | e8113d8d0e33ef08f0754bc09735e8bb6d049f43 (diff) | |
fix(timeline-editor): playback indicator zoom tracking and UX improvements
- Fix indicator position to track scroll offset during zoom/wheel
- Add named constants for all magic numbers (layout, scroll, timing)
- Fix double-click seek positioning (account for left padding)
- Hide indicator by default, show only when audio loaded
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Diffstat (limited to 'tools/timeline_editor')
| -rw-r--r-- | tools/timeline_editor/index.html | 81 |
1 files changed, 53 insertions, 28 deletions
diff --git a/tools/timeline_editor/index.html b/tools/timeline_editor/index.html index 7cd650c..1fbfcbf 100644 --- a/tools/timeline_editor/index.html +++ b/tools/timeline_editor/index.html @@ -49,7 +49,7 @@ #cpuLoadCanvas { position: absolute; left: 0; bottom: 0; height: 10px; display: block; z-index: 1; } #waveformCanvas { position: absolute; left: 0; top: 0; height: 80px; display: block; z-index: 2; } - .playback-indicator { position: absolute; top: 0; bottom: 0; left: 20px; width: 2px; background: var(--accent-red); box-shadow: 0 0 4px rgba(244, 135, 113, 0.8); pointer-events: none; z-index: 110; display: block; } + .playback-indicator { position: absolute; top: 0; bottom: 0; left: 20px; width: 2px; background: var(--accent-red); box-shadow: 0 0 4px rgba(244, 135, 113, 0.8); pointer-events: none; z-index: 110; display: none; } .time-markers { position: relative; height: 30px; margin-top: var(--gap); border-bottom: 1px solid var(--bg-light); } .time-marker { position: absolute; top: 0; font-size: 12px; color: var(--text-muted); } @@ -181,6 +181,19 @@ 'SolarizeEffect', 'VignetteEffect', 'ChromaAberrationEffect', 'DistortEffect', 'ThemeModulationEffect', 'CNNEffect', 'CNNv2Effect']); + const TIMELINE_LEFT_PADDING = 20; + const SCROLL_VIEWPORT_FRACTION = 0.4; + const SMOOTH_SCROLL_SPEED = 0.1; + const VERTICAL_SCROLL_SPEED = 0.3; + const SEQUENCE_GAP = 10; + const SEQUENCE_DEFAULT_WIDTH = 10; + const SEQUENCE_MIN_HEIGHT = 70; + const SEQUENCE_COLLAPSED_HEIGHT = 35; + const SEQUENCE_TOP_PADDING = 20; + const SEQUENCE_BOTTOM_PADDING = 5; + const EFFECT_SPACING = 30; + const EFFECT_HEIGHT = 26; + // State const state = { sequences: [], currentFile: null, selectedItem: null, pixelsPerSecond: 100, @@ -326,6 +339,7 @@ state.audioDuration = state.audioBuffer.duration; renderWaveform(); dom.playbackControls.style.display = 'flex'; + dom.playbackIndicator.style.display = 'block'; dom.clearAudioBtn.disabled = false; showMessage(`Audio loaded: ${state.audioDuration.toFixed(2)}s`, 'success'); renderTimeline(); @@ -461,11 +475,11 @@ function clearAudio() { stopPlayback(); state.audioBuffer = null; state.audioDuration = 0; state.playbackOffset = 0; dom.playbackControls.style.display = 'none'; + dom.playbackIndicator.style.display = 'none'; dom.clearAudioBtn.disabled = true; const ctx = dom.waveformCanvas.getContext('2d'); ctx.clearRect(0, 0, dom.waveformCanvas.width, dom.waveformCanvas.height); renderTimeline(); - updateIndicatorPosition(0, false); showMessage('Audio cleared', 'success'); } @@ -529,12 +543,13 @@ } function updateIndicatorPosition(beats, smoothScroll = false) { - const indicatorX = beats * state.pixelsPerSecond + 20; - dom.playbackIndicator.style.left = `${indicatorX}px`; + const timelineX = beats * state.pixelsPerSecond; + const scrollLeft = dom.timelineContent.scrollLeft; + dom.playbackIndicator.style.left = `${timelineX - scrollLeft + TIMELINE_LEFT_PADDING}px`; if (smoothScroll) { - const targetScroll = beats * state.pixelsPerSecond - dom.timelineContent.clientWidth * 0.4 + 20; - const scrollDiff = targetScroll - dom.timelineContent.scrollLeft; - if (Math.abs(scrollDiff) > 5) dom.timelineContent.scrollLeft += scrollDiff * 0.1; + const targetScroll = timelineX - dom.timelineContent.clientWidth * SCROLL_VIEWPORT_FRACTION; + const scrollDiff = targetScroll - scrollLeft; + if (Math.abs(scrollDiff) > 5) dom.timelineContent.scrollLeft += scrollDiff * SMOOTH_SCROLL_SPEED; } } @@ -566,24 +581,24 @@ marker.textContent = `${t}s`; timeMarkers.appendChild(marker); } } - let cumulativeY = 0, sequenceGap = 10; + let cumulativeY = 0; state.sequences.forEach((seq, seqIndex) => { const seqDiv = document.createElement('div'); seqDiv.className = 'sequence'; seqDiv.dataset.index = seqIndex; - let seqVisualStart = seq.startTime, seqVisualEnd = seq.startTime + 10; + let seqVisualStart = seq.startTime, seqVisualEnd = seq.startTime + SEQUENCE_DEFAULT_WIDTH; if (seq.effects.length > 0) { seqVisualStart = seq.startTime + Math.min(...seq.effects.map(e => e.startTime)); seqVisualEnd = seq.startTime + Math.max(...seq.effects.map(e => e.endTime)); } if (seq._collapsed === undefined) seq._collapsed = false; - const numEffects = seq.effects.length, effectSpacing = 30; - const fullHeight = Math.max(70, 20 + numEffects * effectSpacing + 5); - const seqHeight = seq._collapsed ? 35 : fullHeight; + const numEffects = seq.effects.length; + const fullHeight = Math.max(SEQUENCE_MIN_HEIGHT, SEQUENCE_TOP_PADDING + numEffects * EFFECT_SPACING + SEQUENCE_BOTTOM_PADDING); + const seqHeight = seq._collapsed ? SEQUENCE_COLLAPSED_HEIGHT : fullHeight; seqDiv.style.left = `${seqVisualStart * state.pixelsPerSecond}px`; seqDiv.style.top = `${cumulativeY}px`; seqDiv.style.width = `${(seqVisualEnd - seqVisualStart) * state.pixelsPerSecond}px`; seqDiv.style.height = `${seqHeight}px`; seqDiv.style.minHeight = `${seqHeight}px`; seqDiv.style.maxHeight = `${seqHeight}px`; - seq._yPosition = cumulativeY; cumulativeY += seqHeight + sequenceGap; totalTimelineHeight = cumulativeY; + seq._yPosition = cumulativeY; cumulativeY += seqHeight + SEQUENCE_GAP; totalTimelineHeight = cumulativeY; const seqHeaderDiv = document.createElement('div'); seqHeaderDiv.className = 'sequence-header'; const headerName = document.createElement('span'); headerName.className = 'sequence-header-name'; headerName.textContent = seq.name || `Sequence ${seqIndex + 1}`; @@ -609,9 +624,9 @@ Object.assign(effectDiv.dataset, { seqIndex, effectIndex }); Object.assign(effectDiv.style, { left: `${(seq.startTime + effect.startTime) * state.pixelsPerSecond}px`, - top: `${seq._yPosition + 20 + effectIndex * 30}px`, + top: `${seq._yPosition + SEQUENCE_TOP_PADDING + effectIndex * EFFECT_SPACING}px`, width: `${(effect.endTime - effect.startTime) * state.pixelsPerSecond}px`, - height: '26px' + height: `${EFFECT_HEIGHT}px` }); effectDiv.innerHTML = `<div class="effect-handle left"></div><small>${effect.className}</small><div class="effect-handle right"></div>`; const conflictWarning = conflicts.has(effectIndex) ? @@ -919,22 +934,28 @@ }); dom.zoomSlider.addEventListener('input', e => { - state.pixelsPerSecond = parseInt(e.target.value); dom.zoomLevel.textContent = `${state.pixelsPerSecond}%`; - if (state.audioBuffer) renderWaveform(); renderTimeline(); + state.pixelsPerSecond = parseInt(e.target.value); + dom.zoomLevel.textContent = `${state.pixelsPerSecond}%`; + if (state.audioBuffer) renderWaveform(); + renderTimeline(); updateIndicatorPosition(timeToBeats(state.playbackOffset), false); }); dom.bpmSlider.addEventListener('input', e => { - state.bpm = parseInt(e.target.value); dom.currentBPM.value = state.bpm; - if (state.audioBuffer) renderWaveform(); renderTimeline(); + state.bpm = parseInt(e.target.value); + dom.currentBPM.value = state.bpm; + if (state.audioBuffer) renderWaveform(); + renderTimeline(); updateIndicatorPosition(timeToBeats(state.playbackOffset), false); }); dom.currentBPM.addEventListener('change', e => { const bpm = parseInt(e.target.value); if (!isNaN(bpm) && bpm >= 60 && bpm <= 200) { - state.bpm = bpm; dom.bpmSlider.value = bpm; - if (state.audioBuffer) renderWaveform(); renderTimeline(); + state.bpm = bpm; + dom.bpmSlider.value = bpm; + if (state.audioBuffer) renderWaveform(); + renderTimeline(); updateIndicatorPosition(timeToBeats(state.playbackOffset), false); } else { e.target.value = state.bpm; @@ -950,7 +971,7 @@ dom.timeline.addEventListener('dblclick', async e => { if (e.target !== dom.timeline) return; const containerRect = dom.timelineContent.getBoundingClientRect(); - const clickX = e.clientX - containerRect.left + dom.timelineContent.scrollLeft; + const clickX = e.clientX - containerRect.left + dom.timelineContent.scrollLeft - TIMELINE_LEFT_PADDING; const clickBeats = clickX / state.pixelsPerSecond; const clickTime = beatsToTime(clickBeats); if (state.audioBuffer) { @@ -984,6 +1005,7 @@ dom.cpuLoadCanvas.style.left = `-${scrollLeft}px`; dom.waveformCanvas.style.left = `-${scrollLeft}px`; document.getElementById('timeMarkers').style.transform = `translateX(-${scrollLeft}px)`; + updateIndicatorPosition(timeToBeats(state.playbackOffset), false); }); dom.timelineContent.addEventListener('wheel', e => { @@ -991,13 +1013,16 @@ if (e.ctrlKey || e.metaKey) { const rect = dom.timelineContent.getBoundingClientRect(), mouseX = e.clientX - rect.left; const scrollLeft = dom.timelineContent.scrollLeft, timeUnderCursor = (scrollLeft + mouseX) / state.pixelsPerSecond; - const zoomDelta = e.deltaY > 0 ? -10 : 10, oldPixelsPerSecond = state.pixelsPerSecond; + const zoomDelta = e.deltaY > 0 ? -10 : 10; const newPixelsPerSecond = Math.max(10, Math.min(500, state.pixelsPerSecond + zoomDelta)); - if (newPixelsPerSecond !== oldPixelsPerSecond) { - state.pixelsPerSecond = newPixelsPerSecond; dom.zoomSlider.value = state.pixelsPerSecond; dom.zoomLevel.textContent = `${state.pixelsPerSecond}%`; - if (state.audioBuffer) renderWaveform(); renderTimeline(); - updateIndicatorPosition(timeToBeats(state.playbackOffset), false); + if (newPixelsPerSecond !== state.pixelsPerSecond) { + state.pixelsPerSecond = newPixelsPerSecond; + dom.zoomSlider.value = state.pixelsPerSecond; + dom.zoomLevel.textContent = `${state.pixelsPerSecond}%`; + if (state.audioBuffer) renderWaveform(); + renderTimeline(); dom.timelineContent.scrollLeft = timeUnderCursor * newPixelsPerSecond - mouseX; + updateIndicatorPosition(timeToBeats(state.playbackOffset), false); } return; } @@ -1018,7 +1043,7 @@ } const targetScrollTop = state.sequences[targetSeqIndex]?._yPosition || 0; const currentScrollTop = dom.timelineContent.scrollTop, scrollDiff = targetScrollTop - currentScrollTop; - if (Math.abs(scrollDiff) > 5) dom.timelineContent.scrollTop += scrollDiff * 0.3; + if (Math.abs(scrollDiff) > 5) dom.timelineContent.scrollTop += scrollDiff * VERTICAL_SCROLL_SPEED; }, { passive: false }); window.addEventListener('resize', renderTimeline); |
