Browse Source

高亮跟随播放功能

sequoia00 1 month ago
parent
commit
526484dbff
1 changed files with 246 additions and 34 deletions
  1. 246 34
      static/web/viewer.html

+ 246 - 34
static/web/viewer.html

@@ -489,10 +489,17 @@
             background: #f8fafc;
         }
 
-        /* 音频播放器 */
-        #audio-player {
-            display: none; /* 隐藏默认音频控件,改用自定义按钮 */
-        }
+        /* 音频播放器 */
+        #audio-player {
+            display: none; /* 隐藏默认音频控件,改用自定义按钮 */
+        }
+
+        .tts-progress-highlight {
+            --highlight-bg-color: rgba(125, 190, 255, 0.16);
+            --highlight-selected-bg-color: rgba(125, 190, 255, 0.16);
+            background-color: rgba(125, 190, 255, 0.16) !important;
+            pointer-events: none;
+        }
 
         /* 目录分页样式 */
         #pdfList {
@@ -2097,10 +2104,13 @@
                 // 语音控制
                 let isListening = false;
                 let audioContext = null;
-                let isPaused = false;
-                let currentAudio = null;
-                let activePlayController = null;
-                let activeStreamReader = null;
+                let isPaused = false;
+                let currentAudio = null;
+                let activePlayController = null;
+                let activeStreamReader = null;
+                let playbackHighlightContext = null;
+                let activeHighlightNodes = [];
+                let activeHighlightFrame = null;
 
                 // 初始化 AudioContext
                 function initAudioContext() {
@@ -2179,9 +2189,11 @@
                     pausePlayButton.classList.remove('is-active');
                     pausePlayButton.title = '暂停播放';
                     pausePlayButton.setAttribute('aria-label', '暂停播放');
-                    if (showTip) {
-                        const loadingIndicator = document.getElementById('loading-indicator');
-                        loadingIndicator.textContent = '已停止';
+                    clearActiveTtsHighlight();
+                    playbackHighlightContext = null;
+                    if (showTip) {
+                        const loadingIndicator = document.getElementById('loading-indicator');
+                        loadingIndicator.textContent = '已停止';
                         loadingIndicator.style.display = 'block';
                         setTimeout(() => {
                             loadingIndicator.style.display = 'none';
@@ -2208,9 +2220,9 @@
                     return count;
                 }
 
-                function findNthOccurrence(haystack, needle, n) {
-                    if (!haystack || !needle || !n || n < 1) return -1;
-                    let pos = 0;
+                function findNthOccurrence(haystack, needle, n) {
+                    if (!haystack || !needle || !n || n < 1) return -1;
+                    let pos = 0;
                     let seen = 0;
                     const source = haystack.toLowerCase();
                     const target = needle.toLowerCase();
@@ -2219,9 +2231,202 @@
                         if (idx === -1) return -1;
                         seen++;
                         if (seen === n) return idx;
-                        pos = idx + target.length;
-                    }
-                }
+                        pos = idx + target.length;
+                    }
+                }
+
+                function normalizeForTtsMatch(text) {
+                    return (text || '')
+                        .replace(/\s+/g, ' ')
+                        .replace(/([A-Za-z0-9])\s*\.\s*([A-Za-z0-9])/g, '$1.$2')
+                        .trim();
+                }
+
+                function splitTextForHighlight(text) {
+                    const normalized = normalizeForTtsMatch(text);
+                    if (!normalized) return [];
+                    return normalized
+                        .split(/(?<=[。!?!?;;…\.])\s+/)
+                        .map(item => item.trim())
+                        .filter(Boolean);
+                }
+
+                function clearActiveTtsHighlight() {
+                    if (activeHighlightFrame) {
+                        cancelAnimationFrame(activeHighlightFrame);
+                        activeHighlightFrame = null;
+                    }
+                    for (const node of activeHighlightNodes) {
+                        node.remove();
+                    }
+                    activeHighlightNodes = [];
+                }
+
+                function getCurrentPageTextLayer() {
+                    const { pdfViewer } = PDFViewerApplication || {};
+                    const currentPageNumber = pdfViewer?.currentPageNumber;
+                    if (!currentPageNumber) return null;
+                    return document.querySelector(`.page[data-page-number="${currentPageNumber}"] .textLayer`);
+                }
+
+                function buildTextLayerIndex(textLayer) {
+                    if (!textLayer) return null;
+                    const spans = Array.from(textLayer.querySelectorAll('span')).filter(span => {
+                        return !span.classList.contains('endOfContent') && span.textContent && span.textContent.trim();
+                    });
+                    if (spans.length === 0) return null;
+
+                    const entries = [];
+                    let fullText = '';
+                    for (const span of spans) {
+                        const spanText = span.textContent || '';
+                        const normalizedText = normalizeForTtsMatch(spanText);
+                        if (!normalizedText) continue;
+                        if (fullText && !/\s$/.test(fullText)) {
+                            fullText += ' ';
+                        }
+                        const start = fullText.length;
+                        fullText += normalizedText;
+                        entries.push({
+                            span,
+                            rawText: spanText,
+                            normalizedText,
+                            start,
+                            end: fullText.length
+                        });
+                    }
+
+                    if (!entries.length || !fullText) return null;
+                    return { textLayer, text: fullText, entries };
+                }
+
+                function buildPlaybackHighlightContext(fullText) {
+                    const textLayer = getCurrentPageTextLayer();
+                    const textLayerIndex = buildTextLayerIndex(textLayer);
+                    if (!textLayerIndex) return null;
+                    return {
+                        textLayer,
+                        textLayerIndex,
+                        sentences: splitTextForHighlight(fullText),
+                        searchOffset: 0,
+                        lastWindowKey: '',
+                        currentRange: null
+                    };
+                }
+
+                function updateHighlightWindow(rangeStart, rangeEnd) {
+                    const ctx = playbackHighlightContext;
+                    if (!ctx?.textLayerIndex) return;
+                    const layerText = ctx.textLayerIndex.text;
+                    const safeStart = Math.max(0, Math.min(rangeStart, layerText.length));
+                    const safeEnd = Math.max(safeStart, Math.min(rangeEnd, layerText.length));
+                    const windowKey = `${safeStart}:${safeEnd}`;
+                    if (ctx.lastWindowKey === windowKey) return;
+                    ctx.lastWindowKey = windowKey;
+
+                    for (const node of activeHighlightNodes) {
+                        node.remove();
+                    }
+                    activeHighlightNodes = [];
+
+                    for (const entry of ctx.textLayerIndex.entries) {
+                        if (entry.end <= safeStart) continue;
+                        if (entry.start >= safeEnd) break;
+
+                        const pageRect = ctx.textLayer.getBoundingClientRect();
+                        const spanRect = entry.span.getBoundingClientRect();
+                        const overlapStart = Math.max(safeStart, entry.start);
+                        const overlapEnd = Math.min(safeEnd, entry.end);
+                        if (overlapEnd <= overlapStart) continue;
+
+                        const entryLength = Math.max(1, entry.end - entry.start);
+                        const startRatio = (overlapStart - entry.start) / entryLength;
+                        const endRatio = (overlapEnd - entry.start) / entryLength;
+                        const clippedLeft = spanRect.width * startRatio;
+                        const clippedWidth = Math.max(2, spanRect.width * (endRatio - startRatio));
+                        const left = spanRect.left - pageRect.left + clippedLeft;
+                        const top = spanRect.top - pageRect.top;
+                        const width = Math.min(spanRect.width - clippedLeft, clippedWidth);
+                        const height = spanRect.height;
+                        if (width <= 0 || height <= 0) continue;
+
+                        const overlay = document.createElement('div');
+                        overlay.className = 'highlight appended tts-progress-highlight';
+                        overlay.style.left = `${left}px`;
+                        overlay.style.top = `${top}px`;
+                        overlay.style.width = `${width}px`;
+                        overlay.style.height = `${height}px`;
+                        overlay.style.position = 'absolute';
+                        ctx.textLayer.appendChild(overlay);
+                        activeHighlightNodes.push(overlay);
+                    }
+                }
+
+                function highlightSentenceAtIndex(sentenceIndex, sentenceText = '', audio = null) {
+                    clearActiveTtsHighlight();
+                    const ctx = playbackHighlightContext;
+                    if (!ctx?.textLayerIndex) return;
+
+                    const targetSentence = normalizeForTtsMatch(sentenceText || ctx.sentences?.[sentenceIndex] || '');
+                    if (!targetSentence) return;
+
+                    const layerText = ctx.textLayerIndex.text;
+                    const startSearch = Math.max(0, ctx.searchOffset || 0);
+                    let matchStart = layerText.indexOf(targetSentence, startSearch);
+                    if (matchStart === -1) {
+                        matchStart = layerText.indexOf(targetSentence);
+                    }
+                    if (matchStart === -1) return;
+
+                    const matchEnd = matchStart + targetSentence.length;
+                    ctx.searchOffset = matchEnd;
+                    ctx.currentRange = { start: matchStart, end: matchEnd };
+                    ctx.lastWindowKey = '';
+
+                    const words = targetSentence.match(/\S+/g) || [];
+                    const totalWords = words.length;
+                    const windowWordCount = Math.min(5, Math.max(1, totalWords));
+                    const wordRanges = [];
+                    let searchFrom = matchStart;
+                    for (const word of words) {
+                        const wordStart = layerText.indexOf(word, searchFrom);
+                        if (wordStart === -1) continue;
+                        const wordEnd = wordStart + word.length;
+                        wordRanges.push({ start: wordStart, end: wordEnd });
+                        searchFrom = wordEnd;
+                    }
+
+                    if (wordRanges.length === 0) {
+                        updateHighlightWindow(matchStart, Math.min(matchEnd, matchStart + 24));
+                        return;
+                    }
+
+                    const applyProgressWindow = () => {
+                        if (!audio || !Number.isFinite(audio.duration) || audio.duration <= 0) {
+                            updateHighlightWindow(wordRanges[0].start, wordRanges[Math.min(windowWordCount - 1, wordRanges.length - 1)].end);
+                            return;
+                        }
+                        const progress = Math.min(1, Math.max(0, audio.currentTime / audio.duration));
+                        const maxOffset = Math.max(0, wordRanges.length - windowWordCount);
+                        const offset = Math.min(maxOffset, Math.round(progress * maxOffset));
+                        const startWord = wordRanges[offset];
+                        const endWord = wordRanges[Math.min(wordRanges.length - 1, offset + windowWordCount - 1)];
+                        updateHighlightWindow(startWord.start, endWord.end);
+                    };
+
+                    applyProgressWindow();
+                    const runHighlightFrame = () => {
+                        if (!audio || audio.ended) {
+                            activeHighlightFrame = null;
+                            return;
+                        }
+                        if (!audio.paused) {
+                            applyProgressWindow();
+                        }
+                        activeHighlightFrame = requestAnimationFrame(runHighlightFrame);
+                    };
+                    activeHighlightFrame = requestAnimationFrame(runHighlightFrame);
+                }
 
                 // 仅识别 PDF 页面文字层里的选择,并记录是该页第几次出现
                 function getSelectedContextOnPage() {
@@ -2294,6 +2499,7 @@
 
                             try {
                                 if (signal.aborted) break;
+                                highlightSentenceAtIndex(chunk.index, chunk.text || chunk.sentence || chunk.chunk || '', audio);
                                 loadingIndicator.textContent = `正在播放第 ${chunk.index + 1} 句`;
                                 loadingIndicator.style.display = 'block';
                                 await playAudio(audio);
@@ -2333,6 +2539,7 @@
                             currentAudio.load();
                             currentAudio = null;
                         }
+                        clearActiveTtsHighlight();
                     }
                 }
 
@@ -2383,13 +2590,14 @@
                 }
 
                 // 开始转换(同域代理,保持流式逐句播放)
