Lazy audio loading for interactive and public readers

This commit is contained in:
Ashim Kumar
2026-05-23 17:48:03 +06:00
parent e0e3b65c75
commit 965470853e
3 changed files with 181 additions and 141 deletions

View File

@@ -1,18 +1,12 @@
/**
* Interactive Reader Module — Smart Preload Architecture (v3)
* Interactive Reader Module — Lazy Audio Loading (v4)
*
* 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.
* Strategy:
* - Text + transcription are already loaded (from editorBlocks in memory).
* - Audio is fetched on-demand from /api/projects/<id>/audio/<block_id>
* when the user wants to play that block.
* - Smart preload: at 70% of current block, fetch next block's audio.
* - Memory cap: keep at most MAX_AUDIO_LOADED blob URLs alive.
*/
// ============================================
@@ -72,7 +66,8 @@ function renderInteractiveReader() {
isFirstBlockOfChapter = false;
if (!isImageBlock && blockData && blockData.audio_data) {
// has_audio comes from server; audio_data may not yet be loaded
if (!isImageBlock && blockData && (blockData.audio_data || blockData.has_audio)) {
hasAudio = true;
}
currentIndex++;
@@ -102,7 +97,6 @@ function renderInteractiveReader() {
let html = '<div class="reader-flow">';
// Cleanup any previous instances (revoke blob URLs)
cleanupAllReaderInstances();
readerInstances = [];
@@ -112,7 +106,8 @@ function renderInteractiveReader() {
const blockData = block._editorData;
const isImageBlock = block._isImage;
const hasBlockAudio = !isImageBlock && blockData && blockData.audio_data;
// has_audio is the SOURCE OF TRUTH for whether this block has audio on server
const hasBlockAudio = !isImageBlock && blockData && (blockData.audio_data || blockData.has_audio);
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}">`;
@@ -150,7 +145,7 @@ function renderInteractiveReader() {
wordMap: [],
sentenceData: [],
audio: null,
audioUrl: null, // blob URL ref for cleanup
audioUrl: null,
audioReady: false,
audioLoadingPromise: null,
midPreloadTriggered: false,
@@ -166,7 +161,6 @@ function renderInteractiveReader() {
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}`);
@@ -453,25 +447,52 @@ function setReaderButtonLoading(isLoading) {
}
// ============================================
// Audio Lazy Loading + Memory Management
// Audio Lazy Loading
// ============================================
/**
* Fetch audio for an instance. If already loaded into editorBlocks
* by background loader, use that. Otherwise fetch from API directly.
*/
async function fetchAudioForInstance(inst) {
// Path 1: audio_data already in editorBlocks (loaded in background)
if (inst.blockData && inst.blockData.audio_data) {
return {
audio_data: inst.blockData.audio_data,
audio_format: inst.blockData.audio_format || 'mp3'
};
}
// Path 2: fetch from API
if (!inst.blockData || !inst.blockData.db_id || !currentProject || !currentProject.id) {
throw new Error('Cannot fetch audio: missing block info');
}
const resp = await fetch(`/api/projects/${currentProject.id}/audio/${inst.blockData.db_id}`);
const data = await resp.json();
if (data.error || !data.audio_data) {
throw new Error(data.error || 'No audio data');
}
// Cache into editorBlocks for future use
inst.blockData.audio_data = data.audio_data;
inst.blockData.audio_format = data.audio_format;
return data;
}
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);
inst.audioLoadingPromise = (async () => {
const audioInfo = await fetchAudioForInstance(inst);
const audioBlob = base64ToBlob(audioInfo.audio_data, `audio/${audioInfo.audio_format || 'mp3'}`);
const audioUrl = URL.createObjectURL(audioBlob);
const audio = new Audio(audioUrl);
return new Promise((resolve, reject) => {
const onCanPlay = () => {
audio.removeEventListener('error', onError);
inst.audio = audio;
@@ -488,14 +509,12 @@ function ensureReaderAudioLoaded(inst) {
};
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);
}
});
})().catch(err => {
inst.audioLoadingPromise = null;
throw err;
});
return inst.audioLoadingPromise;
@@ -524,7 +543,6 @@ function wireReaderAudioEvents(inst) {
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;
@@ -651,7 +669,6 @@ async function playReaderInstanceByIndex(index, opts = {}) {
const inst = readerInstances[index];
if (!inst.hasAudio) {
// Skip non-audio blocks
playReaderInstanceByIndex(findNextAudioIndex(index), opts);
return;
}
@@ -680,7 +697,6 @@ async function playReaderInstanceByIndex(index, opts = {}) {
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) {
@@ -749,7 +765,6 @@ function startReaderHighlightLoop(inst) {
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' });
}
@@ -801,7 +816,6 @@ function updateReaderButton(state) {
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) {