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