first commit
This commit is contained in:
460
static/js/timeline.js
Normal file
460
static/js/timeline.js
Normal file
@@ -0,0 +1,460 @@
|
||||
/**
|
||||
* 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
|
||||
? '<option>No tracks generated yet...</option>'
|
||||
: playlist.map(t =>
|
||||
`<option value="${t.id}">Chapter ${t.chapter} - Section ${t.section}</option>`
|
||||
).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();
|
||||
});
|
||||
Reference in New Issue
Block a user