799 lines
29 KiB
JavaScript
799 lines
29 KiB
JavaScript
/**
|
|
* Interactive Reader Module - Rewritten
|
|
* FIXED: Images from manual upload now render correctly using base64 data
|
|
* Features:
|
|
* - Single Start button, becomes play/pause after first click
|
|
* - Auto-advance to next block audio
|
|
* - Click any word to play from that point
|
|
* - Word + Sentence highlighting with smart sync
|
|
* - Left sidebar chapter navigation
|
|
* - Images rendered from base64 data (both processed and uploaded)
|
|
* - Pause/Resume works correctly
|
|
*/
|
|
|
|
// ============================================
|
|
// Reader State
|
|
// ============================================
|
|
|
|
let readerInstances = [];
|
|
let currentReaderInstance = null;
|
|
let currentReaderIndex = -1;
|
|
let readerStarted = false;
|
|
let readerUICreated = false;
|
|
|
|
// ============================================
|
|
// Render Reader
|
|
// ============================================
|
|
|
|
function renderInteractiveReader() {
|
|
const container = document.getElementById('readerContainer');
|
|
const chapters = collectEditorContent();
|
|
|
|
let hasAudio = false;
|
|
const chaptersWithAudio = [];
|
|
|
|
for (const chapter of chapters) {
|
|
const chapterBlocks = [];
|
|
for (const block of chapter.blocks) {
|
|
// Match editor block by ID lookup
|
|
const blockData = findEditorBlockForContent(block);
|
|
|
|
const isImageBlock = block.block_type === 'image' ||
|
|
(block.content && block.content.trim().startsWith(');
|
|
|
|
chapterBlocks.push({
|
|
...block,
|
|
_editorData: blockData || null,
|
|
_isImage: isImageBlock
|
|
});
|
|
|
|
if (!isImageBlock && blockData && blockData.audio_data) {
|
|
hasAudio = true;
|
|
}
|
|
}
|
|
chaptersWithAudio.push({
|
|
...chapter,
|
|
blocks: chapterBlocks
|
|
});
|
|
}
|
|
|
|
if (!hasAudio) {
|
|
container.innerHTML = `
|
|
<div class="reader-empty-state">
|
|
<i class="bi bi-book"></i>
|
|
<p>Generate audio to view the interactive reader</p>
|
|
<p class="text-muted">Go to the Editor tab and click "Generate" on blocks or chapters</p>
|
|
</div>
|
|
`;
|
|
removeReaderUI();
|
|
return;
|
|
}
|
|
|
|
let html = '';
|
|
readerInstances = [];
|
|
let globalBlockIndex = 0;
|
|
|
|
for (const chapter of chaptersWithAudio) {
|
|
html += `<div class="reader-chapter" id="reader-chapter-${chapter.chapter_number}">`;
|
|
html += `<h2 class="reader-chapter-title">Chapter ${chapter.chapter_number}</h2>`;
|
|
|
|
for (const block of chapter.blocks) {
|
|
const blockData = block._editorData;
|
|
const isImageBlock = block._isImage;
|
|
|
|
const hasBlockAudio = !isImageBlock && blockData && blockData.audio_data;
|
|
const blockId = blockData ? blockData.id : `reader_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`;
|
|
|
|
html += `<div class="reader-block" data-block-id="${blockId}" data-reader-index="${globalBlockIndex}" data-has-audio="${!!hasBlockAudio}">`;
|
|
|
|
if (isImageBlock) {
|
|
const imageHtml = buildImageHtml(block, blockData);
|
|
html += `<div class="reader-content reader-image-block">${imageHtml}</div>`;
|
|
} else {
|
|
// Render before-position images from the block's images array
|
|
const blockImages = getBlockImages(block, blockData);
|
|
for (const img of blockImages) {
|
|
if (img.position === 'before' && img.data) {
|
|
html += `<div class="reader-image-block"><img src="data:image/${img.format || 'png'};base64,${img.data}" alt="${img.alt_text || 'Image'}"></div>`;
|
|
}
|
|
}
|
|
|
|
html += `<div class="reader-content" id="reader-content-${globalBlockIndex}"></div>`;
|
|
|
|
// Render after-position images
|
|
for (const img of blockImages) {
|
|
if (img.position === 'after' && img.data) {
|
|
html += `<div class="reader-image-block"><img src="data:image/${img.format || 'png'};base64,${img.data}" alt="${img.alt_text || 'Image'}"></div>`;
|
|
}
|
|
}
|
|
}
|
|
|
|
html += `</div>`;
|
|
|
|
readerInstances.push({
|
|
index: globalBlockIndex,
|
|
blockId: blockId,
|
|
blockData: blockData,
|
|
content: block.content,
|
|
hasAudio: !!hasBlockAudio,
|
|
isImage: isImageBlock,
|
|
chapterNumber: chapter.chapter_number,
|
|
wordSpans: [],
|
|
wordMap: [],
|
|
sentenceData: [],
|
|
audio: null,
|
|
transcription: (!isImageBlock && blockData) ? (blockData.transcription || []) : [],
|
|
animFrameId: null,
|
|
lastWordSpan: null,
|
|
lastSentenceSpans: []
|
|
});
|
|
|
|
globalBlockIndex++;
|
|
}
|
|
|
|
html += `</div>`;
|
|
}
|
|
|
|
container.innerHTML = html;
|
|
|
|
for (const inst of readerInstances) {
|
|
if (inst.isImage || !inst.content) continue;
|
|
|
|
const contentEl = document.getElementById(`reader-content-${inst.index}`);
|
|
if (!contentEl) continue;
|
|
|
|
renderWordsIntoContainer(contentEl, inst);
|
|
|
|
if (inst.hasAudio && inst.transcription.length > 0) {
|
|
runReaderSmartSync(inst);
|
|
}
|
|
}
|
|
|
|
addReaderStyles();
|
|
setupReaderUI(chaptersWithAudio);
|
|
}
|
|
|
|
// ============================================
|
|
// Image Resolution Helpers
|
|
// ============================================
|
|
|
|
/**
|
|
* Find the editorBlocks entry that corresponds to a collected block.
|
|
* Uses multiple strategies: ID match, then content match.
|
|
*/
|
|
function findEditorBlockForContent(block) {
|
|
// Strategy 1: Try matching via DOM element ID
|
|
for (const eb of editorBlocks) {
|
|
const el = document.getElementById(eb.id);
|
|
if (el) {
|
|
const textarea = el.querySelector('.md-block-textarea');
|
|
if (textarea && textarea.value === block.content) {
|
|
return eb;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Strategy 2: Match by content string directly
|
|
for (const eb of editorBlocks) {
|
|
if (eb.content === block.content) {
|
|
return eb;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Get all images for a block from every available source.
|
|
*/
|
|
function getBlockImages(block, blockData) {
|
|
// Priority 1: block.images from collectEditorContent (most reliable)
|
|
if (block.images && block.images.length > 0) {
|
|
const valid = block.images.filter(img => img.data && img.data.length > 0);
|
|
if (valid.length > 0) return valid;
|
|
}
|
|
|
|
// Priority 2: editorBlocks data
|
|
if (blockData && blockData.images && blockData.images.length > 0) {
|
|
const valid = blockData.images.filter(img => img.data && img.data.length > 0);
|
|
if (valid.length > 0) return valid;
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* Build the HTML for an image block in the reader.
|
|
* Resolves base64 data from multiple sources.
|
|
*/
|
|
function buildImageHtml(block, blockData) {
|
|
// Source 1: block.images array (from collectEditorContent)
|
|
if (block.images && block.images.length > 0) {
|
|
let html = '';
|
|
for (const img of block.images) {
|
|
if (img.data && img.data.length > 0) {
|
|
html += `<img src="data:image/${img.format || 'png'};base64,${img.data}" alt="${img.alt_text || 'Image'}">`;
|
|
}
|
|
}
|
|
if (html) return html;
|
|
}
|
|
|
|
// Source 2: editorBlocks images array
|
|
if (blockData && blockData.images && blockData.images.length > 0) {
|
|
let html = '';
|
|
for (const img of blockData.images) {
|
|
if (img.data && img.data.length > 0) {
|
|
html += `<img src="data:image/${img.format || 'png'};base64,${img.data}" alt="${img.alt_text || 'Image'}">`;
|
|
}
|
|
}
|
|
if (html) return html;
|
|
}
|
|
|
|
// Source 3: Extract data URI from markdown content itself
|
|
if (block.content) {
|
|
const dataUriMatch = block.content.match(/!\[([^\]]*)\]\((data:image\/[^)]+)\)/);
|
|
if (dataUriMatch) {
|
|
return `<img src="${dataUriMatch[2]}" alt="${dataUriMatch[1] || 'Image'}">`;
|
|
}
|
|
}
|
|
|
|
// Source 4: Grab the rendered image directly from the editor DOM
|
|
if (blockData && blockData.id) {
|
|
const editorBlock = document.getElementById(blockData.id);
|
|
if (editorBlock) {
|
|
const editorImg = editorBlock.querySelector('.image-block img, .md-block-content img');
|
|
if (editorImg && editorImg.src && editorImg.src.startsWith('data:image')) {
|
|
return `<img src="${editorImg.src}" alt="Image">`;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Source 5: Scan ALL editorBlocks for matching content to find images
|
|
if (block.content) {
|
|
for (const eb of editorBlocks) {
|
|
if (eb.content === block.content && eb.images && eb.images.length > 0) {
|
|
let html = '';
|
|
for (const img of eb.images) {
|
|
if (img.data && img.data.length > 0) {
|
|
html += `<img src="data:image/${img.format || 'png'};base64,${img.data}" alt="${img.alt_text || 'Image'}">`;
|
|
}
|
|
}
|
|
if (html) return html;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fallback: placeholder
|
|
return `<div class="reader-image-placeholder">
|
|
<i class="bi bi-image" style="font-size:2rem;color:#94a3b8;"></i>
|
|
<p style="color:#94a3b8;margin-top:8px;">Image not available</p>
|
|
</div>`;
|
|
}
|
|
|
|
// ============================================
|
|
// Word Rendering & Sync
|
|
// ============================================
|
|
|
|
function renderWordsIntoContainer(container, inst) {
|
|
const div = document.createElement('div');
|
|
div.innerHTML = marked.parse(inst.content, { breaks: true, gfm: true });
|
|
|
|
inst.wordSpans = [];
|
|
|
|
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 = 'reader-word';
|
|
span.textContent = part;
|
|
span.dataset.readerIndex = inst.index;
|
|
span.dataset.wordIdx = inst.wordSpans.length;
|
|
inst.wordSpans.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) container.appendChild(div.firstChild);
|
|
}
|
|
|
|
function runReaderSmartSync(inst) {
|
|
const { wordSpans, transcription } = inst;
|
|
inst.wordMap = new Array(wordSpans.length).fill(undefined);
|
|
let aiIdx = 0;
|
|
|
|
wordSpans.forEach((span, i) => {
|
|
const textWord = span.textContent.toLowerCase().replace(/[^\w]/g, '');
|
|
for (let off = 0; off < 5; off++) {
|
|
if (aiIdx + off >= transcription.length) break;
|
|
const aiWord = transcription[aiIdx + off].word.toLowerCase().replace(/[^\w]/g, '');
|
|
if (textWord === aiWord) {
|
|
inst.wordMap[i] = aiIdx + off;
|
|
aiIdx += off + 1;
|
|
return;
|
|
}
|
|
}
|
|
});
|
|
|
|
inst.sentenceData = [];
|
|
let buffer = [];
|
|
let startIdx = 0;
|
|
|
|
wordSpans.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 (inst.wordMap[k] !== undefined) {
|
|
startT = transcription[inst.wordMap[k]].start;
|
|
break;
|
|
}
|
|
}
|
|
for (let k = i; k >= startIdx; k--) {
|
|
if (inst.wordMap[k] !== undefined) {
|
|
endT = transcription[inst.wordMap[k]].end;
|
|
break;
|
|
}
|
|
}
|
|
if (endT > startT) {
|
|
inst.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 < wordSpans.length; k++) {
|
|
if (inst.wordMap[k] !== undefined) {
|
|
startT = transcription[inst.wordMap[k]].start;
|
|
break;
|
|
}
|
|
}
|
|
for (let k = wordSpans.length - 1; k >= startIdx; k--) {
|
|
if (inst.wordMap[k] !== undefined) {
|
|
endT = transcription[inst.wordMap[k]].end;
|
|
break;
|
|
}
|
|
}
|
|
if (endT > startT) {
|
|
inst.sentenceData.push({ spans: [...buffer], startTime: startT, endTime: endT });
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// Reader UI
|
|
// ============================================
|
|
|
|
function setupReaderUI(chaptersWithAudio) {
|
|
removeReaderUI();
|
|
|
|
const btn = document.createElement('button');
|
|
btn.id = 'reader-floating-btn';
|
|
btn.innerHTML = `
|
|
<span id="reader-btn-text">Start</span>
|
|
<svg id="reader-btn-play" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" style="display:none;width:24px;height:24px;"><path d="M8 5v14l11-7z"/></svg>
|
|
<svg id="reader-btn-pause" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" style="display:none;width:24px;height:24px;"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
|
|
`;
|
|
document.body.appendChild(btn);
|
|
btn.addEventListener('click', handleReaderFloatingClick);
|
|
|
|
const nav = document.createElement('nav');
|
|
nav.id = 'reader-chapter-nav';
|
|
let navHtml = '<ul>';
|
|
const seenChapters = new Set();
|
|
for (const ch of chaptersWithAudio) {
|
|
if (!seenChapters.has(ch.chapter_number)) {
|
|
seenChapters.add(ch.chapter_number);
|
|
navHtml += `<li><a href="#reader-chapter-${ch.chapter_number}" data-chapter="${ch.chapter_number}">${ch.chapter_number}</a></li>`;
|
|
}
|
|
}
|
|
navHtml += '</ul>';
|
|
nav.innerHTML = navHtml;
|
|
document.body.appendChild(nav);
|
|
|
|
const container = document.getElementById('readerContainer');
|
|
container.addEventListener('click', handleReaderWordClick);
|
|
|
|
setupReaderNavObserver();
|
|
|
|
readerStarted = false;
|
|
currentReaderInstance = null;
|
|
currentReaderIndex = -1;
|
|
readerUICreated = true;
|
|
|
|
positionReaderUI();
|
|
window.addEventListener('resize', positionReaderUI);
|
|
window.addEventListener('scroll', positionReaderUI);
|
|
}
|
|
|
|
function positionReaderUI() {
|
|
const readerContainer = document.getElementById('readerContainer');
|
|
const btn = document.getElementById('reader-floating-btn');
|
|
const nav = document.getElementById('reader-chapter-nav');
|
|
|
|
if (!readerContainer || !btn || !nav) return;
|
|
|
|
const containerRect = readerContainer.getBoundingClientRect();
|
|
|
|
btn.style.position = 'fixed';
|
|
btn.style.top = '80px';
|
|
const rightPos = window.innerWidth - (containerRect.right + 8);
|
|
btn.style.right = Math.max(rightPos, 8) + 'px';
|
|
btn.style.left = 'auto';
|
|
|
|
nav.style.position = 'fixed';
|
|
nav.style.top = '50%';
|
|
nav.style.transform = 'translateY(-50%)';
|
|
const navWidth = nav.offsetWidth || 52;
|
|
const leftPos = containerRect.left - navWidth - 8;
|
|
nav.style.left = Math.max(leftPos, 8) + 'px';
|
|
}
|
|
|
|
function removeReaderUI() {
|
|
const oldBtn = document.getElementById('reader-floating-btn');
|
|
if (oldBtn) oldBtn.remove();
|
|
const oldNav = document.getElementById('reader-chapter-nav');
|
|
if (oldNav) oldNav.remove();
|
|
readerStarted = false;
|
|
currentReaderInstance = null;
|
|
currentReaderIndex = -1;
|
|
readerUICreated = false;
|
|
|
|
window.removeEventListener('resize', positionReaderUI);
|
|
window.removeEventListener('scroll', positionReaderUI);
|
|
|
|
for (const inst of readerInstances) {
|
|
if (inst.audio) {
|
|
inst.audio.pause();
|
|
inst.audio = null;
|
|
}
|
|
cancelAnimationFrame(inst.animFrameId);
|
|
}
|
|
}
|
|
|
|
function showReaderUI() {
|
|
const btn = document.getElementById('reader-floating-btn');
|
|
const nav = document.getElementById('reader-chapter-nav');
|
|
if (btn) btn.style.display = 'flex';
|
|
if (nav) nav.style.display = 'block';
|
|
positionReaderUI();
|
|
}
|
|
|
|
function hideReaderUI() {
|
|
const btn = document.getElementById('reader-floating-btn');
|
|
const nav = document.getElementById('reader-chapter-nav');
|
|
if (btn) btn.style.display = 'none';
|
|
if (nav) nav.style.display = 'none';
|
|
}
|
|
|
|
function setupReaderNavObserver() {
|
|
const chapters = document.querySelectorAll('.reader-chapter');
|
|
if (chapters.length === 0) return;
|
|
|
|
const observer = new IntersectionObserver((entries) => {
|
|
entries.forEach(entry => {
|
|
if (entry.isIntersecting) {
|
|
const chNum = entry.target.id.replace('reader-chapter-', '');
|
|
const navLinks = document.querySelectorAll('#reader-chapter-nav a');
|
|
navLinks.forEach(l => l.classList.remove('active'));
|
|
const activeLink = document.querySelector(`#reader-chapter-nav a[data-chapter="${chNum}"]`);
|
|
if (activeLink) activeLink.classList.add('active');
|
|
}
|
|
});
|
|
}, { threshold: 0.3 });
|
|
|
|
chapters.forEach(ch => observer.observe(ch));
|
|
}
|
|
|
|
// ============================================
|
|
// Playback Logic
|
|
// ============================================
|
|
|
|
function handleReaderFloatingClick() {
|
|
if (!readerStarted) {
|
|
readerStarted = true;
|
|
updateReaderButton('playing');
|
|
playReaderInstanceByIndex(findNextAudioIndex(-1));
|
|
return;
|
|
}
|
|
|
|
if (currentReaderInstance && currentReaderInstance.audio) {
|
|
if (currentReaderInstance.audio.paused) {
|
|
currentReaderInstance.audio.play();
|
|
updateReaderButton('playing');
|
|
} else {
|
|
currentReaderInstance.audio.pause();
|
|
updateReaderButton('paused');
|
|
}
|
|
} else {
|
|
playReaderInstanceByIndex(findNextAudioIndex(-1));
|
|
updateReaderButton('playing');
|
|
}
|
|
}
|
|
|
|
function handleReaderWordClick(event) {
|
|
const wordSpan = event.target.closest('.reader-word');
|
|
if (!wordSpan) return;
|
|
|
|
const readerIdx = parseInt(wordSpan.dataset.readerIndex, 10);
|
|
const wordIdx = parseInt(wordSpan.dataset.wordIdx, 10);
|
|
const inst = readerInstances[readerIdx];
|
|
|
|
if (!inst || !inst.hasAudio) return;
|
|
|
|
const aiIdx = inst.wordMap[wordIdx];
|
|
if (aiIdx === undefined) return;
|
|
|
|
const timestamp = inst.transcription[aiIdx].start;
|
|
|
|
if (currentReaderInstance && currentReaderInstance !== inst) {
|
|
stopReaderInstance(currentReaderInstance);
|
|
}
|
|
|
|
readerStarted = true;
|
|
currentReaderIndex = readerIdx;
|
|
currentReaderInstance = inst;
|
|
|
|
ensureAudioLoaded(inst);
|
|
inst.audio.currentTime = timestamp;
|
|
inst.audio.play();
|
|
updateReaderButton('playing');
|
|
startReaderHighlightLoop(inst);
|
|
}
|
|
|
|
function findNextAudioIndex(afterIndex) {
|
|
for (let i = afterIndex + 1; i < readerInstances.length; i++) {
|
|
if (readerInstances[i].hasAudio) return i;
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
function playReaderInstanceByIndex(index) {
|
|
if (index < 0 || index >= readerInstances.length) {
|
|
updateReaderButton('paused');
|
|
currentReaderInstance = null;
|
|
currentReaderIndex = -1;
|
|
return;
|
|
}
|
|
|
|
const inst = readerInstances[index];
|
|
if (!inst.hasAudio) {
|
|
playReaderInstanceByIndex(findNextAudioIndex(index));
|
|
return;
|
|
}
|
|
|
|
if (currentReaderInstance && currentReaderInstance !== inst) {
|
|
stopReaderInstance(currentReaderInstance);
|
|
}
|
|
|
|
currentReaderIndex = index;
|
|
currentReaderInstance = inst;
|
|
|
|
ensureAudioLoaded(inst);
|
|
inst.audio.currentTime = 0;
|
|
inst.audio.play();
|
|
updateReaderButton('playing');
|
|
startReaderHighlightLoop(inst);
|
|
|
|
const blockEl = document.querySelector(`.reader-block[data-reader-index="${index}"]`);
|
|
if (blockEl) {
|
|
blockEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
}
|
|
}
|
|
|
|
function ensureAudioLoaded(inst) {
|
|
if (!inst.audio) {
|
|
const blockData = inst.blockData;
|
|
if (!blockData || !blockData.audio_data) return;
|
|
|
|
const audioBlob = base64ToBlob(blockData.audio_data, `audio/${blockData.audio_format || 'mp3'}`);
|
|
const audioUrl = URL.createObjectURL(audioBlob);
|
|
inst.audio = new Audio(audioUrl);
|
|
|
|
inst.audio.addEventListener('ended', () => {
|
|
stopReaderHighlightLoop(inst);
|
|
clearReaderHighlights(inst);
|
|
const nextIdx = findNextAudioIndex(inst.index);
|
|
if (nextIdx >= 0) {
|
|
playReaderInstanceByIndex(nextIdx);
|
|
} else {
|
|
updateReaderButton('paused');
|
|
currentReaderInstance = null;
|
|
currentReaderIndex = -1;
|
|
}
|
|
});
|
|
|
|
inst.audio.addEventListener('pause', () => {
|
|
stopReaderHighlightLoop(inst);
|
|
});
|
|
|
|
inst.audio.addEventListener('play', () => {
|
|
startReaderHighlightLoop(inst);
|
|
updateReaderButton('playing');
|
|
});
|
|
}
|
|
}
|
|
|
|
function stopReaderInstance(inst) {
|
|
if (inst.audio) {
|
|
inst.audio.pause();
|
|
inst.audio.currentTime = 0;
|
|
}
|
|
stopReaderHighlightLoop(inst);
|
|
clearReaderHighlights(inst);
|
|
}
|
|
|
|
// ============================================
|
|
// Highlighting
|
|
// ============================================
|
|
|
|
function startReaderHighlightLoop(inst) {
|
|
cancelAnimationFrame(inst.animFrameId);
|
|
|
|
function loop() {
|
|
if (!inst.audio || inst.audio.paused) return;
|
|
const currentTime = inst.audio.currentTime;
|
|
|
|
const activeAiIndex = inst.transcription.findIndex(w => currentTime >= w.start && currentTime < w.end);
|
|
if (activeAiIndex !== -1) {
|
|
const activeTextIndex = inst.wordMap.findIndex(i => i === activeAiIndex);
|
|
if (activeTextIndex !== -1) {
|
|
const activeSpan = inst.wordSpans[activeTextIndex];
|
|
if (activeSpan !== inst.lastWordSpan) {
|
|
if (inst.lastWordSpan) inst.lastWordSpan.classList.remove('current-word');
|
|
activeSpan.classList.add('current-word');
|
|
|
|
const rect = activeSpan.getBoundingClientRect();
|
|
if (rect.top < window.innerHeight * 0.25 || rect.bottom > window.innerHeight * 0.75) {
|
|
activeSpan.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
}
|
|
|
|
inst.lastWordSpan = activeSpan;
|
|
}
|
|
}
|
|
}
|
|
|
|
const activeSentence = inst.sentenceData.find(s => currentTime >= s.startTime && currentTime <= s.endTime);
|
|
if (activeSentence && activeSentence.spans !== inst.lastSentenceSpans) {
|
|
if (inst.lastSentenceSpans && inst.lastSentenceSpans.length) {
|
|
inst.lastSentenceSpans.forEach(s => s.classList.remove('current-sentence-bg'));
|
|
}
|
|
activeSentence.spans.forEach(s => s.classList.add('current-sentence-bg'));
|
|
inst.lastSentenceSpans = activeSentence.spans;
|
|
}
|
|
|
|
inst.animFrameId = requestAnimationFrame(loop);
|
|
}
|
|
|
|
inst.animFrameId = requestAnimationFrame(loop);
|
|
}
|
|
|
|
function stopReaderHighlightLoop(inst) {
|
|
cancelAnimationFrame(inst.animFrameId);
|
|
}
|
|
|
|
function clearReaderHighlights(inst) {
|
|
if (inst.lastWordSpan) {
|
|
inst.lastWordSpan.classList.remove('current-word');
|
|
inst.lastWordSpan = null;
|
|
}
|
|
if (inst.lastSentenceSpans && inst.lastSentenceSpans.length) {
|
|
inst.lastSentenceSpans.forEach(s => s.classList.remove('current-sentence-bg'));
|
|
inst.lastSentenceSpans = [];
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// Button State
|
|
// ============================================
|
|
|
|
function updateReaderButton(state) {
|
|
const btn = document.getElementById('reader-floating-btn');
|
|
if (!btn) return;
|
|
|
|
const textEl = document.getElementById('reader-btn-text');
|
|
const playIcon = document.getElementById('reader-btn-play');
|
|
const pauseIcon = document.getElementById('reader-btn-pause');
|
|
|
|
if (readerStarted) {
|
|
if (textEl) textEl.style.display = 'none';
|
|
btn.classList.add('active-mode');
|
|
|
|
if (state === 'playing') {
|
|
playIcon.style.display = 'none';
|
|
pauseIcon.style.display = 'block';
|
|
} else {
|
|
playIcon.style.display = 'block';
|
|
pauseIcon.style.display = 'none';
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// Utility
|
|
// ============================================
|
|
|
|
function base64ToBlob(base64, mimeType) {
|
|
const byteCharacters = atob(base64);
|
|
const byteNumbers = new Array(byteCharacters.length);
|
|
for (let i = 0; i < byteCharacters.length; i++) {
|
|
byteNumbers[i] = byteCharacters.charCodeAt(i);
|
|
}
|
|
const byteArray = new Uint8Array(byteNumbers);
|
|
return new Blob([byteArray], { type: mimeType });
|
|
}
|
|
|
|
function addReaderStyles() {
|
|
if (document.getElementById('readerStyles')) return;
|
|
|
|
const style = document.createElement('style');
|
|
style.id = 'readerStyles';
|
|
style.textContent = `
|
|
.reader-chapter { margin-bottom: 48px; }
|
|
.reader-chapter-title {
|
|
font-family: var(--font-serif); font-size: 1.75rem; font-weight: 700;
|
|
color: var(--text-primary); margin-bottom: 24px; padding-bottom: 12px;
|
|
border-bottom: 2px solid var(--border-color);
|
|
}
|
|
.reader-block { position: relative; margin-bottom: 16px; padding: 8px 16px; border-radius: var(--border-radius-sm); transition: background 0.2s; }
|
|
.reader-content { font-family: var(--font-serif); font-size: 1.125rem; line-height: 1.8; }
|
|
.reader-content p { margin-bottom: 1em; }
|
|
.reader-content h1, .reader-content h2, .reader-content h3 { font-family: var(--font-serif); margin-top: 1.5em; margin-bottom: 0.5em; }
|
|
.reader-image-block { text-align: center; margin: 24px 0; }
|
|
.reader-image-block img {
|
|
max-width: 100%; height: auto; border-radius: 12px;
|
|
box-shadow: 0 4px 12px rgba(0,0,0,0.1); display: block; margin: 0 auto;
|
|
}
|
|
.reader-image { max-width: 100%; height: auto; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); display: block; margin: 16px auto; }
|
|
.reader-image-placeholder { text-align: center; padding: 40px; background: #f8fafc; border: 2px dashed #e2e8f0; border-radius: 12px; }
|
|
.reader-word { cursor: pointer; padding: 1px 0; border-radius: 3px; transition: background 0.15s, color 0.15s; }
|
|
.reader-word:hover { background: #e3f2fd; }
|
|
.reader-word.current-word { color: #3d4e81; text-decoration: underline; text-decoration-thickness: 3px; text-underline-offset: 3px; font-weight: 700; }
|
|
.current-sentence-bg { -webkit-box-decoration-break: clone; box-decoration-break: clone; background-color: #e0e7ff; padding: 0.1em 0.2em; margin: 0 -0.15em; border-radius: 6px; }
|
|
#reader-floating-btn {
|
|
position: fixed; top: 80px; right: 24px; height: 56px; min-width: 56px; padding: 0 20px;
|
|
border-radius: 28px; background: linear-gradient(135deg, var(--primary-color) 0%, #7c3aed 100%);
|
|
border: none; color: white; box-shadow: 0 4px 15px rgba(0,0,0,0.25);
|
|
display: flex; align-items: center; justify-content: center; gap: 8px; cursor: pointer;
|
|
z-index: 1050; transition: transform 0.2s, box-shadow 0.2s;
|
|
font-family: var(--font-sans); font-weight: 600; font-size: 1rem;
|
|
}
|
|
#reader-floating-btn:hover { transform: scale(1.05); box-shadow: 0 6px 20px rgba(0,0,0,0.3); }
|
|
#reader-floating-btn:active { transform: scale(0.95); }
|
|
#reader-floating-btn.active-mode { width: 56px; padding: 0; border-radius: 50%; }
|
|
#reader-floating-btn.active-mode #reader-btn-text { display: none; }
|
|
#reader-chapter-nav {
|
|
position: fixed; top: 50%; transform: translateY(-50%);
|
|
background: rgba(255,255,255,0.9); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
|
|
border-radius: 20px; padding: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
|
z-index: 1000; max-height: 70vh; overflow-y: auto;
|
|
}
|
|
#reader-chapter-nav ul { list-style: none; padding: 0; margin: 0; }
|
|
#reader-chapter-nav a {
|
|
display: flex; align-items: center; justify-content: center; width: 36px; height: 36px;
|
|
margin: 4px 0; border-radius: 50%; text-decoration: none; color: var(--text-secondary);
|
|
font-family: var(--font-sans); font-weight: 600; font-size: 0.85rem; transition: all 0.2s;
|
|
}
|
|
#reader-chapter-nav a:hover { background: #e0e7ff; color: var(--primary-color); }
|
|
#reader-chapter-nav a.active { background: linear-gradient(135deg, var(--primary-color) 0%, #7c3aed 100%); color: white; transform: scale(1.1); }
|
|
@media (max-width: 768px) {
|
|
#reader-chapter-nav { display: none !important; }
|
|
#reader-floating-btn { top: auto; bottom: 20px; right: 20px !important; left: auto !important; }
|
|
}
|
|
`;
|
|
document.head.appendChild(style);
|
|
}
|