Used Codex to fix audio player and add it to Reckoning.

This commit is contained in:
nick 2026-05-27 19:28:37 +01:00
parent fec02ca22d
commit 0c14806520
5 changed files with 621 additions and 1243 deletions

View File

@ -34,7 +34,7 @@ namespace CatherineLynwood.Controllers
return NotFound($"File not found: {filePath}"); return NotFound($"File not found: {filePath}");
var stream = System.IO.File.OpenRead(filePath); var stream = System.IO.File.OpenRead(filePath);
return File(stream, "audio/mpeg"); return File(stream, "audio/mpeg", enableRangeProcessing: true);
} }
@ -51,7 +51,7 @@ namespace CatherineLynwood.Controllers
return NotFound($"File not found: {filePath}"); return NotFound($"File not found: {filePath}");
var stream = System.IO.File.OpenRead(filePath); var stream = System.IO.File.OpenRead(filePath);
return File(stream, "audio/mpeg"); return File(stream, "audio/mpeg", enableRangeProcessing: true);
} }
[HttpGet("playlist/chapter/{chapterNumber:int}")] [HttpGet("playlist/chapter/{chapterNumber:int}")]
@ -83,9 +83,10 @@ namespace CatherineLynwood.Controllers
} }
[HttpGet("playlist/all")] [HttpGet("playlist/all")]
public IActionResult GetAllChapters() public IActionResult GetAllChapters([FromQuery] string? book = null)
{ {
var segments = _cache.GetAll() var segments = _cache.GetAll()
.Where(s => string.IsNullOrWhiteSpace(book) || s.ChapterFolder.Contains(book, StringComparison.OrdinalIgnoreCase))
.OrderBy(s => s.ChapterFolder) .OrderBy(s => s.ChapterFolder)
.ThenBy(s => s.File) .ThenBy(s => s.File)
.ToList(); .ToList();

View File

@ -27,15 +27,20 @@ namespace CatherineLynwood.Services
if (DateTimeOffset.UtcNow.ToUnixTimeSeconds() > expires) if (DateTimeOffset.UtcNow.ToUnixTimeSeconds() > expires)
return false; return false;
if (string.IsNullOrWhiteSpace(token))
return false;
var expected = GenerateToken(id, expires); var expected = GenerateToken(id, expires);
return expected == token; return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(expected),
Encoding.UTF8.GetBytes(token));
} }
public string GenerateUrl(string id) public string GenerateUrl(string id)
{ {
var expires = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + _expirySeconds; var expires = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + _expirySeconds;
var token = GenerateToken(id, expires); var token = GenerateToken(id, expires);
return $"/api/audio/stream/{id}?expires={expires}&token={Uri.EscapeDataString(token)}"; return $"/api/audio/secure-stream/{id}?expires={expires}&token={Uri.EscapeDataString(token)}";
} }
} }
} }

View File

