feat: supports download subtitle

This commit is contained in:
Gabe
2025-11-10 00:21:07 +08:00
parent 7e6376fcb7
commit 3f524ad674
11 changed files with 650 additions and 204 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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