summaryrefslogtreecommitdiff
path: root/tools/timeline_editor/index.html
diff options
context:
space:
mode:
Diffstat (limited to 'tools/timeline_editor/index.html')
-rw-r--r--tools/timeline_editor/index.html446
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();