diff --git a/components/Main.vue b/components/Main.vue
index a4f1bce..befb990 100644
--- a/components/Main.vue
+++ b/components/Main.vue
@@ -167,6 +167,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/entrypoints/content.ts b/entrypoints/content.ts
index 21fb805..05edbea 100644
--- a/entrypoints/content.ts
+++ b/entrypoints/content.ts
@@ -9,7 +9,8 @@ import { mountSelectionTranslator, unmountSelectionTranslator } from "@/entrypoi
import { cancelAllTranslations, translateText } from "@/entrypoints/utils/translateApi";
import { createApp } from 'vue';
import TranslationStatus from '@/components/TranslationStatus.vue';
-import { mountNewApiComponent } from "@/entrypoints/utils/newApi";
+import { mountNewApiComponent } from "@/entrypoints/utils/newApi"
+import { initVideoSubtitle } from "@/entrypoints/video/manager";
export default defineContentScript({
matches: [''], // 匹配所有页面
@@ -55,6 +56,9 @@ export default defineContentScript({
mountNewApiComponent();
+ // 初始化视频字幕翻译(在支持的平台上注入拦截脚本)
+ initVideoSubtitle();
+
cache.cleaner(); // 检测是否清理缓存
// background.ts
diff --git a/entrypoints/utils/model.ts b/entrypoints/utils/model.ts
index 915e58c..b1e2b2e 100644
--- a/entrypoints/utils/model.ts
+++ b/entrypoints/utils/model.ts
@@ -53,6 +53,7 @@ export class Config {
translationStatus: boolean; // 是否启用全文翻译进度面板
inputBoxTranslationTrigger: string; // 输入框翻译触发方式
inputBoxTranslationTarget: string; // 输入框翻译目标语言
+ enableVideoSubtitle: boolean; // 是否启用视频字幕翻译
constructor() {
this.on = true;
@@ -98,6 +99,7 @@ export class Config {
this.translationStatus = true; // 默认启用翻译进度面板
this.inputBoxTranslationTrigger = 'disabled'; // 默认关闭输入框翻译
this.inputBoxTranslationTarget = 'en'; // 默认翻译成英文
+ this.enableVideoSubtitle = true; // 默认启用视频字幕翻译
}
}
diff --git a/entrypoints/video/manager.ts b/entrypoints/video/manager.ts
new file mode 100644
index 0000000..31c2a96
--- /dev/null
+++ b/entrypoints/video/manager.ts
@@ -0,0 +1,344 @@
+import { detectPlatform, getAllSubtitlePatterns } from './platforms'
+import { detectSubtitleFormat, parseYouTubeXML, parseYouTubeJSON3, parseVTT, type SubtitleCue } from './parser'
+import { SubtitleOverlay } from './overlay'
+import { translateText } from '@/entrypoints/utils/translateApi'
+import { config } from '@/entrypoints/utils/config'
+
+// ── 常量 ──────────────────────────────────────────────────────────────────────
+const EVENT_TYPE = 'fr-subtitle-inject'
+const BATCH_SIZE = 5 // 每批翻译的句子组数
+const MERGE_GAP_MS = 600 // 相邻 cue 间隔 < 此值(毫秒)则合并为同一句
+const MAX_WORDS = 20 // 单组超过此词数强制断开
+const QUICK_BTN_ID = 'fr-subtitle-quick-btn'
+
+// ── 类型 ──────────────────────────────────────────────────────────────────────
+interface SentenceGroup {
+ cues: SubtitleCue[]
+ text: string
+}
+
+// ── 模块状态 ──────────────────────────────────────────────────────────────────
+const overlay = new SubtitleOverlay()
+let listenerAttached = false
+let processingUrl = '' // 去重:同一字幕 URL 只翻译一次
+let subtitleEnabled = true
+
+// ── 公开入口 ──────────────────────────────────────────────────────────────────
+
+/** 由 content.ts 调用,初始化视频字幕翻译 */
+export function initVideoSubtitle() {
+ console.log('[FR] initVideoSubtitle called, enableVideoSubtitle=', config.enableVideoSubtitle, 'hostname=', window.location.hostname)
+ if (!config.enableVideoSubtitle) return
+ // 拦截脚本已由 WXT 以 MAIN world content script 形式在 document_start 注入,
+ // 此处只需推送动态配置并开始监听消息。
+ sendConfig()
+ attachMessageListener()
+ watchNavigation()
+ // 在 YouTube 上立即挂载快捷按钮,无需等待字幕捕获
+ if (window.location.hostname.includes('youtube.com')) {
+ console.log('[FR] on YouTube, calling mountQuickButton')
+ mountQuickButton()
+ }
+}
+
+// ── 私有实现 ──────────────────────────────────────────────────────────────────
+
+/** 向注入脚本发送字幕 URL 正则列表(动态更新,覆盖注入脚本内置的默认规则) */
+function sendConfig() {
+ window.postMessage({
+ eventType: EVENT_TYPE,
+ type: 'config',
+ patterns: getAllSubtitlePatterns(),
+ }, '*')
+}
+
+/** 监听来自注入脚本的 postMessage */
+function attachMessageListener() {
+ if (listenerAttached) return
+ listenerAttached = true
+
+ window.addEventListener('message', async (event) => {
+ if (event.source !== window) return
+ const msg = event.data
+ if (!msg || msg.eventType !== EVENT_TYPE) return
+
+ if (msg.type === 'subtitle-captured') {
+ const { url, data } = msg
+ if (!url || !data) return
+ if (!subtitleEnabled) return
+ // 同一 URL 不重复处理
+ if (url === processingUrl) return
+ processingUrl = url
+ try {
+ await handleSubtitleData(url, data)
+ } finally {
+ processingUrl = ''
+ }
+ }
+ })
+}
+
+/** 解析字幕 → 初始化 overlay → 批量翻译 */
+async function handleSubtitleData(url: string, rawData: string) {
+ const format = detectSubtitleFormat(url, rawData)
+ if (!format) return
+
+ const cues: SubtitleCue[] =
+ format === 'youtube-xml' ? parseYouTubeXML(rawData) :
+ format === 'youtube-json3' ? parseYouTubeJSON3(rawData) :
+ parseVTT(rawData)
+
+ if (!cues.length) return
+
+ const video = findVideo()
+ if (!video) return
+
+ const mountTarget = findMountTarget(video)
+ overlay.mount(video, mountTarget)
+ overlay.setCues([...cues]) // 先用原文渲染,避免空白等待
+
+ hideNativeSubtitle()
+ mountQuickButton()
+
+ // 分批翻译,边翻译边更新 overlay
+ await translateCuesBatched(cues, () => overlay.setCues([...cues]))
+}
+
+/**
+ * 按时间间隔合并相邻 cue:
+ * - 相邻两条 cue 的间隔 < MERGE_GAP_MS → 合并为同一句(说话中的正常停顿)
+ * - 间隔 ≥ MERGE_GAP_MS 或词数超过 MAX_WORDS → 断开(句子之间的自然停顿)
+ * 这样"united states"等跨 cue 短语能被合并进同一组,避免词级切断的翻译错误。
+ */
+function mergeByTimeGap(cues: SubtitleCue[]): SentenceGroup[] {
+ const groups: SentenceGroup[] = []
+ let current: SubtitleCue[] = []
+
+ const flush = (arr: SubtitleCue[]) => groups.push({
+ cues: [...arr],
+ text: arr.map(c => c.text).join(' ').replace(/\s+/g, ' ').trim(),
+ })
+
+ for (let i = 0; i < cues.length; i++) {
+ current.push(cues[i])
+ const next = cues[i + 1]
+ const wordCount = current.reduce((n, c) => n + c.text.split(/\s+/).length, 0)
+ const gapMs = next ? (next.start - cues[i].end) * 1000 : Infinity
+ const bigGap = !next || gapMs >= MERGE_GAP_MS
+ const tooLong = wordCount >= MAX_WORDS
+
+ if (bigGap || tooLong) {
+ if (tooLong && !bigGap && current.length > 1) {
+ // 词数超限但下一条紧跟(小间隔)→ 末尾 cue 进位到下一组,
+ // 避免碎片句(如 "but the seeds")因超限被孤立
+ const carryOver = current.pop()!
+ flush(current)
+ current = [carryOver]
+ } else {
+ flush(current)
+ current = []
+ }
+ }
+ }
+ if (current.length) flush(current)
+ return groups
+}
+
+/**
+ * 将 cue 按时间间隔合并为句子组后批量翻译。
+ * 组内所有 cue 共享同一译文,消除跨 cue 词级切断问题。
+ */
+async function translateCuesBatched(cues: SubtitleCue[], onProgress: () => void) {
+ const groups = mergeByTimeGap(cues)
+ const instruction =
+ 'Video subtitle segments. Translate each [N] line. ' +
+ 'Return the same number of [N] lines, no extra explanation.\n\n'
+
+ for (let i = 0; i < groups.length; i += BATCH_SIZE) {
+ const batch = groups.slice(i, i + BATCH_SIZE)
+
+ const prevContext = i > 0
+ ? `[context: ...${groups[i - 1].text.split(' ').slice(-8).join(' ')}]\n`
+ : ''
+
+ const joined = instruction + prevContext
+ + batch.map((g, j) => `[${j + 1}] ${g.text}`).join('\n')
+
+ try {
+ const translated = await translateText(joined, document.title)
+ const map = new Map()
+ for (const line of translated.split('\n')) {
+ const m = line.match(/^\[(\d+)\]\s*(.*)/)
+ if (m) map.set(parseInt(m[1]), m[2].trim())
+ }
+ batch.forEach((group, j) => {
+ const translation = map.get(j + 1) || group.text
+ group.cues.forEach(cue => { cue.translatedText = translation })
+ })
+ } catch {
+ batch.forEach(group => {
+ group.cues.forEach(cue => { cue.translatedText = cue.text })
+ })
+ }
+
+ onProgress()
+ }
+}
+
+// ── YouTube 工具栏快捷按钮 ─────────────────────────────────────────────────────
+
+/**
+ * 在 YouTube 播放器右侧控制栏注入一个翻译开关按钮。
+ * 点击可切换字幕翻译的显示/隐藏,不影响原生字幕。
+ */
+function mountQuickButton() {
+ console.log('[FR] mountQuickButton called, existing=', !!document.getElementById(QUICK_BTN_ID))
+ if (document.getElementById(QUICK_BTN_ID)) return
+
+ // YouTube 右侧控制栏,等待其出现
+ console.log('[FR] waiting for .ytp-right-controls, current=', document.querySelector('.ytp-right-controls'))
+ waitForElement('.ytp-right-controls', (controls) => {
+ console.log('[FR] .ytp-right-controls found, inserting button')
+ if (document.getElementById(QUICK_BTN_ID)) return
+
+ const btn = document.createElement('button')
+ btn.id = QUICK_BTN_ID
+ btn.title = '流畅阅读:字幕翻译'
+ btn.setAttribute('aria-label', '字幕翻译')
+ btn.style.cssText = [
+ 'background:transparent',
+ 'border:none',
+ 'cursor:pointer',
+ 'padding:0 6px',
+ 'height:100%',
+ 'display:inline-flex',
+ 'align-items:center',
+ 'opacity:0.9',
+ 'vertical-align:top',
+ ].join(';')
+
+ btn.appendChild(buildBtnSvg(subtitleEnabled))
+
+ btn.addEventListener('click', () => {
+ subtitleEnabled = !subtitleEnabled
+ btn.replaceChildren(buildBtnSvg(subtitleEnabled))
+ btn.title = subtitleEnabled ? '流畅阅读:字幕翻译(开)' : '流畅阅读:字幕翻译(关)'
+ if (subtitleEnabled) {
+ hideNativeSubtitle()
+ overlay.show()
+ } else {
+ overlay.hide()
+ restoreNativeSubtitle()
+ }
+ })
+
+ // 插入到右侧控制栏最左边
+ controls.prepend(btn)
+ })
+}
+
+function buildBtnSvg(active: boolean): SVGElement {
+ const ns = 'http://www.w3.org/2000/svg'
+ const svg = document.createElementNS(ns, 'svg')
+ svg.setAttribute('viewBox', '0 0 24 24')
+ svg.setAttribute('width', '22')
+ svg.setAttribute('height', '22')
+ svg.setAttribute('fill', active ? '#fff' : 'rgba(255,255,255,0.4)')
+ const path = document.createElementNS(ns, 'path')
+ path.setAttribute('d', 'M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 14H4V6h16v12zM6 10h2v2H6zm0 4h8v2H6zm10 0h2v2h-2zm-6-4h8v2h-8z')
+ svg.appendChild(path)
+ return svg
+}
+
+/** 轮询等待目标元素出现 */
+function waitForElement(selector: string, callback: (el: Element) => void, maxMs = 10000) {
+ const el = document.querySelector(selector)
+ if (el) { callback(el); return }
+
+ const start = Date.now()
+ const timer = setInterval(() => {
+ const found = document.querySelector(selector)
+ if (found) {
+ clearInterval(timer)
+ callback(found)
+ } else if (Date.now() - start > maxMs) {
+ clearInterval(timer)
+ }
+ }, 300)
+}
+
+// ── DOM 工具 ──────────────────────────────────────────────────────────────────
+
+function findVideo(): HTMLVideoElement | null {
+ const platform = detectPlatform(window.location.hostname)
+ if (platform.videoSelector) {
+ const v = document.querySelector(platform.videoSelector)
+ if (v) return v
+ }
+ return document.querySelector('video')
+}
+
+function findMountTarget(video: HTMLVideoElement): HTMLElement {
+ const platform = detectPlatform(window.location.hostname)
+ if (platform.containerSelector) {
+ const el = document.querySelector(platform.containerSelector)
+ if (el) return el
+ }
+ return (video.parentElement as HTMLElement) || document.body
+}
+
+function hideNativeSubtitle() {
+ const platform = detectPlatform(window.location.hostname)
+ if (!platform.hideNativeSelector) return
+ // 用 display:none 彻底隐藏,visibility:hidden 仍占位且有时被 YouTube 重置
+ // 隐藏前将原始内联 display 值存入 data- 属性,供还原时使用
+ document.querySelectorAll(platform.hideNativeSelector)
+ .forEach(el => {
+ el.dataset.frOrigDisplay = el.style.display
+ el.style.setProperty('display', 'none', 'important')
+ })
+}
+
+function restoreNativeSubtitle() {
+ const platform = detectPlatform(window.location.hostname)
+ if (!platform.hideNativeSelector) return
+ document.querySelectorAll(platform.hideNativeSelector)
+ .forEach(el => {
+ const orig = el.dataset.frOrigDisplay
+ if (orig !== undefined) {
+ el.style.display = orig
+ delete el.dataset.frOrigDisplay
+ } else {
+ el.style.removeProperty('display')
+ }
+ })
+}
+
+// ── SPA 导航监听 ──────────────────────────────────────────────────────────────
+
+function watchNavigation() {
+ let lastUrl = location.href
+
+ const onUrlChange = () => {
+ const cur = location.href
+ if (cur !== lastUrl) {
+ lastUrl = cur
+ overlay.cleanup()
+ document.getElementById(QUICK_BTN_ID)?.remove()
+ processingUrl = ''
+ subtitleEnabled = true
+ restoreNativeSubtitle()
+ // SPA 导航到视频页时重新挂载按钮
+ if (window.location.hostname.includes('youtube.com')) {
+ mountQuickButton()
+ }
+ }
+ }
+
+ window.addEventListener('yt-navigate-finish', onUrlChange)
+
+ const titleEl = document.querySelector('title')
+ if (titleEl) {
+ new MutationObserver(onUrlChange).observe(titleEl, { childList: true })
+ }
+}
diff --git a/entrypoints/video/overlay.ts b/entrypoints/video/overlay.ts
new file mode 100644
index 0000000..871388a
--- /dev/null
+++ b/entrypoints/video/overlay.ts
@@ -0,0 +1,151 @@
+import type { SubtitleCue } from './parser'
+import { config } from '@/entrypoints/utils/config'
+
+const OVERLAY_ID = 'fr-subtitle-overlay'
+
+export class SubtitleOverlay {
+ private container: HTMLElement | null = null
+ private video: HTMLVideoElement | null = null
+ private cues: SubtitleCue[] = []
+ private rafId: number | null = null
+ private lastCueKey: string | null = null // 避免每帧重复 DOM 操作
+ private mountTarget: HTMLElement | undefined
+ private originalMountPosition: string | undefined // undefined = 未修改过
+
+ /** 在视频容器内创建字幕层,开始时间轴循环 */
+ mount(video: HTMLVideoElement, mountTarget: HTMLElement) {
+ this.video = video
+ this.cleanup()
+
+ const overlay = document.createElement('div')
+ overlay.id = OVERLAY_ID
+ overlay.style.cssText = [
+ 'position:absolute',
+ 'bottom:8%',
+ 'left:50%',
+ 'transform:translateX(-50%)',
+ 'z-index:2147483640',
+ 'text-align:center',
+ 'pointer-events:none',
+ 'width:max-content',
+ 'max-width:94%',
+ ].join(';')
+
+ // 确保挂载目标有定位上下文,记录原始内联 position 以便 cleanup 还原
+ const computedPos = window.getComputedStyle(mountTarget).position
+ if (computedPos === 'static') {
+ this.originalMountPosition = mountTarget.style.position
+ mountTarget.style.position = 'relative'
+ }
+
+ mountTarget.appendChild(overlay)
+ this.mountTarget = mountTarget
+ this.container = overlay
+ this.startLoop()
+ }
+
+ /** 更新全部字幕数据(解析完成 / 翻译进度更新时调用) */
+ setCues(cues: SubtitleCue[]) {
+ this.cues = cues
+ this.lastCueKey = null // 强制刷新一次显示
+ }
+
+ show() {
+ if (this.container) this.container.style.display = ''
+ }
+
+ hide() {
+ if (this.container) this.container.style.display = 'none'
+ }
+
+ /** 停止渲染并移除 DOM,还原挂载目标的原始 position */
+ cleanup() {
+ if (this.rafId !== null) {
+ cancelAnimationFrame(this.rafId)
+ this.rafId = null
+ }
+ this.container?.remove()
+ if (this.mountTarget !== undefined && this.originalMountPosition !== undefined) {
+ this.mountTarget.style.position = this.originalMountPosition
+ }
+ this.container = null
+ this.cues = []
+ this.lastCueKey = null
+ this.mountTarget = undefined
+ this.originalMountPosition = undefined
+ }
+
+ // ── 内部方法 ──────────────────────────────────────────────────────────────
+
+ private startLoop() {
+ const tick = () => {
+ if (!this.video || !this.container) return
+ const t = this.video.currentTime
+ const cue = this.findCue(t)
+ const key = cue ? `${cue.start}~${cue.end}` : ''
+
+ if (key !== this.lastCueKey) {
+ this.lastCueKey = key
+ this.render(cue)
+ }
+ this.rafId = requestAnimationFrame(tick)
+ }
+ this.rafId = requestAnimationFrame(tick)
+ }
+
+ private findCue(time: number): SubtitleCue | null {
+ // 二分找到第一个 start <= time 且 end > time 的 cue,
+ // 继续向后扫描以处理 YouTube 滚动字幕的 overlap cue(取最后一条)
+ let lo = 0, hi = this.cues.length - 1, result: SubtitleCue | null = null
+ while (lo <= hi) {
+ const mid = (lo + hi) >>> 1
+ const cue = this.cues[mid]
+ if (time < cue.start) {
+ hi = mid - 1
+ } else if (time >= cue.end) {
+ lo = mid + 1
+ } else {
+ result = cue
+ lo = mid + 1 // 继续往后找是否有更晚开始的 overlap cue
+ }
+ }
+ return result
+ }
+
+ private render(cue: SubtitleCue | null) {
+ if (!this.container) return
+ this.container.replaceChildren()
+ if (!cue) return
+
+ const isBilingual = config.display === 1 // 1 = 双语对照
+ const hasTranslation = !!cue.translatedText
+
+ const lineStyle = [
+ 'display:block',
+ 'background:rgba(8,8,8,0.80)',
+ 'color:#fff',
+ 'padding:4px 14px',
+ 'border-radius:4px',
+ 'font-size:22px',
+ 'line-height:1.65',
+ 'white-space:pre-wrap',
+ 'word-break:break-word',
+ 'text-align:center',
+ 'margin-bottom:4px',
+ ].join(';')
+
+ const originalStyle = lineStyle + ';opacity:0.75;font-size:18px'
+
+ if (isBilingual && cue.text) {
+ const div = document.createElement('div')
+ div.style.cssText = originalStyle
+ div.textContent = cue.text
+ this.container.appendChild(div)
+ }
+
+ const mainDiv = document.createElement('div')
+ mainDiv.style.cssText = lineStyle
+ mainDiv.textContent = hasTranslation ? cue.translatedText! : cue.text
+ this.container.appendChild(mainDiv)
+ }
+}
diff --git a/entrypoints/video/parser.ts b/entrypoints/video/parser.ts
new file mode 100644
index 0000000..7f667ac
--- /dev/null
+++ b/entrypoints/video/parser.ts
@@ -0,0 +1,132 @@
+export interface SubtitleCue {
+ start: number // 开始时间(秒)
+ end: number // 结束时间(秒)
+ text: string // 原文
+ translatedText?: string // 译文(翻译后填入)
+}
+
+// ── YouTube timedtext XML ─────────────────────────────────────────────────────
+// 格式:Hello
+export function parseYouTubeXML(xmlText: string): SubtitleCue[] {
+ try {
+ const doc = new DOMParser().parseFromString(xmlText, 'text/xml')
+ if (doc.querySelector('parsererror')) return []
+ const raw: SubtitleCue[] = []
+ doc.querySelectorAll('text').forEach(node => {
+ const start = parseFloat(node.getAttribute('start') || '0')
+ const dur = parseFloat(node.getAttribute('dur') || '0')
+ const text = decodeEntities(node.textContent || '').trim()
+ if (text) raw.push({ start, end: start + dur, text })
+ })
+ // YouTube timedtext 会产生时间重叠的 cue(滚动字幕效果),
+ // 合并重叠 cue 使碎片句回归完整,减少翻译时的上下文割裂
+ return mergeOverlappingCues(raw)
+ } catch {
+ return []
+ }
+}
+
+/** 合并时间上有重叠的相邻 cue,文本以空格拼接,时间取并集 */
+function mergeOverlappingCues(cues: SubtitleCue[]): SubtitleCue[] {
+ if (!cues.length) return cues
+ const merged: SubtitleCue[] = [{ ...cues[0] }]
+ for (let i = 1; i < cues.length; i++) {
+ const prev = merged[merged.length - 1]
+ const cur = cues[i]
+ if (cur.start < prev.end) {
+ // 重叠:合并文本,延伸结束时间
+ prev.text = prev.text + ' ' + cur.text
+ prev.end = Math.max(prev.end, cur.end)
+ } else {
+ merged.push({ ...cur })
+ }
+ }
+ return merged
+}
+
+// ── WebVTT ────────────────────────────────────────────────────────────────────
+export function parseVTT(vttText: string): SubtitleCue[] {
+ const cues: SubtitleCue[] = []
+ // 统一换行符,按空行分割 cue 块
+ const blocks = vttText.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split(/\n\n+/)
+
+ for (const block of blocks) {
+ const lines = block.trim().split('\n')
+ // 找到包含 --> 的时间行
+ const timeLine = lines.find(l => l.includes('-->'))
+ if (!timeLine) continue
+
+ const [startStr, endStr] = timeLine.split('-->').map(s => s.trim().split(/\s/)[0])
+ const start = vttTimeToSeconds(startStr)
+ const end = vttTimeToSeconds(endStr)
+
+ // 时间行之后的行为字幕文本(去掉 VTT 内联标签)
+ const textLines = lines
+ .slice(lines.indexOf(timeLine) + 1)
+ .map(l => stripVttTags(l))
+ .filter(l => l.trim())
+
+ const text = textLines.join(' ').trim()
+ if (text) cues.push({ start, end, text })
+ }
+ return cues
+}
+
+// ── 辅助函数 ──────────────────────────────────────────────────────────────────
+function vttTimeToSeconds(t: string): number {
+ if (!t) return 0
+ const parts = t.split(':')
+ if (parts.length === 3) {
+ return parseInt(parts[0]) * 3600 + parseInt(parts[1]) * 60 + parseFloat(parts[2])
+ } else if (parts.length === 2) {
+ return parseInt(parts[0]) * 60 + parseFloat(parts[1])
+ }
+ return parseFloat(t) || 0
+}
+
+function stripVttTags(text: string): string {
+ // 去掉 , <00:00:00.000>, , 等 VTT 标签
+ return text.replace(/<[^>]+>/g, '').replace(/&/g, '&')
+}
+
+function decodeEntities(text: string): string {
+ try {
+ const doc = new DOMParser().parseFromString(text, 'text/html')
+ return doc.documentElement.textContent || text
+ } catch {
+ return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/'/g, "'").replace(/"/g, '"')
+ }
+}
+
+// ── YouTube timedtext JSON3 ───────────────────────────────────────────────────
+// 格式:{"events":[{"tStartMs":0,"dDurationMs":5000,"segs":[{"utf8":"Hello"}]},...]}
+export function parseYouTubeJSON3(jsonText: string): SubtitleCue[] {
+ try {
+ const data = JSON.parse(jsonText)
+ if (!data.events) return []
+ const cues: SubtitleCue[] = []
+ for (const event of data.events) {
+ if (!event.segs) continue
+ const text = event.segs.map((s: { utf8?: string }) => s.utf8 || '').join('').trim()
+ if (!text || text === '\n') continue
+ const start = (event.tStartMs || 0) / 1000
+ const end = start + (event.dDurationMs || 0) / 1000
+ cues.push({ start, end, text })
+ }
+ return cues
+ } catch {
+ return []
+ }
+}
+
+/**
+ * 自动检测字幕格式
+ */
+export function detectSubtitleFormat(url: string, data: string): 'youtube-xml' | 'youtube-json3' | 'vtt' | null {
+ if (data.trimStart().startsWith('WEBVTT')) return 'vtt'
+ if (url.match(/\.vtt(\?|#|$)/i)) return 'vtt'
+ if (data.trimStart().startsWith('{')) return 'youtube-json3'
+ if (data.includes(' 元素选择器
+ containerSelector: string // 字幕层挂载目标选择器
+ hideNativeSelector?: string // 需要隐藏的原生字幕容器
+}
+
+export const platforms: PlatformConfig[] = [
+ {
+ id: 'youtube',
+ matches: ['youtube.com', 'youtubekids.com'],
+ subtitleUrlPatterns: ['/api/timedtext'],
+ format: 'youtube-xml',
+ videoSelector: '.html5-video-player video',
+ containerSelector: '.html5-video-player',
+ hideNativeSelector: '.ytp-caption-window-container',
+ },
+ {
+ // 通用 WebVTT:覆盖 Udemy / Coursera / Khan Academy 等大多数学习平台
+ id: 'generic-vtt',
+ matches: ['*'],
+ subtitleUrlPatterns: ['\\.vtt(\\?|#|$)', 'subtitles?.*\\.vtt', '/captions/'],
+ format: 'vtt',
+ videoSelector: 'video',
+ containerSelector: '', // 动态确定:video.parentElement
+ },
+]
+
+/**
+ * 根据当前 hostname 匹配平台配置。
+ * 优先返回精确平台,没有则返回通用 VTT 兜底。
+ */
+export function detectPlatform(hostname: string): PlatformConfig {
+ for (const platform of platforms) {
+ if (platform.id === 'generic-vtt') continue
+ if (platform.matches.some(m => hostname.includes(m))) {
+ return platform
+ }
+ }
+ return platforms.find(p => p.id === 'generic-vtt')!
+}
+
+/** 返回所有平台的字幕 URL 正则列表(去重),发送给注入脚本 */
+export function getAllSubtitlePatterns(): string[] {
+ const set = new Set()
+ for (const platform of platforms) {
+ for (const pattern of platform.subtitleUrlPatterns) {
+ set.add(pattern)
+ }
+ }
+ return Array.from(set)
+}
diff --git a/public/video-subtitle-inject.js b/public/video-subtitle-inject.js
new file mode 100644
index 0000000..a63b766
--- /dev/null
+++ b/public/video-subtitle-inject.js
@@ -0,0 +1,92 @@
+(function () {
+ const EVENT_TYPE = 'fr-subtitle-inject';
+ // 默认 patterns,在 content script 发送配置前就能拦截常见字幕请求
+ let subtitlePatterns = ['/api/timedtext', '\\.vtt(\\?|#|$)', 'subtitles?.*\\.vtt', '/captions/'];
+
+ // 缓存最近一次捕获,用于内容脚本晚于字幕请求就绪时补发
+ var lastCapture = null;
+
+ // ── 工具函数 ──────────────────────────────────────────────
+ function isSubtitleUrl(url) {
+ if (!url || !subtitlePatterns.length) return false;
+ return subtitlePatterns.some(function (pattern) {
+ try { return new RegExp(pattern).test(url); } catch (_) { return false; }
+ });
+ }
+
+ function getUrl(input) {
+ if (!input) return '';
+ if (typeof input === 'string') return input;
+ if (input instanceof URL) return input.href;
+ if (input instanceof Request) return input.url;
+ return String(input);
+ }
+
+ function sendToContent(payload) {
+ window.postMessage(Object.assign({ eventType: EVENT_TYPE }, payload), '*');
+ }
+
+ // ── 接收 Content Script 发来的配置 ───────────────────────
+ window.addEventListener('message', function (event) {
+ if (event.source !== window) return;
+ var data = event.data;
+ if (!data || data.eventType !== EVENT_TYPE) return;
+ if (data.type === 'config') {
+ subtitlePatterns = data.patterns || [];
+ // 内容脚本刚就绪,把已缓存的字幕补发过去
+ if (lastCapture) {
+ sendToContent({ type: 'subtitle-captured', url: lastCapture.url, data: lastCapture.data });
+ }
+ }
+ });
+
+ // ── Hook XMLHttpRequest ───────────────────────────────────
+ var originalOpen = XMLHttpRequest.prototype.open;
+ var originalSend = XMLHttpRequest.prototype.send;
+
+ XMLHttpRequest.prototype.open = function () {
+ this._fr_url = typeof arguments[1] === 'string'
+ ? arguments[1]
+ : (arguments[1] && arguments[1].href) || '';
+ return originalOpen.apply(this, arguments);
+ };
+
+ XMLHttpRequest.prototype.send = function () {
+ var url = this._fr_url;
+ if (url && isSubtitleUrl(url)) {
+ var self = this;
+ self.addEventListener('load', function () {
+ if (self.status === 200 && self.responseText) {
+ lastCapture = { url: url, data: self.responseText };
+ sendToContent({ type: 'subtitle-captured', url: url, data: self.responseText });
+ }
+ });
+ }
+ return originalSend.apply(this, arguments);
+ };
+
+ // ── Hook fetch ────────────────────────────────────────────
+ var originalFetch = window.fetch;
+ if (originalFetch) {
+ window.fetch = function (input, init) {
+ var url = getUrl(input);
+ if (url && isSubtitleUrl(url)) {
+ return originalFetch.call(this, input, init).then(function (response) {
+ if (response.ok) {
+ response.clone().text().then(function (text) {
+ if (text) {
+ lastCapture = { url: url, data: text };
+ sendToContent({ type: 'subtitle-captured', url: url, data: text });
+ }
+ }).catch(function () {});
+ }
+ return response;
+ });
+ }
+ return originalFetch.apply(this, arguments);
+ };
+ }
+
+ // ── 通知 Content Script 注入脚本已就绪 ────────────────────
+ sendToContent({ type: 'ready' });
+})();
diff --git a/wxt.config.ts b/wxt.config.ts
index 4835c97..72055a4 100644
--- a/wxt.config.ts
+++ b/wxt.config.ts
@@ -23,6 +23,25 @@ export default defineConfig({
}),
manifest: {
permissions: ['storage', 'contextMenus', 'offscreen'],
+ // 直接在 manifest 中声明 MAIN world 脚本,绕开 WXT entrypoint 命名体系。
+ // public/video-subtitle-inject.js 会被 WXT 原样复制到扩展根目录。
+ // Chrome 加载 manifest content_scripts 时会绕过页面 CSP,不受 YouTube 等限制。
+ content_scripts: [
+ {
+ // 仅注入已支持的视频平台,避免在无关站点执行 XHR/fetch hook。
+ // 如需新增平台,在此同步添加对应域名。
+ matches: [
+ '*://*.youtube.com/*',
+ '*://*.youtubekids.com/*',
+ '*://*.udemy.com/*',
+ '*://*.coursera.org/*',
+ '*://*.khanacademy.org/*',
+ ],
+ js: ['video-subtitle-inject.js'],
+ world: 'MAIN',
+ run_at: 'document_start',
+ } as any,
+ ],
},
});
\ No newline at end of file