diff --git a/src/apis/trans.js b/src/apis/trans.js
index 0f575e3..5768f30 100644
--- a/src/apis/trans.js
+++ b/src/apis/trans.js
@@ -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,
diff --git a/src/config/i18n.js b/src/config/i18n.js
index 209d905..334a23f 100644
--- a/src/config/i18n.js
+++ b/src/config/i18n.js
@@ -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`,
diff --git a/src/config/msg.js b/src/config/msg.js
index 6d867a9..64812f4 100644
--- a/src/config/msg.js
+++ b/src/config/msg.js
@@ -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"
diff --git a/src/libs/shadowDomManager.js b/src/libs/shadowDomManager.js
index 7562793..c0ac965 100644
--- a/src/libs/shadowDomManager.js
+++ b/src/libs/shadowDomManager.js
@@ -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");
diff --git a/src/libs/svg.js b/src/libs/svg.js
index f2645ab..9119532 100644
--- a/src/libs/svg.js
+++ b/src/libs/svg.js
@@ -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 ",
diff --git a/src/libs/utils.js b/src/libs/utils.js
index 5a74038..4a60ddc 100644
--- a/src/libs/utils.js
+++ b/src/libs/utils.js
@@ -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);
+}
diff --git a/src/subtitle/BilingualSubtitleManager.js b/src/subtitle/BilingualSubtitleManager.js
index 5bc934d..a89ff24 100644
--- a/src/subtitle/BilingualSubtitleManager.js
+++ b/src/subtitle/BilingualSubtitleManager.js
@@ -356,4 +356,8 @@ export class BilingualSubtitleManager {
this.#currentSubtitleIndex = -1;
this.onTimeUpdate();
}
+
+ updateSetting(obj) {
+ this.#setting = { ...this.#setting, ...obj };
+ }
}
diff --git a/src/subtitle/Menus.js b/src/subtitle/Menus.js
new file mode 100644
index 0000000..03f26cc
--- /dev/null
+++ b/src/subtitle/Menus.js
@@ -0,0 +1,177 @@
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { MSG_MENUS_PROGRESSED, MSG_MENUS_UPDATEFORM } from "../config";
+
+function Label({ children }) {
+ return (
+
+ {children}
+
+ );
+}
+
+function MenuItem({ children, onClick, disabled = false }) {
+ const [hover, setHover] = useState(false);
+
+ return (
+ setHover(true)}
+ onMouseLeave={() => setHover(false)}
+ onClick={onClick}
+ >
+ {children}
+
+ );
+}
+
+function Switch({ label, name, value, onChange, disabled }) {
+ const handleClick = useCallback(() => {
+ if (disabled) return;
+
+ onChange({ name, value: !value });
+ }, [disabled, onChange, name, value]);
+
+ return (
+
+ );
+}
+
+function Button({ label, onClick, disabled }) {
+ const handleClick = useCallback(() => {
+ if (disabled) return;
+
+ onClick();
+ }, [disabled, onClick]);
+
+ return (
+
+ );
+}
+
+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 (
+
+
+
+
+
+
+ );
+}
diff --git a/src/subtitle/YouTubeCaptionProvider.js b/src/subtitle/YouTubeCaptionProvider.js
index 503732a..12dbfab 100644
--- a/src/subtitle/YouTubeCaptionProvider.js
+++ b/src/subtitle/YouTubeCaptionProvider.js
@@ -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));
diff --git a/src/subtitle/vtt.js b/src/subtitle/vtt.js
index 851905f..fdae471 100644
--- a/src/subtitle/vtt.js
+++ b/src/subtitle/vtt.js
@@ -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