diff options
Diffstat (limited to 'tools/timeline_editor/index.html')
| -rw-r--r-- | tools/timeline_editor/index.html | 446 |
1 files changed, 164 insertions, 282 deletions
diff --git a/tools/timeline_editor/index.html b/tools/timeline_editor/index.html index eca7b97..bc7f2a0 100644 --- a/tools/timeline_editor/index.html +++ b/tools/timeline_editor/index.html @@ -42,14 +42,16 @@ .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; } + .waveform-cursor { position: absolute; top: 0; bottom: 0; width: 1px; background: rgba(78, 201, 176, 0.6); pointer-events: none; z-index: 3; display: none; } + .waveform-tooltip { position: absolute; background: rgba(30, 30, 30, 0.95); color: var(--text-primary); padding: 6px 10px; border-radius: 4px; font-size: 12px; pointer-events: none; z-index: 4; display: none; white-space: nowrap; border: 1px solid var(--border-color); box-shadow: 0 2px 8px rgba(0,0,0,0.3); } - .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: 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); } @@ -125,7 +127,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> @@ -143,22 +145,24 @@ <div id="playbackControls" style="display: none; margin-left: 20px; gap: 10px; align-items: center;"> <span id="playbackTime">0.00s (0.00b)</span> <button id="playPauseBtn">▶ Play</button> + <button id="replayBtn" disabled>↻ Replay</button> </div> </div> <div id="messageArea"></div> - <div class="timeline-container"> + <div class="timeline-container" id="timelineContainer"> + <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 class="waveform-cursor" id="waveformCursor"></div> + <div class="waveform-tooltip" id="waveformTooltip"></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> @@ -176,25 +180,47 @@ <div class="stats" id="stats"></div> </div> - <script> + <script type="module"> + import { ViewportController } from './timeline-viewport.js'; + import { PlaybackController } from './timeline-playback.js'; + // Constants const POST_PROCESS_EFFECTS = new Set(['FadeEffect', 'FlashEffect', 'GaussianBlurEffect', 'SolarizeEffect', 'VignetteEffect', 'ChromaAberrationEffect', 'DistortEffect', 'ThemeModulationEffect', 'CNNEffect', 'CNNv2Effect']); + const SEQUENCE_GAP = 10; + const SEQUENCE_DEFAULT_WIDTH = 10; + const SEQUENCE_DEFAULT_DURATION = 16; + 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; + + // BPM computation helper + const computeBPMValues = (bpm) => ({ + secondsPerBeat: 60.0 / bpm, + beatsPerSecond: bpm / 60.0 + }); + // State + const DEFAULT_BPM = 120; const state = { - sequences: [], currentFile: null, selectedItem: null, pixelsPerSecond: 100, - showBeats: true, quantizeUnit: 1, bpm: 120, isDragging: false, dragOffset: { x: 0, y: 0 }, + sequences: [], currentFile: null, selectedItem: null, pixelsPerBeat: 100, + showBeats: true, quantizeUnit: 1, bpm: DEFAULT_BPM, isDragging: false, dragOffset: { x: 0, y: 0 }, lastActiveSeqIndex: -1, isDraggingHandle: false, handleType: null, handleDragOffset: 0, - audioBuffer: null, audioDuration: 0, audioSource: null, audioContext: null, - isPlaying: false, playbackStartTime: 0, playbackOffset: 0, animationFrameId: null, - lastExpandedSeqIndex: -1, dragMoved: false + audioBuffer: null, audioDurationSeconds: 0, audioSource: null, audioContext: null, + isPlaying: false, playbackStartTime: 0, playbackOffset: 0, playStartPosition: 0, animationFrameId: null, + lastExpandedSeqIndex: -1, dragMoved: false, + ...computeBPMValues(DEFAULT_BPM) }; // DOM const dom = { timeline: document.getElementById('timeline'), + timelineContainer: document.getElementById('timelineContainer'), timelineContent: document.getElementById('timelineContent'), fileInput: document.getElementById('fileInput'), saveBtn: document.getElementById('saveBtn'), @@ -215,15 +241,17 @@ stats: document.getElementById('stats'), playbackControls: document.getElementById('playbackControls'), playPauseBtn: document.getElementById('playPauseBtn'), + replayBtn: document.getElementById('replayBtn'), playbackTime: document.getElementById('playbackTime'), playbackIndicator: document.getElementById('playbackIndicator'), - waveformPlaybackIndicator: document.getElementById('waveformPlaybackIndicator'), panelToggle: document.getElementById('panelToggle'), panelCollapseBtn: document.getElementById('panelCollapseBtn'), bpmSlider: document.getElementById('bpmSlider'), currentBPM: document.getElementById('currentBPM'), showBeatsCheckbox: document.getElementById('showBeatsCheckbox'), - quantizeSelect: document.getElementById('quantizeSelect') + quantizeSelect: document.getElementById('quantizeSelect'), + waveformCursor: document.getElementById('waveformCursor'), + waveformTooltip: document.getElementById('waveformTooltip') }; // Parser @@ -232,7 +260,7 @@ let currentSequence = null, bpm = 120, currentPriority = 0; const parseTime = (timeStr) => { - if (timeStr.endsWith('s')) return parseFloat(timeStr.slice(0, -1)) * bpm / 60.0; + if (timeStr.endsWith('s')) return parseFloat(timeStr.slice(0, -1)) * bpm / 60.0; // Local bpm during parsing if (timeStr.endsWith('b')) return parseFloat(timeStr.slice(0, -1)); return parseFloat(timeStr); }; @@ -278,14 +306,25 @@ } // Helpers - const beatsToTime = (beats) => beats * 60.0 / state.bpm; - const timeToBeats = (seconds) => seconds * state.bpm / 60.0; + const updateBPM = (newBPM) => { + state.bpm = newBPM; + Object.assign(state, computeBPMValues(newBPM)); + }; + const beatsToTime = (beats) => beats * state.secondsPerBeat; + const timeToBeats = (seconds) => seconds * state.beatsPerSecond; const beatRange = (start, end) => { const s = start.toFixed(1), e = end.toFixed(1); const ss = beatsToTime(start).toFixed(1), es = beatsToTime(end).toFixed(1); return state.showBeats ? `${s}-${e}b (${ss}-${es}s)` : `${ss}-${es}s (${s}-${e}b)`; }; + // Utilities + function showMessage(text, type) { + if (type === 'error') console.error(text); + dom.messageArea.innerHTML = `<div class="${type}">${text}</div>`; + setTimeout(() => dom.messageArea.innerHTML = '', 3000); + } + function detectConflicts(seq) { const conflicts = new Set(); const priorityGroups = {}; @@ -319,61 +358,16 @@ return output; } - // Audio - async function loadAudioFile(file) { - try { - const arrayBuffer = await file.arrayBuffer(); - if (!state.audioContext) state.audioContext = new (window.AudioContext || window.webkitAudioContext)(); - state.audioBuffer = await state.audioContext.decodeAudioData(arrayBuffer); - state.audioDuration = state.audioBuffer.duration; - renderWaveform(); - dom.playbackControls.style.display = 'flex'; - dom.clearAudioBtn.disabled = false; - showMessage(`Audio loaded: ${state.audioDuration.toFixed(2)}s`, 'success'); - renderTimeline(); - } catch (err) { - showMessage(`Error loading audio: ${err.message}`, 'error'); - } - } - - function renderWaveform() { - if (!state.audioBuffer) return; - const canvas = dom.waveformCanvas, ctx = canvas.getContext('2d'); - 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); - const samplesPerPixel = Math.ceil(channelData.length / w); - const centerY = h / 2, amplitudeScale = h * 0.4; - - ctx.strokeStyle = '#4ec9b0'; ctx.lineWidth = 1; ctx.beginPath(); - for (let x = 0; x < w; x++) { - const start = Math.floor(x * samplesPerPixel); - const end = Math.min(start + samplesPerPixel, channelData.length); - let min = 1.0, max = -1.0; - for (let i = start; i < end; i++) { - min = Math.min(min, channelData[i]); - max = Math.max(max, channelData[i]); - } - const yMin = centerY - min * amplitudeScale, yMax = centerY - max * amplitudeScale; - x === 0 ? ctx.moveTo(x, yMin) : ctx.lineTo(x, yMin); - ctx.lineTo(x, yMax); - } - ctx.stroke(); - ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)'; - ctx.beginPath(); ctx.moveTo(0, centerY); ctx.lineTo(w, centerY); ctx.stroke(); - } + // Controllers - initialized after DOM setup + let viewportController, playbackController; function computeCPULoad() { - if (state.sequences.length === 0) return { maxTime: 60, loads: [], conflicts: [] }; - let maxTime = Math.max(60, ...state.sequences.flatMap(seq => + if (state.sequences.length === 0) return { maxTimeBeats: 60, loads: [], conflicts: [] }; + let maxTimeBeats = Math.max(60, ...state.sequences.flatMap(seq => seq.effects.map(eff => seq.startTime + eff.endTime))); - if (state.audioDuration > 0) maxTime = Math.max(maxTime, timeToBeats(state.audioDuration)); + if (state.audioDurationSeconds > 0) maxTimeBeats = Math.max(maxTimeBeats, timeToBeats(state.audioDurationSeconds)); - const resolution = 0.1, numSamples = Math.ceil(maxTime / resolution); + const resolution = 0.1, numSamples = Math.ceil(maxTimeBeats / resolution); const loads = new Array(numSamples).fill(0); const conflicts = new Array(numSamples).fill(false); @@ -424,19 +418,19 @@ }); }); - return { maxTime, loads, conflicts, resolution }; + return { maxTimeBeats, loads, conflicts, resolution }; } function renderCPULoad() { const canvas = dom.cpuLoadCanvas, ctx = canvas.getContext('2d'); - const { maxTime, loads, conflicts, resolution } = computeCPULoad(); - const w = maxTime * state.pixelsPerSecond, h = 10; + const { maxTimeBeats, loads, conflicts, resolution } = computeCPULoad(); + const w = maxTimeBeats * state.pixelsPerBeat, h = 10; canvas.width = w; canvas.height = h; canvas.style.width = `${w}px`; canvas.style.height = `${h}px`; ctx.fillStyle = 'rgba(0, 0, 0, 0.3)'; ctx.fillRect(0, 0, w, h); if (loads.length === 0) return; - const barWidth = resolution * state.pixelsPerSecond; + const barWidth = resolution * state.pixelsPerBeat; loads.forEach((load, i) => { if (load === 0) return; const n = Math.min(load / 8, 1.0); @@ -449,123 +443,52 @@ }); } - function clearAudio() { - stopPlayback(); state.audioBuffer = null; state.audioDuration = 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'); - } - - async function startPlayback() { - if (!state.audioBuffer || !state.audioContext) return; - if (state.audioSource) try { state.audioSource.stop(); } catch (e) {} state.audioSource = null; - if (state.audioContext.state === 'suspended') await state.audioContext.resume(); - try { - state.audioSource = state.audioContext.createBufferSource(); - state.audioSource.buffer = state.audioBuffer; - state.audioSource.connect(state.audioContext.destination); - state.audioSource.start(0, state.playbackOffset); - state.playbackStartTime = state.audioContext.currentTime; - state.isPlaying = true; dom.playPauseBtn.textContent = '⏸ Pause'; - updatePlaybackPosition(); - state.audioSource.onended = () => { if (state.isPlaying) stopPlayback(); }; - } catch (e) { - console.error('Failed to start playback:', e); showMessage('Playback failed: ' + e.message, 'error'); - state.audioSource = null; state.isPlaying = false; - } - } - - function stopPlayback(savePosition = true) { - if (state.audioSource) try { state.audioSource.stop(); } catch (e) {} state.audioSource = null; - if (state.animationFrameId) { cancelAnimationFrame(state.animationFrameId); state.animationFrameId = null; } - if (state.isPlaying && savePosition) { - const elapsed = state.audioContext.currentTime - state.playbackStartTime; - state.playbackOffset = Math.min(state.playbackOffset + elapsed, state.audioDuration); - } - state.isPlaying = false; dom.playPauseBtn.textContent = '▶ Play'; - } - - function updatePlaybackPosition() { - if (!state.isPlaying) return; - const elapsed = state.audioContext.currentTime - state.playbackStartTime; - 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; - expandSequenceAtTime(currentBeats); - state.animationFrameId = requestAnimationFrame(updatePlaybackPosition); - } - - function expandSequenceAtTime(currentBeats) { - let activeSeqIndex = -1; - for (let i = 0; i < state.sequences.length; i++) { - const seq = state.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; } - } - if (activeSeqIndex !== state.lastExpandedSeqIndex) { - const seqDivs = dom.timeline.querySelectorAll('.sequence'); - if (state.lastExpandedSeqIndex >= 0 && seqDivs[state.lastExpandedSeqIndex]) { - seqDivs[state.lastExpandedSeqIndex].classList.remove('active-playing'); - } - if (activeSeqIndex >= 0 && seqDivs[activeSeqIndex]) { - seqDivs[activeSeqIndex].classList.add('active-playing'); - } - state.lastExpandedSeqIndex = activeSeqIndex; - } - } - // Render function renderTimeline() { renderCPULoad(); dom.timeline.innerHTML = ''; document.getElementById('timeMarkers').innerHTML = ''; - let maxTime = 60; + let maxTimeBeats = 60; for (const seq of state.sequences) { - maxTime = Math.max(maxTime, seq.startTime + 16); - for (const effect of seq.effects) maxTime = Math.max(maxTime, seq.startTime + effect.endTime); + maxTimeBeats = Math.max(maxTimeBeats, seq.startTime + SEQUENCE_DEFAULT_DURATION); + for (const effect of seq.effects) maxTimeBeats = Math.max(maxTimeBeats, seq.startTime + effect.endTime); } - if (state.audioDuration > 0) maxTime = Math.max(maxTime, state.audioDuration * state.bpm / 60.0); - const timelineWidth = maxTime * state.pixelsPerSecond; + if (state.audioDurationSeconds > 0) maxTimeBeats = Math.max(maxTimeBeats, state.audioDurationSeconds * state.beatsPerSecond); + const timelineWidth = maxTimeBeats * state.pixelsPerBeat; dom.timeline.style.width = `${timelineWidth}px`; let totalTimelineHeight = 0; const timeMarkers = document.getElementById('timeMarkers'); if (state.showBeats) { - for (let beat = 0; beat <= maxTime; beat += 4) { + for (let beat = 0; beat <= maxTimeBeats; beat += 4) { const marker = document.createElement('div'); - marker.className = 'time-marker'; marker.style.left = `${beat * state.pixelsPerSecond}px`; + marker.className = 'time-marker'; marker.style.left = `${beat * state.pixelsPerBeat}px`; marker.textContent = `${beat}b`; timeMarkers.appendChild(marker); } } else { - const maxSeconds = maxTime * 60.0 / state.bpm; + const maxSeconds = maxTimeBeats * state.secondsPerBeat; for (let t = 0; t <= maxSeconds; t += 1) { - const beatPos = t * state.bpm / 60.0, marker = document.createElement('div'); - marker.className = 'time-marker'; marker.style.left = `${beatPos * state.pixelsPerSecond}px`; + const beatPos = t * state.beatsPerSecond, marker = document.createElement('div'); + marker.className = 'time-marker'; marker.style.left = `${beatPos * state.pixelsPerBeat}px`; 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; - seqDiv.style.left = `${seqVisualStart * state.pixelsPerSecond}px`; + 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.pixelsPerBeat}px`; seqDiv.style.top = `${cumulativeY}px`; - seqDiv.style.width = `${(seqVisualEnd - seqVisualStart) * state.pixelsPerSecond}px`; + seqDiv.style.width = `${(seqVisualEnd - seqVisualStart) * state.pixelsPerBeat}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}`; @@ -590,10 +513,10 @@ if (conflicts.has(effectIndex)) effectDiv.classList.add('conflict'); 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`, - width: `${(effect.endTime - effect.startTime) * state.pixelsPerSecond}px`, - height: '26px' + left: `${(seq.startTime + effect.startTime) * state.pixelsPerBeat}px`, + top: `${seq._yPosition + SEQUENCE_TOP_PADDING + effectIndex * EFFECT_SPACING}px`, + width: `${(effect.endTime - effect.startTime) * state.pixelsPerBeat}px`, + 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) ? @@ -616,7 +539,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(); } @@ -636,13 +558,13 @@ if (!state.isDragging || !state.selectedItem) return; state.dragMoved = true; const containerRect = dom.timelineContent.getBoundingClientRect(); - let newTime = Math.max(0, (e.clientX - containerRect.left + dom.timelineContent.scrollLeft - state.dragOffset.x) / state.pixelsPerSecond); - if (state.quantizeUnit > 0) newTime = Math.round(newTime * state.quantizeUnit) / state.quantizeUnit; - if (state.selectedItem.type === 'sequence') state.sequences[state.selectedItem.index].startTime = newTime; + let newTimeBeats = Math.max(0, (e.clientX - containerRect.left + dom.timelineContent.scrollLeft - state.dragOffset.x) / state.pixelsPerBeat); + if (state.quantizeUnit > 0) newTimeBeats = Math.round(newTimeBeats * state.quantizeUnit) / state.quantizeUnit; + if (state.selectedItem.type === 'sequence') state.sequences[state.selectedItem.index].startTime = newTimeBeats; else if (state.selectedItem.type === 'effect') { const seq = state.sequences[state.selectedItem.seqIndex], effect = seq.effects[state.selectedItem.effectIndex]; - const duration = effect.endTime - effect.startTime, relativeTime = newTime - seq.startTime; - effect.startTime = relativeTime; effect.endTime = effect.startTime + duration; + const durationBeats = effect.endTime - effect.startTime, relativeTimeBeats = newTimeBeats - seq.startTime; + effect.startTime = relativeTimeBeats; effect.endTime = effect.startTime + durationBeats; } renderTimeline(); updateProperties(); } @@ -660,7 +582,7 @@ state.selectedItem = { type: 'effect', seqIndex, effectIndex, index: seqIndex }; const seq = state.sequences[seqIndex], effect = seq.effects[effectIndex]; const containerRect = dom.timelineContent.getBoundingClientRect(); - const mouseTimeBeats = (e.clientX - containerRect.left + dom.timelineContent.scrollLeft) / state.pixelsPerSecond; + const mouseTimeBeats = (e.clientX - containerRect.left + dom.timelineContent.scrollLeft) / state.pixelsPerBeat; const handleTimeBeats = seq.startTime + (type === 'left' ? effect.startTime : effect.endTime); state.handleDragOffset = handleTimeBeats - mouseTimeBeats; document.addEventListener('mousemove', onHandleDrag); document.addEventListener('mouseup', stopHandleDrag); @@ -669,13 +591,13 @@ function onHandleDrag(e) { if (!state.isDraggingHandle || !state.selectedItem) return; const containerRect = dom.timelineContent.getBoundingClientRect(); - let newTime = (e.clientX - containerRect.left + dom.timelineContent.scrollLeft) / state.pixelsPerSecond + state.handleDragOffset; - newTime = Math.max(0, newTime); - if (state.quantizeUnit > 0) newTime = Math.round(newTime * state.quantizeUnit) / state.quantizeUnit; + let newTimeBeats = (e.clientX - containerRect.left + dom.timelineContent.scrollLeft) / state.pixelsPerBeat + state.handleDragOffset; + newTimeBeats = Math.max(0, newTimeBeats); + if (state.quantizeUnit > 0) newTimeBeats = Math.round(newTimeBeats * state.quantizeUnit) / state.quantizeUnit; const seq = state.sequences[state.selectedItem.seqIndex], effect = seq.effects[state.selectedItem.effectIndex]; - const relativeTime = newTime - seq.startTime; - if (state.handleType === 'left') effect.startTime = Math.min(relativeTime, effect.endTime - 0.1); - else if (state.handleType === 'right') effect.endTime = Math.max(effect.startTime + 0.1, relativeTime); + const relativeTimeBeats = newTimeBeats - seq.startTime; + if (state.handleType === 'left') effect.startTime = Math.min(relativeTimeBeats, effect.endTime - 0.1); + else if (state.handleType === 'right') effect.endTime = Math.max(effect.startTime + 0.1, relativeTimeBeats); renderTimeline(); updateProperties(); } @@ -713,8 +635,8 @@ const samePriority = effect.priorityModifier === '='; dom.propertiesContent.innerHTML = ` <div class="property-group"><label>Effect Class</label><input type="text" id="propClassName" value="${effect.className}"></div> - <div class="property-group"><label>Start Time (relative to sequence)</label><input type="number" id="propStartTime" value="${effect.startTime}" step="0.1"></div> - <div class="property-group"><label>End Time (relative to sequence)</label><input type="number" id="propEndTime" value="${effect.endTime}" step="0.1"></div> + <div class="property-group"><label>Start Time (beats, relative to sequence)</label><input type="number" id="propStartTime" value="${effect.startTime}" step="0.1"></div> + <div class="property-group"><label>End Time (beats, relative to sequence)</label><input type="number" id="propEndTime" value="${effect.endTime}" step="0.1"></div> <div class="property-group"><label>Constructor Arguments</label><input type="text" id="propArgs" value="${effect.args || ''}"></div> <div class="property-group"><label>Stack Position (determines priority)</label> <div style="display: flex; gap: 5px; margin-bottom: 10px;"> @@ -777,18 +699,11 @@ updateProperties(); } - // Utilities - function showMessage(text, type) { - if (type === 'error') console.error(text); - dom.messageArea.innerHTML = `<div class="${type}">${text}</div>`; - setTimeout(() => dom.messageArea.innerHTML = '', 3000); - } - function updateStats() { const effectCount = state.sequences.reduce((sum, seq) => sum + seq.effects.length, 0); - const maxTime = Math.max(0, ...state.sequences.flatMap(seq => + const maxTimeBeats = Math.max(0, ...state.sequences.flatMap(seq => seq.effects.map(e => seq.startTime + e.endTime).concat(seq.startTime))); - dom.stats.innerHTML = `📊 Sequences: ${state.sequences.length} | 🎬 Effects: ${effectCount} | ⏱️ Duration: ${maxTime.toFixed(2)}s`; + dom.stats.innerHTML = `📊 Sequences: ${state.sequences.length} | 🎬 Effects: ${effectCount} | ⏱️ Duration: ${maxTimeBeats.toFixed(2)}b (${beatsToTime(maxTimeBeats).toFixed(2)}s)`; } async function loadFromURLParams() { @@ -799,19 +714,22 @@ const response = await fetch(seqURL); 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; + state.sequences = parsed.sequences; + updateBPM(parsed.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; + if (viewportController) viewportController.updateIndicatorPosition(0, false); showMessage(`Loaded ${state.currentFile} from URL`, 'success'); } catch (err) { showMessage(`Error loading seq file: ${err.message}`, 'error'); } } - if (wavURL) { + if (wavURL && playbackController) { try { const response = await fetch(wavURL); if (!response.ok) throw new Error(`HTTP ${response.status}`); const blob = await response.blob(), file = new File([blob], wavURL.split('/').pop(), { type: 'audio/wav' }); - await loadAudioFile(file); + await playbackController.loadAudioFile(file); } catch (err) { showMessage(`Error loading audio file: ${err.message}`, 'error'); } } } @@ -825,9 +743,12 @@ reader.onload = e => { 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; + state.sequences = parsed.sequences; + updateBPM(parsed.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; + if (viewportController) viewportController.updateIndicatorPosition(0, false); showMessage(`Loaded ${state.currentFile} - ${state.sequences.length} sequences`, 'success'); } catch (err) { showMessage(`Error parsing file: ${err.message}`, 'error'); } }; @@ -841,29 +762,7 @@ showMessage('File saved', 'success'); }); - dom.audioInput.addEventListener('change', e => { const file = e.target.files[0]; if (file) loadAudioFile(file); }); - dom.clearAudioBtn.addEventListener('click', () => { clearAudio(); dom.audioInput.value = ''; }); - dom.playPauseBtn.addEventListener('click', async () => { - if (state.isPlaying) stopPlayback(); - else { if (state.playbackOffset >= state.audioDuration) state.playbackOffset = 0; await startPlayback(); } - }); - - dom.waveformContainer.addEventListener('click', async e => { - if (!state.audioBuffer) return; - const rect = dom.waveformContainer.getBoundingClientRect(); - const canvasOffset = parseFloat(dom.waveformCanvas.style.left) || 0; - const clickX = e.clientX - rect.left - canvasOffset; - const clickBeats = clickX / state.pixelsPerSecond; - const clickTime = beatsToTime(clickBeats); - const wasPlaying = state.isPlaying; - if (wasPlaying) stopPlayback(false); - 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`; - if (wasPlaying) await startPlayback(); - }); + // Audio/playback event handlers - managed by PlaybackController dom.addSequenceBtn.addEventListener('click', () => { state.sequences.push({ type: 'sequence', startTime: 0, priority: 0, effects: [], _collapsed: true }); @@ -898,14 +797,27 @@ showMessage('Sequences re-ordered by start time', 'success'); }); - dom.zoomSlider.addEventListener('input', e => { - state.pixelsPerSecond = parseInt(e.target.value); dom.zoomLevel.textContent = `${state.pixelsPerSecond}%`; - if (state.audioBuffer) renderWaveform(); renderTimeline(); - }); + // Zoom handler - managed by ViewportController dom.bpmSlider.addEventListener('input', e => { - state.bpm = parseInt(e.target.value); dom.currentBPM.textContent = state.bpm; - if (state.audioBuffer) renderWaveform(); renderTimeline(); + updateBPM(parseInt(e.target.value)); + dom.currentBPM.value = state.bpm; + if (state.audioBuffer && playbackController) playbackController.renderWaveform(); + renderTimeline(); + if (viewportController) viewportController.updateIndicatorPosition(timeToBeats(state.playbackOffset), false); + }); + + dom.currentBPM.addEventListener('change', e => { + const bpm = parseInt(e.target.value); + if (!isNaN(bpm) && bpm >= 60 && bpm <= 200) { + updateBPM(bpm); + dom.bpmSlider.value = bpm; + if (state.audioBuffer && playbackController) playbackController.renderWaveform(); + renderTimeline(); + if (viewportController) viewportController.updateIndicatorPosition(timeToBeats(state.playbackOffset), false); + } else { + e.target.value = state.bpm; + } }); dom.showBeatsCheckbox.addEventListener('change', e => { state.showBeats = e.target.checked; renderTimeline(); }); @@ -914,76 +826,46 @@ dom.panelCollapseBtn.addEventListener('click', () => { dom.propertiesPanel.classList.remove('collapsed'); dom.panelCollapseBtn.classList.remove('visible'); dom.panelToggle.textContent = '▼ Collapse'; }); dom.timeline.addEventListener('click', () => { state.selectedItem = null; dom.deleteBtn.disabled = true; dom.addEffectBtn.disabled = true; renderTimeline(); updateProperties(); }); - dom.timeline.addEventListener('dblclick', async e => { + dom.timeline.addEventListener('dblclick', e => { if (e.target !== dom.timeline) return; + if (!playbackController || !state.audioBuffer) return; const containerRect = dom.timelineContent.getBoundingClientRect(); - const clickX = e.clientX - containerRect.left + dom.timelineContent.scrollLeft; - const clickBeats = clickX / state.pixelsPerSecond; + const clickX = e.clientX - containerRect.left + dom.timelineContent.scrollLeft - viewportController.TIMELINE_LEFT_PADDING; + const clickBeats = clickX / state.pixelsPerBeat; const clickTime = beatsToTime(clickBeats); - if (state.audioBuffer) { - const wasPlaying = state.isPlaying; - if (wasPlaying) stopPlayback(false); - 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`; - if (wasPlaying) await startPlayback(); - showMessage(`Seek to ${clickTime.toFixed(2)}s (${clickBeats.toFixed(2)}b)`, 'success'); - } + const result = playbackController.seekTo(clickBeats, clickTime); + if (result) showMessage(`Seek to ${result.clickTime.toFixed(2)}s (${result.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(); + } } }); - dom.timelineContent.addEventListener('scroll', () => { - const scrollLeft = dom.timelineContent.scrollLeft; - dom.cpuLoadCanvas.style.left = `-${scrollLeft}px`; - dom.waveformCanvas.style.left = `-${scrollLeft}px`; - dom.waveformPlaybackIndicator.style.transform = `translateX(-${scrollLeft}px)`; - }); + // Scroll/wheel handlers - managed by ViewportController - dom.timelineContent.addEventListener('wheel', e => { - e.preventDefault(); - 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 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(); - dom.timelineContent.scrollLeft = timeUnderCursor * newPixelsPerSecond - mouseX; - } - return; - } - dom.timelineContent.scrollLeft += e.deltaY; - const currentScrollLeft = dom.timelineContent.scrollLeft, viewportWidth = dom.timelineContent.clientWidth; - const slack = (viewportWidth / state.pixelsPerSecond) * 0.1, currentTime = (currentScrollLeft / state.pixelsPerSecond) + slack; - let targetSeqIndex = 0; - for (let i = 0; i < state.sequences.length; i++) { - if (state.sequences[i].startTime <= currentTime) targetSeqIndex = i; else break; - } - if (targetSeqIndex !== state.lastActiveSeqIndex && state.sequences.length > 0) { - state.lastActiveSeqIndex = targetSeqIndex; - const seqDivs = dom.timeline.querySelectorAll('.sequence'); - if (seqDivs[targetSeqIndex]) { - seqDivs[targetSeqIndex].classList.add('active-flash'); - setTimeout(() => seqDivs[targetSeqIndex]?.classList.remove('active-flash'), 600); - } + // Initialize controllers + const renderCallback = (trigger) => { + if (trigger === 'zoom' || trigger === 'zoomWheel') { + if (state.audioBuffer && playbackController) playbackController.renderWaveform(); + renderTimeline(); + if (viewportController) viewportController.updateIndicatorPosition(timeToBeats(state.playbackOffset), false); + } else { + renderTimeline(); } - 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; - }, { passive: false }); + }; + + viewportController = new ViewportController(state, dom, renderCallback); + playbackController = new PlaybackController(state, dom, viewportController, renderCallback, showMessage); window.addEventListener('resize', renderTimeline); renderTimeline(); loadFromURLParams(); |
