summaryrefslogtreecommitdiff
path: root/tools
diff options
context:
space:
mode:
authorskal <pascal.massimino@gmail.com>2026-02-15 11:09:01 +0100
committerskal <pascal.massimino@gmail.com>2026-02-15 11:09:01 +0100
commitb54d15620032d2c03d528478f2a6742747125097 (patch)
tree7daffc347744015e0c5cee8c91e5e1eba40a2398 /tools
parente8113d8d0e33ef08f0754bc09735e8bb6d049f43 (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')
-rw-r--r--tools/timeline_editor/index.html81
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);