Audiobook Maker Pro v4.2 — production ready

This commit is contained in:
Ashim Kumar
2026-05-22 18:28:47 +06:00
commit 0617a374dd
41 changed files with 15262 additions and 0 deletions

View File

@@ -0,0 +1,917 @@
/**
* Interactive Reader Module — Smart Preload Architecture (v3)
*
* Loading Strategy:
* - Text and timestamps come from in-memory `editorBlocks` (already loaded).
* - Audio base64 → Blob URL conversion is DEFERRED until needed.
* - When block N plays, preload blob URLs for N+1, N+2 (background).
* - At 70% mark of N's audio, ensure N+1 is ready (safety net).
* - Memory cap: keep at most MAX_AUDIO_LOADED blob URLs alive;
* revoke distant past audio to free browser memory.
*
* Scroll Strategy:
* - Manual navigation (button / outline / word click): scroll block to top.
* - Auto-advance (audio ended → next block): NO block scroll — let the
* word highlighter smoothly carry the user. Prevents jarring jumps.
*/
// ============================================
// Reader State
// ============================================
let readerInstances = [];
let currentReaderInstance = null;
let currentReaderIndex = -1;
let readerStarted = false;
let readerUICreated = false;
// Tunables
const READER_PRELOAD_AHEAD = 2;
const READER_MID_PRELOAD_THRESHOLD = 0.7;
const READER_MAX_AUDIO_LOADED = 5;
const READER_KEEP_BEHIND = 1;
// ============================================
// Render Reader
// ============================================
function renderInteractiveReader() {
const container = document.getElementById('readerContainer');
if (container.style.maxWidth) container.style.maxWidth = '';
const chapters = collectEditorContent();
let hasAudio = false;
const allBlocks = [];
let outlineHtml = '';
let currentIndex = 0;
for (const chapter of chapters) {
if (chapter.blocks.length === 0) continue;
outlineHtml += `
<li onclick="scrollToReaderBlock(${currentIndex})" title="${escapeHtml(chapter.title)}">
${escapeHtml(chapter.title)}
</li>
`;
let isFirstBlockOfChapter = true;
for (const block of chapter.blocks) {
const blockData = findEditorBlockForContent(block);
const isImageBlock = block.block_type === 'image' ||
(block.content && block.content.trim().startsWith('![') && block.content.trim().includes(']('));
allBlocks.push({
...block,
_editorData: blockData || null,
_isImage: isImageBlock,
_chapterTitle: isFirstBlockOfChapter ? chapter.title : null
});
isFirstBlockOfChapter = false;
if (!isImageBlock && blockData && blockData.audio_data) {
hasAudio = true;
}
currentIndex++;
}
}
const readerOutlineSidebar = document.getElementById('readerOutlineSidebar');
const readerOutlineList = document.getElementById('readerOutlineList');
if (!hasAudio) {
container.innerHTML = `
<div class="reader-empty-state">
<i class="bi bi-book"></i>
<p>Generate audio to view the interactive reader</p>
<p class="text-muted">Go to the Editor tab and click "Generate" on the panel</p>
</div>
`;
removeReaderUI();
if (readerOutlineSidebar) readerOutlineSidebar.style.display = 'none';
return;
}
if (readerOutlineSidebar && readerOutlineList) {
readerOutlineSidebar.style.display = 'block';
readerOutlineList.innerHTML = outlineHtml || '<li class="text-muted small">No sections found.</li>';
}
let html = '<div class="reader-flow">';
// Cleanup any previous instances (revoke blob URLs)
cleanupAllReaderInstances();
readerInstances = [];
let globalBlockIndex = 0;
for (const block of allBlocks) {
const blockData = block._editorData;
const isImageBlock = block._isImage;
const hasBlockAudio = !isImageBlock && blockData && blockData.audio_data;
const blockId = blockData ? blockData.id : `reader_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`;
html += `<div class="reader-block" data-block-id="${blockId}" data-reader-index="${globalBlockIndex}" data-has-audio="${!!hasBlockAudio}">`;
if (isImageBlock) {
const imageHtml = buildImageHtml(block, blockData);
html += `<div class="reader-content reader-image-block">${imageHtml}</div>`;
} else {
const blockImages = getBlockImages(block, blockData);
for (const img of blockImages) {
if (img.position === 'before' && img.data) {
html += `<div class="reader-image-block"><img src="data:image/${img.format || 'png'};base64,${img.data}" alt="${img.alt_text || 'Image'}"></div>`;
}
}
html += `<div class="reader-content" id="reader-content-${globalBlockIndex}"></div>`;
for (const img of blockImages) {
if (img.position === 'after' && img.data) {
html += `<div class="reader-image-block"><img src="data:image/${img.format || 'png'};base64,${img.data}" alt="${img.alt_text || 'Image'}"></div>`;
}
}
}
html += `</div>`;
readerInstances.push({
index: globalBlockIndex,
blockId: blockId,
blockData: blockData,
content: block.content,
hasAudio: !!hasBlockAudio,
isImage: isImageBlock,
wordSpans: [],
wordMap: [],
sentenceData: [],
audio: null,
audioUrl: null, // blob URL ref for cleanup
audioReady: false,
audioLoadingPromise: null,
midPreloadTriggered: false,
transcription: (!isImageBlock && blockData) ? (blockData.transcription || []) : [],
animFrameId: null,
lastWordSpan: null,
lastSentenceSpans: []
});
globalBlockIndex++;
}
html += '</div>';
container.innerHTML = html;
// Render words and run sync for every instance (text is cheap and already in memory)
for (const inst of readerInstances) {
if (inst.isImage || !inst.content) continue;
const contentEl = document.getElementById(`reader-content-${inst.index}`);
if (!contentEl) continue;
renderWordsIntoContainer(contentEl, inst);
if (inst.hasAudio && inst.transcription.length > 0) {
runReaderSmartSync(inst);
}
}
addReaderStyles();
setupReaderUI();
}
// ============================================
// Image Resolution Helpers
// ============================================
function findEditorBlockForContent(block) {
for (const eb of editorBlocks) {
const el = document.getElementById(eb.id);
if (el) {
const textarea = el.querySelector('.md-block-textarea');
if (textarea && textarea.value === block.content) {
return eb;
}
}
}
for (const eb of editorBlocks) {
if (eb.content === block.content) return eb;
}
return null;
}
function getBlockImages(block, blockData) {
if (block.images && block.images.length > 0) {
const valid = block.images.filter(img => img.data && img.data.length > 0);
if (valid.length > 0) return valid;
}
if (blockData && blockData.images && blockData.images.length > 0) {
const valid = blockData.images.filter(img => img.data && img.data.length > 0);
if (valid.length > 0) return valid;
}
return [];
}
function buildImageHtml(block, blockData) {
if (block.images && block.images.length > 0) {
let html = '';
for (const img of block.images) {
if (img.data && img.data.length > 0) {
html += `<img src="data:image/${img.format || 'png'};base64,${img.data}" alt="${img.alt_text || 'Image'}">`;
}
}
if (html) return html;
}
if (blockData && blockData.images && blockData.images.length > 0) {
let html = '';
for (const img of blockData.images) {
if (img.data && img.data.length > 0) {
html += `<img src="data:image/${img.format || 'png'};base64,${img.data}" alt="${img.alt_text || 'Image'}">`;
}
}
if (html) return html;
}
if (block.content) {
const dataUriMatch = block.content.match(/!\[([^\]]*)\]\((data:image\/[^)]+)\)/);
if (dataUriMatch) {
return `<img src="${dataUriMatch[2]}" alt="${dataUriMatch[1] || 'Image'}">`;
}
}
if (blockData && blockData.id) {
const editorBlock = document.getElementById(blockData.id);
if (editorBlock) {
const editorImg = editorBlock.querySelector('.image-block img, .md-block-content img');
if (editorImg && editorImg.src && editorImg.src.startsWith('data:image')) {
return `<img src="${editorImg.src}" alt="Image">`;
}
}
}
if (block.content) {
for (const eb of editorBlocks) {
if (eb.content === block.content && eb.images && eb.images.length > 0) {
let html = '';
for (const img of eb.images) {
if (img.data && img.data.length > 0) {
html += `<img src="data:image/${img.format || 'png'};base64,${img.data}" alt="${img.alt_text || 'Image'}">`;
}
}
if (html) return html;
}
}
}
return `<div class="reader-image-placeholder">
<i class="bi bi-image" style="font-size:2rem;color:#94a3b8;"></i>
<p style="color:#94a3b8;margin-top:8px;">Image not available</p>
</div>`;
}
// ============================================
// Word Rendering & Sync
// ============================================
function renderWordsIntoContainer(container, inst) {
const div = document.createElement('div');
div.innerHTML = marked.parse(inst.content, { breaks: true, gfm: true });
inst.wordSpans = [];
function processNode(node) {
if (node.nodeType === Node.TEXT_NODE) {
const words = node.textContent.split(/(\s+)/);
const fragment = document.createDocumentFragment();
words.forEach(part => {
if (part.trim().length > 0) {
const span = document.createElement('span');
span.className = 'reader-word';
span.textContent = part;
span.dataset.readerIndex = inst.index;
span.dataset.wordIdx = inst.wordSpans.length;
inst.wordSpans.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);
}
}
processNode(div);
while (div.firstChild) container.appendChild(div.firstChild);
}
function runReaderSmartSync(inst) {
const { wordSpans, transcription } = inst;
inst.wordMap = new Array(wordSpans.length).fill(undefined);
let aiIdx = 0;
wordSpans.forEach((span, i) => {
const textWord = span.textContent.toLowerCase().replace(/[^\w]/g, '');
for (let off = 0; off < 5; off++) {
if (aiIdx + off >= transcription.length) break;
const aiWord = transcription[aiIdx + off].word.toLowerCase().replace(/[^\w]/g, '');
if (textWord === aiWord) {
inst.wordMap[i] = aiIdx + off;
aiIdx += off + 1;
return;
}
}
});
inst.sentenceData = [];
let buffer = [];
let startIdx = 0;
wordSpans.forEach((span, i) => {
buffer.push(span);
if (/[.!?]["'\u201D\u2019]?$/.test(span.textContent.trim())) {
let startT = 0, endT = 0;
for (let k = startIdx; k <= i; k++) {
if (inst.wordMap[k] !== undefined) { startT = transcription[inst.wordMap[k]].start; break; }
}
for (let k = i; k >= startIdx; k--) {
if (inst.wordMap[k] !== undefined) { endT = transcription[inst.wordMap[k]].end; break; }
}
if (endT > startT) inst.sentenceData.push({ spans: [...buffer], startTime: startT, endTime: endT });
buffer = [];
startIdx = i + 1;
}
});
if (buffer.length > 0) {
let startT = 0, endT = 0;
for (let k = startIdx; k < wordSpans.length; k++) {
if (inst.wordMap[k] !== undefined) { startT = transcription[inst.wordMap[k]].start; break; }
}
for (let k = wordSpans.length - 1; k >= startIdx; k--) {
if (inst.wordMap[k] !== undefined) { endT = transcription[inst.wordMap[k]].end; break; }
}
if (endT > startT) inst.sentenceData.push({ spans: [...buffer], startTime: startT, endTime: endT });
}
}
// ============================================
// Reader UI
// ============================================
function setupReaderUI() {
removeReaderUI();
const btn = document.createElement('button');
btn.id = 'reader-floating-btn';
btn.innerHTML = `
<span id="reader-btn-text">Start</span>
<svg id="reader-btn-play" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" style="display:none;width:24px;height:24px;"><path d="M8 5v14l11-7z"/></svg>
<svg id="reader-btn-pause" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" style="display:none;width:24px;height:24px;"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
<div id="reader-btn-spinner" class="reader-btn-spinner" style="display:none;"></div>
`;
document.body.appendChild(btn);
btn.addEventListener('click', handleReaderFloatingClick);
const container = document.getElementById('readerContainer');
container.addEventListener('click', handleReaderWordClick);
readerStarted = false;
currentReaderInstance = null;
currentReaderIndex = -1;
readerUICreated = true;
positionReaderUI();
window.addEventListener('resize', positionReaderUI);
window.addEventListener('scroll', positionReaderUI);
}
function positionReaderUI() {
const readerContainer = document.getElementById('readerContainer');
const btn = document.getElementById('reader-floating-btn');
if (!readerContainer || !btn) return;
const containerRect = readerContainer.getBoundingClientRect();
btn.style.position = 'fixed';
btn.style.top = '80px';
const rightPos = window.innerWidth - (containerRect.right + 8);
btn.style.right = Math.max(rightPos, 8) + 'px';
btn.style.left = 'auto';
}
function removeReaderUI() {
const oldBtn = document.getElementById('reader-floating-btn');
if (oldBtn) oldBtn.remove();
readerStarted = false;
currentReaderInstance = null;
currentReaderIndex = -1;
readerUICreated = false;
window.removeEventListener('resize', positionReaderUI);
window.removeEventListener('scroll', positionReaderUI);
cleanupAllReaderInstances();
}
function cleanupAllReaderInstances() {
for (const inst of readerInstances) {
if (inst.audio) {
try { inst.audio.pause(); } catch (e) {}
inst.audio = null;
}
if (inst.audioUrl) {
try { URL.revokeObjectURL(inst.audioUrl); } catch (e) {}
inst.audioUrl = null;
}
inst.audioReady = false;
inst.audioLoadingPromise = null;
if (inst.animFrameId) cancelAnimationFrame(inst.animFrameId);
}
}
function showReaderUI() {
const btn = document.getElementById('reader-floating-btn');
if (btn) btn.style.display = 'flex';
positionReaderUI();
}
function hideReaderUI() {
const btn = document.getElementById('reader-floating-btn');
if (btn) btn.style.display = 'none';
}
function setReaderButtonLoading(isLoading) {
const btn = document.getElementById('reader-floating-btn');
if (!btn) return;
btn.classList.toggle('loading', isLoading);
const spinner = document.getElementById('reader-btn-spinner');
if (spinner) spinner.style.display = isLoading ? 'block' : 'none';
}
// ============================================
// Audio Lazy Loading + Memory Management
// ============================================
function ensureReaderAudioLoaded(inst) {
if (inst.audioReady && inst.audio) return Promise.resolve(inst);
if (inst.audioLoadingPromise) return inst.audioLoadingPromise;
inst.audioLoadingPromise = new Promise((resolve, reject) => {
const blockData = inst.blockData;
if (!blockData || !blockData.audio_data) {
inst.audioLoadingPromise = null;
return reject(new Error('No audio data'));
}
try {
const audioBlob = base64ToBlob(blockData.audio_data, `audio/${blockData.audio_format || 'mp3'}`);
const audioUrl = URL.createObjectURL(audioBlob);
const audio = new Audio(audioUrl);
const onCanPlay = () => {
audio.removeEventListener('error', onError);
inst.audio = audio;
inst.audioUrl = audioUrl;
inst.audioReady = true;
wireReaderAudioEvents(inst);
resolve(inst);
};
const onError = () => {
audio.removeEventListener('canplay', onCanPlay);
try { URL.revokeObjectURL(audioUrl); } catch (e) {}
inst.audioLoadingPromise = null;
reject(new Error('Audio failed to load'));
};
audio.addEventListener('canplay', onCanPlay, { once: true });
audio.addEventListener('error', onError, { once: true });
// Audio.load is implicit; setting src starts loading metadata
audio.preload = 'auto';
audio.load();
} catch (err) {
inst.audioLoadingPromise = null;
reject(err);
}
});
return inst.audioLoadingPromise;
}
function wireReaderAudioEvents(inst) {
const audio = inst.audio;
audio.addEventListener('play', () => {
startReaderHighlightLoop(inst);
updateReaderButton('playing');
});
audio.addEventListener('pause', () => {
stopReaderHighlightLoop(inst);
updateReaderButton('paused');
});
audio.addEventListener('ended', () => {
stopReaderHighlightLoop(inst);
clearReaderHighlights(inst);
const nextIdx = findNextAudioIndex(inst.index);
if (nextIdx >= 0) {
playReaderInstanceByIndex(nextIdx, { autoAdvance: true });
} else {
updateReaderButton('paused');
currentReaderInstance = null;
currentReaderIndex = -1;
}
});
// Mid-play safety net: ensure next is ready by 70% of current
audio.addEventListener('timeupdate', () => {
if (inst.midPreloadTriggered) return;
if (!audio.duration || isNaN(audio.duration)) return;
if ((audio.currentTime / audio.duration) >= READER_MID_PRELOAD_THRESHOLD) {
inst.midPreloadTriggered = true;
const nextIdx = findNextAudioIndex(inst.index);
if (nextIdx >= 0) {
ensureReaderAudioLoaded(readerInstances[nextIdx]).catch(() => {});
}
}
});
}
function preloadReaderAhead(fromIndex) {
let preloadedCount = 0;
let idx = fromIndex + 1;
while (idx < readerInstances.length && preloadedCount < READER_PRELOAD_AHEAD) {
const inst = readerInstances[idx];
if (inst.hasAudio) {
ensureReaderAudioLoaded(inst).catch(() => {});
preloadedCount++;
}
idx++;
}
}
function pruneReaderLoadedAudio(currentIndex) {
const loaded = readerInstances.filter(i => i.audioReady && i.audio);
if (loaded.length <= READER_MAX_AUDIO_LOADED) return;
const keepLow = currentIndex - READER_KEEP_BEHIND;
const keepHigh = currentIndex + READER_PRELOAD_AHEAD;
const candidates = loaded
.filter(inst => inst !== currentReaderInstance)
.map(inst => ({
inst,
inWindow: inst.index >= keepLow && inst.index <= keepHigh,
distance: Math.abs(inst.index - currentIndex)
}))
.sort((a, b) => {
if (a.inWindow !== b.inWindow) return a.inWindow ? 1 : -1;
return b.distance - a.distance;
});
let toEvict = loaded.length - READER_MAX_AUDIO_LOADED;
for (const c of candidates) {
if (toEvict <= 0) break;
releaseReaderAudio(c.inst);
toEvict--;
}
}
function releaseReaderAudio(inst) {
if (!inst.audio) return;
try { inst.audio.pause(); } catch (e) {}
if (inst.audioUrl) {
try { URL.revokeObjectURL(inst.audioUrl); } catch (e) {}
inst.audioUrl = null;
}
inst.audio = null;
inst.audioReady = false;
inst.audioLoadingPromise = null;
inst.midPreloadTriggered = false;
}
// ============================================
// Playback & Navigation
// ============================================
function handleReaderFloatingClick() {
if (!readerStarted) {
readerStarted = true;
const firstIdx = findNextAudioIndex(-1);
if (firstIdx >= 0) playReaderInstanceByIndex(firstIdx);
return;
}
if (currentReaderInstance && currentReaderInstance.audio) {
if (currentReaderInstance.audio.paused) {
currentReaderInstance.audio.play().catch(console.error);
updateReaderButton('playing');
} else {
currentReaderInstance.audio.pause();
updateReaderButton('paused');
}
} else {
const firstIdx = findNextAudioIndex(-1);
if (firstIdx >= 0) playReaderInstanceByIndex(firstIdx);
}
}
async function handleReaderWordClick(event) {
const wordSpan = event.target.closest('.reader-word');
if (!wordSpan) return;
const readerIdx = parseInt(wordSpan.dataset.readerIndex, 10);
const wordIdx = parseInt(wordSpan.dataset.wordIdx, 10);
const inst = readerInstances[readerIdx];
if (!inst || !inst.hasAudio) return;
const aiIdx = inst.wordMap[wordIdx];
if (aiIdx === undefined) return;
const timestamp = inst.transcription[aiIdx].start;
playReaderInstanceByIndex(readerIdx, { timestamp });
}
function findNextAudioIndex(afterIndex) {
for (let i = afterIndex + 1; i < readerInstances.length; i++) {
if (readerInstances[i].hasAudio) return i;
}
return -1;
}
async function playReaderInstanceByIndex(index, opts = {}) {
if (index < 0 || index >= readerInstances.length) {
updateReaderButton('paused');
currentReaderInstance = null;
currentReaderIndex = -1;
return;
}
const inst = readerInstances[index];
if (!inst.hasAudio) {
// Skip non-audio blocks
playReaderInstanceByIndex(findNextAudioIndex(index), opts);
return;
}
const isAutoAdvance = opts.autoAdvance === true;
const timestamp = opts.timestamp != null ? opts.timestamp : 0;
if (currentReaderInstance && currentReaderInstance !== inst) {
stopReaderInstance(currentReaderInstance);
}
readerStarted = true;
currentReaderIndex = index;
currentReaderInstance = inst;
inst.midPreloadTriggered = false;
const needsLoad = !inst.audioReady;
if (needsLoad) setReaderButtonLoading(true);
try {
await ensureReaderAudioLoaded(inst);
if (needsLoad) setReaderButtonLoading(false);
inst.audio.currentTime = timestamp;
await inst.audio.play();
updateReaderButton('playing');
// Block-level scroll ONLY for manual navigation
if (!isAutoAdvance) {
const blockEl = document.querySelector(`.reader-block[data-reader-index="${index}"]`);
if (blockEl) {
const rect = blockEl.getBoundingClientRect();
if (rect.top < 0 || rect.top > window.innerHeight * 0.6) {
blockEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
}
preloadReaderAhead(index);
pruneReaderLoadedAudio(index);
} catch (err) {
console.error('Reader playback failed:', err);
setReaderButtonLoading(false);
updateReaderButton('paused');
if (typeof showNotification === 'function') {
showNotification('Failed to load audio. Tap again to retry.', 'warning');
}
}
}
function stopReaderInstance(inst) {
if (inst.audio) {
try {
inst.audio.pause();
inst.audio.currentTime = 0;
} catch (e) {}
}
stopReaderHighlightLoop(inst);
clearReaderHighlights(inst);
}
function scrollToReaderBlock(index) {
const blockEl = document.querySelector(`.reader-block[data-reader-index="${index}"]`);
if (blockEl) {
const headerOffset = 100;
const elementPosition = blockEl.getBoundingClientRect().top;
const offsetPosition = elementPosition + window.pageYOffset - headerOffset;
window.scrollTo({ top: offsetPosition, behavior: 'smooth' });
blockEl.classList.add('highlight-section');
setTimeout(() => blockEl.classList.remove('highlight-section'), 2000);
}
}
// ============================================
// Highlighting
// ============================================
function startReaderHighlightLoop(inst) {
cancelAnimationFrame(inst.animFrameId);
function loop() {
if (!inst.audio || inst.audio.paused) return;
const currentTime = inst.audio.currentTime;
const activeAiIndex = inst.transcription.findIndex(w => currentTime >= w.start && currentTime < w.end);
if (activeAiIndex !== -1) {
const activeTextIndex = inst.wordMap.findIndex(i => i === activeAiIndex);
if (activeTextIndex !== -1) {
const activeSpan = inst.wordSpans[activeTextIndex];
if (activeSpan !== inst.lastWordSpan) {
if (inst.lastWordSpan) inst.lastWordSpan.classList.remove('current-word');
activeSpan.classList.add('current-word');
const rect = activeSpan.getBoundingClientRect();
// Relaxed threshold for smoother scroll
if (rect.top < window.innerHeight * 0.2 || rect.bottom > window.innerHeight * 0.8) {
activeSpan.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
inst.lastWordSpan = activeSpan;
}
}
}
const activeSentence = inst.sentenceData.find(s => currentTime >= s.startTime && currentTime <= s.endTime);
if (activeSentence && activeSentence.spans !== inst.lastSentenceSpans) {
if (inst.lastSentenceSpans && inst.lastSentenceSpans.length) {
inst.lastSentenceSpans.forEach(s => s.classList.remove('current-sentence-bg'));
}
activeSentence.spans.forEach(s => s.classList.add('current-sentence-bg'));
inst.lastSentenceSpans = activeSentence.spans;
}
inst.animFrameId = requestAnimationFrame(loop);
}
inst.animFrameId = requestAnimationFrame(loop);
}
function stopReaderHighlightLoop(inst) {
cancelAnimationFrame(inst.animFrameId);
}
function clearReaderHighlights(inst) {
if (inst.lastWordSpan) {
inst.lastWordSpan.classList.remove('current-word');
inst.lastWordSpan = null;
}
if (inst.lastSentenceSpans && inst.lastSentenceSpans.length) {
inst.lastSentenceSpans.forEach(s => s.classList.remove('current-sentence-bg'));
inst.lastSentenceSpans = [];
}
}
// ============================================
// Button State
// ============================================
function updateReaderButton(state) {
const btn = document.getElementById('reader-floating-btn');
if (!btn) return;
const textEl = document.getElementById('reader-btn-text');
const playIcon = document.getElementById('reader-btn-play');
const pauseIcon = document.getElementById('reader-btn-pause');
// If loading, the spinner overrides icons
if (btn.classList.contains('loading')) return;
if (readerStarted) {
if (textEl) textEl.style.display = 'none';
btn.classList.add('active-mode');
if (state === 'playing') {
playIcon.style.display = 'none';
pauseIcon.style.display = 'block';
} else {
playIcon.style.display = 'block';
pauseIcon.style.display = 'none';
}
}
}
// ============================================
// Utility
// ============================================
function base64ToBlob(base64, mimeType) {
const byteCharacters = atob(base64);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
return new Blob([byteArray], { type: mimeType });
}
function addReaderStyles() {
if (document.getElementById('readerStyles')) return;
const style = document.createElement('style');
style.id = 'readerStyles';
style.textContent = `
@keyframes readerSpin { to { transform: rotate(360deg); } }
#readerContainer {
flex: 1;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
min-height: 500px;
padding: 24px 48px !important;
position: relative;
max-width: 900px !important;
margin: 0 auto;
}
@media (max-width: 768px) {
#readerContainer { padding: 16px 24px !important; }
}
.reader-flow { margin-bottom: 48px; }
.reader-block { position: relative; margin-bottom: 16px; padding: 8px 16px; border-radius: var(--border-radius-sm); transition: background 0.2s; }
.reader-content { font-family: var(--font-serif); font-size: 1.125rem; line-height: 1.8; }
.reader-content p { margin-bottom: 1em; }
.reader-content h1, .reader-content h2, .reader-content h3 { font-family: var(--font-serif); margin-top: 1.5em; margin-bottom: 0.5em; }
.reader-image-block { text-align: center; margin: 24px 0; }
.reader-image-block img {
max-width: 100%; height: auto; border-radius: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1); display: block; margin: 0 auto;
}
.reader-image-placeholder { text-align: center; padding: 40px; background: #f8fafc; border: 2px dashed #e2e8f0; border-radius: 12px; }
.reader-word { cursor: pointer; padding: 1px 0; border-radius: 3px; transition: background 0.15s, color 0.15s; }
.reader-word:hover { background: #e3f2fd; }
.reader-word.current-word { color: #3d4e81; text-decoration: underline; text-decoration-thickness: 3px; text-underline-offset: 3px; font-weight: 700; }
.current-sentence-bg { -webkit-box-decoration-break: clone; box-decoration-break: clone; background-color: #e0e7ff; padding: 0.1em 0.2em; margin: 0 -0.15em; border-radius: 6px; }
#reader-floating-btn {
position: fixed; top: 80px; right: 24px; height: 56px; min-width: 56px; padding: 0 20px;
border-radius: 28px; background: linear-gradient(135deg, var(--primary-color) 0%, #7c3aed 100%);
border: none; color: white; box-shadow: 0 4px 15px rgba(0,0,0,0.25);
display: flex; align-items: center; justify-content: center; gap: 8px; cursor: pointer;
z-index: 1050; transition: transform 0.2s, box-shadow 0.2s, background 0.2s;
font-family: var(--font-sans); font-weight: 600; font-size: 1rem;
}
#reader-floating-btn:hover { transform: scale(1.05); box-shadow: 0 6px 20px rgba(0,0,0,0.3); }
#reader-floating-btn:active { transform: scale(0.95); }
#reader-floating-btn.active-mode { width: 56px; padding: 0; border-radius: 50%; }
#reader-floating-btn.active-mode #reader-btn-text { display: none; }
#reader-floating-btn.loading {
background: linear-gradient(135deg, #6b7280, #9ca3af);
cursor: wait;
}
#reader-floating-btn.loading #reader-btn-text,
#reader-floating-btn.loading #reader-btn-play,
#reader-floating-btn.loading #reader-btn-pause {
display: none !important;
}
.reader-btn-spinner {
width: 24px; height: 24px;
border: 3px solid rgba(255,255,255,0.3);
border-top-color: white;
border-radius: 50%;
animation: readerSpin 0.8s linear infinite;
}
@media (max-width: 768px) {
#reader-floating-btn { top: auto; bottom: 20px; right: 20px !important; left: auto !important; }
}
.highlight-section {
animation: highlightPulse 2s ease-out;
}
@keyframes highlightPulse {
0% { background-color: rgba(79, 70, 229, 0.15); border-left: 4px solid #4f46e5; border-radius: var(--border-radius-sm); }
100% { background-color: transparent; border-left: 4px solid transparent; border-radius: var(--border-radius-sm); }
}
`;
document.head.appendChild(style);
}