diff --git a/package.json b/package.json index 65898799..b95b317c 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,6 @@ "hls.js": "^1.0.7", "i18next": "^22.4.5", "i18next-browser-languagedetector": "^7.0.1", - "i18next-http-backend": "^2.1.0", "json5": "^2.2.0", "lodash.throttle": "^4.1.1", "nanoid": "^4.0.0", diff --git a/src/backend/helpers/captions.ts b/src/backend/helpers/captions.ts new file mode 100644 index 00000000..ca230fa9 --- /dev/null +++ b/src/backend/helpers/captions.ts @@ -0,0 +1,34 @@ +import { mwFetch, proxiedFetch } from "@/backend/helpers/fetch"; +import { MWCaption, MWCaptionType } from "@/backend/helpers/streams"; +import toWebVTT from "srt-webvtt"; + +export async function getCaptionUrl(caption: MWCaption): Promise { + if (caption.type === MWCaptionType.SRT) { + let captionBlob: Blob; + + if (caption.needsProxy) { + captionBlob = await proxiedFetch(caption.url, { + responseType: "blob" as any, + }); + } else { + captionBlob = await mwFetch(caption.url, { + responseType: "blob" as any, + }); + } + + return toWebVTT(captionBlob); + } + + if (caption.type === MWCaptionType.VTT) { + if (caption.needsProxy) { + const blob = await proxiedFetch(caption.url, { + responseType: "blob" as any, + }); + return URL.createObjectURL(blob); + } + + return caption.url; + } + + throw new Error("invalid type"); +} diff --git a/src/backend/helpers/fetch.ts b/src/backend/helpers/fetch.ts index b2871c4f..9428ab03 100644 --- a/src/backend/helpers/fetch.ts +++ b/src/backend/helpers/fetch.ts @@ -40,6 +40,7 @@ export function proxiedFetch(url: string, ops: P[1] = {}): R { Object.entries(ops?.params ?? {}).forEach(([k, v]) => { parsedUrl.searchParams.set(k, v); }); + return baseFetch(conf().BASE_PROXY_URL, { ...ops, baseURL: undefined, diff --git a/src/backend/helpers/streams.ts b/src/backend/helpers/streams.ts index 3c80f7a6..92943d94 100644 --- a/src/backend/helpers/streams.ts +++ b/src/backend/helpers/streams.ts @@ -3,6 +3,11 @@ export enum MWStreamType { HLS = "hls", } +export enum MWCaptionType { + VTT = "vtt", + SRT = "srt", +} + export enum MWStreamQuality { Q360P = "360p", Q480P = "480p", @@ -11,8 +16,16 @@ export enum MWStreamQuality { QUNKNOWN = "unknown", } +export type MWCaption = { + needsProxy?: boolean; + url: string; + type: MWCaptionType; + langIso: string; +}; + export type MWStream = { streamUrl: string; type: MWStreamType; quality: MWStreamQuality; + captions: MWCaption[]; }; diff --git a/src/backend/providers/gdriveplayer.ts b/src/backend/providers/gdriveplayer.ts index 4adcb144..36cc470b 100644 --- a/src/backend/providers/gdriveplayer.ts +++ b/src/backend/providers/gdriveplayer.ts @@ -96,6 +96,7 @@ registerProvider({ streamUrl: `https:${source.file}`, type: source.type, quality, + captions: [], }, embeds: [], }; diff --git a/src/backend/providers/netfilm.ts b/src/backend/providers/netfilm.ts index befc1792..58ba298d 100644 --- a/src/backend/providers/netfilm.ts +++ b/src/backend/providers/netfilm.ts @@ -60,7 +60,7 @@ registerProvider({ streamUrl: source.url, quality: qualityMap[source.quality as QualityInMap], type: MWStreamType.HLS, - // captions: [], + captions: [], }, }; } @@ -121,7 +121,7 @@ registerProvider({ streamUrl: source.url, quality: qualityMap[source.quality as QualityInMap], type: MWStreamType.HLS, - // captions: [], + captions: [], }, }; }, diff --git a/src/backend/providers/superstream/index.ts b/src/backend/providers/superstream/index.ts index 6d91549e..15aceb06 100644 --- a/src/backend/providers/superstream/index.ts +++ b/src/backend/providers/superstream/index.ts @@ -2,10 +2,14 @@ import { registerProvider } from "@/backend/helpers/register"; import { MWMediaType } from "@/backend/metadata/types"; import { customAlphabet } from "nanoid"; -// import toWebVTT from "srt-webvtt"; import CryptoJS from "crypto-js"; import { proxiedFetch } from "@/backend/helpers/fetch"; -import { MWStreamQuality, MWStreamType } from "@/backend/helpers/streams"; +import { + MWCaption, + MWCaptionType, + MWStreamQuality, + MWStreamType, +} from "@/backend/helpers/streams"; const nanoid = customAlphabet("0123456789abcdef", 32); @@ -150,28 +154,27 @@ registerProvider({ if (!hdQuality) throw new Error("No quality could be found."); - // const subtitleApiQuery = { - // fid: hdQuality.fid, - // uid: "", - // module: "Movie_srt_list_v2", - // mid: tmdbId, - // }; + const subtitleApiQuery = { + fid: hdQuality.fid, + uid: "", + module: "Movie_srt_list_v2", + mid: superstreamId, + }; - // const subtitleRes = (await get(subtitleApiQuery).then((r) => r.json())) - // .data; - // const mappedCaptions = await Promise.all( - // subtitleRes.list.map(async (subtitle: any) => { - // const captionBlob = await fetch( - // `${conf().CORS_PROXY_URL}${subtitle.subtitles[0].file_path}` - // ).then((captionRes) => captionRes.blob()); // cross-origin bypass - // const captionUrl = await toWebVTT(captionBlob); // convert to vtt so it's playable - // return { - // id: subtitle.language, - // url: captionUrl, - // label: subtitle.language, - // }; - // }) - // ); + const subtitleRes = (await get(subtitleApiQuery)).data; + + console.log(subtitleRes); + + const mappedCaptions = subtitleRes.list.map( + (subtitle: any): MWCaption => { + return { + needsProxy: true, + langIso: subtitle.language, + url: subtitle.subtitles[0].file_path, + type: MWCaptionType.SRT, + }; + } + ); return { embeds: [], @@ -179,6 +182,7 @@ registerProvider({ streamUrl: hdQuality.path, quality: qualityMap[hdQuality.quality as QualityInMap], type: MWStreamType.MP4, + captions: mappedCaptions, }, }; } @@ -208,29 +212,28 @@ registerProvider({ if (!hdQuality) throw new Error("No quality could be found."); - // const subtitleApiQuery = { - // fid: hdQuality.fid, - // uid: "", - // module: "TV_srt_list_v2", - // episode: media.episodeId, - // tid: media.mediaId, - // season: media.seasonId, - // }; - // const subtitleRes = (await get(subtitleApiQuery).then((r) => r.json())) - // .data; - // const mappedCaptions = await Promise.all( - // subtitleRes.list.map(async (subtitle: any) => { - // const captionBlob = await fetch( - // `${conf().CORS_PROXY_URL}${subtitle.subtitles[0].file_path}` - // ).then((captionRes) => captionRes.blob()); // cross-origin bypass - // const captionUrl = await toWebVTT(captionBlob); // convert to vtt so it's playable - // return { - // id: subtitle.language, - // url: captionUrl, - // label: subtitle.language, - // }; - // }) - // ); + const subtitleApiQuery = { + fid: hdQuality.fid, + uid: "", + module: "TV_srt_list_v2", + episode: + media.meta.seasonData.episodes.find( + (episodeInfo) => episodeInfo.id === episode + )?.number ?? 1, + tid: superstreamId, + season: media.meta.seasonData.number.toString(), + }; + + 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, + }; + }); return { embeds: [], @@ -240,6 +243,7 @@ registerProvider({ ] as MWStreamQuality, streamUrl: hdQuality.path, type: MWStreamType.MP4, + captions: mappedCaptions, }, }; }, diff --git a/src/components/Icon.tsx b/src/components/Icon.tsx index 9987c549..9b88a83a 100644 --- a/src/components/Icon.tsx +++ b/src/components/Icon.tsx @@ -32,6 +32,7 @@ export enum Icons { SKIP_BACKWARD = "skip_backward", FILE = "file", CAPTIONS = "captions", + LINK = "link", } export interface IconProps { @@ -71,6 +72,7 @@ const iconList: Record = { skip_backward: ``, file: ``, captions: ``, + link: ``, }; export const Icon = memo((props: IconProps) => { diff --git a/src/components/layout/Spinner.css b/src/components/layout/Spinner.css index 0ec7f274..51721285 100644 --- a/src/components/layout/Spinner.css +++ b/src/components/layout/Spinner.css @@ -1,7 +1,8 @@ .spinner { - width: 48px; - height: 48px; - border: 5px solid white; + font-size: 48px; + width: 1em; + height: 1em; + border: 0.12em solid var(--color,white); border-bottom-color: transparent; border-radius: 50%; display: inline-block; diff --git a/src/components/layout/Spinner.tsx b/src/components/layout/Spinner.tsx index 98dae6e3..77fc53b3 100644 --- a/src/components/layout/Spinner.tsx +++ b/src/components/layout/Spinner.tsx @@ -1,5 +1,9 @@ import "./Spinner.css"; -export function Spinner() { - return
; +interface SpinnerProps { + className: string; +} + +export function Spinner(props: SpinnerProps) { + return
; } diff --git a/src/hooks/useLoading.ts b/src/hooks/useLoading.ts index 247a05ed..987456db 100644 --- a/src/hooks/useLoading.ts +++ b/src/hooks/useLoading.ts @@ -40,6 +40,7 @@ export function useLoading Promise>( .catch((err) => { if (isMounted) { setError(err); + console.error("USELOADING ERROR", err); setSuccess(false); } resolve(undefined); diff --git a/src/index.tsx b/src/index.tsx index 4bcf297d..279c4048 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -19,7 +19,7 @@ if (key) { initializeChromecast(); // TODO video todos: -// - captions +// - finish captions // - chrome cast support // - bug: mobile controls start showing when resizing // - bug: popouts sometimes stop working when selecting different episode @@ -36,12 +36,11 @@ initializeChromecast(); // - video player error handling // TODO backend system: -// - caption support // - implement jons providers/embedscrapers // - AFTER all that: rank providers/embedscrapers // TODO general todos: -// - localize everything (fix loading screen text (series vs movies)) (and have EN file instead of en-gb) +// - localize everything (fix loading screen text (series vs movies)) ReactDOM.render( diff --git a/src/setup/i18n.ts b/src/setup/i18n.ts index 312380b1..d243c0f5 100644 --- a/src/setup/i18n.ts +++ b/src/setup/i18n.ts @@ -1,14 +1,11 @@ import i18n from "i18next"; import { initReactI18next } from "react-i18next"; - -import Backend from "i18next-http-backend"; import LanguageDetector from "i18next-browser-languagedetector"; +// Languages +import en from "./locales/en/translation.json"; + i18n - // load translation using http -> see /public/locales (i.e. https://github.com/i18next/react-i18next/tree/master/example/react/public/locales) - // learn more: https://github.com/i18next/i18next-http-backend - // want your translations to be loaded from a professional CDN? => https://github.com/locize/react-tutorial#step-2---use-the-locize-cdn - .use(Backend) // detect user language // learn more: https://github.com/i18next/i18next-browser-languageDetector .use(LanguageDetector) @@ -17,7 +14,13 @@ i18n // init i18next // for all options read: https://www.i18next.com/overview/configuration-options .init({ - fallbackLng: "en-GB", + fallbackLng: "en", + + resources: { + en: { + translation: en, + }, + }, interpolation: { escapeValue: false, // not needed for react as it escapes by default diff --git a/public/locales/en-GB/translation.json b/src/setup/locales/en/translation.json similarity index 100% rename from public/locales/en-GB/translation.json rename to src/setup/locales/en/translation.json diff --git a/src/video/components/controllers/MetaController.tsx b/src/video/components/controllers/MetaController.tsx index 103ef0ab..9159a0ab 100644 --- a/src/video/components/controllers/MetaController.tsx +++ b/src/video/components/controllers/MetaController.tsx @@ -1,3 +1,4 @@ +import { MWCaption } from "@/backend/helpers/streams"; import { MWSeasonWithEpisodeMeta } from "@/backend/metadata/types"; import { useVideoPlayerDescriptor } from "@/video/state/hooks"; import { useControls } from "@/video/state/logic/controls"; @@ -7,6 +8,7 @@ import { useEffect } from "react"; interface MetaControllerProps { data?: VideoPlayerMeta; seasonData?: MWSeasonWithEpisodeMeta; + linkedCaptions?: MWCaption[]; } function formatMetadata( @@ -27,6 +29,7 @@ function formatMetadata( meta: props.data.meta, episode: props.data.episode, seasons: seasonsWithEpisodes, + captions: props.linkedCaptions ?? [], }; } diff --git a/src/video/components/internal/VideoElementInternal.tsx b/src/video/components/internal/VideoElementInternal.tsx index 10e09deb..2236db04 100644 --- a/src/video/components/internal/VideoElementInternal.tsx +++ b/src/video/components/internal/VideoElementInternal.tsx @@ -1,6 +1,7 @@ import { useVideoPlayerDescriptor } from "@/video/state/hooks"; import { useMediaPlaying } from "@/video/state/logic/mediaplaying"; import { useMisc } from "@/video/state/logic/misc"; +import { useSource } from "@/video/state/logic/source"; import { setProvider, unsetStateProvider } from "@/video/state/providers/utils"; import { createVideoStateProvider } from "@/video/state/providers/videoStateProvider"; import { useEffect, useMemo, useRef } from "react"; @@ -12,6 +13,7 @@ interface Props { export function VideoElementInternal(props: Props) { const descriptor = useVideoPlayerDescriptor(); const mediaPlaying = useMediaPlaying(descriptor); + const source = useSource(descriptor); const misc = useMisc(descriptor); const ref = useRef(null); @@ -37,6 +39,10 @@ export function VideoElementInternal(props: Props) { muted={mediaPlaying.volume === 0} playsInline className="h-full w-full" - /> + > + {source.source?.caption ? ( + + ) : null} + ); } diff --git a/src/video/components/popouts/CaptionSelectionPopout.tsx b/src/video/components/popouts/CaptionSelectionPopout.tsx index 99e784c3..363f27b4 100644 --- a/src/video/components/popouts/CaptionSelectionPopout.tsx +++ b/src/video/components/popouts/CaptionSelectionPopout.tsx @@ -1,14 +1,70 @@ -import { PopoutSection } from "./PopoutUtils"; +import { getCaptionUrl } from "@/backend/helpers/captions"; +import { MWCaption } from "@/backend/helpers/streams"; +import { Icon, Icons } from "@/components/Icon"; +import { useLoading } from "@/hooks/useLoading"; +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 { PopoutListEntry, PopoutSection } from "./PopoutUtils"; +function makeCaptionId(caption: MWCaption, isLinked: boolean): string { + return isLinked ? `linked-${caption.langIso}` : `external-${caption.langIso}`; +} + +// TODO add option to clear captions export function CaptionSelectionPopout() { + const descriptor = useVideoPlayerDescriptor(); + const meta = useMeta(descriptor); + const source = useSource(descriptor); + const controls = useControls(descriptor); + const linkedCaptions = useMemo( + () => + meta?.captions.map((v) => ({ ...v, id: makeCaptionId(v, true) })) ?? [], + [meta] + ); + const loadingId = useRef(""); + const [setCaption, loading, error] = useLoading( + async (caption: MWCaption, isLinked: boolean) => { + const id = makeCaptionId(caption, isLinked); + loadingId.current = id; + controls.setCaption(id, await getCaptionUrl(caption)); + controls.closePopout(); + } + ); + + const currentCaption = source.source?.caption?.id; + return ( <>
Captions
- -
Hi Jeebies
-
+
+

+ + Linked captions +

+ +
+ {linkedCaptions.map((link) => ( + { + loadingId.current = link.id; + setCaption(link, true); + }} + > + {link.langIso} + + ))} +
+
+
); } diff --git a/src/video/components/popouts/PopoutUtils.tsx b/src/video/components/popouts/PopoutUtils.tsx index 83a88453..867de87f 100644 --- a/src/video/components/popouts/PopoutUtils.tsx +++ b/src/video/components/popouts/PopoutUtils.tsx @@ -1,5 +1,7 @@ import { Icon, Icons } from "@/components/Icon"; +import { Spinner } from "@/components/layout/Spinner"; import { ProgressRing } from "@/components/layout/ProgressRing"; +import { createRef, useEffect, useRef } from "react"; interface PopoutListEntryTypes { active?: boolean; @@ -7,14 +9,37 @@ interface PopoutListEntryTypes { onClick?: () => void; isOnDarkBackground?: boolean; percentageCompleted?: number; + loading?: boolean; + errored?: boolean; } export function PopoutSection(props: { children?: React.ReactNode; className?: string; }) { + const ref = createRef(); + const inited = useRef(false); + + // Scroll to "active" child on first load (AKA mount except React dumb) + useEffect(() => { + if (inited.current) return; + if (!ref.current) return; + const el = ref.current as HTMLDivElement; + const active: HTMLDivElement | null = el.querySelector(".active"); + if (active) { + active?.scrollIntoView({ + block: "nearest", + inline: "nearest", + }); + el.scrollTo({ + top: el.scrollTop + el.offsetHeight / 2 - active.offsetHeight / 2, + }); + } + inited.current = true; + }, [ref]); + return ( -
+
{props.children}
); @@ -32,7 +57,7 @@ export function PopoutListEntry(props: PopoutListEntryTypes) { "group -mx-2 flex cursor-pointer items-center justify-between space-x-1 rounded p-2 font-semibold transition-[background-color,color] duration-150", hover, props.active - ? `${bg} text-white outline-denim-700` + ? `${bg} active text-white outline-denim-700` : "text-denim-700 hover:text-white", ].join(" ")} onClick={props.onClick} @@ -42,11 +67,22 @@ export function PopoutListEntry(props: PopoutListEntryTypes) { )} {props.children}
- - {props.percentageCompleted ? ( + {props.errored && ( + + )} + {props.loading && !props.errored && ( + + )} + {!props.loading && !props.errored && ( + + )} + {props.percentageCompleted && !props.loading && !props.errored ? ( - + = 19.0.0": "integrity" "sha512-Kc+Ow0guRetUq+kv02tj0Yof9zveROPBAmJ8UxxNODLVBRSwsM4iD0Gw3BEieOmkWemF6clU3K1fbnCuTqiN2Q==" "resolved" "https://registry.npmjs.org/i18next/-/i18next-22.4.5.tgz" @@ -2417,13 +2396,6 @@ "resolved" "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.0.1.tgz" "version" "1.0.1" -"node-fetch@2.6.7": - "integrity" "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==" - "resolved" "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz" - "version" "2.6.7" - dependencies: - "whatwg-url" "^5.0.0" - "node-gyp@^9.0.0", "node-gyp@^9.3.0": "version" "9.3.0" dependencies: @@ -3466,11 +3438,6 @@ dependencies: "is-number" "^7.0.0" -"tr46@~0.0.3": - "integrity" "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" - "resolved" "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz" - "version" "0.0.3" - "treeverse@^3.0.0": "version" "3.0.0" @@ -3619,19 +3586,6 @@ dependencies: "defaults" "^1.0.3" -"webidl-conversions@^3.0.0": - "integrity" "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" - "resolved" "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz" - "version" "3.0.1" - -"whatwg-url@^5.0.0": - "integrity" "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==" - "resolved" "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz" - "version" "5.0.0" - dependencies: - "tr46" "~0.0.3" - "webidl-conversions" "^3.0.0" - "which-boxed-primitive@^1.0.2": "integrity" "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==" "resolved" "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz"