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