From b2b5bef9f57d8426c75444fd4dc694e3ae58b28f Mon Sep 17 00:00:00 2001 From: Gabe Date: Tue, 7 Oct 2025 16:35:00 +0800 Subject: [PATCH] feat: support subtitle translate --- config-overrides.js | 2 + public/manifest.firefox.json | 6 + public/manifest.json | 6 + public/manifest.thunderbird.json | 6 + src/common.js | 4 + src/config/i18n.js | 30 +++ src/config/msg.js | 4 + src/config/rules.js | 1 + src/config/setting.js | 40 +++ src/hooks/Subtitle.js | 10 + src/injector.js | 50 ++++ src/libs/injector.js | 21 +- src/libs/svg.js | 41 +++ src/libs/translator.js | 3 + src/libs/utils.js | 12 + src/subtitle/BilingualSubtitleManager.js | 210 +++++++++++++++ src/subtitle/YouTubeCaptionProvider.js | 312 +++++++++++++++++++++++ src/subtitle/globalVariable.js | 38 +++ src/subtitle/subtitle.js | 32 +++ src/views/Options/Navigator.js | 7 + src/views/Options/Subtitle.js | 140 ++++++++++ src/views/Options/index.js | 2 + 22 files changed, 971 insertions(+), 6 deletions(-) create mode 100644 src/hooks/Subtitle.js create mode 100644 src/injector.js create mode 100644 src/subtitle/BilingualSubtitleManager.js create mode 100644 src/subtitle/YouTubeCaptionProvider.js create mode 100644 src/subtitle/globalVariable.js create mode 100644 src/subtitle/subtitle.js create mode 100644 src/views/Options/Subtitle.js diff --git a/config-overrides.js b/config-overrides.js index ea3478d..d279188 100644 --- a/config-overrides.js +++ b/config-overrides.js @@ -32,6 +32,7 @@ const extWebpack = (config, env) => { options: paths.appSrc + "/options.js", background: paths.appSrc + "/background.js", content: paths.appSrc + "/content.js", + injector: paths.appSrc + "/injector.js", }; config.output.filename = "[name].js"; @@ -123,6 +124,7 @@ const userscriptWebpack = (config, env) => { config.entry = { main: paths.appIndexJs, options: paths.appSrc + "/options.js", + injector: paths.appSrc + "/injector.js", "kiss-translator.user": paths.appSrc + "/userscript.js", }; diff --git a/public/manifest.firefox.json b/public/manifest.firefox.json index 41c390b..ad8d567 100644 --- a/public/manifest.firefox.json +++ b/public/manifest.firefox.json @@ -16,6 +16,12 @@ "all_frames": true } ], + "web_accessible_resources": [ + { + "resources": ["injector.js"], + "matches": ["https://www.youtube.com/*"] + } + ], "commands": { "_execute_browser_action": { "suggested_key": { diff --git a/public/manifest.json b/public/manifest.json index 0cb84e3..8756847 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -17,6 +17,12 @@ "all_frames": true } ], + "web_accessible_resources": [ + { + "resources": ["injector.js"], + "matches": ["https://www.youtube.com/*"] + } + ], "commands": { "_execute_action": { "suggested_key": { diff --git a/public/manifest.thunderbird.json b/public/manifest.thunderbird.json index 5ca7f2a..9237257 100644 --- a/public/manifest.thunderbird.json +++ b/public/manifest.thunderbird.json @@ -22,6 +22,12 @@ "all_frames": true } ], + "web_accessible_resources": [ + { + "resources": ["injector.js"], + "matches": ["https://www.youtube.com/*"] + } + ], "commands": { "_execute_browser_action": { "suggested_key": { diff --git a/src/common.js b/src/common.js index 93d7bc5..043af27 100644 --- a/src/common.js +++ b/src/common.js @@ -18,6 +18,7 @@ import { handlePing, injectScript } from "./libs/gm"; import { matchRule } from "./libs/rules"; import { trySyncAllSubRules } from "./libs/subRules"; import { isInBlacklist } from "./libs/blacklist"; +import { runSubtitle } from "./subtitle/subtitle"; /** * 油猴脚本设置页面 @@ -209,6 +210,9 @@ export async function run(isUserscript = false) { return; } + // 字幕翻译 + runSubtitle({ href, setting, rule }); + // 监听消息 // !isUserscript && runtimeListener(translator); diff --git a/src/config/i18n.js b/src/config/i18n.js index 083c71d..9490a30 100644 --- a/src/config/i18n.js +++ b/src/config/i18n.js @@ -1538,4 +1538,34 @@ export const I18N = { en: `Detect result`, zh_TW: `檢測結果`, }, + subtitle_translate: { + zh: `字幕翻译`, + en: `Subtitle translate`, + zh_TW: `字幕翻譯`, + }, + toggle_subtitle_translate: { + zh: `启用字幕翻译`, + en: `Enable subtitle translation`, + zh_TW: `啟用字幕翻譯`, + }, + is_bilingual_view: { + zh: `启用双语显示`, + en: `DEnable bilingual display`, + zh_TW: `啟用雙語顯示`, + }, + background_styles: { + zh: `背景样式`, + en: `DBackground Style`, + zh_TW: `背景樣式`, + }, + origin_styles: { + zh: `原文样式`, + en: `Original style`, + zh_TW: `原文樣式`, + }, + translation_styles: { + zh: `译文样式`, + en: `Translation style`, + zh_TW: `譯文樣式`, + }, }; diff --git a/src/config/msg.js b/src/config/msg.js index fe38579..605c91e 100644 --- a/src/config/msg.js +++ b/src/config/msg.js @@ -24,3 +24,7 @@ export const MSG_INJECT_CSS = "inject_css"; export const MSG_UPDATE_CSP = "update_csp"; export const MSG_BUILTINAI_DETECT = "builtinai_detect"; export const MSG_BUILTINAI_TRANSLATE = "builtinai_translte"; + +export const MSG_XHR_DATA_YOUTUBE = "KISS_XHR_DATA_YOUTUBE"; +export const MSG_GLOBAL_VAR_FETCH = "KISS_GLOBAL_VAR_FETCH"; +export const MSG_GLOBAL_VAR_BACK = "KISS_GLOBAL_VAR_BACK"; diff --git a/src/config/rules.js b/src/config/rules.js index cda5f67..a8224ea 100644 --- a/src/config/rules.js +++ b/src/config/rules.js @@ -190,6 +190,7 @@ const RULES_MAP = { }, "www.youtube.com": { rootsSelector: `ytd-page-manager`, + ignoreSelector: `aside, button, footer, form, header, pre, mark, nav, #ytp-caption-window-container`, transEndHook: `({ parentNode }) => {parentNode.parentElement.style.cssText += "-webkit-line-clamp: unset; max-height: none; height: auto;";}`, textStyle: OPT_STYLE_DASHBOX, }, diff --git a/src/config/setting.js b/src/config/setting.js index 2f79e6e..4c3f641 100644 --- a/src/config/setting.js +++ b/src/config/setting.js @@ -95,6 +95,45 @@ export const DEFAULT_TRANBOX_SETTING = { enSug: OPT_SUG_YOUDAO, // 英文建议 }; +const SUBTITLE_WINDOW_STYLE = `container-type: inline-size; +position: absolute; +bottom: 10%; +left: 50%; +transform: translateX(-50%); +width: 80%; +padding: 10px; +background-color: rgba(0, 0, 0, 0.7); +color: white; +text-align: center; +line-height: 1.2; +text-shadow: 1px 1px 2px black; +pointer-events: none; +z-index: 2147483647; +opacity: 0; +cursor: grab; +transition: opacity 0.2s ease-in-out;`; + +const SUBTITLE_ORIGIN_STYLE = `margin:0; +padding: 0; +opacity: 0.8; +font-size: clamp(2rem, 4cqw, 4rem);`; + +const SUBTITLE_TRANSLATION_STYLE = `margin:0; +padding: 0; +opacity: 1; +font-size: clamp(2rem, 4.5cqw, 4rem);`; + +export const DEFAULT_SUBTITLE_SETTING = { + enabled: true, // 是否开启 + apiSlug: OPT_TRANS_MICROSOFT, + // fromLang: "en", + toLang: "zh-CN", + isBilingual: true, // 是否双语显示 + windowStyle: SUBTITLE_WINDOW_STYLE, // 背景样式 + originStyle: SUBTITLE_ORIGIN_STYLE, // 原文样式 + translationStyle: SUBTITLE_TRANSLATION_STYLE, // 译文样式 +}; + // 订阅列表 export const DEFAULT_SUBRULES_LIST = [ { @@ -154,4 +193,5 @@ export const DEFAULT_SETTING = { mouseHoverSetting: DEFAULT_MOUSE_HOVER_SETTING, // 鼠标悬停翻译 preInit: true, // 是否预加载脚本 transAllnow: false, // 是否立即全部翻译 + subtitleSetting: DEFAULT_SUBTITLE_SETTING, // 字幕设置 }; diff --git a/src/hooks/Subtitle.js b/src/hooks/Subtitle.js new file mode 100644 index 0000000..b045625 --- /dev/null +++ b/src/hooks/Subtitle.js @@ -0,0 +1,10 @@ +import { DEFAULT_SUBTITLE_SETTING } from "../config"; +import { useSetting } from "./Setting"; + +export function useSubtitle() { + const { setting, updateChild } = useSetting(); + const subtitleSetting = setting?.subtitleSetting || DEFAULT_SUBTITLE_SETTING; + const updateSubtitle = updateChild("subtitleSetting"); + + return { subtitleSetting, updateSubtitle }; +} diff --git a/src/injector.js b/src/injector.js new file mode 100644 index 0000000..1d25e44 --- /dev/null +++ b/src/injector.js @@ -0,0 +1,50 @@ +import { + MSG_XHR_DATA_YOUTUBE, + MSG_GLOBAL_VAR_FETCH, + MSG_GLOBAL_VAR_BACK, +} from "./config"; + +// 响应window全局对象查询 +(function () { + window.addEventListener("message", (event) => { + if ( + event.source === window && + event.data && + event.data.type === MSG_GLOBAL_VAR_FETCH + ) { + const { varName, requestId } = event.data; + if (varName) { + const value = window[varName]; + window.postMessage( + { + type: MSG_GLOBAL_VAR_BACK, + payload: value, + requestId: requestId, + }, + window.location.origin + ); + } + } + }); +})(); + +// 拦截字幕数据 +(function () { + const originalOpen = XMLHttpRequest.prototype.open; + XMLHttpRequest.prototype.open = function (...args) { + const url = args[1]; + if (typeof url === "string" && url.includes("timedtext")) { + this.addEventListener("load", function () { + window.postMessage( + { + type: MSG_XHR_DATA_YOUTUBE, + url: this.responseURL, + response: this.responseText, + }, + window.location.origin + ); + }); + } + return originalOpen.apply(this, args); + }; +})(); diff --git a/src/libs/injector.js b/src/libs/injector.js index aa5f87c..8adaa4f 100644 --- a/src/libs/injector.js +++ b/src/libs/injector.js @@ -8,12 +8,21 @@ export const injectInlineJs = (code) => { }; // Function to inject external JavaScript file -export const injectExternalJs = (src) => { - const el = document.createElement("script"); - el.setAttribute("data-source", "kiss-inject injectExternalJs"); - el.setAttribute("type", "text/javascript"); - el.setAttribute("src", src); - document.body?.appendChild(el); +export const injectExternalJs = (src, id = "kiss-translator-injector") => { + if (document.getElementById(id)) { + return; + } + + // const el = document.createElement("script"); + // el.setAttribute("data-source", "kiss-inject injectExternalJs"); + // el.setAttribute("type", "text/javascript"); + // el.setAttribute("src", src); + // el.setAttribute("id", id); + // document.body?.appendChild(el); + const script = document.createElement("script"); + script.id = id; + script.src = src; + (document.head || document.documentElement).appendChild(script); }; // Function to inject internal CSS code diff --git a/src/libs/svg.js b/src/libs/svg.js index 536facf..14ad5f5 100644 --- a/src/libs/svg.js +++ b/src/libs/svg.js @@ -12,3 +12,44 @@ export const loadingSvg = ` `; + +/** + * 创建logo + * @param {*} param0 + * @returns + */ +export const createLogoSvg = ({ + width = "100%", + height = "100%", + viewBox = "-13 -14 60 60", +} = {}) => { + const svgNS = "http://www.w3.org/2000/svg"; + const svgElement = document.createElementNS(svgNS, "svg"); + + svgElement.setAttribute("xmlns", svgNS); + svgElement.setAttribute("width", width); + svgElement.setAttribute("height", height); + svgElement.setAttribute("viewBox", viewBox); + svgElement.setAttribute("version", "1.1"); + + const path1 = document.createElementNS(svgNS, "path"); + path1.setAttribute( + "d", + "M0 0 C10.56 0 21.12 0 32 0 C32 10.56 32 21.12 32 32 C21.44 32 10.88 32 0 32 C0 21.44 0 10.88 0 0 Z " + ); + path1.setAttribute("fill", "#209CEE"); + path1.setAttribute("transform", "translate(0,0)"); + + const path2 = document.createElementNS(svgNS, "path"); + path2.setAttribute( + "d", + "M0 0 C0.66 0 1.32 0 2 0 C2 2.97 2 5.94 2 9 C2.969375 8.2575 3.93875 7.515 4.9375 6.75 C5.48277344 6.33234375 6.02804688 5.9146875 6.58984375 5.484375 C8.39053593 3.83283924 8.39053593 3.83283924 9 0 C13.95 0 18.9 0 24 0 C24 0.99 24 1.98 24 3 C22.68 3 21.36 3 20 3 C20 9.27 20 15.54 20 22 C19.01 22 18.02 22 17 22 C17 15.73 17 9.46 17 3 C15.35 3 13.7 3 12 3 C11.731875 3.598125 11.46375 4.19625 11.1875 4.8125 C10.01506533 6.97224808 8.80630718 8.35790256 7 10 C8.01790655 12.27071461 8.77442829 13.80784632 10.6875 15.4375 C11.120625 15.953125 11.55375 16.46875 12 17 C11.6875 19.6875 11.6875 19.6875 11 22 C10.34 22 9.68 22 9 22 C8.773125 21.236875 8.54625 20.47375 8.3125 19.6875 C6.73268318 16.45263699 5.16717283 15.58358642 2 14 C2 16.64 2 19.28 2 22 C1.34 22 0.68 22 0 22 C0 14.74 0 7.48 0 0 Z " + ); + path2.setAttribute("fill", "#E9F5FD"); + path2.setAttribute("transform", "translate(4,5)"); + + svgElement.appendChild(path1); + svgElement.appendChild(path2); + + return svgElement; +}; diff --git a/src/libs/translator.js b/src/libs/translator.js index 53ccd0c..76091fd 100644 --- a/src/libs/translator.js +++ b/src/libs/translator.js @@ -207,6 +207,9 @@ export class Translator { // 13. 简单时间格式 (例如 12:30, 9:45:30) - [新增] /^\d{1,2}:\d{2}(:\d{2})?$/, + + // 14. 包含常见扩展名的文件名 (例如: document.pdf, image.jpeg) + /^[^\s\\/:]+?\.[a-zA-Z0-9]{2,5}$/, ]; static DEFAULT_OPTIONS = DEFAULT_SETTING; // 默认配置 diff --git a/src/libs/utils.js b/src/libs/utils.js index eba1944..412682d 100644 --- a/src/libs/utils.js +++ b/src/libs/utils.js @@ -350,3 +350,15 @@ export const withTimeout = (task, timeout, timeoutMsg = "Task timed out") => { ), ]); }; + +/** + * 截短字符串 + * @param {*} str + * @param {*} maxLength + * @returns + */ +export const truncateWords = (str, maxLength) => { + if (str.length <= maxLength) return str; + const truncated = str.slice(0, maxLength); + return truncated.slice(0, truncated.lastIndexOf(" ")) + " …"; +}; diff --git a/src/subtitle/BilingualSubtitleManager.js b/src/subtitle/BilingualSubtitleManager.js new file mode 100644 index 0000000..a14b55b --- /dev/null +++ b/src/subtitle/BilingualSubtitleManager.js @@ -0,0 +1,210 @@ +import { logger } from "../libs/log.js"; + +/** + * @class BilingualSubtitleManager + * @description 负责在视频上显示和翻译字幕的核心逻辑 + */ +export class BilingualSubtitleManager { + #videoEl; + #formattedSubtitles = []; + #translationService; + #captionWindowEl = null; + #currentSubtitleIndex = -1; + #preTranslateSeconds = 60; + #setting = {}; + + /** + * @param {object} options + * @param {HTMLVideoElement} options.videoEl - 页面上的 video 元素。 + * @param {Array} options.formattedSubtitles - 已格式化好的字幕数组。 + * @param {(text: string, toLang: string) => Promise} options.translationService - 外部翻译函数。 + * @param {object} options.setting - 配置对象,如目标翻译语言。 + */ + constructor({ videoEl, formattedSubtitles, translationService, setting }) { + this.#setting = setting; + this.#videoEl = videoEl; + this.#formattedSubtitles = formattedSubtitles; + this.#translationService = translationService; + + this.onTimeUpdate = this.onTimeUpdate.bind(this); + this.onSeek = this.onSeek.bind(this); + } + + /** + * 启动字幕显示和翻译。 + */ + start() { + if (this.#formattedSubtitles.length === 0) { + logger.warn("Bilingual Subtitles: No subtitles to display."); + return; + } + + logger.info("Bilingual Subtitle Manager: Starting..."); + this.#createCaptionWindow(); + this.#attachEventListeners(); + this.onTimeUpdate(); + } + + /** + * 销毁实例,清理资源。 + */ + destroy() { + logger.info("Bilingual Subtitle Manager: Destroying..."); + this.#removeEventListeners(); + this.#captionWindowEl?.parentElement?.remove(); + this.#formattedSubtitles = []; + } + + /** + * 创建并配置用于显示字幕的 DOM 元素。 + */ + #createCaptionWindow() { + const container = document.createElement("div"); + container.className = `kiss-caption-window-container notranslate`; + container.style.cssText = `position:absolute; width:100%; height:100%; left:0; top:0;`; + + this.#captionWindowEl = document.createElement("div"); + this.#captionWindowEl.className = `kiss-caption-window`; + this.#captionWindowEl.style.cssText = this.#setting.windowStyle; + + container.appendChild(this.#captionWindowEl); + + const videoContainer = this.#videoEl.parentElement?.parentElement; + if (!videoContainer) { + logger.warn("could not find videoContainer"); + return; + } + + videoContainer.style.position = "relative"; + videoContainer.appendChild(container); + } + + /** + * 绑定视频元素的 timeupdate 和 seeked 事件监听器。 + */ + #attachEventListeners() { + this.#videoEl.addEventListener("timeupdate", this.onTimeUpdate); + this.#videoEl.addEventListener("seeked", this.onSeek); + } + + /** + * 移除事件监听器。 + */ + #removeEventListeners() { + this.#videoEl.removeEventListener("timeupdate", this.onTimeUpdate); + this.#videoEl.removeEventListener("seeked", this.onSeek); + } + + /** + * 视频播放时间更新时的回调,负责更新字幕和触发预翻译。 + */ + onTimeUpdate() { + const currentTimeMs = this.#videoEl.currentTime * 1000; + const subtitleIndex = this.#findSubtitleIndexForTime(currentTimeMs); + + if (subtitleIndex !== this.#currentSubtitleIndex) { + this.#currentSubtitleIndex = subtitleIndex; + const subtitle = + subtitleIndex !== -1 ? this.#formattedSubtitles[subtitleIndex] : null; + this.#updateCaptionDisplay(subtitle); + } + + this.#triggerTranslations(currentTimeMs); + } + + /** + * 用户拖动进度条后的回调。 + */ + onSeek() { + this.#currentSubtitleIndex = -1; + this.onTimeUpdate(); + } + + /** + * 根据时间(毫秒)查找对应的字幕索引。 + * @param {number} currentTimeMs + * @returns {number} 找到的字幕索引,-1 表示没找到。 + */ + #findSubtitleIndexForTime(currentTimeMs) { + return this.#formattedSubtitles.findIndex( + (sub) => currentTimeMs >= sub.start && currentTimeMs <= sub.end + ); + } + + /** + * 更新字幕窗口的显示内容。 + * @param {object | null} subtitle - 字幕对象,或 null 用于清空。 + */ + #updateCaptionDisplay(subtitle) { + if (!this.#captionWindowEl) return; + + if (subtitle) { + const p1 = document.createElement("p"); + p1.style.cssText = this.#setting.originStyle; + p1.textContent = subtitle.text; + + const p2 = document.createElement("p"); + p2.style.cssText = this.#setting.originStyle; + p2.textContent = subtitle.translation || "..."; + + if (this.#setting.isBilingual) { + this.#captionWindowEl.replaceChildren(p1, p2); + } else { + this.#captionWindowEl.replaceChildren(p2); + } + + this.#captionWindowEl.style.opacity = "1"; + } else { + this.#captionWindowEl.style.opacity = "0"; + } + } + + /** + * 提前翻译指定时间范围内的字幕。 + * @param {number} currentTimeMs + */ + #triggerTranslations(currentTimeMs) { + const lookAheadMs = this.#preTranslateSeconds * 1000; + + for (const sub of this.#formattedSubtitles) { + const isCurrent = sub.start <= currentTimeMs && sub.end >= currentTimeMs; + const isUpcoming = + sub.start > currentTimeMs && sub.start <= currentTimeMs + lookAheadMs; + const needsTranslation = !sub.translation && !sub.isTranslating; + + if ((isCurrent || isUpcoming) && needsTranslation) { + this.#translateAndStore(sub); + } + } + } + + /** + * 执行单个字幕的翻译并更新其状态。 + * @param {object} subtitle - 需要翻译的字幕对象。 + */ + async #translateAndStore(subtitle) { + subtitle.isTranslating = true; + try { + const { toLang, apiSetting } = this.#setting; + const [translatedText] = await this.#translationService({ + text: subtitle.text, + fromLang: "en", + toLang, + apiSetting, + }); + subtitle.translation = translatedText; + } catch (error) { + logger.error("Translation failed for:", subtitle.text, error); + subtitle.translation = "[Translation failed]"; + } finally { + subtitle.isTranslating = false; + + const currentSubtitleIndexNow = this.#findSubtitleIndexForTime( + this.#videoEl.currentTime * 1000 + ); + if (this.#formattedSubtitles[currentSubtitleIndexNow] === subtitle) { + this.#updateCaptionDisplay(subtitle); + } + } + } +} diff --git a/src/subtitle/YouTubeCaptionProvider.js b/src/subtitle/YouTubeCaptionProvider.js new file mode 100644 index 0000000..dff5287 --- /dev/null +++ b/src/subtitle/YouTubeCaptionProvider.js @@ -0,0 +1,312 @@ +import { logger } from "../libs/log.js"; +import { apiTranslate } from "../apis/index.js"; +import { BilingualSubtitleManager } from "./BilingualSubtitleManager.js"; +import { getGlobalVariable } from "./globalVariable.js"; +import { MSG_XHR_DATA_YOUTUBE } from "../config"; +import { truncateWords } from "../libs/utils.js"; +import { createLogoSvg } from "../libs/svg.js"; + +const VIDEO_SELECT = "#container video"; +const CONTORLS_SELECT = ".ytp-right-controls"; +const YT_CAPTION_SELECT = "#ytp-caption-window-container"; + +class YouTubeCaptionProvider { + #setting = {}; + #videoId = ""; + #subtitles = []; + #managerInstance = null; + + constructor(setting = {}) { + this.#setting = setting; + } + + initialize() { + window.addEventListener("message", (event) => { + if (event.source !== window) return; + if (event.data?.type === MSG_XHR_DATA_YOUTUBE) { + const { url, response } = event.data; + this.#handleInterceptedRequest(url, response); + } + }); + this.#waitForElement(CONTORLS_SELECT, () => this.#injectToggleButton()); + } + + #waitForElement(selector, callback) { + const element = document.querySelector(selector); + if (element) { + callback(element); + return; + } + + const observer = new MutationObserver((mutations, obs) => { + const targetNode = document.querySelector(selector); + if (targetNode) { + obs.disconnect(); + callback(targetNode); + } + }); + + observer.observe(document.body, { + childList: true, + subtree: true, + }); + } + + #injectToggleButton() { + const controls = document.querySelector(CONTORLS_SELECT); + if (!controls) { + logger.warn("Youtube Provider: Could not find YouTube player controls."); + return; + } + + const kissControls = document.createElement("div"); + kissControls.className = "kiss-bilingual-subtitle-controls"; + Object.assign(kissControls.style, { + height: "100%", + }); + + const toggleButton = document.createElement("button"); + toggleButton.className = + "ytp-button notranslate kiss-bilingual-subtitle-button"; + toggleButton.title = "Toggle Bilingual Subtitles"; + Object.assign(toggleButton.style, { + color: "white", + opacity: "0.8", + }); + + toggleButton.appendChild(createLogoSvg()); + kissControls.appendChild(toggleButton); + + toggleButton.onclick = () => { + if (!this.#managerInstance) { + logger.info(`Youtube Provider: Feature toggled ON.`); + toggleButton.style.opacity = "1"; + this.#setting.enabled = true; + this.#startManager(); + } else { + logger.info(`Youtube Provider: Feature toggled OFF.`); + toggleButton.style.opacity = "0.5"; + this.#setting.enabled = false; + this.#destroyManager(); + } + }; + + controls.before(kissControls); + } + + #findCaptionTrack(ytPlayer) { + const captionTracks = + ytPlayer?.captions?.playerCaptionsTracklistRenderer?.captionTracks || []; + let captionTrack = captionTracks.find((item) => item.vssId === ".en"); + if (!captionTrack) { + captionTrack = captionTracks.find((item) => item.vssId === "a.en"); + } + return captionTrack; + } + + async #getSubtitleEvents(captionTrack, potUrl, responseText) { + if (potUrl.searchParams.get("lang") === captionTrack.languageCode) { + try { + return JSON.parse(responseText)?.events; + } catch (err) { + logger.error("parse responseText", err); + return null; + } + } + + try { + const baseUrl = new URL(captionTrack.baseUrl); + baseUrl.searchParams.set("potc", potUrl.searchParams.get("potc")); + baseUrl.searchParams.set("pot", potUrl.searchParams.get("pot")); + baseUrl.searchParams.set("fmt", "json3"); + baseUrl.searchParams.set("c", potUrl.searchParams.get("c")); + if (potUrl.searchParams.get("kind")) { + baseUrl.searchParams.set("kind", potUrl.searchParams.get("kind")); + } + + const res = await fetch(baseUrl); + if (res.ok) { + const json = await res.json(); + return json?.events; + } + logger.error( + `Youtube Provider: Failed to fetch subtitles: ${res.status}` + ); + return null; + } catch (error) { + logger.error("Youtube Provider: fetching subtitles error", error); + return null; + } + } + + async #handleInterceptedRequest(url, responseText) { + try { + const ytPlayer = await getGlobalVariable("ytInitialPlayerResponse"); + const captionTrack = this.#findCaptionTrack(ytPlayer); + if (!captionTrack) { + logger.warn("Youtube Provider: CaptionTrack not found."); + return; + } + + const potUrl = new URL(url); + const { videoId } = ytPlayer.videoDetails || {}; + if (videoId !== potUrl.searchParams.get("v")) { + logger.info("Youtube Provider: skip other timedtext."); + return; + } + + if (videoId === this.#videoId) { + logger.info("Youtube Provider: skip fetched timedtext."); + return; + } + + const subtitleEvents = await this.#getSubtitleEvents( + captionTrack, + potUrl, + responseText + ); + if (!subtitleEvents) { + logger.warn("Youtube Provider: SubtitleEvents not got."); + return; + } + + this.#onCaptionsReady(videoId, subtitleEvents); + } catch (error) { + logger.error("Youtube Provider: unknow error", error); + } + } + + #onCaptionsReady(videoId, subtitleEvents) { + this.#subtitles = this.#formatSubtitles(subtitleEvents); + this.#videoId = videoId; + + this.#destroyManager(); + + if (this.#setting.enabled) { + this.#startManager(); + } + } + + #startManager() { + if (this.#managerInstance) { + return; + } + + const videoEl = document.querySelector(VIDEO_SELECT); + if (!videoEl) { + logger.warn("Youtube Provider: No video element found"); + return; + } + + if (this.#subtitles?.length === 0) { + // todo: 等待并给出用户提示 + logger.info("Youtube Provider: No subtitles"); + return; + } + + logger.info("Youtube Provider: Starting manager..."); + + const ytCaption = document.querySelector(YT_CAPTION_SELECT); + ytCaption && (ytCaption.style.display = "none"); + + this.#managerInstance = new BilingualSubtitleManager({ + videoEl, + formattedSubtitles: this.#subtitles, + translationService: apiTranslate, + setting: this.#setting, + }); + this.#managerInstance.start(); + } + + #destroyManager() { + if (this.#managerInstance) { + logger.info("Youtube Provider: Destroying manager..."); + + const ytCaption = document.querySelector(YT_CAPTION_SELECT); + ytCaption && (ytCaption.style.display = "block"); + + this.#managerInstance.destroy(); + this.#managerInstance = null; + } + } + + // todo: 没有标点断句的处理 + #formatSubtitles(events) { + if (!Array.isArray(events)) return []; + + const lines = []; + let currentLine = null; + + events.forEach((event) => { + (event.segs ?? []).forEach((seg, segIndex) => { + const text = seg.utf8 ?? ""; + const trimmedText = text.trim(); + const segmentStartTime = event.tStartMs + (seg.tOffsetMs ?? 0); + + if (currentLine) { + if (currentLine.text.endsWith(",") && !text.startsWith(" ")) { + currentLine.text += " "; + } + currentLine.text += text.replaceAll("\n", " "); + } else if (trimmedText) { + if (lines.length > 0) { + const prevLine = lines[lines.length - 1]; + if (!prevLine.end) { + prevLine.end = segmentStartTime; + } + } + currentLine = { + text: text.replaceAll("\n", " "), + start: segmentStartTime, + end: 0, + }; + } + + const isEndOfSentence = /[.?!\]]$/.test(trimmedText); + if (currentLine && trimmedText && isEndOfSentence) { + const isLastSegmentInEvent = + segIndex === (event.segs?.length ?? 0) - 1; + if (isLastSegmentInEvent && event.dDurationMs) { + currentLine.end = event.tStartMs + event.dDurationMs; + } + lines.push(currentLine); + currentLine = null; + } + }); + }); + + if (lines.length > 0) { + const lastLine = lines[lines.length - 1]; + if (!lastLine.end) { + const lastMeaningfulEvent = [...events] + .reverse() + .find((e) => e.dDurationMs); + if (lastMeaningfulEvent) { + lastLine.end = + lastMeaningfulEvent.tStartMs + lastMeaningfulEvent.dDurationMs; + } + } + } + + return lines.map((line) => ({ + ...line, + duration: Math.max(0, line.end - line.start), + text: truncateWords(line.text.trim().replace(/\s+/g, " "), 300), + })); + } +} + +export const YouTubeInitializer = (() => { + let initialized = false; + + return async (setting) => { + if (initialized) { + return; + } + initialized = true; + + logger.info("Bilingual Subtitle Extension: Initializing..."); + const provider = new YouTubeCaptionProvider(setting); + provider.initialize(); + }; +})(); diff --git a/src/subtitle/globalVariable.js b/src/subtitle/globalVariable.js new file mode 100644 index 0000000..5748104 --- /dev/null +++ b/src/subtitle/globalVariable.js @@ -0,0 +1,38 @@ +import { genEventName } from "../libs/utils"; +import { MSG_GLOBAL_VAR_BACK, MSG_GLOBAL_VAR_FETCH } from "../config"; + +export function getGlobalVariable(varName, timeout = 10000) { + return new Promise((resolve, reject) => { + const requestId = genEventName(); + let timeoutId = null; + + const responseHandler = (event) => { + if ( + event.source === window && + event.data && + event.data.type === MSG_GLOBAL_VAR_BACK && + event.data.requestId === requestId + ) { + clearTimeout(timeoutId); + window.removeEventListener("message", responseHandler); + resolve(event.data.payload); + } + }; + + window.addEventListener("message", responseHandler); + + timeoutId = setTimeout(() => { + window.removeEventListener("message", responseHandler); + reject(new Error(`Read "${varName}" timeout: ${timeout}ms`)); + }, timeout); + + window.postMessage( + { + type: MSG_GLOBAL_VAR_FETCH, + varName: varName, + requestId: requestId, + }, + window.location.origin + ); + }); +} diff --git a/src/subtitle/subtitle.js b/src/subtitle/subtitle.js new file mode 100644 index 0000000..c0d3408 --- /dev/null +++ b/src/subtitle/subtitle.js @@ -0,0 +1,32 @@ +import { YouTubeInitializer } from "./YouTubeCaptionProvider.js"; +import { browser } from "../libs/browser.js"; +import { isMatch } from "../libs/utils.js"; +import { DEFAULT_API_SETTING } from "../config/api.js"; +import { DEFAULT_SUBTITLE_SETTING } from "../config/setting.js"; +import { injectExternalJs } from "../libs/injector.js"; +import { logger } from "../libs/log.js"; + +const providers = [ + { pattern: "https://www.youtube.com/watch", start: YouTubeInitializer }, +]; + +export function runSubtitle({ href, setting, rule }) { + try { + const provider = providers.find((item) => isMatch(href, item.pattern)); + if (provider) { + const id = "kiss-translator-injector"; + const src = browser.runtime.getURL("injector.js"); + injectExternalJs(src, id); + + const apiSetting = + setting.transApis.find((api) => api.apiSlug === rule.apiSlug) || + DEFAULT_API_SETTING; + provider.start({ + ...(setting.subtitleSetting || DEFAULT_SUBTITLE_SETTING), + apiSetting, + }); + } + } catch (err) { + logger.error("start subtitle provider", err); + } +} diff --git a/src/views/Options/Navigator.js b/src/views/Options/Navigator.js index 0e40946..8464268 100644 --- a/src/views/Options/Navigator.js +++ b/src/views/Options/Navigator.js @@ -15,6 +15,7 @@ import InputIcon from "@mui/icons-material/Input"; import SelectAllIcon from "@mui/icons-material/SelectAll"; import EventNoteIcon from "@mui/icons-material/EventNote"; import MouseIcon from "@mui/icons-material/Mouse"; +import SubtitlesIcon from "@mui/icons-material/Subtitles"; function LinkItem({ label, url, icon }) { const match = useMatch(url); @@ -59,6 +60,12 @@ export default function Navigator(props) { url: "/mousehover", icon: , }, + { + id: "subtitle_translate", + label: i18n("subtitle_translate"), + url: "/subtitle", + icon: , + }, { id: "apis_setting", label: i18n("apis_setting"), diff --git a/src/views/Options/Subtitle.js b/src/views/Options/Subtitle.js new file mode 100644 index 0000000..1467f60 --- /dev/null +++ b/src/views/Options/Subtitle.js @@ -0,0 +1,140 @@ +import Box from "@mui/material/Box"; +import Stack from "@mui/material/Stack"; +import TextField from "@mui/material/TextField"; +import MenuItem from "@mui/material/MenuItem"; +import Grid from "@mui/material/Grid"; +import { useI18n } from "../../hooks/I18n"; +import { OPT_LANGS_TO } from "../../config"; +import FormControlLabel from "@mui/material/FormControlLabel"; +import Switch from "@mui/material/Switch"; +import { useSubtitle } from "../../hooks/Subtitle"; +import { useApiList } from "../../hooks/Api"; + +export default function SubtitleSetting() { + const i18n = useI18n(); + const { subtitleSetting, updateSubtitle } = useSubtitle(); + const { enabledApis } = useApiList(); + + const handleChange = (e) => { + e.preventDefault(); + let { name, value } = e.target; + updateSubtitle({ + [name]: value, + }); + }; + + const { + enabled, + apiSlug, + toLang, + isBilingual, + windowStyle, + originStyle, + translationStyle, + } = subtitleSetting; + + return ( + + + { + updateSubtitle({ enabled: !enabled }); + }} + /> + } + label={i18n("toggle_subtitle_translate")} + /> + + + + + + {enabledApis.map((api) => ( + + {api.apiName} + + ))} + + + + + {OPT_LANGS_TO.map(([lang, name]) => ( + + {name} + + ))} + + + + + + {i18n("enable")} + {i18n("disable")} + + + + + + + + + + + ); +} diff --git a/src/views/Options/index.js b/src/views/Options/index.js index 02d5ba6..b3a1475 100644 --- a/src/views/Options/index.js +++ b/src/views/Options/index.js @@ -23,6 +23,7 @@ import Tranbox from "./Tranbox"; import FavWords from "./FavWords"; import Playgound from "./Playground"; import MouseHoverSetting from "./MouseHover"; +import SubtitleSetting from "./Subtitle"; import Loading from "../../hooks/Loading"; export default function Options() { @@ -109,6 +110,7 @@ export default function Options() { } /> } /> } /> + } /> } /> } /> } />