feat: support subtitle translate
This commit is contained in:
@@ -32,6 +32,7 @@ const extWebpack = (config, env) => {
|
|||||||
options: paths.appSrc + "/options.js",
|
options: paths.appSrc + "/options.js",
|
||||||
background: paths.appSrc + "/background.js",
|
background: paths.appSrc + "/background.js",
|
||||||
content: paths.appSrc + "/content.js",
|
content: paths.appSrc + "/content.js",
|
||||||
|
injector: paths.appSrc + "/injector.js",
|
||||||
};
|
};
|
||||||
|
|
||||||
config.output.filename = "[name].js";
|
config.output.filename = "[name].js";
|
||||||
@@ -123,6 +124,7 @@ const userscriptWebpack = (config, env) => {
|
|||||||
config.entry = {
|
config.entry = {
|
||||||
main: paths.appIndexJs,
|
main: paths.appIndexJs,
|
||||||
options: paths.appSrc + "/options.js",
|
options: paths.appSrc + "/options.js",
|
||||||
|
injector: paths.appSrc + "/injector.js",
|
||||||
"kiss-translator.user": paths.appSrc + "/userscript.js",
|
"kiss-translator.user": paths.appSrc + "/userscript.js",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,12 @@
|
|||||||
"all_frames": true
|
"all_frames": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"web_accessible_resources": [
|
||||||
|
{
|
||||||
|
"resources": ["injector.js"],
|
||||||
|
"matches": ["https://www.youtube.com/*"]
|
||||||
|
}
|
||||||
|
],
|
||||||
"commands": {
|
"commands": {
|
||||||
"_execute_browser_action": {
|
"_execute_browser_action": {
|
||||||
"suggested_key": {
|
"suggested_key": {
|
||||||
|
|||||||
@@ -17,6 +17,12 @@
|
|||||||
"all_frames": true
|
"all_frames": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"web_accessible_resources": [
|
||||||
|
{
|
||||||
|
"resources": ["injector.js"],
|
||||||
|
"matches": ["https://www.youtube.com/*"]
|
||||||
|
}
|
||||||
|
],
|
||||||
"commands": {
|
"commands": {
|
||||||
"_execute_action": {
|
"_execute_action": {
|
||||||
"suggested_key": {
|
"suggested_key": {
|
||||||
|
|||||||
@@ -22,6 +22,12 @@
|
|||||||
"all_frames": true
|
"all_frames": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"web_accessible_resources": [
|
||||||
|
{
|
||||||
|
"resources": ["injector.js"],
|
||||||
|
"matches": ["https://www.youtube.com/*"]
|
||||||
|
}
|
||||||
|
],
|
||||||
"commands": {
|
"commands": {
|
||||||
"_execute_browser_action": {
|
"_execute_browser_action": {
|
||||||
"suggested_key": {
|
"suggested_key": {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { handlePing, injectScript } from "./libs/gm";
|
|||||||
import { matchRule } from "./libs/rules";
|
import { matchRule } from "./libs/rules";
|
||||||
import { trySyncAllSubRules } from "./libs/subRules";
|
import { trySyncAllSubRules } from "./libs/subRules";
|
||||||
import { isInBlacklist } from "./libs/blacklist";
|
import { isInBlacklist } from "./libs/blacklist";
|
||||||
|
import { runSubtitle } from "./subtitle/subtitle";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 油猴脚本设置页面
|
* 油猴脚本设置页面
|
||||||
@@ -209,6 +210,9 @@ export async function run(isUserscript = false) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 字幕翻译
|
||||||
|
runSubtitle({ href, setting, rule });
|
||||||
|
|
||||||
// 监听消息
|
// 监听消息
|
||||||
// !isUserscript && runtimeListener(translator);
|
// !isUserscript && runtimeListener(translator);
|
||||||
|
|
||||||
|
|||||||
@@ -1538,4 +1538,34 @@ export const I18N = {
|
|||||||
en: `Detect result`,
|
en: `Detect result`,
|
||||||
zh_TW: `檢測結果`,
|
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: `譯文樣式`,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,3 +24,7 @@ export const MSG_INJECT_CSS = "inject_css";
|
|||||||
export const MSG_UPDATE_CSP = "update_csp";
|
export const MSG_UPDATE_CSP = "update_csp";
|
||||||
export const MSG_BUILTINAI_DETECT = "builtinai_detect";
|
export const MSG_BUILTINAI_DETECT = "builtinai_detect";
|
||||||
export const MSG_BUILTINAI_TRANSLATE = "builtinai_translte";
|
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";
|
||||||
|
|||||||
@@ -190,6 +190,7 @@ const RULES_MAP = {
|
|||||||
},
|
},
|
||||||
"www.youtube.com": {
|
"www.youtube.com": {
|
||||||
rootsSelector: `ytd-page-manager`,
|
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;";}`,
|
transEndHook: `({ parentNode }) => {parentNode.parentElement.style.cssText += "-webkit-line-clamp: unset; max-height: none; height: auto;";}`,
|
||||||
textStyle: OPT_STYLE_DASHBOX,
|
textStyle: OPT_STYLE_DASHBOX,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -95,6 +95,45 @@ export const DEFAULT_TRANBOX_SETTING = {
|
|||||||
enSug: OPT_SUG_YOUDAO, // 英文建议
|
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 = [
|
export const DEFAULT_SUBRULES_LIST = [
|
||||||
{
|
{
|
||||||
@@ -154,4 +193,5 @@ export const DEFAULT_SETTING = {
|
|||||||
mouseHoverSetting: DEFAULT_MOUSE_HOVER_SETTING, // 鼠标悬停翻译
|
mouseHoverSetting: DEFAULT_MOUSE_HOVER_SETTING, // 鼠标悬停翻译
|
||||||
preInit: true, // 是否预加载脚本
|
preInit: true, // 是否预加载脚本
|
||||||
transAllnow: false, // 是否立即全部翻译
|
transAllnow: false, // 是否立即全部翻译
|
||||||
|
subtitleSetting: DEFAULT_SUBTITLE_SETTING, // 字幕设置
|
||||||
};
|
};
|
||||||
|
|||||||
10
src/hooks/Subtitle.js
Normal file
10
src/hooks/Subtitle.js
Normal 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
50
src/injector.js
Normal 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);
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -8,12 +8,21 @@ export const injectInlineJs = (code) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Function to inject external JavaScript file
|
// Function to inject external JavaScript file
|
||||||
export const injectExternalJs = (src) => {
|
export const injectExternalJs = (src, id = "kiss-translator-injector") => {
|
||||||
const el = document.createElement("script");
|
if (document.getElementById(id)) {
|
||||||
el.setAttribute("data-source", "kiss-inject injectExternalJs");
|
return;
|
||||||
el.setAttribute("type", "text/javascript");
|
}
|
||||||
el.setAttribute("src", src);
|
|
||||||
document.body?.appendChild(el);
|
// 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
|
// Function to inject internal CSS code
|
||||||
|
|||||||
@@ -12,3 +12,44 @@ export const loadingSvg = `
|
|||||||
</circle>
|
</circle>
|
||||||
</svg>
|
</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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -207,6 +207,9 @@ export class Translator {
|
|||||||
|
|
||||||
// 13. 简单时间格式 (例如 12:30, 9:45:30) - [新增]
|
// 13. 简单时间格式 (例如 12:30, 9:45:30) - [新增]
|
||||||
/^\d{1,2}:\d{2}(:\d{2})?$/,
|
/^\d{1,2}:\d{2}(:\d{2})?$/,
|
||||||
|
|
||||||
|
// 14. 包含常见扩展名的文件名 (例如: document.pdf, image.jpeg)
|
||||||
|
/^[^\s\\/:]+?\.[a-zA-Z0-9]{2,5}$/,
|
||||||
];
|
];
|
||||||
|
|
||||||
static DEFAULT_OPTIONS = DEFAULT_SETTING; // 默认配置
|
static DEFAULT_OPTIONS = DEFAULT_SETTING; // 默认配置
|
||||||
|
|||||||
@@ -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(" ")) + " …";
|
||||||
|
};
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import InputIcon from "@mui/icons-material/Input";
|
|||||||
import SelectAllIcon from "@mui/icons-material/SelectAll";
|
import SelectAllIcon from "@mui/icons-material/SelectAll";
|
||||||
import EventNoteIcon from "@mui/icons-material/EventNote";
|
import EventNoteIcon from "@mui/icons-material/EventNote";
|
||||||
import MouseIcon from "@mui/icons-material/Mouse";
|
import MouseIcon from "@mui/icons-material/Mouse";
|
||||||
|
import SubtitlesIcon from "@mui/icons-material/Subtitles";
|
||||||
|
|
||||||
function LinkItem({ label, url, icon }) {
|
function LinkItem({ label, url, icon }) {
|
||||||
const match = useMatch(url);
|
const match = useMatch(url);
|
||||||
@@ -59,6 +60,12 @@ export default function Navigator(props) {
|
|||||||
url: "/mousehover",
|
url: "/mousehover",
|
||||||
icon: <MouseIcon />,
|
icon: <MouseIcon />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "subtitle_translate",
|
||||||
|
label: i18n("subtitle_translate"),
|
||||||
|
url: "/subtitle",
|
||||||
|
icon: <SubtitlesIcon />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "apis_setting",
|
id: "apis_setting",
|
||||||
label: i18n("apis_setting"),
|
label: i18n("apis_setting"),
|
||||||
|
|||||||
140
src/views/Options/Subtitle.js
Normal file
140
src/views/Options/Subtitle.js
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ import Tranbox from "./Tranbox";
|
|||||||
import FavWords from "./FavWords";
|
import FavWords from "./FavWords";
|
||||||
import Playgound from "./Playground";
|
import Playgound from "./Playground";
|
||||||
import MouseHoverSetting from "./MouseHover";
|
import MouseHoverSetting from "./MouseHover";
|
||||||
|
import SubtitleSetting from "./Subtitle";
|
||||||
import Loading from "../../hooks/Loading";
|
import Loading from "../../hooks/Loading";
|
||||||
|
|
||||||
export default function Options() {
|
export default function Options() {
|
||||||
@@ -109,6 +110,7 @@ export default function Options() {
|
|||||||
<Route path="input" element={<InputSetting />} />
|
<Route path="input" element={<InputSetting />} />
|
||||||
<Route path="tranbox" element={<Tranbox />} />
|
<Route path="tranbox" element={<Tranbox />} />
|
||||||
<Route path="mousehover" element={<MouseHoverSetting />} />
|
<Route path="mousehover" element={<MouseHoverSetting />} />
|
||||||
|
<Route path="subtitle" element={<SubtitleSetting />} />
|
||||||
<Route path="apis" element={<Apis />} />
|
<Route path="apis" element={<Apis />} />
|
||||||
<Route path="sync" element={<SyncSetting />} />
|
<Route path="sync" element={<SyncSetting />} />
|
||||||
<Route path="words" element={<FavWords />} />
|
<Route path="words" element={<FavWords />} />
|
||||||
|
|||||||
Reference in New Issue
Block a user