From 0fcb17f6e0c0ab449c5432f4bbacd6948e1283cd Mon Sep 17 00:00:00 2001 From: skal Date: Thu, 12 Feb 2026 01:48:17 +0100 Subject: feat: timeline editor audio playback with auto-expand/collapse - Audio playback controls (play/pause, spacebar shortcut) - Red playback indicator with auto-scroll (middle third viewport) - Auto-expand active sequence during playback, collapse previous - Click waveform to seek - Sticky header: waveform + timeline ticks stay at top - Sequences confined to separate scrollable container below header - Document known bugs: zoom sync, positioning, reflow issues Co-Authored-By: Claude Sonnet 4.5 --- tools/timeline_editor/index.html | 293 +++++++++++++++++++++++++++++++++++---- 1 file changed, 269 insertions(+), 24 deletions(-) (limited to 'tools/timeline_editor/index.html') diff --git a/tools/timeline_editor/index.html b/tools/timeline_editor/index.html index 62b426f..c9385ad 100644 --- a/tools/timeline_editor/index.html +++ b/tools/timeline_editor/index.html @@ -106,17 +106,24 @@ background: #252526; border-radius: 8px; padding: 20px; - overflow-x: auto; - overflow-y: auto; position: relative; height: calc(100vh - 280px); min-height: 500px; + display: flex; + flex-direction: column; + } + + .timeline-content { + flex: 1; + overflow-x: auto; + overflow-y: auto; + position: relative; /* Hide scrollbars while keeping scroll functionality */ scrollbar-width: none; /* Firefox */ -ms-overflow-style: none; /* IE/Edge */ } - .timeline-container::-webkit-scrollbar { + .timeline-content::-webkit-scrollbar { display: none; /* Chrome/Safari/Opera */ } @@ -126,24 +133,57 @@ border-left: 2px solid #3c3c3c; } + .sticky-header { + position: relative; + background: #252526; + z-index: 100; + padding-bottom: 10px; + border-bottom: 2px solid #3c3c3c; + flex-shrink: 0; + } + + .playback-controls { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 0; + } + + #playPauseBtn { + width: 60px; + padding: 8px 12px; + } + #waveformCanvas { position: relative; height: 80px; width: 100%; - margin-bottom: 10px; background: rgba(0, 0, 0, 0.3); border-radius: 4px; cursor: crosshair; } - .time-markers { - position: sticky; + .playback-indicator { + position: absolute; top: 0; + width: 2px; + height: 100%; + background: #f48771; + box-shadow: 0 0 4px rgba(244, 135, 113, 0.8); + pointer-events: none; + z-index: 90; + display: none; + } + + .playback-indicator.playing { + display: block; + } + + .time-markers { + position: relative; height: 30px; - margin-bottom: 10px; + margin-top: 10px; border-bottom: 1px solid #3c3c3c; - background: #252526; - z-index: 100; } .time-marker { @@ -488,9 +528,18 @@
- -
-
+ +
+
+
+
@@ -521,10 +570,18 @@ let handleType = null; // 'left' or 'right' let audioBuffer = null; // Decoded audio data let audioDuration = 0; // Duration in seconds + let audioSource = null; // Current playback source + let audioContext = null; // Audio context for playback + let isPlaying = false; + let playbackStartTime = 0; // When playback started (audioContext.currentTime) + let playbackOffset = 0; // Offset into audio (seconds) + let animationFrameId = null; + let lastExpandedSeqIndex = -1; // DOM elements const timeline = document.getElementById('timeline'); const timelineContainer = document.querySelector('.timeline-container'); + const timelineContent = document.getElementById('timelineContent'); const fileInput = document.getElementById('fileInput'); const saveBtn = document.getElementById('saveBtn'); const audioInput = document.getElementById('audioInput'); @@ -539,6 +596,10 @@ const zoomSlider = document.getElementById('zoomSlider'); const zoomLevel = document.getElementById('zoomLevel'); const stats = document.getElementById('stats'); + const playPauseBtn = document.getElementById('playPauseBtn'); + const playbackControls = document.getElementById('playbackControls'); + const playbackTime = document.getElementById('playbackTime'); + const playbackIndicator = document.getElementById('playbackIndicator'); // Parser: timeline.seq → JavaScript objects // Format specification: doc/SEQUENCE.md @@ -672,12 +733,15 @@ async function loadAudioFile(file) { try { const arrayBuffer = await file.arrayBuffer(); - const audioContext = new (window.AudioContext || window.webkitAudioContext)(); + if (!audioContext) { + audioContext = new (window.AudioContext || window.webkitAudioContext)(); + } audioBuffer = await audioContext.decodeAudioData(arrayBuffer); audioDuration = audioBuffer.duration; renderWaveform(); waveformCanvas.style.display = 'block'; + playbackControls.style.display = 'flex'; clearAudioBtn.disabled = false; showMessage(`Audio loaded: ${audioDuration.toFixed(2)}s`, 'success'); @@ -761,14 +825,147 @@ } function clearAudio() { + stopPlayback(); audioBuffer = null; audioDuration = 0; waveformCanvas.style.display = 'none'; + playbackControls.style.display = 'none'; clearAudioBtn.disabled = true; renderTimeline(); showMessage('Audio cleared', 'success'); } + // Playback functions + function startPlayback() { + if (!audioBuffer || !audioContext) return; + + // Resume audio context if suspended + if (audioContext.state === 'suspended') { + audioContext.resume(); + } + + // Create and start audio source + audioSource = audioContext.createBufferSource(); + audioSource.buffer = audioBuffer; + audioSource.connect(audioContext.destination); + audioSource.start(0, playbackOffset); + + playbackStartTime = audioContext.currentTime; + isPlaying = true; + playPauseBtn.textContent = '⏸ Pause'; + playbackIndicator.classList.add('playing'); + + // Start animation loop + updatePlaybackPosition(); + + audioSource.onended = () => { + if (isPlaying) { + stopPlayback(); + } + }; + } + + function stopPlayback() { + if (audioSource) { + try { + audioSource.stop(); + } catch (e) { + // Already stopped + } + audioSource = null; + } + + if (animationFrameId) { + cancelAnimationFrame(animationFrameId); + animationFrameId = null; + } + + if (isPlaying) { + // Save current position for resume + const elapsed = audioContext.currentTime - playbackStartTime; + playbackOffset = Math.min(playbackOffset + elapsed, audioDuration); + } + + isPlaying = false; + playPauseBtn.textContent = '▶ Play'; + playbackIndicator.classList.remove('playing'); + } + + function updatePlaybackPosition() { + if (!isPlaying) return; + + const elapsed = audioContext.currentTime - playbackStartTime; + const currentTime = playbackOffset + elapsed; + + // Update time display + playbackTime.textContent = `${currentTime.toFixed(2)}s`; + + // Convert to beats for position calculation + const currentBeats = currentTime * bpm / 60.0; + + // Update playback indicator position + const indicatorX = currentBeats * pixelsPerSecond; + playbackIndicator.style.left = `${indicatorX}px`; + + // Auto-scroll timeline to follow playback + const viewportWidth = timelineContent.clientWidth; + const scrollX = timelineContent.scrollLeft; + const relativeX = indicatorX - scrollX; + + // Keep indicator in middle third of viewport + if (relativeX < viewportWidth * 0.33 || relativeX > viewportWidth * 0.67) { + timelineContent.scrollLeft = indicatorX - viewportWidth * 0.5; + } + + // Auto-expand/collapse sequences + expandSequenceAtTime(currentBeats); + + // Continue animation + animationFrameId = requestAnimationFrame(updatePlaybackPosition); + } + + function expandSequenceAtTime(currentBeats) { + // Find which sequence is active at current time + let activeSeqIndex = -1; + for (let i = 0; i < sequences.length; i++) { + const seq = sequences[i]; + const seqEndBeats = seq.startTime + (seq.effects.length > 0 + ? Math.max(...seq.effects.map(e => e.endTime)) + : 0); + + if (currentBeats >= seq.startTime && currentBeats <= seqEndBeats) { + activeSeqIndex = i; + break; + } + } + + // Changed sequence - collapse old, expand new + if (activeSeqIndex !== lastExpandedSeqIndex) { + // Collapse previous sequence + if (lastExpandedSeqIndex >= 0 && lastExpandedSeqIndex < sequences.length) { + sequences[lastExpandedSeqIndex]._collapsed = true; + } + + // Expand new sequence + if (activeSeqIndex >= 0) { + sequences[activeSeqIndex]._collapsed = false; + lastExpandedSeqIndex = activeSeqIndex; + + // Flash animation + const seqDivs = timeline.querySelectorAll('.sequence'); + if (seqDivs[activeSeqIndex]) { + seqDivs[activeSeqIndex].classList.add('active-flash'); + setTimeout(() => { + seqDivs[activeSeqIndex]?.classList.remove('active-flash'); + }, 600); + } + } + + // Re-render to show collapse/expand changes + renderTimeline(); + } + } + // Render timeline function renderTimeline() { timeline.innerHTML = ''; @@ -1278,6 +1475,45 @@ audioInput.value = ''; // Reset file input }); + playPauseBtn.addEventListener('click', () => { + if (isPlaying) { + stopPlayback(); + } else { + // Reset to beginning if at end + if (playbackOffset >= audioDuration) { + playbackOffset = 0; + } + startPlayback(); + } + }); + + // Waveform click to seek + waveformCanvas.addEventListener('click', (e) => { + if (!audioBuffer) return; + + const rect = waveformCanvas.getBoundingClientRect(); + const clickX = e.clientX - rect.left; + const audioDurationBeats = audioDuration * bpm / 60.0; + const clickBeats = (clickX / waveformCanvas.width) * audioDurationBeats; + const clickTime = clickBeats * 60.0 / bpm; + + const wasPlaying = isPlaying; + if (wasPlaying) { + stopPlayback(); + } + + playbackOffset = Math.max(0, Math.min(clickTime, audioDuration)); + + if (wasPlaying) { + startPlayback(); + } else { + // Update display even when paused + playbackTime.textContent = `${playbackOffset.toFixed(2)}s`; + const indicatorX = (playbackOffset * bpm / 60.0) * pixelsPerSecond; + playbackIndicator.style.left = `${indicatorX}px`; + } + }); + addSequenceBtn.addEventListener('click', () => { sequences.push({ type: 'sequence', @@ -1322,7 +1558,7 @@ const newIndex = sequences.indexOf(currentActiveSeq); if (newIndex >= 0 && sequences[newIndex]._yPosition !== undefined) { // Scroll to keep it in view - timelineContainer.scrollTop = sequences[newIndex]._yPosition; + timelineContent.scrollTop = sequences[newIndex]._yPosition; lastActiveSeqIndex = newIndex; } } @@ -1384,18 +1620,27 @@ updateProperties(); }); + // Keyboard shortcuts + document.addEventListener('keydown', (e) => { + // Spacebar: play/pause (if audio loaded) + if (e.code === 'Space' && audioBuffer) { + e.preventDefault(); + playPauseBtn.click(); + } + }); + // Mouse wheel: zoom (with Ctrl/Cmd) or diagonal scroll - timelineContainer.addEventListener('wheel', (e) => { + timelineContent.addEventListener('wheel', (e) => { e.preventDefault(); // Zoom mode: Ctrl/Cmd + wheel if (e.ctrlKey || e.metaKey) { - // Get mouse position relative to timeline container - const rect = timelineContainer.getBoundingClientRect(); + // Get mouse position relative to timeline content + const rect = timelineContent.getBoundingClientRect(); const mouseX = e.clientX - rect.left; // Mouse X in viewport coordinates // Calculate time position under cursor BEFORE zoom - const scrollLeft = timelineContainer.scrollLeft; + const scrollLeft = timelineContent.scrollLeft; const timeUnderCursor = (scrollLeft + mouseX) / pixelsPerSecond; // Calculate new zoom level @@ -1419,17 +1664,17 @@ // Adjust scroll position so time under cursor stays in same place // After zoom: new_scrollLeft = time_under_cursor * newPixelsPerSecond - mouseX const newScrollLeft = timeUnderCursor * newPixelsPerSecond - mouseX; - timelineContainer.scrollLeft = newScrollLeft; + timelineContent.scrollLeft = newScrollLeft; } return; } // Normal mode: diagonal scroll - timelineContainer.scrollLeft += e.deltaY; + timelineContent.scrollLeft += e.deltaY; // Calculate current time position with 10% headroom for visual comfort - const currentScrollLeft = timelineContainer.scrollLeft; - const viewportWidth = timelineContainer.clientWidth; + const currentScrollLeft = timelineContent.scrollLeft; + const viewportWidth = timelineContent.clientWidth; const slack = (viewportWidth / pixelsPerSecond) * 0.1; // 10% of viewport width in seconds const currentTime = (currentScrollLeft / pixelsPerSecond) + slack; @@ -1461,12 +1706,12 @@ // Smooth vertical scroll to bring target sequence to top of viewport const targetScrollTop = sequences[targetSeqIndex]?._yPosition || 0; - const currentScrollTop = timelineContainer.scrollTop; + const currentScrollTop = timelineContent.scrollTop; const scrollDiff = targetScrollTop - currentScrollTop; // Smooth transition (don't jump instantly) if (Math.abs(scrollDiff) > 5) { - timelineContainer.scrollTop += scrollDiff * 0.3; + timelineContent.scrollTop += scrollDiff * 0.3; } }, { passive: false }); -- cgit v1.2.3