Used Codex to fix audio player and add it to Reckoning.
This commit is contained in:
parent
fec02ca22d
commit
0c14806520
@ -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();
|
||||||
|
|||||||
@ -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)}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
|
||||||
}
|
}
|
||||||
|
|||||||
606
CatherineLynwood/wwwroot/js/audiobook-player.js
Normal file
606
CatherineLynwood/wwwroot/js/audiobook-player.js
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
Loading…
x
Reference in New Issue
Block a user