diff --git a/src/backend/helpers/captions.ts b/src/backend/helpers/captions.ts index 83edaa84..fb5b98da 100644 --- a/src/backend/helpers/captions.ts +++ b/src/backend/helpers/captions.ts @@ -1,15 +1,28 @@ import { mwFetch, proxiedFetch } from "@/backend/helpers/fetch"; -import { MWCaption } from "@/backend/helpers/streams"; +import { MWCaption, MWCaptionType } from "@/backend/helpers/streams"; import DOMPurify from "dompurify"; -import { parse, detect, list } from "subsrt-ts"; +import { parse, detect, list, convert } from "subsrt-ts"; import { ContentCaption } from "subsrt-ts/dist/types/handler"; export const subtitleTypeList = list().map((type) => `.${type}`); +export function isSupportedSubtitle(url: string): boolean { + return subtitleTypeList.some((type) => url.endsWith(type)); +} + +export function getMWCaptionTypeFromUrl(url: string): MWCaptionType { + if (!isSupportedSubtitle(url)) return MWCaptionType.UNKNOWN; + const type = subtitleTypeList.find((t) => url.endsWith(t)); + if (!type) return MWCaptionType.UNKNOWN; + return type.slice(1) as MWCaptionType; +} + export const sanitize = DOMPurify.sanitize; export async function getCaptionUrl(caption: MWCaption): Promise { - if (caption.url.startsWith("blob:")) return caption.url; let captionBlob: Blob; - if (caption.needsProxy) { + if (caption.url.startsWith("blob:")) { + // custom subtitle + captionBlob = await (await fetch(caption.url)).blob(); + } else if (caption.needsProxy) { captionBlob = await proxiedFetch(caption.url, { responseType: "blob" as any, }); @@ -18,7 +31,10 @@ export async function getCaptionUrl(caption: MWCaption): Promise { responseType: "blob" as any, }); } - return URL.createObjectURL(captionBlob); + // convert to vtt for track element source which will be used in PiP mode + const text = await captionBlob.text(); + const vtt = convert(text, "vtt"); + return URL.createObjectURL(new Blob([vtt], { type: "text/vtt" })); } export function revokeCaptionBlob(url: string | undefined) { @@ -28,10 +44,14 @@ export function revokeCaptionBlob(url: string | undefined) { } export function parseSubtitles(text: string): ContentCaption[] { - if (detect(text) === "") { + const textTrimmed = text.trim(); + if (textTrimmed === "") { + throw new Error("Given text is empty"); + } + if (detect(textTrimmed) === "") { throw new Error("Invalid subtitle format"); } - return parse(text).filter( + return parse(textTrimmed).filter( (cue) => cue.type === "caption" ) as ContentCaption[]; } diff --git a/src/backend/helpers/streams.ts b/src/backend/helpers/streams.ts index 12cbc551..95b40503 100644 --- a/src/backend/helpers/streams.ts +++ b/src/backend/helpers/streams.ts @@ -3,9 +3,16 @@ export enum MWStreamType { HLS = "hls", } +// subsrt-ts supported types export enum MWCaptionType { VTT = "vtt", SRT = "srt", + LRC = "lrc", + SBV = "sbv", + SUB = "sub", + SSA = "ssa", + ASS = "ass", + JSON = "json", UNKNOWN = "unknown", } diff --git a/src/backend/providers/flixhq.ts b/src/backend/providers/flixhq.ts index d9440213..99040df5 100644 --- a/src/backend/providers/flixhq.ts +++ b/src/backend/providers/flixhq.ts @@ -1,12 +1,12 @@ import { compareTitle } from "@/utils/titleMatch"; import { proxiedFetch } from "../helpers/fetch"; import { registerProvider } from "../helpers/register"; -import { - MWCaptionType, - MWStreamQuality, - MWStreamType, -} from "../helpers/streams"; +import { MWCaption, MWStreamQuality, MWStreamType } from "../helpers/streams"; import { MWMediaType } from "../metadata/types"; +import { + getMWCaptionTypeFromUrl, + isSupportedSubtitle, +} from "../helpers/captions"; const flixHqBase = "https://api.consumet.org/meta/tmdb"; @@ -19,15 +19,19 @@ interface FLIXMediaBase { type: FlixHQMediaType; releaseDate: string; } - -function castSubtitles({ url, lang }: { url: string; lang: string }) { +interface FLIXSubType { + url: string; + lang: string; +} +function convertSubtitles({ url, lang }: FLIXSubType): MWCaption | null { + if (lang.includes("(maybe)")) return null; + const supported = isSupportedSubtitle(url); + if (!supported) return null; + const type = getMWCaptionTypeFromUrl(url); return { url, langIso: lang, - type: - url.substring(url.length - 3) === "vtt" - ? MWCaptionType.VTT - : MWCaptionType.SRT, + type, }; } @@ -116,11 +120,7 @@ registerProvider({ streamUrl: source.url, quality: qualityMap[source.quality], type: source.isM3U8 ? MWStreamType.HLS : MWStreamType.MP4, - captions: watchInfo.subtitles - .filter( - (x: { url: string; lang: string }) => !x.lang.includes("(maybe)") - ) - .map(castSubtitles), + captions: watchInfo.subtitles.map(convertSubtitles).filter(Boolean), }, }; }, diff --git a/src/backend/providers/superstream/index.ts b/src/backend/providers/superstream/index.ts index 9ebe6262..9d889166 100644 --- a/src/backend/providers/superstream/index.ts +++ b/src/backend/providers/superstream/index.ts @@ -11,6 +11,10 @@ import { MWStreamType, } from "@/backend/helpers/streams"; import { compareTitle } from "@/utils/titleMatch"; +import { + getMWCaptionTypeFromUrl, + isSupportedSubtitle, +} from "@/backend/helpers/captions"; const nanoid = customAlphabet("0123456789abcdef", 32); @@ -111,6 +115,30 @@ const getBestQuality = (list: any[]) => { ); }; +const convertSubtitles = (subtitleGroup: any): MWCaption | null => { + let subtitles = subtitleGroup.subtitles; + subtitles = subtitles + .map((subFile: any) => { + const supported = isSupportedSubtitle(subFile.file_path); + if (!supported) return null; + const type = getMWCaptionTypeFromUrl(subFile.file_path); + return { + ...subFile, + type: type as MWCaptionType, + }; + }) + .filter(Boolean); + + if (!subtitles.length) return null; + const subFile = subtitles[0]; + return { + needsProxy: true, + langIso: subtitleGroup.language, + url: subFile.file_path, + type: subFile.type, + }; +}; + registerProvider({ id: "superstream", displayName: "Superstream", @@ -164,16 +192,9 @@ registerProvider({ const subtitleRes = (await get(subtitleApiQuery)).data; - const mappedCaptions = subtitleRes.list.map( - (subtitle: any): MWCaption => { - return { - needsProxy: true, - langIso: subtitle.language, - url: subtitle.subtitles[0].file_path, - type: MWCaptionType.SRT, - }; - } - ); + const mappedCaptions = subtitleRes.list + .map(convertSubtitles) + .filter(Boolean); return { embeds: [], @@ -224,24 +245,9 @@ registerProvider({ }; const subtitleRes = (await get(subtitleApiQuery)).data; - - const mappedCaptions = subtitleRes.list.map( - (subtitle: any): MWCaption | null => { - const sub = subtitle; - sub.subtitles = subtitle.subtitles.filter((subFile: any) => { - const extension = subFile.file_path.substring( - sub.file_path.length - 3 - ); - return [MWCaptionType.SRT, MWCaptionType.VTT].includes(extension); - }); - return { - needsProxy: true, - langIso: subtitle.language, - url: sub.subtitles[0].file_path, - type: MWCaptionType.SRT, - }; - } - ); + const mappedCaptions = subtitleRes.list + .map(convertSubtitles) + .filter(Boolean); return { embeds: [], stream: { diff --git a/src/video/components/actions/CaptionRendererAction.tsx b/src/video/components/actions/CaptionRendererAction.tsx index ab7edba0..0901725c 100644 --- a/src/video/components/actions/CaptionRendererAction.tsx +++ b/src/video/components/actions/CaptionRendererAction.tsx @@ -2,7 +2,7 @@ import { Transition } from "@/components/Transition"; import { useSettings } from "@/state/settings"; import { sanitize, parseSubtitles } from "@/backend/helpers/captions"; import { ContentCaption } from "subsrt-ts/dist/types/handler"; -import { useRef } from "react"; +import { useRef, useEffect, useCallback } from "react"; import { useAsync } from "react-use"; import { useVideoPlayerDescriptor } from "../../state/hooks"; import { useProgress } from "../../state/logic/progress"; @@ -47,7 +47,7 @@ export function CaptionRendererAction({ const descriptor = useVideoPlayerDescriptor(); const source = useSource(descriptor).source; const videoTime = useProgress(descriptor).time; - const { captionSettings } = useSettings(); + const { captionSettings, setCaptionDelay } = useSettings(); const captions = useRef([]); useAsync(async () => { @@ -60,20 +60,37 @@ export function CaptionRendererAction({ } catch (error) { captions.current = []; } + // reset delay on every subtitle change + setCaptionDelay(0); } else { captions.current = []; } }, [source?.caption?.url]); - + useEffect(() => { + // reset delay after video ends + return () => setCaptionDelay(0); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const isVisible = useCallback( + ( + start: number, + end: number, + delay: number, + currentTime: number + ): boolean => { + const delayedStart = start / 1000 + delay; + const delayedEnd = end / 1000 + delay; + return ( + Math.max(0, delayedStart) <= currentTime && + Math.max(0, delayedEnd) >= currentTime + ); + }, + [] + ); if (!captions.current.length) return null; - const isVisible = (start: number, end: number): boolean => { - const delayedStart = start / 1000 + captionSettings.delay; - const delayedEnd = end / 1000 + captionSettings.delay; - return ( - Math.max(0, delayedStart) <= videoTime && - Math.max(0, delayedEnd) >= videoTime - ); - }; + const visibileCaptions = captions.current.filter(({ start, end }) => + isVisible(start, end, captionSettings.delay, videoTime) + ); return ( - {captions.current.map( - ({ start, end, content }) => - isVisible(start, end) && ( - - ) - )} + {visibileCaptions.map(({ start, end, content }) => ( + + ))} ); }