/** * Interactive Reader Module — Lazy Audio Loading (v4) * * Strategy: * - Text + transcription are already loaded (from editorBlocks in memory). * - Audio is fetched on-demand from /api/projects//audio/ * when the user wants to play that block. * - Smart preload: at 70% of current block, fetch next block's audio. * - Memory cap: keep at most MAX_AUDIO_LOADED blob URLs alive. */ // ============================================ // Reader State // ============================================ let readerInstances = []; let currentReaderInstance = null; let currentReaderIndex = -1; let readerStarted = false; let readerUICreated = false; // Tunables const READER_PRELOAD_AHEAD = 2; const READER_MID_PRELOAD_THRESHOLD = 0.7; const READER_MAX_AUDIO_LOADED = 5; const READER_KEEP_BEHIND = 1; // ============================================ // Render Reader // ============================================ function renderInteractiveReader() { const container = document.getElementById('readerContainer'); if (container.style.maxWidth) container.style.maxWidth = ''; const chapters = collectEditorContent(); let hasAudio = false; const allBlocks = []; let outlineHtml = ''; let currentIndex = 0; for (const chapter of chapters) { if (chapter.blocks.length === 0) continue; outlineHtml += `
  • ${escapeHtml(chapter.title)}
  • `; let isFirstBlockOfChapter = true; for (const block of chapter.blocks) { const blockData = findEditorBlockForContent(block); const isImageBlock = block.block_type === 'image' || (block.content && block.content.trim().startsWith('![') && block.content.trim().includes('](')); allBlocks.push({ ...block, _editorData: blockData || null, _isImage: isImageBlock, _chapterTitle: isFirstBlockOfChapter ? chapter.title : null }); isFirstBlockOfChapter = false; // has_audio comes from server; audio_data may not yet be loaded if (!isImageBlock && blockData && (blockData.audio_data || blockData.has_audio)) { hasAudio = true; } currentIndex++; } } const readerOutlineSidebar = document.getElementById('readerOutlineSidebar'); const readerOutlineList = document.getElementById('readerOutlineList'); if (!hasAudio) { container.innerHTML = `

    Generate audio to view the interactive reader

    Go to the Editor tab and click "Generate" on the panel

    `; removeReaderUI(); if (readerOutlineSidebar) readerOutlineSidebar.style.display = 'none'; return; } if (readerOutlineSidebar && readerOutlineList) { readerOutlineSidebar.style.display = 'block'; readerOutlineList.innerHTML = outlineHtml || '
  • No sections found.
  • '; } let html = '
    '; cleanupAllReaderInstances(); readerInstances = []; let globalBlockIndex = 0; for (const block of allBlocks) { const blockData = block._editorData; const isImageBlock = block._isImage; // has_audio is the SOURCE OF TRUTH for whether this block has audio on server const hasBlockAudio = !isImageBlock && blockData && (blockData.audio_data || blockData.has_audio); const blockId = blockData ? blockData.id : `reader_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`; html += `
    `; if (isImageBlock) { const imageHtml = buildImageHtml(block, blockData); html += `
    ${imageHtml}
    `; } else { const blockImages = getBlockImages(block, blockData); for (const img of blockImages) { if (img.position === 'before' && img.data) { html += `
    ${img.alt_text || 'Image'}
    `; } } html += `
    `; for (const img of blockImages) { if (img.position === 'after' && img.data) { html += `
    ${img.alt_text || 'Image'}
    `; } } } html += `
    `; readerInstances.push({ index: globalBlockIndex, blockId: blockId, blockData: blockData, content: block.content, hasAudio: !!hasBlockAudio, isImage: isImageBlock, wordSpans: [], wordMap: [], sentenceData: [], audio: null, audioUrl: null, audioReady: false, audioLoadingPromise: null, midPreloadTriggered: false, transcription: (!isImageBlock && blockData) ? (blockData.transcription || []) : [], animFrameId: null, lastWordSpan: null, lastSentenceSpans: [] }); globalBlockIndex++; } html += '
    '; 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(); } // ============================================ // Image Resolution Helpers // ============================================ function findEditorBlockForContent(block) { 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; } } } for (const eb of editorBlocks) { if (eb.content === block.content) return eb; } return null; } function getBlockImages(block, blockData) { 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; } 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 []; } function buildImageHtml(block, blockData) { if (block.images && block.images.length > 0) { let html = ''; for (const img of block.images) { if (img.data && img.data.length > 0) { html += `${img.alt_text || 'Image'}`; } } if (html) return html; } 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.alt_text || 'Image'}`; } } if (html) return html; } if (block.content) { const dataUriMatch = block.content.match(/!\[([^\]]*)\]\((data:image\/[^)]+)\)/); if (dataUriMatch) { return `${dataUriMatch[1] || 'Image'}`; } } 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 `Image`; } } } 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.alt_text || 'Image'}`; } } if (html) return html; } } } return `

    Image not available

    `; } // ============================================ // 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() { removeReaderUI(); const btn = document.createElement('button'); btn.id = 'reader-floating-btn'; btn.innerHTML = ` Start `; document.body.appendChild(btn); btn.addEventListener('click', handleReaderFloatingClick); const container = document.getElementById('readerContainer'); container.addEventListener('click', handleReaderWordClick); 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'); if (!readerContainer || !btn) 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'; } function removeReaderUI() { const oldBtn = document.getElementById('reader-floating-btn'); if (oldBtn) oldBtn.remove(); readerStarted = false; currentReaderInstance = null; currentReaderIndex = -1; readerUICreated = false; window.removeEventListener('resize', positionReaderUI); window.removeEventListener('scroll', positionReaderUI); cleanupAllReaderInstances(); } function cleanupAllReaderInstances() { for (const inst of readerInstances) { if (inst.audio) { try { inst.audio.pause(); } catch (e) {} inst.audio = null; } if (inst.audioUrl) { try { URL.revokeObjectURL(inst.audioUrl); } catch (e) {} inst.audioUrl = null; } inst.audioReady = false; inst.audioLoadingPromise = null; if (inst.animFrameId) cancelAnimationFrame(inst.animFrameId); } } function showReaderUI() { const btn = document.getElementById('reader-floating-btn'); if (btn) btn.style.display = 'flex'; positionReaderUI(); } function hideReaderUI() { const btn = document.getElementById('reader-floating-btn'); if (btn) btn.style.display = 'none'; } function setReaderButtonLoading(isLoading) { const btn = document.getElementById('reader-floating-btn'); if (!btn) return; btn.classList.toggle('loading', isLoading); const spinner = document.getElementById('reader-btn-spinner'); if (spinner) spinner.style.display = isLoading ? 'block' : 'none'; } // ============================================ // Audio Lazy Loading // ============================================ /** * Fetch audio for an instance. If already loaded into editorBlocks * by background loader, use that. Otherwise fetch from API directly. */ async function fetchAudioForInstance(inst) { // Path 1: audio_data already in editorBlocks (loaded in background) if (inst.blockData && inst.blockData.audio_data) { return { audio_data: inst.blockData.audio_data, audio_format: inst.blockData.audio_format || 'mp3' }; } // Path 2: fetch from API if (!inst.blockData || !inst.blockData.db_id || !currentProject || !currentProject.id) { throw new Error('Cannot fetch audio: missing block info'); } const resp = await fetch(`/api/projects/${currentProject.id}/audio/${inst.blockData.db_id}`); const data = await resp.json(); if (data.error || !data.audio_data) { throw new Error(data.error || 'No audio data'); } // Cache into editorBlocks for future use inst.blockData.audio_data = data.audio_data; inst.blockData.audio_format = data.audio_format; return data; } function ensureReaderAudioLoaded(inst) { if (inst.audioReady && inst.audio) return Promise.resolve(inst); if (inst.audioLoadingPromise) return inst.audioLoadingPromise; inst.audioLoadingPromise = (async () => { const audioInfo = await fetchAudioForInstance(inst); const audioBlob = base64ToBlob(audioInfo.audio_data, `audio/${audioInfo.audio_format || 'mp3'}`); const audioUrl = URL.createObjectURL(audioBlob); const audio = new Audio(audioUrl); return new Promise((resolve, reject) => { const onCanPlay = () => { audio.removeEventListener('error', onError); inst.audio = audio; inst.audioUrl = audioUrl; inst.audioReady = true; wireReaderAudioEvents(inst); resolve(inst); }; const onError = () => { audio.removeEventListener('canplay', onCanPlay); try { URL.revokeObjectURL(audioUrl); } catch (e) {} inst.audioLoadingPromise = null; reject(new Error('Audio failed to load')); }; audio.addEventListener('canplay', onCanPlay, { once: true }); audio.addEventListener('error', onError, { once: true }); audio.preload = 'auto'; audio.load(); }); })().catch(err => { inst.audioLoadingPromise = null; throw err; }); return inst.audioLoadingPromise; } function wireReaderAudioEvents(inst) { const audio = inst.audio; audio.addEventListener('play', () => { startReaderHighlightLoop(inst); updateReaderButton('playing'); }); audio.addEventListener('pause', () => { stopReaderHighlightLoop(inst); updateReaderButton('paused'); }); audio.addEventListener('ended', () => { stopReaderHighlightLoop(inst); clearReaderHighlights(inst); const nextIdx = findNextAudioIndex(inst.index); if (nextIdx >= 0) { playReaderInstanceByIndex(nextIdx, { autoAdvance: true }); } else { updateReaderButton('paused'); currentReaderInstance = null; currentReaderIndex = -1; } }); audio.addEventListener('timeupdate', () => { if (inst.midPreloadTriggered) return; if (!audio.duration || isNaN(audio.duration)) return; if ((audio.currentTime / audio.duration) >= READER_MID_PRELOAD_THRESHOLD) { inst.midPreloadTriggered = true; const nextIdx = findNextAudioIndex(inst.index); if (nextIdx >= 0) { ensureReaderAudioLoaded(readerInstances[nextIdx]).catch(() => {}); } } }); } function preloadReaderAhead(fromIndex) { let preloadedCount = 0; let idx = fromIndex + 1; while (idx < readerInstances.length && preloadedCount < READER_PRELOAD_AHEAD) { const inst = readerInstances[idx]; if (inst.hasAudio) { ensureReaderAudioLoaded(inst).catch(() => {}); preloadedCount++; } idx++; } } function pruneReaderLoadedAudio(currentIndex) { const loaded = readerInstances.filter(i => i.audioReady && i.audio); if (loaded.length <= READER_MAX_AUDIO_LOADED) return; const keepLow = currentIndex - READER_KEEP_BEHIND; const keepHigh = currentIndex + READER_PRELOAD_AHEAD; const candidates = loaded .filter(inst => inst !== currentReaderInstance) .map(inst => ({ inst, inWindow: inst.index >= keepLow && inst.index <= keepHigh, distance: Math.abs(inst.index - currentIndex) })) .sort((a, b) => { if (a.inWindow !== b.inWindow) return a.inWindow ? 1 : -1; return b.distance - a.distance; }); let toEvict = loaded.length - READER_MAX_AUDIO_LOADED; for (const c of candidates) { if (toEvict <= 0) break; releaseReaderAudio(c.inst); toEvict--; } } function releaseReaderAudio(inst) { if (!inst.audio) return; try { inst.audio.pause(); } catch (e) {} if (inst.audioUrl) { try { URL.revokeObjectURL(inst.audioUrl); } catch (e) {} inst.audioUrl = null; } inst.audio = null; inst.audioReady = false; inst.audioLoadingPromise = null; inst.midPreloadTriggered = false; } // ============================================ // Playback & Navigation // ============================================ function handleReaderFloatingClick() { if (!readerStarted) { readerStarted = true; const firstIdx = findNextAudioIndex(-1); if (firstIdx >= 0) playReaderInstanceByIndex(firstIdx); return; } if (currentReaderInstance && currentReaderInstance.audio) { if (currentReaderInstance.audio.paused) { currentReaderInstance.audio.play().catch(console.error); updateReaderButton('playing'); } else { currentReaderInstance.audio.pause(); updateReaderButton('paused'); } } else { const firstIdx = findNextAudioIndex(-1); if (firstIdx >= 0) playReaderInstanceByIndex(firstIdx); } } async 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; playReaderInstanceByIndex(readerIdx, { timestamp }); } function findNextAudioIndex(afterIndex) { for (let i = afterIndex + 1; i < readerInstances.length; i++) { if (readerInstances[i].hasAudio) return i; } return -1; } async function playReaderInstanceByIndex(index, opts = {}) { if (index < 0 || index >= readerInstances.length) { updateReaderButton('paused'); currentReaderInstance = null; currentReaderIndex = -1; return; } const inst = readerInstances[index]; if (!inst.hasAudio) { playReaderInstanceByIndex(findNextAudioIndex(index), opts); return; } const isAutoAdvance = opts.autoAdvance === true; const timestamp = opts.timestamp != null ? opts.timestamp : 0; if (currentReaderInstance && currentReaderInstance !== inst) { stopReaderInstance(currentReaderInstance); } readerStarted = true; currentReaderIndex = index; currentReaderInstance = inst; inst.midPreloadTriggered = false; const needsLoad = !inst.audioReady; if (needsLoad) setReaderButtonLoading(true); try { await ensureReaderAudioLoaded(inst); if (needsLoad) setReaderButtonLoading(false); inst.audio.currentTime = timestamp; await inst.audio.play(); updateReaderButton('playing'); if (!isAutoAdvance) { const blockEl = document.querySelector(`.reader-block[data-reader-index="${index}"]`); if (blockEl) { const rect = blockEl.getBoundingClientRect(); if (rect.top < 0 || rect.top > window.innerHeight * 0.6) { blockEl.scrollIntoView({ behavior: 'smooth', block: 'center' }); } } } preloadReaderAhead(index); pruneReaderLoadedAudio(index); } catch (err) { console.error('Reader playback failed:', err); setReaderButtonLoading(false); updateReaderButton('paused'); if (typeof showNotification === 'function') { showNotification('Failed to load audio. Tap again to retry.', 'warning'); } } } function stopReaderInstance(inst) { if (inst.audio) { try { inst.audio.pause(); inst.audio.currentTime = 0; } catch (e) {} } stopReaderHighlightLoop(inst); clearReaderHighlights(inst); } function scrollToReaderBlock(index) { const blockEl = document.querySelector(`.reader-block[data-reader-index="${index}"]`); if (blockEl) { const headerOffset = 100; const elementPosition = blockEl.getBoundingClientRect().top; const offsetPosition = elementPosition + window.pageYOffset - headerOffset; window.scrollTo({ top: offsetPosition, behavior: 'smooth' }); blockEl.classList.add('highlight-section'); setTimeout(() => blockEl.classList.remove('highlight-section'), 2000); } } // ============================================ // 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.2 || rect.bottom > window.innerHeight * 0.8) { 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 (btn.classList.contains('loading')) return; 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 = ` @keyframes readerSpin { to { transform: rotate(360deg); } } #readerContainer { flex: 1; background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: var(--border-radius); min-height: 500px; padding: 24px 48px !important; position: relative; max-width: 900px !important; margin: 0 auto; } @media (max-width: 768px) { #readerContainer { padding: 16px 24px !important; } } .reader-flow { margin-bottom: 48px; } .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-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, background 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-floating-btn.loading { background: linear-gradient(135deg, #6b7280, #9ca3af); cursor: wait; } #reader-floating-btn.loading #reader-btn-text, #reader-floating-btn.loading #reader-btn-play, #reader-floating-btn.loading #reader-btn-pause { display: none !important; } .reader-btn-spinner { width: 24px; height: 24px; border: 3px solid rgba(255,255,255,0.3); border-top-color: white; border-radius: 50%; animation: readerSpin 0.8s linear infinite; } @media (max-width: 768px) { #reader-floating-btn { top: auto; bottom: 20px; right: 20px !important; left: auto !important; } } .highlight-section { animation: highlightPulse 2s ease-out; } @keyframes highlightPulse { 0% { background-color: rgba(79, 70, 229, 0.15); border-left: 4px solid #4f46e5; border-radius: var(--border-radius-sm); } 100% { background-color: transparent; border-left: 4px solid transparent; border-radius: var(--border-radius-sm); } } `; document.head.appendChild(style); }