first commit

This commit is contained in:
Ashim Kumar
2026-01-09 21:06:30 +06:00
commit 11d715eb85
19 changed files with 8235 additions and 0 deletions

760
static/css/style.css Normal file
View File

@@ -0,0 +1,760 @@
/* ============================================
CSS Variables
============================================= */
:root {
--bg-gradient-start: #f0f4f8;
--bg-gradient-end: #e2e8f0;
--bg-card: #ffffff;
--editor-bg: #1a1f2e;
--audio-track-bg: #242b3d;
--transcript-track-bg: #2d3548;
--track-border: #3d4558;
--pill-bg-gradient-start: #667eea;
--pill-bg-gradient-end: #764ba2;
--pill-text: #ffffff;
--accent-primary: #667eea;
--reader-bg: rgba(255, 255, 255, 0.95);
--reader-text: #1f2937;
--highlight-word: #2563eb;
--highlight-bg: #dbeafe;
--unmatched-color: #ef4444;
}
/* ============================================
Base Styles
============================================= */
* { box-sizing: border-box; }
body {
background: linear-gradient(135deg, var(--bg-gradient-start) 0%, var(--bg-gradient-end) 100%);
font-family: 'Inter', sans-serif;
color: #1e293b;
min-height: 100vh;
padding: 20px 0 100px 0;
}
.main-container {
max-width: 1400px;
margin: 0 auto;
padding: 0 20px;
}
/* ============================================
App Header
============================================= */
.app-header {
background: linear-gradient(135deg, var(--accent-primary) 0%, var(--pill-bg-gradient-end) 100%);
color: white;
padding: 24px 32px;
border-radius: 20px;
box-shadow: 0 20px 40px rgba(102, 126, 234, 0.3);
margin-bottom: 32px;
display: flex;
justify-content: space-between;
align-items: center;
}
.version-badge {
background: rgba(255, 255, 255, 0.25);
padding: 8px 16px;
border-radius: 50px;
font-weight: 700;
font-size: 14px;
backdrop-filter: blur(10px);
}
/* ============================================
Input Card & Tabs
============================================= */
.input-card {
background: var(--bg-card);
border-radius: 20px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
border: 1px solid rgba(0, 0, 0, 0.05);
margin-bottom: 32px;
overflow: hidden;
position: relative;
}
.nav-tabs .nav-link {
color: #64748b;
font-weight: 600;
border: none;
padding: 1rem 1.5rem;
}
.nav-tabs .nav-link.active {
color: var(--accent-primary);
border-bottom: 3px solid var(--accent-primary);
background: transparent;
}
/* ============================================
Quill Editor Styles
============================================= */
#quill-editor {
height: 300px;
font-family: 'Lora', serif;
font-size: 18px;
line-height: 1.8;
color: #333;
border: none;
}
.ql-container.ql-snow {
border: none !important;
}
.ql-toolbar.ql-snow {
border: none !important;
border-bottom: 1px solid #f0f0f0 !important;
background: #fafafa;
}
.editor-actions {
background: #fafafa;
border-top: 1px solid #eee;
padding: 15px 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
/* ============================================
Bulk Editor (Notion-like)
============================================= */
.notion-editor-wrapper {
position: relative;
min-height: 500px;
background: #fff;
padding: 40px;
font-family: 'Lora', serif;
}
#bulk-editor {
outline: none;
font-size: 18px;
line-height: 1.8;
color: #333;
min-height: 60vh;
height: auto;
overflow-y: visible;
padding-bottom: 100px;
}
#bulk-editor p { margin-bottom: 1em; }
#bulk-editor h1 {
font-size: 2em;
font-weight: bold;
margin: 1em 0 0.5em;
font-family: 'Poppins', sans-serif;
}
#bulk-editor h2 {
font-size: 1.5em;
font-weight: bold;
margin: 1em 0 0.5em;
font-family: 'Poppins', sans-serif;
}
#bulk-editor h3 {
font-size: 1.2em;
font-weight: bold;
margin: 1em 0 0.5em;
font-family: 'Poppins', sans-serif;
}
/* ============================================
Floating Action Buttons
============================================= */
.floating-controls {
position: fixed;
top: 50%;
right: 20px;
transform: translateY(-50%);
display: flex;
flex-direction: column;
gap: 15px;
z-index: 1000;
}
.floating-btn {
width: 60px;
height: 60px;
border-radius: 50%;
border: none;
color: white;
box-shadow: 0 4px 15px rgba(0,0,0,0.2);
transition: all 0.3s;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
position: relative;
}
.floating-btn:hover { transform: scale(1.1); }
.floating-btn.chapter-btn { background: linear-gradient(135deg, #FF6B6B 0%, #EE5253 100%); }
.floating-btn.section-btn { background: linear-gradient(135deg, #4834d4 0%, #686de0 100%); }
.tooltip-text {
position: absolute;
right: 70px;
background: rgba(0,0,0,0.7);
color: white;
padding: 5px 10px;
border-radius: 5px;
font-size: 12px;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s;
white-space: nowrap;
}
.floating-btn:hover .tooltip-text { opacity: 1; }
/* ============================================
Chapter/Section Markers
============================================= */
.editor-marker {
padding: 15px;
margin: 20px 0;
border-radius: 10px;
user-select: none;
cursor: default;
position: relative;
border: 1px solid rgba(0,0,0,0.1);
}
.chapter-marker {
background: #fff0f0;
border-left: 5px solid #FF6B6B;
}
.section-marker {
background: #f0f0ff;
border-left: 5px solid #4834d4;
margin-left: 20px;
}
.marker-header {
display: flex;
align-items: center;
gap: 15px;
flex-wrap: wrap;
}
.marker-title {
font-family: 'Poppins', sans-serif;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1px;
font-size: 14px;
}
.chapter-marker .marker-title { color: #d63031; }
.section-marker .marker-title { color: #4834d4; }
.marker-controls {
display: flex;
align-items: center;
gap: 10px;
}
/* ============================================
Audio Control Panel
============================================= */
.control-panel {
background: var(--bg-card);
border-radius: 16px;
padding: 15px 25px;
margin-bottom: 24px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
display: flex;
gap: 16px;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
}
/* ============================================
Timeline/Waveform Editor
============================================= */
.timeline-wrapper {
background-color: var(--editor-bg);
border-radius: 20px;
position: relative;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
overflow-x: scroll;
overflow-y: hidden;
margin-bottom: 40px;
padding-bottom: 10px;
}
.timeline-wrapper::-webkit-scrollbar {
height: 12px;
background: #1a1f2e;
}
.timeline-wrapper::-webkit-scrollbar-thumb {
background: #4b5563;
border-radius: 10px;
border: 2px solid #1a1f2e;
}
.timeline-content {
position: relative;
min-width: 100%;
}
#timeline-ruler {
height: 25px;
background: #1a1f2e;
border-bottom: 1px solid var(--track-border);
position: sticky;
top: 0;
z-index: 50;
}
.audio-track-container {
background: var(--audio-track-bg);
border-bottom: 3px solid var(--track-border);
height: 120px;
padding: 10px 0;
position: relative;
}
.transcription-track-container {
background: var(--transcript-track-bg);
height: 120px;
padding: 10px 0;
position: relative;
}
.track-label {
position: absolute;
left: 20px;
top: 10px;
font-size: 10px;
font-weight: 800;
letter-spacing: 2px;
text-transform: uppercase;
color: rgba(255,255,255,0.5);
background: rgba(0,0,0,0.3);
padding: 4px 10px;
border-radius: 6px;
z-index: 100;
pointer-events: none;
}
/* ============================================
Playhead
============================================= */
#custom-playhead {
position: absolute;
top: 0;
bottom: 0;
width: 2px;
background-color: #ef4444;
z-index: 300;
left: 0;
cursor: ew-resize;
pointer-events: all;
}
#custom-playhead::after {
content: '';
position: absolute;
top: 0;
left: -6px;
border-left: 7px solid transparent;
border-right: 7px solid transparent;
border-top: 10px solid #ef4444;
}
/* ============================================
Word Pills (Transcription)
============================================= */
.word-pill {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--pill-bg-gradient-start) 0%, var(--pill-bg-gradient-end) 100%);
color: var(--pill-text);
border-radius: 4px;
font-size: 12px;
font-weight: 600;
cursor: grab;
border: 1px solid rgba(255,255,255,0.2);
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
top: 50%;
transform: translateY(-50%);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
user-select: none;
z-index: 200;
padding: 0 5px;
}
.word-pill.selected {
border-color: #fcd34d;
box-shadow: 0 0 0 2px rgba(252, 211, 77, 0.4);
z-index: 210;
}
.resize-handle {
position: absolute;
top: 0;
bottom: 0;
width: 5px;
cursor: ew-resize;
z-index: 220;
}
.resize-handle-left { left: 0; }
.resize-handle-right { right: 0; }
/* ============================================
Interactive Reader
============================================= */
.reader-section {
background-color: var(--reader-bg);
backdrop-filter: blur(12px);
border-radius: 1rem;
padding: 3rem 4rem;
box-shadow: 0 10px 35px rgba(0, 0, 0, 0.08);
border: 1px solid rgba(255, 255, 255, 0.4);
margin-top: 40px;
animation: fadeIn 0.5s ease-in-out;
min-height: 400px;
}
.reader-header {
font-family: "Poppins", sans-serif;
font-weight: 700;
font-size: 1.5rem;
color: #111827;
margin-bottom: 1rem;
border-bottom: 2px solid #e2e8f0;
padding-bottom: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.story-text-container {
font-family: "Lora", serif;
font-size: 24px;
line-height: 2.1;
color: var(--reader-text);
cursor: text;
}
.story-text-container h1,
.story-text-container h2 {
font-family: "Poppins", sans-serif;
margin-top: 1.5em;
font-weight: 700;
}
.story-text-container p { margin-bottom: 1.2em; }
/* ============================================
Word Highlighting
============================================= */
.word {
transition: background-color 0.15s;
border-radius: 3px;
padding: 2px 0;
display: inline;
cursor: pointer;
border-bottom: 2px solid transparent;
}
.word:hover { background-color: #f1f5f9; }
.show-mismatches .word.unmatched {
color: var(--unmatched-color);
text-decoration: underline wavy var(--unmatched-color);
opacity: 1 !important;
}
.current-word {
color: var(--highlight-word);
text-decoration: underline;
text-decoration-thickness: 3px;
text-underline-offset: 3px;
font-weight: 700;
}
.current-sentence-bg {
background-color: var(--highlight-bg);
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
border-radius: 6px;
}
/* ============================================
Loading Overlay
============================================= */
.loading-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(255,255,255,0.9);
z-index: 9999;
flex-direction: column;
justify-content: center;
align-items: center;
}
/* ============================================
Library Modal Items
============================================= */
.library-item {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 15px;
margin-bottom: 10px;
display: flex;
justify-content: space-between;
align-items: center;
transition: all 0.2s;
}
.library-item:hover {
background: #f1f5f9;
border-color: var(--accent-primary);
}
.library-item-info { flex: 1; }
.library-item-title {
font-weight: 600;
color: #1e293b;
margin-bottom: 4px;
}
.library-item-meta {
font-size: 12px;
color: #64748b;
}
.library-item-actions {
display: flex;
gap: 8px;
}
/* ============================================
Database Stats
============================================= */
.db-stats {
background: linear-gradient(135deg, #f0f4f8 0%, #e2e8f0 100%);
border-radius: 12px;
padding: 15px 20px;
display: flex;
gap: 30px;
flex-wrap: wrap;
}
.stat-item { text-align: center; }
.stat-value {
font-size: 24px;
font-weight: 700;
color: var(--accent-primary);
}
.stat-label {
font-size: 12px;
color: #64748b;
text-transform: uppercase;
letter-spacing: 1px;
}
/* ============================================
Animations
============================================= */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* ============================================
Image Upload Styles for Section Markers
============================================= */
.section-image-container {
margin: 10px 0;
padding: 10px;
background: #f8f9fa;
border-radius: 8px;
border: 2px dashed #dee2e6;
transition: all 0.3s ease;
}
.section-image-container.drag-over {
border-color: #667eea;
background: #f0f4ff;
}
.section-image-container.has-image {
border-style: solid;
border-color: #28a745;
background: #f8fff8;
}
.image-drop-zone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 80px;
cursor: pointer;
color: #6c757d;
font-size: 14px;
}
.image-drop-zone i {
font-size: 24px;
margin-bottom: 8px;
color: #adb5bd;
}
.image-drop-zone:hover {
color: #495057;
}
.image-drop-zone:hover i {
color: #667eea;
}
.image-preview-wrapper {
position: relative;
display: inline-block;
max-width: 100%;
}
.section-image-preview {
max-width: 200px;
max-height: 150px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
object-fit: contain;
}
.image-actions {
position: absolute;
top: 5px;
right: 5px;
display: flex;
gap: 5px;
}
.image-actions button {
width: 28px;
height: 28px;
border-radius: 50%;
border: none;
background: rgba(255,255,255,0.9);
color: #333;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
transition: all 0.2s;
}
.image-actions button:hover {
transform: scale(1.1);
}
.image-actions .btn-remove:hover {
background: #dc3545;
color: white;
}
.image-info {
font-size: 11px;
color: #6c757d;
margin-top: 5px;
text-align: center;
}
/* Hidden file input */
.image-file-input {
display: none;
}
/* Save Button Styles */
.save-project-btn {
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
border: none;
color: white;
font-weight: bold;
padding: 8px 20px;
border-radius: 8px;
transition: all 0.3s ease;
}
.save-project-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(40, 167, 69, 0.4);
color: white;
}
.save-project-btn:active {
transform: translateY(0);
}
/* Section marker with image indicator */
.section-marker.has-image .marker-title::after {
content: '🖼️';
margin-left: 8px;
font-size: 12px;
}
/* Image in editor content area */
.section-content-image {
display: block;
max-width: 300px;
max-height: 200px;
margin: 10px 0;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
/* ============================================
User Menu Styles
============================================= */
.dropdown-menu {
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);
border: 1px solid rgba(0, 0, 0, 0.05);
padding: 8px;
}
.dropdown-item {
border-radius: 8px;
padding: 10px 16px;
font-weight: 500;
transition: all 0.2s;
}
.dropdown-item:hover {
background: linear-gradient(135deg, var(--accent-primary) 0%, var(--pill-bg-gradient-end) 100%);
color: white;
}
.dropdown-item i {
width: 20px;
}
.dropdown-divider {
margin: 8px 0;
}

893
static/js/app.js Normal file
View 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

File diff suppressed because it is too large Load Diff

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