diff options
| author | skal <pascal.massimino@gmail.com> | 2026-02-12 09:11:48 +0100 |
|---|---|---|
| committer | skal <pascal.massimino@gmail.com> | 2026-02-12 09:11:48 +0100 |
| commit | ef8b12d25d9a48c2e678669ee3dd9ea811fc968d (patch) | |
| tree | a3137d49d62453ecbcbea910e48d816ee0a4237f /tools/timeline_editor | |
| parent | 4a1870d1d0cc4676797add05762ed196decd339d (diff) | |
refactor: timeline editor - major code reduction and UX improvements
Reduced file size from 1899 to 823 lines (57% reduction) while improving
maintainability and user experience.
CSS improvements:
- Added CSS variables for colors, spacing, and border radius
- Consolidated duplicate button/input/label styles
- Added missing .zoom-controls class definition
- Reduced CSS from ~510 to ~100 lines
JavaScript refactoring:
- Centralized global state into single `state` object
- Created `dom` object to cache all element references
- Removed all inline event handlers (onclick, oninput)
- Replaced with proper addEventListener pattern
- Fixed missing playbackControls reference (bug fix)
- Reduced JS from ~1320 to ~660 lines
UX improvements:
- Playback indicators (red bars) now always visible, start at 0s
- During playback, highlight current sequence green (no expand/collapse reflow)
- Smooth scrolling follows playback indicator (10% interpolation at 40% viewport)
- Moved "Show Beats" checkbox inline with BPM controls
- Fixed playback controls layout (time left of button, proper gap/alignment)
- Error messages now logged to console as well as UI
No functional regressions - all features work identically.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Diffstat (limited to 'tools/timeline_editor')
| -rw-r--r-- | tools/timeline_editor/index.html | 2081 |
1 files changed, 502 insertions, 1579 deletions
diff --git a/tools/timeline_editor/index.html b/tools/timeline_editor/index.html index b6e9223..21bedd1 100644 --- a/tools/timeline_editor/index.html +++ b/tools/timeline_editor/index.html @@ -5,509 +5,100 @@ <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Timeline Editor - timeline.seq</title> <style> - * { - margin: 0; - padding: 0; - box-sizing: border-box; + :root { + --bg-dark: #1e1e1e; + --bg-medium: #252526; + --bg-light: #3c3c3c; + --text-primary: #d4d4d4; + --text-muted: #858585; + --accent-blue: #0e639c; + --accent-blue-hover: #1177bb; + --accent-green: #4ec9b0; + --accent-orange: #ce9178; + --accent-red: #f48771; + --border-color: #858585; + --gap: 10px; + --radius: 4px; } - body { - font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; - background: #1e1e1e; - color: #d4d4d4; - padding: 20px; - margin: 0; - min-height: 100vh; - box-sizing: border-box; - } - - .container { - max-width: 100%; - width: 100%; - margin: 0 auto; - box-sizing: border-box; - } - - header { - background: #252526; - padding: 20px; - border-radius: 8px; - margin-bottom: 20px; - display: flex; - align-items: center; - justify-content: space-between; - gap: 20px; - flex-wrap: wrap; - } - - h1 { - margin: 0; - color: #4ec9b0; - white-space: nowrap; - } - - .controls { - display: flex; - gap: 10px; - flex-wrap: wrap; - align-items: center; - } - - .checkbox-label { - display: flex; - align-items: center; - gap: 8px; - color: #d4d4d4; - cursor: pointer; - user-select: none; - } - - .checkbox-label input[type="checkbox"] { - cursor: pointer; - } - - button { - background: #0e639c; - color: white; - border: none; - padding: 10px 20px; - border-radius: 4px; - cursor: pointer; - font-size: 14px; - } - - button:hover { - background: #1177bb; - } - - button:disabled { - background: #3c3c3c; - cursor: not-allowed; - } - - input[type="file"] { - display: none; - } - - .file-label { - background: #0e639c; - color: white; - padding: 10px 20px; - border-radius: 4px; - cursor: pointer; - display: inline-block; - } - - .file-label:hover { - background: #1177bb; - } - - .timeline-container { - background: #252526; - border-radius: 8px; - padding: 0; - 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; - padding: 0 20px 20px 20px; - /* Hide scrollbars while keeping scroll functionality */ - scrollbar-width: none; /* Firefox */ - -ms-overflow-style: none; /* IE/Edge */ - } - - .timeline-content::-webkit-scrollbar { - display: none; /* Chrome/Safari/Opera */ - } - - .timeline { - position: relative; - min-height: 100%; - border-left: 2px solid #3c3c3c; - } - - .sticky-header { - position: sticky; - top: 0; - background: #252526; - z-index: 100; - padding: 20px 20px 10px 20px; - 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; - } - - .waveform-container { - position: relative; - height: 80px; - overflow: hidden; - background: rgba(0, 0, 0, 0.3); - border-radius: 4px; - cursor: crosshair; - } - - #waveformCanvas { - position: absolute; - left: 0; - top: 0; - height: 80px; - display: block; - } - - .playback-indicator { - position: absolute; - top: 0; - width: 2px; - 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-top: 10px; - border-bottom: 1px solid #3c3c3c; - } + * { margin: 0; padding: 0; box-sizing: border-box; } + body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background: var(--bg-dark); color: var(--text-primary); padding: 20px; min-height: 100vh; } + .container { max-width: 100%; width: 100%; margin: 0 auto; } - .time-marker { - position: absolute; - top: 0; - font-size: 12px; - color: #858585; - } + header { background: var(--bg-medium); padding: 20px; border-radius: 8px; margin-bottom: 20px; display: flex; align-items: center; justify-content: space-between; gap: 20px; flex-wrap: wrap; } + h1 { color: var(--accent-green); white-space: nowrap; } + .controls { display: flex; gap: var(--gap); flex-wrap: wrap; align-items: center; } + .zoom-controls { display: flex; gap: var(--gap); flex-wrap: wrap; align-items: center; margin-bottom: var(--gap); } - .time-marker::before { - content: ''; - position: absolute; - left: 0; - top: 20px; - width: 1px; - height: 10px; - background: #3c3c3c; - } + button, .file-label { background: var(--accent-blue); color: white; border: none; padding: 10px 20px; border-radius: var(--radius); cursor: pointer; font-size: 14px; display: inline-block; } + button:hover, .file-label:hover { background: var(--accent-blue-hover); } + button:disabled { background: var(--bg-light); cursor: not-allowed; } + input[type="file"] { display: none; } - .time-marker::after { - content: ''; - position: absolute; - left: 0; - top: 30px; - width: 1px; - height: 10000px; - background: rgba(60, 60, 60, 0.2); - pointer-events: none; - } + .checkbox-label { display: flex; align-items: center; gap: 8px; cursor: pointer; user-select: none; } + .checkbox-label input[type="checkbox"] { cursor: pointer; } - .sequence { - position: absolute; - background: #264f78; - border: 2px solid #0e639c; - border-radius: 4px; - padding: 8px; - cursor: move; - min-height: 40px; - transition: box-shadow 0.2s; - } + .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); } - .sequence:hover { - box-shadow: 0 0 10px rgba(14, 99, 156, 0.5); - } + .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; } + #waveformCanvas { position: absolute; left: 0; top: 0; height: 80px; display: block; } - .sequence.selected { - border-color: #4ec9b0; - box-shadow: 0 0 10px rgba(78, 201, 176, 0.5); - } + .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; } - .sequence.active-flash { - animation: sequenceFlash 0.6s ease-out; - } + .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); } + .time-marker::before { content: ''; position: absolute; left: 0; top: 20px; width: 1px; height: 10px; background: var(--bg-light); } + .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; border: 2px solid var(--accent-blue); border-radius: var(--radius); padding: 8px; cursor: move; min-height: 40px; transition: box-shadow 0.2s; } + .sequence:hover { box-shadow: 0 0 10px rgba(14, 99, 156, 0.5); } + .sequence.selected { border-color: var(--accent-green); box-shadow: 0 0 10px rgba(78, 201, 176, 0.5); } + .sequence.collapsed { overflow: hidden !important; background: #1a3a4a !important; } + .sequence.collapsed .sequence-name { display: none !important; } + .sequence.active-playing { border-color: var(--accent-green); background: #2a5f4a; } + .sequence.active-flash { animation: sequenceFlash 0.6s ease-out; } @keyframes sequenceFlash { - 0% { - box-shadow: 0 0 20px rgba(78, 201, 176, 0.8); - border-color: #4ec9b0; - } - 100% { - box-shadow: 0 0 10px rgba(14, 99, 156, 0.5); - border-color: #0e639c; - } - } - - .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%; - left: 50%; - transform: translate(-50%, -50%); - font-size: 24px; - font-weight: bold; - color: #ffffff; - text-shadow: 2px 2px 8px rgba(0, 0, 0, 0.9), - -1px -1px 4px rgba(0, 0, 0, 0.7); - pointer-events: none; - white-space: nowrap; - opacity: 1; - transition: opacity 0.3s ease; - z-index: 10; - } - - .sequence.hovered .sequence-name { - opacity: 0; - } - - .sequence-info { - position: absolute; - top: 8px; - left: 8px; - font-size: 11px; - color: #858585; - pointer-events: none; - } - - .effect { - position: absolute; - background: #3a3d41; - border: 1px solid #858585; - border-radius: 3px; - padding: 4px 8px; - cursor: move; - font-size: 11px; - transition: box-shadow 0.2s; - display: flex; - align-items: center; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .effect:hover { - box-shadow: 0 0 8px rgba(133, 133, 133, 0.5); - background: #45484d; - } - - .effect.selected { - border-color: #ce9178; - box-shadow: 0 0 8px rgba(206, 145, 120, 0.5); - } - - .effect small { - font-size: 11px; - color: #d4d4d4; - } - - .effect-handle { - position: absolute; - top: 0; - width: 6px; - height: 100%; - background: rgba(78, 201, 176, 0.8); - cursor: ew-resize; - display: none; - z-index: 10; + 0% { box-shadow: 0 0 20px rgba(78, 201, 176, 0.8); border-color: var(--accent-green); } + 100% { box-shadow: 0 0 10px rgba(14, 99, 156, 0.5); border-color: var(--accent-blue); } } - .effect.selected .effect-handle { - display: block; - } - - .effect-handle.left { - left: 0; - border-radius: 3px 0 0 3px; - } - - .effect-handle.right { - right: 0; - border-radius: 0 3px 3px 0; - } - - .effect-handle:hover { - background: rgba(78, 201, 176, 1); - width: 8px; - } - - .properties-panel { - position: fixed; - bottom: 20px; - left: 20px; - width: 350px; - max-height: 80vh; - background: #252526; - padding: 15px; - border-radius: 8px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); - z-index: 1000; - overflow-y: auto; - transition: transform 0.3s ease; - } - - .properties-panel.collapsed { - transform: translateY(calc(100% + 40px)); - } - - .panel-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 15px; - padding-bottom: 10px; - border-bottom: 1px solid #3c3c3c; - } - - .panel-header h2 { - margin: 0; - color: #4ec9b0; - font-size: 16px; - } - - .panel-toggle { - background: transparent; - border: 1px solid #858585; - color: #d4d4d4; - padding: 4px 8px; - border-radius: 3px; - cursor: pointer; - font-size: 12px; - } - - .panel-toggle:hover { - background: #3c3c3c; - } - - .panel-collapse-btn { - position: fixed; - bottom: 20px; - left: 20px; - background: #252526; - border: 1px solid #858585; - color: #d4d4d4; - padding: 8px 12px; - border-radius: 4px; - cursor: pointer; - z-index: 999; - box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3); - display: none; - } + .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-name { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 24px; font-weight: bold; color: #ffffff; text-shadow: 2px 2px 8px rgba(0, 0, 0, 0.9), -1px -1px 4px rgba(0, 0, 0, 0.7); pointer-events: none; white-space: nowrap; opacity: 1; transition: opacity 0.3s ease; z-index: 10; } + .sequence.hovered .sequence-name { opacity: 0; } - .panel-collapse-btn:hover { - background: #3c3c3c; - } - - .panel-collapse-btn.visible { - display: block; - } + .effect { position: absolute; background: #3a3d41; border: 1px solid var(--border-color); border-radius: 3px; padding: 4px 8px; cursor: move; font-size: 11px; transition: box-shadow 0.2s; display: flex; align-items: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .effect:hover { box-shadow: 0 0 8px rgba(133, 133, 133, 0.5); background: #45484d; } + .effect.selected { border-color: var(--accent-orange); box-shadow: 0 0 8px rgba(206, 145, 120, 0.5); } + .effect-handle { position: absolute; top: 0; width: 6px; height: 100%; background: rgba(78, 201, 176, 0.8); cursor: ew-resize; display: none; z-index: 10; } + .effect.selected .effect-handle { display: block; } + .effect-handle.left { left: 0; border-radius: 3px 0 0 3px; } + .effect-handle.right { right: 0; border-radius: 0 3px 3px 0; } + .effect-handle:hover { background: var(--accent-green); width: 8px; } - .property-group { - margin-bottom: 15px; - } + .properties-panel { position: fixed; bottom: 20px; left: 20px; width: 350px; max-height: 80vh; background: var(--bg-medium); padding: 15px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); z-index: 1000; overflow-y: auto; transition: transform 0.3s ease; } + .properties-panel.collapsed { transform: translateY(calc(100% + 40px)); } + .panel-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; padding-bottom: 10px; border-bottom: 1px solid var(--bg-light); } + .panel-header h2 { margin: 0; color: var(--accent-green); font-size: 16px; } + .panel-toggle { background: transparent; border: 1px solid var(--border-color); color: var(--text-primary); padding: 4px 8px; border-radius: 3px; cursor: pointer; font-size: 12px; } + .panel-toggle:hover { background: var(--bg-light); } + .panel-collapse-btn { position: fixed; bottom: 20px; left: 20px; background: var(--bg-medium); border: 1px solid var(--border-color); color: var(--text-primary); padding: 8px 12px; border-radius: var(--radius); cursor: pointer; z-index: 999; box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3); display: none; } + .panel-collapse-btn:hover { background: var(--bg-light); } + .panel-collapse-btn.visible { display: block; } - .property-group label { - display: block; - margin-bottom: 5px; - color: #858585; - font-size: 14px; - } - - .property-group input, - .property-group select { - width: 100%; - padding: 8px; - background: #3c3c3c; - border: 1px solid #858585; - border-radius: 4px; - color: #d4d4d4; - font-size: 14px; - } + .property-group { margin-bottom: 15px; } + .property-group label { display: block; margin-bottom: 5px; color: var(--text-muted); font-size: 14px; } + .property-group input, .property-group select { width: 100%; padding: 8px; background: var(--bg-light); border: 1px solid var(--border-color); border-radius: var(--radius); color: var(--text-primary); font-size: 14px; } - .zoom-controls { - margin-bottom: 10px; - } - - .stats { - background: #1e1e1e; - padding: 10px; - border-radius: 4px; - margin-top: 10px; - font-size: 12px; - color: #858585; - } - - #messageArea { - position: fixed; - top: 80px; - right: 20px; - z-index: 2000; - max-width: 400px; - } - - .error { - background: #5a1d1d; - color: #f48771; - padding: 10px; - border-radius: 4px; - box-shadow: 0 2px 8px rgba(0,0,0,0.3); - } - - .success { - background: #1e5231; - color: #89d185; - padding: 10px; - border-radius: 4px; - box-shadow: 0 2px 8px rgba(0,0,0,0.3); - } + .stats { background: var(--bg-dark); padding: 10px; border-radius: var(--radius); margin-top: 10px; font-size: 12px; color: var(--text-muted); } + #messageArea { position: fixed; top: 80px; right: 20px; z-index: 2000; max-width: 400px; } + .error { background: #5a1d1d; color: var(--accent-red); padding: 10px; border-radius: var(--radius); box-shadow: 0 2px 8px rgba(0,0,0,0.3); } + .success { background: #1e5231; color: #89d185; padding: 10px; border-radius: var(--radius); box-shadow: 0 2px 8px rgba(0,0,0,0.3); } </style> </head> <body> @@ -515,15 +106,9 @@ <header> <h1>📊 Timeline Editor</h1> <div class="controls"> - <label class="file-label"> - 📂 Load timeline.seq - <input type="file" id="fileInput" accept=".seq"> - </label> + <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> + <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> @@ -536,14 +121,13 @@ <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> - <div id="playbackControls" style="display: none; margin-left: 20px;"> - <button id="playPauseBtn">▶ Play</button> - <span id="playbackTime">0.00s (0.00b)</span> - </div> <label class="checkbox-label" style="margin-left: 20px"> - <input type="checkbox" id="showBeatsCheckbox" checked> - Show Beats + <input type="checkbox" id="showBeatsCheckbox" checked>Show Beats </label> + <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> + </div> </div> <div id="messageArea"></div> @@ -576,198 +160,129 @@ </div> <script> - // Global state - let sequences = []; - let currentFile = null; - let selectedItem = null; - let pixelsPerSecond = 100; - let showBeats = true; - let bpm = 120; - let isDragging = false; - let dragOffset = { x: 0, y: 0 }; - let lastActiveSeqIndex = -1; - let isDraggingHandle = false; - 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; + // State + const state = { + sequences: [], currentFile: null, selectedItem: null, pixelsPerSecond: 100, + showBeats: true, bpm: 120, isDragging: false, dragOffset: { x: 0, y: 0 }, + lastActiveSeqIndex: -1, isDraggingHandle: false, handleType: null, + audioBuffer: null, audioDuration: 0, audioSource: null, audioContext: null, + isPlaying: false, playbackStartTime: 0, playbackOffset: 0, animationFrameId: null, + 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'); - const clearAudioBtn = document.getElementById('clearAudioBtn'); - const waveformCanvas = document.getElementById('waveformCanvas'); - const waveformContainer = document.getElementById('waveformContainer'); - const addSequenceBtn = document.getElementById('addSequenceBtn'); - const deleteBtn = document.getElementById('deleteBtn'); - const reorderBtn = document.getElementById('reorderBtn'); - const propertiesPanel = document.getElementById('propertiesPanel'); - const propertiesContent = document.getElementById('propertiesContent'); - const messageArea = document.getElementById('messageArea'); - 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'); - const waveformPlaybackIndicator = document.getElementById('waveformPlaybackIndicator'); + // DOM + const dom = { + timeline: document.getElementById('timeline'), + timelineContent: document.getElementById('timelineContent'), + fileInput: document.getElementById('fileInput'), + saveBtn: document.getElementById('saveBtn'), + audioInput: document.getElementById('audioInput'), + clearAudioBtn: document.getElementById('clearAudioBtn'), + waveformCanvas: document.getElementById('waveformCanvas'), + waveformContainer: document.getElementById('waveformContainer'), + addSequenceBtn: document.getElementById('addSequenceBtn'), + deleteBtn: document.getElementById('deleteBtn'), + reorderBtn: document.getElementById('reorderBtn'), + propertiesPanel: document.getElementById('propertiesPanel'), + propertiesContent: document.getElementById('propertiesContent'), + messageArea: document.getElementById('messageArea'), + zoomSlider: document.getElementById('zoomSlider'), + zoomLevel: document.getElementById('zoomLevel'), + stats: document.getElementById('stats'), + playbackControls: document.getElementById('playbackControls'), + playPauseBtn: document.getElementById('playPauseBtn'), + 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') + }; - // Parser: timeline.seq → JavaScript objects - // Format specification: doc/SEQUENCE.md + // Parser function parseSeqFile(content) { const sequences = []; - const lines = content.split('\n'); - let currentSequence = null; - let bpm = 120; // Default BPM - let currentPriority = 0; // Track priority for + = - modifiers + let currentSequence = null, bpm = 120, currentPriority = 0; - // 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')) { - // Explicit beats: "4b" = 4 beats - return parseFloat(timeStr.slice(0, -1)); - } - // Default: beats + const parseTime = (timeStr) => { + if (timeStr.endsWith('s')) return parseFloat(timeStr.slice(0, -1)) * bpm / 60.0; + if (timeStr.endsWith('b')) return parseFloat(timeStr.slice(0, -1)); return parseFloat(timeStr); - } + }; - // Helper: Strip inline comments - function stripComment(line) { - const commentIdx = line.indexOf('#'); - if (commentIdx >= 0) { - return line.slice(0, commentIdx).trim(); - } - return line; - } + const stripComment = (line) => { + const idx = line.indexOf('#'); + return idx >= 0 ? line.slice(0, idx).trim() : line; + }; - for (let line of lines) { + for (let line of content.split('\n')) { line = line.trim(); - - // Skip empty lines - if (!line) continue; - - // Parse BPM comment - if (line.startsWith('# BPM ')) { - const bpmMatch = line.match(/# BPM (\d+)/); - if (bpmMatch) { - bpm = parseInt(bpmMatch[1]); + if (!line || line.startsWith('#')) { + if (line.startsWith('# BPM ')) { + const m = line.match(/# BPM (\d+)/); + if (m) bpm = parseInt(m[1]); } continue; } - - // Skip other comments - if (line.startsWith('#')) continue; - - // Strip inline comments line = stripComment(line); if (!line) continue; - // Parse SEQUENCE line: SEQUENCE <time> <priority> [name] [end] const seqMatch = line.match(/^SEQUENCE\s+(\S+)\s+(\d+)(?:\s+"([^"]+)")?(?:\s+(\S+))?$/); if (seqMatch) { - currentSequence = { - type: 'sequence', - startTime: parseTime(seqMatch[1]), - priority: parseInt(seqMatch[2]), - effects: [], - name: seqMatch[3] || '', - _collapsed: true - }; + currentSequence = { type: 'sequence', startTime: parseTime(seqMatch[1]), priority: parseInt(seqMatch[2]), effects: [], name: seqMatch[3] || '', _collapsed: true }; sequences.push(currentSequence); - currentPriority = -1; // Reset effect priority for new sequence + currentPriority = -1; continue; } - // Parse EFFECT line: EFFECT <modifier> <ClassName> <start> <end> [args] const effectMatch = line.match(/^EFFECT\s+([+=-])\s+(\w+)\s+(\S+)\s+(\S+)(?:\s+(.*))?$/); if (effectMatch && currentSequence) { const modifier = effectMatch[1]; - - // Update priority based on modifier - if (modifier === '+') { - currentPriority++; - } else if (modifier === '-') { - currentPriority--; - } - // '=' keeps current priority - - const effect = { - type: 'effect', - className: effectMatch[2], - startTime: parseTime(effectMatch[3]), - endTime: parseTime(effectMatch[4]), - priority: currentPriority, - priorityModifier: modifier, - args: effectMatch[5] || '' - }; - currentSequence.effects.push(effect); + if (modifier === '+') currentPriority++; + else if (modifier === '-') currentPriority--; + currentSequence.effects.push({ + type: 'effect', className: effectMatch[2], + startTime: parseTime(effectMatch[3]), endTime: parseTime(effectMatch[4]), + priority: currentPriority, priorityModifier: modifier, args: effectMatch[5] || '' + }); } } - return { sequences, bpm }; } - // Serializer: JavaScript objects → timeline.seq (outputs beats) function serializeSeqFile(sequences) { - let output = '# Demo Timeline\n'; - output += '# Generated by Timeline Editor\n'; - output += `# BPM ${bpm}\n\n`; - + let output = `# Demo Timeline\n# Generated by Timeline Editor\n# BPM ${state.bpm}\n\n`; for (const seq of sequences) { const seqLine = `SEQUENCE ${seq.startTime.toFixed(2)} ${seq.priority}`; output += seq.name ? `${seqLine} "${seq.name}"\n` : `${seqLine}\n`; - for (const effect of seq.effects) { const modifier = effect.priorityModifier || '+'; output += ` EFFECT ${modifier} ${effect.className} ${effect.startTime.toFixed(2)} ${effect.endTime.toFixed(2)}`; if (effect.args) { - // Strip priority comments from args const cleanArgs = effect.args.replace(/\s*#\s*Priority:\s*\d+/i, '').trim(); - if (cleanArgs) { - output += ` ${cleanArgs}`; - } + if (cleanArgs) output += ` ${cleanArgs}`; } output += '\n'; } output += '\n'; } - return output; } - // Audio waveform visualization + // Audio async function loadAudioFile(file) { try { const arrayBuffer = await file.arrayBuffer(); - if (!audioContext) { - audioContext = new (window.AudioContext || window.webkitAudioContext)(); - } - audioBuffer = await audioContext.decodeAudioData(arrayBuffer); - audioDuration = audioBuffer.duration; - + if (!state.audioContext) state.audioContext = new (window.AudioContext || window.webkitAudioContext)(); + state.audioBuffer = await state.audioContext.decodeAudioData(arrayBuffer); + state.audioDuration = state.audioBuffer.duration; renderWaveform(); - waveformContainer.style.display = 'block'; - playbackControls.style.display = 'flex'; - clearAudioBtn.disabled = false; - showMessage(`Audio loaded: ${audioDuration.toFixed(2)}s`, 'success'); - - // Extend timeline if audio is longer than current max time + dom.waveformContainer.style.display = 'block'; + 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'); @@ -775,1124 +290,532 @@ } function renderWaveform() { - if (!audioBuffer) return; - - const canvas = waveformCanvas; - const ctx = canvas.getContext('2d'); - - // 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) - canvas.width = canvasWidth; - canvas.height = canvasHeight; - - // Set CSS size to match - canvas.style.width = `${canvasWidth}px`; - canvas.style.height = `${canvasHeight}px`; - - // Set waveform playback indicator height - waveformPlaybackIndicator.style.height = `${canvasHeight}px`; - - // Clear canvas - ctx.fillStyle = 'rgba(0, 0, 0, 0.3)'; - ctx.fillRect(0, 0, canvasWidth, canvasHeight); - - // Get audio data (use first channel for mono, or mix for stereo) - const channelData = audioBuffer.getChannelData(0); - const sampleRate = audioBuffer.sampleRate; + if (!state.audioBuffer) return; + const canvas = dom.waveformCanvas, ctx = canvas.getContext('2d'); + const audioDurationBeats = state.audioDuration * state.bpm / 60.0; + const canvasWidth = audioDurationBeats * state.pixelsPerSecond, canvasHeight = 80; + canvas.width = canvasWidth; canvas.height = canvasHeight; + canvas.style.width = `${canvasWidth}px`; canvas.style.height = `${canvasHeight}px`; + dom.waveformPlaybackIndicator.style.height = `${canvasHeight}px`; + ctx.fillStyle = 'rgba(0, 0, 0, 0.3)'; ctx.fillRect(0, 0, canvasWidth, canvasHeight); + const channelData = state.audioBuffer.getChannelData(0); const samplesPerPixel = Math.ceil(channelData.length / canvasWidth); - - // Draw waveform - ctx.strokeStyle = '#4ec9b0'; - ctx.lineWidth = 1; - ctx.beginPath(); - - const centerY = canvasHeight / 2; - const amplitudeScale = canvasHeight * 0.4; // Use 80% of height - + ctx.strokeStyle = '#4ec9b0'; ctx.lineWidth = 1; ctx.beginPath(); + const centerY = canvasHeight / 2, amplitudeScale = canvasHeight * 0.4; for (let x = 0; x < canvasWidth; x++) { const startSample = Math.floor(x * samplesPerPixel); const endSample = Math.min(startSample + samplesPerPixel, channelData.length); - - // Find min and max amplitude in this pixel range (for better visualization) - let min = 1.0; - let max = -1.0; + let min = 1.0, max = -1.0; for (let i = startSample; i < endSample; i++) { const sample = channelData[i]; if (sample < min) min = sample; if (sample > max) max = sample; } - - // Draw vertical line from min to max - const yMin = centerY - min * amplitudeScale; - const yMax = centerY - max * amplitudeScale; - - if (x === 0) { - ctx.moveTo(x, yMin); - } else { - ctx.lineTo(x, yMin); - } + const yMin = centerY - min * amplitudeScale, yMax = centerY - max * amplitudeScale; + if (x === 0) ctx.moveTo(x, yMin); else ctx.lineTo(x, yMin); ctx.lineTo(x, yMax); } - - ctx.stroke(); - - // Draw center line - ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)'; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.moveTo(0, centerY); - ctx.lineTo(canvasWidth, centerY); ctx.stroke(); + ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)'; ctx.lineWidth = 1; ctx.beginPath(); + ctx.moveTo(0, centerY); ctx.lineTo(canvasWidth, centerY); ctx.stroke(); } function clearAudio() { - stopPlayback(); - audioBuffer = null; - audioDuration = 0; - waveformContainer.style.display = 'none'; - playbackControls.style.display = 'none'; - clearAudioBtn.disabled = true; - renderTimeline(); - showMessage('Audio cleared', 'success'); + stopPlayback(); state.audioBuffer = null; state.audioDuration = 0; + dom.waveformContainer.style.display = 'none'; dom.playbackControls.style.display = 'none'; + dom.clearAudioBtn.disabled = true; renderTimeline(); showMessage('Audio cleared', 'success'); } - // Sync waveform scroll with timeline scroll - timelineContent.addEventListener('scroll', () => { - if (waveformCanvas) { - waveformCanvas.style.left = `-${timelineContent.scrollLeft}px`; - waveformPlaybackIndicator.style.transform = `translateX(-${timelineContent.scrollLeft}px)`; - } - }); - - // Playback functions async function startPlayback() { - if (!audioBuffer || !audioContext) return; - - // Stop any existing source first - if (audioSource) { - try { - audioSource.stop(); - } catch (e) { - // Already stopped - } - audioSource = null; - } - - // Resume audio context if suspended - if (audioContext.state === 'suspended') { - await audioContext.resume(); - } - + 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 { - // 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'); - waveformPlaybackIndicator.classList.add('playing'); - - // Start animation loop + 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(); - - audioSource.onended = () => { - if (isPlaying) { - stopPlayback(); - } - }; + state.audioSource.onended = () => { if (state.isPlaying) stopPlayback(); }; } catch (e) { - console.error('Failed to start playback:', e); - showMessage('Playback failed: ' + e.message, 'error'); - audioSource = null; - isPlaying = false; + console.error('Failed to start playback:', e); showMessage('Playback failed: ' + e.message, 'error'); + state.audioSource = null; state.isPlaying = false; } } function stopPlayback(savePosition = true) { - if (audioSource) { - try { - audioSource.stop(); - } catch (e) { - // Already stopped - } - audioSource = null; - } - - if (animationFrameId) { - cancelAnimationFrame(animationFrameId); - animationFrameId = null; + 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); } - - if (isPlaying && savePosition) { - // 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'); - waveformPlaybackIndicator.classList.remove('playing'); + state.isPlaying = false; dom.playPauseBtn.textContent = '▶ Play'; } function updatePlaybackPosition() { - if (!isPlaying) return; - - const elapsed = audioContext.currentTime - playbackStartTime; - const currentTime = playbackOffset + elapsed; - const currentBeats = currentTime * bpm / 60.0; - - // Update time display - playbackTime.textContent = `${currentTime.toFixed(2)}s (${currentBeats.toFixed(2)}b)`; - - // Update playback indicator position - const indicatorX = currentBeats * pixelsPerSecond; - playbackIndicator.style.left = `${indicatorX}px`; - waveformPlaybackIndicator.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; + if (!state.isPlaying) return; + const elapsed = state.audioContext.currentTime - state.playbackStartTime; + const currentTime = state.playbackOffset + elapsed, currentBeats = currentTime * state.bpm / 60.0; + dom.playbackTime.textContent = `${currentTime.toFixed(2)}s (${currentBeats.toFixed(2)}b)`; + const indicatorX = currentBeats * state.pixelsPerSecond; + dom.playbackIndicator.style.left = `${indicatorX}px`; + dom.waveformPlaybackIndicator.style.left = `${indicatorX}px`; + const viewportWidth = dom.timelineContent.clientWidth; + const targetScrollX = indicatorX - viewportWidth * 0.4; + const currentScrollX = dom.timelineContent.scrollLeft; + const scrollDiff = targetScrollX - currentScrollX; + if (Math.abs(scrollDiff) > 5) { + dom.timelineContent.scrollLeft += scrollDiff * 0.1; } - - // Auto-expand/collapse sequences expandSequenceAtTime(currentBeats); - - // Continue animation - animationFrameId = requestAnimationFrame(updatePlaybackPosition); + state.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; - } + 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; } } - - // Changed sequence - collapse old, expand new - if (activeSeqIndex !== lastExpandedSeqIndex) { - // Collapse previous sequence - if (lastExpandedSeqIndex >= 0 && lastExpandedSeqIndex < sequences.length) { - sequences[lastExpandedSeqIndex]._collapsed = true; + if (activeSeqIndex !== state.lastExpandedSeqIndex) { + const seqDivs = dom.timeline.querySelectorAll('.sequence'); + if (state.lastExpandedSeqIndex >= 0 && seqDivs[state.lastExpandedSeqIndex]) { + seqDivs[state.lastExpandedSeqIndex].classList.remove('active-playing'); } - - // 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); - } + if (activeSeqIndex >= 0 && seqDivs[activeSeqIndex]) { + seqDivs[activeSeqIndex].classList.add('active-playing'); } - - // Re-render to show collapse/expand changes - renderTimeline(); + state.lastExpandedSeqIndex = activeSeqIndex; } } - // Render timeline + // Render function renderTimeline() { - timeline.innerHTML = ''; - const timeMarkers = document.getElementById('timeMarkers'); - timeMarkers.innerHTML = ''; - - // Calculate max time (in beats) - let maxTime = 60; // Default 60 beats (15 bars) - for (const seq of sequences) { - const seqEnd = seq.startTime + 16; // Default 4 bars - maxTime = Math.max(maxTime, seqEnd); - - for (const effect of seq.effects) { - maxTime = Math.max(maxTime, seq.startTime + effect.endTime); - } - } - - // Extend timeline to fit audio if loaded - if (audioDuration > 0) { - const audioBeats = audioDuration * bpm / 60.0; - maxTime = Math.max(maxTime, audioBeats); + dom.timeline.innerHTML = ''; document.getElementById('timeMarkers').innerHTML = ''; + let maxTime = 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); } - - // Render time markers - const timelineWidth = maxTime * pixelsPerSecond; - timeline.style.width = `${timelineWidth}px`; - - // Track timeline height for playback indicator + if (state.audioDuration > 0) maxTime = Math.max(maxTime, state.audioDuration * state.bpm / 60.0); + const timelineWidth = maxTime * state.pixelsPerSecond; + dom.timeline.style.width = `${timelineWidth}px`; let totalTimelineHeight = 0; - - if (showBeats) { - // Show beats (default) + const timeMarkers = document.getElementById('timeMarkers'); + if (state.showBeats) { for (let beat = 0; beat <= maxTime; beat += 4) { const marker = document.createElement('div'); - marker.className = 'time-marker'; - marker.style.left = `${beat * pixelsPerSecond}px`; - marker.textContent = `${beat}b`; - timeMarkers.appendChild(marker); + marker.className = 'time-marker'; marker.style.left = `${beat * state.pixelsPerSecond}px`; + marker.textContent = `${beat}b`; timeMarkers.appendChild(marker); } } else { - // Show seconds - const maxSeconds = maxTime * 60.0 / bpm; + const maxSeconds = maxTime * 60.0 / state.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 = `${beatPos * pixelsPerSecond}px`; - marker.textContent = `${t}s`; - timeMarkers.appendChild(marker); + const beatPos = t * state.bpm / 60.0, marker = document.createElement('div'); + marker.className = 'time-marker'; marker.style.left = `${beatPos * state.pixelsPerSecond}px`; + marker.textContent = `${t}s`; timeMarkers.appendChild(marker); } } - - // Render sequences (with dynamic Y positioning to prevent overlap) - let cumulativeY = 0; - const sequenceGap = 10; // Gap between sequences - - sequences.forEach((seq, seqIndex) => { + let cumulativeY = 0, sequenceGap = 10; + state.sequences.forEach((seq, seqIndex) => { const seqDiv = document.createElement('div'); - seqDiv.className = 'sequence'; - seqDiv.dataset.index = seqIndex; - - // Calculate sequence bounds based on effects (dynamic start/end) - let seqVisualStart = seq.startTime; - let seqVisualEnd = seq.startTime + 10; // Default 10s duration - + seqDiv.className = 'sequence'; seqDiv.dataset.index = seqIndex; + let seqVisualStart = seq.startTime, seqVisualEnd = seq.startTime + 10; if (seq.effects.length > 0) { - const minEffectStart = Math.min(...seq.effects.map(e => e.startTime)); - const maxEffectEnd = Math.max(...seq.effects.map(e => e.endTime)); - seqVisualStart = seq.startTime + minEffectStart; - seqVisualEnd = seq.startTime + maxEffectEnd; + seqVisualStart = seq.startTime + Math.min(...seq.effects.map(e => e.startTime)); + seqVisualEnd = seq.startTime + Math.max(...seq.effects.map(e => e.endTime)); } - - 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; + 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 * pixelsPerSecond}px`; + seqDiv.style.left = `${seqVisualStart * state.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; - totalTimelineHeight = cumulativeY; - - // 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'; + seqDiv.style.width = `${(seqVisualEnd - seqVisualStart) * state.pixelsPerSecond}px`; + seqDiv.style.height = `${seqHeight}px`; seqDiv.style.minHeight = `${seqHeight}px`; seqDiv.style.maxHeight = `${seqHeight}px`; + seq._yPosition = cumulativeY; cumulativeY += seqHeight + sequenceGap; 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}`; - 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(); - }); - + seqHeaderDiv.addEventListener('mousedown', e => e.stopPropagation()); + 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'; - seqNameDiv.textContent = seq.name || `Sequence ${seqIndex + 1}`; - - 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'); - } - - // Fade name on hover - seqDiv.addEventListener('mouseenter', () => { - seqDiv.classList.add('hovered'); - }); - seqDiv.addEventListener('mouseleave', () => { - seqDiv.classList.remove('hovered'); - }); - - seqDiv.addEventListener('mousedown', (e) => startDrag(e, 'sequence', seqIndex)); - seqDiv.addEventListener('click', (e) => { - e.stopPropagation(); - selectItem('sequence', seqIndex); - }); - - timeline.appendChild(seqDiv); - - // Render effects within sequence (skip if collapsed) + const seqNameDiv = document.createElement('div'); seqNameDiv.className = 'sequence-name'; + seqNameDiv.textContent = seq.name || `Sequence ${seqIndex + 1}`; seqDiv.appendChild(seqNameDiv); + if (seq._collapsed) seqDiv.classList.add('collapsed'); + if (state.selectedItem && state.selectedItem.type === 'sequence' && state.selectedItem.index === seqIndex) seqDiv.classList.add('selected'); + seqDiv.addEventListener('mouseenter', () => seqDiv.classList.add('hovered')); + seqDiv.addEventListener('mouseleave', () => seqDiv.classList.remove('hovered')); + seqDiv.addEventListener('mousedown', e => startDrag(e, 'sequence', seqIndex)); + seqDiv.addEventListener('click', e => { e.stopPropagation(); selectItem('sequence', seqIndex); }); + dom.timeline.appendChild(seqDiv); if (!seq._collapsed) { - seq.effects.forEach((effect, effectIndex) => { - const effectDiv = document.createElement('div'); - effectDiv.className = 'effect'; - effectDiv.dataset.seqIndex = seqIndex; - effectDiv.dataset.effectIndex = effectIndex; - - const effectStart = (seq.startTime + effect.startTime) * pixelsPerSecond; - const effectWidth = (effect.endTime - effect.startTime) * pixelsPerSecond; - - effectDiv.style.left = `${effectStart}px`; - effectDiv.style.top = `${seq._yPosition + 20 + effectIndex * 30}px`; - effectDiv.style.width = `${effectWidth}px`; - effectDiv.style.height = '26px'; - - // 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 = ` - <div class="effect-handle left"></div> - <small>${effect.className}</small> - <div class="effect-handle right"></div> - `; - effectDiv.title = `${effect.className}\n${timeDisplay}\nPriority: ${effect.priority}\n${effect.args || '(no args)'}`; - - if (selectedItem && selectedItem.type === 'effect' && - selectedItem.seqIndex === seqIndex && selectedItem.effectIndex === effectIndex) { - effectDiv.classList.add('selected'); - } - - // Handle resizing (only for selected effects) - const leftHandle = effectDiv.querySelector('.effect-handle.left'); - const rightHandle = effectDiv.querySelector('.effect-handle.right'); - - leftHandle.addEventListener('mousedown', (e) => { - e.stopPropagation(); - startHandleDrag(e, 'left', seqIndex, effectIndex); - }); - - rightHandle.addEventListener('mousedown', (e) => { - e.stopPropagation(); - startHandleDrag(e, 'right', seqIndex, effectIndex); + seq.effects.forEach((effect, effectIndex) => { + const effectDiv = document.createElement('div'); effectDiv.className = 'effect'; + effectDiv.dataset.seqIndex = seqIndex; effectDiv.dataset.effectIndex = effectIndex; + const effectStart = (seq.startTime + effect.startTime) * state.pixelsPerSecond; + const effectWidth = (effect.endTime - effect.startTime) * state.pixelsPerSecond; + effectDiv.style.left = `${effectStart}px`; effectDiv.style.top = `${seq._yPosition + 20 + effectIndex * 30}px`; + effectDiv.style.width = `${effectWidth}px`; effectDiv.style.height = '26px'; + const startBeat = effect.startTime.toFixed(1), endBeat = effect.endTime.toFixed(1); + const startSec = (effect.startTime * 60.0 / state.bpm).toFixed(1), endSec = (effect.endTime * 60.0 / state.bpm).toFixed(1); + const timeDisplay = state.showBeats ? `${startBeat}-${endBeat}b (${startSec}-${endSec}s)` : `${startSec}-${endSec}s (${startBeat}-${endBeat}b)`; + effectDiv.innerHTML = `<div class="effect-handle left"></div><small>${effect.className}</small><div class="effect-handle right"></div>`; + effectDiv.title = `${effect.className}\n${timeDisplay}\nPriority: ${effect.priority}\n${effect.args || '(no args)'}`; + if (state.selectedItem && state.selectedItem.type === 'effect' && state.selectedItem.seqIndex === seqIndex && state.selectedItem.effectIndex === effectIndex) effectDiv.classList.add('selected'); + const leftHandle = effectDiv.querySelector('.effect-handle.left'); + const rightHandle = effectDiv.querySelector('.effect-handle.right'); + leftHandle.addEventListener('mousedown', e => { e.stopPropagation(); startHandleDrag(e, 'left', seqIndex, effectIndex); }); + rightHandle.addEventListener('mousedown', e => { e.stopPropagation(); startHandleDrag(e, 'right', seqIndex, effectIndex); }); + effectDiv.addEventListener('mousedown', e => { if (!e.target.classList.contains('effect-handle')) { e.stopPropagation(); startDrag(e, 'effect', seqIndex, effectIndex); } }); + effectDiv.addEventListener('click', e => { e.stopPropagation(); selectItem('effect', seqIndex, effectIndex); }); + dom.timeline.appendChild(effectDiv); }); - - effectDiv.addEventListener('mousedown', (e) => { - // Only drag if not clicking on a handle - if (!e.target.classList.contains('effect-handle')) { - e.stopPropagation(); - startDrag(e, 'effect', seqIndex, effectIndex); - } - }); - effectDiv.addEventListener('click', (e) => { - e.stopPropagation(); - selectItem('effect', seqIndex, effectIndex); - }); - - timeline.appendChild(effectDiv); - }); } }); - - // Set timeline minimum height to fit all sequences - timeline.style.minHeight = `${Math.max(totalTimelineHeight, timelineContent.offsetHeight)}px`; - - // Update playback indicator height to match - if (playbackIndicator) { - playbackIndicator.style.height = `${Math.max(totalTimelineHeight, timelineContent.offsetHeight)}px`; - } - + 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(); } - // Drag handling + // Drag function startDrag(e, type, seqIndex, effectIndex = null) { - e.preventDefault(); - isDragging = true; - - // Calculate offset from timeline origin (not from element edge) - // CRITICAL: Use currentTarget (element with listener) not target (what was clicked) - const timelineRect = timeline.getBoundingClientRect(); + e.preventDefault(); state.isDragging = true; + const timelineRect = dom.timeline.getBoundingClientRect(); const currentLeft = parseFloat(e.currentTarget.style.left) || 0; - dragOffset.x = e.clientX - timelineRect.left - currentLeft; - dragOffset.y = e.clientY - e.currentTarget.getBoundingClientRect().top; - - selectedItem = { type, index: seqIndex, seqIndex, effectIndex }; - renderTimeline(); - updateProperties(); - - document.addEventListener('mousemove', onDrag); - document.addEventListener('mouseup', stopDrag); + state.dragOffset.x = e.clientX - timelineRect.left - currentLeft; + state.dragOffset.y = e.clientY - e.currentTarget.getBoundingClientRect().top; + state.selectedItem = { type, index: seqIndex, seqIndex, effectIndex }; + renderTimeline(); updateProperties(); + document.addEventListener('mousemove', onDrag); document.addEventListener('mouseup', stopDrag); } function onDrag(e) { - if (!isDragging || !selectedItem) return; - - const timelineRect = timeline.getBoundingClientRect(); - const newX = e.clientX - timelineRect.left - dragOffset.x; - let newTime = Math.max(0, newX / pixelsPerSecond); - - // Snap to beat when enabled - if (showBeats) { - newTime = Math.round(newTime); - } - - if (selectedItem.type === 'sequence') { - sequences[selectedItem.index].startTime = Math.round(newTime * 100) / 100; - } else if (selectedItem.type === 'effect') { - // Effects have times relative to their parent sequence - const seq = sequences[selectedItem.seqIndex]; - const effect = seq.effects[selectedItem.effectIndex]; - const duration = effect.endTime - effect.startTime; - - // Convert absolute timeline position to relative time within sequence - const relativeTime = newTime - seq.startTime; - effect.startTime = Math.round(relativeTime * 100) / 100; - effect.endTime = effect.startTime + duration; + if (!state.isDragging || !state.selectedItem) return; + const timelineRect = dom.timeline.getBoundingClientRect(); + let newTime = Math.max(0, (e.clientX - timelineRect.left - state.dragOffset.x) / state.pixelsPerSecond); + if (state.showBeats) newTime = Math.round(newTime); + if (state.selectedItem.type === 'sequence') state.sequences[state.selectedItem.index].startTime = Math.round(newTime * 100) / 100; + 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 = Math.round(relativeTime * 100) / 100; effect.endTime = effect.startTime + duration; } - - renderTimeline(); - updateProperties(); + renderTimeline(); updateProperties(); } function stopDrag() { - isDragging = false; - document.removeEventListener('mousemove', onDrag); - document.removeEventListener('mouseup', stopDrag); + state.isDragging = false; + document.removeEventListener('mousemove', onDrag); document.removeEventListener('mouseup', stopDrag); } - // Handle dragging (for resizing effects) function startHandleDrag(e, type, seqIndex, effectIndex) { - e.preventDefault(); - isDraggingHandle = true; - handleType = type; - selectedItem = { type: 'effect', seqIndex, effectIndex, index: seqIndex }; - renderTimeline(); - updateProperties(); - - document.addEventListener('mousemove', onHandleDrag); - document.addEventListener('mouseup', stopHandleDrag); + e.preventDefault(); state.isDraggingHandle = true; state.handleType = type; + state.selectedItem = { type: 'effect', seqIndex, effectIndex, index: seqIndex }; + renderTimeline(); updateProperties(); + document.addEventListener('mousemove', onHandleDrag); document.addEventListener('mouseup', stopHandleDrag); } function onHandleDrag(e) { - if (!isDraggingHandle || !selectedItem) return; - - const timelineRect = timeline.getBoundingClientRect(); - const newX = e.clientX - timelineRect.left; - let newTime = Math.max(0, newX / pixelsPerSecond); - - // Snap to beat when enabled - if (showBeats) { - newTime = Math.round(newTime); - } - - const seq = sequences[selectedItem.seqIndex]; - const effect = seq.effects[selectedItem.effectIndex]; - - // Convert to relative time + if (!state.isDraggingHandle || !state.selectedItem) return; + const timelineRect = dom.timeline.getBoundingClientRect(); + let newTime = Math.max(0, (e.clientX - timelineRect.left) / state.pixelsPerSecond); + if (state.showBeats) newTime = Math.round(newTime); + const seq = state.sequences[state.selectedItem.seqIndex], effect = seq.effects[state.selectedItem.effectIndex]; const relativeTime = newTime - seq.startTime; - - if (handleType === 'left') { - // Adjust start time, keep end time fixed - // Allow negative times (effect can extend before sequence start) - const newStartTime = Math.round(relativeTime * 100) / 100; - effect.startTime = Math.min(newStartTime, effect.endTime - 0.1); - } else if (handleType === 'right') { - // Adjust end time, keep start time fixed - effect.endTime = Math.max(effect.startTime + 0.1, Math.round(relativeTime * 100) / 100); - } - - renderTimeline(); - updateProperties(); + if (state.handleType === 'left') effect.startTime = Math.min(Math.round(relativeTime * 100) / 100, effect.endTime - 0.1); + else if (state.handleType === 'right') effect.endTime = Math.max(effect.startTime + 0.1, Math.round(relativeTime * 100) / 100); + renderTimeline(); updateProperties(); } function stopHandleDrag() { - isDraggingHandle = false; - handleType = null; - document.removeEventListener('mousemove', onHandleDrag); - document.removeEventListener('mouseup', stopHandleDrag); + state.isDraggingHandle = false; state.handleType = null; + document.removeEventListener('mousemove', onHandleDrag); document.removeEventListener('mouseup', stopHandleDrag); } - // Selection function selectItem(type, seqIndex, effectIndex = null) { - selectedItem = { type, index: seqIndex, seqIndex, effectIndex }; - renderTimeline(); - updateProperties(); - deleteBtn.disabled = false; + state.selectedItem = { type, index: seqIndex, seqIndex, effectIndex }; + renderTimeline(); updateProperties(); dom.deleteBtn.disabled = false; } - // Properties panel + // Properties function updateProperties() { - if (!selectedItem) { - propertiesPanel.style.display = 'none'; - return; - } - - propertiesPanel.style.display = 'block'; - - if (selectedItem.type === 'sequence') { - const seq = sequences[selectedItem.index]; - propertiesContent.innerHTML = ` - <div class="property-group"> - <label>Name</label> - <input type="text" id="propName" value="${seq.name || ''}" placeholder="Sequence name" oninput="autoApplyProperties()"> - </div> - <div class="property-group"> - <label>Start Time (seconds)</label> - <input type="number" id="propStartTime" value="${seq.startTime}" step="0.1" min="0" oninput="autoApplyProperties()"> - </div> + if (!state.selectedItem) { dom.propertiesPanel.style.display = 'none'; return; } + dom.propertiesPanel.style.display = 'block'; + if (state.selectedItem.type === 'sequence') { + const seq = state.sequences[state.selectedItem.index]; + dom.propertiesContent.innerHTML = ` + <div class="property-group"><label>Name</label><input type="text" id="propName" value="${seq.name || ''}" placeholder="Sequence name"></div> + <div class="property-group"><label>Start Time (seconds)</label><input type="number" id="propStartTime" value="${seq.startTime}" step="0.1" min="0"></div> `; - } else if (selectedItem.type === 'effect') { - const effect = sequences[selectedItem.seqIndex].effects[selectedItem.effectIndex]; - const effects = sequences[selectedItem.seqIndex].effects; - const canMoveUp = selectedItem.effectIndex < effects.length - 1; - const canMoveDown = selectedItem.effectIndex > 0; + document.getElementById('propName').addEventListener('input', applyProperties); + document.getElementById('propStartTime').addEventListener('input', applyProperties); + } else if (state.selectedItem.type === 'effect') { + const effect = state.sequences[state.selectedItem.seqIndex].effects[state.selectedItem.effectIndex]; + const effects = state.sequences[state.selectedItem.seqIndex].effects; + const canMoveUp = state.selectedItem.effectIndex < effects.length - 1, canMoveDown = state.selectedItem.effectIndex > 0; const samePriority = effect.priorityModifier === '='; - - propertiesContent.innerHTML = ` - <div class="property-group"> - <label>Effect Class</label> - <input type="text" id="propClassName" value="${effect.className}" oninput="autoApplyProperties()"> - </div> - <div class="property-group"> - <label>Start Time (relative to sequence)</label> - <input type="number" id="propStartTime" value="${effect.startTime}" step="0.1" oninput="autoApplyProperties()"> - </div> - <div class="property-group"> - <label>End Time (relative to sequence)</label> - <input type="number" id="propEndTime" value="${effect.endTime}" step="0.1" oninput="autoApplyProperties()"> - </div> - <div class="property-group"> - <label>Constructor Arguments</label> - <input type="text" id="propArgs" value="${effect.args || ''}" oninput="autoApplyProperties()"> - </div> - <div class="property-group"> - <label>Stack Position (determines priority)</label> + 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>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;"> - <button onclick="moveEffectUp()" ${!canMoveUp ? 'disabled' : ''} style="flex: 1;">↑ Up</button> - <button onclick="moveEffectDown()" ${!canMoveDown ? 'disabled' : ''} style="flex: 1;">↓ Down</button> + <button id="moveUpBtn" ${!canMoveUp ? 'disabled' : ''} style="flex: 1;">↑ Up</button> + <button id="moveDownBtn" ${!canMoveDown ? 'disabled' : ''} style="flex: 1;">↓ Down</button> </div> - <button onclick="toggleSamePriority()" style="width: 100%;"> - ${samePriority ? '✓ Same as Above (=)' : 'Increment (+)'} - </button> + <button id="togglePriorityBtn" style="width: 100%;">${samePriority ? '✓ Same as Above (=)' : 'Increment (+)'}</button> </div> `; + document.getElementById('propClassName').addEventListener('input', applyProperties); + document.getElementById('propStartTime').addEventListener('input', applyProperties); + document.getElementById('propEndTime').addEventListener('input', applyProperties); + document.getElementById('propArgs').addEventListener('input', applyProperties); + document.getElementById('moveUpBtn').addEventListener('click', moveEffectUp); + document.getElementById('moveDownBtn').addEventListener('click', moveEffectDown); + document.getElementById('togglePriorityBtn').addEventListener('click', toggleSamePriority); } } - // Auto-apply properties on input change (no Apply button needed) - function autoApplyProperties() { - if (!selectedItem) return; - - if (selectedItem.type === 'sequence') { - const seq = sequences[selectedItem.index]; + function applyProperties() { + if (!state.selectedItem) return; + if (state.selectedItem.type === 'sequence') { + const seq = state.sequences[state.selectedItem.index]; seq.name = document.getElementById('propName').value; seq.startTime = parseFloat(document.getElementById('propStartTime').value); - } else if (selectedItem.type === 'effect') { - const effect = sequences[selectedItem.seqIndex].effects[selectedItem.effectIndex]; + } else if (state.selectedItem.type === 'effect') { + const effect = state.sequences[state.selectedItem.seqIndex].effects[state.selectedItem.effectIndex]; effect.className = document.getElementById('propClassName').value; effect.startTime = parseFloat(document.getElementById('propStartTime').value); effect.endTime = parseFloat(document.getElementById('propEndTime').value); effect.args = document.getElementById('propArgs').value; } - - // Re-render timeline (recalculates sequence bounds) renderTimeline(); } - // Move effect up in stack (higher priority) function moveEffectUp() { - if (!selectedItem || selectedItem.type !== 'effect') return; - - const effects = sequences[selectedItem.seqIndex].effects; - const index = selectedItem.effectIndex; - + if (!state.selectedItem || state.selectedItem.type !== 'effect') return; + const effects = state.sequences[state.selectedItem.seqIndex].effects, index = state.selectedItem.effectIndex; if (index < effects.length - 1) { - // Swap with effect above [effects[index], effects[index + 1]] = [effects[index + 1], effects[index]]; - selectedItem.effectIndex = index + 1; - renderTimeline(); - updateProperties(); + state.selectedItem.effectIndex = index + 1; renderTimeline(); updateProperties(); } } - // Move effect down in stack (lower priority) function moveEffectDown() { - if (!selectedItem || selectedItem.type !== 'effect') return; - - const effects = sequences[selectedItem.seqIndex].effects; - const index = selectedItem.effectIndex; - + if (!state.selectedItem || state.selectedItem.type !== 'effect') return; + const effects = state.sequences[state.selectedItem.seqIndex].effects, index = state.selectedItem.effectIndex; if (index > 0) { - // Swap with effect below [effects[index], effects[index - 1]] = [effects[index - 1], effects[index]]; - selectedItem.effectIndex = index - 1; - renderTimeline(); - updateProperties(); + state.selectedItem.effectIndex = index - 1; renderTimeline(); updateProperties(); } } - // Toggle same priority as previous effect (= modifier) function toggleSamePriority() { - if (!selectedItem || selectedItem.type !== 'effect') return; - - const effect = sequences[selectedItem.seqIndex].effects[selectedItem.effectIndex]; + if (!state.selectedItem || state.selectedItem.type !== 'effect') return; + const effect = state.sequences[state.selectedItem.seqIndex].effects[state.selectedItem.effectIndex]; effect.priorityModifier = effect.priorityModifier === '=' ? '+' : '='; updateProperties(); } - // File operations - fileInput.addEventListener('change', (e) => { + // 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 = state.sequences.reduce((max, seq) => { + const seqMax = seq.effects.reduce((m, e) => Math.max(m, seq.startTime + e.endTime), seq.startTime); + return Math.max(max, seqMax); + }, 0); + dom.stats.innerHTML = `📊 Sequences: ${state.sequences.length} | 🎬 Effects: ${effectCount} | ⏱️ Duration: ${maxTime.toFixed(2)}s`; + } + + async function loadFromURLParams() { + const params = new URLSearchParams(window.location.search); + const seqURL = params.get('seq'), wavURL = params.get('wav'); + if (seqURL) { + try { + 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.currentFile = seqURL.split('/').pop(); + renderTimeline(); dom.saveBtn.disabled = false; dom.addSequenceBtn.disabled = false; dom.reorderBtn.disabled = false; + showMessage(`Loaded ${state.currentFile} from URL`, 'success'); + } catch (err) { showMessage(`Error loading seq file: ${err.message}`, 'error'); } + } + if (wavURL) { + 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); + } catch (err) { showMessage(`Error loading audio file: ${err.message}`, 'error'); } + } + } + + // Event handlers + dom.fileInput.addEventListener('change', e => { const file = e.target.files[0]; if (!file) return; - - currentFile = file.name; + state.currentFile = file.name; const reader = new FileReader(); - - reader.onload = (e) => { + reader.onload = e => { try { const parsed = parseSeqFile(e.target.result); - sequences = parsed.sequences; - bpm = parsed.bpm; - document.getElementById('currentBPM').textContent = bpm; - document.getElementById('bpmSlider').value = bpm; - renderTimeline(); - saveBtn.disabled = false; - addSequenceBtn.disabled = false; - reorderBtn.disabled = false; - showMessage(`Loaded ${currentFile} - ${sequences.length} sequences`, 'success'); - } catch (err) { - showMessage(`Error parsing file: ${err.message}`, 'error'); - } + state.sequences = parsed.sequences; state.bpm = parsed.bpm; + dom.currentBPM.textContent = state.bpm; dom.bpmSlider.value = state.bpm; + renderTimeline(); dom.saveBtn.disabled = false; dom.addSequenceBtn.disabled = false; dom.reorderBtn.disabled = false; + showMessage(`Loaded ${state.currentFile} - ${state.sequences.length} sequences`, 'success'); + } catch (err) { showMessage(`Error parsing file: ${err.message}`, 'error'); } }; - reader.readAsText(file); }); - saveBtn.addEventListener('click', () => { - const content = serializeSeqFile(sequences); - const blob = new Blob([content], { type: 'text/plain' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = currentFile || 'timeline.seq'; - a.click(); - URL.revokeObjectURL(url); + dom.saveBtn.addEventListener('click', () => { + const content = serializeSeqFile(state.sequences), blob = new Blob([content], { type: 'text/plain' }); + const url = URL.createObjectURL(blob), a = document.createElement('a'); + a.href = url; a.download = state.currentFile || 'timeline.seq'; a.click(); URL.revokeObjectURL(url); showMessage('File saved', 'success'); }); - audioInput.addEventListener('change', (e) => { - const file = e.target.files[0]; - if (!file) return; - loadAudioFile(file); - }); - - clearAudioBtn.addEventListener('click', () => { - clearAudio(); - audioInput.value = ''; // Reset file input - }); - - playPauseBtn.addEventListener('click', async () => { - if (isPlaying) { - stopPlayback(); - } else { - // Reset to beginning if at end - if (playbackOffset >= audioDuration) { - playbackOffset = 0; - } - await startPlayback(); - } + 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(); } }); - // Waveform click to seek - waveformContainer.addEventListener('click', async (e) => { - if (!audioBuffer) return; - - const rect = waveformContainer.getBoundingClientRect(); - const clickX = e.clientX - rect.left + timelineContent.scrollLeft; - const clickBeats = clickX / pixelsPerSecond; - const clickTime = clickBeats * 60.0 / bpm; - - const wasPlaying = isPlaying; - if (wasPlaying) { - stopPlayback(false); // Don't save position, we're jumping - } - - playbackOffset = Math.max(0, Math.min(clickTime, audioDuration)); - - // Update display and position - const clickBeats = playbackOffset * bpm / 60.0; - playbackTime.textContent = `${playbackOffset.toFixed(2)}s (${clickBeats.toFixed(2)}b)`; - const indicatorX = clickBeats * pixelsPerSecond; - playbackIndicator.style.left = `${indicatorX}px`; - waveformPlaybackIndicator.style.left = `${indicatorX}px`; - - if (wasPlaying) { - await startPlayback(); - } + dom.waveformContainer.addEventListener('click', async e => { + if (!state.audioBuffer) return; + const rect = dom.waveformContainer.getBoundingClientRect(); + const clickX = e.clientX - rect.left + dom.timelineContent.scrollLeft; + const clickTime = (clickX / state.pixelsPerSecond) * 60.0 / state.bpm; + const wasPlaying = state.isPlaying; + if (wasPlaying) stopPlayback(false); + state.playbackOffset = Math.max(0, Math.min(clickTime, state.audioDuration)); + const clickBeats = state.playbackOffset * state.bpm / 60.0; + dom.playbackTime.textContent = `${state.playbackOffset.toFixed(2)}s (${clickBeats.toFixed(2)}b)`; + const indicatorX = clickBeats * state.pixelsPerSecond; + dom.playbackIndicator.style.left = `${indicatorX}px`; + dom.waveformPlaybackIndicator.style.left = `${indicatorX}px`; + if (wasPlaying) await startPlayback(); }); - addSequenceBtn.addEventListener('click', () => { - sequences.push({ - type: 'sequence', - startTime: 0, - priority: 0, - effects: [], - _collapsed: true - }); - renderTimeline(); - showMessage('New sequence added', 'success'); + dom.addSequenceBtn.addEventListener('click', () => { + state.sequences.push({ type: 'sequence', startTime: 0, priority: 0, effects: [], _collapsed: true }); + renderTimeline(); showMessage('New sequence added', 'success'); }); - deleteBtn.addEventListener('click', () => { - if (!selectedItem) return; - - if (selectedItem.type === 'sequence') { - sequences.splice(selectedItem.index, 1); - } else if (selectedItem.type === 'effect') { - sequences[selectedItem.seqIndex].effects.splice(selectedItem.effectIndex, 1); - } - - selectedItem = null; - deleteBtn.disabled = true; - renderTimeline(); - updateProperties(); + dom.deleteBtn.addEventListener('click', () => { + if (!state.selectedItem) return; + if (state.selectedItem.type === 'sequence') state.sequences.splice(state.selectedItem.index, 1); + else if (state.selectedItem.type === 'effect') state.sequences[state.selectedItem.seqIndex].effects.splice(state.selectedItem.effectIndex, 1); + state.selectedItem = null; dom.deleteBtn.disabled = true; renderTimeline(); updateProperties(); showMessage('Item deleted', 'success'); }); - // Re-order sequences by time - reorderBtn.addEventListener('click', () => { - // Store current active sequence (if any) - const currentActiveSeq = lastActiveSeqIndex >= 0 ? sequences[lastActiveSeqIndex] : null; - - // Sort sequences by start time (ascending) - sequences.sort((a, b) => a.startTime - b.startTime); - - // Re-render timeline - renderTimeline(); - - // Restore focus on previously active sequence + dom.reorderBtn.addEventListener('click', () => { + const currentActiveSeq = state.lastActiveSeqIndex >= 0 ? state.sequences[state.lastActiveSeqIndex] : null; + state.sequences.sort((a, b) => a.startTime - b.startTime); renderTimeline(); if (currentActiveSeq) { - const newIndex = sequences.indexOf(currentActiveSeq); - if (newIndex >= 0 && sequences[newIndex]._yPosition !== undefined) { - // Scroll to keep it in view - timelineContent.scrollTop = sequences[newIndex]._yPosition; - lastActiveSeqIndex = newIndex; + const newIndex = state.sequences.indexOf(currentActiveSeq); + if (newIndex >= 0 && state.sequences[newIndex]._yPosition !== undefined) { + dom.timelineContent.scrollTop = state.sequences[newIndex]._yPosition; state.lastActiveSeqIndex = newIndex; } } - showMessage('Sequences re-ordered by start time', 'success'); }); - // Zoom - zoomSlider.addEventListener('input', (e) => { - const zoom = parseInt(e.target.value); - pixelsPerSecond = zoom; - zoomLevel.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) => { - showBeats = e.target.checked; - renderTimeline(); - }); - - // Properties panel collapse/expand - const panelToggle = document.getElementById('panelToggle'); - const panelCollapseBtn = document.getElementById('panelCollapseBtn'); - - panelToggle.addEventListener('click', () => { - propertiesPanel.classList.add('collapsed'); - panelCollapseBtn.classList.add('visible'); - panelToggle.textContent = '▲ Expand'; - }); - - panelCollapseBtn.addEventListener('click', () => { - propertiesPanel.classList.remove('collapsed'); - panelCollapseBtn.classList.remove('visible'); - panelToggle.textContent = '▼ Collapse'; + dom.zoomSlider.addEventListener('input', e => { + state.pixelsPerSecond = parseInt(e.target.value); dom.zoomLevel.textContent = `${state.pixelsPerSecond}%`; + if (state.audioBuffer) renderWaveform(); renderTimeline(); }); - // Click outside to deselect - timeline.addEventListener('click', () => { - selectedItem = null; - deleteBtn.disabled = true; - renderTimeline(); - updateProperties(); + dom.bpmSlider.addEventListener('input', e => { + state.bpm = parseInt(e.target.value); dom.currentBPM.textContent = state.bpm; + if (state.audioBuffer) renderWaveform(); renderTimeline(); }); - // Double-click to seek (jump playback position) - timeline.addEventListener('dblclick', async (e) => { - // Only handle clicks on timeline background (not on sequences/effects) - if (e.target !== timeline) return; - - const timelineRect = timeline.getBoundingClientRect(); - const clickX = e.clientX - timelineRect.left + timelineContent.scrollLeft; - const clickBeats = clickX / pixelsPerSecond; - const clickTime = clickBeats * 60.0 / bpm; - - if (audioBuffer) { - const wasPlaying = isPlaying; - if (wasPlaying) { - stopPlayback(false); // Don't save position, we're jumping - } - - playbackOffset = Math.max(0, Math.min(clickTime, audioDuration)); - - // Update display and position - const pausedBeats = playbackOffset * bpm / 60.0; - playbackTime.textContent = `${playbackOffset.toFixed(2)}s (${pausedBeats.toFixed(2)}b)`; - const indicatorX = pausedBeats * pixelsPerSecond; - const indicatorHeight = Math.max(timeline.offsetHeight, timelineContent.offsetHeight); - playbackIndicator.style.left = `${indicatorX}px`; - playbackIndicator.style.height = `${indicatorHeight}px`; - waveformPlaybackIndicator.style.left = `${indicatorX}px`; - - if (wasPlaying) { - // Resume playback at new position - await startPlayback(); - } else { - // Brief flash when paused - playbackIndicator.classList.add('playing'); - waveformPlaybackIndicator.classList.add('playing'); - setTimeout(() => { - if (!isPlaying) { - playbackIndicator.classList.remove('playing'); - waveformPlaybackIndicator.classList.remove('playing'); - } - }, 500); - } + dom.showBeatsCheckbox.addEventListener('change', e => { state.showBeats = e.target.checked; renderTimeline(); }); + dom.panelToggle.addEventListener('click', () => { dom.propertiesPanel.classList.add('collapsed'); dom.panelCollapseBtn.classList.add('visible'); dom.panelToggle.textContent = '▲ Expand'; }); + 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; renderTimeline(); updateProperties(); }); + dom.timeline.addEventListener('dblclick', async e => { + if (e.target !== dom.timeline) return; + const timelineRect = dom.timeline.getBoundingClientRect(); + const clickX = e.clientX - timelineRect.left + dom.timelineContent.scrollLeft; + const clickBeats = clickX / state.pixelsPerSecond, clickTime = clickBeats * 60.0 / state.bpm; + if (state.audioBuffer) { + const wasPlaying = state.isPlaying; + if (wasPlaying) stopPlayback(false); + state.playbackOffset = Math.max(0, Math.min(clickTime, state.audioDuration)); + const pausedBeats = state.playbackOffset * state.bpm / 60.0; + dom.playbackTime.textContent = `${state.playbackOffset.toFixed(2)}s (${pausedBeats.toFixed(2)}b)`; + const indicatorX = pausedBeats * state.pixelsPerSecond; + dom.playbackIndicator.style.left = `${indicatorX}px`; dom.waveformPlaybackIndicator.style.left = `${indicatorX}px`; + if (wasPlaying) await startPlayback(); showMessage(`Seek to ${clickTime.toFixed(2)}s (${clickBeats.toFixed(2)}b)`, 'success'); } }); - // Keyboard shortcuts - document.addEventListener('keydown', (e) => { - // Spacebar: play/pause (if audio loaded) - if (e.code === 'Space' && audioBuffer) { - e.preventDefault(); - playPauseBtn.click(); + document.addEventListener('keydown', e => { if (e.code === 'Space' && state.audioBuffer) { e.preventDefault(); dom.playPauseBtn.click(); } }); + + dom.timelineContent.addEventListener('scroll', () => { + if (dom.waveformCanvas) { + dom.waveformCanvas.style.left = `-${dom.timelineContent.scrollLeft}px`; + dom.waveformPlaybackIndicator.style.transform = `translateX(-${dom.timelineContent.scrollLeft}px)`; } }); - // Mouse wheel: zoom (with Ctrl/Cmd) or diagonal scroll - timelineContent.addEventListener('wheel', (e) => { + dom.timelineContent.addEventListener('wheel', e => { e.preventDefault(); - - // Zoom mode: Ctrl/Cmd + wheel if (e.ctrlKey || e.metaKey) { - // 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 = timelineContent.scrollLeft; - const timeUnderCursor = (scrollLeft + mouseX) / pixelsPerSecond; - - // Calculate new zoom level - const zoomDelta = e.deltaY > 0 ? -10 : 10; // Wheel down = zoom out, wheel up = zoom in - const oldPixelsPerSecond = pixelsPerSecond; - const newPixelsPerSecond = Math.max(10, Math.min(500, pixelsPerSecond + zoomDelta)); - + 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) { - pixelsPerSecond = newPixelsPerSecond; - - // Update zoom slider and labels - zoomSlider.value = pixelsPerSecond; - zoomLevel.textContent = `${pixelsPerSecond}%`; - - // Re-render waveform and timeline at new zoom - if (audioBuffer) { - renderWaveform(); - } - renderTimeline(); - - // 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; - timelineContent.scrollLeft = newScrollLeft; + 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; } - - // Normal mode: diagonal scroll - timelineContent.scrollLeft += e.deltaY; - - // Calculate current time position with 10% headroom for visual comfort - 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; - - // Find the closest sequence that should be visible at current time - // (the last sequence that starts before or at current time + slack) + 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 < sequences.length; i++) { - if (sequences[i].startTime <= currentTime) { - targetSeqIndex = i; - } else { - break; - } + for (let i = 0; i < state.sequences.length; i++) { + if (state.sequences[i].startTime <= currentTime) targetSeqIndex = i; else break; } - - // Flash effect when active sequence changes - if (targetSeqIndex !== lastActiveSeqIndex && sequences.length > 0) { - lastActiveSeqIndex = targetSeqIndex; - - // Add flash class to target sequence - const seqDivs = timeline.querySelectorAll('.sequence'); + 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'); - // Remove class after animation completes - setTimeout(() => { - seqDivs[targetSeqIndex]?.classList.remove('active-flash'); - }, 600); + setTimeout(() => seqDivs[targetSeqIndex]?.classList.remove('active-flash'), 600); } } - - // Smooth vertical scroll to bring target sequence to top of viewport - const targetScrollTop = sequences[targetSeqIndex]?._yPosition || 0; - const currentScrollTop = timelineContent.scrollTop; - const scrollDiff = targetScrollTop - currentScrollTop; - - // Smooth transition (don't jump instantly) - if (Math.abs(scrollDiff) > 5) { - timelineContent.scrollTop += scrollDiff * 0.3; - } + 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 }); - // Window resize handler - window.addEventListener('resize', () => { - renderTimeline(); - }); - - // Utilities - function showMessage(text, type) { - messageArea.innerHTML = `<div class="${type}">${text}</div>`; - setTimeout(() => messageArea.innerHTML = '', 3000); - } - - function updateStats() { - const effectCount = sequences.reduce((sum, seq) => sum + seq.effects.length, 0); - const maxTime = sequences.reduce((max, seq) => { - const seqMax = seq.effects.reduce((m, e) => Math.max(m, seq.startTime + e.endTime), seq.startTime); - return Math.max(max, seqMax); - }, 0); - - stats.innerHTML = ` - 📊 Sequences: ${sequences.length} | - 🎬 Effects: ${effectCount} | - ⏱️ Duration: ${maxTime.toFixed(2)}s - `; - } - - // Load files from URL parameters - async function loadFromURLParams() { - const params = new URLSearchParams(window.location.search); - const seqURL = params.get('seq'); - const wavURL = params.get('wav'); - - if (seqURL) { - try { - const response = await fetch(seqURL); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - const content = await response.text(); - const parsed = parseSeqFile(content); - sequences = parsed.sequences; - bpm = parsed.bpm; - document.getElementById('currentBPM').textContent = bpm; - document.getElementById('bpmSlider').value = bpm; - currentFile = seqURL.split('/').pop(); - renderTimeline(); - saveBtn.disabled = false; - addSequenceBtn.disabled = false; - reorderBtn.disabled = false; - showMessage(`Loaded ${currentFile} from URL`, 'success'); - } catch (err) { - showMessage(`Error loading seq file: ${err.message}`, 'error'); - } - } - - if (wavURL) { - try { - const response = await fetch(wavURL); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - const blob = await response.blob(); - const file = new File([blob], wavURL.split('/').pop(), { type: 'audio/wav' }); - await loadAudioFile(file); - } catch (err) { - showMessage(`Error loading audio file: ${err.message}`, 'error'); - } - } - } - - // Initial render - renderTimeline(); - loadFromURLParams(); + window.addEventListener('resize', renderTimeline); + renderTimeline(); loadFromURLParams(); </script> </body> </html> |
