Explorar o código

合并tts和Grammar插件功能,美化播放器

sequoiaoo hai 1 mes
achega
457d4dc234
Modificáronse 7 ficheiros con 1364 adicións e 0 borrados
  1. 271 0
      background.js
  2. 261 0
      content.js
  3. BIN=BIN
      icon.png
  4. 30 0
      manifest.json
  5. 132 0
      popup.html
  6. 651 0
      popup.js
  7. 19 0
      styles.css

+ 271 - 0
background.js

@@ -0,0 +1,271 @@
+let isPlayerActive = false;
+const ttsUrl = 'https://vs.aimanyi.top/generate';
+const grammarApiBase = 'https://gram.aimanyi.top';
+
+// ---------- Grammar API ----------
+
+async function fetchHighlights(text) {
+  const response = await fetch(`${grammarApiBase}/analyze`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ text })
+  });
+
+  if (!response.ok) {
+    const detail = await response.text();
+    throw new Error(detail || '后台服务调用失败。');
+  }
+
+  const data = await response.json();
+  return data.highlighted_html;
+}
+
+async function fetchParagraphHighlights(paragraphs) {
+  const results = [];
+  for (const paragraph of paragraphs) {
+    const highlighted = await fetchHighlights(paragraph);
+    results.push(highlighted);
+  }
+  return results;
+}
+
+chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
+  if (message?.type !== 'GRAMMAR_API_REQUEST') {
+    return;
+  }
+
+  const { text, paragraphs } = message;
+
+  (Array.isArray(paragraphs) && paragraphs.length > 0
+    ? fetchParagraphHighlights(paragraphs).then((highlightedHtmls) => ({
+        highlightedHtmls
+      }))
+    : fetchHighlights(text).then((highlightedHtml) => ({ highlightedHtml })))
+    .then((payload) => sendResponse(payload))
+    .catch((error) => {
+      sendResponse({ error: error.message || '后台服务调用失败。' });
+    });
+
+  return true;
+});
+
+// ---------- TTS Stream ----------
+
+chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
+  if (request.action === "fetchTTS") {
+    handleTTSRequest(request, sendResponse);
+    return true;
+  }
+});
+
+async function handleTTSRequest(request, sendResponse) {
+  try {
+    const response = await fetch(ttsUrl, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({
+        text: request.text,
+        voice: request.voice,
+        speed: request.speed
+      })
+    });
+
+    if (!response.ok) {
+      throw new Error(`Server error: ${response.status}`);
+    }
+
+    const reader = response.body.getReader();
+    const decoder = new TextDecoder();
+    let buffer = '';
+    const audioChunks = [];
+
+    while (true) {
+      const { done, value } = await reader.read();
+      if (done) break;
+
+      buffer += decoder.decode(value, { stream: true });
+      const lines = buffer.split('\n');
+      buffer = lines.pop() || '';
+
+      for (const line of lines) {
+        if (!line.trim()) continue;
+        try {
+          const data = JSON.parse(line);
+          if (data.audio) {
+            audioChunks.push(data);
+          }
+        } catch (e) {
+          console.error('Parse error:', e);
+        }
+      }
+    }
+
+    if (buffer.trim()) {
+      try {
+        const data = JSON.parse(buffer.trim());
+        if (data.audio) {
+          audioChunks.push(data);
+        }
+      } catch (e) {
+        console.error('Parse error:', e);
+      }
+    }
+
+    sendResponse({ audioChunks });
+  } catch (error) {
+    console.error('TTS request failed:', error);
+    sendResponse({ error: error.message });
+  }
+}
+
+chrome.runtime.onConnect.addListener((port) => {
+  if (port.name !== "tts-stream") {
+    return;
+  }
+
+  let isDisconnected = false;
+
+  const closePort = () => {
+    if (isDisconnected) return;
+    isDisconnected = true;
+    try {
+      port.disconnect();
+    } catch (err) {
+      console.warn('Port disconnect failed:', err);
+    }
+  };
+
+  const safePostMessage = (payload) => {
+    if (isDisconnected) return;
+    try {
+      port.postMessage(payload);
+    } catch (err) {
+      console.warn('Port post failed:', err);
+      closePort();
+    }
+  };
+
+  port.onDisconnect.addListener(() => {
+    isDisconnected = true;
+  });
+
+  port.onMessage.addListener(async (request) => {
+    if (request.action !== "startStreamTTS" || isDisconnected) {
+      return;
+    }
+
+    try {
+      const response = await fetch(ttsUrl, {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({
+          text: request.text,
+          voice: request.voice,
+          speed: request.speed
+        })
+      });
+
+      if (!response.ok) {
+        safePostMessage({ error: `Server error: ${response.status}` });
+        closePort();
+        return;
+      }
+
+      const reader = response.body.getReader();
+      const decoder = new TextDecoder();
+      let buffer = '';
+      let index = 0;
+
+      while (!isDisconnected) {
+        const { done, value } = await reader.read();
+        if (done) break;
+
+        buffer += decoder.decode(value, { stream: true });
+        const lines = buffer.split('\n');
+        buffer = lines.pop() || '';
+
+        for (const line of lines) {
+          if (!line.trim() || isDisconnected) continue;
+
+          try {
+            const data = JSON.parse(line);
+            if (data.audio) {
+              safePostMessage({
+                audio: data.audio,
+                index: index++
+              });
+            }
+          } catch (e) {
+            console.error('Parse error:', e);
+          }
+        }
+      }
+
+      if (!isDisconnected && buffer.trim()) {
+        try {
+          const data = JSON.parse(buffer.trim());
+          if (data.audio) {
+            safePostMessage({
+              audio: data.audio,
+              index: index++
+            });
+          }
+        } catch (e) {
+          console.error('Parse error:', e);
+        }
+      }
+
+      safePostMessage({ completed: true });
+      closePort();
+    } catch (error) {
+      safePostMessage({ error: error.message });
+      closePort();
+    }
+  });
+});
+
+// ---------- Player Toggle ----------
+
+chrome.action.onClicked.addListener(async (tab) => {
+  const url = tab?.url ?? tab?.pendingUrl ?? '';
+  if (!url || /^(chrome|chrome-extension|edge|about):/i.test(url)) {
+    console.warn('受限制的页面,跳过播放器注入:', url);
+    return;
+  }
+
+  if (!tab?.id) {
+    console.warn('无法获取标签页 ID,跳过播放器注入');
+    return;
+  }
+
+  try {
+    if (!isPlayerActive) {
+      await chrome.scripting.executeScript({
+        target: { tabId: tab.id },
+        files: ['content.js']
+      });
+
+      await chrome.scripting.insertCSS({
+        target: { tabId: tab.id },
+        files: ['styles.css']
+      });
+    }
+  } catch (error) {
+    const lastError = chrome.runtime.lastError?.message || error.message;
+    console.warn('播放器注入失败:', lastError);
+    return;
+  }
+
+  chrome.tabs.sendMessage(
+    tab.id,
+    { action: isPlayerActive ? "deactivatePlayer" : "activatePlayer" },
+    () => {
+      const message = chrome.runtime.lastError?.message;
+      if (message) {
+        console.warn('发送播放器指令失败:', message);
+        return;
+      }
+      isPlayerActive = !isPlayerActive;
+    }
+  );
+});

