Merge pull request #178 from frost768/dev

feature: subtitle uploading
This commit is contained in:
mrjvs 2023-03-04 15:41:43 +01:00 committed by GitHub
commit 935cb2427b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 74 additions and 3 deletions

View File

@ -2,6 +2,7 @@ import { mwFetch, proxiedFetch } from "@/backend/helpers/fetch";
import { MWCaption, MWCaptionType } from "@/backend/helpers/streams"; import { MWCaption, MWCaptionType } from "@/backend/helpers/streams";
import toWebVTT from "srt-webvtt"; import toWebVTT from "srt-webvtt";
export const CUSTOM_CAPTION_ID = "customCaption";
export async function getCaptionUrl(caption: MWCaption): Promise<string> { export async function getCaptionUrl(caption: MWCaption): Promise<string> {
if (caption.type === MWCaptionType.SRT) { if (caption.type === MWCaptionType.SRT) {
let captionBlob: Blob; let captionBlob: Blob;
@ -32,3 +33,18 @@ export async function getCaptionUrl(caption: MWCaption): Promise<string> {
throw new Error("invalid type"); 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);
}
}

View File

@ -70,6 +70,8 @@
"episode": "E{{index}} - {{title}}", "episode": "E{{index}} - {{title}}",
"noCaptions": "No captions", "noCaptions": "No captions",
"linkedCaptions": "Linked captions", "linkedCaptions": "Linked captions",
"customCaption": "Custom caption",
"uploadCustomCaption": "Upload caption (SRT, VTT)",
"noEmbeds": "No embeds were found for this source", "noEmbeds": "No embeds were found for this source",
"errors": { "errors": {
"loadingWentWong": "Something went wrong loading the episodes for {{seasonTitle}}", "loadingWentWong": "Something went wrong loading the episodes for {{seasonTitle}}",

View File

@ -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 { MWCaption } from "@/backend/helpers/streams";
import { Icon, Icons } from "@/components/Icon"; import { Icon, Icons } from "@/components/Icon";
import { useLoading } from "@/hooks/useLoading"; import { useLoading } from "@/hooks/useLoading";
@ -6,7 +10,7 @@ import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useControls } from "@/video/state/logic/controls"; import { useControls } from "@/video/state/logic/controls";
import { useMeta } from "@/video/state/logic/meta"; import { useMeta } from "@/video/state/logic/meta";
import { useSource } from "@/video/state/logic/source"; import { useSource } from "@/video/state/logic/source";
import { useMemo, useRef } from "react"; import { ChangeEvent, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { PopoutListEntry, PopoutSection } from "./PopoutUtils"; import { PopoutListEntry, PopoutSection } from "./PopoutUtils";
@ -37,6 +41,29 @@ export function CaptionSelectionPopout() {
); );
const currentCaption = source.source?.caption?.id; const currentCaption = source.source?.caption?.id;
const customCaptionUploadElement = useRef<HTMLInputElement>(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<HTMLInputElement>) {
if (!e.target.files) {
return;
}
const captionFile = e.target.files[0];
setCustomCaption(captionFile);
}
return ( return (
<> <>
@ -54,6 +81,26 @@ export function CaptionSelectionPopout() {
> >
{t("videoPlayer.popouts.noCaptions")} {t("videoPlayer.popouts.noCaptions")}
</PopoutListEntry> </PopoutListEntry>
<PopoutListEntry
key={CUSTOM_CAPTION_ID}
active={currentCaption === CUSTOM_CAPTION_ID}
loading={loadingCustomCaption}
errored={!!errorCustomCaption}
onClick={() => {
customCaptionUploadElement.current?.click();
}}
>
{currentCaption === CUSTOM_CAPTION_ID
? t("videoPlayer.popouts.customCaption")
: t("videoPlayer.popouts.uploadCustomCaption")}
<input
ref={customCaptionUploadElement}
type="file"
onChange={handleUploadCaption}
className="hidden"
accept=".vtt, .srt"
/>
</PopoutListEntry>
</PopoutSection> </PopoutSection>
<p className="sticky top-0 z-10 flex items-center space-x-1 bg-ash-200 px-5 py-3 text-sm font-bold uppercase"> <p className="sticky top-0 z-10 flex items-center space-x-1 bg-ash-200 px-5 py-3 text-sm font-bold uppercase">

View File

@ -96,7 +96,7 @@ export function PopoutListEntry(props: PopoutListEntryTypes) {
return ( return (
<div <div
className={[ className={[
"group -mx-2 flex cursor-pointer items-center justify-between space-x-1 rounded p-2 font-semibold transition-[background-color,color] duration-150", "group my-2 -mx-2 flex cursor-pointer items-center justify-between space-x-1 rounded p-2 font-semibold transition-[background-color,color] duration-150",
hover, hover,
props.active props.active
? `${bg} active text-white outline-denim-700` ? `${bg} active text-white outline-denim-700`

View File

@ -12,6 +12,7 @@ import {
} from "@/video/components/hooks/volumeStore"; } from "@/video/components/hooks/volumeStore";
import { resetStateForSource } from "@/video/state/providers/helpers"; import { resetStateForSource } from "@/video/state/providers/helpers";
import { updateInterface } from "@/video/state/logic/interface"; import { updateInterface } from "@/video/state/logic/interface";
import { revokeCaptionBlob } from "@/backend/helpers/captions";
import { getPlayerState } from "../cache"; import { getPlayerState } from "../cache";
import { updateMediaPlaying } from "../logic/mediaplaying"; import { updateMediaPlaying } from "../logic/mediaplaying";
import { VideoPlayerStateProvider } from "./providerTypes"; import { VideoPlayerStateProvider } from "./providerTypes";
@ -135,6 +136,7 @@ export function createCastingStateProvider(
}, },
setCaption(id, url) { setCaption(id, url) {
if (state.source) { if (state.source) {
revokeCaptionBlob(state.source.caption?.url);
state.source.caption = { state.source.caption = {
id, id,
url, url,
@ -144,6 +146,7 @@ export function createCastingStateProvider(
}, },
clearCaption() { clearCaption() {
if (state.source) { if (state.source) {
revokeCaptionBlob(state.source.caption?.url);
state.source.caption = null; state.source.caption = null;
updateSource(descriptor, state); updateSource(descriptor, state);
} }

View File

@ -16,6 +16,7 @@ import {
import { updateError } from "@/video/state/logic/error"; import { updateError } from "@/video/state/logic/error";
import { updateMisc } from "@/video/state/logic/misc"; import { updateMisc } from "@/video/state/logic/misc";
import { resetStateForSource } from "@/video/state/providers/helpers"; import { resetStateForSource } from "@/video/state/providers/helpers";
import { revokeCaptionBlob } from "@/backend/helpers/captions";
import { getPlayerState } from "../cache"; import { getPlayerState } from "../cache";
import { updateMediaPlaying } from "../logic/mediaplaying"; import { updateMediaPlaying } from "../logic/mediaplaying";
import { VideoPlayerStateProvider } from "./providerTypes"; import { VideoPlayerStateProvider } from "./providerTypes";
@ -191,6 +192,7 @@ export function createVideoStateProvider(
}, },
setCaption(id, url) { setCaption(id, url) {
if (state.source) { if (state.source) {
revokeCaptionBlob(state.source.caption?.url);
state.source.caption = { state.source.caption = {
id, id,
url, url,
@ -200,6 +202,7 @@ export function createVideoStateProvider(
}, },
clearCaption() { clearCaption() {
if (state.source) { if (state.source) {
revokeCaptionBlob(state.source.caption?.url);
state.source.caption = null; state.source.caption = null;
updateSource(descriptor, state); updateSource(descriptor, state);
} }