first commit
This commit is contained in:
612
reader_templates/Reader.html
Executable file
612
reader_templates/Reader.html
Executable file
@@ -0,0 +1,612 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Interactive Audiobook Reader</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Lora:ital,wght@0,400..700;1,400..700&family=Poppins:wght@500;700&display=swap" rel="stylesheet" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<style>
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
|
||||
|
||||
html { scroll-behavior: smooth; }
|
||||
body {
|
||||
background-image: linear-gradient(to top, #f3e7e9 0%, #e3eeff 99%, #e3eeff 100%);
|
||||
color: #1f2937; font-family: "Lora", serif;
|
||||
}
|
||||
.story-title { font-family: "Poppins", sans-serif; font-weight: 700; font-size: 2.5rem; color: #111827; }
|
||||
.story-subtitle { font-family: "Poppins", sans-serif; color: #4b5563; font-weight: 500; font-size: 1.1rem; }
|
||||
.main-content-card {
|
||||
background-color: rgba(255,255,255,0.9); backdrop-filter: blur(12px);
|
||||
border-radius: 1rem; padding: 3rem 4rem; box-shadow: 0 10px 35px rgba(0,0,0,0.08);
|
||||
border: 1px solid rgba(255,255,255,0.2); max-width: 1400px; margin: 0 auto;
|
||||
animation: fadeIn 0.5s ease-in-out;
|
||||
}
|
||||
#load-folder-btn {
|
||||
background-image: linear-gradient(45deg, #3d4e81 0%, #5753c9 50%, #6e78da 100%);
|
||||
background-size: 200% auto; border: none; transition: all 0.4s ease-in-out !important;
|
||||
box-shadow: 0 4px 15px rgba(0,0,0,0.2);
|
||||
}
|
||||
#load-folder-btn:hover { background-position: right center; transform: translateY(-3px); box-shadow: 0 8px 20px rgba(0,0,0,0.25); }
|
||||
|
||||
.story-text-container { font-size: 36px; line-height: 2.1; color: #1f2937; cursor: pointer; }
|
||||
.story-text-container h1, .story-text-container h2, .story-text-container h3 {
|
||||
font-family: "Poppins", sans-serif; color: #111827; line-height: 1.8; margin-top: 1.5em; margin-bottom: 0.8em;
|
||||
}
|
||||
.story-text-container h1 { font-size: 2.2em; }
|
||||
.story-text-container h2 { font-size: 1.8em; }
|
||||
.story-text-container h3 { font-size: 1.5em; }
|
||||
.story-text-container p { margin-bottom: 1.2em; }
|
||||
.story-text-container img { max-width: 100%; height: auto; border-radius: 8px; margin: 16px auto; display: block; }
|
||||
|
||||
.word { transition: all 0.15s ease; border-radius: 3px; }
|
||||
.word:hover { background-color: #f1f5f9; }
|
||||
.current-sentence-bg {
|
||||
-webkit-box-decoration-break: clone; box-decoration-break: clone;
|
||||
background-color: #e0e7ff; padding: 0.1em 0.25em; margin: 0 -0.2em; border-radius: 8px;
|
||||
}
|
||||
.current-word { color: #3d4e81; text-decoration: underline; text-decoration-thickness: 3px; text-underline-offset: 3px; font-weight: 700; }
|
||||
|
||||
/* Image blocks in reader */
|
||||
.story-image-block { text-align: center; margin: 24px 0; }
|
||||
.story-image-block img { max-width: 100%; height: auto; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
|
||||
|
||||
/* Floating Button - TOP RIGHT adjacent to content */
|
||||
#floating-player-btn {
|
||||
position: fixed; top: 2rem; height: 60px; min-width: 60px; padding: 0 24px;
|
||||
border-radius: 30px; background-image: linear-gradient(45deg, #3d4e81 0%, #5753c9 50%, #6e78da 100%);
|
||||
background-size: 200% auto; border: none; color: white; box-shadow: 0 8px 25px rgba(0,0,0,0.3);
|
||||
display: none; align-items: center; justify-content: center; cursor: pointer; z-index: 1050;
|
||||
transition: transform 0.2s ease-out, opacity 0.3s ease-out, width 0.3s ease, padding 0.3s ease, border-radius 0.3s ease;
|
||||
opacity: 0; transform: scale(0.8);
|
||||
}
|
||||
#floating-player-btn.visible { display: flex; opacity: 1; transform: scale(1); }
|
||||
#floating-player-btn:hover { transform: scale(1.05); }
|
||||
#floating-player-btn:active { transform: scale(0.95); }
|
||||
#floating-player-btn svg { width: 28px; height: 28px; }
|
||||
#fp-start-text { font-weight: 600; margin-right: 10px; font-family: "Poppins", sans-serif; font-size: 1.1rem; }
|
||||
#floating-player-btn.active-mode { width: 60px; padding: 0; border-radius: 50%; }
|
||||
|
||||
/* Navigation - LEFT SIDEBAR adjacent to content */
|
||||
#story-nav {
|
||||
position: fixed; top: 50%; background-color: rgba(255,255,255,0.85);
|
||||
backdrop-filter: blur(10px); border-radius: 25px; padding: 0.5rem;
|
||||
box-shadow: 0 5px 15px rgba(0,0,0,0.1); z-index: 1000; max-height: 80vh; overflow-y: auto;
|
||||
opacity: 0; transform: translateY(-50%) translateX(-20px);
|
||||
transition: opacity 0.4s ease-out, transform 0.4s ease-out;
|
||||
}
|
||||
#story-nav.visible { opacity: 1; transform: translateY(-50%) translateX(0); }
|
||||
#story-nav ul { padding-left: 0; margin-bottom: 0; }
|
||||
#story-nav a {
|
||||
display: flex; align-items: center; justify-content: center; width: 40px; height: 40px; margin: 0.5rem 0;
|
||||
border-radius: 50%; text-decoration: none; color: #4b5563; font-family: "Poppins", sans-serif;
|
||||
font-weight: 500; transition: all 0.3s ease;
|
||||
}
|
||||
#story-nav a:hover { background-color: #e0e7ff; color: #3d4e81; }
|
||||
#story-nav a.active { background-image: linear-gradient(45deg, #3d4e81 0%, #5753c9 50%, #6e78da 100%); color: white; transform: scale(1.1); }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.main-content-card { padding: 1.5rem; }
|
||||
.story-title { font-size: 2rem; }
|
||||
.story-text-container { font-size: 24px; line-height: 1.9; }
|
||||
#floating-player-btn { top: 1rem; right: 1rem !important; height: 50px; min-width: 50px; }
|
||||
#floating-player-btn.active-mode { width: 50px; }
|
||||
#story-nav { display: none !important; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav id="story-nav">
|
||||
<ul id="story-nav-list" class="list-unstyled"></ul>
|
||||
</nav>
|
||||
|
||||
<button id="floating-player-btn">
|
||||
<span id="fp-start-text">Start</span>
|
||||
<svg id="fp-pause-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" style="display:none;"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
|
||||
<svg id="fp-play-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" style="display:none;"><path d="M8 5v14l11-7z"/></svg>
|
||||
</button>
|
||||
|
||||
<div class="container-fluid my-5 px-md-5">
|
||||
<main id="main-content" class="main-content-card">
|
||||
<header class="text-center mb-5" id="main-header">
|
||||
<h1 class="story-title">Interactive Reader</h1>
|
||||
<p class="story-subtitle">Select your assets folder to begin.</p>
|
||||
</header>
|
||||
|
||||
<div id="resume-alert" class="alert alert-info d-flex justify-content-between align-items-center" style="display:none;">
|
||||
<span>Welcome back! Resume from where you left off?</span>
|
||||
<button id="resume-btn" class="btn btn-primary btn-sm">Resume Playback</button>
|
||||
</div>
|
||||
|
||||
<div class="text-center" id="loader-section">
|
||||
<p>Please select the folder containing your files (e.g., the 'book' folder).</p>
|
||||
<input type="file" id="folder-input" webkitdirectory directory multiple style="display:none;" />
|
||||
<button id="load-folder-btn" class="btn btn-dark btn-lg">Select a Folder</button>
|
||||
<div id="info-alert" class="alert mt-4" style="display:none;"></div>
|
||||
</div>
|
||||
|
||||
<div id="stories-main-container" class="d-none"></div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const mainContainer = document.getElementById("stories-main-container");
|
||||
const mainContentCard = document.getElementById("main-content");
|
||||
const folderInput = document.getElementById("folder-input");
|
||||
const loadFolderBtn = document.getElementById("load-folder-btn");
|
||||
const floatingPlayerBtn = document.getElementById("floating-player-btn");
|
||||
const fpStartText = document.getElementById("fp-start-text");
|
||||
const fpPauseIcon = document.getElementById("fp-pause-icon");
|
||||
const fpPlayIcon = document.getElementById("fp-play-icon");
|
||||
const storyNav = document.getElementById("story-nav");
|
||||
const storyNavList = document.getElementById("story-nav-list");
|
||||
const resumeAlert = document.getElementById("resume-alert");
|
||||
const resumeBtn = document.getElementById("resume-btn");
|
||||
|
||||
let storyInstances = [];
|
||||
let currentlyPlayingInstance = null;
|
||||
let currentlyPlayingIndex = -1;
|
||||
let hasStarted = false;
|
||||
let navObserver;
|
||||
let currentBookId = null;
|
||||
const PROGRESS_KEY = "interactiveReaderProgress";
|
||||
|
||||
loadFolderBtn.addEventListener("click", () => folderInput.click());
|
||||
folderInput.addEventListener("change", handleFolderSelection);
|
||||
mainContainer.addEventListener("click", handleTextClick);
|
||||
floatingPlayerBtn.addEventListener("click", handleFloatingBtnClick);
|
||||
window.addEventListener("beforeunload", saveCurrentProgress);
|
||||
window.addEventListener("resize", positionUI);
|
||||
window.addEventListener("scroll", positionUI);
|
||||
|
||||
function positionUI() {
|
||||
const rect = mainContentCard.getBoundingClientRect();
|
||||
const btnRight = window.innerWidth - rect.right - 8;
|
||||
floatingPlayerBtn.style.right = Math.max(btnRight, 8) + "px";
|
||||
floatingPlayerBtn.style.left = "auto";
|
||||
const navWidth = storyNav.offsetWidth || 52;
|
||||
const navLeft = rect.left - navWidth - 8;
|
||||
storyNav.style.left = Math.max(navLeft, 8) + "px";
|
||||
}
|
||||
|
||||
function saveCurrentProgress() {
|
||||
if (!currentlyPlayingInstance || !currentBookId) return;
|
||||
const idx = storyInstances.indexOf(currentlyPlayingInstance);
|
||||
saveProgress(currentBookId, idx, currentlyPlayingInstance.getAudioElement().currentTime);
|
||||
}
|
||||
function saveProgress(bookId, idx, timestamp) {
|
||||
localStorage.setItem(PROGRESS_KEY, JSON.stringify({ bookId, instanceIndex: idx, timestamp, lastUpdate: Date.now() }));
|
||||
}
|
||||
function loadProgress(bookId) {
|
||||
const saved = localStorage.getItem(PROGRESS_KEY);
|
||||
if (!saved) return;
|
||||
const p = JSON.parse(saved);
|
||||
if (p.bookId !== bookId) return;
|
||||
const target = storyInstances[p.instanceIndex];
|
||||
if (!target) return;
|
||||
resumeAlert.style.display = "flex";
|
||||
resumeBtn.onclick = () => {
|
||||
resumeAlert.style.display = "none";
|
||||
hasStarted = true;
|
||||
currentlyPlayingInstance = target;
|
||||
currentlyPlayingIndex = p.instanceIndex;
|
||||
target.playAt(p.timestamp);
|
||||
updateFloatingButton("playing");
|
||||
};
|
||||
}
|
||||
|
||||
function updateFloatingButton(state) {
|
||||
if (hasStarted) {
|
||||
fpStartText.style.display = "none";
|
||||
floatingPlayerBtn.classList.add("active-mode");
|
||||
}
|
||||
if (state === "playing") { fpPauseIcon.style.display = "block"; fpPlayIcon.style.display = "none"; }
|
||||
else { fpPauseIcon.style.display = "none"; fpPlayIcon.style.display = "block"; }
|
||||
}
|
||||
|
||||
function handleFloatingBtnClick() {
|
||||
if (!hasStarted) {
|
||||
hasStarted = true;
|
||||
if (storyInstances.length > 0) {
|
||||
currentlyPlayingInstance = storyInstances[0];
|
||||
currentlyPlayingIndex = 0;
|
||||
currentlyPlayingInstance.playAt(0);
|
||||
updateFloatingButton("playing");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (currentlyPlayingInstance) {
|
||||
const audio = currentlyPlayingInstance.getAudioElement();
|
||||
if (audio.paused) {
|
||||
currentlyPlayingInstance.play();
|
||||
updateFloatingButton("playing");
|
||||
} else {
|
||||
currentlyPlayingInstance.pause();
|
||||
updateFloatingButton("paused");
|
||||
}
|
||||
} else {
|
||||
if (storyInstances.length > 0) {
|
||||
currentlyPlayingInstance = storyInstances[0];
|
||||
currentlyPlayingIndex = 0;
|
||||
currentlyPlayingInstance.playAt(0);
|
||||
updateFloatingButton("playing");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function playNextInstance() {
|
||||
const next = currentlyPlayingIndex + 1;
|
||||
if (next < storyInstances.length) {
|
||||
if (currentlyPlayingInstance) currentlyPlayingInstance.stopAndReset();
|
||||
currentlyPlayingInstance = storyInstances[next];
|
||||
currentlyPlayingIndex = next;
|
||||
currentlyPlayingInstance.playAt(0);
|
||||
} else {
|
||||
updateFloatingButton("paused");
|
||||
currentlyPlayingInstance = null;
|
||||
currentlyPlayingIndex = -1;
|
||||
}
|
||||
}
|
||||
|
||||
function handleTextClick(event) {
|
||||
const wordSpan = event.target.closest(".word");
|
||||
if (!wordSpan) return;
|
||||
const storyBlock = event.target.closest(".story-block");
|
||||
if (!storyBlock) return;
|
||||
const idx = parseInt(storyBlock.dataset.instanceIndex, 10);
|
||||
const target = storyInstances[idx];
|
||||
if (!target) return;
|
||||
const timestamp = target.getTimeForSpan(wordSpan);
|
||||
if (timestamp !== null) {
|
||||
hasStarted = true;
|
||||
if (currentlyPlayingInstance && currentlyPlayingInstance !== target) {
|
||||
currentlyPlayingInstance.stopAndReset();
|
||||
}
|
||||
currentlyPlayingInstance = target;
|
||||
currentlyPlayingIndex = idx;
|
||||
currentlyPlayingInstance.playAt(timestamp);
|
||||
updateFloatingButton("playing");
|
||||
}
|
||||
}
|
||||
|
||||
function handleFolderSelection(event) {
|
||||
const files = event.target.files;
|
||||
if (files.length === 0) return;
|
||||
|
||||
// Categorize all files by their numeric prefix (e.g., "1.1_")
|
||||
const allItemsMap = new Map(); // prefix -> { audioFile, textFile, jsonFile, imageFiles: [] }
|
||||
|
||||
for (const file of files) {
|
||||
const name = file.name;
|
||||
const ext = name.split(".").pop().toLowerCase();
|
||||
|
||||
// Match files like "1.1_something.ext"
|
||||
const prefixMatch = name.match(/^([\d]+\.[\d]+)_/);
|
||||
if (!prefixMatch) continue;
|
||||
|
||||
const sortKey = prefixMatch[1]; // e.g., "1.1"
|
||||
|
||||
if (!allItemsMap.has(sortKey)) {
|
||||
allItemsMap.set(sortKey, { audioFile: null, textFile: null, jsonFile: null, imageFiles: [] });
|
||||
}
|
||||
const entry = allItemsMap.get(sortKey);
|
||||
|
||||
if (["wav", "mp3"].includes(ext)) entry.audioFile = file;
|
||||
else if (ext === "txt") entry.textFile = file;
|
||||
else if (ext === "json") entry.jsonFile = file;
|
||||
else if (["jpg", "jpeg", "png", "gif", "webp"].includes(ext)) entry.imageFiles.push(file);
|
||||
}
|
||||
|
||||
// Sort all entries by their numeric prefix
|
||||
const sortedEntries = Array.from(allItemsMap.entries())
|
||||
.sort(([a], [b]) => {
|
||||
const [aMaj, aMin] = a.split(".").map(Number);
|
||||
const [bMaj, bMin] = b.split(".").map(Number);
|
||||
return aMaj !== bMaj ? aMaj - bMaj : aMin - bMin;
|
||||
});
|
||||
|
||||
// Build ordered list of items: each is either { type: 'audio', ... } or { type: 'image', ... }
|
||||
const orderedItems = [];
|
||||
for (const [sortKey, entry] of sortedEntries) {
|
||||
if (entry.audioFile && entry.textFile && entry.jsonFile) {
|
||||
// Audio block — may also have associated images
|
||||
orderedItems.push({
|
||||
type: "audio",
|
||||
sortKey,
|
||||
audioFile: entry.audioFile,
|
||||
textFile: entry.textFile,
|
||||
jsonFile: entry.jsonFile,
|
||||
imageFiles: entry.imageFiles
|
||||
});
|
||||
} else if (entry.imageFiles.length > 0) {
|
||||
// Image-only block
|
||||
orderedItems.push({
|
||||
type: "image",
|
||||
sortKey,
|
||||
imageFiles: entry.imageFiles
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const audioItems = orderedItems.filter(i => i.type === "audio");
|
||||
if (audioItems.length === 0) {
|
||||
document.getElementById("info-alert").textContent = "No valid story parts found. Ensure files have matching .txt, .json, and audio files.";
|
||||
document.getElementById("info-alert").style.display = "block";
|
||||
return;
|
||||
}
|
||||
|
||||
currentBookId = audioItems.map(i => i.sortKey).join("|");
|
||||
document.getElementById("loader-section").style.display = "none";
|
||||
document.getElementById("main-header").querySelector(".story-subtitle").textContent = "An interactive reading experience.";
|
||||
mainContainer.classList.remove("d-none");
|
||||
mainContainer.innerHTML = "";
|
||||
storyNavList.innerHTML = "";
|
||||
resumeAlert.style.display = "none";
|
||||
|
||||
// Build DOM blocks — audio blocks get story-block class, image blocks get image-only rendering
|
||||
let audioInstanceIndex = 0;
|
||||
let lastChapter = null;
|
||||
|
||||
orderedItems.forEach((item, globalIdx) => {
|
||||
const chapter = item.sortKey.split(".")[0];
|
||||
|
||||
if (item.type === "audio") {
|
||||
// Add chapter nav entry if new chapter
|
||||
if (chapter !== lastChapter) {
|
||||
const li = document.createElement("li");
|
||||
const a = document.createElement("a");
|
||||
a.href = `#story-block-${audioInstanceIndex}`;
|
||||
a.textContent = chapter;
|
||||
a.dataset.chapter = chapter;
|
||||
li.appendChild(a);
|
||||
storyNavList.appendChild(li);
|
||||
lastChapter = chapter;
|
||||
}
|
||||
|
||||
const html = `<div id="story-block-${audioInstanceIndex}" class="story-block mt-4" data-instance-index="${audioInstanceIndex}" data-sort-key="${item.sortKey}">
|
||||
<div class="image-container text-center mb-4"></div>
|
||||
<div class="loading-indicator text-center p-5"><div class="spinner-border"></div></div>
|
||||
<audio class="audio-player" style="display:none;"></audio>
|
||||
<article class="story-text-container" style="display:none;"></article>
|
||||
</div>`;
|
||||
mainContainer.insertAdjacentHTML("beforeend", html);
|
||||
audioInstanceIndex++;
|
||||
|
||||
} else if (item.type === "image") {
|
||||
// Render image block directly
|
||||
const imgDiv = document.createElement("div");
|
||||
imgDiv.className = "story-image-block mt-4";
|
||||
imgDiv.dataset.sortKey = item.sortKey;
|
||||
for (const imgFile of item.imageFiles) {
|
||||
const img = document.createElement("img");
|
||||
img.src = URL.createObjectURL(imgFile);
|
||||
img.className = "img-fluid rounded shadow-sm";
|
||||
img.style.maxHeight = "70vh";
|
||||
imgDiv.appendChild(img);
|
||||
}
|
||||
mainContainer.appendChild(imgDiv);
|
||||
|
||||
// Chapter nav for image-only items too
|
||||
if (chapter !== lastChapter) {
|
||||
const prevAudioBlock = mainContainer.querySelector(".story-block:last-of-type");
|
||||
if (!prevAudioBlock) {
|
||||
// No audio block yet for this chapter, still add nav
|
||||
const li = document.createElement("li");
|
||||
const a = document.createElement("a");
|
||||
a.href = `#story-block-0`;
|
||||
a.textContent = chapter;
|
||||
a.dataset.chapter = chapter;
|
||||
li.appendChild(a);
|
||||
storyNavList.appendChild(li);
|
||||
}
|
||||
lastChapter = chapter;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
storyNav.classList.add("visible");
|
||||
floatingPlayerBtn.classList.add("visible");
|
||||
|
||||
// Create story player instances only for audio blocks
|
||||
storyInstances = [];
|
||||
let instIdx = 0;
|
||||
for (const item of orderedItems) {
|
||||
if (item.type !== "audio") continue;
|
||||
const block = document.getElementById(`story-block-${instIdx}`);
|
||||
|
||||
// Show associated images inside the audio block's image container
|
||||
if (item.imageFiles && item.imageFiles.length > 0) {
|
||||
const imgContainer = block.querySelector(".image-container");
|
||||
for (const imgFile of item.imageFiles) {
|
||||
const img = document.createElement("img");
|
||||
img.src = URL.createObjectURL(imgFile);
|
||||
img.className = "img-fluid rounded shadow-sm";
|
||||
img.style.maxHeight = "60vh";
|
||||
imgContainer.appendChild(img);
|
||||
}
|
||||
}
|
||||
|
||||
storyInstances.push(createStoryPlayer(block, item, instIdx));
|
||||
instIdx++;
|
||||
}
|
||||
|
||||
Promise.all(storyInstances.map(i => i.isReady())).then(() => {
|
||||
loadProgress(currentBookId);
|
||||
positionUI();
|
||||
});
|
||||
setupNavObserver();
|
||||
setTimeout(positionUI, 100);
|
||||
}
|
||||
|
||||
function setupNavObserver() {
|
||||
if (navObserver) navObserver.disconnect();
|
||||
navObserver = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const sk = entry.target.dataset.sortKey;
|
||||
if (sk) {
|
||||
const ch = sk.split(".")[0];
|
||||
storyNavList.querySelectorAll("a").forEach(l => l.classList.remove("active"));
|
||||
const al = storyNavList.querySelector(`a[data-chapter='${ch}']`);
|
||||
if (al) al.classList.add("active");
|
||||
}
|
||||
}
|
||||
});
|
||||
}, { threshold: 0.4 });
|
||||
document.querySelectorAll(".story-block").forEach(b => navObserver.observe(b));
|
||||
}
|
||||
|
||||
function createStoryPlayer(storyBlock, item, instanceIndex) {
|
||||
const audioPlayer = storyBlock.querySelector(".audio-player");
|
||||
const storyContainer = storyBlock.querySelector(".story-text-container");
|
||||
let wordTimestamps = [];
|
||||
let sentenceData = [];
|
||||
let allWordSpans = [];
|
||||
let wordMap = [];
|
||||
let animationFrameId = null;
|
||||
let lastHighlightedWordSpan = null;
|
||||
let lastHighlightedSentenceSpans = [];
|
||||
|
||||
const readyPromise = new Promise(async (resolve) => {
|
||||
const readFile = (f) => new Promise((res, rej) => {
|
||||
const r = new FileReader();
|
||||
r.onload = () => res(r.result);
|
||||
r.onerror = rej;
|
||||
r.readAsText(f);
|
||||
});
|
||||
|
||||
const [text, json] = await Promise.all([readFile(item.textFile), readFile(item.jsonFile)]);
|
||||
wordTimestamps = JSON.parse(json);
|
||||
audioPlayer.src = URL.createObjectURL(item.audioFile);
|
||||
|
||||
renderMarkdown(text);
|
||||
smartSync();
|
||||
|
||||
storyBlock.querySelector(".loading-indicator").style.display = "none";
|
||||
storyContainer.style.display = "block";
|
||||
|
||||
audioPlayer.addEventListener("play", () => { startLoop(); updateFloatingButton("playing"); });
|
||||
audioPlayer.addEventListener("pause", () => { stopLoop(); updateFloatingButton("paused"); saveCurrentProgress(); });
|
||||
audioPlayer.addEventListener("ended", () => { stopLoop(); clearAllHighlights(); playNextInstance(); });
|
||||
|
||||
resolve();
|
||||
});
|
||||
|
||||
function renderMarkdown(text) {
|
||||
storyContainer.innerHTML = "";
|
||||
allWordSpans = [];
|
||||
const div = document.createElement("div");
|
||||
div.innerHTML = marked.parse(text, { breaks: true, gfm: true });
|
||||
|
||||
function processNode(node) {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
const words = node.textContent.split(/(\s+)/);
|
||||
const fragment = document.createDocumentFragment();
|
||||
words.forEach(part => {
|
||||
if (part.trim().length > 0) {
|
||||
const span = document.createElement("span");
|
||||
span.className = "word";
|
||||
span.textContent = part;
|
||||
allWordSpans.push(span);
|
||||
fragment.appendChild(span);
|
||||
} else {
|
||||
fragment.appendChild(document.createTextNode(part));
|
||||
}
|
||||
});
|
||||
node.parentNode.replaceChild(fragment, node);
|
||||
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
Array.from(node.childNodes).forEach(processNode);
|
||||
}
|
||||
}
|
||||
processNode(div);
|
||||
while (div.firstChild) storyContainer.appendChild(div.firstChild);
|
||||
}
|
||||
|
||||
function smartSync() {
|
||||
wordMap = new Array(allWordSpans.length).fill(undefined);
|
||||
let aiIdx = 0;
|
||||
allWordSpans.forEach((span, i) => {
|
||||
const textWord = span.textContent.toLowerCase().replace(/[^\w]/g, "");
|
||||
for (let off = 0; off < 5; off++) {
|
||||
if (aiIdx + off >= wordTimestamps.length) break;
|
||||
const aiWord = wordTimestamps[aiIdx + off].word.toLowerCase().replace(/[^\w]/g, "");
|
||||
if (textWord === aiWord) { wordMap[i] = aiIdx + off; aiIdx += off + 1; return; }
|
||||
}
|
||||
});
|
||||
|
||||
sentenceData = [];
|
||||
let buffer = [], startIdx = 0;
|
||||
allWordSpans.forEach((span, i) => {
|
||||
buffer.push(span);
|
||||
if (/[.!?]["'\u201D\u2019]?$/.test(span.textContent.trim())) {
|
||||
let startT = 0, endT = 0;
|
||||
for (let k = startIdx; k <= i; k++) if (wordMap[k] !== undefined) { startT = wordTimestamps[wordMap[k]].start; break; }
|
||||
for (let k = i; k >= startIdx; k--) if (wordMap[k] !== undefined) { endT = wordTimestamps[wordMap[k]].end; break; }
|
||||
if (endT > startT) sentenceData.push({ spans: [...buffer], startTime: startT, endTime: endT });
|
||||
buffer = []; startIdx = i + 1;
|
||||
}
|
||||
});
|
||||
if (buffer.length > 0) {
|
||||
let startT = 0, endT = 0;
|
||||
for (let k = startIdx; k < allWordSpans.length; k++) if (wordMap[k] !== undefined) { startT = wordTimestamps[wordMap[k]].start; break; }
|
||||
for (let k = allWordSpans.length - 1; k >= startIdx; k--) if (wordMap[k] !== undefined) { endT = wordTimestamps[wordMap[k]].end; break; }
|
||||
if (endT > startT) sentenceData.push({ spans: [...buffer], startTime: startT, endTime: endT });
|
||||
}
|
||||
}
|
||||
|
||||
function highlightLoop() {
|
||||
if (audioPlayer.paused) return;
|
||||
const t = audioPlayer.currentTime;
|
||||
const aiIdx = wordTimestamps.findIndex(w => t >= w.start && t < w.end);
|
||||
if (aiIdx !== -1) {
|
||||
const tIdx = wordMap.findIndex(i => i === aiIdx);
|
||||
if (tIdx !== -1) {
|
||||
const sp = allWordSpans[tIdx];
|
||||
if (sp !== lastHighlightedWordSpan) {
|
||||
if (lastHighlightedWordSpan) lastHighlightedWordSpan.classList.remove("current-word");
|
||||
sp.classList.add("current-word");
|
||||
const rect = sp.getBoundingClientRect();
|
||||
if (rect.top < window.innerHeight * 0.3 || rect.bottom > window.innerHeight * 0.7) sp.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
lastHighlightedWordSpan = sp;
|
||||
}
|
||||
}
|
||||
}
|
||||
const sent = sentenceData.find(s => t >= s.startTime && t <= s.endTime);
|
||||
if (sent && sent.spans !== lastHighlightedSentenceSpans) {
|
||||
lastHighlightedSentenceSpans.forEach(s => s.classList.remove("current-sentence-bg"));
|
||||
sent.spans.forEach(s => s.classList.add("current-sentence-bg"));
|
||||
lastHighlightedSentenceSpans = sent.spans;
|
||||
}
|
||||
animationFrameId = requestAnimationFrame(highlightLoop);
|
||||
}
|
||||
|
||||
function clearAllHighlights() {
|
||||
if (lastHighlightedWordSpan) lastHighlightedWordSpan.classList.remove("current-word");
|
||||
lastHighlightedSentenceSpans.forEach(s => s.classList.remove("current-sentence-bg"));
|
||||
lastHighlightedWordSpan = null; lastHighlightedSentenceSpans = [];
|
||||
}
|
||||
function startLoop() { cancelAnimationFrame(animationFrameId); animationFrameId = requestAnimationFrame(highlightLoop); }
|
||||
function stopLoop() { cancelAnimationFrame(animationFrameId); }
|
||||
|
||||
return {
|
||||
play: () => audioPlayer.play(),
|
||||
pause: () => audioPlayer.pause(),
|
||||
playAt: (time) => { audioPlayer.currentTime = time; audioPlayer.play(); },
|
||||
stopAndReset: () => { audioPlayer.pause(); audioPlayer.currentTime = 0; clearAllHighlights(); },
|
||||
getAudioElement: () => audioPlayer,
|
||||
getTimeForSpan: (span) => {
|
||||
const idx = allWordSpans.indexOf(span);
|
||||
const aiIdx = wordMap[idx];
|
||||
return aiIdx !== undefined ? wordTimestamps[aiIdx].start : null;
|
||||
},
|
||||
isReady: () => readyPromise
|
||||
};
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
496
reader_templates/index.html
Executable file
496
reader_templates/index.html
Executable file
@@ -0,0 +1,496 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Interactive Audiobook Reader</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Lora:ital,wght@0,400..700;1,400..700&family=Poppins:wght@500;700&display=swap" rel="stylesheet" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<style>
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
|
||||
|
||||
html { scroll-behavior: smooth; }
|
||||
body {
|
||||
background-image: linear-gradient(to top, #f3e7e9 0%, #e3eeff 99%, #e3eeff 100%);
|
||||
color: #1f2937; font-family: "Lora", serif;
|
||||
}
|
||||
.story-title { font-family: "Poppins", sans-serif; font-weight: 700; font-size: 2.5rem; color: #111827; }
|
||||
.story-subtitle { font-family: "Poppins", sans-serif; color: #4b5563; font-weight: 500; font-size: 1.1rem; }
|
||||
.main-content-card {
|
||||
background-color: rgba(255,255,255,0.9); backdrop-filter: blur(12px);
|
||||
border-radius: 1rem; padding: 3rem 4rem; box-shadow: 0 10px 35px rgba(0,0,0,0.08);
|
||||
border: 1px solid rgba(255,255,255,0.2); max-width: 1400px; margin: 0 auto;
|
||||
animation: fadeIn 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
.story-text-container { font-size: 36px; line-height: 2.1; color: #1f2937; cursor: pointer; }
|
||||
.story-text-container h1,.story-text-container h2,.story-text-container h3 {
|
||||
font-family: "Poppins", sans-serif; color: #111827; line-height: 1.8; margin-top: 1.5em; margin-bottom: 0.8em;
|
||||
}
|
||||
.story-text-container h1 { font-size: 2.2em; }
|
||||
.story-text-container h2 { font-size: 1.8em; }
|
||||
.story-text-container h3 { font-size: 1.5em; }
|
||||
.story-text-container p { margin-bottom: 1.2em; }
|
||||
.story-text-container img { max-width: 100%; height: auto; border-radius: 8px; margin: 16px auto; display: block; }
|
||||
|
||||
.word { transition: all 0.15s ease; border-radius: 3px; }
|
||||
.word:hover { background-color: #f1f5f9; }
|
||||
.current-sentence-bg {
|
||||
-webkit-box-decoration-break: clone; box-decoration-break: clone;
|
||||
background-color: #e0e7ff; padding: 0.1em 0.25em; margin: 0 -0.2em; border-radius: 8px;
|
||||
}
|
||||
.current-word { color: #3d4e81; text-decoration: underline; text-decoration-thickness: 3px; text-underline-offset: 3px; font-weight: 700; }
|
||||
|
||||
.story-image-block { text-align: center; margin: 24px 0; }
|
||||
.story-image-block img { max-width: 100%; height: auto; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
|
||||
|
||||
#floating-player-btn {
|
||||
position: fixed; top: 2rem; height: 60px; min-width: 60px; padding: 0 24px;
|
||||
border-radius: 30px; background-image: linear-gradient(45deg, #3d4e81 0%, #5753c9 50%, #6e78da 100%);
|
||||
background-size: 200% auto; border: none; color: white; box-shadow: 0 8px 25px rgba(0,0,0,0.3);
|
||||
display: none; align-items: center; justify-content: center; cursor: pointer; z-index: 1050;
|
||||
transition: transform 0.2s, opacity 0.3s, width 0.3s, padding 0.3s, border-radius 0.3s;
|
||||
opacity: 0; transform: scale(0.8);
|
||||
}
|
||||
#floating-player-btn.visible { display: flex; opacity: 1; transform: scale(1); }
|
||||
#floating-player-btn:hover { transform: scale(1.05); }
|
||||
#floating-player-btn:active { transform: scale(0.95); }
|
||||
#floating-player-btn svg { width: 28px; height: 28px; }
|
||||
#fp-start-text { font-weight: 600; margin-right: 10px; font-family: "Poppins", sans-serif; font-size: 1.1rem; }
|
||||
#floating-player-btn.active-mode { width: 60px; padding: 0; border-radius: 50%; }
|
||||
|
||||
#story-nav {
|
||||
position: fixed; top: 50%; background-color: rgba(255,255,255,0.85);
|
||||
backdrop-filter: blur(10px); border-radius: 25px; padding: 0.5rem;
|
||||
box-shadow: 0 5px 15px rgba(0,0,0,0.1); z-index: 1000; max-height: 80vh; overflow-y: auto;
|
||||
opacity: 0; transform: translateY(-50%) translateX(-20px);
|
||||
transition: opacity 0.4s ease-out, transform 0.4s ease-out;
|
||||
}
|
||||
#story-nav.visible { opacity: 1; transform: translateY(-50%) translateX(0); }
|
||||
#story-nav ul { padding-left: 0; margin-bottom: 0; }
|
||||
#story-nav a {
|
||||
display: flex; align-items: center; justify-content: center; width: 40px; height: 40px; margin: 0.5rem 0;
|
||||
border-radius: 50%; text-decoration: none; color: #4b5563; font-family: "Poppins", sans-serif;
|
||||
font-weight: 500; transition: all 0.3s ease;
|
||||
}
|
||||
#story-nav a:hover { background-color: #e0e7ff; color: #3d4e81; }
|
||||
#story-nav a.active { background-image: linear-gradient(45deg, #3d4e81 0%, #5753c9 50%, #6e78da 100%); color: white; transform: scale(1.1); }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.main-content-card { padding: 1.5rem; }
|
||||
.story-text-container { font-size: 24px; line-height: 1.9; }
|
||||
#floating-player-btn { top: 1rem; right: 1rem !important; height: 50px; min-width: 50px; }
|
||||
#floating-player-btn.active-mode { width: 50px; }
|
||||
#story-nav { display: none !important; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav id="story-nav"><ul id="story-nav-list" class="list-unstyled"></ul></nav>
|
||||
|
||||
<button id="floating-player-btn">
|
||||
<span id="fp-start-text">Start</span>
|
||||
<svg id="fp-pause-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" style="display:none;"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
|
||||
<svg id="fp-play-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" style="display:none;"><path d="M8 5v14l11-7z"/></svg>
|
||||
</button>
|
||||
|
||||
<div class="container-fluid my-5 px-md-5">
|
||||
<main id="main-content" class="main-content-card">
|
||||
<header class="text-center mb-5" id="main-header">
|
||||
<h1 class="story-title">Interactive Reader</h1>
|
||||
<p class="story-subtitle">Loading book...</p>
|
||||
</header>
|
||||
<div id="resume-alert" class="alert alert-info d-flex justify-content-between align-items-center" style="display:none;">
|
||||
<span>Welcome back! Resume from where you left off?</span>
|
||||
<button id="resume-btn" class="btn btn-primary btn-sm">Resume Playback</button>
|
||||
</div>
|
||||
<div id="stories-main-container"></div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
const mainContainer = document.getElementById("stories-main-container");
|
||||
const mainContentCard = document.getElementById("main-content");
|
||||
const floatingPlayerBtn = document.getElementById("floating-player-btn");
|
||||
const fpStartText = document.getElementById("fp-start-text");
|
||||
const fpPauseIcon = document.getElementById("fp-pause-icon");
|
||||
const fpPlayIcon = document.getElementById("fp-play-icon");
|
||||
const storyNav = document.getElementById("story-nav");
|
||||
const storyNavList = document.getElementById("story-nav-list");
|
||||
const resumeAlert = document.getElementById("resume-alert");
|
||||
const resumeBtn = document.getElementById("resume-btn");
|
||||
|
||||
let storyInstances = [];
|
||||
let currentlyPlayingInstance = null;
|
||||
let currentlyPlayingIndex = -1;
|
||||
let hasStarted = false;
|
||||
let navObserver;
|
||||
let currentBookId = null;
|
||||
let audioAssets = [];
|
||||
const PROGRESS_KEY = "interactiveReaderProgress";
|
||||
|
||||
floatingPlayerBtn.addEventListener("click", handleFloatingBtnClick);
|
||||
mainContainer.addEventListener("click", handleTextClick);
|
||||
window.addEventListener("beforeunload", saveCurrentProgress);
|
||||
window.addEventListener("resize", positionUI);
|
||||
window.addEventListener("scroll", positionUI);
|
||||
|
||||
function positionUI() {
|
||||
const rect = mainContentCard.getBoundingClientRect();
|
||||
const btnRight = window.innerWidth - rect.right - 8;
|
||||
floatingPlayerBtn.style.right = Math.max(btnRight, 8) + "px";
|
||||
floatingPlayerBtn.style.left = "auto";
|
||||
const navWidth = storyNav.offsetWidth || 52;
|
||||
storyNav.style.left = Math.max(rect.left - navWidth - 8, 8) + "px";
|
||||
}
|
||||
|
||||
function saveCurrentProgress() {
|
||||
if (!currentlyPlayingInstance || !currentBookId) return;
|
||||
const idx = storyInstances.indexOf(currentlyPlayingInstance);
|
||||
saveProgress(currentBookId, idx, currentlyPlayingInstance.getAudioElement().currentTime);
|
||||
}
|
||||
function saveProgress(bookId, idx, timestamp) {
|
||||
localStorage.setItem(PROGRESS_KEY, JSON.stringify({ bookId, instanceIndex: idx, timestamp, lastUpdate: Date.now() }));
|
||||
}
|
||||
function loadProgress(bookId) {
|
||||
const saved = localStorage.getItem(PROGRESS_KEY);
|
||||
if (!saved) return;
|
||||
const p = JSON.parse(saved);
|
||||
if (p.bookId !== bookId) return;
|
||||
const target = storyInstances[p.instanceIndex];
|
||||
if (!target) return;
|
||||
resumeAlert.style.display = "flex";
|
||||
resumeBtn.onclick = () => {
|
||||
resumeAlert.style.display = "none";
|
||||
hasStarted = true;
|
||||
currentlyPlayingInstance = target;
|
||||
currentlyPlayingIndex = p.instanceIndex;
|
||||
target.playAt(p.timestamp);
|
||||
updateFloatingButton("playing");
|
||||
};
|
||||
}
|
||||
|
||||
function updateFloatingButton(state) {
|
||||
if (hasStarted) { fpStartText.style.display = "none"; floatingPlayerBtn.classList.add("active-mode"); }
|
||||
if (state === "playing") { fpPauseIcon.style.display = "block"; fpPlayIcon.style.display = "none"; }
|
||||
else { fpPauseIcon.style.display = "none"; fpPlayIcon.style.display = "block"; }
|
||||
}
|
||||
|
||||
function handleFloatingBtnClick() {
|
||||
if (!hasStarted) {
|
||||
hasStarted = true;
|
||||
if (storyInstances.length > 0) { currentlyPlayingInstance = storyInstances[0]; currentlyPlayingIndex = 0; currentlyPlayingInstance.playAt(0); updateFloatingButton("playing"); }
|
||||
return;
|
||||
}
|
||||
if (currentlyPlayingInstance) {
|
||||
const audio = currentlyPlayingInstance.getAudioElement();
|
||||
if (audio.paused) { currentlyPlayingInstance.play(); updateFloatingButton("playing"); }
|
||||
else { currentlyPlayingInstance.pause(); updateFloatingButton("paused"); }
|
||||
} else {
|
||||
if (storyInstances.length > 0) { currentlyPlayingInstance = storyInstances[0]; currentlyPlayingIndex = 0; currentlyPlayingInstance.playAt(0); updateFloatingButton("playing"); }
|
||||
}
|
||||
}
|
||||
|
||||
function playNextInstance() {
|
||||
const next = currentlyPlayingIndex + 1;
|
||||
if (next < storyInstances.length) {
|
||||
if (currentlyPlayingInstance) currentlyPlayingInstance.stopAndReset();
|
||||
currentlyPlayingInstance = storyInstances[next]; currentlyPlayingIndex = next; currentlyPlayingInstance.playAt(0);
|
||||
} else { updateFloatingButton("paused"); currentlyPlayingInstance = null; currentlyPlayingIndex = -1; }
|
||||
}
|
||||
|
||||
function handleTextClick(event) {
|
||||
const wordSpan = event.target.closest(".word");
|
||||
if (!wordSpan) return;
|
||||
const storyBlock = event.target.closest(".story-block");
|
||||
if (!storyBlock) return;
|
||||
const idx = parseInt(storyBlock.dataset.instanceIndex, 10);
|
||||
const target = storyInstances[idx];
|
||||
if (!target) return;
|
||||
const timestamp = target.getTimeForSpan(wordSpan);
|
||||
if (timestamp !== null) {
|
||||
hasStarted = true;
|
||||
if (currentlyPlayingInstance && currentlyPlayingInstance !== target) currentlyPlayingInstance.stopAndReset();
|
||||
currentlyPlayingInstance = target; currentlyPlayingIndex = idx;
|
||||
currentlyPlayingInstance.playAt(timestamp); updateFloatingButton("playing");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Load manifest and scan for images ──
|
||||
try {
|
||||
const resp = await fetch("manifest.json");
|
||||
const manifest = await resp.json();
|
||||
|
||||
document.getElementById("main-header").querySelector(".story-title").textContent = manifest.title || "Interactive Reader";
|
||||
document.getElementById("main-header").querySelector(".story-subtitle").textContent = "An interactive reading experience.";
|
||||
|
||||
audioAssets = (manifest.assets || []).filter(a => a.textFile && a.audioFile && a.jsonFile);
|
||||
|
||||
if (audioAssets.length === 0) {
|
||||
mainContainer.innerHTML = '<p class="text-center text-muted">No playable content found.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Scan the book/ directory for image files by trying common patterns
|
||||
// We'll extract all known prefixes from audio assets and also look for image-only prefixes
|
||||
const allPrefixes = new Set();
|
||||
const audioPrefixSet = new Set();
|
||||
|
||||
for (const asset of audioAssets) {
|
||||
// prefix is like "1.1_"
|
||||
const match = asset.prefix.match(/^([\d]+\.[\d]+)_/);
|
||||
if (match) {
|
||||
allPrefixes.add(match[1]);
|
||||
audioPrefixSet.add(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
// Build ordered items: audio assets + try to discover image files
|
||||
// Since we can't list directories via fetch, we'll look for images referenced
|
||||
// in the manifest or try common image patterns
|
||||
|
||||
// For now, build from audio assets and try to fetch potential image files
|
||||
const orderedItems = [];
|
||||
|
||||
// Collect all numeric prefixes we should check for images
|
||||
// Check for image files at each audio prefix position and also between them
|
||||
const sortedAudioPrefixes = Array.from(audioPrefixSet).sort((a, b) => {
|
||||
const [aMaj, aMin] = a.split(".").map(Number);
|
||||
const [bMaj, bMin] = b.split(".").map(Number);
|
||||
return aMaj !== bMaj ? aMaj - bMaj : aMin - bMin;
|
||||
});
|
||||
|
||||
// Determine all possible prefixes (including gaps for images)
|
||||
const allSortKeys = new Set();
|
||||
for (const p of sortedAudioPrefixes) allSortKeys.add(p);
|
||||
|
||||
// Check for images at each prefix — try _img0.jpg, _img0.png
|
||||
const imageMap = new Map();
|
||||
const imgExts = ["jpg", "jpeg", "png", "gif", "webp"];
|
||||
|
||||
// Also check prefixes between/around audio prefixes for image-only blocks
|
||||
if (sortedAudioPrefixes.length > 0) {
|
||||
const chapters = new Set(sortedAudioPrefixes.map(p => parseInt(p.split(".")[0])));
|
||||
for (const ch of chapters) {
|
||||
// Check sub-indices 1 through 50 for this chapter
|
||||
for (let sub = 1; sub <= 50; sub++) {
|
||||
allSortKeys.add(`${ch}.${sub}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to fetch images for all potential sort keys
|
||||
const projectName = manifest.title ? manifest.title.replace(/[^a-zA-Z0-9_\- ]/g, "") : "book";
|
||||
|
||||
for (const sortKey of Array.from(allSortKeys).sort((a, b) => {
|
||||
const [aMaj, aMin] = a.split(".").map(Number);
|
||||
const [bMaj, bMin] = b.split(".").map(Number);
|
||||
return aMaj !== bMaj ? aMaj - bMaj : aMin - bMin;
|
||||
})) {
|
||||
// Try fetching image files: book/{sortKey}_img0.{ext}
|
||||
for (const ext of imgExts) {
|
||||
try {
|
||||
const imgUrl = `book/${sortKey}_img0.${ext}`;
|
||||
const imgResp = await fetch(imgUrl, { method: "HEAD" });
|
||||
if (imgResp.ok) {
|
||||
if (!imageMap.has(sortKey)) imageMap.set(sortKey, []);
|
||||
imageMap.get(sortKey).push(imgUrl);
|
||||
break; // Found one format, skip others
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Now build final ordered list
|
||||
const finalSortKeys = new Set([...audioPrefixSet, ...imageMap.keys()]);
|
||||
const sortedFinalKeys = Array.from(finalSortKeys).sort((a, b) => {
|
||||
const [aMaj, aMin] = a.split(".").map(Number);
|
||||
const [bMaj, bMin] = b.split(".").map(Number);
|
||||
return aMaj !== bMaj ? aMaj - bMaj : aMin - bMin;
|
||||
});
|
||||
|
||||
currentBookId = audioAssets.map(a => a.prefix).join("|");
|
||||
|
||||
let audioInstanceIndex = 0;
|
||||
let lastChapter = null;
|
||||
|
||||
for (const sortKey of sortedFinalKeys) {
|
||||
const chapter = sortKey.split(".")[0];
|
||||
const isAudio = audioPrefixSet.has(sortKey);
|
||||
const hasImages = imageMap.has(sortKey);
|
||||
|
||||
if (isAudio) {
|
||||
// Find matching audio asset
|
||||
const asset = audioAssets.find(a => a.prefix.startsWith(sortKey + "_"));
|
||||
if (!asset) continue;
|
||||
|
||||
if (chapter !== lastChapter) {
|
||||
const li = document.createElement("li");
|
||||
const a = document.createElement("a");
|
||||
a.href = `#story-block-${audioInstanceIndex}`;
|
||||
a.textContent = chapter;
|
||||
a.dataset.chapter = chapter;
|
||||
li.appendChild(a);
|
||||
storyNavList.appendChild(li);
|
||||
lastChapter = chapter;
|
||||
}
|
||||
|
||||
mainContainer.insertAdjacentHTML("beforeend",
|
||||
`<div id="story-block-${audioInstanceIndex}" class="story-block mt-4" data-instance-index="${audioInstanceIndex}" data-sort-key="${sortKey}">
|
||||
<div class="image-container text-center mb-4"></div>
|
||||
<div class="loading-indicator text-center p-5"><div class="spinner-border"></div></div>
|
||||
<audio class="audio-player" style="display:none;"></audio>
|
||||
<article class="story-text-container" style="display:none;"></article>
|
||||
</div>`
|
||||
);
|
||||
|
||||
// Show images for this audio block if any
|
||||
if (hasImages) {
|
||||
const block = document.getElementById(`story-block-${audioInstanceIndex}`);
|
||||
const imgContainer = block.querySelector(".image-container");
|
||||
for (const imgUrl of imageMap.get(sortKey)) {
|
||||
const img = document.createElement("img");
|
||||
img.src = imgUrl;
|
||||
img.className = "img-fluid rounded shadow-sm";
|
||||
img.style.maxHeight = "60vh";
|
||||
imgContainer.appendChild(img);
|
||||
}
|
||||
}
|
||||
|
||||
audioInstanceIndex++;
|
||||
|
||||
} else if (hasImages && !isAudio) {
|
||||
// Image-only block
|
||||
if (chapter !== lastChapter) {
|
||||
const nearestAudioIdx = Math.max(0, audioInstanceIndex - 1);
|
||||
const li = document.createElement("li");
|
||||
const a = document.createElement("a");
|
||||
a.href = `#story-block-${nearestAudioIdx}`;
|
||||
a.textContent = chapter;
|
||||
a.dataset.chapter = chapter;
|
||||
li.appendChild(a);
|
||||
storyNavList.appendChild(li);
|
||||
lastChapter = chapter;
|
||||
}
|
||||
|
||||
const imgDiv = document.createElement("div");
|
||||
imgDiv.className = "story-image-block mt-4";
|
||||
for (const imgUrl of imageMap.get(sortKey)) {
|
||||
const img = document.createElement("img");
|
||||
img.src = imgUrl;
|
||||
img.className = "img-fluid rounded shadow-sm";
|
||||
img.style.maxHeight = "70vh";
|
||||
imgDiv.appendChild(img);
|
||||
}
|
||||
mainContainer.appendChild(imgDiv);
|
||||
}
|
||||
}
|
||||
|
||||
storyNav.classList.add("visible");
|
||||
floatingPlayerBtn.classList.add("visible");
|
||||
|
||||
// Create player instances for audio blocks
|
||||
storyInstances = await Promise.all(
|
||||
audioAssets.map(async (asset, index) => {
|
||||
const block = document.getElementById(`story-block-${index}`);
|
||||
const [text, timestamps] = await Promise.all([
|
||||
fetch(asset.textFile).then(r => r.text()),
|
||||
fetch(asset.jsonFile).then(r => r.json())
|
||||
]);
|
||||
return createStoryPlayer(block, { text, timestamps, audioUrl: asset.audioFile }, index);
|
||||
})
|
||||
);
|
||||
|
||||
loadProgress(currentBookId);
|
||||
setupNavObserver();
|
||||
setTimeout(positionUI, 100);
|
||||
|
||||
} catch (e) {
|
||||
mainContainer.innerHTML = `<p class="text-center text-danger">Error loading: ${e.message}</p>`;
|
||||
}
|
||||
|
||||
function setupNavObserver() {
|
||||
if (navObserver) navObserver.disconnect();
|
||||
navObserver = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const sk = entry.target.dataset.sortKey;
|
||||
if (sk) {
|
||||
const ch = sk.split(".")[0];
|
||||
storyNavList.querySelectorAll("a").forEach(l => l.classList.remove("active"));
|
||||
const al = storyNavList.querySelector(`a[data-chapter='${ch}']`);
|
||||
if (al) al.classList.add("active");
|
||||
}
|
||||
}
|
||||
});
|
||||
}, { threshold: 0.4 });
|
||||
document.querySelectorAll(".story-block").forEach(b => navObserver.observe(b));
|
||||
}
|
||||
|
||||
function createStoryPlayer(storyBlock, data, instanceIndex) {
|
||||
const audioPlayer = storyBlock.querySelector(".audio-player");
|
||||
const storyContainer = storyBlock.querySelector(".story-text-container");
|
||||
let wordTimestamps = data.timestamps, sentenceData = [], allWordSpans = [], wordMap = [];
|
||||
let animationFrameId = null, lastW = null, lastSS = [];
|
||||
|
||||
audioPlayer.src = data.audioUrl;
|
||||
renderMd(data.text);
|
||||
smartSync();
|
||||
storyBlock.querySelector(".loading-indicator").style.display = "none";
|
||||
storyContainer.style.display = "block";
|
||||
|
||||
audioPlayer.addEventListener("play", () => { startLoop(); updateFloatingButton("playing"); });
|
||||
audioPlayer.addEventListener("pause", () => { stopLoop(); updateFloatingButton("paused"); saveCurrentProgress(); });
|
||||
audioPlayer.addEventListener("ended", () => { stopLoop(); clearHL(); playNextInstance(); });
|
||||
|
||||
function renderMd(text) {
|
||||
storyContainer.innerHTML = ""; allWordSpans = [];
|
||||
const div = document.createElement("div");
|
||||
div.innerHTML = marked.parse(text, { breaks: true, gfm: true });
|
||||
function proc(n) {
|
||||
if (n.nodeType === Node.TEXT_NODE) {
|
||||
const ws = n.textContent.split(/(\s+)/); const f = document.createDocumentFragment();
|
||||
ws.forEach(p => { if (p.trim().length > 0) { const s = document.createElement("span"); s.className = "word"; s.textContent = p; allWordSpans.push(s); f.appendChild(s); } else f.appendChild(document.createTextNode(p)); });
|
||||
n.parentNode.replaceChild(f, n);
|
||||
} else if (n.nodeType === Node.ELEMENT_NODE) Array.from(n.childNodes).forEach(proc);
|
||||
}
|
||||
proc(div); while (div.firstChild) storyContainer.appendChild(div.firstChild);
|
||||
}
|
||||
|
||||
function smartSync() {
|
||||
wordMap = new Array(allWordSpans.length).fill(undefined); let ai = 0;
|
||||
allWordSpans.forEach((s, i) => { const tw = s.textContent.toLowerCase().replace(/[^\w]/g, ""); for (let o = 0; o < 5; o++) { if (ai + o >= wordTimestamps.length) break; if (tw === wordTimestamps[ai + o].word.toLowerCase().replace(/[^\w]/g, "")) { wordMap[i] = ai + o; ai += o + 1; return; } } });
|
||||
sentenceData = []; let buf = [], si = 0;
|
||||
allWordSpans.forEach((s, i) => { buf.push(s); if (/[.!?]["'\u201D\u2019]?$/.test(s.textContent.trim())) { let sT = 0, eT = 0; for (let k = si; k <= i; k++) if (wordMap[k] !== undefined) { sT = wordTimestamps[wordMap[k]].start; break; } for (let k = i; k >= si; k--) if (wordMap[k] !== undefined) { eT = wordTimestamps[wordMap[k]].end; break; } if (eT > sT) sentenceData.push({ spans: [...buf], startTime: sT, endTime: eT }); buf = []; si = i + 1; } });
|
||||
if (buf.length > 0) { let sT = 0, eT = 0; for (let k = si; k < allWordSpans.length; k++) if (wordMap[k] !== undefined) { sT = wordTimestamps[wordMap[k]].start; break; } for (let k = allWordSpans.length - 1; k >= si; k--) if (wordMap[k] !== undefined) { eT = wordTimestamps[wordMap[k]].end; break; } if (eT > sT) sentenceData.push({ spans: [...buf], startTime: sT, endTime: eT }); }
|
||||
}
|
||||
|
||||
function hlLoop() {
|
||||
if (audioPlayer.paused) return; const t = audioPlayer.currentTime;
|
||||
const ai = wordTimestamps.findIndex(w => t >= w.start && t < w.end);
|
||||
if (ai !== -1) { const ti = wordMap.findIndex(i => i === ai); if (ti !== -1) { const sp = allWordSpans[ti]; if (sp !== lastW) { if (lastW) lastW.classList.remove("current-word"); sp.classList.add("current-word"); const r = sp.getBoundingClientRect(); if (r.top < window.innerHeight * 0.3 || r.bottom > window.innerHeight * 0.7) sp.scrollIntoView({ behavior: "smooth", block: "center" }); lastW = sp; } } }
|
||||
const sent = sentenceData.find(s => t >= s.startTime && t <= s.endTime);
|
||||
if (sent && sent.spans !== lastSS) { lastSS.forEach(s => s.classList.remove("current-sentence-bg")); sent.spans.forEach(s => s.classList.add("current-sentence-bg")); lastSS = sent.spans; }
|
||||
animationFrameId = requestAnimationFrame(hlLoop);
|
||||
}
|
||||
function clearHL() { if (lastW) lastW.classList.remove("current-word"); lastSS.forEach(s => s.classList.remove("current-sentence-bg")); lastW = null; lastSS = []; }
|
||||
function startLoop() { cancelAnimationFrame(animationFrameId); animationFrameId = requestAnimationFrame(hlLoop); }
|
||||
function stopLoop() { cancelAnimationFrame(animationFrameId); }
|
||||
|
||||
return {
|
||||
play: () => audioPlayer.play(), pause: () => audioPlayer.pause(),
|
||||
playAt: (t) => { audioPlayer.currentTime = t; audioPlayer.play(); },
|
||||
stopAndReset: () => { audioPlayer.pause(); audioPlayer.currentTime = 0; clearHL(); },
|
||||
getAudioElement: () => audioPlayer,
|
||||
getTimeForSpan: (span) => { const i = allWordSpans.indexOf(span); const ai = wordMap[i]; return ai !== undefined ? wordTimestamps[ai].start : null; },
|
||||
isReady: () => Promise.resolve()
|
||||
};
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user