From 0c1480652061bfb54ff22331f63e2b85753685d0 Mon Sep 17 00:00:00 2001 From: nick Date: Wed, 27 May 2026 19:28:37 +0100 Subject: [PATCH] Used Codex to fix audio player and add it to Reckoning. --- .../Controllers/AudioController.cs | 7 +- .../Services/AudioTokenService.cs | 9 +- .../Views/Discovery/Listen.cshtml | 621 +----------------- .../Views/Reckoning/Listen.cshtml | 621 +----------------- .../wwwroot/js/audiobook-player.js | 606 +++++++++++++++++ 5 files changed, 621 insertions(+), 1243 deletions(-) create mode 100644 CatherineLynwood/wwwroot/js/audiobook-player.js diff --git a/CatherineLynwood/Controllers/AudioController.cs b/CatherineLynwood/Controllers/AudioController.cs index 3725f35..2063815 100644 --- a/CatherineLynwood/Controllers/AudioController.cs +++ b/CatherineLynwood/Controllers/AudioController.cs @@ -34,7 +34,7 @@ namespace CatherineLynwood.Controllers return NotFound($"File not found: {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}"); var stream = System.IO.File.OpenRead(filePath); - return File(stream, "audio/mpeg"); + return File(stream, "audio/mpeg", enableRangeProcessing: true); } [HttpGet("playlist/chapter/{chapterNumber:int}")] @@ -83,9 +83,10 @@ namespace CatherineLynwood.Controllers } [HttpGet("playlist/all")] - public IActionResult GetAllChapters() + public IActionResult GetAllChapters([FromQuery] string? book = null) { var segments = _cache.GetAll() + .Where(s => string.IsNullOrWhiteSpace(book) || s.ChapterFolder.Contains(book, StringComparison.OrdinalIgnoreCase)) .OrderBy(s => s.ChapterFolder) .ThenBy(s => s.File) .ToList(); diff --git a/CatherineLynwood/Services/AudioTokenService.cs b/CatherineLynwood/Services/AudioTokenService.cs index 4c7245f..46dab94 100644 --- a/CatherineLynwood/Services/AudioTokenService.cs +++ b/CatherineLynwood/Services/AudioTokenService.cs @@ -27,15 +27,20 @@ namespace CatherineLynwood.Services if (DateTimeOffset.UtcNow.ToUnixTimeSeconds() > expires) return false; + if (string.IsNullOrWhiteSpace(token)) + return false; + var expected = GenerateToken(id, expires); - return expected == token; + return CryptographicOperations.FixedTimeEquals( + Encoding.UTF8.GetBytes(expected), + Encoding.UTF8.GetBytes(token)); } public string GenerateUrl(string id) { var expires = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + _expirySeconds; 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)}"; } } } diff --git a/CatherineLynwood/Views/Discovery/Listen.cshtml b/CatherineLynwood/Views/Discovery/Listen.cshtml index 4c7a6b3..5721ac5 100644 --- a/CatherineLynwood/Views/Discovery/Listen.cshtml +++ b/CatherineLynwood/Views/Discovery/Listen.cshtml @@ -21,7 +21,7 @@

Loading...

- +
@@ -53,622 +53,5 @@
@section Scripts { - + } diff --git a/CatherineLynwood/Views/Reckoning/Listen.cshtml b/CatherineLynwood/Views/Reckoning/Listen.cshtml index 0037dae..6d5c4db 100644 --- a/CatherineLynwood/Views/Reckoning/Listen.cshtml +++ b/CatherineLynwood/Views/Reckoning/Listen.cshtml @@ -21,7 +21,7 @@

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); + } + }); +})();