Loading...
@@ -53,622 +53,5 @@
@section Scripts {
-
+
}
diff --git a/CatherineLynwood/wwwroot/js/audiobook-player.js b/CatherineLynwood/wwwroot/js/audiobook-player.js
new file mode 100644
index 0000000..8422f14
--- /dev/null
+++ b/CatherineLynwood/wwwroot/js/audiobook-player.js
@@ -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 += `
+
+ `;
+
+ const addPageItem = (i, label = null) => {
+ const chapterNumber = getChapterNumber(chapterStartIndices[i].name) || "?";
+ const isActive = i === currentPos;
+ listHtml += `
+ ${label || chapterNumber}
+ `;
+ };
+
+ addPageItem(0);
+
+ if (currentPos - halfWindow > 1) {
+ listHtml += `
...`;
+ }
+
+ 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 += `
...`;
+ }
+
+ if (totalChapters > 1) {
+ addPageItem(totalChapters - 1);
+ }
+
+ listHtml += `
+
+ `;
+
+ 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 = '
';
+ 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 = '
';
+ } 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 = '
';
+ }
+ }
+
+ 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 = '
';
+ }
+ }
+ }, 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 = '
';
+ maybeTriggerPreload();
+ });
+
+ audio.addEventListener("pause", () => {
+ toggleButton.innerHTML = '
';
+ });
+
+ 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);
+ }
+ });
+})();