+ 261 - 0
content.js

@@ -0,0 +1,261 @@
+(function() {
+    let playerContainer = null;
+
+    function createPlayer() {
+        if (playerContainer) return;
+
+        playerContainer = document.createElement('div');
+        playerContainer.id = 'readvox-player';
+
+        const playerIframe = document.createElement('iframe');
+        playerIframe.src = chrome.runtime.getURL('popup.html');
+        playerIframe.className = 'player-iframe';
+
+        playerContainer.appendChild(playerIframe);
+        document.body.appendChild(playerContainer);
+
+        makeDraggable(playerContainer);
+    }
+
+    function removePlayer() {
+        if (playerContainer) {
+            playerContainer.remove();
+            playerContainer = null;
+        }
+    }
+
+    function makeDraggable(element) {
+        let pos1 = 0, pos2 = 0, pos3 = 0, pos4 = 0;
+
+        const dragMouseDown = (e) => {
+            e = e || window.event;
+
+            pos3 = e.clientX;
+            pos4 = e.clientY;
+            document.onmouseup = closeDragElement;
+            document.onmousemove = elementDrag;
+        };
+
+        const elementDrag = (e) => {
+            e = e || window.event;
+            e.preventDefault();
+
+            pos1 = pos3 - e.clientX;
+            pos2 = pos4 - e.clientY;
+            pos3 = e.clientX;
+            pos4 = e.clientY;
+
+            element.style.top = (element.offsetTop - pos2) + "px";
+            element.style.left = (element.offsetLeft - pos1) + "px";
+        };
+
+        const closeDragElement = () => {
+            document.onmouseup = null;
+            document.onmousemove = null;
+        };
+
+        element.addEventListener('mousedown', dragMouseDown);
+    }
+
+    // ---------- Grammar Highlight Logic ----------
+
+    const PARAGRAPH_SELECTORS = 'p, article p, section p, div';
+
+    function callBackend(payload) {
+        return new Promise((resolve, reject) => {
+            chrome.runtime.sendMessage(
+                { type: 'GRAMMAR_API_REQUEST', ...payload },
+                (response) => {
+                    if (chrome.runtime.lastError) {
+                        reject(new Error(chrome.runtime.lastError.message));
+                        return;
+                    }
+                    if (!response) {
+                        reject(new Error('后台未返回数据。'));
+                        return;
+                    }
+                    if (response.error) {
+                        reject(new Error(response.error));
+                        return;
+                    }
+                    resolve(response);
+                }
+            );
+        });
+    }
+
+    function isParagraphElement(node) {
+        return (
+            node &&
+            node.nodeType === Node.ELEMENT_NODE &&
+            node.matches &&
+            node.matches(PARAGRAPH_SELECTORS)
+        );
+    }
+
+    function collectParagraphNodes(container) {
+        if (!container || container.nodeType !== Node.ELEMENT_NODE) return [];
+        const paragraphs = Array.from(container.querySelectorAll('p')).filter(
+            (el) => el.innerText.trim()
+        );
+        if (paragraphs.length > 0) return paragraphs;
+        return [container];
+    }
+
+    function buildParagraphTarget(nodes) {
+        const usableNodes = nodes.filter(
+            (node) => node && node.innerText && node.innerText.trim()
+        );
+        if (usableNodes.length === 0) {
+            throw new Error('未找到可用段落文本。');
+        }
+
+        const originals = usableNodes.map((node) => node.innerHTML);
+        const paragraphs = usableNodes.map((node) => node.innerText.trim());
+
+        return {
+            payload: { paragraphs },
+            apply(htmlList) {
+                const list = Array.isArray(htmlList) ? htmlList : [];
+                usableNodes.forEach((node, idx) => {
+                    const html = list[idx];
+                    if (typeof html === 'string' && html.trim()) {
+                        node.innerHTML = html;
+                    }
+                });
+            },
+            restore() {
+                usableNodes.forEach((node, idx) => {
+                    node.innerHTML = originals[idx];
+                });
+            }
+        };
+    }
+
+    function getSelectionTarget() {
+        const selection = window.getSelection();
+        if (!selection || selection.rangeCount === 0) {
+            throw new Error('请先在页面中选择一段文本。');
+        }
+        const range = selection.getRangeAt(0);
+        if (range.collapsed) {
+            throw new Error('所选文本为空。');
+        }
+
+        const root =
+            range.commonAncestorContainer.nodeType === Node.ELEMENT_NODE
+                ? range.commonAncestorContainer
+                : range.commonAncestorContainer.parentElement || document.body;
+
+        const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, {
+            acceptNode(node) {
+                if (!(node instanceof HTMLElement)) return NodeFilter.FILTER_SKIP;
+                return isParagraphElement(node) && range.intersectsNode(node)
+                    ? NodeFilter.FILTER_ACCEPT
+                    : NodeFilter.FILTER_SKIP;
+            }
+        });
+
+        const nodes = [];
+        let current = walker.nextNode();
+        while (current) {
+            nodes.push(current);
+            current = walker.nextNode();
+        }
+
+        if (nodes.length === 0) {
+            const fallback =
+                (range.commonAncestorContainer.nodeType === Node.ELEMENT_NODE
+                    ? range.commonAncestorContainer
+                    : range.commonAncestorContainer.parentElement) || document.body;
+            nodes.push(fallback.closest(PARAGRAPH_SELECTORS) || fallback);
+        }
+
+        return buildParagraphTarget(nodes);
+    }
+
+    function getParagraphTarget() {
+        const selection = window.getSelection();
+        let node =
+            selection && selection.rangeCount > 0
+                ? selection.getRangeAt(0).commonAncestorContainer
+                : document.activeElement;
+
+        if (!node) {
+            node = document.body;
+        }
+
+        if (node.nodeType === Node.TEXT_NODE) {
+            node = node.parentElement;
+        }
+
+        const paragraph =
+            node.closest(PARAGRAPH_SELECTORS) || document.querySelector('p') || document.body;
+
+        return buildParagraphTarget([paragraph]);
+    }
+
+    function getArticleTarget() {
+        const container =
+            document.querySelector('article') ||
+            document.querySelector('main') ||
+            document.body;
+
+        const nodes = collectParagraphNodes(container).filter(
+            (node) => node.innerText.trim()
+        );
+
+        return buildParagraphTarget(nodes);
+    }
+
+    async function handleAnalyze(mode) {
+        let targetGetter;
+        switch (mode) {
+            case 'selection':
+                targetGetter = getSelectionTarget;
+                break;
+            case 'paragraph':
+                targetGetter = getParagraphTarget;
+                break;
+            case 'page':
+            case 'article':
+                targetGetter = getArticleTarget;
+                break;
+            default:
+                throw new Error('未知的分析模式。');
+        }
+
+        const target = targetGetter();
+        try {
+            const response = await callBackend(target.payload);
+            const htmlList =
+                response.highlightedHtmls ||
+                (response.highlightedHtml ? [response.highlightedHtml] : []);
+            if (!htmlList || htmlList.length === 0) {
+                throw new Error('后台未返回数据。');
+            }
+            target.apply(htmlList);
+            return {};
+        } catch (error) {
+            target.restore?.();
+            throw error;
+        }
+    }
+
+    // ---------- Message Handler ----------
+
+    chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
+        if (request.action === "activatePlayer") {
+            createPlayer();
+        } else if (request.action === "deactivatePlayer") {
+            removePlayer();
+        } else if (request.type === 'EXECUTE_GRAMMAR') {
+            handleAnalyze(request.mode)
+                .then(() => sendResponse({ success: true }))
+                .catch((error) => {
+                    sendResponse({ error: error.message || '未知错误' });
+                });
+            return true;
+        }
+    });
+})();

