/** * Timeline Module * Handles WaveSurfer, audio playback, word pills, and timeline interactions */ // ========================================== // GLOBAL STATE VARIABLES // ========================================== // --- WaveSurfer Instance --- let wavesurfer = null; // --- Transcription Data --- let transcriptionData = []; // --- Timeline Settings --- let pixelsPerSecond = 100; let audioDuration = 0; // --- Current Audio --- let currentAudioData = ""; let currentAudioFormat = "mp3"; let currentProjectId = null; // --- Playlist --- let playlist = []; let currentTrackIndex = -1; // --- Drag/Resize State --- let isDragging = false; let isResizing = false; let isScrubbing = false; let currentPill = null; let currentIndex = -1; let selectedPillIndex = -1; let hasMovedDuringDrag = false; // ========================================== // WAVESURFER INITIALIZATION // ========================================== /** * Initialize WaveSurfer from base64 audio data * @param {string} base64Data - Base64 encoded audio * @param {string} format - Audio format (mp3, wav, etc.) */ function initWaveSurferFromBase64(base64Data, format) { if (wavesurfer) wavesurfer.destroy(); const byteArray = Uint8Array.from(atob(base64Data), c => c.charCodeAt(0)); const blob = new Blob([byteArray], { type: `audio/${format}` }); const url = URL.createObjectURL(blob); wavesurfer = WaveSurfer.create({ container: '#waveform', waveColor: '#a5b4fc', progressColor: '#818cf8', url, height: 120, normalize: true, minPxPerSec: pixelsPerSecond, hideScrollbar: true, plugins: [ WaveSurfer.Timeline.create({ container: '#timeline-ruler', height: 25 }) ] }); wavesurfer.on('decode', () => { audioDuration = wavesurfer.getDuration(); updateTimelineWidth(); renderPills(); }); wavesurfer.on('timeupdate', (t) => { if (!isScrubbing) { document.getElementById('custom-playhead').style.left = `${t * pixelsPerSecond}px`; const wrapper = document.getElementById('timelineWrapper'); if (t * pixelsPerSecond > wrapper.clientWidth / 2) { wrapper.scrollLeft = t * pixelsPerSecond - wrapper.clientWidth / 2; } } syncReader(t); }); wavesurfer.on('finish', updatePlayBtn); initScrubber(); } // ========================================== // PLAYHEAD/SCRUBBER FUNCTIONS // ========================================== /** * Initialize scrubber interactions */ function initScrubber() { const playhead = document.getElementById('custom-playhead'); const wrapper = document.getElementById('timelineContent'); playhead.onmousedown = (e) => { isScrubbing = true; e.preventDefault(); }; document.onmousemove = (e) => { if (!isScrubbing) return; const time = Math.max(0, Math.min( (e.clientX - wrapper.getBoundingClientRect().left) / pixelsPerSecond, audioDuration )); playhead.style.left = `${time * pixelsPerSecond}px`; wavesurfer.setTime(time); }; document.onmouseup = () => isScrubbing = false; wrapper.onclick = (e) => { if (!isDragging && !e.target.closest('.word-pill')) { const time = (e.clientX - wrapper.getBoundingClientRect().left) / pixelsPerSecond; if (time >= 0) wavesurfer.setTime(time); } }; } // ========================================== // TIMELINE WIDTH UPDATE // ========================================== /** * Update timeline width based on audio duration and zoom */ function updateTimelineWidth() { if (wavesurfer) { const w = wavesurfer.getDuration() * pixelsPerSecond; document.getElementById('timelineContent').style.width = w + 'px'; document.getElementById('waveform').style.width = w + 'px'; } } // ========================================== // WORD PILL FUNCTIONS // ========================================== /** * Insert new pill at playhead position */ function insertPillAtPlayhead() { if (!wavesurfer) return; const t = wavesurfer.getCurrentTime(); transcriptionData.push({ word: "New", start: t, end: t + 0.5 }); transcriptionData.sort((a, b) => a.start - b.start); renderPills(); runSmartSync(); } /** * Delete currently selected pill */ function deleteSelectedPill() { if (selectedPillIndex === -1) return; transcriptionData.splice(selectedPillIndex, 1); selectedPillIndex = -1; renderPills(); runSmartSync(); document.getElementById('deleteBtn').disabled = true; } /** * Select a pill by index * @param {number} index - Pill index */ function selectPill(index) { document.querySelectorAll('.word-pill').forEach(p => p.classList.remove('selected')); const pills = document.querySelectorAll('.word-pill'); if (pills[index]) { pills[index].classList.add('selected'); selectedPillIndex = index; document.getElementById('deleteBtn').disabled = false; } } /** * Render all word pills on the timeline */ function renderPills() { const container = document.getElementById('transcription-content'); container.innerHTML = ''; transcriptionData.forEach((item, index) => { const pill = document.createElement('div'); pill.className = `word-pill ${index === selectedPillIndex ? 'selected' : ''}`; pill.textContent = item.word; pill.dataset.index = index; pill.style.left = `${item.start * pixelsPerSecond}px`; pill.style.width = `${(item.end - item.start) * pixelsPerSecond}px`; // Resize handles const lh = document.createElement('div'); lh.className = 'resize-handle resize-handle-left'; const rh = document.createElement('div'); rh.className = 'resize-handle resize-handle-right'; pill.append(lh, rh); // Event handlers pill.onmousedown = handleDragStart; lh.onmousedown = (e) => handleResizeStart(e, 'left'); rh.onmousedown = (e) => handleResizeStart(e, 'right'); pill.onclick = (e) => { e.stopPropagation(); selectPill(index); }; pill.ondblclick = (e) => { e.stopPropagation(); const input = document.createElement('input'); input.value = item.word; input.style.cssText = 'all:unset;width:100%;text-align:center;'; pill.innerHTML = ''; pill.appendChild(input); input.focus(); input.onblur = () => { item.word = input.value; renderPills(); runSmartSync(); }; input.onkeydown = (ev) => { if (ev.key === 'Enter') input.blur(); }; }; container.appendChild(pill); }); } // ========================================== // PILL DRAG/RESIZE HANDLERS // ========================================== /** * Handle pill drag start * @param {MouseEvent} e - Mouse event */ function handleDragStart(e) { if (e.target.classList.contains('resize-handle') || e.target.tagName === 'INPUT') return; isDragging = true; hasMovedDuringDrag = false; currentPill = e.currentTarget; currentIndex = parseInt(currentPill.dataset.index); const startX = e.clientX; const initialLeft = parseFloat(currentPill.style.left); selectPill(currentIndex); const onMove = (me) => { hasMovedDuringDrag = true; const newStart = Math.max(0, (initialLeft + (me.clientX - startX)) / pixelsPerSecond); const dur = transcriptionData[currentIndex].end - transcriptionData[currentIndex].start; transcriptionData[currentIndex].start = newStart; transcriptionData[currentIndex].end = newStart + dur; currentPill.style.left = `${newStart * pixelsPerSecond}px`; }; const onUp = () => { isDragging = false; document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); if (hasMovedDuringDrag) { transcriptionData.sort((a, b) => a.start - b.start); renderPills(); } }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); } /** * Handle pill resize start * @param {MouseEvent} e - Mouse event * @param {string} side - 'left' or 'right' */ function handleResizeStart(e, side) { e.stopPropagation(); isResizing = true; currentPill = e.target.parentElement; currentIndex = parseInt(currentPill.dataset.index); selectPill(currentIndex); const onMove = (me) => { const time = (me.clientX - document.getElementById('transcription-content').getBoundingClientRect().left) / pixelsPerSecond; if (side === 'left') { transcriptionData[currentIndex].start = Math.max(0, Math.min(time, transcriptionData[currentIndex].end - 0.1)); } else { transcriptionData[currentIndex].end = Math.max(time, transcriptionData[currentIndex].start + 0.1); } renderPills(); }; const onUp = () => { isResizing = false; document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); } // ========================================== // PLAYBACK CONTROL FUNCTIONS // ========================================== /** * Toggle play/pause */ function togglePlayPause() { if (wavesurfer) wavesurfer.playPause(); updatePlayBtn(); } /** * Stop audio playback */ function stopAudio() { if (wavesurfer) wavesurfer.stop(); updatePlayBtn(); } /** * Update play button state */ function updatePlayBtn() { const playing = wavesurfer?.isPlaying(); document.getElementById('playIcon').className = playing ? 'bi bi-pause-fill text-primary' : 'bi bi-play-fill text-primary'; document.getElementById('playText').textContent = playing ? 'Pause' : 'Play'; } // ========================================== // PLAYLIST FUNCTIONS // ========================================== /** * Update playlist dropdown UI */ function updatePlaylistUI() { const select = document.getElementById('trackSelect'); select.innerHTML = playlist.length === 0 ? '' : playlist.map(t => `` ).join(''); } /** * Load track from playlist by ID * @param {string} id - Track ID */ function loadTrackFromPlaylist(id) { const idx = playlist.findIndex(t => t.id == id); if (idx === -1) return; currentTrackIndex = idx; const track = playlist[idx]; document.getElementById('trackSelect').value = track.id; document.getElementById('editorSection').classList.remove('d-none'); document.getElementById('editorSection').scrollIntoView({ behavior: 'smooth' }); document.getElementById('trackInfo').textContent = `Now Playing: Ch ${track.chapter} / Sec ${track.section}`; transcriptionData = track.transcription || []; currentAudioData = track.audioData; currentAudioFormat = track.audioFormat; if (track.audioData) { initWaveSurferFromBase64(track.audioData, track.audioFormat); } initReader(track.text); } /** * Play next track in playlist */ function playNextTrack() { if (currentTrackIndex < playlist.length - 1) { loadTrackFromPlaylist(playlist[currentTrackIndex + 1].id); } } /** * Play previous track in playlist */ function playPrevTrack() { if (currentTrackIndex > 0) { loadTrackFromPlaylist(playlist[currentTrackIndex - 1].id); } } // ========================================== // SLIDER EVENT HANDLERS // ========================================== /** * Initialize slider event handlers */ function initSliders() { // Zoom slider document.getElementById('zoomSlider').oninput = (e) => { pixelsPerSecond = parseInt(e.target.value); if (wavesurfer) { wavesurfer.zoom(pixelsPerSecond); updateTimelineWidth(); renderPills(); } }; // Speed slider document.getElementById('speedSlider').oninput = (e) => { const rate = parseFloat(e.target.value); if (wavesurfer) wavesurfer.setPlaybackRate(rate); document.getElementById('speedDisplay').textContent = rate.toFixed(1) + 'x'; }; } // ========================================== // KEYBOARD SHORTCUTS // ========================================== /** * Initialize keyboard shortcuts */ function initKeyboardShortcuts() { document.onkeydown = (e) => { // Ignore if typing in input/textarea or bulk editor if (['INPUT', 'TEXTAREA'].includes(e.target.tagName) || document.getElementById('bulk-editor').contains(e.target)) return; // Space - Play/Pause if (e.code === 'Space') { e.preventDefault(); togglePlayPause(); } // Delete - Delete selected pill if (e.code === 'Delete') deleteSelectedPill(); }; } // ========================================== // INITIALIZATION // ========================================== // Initialize on DOM ready document.addEventListener('DOMContentLoaded', function() { initSliders(); initKeyboardShortcuts(); });