@ -21,7 +21,7 @@
<div class="col"> <div class="col">
<p class="h3 text-center" id="status">Loading...</p> <p class="h3 text-center" id="status">Loading...</p>
<ul class="pagination justify-content-center" id="chapter-list"></ul> <ul class="pagination justify-content-center" id="chapter-list"></ul>
<audio id="audio" preload="none"></audio> <audio id="audio" preload="none" data-book-key="Discovery" data-playlist-url="/api/audio/playlist/all?book=Discovery"></audio>
<div class="input-group border border-2 border-dark rounded-3"> <div class="input-group border border-2 border-dark rounded-3">
<!-- Progress Bar (styled to look like an input) --> <!-- Progress Bar (styled to look like an input) -->
<div class="form-control p-0" style="height: 40px;"> <div class="form-control p-0" style="height: 40px;">
@ -53,622 +53,5 @@
</div> </div>
@section Scripts { @section Scripts {
<script> <script src="~/js/audiobook-player.js" asp-append-version="true"></script>
const audioBlobCache = new Map(); // id => Blob
const audioBlobSizes = new Map(); // id => size in bytes
let totalBlobSize = 0;
const MAX_BLOB_CACHE_SIZE = 100 * 1024 * 1024; // 100 MB
const BUFFER_TARGET_SECONDS = 30 * 60; // 30 minutes
let masterPlaylist = [];
let chapterStartIndices = [];
let currentIndex = 0;
let preloadedAudio = null;
const audio = document.getElementById("audio");
const status = document.getElementById("status");
const toggleButton = document.getElementById("toggle-play");
// Load the playlist
fetch("/api/audio/playlist/all")
.then(res => res.json())
.then(data => {
// ✅ Sort chapters numerically by chapter number
data.sort((a, b) => {
const aNum = parseInt(a.chapter.match(/Chapter_(\d+)/i)?.[1] || "0", 10);
const bNum = parseInt(b.chapter.match(/Chapter_(\d+)/i)?.[1] || "0", 10);
return aNum - bNum;
});
let flatIndex = 0;
chapterStartIndices = []; // global or higher scoped variable
masterPlaylist = []; // reset in case of reload
data.forEach(chapter => {
chapterStartIndices.push({ index: flatIndex, name: chapter.chapter });
chapter.segments.forEach(segment => {
masterPlaylist.push({
id: segment.id,
display: segment.display,
chapter: chapter.chapter,
duration: parseDuration(segment.display),
text: segment.text || ""
});
flatIndex++;
});
});
// ✅ Determine saved/resume position
const savedIndex = parseInt(localStorage.getItem("lastAudioIndex") || "0", 10);
const currentSeg = masterPlaylist[savedIndex] || {};
const currentChapter = currentSeg.chapter || chapterStartIndices[0].name;
// ✅ Ensure pagination is rendered properly
renderChapterPagination(currentChapter);
status.textContent = `Ready – ${masterPlaylist.length} segments total.`;
// ✅ Preload first segments of each chapter (for snappy chapter switches)
preloadFirstSegments();
// ✅ Preload 10 minutes of sequential audio starting from resume position or beginning
const preloadStart = (savedIndex >= 0 && savedIndex < masterPlaylist.length)
? savedIndex
: 0;
preloadSegmentsAhead(preloadStart, BUFFER_TARGET_SECONDS); // preload 10 mins ahead
// ✅ Resume if index is valid
if (savedIndex >= 0 && savedIndex < masterPlaylist.length) {
const seg = masterPlaylist[savedIndex];
renderChapterText(seg.chapter);
initChapterProgress(seg.chapter);
loadSegment(savedIndex);
}
});
function fetchBlobWithRetry(url, retries = 3, delayMs = 1000) {
return new Promise((resolve, reject) => {
const attempt = (n) => {
fetch(url)
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.blob();
})
.then(resolve)
.catch(err => {
if (n > 0) {
setTimeout(() => attempt(n - 1), delayMs);
} else {
console.warn(`Failed to fetch blob ${url}:`, err);
reject(err);
}
});
};
attempt(retries);
});
}
function renderChapterPagination(currentChapter) {
let listHtml = '';
const currentPos = chapterStartIndices.findIndex(c => c.name === currentChapter);
const totalChapters = chapterStartIndices.length;
// Dynamically determine window size based on screen width
const windowSize = getWindowSize();
const halfWindow = Math.floor(windowSize / 2);
// Previous button
listHtml += `<li class="page-item ${currentPos <= 0 ? 'disabled' : ''}">
<a class="page-link" href="#" onclick="goToPrevChapter(${currentPos})"><i class="fad fa-step-backward"></i></a>
</li>`;
const addPageItem = (i, label = null) => {
const chapterNumber = chapterStartIndices[i].name.match(/Chapter_(\d+)/i)?.[1] || "?";
const isActive = i === currentPos;
listHtml += `<li class="page-item ${isActive ? 'active' : ''}">
<a class="page-link" href="#" onclick="startFrom(${chapterStartIndices[i].index})">${label || chapterNumber}</a>
</li>`;
};
// First button
addPageItem(0);
// Ellipsis after first if needed
if (currentPos - halfWindow > 1) {
listHtml += `<li class="page-item disabled d-none d-md-block"><span class="page-link">…</span></li>`;
}
// Sliding window
const start = Math.max(1, currentPos - halfWindow);
const end = Math.min(totalChapters - 2, currentPos + halfWindow);
for (let i = start; i <= end; i++) {
if (i !== 0 && i !== totalChapters - 1) {
addPageItem(i);
}
}
// Ellipsis before last if needed
if (currentPos + halfWindow < totalChapters - 2) {
listHtml += `<li class="page-item disabled d-none d-md-block"><span class="page-link">…</span></li>`;
}
// Last button
if (totalChapters > 1) {
addPageItem(totalChapters - 1);
}
// Next button
listHtml += `<li class="page-item ${currentPos >= totalChapters - 1 ? 'disabled' : ''}">
<a class="page-link" href="#" onclick="goToNextChapter(${currentPos})"><i class="fad fa-step-forward"></i></a>
</li>`;
document.getElementById("chapter-list").innerHTML = listHtml;
}
function getWindowSize() {
const width = window.innerWidth;
if (width < 576) {
return 5; // Extra small (mobile)
} else if (width < 768) {
return 7; // Small (phones / small tablets)
} else if (width < 992) {
return 9; // Medium (tablets)
} else if (width < 1200) {
return 13; // Large (small desktops)
} else {
return 19; // Extra large (wide desktop screens)
}
}
function goToPrevChapter(currentPos) {
if (currentPos <= 0) return;
const newIndex = chapterStartIndices[currentPos - 1].index;
const newChapter = chapterStartIndices[currentPos - 1].name;
renderChapterPagination(newChapter);
startFrom(newIndex);
}
function goToNextChapter(currentPos) {
if (currentPos >= chapterStartIndices.length - 1) return;
const newIndex = chapterStartIndices[currentPos + 1].index;
const newChapter = chapterStartIndices[currentPos + 1].name;
renderChapterPagination(newChapter);
startFrom(newIndex);
}
function parseDuration(display) {
const parts = display.split(":").map(Number);
return parts[0] * 60 + (parts[1] || 0);
}
// Call this in startFrom()
function startFrom(index) {
const seg = masterPlaylist[index];
// Ensure autoplay works (helps with Chrome mobile restrictions)
audio.muted = false;
audio.autoplay = true;
renderChapterPagination(seg.chapter);
renderChapterText(seg.chapter);
initChapterProgress(seg.chapter);
loadSegment(index);
}
function loadSegment(index) {
if (index < 0 || index >= masterPlaylist.length) return;
currentIndex = index;
updateBufferBar(index);
localStorage.setItem("lastAudioIndex", index.toString());
const seg = masterPlaylist[index];
// ✅ Scroll and highlight current paragraph
document.querySelectorAll(".chapter-text-scroll p").forEach(p => p.classList.remove("active"));
const activePara = document.getElementById(`text-seg-${index}`);
if (activePara) {
activePara.classList.add("active");
const container = document.querySelector(".chapter-text-scroll");
const paraTop = activePara.offsetTop;
const paraHeight = activePara.offsetHeight;
const containerHeight = container.clientHeight;
const scrollTarget = paraTop - container.offsetTop - (containerHeight / 2) + (paraHeight / 2);
container.scrollTo({ top: scrollTarget, behavior: "smooth" });
}
const tryPlay = async (blob, retryCount = 0) => {
try {
const blobUrl = URL.createObjectURL(blob);
audio.src = blobUrl;
await audio.play();
status.textContent = `Playing: ${seg.chapter.replace(/_/g, ' ')}`;
toggleButton.innerHTML = '<i class="fad fa-pause"></i>';
} catch (err) {
if (retryCount < 10) {
console.warn(`Retrying audio playback (${retryCount + 1})...`);
setTimeout(() => tryPlay(blob, retryCount + 1), 3000);
} else {
console.error("Playback failed permanently:", err);
status.textContent = "Error loading audio segment.";
toggleButton.innerHTML = '<i class="fad fa-play"></i>';
}
}
};
const fetchAndPlay = async () => {
try {
const response = await fetchWithRetry(`/api/audio/token-url/${seg.id}`, 5, 2000);
const blob = await fetch(response).then(r => r.blob());
audioBlobCache.set(seg.id, blob);
tryPlay(blob);
} catch (err) {
console.error("Initial fetch failed, will retry in background:", err);
status.textContent = "Waiting for signal...";
let retries = 0;
const maxRetries = 12;
const retryLoop = setInterval(async () => {
try {
const response = await fetchWithRetry(`/api/audio/token-url/${seg.id}`, 3, 2000);
const blob = await fetch(response).then(r => r.blob());
clearInterval(retryLoop);
audioBlobCache.set(seg.id, blob);
tryPlay(blob);
} catch (err) {
retries++;
console.warn(`Retry ${retries}/${maxRetries} failed`);
if (retries >= maxRetries) {
clearInterval(retryLoop);
status.textContent = "Unable to load segment.";
}
}
}, 10000);
}
};
if (audioBlobCache.has(seg.id)) {
tryPlay(audioBlobCache.get(seg.id));
} else {
fetchAndPlay();
}
maybeTriggerPreload();
}
function maybeTriggerPreload() {
if (audio.paused) return; // only preload while playing
const bufferedPercent = getBufferedPercent(currentIndex);
if (bufferedPercent < 90) {
preloadSegmentsAhead(currentIndex + 1, BUFFER_TARGET_SECONDS);
}
}
function getBufferedPercent(currentIdx) {
let bufferedSeconds = 0;
let totalChecked = 0;
let i = currentIdx + 1;
while (i < masterPlaylist.length && totalChecked < BUFFER_TARGET_SECONDS) {
const seg = masterPlaylist[i];
totalChecked += seg.duration;
if (audioBlobCache.has(seg.id)) {
bufferedSeconds += seg.duration;
}
i++;
}
return Math.min((bufferedSeconds / BUFFER_TARGET_SECONDS) * 100, 100);
}
function preloadSegmentsAhead(startIndex, targetSeconds) {
let totalDuration = 0;
let i = startIndex;
while (i < masterPlaylist.length && totalDuration < targetSeconds) {
const seg = masterPlaylist[i];
if (!audioBlobCache.has(seg.id)) {
fetch(`/api/audio/token-url/${seg.id}`)
.then(res => res.text())
.then(url => fetchBlobWithRetry(url))
.then(blob => {
const blobSize = blob.size;
audioBlobCache.set(seg.id, blob);
audioBlobSizes.set(seg.id, blobSize);
totalBlobSize += blobSize;
enforceBlobCacheLimit();
updateBufferBar(currentIndex);
})
.catch(err => console.warn("Preload failed:", err));
}
totalDuration += seg.duration;
i++;
}
}
function fetchWithRetry(url, retries = 3, delayMs = 1000) {
return new Promise((resolve, reject) => {
const attempt = (n) => {
fetch(url)
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.text();
})
.then(resolve)
.catch(err => {
if (n > 0) {
setTimeout(() => attempt(n - 1), delayMs);
} else {
console.warn(`Failed to fetch ${url} after retries:`, err);
reject(err);
}
});
};
attempt(retries);
});
}
function renderChapterText(chapterName) {
const container = document.getElementById("chapter-text-content");
container.innerHTML = "";
masterPlaylist.forEach((seg, i) => {
if (seg.chapter === chapterName) {
const p = document.createElement("p");
p.textContent = seg.text || "";
p.id = `text-seg-${i}`;
container.appendChild(p);
}
});
}
let currentChapter = "";
let chapterSegments = [];
let chapterTotalDuration = 0;
function initChapterProgress(chapterName) {
currentChapter = chapterName;
chapterSegments = masterPlaylist.filter(s => s.chapter === chapterName);
chapterTotalDuration = chapterSegments.reduce((sum, seg) => sum + seg.duration, 0);
updateProgressUI(0);
renderChapterTicks(); // ✅ Added here
}
function updateProgressUI(elapsedSeconds) {
const percent = (elapsedSeconds / chapterTotalDuration) * 100;
document.getElementById("chapter-progress-bar").style.width = `${percent}%`;
const mins = Math.floor(elapsedSeconds / 60).toString().padStart(2, "0");
const secs = Math.floor(elapsedSeconds % 60).toString().padStart(2, "0");
const totalMins = Math.floor(chapterTotalDuration / 60).toString().padStart(2, "0");
const totalSecs = Math.floor(chapterTotalDuration % 60).toString().padStart(2, "0");
document.getElementById("chapter-timestamp").textContent = `${mins}:${secs} / ${totalMins}:${totalSecs}`;
}
function getElapsedInChapter(currentIdx, audioCurrentTime) {
let sum = 0;
for (let i = 0; i < currentIdx; i++) {
if (masterPlaylist[i].chapter === currentChapter) {
sum += masterPlaylist[i].duration;
}
}
return sum + audioCurrentTime;
}
function renderChapterTicks() {
const ticksContainer = document.getElementById("chapter-ticks");
ticksContainer.innerHTML = "";
let elapsed = 0;
chapterSegments.forEach(seg => {
const percent = (elapsed / chapterTotalDuration) * 100;
const tick = document.createElement("div");
tick.className = "chapter-tick";
tick.style.left = `${percent}%`;
ticksContainer.appendChild(tick);
// const label = document.createElement("div");
// label.className = "chapter-tick-label";
// label.style.left = `${percent}%`;
// label.textContent = seg.display;
// ticksContainer.appendChild(label);
elapsed += seg.duration;
});
}
function updatePaginationHighlight(currentChapter) {
document.querySelectorAll("#chapter-list .page-item").forEach((li, idx) => {
const chapterNum = parseInt(li.textContent);
const currentNum = parseInt(currentChapter.match(/Chapter_(\d+)/i)?.[1]);
if (chapterNum === currentNum) {
li.classList.add("active");
} else {
li.classList.remove("active");
}
});
}
function preloadFirstSegments() {
chapterStartIndices.forEach(({ index }) => {
const seg = masterPlaylist[index];
if (!audioBlobCache.has(seg.id)) {
fetch(`/api/audio/token-url/${seg.id}`)
.then(res => res.text())
.then(url => fetchBlobWithRetry(url))
.then(blob => {
const blobSize = blob.size;
audioBlobCache.set(seg.id, blob);
audioBlobSizes.set(seg.id, blobSize);
totalBlobSize += blobSize;
enforceBlobCacheLimit();
updateBufferBar(currentIndex);
})
.catch(err => console.warn(`Preload of chapter start failed (${seg.id}):`, err));
}
});
}
function enforceBlobCacheLimit() {
const iterator = audioBlobCache.keys();
while (totalBlobSize > MAX_BLOB_CACHE_SIZE && audioBlobCache.size > 0) {
const oldestKey = iterator.next().value;
const size = audioBlobSizes.get(oldestKey) || 0;
audioBlobCache.delete(oldestKey);
audioBlobSizes.delete(oldestKey);
totalBlobSize -= size;
console.log(`Evicted ${oldestKey} to maintain cache size. Remaining size: ${totalBlobSize} bytes.`);
}
}
function updateBufferBar(currentIdx) {
let bufferedSeconds = 0;
let totalChecked = 0;
let i = currentIdx + 1;
while (i < masterPlaylist.length && totalChecked < BUFFER_TARGET_SECONDS) {
const seg = masterPlaylist[i];
totalChecked += seg.duration;
if (audioBlobCache.has(seg.id)) {
bufferedSeconds += seg.duration;
}
i++;
}
const percent = Math.min((bufferedSeconds / BUFFER_TARGET_SECONDS) * 100, 100);
const bar = document.getElementById("buffer-progress-bar");
bar.style.width = `${percent.toFixed(1)}%`;
bar.setAttribute("aria-valuenow", percent.toFixed(1));
// ✅ Set Bootstrap colour classes
bar.classList.remove("bg-primary", "bg-warning", "bg-danger");
if (percent >= 75) {
bar.classList.add("bg-primary");
} else if (percent >= 30) {
bar.classList.add("bg-warning");
} else {
bar.classList.add("bg-danger");
}
}
audio.addEventListener("ended", () => {
if (currentIndex < masterPlaylist.length - 1) {
const nextIndex = currentIndex + 1;
const nextSeg = masterPlaylist[nextIndex];
const currentChapter = masterPlaylist[currentIndex].chapter;
const nextChapter = nextSeg.chapter;
if (nextChapter !== currentChapter) {
renderChapterPagination(nextChapter); // ✅ Update pagination
renderChapterText(nextChapter);
initChapterProgress(nextChapter);
}
loadSegment(nextIndex);
} else {
status.textContent = "Playback complete.";
}
});
// Update during playback
audio.addEventListener("timeupdate", () => {
if (!chapterSegments.length || !audio.duration) return;
const elapsed = getElapsedInChapter(currentIndex, audio.currentTime);
updateProgressUI(elapsed);
});
toggleButton.addEventListener("click", () => {
if (audio.paused) {
audio.play();
toggleButton.innerHTML = '<i class="fad fa-pause"></i>';
} else {
audio.pause();
toggleButton.innerHTML = '<i class="fad fa-play"></i>';
}
});
audio.addEventListener("play", () => {
toggleButton.innerHTML = '<i class="fad fa-pause"></i>';
});
audio.addEventListener("pause", () => {
toggleButton.innerHTML = '<i class="fad fa-play"></i>';
});
// ✅ These are additional listeners — do not replace existing ones
audio.addEventListener("play", maybeTriggerPreload);
audio.addEventListener("ended", maybeTriggerPreload);
document.getElementById("chapter-progress").addEventListener("click", (e) => {
const container = e.currentTarget;
const rect = container.getBoundingClientRect();
const clickX = e.clientX - rect.left;
const clickPercent = clickX / rect.width;
const targetTime = clickPercent * chapterTotalDuration;
// Find approximate segment
let cumulative = 0;
for (let i = 0; i < chapterSegments.length; i++) {
const seg = chapterSegments[i];
if (cumulative + seg.duration >= targetTime) {
const globalIndex = masterPlaylist.findIndex(s => s.id === seg.id);
if (globalIndex >= 0) loadSegment(globalIndex);
break;
}
cumulative += seg.duration;
}
});
</script>
} }

