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

@@ -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",
};

View File

@@ -16,6 +16,12 @@
"all_frames": true
}
],
"web_accessible_resources": [
{
"resources": ["injector.js"],
"matches": ["https://www.youtube.com/*"]
}
],
"commands": {
"_execute_browser_action": {
"suggested_key": {

View File

@@ -17,6 +17,12 @@
"all_frames": true
}
],
"web_accessible_resources": [
{
"resources": ["injector.js"],
"matches": ["https://www.youtube.com/*"]
}
],
"commands": {
"_execute_action": {
"suggested_key": {

View File

@@ -22,6 +22,12 @@
"all_frames": true
}
],
"web_accessible_resources": [
{
"resources": ["injector.js"],
"matches": ["https://www.youtube.com/*"]
}
],
"commands": {
"_execute_browser_action": {
"suggested_key": {

View File

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

View File

@@ -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: `譯文樣式`,
},
};

View File

@@ -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";

View File

@@ -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,
},

View File

@@ -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, // 字幕设置
};

10
src/hooks/Subtitle.js Normal file
View File

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

50
src/injector.js Normal file
View File

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

View File

@@ -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

View File

@@ -12,3 +12,44 @@ export const loadingSvg = `
</circle>
</svg>
`;
/**
* 创建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;
};

View File

@@ -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; // 默认配置

View File

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

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

View File

@@ -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: <MouseIcon />,
},
{
id: "subtitle_translate",
label: i18n("subtitle_translate"),
url: "/subtitle",
icon: <SubtitlesIcon />,
},
{
id: "apis_setting",
label: i18n("apis_setting"),

View File

@@ -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 (
<Box>
<Stack spacing={3}>
<FormControlLabel
control={
<Switch
size="small"
name="enabled"
checked={enabled}
onChange={() => {
updateSubtitle({ enabled: !enabled });
}}
/>
}
label={i18n("toggle_subtitle_translate")}
/>
<Box>
<Grid container spacing={2} columns={12}>
<Grid item xs={12} sm={12} md={6} lg={3}>
<TextField
select
fullWidth
size="small"
name="apiSlug"
value={apiSlug}
label={i18n("translate_service")}
onChange={handleChange}
>
{enabledApis.map((api) => (
<MenuItem key={api.apiSlug} value={api.apiSlug}>
{api.apiName}
</MenuItem>
))}
</TextField>
</Grid>
<Grid item xs={12} sm={12} md={6} lg={3}>
<TextField
fullWidth
select
size="small"
name="toLang"
value={toLang}
label={i18n("to_lang")}
onChange={handleChange}
>
{OPT_LANGS_TO.map(([lang, name]) => (
<MenuItem key={lang} value={lang}>
{name}
</MenuItem>
))}
</TextField>
</Grid>
<Grid item xs={12} sm={12} md={6} lg={3}>
<TextField
fullWidth
select
size="small"
name="isBilingual"
value={isBilingual}
label={i18n("is_bilingual_view")}
onChange={handleChange}
>
<MenuItem value={true}>{i18n("enable")}</MenuItem>
<MenuItem value={false}>{i18n("disable")}</MenuItem>
</TextField>
</Grid>
</Grid>
</Box>
<TextField
size="small"
label={i18n("background_styles")}
name="windowStyle"
value={windowStyle}
onChange={handleChange}
maxRows={10}
multiline
fullWidth
/>
<TextField
size="small"
label={i18n("origin_styles")}
name="originStyle"
value={originStyle}
onChange={handleChange}
maxRows={10}
multiline
fullWidth
/>
<TextField
size="small"
label={i18n("translation_styles")}
name="translationStyle"
value={translationStyle}
onChange={handleChange}
maxRows={10}
multiline
fullWidth
/>
</Stack>
</Box>
);
}

View File

@@ -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() {
<Route path="input" element={<InputSetting />} />
<Route path="tranbox" element={<Tranbox />} />
<Route path="mousehover" element={<MouseHoverSetting />} />
<Route path="subtitle" element={<SubtitleSetting />} />
<Route path="apis" element={<Apis />} />
<Route path="sync" element={<SyncSetting />} />
<Route path="words" element={<FavWords />} />