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