feat: support subtitle translate

This commit is contained in:
Gabe
2025-10-07 16:35:00 +08:00
parent df8c96569a
commit b2b5bef9f5
22 changed files with 971 additions and 6 deletions

View File

@@ -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<object>} options.formattedSubtitles - 已格式化好的字幕数组。
* @param {(text: string, toLang: string) => Promise<string>} 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);
}
}
}
}

View File

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

View File

@@ -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
);
});
}

32
src/subtitle/subtitle.js Normal file
View File

@@ -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);
}
}