first commit
This commit is contained in:
893
static/js/app.js
Normal file
893
static/js/app.js
Normal file
@@ -0,0 +1,893 @@
|
||||
/**
|
||||
* Main Application Module
|
||||
* Handles app initialization, API calls, database operations, and library management
|
||||
*/
|
||||
|
||||
// ==========================================
|
||||
// LOADER FUNCTIONS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Show loading overlay
|
||||
* @param {string} msg - Main message
|
||||
* @param {string} subtext - Subtext message
|
||||
*/
|
||||
function showLoader(msg, subtext = '') {
|
||||
document.getElementById('loadingText').textContent = msg;
|
||||
document.getElementById('loadingSubtext').textContent = subtext || 'Please wait...';
|
||||
document.getElementById('loader').style.display = 'flex';
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide loading overlay
|
||||
*/
|
||||
function hideLoader() {
|
||||
document.getElementById('loader').style.display = 'none';
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// UTILITY FUNCTIONS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Format bytes to human readable string
|
||||
* @param {number} bytes - Byte count
|
||||
* @returns {string} Formatted string
|
||||
*/
|
||||
function formatBytes(bytes) {
|
||||
if (!bytes) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date to human readable string
|
||||
* @param {string} dateStr - Date string
|
||||
* @returns {string} Formatted date
|
||||
*/
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// APP INITIALIZATION
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Initialize app with data
|
||||
* @param {Object} data - Audio and transcription data
|
||||
*/
|
||||
function initApp(data) {
|
||||
document.getElementById('editorSection').classList.remove('d-none');
|
||||
document.getElementById('editorSection').scrollIntoView({ behavior: 'smooth' });
|
||||
transcriptionData = data.transcription;
|
||||
currentAudioData = data.audio_data;
|
||||
currentAudioFormat = data.audio_format;
|
||||
initWaveSurferFromBase64(data.audio_data, data.audio_format);
|
||||
initReader(data.text_content);
|
||||
refreshLibraryStats();
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// UPLOAD FORM HANDLER
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Initialize upload form handler
|
||||
*/
|
||||
function initUploadForm() {
|
||||
document.getElementById('uploadForm').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const audioInput = document.getElementById('audioFile').files[0];
|
||||
const txtInput = document.getElementById('txtFile').files[0];
|
||||
|
||||
if (!audioInput || !txtInput) return;
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('audioFile', audioInput);
|
||||
fd.append('txtFile', txtInput);
|
||||
|
||||
showLoader("Uploading...", "Processing audio and text files...");
|
||||
|
||||
try {
|
||||
const res = await fetch('/upload', { method: 'POST', body: fd });
|
||||
const data = await res.json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
initApp(data);
|
||||
await refreshLibraryStats();
|
||||
} catch (e) {
|
||||
alert(e.message);
|
||||
} finally {
|
||||
hideLoader();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// PROJECT/DATABASE FUNCTIONS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Get or create project by name
|
||||
* @returns {Promise<number|null>} Project ID or null
|
||||
*/
|
||||
async function getOrCreateProject() {
|
||||
const projectName = document.getElementById('bulkProjectName').value.trim() || 'Book-1';
|
||||
|
||||
try {
|
||||
const listRes = await fetch('/projects');
|
||||
const listData = await listRes.json();
|
||||
const existing = listData.projects.find(p => p.name === projectName);
|
||||
|
||||
if (existing) {
|
||||
currentProjectId = existing.id;
|
||||
return existing.id;
|
||||
}
|
||||
|
||||
const createRes = await fetch('/projects', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ name: projectName })
|
||||
});
|
||||
const createData = await createRes.json();
|
||||
|
||||
if (createData.error) {
|
||||
console.error('Error creating project:', createData.error);
|
||||
return null;
|
||||
}
|
||||
|
||||
currentProjectId = createData.project_id;
|
||||
return createData.project_id;
|
||||
} catch (e) {
|
||||
console.error('Error getting/creating project:', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save all sections to database
|
||||
* @returns {Promise<boolean>} Success status
|
||||
*/
|
||||
async function saveAllSectionsToDatabase() {
|
||||
const projectId = await getOrCreateProject();
|
||||
if (!projectId) {
|
||||
console.error('Could not get project ID');
|
||||
return false;
|
||||
}
|
||||
|
||||
const sections = collectAllSectionsFromEditor();
|
||||
console.log(`💾 Saving ${sections.length} sections to database...`);
|
||||
|
||||
for (const sec of sections) {
|
||||
try {
|
||||
await fetch(`/projects/${projectId}/sections/save`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
chapter: sec.chapter,
|
||||
section: sec.section,
|
||||
text: sec.text || '',
|
||||
html_content: sec.htmlContent || '',
|
||||
tts_text: sec.ttsText || '',
|
||||
audio_data: sec.audioData || '',
|
||||
audio_format: sec.audioFormat || 'mp3',
|
||||
transcription: sec.transcription || [],
|
||||
voice: sec.voice || 'af_heart',
|
||||
image_data: sec.imageData || '',
|
||||
image_format: sec.imageFormat || 'png'
|
||||
})
|
||||
});
|
||||
console.log(` ✅ Saved Ch${sec.chapter}.Sec${sec.section}`);
|
||||
} catch (e) {
|
||||
console.error(` ❌ Error saving Ch${sec.chapter}.Sec${sec.section}:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
await refreshLibraryStats();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save single section to database
|
||||
* @param {number} chapterNum - Chapter number
|
||||
* @param {number} sectionNum - Section number
|
||||
* @param {Object} data - Section data
|
||||
* @returns {Promise<boolean>} Success status
|
||||
*/
|
||||
async function saveSectionToDatabase(chapterNum, sectionNum, data) {
|
||||
const projectId = await getOrCreateProject();
|
||||
if (!projectId) {
|
||||
console.error('Could not get project ID');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/projects/${projectId}/sections/save`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
chapter: chapterNum,
|
||||
section: sectionNum,
|
||||
text: data.text || '',
|
||||
html_content: data.htmlContent || '',
|
||||
tts_text: data.ttsText || '',
|
||||
audio_data: data.audioData || '',
|
||||
audio_format: data.audioFormat || 'mp3',
|
||||
transcription: data.transcription || [],
|
||||
voice: data.voice || 'af_heart',
|
||||
image_data: data.imageData || '',
|
||||
image_format: data.imageFormat || 'png'
|
||||
})
|
||||
});
|
||||
|
||||
const result = await res.json();
|
||||
if (result.error) {
|
||||
console.error('Error saving section:', result.error);
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log(`✅ Saved Ch${chapterNum}.Sec${sectionNum} to database`);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('Error saving section to database:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// AUDIO GENERATION FUNCTIONS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Generate audio for a marker (section or chapter)
|
||||
* @param {string} id - Marker ID
|
||||
*/
|
||||
async function generateMarkerAudio(id) {
|
||||
const markerEl = document.getElementById(`marker-${id}`);
|
||||
if (!markerEl) return;
|
||||
|
||||
const type = markerEl.dataset.type;
|
||||
const num = markerEl.querySelector('input[type="number"]').value;
|
||||
const voice = markerEl.querySelector('select').value;
|
||||
const allMarkers = Array.from(document.querySelectorAll('.editor-marker'));
|
||||
const myIdx = allMarkers.indexOf(markerEl);
|
||||
|
||||
// Save all sections first
|
||||
showLoader('Saving all sections...', 'Preserving your content before generation...');
|
||||
await saveAllSectionsToDatabase();
|
||||
|
||||
// --- Generate for single section ---
|
||||
if (type === 'section') {
|
||||
const htmlContent = extractHtmlForMarker(markerEl, allMarkers);
|
||||
const displayText = extractMarkdownForMarker(markerEl, allMarkers);
|
||||
|
||||
if (!displayText || displayText.trim().length < 2) {
|
||||
hideLoader();
|
||||
alert("No text found.");
|
||||
return;
|
||||
}
|
||||
|
||||
let genText = markerState[id]?.ttsText || extractPlainTextForMarker(markerEl, allMarkers);
|
||||
|
||||
// Get image data from marker state
|
||||
const imageData = markerState[id]?.imageData || '';
|
||||
const imageFormat = markerState[id]?.imageFormat || 'png';
|
||||
|
||||
// Find chapter number
|
||||
let chapterNum = 0;
|
||||
for (let i = myIdx - 1; i >= 0; i--) {
|
||||
if (allMarkers[i].classList.contains('chapter-marker')) {
|
||||
chapterNum = allMarkers[i].querySelector('input[type="number"]').value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const trackId = await processSingleSection(chapterNum, num, genText, displayText, htmlContent, voice, id, true, imageData, imageFormat);
|
||||
if (trackId) loadTrackFromPlaylist(trackId);
|
||||
|
||||
}
|
||||
// --- Generate for entire chapter ---
|
||||
else if (type === 'chapter') {
|
||||
await generateChapterAudio(markerEl, allMarkers, myIdx, num, voice, id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate audio for entire chapter
|
||||
* @param {Element} markerEl - Chapter marker element
|
||||
* @param {Element[]} allMarkers - All marker elements
|
||||
* @param {number} myIdx - Index of chapter marker
|
||||
* @param {number} num - Chapter number
|
||||
* @param {string} voice - Voice ID
|
||||
* @param {string} id - Marker ID
|
||||
*/
|
||||
async function generateChapterAudio(markerEl, allMarkers, myIdx, num, voice, id) {
|
||||
const sectionsToGenerate = [];
|
||||
|
||||
// Check for implicit content after chapter marker
|
||||
let hasDirectContent = false;
|
||||
let nextEl = markerEl.nextSibling;
|
||||
while (nextEl) {
|
||||
if (nextEl.nodeType === 1 && nextEl.classList && nextEl.classList.contains('editor-marker')) {
|
||||
if (nextEl.classList.contains('section-marker')) break;
|
||||
else if (nextEl.classList.contains('chapter-marker')) break;
|
||||
} else if (nextEl.nodeType === 1 || (nextEl.nodeType === 3 && nextEl.textContent.trim())) {
|
||||
hasDirectContent = true;
|
||||
}
|
||||
nextEl = nextEl.nextSibling;
|
||||
}
|
||||
|
||||
// Add implicit section if content exists without section markers
|
||||
if (hasDirectContent) {
|
||||
let nextMarkerIdx = myIdx + 1;
|
||||
let nextMarker = nextMarkerIdx < allMarkers.length ? allMarkers[nextMarkerIdx] : null;
|
||||
|
||||
if (!nextMarker || nextMarker.classList.contains('chapter-marker')) {
|
||||
const secHtml = extractHtmlForMarker(markerEl, allMarkers);
|
||||
const secDisplay = extractMarkdownForMarker(markerEl, allMarkers);
|
||||
const secPlain = extractPlainTextForMarker(markerEl, allMarkers);
|
||||
|
||||
if (secDisplay && secDisplay.trim().length > 1) {
|
||||
sectionsToGenerate.push({
|
||||
id: id + '_implicit_1',
|
||||
num: 1,
|
||||
genText: secPlain,
|
||||
displayText: secDisplay,
|
||||
htmlContent: secHtml,
|
||||
voice: voice,
|
||||
imageData: '',
|
||||
imageFormat: 'png'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collect explicit section markers in this chapter
|
||||
for (let i = myIdx + 1; i < allMarkers.length; i++) {
|
||||
const m = allMarkers[i];
|
||||
if (m.classList.contains('chapter-marker')) break;
|
||||
if (m.classList.contains('section-marker')) {
|
||||
const secId = m.dataset.markerId;
|
||||
const secNum = m.querySelector('input[type="number"]').value;
|
||||
const secVoice = m.querySelector('select').value;
|
||||
const secHtml = extractHtmlForMarker(m, allMarkers);
|
||||
const secDisplay = extractMarkdownForMarker(m, allMarkers);
|
||||
const secPlain = extractPlainTextForMarker(m, allMarkers);
|
||||
const secGen = markerState[secId]?.ttsText || secPlain;
|
||||
const imageData = markerState[secId]?.imageData || '';
|
||||
const imageFormat = markerState[secId]?.imageFormat || 'png';
|
||||
|
||||
if (secDisplay && secDisplay.trim().length > 1) {
|
||||
sectionsToGenerate.push({
|
||||
id: secId,
|
||||
num: secNum,
|
||||
genText: secGen,
|
||||
displayText: secDisplay,
|
||||
htmlContent: secHtml,
|
||||
voice: secVoice,
|
||||
imageData: imageData,
|
||||
imageFormat: imageFormat
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sectionsToGenerate.length === 0) {
|
||||
hideLoader();
|
||||
alert("No sections found in this chapter.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`📚 Found ${sectionsToGenerate.length} sections in Chapter ${num}`);
|
||||
|
||||
// Generate audio for each section
|
||||
showLoader(`Generating ${sectionsToGenerate.length} sections...`, 'This may take a while...');
|
||||
let firstId = null;
|
||||
|
||||
for (let i = 0; i < sectionsToGenerate.length; i++) {
|
||||
const sec = sectionsToGenerate[i];
|
||||
document.getElementById('loadingText').textContent = `Generating section ${i + 1} of ${sectionsToGenerate.length}...`;
|
||||
document.getElementById('loadingSubtext').textContent = `Chapter ${num}, Section ${sec.num}`;
|
||||
|
||||
console.log(`🔊 Generating Ch${num}.Sec${sec.num}...`);
|
||||
|
||||
const trackId = await processSingleSection(num, sec.num, sec.genText, sec.displayText, sec.htmlContent, sec.voice, sec.id, false, sec.imageData, sec.imageFormat);
|
||||
|
||||
if (trackId && i === 0) firstId = trackId;
|
||||
}
|
||||
|
||||
hideLoader();
|
||||
await refreshLibraryStats();
|
||||
|
||||
if (firstId) {
|
||||
loadTrackFromPlaylist(firstId);
|
||||
alert(`Chapter ${num} generation complete! Generated ${sectionsToGenerate.length} sections.`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process single section generation
|
||||
* @param {number} cNum - Chapter number
|
||||
* @param {number} sNum - Section number
|
||||
* @param {string} genText - Text for TTS generation
|
||||
* @param {string} displayText - Display text (markdown)
|
||||
* @param {string} htmlContent - HTML content
|
||||
* @param {string} voice - Voice ID
|
||||
* @param {string} markerId - Marker ID
|
||||
* @param {boolean} autoHide - Whether to auto-hide loader
|
||||
* @param {string} imageData - Base64 image data
|
||||
* @param {string} imageFormat - Image format (png, jpg, etc.)
|
||||
* @returns {Promise<string|null>} Track ID or null
|
||||
*/
|
||||
async function processSingleSection(cNum, sNum, genText, displayText, htmlContent, voice, markerId, autoHide = true, imageData = '', imageFormat = 'png') {
|
||||
if (autoHide) showLoader(`Generating Section ${cNum}.${sNum}...`, 'Creating audio and timestamps...');
|
||||
|
||||
try {
|
||||
const res = await fetch('/generate', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ text: genText, voice, save_to_db: false })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
|
||||
const track = {
|
||||
id: markerId || (Date.now() + '_' + Math.random().toString(36).substr(2, 9)),
|
||||
chapter: cNum,
|
||||
section: sNum,
|
||||
audioData: data.audio_data,
|
||||
audioFormat: data.audio_format,
|
||||
transcription: data.transcription,
|
||||
text: displayText,
|
||||
htmlContent: htmlContent,
|
||||
ttsText: genText,
|
||||
voice: voice,
|
||||
imageData: imageData,
|
||||
imageFormat: imageFormat
|
||||
};
|
||||
|
||||
// Update playlist
|
||||
const existingIdx = playlist.findIndex(t => t.chapter == cNum && t.section == sNum);
|
||||
if (existingIdx !== -1) {
|
||||
playlist[existingIdx] = track;
|
||||
} else {
|
||||
playlist.push(track);
|
||||
}
|
||||
|
||||
// Sort playlist by chapter then section
|
||||
playlist.sort((a, b) => {
|
||||
if (Number(a.chapter) !== Number(b.chapter)) return Number(a.chapter) - Number(b.chapter);
|
||||
return Number(a.section) - Number(b.section);
|
||||
});
|
||||
|
||||
updatePlaylistUI();
|
||||
|
||||
// Save to database
|
||||
await saveSectionToDatabase(cNum, sNum, track);
|
||||
|
||||
if (autoHide) {
|
||||
hideLoader();
|
||||
await refreshLibraryStats();
|
||||
}
|
||||
|
||||
return track.id;
|
||||
|
||||
} catch (e) {
|
||||
console.error('Generation error:', e);
|
||||
if (autoHide) {
|
||||
hideLoader();
|
||||
alert("Error: " + e.message);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// LIBRARY MODAL FUNCTIONS
|
||||
// ==========================================
|
||||
|
||||
let libraryModal = null;
|
||||
|
||||
/**
|
||||
* Open library modal
|
||||
*/
|
||||
function openLibrary() {
|
||||
if (!libraryModal) libraryModal = new bootstrap.Modal(document.getElementById('libraryModal'));
|
||||
loadLibraryData();
|
||||
libraryModal.show();
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh library statistics
|
||||
*/
|
||||
async function refreshLibraryStats() {
|
||||
try {
|
||||
const res = await fetch('/db/stats');
|
||||
const stats = await res.json();
|
||||
document.getElementById('statUploads').textContent = stats.uploads;
|
||||
document.getElementById('statGenerations').textContent = stats.generations;
|
||||
document.getElementById('statProjects').textContent = stats.projects;
|
||||
document.getElementById('statSections').textContent = stats.sections;
|
||||
document.getElementById('statDbSize').textContent = stats.database_size_mb + ' MB';
|
||||
console.log('📊 Stats:', stats);
|
||||
} catch (e) {
|
||||
console.error('Stats error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all library data
|
||||
*/
|
||||
async function loadLibraryData() {
|
||||
await refreshLibraryStats();
|
||||
await Promise.all([loadUploads(), loadGenerations(), loadProjects()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load uploads list
|
||||
*/
|
||||
async function loadUploads() {
|
||||
const container = document.getElementById('uploadsList');
|
||||
try {
|
||||
const data = await (await fetch('/uploads')).json();
|
||||
container.innerHTML = data.uploads.length === 0
|
||||
? '<div class="text-center text-muted py-4">No uploads yet</div>'
|
||||
: data.uploads.map(u => `
|
||||
<div class="library-item">
|
||||
<div class="library-item-info">
|
||||
<div class="library-item-title"><i class="bi bi-file-earmark-music me-2"></i>${u.filename}</div>
|
||||
<div class="library-item-meta">${u.audio_format.toUpperCase()} • ${formatBytes(u.audio_size)} • ${formatDate(u.created_at)}</div>
|
||||
</div>
|
||||
<div class="library-item-actions">
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="loadUpload(${u.id})"><i class="bi bi-play-fill"></i></button>
|
||||
<button class="btn btn-sm btn-outline-success" onclick="downloadUpload(${u.id})"><i class="bi bi-download"></i></button>
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="deleteUpload(${u.id})"><i class="bi bi-trash"></i></button>
|
||||
</div>
|
||||
</div>`).join('');
|
||||
} catch (e) {
|
||||
container.innerHTML = '<div class="text-danger">Failed to load</div>';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load generations list
|
||||
*/
|
||||
async function loadGenerations() {
|
||||
const container = document.getElementById('generationsList');
|
||||
try {
|
||||
const data = await (await fetch('/generations')).json();
|
||||
container.innerHTML = data.generations.length === 0
|
||||
? '<div class="text-center text-muted py-4">No generations yet</div>'
|
||||
: data.generations.map(g => `
|
||||
<div class="library-item">
|
||||
<div class="library-item-info">
|
||||
<div class="library-item-title"><i class="bi bi-soundwave me-2"></i>${g.name}</div>
|
||||
<div class="library-item-meta">Voice: ${g.voice} • ${formatBytes(g.audio_size)} • ${formatDate(g.created_at)}</div>
|
||||
</div>
|
||||
<div class="library-item-actions">
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="loadGeneration(${g.id})"><i class="bi bi-play-fill"></i></button>
|
||||
<button class="btn btn-sm btn-outline-success" onclick="downloadGeneration(${g.id})"><i class="bi bi-download"></i></button>
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="deleteGeneration(${g.id})"><i class="bi bi-trash"></i></button>
|
||||
</div>
|
||||
</div>`).join('');
|
||||
} catch (e) {
|
||||
container.innerHTML = '<div class="text-danger">Failed to load</div>';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load projects list
|
||||
*/
|
||||
async function loadProjects() {
|
||||
const container = document.getElementById('projectsList');
|
||||
try {
|
||||
const data = await (await fetch('/projects')).json();
|
||||
container.innerHTML = data.projects.length === 0
|
||||
? '<div class="text-center text-muted py-4">No projects yet</div>'
|
||||
: data.projects.map(p => `
|
||||
<div class="library-item">
|
||||
<div class="library-item-info">
|
||||
<div class="library-item-title"><i class="bi bi-folder me-2"></i>${p.name}</div>
|
||||
<div class="library-item-meta">${p.section_count} sections • ${formatDate(p.updated_at)}</div>
|
||||
</div>
|
||||
<div class="library-item-actions">
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="loadProject(${p.id})"><i class="bi bi-folder-symlink"></i></button>
|
||||
<button class="btn btn-sm btn-outline-success" onclick="downloadProject(${p.id})"><i class="bi bi-download"></i></button>
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="deleteProject(${p.id})"><i class="bi bi-trash"></i></button>
|
||||
</div>
|
||||
</div>`).join('');
|
||||
} catch (e) {
|
||||
container.innerHTML = '<div class="text-danger">Failed to load</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// LIBRARY ITEM LOADERS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Load upload by ID
|
||||
* @param {number} id - Upload ID
|
||||
*/
|
||||
async function loadUpload(id) {
|
||||
showLoader('Loading...', 'Retrieving upload data...');
|
||||
try {
|
||||
const data = await (await fetch(`/uploads/${id}`)).json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
if (libraryModal) libraryModal.hide();
|
||||
initApp(data);
|
||||
} catch (e) {
|
||||
alert(e.message);
|
||||
} finally {
|
||||
hideLoader();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load generation by ID
|
||||
* @param {number} id - Generation ID
|
||||
*/
|
||||
async function loadGeneration(id) {
|
||||
showLoader('Loading...', 'Retrieving generation data...');
|
||||
try {
|
||||
const data = await (await fetch(`/generations/${id}`)).json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
if (libraryModal) libraryModal.hide();
|
||||
initApp(data);
|
||||
} catch (e) {
|
||||
alert(e.message);
|
||||
} finally {
|
||||
hideLoader();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load project by ID
|
||||
* @param {number} id - Project ID
|
||||
*/
|
||||
async function loadProject(id) {
|
||||
showLoader('Loading project...', 'Retrieving all sections...');
|
||||
try {
|
||||
const data = await (await fetch(`/projects/${id}`)).json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
|
||||
if (libraryModal) libraryModal.hide();
|
||||
document.getElementById('bulk-tab').click();
|
||||
document.getElementById('bulkProjectName').value = data.name;
|
||||
currentProjectId = id;
|
||||
|
||||
const editor = document.getElementById('bulk-editor');
|
||||
editor.innerHTML = '';
|
||||
Object.keys(markerState).forEach(key => delete markerState[key]);
|
||||
chapterCounter = 1;
|
||||
sectionCounter = 1;
|
||||
|
||||
// Group sections by chapter
|
||||
const chapters = {};
|
||||
data.sections.forEach(sec => {
|
||||
if (!chapters[sec.chapter]) chapters[sec.chapter] = [];
|
||||
chapters[sec.chapter].push(sec);
|
||||
});
|
||||
|
||||
// Rebuild editor
|
||||
const sortedChapterNums = Object.keys(chapters).sort((a, b) => Number(a) - Number(b));
|
||||
|
||||
sortedChapterNums.forEach(chapterNum => {
|
||||
const chapterSections = chapters[chapterNum];
|
||||
const chapterVoice = chapterSections[0]?.voice || 'af_alloy';
|
||||
const chapterMarkerId = `ch_${chapterNum}_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`;
|
||||
|
||||
// Insert chapter marker
|
||||
const chapterMarkerHtml = createMarkerHTML('chapter', chapterNum, chapterVoice, chapterMarkerId);
|
||||
editor.insertAdjacentHTML('beforeend', chapterMarkerHtml);
|
||||
|
||||
if (Number(chapterNum) >= chapterCounter) chapterCounter = Number(chapterNum) + 1;
|
||||
|
||||
// Sort and insert sections
|
||||
chapterSections.sort((a, b) => a.section - b.section);
|
||||
|
||||
chapterSections.forEach(sec => {
|
||||
const secMarkerId = `sec_${chapterNum}_${sec.section}_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`;
|
||||
const secMarkerHtml = createMarkerHTML('section', sec.section, sec.voice, secMarkerId);
|
||||
editor.insertAdjacentHTML('beforeend', secMarkerHtml);
|
||||
|
||||
const marker = document.getElementById(`marker-${secMarkerId}`);
|
||||
if (marker) {
|
||||
let content = sec.html_content;
|
||||
if (!content || content.trim() === '') {
|
||||
content = sec.text_content ? `<p>${sec.text_content}</p>` : '<p><br></p>';
|
||||
}
|
||||
|
||||
// Remove placeholder paragraph
|
||||
let nextEl = marker.nextElementSibling;
|
||||
if (nextEl && nextEl.tagName === 'P' && (nextEl.innerHTML === '<br>' || nextEl.innerHTML.trim() === '')) {
|
||||
nextEl.remove();
|
||||
}
|
||||
|
||||
// Insert content after marker
|
||||
const tempContainer = document.createElement('div');
|
||||
tempContainer.innerHTML = content;
|
||||
const insertBeforeElement = marker.nextSibling;
|
||||
while (tempContainer.firstChild) {
|
||||
editor.insertBefore(tempContainer.firstChild, insertBeforeElement);
|
||||
}
|
||||
|
||||
// Ensure at least one paragraph exists after content
|
||||
if (!marker.nextSibling || (marker.nextSibling.classList && marker.nextSibling.classList.contains('editor-marker'))) {
|
||||
const emptyP = document.createElement('p');
|
||||
emptyP.innerHTML = '<br>';
|
||||
if (insertBeforeElement) {
|
||||
editor.insertBefore(emptyP, insertBeforeElement);
|
||||
} else {
|
||||
editor.appendChild(emptyP);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Restore TTS text to marker state
|
||||
if (sec.tts_text) {
|
||||
updateMarkerData(secMarkerId, 'ttsText', sec.tts_text);
|
||||
}
|
||||
|
||||
// Restore image data to marker state and show preview
|
||||
if (sec.image_data && sec.image_data.length > 0) {
|
||||
updateMarkerData(secMarkerId, 'imageData', sec.image_data);
|
||||
updateMarkerData(secMarkerId, 'imageFormat', sec.image_format || 'png');
|
||||
|
||||
// Show image preview after DOM is ready
|
||||
setTimeout(() => {
|
||||
const imgFormat = sec.image_format || 'png';
|
||||
const dataUrl = `data:image/${imgFormat};base64,${sec.image_data}`;
|
||||
showImagePreview(secMarkerId, dataUrl, 'Loaded image', 0, imgFormat);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
if (Number(sec.section) >= sectionCounter) sectionCounter = Number(sec.section) + 1;
|
||||
});
|
||||
});
|
||||
|
||||
// Initialize image handlers for newly created markers
|
||||
setTimeout(() => {
|
||||
initializeImageHandlers();
|
||||
}, 300);
|
||||
|
||||
// Build playlist from sections with audio
|
||||
playlist = data.sections
|
||||
.filter(s => s.audio_data && s.audio_data.length > 0)
|
||||
.map(sec => ({
|
||||
id: `sec_${sec.chapter}_${sec.section}_loaded`,
|
||||
chapter: sec.chapter,
|
||||
section: sec.section,
|
||||
audioData: sec.audio_data,
|
||||
audioFormat: sec.audio_format || 'mp3',
|
||||
transcription: sec.transcription || [],
|
||||
text: sec.text_content,
|
||||
htmlContent: sec.html_content,
|
||||
ttsText: sec.tts_text,
|
||||
voice: sec.voice,
|
||||
imageData: sec.image_data || '',
|
||||
imageFormat: sec.image_format || 'png'
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (Number(a.chapter) !== Number(b.chapter)) return Number(a.chapter) - Number(b.chapter);
|
||||
return Number(a.section) - Number(b.section);
|
||||
});
|
||||
|
||||
updatePlaylistUI();
|
||||
document.getElementById('editorSection').classList.remove('d-none');
|
||||
|
||||
// Load first track if available
|
||||
if (playlist.length > 0) {
|
||||
loadTrackFromPlaylist(playlist[0].id);
|
||||
}
|
||||
|
||||
console.log(`✅ Loaded project: ${data.name} with ${data.sections.length} sections, ${playlist.length} with audio`);
|
||||
|
||||
} catch (e) {
|
||||
alert(e.message);
|
||||
console.error('Load project error:', e);
|
||||
} finally {
|
||||
hideLoader();
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// DOWNLOAD FUNCTIONS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Download upload by ID
|
||||
* @param {number} id - Upload ID
|
||||
*/
|
||||
function downloadUpload(id) {
|
||||
window.location.href = `/uploads/${id}/download`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download generation by ID
|
||||
* @param {number} id - Generation ID
|
||||
*/
|
||||
function downloadGeneration(id) {
|
||||
window.location.href = `/generations/${id}/download`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download project by ID
|
||||
* @param {number} id - Project ID
|
||||
*/
|
||||
function downloadProject(id) {
|
||||
window.location.href = `/projects/${id}/download`;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// DELETE FUNCTIONS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Delete upload by ID
|
||||
* @param {number} id - Upload ID
|
||||
*/
|
||||
async function deleteUpload(id) {
|
||||
if (!confirm('Delete this upload?')) return;
|
||||
showLoader('Deleting...', 'Removing upload from database...');
|
||||
try {
|
||||
await fetch(`/uploads/${id}`, { method: 'DELETE' });
|
||||
await loadLibraryData();
|
||||
} catch (e) {
|
||||
alert(e.message);
|
||||
} finally {
|
||||
hideLoader();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete generation by ID
|
||||
* @param {number} id - Generation ID
|
||||
*/
|
||||
async function deleteGeneration(id) {
|
||||
if (!confirm('Delete this generation?')) return;
|
||||
showLoader('Deleting...', 'Removing generation from database...');
|
||||
try {
|
||||
await fetch(`/generations/${id}`, { method: 'DELETE' });
|
||||
await loadLibraryData();
|
||||
} catch (e) {
|
||||
alert(e.message);
|
||||
} finally {
|
||||
hideLoader();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete project by ID
|
||||
* @param {number} id - Project ID
|
||||
*/
|
||||
async function deleteProject(id) {
|
||||
if (!confirm('Delete this project and all its sections?')) return;
|
||||
showLoader('Deleting...', 'Removing project from database...');
|
||||
try {
|
||||
await fetch(`/projects/${id}`, { method: 'DELETE' });
|
||||
await loadLibraryData();
|
||||
} catch (e) {
|
||||
alert(e.message);
|
||||
} finally {
|
||||
hideLoader();
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// INITIALIZATION
|
||||
// ==========================================
|
||||
|
||||
// Initialize on DOM ready
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initUploadForm();
|
||||
refreshLibraryStats();
|
||||
});
|
||||
1070
static/js/editor.js
Normal file
1070
static/js/editor.js
Normal file
File diff suppressed because it is too large
Load Diff
270
static/js/interactive-reader.js
Normal file
270
static/js/interactive-reader.js
Normal file
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* Interactive Reader Module
|
||||
* Handles text display, word highlighting, and audio sync
|
||||
*/
|
||||
|
||||
// ==========================================
|
||||
// READER STATE VARIABLES
|
||||
// ==========================================
|
||||
|
||||
let allWordSpans = [];
|
||||
let wordMap = [];
|
||||
let sentenceData = [];
|
||||
let lastHighlightedWordSpan = null;
|
||||
let lastHighlightedSentenceSpans = [];
|
||||
|
||||
// ==========================================
|
||||
// READER INITIALIZATION
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Initialize reader with markdown text
|
||||
* @param {string} markdownText - Text content in markdown format
|
||||
*/
|
||||
function initReader(markdownText) {
|
||||
const container = document.getElementById('readerContent');
|
||||
container.innerHTML = '';
|
||||
allWordSpans = [];
|
||||
wordMap = [];
|
||||
|
||||
const html = marked.parse(markdownText || '', { breaks: true });
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = html;
|
||||
|
||||
// Process nodes to wrap words in spans
|
||||
processNode(tempDiv);
|
||||
|
||||
while (tempDiv.firstChild) {
|
||||
container.appendChild(tempDiv.firstChild);
|
||||
}
|
||||
|
||||
runSmartSync();
|
||||
}
|
||||
|
||||
/**
|
||||
* Process DOM node to wrap words in clickable spans
|
||||
* @param {Node} node - DOM node to process
|
||||
*/
|
||||
function processNode(node) {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
const words = node.textContent.split(/(\s+|[^\w'])/g);
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
words.forEach(part => {
|
||||
if (part.trim().length > 0) {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'word';
|
||||
span.textContent = part;
|
||||
span.onclick = handleWordClick;
|
||||
allWordSpans.push(span);
|
||||
fragment.appendChild(span);
|
||||
} else {
|
||||
fragment.appendChild(document.createTextNode(part));
|
||||
}
|
||||
});
|
||||
|
||||
node.parentNode.replaceChild(fragment, node);
|
||||
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
Array.from(node.childNodes).forEach(processNode);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle click on a word span
|
||||
* @param {MouseEvent} e - Click event
|
||||
*/
|
||||
function handleWordClick(e) {
|
||||
e.stopPropagation();
|
||||
const spanIndex = allWordSpans.indexOf(e.target);
|
||||
const aiIdx = wordMap[spanIndex];
|
||||
|
||||
if (aiIdx !== undefined && transcriptionData[aiIdx] && wavesurfer) {
|
||||
wavesurfer.setTime(transcriptionData[aiIdx].start);
|
||||
wavesurfer.play();
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// TEXT-AUDIO SYNC FUNCTIONS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Run smart sync between text words and transcription data
|
||||
*/
|
||||
function runSmartSync() {
|
||||
wordMap = new Array(allWordSpans.length).fill(undefined);
|
||||
let aiIdx = 0;
|
||||
let matchCount = 0;
|
||||
|
||||
allWordSpans.forEach((span, i) => {
|
||||
const clean = span.textContent.toLowerCase().replace(/[^\w]/g, '');
|
||||
|
||||
if (clean.length === 0) {
|
||||
span.classList.add('unmatched');
|
||||
return;
|
||||
}
|
||||
|
||||
// Look ahead up to 5 positions for a match
|
||||
for (let off = 0; off < 5; off++) {
|
||||
if (aiIdx + off >= transcriptionData.length) break;
|
||||
|
||||
const transcriptWord = transcriptionData[aiIdx + off].word.toLowerCase().replace(/[^\w]/g, '');
|
||||
|
||||
if (transcriptWord === clean) {
|
||||
wordMap[i] = aiIdx + off;
|
||||
aiIdx += off + 1;
|
||||
span.classList.remove('unmatched');
|
||||
matchCount++;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
span.classList.add('unmatched');
|
||||
});
|
||||
|
||||
mapSentences();
|
||||
updateSyncStatus(matchCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update sync status badge
|
||||
* @param {number} matchCount - Number of matched words
|
||||
*/
|
||||
function updateSyncStatus(matchCount) {
|
||||
const badge = document.getElementById('syncStatus');
|
||||
badge.textContent = `Synced (${matchCount}/${allWordSpans.length})`;
|
||||
badge.className = matchCount > allWordSpans.length * 0.8
|
||||
? 'badge bg-success'
|
||||
: 'badge bg-warning text-dark';
|
||||
}
|
||||
|
||||
/**
|
||||
* Map sentences for sentence-level highlighting
|
||||
*/
|
||||
function mapSentences() {
|
||||
sentenceData = [];
|
||||
let buffer = [];
|
||||
let startIdx = 0;
|
||||
|
||||
allWordSpans.forEach((span, i) => {
|
||||
buffer.push(span);
|
||||
|
||||
// Check for sentence-ending punctuation
|
||||
if (/[.!?]["']?$/.test(span.textContent.trim())) {
|
||||
let startT = 0;
|
||||
let endT = 0;
|
||||
|
||||
// Find start time
|
||||
for (let k = startIdx; k <= i; k++) {
|
||||
if (wordMap[k] !== undefined) {
|
||||
startT = transcriptionData[wordMap[k]].start;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Find end time
|
||||
for (let k = i; k >= startIdx; k--) {
|
||||
if (wordMap[k] !== undefined) {
|
||||
endT = transcriptionData[wordMap[k]].end;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (endT > startT) {
|
||||
sentenceData.push({
|
||||
spans: [...buffer],
|
||||
start: startT,
|
||||
end: endT
|
||||
});
|
||||
}
|
||||
|
||||
buffer = [];
|
||||
startIdx = i + 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync reader highlighting with audio playback time
|
||||
* @param {number} t - Current playback time in seconds
|
||||
*/
|
||||
function syncReader(t) {
|
||||
// Highlight current word
|
||||
highlightCurrentWord(t);
|
||||
|
||||
// Highlight current sentence
|
||||
highlightCurrentSentence(t);
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlight the current word based on playback time
|
||||
* @param {number} t - Current playback time
|
||||
*/
|
||||
function highlightCurrentWord(t) {
|
||||
const aiIdx = transcriptionData.findIndex(d => t >= d.start && t < d.end);
|
||||
|
||||
if (aiIdx !== -1) {
|
||||
const txtIdx = wordMap.findIndex(i => i === aiIdx);
|
||||
|
||||
if (txtIdx !== -1 && allWordSpans[txtIdx] !== lastHighlightedWordSpan) {
|
||||
// Remove previous highlight
|
||||
if (lastHighlightedWordSpan) {
|
||||
lastHighlightedWordSpan.classList.remove('current-word');
|
||||
}
|
||||
|
||||
// Add new highlight
|
||||
lastHighlightedWordSpan = allWordSpans[txtIdx];
|
||||
lastHighlightedWordSpan.classList.add('current-word');
|
||||
|
||||
// Scroll into view if needed
|
||||
scrollWordIntoView(lastHighlightedWordSpan);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlight the current sentence based on playback time
|
||||
* @param {number} t - Current playback time
|
||||
*/
|
||||
function highlightCurrentSentence(t) {
|
||||
const sent = sentenceData.find(s => t >= s.start && t <= s.end);
|
||||
|
||||
if (sent && sent.spans !== lastHighlightedSentenceSpans) {
|
||||
// Remove previous highlight
|
||||
if (lastHighlightedSentenceSpans) {
|
||||
lastHighlightedSentenceSpans.forEach(s => s.classList.remove('current-sentence-bg'));
|
||||
}
|
||||
|
||||
// Add new highlight
|
||||
lastHighlightedSentenceSpans = sent.spans;
|
||||
lastHighlightedSentenceSpans.forEach(s => s.classList.add('current-sentence-bg'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll word into view if outside visible area
|
||||
* @param {Element} wordSpan - Word span element
|
||||
*/
|
||||
function scrollWordIntoView(wordSpan) {
|
||||
const r = wordSpan.getBoundingClientRect();
|
||||
const c = document.querySelector('.reader-section').getBoundingClientRect();
|
||||
|
||||
if (r.top < c.top || r.bottom > c.bottom) {
|
||||
wordSpan.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// MISMATCH TOGGLE
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Toggle display of mismatched words
|
||||
*/
|
||||
function toggleMismatches() {
|
||||
const toggle = document.getElementById('mismatchToggle');
|
||||
document.getElementById('readerContent').classList.toggle('show-mismatches', toggle.checked);
|
||||
}
|
||||
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