View File

@ -21,7 +21,7 @@
<div class="col"> <div class="col">
<p class="h3 text-center" id="status">Loading...</p> <p class="h3 text-center" id="status">Loading...</p>
<ul class="pagination justify-content-center" id="chapter-list"></ul> <ul class="pagination justify-content-center" id="chapter-list"></ul>
<audio id="audio" preload="none"></audio> <audio id="audio" preload="none" data-book-key="Reckoning" data-playlist-url="/api/audio/playlist/all?book=Reckoning"></audio>
<div class="input-group border border-2 border-dark rounded-3"> <div class="input-group border border-2 border-dark rounded-3">
<!-- Progress Bar (styled to look like an input) --> <!-- Progress Bar (styled to look like an input) -->
<div class="form-control p-0" style="height: 40px;"> <div class="form-control p-0" style="height: 40px;">
@ -53,622 +53,5 @@
</div> </div>
@section Scripts { @section Scripts {
<script> <script src="~/js/audiobook-player.js" asp-append-version="true"></script>
const audioBlobCache = new Map(); // id => Blob
const audioBlobSizes = new Map(); // id => size in bytes
let totalBlobSize = 0;
const MAX_BLOB_CACHE_SIZE = 100 * 1024 * 1024; // 100 MB
const BUFFER_TARGET_SECONDS = 30 * 60; // 30 minutes
let masterPlaylist = [];
let chapterStartIndices = [];
let currentIndex = 0;
let preloadedAudio = null;
const audio = document.getElementById("audio");
const status = document.getElementById("status");
const toggleButton = document.getElementById("toggle-play");
// Load the playlist
fetch("/api/audio/playlist/all")
.then(res => res.json())
.then(data => {
// ✅ Sort chapters numerically by chapter number
data.sort((a, b) => {
const aNum = parseInt(a.chapter.match(/Chapter_(\d+)/i)?.[1] || "0", 10);
const bNum = parseInt(b.chapter.match(/Chapter_(\d+)/i)?.[1] || "0", 10);
return aNum - bNum;
});
let flatIndex = 0;
chapterStartIndices = []; // global or higher scoped variable
masterPlaylist = []; // reset in case of reload
data.forEach(chapter => {
chapterStartIndices.push({ index: flatIndex, name: chapter.chapter });
chapter.segments.forEach(segment => {
masterPlaylist.push({
id: segment.id,
display: segment.display,
chapter: chapter.chapter,
duration: parseDuration(segment.display),
text: segment.text || ""
});
flatIndex++;
});
});
// ✅ Determine saved/resume position
const savedIndex = parseInt(localStorage.getItem("lastAudioIndex") || "0", 10);
const currentSeg = masterPlaylist[savedIndex] || {};
const currentChapter = currentSeg.chapter || chapterStartIndices[0].name;
// ✅ Ensure pagination is rendered properly
renderChapterPagination(currentChapter);
status.textContent = `Ready – ${masterPlaylist.length} segments total.`;
// ✅ Preload first segments of each chapter (for snappy chapter switches)
preloadFirstSegments();
// ✅ Preload 10 minutes of sequential audio starting from resume position or beginning
const preloadStart = (savedIndex >= 0 && savedIndex < masterPlaylist.length)
? savedIndex
: 0;
preloadSegmentsAhead(preloadStart, BUFFER_TARGET_SECONDS); // preload 10 mins ahead
// ✅ Resume if index is valid
if (savedIndex >= 0 && savedIndex < masterPlaylist.length) {
const seg = masterPlaylist[savedIndex];
renderChapterText(seg.chapter);
initChapterProgress(seg.chapter);
loadSegment(savedIndex);
}
});
function fetchBlobWithRetry(url, retries = 3, delayMs = 1000) {
return new Promise((resolve, reject) => {
const attempt = (n) => {
fetch(url)
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.blob();
})
.then(resolve)
.catch(err => {
if (n > 0) {
setTimeout(() => attempt(n - 1), delayMs);
} else {
console.warn(`Failed to fetch blob ${url}:`, err);
reject(err);
}
});
};
attempt(retries);
});
}
function renderChapterPagination(currentChapter) {
let listHtml = '';
const currentPos = chapterStartIndices.findIndex(c => c.name === currentChapter);
const totalChapters = chapterStartIndices.length;
// Dynamically determine window size based on screen width
const windowSize = getWindowSize();
const halfWindow = Math.floor(windowSize / 2);
// Previous button
listHtml += `<li class="page-item ${currentPos <= 0 ? 'disabled' : ''}">
<a class="page-link" href="#" onclick="goToPrevChapter(${currentPos})"><i class="fad fa-step-backward"></i></a>
</li>`;
const addPageItem = (i, label = null) => {
const chapterNumber = chapterStartIndices[i].name.match(/Chapter_(\d+)/i)?.[1] || "?";
const isActive = i === currentPos;
listHtml += `<li class="page-item ${isActive ? 'active' : ''}">
<a class="page-link" href="#" onclick="startFrom(${chapterStartIndices[i].index})">${label || chapterNumber}</a>
</li>`;
};
// First button
addPageItem(0);
// Ellipsis after first if needed
if (currentPos - halfWindow > 1) {
listHtml += `<li class="page-item disabled d-none d-md-block"><span class="page-link">…</span></li>`;
}
// Sliding window
const start = Math.max(1, currentPos - halfWindow);
const end = Math.min(totalChapters - 2, currentPos + halfWindow);
for (let i = start; i <= end; i++) {
if (i !== 0 && i !== totalChapters - 1) {
addPageItem(i);
}
}
// Ellipsis before last if needed
if (currentPos + halfWindow < totalChapters - 2) {
listHtml += `<li class="page-item disabled d-none d-md-block"><span class="page-link">…</span></li>`;
}
// Last button
if (totalChapters > 1) {
addPageItem(totalChapters - 1);
}
// Next button
listHtml += `<li class="page-item ${currentPos >= totalChapters - 1 ? 'disabled' : ''}">
<a class="page-link" href="#" onclick="goToNextChapter(${currentPos})"><i class="fad fa-step-forward"></i></a>
</li>`;
document.getElementById("chapter-list").innerHTML = listHtml;
}
function getWindowSize() {
const width = window.innerWidth;
if (width < 576) {
return 5; // Extra small (mobile)
} else if (width < 768) {
return 7; // Small (phones / small tablets)
} else if (width < 992) {
return 9; // Medium (tablets)
} else if (width < 1200) {
return 13; // Large (small desktops)
} else {
return 19; // Extra large (wide desktop screens)
}
}
function goToPrevChapter(currentPos) {
if (currentPos <= 0) return;
const newIndex = chapterStartIndices[currentPos - 1].index;
const newChapter = chapterStartIndices[currentPos - 1].name;
renderChapterPagination(newChapter);
startFrom(newIndex);
}
function goToNextChapter(currentPos) {
if (currentPos >= chapterStartIndices.length - 1) return;
const newIndex = chapterStartIndices[currentPos + 1].index;
const newChapter = chapterStartIndices[currentPos + 1].name;
renderChapterPagination(newChapter);
startFrom(newIndex);
}
function parseDuration(display) {
const parts = display.split(":").map(Number);
return parts[0] * 60 + (parts[1] || 0);
}
// Call this in startFrom()
function startFrom(index) {
const seg = masterPlaylist[index];
// Ensure autoplay works (helps with Chrome mobile restrictions)
audio.muted = false;
audio.autoplay = true;
renderChapterPagination(seg.chapter);
renderChapterText(seg.chapter);
initChapterProgress(seg.chapter);
loadSegment(index);
}
function loadSegment(index) {
if (index < 0 || index >= masterPlaylist.length) return;
currentIndex = index;
updateBufferBar(index);
localStorage.setItem("lastAudioIndex", index.toString());
const seg = masterPlaylist[index];
// ✅ Scroll and highlight current paragraph
document.querySelectorAll(".chapter-text-scroll p").forEach(p => p.classList.remove("active"));
const activePara = document.getElementById(`text-seg-${index}`);
if (activePara) {
activePara.classList.add("active");
const container = document.querySelector(".chapter-text-scroll");
const paraTop = activePara.offsetTop;
const paraHeight = activePara.offsetHeight;
const containerHeight = container.clientHeight;
const scrollTarget = paraTop - container.offsetTop - (containerHeight / 2) + (paraHeight / 2);
container.scrollTo({ top: scrollTarget, behavior: "smooth" });
}
const tryPlay = async (blob, retryCount = 0) => {
try {
const blobUrl = URL.createObjectURL(blob);
audio.src = blobUrl;
await audio.play();
status.textContent = `Playing: ${seg.chapter.replace(/_/g, ' ')}`;
toggleButton.innerHTML = '<i class="fad fa-pause"></i>';
} catch (err) {
if (retryCount < 10) {
console.warn(`Retrying audio playback (${retryCount + 1})...`);
setTimeout(() => tryPlay(blob, retryCount + 1), 3000);
} else {
console.error("Playback failed permanently:", err);
status.textContent = "Error loading audio segment.";
toggleButton.innerHTML = '<i class="fad fa-play"></i>';
}
}
};
const fetchAndPlay = async () => {
try {
const response = await fetchWithRetry(`/api/audio/token-url/${seg.id}`, 5, 2000);
const blob = await fetch(response).then(r => r.blob());
audioBlobCache.set(seg.id, blob);
tryPlay(blob);
} catch (err) {
console.error("Initial fetch failed, will retry in background:", err);
status.textContent = "Waiting for signal...";
let retries = 0;
const maxRetries = 12;
const retryLoop = setInterval(async () => {
try {
const response = await fetchWithRetry(`/api/audio/token-url/${seg.id}`, 3, 2000);
const blob = await fetch(response).then(r => r.blob());
clearInterval(retryLoop);
audioBlobCache.set(seg.id, blob);
tryPlay(blob);
} catch (err) {
retries++;
console.warn(`Retry ${retries}/${maxRetries} failed`);
if (retries >= maxRetries) {
clearInterval(retryLoop);
status.textContent = "Unable to load segment.";
}
}
}, 10000);
}
};
if (audioBlobCache.has(seg.id)) {
tryPlay(audioBlobCache.get(seg.id));
} else {
fetchAndPlay();
}
maybeTriggerPreload();
}
function maybeTriggerPreload() {
if (audio.paused) return; // only preload while playing
const bufferedPercent = getBufferedPercent(currentIndex);
if (bufferedPercent < 90) {
preloadSegmentsAhead(currentIndex + 1, BUFFER_TARGET_SECONDS);
}
}
function getBufferedPercent(currentIdx) {
let bufferedSeconds = 0;
let totalChecked = 0;
let i = currentIdx + 1;
while (i < masterPlaylist.length && totalChecked < BUFFER_TARGET_SECONDS) {
const seg = masterPlaylist[i];
totalChecked += seg.duration;
if (audioBlobCache.has(seg.id)) {
bufferedSeconds += seg.duration;
}
i++;
}
return Math.min((bufferedSeconds / BUFFER_TARGET_SECONDS) * 100, 100);
}
function preloadSegmentsAhead(startIndex, targetSeconds) {
let totalDuration = 0;
let i = startIndex;
while (i < masterPlaylist.length && totalDuration < targetSeconds) {
const seg = masterPlaylist[i];
if (!audioBlobCache.has(seg.id)) {
fetch(`/api/audio/token-url/${seg.id}`)
.then(res => res.text())
.then(url => fetchBlobWithRetry(url))
.then(blob => {
const blobSize = blob.size;
audioBlobCache.set(seg.id, blob);
audioBlobSizes.set(seg.id, blobSize);
totalBlobSize += blobSize;
enforceBlobCacheLimit();
updateBufferBar(currentIndex);
})
.catch(err => console.warn("Preload failed:", err));
}
totalDuration += seg.duration;
i++;
}
}
function fetchWithRetry(url, retries = 3, delayMs = 1000) {
return new Promise((resolve, reject) => {
const attempt = (n) => {
fetch(url)
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.text();
})
.then(resolve)
.catch(err => {
if (n > 0) {
setTimeout(() => attempt(n - 1), delayMs);
} else {
console.warn(`Failed to fetch ${url} after retries:`, err);
reject(err);
}
});
};
attempt(retries);
});
}
function renderChapterText(chapterName) {
const container = document.getElementById("chapter-text-content");
container.innerHTML = "";
masterPlaylist.forEach((seg, i) => {
if (seg.chapter === chapterName) {
const p = document.createElement("p");
p.textContent = seg.text || "";
p.id = `text-seg-${i}`;
container.appendChild(p);
}
});
}
let currentChapter = "";
let chapterSegments = [];
let chapterTotalDuration = 0;
function initChapterProgress(chapterName) {
currentChapter = chapterName;
chapterSegments = masterPlaylist.filter(s => s.chapter === chapterName);
chapterTotalDuration = chapterSegments.reduce((sum, seg) => sum + seg.duration, 0);
updateProgressUI(0);
renderChapterTicks(); // ✅ Added here
}
function updateProgressUI(elapsedSeconds) {
const percent = (elapsedSeconds / chapterTotalDuration) * 100;
document.getElementById("chapter-progress-bar").style.width = `${percent}%`;
const mins = Math.floor(elapsedSeconds / 60).toString().padStart(2, "0");
const secs = Math.floor(elapsedSeconds % 60).toString().padStart(2, "0");
const totalMins = Math.floor(chapterTotalDuration / 60).toString().padStart(2, "0");
const totalSecs = Math.floor(chapterTotalDuration % 60).toString().padStart(2, "0");
document.getElementById("chapter-timestamp").textContent = `${mins}:${secs} / ${totalMins}:${totalSecs}`;
}
function getElapsedInChapter(currentIdx, audioCurrentTime) {
let sum = 0;
for (let i = 0; i < currentIdx; i++) {
if (masterPlaylist[i].chapter === currentChapter) {
sum += masterPlaylist[i].duration;
}
}
return sum + audioCurrentTime;
}
function renderChapterTicks() {
const ticksContainer = document.getElementById("chapter-ticks");
ticksContainer.innerHTML = "";
let elapsed = 0;
chapterSegments.forEach(seg => {
const percent = (elapsed / chapterTotalDuration) * 100;
const tick = document.createElement("div");
tick.className = "chapter-tick";
tick.style.left = `${percent}%`;
ticksContainer.appendChild(tick);
// const label = document.createElement("div");
// label.className = "chapter-tick-label";
// label.style.left = `${percent}%`;
// label.textContent = seg.display;
// ticksContainer.appendChild(label);
elapsed += seg.duration;
});
}
function updatePaginationHighlight(currentChapter) {
document.querySelectorAll("#chapter-list .page-item").forEach((li, idx) => {
const chapterNum = parseInt(li.textContent);
const currentNum = parseInt(currentChapter.match(/Chapter_(\d+)/i)?.[1]);
if (chapterNum === currentNum) {
li.classList.add("active");
} else {
li.classList.remove("active");
}
});
}
function preloadFirstSegments() {
chapterStartIndices.forEach(({ index }) => {
const seg = masterPlaylist[index];
if (!audioBlobCache.has(seg.id)) {
fetch(`/api/audio/token-url/${seg.id}`)
.then(res => res.text())
.then(url => fetchBlobWithRetry(url))
.then(blob => {
const blobSize = blob.size;
audioBlobCache.set(seg.id, blob);
audioBlobSizes.set(seg.id, blobSize);
totalBlobSize += blobSize;
enforceBlobCacheLimit();
updateBufferBar(currentIndex);
})
.catch(err => console.warn(`Preload of chapter start failed (${seg.id}):`, err));
}
});
}
function enforceBlobCacheLimit() {
const iterator = audioBlobCache.keys();
while (totalBlobSize > MAX_BLOB_CACHE_SIZE && audioBlobCache.size > 0) {
const oldestKey = iterator.next().value;
const size = audioBlobSizes.get(oldestKey) || 0;
audioBlobCache.delete(oldestKey);
audioBlobSizes.delete(oldestKey);
totalBlobSize -= size;
console.log(`Evicted ${oldestKey} to maintain cache size. Remaining size: ${totalBlobSize} bytes.`);
}
}
function updateBufferBar(currentIdx) {
let bufferedSeconds = 0;
let totalChecked = 0;
let i = currentIdx + 1;
while (i < masterPlaylist.length && totalChecked < BUFFER_TARGET_SECONDS) {
const seg = masterPlaylist[i];
totalChecked += seg.duration;
if (audioBlobCache.has(seg.id)) {
bufferedSeconds += seg.duration;
}
i++;
}
const percent = Math.min((bufferedSeconds / BUFFER_TARGET_SECONDS) * 100, 100);
const bar = document.getElementById("buffer-progress-bar");
bar.style.width = `${percent.toFixed(1)}%`;
bar.setAttribute("aria-valuenow", percent.toFixed(1));
// ✅ Set Bootstrap colour classes
bar.classList.remove("bg-primary", "bg-warning", "bg-danger");
if (percent >= 75) {
bar.classList.add("bg-primary");
} else if (percent >= 30) {
bar.classList.add("bg-warning");
} else {
bar.classList.add("bg-danger");
}
}
audio.addEventListener("ended", () => {
if (currentIndex < masterPlaylist.length - 1) {
const nextIndex = currentIndex + 1;
const nextSeg = masterPlaylist[nextIndex];
const currentChapter = masterPlaylist[currentIndex].chapter;
const nextChapter = nextSeg.chapter;
if (nextChapter !== currentChapter) {
renderChapterPagination(nextChapter); // ✅ Update pagination
renderChapterText(nextChapter);
initChapterProgress(nextChapter);
}
loadSegment(nextIndex);
} else {
status.textContent = "Playback complete.";
}
});
// Update during playback
audio.addEventListener("timeupdate", () => {
if (!chapterSegments.length || !audio.duration) return;
const elapsed = getElapsedInChapter(currentIndex, audio.currentTime);
updateProgressUI(elapsed);
});
toggleButton.addEventListener("click", () => {
if (audio.paused) {
audio.play();
toggleButton.innerHTML = '<i class="fad fa-pause"></i>';
} else {
audio.pause();
toggleButton.innerHTML = '<i class="fad fa-play"></i>';
}
});
audio.addEventListener("play", () => {
toggleButton.innerHTML = '<i class="fad fa-pause"></i>';
});
audio.addEventListener("pause", () => {
toggleButton.innerHTML = '<i class="fad fa-play"></i>';
});
// ✅ These are additional listeners — do not replace existing ones
audio.addEventListener("play", maybeTriggerPreload);
audio.addEventListener("ended", maybeTriggerPreload);
document.getElementById("chapter-progress").addEventListener("click", (e) => {
const container = e.currentTarget;
const rect = container.getBoundingClientRect();
const clickX = e.clientX - rect.left;
const clickPercent = clickX / rect.width;
const targetTime = clickPercent * chapterTotalDuration;
// Find approximate segment
let cumulative = 0;
for (let i = 0; i < chapterSegments.length; i++) {
const seg = chapterSegments[i];
if (cumulative + seg.duration >= targetTime) {
const globalIndex = masterPlaylist.findIndex(s => s.id === seg.id);
if (globalIndex >= 0) loadSegment(globalIndex);
break;
}
cumulative += seg.duration;
}
});
</script>
} }

