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)) {
|
if (API_SPE_TYPES.ai.has(apiType)) {
|
||||||
args.systemPrompt = genSystemPrompt({
|
args.systemPrompt = events
|
||||||
systemPrompt: useBatchFetch ? systemPrompt : nobatchPrompt,
|
? systemPrompt
|
||||||
from,
|
: genSystemPrompt({
|
||||||
to,
|
systemPrompt: useBatchFetch ? systemPrompt : nobatchPrompt,
|
||||||
fromLang,
|
from,
|
||||||
toLang,
|
to,
|
||||||
texts,
|
fromLang,
|
||||||
docInfo,
|
toLang,
|
||||||
tone,
|
texts,
|
||||||
});
|
docInfo,
|
||||||
args.userPrompt = !!events
|
tone,
|
||||||
|
});
|
||||||
|
args.userPrompt = events
|
||||||
? JSON.stringify(events)
|
? JSON.stringify(events)
|
||||||
: genUserPrompt({
|
: genUserPrompt({
|
||||||
nobatchUserPrompt,
|
nobatchUserPrompt,
|
||||||
|
|||||||
@@ -1674,9 +1674,14 @@ export const I18N = {
|
|||||||
zh_TW: `雙語顯示`,
|
zh_TW: `雙語顯示`,
|
||||||
},
|
},
|
||||||
is_skip_ad: {
|
is_skip_ad: {
|
||||||
zh: `是否快进广告`,
|
zh: `快进广告`,
|
||||||
en: `Should I fast forward to the ad?`,
|
en: `Skip AD`,
|
||||||
zh_TW: `是否快轉廣告`,
|
zh_TW: `快轉廣告`,
|
||||||
|
},
|
||||||
|
download_subtitles: {
|
||||||
|
zh: `下载字幕`,
|
||||||
|
en: `Download subtitles`,
|
||||||
|
zh_TW: `下载字幕`,
|
||||||
},
|
},
|
||||||
background_styles: {
|
background_styles: {
|
||||||
zh: `背景样式`,
|
zh: `背景样式`,
|
||||||
@@ -1753,6 +1758,36 @@ export const I18N = {
|
|||||||
en: `The subtitle data is ready, please click the KT button to load it`,
|
en: `The subtitle data is ready, please click the KT button to load it`,
|
||||||
zh_TW: `字幕資料已準備就緒,請點擊KT按鈕加載`,
|
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: {
|
log_level: {
|
||||||
zh: `日志级别`,
|
zh: `日志级别`,
|
||||||
en: `Log Level`,
|
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_XHR_DATA_YOUTUBE = "KISS_XHR_DATA_YOUTUBE";
|
||||||
// export const MSG_GLOBAL_VAR_FETCH = "KISS_GLOBAL_VAR_FETCH";
|
// export const MSG_GLOBAL_VAR_FETCH = "KISS_GLOBAL_VAR_FETCH";
|
||||||
// export const MSG_GLOBAL_VAR_BACK = "KISS_GLOBAL_VAR_BACK";
|
// 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;
|
_ReactComponent;
|
||||||
_props;
|
_props;
|
||||||
|
|
||||||
constructor({ id, className = "", reactComponent, props = {} }) {
|
constructor({
|
||||||
|
id,
|
||||||
|
className = "",
|
||||||
|
reactComponent,
|
||||||
|
props = {},
|
||||||
|
rootElement = document.body,
|
||||||
|
}) {
|
||||||
if (!id || !reactComponent) {
|
if (!id || !reactComponent) {
|
||||||
throw new Error("ID and a React Component must be provided.");
|
throw new Error("ID and a React Component must be provided.");
|
||||||
}
|
}
|
||||||
@@ -23,6 +29,7 @@ export default class ShadowDomManager {
|
|||||||
this._className = className;
|
this._className = className;
|
||||||
this._ReactComponent = reactComponent;
|
this._ReactComponent = reactComponent;
|
||||||
this._props = props;
|
this._props = props;
|
||||||
|
this._rootElement = rootElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
get isVisible() {
|
get isVisible() {
|
||||||
@@ -93,7 +100,7 @@ export default class ShadowDomManager {
|
|||||||
host.className = this._className;
|
host.className = this._className;
|
||||||
}
|
}
|
||||||
|
|
||||||
document.body.appendChild(host);
|
this._rootElement.appendChild(host);
|
||||||
this.#hostElement = host;
|
this.#hostElement = host;
|
||||||
const shadowContainer = host.attachShadow({ mode: "open" });
|
const shadowContainer = host.attachShadow({ mode: "open" });
|
||||||
const appRoot = document.createElement("div");
|
const appRoot = document.createElement("div");
|
||||||
|
|||||||
@@ -83,8 +83,8 @@ export function createLogoSVG({
|
|||||||
const primaryColor = "#209CEE";
|
const primaryColor = "#209CEE";
|
||||||
const secondaryColor = "#E9F5FD";
|
const secondaryColor = "#E9F5FD";
|
||||||
|
|
||||||
const path1Fill = isSelected ? primaryColor : secondaryColor;
|
const path1Fill = isSelected ? secondaryColor : primaryColor;
|
||||||
const path2Fill = isSelected ? secondaryColor : primaryColor;
|
const path2Fill = isSelected ? primaryColor : secondaryColor;
|
||||||
|
|
||||||
const path1 = createSVGElement("path", {
|
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 ",
|
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;
|
const value = Math.random() * (max - min) + min;
|
||||||
return integer ? Math.floor(value) : value;
|
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.#currentSubtitleIndex = -1;
|
||||||
this.onTimeUpdate();
|
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,
|
APP_NAME,
|
||||||
OPT_LANGS_TO_CODE,
|
OPT_LANGS_TO_CODE,
|
||||||
OPT_TRANS_MICROSOFT,
|
OPT_TRANS_MICROSOFT,
|
||||||
|
MSG_MENUS_PROGRESSED,
|
||||||
|
MSG_MENUS_UPDATEFORM,
|
||||||
} from "../config";
|
} from "../config";
|
||||||
import { sleep } from "../libs/utils.js";
|
import { sleep, genEventName, downloadBlobFile } from "../libs/utils.js";
|
||||||
import { createLogoSVG } from "../libs/svg.js";
|
import { createLogoSVG } from "../libs/svg.js";
|
||||||
import { randomBetween } from "../libs/utils.js";
|
import { randomBetween } from "../libs/utils.js";
|
||||||
import { newI18n } from "../config";
|
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 VIDEO_SELECT = "#container video";
|
||||||
const CONTORLS_SELECT = ".ytp-right-controls";
|
const CONTORLS_SELECT = ".ytp-right-controls";
|
||||||
const YT_CAPTION_SELECT = "#ytp-caption-window-container";
|
const YT_CAPTION_SELECT = "#ytp-caption-window-container";
|
||||||
const YT_AD_SELECT = ".video-ads";
|
const YT_AD_SELECT = ".video-ads";
|
||||||
|
const YT_SUBTITLE_BTN_SELECT = "button.ytp-subtitles-button";
|
||||||
|
|
||||||
class YouTubeCaptionProvider {
|
class YouTubeCaptionProvider {
|
||||||
#setting = {};
|
#setting = {};
|
||||||
#videoId = "";
|
|
||||||
#subtitles = [];
|
#subtitles = [];
|
||||||
|
#flatEvents = [];
|
||||||
|
#progressedNum = 0;
|
||||||
|
#fromLang = "auto";
|
||||||
|
|
||||||
|
#processingId = null;
|
||||||
|
|
||||||
#managerInstance = null;
|
#managerInstance = null;
|
||||||
#toggleButton = null;
|
#toggleButton = null;
|
||||||
#enabled = false;
|
#isMenuShow = false;
|
||||||
#ytControls = null;
|
|
||||||
#isBusy = false;
|
|
||||||
#fromLang = "auto";
|
|
||||||
#notificationEl = null;
|
#notificationEl = null;
|
||||||
#notificationTimeout = null;
|
#notificationTimeout = null;
|
||||||
#i18n = () => "";
|
#i18n = () => "";
|
||||||
|
#menuEventName = "kiss-event";
|
||||||
|
|
||||||
constructor(setting = {}) {
|
constructor(setting = {}) {
|
||||||
this.#setting = setting;
|
this.#setting = { ...setting, isAISegment: false };
|
||||||
this.#i18n = newI18n(setting.uiLang || "zh");
|
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() {
|
initialize() {
|
||||||
@@ -47,35 +76,47 @@ class YouTubeCaptionProvider {
|
|||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener("yt-navigate-finish", () => {
|
window.addEventListener("yt-navigate-finish", () => {
|
||||||
setTimeout(() => {
|
logger.debug("Youtube Provider: yt-navigate-finish", this.#videoId);
|
||||||
if (this.#toggleButton) {
|
|
||||||
this.#toggleButton.style.opacity = "0.5";
|
this.#destroyManager();
|
||||||
}
|
|
||||||
this.#destroyManager();
|
this.#subtitles = [];
|
||||||
this.#doubleClick();
|
this.#flatEvents = [];
|
||||||
}, 1000);
|
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.#waitForElement(CONTORLS_SELECT, (ytControls) => {
|
||||||
this.#injectToggleButton(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.#waitForElement(YT_AD_SELECT, (adContainer) => {
|
||||||
this.#moAds(adContainer);
|
this.#moAds(adContainer);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
get #videoEl() {
|
|
||||||
return document.querySelector(VIDEO_SELECT);
|
|
||||||
}
|
|
||||||
|
|
||||||
#moAds(adContainer) {
|
#moAds(adContainer) {
|
||||||
const { skipAd = false } = this.#setting;
|
|
||||||
|
|
||||||
const adLayoutSelector = ".ytp-ad-player-overlay-layout";
|
const adLayoutSelector = ".ytp-ad-player-overlay-layout";
|
||||||
const skipBtnSelector =
|
const skipBtnSelector =
|
||||||
".ytp-skip-ad-button, .ytp-ad-skip-button, .ytp-ad-skip-button-modern";
|
".ytp-skip-ad-button, .ytp-ad-skip-button, .ytp-ad-skip-button-modern";
|
||||||
const observer = new MutationObserver((mutations) => {
|
const observer = new MutationObserver((mutations) => {
|
||||||
|
const { skipAd = false } = this.#setting;
|
||||||
for (const mutation of mutations) {
|
for (const mutation of mutations) {
|
||||||
if (mutation.type === "childList") {
|
if (mutation.type === "childList") {
|
||||||
const videoEl = this.#videoEl;
|
const videoEl = this.#videoEl;
|
||||||
@@ -149,60 +190,94 @@ class YouTubeCaptionProvider {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async #doubleClick() {
|
updateSetting({ name, value }) {
|
||||||
const button = this.#ytControls?.querySelector(
|
if (this.#setting[name] === value) return;
|
||||||
"button.ytp-subtitles-button"
|
|
||||||
);
|
logger.debug("Youtube Provider: update setting", name, value);
|
||||||
if (button) {
|
this.#setting[name] = value;
|
||||||
await sleep(randomBetween(50, 100));
|
|
||||||
button.click();
|
if (name === "isBilingual") {
|
||||||
await sleep(randomBetween(500, 1000));
|
this.#managerInstance?.updateSetting({ [name]: value });
|
||||||
button.click();
|
} else if (name === "isAISegment") {
|
||||||
|
this.#reProcessEvents();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#injectToggleButton(ytControls) {
|
downloadSubtitle() {
|
||||||
this.#ytControls = ytControls;
|
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");
|
const kissControls = document.createElement("div");
|
||||||
kissControls.className = "notranslate kiss-subtitle-controls";
|
kissControls.className = "notranslate kiss-subtitle-controls";
|
||||||
Object.assign(kissControls.style, {
|
Object.assign(kissControls.style, {
|
||||||
height: "100%",
|
height: "100%",
|
||||||
|
position: "relative",
|
||||||
});
|
});
|
||||||
|
|
||||||
const toggleButton = document.createElement("button");
|
const toggleButton = document.createElement("button");
|
||||||
toggleButton.className = "ytp-button kiss-subtitle-button";
|
toggleButton.className = "ytp-button kiss-subtitle-button";
|
||||||
toggleButton.title = APP_NAME;
|
toggleButton.title = APP_NAME;
|
||||||
Object.assign(toggleButton.style, {
|
|
||||||
color: "white",
|
|
||||||
opacity: "0.5",
|
|
||||||
});
|
|
||||||
|
|
||||||
toggleButton.appendChild(createLogoSVG());
|
toggleButton.appendChild(createLogoSVG());
|
||||||
kissControls.appendChild(toggleButton);
|
kissControls.appendChild(toggleButton);
|
||||||
|
|
||||||
toggleButton.onclick = () => {
|
const { segApiSetting, isAISegment, skipAd, isBilingual } = this.#setting;
|
||||||
if (this.#isBusy) {
|
const menu = new ShadowDomManager({
|
||||||
logger.info(`Youtube Provider: It's budy now...`);
|
id: "kiss-subtitle-menus",
|
||||||
this.#showNotification(this.#i18n("subtitle_data_processing"));
|
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) {
|
toggleButton.onclick = () => {
|
||||||
logger.info(`Youtube Provider: Feature toggled ON.`);
|
if (!this.#isMenuShow) {
|
||||||
this.#enabled = true;
|
this.#isMenuShow = true;
|
||||||
this.#toggleButton?.replaceChildren(
|
this.#toggleButton?.replaceChildren(
|
||||||
createLogoSVG({ isSelected: true })
|
createLogoSVG({ isSelected: true })
|
||||||
);
|
);
|
||||||
this.#startManager();
|
menu.show();
|
||||||
} else {
|
} else {
|
||||||
logger.info(`Youtube Provider: Feature toggled OFF.`);
|
this.#isMenuShow = false;
|
||||||
this.#enabled = false;
|
|
||||||
this.#toggleButton?.replaceChildren(createLogoSVG());
|
this.#toggleButton?.replaceChildren(createLogoSVG());
|
||||||
this.#destroyManager();
|
menu.hide();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
this.#toggleButton = toggleButton;
|
this.#toggleButton = toggleButton;
|
||||||
this.#ytControls?.prepend(kissControls);
|
|
||||||
|
ytControls?.prepend(kissControls);
|
||||||
}
|
}
|
||||||
|
|
||||||
#isSameLang(lang1, lang2) {
|
#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 }) {
|
async #aiSegment({ videoId, fromLang, toLang, chunkEvents, segApiSetting }) {
|
||||||
try {
|
try {
|
||||||
const events = chunkEvents.filter((item) => item.text);
|
const events = chunkEvents.filter((item) => item.text);
|
||||||
@@ -326,36 +396,38 @@ class YouTubeCaptionProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async #handleInterceptedRequest(url, responseText) {
|
async #handleInterceptedRequest(url, responseText) {
|
||||||
if (this.#isBusy) {
|
const videoId = this.#videoId;
|
||||||
logger.info("Youtube Provider is busy...");
|
if (!videoId) {
|
||||||
|
logger.debug("Youtube Provider: videoId not found.");
|
||||||
return;
|
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 {
|
try {
|
||||||
const videoId = this.#getVideoId();
|
this.#showNotification(this.#i18n("starting_to_process_subtitle"));
|
||||||
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;
|
|
||||||
|
|
||||||
|
const { toLang } = this.#setting;
|
||||||
const captionTracks = await this.#getCaptionTracks(videoId);
|
const captionTracks = await this.#getCaptionTracks(videoId);
|
||||||
const captionTrack = this.#findCaptionTrack(captionTracks);
|
const captionTrack = this.#findCaptionTrack(captionTracks);
|
||||||
if (!captionTrack) {
|
if (!captionTrack) {
|
||||||
logger.info("Youtube Provider: CaptionTrack not found.");
|
logger.debug("Youtube Provider: CaptionTrack not found:", videoId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,7 +438,7 @@ class YouTubeCaptionProvider {
|
|||||||
responseText
|
responseText
|
||||||
);
|
);
|
||||||
if (!events?.length) {
|
if (!events?.length) {
|
||||||
logger.info("Youtube Provider: SubtitleEvents not got.");
|
logger.debug("Youtube Provider: events not got:", videoId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -380,108 +452,131 @@ class YouTubeCaptionProvider {
|
|||||||
`Youtube Provider: fromLang: ${fromLang}, toLang: ${toLang}`
|
`Youtube Provider: fromLang: ${fromLang}, toLang: ${toLang}`
|
||||||
);
|
);
|
||||||
if (this.#isSameLang(fromLang, 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;
|
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);
|
this.#flatEvents = flatEvents;
|
||||||
if (!flatEvents.length) return;
|
this.#fromLang = fromLang;
|
||||||
|
|
||||||
if (potUrl.searchParams.get("kind") === "asr" && segApiSetting) {
|
this.#processEvents({
|
||||||
logger.info("Youtube Provider: Starting AI ...");
|
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(
|
async #processEvents({ videoId, flatEvents, fromLang }) {
|
||||||
flatEvents,
|
try {
|
||||||
segApiSetting.chunkLength
|
const [subtitles, progressed] = await this.#eventsToSubtitles({
|
||||||
|
videoId,
|
||||||
|
flatEvents,
|
||||||
|
fromLang,
|
||||||
|
});
|
||||||
|
if (!subtitles?.length) {
|
||||||
|
logger.debug(
|
||||||
|
"Youtube Provider: events to subtitles got empty",
|
||||||
|
videoId
|
||||||
);
|
);
|
||||||
const subtitlesFallback = () =>
|
return;
|
||||||
this.#formatSubtitles(flatEvents, fromLang);
|
}
|
||||||
|
|
||||||
if (eventChunks.length === 0) {
|
if (videoId !== this.#videoId) {
|
||||||
this.#onCaptionsReady({
|
logger.debug(
|
||||||
videoId,
|
"Youtube Provider: videoId changed!",
|
||||||
subtitles: subtitlesFallback(),
|
videoId,
|
||||||
fromLang,
|
this.#videoId
|
||||||
isInitialLoad: true,
|
);
|
||||||
});
|
return;
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
this.#subtitles = subtitles;
|
||||||
const firstChunkEvents = eventChunks[0];
|
this.#progressed = progressed;
|
||||||
const firstBatchSubtitles = await this.#aiSegment({
|
|
||||||
|
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,
|
videoId,
|
||||||
chunkEvents: firstChunkEvents,
|
|
||||||
fromLang,
|
fromLang,
|
||||||
toLang,
|
toLang,
|
||||||
segApiSetting,
|
segApiSetting,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!firstBatchSubtitles?.length) {
|
return [firstBatchSubtitles, 100 / eventChunks.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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
const subtitles = this.#formatSubtitles(flatEvents, fromLang);
|
return [firstBatchSubtitles, 100];
|
||||||
if (!subtitles?.length) {
|
|
||||||
logger.info("Youtube Provider: No subtitles after format.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.#onCaptionsReady({
|
|
||||||
videoId,
|
|
||||||
subtitles,
|
|
||||||
fromLang,
|
|
||||||
isInitialLoad: true,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} 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();
|
return subtitlesFallback();
|
||||||
if (this.#enabled) {
|
|
||||||
this.#startManager();
|
|
||||||
} else {
|
|
||||||
this.#showNotification(this.#i18n("subtitle_data_is_ready"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#startManager() {
|
#startManager() {
|
||||||
@@ -489,11 +584,8 @@ class YouTubeCaptionProvider {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const videoId = this.#getVideoId();
|
if (!this.#subtitles.length) {
|
||||||
if (!this.#subtitles?.length || this.#videoId !== videoId) {
|
this.#showNotification(this.#i18n("waitting_for_subtitle"));
|
||||||
logger.info("Youtube Provider: No subtitles");
|
|
||||||
this.#showNotification(this.#i18n("try_get_subtitle_data"));
|
|
||||||
this.#doubleClick();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -746,7 +838,7 @@ class YouTubeCaptionProvider {
|
|||||||
return sentences;
|
return sentences;
|
||||||
}
|
}
|
||||||
|
|
||||||
#flatEvents(events = []) {
|
#genFlatEvents(events = []) {
|
||||||
const segments = [];
|
const segments = [];
|
||||||
let buffer = null;
|
let buffer = null;
|
||||||
|
|
||||||
@@ -829,6 +921,7 @@ class YouTubeCaptionProvider {
|
|||||||
|
|
||||||
async #processRemainingChunksAsync({
|
async #processRemainingChunksAsync({
|
||||||
chunks,
|
chunks,
|
||||||
|
chunkCount,
|
||||||
videoId,
|
videoId,
|
||||||
fromLang,
|
fromLang,
|
||||||
toLang,
|
toLang,
|
||||||
@@ -839,7 +932,7 @@ class YouTubeCaptionProvider {
|
|||||||
for (let i = 0; i < chunks.length; i++) {
|
for (let i = 0; i < chunks.length; i++) {
|
||||||
const chunkEvents = chunks[i];
|
const chunkEvents = chunks[i];
|
||||||
const chunkNum = i + 2;
|
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}`
|
`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) {
|
if (aiSubtitles?.length > 0) {
|
||||||
subtitlesForThisChunk = aiSubtitles;
|
subtitlesForThisChunk = aiSubtitles;
|
||||||
} else {
|
} else {
|
||||||
logger.info(
|
logger.debug(
|
||||||
`Youtube Provider: AI segmentation for chunk ${chunkNum} returned no data.`
|
`Youtube Provider: AI segmentation for chunk ${chunkNum} returned no data.`
|
||||||
);
|
);
|
||||||
subtitlesForThisChunk = this.#formatSubtitles(chunkEvents, fromLang);
|
subtitlesForThisChunk = this.#formatSubtitles(chunkEvents, fromLang);
|
||||||
@@ -866,19 +959,29 @@ class YouTubeCaptionProvider {
|
|||||||
subtitlesForThisChunk = this.#formatSubtitles(chunkEvents, fromLang);
|
subtitlesForThisChunk = this.#formatSubtitles(chunkEvents, fromLang);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.#getVideoId() !== videoId) {
|
if (videoId !== this.#videoId) {
|
||||||
logger.info("Youtube Provider: videoId changed!");
|
logger.info(
|
||||||
|
"Youtube Provider: videoId changed!!",
|
||||||
|
videoId,
|
||||||
|
this.#videoId
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (subtitlesForThisChunk.length > 0 && this.#managerInstance) {
|
if (subtitlesForThisChunk.length > 0) {
|
||||||
logger.info(
|
const progressed = (chunkNum * 100) / chunkCount;
|
||||||
`Youtube Provider: Appending ${subtitlesForThisChunk.length} subtitles from chunk ${chunkNum}.`
|
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 {
|
} else {
|
||||||
logger.info(`Youtube Provider: Chunk ${chunkNum} no subtitles.`);
|
logger.debug(`Youtube Provider: Chunk ${chunkNum} no subtitles.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await sleep(randomBetween(500, 1000));
|
await sleep(randomBetween(500, 1000));
|
||||||
|
|||||||
@@ -54,6 +54,25 @@ function parseTimestampToMilliseconds(timestamp) {
|
|||||||
return (hours * 3600 + minutes * 60 + seconds) * 1000 + milliseconds;
|
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文件内容。
|
* 解析包含双语字幕的VTT文件内容。
|
||||||
* @param {string} vttText - VTT文件的文本内容。
|
* @param {string} vttText - VTT文件的文本内容。
|
||||||
@@ -97,3 +116,31 @@ export function parseBilingualVtt(vttText) {
|
|||||||
|
|
||||||
return result;
|
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 LoadingButton from "@mui/lab/LoadingButton";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { kissLog } from "../../libs/log";
|
import { kissLog } from "../../libs/log";
|
||||||
|
import { downloadBlobFile } from "../../libs/utils";
|
||||||
|
|
||||||
export default function DownloadButton({ handleData, text, fileName }) {
|
export default function DownloadButton({ handleData, text, fileName }) {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -10,13 +11,7 @@ export default function DownloadButton({ handleData, text, fileName }) {
|
|||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const data = await handleData();
|
const data = await handleData();
|
||||||
const url = window.URL.createObjectURL(new Blob([data]));
|
downloadBlobFile(data, fileName);
|
||||||
const link = document.createElement("a");
|
|
||||||
link.href = url;
|
|
||||||
link.setAttribute("download", fileName || `${Date.now()}.json`);
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
link.remove();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
kissLog("download", err);
|
kissLog("download", err);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
Reference in New Issue
Block a user