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

@@ -99,17 +99,6 @@
.story-text-container p { margin-bottom: 1.2em; }
.story-text-container img { max-width: 100%; height: auto; border-radius: 8px; margin: 16px auto; display: block; }
.block-loading-spinner {
display: inline-flex; align-items: center; gap: 8px;
color: #6b7280; font-size: 0.9rem; font-family: "Inter", sans-serif;
padding: 8px 0;
}
.block-loading-spinner::before {
content: ''; width: 16px; height: 16px;
border: 2px solid #e2e8f0; border-top-color: #5753c9;
border-radius: 50%; animation: spin 0.8s linear infinite;
}
.word { transition: all 0.15s ease; border-radius: 3px; cursor: pointer; }
.word:hover { background-color: #f1f5f9; }
.current-sentence-bg {
@@ -121,7 +110,6 @@
.story-image-block { text-align: center; margin: 24px 0; }
.story-image-block img { max-width: 100%; height: auto; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
/* Floating Player Button — Fixed RIGHT side */
#floating-player-btn {
position: fixed;
top: 5rem;
@@ -186,7 +174,6 @@
border-radius: 50%; animation: spin 0.8s linear infinite;
}
/* Block highlight on outline click */
.story-block.highlight-section,
.story-image-block.highlight-section {
animation: highlightPulse 2s ease-out;
@@ -265,19 +252,11 @@
<script>
/**
* Public Reader — Smart Preload Architecture (v3)
* Public Reader — Lazy Audio Loading
*
* Loading Strategy:
* 1. TEXT + TIMESTAMPS: loaded eagerly from /api/public/books/<id> in single batch.
* 2. AUDIO: base64 → Blob URL conversion is DEFERRED until needed.
* 3. Smart preload: when block N plays, preload blob URLs for N+1, N+2.
* At 70% mark of N, ensure N+1 is ready (safety net).
* 4. Memory cap: keep at most MAX_AUDIO_LOADED blob URLs alive;
* revoke distant past audio to free browser memory.
*
* Scroll Strategy:
* - Manual navigation (Start / outline click / word click): scroll to block.
* - Auto-advance (audio ended → next): NO block scroll — word highlighter carries user.
* Audio is fetched per-block from /api/public/books/<id>/audio/<block_id>
* when the user wants to play it. This avoids loading 15-20 MB
* of base64 audio up front (which gets truncated by reverse proxies).
*/
const pathParts = window.location.pathname.split('/');
@@ -297,7 +276,7 @@
let hasStarted = false;
let navObserver = null;
// Tunables (matches reader_templates/index.html)
// Tunables
const PRELOAD_AHEAD = 2;
const MID_PRELOAD_THRESHOLD = 0.7;
const MAX_AUDIO_LOADED = 5;
@@ -306,9 +285,6 @@
floatingPlayerBtn.addEventListener("click", handleFloatingBtnClick);
mainContainer.addEventListener("click", handleTextClick);
// ===================================================
// UI Helpers
// ===================================================
function showToast(msg) { toastText.textContent = msg; toastEl.classList.add("visible"); }
function hideToast() { toastEl.classList.remove("visible"); }
function setButtonLoading(b) { floatingPlayerBtn.classList.toggle("loading", b); }
@@ -329,10 +305,8 @@
}
}
// ===================================================
// INITIAL LOAD
// ===================================================
try {
// Step 1: Load metadata only (no audio_data)
const resp = await fetch(`/api/public/books/${BOOK_ID}`);
if (!resp.ok) throw new Error('Failed to load book');
const book = await resp.json();
@@ -341,7 +315,6 @@
document.getElementById("book-subtitle").textContent = book.author ? `by ${book.author}` : 'An interactive audiobook';
document.title = book.name + ' - Audiobook Reader';
// Build flat list of blocks across chapters
let globalIdx = 0;
for (const chapter of book.chapters) {
let firstInChapter = true;
@@ -369,7 +342,8 @@
continue;
}
if (!block.audio_data) continue;
// Skip blocks that have no audio at all
if (!block.has_audio) continue;
const blockId = `story-block-${globalIdx}`;
@@ -379,7 +353,6 @@
mainContainer.insertAdjacentHTML("beforeend", `
<div id="${blockId}" class="story-block mt-4" data-instance-index="${storyInstances.length}" data-chapter="${chapter.chapter_number}">
<article class="story-text-container"></article>
<audio class="audio-player" preload="none" style="display:none;"></audio>
</div>
`);
@@ -388,8 +361,9 @@
blockEl: document.getElementById(blockId),
block: block,
chapter: chapter,
dbBlockId: block.id, // Database block ID for lazy fetch
audio: null,
audioUrl: null, // blob URL ref for cleanup
audioUrl: null,
audioReady: false,
audioLoadingPromise: null,
midPreloadTriggered: false,
@@ -405,7 +379,7 @@
}
}
// Render text + sync for each instance (cheap, in-memory)
// Render text + sync for each instance
for (const inst of storyInstances) {
renderMarkdownInto(inst);
smartSync(inst);
@@ -420,9 +394,6 @@
mainContainer.innerHTML = `<p class="text-center text-danger">Error loading book: ${e.message}</p>`;
}
// ===================================================
// Outline / Navigation
// ===================================================
function addOutlineEntry(title, chapter, targetId) {
const li = document.createElement("li");
li.textContent = title;
@@ -465,9 +436,6 @@
document.querySelectorAll('.story-block, .story-image-block').forEach(b => navObserver.observe(b));
}
// ===================================================
// Render & Sync
// ===================================================
function renderMarkdownInto(inst) {
const container = inst.blockEl.querySelector(".story-text-container");
container.innerHTML = "";
@@ -531,23 +499,27 @@
}
// ===================================================
// AUDIO LAZY LOADING + MEMORY MANAGEMENT
// AUDIO LAZY LOADING (per-block fetch)
// ===================================================
async function fetchAudioBlob(inst) {
const resp = await fetch(`/api/public/books/${BOOK_ID}/audio/${inst.dbBlockId}`);
if (!resp.ok) throw new Error('Failed to fetch audio');
const data = await resp.json();
if (data.error || !data.audio_data) throw new Error(data.error || 'No audio data');
return data;
}
function ensureAudioLoaded(inst) {
if (inst.audioReady && inst.audio) return Promise.resolve(inst);
if (inst.audioLoadingPromise) return inst.audioLoadingPromise;
inst.audioLoadingPromise = new Promise((resolve, reject) => {
if (!inst.block.audio_data) {
inst.audioLoadingPromise = null;
return reject(new Error('No audio data'));
}
inst.audioLoadingPromise = (async () => {
const audioData = await fetchAudioBlob(inst);
const blob = base64ToBlob(audioData.audio_data, `audio/${audioData.audio_format || 'mp3'}`);
const url = URL.createObjectURL(blob);
const audio = new Audio(url);
try {
const audio = inst.blockEl.querySelector('.audio-player');
const blob = base64ToBlob(inst.block.audio_data, `audio/${inst.block.audio_format || 'mp3'}`);
const url = URL.createObjectURL(blob);
return new Promise((resolve, reject) => {
const onCanPlay = () => {
audio.removeEventListener('error', onError);
inst.audio = audio;
@@ -562,18 +534,16 @@
inst.audioLoadingPromise = null;
reject(new Error('Audio failed to load'));
};
audio.addEventListener('canplay', onCanPlay, { once: true });
audio.addEventListener('error', onError, { once: true });
audio.preload = 'auto';
audio.src = url;
audio.load();
} catch (err) {
inst.audioLoadingPromise = null;
reject(err);
}
});
})().catch(err => {
inst.audioLoadingPromise = null;
throw err;
});
return inst.audioLoadingPromise;
}
@@ -600,7 +570,6 @@
updateFloatingButton('paused');
}
});
// Mid-play safety net
audio.addEventListener('timeupdate', () => {
if (inst.midPreloadTriggered) return;
if (!audio.duration || isNaN(audio.duration)) return;
@@ -614,9 +583,6 @@
});
}
/**
* Preload N audio blocks ahead (fire-and-forget).
*/
function preloadAhead(fromIndex) {
for (let i = 1; i <= PRELOAD_AHEAD; i++) {
const idx = fromIndex + i;
@@ -625,11 +591,6 @@
}
}
/**
* Memory management: keep only sliding window of audio elements loaded.
* Window = [currentIndex - KEEP_BEHIND, currentIndex + PRELOAD_AHEAD]
* Bounded to MAX_AUDIO_LOADED total.
*/
function pruneLoadedAudio(currentIndex) {
const loaded = storyInstances.filter(i => i.audioReady && i.audio && i.audioUrl);
if (loaded.length <= MAX_AUDIO_LOADED) return;
@@ -659,23 +620,17 @@
function releaseAudio(inst) {
if (!inst.audio) return;
try {
inst.audio.pause();
inst.audio.removeAttribute('src');
inst.audio.load();
} catch (e) { /* ignore */ }
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
// ===================================================
async function playInstance(idx, ts = 0, opts = {}) {
if (idx < 0 || idx >= storyInstances.length) return;
const inst = storyInstances[idx];
@@ -707,7 +662,6 @@
await inst.audio.play();
updateFloatingButton('playing');
// Block-level scroll ONLY for manual navigation
if (!isAutoAdvance) {
const rect = inst.blockEl.getBoundingClientRect();
if (rect.top < 0 || rect.top > window.innerHeight * 0.6) {
@@ -767,9 +721,6 @@
playInstance(idx, inst.block.transcription[aiIdx].start);
}
// ===================================================
// Highlighting
// ===================================================
function startHighlightLoop(inst) {
cancelAnimationFrame(inst.animFrameId);
inst.animFrameId = requestAnimationFrame(() => highlightLoop(inst));
@@ -812,9 +763,6 @@
inst.lastSentenceSpans = [];
}
// ===================================================
// Utility
// ===================================================
function base64ToBlob(b64, mime) {
const bin = atob(b64);
const arr = new Uint8Array(bin.length);