View File

@ -0,0 +1,606 @@
(function () {
const audioBlobCache = new Map();
const audioBlobSizes = new Map();
const inFlightPreloads = new Map();
let totalBlobSize = 0;
const MAX_BLOB_CACHE_SIZE = 100 * 1024 * 1024;
const BUFFER_TARGET_SECONDS = 30 * 60;
const PRELOAD_CONCURRENCY = 3;
const PLAYBACK_RETRIES = 12;
let masterPlaylist = [];
let chapterStartIndices = [];
let currentIndex = 0;
let currentChapter = "";
let chapterSegments = [];
let chapterTotalDuration = 0;
let playbackRequestId = 0;
let currentBlobUrl = "";
let currentFetchController = null;
let desiredPlaying = false;
let activePreloads = 0;
const preloadQueue = [];
const audio = document.getElementById("audio");
const status = document.getElementById("status");
const toggleButton = document.getElementById("toggle-play");
const chapterList = document.getElementById("chapter-list");
const progress = document.getElementById("chapter-progress");
if (!audio || !status || !toggleButton || !chapterList || !progress) {
return;
}
const playlistUrl = audio.dataset.playlistUrl || "/api/audio/playlist/all";
const storageKey = `lastAudioIndex:${audio.dataset.bookKey || playlistUrl}`;
fetch(playlistUrl)
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(data => {
data.sort((a, b) => getChapterNumber(a.chapter) - getChapterNumber(b.chapter));
let flatIndex = 0;
chapterStartIndices = [];
masterPlaylist = [];
data.forEach(chapter => {
chapterStartIndices.push({ index: flatIndex, name: chapter.chapter });
chapter.segments.forEach(segment => {
masterPlaylist.push({
id: segment.id,
display: segment.display,
chapter: chapter.chapter,
duration: parseDuration(segment.display),
text: segment.text || ""
});
flatIndex++;
});
});
if (!masterPlaylist.length) {
status.textContent = "No audio is available yet.";
toggleButton.disabled = true;
return;
}
const savedIndex = parseInt(localStorage.getItem(storageKey) || "0", 10);
const resumeIndex = savedIndex >= 0 && savedIndex < masterPlaylist.length ? savedIndex : 0;
const seg = masterPlaylist[resumeIndex];
renderChapterPagination(seg.chapter);
renderChapterText(seg.chapter);
initChapterProgress(seg.chapter);
prepareSegment(resumeIndex);
status.textContent = `Ready - ${masterPlaylist.length} segments total.`;
preloadFirstSegments();
preloadSegmentsAhead(resumeIndex, BUFFER_TARGET_SECONDS);
})
.catch(err => {
console.warn("Playlist load failed:", err);
status.textContent = navigator.onLine
? "Unable to load the audiobook."
: "Waiting for signal...";
toggleButton.disabled = true;
});
function getChapterNumber(chapterName) {
return parseInt(chapterName.match(/Chapter_(\d+)/i)?.[1] || "0", 10);
}
function parseDuration(display) {
const parts = display.split(":").map(Number);
return parts[0] * 60 + (parts[1] || 0);
}
function fetchTextWithRetry(url, retries = 3, delayMs = 1000, signal) {
return fetchWithRetry(url, response => response.text(), retries, delayMs, signal);
}
function fetchBlobWithRetry(url, retries = 3, delayMs = 1000, signal) {
return fetchWithRetry(url, response => response.blob(), retries, delayMs, signal);
}
function fetchWithRetry(url, readResponse, retries, delayMs, signal) {
return new Promise((resolve, reject) => {
const attempt = (remaining, waitMs) => {
fetch(url, { signal, cache: "no-store" })
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return readResponse(res);
})
.then(resolve)
.catch(err => {
if (err.name === "AbortError") {
reject(err);
return;
}
if (remaining > 0) {
setTimeout(() => attempt(remaining - 1, Math.min(waitMs * 1.5, 10000)), waitMs);
} else {
reject(err);
}
});
};
attempt(retries, delayMs);
});
}
async function fetchSegmentBlob(seg, options = {}) {
if (audioBlobCache.has(seg.id)) {
return audioBlobCache.get(seg.id);
}
const signal = options.signal;
const tokenUrl = await fetchTextWithRetry(`/api/audio/token-url/${seg.id}`, 4, 1000, signal);
const blob = await fetchBlobWithRetry(tokenUrl, 4, 1000, signal);
rememberBlob(seg.id, blob);
return blob;
}
function rememberBlob(id, blob) {
if (audioBlobCache.has(id)) return;
audioBlobCache.set(id, blob);
audioBlobSizes.set(id, blob.size);
totalBlobSize += blob.size;
enforceBlobCacheLimit();
updateBufferBar(currentIndex);
}
function renderChapterPagination(chapterName) {
let listHtml = "";
const currentPos = chapterStartIndices.findIndex(c => c.name === chapterName);
const totalChapters = chapterStartIndices.length;
const windowSize = getWindowSize();
const halfWindow = Math.floor(windowSize / 2);
listHtml += `<li class="page-item ${currentPos <= 0 ? "disabled" : ""}">
<a class="page-link" href="#" data-chapter-offset="-1"><i class="fad fa-step-backward"></i></a>
</li>`;
const addPageItem = (i, label = null) => {
const chapterNumber = getChapterNumber(chapterStartIndices[i].name) || "?";
const isActive = i === currentPos;
listHtml += `<li class="page-item ${isActive ? "active" : ""}">
<a class="page-link" href="#" data-start-index="${chapterStartIndices[i].index}">${label || chapterNumber}</a>
</li>`;
};
addPageItem(0);
if (currentPos - halfWindow > 1) {
listHtml += `<li class="page-item disabled d-none d-md-block"><span class="page-link">...</span></li>`;
}
const start = Math.max(1, currentPos - halfWindow);
const end = Math.min(totalChapters - 2, currentPos + halfWindow);
for (let i = start; i <= end; i++) {
if (i !== 0 && i !== totalChapters - 1) {
addPageItem(i);
}
}
if (currentPos + halfWindow < totalChapters - 2) {
listHtml += `<li class="page-item disabled d-none d-md-block"><span class="page-link">...</span></li>`;
}
if (totalChapters > 1) {
addPageItem(totalChapters - 1);
}
listHtml += `<li class="page-item ${currentPos >= totalChapters - 1 ? "disabled" : ""}">
<a class="page-link" href="#" data-chapter-offset="1"><i class="fad fa-step-forward"></i></a>
</li>`;
chapterList.innerHTML = listHtml;
}
function getWindowSize() {
const width = window.innerWidth;
if (width < 576) return 5;
if (width < 768) return 7;
if (width < 992) return 9;
if (width < 1200) return 13;
return 19;
}
function startFrom(index) {
const seg = masterPlaylist[index];
if (!seg) return;
desiredPlaying = true;
renderChapterPagination(seg.chapter);
renderChapterText(seg.chapter);
initChapterProgress(seg.chapter);
loadSegment(index, true);
}
function prepareSegment(index) {
loadSegment(index, false);
}
function loadSegment(index, shouldPlay) {
if (index < 0 || index >= masterPlaylist.length) return;
playbackRequestId++;
const requestId = playbackRequestId;
if (currentFetchController) {
currentFetchController.abort();
}
currentFetchController = new AbortController();
currentIndex = index;
updateBufferBar(index);
localStorage.setItem(storageKey, index.toString());
const seg = masterPlaylist[index];
highlightCurrentParagraph(index);
if (!shouldPlay) {
status.textContent = `Ready: ${formatChapterName(seg.chapter)}`;
toggleButton.innerHTML = '<i class="fad fa-play"></i>';
fetchSegmentBlob(seg, { signal: currentFetchController.signal }).catch(() => { });
maybeTriggerPreload();
return;
}
status.textContent = audioBlobCache.has(seg.id) ? "Starting..." : "Loading audio...";
fetchSegmentBlob(seg, { signal: currentFetchController.signal })
.then(blob => {
if (requestId !== playbackRequestId) return;
return tryPlay(blob, seg, requestId);
})
.catch(err => {
if (err.name === "AbortError" || requestId !== playbackRequestId) return;
waitForSegment(seg, requestId);
});
maybeTriggerPreload();
}
function highlightCurrentParagraph(index) {
document.querySelectorAll(".chapter-text-scroll p").forEach(p => p.classList.remove("active"));
const activePara = document.getElementById(`text-seg-${index}`);
if (!activePara) return;
activePara.classList.add("active");
const container = document.querySelector(".chapter-text-scroll");
const paraTop = activePara.offsetTop;
const paraHeight = activePara.offsetHeight;
const containerHeight = container.clientHeight;
const scrollTarget = paraTop - container.offsetTop - (containerHeight / 2) + (paraHeight / 2);
container.scrollTo({ top: scrollTarget, behavior: "smooth" });
}
async function tryPlay(blob, seg, requestId, retryCount = 0) {
try {
if (currentBlobUrl) {
URL.revokeObjectURL(currentBlobUrl);
}
currentBlobUrl = URL.createObjectURL(blob);
audio.src = currentBlobUrl;
await audio.play();
if (requestId !== playbackRequestId) return;
status.textContent = `Playing: ${formatChapterName(seg.chapter)}`;
toggleButton.innerHTML = '<i class="fad fa-pause"></i>';
} catch (err) {
if (requestId !== playbackRequestId) return;
if (retryCount < PLAYBACK_RETRIES && desiredPlaying) {
setTimeout(() => tryPlay(blob, seg, requestId, retryCount + 1), 3000);
return;
}
status.textContent = "Tap play to continue.";
toggleButton.innerHTML = '<i class="fad fa-play"></i>';
}
}
function waitForSegment(seg, requestId) {
status.textContent = navigator.onLine ? "Trying again..." : "Waiting for signal...";
let retries = 0;
const retryLoop = setInterval(async () => {
if (requestId !== playbackRequestId) {
clearInterval(retryLoop);
return;
}
try {
const blob = await fetchSegmentBlob(seg);
clearInterval(retryLoop);
tryPlay(blob, seg, requestId);
} catch (err) {
retries++;
status.textContent = navigator.onLine ? "Trying again..." : "Waiting for signal...";
if (retries >= PLAYBACK_RETRIES) {
clearInterval(retryLoop);
status.textContent = "Unable to load this part. Check the signal and tap play.";
toggleButton.innerHTML = '<i class="fad fa-play"></i>';
}
}
}, 5000);
}
function maybeTriggerPreload() {
const bufferedPercent = getBufferedPercent(currentIndex);
if (bufferedPercent < 90) {
preloadSegmentsAhead(currentIndex + 1, BUFFER_TARGET_SECONDS);
}
}
function getBufferedPercent(currentIdx) {
let bufferedSeconds = 0;
let totalChecked = 0;
let i = currentIdx + 1;
while (i < masterPlaylist.length && totalChecked < BUFFER_TARGET_SECONDS) {
const seg = masterPlaylist[i];
totalChecked += seg.duration;
if (audioBlobCache.has(seg.id)) {
bufferedSeconds += seg.duration;
}
i++;
}
return Math.min((bufferedSeconds / BUFFER_TARGET_SECONDS) * 100, 100);
}
function preloadSegmentsAhead(startIndex, targetSeconds) {
let totalDuration = 0;
let i = startIndex;
while (i < masterPlaylist.length && totalDuration < targetSeconds) {
enqueuePreload(masterPlaylist[i]);
totalDuration += masterPlaylist[i].duration;
i++;
}
drainPreloadQueue();
}
function enqueuePreload(seg) {
if (audioBlobCache.has(seg.id) || inFlightPreloads.has(seg.id)) return;
inFlightPreloads.set(seg.id, true);
preloadQueue.push(seg);
}
function drainPreloadQueue() {
if (!navigator.onLine) return;
while (activePreloads < PRELOAD_CONCURRENCY && preloadQueue.length) {
const seg = preloadQueue.shift();
activePreloads++;
fetchSegmentBlob(seg)
.catch(err => console.warn("Preload failed:", err))
.finally(() => {
activePreloads--;
inFlightPreloads.delete(seg.id);
drainPreloadQueue();
});
}
}
function preloadFirstSegments() {
chapterStartIndices.forEach(({ index }) => enqueuePreload(masterPlaylist[index]));
drainPreloadQueue();
}
function enforceBlobCacheLimit() {
const currentId = masterPlaylist[currentIndex]?.id;
while (totalBlobSize > MAX_BLOB_CACHE_SIZE && audioBlobCache.size > 1) {
const oldestKey = Array.from(audioBlobCache.keys()).find(key => key !== currentId);
if (!oldestKey) break;
const size = audioBlobSizes.get(oldestKey) || 0;
audioBlobCache.delete(oldestKey);
audioBlobSizes.delete(oldestKey);
totalBlobSize -= size;
}
}
function updateBufferBar(currentIdx) {
const percent = getBufferedPercent(currentIdx);
const bar = document.getElementById("buffer-progress-bar");
bar.style.width = `${percent.toFixed(1)}%`;
bar.setAttribute("aria-valuenow", percent.toFixed(1));
bar.classList.remove("bg-primary", "bg-warning", "bg-danger");
if (percent >= 75) {
bar.classList.add("bg-primary");
} else if (percent >= 30) {
bar.classList.add("bg-warning");
} else {
bar.classList.add("bg-danger");
}
}
function renderChapterText(chapterName) {
const container = document.getElementById("chapter-text-content");
container.innerHTML = "";
masterPlaylist.forEach((seg, i) => {
if (seg.chapter === chapterName) {
const p = document.createElement("p");
p.textContent = seg.text || "";
p.id = `text-seg-${i}`;
container.appendChild(p);
}
});
}
function initChapterProgress(chapterName) {
currentChapter = chapterName;
chapterSegments = masterPlaylist.filter(s => s.chapter === chapterName);
chapterTotalDuration = chapterSegments.reduce((sum, seg) => sum + seg.duration, 0);
updateProgressUI(0);
renderChapterTicks();
}
function updateProgressUI(elapsedSeconds) {
const percent = chapterTotalDuration ? (elapsedSeconds / chapterTotalDuration) * 100 : 0;
document.getElementById("chapter-progress-bar").style.width = `${percent}%`;
const mins = Math.floor(elapsedSeconds / 60).toString().padStart(2, "0");
const secs = Math.floor(elapsedSeconds % 60).toString().padStart(2, "0");
const totalMins = Math.floor(chapterTotalDuration / 60).toString().padStart(2, "0");
const totalSecs = Math.floor(chapterTotalDuration % 60).toString().padStart(2, "0");
document.getElementById("chapter-timestamp").textContent = `${mins}:${secs} / ${totalMins}:${totalSecs}`;
}
function getElapsedInChapter(currentIdx, audioCurrentTime) {
let sum = 0;
for (let i = 0; i < currentIdx; i++) {
if (masterPlaylist[i].chapter === currentChapter) {
sum += masterPlaylist[i].duration;
}
}
return sum + audioCurrentTime;
}
function renderChapterTicks() {
const ticksContainer = document.getElementById("chapter-ticks");
ticksContainer.innerHTML = "";
let elapsed = 0;
chapterSegments.forEach(seg => {
const percent = chapterTotalDuration ? (elapsed / chapterTotalDuration) * 100 : 0;
const tick = document.createElement("div");
tick.className = "chapter-tick";
tick.style.left = `${percent}%`;
ticksContainer.appendChild(tick);
elapsed += seg.duration;
});
}
function formatChapterName(chapterName) {
return chapterName.replace(/_/g, " ");
}
chapterList.addEventListener("click", event => {
const link = event.target.closest("a");
if (!link || link.parentElement.classList.contains("disabled")) return;
event.preventDefault();
const startIndex = link.dataset.startIndex;
if (startIndex !== undefined) {
startFrom(parseInt(startIndex, 10));
return;
}
const offset = parseInt(link.dataset.chapterOffset || "0", 10);
const currentPos = chapterStartIndices.findIndex(c => c.name === currentChapter);
const nextPos = currentPos + offset;
if (nextPos >= 0 && nextPos < chapterStartIndices.length) {
startFrom(chapterStartIndices[nextPos].index);
}
});
audio.addEventListener("ended", () => {
if (currentIndex < masterPlaylist.length - 1) {
const nextIndex = currentIndex + 1;
const nextSeg = masterPlaylist[nextIndex];
const currentSeg = masterPlaylist[currentIndex];
if (nextSeg.chapter !== currentSeg.chapter) {
renderChapterPagination(nextSeg.chapter);
renderChapterText(nextSeg.chapter);
initChapterProgress(nextSeg.chapter);
}
loadSegment(nextIndex, true);
} else {
desiredPlaying = false;
status.textContent = "Playback complete.";
}
});
audio.addEventListener("timeupdate", () => {
if (!chapterSegments.length || !audio.duration) return;
updateProgressUI(getElapsedInChapter(currentIndex, audio.currentTime));
});
toggleButton.addEventListener("click", () => {
if (audio.paused) {
desiredPlaying = true;
if (audio.src) {
audio.play().catch(() => loadSegment(currentIndex, true));
} else {
loadSegment(currentIndex, true);
}
} else {
desiredPlaying = false;
audio.pause();
}
});
audio.addEventListener("play", () => {
toggleButton.innerHTML = '<i class="fad fa-pause"></i>';
maybeTriggerPreload();
});
audio.addEventListener("pause", () => {
toggleButton.innerHTML = '<i class="fad fa-play"></i>';
});
progress.addEventListener("click", e => {
const rect = progress.getBoundingClientRect();
const clickPercent = (e.clientX - rect.left) / rect.width;
const targetTime = clickPercent * chapterTotalDuration;
let cumulative = 0;
for (let i = 0; i < chapterSegments.length; i++) {
const seg = chapterSegments[i];
if (cumulative + seg.duration >= targetTime) {
const globalIndex = masterPlaylist.findIndex(s => s.id === seg.id);
if (globalIndex >= 0) startFrom(globalIndex);
break;
}
cumulative += seg.duration;
}
});
window.addEventListener("online", () => {
status.textContent = audio.paused ? "Signal restored. Tap play to continue." : "Signal restored.";
drainPreloadQueue();
if (desiredPlaying && audio.paused) {
loadSegment(currentIndex, true);
}
});
window.addEventListener("offline", () => {
status.textContent = "Signal lost. Cached audio will keep playing where available.";
});
window.addEventListener("beforeunload", () => {
if (currentBlobUrl) {
URL.revokeObjectURL(currentBlobUrl);
}
});
})();