-                async function startConversion(fullText) {
-                    const loadingIndicator = document.getElementById('loading-indicator');
-                    try {
-                        stopCurrentPlayback(false);
-                        const response = await fetch('/generate', {
-                            method: 'POST',
-                            headers: { 'Content-Type': 'application/json' },
+                async function startConversion(fullText) {
+                    const loadingIndicator = document.getElementById('loading-indicator');
+                    try {
+                        stopCurrentPlayback(false);
+                        playbackHighlightContext = buildPlaybackHighlightContext(fullText);
+                        const response = await fetch('/generate', {
+                            method: 'POST',
+                            headers: { 'Content-Type': 'application/json' },
                             body: JSON.stringify({ user_input: fullText, voice: "af_heart", speed: 1.0 })
                         });
 
@@ -2432,16 +2640,20 @@
                                 insertInOrder(audioQueue, createAudioChunk(data));
                             }
                         }
-
-                        audioQueue.push(null);
-                        await playPromise;
-                        activePlayController = null;
-                        activeStreamReader = null;
-
-                    } catch (error) {
-                        activePlayController = null;
-                        activeStreamReader = null;
-                        if (error.name === 'AbortError') {
+
+                        audioQueue.push(null);
+                        await playPromise;
+                        clearActiveTtsHighlight();
+                        activePlayController = null;
+                        activeStreamReader = null;
+                        playbackHighlightContext = null;
+
+                    } catch (error) {
+                        clearActiveTtsHighlight();
+                        playbackHighlightContext = null;
+                        activePlayController = null;
+                        activeStreamReader = null;
+                        if (error.name === 'AbortError') {
                             return;
                         }
                         console.error(error);