feat: support subtitle translate
This commit is contained in:
210
src/subtitle/BilingualSubtitleManager.js
Normal file
210
src/subtitle/BilingualSubtitleManager.js
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
312
src/subtitle/YouTubeCaptionProvider.js
Normal file
312
src/subtitle/YouTubeCaptionProvider.js
Normal 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();
|
||||
};
|
||||
})();
|
||||
38
src/subtitle/globalVariable.js
Normal file
38
src/subtitle/globalVariable.js
Normal 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
32
src/subtitle/subtitle.js
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user