Lazy audio loading for interactive and public readers
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user