diff --git a/.vscode/settings.json b/.vscode/settings.json index 5d671d55..0ab972e4 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,7 +5,16 @@ "[json]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, + "[jsonc]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[javascript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, "[typescriptreact]": { - "editor.defaultFormatter": "dbaeumer.vscode-eslint" + "editor.defaultFormatter": "esbenp.prettier-vscode" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2aaee3f8..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/5fa33694229da0506e7a265ff041acdb43e25ff0 + 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/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/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/5fa33694229da0506e7a265ff041acdb43e25ff0': + '@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/assets/locales/en.json b/src/assets/locales/en.json index 35477faa..e683c815 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -855,7 +855,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/Icon.tsx b/src/components/Icon.tsx index ad256aa0..dba224d1 100644 --- a/src/components/Icon.tsx +++ b/src/components/Icon.tsx @@ -83,6 +83,9 @@ export enum Icons { RELOAD = "reload", REPEAT = "repeat", PLUS = "plus", + TRANSLATE = "translate", + THUMBS_UP = "thumbsUp", + THUMBS_DOWN = "thumbsDown", } export interface IconProps { @@ -183,6 +186,9 @@ const iconList: Record = { reload: ``, repeat: ``, plus: ``, + translate: ``, + thumbsUp: ``, + thumbsDown: ``, }; export const Icon = memo((props: IconProps) => { 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 ( (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 @@ -76,7 +80,7 @@ function SettingsOverlay({ id }: { id: string }) { @@ -84,6 +88,23 @@ function SettingsOverlay({ id }: { id: string }) { + )} + + + + + {captionToTranslate && ( + )} @@ -133,12 +154,28 @@ 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 d51c9fb3..3f4fb649 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,14 +27,18 @@ 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; + disabled?: boolean; loading?: boolean; onClick?: () => void; error?: React.ReactNode; flag?: boolean; + translatable?: boolean; + isTranslatedTarget?: boolean; subtitleUrl?: string; subtitleType?: string; // subtitle details from wyzie @@ -41,7 +46,63 @@ 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.translatable && ( + { + 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(); @@ -108,8 +169,10 @@ export function CaptionOption(props: { selected={props.selected} loading={props.loading} error={props.error} + disabled={props.disabled} onClick={props.onClick} onDoubleClick={props.onDoubleClick} + rightSide={} > 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); @@ -646,7 +710,12 @@ 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 ccdbab45..aef7c686 100644 --- a/src/components/player/atoms/settings/LanguageSubtitlesView.tsx +++ b/src/components/player/atoms/settings/LanguageSubtitlesView.tsx @@ -17,16 +17,19 @@ export interface LanguageSubtitlesViewProps { id: string; language: string; overlayBackLink?: boolean; + onTranslateSubtitle?: (caption: CaptionListItem) => void; } export function LanguageSubtitlesView({ id, language, overlayBackLink, + onTranslateSubtitle, }: LanguageSubtitlesViewProps) { 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 @@ -122,16 +125,51 @@ export function LanguageSubtitlesView({ startDownload(v.id)} + onClick={() => + (!currentTranslateTask || + currentTranslateTask.done || + currentTranslateTask.error) && + startDownload(v.id) + } + onTranslate={() => { + onTranslateSubtitle?.(v); + router.navigate( + overlayBackLink + ? "/captionsOverlay/translateSubtitleOverlay" + : "/captions/translateSubtitle", + ); + }} + isTranslatedTarget={ + !!currentTranslateTask && + !currentTranslateTask.error && + v.id === currentTranslateTask.targetCaption.id + } onDoubleClick={handleDoubleClick} flag + translatable subtitleUrl={v.url} subtitleType={v.type} subtitleSource={v.source} diff --git a/src/components/player/atoms/settings/TranslateSubtitleView.tsx b/src/components/player/atoms/settings/TranslateSubtitleView.tsx new file mode 100644 index 00000000..b26e5d2c --- /dev/null +++ b/src/components/player/atoms/settings/TranslateSubtitleView.tsx @@ -0,0 +1,175 @@ +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"; +import { useCaptions } from "../../hooks/useCaptions"; + +// https://developers.google.com/workspace/admin/directory/v1/languages +const availableLanguages: string[] = [ + "am", + "ar", + "eu", + "bn", + "en-GB", + "pt-BR", + "bg", + "ca", + "chr", + "hr", + "cs", + "da", + "nl", + "en", + "et", + "fil", + "fi", + "fr", + "de", + "el", + "gu", + "iw", + "hi", + "hu", + "is", + "id", + "it", + "ja", + "kn", + "ko", + "lv", + "lt", + "ms", + "ml", + "mr", + "no", + "pl", + "pt-PT", + "ro", + "ru", + "sr", + "zh-CN", + "sk", + "sl", + "es", + "sw", + "sv", + "ta", + "te", + "th", + "zh-TW", + "tr", + "ur", + "uk", + "vi", + "cy", +]; + +export interface TranslateSubtitlesViewProps { + id: string; + caption: CaptionListItem; + overlayBackLink?: boolean; +} + +export function TranslateSubtitleView({ + id, + caption, + overlayBackLink, +}: TranslateSubtitlesViewProps) { + const { t } = useTranslation(); + const router = useOverlayRouter(id); + const { disable: disableCaptions } = useCaptions(); + const translateTask = usePlayerStore((s) => s.caption.translateTask); + const translateCaption = usePlayerStore((s) => s.translateCaption); + const clearTranslateTask = usePlayerStore((s) => s.clearTranslateTask); + + function renderTargetLang(langCode: string) { + const friendlyName = getPrettyLanguageNameFromLocale(langCode); + + async function onClick() { + clearTranslateTask(); + disableCaptions(); + await translateCaption(caption, langCode); + } + + return ( + + !translateTask || translateTask.done || translateTask.error + ? onClick() + : undefined + } + flag + > + {friendlyName} + + ); + } + + return ( + <> + + router.navigate( + overlayBackLink + ? "/captionsOverlay/languagesOverlay" + : "/captions/languages", + ) + } + > + + + + {t("player.menus.subtitles.translate.title", { + replace: { + language: + getPrettyLanguageNameFromLocale(caption.language) ?? + caption.language, + }, + })} + + + + +
+ {availableLanguages + .filter( + (lang) => + lang !== caption.language && + !lang.includes(caption.language) && + !caption.language.includes(lang), + ) + .map(renderTargetLang)} +
+ + ); +} diff --git a/src/components/player/hooks/useCaptions.ts b/src/components/player/hooks/useCaptions.ts index 0cca529f..7d51475c 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,7 @@ export function useCaptions() { (s) => s.resetSubtitleSpecificSettings, ); const setCaption = usePlayerStore((s) => s.setCaption); + const currentTranslateTask = usePlayerStore((s) => s.caption.translateTask); const lastSelectedLanguage = useSubtitleStore((s) => s.lastSelectedLanguage); const setIsOpenSubtitles = useSubtitleStore((s) => s.setIsOpenSubtitles); @@ -42,6 +43,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,37 +118,9 @@ 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); - } + setDirectCaption(captionToSet, caption); }, - [ - setIsOpenSubtitles, - setLanguage, - captions, - setCaption, - resetSubtitleSpecificSettings, - getSubtitleTracks, - setSubtitlePreference, - source, - setCaptionAsTrack, - enableNativeSubtitles, - selectedCaption, - ], + [captions, getSubtitleTracks, setSubtitlePreference, setDirectCaption], ); const selectLanguage = useCallback( @@ -188,13 +193,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) { @@ -205,7 +220,13 @@ export function useCaptions() { setCaption(null); } } - }, [captions, selectedCaption, setCaption, selectCaptionById]); + }, [ + captions, + selectedCaption, + setCaption, + selectCaptionById, + currentTranslateTask, + ]); return { selectLanguage, @@ -213,6 +234,7 @@ export function useCaptions() { selectLastUsedLanguage, toggleLastUsed, selectLastUsedLanguageIfEnabled, + setDirectCaption, selectCaptionById, selectRandomCaptionFromLastUsedLanguage, }; diff --git a/src/pages/developer/TestView.tsx b/src/pages/developer/TestView.tsx index d6f5a053..b257f463 100644 --- a/src/pages/developer/TestView.tsx +++ b/src/pages/developer/TestView.tsx @@ -4,9 +4,11 @@ import { Button } from "@/components/buttons/Button"; // mostly empty view, add whatever you need export default function TestView() { - const [val, setVal] = useState(false); + const [shouldCrash, setShouldCrash] = useState(false); - if (val) throw new Error("I crashed"); + if (shouldCrash) { + throw new Error("I crashed"); + } - return ; + return ; } diff --git a/src/pages/developer/VideoTesterView.tsx b/src/pages/developer/VideoTesterView.tsx index 7c234c54..09f4a2aa 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 carry over, so reset on mount + useEffect(() => { + if (status !== playerStatus.IDLE) { + reset(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return ( {status === playerStatus.IDLE ? ( diff --git a/src/stores/player/slices/source.ts b/src/stores/player/slices/source.ts index c667818c..dab67ce2 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, @@ -8,6 +9,8 @@ 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"; export const playerStatus = { @@ -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) => { @@ -218,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; }); @@ -374,9 +402,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 +443,102 @@ export const createSourceSlice: MakeSlice = (set, get) => ({ }); } }, + + clearTranslateTask() { + set((s) => { + if (s.caption.translateTask) { + 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; + } + + const abortController = new AbortController(); + + set((s) => { + s.caption.translateTask = { + targetCaption, + targetLanguage, + done: false, + error: false, + cancel() { + if (!this.done && !this.error) { + console.log("Translation task was cancelled"); + } + abortController.abort(); + }, + }; + }); + + function handleError(err: any) { + 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; + }); + } + + try { + const srtData = await downloadCaption(targetCaption); + if (abortController.signal.aborted) { + 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 translate( + store.caption.translateTask!.fetchedTargetCaption!, + targetLanguage, + googletranslate, + abortController.signal, + ); + if (abortController.signal.aborted) { + 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; + }); + } catch (err) { + handleError(err); + } + }, }); diff --git a/src/utils/translation/googletranslate.ts b/src/utils/translation/googletranslate.ts new file mode 100644 index 00000000..59e93149 --- /dev/null +++ b/src/utils/translation/googletranslate.ts @@ -0,0 +1,84 @@ +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 { + single: { + batchSize: 250, + batchDelayMs: 1000, + }, + multi: { + batchSize: 80, + batchDelayMs: 200, + }, + maxRetryCount: 3, + }; + }, + + 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", + }, + }, + ) + ).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("") + .replaceAll("
", "\n"); + }, + + 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, + }, + 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).replaceAll("
", "\n"), + ); + }, +} satisfies TranslateService; diff --git a/src/utils/translation/index.ts b/src/utils/translation/index.ts new file mode 100644 index 00000000..8064965f --- /dev/null +++ b/src/utils/translation/index.ts @@ -0,0 +1,253 @@ +/* eslint-disable no-console */ +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(); + +// 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(): TranslateServiceConfig; + translate( + str: string, + targetLang: string, + abortSignal?: AbortSignal, + ): Promise; + translateMulti( + batch: string[], + targetLang: string, + abortSignal?: AbortSignal, + ): Promise; +} + +class Translator { + private captions: Caption[]; + + private contentCaptions: ContentCaption[] = []; + + private contentCache: Map = new Map(); + + private targetLang: string; + + private 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") { + 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 < this.serviceCfg.maxRetryCount) { + try { + 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); + attempts += 1; + } + } + + if (this.abortSignal?.aborted) { + return false; + } + + if (!result) { + console.warn("Translation failed", errors); + return false; + } + + this.contentCache.set(content.text, result); + content.text = result; + return true; + } + + async translateContentBatch(batch: ContentCaption[]): Promise { + try { + const result = await this.service.translateMulti( + batch.map((content) => content.text), + this.targetLang, + this.abortSignal, + ); + + 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) { + this.contentCache.set(batch[i].text, result[i]); + batch[i].text = result[i]; + } + + return true; + } catch (err) { + if (this.abortSignal?.aborted) { + return false; + } + console.warn("Batch translation failed", err); + return false; + } + } + + takeBatch(): ContentCaption[] { + const batch: ContentCaption[] = []; + const batchSize = !this.serviceCfg.multi + ? this.serviceCfg.single.batchSize + : this.serviceCfg.multi!.batchSize; + + 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 { + 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 batch", batch.length, batch); + + if (!this.serviceCfg.multi) { + result = ( + await Promise.all( + batch.map((content) => this.translateContent(content)), + ) + ).every((res) => res); + } else { + result = await this.translateContentBatch(batch); + } + + if (this.abortSignal?.aborted) { + return undefined; + } + + if (!result) { + console.error("Failed to translate batch", batch.length, batch); + return undefined; + } + + batch = this.takeBatch(); + await sleep(batchDelay); + } + + if (this.abortSignal?.aborted) { + return undefined; + } + + console.timeEnd("translation"); + return subsrt.build(this.captions, { format: "srt" }); + } +} + +export async function translate( + caption: PlayerCaption, + targetLang: string, + service: TranslateService, + abortSignal?: AbortSignal, +): 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, + abortSignal, + ); + + const result = await translator.translate(); + if (!result || abortSignal?.aborted) { + 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); + }); +}