diff --git a/src/backend/helpers/captions.ts b/src/backend/helpers/captions.ts index ca230fa9..61adbdc9 100644 --- a/src/backend/helpers/captions.ts +++ b/src/backend/helpers/captions.ts @@ -2,6 +2,7 @@ import { mwFetch, proxiedFetch } from "@/backend/helpers/fetch"; import { MWCaption, MWCaptionType } from "@/backend/helpers/streams"; import toWebVTT from "srt-webvtt"; +export const CUSTOM_CAPTION_ID = "customCaption"; export async function getCaptionUrl(caption: MWCaption): Promise { if (caption.type === MWCaptionType.SRT) { let captionBlob: Blob; @@ -32,3 +33,18 @@ export async function getCaptionUrl(caption: MWCaption): Promise { throw new Error("invalid type"); } + +export async function convertCustomCaptionFileToWebVTT(file: File) { + const header = await file.slice(0, 6).text(); + const isWebVTT = header === "WEBVTT"; + if (!isWebVTT) { + return toWebVTT(file); + } + return URL.createObjectURL(file); +} + +export function revokeCaptionBlob(url: string | undefined) { + if (url && url.startsWith("blob:")) { + URL.revokeObjectURL(url); + } +} diff --git a/src/setup/locales/en/translation.json b/src/setup/locales/en/translation.json index 5a3fe230..78832ad1 100644 --- a/src/setup/locales/en/translation.json +++ b/src/setup/locales/en/translation.json @@ -70,6 +70,8 @@ "episode": "E{{index}} - {{title}}", "noCaptions": "No captions", "linkedCaptions": "Linked captions", + "customCaption": "Custom caption", + "uploadCustomCaption": "Upload caption (SRT, VTT)", "noEmbeds": "No embeds were found for this source", "errors": { "loadingWentWong": "Something went wrong loading the episodes for {{seasonTitle}}", diff --git a/src/video/components/popouts/CaptionSelectionPopout.tsx b/src/video/components/popouts/CaptionSelectionPopout.tsx index e5ecdaeb..2b4bf0aa 100644 --- a/src/video/components/popouts/CaptionSelectionPopout.tsx +++ b/src/video/components/popouts/CaptionSelectionPopout.tsx @@ -1,4 +1,8 @@ -import { getCaptionUrl } from "@/backend/helpers/captions"; +import { + getCaptionUrl, + convertCustomCaptionFileToWebVTT, + CUSTOM_CAPTION_ID, +} from "@/backend/helpers/captions"; import { MWCaption } from "@/backend/helpers/streams"; import { Icon, Icons } from "@/components/Icon"; import { useLoading } from "@/hooks/useLoading"; @@ -6,7 +10,7 @@ import { useVideoPlayerDescriptor } from "@/video/state/hooks"; import { useControls } from "@/video/state/logic/controls"; import { useMeta } from "@/video/state/logic/meta"; import { useSource } from "@/video/state/logic/source"; -import { useMemo, useRef } from "react"; +import { ChangeEvent, useMemo, useRef } from "react"; import { useTranslation } from "react-i18next"; import { PopoutListEntry, PopoutSection } from "./PopoutUtils"; @@ -37,6 +41,29 @@ export function CaptionSelectionPopout() { ); const currentCaption = source.source?.caption?.id; + const customCaptionUploadElement = useRef(null); + const [setCustomCaption, loadingCustomCaption, errorCustomCaption] = + useLoading(async (captionFile: File) => { + if ( + !captionFile.name.endsWith(".srt") && + !captionFile.name.endsWith(".vtt") + ) { + throw new Error("Only SRT or VTT files are allowed"); + } + controls.setCaption( + CUSTOM_CAPTION_ID, + await convertCustomCaptionFileToWebVTT(captionFile) + ); + controls.closePopout(); + }); + + async function handleUploadCaption(e: ChangeEvent) { + if (!e.target.files) { + return; + } + const captionFile = e.target.files[0]; + setCustomCaption(captionFile); + } return ( <> @@ -54,6 +81,26 @@ export function CaptionSelectionPopout() { > {t("videoPlayer.popouts.noCaptions")} + { + customCaptionUploadElement.current?.click(); + }} + > + {currentCaption === CUSTOM_CAPTION_ID + ? t("videoPlayer.popouts.customCaption") + : t("videoPlayer.popouts.uploadCustomCaption")} + +

diff --git a/src/video/components/popouts/PopoutUtils.tsx b/src/video/components/popouts/PopoutUtils.tsx index 018afb20..be5b2b38 100644 --- a/src/video/components/popouts/PopoutUtils.tsx +++ b/src/video/components/popouts/PopoutUtils.tsx @@ -96,7 +96,7 @@ export function PopoutListEntry(props: PopoutListEntryTypes) { return (