From 3f524ad6740ebd030a14f9bbc4c2f57048ca031c Mon Sep 17 00:00:00 2001 From: Gabe Date: Mon, 10 Nov 2025 00:21:07 +0800 Subject: [PATCH] feat: supports download subtitle --- src/apis/trans.js | 24 +- src/config/i18n.js | 41 +- src/config/msg.js | 3 + src/libs/shadowDomManager.js | 11 +- src/libs/svg.js | 4 +- src/libs/utils.js | 73 ++++ src/subtitle/BilingualSubtitleManager.js | 4 + src/subtitle/Menus.js | 177 +++++++++ src/subtitle/YouTubeCaptionProvider.js | 461 ++++++++++++++--------- src/subtitle/vtt.js | 47 +++ src/views/Options/DownloadButton.js | 9 +- 11 files changed, 650 insertions(+), 204 deletions(-) create mode 100644 src/subtitle/Menus.js 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} 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"); +} diff --git a/src/views/Options/DownloadButton.js b/src/views/Options/DownloadButton.js index 4d865d3..0fa7327 100644 --- a/src/views/Options/DownloadButton.js +++ b/src/views/Options/DownloadButton.js @@ -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 {