From 5aea7724775fc654032e90860e49a245e6c5ce55 Mon Sep 17 00:00:00 2001 From: vlOd2 <66838724+vlOd2@users.noreply.github.com> Date: Thu, 25 Dec 2025 22:52:50 +0200 Subject: [PATCH 01/25] Prepare for captions translation --- .vscode/settings.json | 8 +- pnpm-lock.yaml | 8 +- .../player/utils/captionstranslation.ts | 201 ++++++++++++++++++ src/pages/developer/TestView.tsx | 70 +++++- 4 files changed, 278 insertions(+), 9 deletions(-) create mode 100644 src/components/player/utils/captionstranslation.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 5d671d55..cb78ef1f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,7 +5,13 @@ "[json]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, - "[typescriptreact]": { + "[jsonc]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[type.scriptreact]": { "editor.defaultFormatter": "dbaeumer.vscode-eslint" + }, + "[javascript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2aaee3f8..48ff924f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,7 +44,7 @@ importers: version: 1.8.0 '@p-stream/providers': specifier: github:p-stream/providers#production - version: https://codeload.github.com/p-stream/providers/tar.gz/5fa33694229da0506e7a265ff041acdb43e25ff0 + version: https://codeload.github.com/p-stream/providers/tar.gz/6a94f978c64ec025171246b7b27e5867bdf21ed1 '@plasmohq/messaging': specifier: ^0.6.2 version: 0.6.2(react@18.3.1) @@ -1207,8 +1207,8 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} - '@p-stream/providers@https://codeload.github.com/p-stream/providers/tar.gz/5fa33694229da0506e7a265ff041acdb43e25ff0': - resolution: {tarball: https://codeload.github.com/p-stream/providers/tar.gz/5fa33694229da0506e7a265ff041acdb43e25ff0} + '@p-stream/providers@https://codeload.github.com/p-stream/providers/tar.gz/6a94f978c64ec025171246b7b27e5867bdf21ed1': + resolution: {tarball: https://codeload.github.com/p-stream/providers/tar.gz/6a94f978c64ec025171246b7b27e5867bdf21ed1} version: 3.2.0 '@pkgjs/parseargs@0.11.0': @@ -5523,7 +5523,7 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} - '@p-stream/providers@https://codeload.github.com/p-stream/providers/tar.gz/5fa33694229da0506e7a265ff041acdb43e25ff0': + '@p-stream/providers@https://codeload.github.com/p-stream/providers/tar.gz/6a94f978c64ec025171246b7b27e5867bdf21ed1': dependencies: abort-controller: 3.0.0 cheerio: 1.0.0-rc.12 diff --git a/src/components/player/utils/captionstranslation.ts b/src/components/player/utils/captionstranslation.ts new file mode 100644 index 00000000..85b729df --- /dev/null +++ b/src/components/player/utils/captionstranslation.ts @@ -0,0 +1,201 @@ +import subsrt from "subsrt-ts"; +import { Caption, ContentCaption } from "subsrt-ts/dist/types/handler"; + +const API_URL = + "https://translate.googleapis.com/translate_a/single?client=gtx&dt=t&dj=1&ie=UTF-8&oe=UTF-8&sl=auto&tl={TARGET_LANG}&q="; +const RETRY_COUNT = 3; +const FETCH_RATE = 100; +const SUBTITLES_CACHE: Map = new Map< + string, + ArrayBuffer +>(); + +async function translateText(text: string): Promise { + if (!text) { + return ""; + } + + const response = await ( + await fetch( + `${API_URL.replace("TARGET_LANG", "ro")}${encodeURIComponent(text)}`, + { + method: "GET", + headers: { + Accept: "application/json", + }, + }, + ) + ).json(); + + if (!response) { + throw new Error("Empty response"); + } + + return (response.sentences as any[]) + .map((s: any) => s.trans as string) + .join(""); +} + +async function translateCaption(caption: ContentCaption): Promise { + (caption as any).oldText = caption.text; + let text: string | undefined; + for (let i = 0; i < RETRY_COUNT; i += 1) { + try { + text = await translateText(caption.text.replace("\n", "
")); + if (text) { + text = text.replace("
", "\n"); + break; + } + } catch (error) { + console.warn("[CTR] Re-trying caption translation", caption, error); + } + } + if (!text) { + console.error("[CTR] Failed to translate caption"); + caption.text = `(CAPTION COULD NOT BE TRANSLATED)\n${caption.text}}`; + return false; + } + caption.text = text.trim(); + return true; +} + +async function translateCaptions(captions: ContentCaption[]): Promise { + console.log("[CTR] Translating", captions.length, "captions"); + try { + const results: boolean[] = await Promise.all( + captions.map((c) => translateCaption(c)), + ); + + const successCount = results.filter((v) => v).length; + const failedCount = results.length - successCount; + const successPercentange = (successCount / results.length) * 100; + const failedPercentange = (failedCount / results.length) * 100; + console.log( + "[CTR] Done translating captions", + results.length, + successCount, + failedCount, + successPercentange, + failedPercentange, + ); + + if (failedPercentange > successPercentange) { + throw new Error("Success percentage is not acceptable"); + } + } catch (error) { + console.error( + "[CTR] Could not translate", + captions.length, + "captions", + error, + ); + return false; + } + return true; +} + +function tryUseCached( + caption: ContentCaption, + cache: Map, +): boolean { + const text: string | undefined = cache.get(caption.text); + if (text) { + caption.text = text; + return true; + } + return false; +} + +async function translateSRTData(data: string): Promise { + let captions: Caption[]; + try { + captions = subsrt.parse(data); + } catch (error) { + console.error("[CTR] Failed to parse subtitle data", error); + return undefined; + } + + let translatedCaptions: Caption[] | undefined = []; + const contentCaptions: ContentCaption[] = []; + const translatedCache: Map = new Map(); + + for (const caption of captions) { + translatedCaptions.push(caption); + if (caption.type !== "caption") { + continue; + } + caption.text = caption.text + .trim() + .replace("\r\n", "\n") + .replace("\r", "\n"); + contentCaptions.push(caption); + } + + for (let i = 0; i < contentCaptions.length; i += 1) { + if (tryUseCached(contentCaptions[i], translatedCache)) { + continue; + } + const batch: ContentCaption[] = [contentCaptions[i]]; + + let j; + for (j = 1; j < FETCH_RATE; j += 1) { + if (i + j >= contentCaptions.length) { + break; + } + if (tryUseCached(contentCaptions[i + j], translatedCache)) { + continue; + } + batch.push(contentCaptions[i + j]); + } + i += j; + + if (!(await translateCaptions(batch))) { + translatedCaptions = undefined; + break; + } + + batch.forEach((c) => translatedCache.set((c as any).oldText!, c.text)); + } + + return translatedCaptions + ? subsrt.build(translatedCaptions, { format: "srt" }) + : undefined; +} + +async function compressStr(string: string): Promise { + const byteArray = new TextEncoder().encode(string); + const cs = new CompressionStream("deflate"); + const writer = cs.writable.getWriter(); + writer.write(byteArray); + writer.close(); + return new Response(cs.readable).arrayBuffer(); +} + +async function decompressStr(byteArray: ArrayBuffer): Promise { + const cs = new DecompressionStream("deflate"); + const writer = cs.writable.getWriter(); + writer.write(byteArray); + writer.close(); + return new Response(cs.readable).arrayBuffer().then(function (arrayBuffer) { + return new TextDecoder().decode(arrayBuffer); + }); +} + +export async function translateSubtitles( + id: string, + srtData: string, +): Promise { + const cachedData: ArrayBuffer | undefined = SUBTITLES_CACHE.get(id); + if (cachedData) { + console.log("[CTR] Using cached translation for", id); + return decompressStr(cachedData); + } + console.log("[CTR] Translating", id); + const translatedData: string | undefined = await translateSRTData(srtData); + if (!translatedData) { + return undefined; + } + console.log("[CTR] Caching translation for", id); + SUBTITLES_CACHE.set(id, await compressStr(translatedData)); + return translatedData; +} diff --git a/src/pages/developer/TestView.tsx b/src/pages/developer/TestView.tsx index d6f5a053..375ed131 100644 --- a/src/pages/developer/TestView.tsx +++ b/src/pages/developer/TestView.tsx @@ -1,12 +1,74 @@ -import { useState } from "react"; +import { useCallback, useState } from "react"; import { Button } from "@/components/buttons/Button"; +import { usePlayer } from "@/components/player/hooks/usePlayer"; +import { PlaybackErrorPart } from "@/pages/parts/player/PlaybackErrorPart"; +import { PlayerPart } from "@/pages/parts/player/PlayerPart"; +import { + CaptionListItem, + PlayerMeta, + playerStatus, +} from "@/stores/player/slices/source"; +import { SourceSliceSource } from "@/stores/player/utils/qualities"; + +const subtitlesTestMeta: PlayerMeta = { + type: "movie", + title: "Subtitles Test", + releaseYear: 2024, + tmdbId: "0", +}; + +const subtitlesTestSource: SourceSliceSource = { + type: "hls", + url: "http://localhost:8000/media/master.m3u8", +}; + +const subtitlesTestSubs: CaptionListItem[] = [ + { + id: "English", + language: "en", + url: "http://localhost:8000/subs/en.srt", + needsProxy: false, + }, + { + id: "Romanian", + language: "ro", + url: "http://localhost:8000/subs/ro.srt", + needsProxy: false, + }, +]; // mostly empty view, add whatever you need export default function TestView() { - const [val, setVal] = useState(false); + const player = usePlayer(); + const [showPlayer, setShowPlayer] = useState(false); + const [shouldCrash, setShouldCrash] = useState(false); - if (val) throw new Error("I crashed"); + if (shouldCrash) { + throw new Error("I crashed"); + } - return ; + const subtitlesTest = useCallback(async () => { + setShowPlayer(true); + player.reset(); + await new Promise((r) => { + setTimeout(r, 100); + }); + player.setShouldStartFromBeginning(true); + player.setMeta(subtitlesTestMeta); + player.playMedia(subtitlesTestSource, subtitlesTestSubs, null); + }, [player]); + + return showPlayer ? ( + + {player && (player as any).status === playerStatus.PLAYBACK_ERROR ? ( + + ) : null} + + ) : ( + <> + + + + ); } From 9a846a818139ef638fd5fe4867fc7d9c953a24ca Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Thu, 25 Dec 2025 13:00:59 -0700 Subject: [PATCH 02/25] hide WatchPartyInputLink if backend requirement isnt met --- src/components/LinksDropdown.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/components/LinksDropdown.tsx b/src/components/LinksDropdown.tsx index 50ae38c0..21573dc7 100644 --- a/src/components/LinksDropdown.tsx +++ b/src/components/LinksDropdown.tsx @@ -2,8 +2,10 @@ import classNames from "classnames"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; +import { useAsync } from "react-use"; import { base64ToBuffer, decryptData } from "@/backend/accounts/crypto"; +import { getBackendMeta } from "@/backend/accounts/meta"; import { getRoomStatuses } from "@/backend/player/status"; import { UserAvatar } from "@/components/Avatar"; import { Icon, Icons } from "@/components/Icon"; @@ -213,6 +215,17 @@ export function LinksDropdown(props: { children: React.ReactNode }) { [seed], ); const { logout } = useAuth(); + const backendUrl = useBackendUrl(); + + // Check backend compatibility for watch party + const backendMeta = useAsync(async () => { + if (!backendUrl) return; + return getBackendMeta(backendUrl); + }, [backendUrl]); + + const backendSupportsWatchParty = backendMeta?.value?.version + ? backendMeta.value.version >= "2.0.1" + : false; useEffect(() => { function onWindowClick(evt: MouseEvent) { @@ -291,7 +304,7 @@ export function LinksDropdown(props: { children: React.ReactNode }) { {t("navigation.menu.discover")} )} - + {backendSupportsWatchParty && } {deviceName ? ( Date: Thu, 25 Dec 2025 23:08:42 +0200 Subject: [PATCH 03/25] Add reset functionality to VideoTesterView on mount --- src/pages/developer/VideoTesterView.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/pages/developer/VideoTesterView.tsx b/src/pages/developer/VideoTesterView.tsx index 7c234c54..ce764b19 100644 --- a/src/pages/developer/VideoTesterView.tsx +++ b/src/pages/developer/VideoTesterView.tsx @@ -36,7 +36,7 @@ const streamTypes: Record = { }; export default function VideoTesterView() { - const { status, playMedia, setMeta } = usePlayer(); + const { status, playMedia, setMeta, reset } = usePlayer(); const [selected, setSelected] = useState("mp4"); const [inputSource, setInputSource] = useState(""); const [extensionState, setExtensionState] = @@ -236,6 +236,14 @@ export default function VideoTesterView() { } }, [playMedia, setMeta, extensionState]); + // player meta and streams cary over, so reset on mount + useEffect(() => { + if (status !== playerStatus.IDLE) { + reset(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return ( {status === playerStatus.IDLE ? ( From 6bc4907399e99599225bb7898c45462204fae6df Mon Sep 17 00:00:00 2001 From: vlOd2 <66838724+vlOd2@users.noreply.github.com> Date: Fri, 26 Dec 2025 01:00:09 +0200 Subject: [PATCH 04/25] Begin work on translate subtitle view --- .vscode/settings.json | 5 +- src/components/Icon.tsx | 2 + src/components/player/atoms/Settings.tsx | 39 +++++++++++- .../player/atoms/settings/CaptionsView.tsx | 62 ++++++++++++++++++- .../atoms/settings/LanguageSubtitlesView.tsx | 10 +++ .../atoms/settings/TranslateSubtitleView.tsx | 46 ++++++++++++++ .../player/utils/captionstranslation.ts | 2 +- 7 files changed, 161 insertions(+), 5 deletions(-) create mode 100644 src/components/player/atoms/settings/TranslateSubtitleView.tsx diff --git a/.vscode/settings.json b/.vscode/settings.json index cb78ef1f..77974714 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -9,9 +9,12 @@ "editor.defaultFormatter": "esbenp.prettier-vscode" }, "[type.scriptreact]": { - "editor.defaultFormatter": "dbaeumer.vscode-eslint" + "editor.defaultFormatter": "esbenp.prettier-vscode" }, "[javascript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" } } diff --git a/src/components/Icon.tsx b/src/components/Icon.tsx index ad256aa0..d2a3aad1 100644 --- a/src/components/Icon.tsx +++ b/src/components/Icon.tsx @@ -83,6 +83,7 @@ export enum Icons { RELOAD = "reload", REPEAT = "repeat", PLUS = "plus", + TRANSLATE = "translate", } export interface IconProps { @@ -183,6 +184,7 @@ const iconList: Record = { reload: ``, repeat: ``, plus: ``, + translate: ``, }; export const Icon = memo((props: IconProps) => { diff --git a/src/components/player/atoms/Settings.tsx b/src/components/player/atoms/Settings.tsx index 355510c9..07042d2e 100644 --- a/src/components/player/atoms/Settings.tsx +++ b/src/components/player/atoms/Settings.tsx @@ -12,6 +12,7 @@ import { import { VideoPlayerButton } from "@/components/player/internals/Button"; import { Menu } from "@/components/player/internals/ContextMenu"; import { useOverlayRouter } from "@/hooks/useOverlayRouter"; +import { CaptionListItem } from "@/stores/player/slices/source"; import { usePlayerStore } from "@/stores/player/store"; import { AudioView } from "./settings/AudioView"; @@ -23,11 +24,14 @@ import { PlaybackSettingsView } from "./settings/PlaybackSettingsView"; import { QualityView } from "./settings/QualityView"; import { SettingsMenu } from "./settings/SettingsMenu"; import { TranscriptView } from "./settings/TranscriptView"; +import { TranslateSubtitleView } from "./settings/TranslateSubtitleView"; import { WatchPartyView } from "./settings/WatchPartyView"; function SettingsOverlay({ id }: { id: string }) { const [chosenSourceId, setChosenSourceId] = useState(null); const [chosenLanguage, setChosenLanguage] = useState(null); + const [captionToTranslate, setCaptionToTranslate] = + useState(null); const router = useOverlayRouter(id); // reset source id and language when going to home or closing overlay @@ -84,6 +88,23 @@ function SettingsOverlay({ id }: { id: string }) { + )} + + + + + {captionToTranslate && ( + )} @@ -138,7 +159,23 @@ function SettingsOverlay({ id }: { id: string }) { > {chosenLanguage && ( - + + )} + + + + + {captionToTranslate && ( + )} diff --git a/src/components/player/atoms/settings/CaptionsView.tsx b/src/components/player/atoms/settings/CaptionsView.tsx index 6681dbe4..b2f523f9 100644 --- a/src/components/player/atoms/settings/CaptionsView.tsx +++ b/src/components/player/atoms/settings/CaptionsView.tsx @@ -9,6 +9,7 @@ import { subtitleTypeList } from "@/backend/helpers/subs"; import { FileDropHandler } from "@/components/DropFile"; import { FlagIcon } from "@/components/FlagIcon"; import { Icon, Icons } from "@/components/Icon"; +import { Spinner } from "@/components/layout/Spinner"; import { useCaptions } from "@/components/player/hooks/useCaptions"; import { Menu } from "@/components/player/internals/ContextMenu"; import { SelectableLink } from "@/components/player/internals/ContextMenu/Links"; @@ -26,7 +27,8 @@ import { sortLangCodes, } from "@/utils/language"; -export function CaptionOption(props: { +/* eslint-disable react/no-unused-prop-types */ +export interface CaptionOptionProps { countryCode?: string; children: React.ReactNode; selected?: boolean; @@ -41,7 +43,62 @@ export function CaptionOption(props: { subtitleEncoding?: string; isHearingImpaired?: boolean; onDoubleClick?: () => void; -}) { + onTranslate?: () => void; +} +/* eslint-enable react/no-unused-prop-types */ + +function CaptionOptionRightSide(props: CaptionOptionProps) { + if (props.loading) { + // should override selected and error and not show translate button + return ; + } + + function translateBtn(margin: boolean) { + return ( + props.countryCode && ( + { + e.stopPropagation(); + props.onTranslate?.(); + }} + > + + + ) + ); + } + + if (props.selected || props.error) { + return ( +
+ {translateBtn(true)} + {props.error ? ( + + + + ) : ( + + )} +
+ ); + } + + return translateBtn(false); +} + +export function CaptionOption(props: CaptionOptionProps) { const [showTooltip, setShowTooltip] = useState(false); const tooltipTimeoutRef = useRef(null); const { t } = useTranslation(); @@ -110,6 +167,7 @@ export function CaptionOption(props: { error={props.error} onClick={props.onClick} onDoubleClick={props.onDoubleClick} + rightSide={} > void; } export function LanguageSubtitlesView({ id, language, overlayBackLink, + onTranslateSubtitle, }: LanguageSubtitlesViewProps) { const { t } = useTranslation(); const router = useOverlayRouter(id); @@ -130,6 +132,14 @@ export function LanguageSubtitlesView({ : undefined } onClick={() => startDownload(v.id)} + onTranslate={() => { + onTranslateSubtitle?.(v); + router.navigate( + overlayBackLink + ? "/captions/translateSubtitle" + : "/captionsOverlay/translateSubtitleOverlay", + ); + }} onDoubleClick={handleDoubleClick} flag subtitleUrl={v.url} diff --git a/src/components/player/atoms/settings/TranslateSubtitleView.tsx b/src/components/player/atoms/settings/TranslateSubtitleView.tsx new file mode 100644 index 00000000..bd0d702b --- /dev/null +++ b/src/components/player/atoms/settings/TranslateSubtitleView.tsx @@ -0,0 +1,46 @@ +import { useTranslation } from "react-i18next"; + +import { FlagIcon } from "@/components/FlagIcon"; +import { Menu } from "@/components/player/internals/ContextMenu"; +import { useOverlayRouter } from "@/hooks/useOverlayRouter"; +import { CaptionListItem } from "@/stores/player/slices/source"; + +export interface LanguageSubtitlesViewProps { + id: string; + caption: CaptionListItem; + overlayBackLink?: boolean; +} + +export function TranslateSubtitleView({ + id, + caption, + overlayBackLink, +}: LanguageSubtitlesViewProps) { + const { t } = useTranslation(); + const router = useOverlayRouter(id); + + return ( + <> + + router.navigate( + overlayBackLink + ? "/captionsOverlay/languagesOverlay" + : "/captions/languages", + ) + } + > + + + Translate from {caption.id} + + + +
+
+ {t("player.menus.subtitles.notFound")} +
+
+ + ); +} diff --git a/src/components/player/utils/captionstranslation.ts b/src/components/player/utils/captionstranslation.ts index 85b729df..db0fc1bc 100644 --- a/src/components/player/utils/captionstranslation.ts +++ b/src/components/player/utils/captionstranslation.ts @@ -176,7 +176,7 @@ async function decompressStr(byteArray: ArrayBuffer): Promise { const writer = cs.writable.getWriter(); writer.write(byteArray); writer.close(); - return new Response(cs.readable).arrayBuffer().then(function (arrayBuffer) { + return new Response(cs.readable).arrayBuffer().then((arrayBuffer) => { return new TextDecoder().decode(arrayBuffer); }); } From f72c6214e8dd92fa5dbb866a63616c4688604a1f Mon Sep 17 00:00:00 2001 From: vlOd2 <66838724+vlOd2@users.noreply.github.com> Date: Fri, 26 Dec 2025 04:40:10 +0200 Subject: [PATCH 05/25] Add languages to translation UI --- src/assets/locales/en.json | 5 +- .../player/atoms/settings/CaptionsView.tsx | 3 +- .../atoms/settings/LanguageSubtitlesView.tsx | 1 + .../atoms/settings/TranslateSubtitleView.tsx | 87 ++++++++++++++++++- 4 files changed, 90 insertions(+), 6 deletions(-) diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json index a0f93d16..6d76f7b4 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -837,7 +837,10 @@ "useNativeSubtitles": "Native video subtitles", "useNativeSubtitlesDescription": "Broadcast subtitles for native fullscreen and PiP", "delayLate": "Heard audio", - "delayEarly": "Saw caption" + "delayEarly": "Saw caption", + "translate": { + "title": "Translate from {{language}}" + } }, "watchparty": { "watchpartyItem": "Watch Party", diff --git a/src/components/player/atoms/settings/CaptionsView.tsx b/src/components/player/atoms/settings/CaptionsView.tsx index b2f523f9..d696fae3 100644 --- a/src/components/player/atoms/settings/CaptionsView.tsx +++ b/src/components/player/atoms/settings/CaptionsView.tsx @@ -36,6 +36,7 @@ export interface CaptionOptionProps { onClick?: () => void; error?: React.ReactNode; flag?: boolean; + translatable?: boolean; subtitleUrl?: string; subtitleType?: string; // subtitle details from wyzie @@ -55,7 +56,7 @@ function CaptionOptionRightSide(props: CaptionOptionProps) { function translateBtn(margin: boolean) { return ( - props.countryCode && ( + props.translatable && ( + {friendlyName} + + ); + } + return ( <> - Translate from {caption.id} + + {t("player.menus.subtitles.translate.title", { + replace: { + language: + getPrettyLanguageNameFromLocale(caption.language) ?? + caption.language, + }, + })} +
-
- {t("player.menus.subtitles.notFound")} -
+ {availableLanguages.map(renderTargetLang)}
); From a3dd8512bd6d1037e50cf3e87306e8ab511e4db2 Mon Sep 17 00:00:00 2001 From: vlOd2 <66838724+vlOd2@users.noreply.github.com> Date: Fri, 26 Dec 2025 21:27:30 +0200 Subject: [PATCH 06/25] implement translation task --- .../player/atoms/settings/CaptionsView.tsx | 4 +- .../atoms/settings/TranslateSubtitleView.tsx | 51 +++++++- src/pages/developer/TestView.tsx | 13 +- src/stores/player/slices/source.ts | 120 ++++++++++++++++++ .../player/utils/captionstranslation.ts | 78 +++++++----- 5 files changed, 233 insertions(+), 33 deletions(-) rename src/{components => stores}/player/utils/captionstranslation.ts (85%) diff --git a/src/components/player/atoms/settings/CaptionsView.tsx b/src/components/player/atoms/settings/CaptionsView.tsx index d696fae3..5e6f4871 100644 --- a/src/components/player/atoms/settings/CaptionsView.tsx +++ b/src/components/player/atoms/settings/CaptionsView.tsx @@ -32,6 +32,7 @@ export interface CaptionOptionProps { countryCode?: string; children: React.ReactNode; selected?: boolean; + disabled?: boolean; loading?: boolean; onClick?: () => void; error?: React.ReactNode; @@ -83,7 +84,7 @@ function CaptionOptionRightSide(props: CaptionOptionProps) {
{translateBtn(true)} {props.error ? ( - + ) : ( @@ -166,6 +167,7 @@ export function CaptionOption(props: CaptionOptionProps) { selected={props.selected} loading={props.loading} error={props.error} + disabled={props.disabled} onClick={props.onClick} onDoubleClick={props.onDoubleClick} rightSide={} diff --git a/src/components/player/atoms/settings/TranslateSubtitleView.tsx b/src/components/player/atoms/settings/TranslateSubtitleView.tsx index caab3d3d..9e4b2f93 100644 --- a/src/components/player/atoms/settings/TranslateSubtitleView.tsx +++ b/src/components/player/atoms/settings/TranslateSubtitleView.tsx @@ -1,9 +1,11 @@ +import { useEffect } from "react"; import { useTranslation } from "react-i18next"; import { FlagIcon } from "@/components/FlagIcon"; import { Menu } from "@/components/player/internals/ContextMenu"; import { useOverlayRouter } from "@/hooks/useOverlayRouter"; import { CaptionListItem } from "@/stores/player/slices/source"; +import { usePlayerStore } from "@/stores/player/store"; import { getPrettyLanguageNameFromLocale } from "@/utils/language"; import { CaptionOption } from "./CaptionsView"; @@ -81,12 +83,59 @@ export function TranslateSubtitleView({ }: LanguageSubtitlesViewProps) { const { t } = useTranslation(); const router = useOverlayRouter(id); + const setCaption = usePlayerStore((s) => s.setCaption); + const translateTask = usePlayerStore((s) => s.caption.translateTask); + const translateCaption = usePlayerStore((s) => s.translateCaption); + const clearTranslateTask = usePlayerStore((s) => s.clearTranslateTask); + + useEffect(() => { + if (!translateTask) { + return; + } + if (translateTask.done) { + console.log(translateTask.translatedCaption); + // setCaption(translateTask.translatedCaption!); + } + }, [translateTask, setCaption]); function renderTargetLang(langCode: string) { const friendlyName = getPrettyLanguageNameFromLocale(langCode); + async function onClick() { + clearTranslateTask(); + await translateCaption(caption, langCode); + } + return ( - + + !translateTask || translateTask.done || translateTask.error + ? onClick() + : undefined + } + flag + > {friendlyName} ); diff --git a/src/pages/developer/TestView.tsx b/src/pages/developer/TestView.tsx index 375ed131..83b7997e 100644 --- a/src/pages/developer/TestView.tsx +++ b/src/pages/developer/TestView.tsx @@ -25,13 +25,22 @@ const subtitlesTestSource: SourceSliceSource = { const subtitlesTestSubs: CaptionListItem[] = [ { - id: "English", + id: "http://localhost:8000/subs/en.srt", + display: "English", language: "en", url: "http://localhost:8000/subs/en.srt", needsProxy: false, }, { - id: "Romanian", + id: "http://localhost:8000/subs/en-small.srt", + display: "English (small)", + language: "en", + url: "http://localhost:8000/subs/en-small.srt", + needsProxy: false, + }, + { + id: "http://localhost:8000/subs/ro.srt", + display: "Romanian", language: "ro", url: "http://localhost:8000/subs/ro.srt", needsProxy: false, diff --git a/src/stores/player/slices/source.ts b/src/stores/player/slices/source.ts index c667818c..3d435e9f 100644 --- a/src/stores/player/slices/source.ts +++ b/src/stores/player/slices/source.ts @@ -1,6 +1,7 @@ /* eslint-disable no-console */ import { ScrapeMedia } from "@p-stream/providers"; +import { downloadCaption } from "@/backend/helpers/subs"; import { MakeSlice } from "@/stores/player/slices/types"; import { SourceQuality, @@ -10,6 +11,8 @@ import { import { useQualityStore } from "@/stores/quality"; import { ValuesOf } from "@/utils/typeguard"; +import { translateSubtitle } from "../utils/captionstranslation"; + export const playerStatus = { IDLE: "idle", RESUME: "resume", @@ -73,6 +76,16 @@ export interface AudioTrack { language: string; } +export interface TranslateTask { + targetCaption: CaptionListItem; + fetchedTargetCaption?: Caption; + targetLanguage: string; + translatedCaption?: Caption; + done: boolean; + error: boolean; + cancel: () => void; +} + export interface SourceSlice { status: PlayerStatus; source: SourceSliceSource | null; @@ -87,6 +100,7 @@ export interface SourceSlice { caption: { selected: Caption | null; asTrack: boolean; + translateTask: TranslateTask | null; }; meta: PlayerMeta | null; failedSourcesPerMedia: Record; // mediaKey -> array of failed sourceIds @@ -106,6 +120,11 @@ export interface SourceSlice { redisplaySource(startAt: number): void; setCaptionAsTrack(asTrack: boolean): void; addExternalSubtitles(): Promise; + translateCaption( + targetCaption: CaptionListItem, + targetLanguage: string, + ): Promise; + clearTranslateTask(): void; addFailedSource(sourceId: string): void; addFailedEmbed(sourceId: string, embedId: string): void; clearFailedSources(mediaKey?: string): void; @@ -174,6 +193,7 @@ export const createSourceSlice: MakeSlice = (set, get) => ({ caption: { selected: null, asTrack: false, + translateTask: null, }, setSourceId(id) { set((s) => { @@ -374,9 +394,11 @@ export const createSourceSlice: MakeSlice = (set, get) => ({ s.meta = null; s.failedSourcesPerMedia = {}; s.failedEmbedsPerMedia = {}; + this.clearTranslateTask(); s.caption = { selected: null, asTrack: false, + translateTask: null, }; }); }, @@ -413,4 +435,102 @@ export const createSourceSlice: MakeSlice = (set, get) => ({ }); } }, + + clearTranslateTask() { + set((s) => { + console.log("Clearing translate task"); + if (s.caption.translateTask) { + console.log("Cancelling ongoing translate task"); + s.caption.translateTask.cancel(); + } + s.caption.translateTask = null; + }); + }, + + async translateCaption( + targetCaption: CaptionListItem, + targetLanguage: string, + ) { + let store = get(); + + if (store.caption.translateTask) { + console.warn("A translation task is already in progress"); + return; + } + + let cancelled = false; + + set((s) => { + s.caption.translateTask = { + targetCaption, + targetLanguage, + done: false, + error: false, + cancel() { + console.log("Translation task cancelled by user"); + cancelled = true; + }, + }; + }); + + function handleError(err: any) { + console.error("Translation task ran into an error", err); + if (cancelled) { + return; + } + set((s) => { + if (!s.caption.translateTask) return; + s.caption.translateTask.error = true; + }); + } + + try { + const srtData = await downloadCaption(targetCaption); + if (cancelled) { + return; + } + if (!srtData) { + throw new Error("Fetching failed"); + } + set((s) => { + if (!s.caption.translateTask) return; + s.caption.translateTask.fetchedTargetCaption = { + id: targetCaption.id, + language: targetCaption.language, + srtData, + }; + }); + store = get(); + } catch (err) { + handleError(err); + return; + } + + try { + const result = await translateSubtitle( + targetCaption.id, + store.caption.translateTask!.fetchedTargetCaption!.srtData, + targetLanguage, + ); + if (cancelled) { + return; + } + if (!result) { + throw new Error("Translation failed"); + } + set((s) => { + if (!s.caption.translateTask) return; + const translatedCaption: Caption = { + id: `${targetCaption.id}-translated-${targetLanguage}`, + language: targetLanguage, + srtData: result, + }; + s.caption.translateTask.done = true; + s.caption.translateTask.translatedCaption = translatedCaption; + console.log("Caption translation completed", s.caption.translateTask); + }); + } catch (err) { + handleError(err); + } + }, }); diff --git a/src/components/player/utils/captionstranslation.ts b/src/stores/player/utils/captionstranslation.ts similarity index 85% rename from src/components/player/utils/captionstranslation.ts rename to src/stores/player/utils/captionstranslation.ts index db0fc1bc..18009875 100644 --- a/src/components/player/utils/captionstranslation.ts +++ b/src/stores/player/utils/captionstranslation.ts @@ -10,14 +10,36 @@ const SUBTITLES_CACHE: Map = new Map< ArrayBuffer >(); -async function translateText(text: string): Promise { +async function compressStr(string: string): Promise { + const byteArray = new TextEncoder().encode(string); + const cs = new CompressionStream("deflate"); + const writer = cs.writable.getWriter(); + writer.write(byteArray); + writer.close(); + return new Response(cs.readable).arrayBuffer(); +} + +async function decompressStr(byteArray: ArrayBuffer): Promise { + const cs = new DecompressionStream("deflate"); + const writer = cs.writable.getWriter(); + writer.write(byteArray); + writer.close(); + return new Response(cs.readable).arrayBuffer().then((arrayBuffer) => { + return new TextDecoder().decode(arrayBuffer); + }); +} + +async function translateText( + text: string, + targetLang: string, +): Promise { if (!text) { return ""; } const response = await ( await fetch( - `${API_URL.replace("TARGET_LANG", "ro")}${encodeURIComponent(text)}`, + `${API_URL.replace("{TARGET_LANG}", targetLang)}${encodeURIComponent(text)}`, { method: "GET", headers: { @@ -36,12 +58,18 @@ async function translateText(text: string): Promise { .join(""); } -async function translateCaption(caption: ContentCaption): Promise { +async function translateCaption( + caption: ContentCaption, + targetLang: string, +): Promise { (caption as any).oldText = caption.text; let text: string | undefined; for (let i = 0; i < RETRY_COUNT; i += 1) { try { - text = await translateText(caption.text.replace("\n", "
")); + text = await translateText( + caption.text.replace("\n", "
"), + targetLang, + ); if (text) { text = text.replace("
", "\n"); break; @@ -59,11 +87,14 @@ async function translateCaption(caption: ContentCaption): Promise { return true; } -async function translateCaptions(captions: ContentCaption[]): Promise { +async function translateCaptions( + captions: ContentCaption[], + targetLang: string, +): Promise { console.log("[CTR] Translating", captions.length, "captions"); try { const results: boolean[] = await Promise.all( - captions.map((c) => translateCaption(c)), + captions.map((c) => translateCaption(c, targetLang)), ); const successCount = results.filter((v) => v).length; @@ -106,7 +137,10 @@ function tryUseCached( return false; } -async function translateSRTData(data: string): Promise { +async function translateSRTData( + data: string, + targetLang: string, +): Promise { let captions: Caption[]; try { captions = subsrt.parse(data); @@ -149,7 +183,7 @@ async function translateSRTData(data: string): Promise { } i += j; - if (!(await translateCaptions(batch))) { + if (!(await translateCaptions(batch, targetLang))) { translatedCaptions = undefined; break; } @@ -162,36 +196,22 @@ async function translateSRTData(data: string): Promise { : undefined; } -async function compressStr(string: string): Promise { - const byteArray = new TextEncoder().encode(string); - const cs = new CompressionStream("deflate"); - const writer = cs.writable.getWriter(); - writer.write(byteArray); - writer.close(); - return new Response(cs.readable).arrayBuffer(); -} - -async function decompressStr(byteArray: ArrayBuffer): Promise { - const cs = new DecompressionStream("deflate"); - const writer = cs.writable.getWriter(); - writer.write(byteArray); - writer.close(); - return new Response(cs.readable).arrayBuffer().then((arrayBuffer) => { - return new TextDecoder().decode(arrayBuffer); - }); -} - -export async function translateSubtitles( +export async function translateSubtitle( id: string, srtData: string, + targetLang: string, ): Promise { + id = `${id}_${targetLang}`; const cachedData: ArrayBuffer | undefined = SUBTITLES_CACHE.get(id); if (cachedData) { console.log("[CTR] Using cached translation for", id); return decompressStr(cachedData); } console.log("[CTR] Translating", id); - const translatedData: string | undefined = await translateSRTData(srtData); + const translatedData: string | undefined = await translateSRTData( + srtData, + targetLang, + ); if (!translatedData) { return undefined; } From 0d6aca41d0d683a82849b721568a4c8541808d58 Mon Sep 17 00:00:00 2001 From: vlOd2 <66838724+vlOd2@users.noreply.github.com> Date: Fri, 26 Dec 2025 22:13:56 +0200 Subject: [PATCH 07/25] actually set captions and also tweak main view to show translated from language as highlighted --- .../player/atoms/settings/CaptionsView.tsx | 7 +- .../atoms/settings/LanguageSubtitlesView.tsx | 6 +- .../atoms/settings/TranslateSubtitleView.tsx | 23 ++++- src/components/player/hooks/useCaptions.ts | 93 +++++++++++++------ 4 files changed, 92 insertions(+), 37 deletions(-) diff --git a/src/components/player/atoms/settings/CaptionsView.tsx b/src/components/player/atoms/settings/CaptionsView.tsx index 20fab0bf..e6749147 100644 --- a/src/components/player/atoms/settings/CaptionsView.tsx +++ b/src/components/player/atoms/settings/CaptionsView.tsx @@ -419,6 +419,7 @@ export function CaptionsView({ const { t } = useTranslation(); const router = useOverlayRouter(id); const selectedCaptionId = usePlayerStore((s) => s.caption.selected?.id); + const currentTranslateTask = usePlayerStore((s) => s.caption.translateTask); const { disable, selectRandomCaptionFromLastUsedLanguage } = useCaptions(); const [isRandomSelecting, setIsRandomSelecting] = useState(false); const [dragging, setDragging] = useState(false); @@ -707,7 +708,11 @@ export function CaptionsView({ ({ language, languageName, captions: captionsForLang }) => ( { onChooseLanguage?.(language); diff --git a/src/components/player/atoms/settings/LanguageSubtitlesView.tsx b/src/components/player/atoms/settings/LanguageSubtitlesView.tsx index c3129cd3..47fc79b7 100644 --- a/src/components/player/atoms/settings/LanguageSubtitlesView.tsx +++ b/src/components/player/atoms/settings/LanguageSubtitlesView.tsx @@ -29,6 +29,7 @@ export function LanguageSubtitlesView({ const { t } = useTranslation(); const router = useOverlayRouter(id); const selectedCaptionId = usePlayerStore((s) => s.caption.selected?.id); + const currentTranslateTask = usePlayerStore((s) => s.caption.translateTask); const { selectCaptionById } = useCaptions(); const [currentlyDownloading, setCurrentlyDownloading] = useState< string | null @@ -124,7 +125,10 @@ export function LanguageSubtitlesView({ s.setCaption); + const { setDirectCaption } = useCaptions(); const translateTask = usePlayerStore((s) => s.caption.translateTask); const translateCaption = usePlayerStore((s) => s.translateCaption); const clearTranslateTask = usePlayerStore((s) => s.clearTranslateTask); @@ -93,10 +94,16 @@ export function TranslateSubtitleView({ return; } if (translateTask.done) { - console.log(translateTask.translatedCaption); - // setCaption(translateTask.translatedCaption!); + const tCaption = translateTask.translatedCaption!; + setDirectCaption(tCaption, { + id: tCaption.id, + language: tCaption.language, + needsProxy: false, + url: "", + source: "translation", + }); } - }, [translateTask, setCaption]); + }, [translateTask, setDirectCaption]); function renderTargetLang(langCode: string) { const friendlyName = getPrettyLanguageNameFromLocale(langCode); @@ -115,22 +122,28 @@ export function TranslateSubtitleView({ } loading={ !!translateTask && + translateTask.targetCaption.id === caption.id && !translateTask.done && !translateTask.error && translateTask.targetLanguage === langCode } error={ !!translateTask && + translateTask.targetCaption.id === caption.id && translateTask.error && translateTask.targetLanguage === langCode } selected={ !!translateTask && + translateTask.targetCaption.id === caption.id && translateTask.done && translateTask.targetLanguage === langCode } onClick={() => - !translateTask || translateTask.done || translateTask.error + !translateTask || + translateTask.targetCaption.id !== caption.id || + translateTask.done || + translateTask.error ? onClick() : undefined } diff --git a/src/components/player/hooks/useCaptions.ts b/src/components/player/hooks/useCaptions.ts index 0cca529f..199c05b8 100644 --- a/src/components/player/hooks/useCaptions.ts +++ b/src/components/player/hooks/useCaptions.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo } from "react"; import subsrt from "subsrt-ts"; import { downloadCaption, downloadWebVTT } from "@/backend/helpers/subs"; -import { Caption } from "@/stores/player/slices/source"; +import { Caption, CaptionListItem } from "@/stores/player/slices/source"; import { usePlayerStore } from "@/stores/player/store"; import { usePreferencesStore } from "@/stores/preferences"; import { useSubtitleStore } from "@/stores/subtitles"; @@ -19,6 +19,8 @@ export function useCaptions() { (s) => s.resetSubtitleSpecificSettings, ); const setCaption = usePlayerStore((s) => s.setCaption); + const clearTranslateTask = usePlayerStore((s) => s.clearTranslateTask); + const currentTranslateTask = usePlayerStore((s) => s.caption.translateTask); const lastSelectedLanguage = useSubtitleStore((s) => s.lastSelectedLanguage); const setIsOpenSubtitles = useSubtitleStore((s) => s.setIsOpenSubtitles); @@ -42,6 +44,38 @@ export function useCaptions() { [captionList, getHlsCaptionList], ); + const setDirectCaption = useCallback( + (caption: Caption, listItem: CaptionListItem) => { + setIsOpenSubtitles(!!listItem.opensubtitles); + setCaption(caption); + + // Only reset subtitle settings if selecting a different caption + if (selectedCaption?.id !== caption.id) { + resetSubtitleSpecificSettings(); + } + + setLanguage(caption.language); + + // Use native tracks for MP4 streams instead of custom rendering + if (source?.type === "file" && enableNativeSubtitles) { + setCaptionAsTrack(true); + } else { + // For HLS sources or when native subtitles are disabled, use custom rendering + setCaptionAsTrack(false); + } + }, + [ + setIsOpenSubtitles, + setLanguage, + setCaption, + resetSubtitleSpecificSettings, + source, + setCaptionAsTrack, + enableNativeSubtitles, + selectedCaption, + ], + ); + const selectCaptionById = useCallback( async (captionId: string) => { const caption = captions.find((v) => v.id === captionId); @@ -85,36 +119,15 @@ export function useCaptions() { captionToSet.srtData = srtData; } - setIsOpenSubtitles(!!caption.opensubtitles); - setCaption(captionToSet); - - // Only reset subtitle settings if selecting a different caption - if (selectedCaption?.id !== caption.id) { - resetSubtitleSpecificSettings(); - } - - setLanguage(caption.language); - - // Use native tracks for MP4 streams instead of custom rendering - if (source?.type === "file" && enableNativeSubtitles) { - setCaptionAsTrack(true); - } else { - // For HLS sources or when native subtitles are disabled, use custom rendering - setCaptionAsTrack(false); - } + clearTranslateTask(); + setDirectCaption(captionToSet, caption); }, [ - setIsOpenSubtitles, - setLanguage, captions, - setCaption, - resetSubtitleSpecificSettings, getSubtitleTracks, setSubtitlePreference, - source, - setCaptionAsTrack, - enableNativeSubtitles, - selectedCaption, + setDirectCaption, + clearTranslateTask, ], ); @@ -128,10 +141,11 @@ export function useCaptions() { ); const disable = useCallback(async () => { + clearTranslateTask(); setIsOpenSubtitles(false); setCaption(null); setLanguage(null); - }, [setCaption, setLanguage, setIsOpenSubtitles]); + }, [setCaption, setLanguage, setIsOpenSubtitles, clearTranslateTask]); const selectLastUsedLanguage = useCallback(async () => { const language = lastSelectedLanguage ?? "en"; @@ -188,13 +202,23 @@ export function useCaptions() { if (isCustomCaption) return; const isSelectedCaptionStillAvailable = captions.some( - (caption) => caption.id === selectedCaption.id, + (caption) => + caption.id === + (currentTranslateTask + ? currentTranslateTask.targetCaption + : selectedCaption + ).id, ); if (!isSelectedCaptionStillAvailable) { // Try to find a caption with the same language const sameLanguageCaption = captions.find( - (caption) => caption.language === selectedCaption.language, + (caption) => + caption.language === + (currentTranslateTask + ? currentTranslateTask.targetCaption + : selectedCaption + ).language, ); if (sameLanguageCaption) { @@ -202,10 +226,18 @@ export function useCaptions() { selectCaptionById(sameLanguageCaption.id); } else { // No caption with the same language found, clear the selection + clearTranslateTask(); setCaption(null); } } - }, [captions, selectedCaption, setCaption, selectCaptionById]); + }, [ + captions, + selectedCaption, + setCaption, + selectCaptionById, + clearTranslateTask, + currentTranslateTask, + ]); return { selectLanguage, @@ -213,6 +245,7 @@ export function useCaptions() { selectLastUsedLanguage, toggleLastUsed, selectLastUsedLanguageIfEnabled, + setDirectCaption, selectCaptionById, selectRandomCaptionFromLastUsedLanguage, }; From f71b659a7044fcbe7399f780b3a57547580875c1 Mon Sep 17 00:00:00 2001 From: vlOd2 <66838724+vlOd2@users.noreply.github.com> Date: Fri, 26 Dec 2025 22:35:38 +0200 Subject: [PATCH 08/25] Improve UX for translated subtitles --- .../player/atoms/settings/CaptionsView.tsx | 8 +++++--- .../atoms/settings/LanguageSubtitlesView.tsx | 14 ++++++++++++-- .../atoms/settings/TranslateSubtitleView.tsx | 17 ++++++++++------- 3 files changed, 27 insertions(+), 12 deletions(-) diff --git a/src/components/player/atoms/settings/CaptionsView.tsx b/src/components/player/atoms/settings/CaptionsView.tsx index e6749147..9129326c 100644 --- a/src/components/player/atoms/settings/CaptionsView.tsx +++ b/src/components/player/atoms/settings/CaptionsView.tsx @@ -38,6 +38,7 @@ export interface CaptionOptionProps { error?: React.ReactNode; flag?: boolean; translatable?: boolean; + isTranslatedTarget?: boolean; subtitleUrl?: string; subtitleType?: string; // subtitle details from wyzie @@ -61,12 +62,13 @@ function CaptionOptionRightSide(props: CaptionOptionProps) { { e.stopPropagation(); diff --git a/src/components/player/atoms/settings/LanguageSubtitlesView.tsx b/src/components/player/atoms/settings/LanguageSubtitlesView.tsx index 47fc79b7..1e51b8b0 100644 --- a/src/components/player/atoms/settings/LanguageSubtitlesView.tsx +++ b/src/components/player/atoms/settings/LanguageSubtitlesView.tsx @@ -129,13 +129,22 @@ export function LanguageSubtitlesView({ v.id === selectedCaptionId || v.id === currentTranslateTask?.targetCaption?.id } - loading={v.id === currentlyDownloading && downloadReq.loading} + disabled={!!currentTranslateTask && !currentTranslateTask.done} + loading={ + (v.id === currentlyDownloading && downloadReq.loading) || + (!!currentTranslateTask && + v.id === currentTranslateTask.targetCaption.id && + !currentTranslateTask.done) + } error={ v.id === currentlyDownloading && downloadReq.error ? downloadReq.error.toString() : undefined } - onClick={() => startDownload(v.id)} + onClick={() => + (!currentTranslateTask || currentTranslateTask.done) && + startDownload(v.id) + } onTranslate={() => { onTranslateSubtitle?.(v); router.navigate( @@ -144,6 +153,7 @@ export function LanguageSubtitlesView({ : "/captionsOverlay/translateSubtitleOverlay", ); }} + isTranslatedTarget={v.id === currentTranslateTask?.targetCaption?.id} onDoubleClick={handleDoubleClick} flag translatable diff --git a/src/components/player/atoms/settings/TranslateSubtitleView.tsx b/src/components/player/atoms/settings/TranslateSubtitleView.tsx index 705d61ba..b515adfe 100644 --- a/src/components/player/atoms/settings/TranslateSubtitleView.tsx +++ b/src/components/player/atoms/settings/TranslateSubtitleView.tsx @@ -97,10 +97,9 @@ export function TranslateSubtitleView({ const tCaption = translateTask.translatedCaption!; setDirectCaption(tCaption, { id: tCaption.id, + url: "", language: tCaption.language, needsProxy: false, - url: "", - source: "translation", }); } }, [translateTask, setDirectCaption]); @@ -140,10 +139,7 @@ export function TranslateSubtitleView({ translateTask.targetLanguage === langCode } onClick={() => - !translateTask || - translateTask.targetCaption.id !== caption.id || - translateTask.done || - translateTask.error + !translateTask || translateTask.done || translateTask.error ? onClick() : undefined } @@ -180,7 +176,14 @@ export function TranslateSubtitleView({
- {availableLanguages.map(renderTargetLang)} + {availableLanguages + .filter( + (lang) => + lang !== caption.language && + !lang.includes(caption.language) && + !caption.language.includes(lang), + ) + .map(renderTargetLang)}
); From 48f72c228ed0b663eda473e7e6ee7c633cbe1c93 Mon Sep 17 00:00:00 2001 From: vlOd2 <66838724+vlOd2@users.noreply.github.com> Date: Fri, 26 Dec 2025 22:57:59 +0200 Subject: [PATCH 09/25] Refactor translation task handling to improve error management and state updates --- .../player/atoms/settings/CaptionsView.tsx | 1 + .../atoms/settings/LanguageSubtitlesView.tsx | 23 +++++++++++++++---- .../atoms/settings/TranslateSubtitleView.tsx | 3 ++- src/components/player/hooks/useCaptions.ts | 15 ++---------- src/stores/player/slices/source.ts | 8 +++++++ 5 files changed, 31 insertions(+), 19 deletions(-) diff --git a/src/components/player/atoms/settings/CaptionsView.tsx b/src/components/player/atoms/settings/CaptionsView.tsx index 9129326c..1872e361 100644 --- a/src/components/player/atoms/settings/CaptionsView.tsx +++ b/src/components/player/atoms/settings/CaptionsView.tsx @@ -713,6 +713,7 @@ export function CaptionsView({ selected={ (!currentTranslateTask && selectedLanguage === language) || (!!currentTranslateTask && + !currentTranslateTask.error && currentTranslateTask.targetCaption.language === language) } rightText={captionsForLang.length.toString()} diff --git a/src/components/player/atoms/settings/LanguageSubtitlesView.tsx b/src/components/player/atoms/settings/LanguageSubtitlesView.tsx index 1e51b8b0..09de7d02 100644 --- a/src/components/player/atoms/settings/LanguageSubtitlesView.tsx +++ b/src/components/player/atoms/settings/LanguageSubtitlesView.tsx @@ -127,14 +127,21 @@ export function LanguageSubtitlesView({ countryCode={v.language} selected={ v.id === selectedCaptionId || - v.id === currentTranslateTask?.targetCaption?.id + (!!currentTranslateTask && + !currentTranslateTask.error && + v.id === currentTranslateTask.targetCaption.id) + } + disabled={ + !!currentTranslateTask && + !currentTranslateTask.done && + !currentTranslateTask.error } - disabled={!!currentTranslateTask && !currentTranslateTask.done} loading={ (v.id === currentlyDownloading && downloadReq.loading) || (!!currentTranslateTask && v.id === currentTranslateTask.targetCaption.id && - !currentTranslateTask.done) + !currentTranslateTask.done && + !currentTranslateTask.error) } error={ v.id === currentlyDownloading && downloadReq.error @@ -142,7 +149,9 @@ export function LanguageSubtitlesView({ : undefined } onClick={() => - (!currentTranslateTask || currentTranslateTask.done) && + (!currentTranslateTask || + currentTranslateTask.done || + currentTranslateTask.error) && startDownload(v.id) } onTranslate={() => { @@ -153,7 +162,11 @@ export function LanguageSubtitlesView({ : "/captionsOverlay/translateSubtitleOverlay", ); }} - isTranslatedTarget={v.id === currentTranslateTask?.targetCaption?.id} + isTranslatedTarget={ + !!currentTranslateTask && + !currentTranslateTask.error && + v.id === currentTranslateTask.targetCaption.id + } onDoubleClick={handleDoubleClick} flag translatable diff --git a/src/components/player/atoms/settings/TranslateSubtitleView.tsx b/src/components/player/atoms/settings/TranslateSubtitleView.tsx index b515adfe..62223141 100644 --- a/src/components/player/atoms/settings/TranslateSubtitleView.tsx +++ b/src/components/player/atoms/settings/TranslateSubtitleView.tsx @@ -84,7 +84,7 @@ export function TranslateSubtitleView({ }: LanguageSubtitlesViewProps) { const { t } = useTranslation(); const router = useOverlayRouter(id); - const { setDirectCaption } = useCaptions(); + const { setDirectCaption, disable: disableCaptions } = useCaptions(); const translateTask = usePlayerStore((s) => s.caption.translateTask); const translateCaption = usePlayerStore((s) => s.translateCaption); const clearTranslateTask = usePlayerStore((s) => s.clearTranslateTask); @@ -109,6 +109,7 @@ export function TranslateSubtitleView({ async function onClick() { clearTranslateTask(); + disableCaptions(); await translateCaption(caption, langCode); } diff --git a/src/components/player/hooks/useCaptions.ts b/src/components/player/hooks/useCaptions.ts index 199c05b8..7d51475c 100644 --- a/src/components/player/hooks/useCaptions.ts +++ b/src/components/player/hooks/useCaptions.ts @@ -19,7 +19,6 @@ export function useCaptions() { (s) => s.resetSubtitleSpecificSettings, ); const setCaption = usePlayerStore((s) => s.setCaption); - const clearTranslateTask = usePlayerStore((s) => s.clearTranslateTask); const currentTranslateTask = usePlayerStore((s) => s.caption.translateTask); const lastSelectedLanguage = useSubtitleStore((s) => s.lastSelectedLanguage); const setIsOpenSubtitles = useSubtitleStore((s) => s.setIsOpenSubtitles); @@ -119,16 +118,9 @@ export function useCaptions() { captionToSet.srtData = srtData; } - clearTranslateTask(); setDirectCaption(captionToSet, caption); }, - [ - captions, - getSubtitleTracks, - setSubtitlePreference, - setDirectCaption, - clearTranslateTask, - ], + [captions, getSubtitleTracks, setSubtitlePreference, setDirectCaption], ); const selectLanguage = useCallback( @@ -141,11 +133,10 @@ export function useCaptions() { ); const disable = useCallback(async () => { - clearTranslateTask(); setIsOpenSubtitles(false); setCaption(null); setLanguage(null); - }, [setCaption, setLanguage, setIsOpenSubtitles, clearTranslateTask]); + }, [setCaption, setLanguage, setIsOpenSubtitles]); const selectLastUsedLanguage = useCallback(async () => { const language = lastSelectedLanguage ?? "en"; @@ -226,7 +217,6 @@ export function useCaptions() { selectCaptionById(sameLanguageCaption.id); } else { // No caption with the same language found, clear the selection - clearTranslateTask(); setCaption(null); } } @@ -235,7 +225,6 @@ export function useCaptions() { selectedCaption, setCaption, selectCaptionById, - clearTranslateTask, currentTranslateTask, ]); diff --git a/src/stores/player/slices/source.ts b/src/stores/player/slices/source.ts index 3d435e9f..911e0853 100644 --- a/src/stores/player/slices/source.ts +++ b/src/stores/player/slices/source.ts @@ -238,6 +238,14 @@ export const createSourceSlice: MakeSlice = (set, get) => ({ setCaption(caption) { const store = get(); store.display?.setCaption(caption); + if ( + !caption || + (store.caption.translateTask && + store.caption.translateTask.targetCaption.id !== caption?.id && + store.caption.translateTask.translatedCaption?.id !== caption?.id) + ) { + store.clearTranslateTask(); + } set((s) => { s.caption.selected = caption; }); From edb9cad8dc67df3db45786a95b5dc779dd58a5db Mon Sep 17 00:00:00 2001 From: vlOd2 <66838724+vlOd2@users.noreply.github.com> Date: Fri, 26 Dec 2025 23:14:45 +0200 Subject: [PATCH 10/25] adjust margin for translate button --- src/components/player/atoms/settings/CaptionsView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/player/atoms/settings/CaptionsView.tsx b/src/components/player/atoms/settings/CaptionsView.tsx index 1872e361..3f4fb649 100644 --- a/src/components/player/atoms/settings/CaptionsView.tsx +++ b/src/components/player/atoms/settings/CaptionsView.tsx @@ -63,7 +63,7 @@ function CaptionOptionRightSide(props: CaptionOptionProps) { className={classNames( "text-buttons-secondaryText px-2 py-1 rounded bg-opacity-0", { - "mr-3": margin, + "mr-1": margin, "bg-opacity-100 bg-buttons-purpleHover": props.isTranslatedTarget, }, "transition duration-300 ease-in-out", From 9ad28631e9c4fdd67b5770705b676dd37822f0ea Mon Sep 17 00:00:00 2001 From: vlOd2 <66838724+vlOd2@users.noreply.github.com> Date: Fri, 26 Dec 2025 23:23:17 +0200 Subject: [PATCH 11/25] Cleanup translation console logs --- src/stores/player/slices/source.ts | 7 +- .../player/utils/captionstranslation.ts | 90 +++++++++---------- 2 files changed, 46 insertions(+), 51 deletions(-) diff --git a/src/stores/player/slices/source.ts b/src/stores/player/slices/source.ts index 911e0853..b9fc8b7d 100644 --- a/src/stores/player/slices/source.ts +++ b/src/stores/player/slices/source.ts @@ -446,9 +446,7 @@ export const createSourceSlice: MakeSlice = (set, get) => ({ clearTranslateTask() { set((s) => { - console.log("Clearing translate task"); if (s.caption.translateTask) { - console.log("Cancelling ongoing translate task"); s.caption.translateTask.cancel(); } s.caption.translateTask = null; @@ -475,7 +473,9 @@ export const createSourceSlice: MakeSlice = (set, get) => ({ done: false, error: false, cancel() { - console.log("Translation task cancelled by user"); + if (!this.done || !this.error) { + console.log("Translation task was cancelled"); + } cancelled = true; }, }; @@ -535,7 +535,6 @@ export const createSourceSlice: MakeSlice = (set, get) => ({ }; s.caption.translateTask.done = true; s.caption.translateTask.translatedCaption = translatedCaption; - console.log("Caption translation completed", s.caption.translateTask); }); } catch (err) { handleError(err); diff --git a/src/stores/player/utils/captionstranslation.ts b/src/stores/player/utils/captionstranslation.ts index 18009875..9219df09 100644 --- a/src/stores/player/utils/captionstranslation.ts +++ b/src/stores/player/utils/captionstranslation.ts @@ -2,7 +2,7 @@ import subsrt from "subsrt-ts"; import { Caption, ContentCaption } from "subsrt-ts/dist/types/handler"; const API_URL = - "https://translate.googleapis.com/translate_a/single?client=gtx&dt=t&dj=1&ie=UTF-8&oe=UTF-8&sl=auto&tl={TARGET_LANG}&q="; + "https://translate.googleapis.com/translate_a/single?client=gtx&dt=t&dj=1&ie=UTF-8&oe=UTF-8&sl=auto"; const RETRY_COUNT = 3; const FETCH_RATE = 100; const SUBTITLES_CACHE: Map = new Map< @@ -29,6 +29,18 @@ async function decompressStr(byteArray: ArrayBuffer): Promise { }); } +function tryUseCachedCaption( + caption: ContentCaption, + cache: Map, +): boolean { + const text: string | undefined = cache.get(caption.text); + if (text) { + caption.text = text; + return true; + } + return false; +} + async function translateText( text: string, targetLang: string, @@ -38,15 +50,12 @@ async function translateText( } const response = await ( - await fetch( - `${API_URL.replace("{TARGET_LANG}", targetLang)}${encodeURIComponent(text)}`, - { - method: "GET", - headers: { - Accept: "application/json", - }, + await fetch(`${API_URL}&tl=${targetLang}&q=${encodeURIComponent(text)}`, { + method: "GET", + headers: { + Accept: "application/json", }, - ) + }) ).json(); if (!response) { @@ -75,11 +84,11 @@ async function translateCaption( break; } } catch (error) { - console.warn("[CTR] Re-trying caption translation", caption, error); + console.warn("Re-trying caption translation", caption, error); } } if (!text) { - console.error("[CTR] Failed to translate caption"); + console.error("Failed to translate caption"); caption.text = `(CAPTION COULD NOT BE TRANSLATED)\n${caption.text}}`; return false; } @@ -91,7 +100,7 @@ async function translateCaptions( captions: ContentCaption[], targetLang: string, ): Promise { - console.log("[CTR] Translating", captions.length, "captions"); + // console.log("Translating", captions.length, "captions"); try { const results: boolean[] = await Promise.all( captions.map((c) => translateCaption(c, targetLang)), @@ -101,42 +110,25 @@ async function translateCaptions( const failedCount = results.length - successCount; const successPercentange = (successCount / results.length) * 100; const failedPercentange = (failedCount / results.length) * 100; - console.log( - "[CTR] Done translating captions", - results.length, - successCount, - failedCount, - successPercentange, - failedPercentange, - ); + // console.log( + // "Done translating captions", + // results.length, + // successCount, + // failedCount, + // successPercentange, + // failedPercentange, + // ); if (failedPercentange > successPercentange) { throw new Error("Success percentage is not acceptable"); } } catch (error) { - console.error( - "[CTR] Could not translate", - captions.length, - "captions", - error, - ); + console.error("Could not translate", captions.length, "captions", error); return false; } return true; } -function tryUseCached( - caption: ContentCaption, - cache: Map, -): boolean { - const text: string | undefined = cache.get(caption.text); - if (text) { - caption.text = text; - return true; - } - return false; -} - async function translateSRTData( data: string, targetLang: string, @@ -145,7 +137,7 @@ async function translateSRTData( try { captions = subsrt.parse(data); } catch (error) { - console.error("[CTR] Failed to parse subtitle data", error); + console.error("Failed to parse subtitle data", error); return undefined; } @@ -166,7 +158,7 @@ async function translateSRTData( } for (let i = 0; i < contentCaptions.length; i += 1) { - if (tryUseCached(contentCaptions[i], translatedCache)) { + if (tryUseCachedCaption(contentCaptions[i], translatedCache)) { continue; } const batch: ContentCaption[] = [contentCaptions[i]]; @@ -176,7 +168,7 @@ async function translateSRTData( if (i + j >= contentCaptions.length) { break; } - if (tryUseCached(contentCaptions[i + j], translatedCache)) { + if (tryUseCachedCaption(contentCaptions[i + j], translatedCache)) { continue; } batch.push(contentCaptions[i + j]); @@ -196,18 +188,21 @@ async function translateSRTData( : undefined; } +// TODO: make this support multiple providers rather than just google translate export async function translateSubtitle( id: string, srtData: string, targetLang: string, ): Promise { - id = `${id}_${targetLang}`; - const cachedData: ArrayBuffer | undefined = SUBTITLES_CACHE.get(id); + const cacheID = `${id}_${targetLang}`; + + const cachedData: ArrayBuffer | undefined = SUBTITLES_CACHE.get(cacheID); if (cachedData) { - console.log("[CTR] Using cached translation for", id); + // console.log("Using cached translation for", id, cacheID); return decompressStr(cachedData); } - console.log("[CTR] Translating", id); + + // console.log("Translating", id); const translatedData: string | undefined = await translateSRTData( srtData, targetLang, @@ -215,7 +210,8 @@ export async function translateSubtitle( if (!translatedData) { return undefined; } - console.log("[CTR] Caching translation for", id); - SUBTITLES_CACHE.set(id, await compressStr(translatedData)); + + // console.log("Caching translation for", id, cacheID); + SUBTITLES_CACHE.set(cacheID, await compressStr(translatedData)); return translatedData; } From ed51c6d1e8c555d0b081026aa2fad76e286ee9aa Mon Sep 17 00:00:00 2001 From: vlOd2 <66838724+vlOd2@users.noreply.github.com> Date: Fri, 26 Dec 2025 23:25:34 +0200 Subject: [PATCH 12/25] remove subtitles testing from testview --- src/pages/developer/TestView.tsx | 75 +------------------------------- 1 file changed, 2 insertions(+), 73 deletions(-) diff --git a/src/pages/developer/TestView.tsx b/src/pages/developer/TestView.tsx index 83b7997e..590bd4aa 100644 --- a/src/pages/developer/TestView.tsx +++ b/src/pages/developer/TestView.tsx @@ -1,83 +1,12 @@ -import { useCallback, useState } from "react"; +import { useState } from "react"; import { Button } from "@/components/buttons/Button"; -import { usePlayer } from "@/components/player/hooks/usePlayer"; -import { PlaybackErrorPart } from "@/pages/parts/player/PlaybackErrorPart"; -import { PlayerPart } from "@/pages/parts/player/PlayerPart"; -import { - CaptionListItem, - PlayerMeta, - playerStatus, -} from "@/stores/player/slices/source"; -import { SourceSliceSource } from "@/stores/player/utils/qualities"; - -const subtitlesTestMeta: PlayerMeta = { - type: "movie", - title: "Subtitles Test", - releaseYear: 2024, - tmdbId: "0", -}; - -const subtitlesTestSource: SourceSliceSource = { - type: "hls", - url: "http://localhost:8000/media/master.m3u8", -}; - -const subtitlesTestSubs: CaptionListItem[] = [ - { - id: "http://localhost:8000/subs/en.srt", - display: "English", - language: "en", - url: "http://localhost:8000/subs/en.srt", - needsProxy: false, - }, - { - id: "http://localhost:8000/subs/en-small.srt", - display: "English (small)", - language: "en", - url: "http://localhost:8000/subs/en-small.srt", - needsProxy: false, - }, - { - id: "http://localhost:8000/subs/ro.srt", - display: "Romanian", - language: "ro", - url: "http://localhost:8000/subs/ro.srt", - needsProxy: false, - }, -]; // mostly empty view, add whatever you need export default function TestView() { - const player = usePlayer(); - const [showPlayer, setShowPlayer] = useState(false); const [shouldCrash, setShouldCrash] = useState(false); - if (shouldCrash) { throw new Error("I crashed"); } - - const subtitlesTest = useCallback(async () => { - setShowPlayer(true); - player.reset(); - await new Promise((r) => { - setTimeout(r, 100); - }); - player.setShouldStartFromBeginning(true); - player.setMeta(subtitlesTestMeta); - player.playMedia(subtitlesTestSource, subtitlesTestSubs, null); - }, [player]); - - return showPlayer ? ( - - {player && (player as any).status === playerStatus.PLAYBACK_ERROR ? ( - - ) : null} - - ) : ( - <> - - - - ); + return ; } From b797200c3ae84dd04ed56cc930122492cc0e001f Mon Sep 17 00:00:00 2001 From: vlOd2 <66838724+vlOd2@users.noreply.github.com> Date: Sat, 27 Dec 2025 00:28:30 +0200 Subject: [PATCH 13/25] increase language overlay width --- src/components/player/atoms/Settings.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/player/atoms/Settings.tsx b/src/components/player/atoms/Settings.tsx index 07042d2e..4eeed626 100644 --- a/src/components/player/atoms/Settings.tsx +++ b/src/components/player/atoms/Settings.tsx @@ -80,7 +80,7 @@ function SettingsOverlay({ id }: { id: string }) { @@ -154,7 +154,7 @@ function SettingsOverlay({ id }: { id: string }) { From eab6113a0fc2fa46bcbf1b71cd1f6aa2fdaba596 Mon Sep 17 00:00:00 2001 From: vlOd <66838724+vlOd2@users.noreply.github.com> Date: Sat, 27 Dec 2025 01:15:53 +0200 Subject: [PATCH 14/25] Update src/pages/developer/VideoTesterView.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/pages/developer/VideoTesterView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/developer/VideoTesterView.tsx b/src/pages/developer/VideoTesterView.tsx index ce764b19..09f4a2aa 100644 --- a/src/pages/developer/VideoTesterView.tsx +++ b/src/pages/developer/VideoTesterView.tsx @@ -236,7 +236,7 @@ export default function VideoTesterView() { } }, [playMedia, setMeta, extensionState]); - // player meta and streams cary over, so reset on mount + // player meta and streams carry over, so reset on mount useEffect(() => { if (status !== playerStatus.IDLE) { reset(); From 96b74aa169cd336fdef8e6757a568c37e1144ea7 Mon Sep 17 00:00:00 2001 From: vlOd <66838724+vlOd2@users.noreply.github.com> Date: Sat, 27 Dec 2025 01:18:17 +0200 Subject: [PATCH 15/25] Update src/stores/player/utils/captionstranslation.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/stores/player/utils/captionstranslation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/stores/player/utils/captionstranslation.ts b/src/stores/player/utils/captionstranslation.ts index 9219df09..da95699b 100644 --- a/src/stores/player/utils/captionstranslation.ts +++ b/src/stores/player/utils/captionstranslation.ts @@ -89,7 +89,7 @@ async function translateCaption( } if (!text) { console.error("Failed to translate caption"); - caption.text = `(CAPTION COULD NOT BE TRANSLATED)\n${caption.text}}`; + caption.text = `(CAPTION COULD NOT BE TRANSLATED)\n${caption.text}`; return false; } caption.text = text.trim(); From 071bdeca0d294f2cbec66ccd2d448eb1be38ed8d Mon Sep 17 00:00:00 2001 From: vlOd <66838724+vlOd2@users.noreply.github.com> Date: Sat, 27 Dec 2025 01:21:29 +0200 Subject: [PATCH 16/25] Update src/stores/player/utils/captionstranslation.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/stores/player/utils/captionstranslation.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/stores/player/utils/captionstranslation.ts b/src/stores/player/utils/captionstranslation.ts index da95699b..aa0613cc 100644 --- a/src/stores/player/utils/captionstranslation.ts +++ b/src/stores/player/utils/captionstranslation.ts @@ -108,18 +108,18 @@ async function translateCaptions( const successCount = results.filter((v) => v).length; const failedCount = results.length - successCount; - const successPercentange = (successCount / results.length) * 100; - const failedPercentange = (failedCount / results.length) * 100; + const successPercentage = (successCount / results.length) * 100; + const failedPercentage = (failedCount / results.length) * 100; // console.log( // "Done translating captions", // results.length, // successCount, // failedCount, - // successPercentange, - // failedPercentange, + // successPercentage, + // failedPercentage, // ); - if (failedPercentange > successPercentange) { + if (failedPercentage > successPercentage) { throw new Error("Success percentage is not acceptable"); } } catch (error) { From bdf5cba98ba05e22f3ada29df046b9225e7c7d44 Mon Sep 17 00:00:00 2001 From: vlOd <66838724+vlOd2@users.noreply.github.com> Date: Sat, 27 Dec 2025 01:21:40 +0200 Subject: [PATCH 17/25] Update src/stores/player/utils/captionstranslation.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/stores/player/utils/captionstranslation.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/stores/player/utils/captionstranslation.ts b/src/stores/player/utils/captionstranslation.ts index aa0613cc..b65913cc 100644 --- a/src/stores/player/utils/captionstranslation.ts +++ b/src/stores/player/utils/captionstranslation.ts @@ -76,11 +76,11 @@ async function translateCaption( for (let i = 0; i < RETRY_COUNT; i += 1) { try { text = await translateText( - caption.text.replace("\n", "
"), + caption.text.replaceAll("\n", "
"), targetLang, ); if (text) { - text = text.replace("
", "\n"); + text = text.replaceAll("
", "\n"); break; } } catch (error) { From 17c0e7a768245d89a22c240826fade26710a7b2b Mon Sep 17 00:00:00 2001 From: vlOd2 <66838724+vlOd2@users.noreply.github.com> Date: Sat, 27 Dec 2025 01:25:19 +0200 Subject: [PATCH 18/25] Apply copilot fixes --- .vscode/settings.json | 2 +- src/components/Icon.tsx | 2 +- .../player/atoms/settings/LanguageSubtitlesView.tsx | 4 ++-- src/stores/player/slices/source.ts | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 77974714..0ab972e4 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -8,7 +8,7 @@ "[jsonc]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, - "[type.scriptreact]": { + "[typescript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, "[javascript]": { diff --git a/src/components/Icon.tsx b/src/components/Icon.tsx index d2a3aad1..4a7da506 100644 --- a/src/components/Icon.tsx +++ b/src/components/Icon.tsx @@ -184,7 +184,7 @@ const iconList: Record = { reload: ``, repeat: ``, plus: ``, - translate: ``, + translate: ``, }; export const Icon = memo((props: IconProps) => { diff --git a/src/components/player/atoms/settings/LanguageSubtitlesView.tsx b/src/components/player/atoms/settings/LanguageSubtitlesView.tsx index 09de7d02..aef7c686 100644 --- a/src/components/player/atoms/settings/LanguageSubtitlesView.tsx +++ b/src/components/player/atoms/settings/LanguageSubtitlesView.tsx @@ -158,8 +158,8 @@ export function LanguageSubtitlesView({ onTranslateSubtitle?.(v); router.navigate( overlayBackLink - ? "/captions/translateSubtitle" - : "/captionsOverlay/translateSubtitleOverlay", + ? "/captionsOverlay/translateSubtitleOverlay" + : "/captions/translateSubtitle", ); }} isTranslatedTarget={ diff --git a/src/stores/player/slices/source.ts b/src/stores/player/slices/source.ts index b9fc8b7d..9de66ce7 100644 --- a/src/stores/player/slices/source.ts +++ b/src/stores/player/slices/source.ts @@ -473,7 +473,7 @@ export const createSourceSlice: MakeSlice = (set, get) => ({ done: false, error: false, cancel() { - if (!this.done || !this.error) { + if (!this.done && !this.error) { console.log("Translation task was cancelled"); } cancelled = true; From 5539061ae4be37e90db68c441919e7a29f82cd0c Mon Sep 17 00:00:00 2001 From: vlOd2 <66838724+vlOd2@users.noreply.github.com> Date: Sun, 28 Dec 2025 15:14:52 +0200 Subject: [PATCH 19/25] Fix translated caption not applying when menu is closed --- src/components/player/atoms/Captions.tsx | 18 ++++++++++++++++ .../atoms/settings/TranslateSubtitleView.tsx | 21 +++---------------- 2 files changed, 21 insertions(+), 18 deletions(-) diff --git a/src/components/player/atoms/Captions.tsx b/src/components/player/atoms/Captions.tsx index 89e419e0..d5ebb43f 100644 --- a/src/components/player/atoms/Captions.tsx +++ b/src/components/player/atoms/Captions.tsx @@ -2,6 +2,7 @@ import { useEffect } from "react"; import { Icons } from "@/components/Icon"; import { OverlayAnchor } from "@/components/overlays/OverlayAnchor"; +import { useCaptions } from "@/components/player/hooks/useCaptions"; import { VideoPlayerButton } from "@/components/player/internals/Button"; import { useOverlayRouter } from "@/hooks/useOverlayRouter"; import { usePlayerStore } from "@/stores/player/store"; @@ -9,11 +10,28 @@ import { usePlayerStore } from "@/stores/player/store"; export function Captions() { const router = useOverlayRouter("settings"); const setHasOpenOverlay = usePlayerStore((s) => s.setHasOpenOverlay); + const { setDirectCaption } = useCaptions(); + const translateTask = usePlayerStore((s) => s.caption.translateTask); useEffect(() => { setHasOpenOverlay(router.isRouterActive); }, [setHasOpenOverlay, router.isRouterActive]); + useEffect(() => { + if (!translateTask) { + return; + } + if (translateTask.done) { + const tCaption = translateTask.translatedCaption!; + setDirectCaption(tCaption, { + id: tCaption.id, + url: "", + language: tCaption.language, + needsProxy: false, + }); + } + }, [translateTask, setDirectCaption]); + return ( s.caption.translateTask); const translateCaption = usePlayerStore((s) => s.translateCaption); const clearTranslateTask = usePlayerStore((s) => s.clearTranslateTask); - useEffect(() => { - if (!translateTask) { - return; - } - if (translateTask.done) { - const tCaption = translateTask.translatedCaption!; - setDirectCaption(tCaption, { - id: tCaption.id, - url: "", - language: tCaption.language, - needsProxy: false, - }); - } - }, [translateTask, setDirectCaption]); - function renderTargetLang(langCode: string) { const friendlyName = getPrettyLanguageNameFromLocale(langCode); From 81f1272f7de2428de8fb3116bf422fce1674dc88 Mon Sep 17 00:00:00 2001 From: vlOd2 <66838724+vlOd2@users.noreply.github.com> Date: Sun, 28 Dec 2025 22:36:47 +0200 Subject: [PATCH 20/25] Refactor translator service to be less jank and more modular --- src/pages/developer/TestView.tsx | 75 +++++- src/stores/player/slices/source.ts | 10 +- .../player/utils/captionstranslation.ts | 217 ------------------ src/utils/translation/googletranslate.ts | 72 ++++++ src/utils/translation/index.ts | 187 +++++++++++++++ src/utils/translation/utils.ts | 24 ++ 6 files changed, 361 insertions(+), 224 deletions(-) delete mode 100644 src/stores/player/utils/captionstranslation.ts create mode 100644 src/utils/translation/googletranslate.ts create mode 100644 src/utils/translation/index.ts create mode 100644 src/utils/translation/utils.ts diff --git a/src/pages/developer/TestView.tsx b/src/pages/developer/TestView.tsx index 590bd4aa..9a6293fb 100644 --- a/src/pages/developer/TestView.tsx +++ b/src/pages/developer/TestView.tsx @@ -1,12 +1,83 @@ -import { useState } from "react"; +import { useCallback, useState } from "react"; import { Button } from "@/components/buttons/Button"; +import { usePlayer } from "@/components/player/hooks/usePlayer"; +import { PlaybackErrorPart } from "@/pages/parts/player/PlaybackErrorPart"; +import { PlayerPart } from "@/pages/parts/player/PlayerPart"; +import { + CaptionListItem, + PlayerMeta, + playerStatus, +} from "@/stores/player/slices/source"; +import { SourceSliceSource } from "@/stores/player/utils/qualities"; + +const subtitlesTestMeta: PlayerMeta = { + type: "movie", + title: "Subtitles Test", + releaseYear: 2024, + tmdbId: "0", +}; + +const subtitlesTestSource: SourceSliceSource = { + type: "hls", + url: "http://localhost:8000/media/master.m3u8", +}; + +const subtitlesTestSubs: CaptionListItem[] = [ + { + id: "http://localhost:8000/subs/en.srt", + display: "English", + language: "en", + url: "http://localhost:8000/subs/en.srt", + needsProxy: false, + }, + { + id: "http://localhost:8000/subs/en-small.srt", + display: "English Small", + language: "en", + url: "http://localhost:8000/subs/en-small.srt", + needsProxy: false, + }, + { + id: "http://localhost:8000/subs/ro.srt", + display: "Romanian", + language: "ro", + url: "http://localhost:8000/subs/ro.srt", + needsProxy: false, + }, +]; // mostly empty view, add whatever you need export default function TestView() { + const player = usePlayer(); + const [showPlayer, setShowPlayer] = useState(false); const [shouldCrash, setShouldCrash] = useState(false); + if (shouldCrash) { throw new Error("I crashed"); } - return ; + + const subtitlesTest = useCallback(async () => { + setShowPlayer(true); + player.reset(); + await new Promise((r) => { + setTimeout(r, 100); + }); + player.setShouldStartFromBeginning(true); + player.setMeta(subtitlesTestMeta); + player.playMedia(subtitlesTestSource, subtitlesTestSubs, null); + }, [player]); + + return showPlayer ? ( + + {player && (player as any).status === playerStatus.PLAYBACK_ERROR ? ( + + ) : null} + + ) : ( + <> + + + + ); } diff --git a/src/stores/player/slices/source.ts b/src/stores/player/slices/source.ts index 9de66ce7..5fe7f395 100644 --- a/src/stores/player/slices/source.ts +++ b/src/stores/player/slices/source.ts @@ -9,10 +9,10 @@ import { selectQuality, } from "@/stores/player/utils/qualities"; import { useQualityStore } from "@/stores/quality"; +import googletranslate from "@/utils/translation/googletranslate"; +import { translate } from "@/utils/translation/index"; import { ValuesOf } from "@/utils/typeguard"; -import { translateSubtitle } from "../utils/captionstranslation"; - export const playerStatus = { IDLE: "idle", RESUME: "resume", @@ -515,10 +515,10 @@ export const createSourceSlice: MakeSlice = (set, get) => ({ } try { - const result = await translateSubtitle( - targetCaption.id, - store.caption.translateTask!.fetchedTargetCaption!.srtData, + const result = await translate( + store.caption.translateTask!.fetchedTargetCaption!, targetLanguage, + googletranslate, ); if (cancelled) { return; diff --git a/src/stores/player/utils/captionstranslation.ts b/src/stores/player/utils/captionstranslation.ts deleted file mode 100644 index b65913cc..00000000 --- a/src/stores/player/utils/captionstranslation.ts +++ /dev/null @@ -1,217 +0,0 @@ -import subsrt from "subsrt-ts"; -import { Caption, ContentCaption } from "subsrt-ts/dist/types/handler"; - -const API_URL = - "https://translate.googleapis.com/translate_a/single?client=gtx&dt=t&dj=1&ie=UTF-8&oe=UTF-8&sl=auto"; -const RETRY_COUNT = 3; -const FETCH_RATE = 100; -const SUBTITLES_CACHE: Map = new Map< - string, - ArrayBuffer ->(); - -async function compressStr(string: string): Promise { - const byteArray = new TextEncoder().encode(string); - const cs = new CompressionStream("deflate"); - const writer = cs.writable.getWriter(); - writer.write(byteArray); - writer.close(); - return new Response(cs.readable).arrayBuffer(); -} - -async function decompressStr(byteArray: ArrayBuffer): Promise { - const cs = new DecompressionStream("deflate"); - const writer = cs.writable.getWriter(); - writer.write(byteArray); - writer.close(); - return new Response(cs.readable).arrayBuffer().then((arrayBuffer) => { - return new TextDecoder().decode(arrayBuffer); - }); -} - -function tryUseCachedCaption( - caption: ContentCaption, - cache: Map, -): boolean { - const text: string | undefined = cache.get(caption.text); - if (text) { - caption.text = text; - return true; - } - return false; -} - -async function translateText( - text: string, - targetLang: string, -): Promise { - if (!text) { - return ""; - } - - const response = await ( - await fetch(`${API_URL}&tl=${targetLang}&q=${encodeURIComponent(text)}`, { - method: "GET", - headers: { - Accept: "application/json", - }, - }) - ).json(); - - if (!response) { - throw new Error("Empty response"); - } - - return (response.sentences as any[]) - .map((s: any) => s.trans as string) - .join(""); -} - -async function translateCaption( - caption: ContentCaption, - targetLang: string, -): Promise { - (caption as any).oldText = caption.text; - let text: string | undefined; - for (let i = 0; i < RETRY_COUNT; i += 1) { - try { - text = await translateText( - caption.text.replaceAll("\n", "
"), - targetLang, - ); - if (text) { - text = text.replaceAll("
", "\n"); - break; - } - } catch (error) { - console.warn("Re-trying caption translation", caption, error); - } - } - if (!text) { - console.error("Failed to translate caption"); - caption.text = `(CAPTION COULD NOT BE TRANSLATED)\n${caption.text}`; - return false; - } - caption.text = text.trim(); - return true; -} - -async function translateCaptions( - captions: ContentCaption[], - targetLang: string, -): Promise { - // console.log("Translating", captions.length, "captions"); - try { - const results: boolean[] = await Promise.all( - captions.map((c) => translateCaption(c, targetLang)), - ); - - const successCount = results.filter((v) => v).length; - const failedCount = results.length - successCount; - const successPercentage = (successCount / results.length) * 100; - const failedPercentage = (failedCount / results.length) * 100; - // console.log( - // "Done translating captions", - // results.length, - // successCount, - // failedCount, - // successPercentage, - // failedPercentage, - // ); - - if (failedPercentage > successPercentage) { - throw new Error("Success percentage is not acceptable"); - } - } catch (error) { - console.error("Could not translate", captions.length, "captions", error); - return false; - } - return true; -} - -async function translateSRTData( - data: string, - targetLang: string, -): Promise { - let captions: Caption[]; - try { - captions = subsrt.parse(data); - } catch (error) { - console.error("Failed to parse subtitle data", error); - return undefined; - } - - let translatedCaptions: Caption[] | undefined = []; - const contentCaptions: ContentCaption[] = []; - const translatedCache: Map = new Map(); - - for (const caption of captions) { - translatedCaptions.push(caption); - if (caption.type !== "caption") { - continue; - } - caption.text = caption.text - .trim() - .replace("\r\n", "\n") - .replace("\r", "\n"); - contentCaptions.push(caption); - } - - for (let i = 0; i < contentCaptions.length; i += 1) { - if (tryUseCachedCaption(contentCaptions[i], translatedCache)) { - continue; - } - const batch: ContentCaption[] = [contentCaptions[i]]; - - let j; - for (j = 1; j < FETCH_RATE; j += 1) { - if (i + j >= contentCaptions.length) { - break; - } - if (tryUseCachedCaption(contentCaptions[i + j], translatedCache)) { - continue; - } - batch.push(contentCaptions[i + j]); - } - i += j; - - if (!(await translateCaptions(batch, targetLang))) { - translatedCaptions = undefined; - break; - } - - batch.forEach((c) => translatedCache.set((c as any).oldText!, c.text)); - } - - return translatedCaptions - ? subsrt.build(translatedCaptions, { format: "srt" }) - : undefined; -} - -// TODO: make this support multiple providers rather than just google translate -export async function translateSubtitle( - id: string, - srtData: string, - targetLang: string, -): Promise { - const cacheID = `${id}_${targetLang}`; - - const cachedData: ArrayBuffer | undefined = SUBTITLES_CACHE.get(cacheID); - if (cachedData) { - // console.log("Using cached translation for", id, cacheID); - return decompressStr(cachedData); - } - - // console.log("Translating", id); - const translatedData: string | undefined = await translateSRTData( - srtData, - targetLang, - ); - if (!translatedData) { - return undefined; - } - - // console.log("Caching translation for", id, cacheID); - SUBTITLES_CACHE.set(cacheID, await compressStr(translatedData)); - return translatedData; -} diff --git a/src/utils/translation/googletranslate.ts b/src/utils/translation/googletranslate.ts new file mode 100644 index 00000000..47ceafc6 --- /dev/null +++ b/src/utils/translation/googletranslate.ts @@ -0,0 +1,72 @@ +import { TranslateService } from "."; + +const SINGLE_API_URL = + "https://translate.googleapis.com/translate_a/single?client=gtx&dt=t&dj=1&ie=UTF-8&oe=UTF-8&sl=auto"; +const BATCH_API_URL = "https://translate-pa.googleapis.com/v1/translateHtml"; +const BATCH_API_KEY = "AIzaSyATBXajvzQLTDHEQbcpq0Ihe0vWDHmO520"; + +export default { + getName() { + return "Google Translate"; + }, + + getConfig() { + return { + singleBatchSize: 15, + multiBatchSize: 80, + maxRetryCount: 3, + batchSleepMs: 200, + }; + }, + + async translate(str, targetLang) { + if (!str) { + return ""; + } + + const response = await ( + await fetch( + `${SINGLE_API_URL}&tl=${targetLang}&q=${encodeURIComponent(str)}`, + { + method: "GET", + headers: { + Accept: "application/json", + }, + }, + ) + ).json(); + + if (!response.sentences) { + console.warn("Invalid gt response", response); + throw new Error("Invalid response"); + } + + return (response.sentences as any[]) + .map((s: any) => s.trans as string) + .join(""); + }, + + async translateMulti(batch, targetLang) { + if (!batch || batch.length === 0) { + return []; + } + + const response = await ( + await fetch(BATCH_API_URL, { + method: "POST", + headers: { + "Content-Type": "application/json+protobuf", + "X-goog-api-key": BATCH_API_KEY, + }, + body: JSON.stringify([[batch, "auto", targetLang], "te"]), + }) + ).json(); + + if (!Array.isArray(response) || response.length < 1) { + console.warn("Invalid gt batch response", response); + throw new Error("Invalid response"); + } + + return response[0].map((s: any) => s as string); + }, +} satisfies TranslateService; diff --git a/src/utils/translation/index.ts b/src/utils/translation/index.ts new file mode 100644 index 00000000..713f8c0b --- /dev/null +++ b/src/utils/translation/index.ts @@ -0,0 +1,187 @@ +import subsrt from "subsrt-ts"; +import { Caption, ContentCaption } from "subsrt-ts/dist/types/handler"; + +import { Caption as PlayerCaption } from "@/stores/player/slices/source"; + +import { compressStr, decompressStr, sleep } from "./utils"; + +const CAPTIONS_CACHE: Map = new Map(); + +export interface TranslateService { + getName(): string; + getConfig(): { + singleBatchSize: number; + multiBatchSize: number; // -1 = unsupported + maxRetryCount: number; + batchSleepMs: number; + }; + translate(str: string, targetLang: string): Promise; + translateMulti(batch: string[], targetLang: string): Promise; +} + +class Translator { + private captions: Caption[]; + + private contentCaptions: ContentCaption[] = []; + + private contentCache: Map = new Map(); + + private targetLang: string; + + private service: TranslateService; + + constructor(srtData: string, targetLang: string, service: TranslateService) { + this.captions = subsrt.parse(srtData); + this.targetLang = targetLang; + this.service = service; + + for (const caption of this.captions) { + if (caption.type !== "caption") { + continue; + } + // Normalize line endings + caption.text = caption.text + .trim() + .replaceAll("\r\n", "\n") + .replaceAll("\r", "\n"); + this.contentCaptions.push(caption); + } + } + + fillContentFromCache(content: ContentCaption): boolean { + const text: string | undefined = this.contentCache.get(content.text); + if (text) { + content.text = text; + return true; + } + return false; + } + + async translateContent(content: ContentCaption): Promise { + let result; + let attempts = 0; + const errors: any[] = []; + + while (!result && attempts < 3) { + try { + result = await this.service.translate(content.text, this.targetLang); + } catch (err) { + console.warn("Translation attempt failed"); + errors.push(err); + await sleep(500); + attempts += 1; + } + } + + if (!result) { + console.warn("Translation failed", errors); + return false; + } + + content.text = result; + this.contentCache.set(content.text, result); + return true; + } + + async translateContentBatch(batch: ContentCaption[]): Promise { + try { + const result = await this.service.translateMulti( + batch.map((content) => content.text), + this.targetLang, + ); + + if (result.length !== batch.length) { + console.warn( + "Batch translation size mismatch", + result.length, + batch.length, + ); + return false; + } + + for (let i = 0; i < batch.length; i += 1) { + batch[i].text = result[i]; + this.contentCache.set(batch[i].text, result[i]); + } + + return true; + } catch (err) { + console.warn("Batch translation failed", err); + return false; + } + } + + takeBatch(): ContentCaption[] { + const batch: ContentCaption[] = []; + const batchSize = + this.service.getConfig().multiBatchSize === -1 + ? this.service.getConfig().singleBatchSize + : this.service.getConfig().multiBatchSize; + + let count = 0; + while (count < batchSize && this.contentCaptions.length > 0) { + const content: ContentCaption = this.contentCaptions.shift()!; + if (this.fillContentFromCache(content)) { + continue; + } + batch.push(content); + count += 1; + } + + return batch; + } + + async translate(): Promise { + let batch: ContentCaption[] = this.takeBatch(); + while (batch.length > 0) { + let result: boolean; + console.info("Translating captions batch", batch.length, batch); + + if (this.service.getConfig().multiBatchSize === -1) { + result = ( + await Promise.all( + batch.map((content) => this.translateContent(content)), + ) + ).every((res) => res); + } else { + result = await this.translateContentBatch(batch); + } + + if (!result) { + console.error( + "Failed to translate captions batch", + batch.length, + batch, + ); + return undefined; + } + + batch = this.takeBatch(); + await sleep(this.service.getConfig().batchSleepMs); + } + return subsrt.build(this.captions, { format: "srt" }); + } +} + +export async function translate( + caption: PlayerCaption, + targetLang: string, + service: TranslateService, +): Promise { + const cacheID = `${caption.id}_${targetLang}`; + + const cachedData: ArrayBuffer | undefined = CAPTIONS_CACHE.get(cacheID); + if (cachedData) { + return decompressStr(cachedData); + } + + const translator = new Translator(caption.srtData, targetLang, service); + + const result = await translator.translate(); + if (!result) { + return undefined; + } + + CAPTIONS_CACHE.set(cacheID, await compressStr(result)); + return result; +} diff --git a/src/utils/translation/utils.ts b/src/utils/translation/utils.ts new file mode 100644 index 00000000..f2933214 --- /dev/null +++ b/src/utils/translation/utils.ts @@ -0,0 +1,24 @@ +export async function compressStr(string: string): Promise { + const byteArray = new TextEncoder().encode(string); + const cs = new CompressionStream("deflate"); + const writer = cs.writable.getWriter(); + writer.write(byteArray); + writer.close(); + return new Response(cs.readable).arrayBuffer(); +} + +export async function decompressStr(byteArray: ArrayBuffer): Promise { + const cs = new DecompressionStream("deflate"); + const writer = cs.writable.getWriter(); + writer.write(byteArray); + writer.close(); + return new Response(cs.readable).arrayBuffer().then((arrayBuffer) => { + return new TextDecoder().decode(arrayBuffer); + }); +} + +export function sleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} From b8a972f9acdab12f49a1bfc29967744d7c4498be Mon Sep 17 00:00:00 2001 From: vlOd2 <66838724+vlOd2@users.noreply.github.com> Date: Sun, 28 Dec 2025 23:44:36 +0200 Subject: [PATCH 21/25] Add support for aborting and new lines --- src/stores/player/slices/source.ts | 13 +-- src/utils/translation/googletranslate.ts | 26 ++++-- src/utils/translation/index.ts | 114 ++++++++++++++++++----- 3 files changed, 116 insertions(+), 37 deletions(-) diff --git a/src/stores/player/slices/source.ts b/src/stores/player/slices/source.ts index 5fe7f395..dab67ce2 100644 --- a/src/stores/player/slices/source.ts +++ b/src/stores/player/slices/source.ts @@ -464,7 +464,7 @@ export const createSourceSlice: MakeSlice = (set, get) => ({ return; } - let cancelled = false; + const abortController = new AbortController(); set((s) => { s.caption.translateTask = { @@ -476,16 +476,16 @@ export const createSourceSlice: MakeSlice = (set, get) => ({ if (!this.done && !this.error) { console.log("Translation task was cancelled"); } - cancelled = true; + abortController.abort(); }, }; }); function handleError(err: any) { - console.error("Translation task ran into an error", err); - if (cancelled) { + if (abortController.signal.aborted) { return; } + console.error("Translation task ran into an error", err); set((s) => { if (!s.caption.translateTask) return; s.caption.translateTask.error = true; @@ -494,7 +494,7 @@ export const createSourceSlice: MakeSlice = (set, get) => ({ try { const srtData = await downloadCaption(targetCaption); - if (cancelled) { + if (abortController.signal.aborted) { return; } if (!srtData) { @@ -519,8 +519,9 @@ export const createSourceSlice: MakeSlice = (set, get) => ({ store.caption.translateTask!.fetchedTargetCaption!, targetLanguage, googletranslate, + abortController.signal, ); - if (cancelled) { + if (abortController.signal.aborted) { return; } if (!result) { diff --git a/src/utils/translation/googletranslate.ts b/src/utils/translation/googletranslate.ts index 47ceafc6..59e93149 100644 --- a/src/utils/translation/googletranslate.ts +++ b/src/utils/translation/googletranslate.ts @@ -12,23 +12,30 @@ export default { getConfig() { return { - singleBatchSize: 15, - multiBatchSize: 80, + single: { + batchSize: 250, + batchDelayMs: 1000, + }, + multi: { + batchSize: 80, + batchDelayMs: 200, + }, maxRetryCount: 3, - batchSleepMs: 200, }; }, - async translate(str, targetLang) { + async translate(str, targetLang, abortSignal) { if (!str) { return ""; } + str = str.replaceAll("\n", "
"); const response = await ( await fetch( `${SINGLE_API_URL}&tl=${targetLang}&q=${encodeURIComponent(str)}`, { method: "GET", + signal: abortSignal, headers: { Accept: "application/json", }, @@ -43,17 +50,20 @@ export default { return (response.sentences as any[]) .map((s: any) => s.trans as string) - .join(""); + .join("") + .replaceAll("
", "\n"); }, - async translateMulti(batch, targetLang) { + async translateMulti(batch, targetLang, abortSignal) { if (!batch || batch.length === 0) { return []; } + batch = batch.map((s) => s.replaceAll("\n", "
")); const response = await ( await fetch(BATCH_API_URL, { method: "POST", + signal: abortSignal, headers: { "Content-Type": "application/json+protobuf", "X-goog-api-key": BATCH_API_KEY, @@ -67,6 +77,8 @@ export default { throw new Error("Invalid response"); } - return response[0].map((s: any) => s as string); + return response[0].map((s: any) => + (s as string).replaceAll("
", "\n"), + ); }, } satisfies TranslateService; diff --git a/src/utils/translation/index.ts b/src/utils/translation/index.ts index 713f8c0b..249d9401 100644 --- a/src/utils/translation/index.ts +++ b/src/utils/translation/index.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-console */ import subsrt from "subsrt-ts"; import { Caption, ContentCaption } from "subsrt-ts/dist/types/handler"; @@ -7,16 +8,32 @@ import { compressStr, decompressStr, sleep } from "./utils"; const CAPTIONS_CACHE: Map = new Map(); +// single will not be used if multi-line is supported +export interface TranslateServiceConfig { + single: { + batchSize: number; + batchDelayMs: number; + }; + multi?: { + batchSize: number; + batchDelayMs: number; + }; + maxRetryCount: number; +} + export interface TranslateService { getName(): string; - getConfig(): { - singleBatchSize: number; - multiBatchSize: number; // -1 = unsupported - maxRetryCount: number; - batchSleepMs: number; - }; - translate(str: string, targetLang: string): Promise; - translateMulti(batch: string[], targetLang: string): Promise; + getConfig(): TranslateServiceConfig; + translate( + str: string, + targetLang: string, + abortSignal?: AbortSignal, + ): Promise; + translateMulti( + batch: string[], + targetLang: string, + abortSignal?: AbortSignal, + ): Promise; } class Translator { @@ -30,10 +47,21 @@ class Translator { private service: TranslateService; - constructor(srtData: string, targetLang: string, service: TranslateService) { + private serviceCfg: TranslateServiceConfig; + + private abortSignal?: AbortSignal; + + constructor( + srtData: string, + targetLang: string, + service: TranslateService, + abortSignal?: AbortSignal, + ) { this.captions = subsrt.parse(srtData); this.targetLang = targetLang; this.service = service; + this.serviceCfg = service.getConfig(); + this.abortSignal = abortSignal; for (const caption of this.captions) { if (caption.type !== "caption") { @@ -64,8 +92,15 @@ class Translator { while (!result && attempts < 3) { try { - result = await this.service.translate(content.text, this.targetLang); + result = await this.service.translate( + content.text, + this.targetLang, + this.abortSignal, + ); } catch (err) { + if (this.abortSignal?.aborted) { + break; + } console.warn("Translation attempt failed"); errors.push(err); await sleep(500); @@ -73,6 +108,10 @@ class Translator { } } + if (this.abortSignal?.aborted) { + return false; + } + if (!result) { console.warn("Translation failed", errors); return false; @@ -88,6 +127,7 @@ class Translator { const result = await this.service.translateMulti( batch.map((content) => content.text), this.targetLang, + this.abortSignal, ); if (result.length !== batch.length) { @@ -106,6 +146,9 @@ class Translator { return true; } catch (err) { + if (this.abortSignal?.aborted) { + return false; + } console.warn("Batch translation failed", err); return false; } @@ -113,10 +156,9 @@ class Translator { takeBatch(): ContentCaption[] { const batch: ContentCaption[] = []; - const batchSize = - this.service.getConfig().multiBatchSize === -1 - ? this.service.getConfig().singleBatchSize - : this.service.getConfig().multiBatchSize; + const batchSize = !this.serviceCfg.multi + ? this.serviceCfg.single.batchSize + : this.serviceCfg.multi!.batchSize; let count = 0; while (count < batchSize && this.contentCaptions.length > 0) { @@ -132,12 +174,24 @@ class Translator { } async translate(): Promise { + const batchDelay = !this.serviceCfg.multi + ? this.serviceCfg.single.batchDelayMs + : this.serviceCfg.multi!.batchDelayMs; + + console.info( + "Translating captions", + this.service.getName(), + this.contentCaptions.length, + batchDelay, + ); + console.time("translation"); + let batch: ContentCaption[] = this.takeBatch(); while (batch.length > 0) { let result: boolean; - console.info("Translating captions batch", batch.length, batch); + console.info("Translating batch", batch.length, batch); - if (this.service.getConfig().multiBatchSize === -1) { + if (!this.serviceCfg.multi) { result = ( await Promise.all( batch.map((content) => this.translateContent(content)), @@ -147,18 +201,24 @@ class Translator { result = await this.translateContentBatch(batch); } + if (this.abortSignal?.aborted) { + return undefined; + } + if (!result) { - console.error( - "Failed to translate captions batch", - batch.length, - batch, - ); + console.error("Failed to translate batch", batch.length, batch); return undefined; } batch = this.takeBatch(); - await sleep(this.service.getConfig().batchSleepMs); + await sleep(batchDelay); } + + if (this.abortSignal?.aborted) { + return undefined; + } + + console.timeEnd("translation"); return subsrt.build(this.captions, { format: "srt" }); } } @@ -167,6 +227,7 @@ export async function translate( caption: PlayerCaption, targetLang: string, service: TranslateService, + abortSignal?: AbortSignal, ): Promise { const cacheID = `${caption.id}_${targetLang}`; @@ -175,10 +236,15 @@ export async function translate( return decompressStr(cachedData); } - const translator = new Translator(caption.srtData, targetLang, service); + const translator = new Translator( + caption.srtData, + targetLang, + service, + abortSignal, + ); const result = await translator.translate(); - if (!result) { + if (!result || abortSignal?.aborted) { return undefined; } From ca61d3b96678b3eff5afeab3311ffb9e0e32f25f Mon Sep 17 00:00:00 2001 From: vlOd2 <66838724+vlOd2@users.noreply.github.com> Date: Mon, 29 Dec 2025 00:02:32 +0200 Subject: [PATCH 22/25] Remove unused import --- pnpm-lock.yaml | 16 ++++++++-------- .../atoms/settings/TranslateSubtitleView.tsx | 1 - 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 48ff924f..1e419a64 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,7 +44,7 @@ importers: version: 1.8.0 '@p-stream/providers': specifier: github:p-stream/providers#production - version: https://codeload.github.com/p-stream/providers/tar.gz/6a94f978c64ec025171246b7b27e5867bdf21ed1 + version: https://codeload.github.com/p-stream/providers/tar.gz/fc5a98210c5e14588c8c2daa4f3cba3970d84103 '@plasmohq/messaging': specifier: ^0.6.2 version: 0.6.2(react@18.3.1) @@ -1207,8 +1207,8 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} - '@p-stream/providers@https://codeload.github.com/p-stream/providers/tar.gz/6a94f978c64ec025171246b7b27e5867bdf21ed1': - resolution: {tarball: https://codeload.github.com/p-stream/providers/tar.gz/6a94f978c64ec025171246b7b27e5867bdf21ed1} + '@p-stream/providers@https://codeload.github.com/p-stream/providers/tar.gz/fc5a98210c5e14588c8c2daa4f3cba3970d84103': + resolution: {tarball: https://codeload.github.com/p-stream/providers/tar.gz/fc5a98210c5e14588c8c2daa4f3cba3970d84103} version: 3.2.0 '@pkgjs/parseargs@0.11.0': @@ -3750,8 +3750,8 @@ packages: serialize-javascript@6.0.2: resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} - set-cookie-parser@2.7.1: - resolution: {integrity: sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==} + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} @@ -5523,7 +5523,7 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} - '@p-stream/providers@https://codeload.github.com/p-stream/providers/tar.gz/6a94f978c64ec025171246b7b27e5867bdf21ed1': + '@p-stream/providers@https://codeload.github.com/p-stream/providers/tar.gz/fc5a98210c5e14588c8c2daa4f3cba3970d84103': dependencies: abort-controller: 3.0.0 cheerio: 1.0.0-rc.12 @@ -5536,7 +5536,7 @@ snapshots: json5: 2.2.3 nanoid: 3.3.11 node-fetch: 3.3.2 - set-cookie-parser: 2.7.1 + set-cookie-parser: 2.7.2 unpacker: 1.0.1 '@pkgjs/parseargs@0.11.0': @@ -8215,7 +8215,7 @@ snapshots: dependencies: randombytes: 2.1.0 - set-cookie-parser@2.7.1: {} + set-cookie-parser@2.7.2: {} set-function-length@1.2.2: dependencies: diff --git a/src/components/player/atoms/settings/TranslateSubtitleView.tsx b/src/components/player/atoms/settings/TranslateSubtitleView.tsx index e14a7fbc..b26e5d2c 100644 --- a/src/components/player/atoms/settings/TranslateSubtitleView.tsx +++ b/src/components/player/atoms/settings/TranslateSubtitleView.tsx @@ -1,4 +1,3 @@ -import { useEffect } from "react"; import { useTranslation } from "react-i18next"; import { FlagIcon } from "@/components/FlagIcon"; From ceecfc7a2260709c8bf6e8e6a2c41e785275ba38 Mon Sep 17 00:00:00 2001 From: vlOd2 <66838724+vlOd2@users.noreply.github.com> Date: Mon, 29 Dec 2025 00:13:15 +0200 Subject: [PATCH 23/25] Revert testview to how it was before --- src/pages/developer/TestView.tsx | 73 +------------------------------- 1 file changed, 2 insertions(+), 71 deletions(-) diff --git a/src/pages/developer/TestView.tsx b/src/pages/developer/TestView.tsx index 9a6293fb..b257f463 100644 --- a/src/pages/developer/TestView.tsx +++ b/src/pages/developer/TestView.tsx @@ -1,83 +1,14 @@ -import { useCallback, useState } from "react"; +import { useState } from "react"; import { Button } from "@/components/buttons/Button"; -import { usePlayer } from "@/components/player/hooks/usePlayer"; -import { PlaybackErrorPart } from "@/pages/parts/player/PlaybackErrorPart"; -import { PlayerPart } from "@/pages/parts/player/PlayerPart"; -import { - CaptionListItem, - PlayerMeta, - playerStatus, -} from "@/stores/player/slices/source"; -import { SourceSliceSource } from "@/stores/player/utils/qualities"; - -const subtitlesTestMeta: PlayerMeta = { - type: "movie", - title: "Subtitles Test", - releaseYear: 2024, - tmdbId: "0", -}; - -const subtitlesTestSource: SourceSliceSource = { - type: "hls", - url: "http://localhost:8000/media/master.m3u8", -}; - -const subtitlesTestSubs: CaptionListItem[] = [ - { - id: "http://localhost:8000/subs/en.srt", - display: "English", - language: "en", - url: "http://localhost:8000/subs/en.srt", - needsProxy: false, - }, - { - id: "http://localhost:8000/subs/en-small.srt", - display: "English Small", - language: "en", - url: "http://localhost:8000/subs/en-small.srt", - needsProxy: false, - }, - { - id: "http://localhost:8000/subs/ro.srt", - display: "Romanian", - language: "ro", - url: "http://localhost:8000/subs/ro.srt", - needsProxy: false, - }, -]; // mostly empty view, add whatever you need export default function TestView() { - const player = usePlayer(); - const [showPlayer, setShowPlayer] = useState(false); const [shouldCrash, setShouldCrash] = useState(false); if (shouldCrash) { throw new Error("I crashed"); } - const subtitlesTest = useCallback(async () => { - setShowPlayer(true); - player.reset(); - await new Promise((r) => { - setTimeout(r, 100); - }); - player.setShouldStartFromBeginning(true); - player.setMeta(subtitlesTestMeta); - player.playMedia(subtitlesTestSource, subtitlesTestSubs, null); - }, [player]); - - return showPlayer ? ( - - {player && (player as any).status === playerStatus.PLAYBACK_ERROR ? ( - - ) : null} - - ) : ( - <> - - - - ); + return ; } From 9d63404783e5c51f38fc2809dad9aaf92eddf6e4 Mon Sep 17 00:00:00 2001 From: vlOd <66838724+vlOd2@users.noreply.github.com> Date: Mon, 29 Dec 2025 00:17:08 +0200 Subject: [PATCH 24/25] Update src/utils/translation/index.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/utils/translation/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/translation/index.ts b/src/utils/translation/index.ts index 249d9401..a50ea57c 100644 --- a/src/utils/translation/index.ts +++ b/src/utils/translation/index.ts @@ -90,7 +90,7 @@ class Translator { let attempts = 0; const errors: any[] = []; - while (!result && attempts < 3) { + while (!result && attempts < this.serviceCfg.maxRetryCount) { try { result = await this.service.translate( content.text, From d69344f251e3a1a769b9db98c61c156027a727ff Mon Sep 17 00:00:00 2001 From: vlOd2 <66838724+vlOd2@users.noreply.github.com> Date: Mon, 29 Dec 2025 00:19:43 +0200 Subject: [PATCH 25/25] Fix cache --- src/utils/translation/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/utils/translation/index.ts b/src/utils/translation/index.ts index 249d9401..b55bf4e2 100644 --- a/src/utils/translation/index.ts +++ b/src/utils/translation/index.ts @@ -117,8 +117,8 @@ class Translator { return false; } - content.text = result; this.contentCache.set(content.text, result); + content.text = result; return true; } @@ -140,8 +140,8 @@ class Translator { } for (let i = 0; i < batch.length; i += 1) { - batch[i].text = result[i]; this.contentCache.set(batch[i].text, result[i]); + batch[i].text = result[i]; } return true;