first commit

This commit is contained in:
Ashim Kumar
2026-01-09 21:06:30 +06:00
commit 11d715eb85
19 changed files with 8235 additions and 0 deletions

503
reader_templates/Reader.html Executable file
View File

@@ -0,0 +1,503 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Interactive Reader (Local)</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>
@import url("https://fonts.googleapis.com/css2?family=Lora:ital,wght@0,400..700;1,400..700&family=Poppins:wght@500;700&display=swap");
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
@keyframes highlightFade { 0% { background-color: #c7d2fe; } 100% { background-color: transparent; } }
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); -webkit-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); }
/* Typography */
.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 strong { font-weight: bold; }
.story-text-container em { font-style: italic; }
.sentence, .word { transition: all 0.15s ease; border-radius: 3px; border-bottom: 2px solid transparent; }
.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; }
.resume-highlight { animation: highlightFade 2.5s ease-out forwards; }
/* Floating Button */
#floating-player-btn {
position: fixed; top: 2rem; right: 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; display: inline-block; }
#floating-player-btn.active-mode { width: 60px; padding: 0; border-radius: 50%; }
/* Navigation */
#story-nav {
position: fixed; left: 2rem; top: 50%; background-color: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(10px); -webkit-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; background-color: transparent; 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: 992px) {
#story-nav { left: 1rem; }
#story-nav a { width: 35px; height: 35px; font-size: 0.9rem; }
}
@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; height: 50px; min-width: 50px; }
#floating-player-btn.active-mode { width: 50px; }
#floating-player-btn svg { width: 22px; height: 22px; }
#story-nav { display: none; }
}
</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"><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 'assets' 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>
<footer class="text-center text-muted mt-4"></footer>
</div>
<script>
document.addEventListener("DOMContentLoaded", () => {
const mainContainer = document.getElementById("stories-main-container");
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 navObserver;
let currentBookId = null;
let validStoryParts = [];
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);
function saveCurrentProgress() {
if (!currentlyPlayingInstance || !currentBookId) return;
const instanceIndex = storyInstances.indexOf(currentlyPlayingInstance);
const timestamp = currentlyPlayingInstance.getAudioElement().currentTime;
saveProgress(currentBookId, instanceIndex, timestamp);
}
function saveProgress(bookId, instanceIndex, timestamp) {
const progress = { bookId, instanceIndex, timestamp, lastUpdate: Date.now() };
localStorage.setItem(PROGRESS_KEY, JSON.stringify(progress));
}
function loadProgress(bookId) {
const savedProgress = localStorage.getItem(PROGRESS_KEY);
if (!savedProgress) return;
const progress = JSON.parse(savedProgress);
if (progress.bookId !== bookId) return;
const targetInstance = storyInstances[progress.instanceIndex];
if (!targetInstance) return;
resumeAlert.style.display = 'flex';
resumeBtn.onclick = () => {
resumeAlert.style.display = 'none';
currentlyPlayingInstance = targetInstance;
targetInstance.playAt(progress.timestamp);
};
}
function updateFloatingButton(state) {
if (fpStartText) 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 (!currentlyPlayingInstance) {
if (storyInstances.length > 0) {
currentlyPlayingInstance = storyInstances[0];
currentlyPlayingInstance.playAt(0);
updateFloatingButton('playing');
}
return;
}
currentlyPlayingInstance.getAudioElement().paused ? currentlyPlayingInstance.play() : currentlyPlayingInstance.pause();
}
function handleTextClick(event) {
const wordSpan = event.target.closest(".word");
if (!wordSpan) return;
const storyBlock = event.target.closest('.story-block');
const instanceIndex = parseInt(storyBlock.dataset.instanceIndex, 10);
const targetInstance = storyInstances[instanceIndex];
// SMART SYNC TIME LOOKUP
const timestamp = targetInstance.getTimeForSpan(wordSpan);
if (timestamp !== null) {
wordSpan.scrollIntoView({ behavior: 'auto', block: 'center' });
if (currentlyPlayingInstance && currentlyPlayingInstance !== targetInstance) {
currentlyPlayingInstance.stopAndReset();
}
currentlyPlayingInstance = targetInstance;
currentlyPlayingInstance.playAt(timestamp);
}
}
function handleFolderSelection(event) {
const files = event.target.files; if (files.length === 0) return;
const storyPartsMap = new Map();
for (const file of files) {
const match = file.name.match(/^([\d\.]+_)/);
if (match) {
const prefix = match[0];
const extension = file.name.split('.').pop().toLowerCase();
if (!storyPartsMap.has(prefix)) storyPartsMap.set(prefix, {});
const part = storyPartsMap.get(prefix);
if (['wav', 'mp3'].includes(extension)) part.audioFile = file;
else if (extension === 'txt') part.textFile = file;
else if (extension === 'json') part.jsonFile = file;
else if (['jpg', 'jpeg', 'png', 'gif'].includes(extension)) part.imageFile = file;
}
}
validStoryParts = Array.from(storyPartsMap.entries())
.filter(([p, f]) => f.audioFile && f.textFile && f.jsonFile)
.sort(([pA], [pB]) => pA.localeCompare(pB, undefined, { numeric: true }));
if (validStoryParts.length === 0) {
document.getElementById('info-alert').textContent = 'No valid story parts found. Ensure files are named correctly (e.g., "1.1_intro.txt").';
document.getElementById('info-alert').style.display = 'block'; return;
}
currentBookId = validStoryParts.map(([prefix]) => prefix).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';
let lastChapter = null;
validStoryParts.forEach(([prefix, files], index) => {
const storyHtml = `<div id="story-block-${index}" class="story-block ${index > 0 ? 'mt-5' : ''}" data-instance-index="${index}"><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.innerHTML += storyHtml;
const chapter = prefix.split('.')[0];
if (chapter !== lastChapter) {
const navItem = document.createElement('li');
const navLink = document.createElement('a');
navLink.href = `#story-block-${index}`;
navLink.textContent = chapter;
navLink.dataset.chapter = chapter;
navItem.appendChild(navLink);
storyNavList.appendChild(navItem);
lastChapter = chapter;
}
});
storyNav.classList.add('visible');
floatingPlayerBtn.classList.add('visible');
storyInstances = validStoryParts.map(([prefix, files], index) => {
const storyBlock = document.getElementById(`story-block-${index}`);
if (files.imageFile) {
const imgContainer = storyBlock.querySelector('.image-container');
const img = document.createElement('img');
img.src = URL.createObjectURL(files.imageFile);
img.className = 'img-fluid rounded shadow-sm';
img.style.maxHeight = '60vh';
imgContainer.appendChild(img);
}
return createStoryPlayer(document.getElementById(`story-block-${index}`), files, index);
});
Promise.all(storyInstances.map(inst => inst.isReady())).then(() => {
loadProgress(currentBookId);
});
setupNavObserver();
}
function setupNavObserver() {
if (navObserver) navObserver.disconnect();
const options = { root: null, rootMargin: '0px', threshold: 0.4 };
navObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const index = entry.target.dataset.instanceIndex;
const storyPart = validStoryParts[parseInt(index, 10)];
if (storyPart) {
const prefix = storyPart[0];
const chapter = prefix.split('.')[0];
storyNavList.querySelectorAll('a').forEach(l => l.classList.remove('active'));
const activeLink = storyNavList.querySelector(`a[data-chapter='${chapter}']`);
if (activeLink) activeLink.classList.add('active');
}
}
});
}, options);
document.querySelectorAll('.story-block').forEach(block => navObserver.observe(block));
}
function createStoryPlayer(storyBlock, files, instanceIndex) {
const audioPlayer = storyBlock.querySelector(".audio-player");
const storyContainer = storyBlock.querySelector(".story-text-container");
let wordTimestamps = [], sentenceData = [], allWordSpans = [], wordMap = [];
let animationFrameId = null, lastHighlightedWordSpan = null, lastHighlightedSentenceSpans = [];
const readyPromise = new Promise(async (resolve) => {
const readFileAsText = (file) => new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result); reader.onerror = reject; reader.readAsText(file);
});
const [storyText, jsonText] = await Promise.all([ readFileAsText(files.textFile), readFileAsText(files.jsonFile) ]);
wordTimestamps = JSON.parse(jsonText);
audioPlayer.src = URL.createObjectURL(files.audioFile);
renderStoryWithMarkdown(storyText);
// --- CRITICAL FIX: Run Smart Sync ---
runSmartSync();
storyBlock.querySelector('.loading-indicator').style.display = 'none';
storyContainer.style.display = 'block';
audioPlayer.addEventListener('play', () => { startLoop(); updateFloatingButton('playing'); currentlyPlayingInstance = storyInstances[instanceIndex]; });
audioPlayer.addEventListener('pause', () => { stopLoop(); updateFloatingButton('paused'); saveCurrentProgress(); });
audioPlayer.addEventListener('ended', () => {
stopLoop(); updateFloatingButton('paused');
if (instanceIndex + 1 < storyInstances.length && currentlyPlayingInstance === storyInstances[instanceIndex]) {
currentlyPlayingInstance = storyInstances[instanceIndex + 1];
currentlyPlayingInstance.playAt(0);
}
});
resolve();
});
function renderStoryWithMarkdown(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);
}
// --- SMART SYNC LOGIC (The Fix) ---
function runSmartSync() {
wordMap = new Array(allWordSpans.length).fill(undefined);
let aiIdx = 0;
allWordSpans.forEach((span, i) => {
const textWord = span.textContent.toLowerCase().replace(/[^\w]/g, '');
// Lookahead fuzzy matching
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;
}
}
});
mapSentencesToTimestamps();
}
function mapSentencesToTimestamps() {
sentenceData = []; let buffer = [], startIdx = 0;
allWordSpans.forEach((span, i) => {
buffer.push(span);
if (/[.!?]["']?$/.test(span.textContent.trim())) {
let startT = 0, endT = 0;
// Find first/last mapped word
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;
}
});
}
function highlightLoop() {
if (audioPlayer.paused) return;
const currentTime = audioPlayer.currentTime;
// Word Highlight using Map
const activeAiIndex = wordTimestamps.findIndex(w => currentTime >= w.start && currentTime < w.end);
if (activeAiIndex !== -1) {
const activeTextIndex = wordMap.findIndex(i => i === activeAiIndex);
if (activeTextIndex !== -1) {
const activeSpan = allWordSpans[activeTextIndex];
if (activeSpan !== lastHighlightedWordSpan) {
if (lastHighlightedWordSpan) lastHighlightedWordSpan.classList.remove("current-word");
activeSpan.classList.add("current-word");
const rect = activeSpan.getBoundingClientRect();
if (rect.top < window.innerHeight * 0.3 || rect.bottom > window.innerHeight * 0.7) {
activeSpan.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
lastHighlightedWordSpan = activeSpan;
}
}
}
// Sentence Highlight
const activeSentence = sentenceData.find(s => currentTime >= s.startTime && currentTime <= s.endTime);
if (activeSentence && activeSentence.spans !== lastHighlightedSentenceSpans) {
lastHighlightedSentenceSpans.forEach(s => s.classList.remove("current-sentence-bg"));
activeSentence.spans.forEach(s => s.classList.add("current-sentence-bg"));
lastHighlightedSentenceSpans = activeSentence.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,
getWordSpans: () => allWordSpans,
getTimestamps: () => wordTimestamps,
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
View 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 Reader (Local)</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>
@import url("https://fonts.googleapis.com/css2?family=Lora:ital,wght@0,400..700;1,400..700&family=Poppins:wght@500;700&display=swap");
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
@keyframes highlightFade { 0% { background-color: #c7d2fe; } 100% { background-color: transparent; } }
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); -webkit-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 strong { font-weight: bold; }
.story-text-container em { font-style: italic; }
.sentence, .word { transition: all 0.15s ease; border-radius: 3px; border-bottom: 2px solid transparent; }
.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; }
.resume-highlight { animation: highlightFade 2.5s ease-out forwards; }
#floating-player-btn {
position: fixed; top: 2rem; right: 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; display: inline-block; }
#floating-player-btn.active-mode { width: 60px; padding: 0; border-radius: 50%; }
#story-nav {
position: fixed; left: 2rem; top: 50%; background-color: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(10px); -webkit-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; background-color: transparent; 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: 992px) {
#story-nav { left: 1rem; }
#story-nav a { width: 35px; height: 35px; font-size: 0.9rem; }
}
@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; height: 50px; min-width: 50px; }
#floating-player-btn.active-mode { width: 50px; }
#floating-player-btn svg { width: 22px; height: 22px; }
#story-nav { display: none; }
}
</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"><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 'assets' 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>
<footer class="text-center text-muted mt-4"></footer>
</div>
<script>
document.addEventListener("DOMContentLoaded", () => {
const mainContainer = document.getElementById("stories-main-container");
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 navObserver;
let currentBookId = null;
let validStoryParts = [];
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);
function saveCurrentProgress() {
if (!currentlyPlayingInstance || !currentBookId) return;
const instanceIndex = storyInstances.indexOf(currentlyPlayingInstance);
const timestamp = currentlyPlayingInstance.getAudioElement().currentTime;
saveProgress(currentBookId, instanceIndex, timestamp);
}
function saveProgress(bookId, instanceIndex, timestamp) {
const progress = { bookId, instanceIndex, timestamp, lastUpdate: Date.now() };
localStorage.setItem(PROGRESS_KEY, JSON.stringify(progress));
}
function loadProgress(bookId) {
const savedProgress = localStorage.getItem(PROGRESS_KEY);
if (!savedProgress) return;
const progress = JSON.parse(savedProgress);
if (progress.bookId !== bookId) return;
const targetInstance = storyInstances[progress.instanceIndex];
if (!targetInstance) return;
resumeAlert.style.display = 'flex';
resumeBtn.onclick = () => {
resumeAlert.style.display = 'none';
currentlyPlayingInstance = targetInstance;
targetInstance.playAt(progress.timestamp);
};
}
function updateFloatingButton(state) {
if (fpStartText) 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 (!currentlyPlayingInstance) {
if (storyInstances.length > 0) {
currentlyPlayingInstance = storyInstances[0];
currentlyPlayingInstance.playAt(0);
updateFloatingButton('playing');
}
return;
}
currentlyPlayingInstance.getAudioElement().paused ? currentlyPlayingInstance.play() : currentlyPlayingInstance.pause();
}
function handleTextClick(event) {
const wordSpan = event.target.closest(".word");
if (!wordSpan) return;
const storyBlock = event.target.closest('.story-block');
const instanceIndex = parseInt(storyBlock.dataset.instanceIndex, 10);
const targetInstance = storyInstances[instanceIndex];
// SMART SYNC LOOKUP
const timestamp = targetInstance.getTimeForSpan(wordSpan);
if (timestamp !== null) {
wordSpan.scrollIntoView({ behavior: 'auto', block: 'center' });
if (currentlyPlayingInstance && currentlyPlayingInstance !== targetInstance) {
currentlyPlayingInstance.stopAndReset();
}
currentlyPlayingInstance = targetInstance;
currentlyPlayingInstance.playAt(timestamp);
}
}
function handleFolderSelection(event) {
const files = event.target.files; if (files.length === 0) return;
const storyPartsMap = new Map();
for (const file of files) {
const match = file.name.match(/^([\d\.]+_)/);
if (match) {
const prefix = match[0];
const extension = file.name.split('.').pop().toLowerCase();
if (!storyPartsMap.has(prefix)) storyPartsMap.set(prefix, {});
const part = storyPartsMap.get(prefix);
if (['wav', 'mp3'].includes(extension)) part.audioFile = file;
else if (extension === 'txt') part.textFile = file;
else if (extension === 'json') part.jsonFile = file;
else if (['jpg', 'jpeg', 'png', 'gif'].includes(extension)) part.imageFile = file;
}
}
validStoryParts = Array.from(storyPartsMap.entries())
.filter(([p, f]) => f.audioFile && f.textFile && f.jsonFile)
.sort(([pA], [pB]) => pA.localeCompare(pB, undefined, { numeric: true }));
if (validStoryParts.length === 0) {
document.getElementById('info-alert').textContent = 'No valid story parts found. Ensure files are named correctly (e.g., "1.1_intro.txt").';
document.getElementById('info-alert').style.display = 'block'; return;
}
currentBookId = validStoryParts.map(([prefix]) => prefix).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';
let lastChapter = null;
validStoryParts.forEach(([prefix, files], index) => {
const storyHtml = `<div id="story-block-${index}" class="story-block ${index > 0 ? 'mt-5' : ''}" data-instance-index="${index}"><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.innerHTML += storyHtml;
const chapter = prefix.split('.')[0];
if (chapter !== lastChapter) {
const navItem = document.createElement('li');
const navLink = document.createElement('a');
navLink.href = `#story-block-${index}`;
navLink.textContent = chapter;
navLink.dataset.chapter = chapter;
navItem.appendChild(navLink);
storyNavList.appendChild(navItem);
lastChapter = chapter;
}
});
storyNav.classList.add('visible');
floatingPlayerBtn.classList.add('visible');
storyInstances = validStoryParts.map(([prefix, files], index) => {
const storyBlock = document.getElementById(`story-block-${index}`);
if (files.imageFile) {
const imgContainer = storyBlock.querySelector('.image-container');
const img = document.createElement('img');
img.src = URL.createObjectURL(files.imageFile);
img.className = 'img-fluid rounded shadow-sm';
img.style.maxHeight = '60vh';
imgContainer.appendChild(img);
}
return createStoryPlayer(document.getElementById(`story-block-${index}`), files, index);
});
Promise.all(storyInstances.map(inst => inst.isReady())).then(() => {
loadProgress(currentBookId);
});
setupNavObserver();
}
function setupNavObserver() {
if (navObserver) navObserver.disconnect();
const options = { root: null, rootMargin: '0px', threshold: 0.4 };
navObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const index = entry.target.dataset.instanceIndex;
const storyPart = validStoryParts[parseInt(index, 10)];
if (storyPart) {
const prefix = storyPart[0];
const chapter = prefix.split('.')[0];
storyNavList.querySelectorAll('a').forEach(l => l.classList.remove('active'));
const activeLink = storyNavList.querySelector(`a[data-chapter='${chapter}']`);
if (activeLink) activeLink.classList.add('active');
}
}
});
}, options);
document.querySelectorAll('.story-block').forEach(block => navObserver.observe(block));
}
function createStoryPlayer(storyBlock, files, instanceIndex) {
const audioPlayer = storyBlock.querySelector(".audio-player");
const storyContainer = storyBlock.querySelector(".story-text-container");
let wordTimestamps = [], sentenceData = [], allWordSpans = [], wordMap = [];
let animationFrameId = null, lastHighlightedWordSpan = null, lastHighlightedSentenceSpans = [];
const readyPromise = new Promise(async (resolve) => {
const readFileAsText = (file) => new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result); reader.onerror = reject; reader.readAsText(file);
});
const [storyText, jsonText] = await Promise.all([ readFileAsText(files.textFile), readFileAsText(files.jsonFile) ]);
wordTimestamps = JSON.parse(jsonText);
audioPlayer.src = URL.createObjectURL(files.audioFile);
renderStoryWithMarkdown(storyText);
// CRITICAL FIX: Run Smart Sync
runSmartSync();
storyBlock.querySelector('.loading-indicator').style.display = 'none';
storyContainer.style.display = 'block';
audioPlayer.addEventListener('play', () => { startLoop(); updateFloatingButton('playing'); currentlyPlayingInstance = storyInstances[instanceIndex]; });
audioPlayer.addEventListener('pause', () => { stopLoop(); updateFloatingButton('paused'); saveCurrentProgress(); });
audioPlayer.addEventListener('ended', () => {
stopLoop(); updateFloatingButton('paused');
if (instanceIndex + 1 < storyInstances.length && currentlyPlayingInstance === storyInstances[instanceIndex]) {
currentlyPlayingInstance = storyInstances[instanceIndex + 1];
currentlyPlayingInstance.playAt(0);
}
});
resolve();
});
function renderStoryWithMarkdown(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.trim().split(/\s+/).filter(w => w.length > 0);
if (words.length > 0) {
const fragment = document.createDocumentFragment();
words.forEach(wordText => {
const wordSpan = document.createElement("span");
wordSpan.className = "word";
wordSpan.textContent = wordText;
allWordSpans.push(wordSpan);
fragment.appendChild(wordSpan);
fragment.appendChild(document.createTextNode(" "));
});
node.parentNode.replaceChild(fragment, node);
}
} else if (node.nodeType === Node.ELEMENT_NODE) {
[...node.childNodes].forEach(processNode);
}
}
processNode(div);
while (div.firstChild) storyContainer.appendChild(div.firstChild);
}
// --- SMART SYNC (The Fix) ---
function runSmartSync() {
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;
}
}
});
mapSentencesToTimestamps();
}
function mapSentencesToTimestamps() {
sentenceData = []; let buffer = [], startIdx = 0;
allWordSpans.forEach((span, i) => {
buffer.push(span);
if (/[.!?]["']?$/.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;
}
});
}
function highlightLoop() {
if (audioPlayer.paused) return;
const currentTime = audioPlayer.currentTime;
// Word Highlight using Map
const activeAiIndex = wordTimestamps.findIndex(w => currentTime >= w.start && currentTime < w.end);
if (activeAiIndex !== -1) {
const activeTextIndex = wordMap.findIndex(i => i === activeAiIndex);
if (activeTextIndex !== -1) {
const activeSpan = allWordSpans[activeTextIndex];
if (activeSpan !== lastHighlightedWordSpan) {
if (lastHighlightedWordSpan) lastHighlightedWordSpan.classList.remove("current-word");
activeSpan.classList.add("current-word");
const rect = activeSpan.getBoundingClientRect();
if (rect.top < window.innerHeight * 0.3 || rect.bottom > window.innerHeight * 0.7) {
activeSpan.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
lastHighlightedWordSpan = activeSpan;
}
}
}
// Sentence Highlight
const activeSentence = sentenceData.find(s => currentTime >= s.startTime && currentTime <= s.endTime);
if (activeSentence && activeSentence.spans !== lastHighlightedSentenceSpans) {
lastHighlightedSentenceSpans.forEach(s => s.classList.remove("current-sentence-bg"));
activeSentence.spans.forEach(s => s.classList.add("current-sentence-bg"));
lastHighlightedSentenceSpans = activeSentence.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,
getWordSpans: () => allWordSpans,
getTimestamps: () => wordTimestamps,
getTimeForSpan: (span) => {
const idx = allWordSpans.indexOf(span);
const aiIdx = wordMap[idx];
return aiIdx !== undefined ? wordTimestamps[aiIdx].start : null;
},
isReady: () => readyPromise
};
}
});
</script>
</body>
</html>