diff --git a/src/config/setting.js b/src/config/setting.js index 6a39e7d..477700b 100644 --- a/src/config/setting.js +++ b/src/config/setting.js @@ -95,33 +95,18 @@ export const DEFAULT_TRANBOX_SETTING = { enSug: OPT_SUG_YOUDAO, // 英文建议 }; -const SUBTITLE_WINDOW_STYLE = `container-type: inline-size; -position: absolute; -bottom: 10%; -left: 50%; -transform: translateX(-50%); -width: 80%; -padding: 10px; -background-color: rgba(0, 0, 0, 0.7); +const SUBTITLE_WINDOW_STYLE = `padding: 0.5em 1em; +background-color: rgba(0, 0, 0, 0.5); color: white; -text-align: center; line-height: 1.2; text-shadow: 1px 1px 2px black; -pointer-events: none; -z-index: 2147483647; opacity: 0; -cursor: grab; -transition: opacity 0.2s ease-in-out;`; +transition: opacity 0.2s ease-in-out; +display: inline-block`; -const SUBTITLE_ORIGIN_STYLE = `margin:0; -padding: 0; -opacity: 0.8; -font-size: clamp(1.5rem, 3cqw, 3rem);`; +const SUBTITLE_ORIGIN_STYLE = `font-size: clamp(1.5rem, 3cqw, 3rem);`; -const SUBTITLE_TRANSLATION_STYLE = `margin:0; -padding: 0; -opacity: 1; -font-size: clamp(1.5rem, 3cqw, 3rem);`; +const SUBTITLE_TRANSLATION_STYLE = `font-size: clamp(1.5rem, 3cqw, 3rem);`; export const DEFAULT_SUBTITLE_SETTING = { enabled: true, // 是否开启 diff --git a/src/libs/svg.js b/src/libs/svg.js index 14ad5f5..34de861 100644 --- a/src/libs/svg.js +++ b/src/libs/svg.js @@ -21,7 +21,8 @@ export const loadingSvg = ` export const createLogoSvg = ({ width = "100%", height = "100%", - viewBox = "-13 -14 60 60", + viewBox = "-20 -20 70 70", + isSelected = false, } = {}) => { const svgNS = "http://www.w3.org/2000/svg"; const svgElement = document.createElementNS(svgNS, "svg"); @@ -51,5 +52,14 @@ export const createLogoSvg = ({ svgElement.appendChild(path1); svgElement.appendChild(path2); + if (isSelected) { + const redLine = document.createElementNS(svgNS, "path"); + redLine.setAttribute("d", "M0 36 L32 36"); + redLine.setAttribute("stroke", "red"); + redLine.setAttribute("stroke-width", "3"); + redLine.setAttribute("stroke-linecap", "round"); + svgElement.appendChild(redLine); + } + return svgElement; }; diff --git a/src/libs/utils.js b/src/libs/utils.js index 412682d..f436e15 100644 --- a/src/libs/utils.js +++ b/src/libs/utils.js @@ -362,3 +362,15 @@ export const truncateWords = (str, maxLength) => { const truncated = str.slice(0, maxLength); return truncated.slice(0, truncated.lastIndexOf(" ")) + " …"; }; + +/** + * 生成随机数 + * @param {*} min + * @param {*} max + * @param {*} integer + * @returns + */ +export const randomBetween = (min, max, integer = true) => { + const value = Math.random() * (max - min) + min; + return integer ? Math.floor(value) : value; +}; diff --git a/src/subtitle/BilingualSubtitleManager.js b/src/subtitle/BilingualSubtitleManager.js index a14b55b..f8a6ef2 100644 --- a/src/subtitle/BilingualSubtitleManager.js +++ b/src/subtitle/BilingualSubtitleManager.js @@ -51,7 +51,7 @@ export class BilingualSubtitleManager { destroy() { logger.info("Bilingual Subtitle Manager: Destroying..."); this.#removeEventListeners(); - this.#captionWindowEl?.parentElement?.remove(); + this.#captionWindowEl?.parentElement?.parentElement?.remove(); this.#formattedSubtitles = []; } @@ -60,14 +60,36 @@ export class BilingualSubtitleManager { */ #createCaptionWindow() { const container = document.createElement("div"); - container.className = `kiss-caption-window-container notranslate`; - container.style.cssText = `position:absolute; width:100%; height:100%; left:0; top:0;`; + container.className = `kiss-caption-container notranslate`; + Object.assign(container.style, { + position: "absolute", + width: "100%", + height: "100%", + left: "0", + top: "0", + }); + + const paper = document.createElement("div"); + paper.className = `kiss-caption-paper`; + Object.assign(paper.style, { + position: "absolute", + width: "80%", + left: "50%", + bottom: "10%", + transform: "translateX(-50%)", + textAlign: "center", + cursor: "grab", + containerType: "inline-size", + pointerEvents: "none", + zIndex: "2147483647", + }); this.#captionWindowEl = document.createElement("div"); this.#captionWindowEl.className = `kiss-caption-window`; this.#captionWindowEl.style.cssText = this.#setting.windowStyle; - container.appendChild(this.#captionWindowEl); + paper.appendChild(this.#captionWindowEl); + container.appendChild(paper); const videoContainer = this.#videoEl.parentElement?.parentElement; if (!videoContainer) { diff --git a/src/subtitle/YouTubeCaptionProvider.js b/src/subtitle/YouTubeCaptionProvider.js index fcef427..0d8d62c 100644 --- a/src/subtitle/YouTubeCaptionProvider.js +++ b/src/subtitle/YouTubeCaptionProvider.js @@ -2,9 +2,10 @@ import { logger } from "../libs/log.js"; import { apiTranslate } from "../apis/index.js"; import { BilingualSubtitleManager } from "./BilingualSubtitleManager.js"; import { getGlobalVariable } from "./globalVariable.js"; -import { MSG_XHR_DATA_YOUTUBE } from "../config"; -import { truncateWords } from "../libs/utils.js"; +import { MSG_XHR_DATA_YOUTUBE, APP_NAME } from "../config"; +import { truncateWords, sleep } from "../libs/utils.js"; import { createLogoSvg } from "../libs/svg.js"; +import { randomBetween } from "../libs/utils.js"; const VIDEO_SELECT = "#container video"; const CONTORLS_SELECT = ".ytp-right-controls"; @@ -15,6 +16,9 @@ class YouTubeCaptionProvider { #videoId = ""; #subtitles = []; #managerInstance = null; + #toggleButton = null; + #enabled = false; + #ytControls = null; constructor(setting = {}) { this.#setting = setting; @@ -52,9 +56,21 @@ 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(); + } + } + #injectToggleButton() { - const controls = document.querySelector(CONTORLS_SELECT); - if (!controls) { + this.#ytControls = document.querySelector(CONTORLS_SELECT); + if (!this.#ytControls) { logger.warn("Youtube Provider: Could not find YouTube player controls."); return; } @@ -68,30 +84,28 @@ class YouTubeCaptionProvider { const toggleButton = document.createElement("button"); toggleButton.className = "ytp-button notranslate kiss-bilingual-subtitle-button"; - toggleButton.title = "Toggle Bilingual Subtitles"; + toggleButton.title = APP_NAME; Object.assign(toggleButton.style, { color: "white", - opacity: "0.8", + opacity: "0.5", }); toggleButton.appendChild(createLogoSvg()); kissControls.appendChild(toggleButton); toggleButton.onclick = () => { - if (!this.#managerInstance) { + if (!this.#enabled) { logger.info(`Youtube Provider: Feature toggled ON.`); - toggleButton.style.opacity = "1"; - this.#setting.enabled = true; this.#startManager(); } else { logger.info(`Youtube Provider: Feature toggled OFF.`); - toggleButton.style.opacity = "0.5"; - this.#setting.enabled = false; this.#destroyManager(); } }; + this.#toggleButton = toggleButton; + this.#ytControls.before(kissControls); - controls.before(kissControls); + this.#doubleClick(); } #findCaptionTrack(ytPlayer) { @@ -144,6 +158,10 @@ class YouTubeCaptionProvider { async #handleInterceptedRequest(url, responseText) { try { + if (!responseText) { + return; + } + const ytPlayer = await getGlobalVariable("ytInitialPlayerResponse"); const captionTrack = this.#findCaptionTrack(ytPlayer); if (!captionTrack) { @@ -169,13 +187,13 @@ class YouTubeCaptionProvider { responseText ); if (!subtitleEvents) { - logger.warn("Youtube Provider: SubtitleEvents not got."); + logger.info("Youtube Provider: SubtitleEvents not got."); return; } const subtitles = this.#formatSubtitles(subtitleEvents); if (subtitles.length === 0) { - logger.warn("Youtube Provider: No subtitles after format."); + logger.info("Youtube Provider: No subtitles after format."); return; } @@ -189,17 +207,22 @@ class YouTubeCaptionProvider { this.#subtitles = subtitles; this.#videoId = videoId; - this.#destroyManager(); + if (this.#toggleButton) { + this.#toggleButton.style.opacity = subtitles.length ? "1" : "0.5"; + } - if (this.#setting.enabled) { + if (this.#enabled) { + this.#destroyManager(); this.#startManager(); } } #startManager() { - if (this.#managerInstance) { + if (this.#enabled || this.#managerInstance) { return; } + this.#enabled = true; + this.#toggleButton?.replaceChildren(createLogoSvg({ isSelected: true })); const videoEl = document.querySelector(VIDEO_SELECT); if (!videoEl) { @@ -210,14 +233,12 @@ class YouTubeCaptionProvider { if (this.#subtitles?.length === 0) { // todo: 等待并给出用户提示 logger.info("Youtube Provider: No subtitles"); + this.#doubleClick(); return; } logger.info("Youtube Provider: Starting manager..."); - const ytCaption = document.querySelector(YT_CAPTION_SELECT); - ytCaption && (ytCaption.style.display = "none"); - this.#managerInstance = new BilingualSubtitleManager({ videoEl, formattedSubtitles: this.#subtitles, @@ -225,21 +246,29 @@ class YouTubeCaptionProvider { setting: this.#setting, }); this.#managerInstance.start(); + + const ytCaption = document.querySelector(YT_CAPTION_SELECT); + ytCaption && (ytCaption.style.display = "none"); } #destroyManager() { + if (!this.#enabled) { + return; + } + this.#enabled = false; + this.#toggleButton?.replaceChildren(createLogoSvg()); + + logger.info("Youtube Provider: Destroying manager..."); + + const ytCaption = document.querySelector(YT_CAPTION_SELECT); + ytCaption && (ytCaption.style.display = "block"); + if (this.#managerInstance) { - logger.info("Youtube Provider: Destroying manager..."); - - const ytCaption = document.querySelector(YT_CAPTION_SELECT); - ytCaption && (ytCaption.style.display = "block"); - this.#managerInstance.destroy(); this.#managerInstance = null; } } - // todo: 没有标点断句的处理 #formatSubtitles(data) { const events = data?.events; if (!Array.isArray(events)) return []; @@ -300,10 +329,179 @@ class YouTubeCaptionProvider { } } - return lines.map((line) => ({ - ...line, - duration: Math.max(0, line.end - line.start), - text: truncateWords(line.text.trim().replace(/\s+/g, " "), 300), + const isPoor = this.#isQualityPoor(lines); + if (isPoor) { + return this.#processSubtitles(data); + } + + return lines.map((item) => ({ + ...item, + duration: Math.max(0, item.end - item.start), + text: truncateWords(item.text.trim().replace(/\s+/g, " "), 250), + })); + } + + #isQualityPoor(lines, lengthThreshold = 250, percentageThreshold = 0.1) { + if (lines.length === 0) return false; + const longLinesCount = lines.filter( + (line) => line.text.length > lengthThreshold + ).length; + return longLinesCount / lines.length > percentageThreshold; + } + + #processSubtitles(data, { timeout = 1500, maxWords = 15 } = {}) { + const groupedPauseWords = { + 1: new Set([ + "actually", + "also", + "although", + "and", + "anyway", + "as", + "basically", + "because", + "but", + "eventually", + "frankly", + "honestly", + "hopefully", + "however", + "if", + "instead", + "it's", + "just", + "let's", + "like", + "literally", + "maybe", + "meanwhile", + "nevertheless", + "nonetheless", + "now", + "okay", + "or", + "otherwise", + "perhaps", + "personally", + "probably", + "right", + "since", + "so", + "suddenly", + "that's", + "then", + "there's", + "therefore", + "though", + "thus", + "unless", + "until", + "well", + "while", + ]), + 2: new Set([ + "after all", + "at first", + "at least", + "even if", + "even though", + "for example", + "for instance", + "i believe", + "i guess", + "i mean", + "i suppose", + "i think", + "in fact", + "in the end", + "of course", + "then again", + "to be fair", + "you know", + "you see", + ]), + 3: new Set([ + "as a result", + "by the way", + "in other words", + "in that case", + "in this case", + "to be clear", + "to be honest", + ]), + }; + + const sentences = []; + let currentBuffer = []; + let bufferWordCount = 0; + + const joinSegs = (segs) => ({ + text: segs + .map((s) => s.text) + .join(" ") + .trim(), + start: segs[0].start, + end: segs[segs.length - 1].end, + }); + + const flushBuffer = () => { + if (currentBuffer.length > 0) { + sentences.push(joinSegs(currentBuffer)); + } + currentBuffer = []; + bufferWordCount = 0; + }; + + data.events?.forEach((event) => { + event.segs?.forEach((seg, j) => { + const text = seg.utf8?.trim() || ""; + if (!text) return; + + const start = event.tStartMs + (seg.tOffsetMs ?? 0); + const lastSegment = currentBuffer[currentBuffer.length - 1]; + + if (lastSegment) { + if (!lastSegment.end) { + lastSegment.end = start; + } + + const isEndOfSentence = /[.?!\]]$/.test(lastSegment.text); + const isTimeout = start - lastSegment.end > timeout; + const isWordLimitExceeded = bufferWordCount >= maxWords; + const startsWithPauseWord = groupedPauseWords["1"].has( + text.toLowerCase().split(" ")[0] + ); + + // todo: 考虑连词开头 + const isNewClause = + (startsWithPauseWord && currentBuffer.length > 1) || + text.startsWith("["); + + if ( + isEndOfSentence || + isTimeout || + isWordLimitExceeded || + isNewClause + ) { + flushBuffer(); + } + } + + const currentSegment = { text, start }; + if (j === event.segs.length - 1) { + currentSegment.end = event.tStartMs + event.dDurationMs; + } + + currentBuffer.push(currentSegment); + bufferWordCount += text.split(/\s+/).length; + }); + }); + + flushBuffer(); + + return sentences.map((item) => ({ + ...item, + duration: item.end - item.start, })); } } diff --git a/src/subtitle/subtitle.js b/src/subtitle/subtitle.js index c0d3408..192ae7c 100644 --- a/src/subtitle/subtitle.js +++ b/src/subtitle/subtitle.js @@ -12,6 +12,11 @@ const providers = [ export function runSubtitle({ href, setting, rule }) { try { + const subtitleSetting = setting.subtitleSetting || DEFAULT_SUBTITLE_SETTING; + if (!subtitleSetting.enabled) { + return; + } + const provider = providers.find((item) => isMatch(href, item.pattern)); if (provider) { const id = "kiss-translator-injector"; @@ -22,7 +27,7 @@ export function runSubtitle({ href, setting, rule }) { setting.transApis.find((api) => api.apiSlug === rule.apiSlug) || DEFAULT_API_SETTING; provider.start({ - ...(setting.subtitleSetting || DEFAULT_SUBTITLE_SETTING), + ...subtitleSetting, apiSetting, }); } diff --git a/src/views/Options/Subtitle.js b/src/views/Options/Subtitle.js index 1467f60..959b3e4 100644 --- a/src/views/Options/Subtitle.js +++ b/src/views/Options/Subtitle.js @@ -104,16 +104,6 @@ export default function SubtitleSetting() { - + );