| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651 |
- 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();
- });
|