diff options
Diffstat (limited to 'tools/timeline_editor/index.html')
| -rw-r--r-- | tools/timeline_editor/index.html | 543 |
1 files changed, 444 insertions, 99 deletions
diff --git a/tools/timeline_editor/index.html b/tools/timeline_editor/index.html index db71beb..c9385ad 100644 --- a/tools/timeline_editor/index.html +++ b/tools/timeline_editor/index.html @@ -3,7 +3,7 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>Timeline Editor - demo.seq</title> + <title>Timeline Editor - timeline.seq</title> <style> * { margin: 0; @@ -33,11 +33,17 @@ padding: 20px; border-radius: 8px; margin-bottom: 20px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + flex-wrap: wrap; } h1 { - margin-bottom: 10px; + margin: 0; color: #4ec9b0; + white-space: nowrap; } .controls { @@ -45,7 +51,6 @@ gap: 10px; flex-wrap: wrap; align-items: center; - margin-bottom: 20px; } .checkbox-label { @@ -101,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 */ } @@ -121,20 +133,56 @@ 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; } + .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; } @@ -155,6 +203,17 @@ background: #3c3c3c; } + .time-marker::after { + content: ''; + position: absolute; + left: 0; + top: 30px; + width: 1px; + height: 10000px; + background: rgba(60, 60, 60, 0.2); + pointer-events: none; + } + .sequence { position: absolute; background: #264f78; @@ -190,6 +249,36 @@ } } + .sequence-header { + position: absolute; + top: 0; + left: 0; + right: 0; + padding: 8px; + z-index: 5; + cursor: pointer; + user-select: none; + } + + .sequence-header-name { + font-size: 14px; + font-weight: bold; + color: #ffffff; + } + + .sequence:not(.collapsed) .sequence-header-name { + display: none; + } + + .sequence.collapsed { + overflow: hidden !important; + background: #1a3a4a !important; + } + + .sequence.collapsed .sequence-name { + display: none !important; + } + .sequence-name { position: absolute; top: 50%; @@ -283,8 +372,8 @@ .properties-panel { position: fixed; - top: 80px; - right: 20px; + bottom: 20px; + left: 20px; width: 350px; max-height: 80vh; background: #252526; @@ -297,7 +386,7 @@ } .properties-panel.collapsed { - transform: translateX(370px); + transform: translateY(calc(100% + 40px)); } .panel-header { @@ -331,8 +420,8 @@ .panel-collapse-btn { position: fixed; - top: 80px; - right: 20px; + bottom: 20px; + left: 20px; background: #252526; border: 1px solid #858585; color: #d4d4d4; @@ -408,49 +497,57 @@ <div class="container"> <header> <h1>📊 Timeline Editor</h1> - <p>Interactive editor for demo.seq files</p> + <div class="controls"> + <label class="file-label"> + 📂 Load timeline.seq + <input type="file" id="fileInput" accept=".seq"> + </label> + <button id="saveBtn" disabled>💾 Save timeline.seq</button> + <label class="file-label"> + 🎵 Load Audio (WAV) + <input type="file" id="audioInput" accept=".wav"> + </label> + <button id="clearAudioBtn" disabled>✖ Clear Audio</button> + <button id="addSequenceBtn" disabled>➕ Add Sequence</button> + <button id="deleteBtn" disabled>🗑️ Delete Selected</button> + <button id="reorderBtn" disabled>🔄 Re-order by Time</button> + </div> </header> - <div class="controls"> - <label class="file-label"> - 📂 Load demo.seq - <input type="file" id="fileInput" accept=".seq"> - </label> - <button id="saveBtn" disabled>💾 Save demo.seq</button> - <label class="file-label"> - 🎵 Load Audio (WAV) - <input type="file" id="audioInput" accept=".wav"> - </label> - <button id="clearAudioBtn" disabled>✖ Clear Audio</button> - <button id="addSequenceBtn" disabled>➕ Add Sequence</button> - <button id="deleteBtn" disabled>🗑️ Delete Selected</button> - <button id="reorderBtn" disabled>🔄 Re-order by Time</button> - </div> - <div class="zoom-controls"> <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">Pixels per second: <span id="pixelsPerSec">100</span></label> + <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> <label class="checkbox-label" style="margin-left: 20px"> - <input type="checkbox" id="showBeatsCheckbox"> - Show Beats (BPM: <span id="currentBPM">120</span>) + <input type="checkbox" id="showBeatsCheckbox" checked> + Show Beats </label> </div> <div id="messageArea"></div> <div class="timeline-container"> - <canvas id="waveformCanvas" style="display: none;"></canvas> - <div class="time-markers" id="timeMarkers"></div> - <div class="timeline" id="timeline"></div> + <div class="sticky-header"> + <div class="playback-controls" id="playbackControls" style="display: none;"> + <button id="playPauseBtn">▶ Play</button> + <span id="playbackTime">0.00s</span> + </div> + <canvas id="waveformCanvas" style="display: none;"></canvas> + <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> - <button class="panel-collapse-btn" id="panelCollapseBtn">◀ Properties</button> + <button class="panel-collapse-btn" id="panelCollapseBtn">▲ Properties</button> <div class="properties-panel" id="propertiesPanel" style="display: none;"> <div class="panel-header"> <h2>Properties</h2> - <button class="panel-toggle" id="panelToggle">▶ Collapse</button> + <button class="panel-toggle" id="panelToggle">▼ Collapse</button> </div> <div id="propertiesContent"></div> </div> @@ -464,7 +561,7 @@ let currentFile = null; let selectedItem = null; let pixelsPerSecond = 100; - let showBeats = false; + let showBeats = true; let bpm = 120; let isDragging = false; let dragOffset = { x: 0, y: 0 }; @@ -473,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'); @@ -490,10 +595,13 @@ const messageArea = document.getElementById('messageArea'); const zoomSlider = document.getElementById('zoomSlider'); const zoomLevel = document.getElementById('zoomLevel'); - const pixelsPerSecLabel = document.getElementById('pixelsPerSec'); 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: demo.seq → JavaScript objects + // Parser: timeline.seq → JavaScript objects // Format specification: doc/SEQUENCE.md function parseSeqFile(content) { const sequences = []; @@ -502,13 +610,18 @@ let bpm = 120; // Default BPM let currentPriority = 0; // Track priority for + = - modifiers - // Helper: Convert time notation to seconds + // Helper: Parse time notation (returns beats) function parseTime(timeStr) { + if (timeStr.endsWith('s')) { + // Explicit seconds: "2.5s" = convert to beats + const seconds = parseFloat(timeStr.slice(0, -1)); + return seconds * bpm / 60.0; + } if (timeStr.endsWith('b')) { - // Beat notation: "4b" = 4 beats - const beats = parseFloat(timeStr.slice(0, -1)); - return beats * (60.0 / bpm); + // Explicit beats: "4b" = 4 beats + return parseFloat(timeStr.slice(0, -1)); } + // Default: beats return parseFloat(timeStr); } @@ -551,7 +664,8 @@ startTime: parseTime(seqMatch[1]), priority: parseInt(seqMatch[2]), effects: [], - name: seqMatch[3] || '' + name: seqMatch[3] || '', + _collapsed: true }; sequences.push(currentSequence); currentPriority = -1; // Reset effect priority for new sequence @@ -587,7 +701,7 @@ return { sequences, bpm }; } - // Serializer: JavaScript objects → demo.seq + // Serializer: JavaScript objects → timeline.seq (outputs beats) function serializeSeqFile(sequences) { let output = '# Demo Timeline\n'; output += '# Generated by Timeline Editor\n'; @@ -619,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'); @@ -641,8 +758,9 @@ const canvas = waveformCanvas; const ctx = canvas.getContext('2d'); - // Set canvas size based on audio duration and zoom - const canvasWidth = audioDuration * pixelsPerSecond; + // Set canvas size based on audio duration (convert to beats) and zoom + const audioDurationBeats = audioDuration * bpm / 60.0; + const canvasWidth = audioDurationBeats * pixelsPerSecond; const canvasHeight = 80; // Set actual canvas resolution (for sharp rendering) @@ -707,24 +825,157 @@ } 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 = ''; const timeMarkers = document.getElementById('timeMarkers'); timeMarkers.innerHTML = ''; - // Calculate max time - let maxTime = 30; // Default 30 seconds + // Calculate max time (in beats) + let maxTime = 60; // Default 60 beats (15 bars) for (const seq of sequences) { - const seqEnd = seq.startTime + 10; // Default sequence duration + const seqEnd = seq.startTime + 16; // Default 4 bars maxTime = Math.max(maxTime, seqEnd); for (const effect of seq.effects) { @@ -734,7 +985,8 @@ // Extend timeline to fit audio if loaded if (audioDuration > 0) { - maxTime = Math.max(maxTime, audioDuration); + const audioBeats = audioDuration * bpm / 60.0; + maxTime = Math.max(maxTime, audioBeats); } // Render time markers @@ -742,23 +994,22 @@ timeline.style.width = `${timelineWidth}px`; if (showBeats) { - // Show beats - const beatDuration = 60.0 / bpm; // seconds per beat - const maxBeats = Math.ceil(maxTime / beatDuration); - for (let beat = 0; beat <= maxBeats; beat++) { - const timeSec = beat * beatDuration; + // Show beats (default) + for (let beat = 0; beat <= maxTime; beat += 4) { const marker = document.createElement('div'); marker.className = 'time-marker'; - marker.style.left = `${timeSec * pixelsPerSecond}px`; + marker.style.left = `${beat * pixelsPerSecond}px`; marker.textContent = `${beat}b`; timeMarkers.appendChild(marker); } } else { // Show seconds - for (let t = 0; t <= maxTime; t += 1) { + const maxSeconds = maxTime * 60.0 / bpm; + for (let t = 0; t <= maxSeconds; t += 1) { + const beatPos = t * bpm / 60.0; const marker = document.createElement('div'); marker.className = 'time-marker'; - marker.style.left = `${t * pixelsPerSecond}px`; + marker.style.left = `${beatPos * pixelsPerSecond}px`; marker.textContent = `${t}s`; timeMarkers.appendChild(marker); } @@ -786,20 +1037,53 @@ const seqVisualWidth = seqVisualEnd - seqVisualStart; + // Initialize collapsed state if undefined + if (seq._collapsed === undefined) { + seq._collapsed = false; + } + // Calculate sequence height based on number of effects (stacked vertically) const numEffects = seq.effects.length; const effectSpacing = 30; - const seqHeight = Math.max(70, 20 + numEffects * effectSpacing + 5); + const fullHeight = Math.max(70, 20 + numEffects * effectSpacing + 5); + const seqHeight = seq._collapsed ? 35 : fullHeight; seqDiv.style.left = `${seqVisualStart * pixelsPerSecond}px`; seqDiv.style.top = `${cumulativeY}px`; seqDiv.style.width = `${seqVisualWidth * pixelsPerSecond}px`; seqDiv.style.height = `${seqHeight}px`; + seqDiv.style.minHeight = `${seqHeight}px`; + seqDiv.style.maxHeight = `${seqHeight}px`; // Store Y position for this sequence (used by effects and scroll) seq._yPosition = cumulativeY; cumulativeY += seqHeight + sequenceGap; + // Create sequence header (double-click to collapse) + 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}`; + + seqHeaderDiv.appendChild(headerName); + + // Prevent drag on header + seqHeaderDiv.addEventListener('mousedown', (e) => { + e.stopPropagation(); + }); + + // Double-click to toggle collapse + seqHeaderDiv.addEventListener('dblclick', (e) => { + e.stopPropagation(); + e.preventDefault(); + seq._collapsed = !seq._collapsed; + renderTimeline(); + }); + + seqDiv.appendChild(seqHeaderDiv); + // Create sequence name overlay (large, centered, fades on hover) const seqNameDiv = document.createElement('div'); seqNameDiv.className = 'sequence-name'; @@ -807,6 +1091,11 @@ seqDiv.appendChild(seqNameDiv); + // Apply collapsed state + if (seq._collapsed) { + seqDiv.classList.add('collapsed'); + } + if (selectedItem && selectedItem.type === 'sequence' && selectedItem.index === seqIndex) { seqDiv.classList.add('selected'); } @@ -827,7 +1116,8 @@ timeline.appendChild(seqDiv); - // Render effects within sequence + // Render effects within sequence (skip if collapsed) + if (!seq._collapsed) { seq.effects.forEach((effect, effectIndex) => { const effectDiv = document.createElement('div'); effectDiv.className = 'effect'; @@ -842,16 +1132,14 @@ effectDiv.style.width = `${effectWidth}px`; effectDiv.style.height = '26px'; - // Format time display based on mode (for tooltip) - let timeDisplay; - if (showBeats) { - const beatDuration = 60.0 / bpm; - const startBeat = (effect.startTime / beatDuration).toFixed(1); - const endBeat = (effect.endTime / beatDuration).toFixed(1); - timeDisplay = `${startBeat}-${endBeat}b`; - } else { - timeDisplay = `${effect.startTime.toFixed(1)}-${effect.endTime.toFixed(1)}s`; - } + // Format time display (beats primary, seconds in tooltip) + const startBeat = effect.startTime.toFixed(1); + const endBeat = effect.endTime.toFixed(1); + const startSec = (effect.startTime * 60.0 / bpm).toFixed(1); + const endSec = (effect.endTime * 60.0 / bpm).toFixed(1); + const timeDisplay = showBeats + ? `${startBeat}-${endBeat}b (${startSec}-${endSec}s)` + : `${startSec}-${endSec}s (${startBeat}-${endBeat}b)`; // Show only class name, full info on hover effectDiv.innerHTML = ` @@ -894,6 +1182,7 @@ timeline.appendChild(effectDiv); }); + } }); updateStats(); @@ -926,11 +1215,9 @@ const newX = e.clientX - timelineRect.left - dragOffset.x; let newTime = Math.max(0, newX / pixelsPerSecond); - // Snap to beat when in beat mode + // Snap to beat when enabled if (showBeats) { - const beatDuration = 60.0 / bpm; - const nearestBeat = Math.round(newTime / beatDuration); - newTime = nearestBeat * beatDuration; + newTime = Math.round(newTime); } if (selectedItem.type === 'sequence') { @@ -977,11 +1264,9 @@ const newX = e.clientX - timelineRect.left; let newTime = Math.max(0, newX / pixelsPerSecond); - // Snap to beat when in beat mode + // Snap to beat when enabled if (showBeats) { - const beatDuration = 60.0 / bpm; - const nearestBeat = Math.round(newTime / beatDuration); - newTime = nearestBeat * beatDuration; + newTime = Math.round(newTime); } const seq = sequences[selectedItem.seqIndex]; @@ -1153,6 +1438,7 @@ sequences = parsed.sequences; bpm = parsed.bpm; document.getElementById('currentBPM').textContent = bpm; + document.getElementById('bpmSlider').value = bpm; renderTimeline(); saveBtn.disabled = false; addSequenceBtn.disabled = false; @@ -1172,7 +1458,7 @@ const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; - a.download = currentFile || 'demo.seq'; + a.download = currentFile || 'timeline.seq'; a.click(); URL.revokeObjectURL(url); showMessage('File saved', 'success'); @@ -1189,12 +1475,52 @@ 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', startTime: 0, priority: 0, - effects: [] + effects: [], + _collapsed: true }); renderTimeline(); showMessage('New sequence added', 'success'); @@ -1232,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; } } @@ -1245,13 +1571,24 @@ const zoom = parseInt(e.target.value); pixelsPerSecond = zoom; zoomLevel.textContent = `${zoom}%`; - pixelsPerSecLabel.textContent = zoom; if (audioBuffer) { renderWaveform(); // Re-render waveform at new zoom } renderTimeline(); }); + // BPM slider + const bpmSlider = document.getElementById('bpmSlider'); + const currentBPMDisplay = document.getElementById('currentBPM'); + bpmSlider.addEventListener('input', (e) => { + bpm = parseInt(e.target.value); + currentBPMDisplay.textContent = bpm; + if (audioBuffer) { + renderWaveform(); + } + renderTimeline(); + }); + // Beats toggle const showBeatsCheckbox = document.getElementById('showBeatsCheckbox'); showBeatsCheckbox.addEventListener('change', (e) => { @@ -1266,13 +1603,13 @@ panelToggle.addEventListener('click', () => { propertiesPanel.classList.add('collapsed'); panelCollapseBtn.classList.add('visible'); - panelToggle.textContent = '◀ Expand'; + panelToggle.textContent = '▲ Expand'; }); panelCollapseBtn.addEventListener('click', () => { propertiesPanel.classList.remove('collapsed'); panelCollapseBtn.classList.remove('visible'); - panelToggle.textContent = '▶ Collapse'; + panelToggle.textContent = '▼ Collapse'; }); // Click outside to deselect @@ -1283,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 @@ -1308,7 +1654,6 @@ // Update zoom slider and labels zoomSlider.value = pixelsPerSecond; zoomLevel.textContent = `${pixelsPerSecond}%`; - pixelsPerSecLabel.textContent = pixelsPerSecond; // Re-render waveform and timeline at new zoom if (audioBuffer) { @@ -1319,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; @@ -1361,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 }); |
