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}");
|
||||
|
||||
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();
|
||||
|
||||
@ -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)}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -21,7 +21,7 @@
|
||||
<div class="col">
|
||||
<p class="h3 text-center" id="status">Loading...</p>
|
||||
<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">
|
||||
<!-- Progress Bar (styled to look like an input) -->
|
||||
<div class="form-control p-0" style="height: 40px;">
|
||||
@ -53,622 +53,5 @@
|
||||
</div>
|
||||
|
||||
@section Scripts {
|
||||
<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>
|
||||
<script src="~/js/audiobook-player.js" asp-append-version="true"></script>
|
||||
}
|
||||
|
||||
@ -21,7 +21,7 @@
|
||||
<div class="col">
|
||||
<p class="h3 text-center" id="status">Loading...</p>
|
||||
<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">
|
||||
<!-- Progress Bar (styled to look like an input) -->
|
||||
<div class="form-control p-0" style="height: 40px;">
|
||||
@ -53,622 +53,5 @@
|
||||
</div>
|
||||
|
||||
@section Scripts {
|
||||
<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>
|
||||
<script src="~/js/audiobook-player.js" asp-append-version="true"></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