diff options
| author | skal <pascal.massimino@gmail.com> | 2026-02-15 10:58:06 +0100 |
|---|---|---|
| committer | skal <pascal.massimino@gmail.com> | 2026-02-15 10:58:06 +0100 |
| commit | e8113d8d0e33ef08f0754bc09735e8bb6d049f43 (patch) | |
| tree | 8ca00657f9288833998081ecc2e3f92a5b315c42 /tools/timeline_editor/index.html | |
| parent | ceb2fcf2262858ccf8d8294d6b344b652ebc27bb (diff) | |
feat(timeline-editor): consolidate playback indicator positioning and improve UX
Major improvements to timeline editor playback indicator and user experience:
**Indicator positioning:**
- Consolidated all indicator positioning into single updateIndicatorPosition() function
- Removed dual indicator architecture (waveform + timeline), now single indicator spans full height
- Positioned at timeline-container level with correct offset calculation (beats * pixelsPerSecond + 20px)
- Indicator properly updates on all state changes: playback, zoom, BPM, seek, file load
**UX improvements:**
- Made BPM value editable (number input instead of span)
- Added faint vertical beat markers to waveform (rgba(255,255,255,0.15))
- Fixed keyboard event handling to not intercept number keys when typing in input fields
- Reset playback offset to 0 on file load and audio clear
**Bug fixes:**
- Removed timeline border-left that caused offset issues
- Fixed indicator not updating on mouse wheel zoom
- Fixed indicator visibility (z-index 110 above sticky header)
- All zoom/BPM/file operations now consistently update indicator
Architecture is now clean and maintainable with single source of truth for positioning.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Diffstat (limited to 'tools/timeline_editor/index.html')
| -rw-r--r-- | tools/timeline_editor/index.html | 92 |
1 files changed, 64 insertions, 28 deletions
diff --git a/tools/timeline_editor/index.html b/tools/timeline_editor/index.html index eca7b97..7cd650c 100644 --- a/tools/timeline_editor/index.html +++ b/tools/timeline_editor/index.html @@ -42,14 +42,14 @@ .timeline-container { background: var(--bg-medium); border-radius: 8px; position: relative; height: calc(100vh - 280px); min-height: 500px; display: flex; flex-direction: column; } .timeline-content { flex: 1; overflow: auto; position: relative; padding: 0 20px 20px 20px; scrollbar-width: none; -ms-overflow-style: none; } .timeline-content::-webkit-scrollbar { display: none; } - .timeline { position: relative; min-height: 100%; border-left: 2px solid var(--bg-light); } + .timeline { position: relative; min-height: 100%; } .sticky-header { position: sticky; top: 0; background: var(--bg-medium); z-index: 100; padding: 20px 20px 10px 20px; border-bottom: 2px solid var(--bg-light); flex-shrink: 0; } .waveform-container { position: relative; height: 80px; overflow: hidden; background: rgba(0, 0, 0, 0.3); border-radius: var(--radius); cursor: crosshair; } #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; left: 0; width: 2px; background: var(--accent-red); box-shadow: 0 0 4px rgba(244, 135, 113, 0.8); pointer-events: none; z-index: 90; 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: block; } .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); } @@ -125,7 +125,7 @@ <label>Zoom: <input type="range" id="zoomSlider" min="10" max="200" value="100" step="10"></label> <span id="zoomLevel">100%</span> <label style="margin-left: 20px">BPM: <input type="range" id="bpmSlider" min="60" max="200" value="120" step="1"></label> - <span id="currentBPM">120</span> + <input type="number" id="currentBPM" value="120" min="60" max="200" step="1" style="width: 60px; padding: 4px; background: var(--bg-light); border: 1px solid var(--border-color); border-radius: var(--radius); color: var(--text-primary); text-align: center;"> <label class="checkbox-label" style="margin-left: 20px"> <input type="checkbox" id="showBeatsCheckbox" checked>Show Beats </label> @@ -149,16 +149,15 @@ <div id="messageArea"></div> <div class="timeline-container"> + <div class="playback-indicator" id="playbackIndicator"></div> <div class="sticky-header"> <div class="waveform-container" id="waveformContainer"> <canvas id="cpuLoadCanvas"></canvas> <canvas id="waveformCanvas"></canvas> - <div class="playback-indicator" id="waveformPlaybackIndicator"></div> </div> <div class="time-markers" id="timeMarkers"></div> </div> <div class="timeline-content" id="timelineContent"> - <div class="playback-indicator" id="playbackIndicator"></div> <div class="timeline" id="timeline"></div> </div> </div> @@ -217,7 +216,6 @@ playPauseBtn: document.getElementById('playPauseBtn'), playbackTime: document.getElementById('playbackTime'), playbackIndicator: document.getElementById('playbackIndicator'), - waveformPlaybackIndicator: document.getElementById('waveformPlaybackIndicator'), panelToggle: document.getElementById('panelToggle'), panelCollapseBtn: document.getElementById('panelCollapseBtn'), bpmSlider: document.getElementById('bpmSlider'), @@ -342,7 +340,6 @@ const w = timeToBeats(state.audioDuration) * state.pixelsPerSecond, h = 80; canvas.width = w; canvas.height = h; canvas.style.width = `${w}px`; canvas.style.height = `${h}px`; - dom.waveformPlaybackIndicator.style.height = `${h}px`; ctx.fillStyle = 'rgba(0, 0, 0, 0.3)'; ctx.fillRect(0, 0, w, h); const channelData = state.audioBuffer.getChannelData(0); @@ -365,6 +362,18 @@ ctx.stroke(); ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)'; ctx.beginPath(); ctx.moveTo(0, centerY); ctx.lineTo(w, centerY); ctx.stroke(); + + // Draw beat markers + const maxBeats = timeToBeats(state.audioDuration); + ctx.strokeStyle = 'rgba(255, 255, 255, 0.15)'; + ctx.lineWidth = 1; + for (let beat = 0; beat <= maxBeats; beat++) { + const x = beat * state.pixelsPerSecond; + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, h); + ctx.stroke(); + } } function computeCPULoad() { @@ -450,12 +459,14 @@ } function clearAudio() { - stopPlayback(); state.audioBuffer = null; state.audioDuration = 0; + stopPlayback(); state.audioBuffer = null; state.audioDuration = 0; state.playbackOffset = 0; dom.playbackControls.style.display = 'none'; dom.clearAudioBtn.disabled = true; const ctx = dom.waveformCanvas.getContext('2d'); ctx.clearRect(0, 0, dom.waveformCanvas.width, dom.waveformCanvas.height); - renderTimeline(); showMessage('Audio cleared', 'success'); + renderTimeline(); + updateIndicatorPosition(0, false); + showMessage('Audio cleared', 'success'); } async function startPlayback() { @@ -493,10 +504,7 @@ const currentTime = state.playbackOffset + elapsed; const currentBeats = timeToBeats(currentTime); dom.playbackTime.textContent = `${currentTime.toFixed(2)}s (${currentBeats.toFixed(2)}b)`; - const indicatorX = currentBeats * state.pixelsPerSecond; - dom.playbackIndicator.style.left = dom.waveformPlaybackIndicator.style.left = `${indicatorX}px`; - const scrollDiff = indicatorX - dom.timelineContent.clientWidth * 0.4 - dom.timelineContent.scrollLeft; - if (Math.abs(scrollDiff) > 5) dom.timelineContent.scrollLeft += scrollDiff * 0.1; + updateIndicatorPosition(currentBeats, true); expandSequenceAtTime(currentBeats); state.animationFrameId = requestAnimationFrame(updatePlaybackPosition); } @@ -520,6 +528,16 @@ } } + function updateIndicatorPosition(beats, smoothScroll = false) { + const indicatorX = beats * state.pixelsPerSecond + 20; + dom.playbackIndicator.style.left = `${indicatorX}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; + } + } + // Render function renderTimeline() { renderCPULoad(); @@ -616,7 +634,6 @@ } }); dom.timeline.style.minHeight = `${Math.max(totalTimelineHeight, dom.timelineContent.offsetHeight)}px`; - if (dom.playbackIndicator) dom.playbackIndicator.style.height = `${Math.max(totalTimelineHeight, dom.timelineContent.offsetHeight)}px`; updateStats(); } @@ -800,9 +817,11 @@ if (!response.ok) throw new Error(`HTTP ${response.status}`); const content = await response.text(), parsed = parseSeqFile(content); state.sequences = parsed.sequences; state.bpm = parsed.bpm; - dom.currentBPM.textContent = state.bpm; dom.bpmSlider.value = state.bpm; + dom.currentBPM.value = state.bpm; dom.bpmSlider.value = state.bpm; state.currentFile = seqURL.split('/').pop(); + state.playbackOffset = 0; renderTimeline(); dom.saveBtn.disabled = false; dom.addSequenceBtn.disabled = false; dom.reorderBtn.disabled = false; + updateIndicatorPosition(0, false); showMessage(`Loaded ${state.currentFile} from URL`, 'success'); } catch (err) { showMessage(`Error loading seq file: ${err.message}`, 'error'); } } @@ -826,8 +845,10 @@ try { const parsed = parseSeqFile(e.target.result); state.sequences = parsed.sequences; state.bpm = parsed.bpm; - dom.currentBPM.textContent = state.bpm; dom.bpmSlider.value = state.bpm; + dom.currentBPM.value = state.bpm; dom.bpmSlider.value = state.bpm; + state.playbackOffset = 0; renderTimeline(); dom.saveBtn.disabled = false; dom.addSequenceBtn.disabled = false; dom.reorderBtn.disabled = false; + updateIndicatorPosition(0, false); showMessage(`Loaded ${state.currentFile} - ${state.sequences.length} sequences`, 'success'); } catch (err) { showMessage(`Error parsing file: ${err.message}`, 'error'); } }; @@ -860,8 +881,7 @@ state.playbackOffset = Math.max(0, Math.min(clickTime, state.audioDuration)); const pausedBeats = timeToBeats(state.playbackOffset); dom.playbackTime.textContent = `${state.playbackOffset.toFixed(2)}s (${pausedBeats.toFixed(2)}b)`; - const indicatorX = pausedBeats * state.pixelsPerSecond; - dom.playbackIndicator.style.left = dom.waveformPlaybackIndicator.style.left = `${indicatorX}px`; + updateIndicatorPosition(pausedBeats, false); if (wasPlaying) await startPlayback(); }); @@ -901,11 +921,24 @@ dom.zoomSlider.addEventListener('input', e => { 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.textContent = state.bpm; + 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(); + updateIndicatorPosition(timeToBeats(state.playbackOffset), false); + } else { + e.target.value = state.bpm; + } }); dom.showBeatsCheckbox.addEventListener('change', e => { state.showBeats = e.target.checked; renderTimeline(); }); @@ -926,21 +959,23 @@ state.playbackOffset = Math.max(0, Math.min(clickTime, state.audioDuration)); const pausedBeats = timeToBeats(state.playbackOffset); dom.playbackTime.textContent = `${state.playbackOffset.toFixed(2)}s (${pausedBeats.toFixed(2)}b)`; - const indicatorX = pausedBeats * state.pixelsPerSecond; - dom.playbackIndicator.style.left = dom.waveformPlaybackIndicator.style.left = `${indicatorX}px`; + updateIndicatorPosition(pausedBeats, false); if (wasPlaying) await startPlayback(); showMessage(`Seek to ${clickTime.toFixed(2)}s (${clickBeats.toFixed(2)}b)`, 'success'); } }); document.addEventListener('keydown', e => { - if (e.code === 'Space' && state.audioBuffer) { e.preventDefault(); dom.playPauseBtn.click(); } + const isTyping = document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA'; + if (e.code === 'Space' && state.audioBuffer && !isTyping) { e.preventDefault(); dom.playPauseBtn.click(); } // Quantize hotkeys: 0=Off, 1=1beat, 2=1/2, 3=1/4, 4=1/8, 5=1/16, 6=1/32 - const quantizeMap = { '0': '0', '1': '1', '2': '2', '3': '4', '4': '8', '5': '16', '6': '32' }; - if (quantizeMap[e.key]) { - state.quantizeUnit = parseFloat(quantizeMap[e.key]); - dom.quantizeSelect.value = quantizeMap[e.key]; - e.preventDefault(); + if (!isTyping) { + const quantizeMap = { '0': '0', '1': '1', '2': '2', '3': '4', '4': '8', '5': '16', '6': '32' }; + if (quantizeMap[e.key]) { + state.quantizeUnit = parseFloat(quantizeMap[e.key]); + dom.quantizeSelect.value = quantizeMap[e.key]; + e.preventDefault(); + } } }); @@ -948,7 +983,7 @@ const scrollLeft = dom.timelineContent.scrollLeft; dom.cpuLoadCanvas.style.left = `-${scrollLeft}px`; dom.waveformCanvas.style.left = `-${scrollLeft}px`; - dom.waveformPlaybackIndicator.style.transform = `translateX(-${scrollLeft}px)`; + document.getElementById('timeMarkers').style.transform = `translateX(-${scrollLeft}px)`; }); dom.timelineContent.addEventListener('wheel', e => { @@ -961,6 +996,7 @@ 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); dom.timelineContent.scrollLeft = timeUnderCursor * newPixelsPerSecond - mouseX; } return; |
