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