Files
audiobook-studio-pro-v3/static/js/timeline.js
Ashim Kumar 11d715eb85 first commit
2026-01-09 21:06:30 +06:00

461 lines
13 KiB
JavaScript

/**
* 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();
});