BIN=BIN
icon.png


+ 30 - 0
manifest.json

@@ -0,0 +1,30 @@
+{
+  "manifest_version": 3,
+  "name": "ReadVox",
+  "version": "1.0",
+  "description": "TTS播报与语法高亮一体化插件",
+  "permissions": ["activeTab", "scripting", "storage"],
+  "host_permissions": ["*://*/*", "https://gram.aimanyi.top/*"],
+  "background": {
+    "service_worker": "background.js"
+  },
+  "action": {
+    "default_icon": {
+      "128": "icon.png"
+    },
+    "default_title": "ReadVox"
+  },
+  "content_scripts": [
+    {
+      "matches": ["<all_urls>"],
+      "js": ["content.js"],
+      "run_at": "document_idle"
+    }
+  ],
+  "web_accessible_resources": [
+    {
+      "resources": ["popup.html", "styles.css"],
+      "matches": ["<all_urls>"]
+    }
+  ]
+}

+ 132 - 0
popup.html

@@ -0,0 +1,132 @@
+<!DOCTYPE html>
+<html lang="zh">
+<head>
+  <meta charset="UTF-8">
+  <title>ReadVox</title>
+  <style>
+    * { box-sizing: border-box; }
+    body {
+      margin: 0;
+      font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
+      background: #1e1e2e;
+      height: 100%;
+      overflow: hidden;
+      color: #e0e0e0;
+    }
+    .popup-container {
+      display: flex;
+      flex-direction: column;
+      height: 100%;
+      padding: 5px;
+      gap: 5px;
+    }
+    .popup-header {
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+    }
+    .popup-title {
+      font-size: 12px;
+      font-weight: 600;
+      color: #e0e0e0;
+      letter-spacing: 0.5px;
+    }
+    .close-btn {
+      width: 18px;
+      height: 18px;
+      border: none;
+      border-radius: 4px;
+      background: rgba(255,255,255,0.1);
+      color: #e0e0e0;
+      font-size: 11px;
+      cursor: pointer;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      line-height: 1;
+    }
+    .close-btn:hover { background: rgba(255,255,255,0.2); }
+    .control-row {
+      display: flex;
+      justify-content: center;
+      gap: 11px;
+      align-items: center;
+    }
+    .icon-btn {
+      width: 32px;
+      height: 32px;
+      border: none;
+      border-radius: 50%;
+      color: #fff;
+      font-size: 13px;
+      cursor: pointer;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      transition: all 0.2s ease;
+      padding: 0;
+      box-shadow: 0 2px 8px rgba(0,0,0,0.3);
+    }
+    .icon-btn:disabled { opacity: 0.4; cursor: not-allowed; }
+    .play-btn { background: #4285f4; }
+    .play-btn:hover { background: #3367d6; transform: scale(1.05); }
+    .stop-btn { background: #2e7d32; }
+    .stop-btn:hover { background: #1b5e20; transform: scale(1.05); }
+    .grammar-btn { background: #7c3aed; }
+    .grammar-btn:hover { background: #6d28d9; transform: scale(1.05); }
+    .speed-btn { background: #f59e0b; font-size: 10px; }
+    .speed-btn:hover { background: #d97706; transform: scale(1.05); }
+    .mode-row {
+      display: flex;
+      justify-content: center;
+      gap: 10px;
+    }
+    .mode-btn {
+      padding: 3px 10px;
+      height: 23px;
+      border: 1px solid rgba(255,255,255,0.05);
+      border-radius: 6px;
+      background: rgba(255,255,255,0.08);
+      color: #aaa;
+      font-size: 11px;
+      cursor: pointer;
+      transition: all 0.2s ease;
+      text-align: center;
+    }
+    .mode-btn:hover { background: rgba(255,255,255,0.14); }
+    .mode-btn.active { background: #4285f4; color: #fff; border-color: transparent; }
+    .status-text {
+      text-align: center;
+      font-size: 11px;
+      color: #999;
+      min-height: 22px;
+      line-height: 1.3;
+      word-break: break-word;
+    }
+  </style>
+</head>
+<body>
+  <div class="popup-container">
+    <div class="popup-header">
+      <span class="popup-title">ReadVox</span>
+      <button id="close-btn" class="close-btn" title="关闭">&#x2715;</button>
+    </div>
+
+    <div class="control-row">
+      <button id="play-btn" class="icon-btn play-btn" title="播放">▶</button>
+      <button id="stop-btn" class="icon-btn stop-btn" title="停止">⏹</button>
+      <button id="grammar-btn" class="icon-btn grammar-btn" title="语法高亮">G</button>
+      <button id="speed-btn" class="icon-btn speed-btn" title="倍速">1x</button>
+    </div>
+
+    <div class="mode-row">
+      <button id="mode-paragraph" class="mode-btn" data-mode="paragraph">Para</button>
+      <button id="mode-selection" class="mode-btn" data-mode="selection">Select</button>
+      <button id="mode-page" class="mode-btn" data-mode="page">Page</button>
+    </div>
+
+    <div class="status-text" id="status">Ready</div>
+  </div>
+  <script src="popup.js"></script>
+</body>
+</html>

+ 651 - 0
popup.js

@@ -0,0 +1,651 @@
+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();
+});

+ 19 - 0
styles.css

@@ -0,0 +1,19 @@
+#readvox-player {
+    position: fixed;
+    bottom: 12px;
+    right: 12px;
+    z-index: 999999;
+    width: 200px;
+    height: 120px;
+    border-radius: 16px;
+    box-shadow: 0 3px 10px rgba(0, 0, 0, 0.25);
+    overflow: hidden;
+    background: #1e1e2e;
+    border: 1px solid rgba(255, 255, 255, 0.05);
+}
+
+.player-iframe {
+    width: 100%;
+    height: 100%;
+    border: none;
+}