feat: supports download subtitle
This commit is contained in:
@@ -690,17 +690,19 @@ export const genTransReq = async ({ reqHook, ...args }) => {
|
||||
}
|
||||
|
||||
if (API_SPE_TYPES.ai.has(apiType)) {
|
||||
args.systemPrompt = genSystemPrompt({
|
||||
systemPrompt: useBatchFetch ? systemPrompt : nobatchPrompt,
|
||||
from,
|
||||
to,
|
||||
fromLang,
|
||||
toLang,
|
||||
texts,
|
||||
docInfo,
|
||||
tone,
|
||||
});
|
||||
args.userPrompt = !!events
|
||||
args.systemPrompt = events
|
||||
? systemPrompt
|
||||
: genSystemPrompt({
|
||||
systemPrompt: useBatchFetch ? systemPrompt : nobatchPrompt,
|
||||
from,
|
||||
to,
|
||||
fromLang,
|
||||
toLang,
|
||||
texts,
|
||||
docInfo,
|
||||
tone,
|
||||
});
|
||||
args.userPrompt = events
|
||||
? JSON.stringify(events)
|
||||
: genUserPrompt({
|
||||
nobatchUserPrompt,
|
||||
|
||||
@@ -1674,9 +1674,14 @@ export const I18N = {
|
||||
zh_TW: `雙語顯示`,
|
||||
},
|
||||
is_skip_ad: {
|
||||
zh: `是否快进广告`,
|
||||
en: `Should I fast forward to the ad?`,
|
||||
zh_TW: `是否快轉廣告`,
|
||||
zh: `快进广告`,
|
||||
en: `Skip AD`,
|
||||
zh_TW: `快轉廣告`,
|
||||
},
|
||||
download_subtitles: {
|
||||
zh: `下载字幕`,
|
||||
en: `Download subtitles`,
|
||||
zh_TW: `下载字幕`,
|
||||
},
|
||||
background_styles: {
|
||||
zh: `背景样式`,
|
||||
@@ -1753,6 +1758,36 @@ export const I18N = {
|
||||
en: `The subtitle data is ready, please click the KT button to load it`,
|
||||
zh_TW: `字幕資料已準備就緒,請點擊KT按鈕加載`,
|
||||
},
|
||||
starting_reprocess_events: {
|
||||
zh: `重新处理字幕数据...`,
|
||||
en: `Reprocess the subtitle data...`,
|
||||
zh_TW: `重新处理字幕数据...`,
|
||||
},
|
||||
waitting_for_subtitle: {
|
||||
zh: `请等待字幕数据`,
|
||||
en: `Please wait for the subtitle data.`,
|
||||
zh_TW: `请等待字幕数据`,
|
||||
},
|
||||
ai_processing_pls_wait: {
|
||||
zh: `AI处理中,请稍等...`,
|
||||
en: `AI processing in progress, please wait...`,
|
||||
zh_TW: `AI处理中,请稍等...`,
|
||||
},
|
||||
processing_subtitles: {
|
||||
zh: `字幕处理中...`,
|
||||
en: `Subtitle processing...`,
|
||||
zh_TW: `字幕处理中...`,
|
||||
},
|
||||
waiting_subtitles: {
|
||||
zh: `等待字幕中`,
|
||||
en: `Waiting for subtitles`,
|
||||
zh_TW: `等待字幕中`,
|
||||
},
|
||||
subtitle_is_not_yet_ready: {
|
||||
zh: `字幕数据尚未准备好`,
|
||||
en: `Subtitle is not yet ready.`,
|
||||
zh_TW: `字幕数据尚未准备好`,
|
||||
},
|
||||
log_level: {
|
||||
zh: `日志级别`,
|
||||
en: `Log Level`,
|
||||
|
||||
@@ -33,3 +33,6 @@ export const EVENT_KISS = "event_kiss_translate";
|
||||
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";
|
||||
|
||||
export const MSG_MENUS_PROGRESSED = "progressed"
|
||||
export const MSG_MENUS_UPDATEFORM = "updateFormData"
|
||||
|
||||
@@ -15,7 +15,13 @@ export default class ShadowDomManager {
|
||||
_ReactComponent;
|
||||
_props;
|
||||
|
||||
constructor({ id, className = "", reactComponent, props = {} }) {
|
||||
constructor({
|
||||
id,
|
||||
className = "",
|
||||
reactComponent,
|
||||
props = {},
|
||||
rootElement = document.body,
|
||||
}) {
|
||||
if (!id || !reactComponent) {
|
||||
throw new Error("ID and a React Component must be provided.");
|
||||
}
|
||||
@@ -23,6 +29,7 @@ export default class ShadowDomManager {
|
||||
this._className = className;
|
||||
this._ReactComponent = reactComponent;
|
||||
this._props = props;
|
||||
this._rootElement = rootElement;
|
||||
}
|
||||
|
||||
get isVisible() {
|
||||
@@ -93,7 +100,7 @@ export default class ShadowDomManager {
|
||||
host.className = this._className;
|
||||
}
|
||||
|
||||
document.body.appendChild(host);
|
||||
this._rootElement.appendChild(host);
|
||||
this.#hostElement = host;
|
||||
const shadowContainer = host.attachShadow({ mode: "open" });
|
||||
const appRoot = document.createElement("div");
|
||||
|
||||
@@ -83,8 +83,8 @@ export function createLogoSVG({
|
||||
const primaryColor = "#209CEE";
|
||||
const secondaryColor = "#E9F5FD";
|
||||
|
||||
const path1Fill = isSelected ? primaryColor : secondaryColor;
|
||||
const path2Fill = isSelected ? secondaryColor : primaryColor;
|
||||
const path1Fill = isSelected ? secondaryColor : primaryColor;
|
||||
const path2Fill = isSelected ? primaryColor : secondaryColor;
|
||||
|
||||
const path1 = createSVGElement("path", {
|
||||
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 ",
|
||||
|
||||
@@ -409,3 +409,76 @@ export const randomBetween = (min, max, integer = true) => {
|
||||
const value = Math.random() * (max - min) + min;
|
||||
return integer ? Math.floor(value) : value;
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据文件名自动获取 MIME 类型
|
||||
* @param {*} filename
|
||||
* @returns
|
||||
*/
|
||||
function getMimeTypeFromFilename(filename) {
|
||||
const defaultType = "application/octet-stream";
|
||||
if (!filename || filename.indexOf(".") === -1) {
|
||||
return defaultType;
|
||||
}
|
||||
|
||||
const extension = filename.split(".").pop().toLowerCase();
|
||||
const mimeMap = {
|
||||
// 文本
|
||||
txt: "text/plain;charset=utf-8",
|
||||
html: "text/html;charset=utf-8",
|
||||
css: "text/css;charset=utf-8",
|
||||
js: "text/javascript;charset=utf-8",
|
||||
json: "application/json;charset=utf-8",
|
||||
xml: "application/xml;charset=utf-8",
|
||||
md: "text/markdown;charset=utf-8",
|
||||
vtt: "text/vtt;charset=utf-8",
|
||||
|
||||
// 图像
|
||||
png: "image/png",
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
gif: "image/gif",
|
||||
svg: "image/svg+xml",
|
||||
webp: "image/webp",
|
||||
ico: "image/x-icon",
|
||||
|
||||
// 音频/视频
|
||||
mp3: "audio/mpeg",
|
||||
mp4: "video/mp4",
|
||||
webm: "video/webm",
|
||||
wav: "audio/wav",
|
||||
|
||||
// 应用程序/文档
|
||||
pdf: "application/pdf",
|
||||
zip: "application/zip",
|
||||
doc: "application/msword",
|
||||
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
xls: "application/vnd.ms-excel",
|
||||
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
};
|
||||
|
||||
// 默认值
|
||||
return mimeMap[extension] || defaultType;
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载文件
|
||||
* @param {*} str
|
||||
* @param {*} filename
|
||||
*/
|
||||
export function downloadBlobFile(str, filename = "kiss-file.txt") {
|
||||
const mimeType = getMimeTypeFromFilename(filename);
|
||||
const blob = new Blob([str], { type: mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const a = document.createElement("a");
|
||||
a.style.display = "none";
|
||||
a.href = url;
|
||||
a.download = filename || `kiss-file.txt`;
|
||||
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
@@ -356,4 +356,8 @@ export class BilingualSubtitleManager {
|
||||
this.#currentSubtitleIndex = -1;
|
||||
this.onTimeUpdate();
|
||||
}
|
||||
|
||||
updateSetting(obj) {
|
||||
this.#setting = { ...this.#setting, ...obj };
|
||||
}
|
||||
}
|
||||
|
||||
177
src/subtitle/Menus.js
Normal file
177
src/subtitle/Menus.js
Normal file
@@ -0,0 +1,177 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { MSG_MENUS_PROGRESSED, MSG_MENUS_UPDATEFORM } from "../config";
|
||||
|
||||
function Label({ children }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuItem({ children, onClick, disabled = false }) {
|
||||
const [hover, setHover] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: "0px 8px",
|
||||
opacity: hover ? 1 : 0.8,
|
||||
background: `rgba(255, 255, 255, ${hover ? 0.1 : 0})`,
|
||||
cursor: disabled ? "default" : "pointer",
|
||||
transition: "background 0.2s, opacity 0.2s",
|
||||
borderRadius: 5,
|
||||
}}
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Switch({ label, name, value, onChange, disabled }) {
|
||||
const handleClick = useCallback(() => {
|
||||
if (disabled) return;
|
||||
|
||||
onChange({ name, value: !value });
|
||||
}, [disabled, onChange, name, value]);
|
||||
|
||||
return (
|
||||
<MenuItem onClick={handleClick} disabled={disabled}>
|
||||
<Label>{label}</Label>
|
||||
<div
|
||||
style={{
|
||||
width: 40,
|
||||
height: 24,
|
||||
borderRadius: 12,
|
||||
background: value ? "rgba(32,156,238,.8)" : "rgba(255,255,255,.3)",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: 10,
|
||||
position: "absolute",
|
||||
left: 2,
|
||||
top: 2,
|
||||
background: "rgba(255,255,255,.9)",
|
||||
transform: `translateX(${value ? 16 : 0}px)`,
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function Button({ label, onClick, disabled }) {
|
||||
const handleClick = useCallback(() => {
|
||||
if (disabled) return;
|
||||
|
||||
onClick();
|
||||
}, [disabled, onClick]);
|
||||
|
||||
return (
|
||||
<MenuItem onClick={handleClick} disabled={disabled}>
|
||||
<Label>{label}</Label>
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
export function Menus({
|
||||
i18n,
|
||||
initData,
|
||||
updateSetting,
|
||||
downloadSubtitle,
|
||||
hasSegApi,
|
||||
eventName,
|
||||
}) {
|
||||
const [formData, setFormData] = useState(initData);
|
||||
const [progressed, setProgressed] = useState(0);
|
||||
|
||||
const handleChange = useCallback(
|
||||
({ name, value }) => {
|
||||
setFormData((pre) => ({ ...pre, [name]: value }));
|
||||
updateSetting({ name, value });
|
||||
},
|
||||
[updateSetting]
|
||||
);
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
downloadSubtitle();
|
||||
}, [downloadSubtitle]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e) => {
|
||||
const { action, data } = e.detail || {};
|
||||
if (action === MSG_MENUS_PROGRESSED) {
|
||||
setProgressed(data);
|
||||
} else if (action === MSG_MENUS_UPDATEFORM) {
|
||||
setFormData((pre) => ({ ...pre, ...data }));
|
||||
}
|
||||
};
|
||||
window.addEventListener(eventName, handler);
|
||||
return () => window.removeEventListener(eventName, handler);
|
||||
}, [eventName]);
|
||||
|
||||
const status = useMemo(() => {
|
||||
if (progressed === 0) return i18n("waiting_subtitles");
|
||||
if (progressed === 100) return i18n("download_subtitles");
|
||||
return i18n("processing_subtitles");
|
||||
}, [progressed, i18n]);
|
||||
|
||||
const { isAISegment, skipAd, isBilingual } = formData;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
bottom: 100,
|
||||
background: "rgba(0,0,0,.6)",
|
||||
width: 200,
|
||||
lineHeight: "40px",
|
||||
fontSize: 16,
|
||||
padding: 8,
|
||||
borderRadius: 5,
|
||||
}}
|
||||
>
|
||||
<Switch
|
||||
onChange={handleChange}
|
||||
name="isAISegment"
|
||||
value={isAISegment}
|
||||
label={i18n("ai_segmentation")}
|
||||
disabled={!hasSegApi}
|
||||
/>
|
||||
<Switch
|
||||
onChange={handleChange}
|
||||
name="isBilingual"
|
||||
value={isBilingual}
|
||||
label={i18n("is_bilingual_view")}
|
||||
/>
|
||||
<Switch
|
||||
onChange={handleChange}
|
||||
name="skipAd"
|
||||
value={skipAd}
|
||||
label={i18n("is_skip_ad")}
|
||||
/>
|
||||
<Button
|
||||
label={`${status} [${progressed}%] `}
|
||||
onClick={handleDownload}
|
||||
disabled={progressed !== 100}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,34 +6,63 @@ import {
|
||||
APP_NAME,
|
||||
OPT_LANGS_TO_CODE,
|
||||
OPT_TRANS_MICROSOFT,
|
||||
MSG_MENUS_PROGRESSED,
|
||||
MSG_MENUS_UPDATEFORM,
|
||||
} from "../config";
|
||||
import { sleep } from "../libs/utils.js";
|
||||
import { sleep, genEventName, downloadBlobFile } from "../libs/utils.js";
|
||||
import { createLogoSVG } from "../libs/svg.js";
|
||||
import { randomBetween } from "../libs/utils.js";
|
||||
import { newI18n } from "../config";
|
||||
import ShadowDomManager from "../libs/shadowDomManager.js";
|
||||
import { Menus } from "./Menus.js";
|
||||
import { buildBilingualVtt } from "./vtt.js";
|
||||
|
||||
const VIDEO_SELECT = "#container video";
|
||||
const CONTORLS_SELECT = ".ytp-right-controls";
|
||||
const YT_CAPTION_SELECT = "#ytp-caption-window-container";
|
||||
const YT_AD_SELECT = ".video-ads";
|
||||
const YT_SUBTITLE_BTN_SELECT = "button.ytp-subtitles-button";
|
||||
|
||||
class YouTubeCaptionProvider {
|
||||
#setting = {};
|
||||
#videoId = "";
|
||||
|
||||
#subtitles = [];
|
||||
#flatEvents = [];
|
||||
#progressedNum = 0;
|
||||
#fromLang = "auto";
|
||||
|
||||
#processingId = null;
|
||||
|
||||
#managerInstance = null;
|
||||
#toggleButton = null;
|
||||
#enabled = false;
|
||||
#ytControls = null;
|
||||
#isBusy = false;
|
||||
#fromLang = "auto";
|
||||
#isMenuShow = false;
|
||||
#notificationEl = null;
|
||||
#notificationTimeout = null;
|
||||
#i18n = () => "";
|
||||
#menuEventName = "kiss-event";
|
||||
|
||||
constructor(setting = {}) {
|
||||
this.#setting = setting;
|
||||
this.#setting = { ...setting, isAISegment: false };
|
||||
this.#i18n = newI18n(setting.uiLang || "zh");
|
||||
this.#menuEventName = genEventName();
|
||||
}
|
||||
|
||||
get #videoId() {
|
||||
const docUrl = new URL(document.location.href);
|
||||
return docUrl.searchParams.get("v");
|
||||
}
|
||||
|
||||
get #videoEl() {
|
||||
return document.querySelector(VIDEO_SELECT);
|
||||
}
|
||||
|
||||
set #progressed(num) {
|
||||
this.#progressedNum = num;
|
||||
this.#sendMenusMsg({ action: MSG_MENUS_PROGRESSED, data: num });
|
||||
}
|
||||
|
||||
get #progressed() {
|
||||
return this.#progressedNum;
|
||||
}
|
||||
|
||||
initialize() {
|
||||
@@ -47,35 +76,47 @@ class YouTubeCaptionProvider {
|
||||
});
|
||||
|
||||
window.addEventListener("yt-navigate-finish", () => {
|
||||
setTimeout(() => {
|
||||
if (this.#toggleButton) {
|
||||
this.#toggleButton.style.opacity = "0.5";
|
||||
}
|
||||
this.#destroyManager();
|
||||
this.#doubleClick();
|
||||
}, 1000);
|
||||
logger.debug("Youtube Provider: yt-navigate-finish", this.#videoId);
|
||||
|
||||
this.#destroyManager();
|
||||
|
||||
this.#subtitles = [];
|
||||
this.#flatEvents = [];
|
||||
this.#progressed = 0;
|
||||
this.#fromLang = "auto";
|
||||
this.#setting.isAISegment = false;
|
||||
this.#sendMenusMsg({
|
||||
action: MSG_MENUS_UPDATEFORM,
|
||||
data: { isAISegment: false },
|
||||
});
|
||||
});
|
||||
|
||||
this.#waitForElement(CONTORLS_SELECT, (ytControls) =>
|
||||
this.#injectToggleButton(ytControls)
|
||||
);
|
||||
this.#waitForElement(CONTORLS_SELECT, (ytControls) => {
|
||||
const ytSubtitleBtn = ytControls.querySelector(YT_SUBTITLE_BTN_SELECT);
|
||||
if (ytSubtitleBtn) {
|
||||
ytSubtitleBtn.addEventListener("click", () => {
|
||||
if (ytSubtitleBtn.getAttribute("aria-pressed") === "true") {
|
||||
this.#startManager();
|
||||
} else {
|
||||
this.#destroyManager();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.#injectToggleButton(ytControls);
|
||||
});
|
||||
|
||||
this.#waitForElement(YT_AD_SELECT, (adContainer) => {
|
||||
this.#moAds(adContainer);
|
||||
});
|
||||
}
|
||||
|
||||
get #videoEl() {
|
||||
return document.querySelector(VIDEO_SELECT);
|
||||
}
|
||||
|
||||
#moAds(adContainer) {
|
||||
const { skipAd = false } = this.#setting;
|
||||
|
||||
const adLayoutSelector = ".ytp-ad-player-overlay-layout";
|
||||
const skipBtnSelector =
|
||||
".ytp-skip-ad-button, .ytp-ad-skip-button, .ytp-ad-skip-button-modern";
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
const { skipAd = false } = this.#setting;
|
||||
for (const mutation of mutations) {
|
||||
if (mutation.type === "childList") {
|
||||
const videoEl = this.#videoEl;
|
||||
@@ -149,60 +190,94 @@ class YouTubeCaptionProvider {
|
||||
});
|
||||
}
|
||||
|
||||
async #doubleClick() {
|
||||
const button = this.#ytControls?.querySelector(
|
||||
"button.ytp-subtitles-button"
|
||||
);
|
||||
if (button) {
|
||||
await sleep(randomBetween(50, 100));
|
||||
button.click();
|
||||
await sleep(randomBetween(500, 1000));
|
||||
button.click();
|
||||
updateSetting({ name, value }) {
|
||||
if (this.#setting[name] === value) return;
|
||||
|
||||
logger.debug("Youtube Provider: update setting", name, value);
|
||||
this.#setting[name] = value;
|
||||
|
||||
if (name === "isBilingual") {
|
||||
this.#managerInstance?.updateSetting({ [name]: value });
|
||||
} else if (name === "isAISegment") {
|
||||
this.#reProcessEvents();
|
||||
}
|
||||
}
|
||||
|
||||
#injectToggleButton(ytControls) {
|
||||
this.#ytControls = ytControls;
|
||||
downloadSubtitle() {
|
||||
if (!this.#subtitles.length || this.#progressed !== 100) {
|
||||
logger.debug("Youtube Provider: The subtitle is not yet ready.");
|
||||
this.#showNotification(this.#i18n("subtitle_is_not_yet_ready"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const vtt = buildBilingualVtt(this.#subtitles);
|
||||
downloadBlobFile(
|
||||
vtt,
|
||||
`kiss-subtitles-${this.#videoId}_${Date.now()}.vtt`
|
||||
);
|
||||
} catch (error) {
|
||||
logger.info("Youtube Provider: download subtitles:", error);
|
||||
}
|
||||
}
|
||||
|
||||
#sendMenusMsg({ action, data }) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(this.#menuEventName, { detail: { action, data } })
|
||||
);
|
||||
}
|
||||
|
||||
#injectToggleButton(ytControls) {
|
||||
const kissControls = document.createElement("div");
|
||||
kissControls.className = "notranslate kiss-subtitle-controls";
|
||||
Object.assign(kissControls.style, {
|
||||
height: "100%",
|
||||
position: "relative",
|
||||
});
|
||||
|
||||
const toggleButton = document.createElement("button");
|
||||
toggleButton.className = "ytp-button kiss-subtitle-button";
|
||||
toggleButton.title = APP_NAME;
|
||||
Object.assign(toggleButton.style, {
|
||||
color: "white",
|
||||
opacity: "0.5",
|
||||
});
|
||||
|
||||
toggleButton.appendChild(createLogoSVG());
|
||||
kissControls.appendChild(toggleButton);
|
||||
|
||||
toggleButton.onclick = () => {
|
||||
if (this.#isBusy) {
|
||||
logger.info(`Youtube Provider: It's budy now...`);
|
||||
this.#showNotification(this.#i18n("subtitle_data_processing"));
|
||||
}
|
||||
const { segApiSetting, isAISegment, skipAd, isBilingual } = this.#setting;
|
||||
const menu = new ShadowDomManager({
|
||||
id: "kiss-subtitle-menus",
|
||||
className: "notranslate",
|
||||
reactComponent: Menus,
|
||||
rootElement: kissControls,
|
||||
props: {
|
||||
i18n: this.#i18n,
|
||||
updateSetting: this.updateSetting.bind(this),
|
||||
downloadSubtitle: this.downloadSubtitle.bind(this),
|
||||
hasSegApi: !!segApiSetting,
|
||||
eventName: this.#menuEventName,
|
||||
initData: {
|
||||
isAISegment,
|
||||
skipAd,
|
||||
isBilingual,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!this.#enabled) {
|
||||
logger.info(`Youtube Provider: Feature toggled ON.`);
|
||||
this.#enabled = true;
|
||||
toggleButton.onclick = () => {
|
||||
if (!this.#isMenuShow) {
|
||||
this.#isMenuShow = true;
|
||||
this.#toggleButton?.replaceChildren(
|
||||
createLogoSVG({ isSelected: true })
|
||||
);
|
||||
this.#startManager();
|
||||
menu.show();
|
||||
} else {
|
||||
logger.info(`Youtube Provider: Feature toggled OFF.`);
|
||||
this.#enabled = false;
|
||||
this.#isMenuShow = false;
|
||||
this.#toggleButton?.replaceChildren(createLogoSVG());
|
||||
this.#destroyManager();
|
||||
menu.hide();
|
||||
}
|
||||
};
|
||||
this.#toggleButton = toggleButton;
|
||||
this.#ytControls?.prepend(kissControls);
|
||||
|
||||
ytControls?.prepend(kissControls);
|
||||
}
|
||||
|
||||
#isSameLang(lang1, lang2) {
|
||||
@@ -290,11 +365,6 @@ class YouTubeCaptionProvider {
|
||||
}
|
||||
}
|
||||
|
||||
#getVideoId() {
|
||||
const docUrl = new URL(document.location.href);
|
||||
return docUrl.searchParams.get("v");
|
||||
}
|
||||
|
||||
async #aiSegment({ videoId, fromLang, toLang, chunkEvents, segApiSetting }) {
|
||||
try {
|
||||
const events = chunkEvents.filter((item) => item.text);
|
||||
@@ -326,36 +396,38 @@ class YouTubeCaptionProvider {
|
||||
}
|
||||
|
||||
async #handleInterceptedRequest(url, responseText) {
|
||||
if (this.#isBusy) {
|
||||
logger.info("Youtube Provider is busy...");
|
||||
const videoId = this.#videoId;
|
||||
if (!videoId) {
|
||||
logger.debug("Youtube Provider: videoId not found.");
|
||||
return;
|
||||
}
|
||||
this.#isBusy = true;
|
||||
|
||||
const potUrl = new URL(url);
|
||||
if (videoId !== potUrl.searchParams.get("v")) {
|
||||
logger.debug("Youtube Provider: skip other timedtext:", videoId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.#flatEvents.length) {
|
||||
logger.debug("Youtube Provider: video was processed:", videoId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (videoId === this.#processingId) {
|
||||
logger.debug("Youtube Provider: video is processing:", videoId);
|
||||
return;
|
||||
}
|
||||
|
||||
this.#processingId = videoId;
|
||||
|
||||
try {
|
||||
const videoId = this.#getVideoId();
|
||||
if (!videoId) {
|
||||
logger.info("Youtube Provider: videoId not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (videoId === this.#videoId) {
|
||||
logger.info("Youtube Provider: videoId already processed.");
|
||||
return;
|
||||
}
|
||||
|
||||
const potUrl = new URL(url);
|
||||
if (videoId !== potUrl.searchParams.get("v")) {
|
||||
logger.info("Youtube Provider: skip other timedtext.");
|
||||
return;
|
||||
}
|
||||
|
||||
const { segApiSetting, toLang } = this.#setting;
|
||||
this.#showNotification(this.#i18n("starting_to_process_subtitle"));
|
||||
|
||||
const { toLang } = this.#setting;
|
||||
const captionTracks = await this.#getCaptionTracks(videoId);
|
||||
const captionTrack = this.#findCaptionTrack(captionTracks);
|
||||
if (!captionTrack) {
|
||||
logger.info("Youtube Provider: CaptionTrack not found.");
|
||||
logger.debug("Youtube Provider: CaptionTrack not found:", videoId);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -366,7 +438,7 @@ class YouTubeCaptionProvider {
|
||||
responseText
|
||||
);
|
||||
if (!events?.length) {
|
||||
logger.info("Youtube Provider: SubtitleEvents not got.");
|
||||
logger.debug("Youtube Provider: events not got:", videoId);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -380,108 +452,131 @@ class YouTubeCaptionProvider {
|
||||
`Youtube Provider: fromLang: ${fromLang}, toLang: ${toLang}`
|
||||
);
|
||||
if (this.#isSameLang(fromLang, toLang)) {
|
||||
logger.info("Youtube Provider: skip same lang", fromLang, toLang);
|
||||
logger.debug("Youtube Provider: skip same lang", fromLang, toLang);
|
||||
return;
|
||||
}
|
||||
|
||||
this.#showNotification(this.#i18n("starting_to_process_subtitle"));
|
||||
const flatEvents = this.#genFlatEvents(events);
|
||||
if (!flatEvents?.length) {
|
||||
logger.debug("Youtube Provider: flatEvents not got:", videoId);
|
||||
return;
|
||||
}
|
||||
|
||||
const flatEvents = this.#flatEvents(events);
|
||||
if (!flatEvents.length) return;
|
||||
this.#flatEvents = flatEvents;
|
||||
this.#fromLang = fromLang;
|
||||
|
||||
if (potUrl.searchParams.get("kind") === "asr" && segApiSetting) {
|
||||
logger.info("Youtube Provider: Starting AI ...");
|
||||
this.#processEvents({
|
||||
videoId,
|
||||
flatEvents,
|
||||
fromLang,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn("Youtube Provider: handle subtitle", error);
|
||||
this.#showNotification(this.#i18n("subtitle_load_failed"));
|
||||
} finally {
|
||||
this.#processingId = null;
|
||||
}
|
||||
}
|
||||
|
||||
const eventChunks = this.#splitEventsIntoChunks(
|
||||
flatEvents,
|
||||
segApiSetting.chunkLength
|
||||
async #processEvents({ videoId, flatEvents, fromLang }) {
|
||||
try {
|
||||
const [subtitles, progressed] = await this.#eventsToSubtitles({
|
||||
videoId,
|
||||
flatEvents,
|
||||
fromLang,
|
||||
});
|
||||
if (!subtitles?.length) {
|
||||
logger.debug(
|
||||
"Youtube Provider: events to subtitles got empty",
|
||||
videoId
|
||||
);
|
||||
const subtitlesFallback = () =>
|
||||
this.#formatSubtitles(flatEvents, fromLang);
|
||||
return;
|
||||
}
|
||||
|
||||
if (eventChunks.length === 0) {
|
||||
this.#onCaptionsReady({
|
||||
videoId,
|
||||
subtitles: subtitlesFallback(),
|
||||
fromLang,
|
||||
isInitialLoad: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const firstChunkEvents = eventChunks[0];
|
||||
const firstBatchSubtitles = await this.#aiSegment({
|
||||
if (videoId !== this.#videoId) {
|
||||
logger.debug(
|
||||
"Youtube Provider: videoId changed!",
|
||||
videoId,
|
||||
this.#videoId
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.#subtitles = subtitles;
|
||||
this.#progressed = progressed;
|
||||
|
||||
this.#startManager();
|
||||
} catch (error) {
|
||||
logger.info("Youtube Provider: process events", error);
|
||||
this.#showNotification(this.#i18n("subtitle_load_failed"));
|
||||
}
|
||||
}
|
||||
|
||||
#reProcessEvents() {
|
||||
const videoId = this.#videoId;
|
||||
const flatEvents = this.#flatEvents;
|
||||
const fromLang = this.#fromLang;
|
||||
if (!videoId || !flatEvents.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#showNotification(this.#i18n("starting_reprocess_events"));
|
||||
|
||||
this.#destroyManager();
|
||||
|
||||
this.#processEvents({ videoId, flatEvents, fromLang });
|
||||
}
|
||||
|
||||
async #eventsToSubtitles({ videoId, flatEvents, fromLang }) {
|
||||
const { isAISegment, segApiSetting, chunkLength, toLang } = this.#setting;
|
||||
const subtitlesFallback = () => [
|
||||
this.#formatSubtitles(flatEvents, fromLang),
|
||||
100,
|
||||
];
|
||||
|
||||
// potUrl.searchParams.get("kind") === "asr"
|
||||
if (isAISegment && segApiSetting) {
|
||||
logger.info("Youtube Provider: Starting AI ...");
|
||||
this.#showNotification(this.#i18n("ai_processing_pls_wait"));
|
||||
|
||||
const eventChunks = this.#splitEventsIntoChunks(flatEvents, chunkLength);
|
||||
|
||||
if (eventChunks.length === 0) {
|
||||
return subtitlesFallback();
|
||||
}
|
||||
|
||||
const firstChunkEvents = eventChunks[0];
|
||||
const firstBatchSubtitles = await this.#aiSegment({
|
||||
videoId,
|
||||
chunkEvents: firstChunkEvents,
|
||||
fromLang,
|
||||
toLang,
|
||||
segApiSetting,
|
||||
});
|
||||
|
||||
if (!firstBatchSubtitles?.length) {
|
||||
return subtitlesFallback();
|
||||
}
|
||||
|
||||
const chunkCount = eventChunks.length;
|
||||
if (chunkCount > 1) {
|
||||
const remainingChunks = eventChunks.slice(1);
|
||||
this.#processRemainingChunksAsync({
|
||||
chunks: remainingChunks,
|
||||
chunkCount,
|
||||
videoId,
|
||||
chunkEvents: firstChunkEvents,
|
||||
fromLang,
|
||||
toLang,
|
||||
segApiSetting,
|
||||
});
|
||||
|
||||
if (!firstBatchSubtitles?.length) {
|
||||
this.#onCaptionsReady({
|
||||
videoId,
|
||||
subtitles: subtitlesFallback(),
|
||||
fromLang,
|
||||
isInitialLoad: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.#onCaptionsReady({
|
||||
videoId,
|
||||
subtitles: firstBatchSubtitles,
|
||||
fromLang,
|
||||
isInitialLoad: true,
|
||||
});
|
||||
|
||||
if (eventChunks.length > 1) {
|
||||
const remainingChunks = eventChunks.slice(1);
|
||||
this.#processRemainingChunksAsync({
|
||||
chunks: remainingChunks,
|
||||
videoId,
|
||||
fromLang,
|
||||
toLang,
|
||||
segApiSetting,
|
||||
});
|
||||
}
|
||||
return [firstBatchSubtitles, 100 / eventChunks.length];
|
||||
} else {
|
||||
const subtitles = this.#formatSubtitles(flatEvents, fromLang);
|
||||
if (!subtitles?.length) {
|
||||
logger.info("Youtube Provider: No subtitles after format.");
|
||||
return;
|
||||
}
|
||||
|
||||
this.#onCaptionsReady({
|
||||
videoId,
|
||||
subtitles,
|
||||
fromLang,
|
||||
isInitialLoad: true,
|
||||
});
|
||||
return [firstBatchSubtitles, 100];
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn("Youtube Provider: unknow error", error);
|
||||
this.#showNotification(this.#i18n("subtitle_load_failed"));
|
||||
} finally {
|
||||
this.#isBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
#onCaptionsReady({ videoId, subtitles, fromLang }) {
|
||||
this.#subtitles = subtitles;
|
||||
this.#videoId = videoId;
|
||||
this.#fromLang = fromLang;
|
||||
|
||||
if (this.#toggleButton) {
|
||||
this.#toggleButton.style.opacity = subtitles.length ? "1" : "0.5";
|
||||
}
|
||||
|
||||
this.#destroyManager();
|
||||
if (this.#enabled) {
|
||||
this.#startManager();
|
||||
} else {
|
||||
this.#showNotification(this.#i18n("subtitle_data_is_ready"));
|
||||
}
|
||||
return subtitlesFallback();
|
||||
}
|
||||
|
||||
#startManager() {
|
||||
@@ -489,11 +584,8 @@ class YouTubeCaptionProvider {
|
||||
return;
|
||||
}
|
||||
|
||||
const videoId = this.#getVideoId();
|
||||
if (!this.#subtitles?.length || this.#videoId !== videoId) {
|
||||
logger.info("Youtube Provider: No subtitles");
|
||||
this.#showNotification(this.#i18n("try_get_subtitle_data"));
|
||||
this.#doubleClick();
|
||||
if (!this.#subtitles.length) {
|
||||
this.#showNotification(this.#i18n("waitting_for_subtitle"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -746,7 +838,7 @@ class YouTubeCaptionProvider {
|
||||
return sentences;
|
||||
}
|
||||
|
||||
#flatEvents(events = []) {
|
||||
#genFlatEvents(events = []) {
|
||||
const segments = [];
|
||||
let buffer = null;
|
||||
|
||||
@@ -829,6 +921,7 @@ class YouTubeCaptionProvider {
|
||||
|
||||
async #processRemainingChunksAsync({
|
||||
chunks,
|
||||
chunkCount,
|
||||
videoId,
|
||||
fromLang,
|
||||
toLang,
|
||||
@@ -839,7 +932,7 @@ class YouTubeCaptionProvider {
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
const chunkEvents = chunks[i];
|
||||
const chunkNum = i + 2;
|
||||
logger.info(
|
||||
logger.debug(
|
||||
`Youtube Provider: Processing subtitle chunk ${chunkNum}/${chunks.length + 1}: ${chunkEvents[0]?.start} --> ${chunkEvents[chunkEvents.length - 1]?.start}`
|
||||
);
|
||||
|
||||
@@ -857,7 +950,7 @@ class YouTubeCaptionProvider {
|
||||
if (aiSubtitles?.length > 0) {
|
||||
subtitlesForThisChunk = aiSubtitles;
|
||||
} else {
|
||||
logger.info(
|
||||
logger.debug(
|
||||
`Youtube Provider: AI segmentation for chunk ${chunkNum} returned no data.`
|
||||
);
|
||||
subtitlesForThisChunk = this.#formatSubtitles(chunkEvents, fromLang);
|
||||
@@ -866,19 +959,29 @@ class YouTubeCaptionProvider {
|
||||
subtitlesForThisChunk = this.#formatSubtitles(chunkEvents, fromLang);
|
||||
}
|
||||
|
||||
if (this.#getVideoId() !== videoId) {
|
||||
logger.info("Youtube Provider: videoId changed!");
|
||||
if (videoId !== this.#videoId) {
|
||||
logger.info(
|
||||
"Youtube Provider: videoId changed!!",
|
||||
videoId,
|
||||
this.#videoId
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
if (subtitlesForThisChunk.length > 0 && this.#managerInstance) {
|
||||
logger.info(
|
||||
`Youtube Provider: Appending ${subtitlesForThisChunk.length} subtitles from chunk ${chunkNum}.`
|
||||
if (subtitlesForThisChunk.length > 0) {
|
||||
const progressed = (chunkNum * 100) / chunkCount;
|
||||
this.#subtitles.push(...subtitlesForThisChunk);
|
||||
this.#progressed = progressed;
|
||||
|
||||
logger.debug(
|
||||
`Youtube Provider: Appending ${subtitlesForThisChunk.length} subtitles from chunk ${chunkNum} (${this.#progressed}%).`
|
||||
);
|
||||
this.#subtitles.push(subtitlesForThisChunk);
|
||||
this.#managerInstance.appendSubtitles(subtitlesForThisChunk);
|
||||
|
||||
if (this.#managerInstance) {
|
||||
this.#managerInstance.appendSubtitles(subtitlesForThisChunk);
|
||||
}
|
||||
} else {
|
||||
logger.info(`Youtube Provider: Chunk ${chunkNum} no subtitles.`);
|
||||
logger.debug(`Youtube Provider: Chunk ${chunkNum} no subtitles.`);
|
||||
}
|
||||
|
||||
await sleep(randomBetween(500, 1000));
|
||||
|
||||
@@ -54,6 +54,25 @@ function parseTimestampToMilliseconds(timestamp) {
|
||||
return (hours * 3600 + minutes * 60 + seconds) * 1000 + milliseconds;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将毫秒数转换为VTT时间戳字符串 (HH:MM:SS.mmm).
|
||||
*
|
||||
* @param {number} ms - 总毫秒数.
|
||||
* @returns {string} - 格式化的VTT时间戳 (HH:MM:SS.mmm).
|
||||
*/
|
||||
function formatMillisecondsToTimestamp(ms) {
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
const milliseconds = String(ms % 1000).padStart(3, "0");
|
||||
|
||||
const totalMinutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = String(totalSeconds % 60).padStart(2, "0");
|
||||
|
||||
const hours = String(Math.floor(totalMinutes / 60)).padStart(2, "0");
|
||||
const minutes = String(totalMinutes % 60).padStart(2, "0");
|
||||
|
||||
return `${hours}:${minutes}:${seconds}.${milliseconds}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析包含双语字幕的VTT文件内容。
|
||||
* @param {string} vttText - VTT文件的文本内容。
|
||||
@@ -97,3 +116,31 @@ export function parseBilingualVtt(vttText) {
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 parseBilingualVtt 生成的JSON数据转换回标准的VTT字幕字符串。
|
||||
* @param {Array<Object>} cues - 字幕对象数组,
|
||||
* @returns {string} - 格式化的VTT文件内容字符串。
|
||||
*/
|
||||
export function buildBilingualVtt(cues) {
|
||||
if (!Array.isArray(cues)) {
|
||||
return "WEBVTT";
|
||||
}
|
||||
|
||||
const header = "WEBVTT";
|
||||
|
||||
const cueBlocks = cues.map((cue, index) => {
|
||||
const startTime = formatMillisecondsToTimestamp(cue.start);
|
||||
const endTime = formatMillisecondsToTimestamp(cue.end);
|
||||
|
||||
const cueIndex = index + 1;
|
||||
const timestampLine = `${startTime} --> ${endTime}`;
|
||||
|
||||
const textLine = cue.text || "";
|
||||
const translationLine = cue.translation || "";
|
||||
|
||||
return `${cueIndex}\n${timestampLine}\n${textLine}\n${translationLine}`;
|
||||
});
|
||||
|
||||
return [header, ...cueBlocks].join("\n\n");
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import FileDownloadIcon from "@mui/icons-material/FileDownload";
|
||||
import LoadingButton from "@mui/lab/LoadingButton";
|
||||
import { useState } from "react";
|
||||
import { kissLog } from "../../libs/log";
|
||||
import { downloadBlobFile } from "../../libs/utils";
|
||||
|
||||
export default function DownloadButton({ handleData, text, fileName }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -10,13 +11,7 @@ export default function DownloadButton({ handleData, text, fileName }) {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await handleData();
|
||||
const url = window.URL.createObjectURL(new Blob([data]));
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.setAttribute("download", fileName || `${Date.now()}.json`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
downloadBlobFile(data, fileName);
|
||||
} catch (err) {
|
||||
kissLog("download", err);
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user