mirror of
https://github.com/movie-web/movie-web.git
synced 2024-12-26 05:31:50 +01:00
better subtitle handling
This commit is contained in:
parent
4d5f03337d
commit
307f555b70
@ -19,7 +19,6 @@
|
||||
"json5": "^2.2.0",
|
||||
"lodash.throttle": "^4.1.1",
|
||||
"nanoid": "^4.0.0",
|
||||
"node-webvtt": "^1.9.4",
|
||||
"ofetch": "^1.0.0",
|
||||
"pako": "^2.1.0",
|
||||
"react": "^17.0.2",
|
||||
@ -31,7 +30,7 @@
|
||||
"react-stickynode": "^4.1.0",
|
||||
"react-transition-group": "^4.4.5",
|
||||
"react-use": "^17.4.0",
|
||||
"srt-webvtt": "^2.0.0",
|
||||
"subsrt-ts": "^2.1.0",
|
||||
"unpacker": "^1.0.1"
|
||||
},
|
||||
"scripts": {
|
||||
|
27
src/@types/node_webtt.d.ts
vendored
27
src/@types/node_webtt.d.ts
vendored
@ -1,27 +0,0 @@
|
||||
declare module "node-webvtt" {
|
||||
interface Cue {
|
||||
identifier: string;
|
||||
start: number;
|
||||
end: number;
|
||||
text: string;
|
||||
styles: string;
|
||||
}
|
||||
interface Options {
|
||||
meta?: boolean;
|
||||
strict?: boolean;
|
||||
}
|
||||
type ParserError = Error;
|
||||
interface ParseResult {
|
||||
valid: boolean;
|
||||
strict: boolean;
|
||||
cues: Cue[];
|
||||
errors: ParserError[];
|
||||
meta?: Map<string, string>;
|
||||
}
|
||||
interface Segment {
|
||||
duration: number;
|
||||
cues: Cue[];
|
||||
}
|
||||
function parse(text: string, options: Options): ParseResult;
|
||||
function segment(input: string, segmentLength?: number): Segment[];
|
||||
}
|
@ -1,14 +1,16 @@
|
||||
import { mwFetch, proxiedFetch } from "@/backend/helpers/fetch";
|
||||
import { MWCaption, MWCaptionType } from "@/backend/helpers/streams";
|
||||
import toWebVTT from "srt-webvtt";
|
||||
import { MWCaption } from "@/backend/helpers/streams";
|
||||
import DOMPurify from "dompurify";
|
||||
import { list, parse, detect } from "subsrt-ts";
|
||||
import { ContentCaption } from "subsrt-ts/dist/types/handler";
|
||||
|
||||
export const subtitleTypeList = list()
|
||||
.map((v) => `.${v}`)
|
||||
.join(",");
|
||||
export const sanitize = DOMPurify.sanitize;
|
||||
export const CUSTOM_CAPTION_ID = "customCaption";
|
||||
export async function getCaptionUrl(caption: MWCaption): Promise<string> {
|
||||
if (caption.type === MWCaptionType.SRT) {
|
||||
if (caption.url.startsWith("blob:")) return caption.url;
|
||||
let captionBlob: Blob;
|
||||
|
||||
if (caption.needsProxy) {
|
||||
captionBlob = await proxiedFetch<Blob>(caption.url, {
|
||||
responseType: "blob" as any,
|
||||
@ -18,31 +20,7 @@ export async function getCaptionUrl(caption: MWCaption): Promise<string> {
|
||||
responseType: "blob" as any,
|
||||
});
|
||||
}
|
||||
|
||||
return toWebVTT(captionBlob);
|
||||
}
|
||||
|
||||
if (caption.type === MWCaptionType.VTT) {
|
||||
if (caption.needsProxy) {
|
||||
const blob = await proxiedFetch<Blob>(caption.url, {
|
||||
responseType: "blob" as any,
|
||||
});
|
||||
return URL.createObjectURL(blob);
|
||||
}
|
||||
|
||||
return caption.url;
|
||||
}
|
||||
|
||||
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);
|
||||
return URL.createObjectURL(captionBlob);
|
||||
}
|
||||
|
||||
export function revokeCaptionBlob(url: string | undefined) {
|
||||
@ -50,3 +28,12 @@ export function revokeCaptionBlob(url: string | undefined) {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
export function parseSubtitles(text: string): ContentCaption[] {
|
||||
if (detect(text) === "") {
|
||||
throw new Error("Invalid subtitle format");
|
||||
}
|
||||
return parse(text)
|
||||
.filter((cue) => cue.type === "caption")
|
||||
.map((cue) => cue as ContentCaption);
|
||||
}
|
||||
|
@ -20,7 +20,7 @@ export enum MWStreamQuality {
|
||||
export type MWCaption = {
|
||||
needsProxy?: boolean;
|
||||
url: string;
|
||||
type: MWCaptionType;
|
||||
type?: MWCaptionType;
|
||||
langIso: string;
|
||||
};
|
||||
|
||||
|
@ -80,7 +80,7 @@
|
||||
"noCaptions": "No captions",
|
||||
"linkedCaptions": "Linked captions",
|
||||
"customCaption": "Custom caption",
|
||||
"uploadCustomCaption": "Upload caption (SRT, VTT)",
|
||||
"uploadCustomCaption": "Upload caption",
|
||||
"noEmbeds": "No embeds were found for this source",
|
||||
"errors": {
|
||||
"loadingWentWong": "Something went wrong loading the episodes for {{seasonTitle}}",
|
||||
|
@ -62,7 +62,7 @@
|
||||
"noCaptions": "Pas de sous-titres",
|
||||
"linkedCaptions": "Sous-titres liés",
|
||||
"customCaption": "Sous-titres personnalisés",
|
||||
"uploadCustomCaption": "Télécharger des sous-titres (SRT, VTT)",
|
||||
"uploadCustomCaption": "Télécharger des sous-titres",
|
||||
"noEmbeds": "Aucun contenu intégré n'a été trouvé pour cette source",
|
||||
"errors": {
|
||||
"loadingWentWong": "Un problème est survenu lors du chargement des épisodes pour {{seasonTitle}}",
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { Transition } from "@/components/Transition";
|
||||
import { useSettings } from "@/state/settings";
|
||||
import { sanitize } from "@/backend/helpers/captions";
|
||||
import { parse, Cue } from "node-webvtt";
|
||||
import { sanitize, parseSubtitles } from "@/backend/helpers/captions";
|
||||
import { ContentCaption } from "subsrt-ts/dist/types/handler";
|
||||
import { useRef } from "react";
|
||||
import { useAsync } from "react-use";
|
||||
import { useVideoPlayerDescriptor } from "../../state/hooks";
|
||||
@ -48,16 +48,18 @@ export function CaptionRendererAction({
|
||||
const source = useSource(descriptor).source;
|
||||
const videoTime = useProgress(descriptor).time;
|
||||
const { captionSettings } = useSettings();
|
||||
const captions = useRef<Cue[]>([]);
|
||||
const captions = useRef<ContentCaption[]>([]);
|
||||
|
||||
useAsync(async () => {
|
||||
const url = source?.caption?.url;
|
||||
if (url) {
|
||||
// Is there a better way?
|
||||
const result = await fetch(url);
|
||||
// Uses UTF-8 by default
|
||||
const blobUrl = source?.caption?.url;
|
||||
if (blobUrl) {
|
||||
const result = await fetch(blobUrl);
|
||||
const text = await result.text();
|
||||
captions.current = parse(text, { strict: false }).cues;
|
||||
try {
|
||||
captions.current = parseSubtitles(text);
|
||||
} catch (error) {
|
||||
captions.current = [];
|
||||
}
|
||||
} else {
|
||||
captions.current = [];
|
||||
}
|
||||
@ -65,8 +67,8 @@ export function CaptionRendererAction({
|
||||
|
||||
if (!captions.current.length) return null;
|
||||
const isVisible = (start: number, end: number): boolean => {
|
||||
const delayedStart = start + captionSettings.delay;
|
||||
const delayedEnd = end + captionSettings.delay;
|
||||
const delayedStart = start / 1000 + captionSettings.delay;
|
||||
const delayedEnd = end / 1000 + captionSettings.delay;
|
||||
return (
|
||||
Math.max(0, delayedStart) <= videoTime &&
|
||||
Math.max(0, delayedEnd) >= videoTime
|
||||
@ -82,9 +84,9 @@ export function CaptionRendererAction({
|
||||
show
|
||||
>
|
||||
{captions.current.map(
|
||||
({ identifier, end, start, text }) =>
|
||||
({ start, end, content }) =>
|
||||
isVisible(start, end) && (
|
||||
<CaptionCue key={identifier || `${start}-${end}`} text={text} />
|
||||
<CaptionCue key={`${start}-${end}`} text={content} />
|
||||
)
|
||||
)}
|
||||
</Transition>
|
||||
|
@ -1,7 +1,7 @@
|
||||
import {
|
||||
getCaptionUrl,
|
||||
convertCustomCaptionFileToWebVTT,
|
||||
CUSTOM_CAPTION_ID,
|
||||
parseSubtitles,
|
||||
subtitleTypeList,
|
||||
} from "@/backend/helpers/captions";
|
||||
import { MWCaption } from "@/backend/helpers/streams";
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
@ -13,10 +13,11 @@ 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 { ChangeEvent, useMemo, useRef } from "react";
|
||||
import { useMemo, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PopoutListEntry, PopoutSection } from "./PopoutUtils";
|
||||
|
||||
const customCaption = "external-custom";
|
||||
function makeCaptionId(caption: MWCaption, isLinked: boolean): string {
|
||||
return isLinked ? `linked-${caption.langIso}` : `external-${caption.langIso}`;
|
||||
}
|
||||
@ -41,35 +42,17 @@ export function CaptionSelectionPopout(props: {
|
||||
async (caption: MWCaption, isLinked: boolean) => {
|
||||
const id = makeCaptionId(caption, isLinked);
|
||||
loadingId.current = id;
|
||||
controls.setCaption(id, await getCaptionUrl(caption));
|
||||
const blobUrl = await getCaptionUrl(caption);
|
||||
const result = await fetch(blobUrl);
|
||||
const text = await result.text();
|
||||
parseSubtitles(text); // This will throw if the file is invalid
|
||||
controls.setCaption(id, blobUrl);
|
||||
controls.closePopout();
|
||||
}
|
||||
);
|
||||
|
||||
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 (
|
||||
<FloatingView
|
||||
{...props.router.pageProps(props.prefix)}
|
||||
@ -105,23 +88,28 @@ export function CaptionSelectionPopout(props: {
|
||||
{t("videoPlayer.popouts.noCaptions")}
|
||||
</PopoutListEntry>
|
||||
<PopoutListEntry
|
||||
key={CUSTOM_CAPTION_ID}
|
||||
active={currentCaption === CUSTOM_CAPTION_ID}
|
||||
loading={loadingCustomCaption}
|
||||
errored={!!errorCustomCaption}
|
||||
onClick={() => {
|
||||
customCaptionUploadElement.current?.click();
|
||||
}}
|
||||
key={customCaption}
|
||||
active={currentCaption === customCaption}
|
||||
loading={loading && loadingId.current === customCaption}
|
||||
errored={error && loadingId.current === customCaption}
|
||||
onClick={() => customCaptionUploadElement.current?.click()}
|
||||
>
|
||||
{currentCaption === CUSTOM_CAPTION_ID
|
||||
{currentCaption === customCaption
|
||||
? t("videoPlayer.popouts.customCaption")
|
||||
: t("videoPlayer.popouts.uploadCustomCaption")}
|
||||
<input
|
||||
ref={customCaptionUploadElement}
|
||||
type="file"
|
||||
onChange={handleUploadCaption}
|
||||
className="hidden"
|
||||
accept=".vtt, .srt"
|
||||
ref={customCaptionUploadElement}
|
||||
accept={subtitleTypeList}
|
||||
type="file"
|
||||
onChange={(e) => {
|
||||
if (!e.target.files) return;
|
||||
const customSubtitle = {
|
||||
langIso: "custom",
|
||||
url: URL.createObjectURL(e.target.files[0]),
|
||||
};
|
||||
setCaption(customSubtitle, false);
|
||||
}}
|
||||
/>
|
||||
</PopoutListEntry>
|
||||
</PopoutSection>
|
||||
|
@ -16,7 +16,6 @@
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"baseUrl": "./src",
|
||||
"typeRoots": ["./src/@types"],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
},
|
||||
|
22
yarn.lock
22
yarn.lock
@ -2123,11 +2123,6 @@ commander@^2.20.0:
|
||||
resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
|
||||
integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
|
||||
|
||||
commander@^7.1.0:
|
||||
version "7.2.0"
|
||||
resolved "https://registry.yarnpkg.com/commander/-/commander-7.2.0.tgz#a36cb57d0b501ce108e4d20559a150a391d97ab7"
|
||||
integrity sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==
|
||||
|
||||
commander@^8.0.0:
|
||||
version "8.3.0"
|
||||
resolved "https://registry.yarnpkg.com/commander/-/commander-8.3.0.tgz#4837ea1b2da67b9c616a67afbb0fafee567bca66"
|
||||
@ -3854,13 +3849,6 @@ node-releases@^2.0.8:
|
||||
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.10.tgz#c311ebae3b6a148c89b1813fd7c4d3c024ef537f"
|
||||
integrity sha512-5GFldHPXVG/YZmFzJvKK2zDSzPKhEp0+ZR5SVaoSag9fsL5YgHbUHDfnG5494ISANDcK4KwPXAx2xqVEydmd7w==
|
||||
|
||||
node-webvtt@^1.9.4:
|
||||
version "1.9.4"
|
||||
resolved "https://registry.yarnpkg.com/node-webvtt/-/node-webvtt-1.9.4.tgz#b71b98f879c6c88ebeda40c358bd45a882ca5d89"
|
||||
integrity sha512-EjrJdKdxSyd8j4LMLW6s2Ah4yNoeVXp18Ob04CQl1In18xcUmKzEE8pcsxxnFVqanTyjbGYph2VnvtwIXR4EjA==
|
||||
dependencies:
|
||||
commander "^7.1.0"
|
||||
|
||||
normalize-path@^3.0.0, normalize-path@~3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65"
|
||||
@ -4699,11 +4687,6 @@ sourcemap-codec@^1.4.8:
|
||||
resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4"
|
||||
integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==
|
||||
|
||||
srt-webvtt@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/srt-webvtt/-/srt-webvtt-2.0.0.tgz#debd2f56dd2b6600894caa11bb78893e5fc6509b"
|
||||
integrity sha512-G2Z7/Jf2NRKrmLYNSIhSYZZYE6OFlKXFp9Au2/zJBKgrioUzmrAys1x7GT01dwl6d2sEnqr5uahEIOd0JW/Rbw==
|
||||
|
||||
stack-generator@^2.0.5:
|
||||
version "2.0.10"
|
||||
resolved "https://registry.yarnpkg.com/stack-generator/-/stack-generator-2.0.10.tgz#8ae171e985ed62287d4f1ed55a1633b3fb53bb4d"
|
||||
@ -4859,6 +4842,11 @@ subscribe-ui-event@^2.0.6:
|
||||
lodash "^4.17.15"
|
||||
raf "^3.0.0"
|
||||
|
||||
subsrt-ts@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/subsrt-ts/-/subsrt-ts-2.1.0.tgz#97b5e0f97800fb08b64465b53c7c4f14f43d6fd4"
|
||||
integrity sha512-LOdp6A91l/yPLPFuEaYvGzFDusUz0J52ksZjaCFdl347DOhedZOVQEciTaH7KaVDRlb7wstOx4dPFdjf9AyuFw==
|
||||
|
||||
supports-color@^5.3.0:
|
||||
version "5.5.0"
|
||||
resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f"
|
||||
|
Loading…
Reference in New Issue
Block a user