document.addEventListener("DOMContentLoaded", () => { const modeBtns = { page: document.getElementById("mode-page"), paragraph: document.getElementById("mode-paragraph"), selection: document.getElementById("mode-selection"), }; const playBtn = document.getElementById("play-btn"); const grammarBtn = document.getElementById("grammar-btn"); const stopBtn = document.getElementById("stop-btn"); const closeBtn = document.getElementById("close-btn"); const statusDiv = document.getElementById("status"); const speedBtn = document.getElementById("speed-btn"); let currentMode = "paragraph"; let currentSpeed = 1.0; const speedOptions = [1.0, 1.2, 1.5, 2.0]; let audioContext = null; let currentAudio = null; let playController = null; let isPaused = false; let isPlaying = false; function initAudioContext() { if (!audioContext) { audioContext = new (window.AudioContext || window.webkitAudioContext)(); } } function setMode(mode) { currentMode = mode; Object.entries(modeBtns).forEach(([key, btn]) => { btn.classList.toggle("active", key === mode); }); updateStatus(`Mode: ${mode}`); } function setSpeed(speed) { currentSpeed = speed; speedBtn.textContent = speed === Math.floor(speed) ? `${speed.toFixed(0)}x` : `${speed}x`; } function updatePlayButton() { if (isPaused) { playBtn.textContent = "▶"; } else if (isPlaying) { playBtn.textContent = "⏸"; } else { playBtn.textContent = "▶"; } } function updateStatus(message) { statusDiv.textContent = message; } function handleError(error) { console.error("Error:", error); const message = error?.message || "Unknown error"; isPaused = false; isPlaying = false; updatePlayButton(); updateStatus(`Error: ${message.replace(/^Error:\s*/i, "")}`); } // ---------- Mode Switching ---------- Object.entries(modeBtns).forEach(([key, btn]) => { btn.addEventListener("click", () => setMode(key)); }); // ---------- Close ---------- closeBtn.addEventListener("click", async () => { try { const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); if (tab?.id) { chrome.tabs.sendMessage(tab.id, { action: "deactivatePlayer" }).catch(() => {}); } } catch (e) { console.error(e); } }); // ---------- Speed ---------- speedBtn.addEventListener("click", () => { const idx = speedOptions.indexOf(currentSpeed); const next = speedOptions[(idx + 1) % speedOptions.length]; setSpeed(next); }); // ---------- Play / Pause / Resume ---------- playBtn.addEventListener("click", async () => { try { if (isPaused && currentAudio) { const resumed = await resumeCurrentAudio(); if (!resumed) { updateStatus("Nothing to resume"); } return; } if (isPlaying && currentAudio && !currentAudio.paused) { const paused = pauseCurrentAudio(); if (!paused) { updateStatus("Nothing is playing"); } return; } initAudioContext(); await getPlayPermission(); const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); if (!tab?.id) { updateStatus("Unable to access current tab"); return; } let payload = null; if (currentMode === "selection") { const selectedText = await extractSelectedText(tab.id); if (!selectedText) { updateStatus("No text is selected"); return; } payload = { text: selectedText, scope: "selection" }; } else if (currentMode === "paragraph") { updateStatus("Finding paragraph near cursor..."); const paragraph = await extractContextualParagraph(tab.id); if (!paragraph?.text) { updateStatus("No readable paragraph detected"); return; } payload = { ...paragraph, scope: "paragraph" }; } else { updateStatus("Scanning article body..."); const article = await extractArticleContent(tab.id); if (!article?.text) { updateStatus("Could not identify main content"); return; } payload = { ...article, scope: "page" }; } await startConversion(payload); } catch (error) { handleError(error); } }); // ---------- Grammar ---------- grammarBtn.addEventListener("click", async () => { try { const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); if (!tab?.id) { updateStatus("Unable to access current tab"); return; } updateStatus("Analyzing grammar..."); grammarBtn.disabled = true; const response = await new Promise((resolve) => { chrome.tabs.sendMessage( tab.id, { type: "EXECUTE_GRAMMAR", mode: currentMode }, (res) => { if (chrome.runtime.lastError) { resolve({ error: chrome.runtime.lastError.message }); return; } resolve(res || { error: "No response" }); } ); }); grammarBtn.disabled = false; if (response.error) { updateStatus(`Grammar error: ${response.error}`); } else { updateStatus("Grammar highlighted!"); } } catch (error) { grammarBtn.disabled = false; handleError(error); } }); // ---------- Stop ---------- stopBtn.addEventListener("click", () => { stopPlayback(); updateStatus("Playback stopped"); }); // ---------- Text Extraction ---------- async function extractSelectedText(tabId) { return new Promise((resolve) => { chrome.scripting.executeScript( { target: { tabId }, function: () => { const selection = window.getSelection(); return selection ? selection.toString().trim() : ""; } }, (results) => resolve(results?.[0]?.result || "") ); }); } async function extractContextualParagraph(tabId) { return new Promise((resolve) => { chrome.scripting.executeScript( { target: { tabId }, function: () => { const adLikePattern = /(advert|广告|推广|sponsored|subscribe|buy now|click (here|to)|share this|follow us|免责声明|copyright)/i; const minLength = 40; function findParagraphElement(node) { const blockTags = ["P", "DIV", "ARTICLE", "SECTION", "LI", "BLOCKQUOTE"]; while (node && node !== document.body) { if (node.nodeType === Node.ELEMENT_NODE && blockTags.includes(node.nodeName)) { return node; } node = node.parentElement; } return null; } function isVisible(el) { if (!el || el.nodeType !== Node.ELEMENT_NODE) return false; const rect = el.getBoundingClientRect(); if (!rect || rect.width === 0 || rect.height === 0) return false; const style = window.getComputedStyle(el); return style.display !== "none" && style.visibility !== "hidden" && parseFloat(style.opacity) > 0; } function cleanText(value) { return (value || "").replace(/\s+/g, " ").trim(); } const selection = window.getSelection(); const anchorNode = selection ? selection.anchorNode : null; if (anchorNode) { const candidate = findParagraphElement(anchorNode); if (candidate && isVisible(candidate)) { const text = cleanText(candidate.innerText); if (text.length >= minLength && !adLikePattern.test(text)) { return { text }; } } } const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT, { acceptNode: (node) => { if (!isVisible(node)) return NodeFilter.FILTER_REJECT; const validTags = ["P", "DIV", "ARTICLE", "SECTION", "LI", "BLOCKQUOTE"]; if (!validTags.includes(node.nodeName)) return NodeFilter.FILTER_SKIP; const text = cleanText(node.innerText); if (text.length < minLength) return NodeFilter.FILTER_SKIP; if (adLikePattern.test(text)) return NodeFilter.FILTER_SKIP; return NodeFilter.FILTER_ACCEPT; } }); const node = walker.nextNode(); if (node) { const text = cleanText(node.innerText); return { text }; } return null; } }, (results) => resolve(results?.[0]?.result || null) ); }); } async function extractArticleContent(tabId) { return new Promise((resolve) => { chrome.scripting.executeScript( { target: { tabId }, function: () => { const blockedPattern = /(nav|header|footer|aside|breadcrumb|sidebar|advert|promo|sponsor|toolbar|comment|popup|share|related|recommend|subscribe|newsletter)/i; const adKeywordPattern = /(advertisement|sponsored|promotion|buy now|click here|subscribe|share this|follow us|版权所有|广告|推广)/i; function cleanText(value) { return (value || "").replace(/\s+/g, " ").trim(); } function isVisible(el) { if (!el || el.nodeType !== Node.ELEMENT_NODE) return false; const rect = el.getBoundingClientRect(); if (!rect || rect.width === 0 || rect.height === 0) return false; const style = window.getComputedStyle(el); if (style.display === "none" || style.visibility === "hidden" || parseFloat(style.opacity) === 0) { return false; } return true; } function scoreNode(node) { const text = cleanText(node.innerText); if (text.length < 300) return 0; const links = node.querySelectorAll("a"); let linkTextLength = 0; links.forEach((link) => { linkTextLength += cleanText(link.innerText).length; }); const linkDensity = text.length ? Math.min(linkTextLength / text.length, 1) : 0; const headingPenalty = node.querySelectorAll("h1,h2,h3").length * 40; const meta = `${node.className || ""} ${node.id || ""}`; const adPenalty = blockedPattern.test(meta) ? 200 : 0; return text.length * (1 - linkDensity) - headingPenalty - adPenalty; } let candidates = Array.from(document.querySelectorAll("article, main, section")); candidates = candidates.filter( (node) => isVisible(node) && !blockedPattern.test(`${node.className || ""} ${node.id || ""}`) ); if (!candidates.length) { candidates = Array.from(document.querySelectorAll("div")); candidates = candidates.filter( (node) => isVisible(node) && !blockedPattern.test(`${node.className || ""} ${node.id || ""}`) ); } candidates.sort((a, b) => scoreNode(b) - scoreNode(a)); const best = candidates[0] || document.body; const bannedSelectors = [ "header", "nav", "aside", "footer", "form", "button", "#header", "#nav", "#footer", "#sidebar", '[id*="ad" i]', '[class*="ad" i]', '[id*="promo" i]', '[class*="promo" i]', '[id*="sponsor" i]', '[class*="sponsor" i]' ]; const uniqueTexts = new Set(); const chunks = []; const walker = document.createTreeWalker(best, NodeFilter.SHOW_ELEMENT, { acceptNode: (node) => { if (!isVisible(node)) return NodeFilter.FILTER_REJECT; for (const selector of bannedSelectors) { if (typeof selector === "string") { try { if (node.closest(selector)) { return NodeFilter.FILTER_REJECT; } } catch (error) { // ignore invalid selectors } } } if (/^H[1-6]$/i.test(node.nodeName)) { return NodeFilter.FILTER_REJECT; } const allowedTags = ["P", "DIV", "SECTION", "ARTICLE", "LI", "BLOCKQUOTE", "PRE"]; if (!allowedTags.includes(node.nodeName)) { return NodeFilter.FILTER_SKIP; } const text = cleanText(node.innerText); if (text.length < 40) return NodeFilter.FILTER_SKIP; if (adKeywordPattern.test(text)) return NodeFilter.FILTER_SKIP; return NodeFilter.FILTER_ACCEPT; } }); while (walker.nextNode()) { const text = cleanText(walker.currentNode.innerText); if (!text) continue; const key = text.slice(0, 120); if (uniqueTexts.has(key)) continue; uniqueTexts.add(key); chunks.push(text); } if (!chunks.length) { const fallback = cleanText(best.innerText); if (fallback.length < 80) { return null; } return { text: fallback.slice(0, 8000), meta: { paragraphCount: 1, charCount: Math.min(8000, fallback.length) } }; } const combined = chunks.join("\n\n"); const truncated = combined.length > 8000 ? combined.slice(0, 8000) : combined; return { text: truncated, meta: { paragraphCount: chunks.length, charCount: truncated.length } }; } }, (results) => resolve(results?.[0]?.result || null) ); }); } // ---------- Audio Helpers ---------- async function getPlayPermission() { return new Promise((resolve) => { const dummyAudio = new Audio("data:audio/wav;base64,UklGRl9vT19XQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YU"); dummyAudio .play() .then(() => { dummyAudio.pause(); dummyAudio.remove(); resolve(); }) .catch(() => { updateStatus("Click again to enable audio playback"); resolve(); }); }); } function pauseCurrentAudio() { if (!currentAudio || currentAudio.paused || currentAudio.ended) { return false; } currentAudio.pause(); isPaused = true; isPlaying = false; updatePlayButton(); updateStatus("Playback paused"); return true; } async function resumeCurrentAudio() { if (!currentAudio || !currentAudio.paused || currentAudio.ended) { isPaused = false; isPlaying = false; updatePlayButton(); return false; } initAudioContext(); if (audioContext.state !== "running") { await audioContext.resume(); } await currentAudio.play(); isPaused = false; isPlaying = true; updatePlayButton(); updateStatus("Resuming..."); return true; } function stopPlayback() { if (playController) { playController.abort(); playController = null; } if (currentAudio) { currentAudio.pause(); currentAudio = null; } isPaused = false; isPlaying = false; updatePlayButton(); } async function startConversion(payload) { try { if (!payload?.text) { updateStatus("No readable text"); return; } stopPlayback(); playController = new AbortController(); isPaused = false; isPlaying = true; updatePlayButton(); let message = "Starting playback..."; if (payload.scope === "selection") { message = "Playing selected text..."; } else if (payload.scope === "paragraph") { message = "Playing paragraph..."; } else if (payload.scope === "page") { const count = payload.meta?.paragraphCount; message = count ? `Playing article (${count} sections)...` : "Playing article..."; } updateStatus(message); await streamTTSAndPlay(payload.text, playController.signal); playController = null; isPaused = false; isPlaying = false; updatePlayButton(); updateStatus("Playback finished"); } catch (error) { handleError(error); } } async function streamTTSAndPlay(text, signal) { try { const port = chrome.runtime.connect({ name: "tts-stream" }); const playQueue = []; let isPlayingQueue = false; let streamCompleted = false; const abortHandler = () => { playQueue.length = 0; port.disconnect(); if (currentAudio) { currentAudio.pause(); currentAudio = null; } isPaused = false; isPlaying = false; updatePlayButton(); }; signal.addEventListener("abort", abortHandler, { once: true }); port.postMessage({ action: "startStreamTTS", text, voice: "af_heart", speed: currentSpeed }); port.onMessage.addListener((response) => { if (signal.aborted) { return; } if (response.error) { console.error("TTS error:", response.error); updateStatus(`TTS error: ${response.error}`); port.disconnect(); if (currentAudio) { currentAudio.pause(); currentAudio = null; } isPaused = false; isPlaying = false; updatePlayButton(); return; } if (response.audio) { playQueue.push({ audio: response.audio.startsWith("data:") ? response.audio : `data:audio/wav;base64,${response.audio}`, index: response.index }); if (!isPlayingQueue) { void playFromQueue(); } } if (response.completed) { streamCompleted = true; port.disconnect(); } }); function playFromQueue() { if (signal.aborted || !playQueue.length) { isPlayingQueue = false; isPlaying = false; updatePlayButton(); return Promise.resolve(); } isPlayingQueue = true; const chunk = playQueue.shift(); return (async () => { try { initAudioContext(); if (audioContext.state !== "running") { await audioContext.resume(); } currentAudio = new Audio(chunk.audio); isPaused = false; isPlaying = true; updatePlayButton(); updateStatus(`Playing part ${chunk.index + 1}`); await playAudio(currentAudio); currentAudio = null; updatePlayButton(); return playFromQueue(); } catch (error) { console.error("Playback error:", error); isPlayingQueue = false; } })(); } await new Promise((resolve) => { port.onDisconnect.addListener(() => { signal.removeEventListener("abort", abortHandler); const waitForQueue = () => { if (!playQueue.length && (!isPlayingQueue || signal.aborted || streamCompleted)) { resolve(); } else { setTimeout(waitForQueue, 100); } }; waitForQueue(); }); }); } catch (error) { throw error; } } function playAudio(audio) { return new Promise((resolve, reject) => { audio .play() .then(() => { audio.onended = resolve; audio.onerror = reject; }) .catch(reject); }); } // ---------- Init ---------- setMode("paragraph"); setSpeed(1.0); updatePlayButton(); });