/**
* 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 += `
';
// 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 += `
`;
if (isImageBlock) {
const imageHtml = buildImageHtml(block, blockData);
html += `
${imageHtml}
`;
} else {
const blockImages = getBlockImages(block, blockData);
for (const img of blockImages) {
if (img.position === 'before' && img.data) {
html += `
`;
}
}
html += `
`;
for (const img of blockImages) {
if (img.position === 'after' && img.data) {
html += `
`;
}
}
}
html += `
`;
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 += '
';
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 += `