mirror of
https://github.com/movie-web/movie-web.git
synced 2024-12-24 04:31:51 +01:00
captions + translation fix
Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com> Co-authored-by: James Hawkins <jhawki2005@gmail.com>
This commit is contained in:
parent
c4712044a9
commit
f97b84516b
@ -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",
|
||||
|
34
src/backend/helpers/captions.ts
Normal file
34
src/backend/helpers/captions.ts
Normal file
@ -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<string> {
|
||||
if (caption.type === MWCaptionType.SRT) {
|
||||
let captionBlob: Blob;
|
||||
|
||||
if (caption.needsProxy) {
|
||||
captionBlob = await proxiedFetch<Blob>(caption.url, {
|
||||
responseType: "blob" as any,
|
||||
});
|
||||
} else {
|
||||
captionBlob = await mwFetch<Blob>(caption.url, {
|
||||
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");
|
||||
}
|
@ -40,6 +40,7 @@ export function proxiedFetch<T>(url: string, ops: P<T>[1] = {}): R<T> {
|
||||
Object.entries(ops?.params ?? {}).forEach(([k, v]) => {
|
||||
parsedUrl.searchParams.set(k, v);
|
||||
});
|
||||
|
||||
return baseFetch<T>(conf().BASE_PROXY_URL, {
|
||||
...ops,
|
||||
baseURL: undefined,
|
||||
|
@ -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[];
|
||||
};
|
||||
|
@ -96,6 +96,7 @@ registerProvider({
|
||||
streamUrl: `https:${source.file}`,
|
||||
type: source.type,
|
||||
quality,
|
||||
captions: [],
|
||||
},
|
||||
embeds: [],
|
||||
};
|
||||
|
@ -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: [],
|
||||
},
|
||||
};
|
||||
},
|
||||
|
@ -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,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
@ -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<Icons, string> = {
|
||||
skip_backward: `<svg width="1em" height="1em" viewBox="0 0 25 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M13.6667 12.3333L9 7.66667M9 7.66667L13.6667 3M9 7.66667H18.3333C19.571 7.66667 20.758 8.15833 21.6332 9.0335C22.5083 9.90867 23 11.0957 23 12.3333C23 13.571 22.5083 14.758 21.6332 15.6332C20.758 16.5083 19.571 17 18.3333 17H16" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M4.50426 14.2727V23H2.65909V16.0241H2.60795L0.609375 17.277V15.6406L2.76989 14.2727H4.50426ZM10.0004 23.1918C9.2674 23.1889 8.63672 23.0085 8.10831 22.6506C7.58274 22.2926 7.17791 21.7741 6.89382 21.0952C6.61257 20.4162 6.47337 19.5994 6.47621 18.6449C6.47621 17.6932 6.61683 16.8821 6.89808 16.2116C7.18217 15.5412 7.587 15.0312 8.11257 14.6818C8.64098 14.3295 9.27024 14.1534 10.0004 14.1534C10.7305 14.1534 11.3583 14.3295 11.8839 14.6818C12.4123 15.0341 12.8185 15.5455 13.1026 16.2159C13.3867 16.8835 13.5273 17.6932 13.5245 18.6449C13.5245 19.6023 13.3825 20.4205 13.0984 21.0994C12.8171 21.7784 12.4137 22.2969 11.8881 22.6548C11.3626 23.0128 10.7333 23.1918 10.0004 23.1918ZM10.0004 21.6619C10.5004 21.6619 10.8995 21.4105 11.1978 20.9077C11.4961 20.4048 11.6438 19.6506 11.641 18.6449C11.641 17.983 11.5728 17.4318 11.4364 16.9915C11.3029 16.5511 11.1126 16.2202 10.8654 15.9986C10.6211 15.777 10.3327 15.6662 10.0004 15.6662C9.5032 15.6662 9.10547 15.9148 8.80717 16.4119C8.50888 16.9091 8.35831 17.6534 8.35547 18.6449C8.35547 19.3153 8.42223 19.875 8.55575 20.3239C8.69212 20.7699 8.88388 21.1051 9.13104 21.3295C9.3782 21.5511 9.66797 21.6619 10.0004 21.6619Z" fill="currentColor"/></svg>`,
|
||||
file: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-file"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path><polyline points="13 2 13 9 20 9"></polyline></svg>`,
|
||||
captions: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 576 512"><!--! Font Awesome Pro 6.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path fill="currentColor" d="M0 96C0 60.7 28.7 32 64 32H512c35.3 0 64 28.7 64 64V416c0 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64V96zM200 208c14.2 0 27 6.1 35.8 16c8.8 9.9 24 10.7 33.9 1.9s10.7-24 1.9-33.9c-17.5-19.6-43.1-32-71.5-32c-53 0-96 43-96 96s43 96 96 96c28.4 0 54-12.4 71.5-32c8.8-9.9 8-25-1.9-33.9s-25-8-33.9 1.9c-8.8 9.9-21.6 16-35.8 16c-26.5 0-48-21.5-48-48s21.5-48 48-48zm144 48c0-26.5 21.5-48 48-48c14.2 0 27 6.1 35.8 16c8.8 9.9 24 10.7 33.9 1.9s10.7-24 1.9-33.9c-17.5-19.6-43.1-32-71.5-32c-53 0-96 43-96 96s43 96 96 96c28.4 0 54-12.4 71.5-32c8.8-9.9 8-25-1.9-33.9s-25-8-33.9 1.9c-8.8 9.9-21.6 16-35.8 16c-26.5 0-48-21.5-48-48z"/></svg>`,
|
||||
link: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>`,
|
||||
};
|
||||
|
||||
export const Icon = memo((props: IconProps) => {
|
||||
|
@ -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;
|
||||
|
@ -1,5 +1,9 @@
|
||||
import "./Spinner.css";
|
||||
|
||||
export function Spinner() {
|
||||
return <div className="spinner" />;
|
||||
interface SpinnerProps {
|
||||
className: string;
|
||||
}
|
||||
|
||||
export function Spinner(props: SpinnerProps) {
|
||||
return <div className={["spinner", props.className].join(" ")} />;
|
||||
}
|
||||
|
@ -40,6 +40,7 @@ export function useLoading<T extends (...args: any) => Promise<any>>(
|
||||
.catch((err) => {
|
||||
if (isMounted) {
|
||||
setError(err);
|
||||
console.error("USELOADING ERROR", err);
|
||||
setSuccess(false);
|
||||
}
|
||||
resolve(undefined);
|
||||
|
@ -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(
|
||||
<React.StrictMode>
|
||||
|
@ -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
|
||||
|
@ -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 ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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<HTMLVideoElement>(null);
|
||||
|
||||
@ -37,6 +39,10 @@ export function VideoElementInternal(props: Props) {
|
||||
muted={mediaPlaying.volume === 0}
|
||||
playsInline
|
||||
className="h-full w-full"
|
||||
/>
|
||||
>
|
||||
{source.source?.caption ? (
|
||||
<track default kind="captions" src={source.source.caption.url} />
|
||||
) : null}
|
||||
</video>
|
||||
);
|
||||
}
|
||||
|
@ -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<string>("");
|
||||
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 (
|
||||
<>
|
||||
<PopoutSection className="bg-ash-100 font-bold text-white">
|
||||
<div>Captions</div>
|
||||
</PopoutSection>
|
||||
<PopoutSection>
|
||||
<div>Hi Jeebies</div>
|
||||
</PopoutSection>
|
||||
<div className="relative overflow-y-auto">
|
||||
<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">
|
||||
<Icon className="text-base" icon={Icons.LINK} />
|
||||
<span>Linked captions</span>
|
||||
</p>
|
||||
<PopoutSection className="pt-0">
|
||||
<div>
|
||||
{linkedCaptions.map((link) => (
|
||||
<PopoutListEntry
|
||||
key={link.langIso}
|
||||
active={link.id === currentCaption}
|
||||
loading={loading && link.id === loadingId.current}
|
||||
errored={error && link.id === loadingId.current}
|
||||
onClick={() => {
|
||||
loadingId.current = link.id;
|
||||
setCaption(link, true);
|
||||
}}
|
||||
>
|
||||
{link.langIso}
|
||||
</PopoutListEntry>
|
||||
))}
|
||||
</div>
|
||||
</PopoutSection>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -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<HTMLDivElement>();
|
||||
const inited = useRef<boolean>(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 (
|
||||
<div className={["p-5", props.className || ""].join(" ")}>
|
||||
<div className={["p-5", props.className || ""].join(" ")} ref={ref}>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
@ -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) {
|
||||
)}
|
||||
<span className="truncate">{props.children}</span>
|
||||
<div className="relative h-4 w-4 min-w-[1rem]">
|
||||
<Icon
|
||||
className="absolute inset-0 translate-x-2 text-white opacity-0 transition-[opacity,transform] duration-100 group-hover:translate-x-0 group-hover:opacity-100"
|
||||
icon={Icons.CHEVRON_RIGHT}
|
||||
/>
|
||||
{props.percentageCompleted ? (
|
||||
{props.errored && (
|
||||
<Icon
|
||||
icon={Icons.WARNING}
|
||||
className="absolute inset-0 text-rose-400"
|
||||
/>
|
||||
)}
|
||||
{props.loading && !props.errored && (
|
||||
<Spinner className="absolute inset-0 text-base [--color:#9C93B5]" />
|
||||
)}
|
||||
{!props.loading && !props.errored && (
|
||||
<Icon
|
||||
className="absolute inset-0 translate-x-2 text-white opacity-0 transition-[opacity,transform] duration-100 group-hover:translate-x-0 group-hover:opacity-100"
|
||||
icon={Icons.CHEVRON_RIGHT}
|
||||
/>
|
||||
)}
|
||||
{props.percentageCompleted && !props.loading && !props.errored ? (
|
||||
<ProgressRing
|
||||
className="absolute inset-0 text-bink-600 opacity-100 transition-[opacity] group-hover:opacity-0"
|
||||
backingRingClassname="stroke-ash-500"
|
||||
|
@ -49,6 +49,12 @@ export function useControls(
|
||||
startAirplay() {
|
||||
state.stateProvider?.startAirplay();
|
||||
},
|
||||
setCaption(id, url) {
|
||||
state.stateProvider?.setCaption(id, url);
|
||||
},
|
||||
clearCaption() {
|
||||
state.stateProvider?.clearCaption();
|
||||
},
|
||||
|
||||
// other controls
|
||||
setLeftControlsHover(hovering) {
|
||||
|
@ -9,6 +9,10 @@ export type VideoSourceEvent = {
|
||||
quality: MWStreamQuality;
|
||||
url: string;
|
||||
type: MWStreamType;
|
||||
caption: null | {
|
||||
id: string;
|
||||
url: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -16,6 +16,8 @@ export type VideoPlayerStateController = {
|
||||
enterFullscreen(): void;
|
||||
setVolume(volume: number): void;
|
||||
startAirplay(): void;
|
||||
setCaption(id: string, url: string): void;
|
||||
clearCaption(): void;
|
||||
};
|
||||
|
||||
export type VideoPlayerStateProvider = VideoPlayerStateController & {
|
||||
|
@ -173,9 +173,25 @@ export function createVideoStateProvider(
|
||||
quality: source.quality,
|
||||
type: source.type,
|
||||
url: source.source,
|
||||
caption: null,
|
||||
};
|
||||
updateSource(descriptor, state);
|
||||
},
|
||||
setCaption(id, url) {
|
||||
if (state.source) {
|
||||
state.source.caption = {
|
||||
id,
|
||||
url,
|
||||
};
|
||||
updateSource(descriptor, state);
|
||||
}
|
||||
},
|
||||
clearCaption() {
|
||||
if (state.source) {
|
||||
state.source.caption = null;
|
||||
updateSource(descriptor, state);
|
||||
}
|
||||
},
|
||||
providerStart() {
|
||||
this.setVolume(getStoredVolume());
|
||||
|
||||
|
@ -1,9 +1,14 @@
|
||||
import { MWStreamQuality, MWStreamType } from "@/backend/helpers/streams";
|
||||
import {
|
||||
MWCaption,
|
||||
MWStreamQuality,
|
||||
MWStreamType,
|
||||
} from "@/backend/helpers/streams";
|
||||
import { MWMediaMeta } from "@/backend/metadata/types";
|
||||
import { VideoPlayerStateProvider } from "./providers/providerTypes";
|
||||
|
||||
export type VideoPlayerMeta = {
|
||||
meta: MWMediaMeta;
|
||||
captions: MWCaption[];
|
||||
episode?: {
|
||||
episodeId: string;
|
||||
seasonId: string;
|
||||
@ -52,6 +57,10 @@ export type VideoPlayerState = {
|
||||
quality: MWStreamQuality;
|
||||
url: string;
|
||||
type: MWStreamType;
|
||||
caption: null | {
|
||||
url: string;
|
||||
id: string;
|
||||
};
|
||||
};
|
||||
|
||||
// misc
|
||||
|
@ -112,6 +112,7 @@ export function MediaViewPlayer(props: MediaViewPlayerProps) {
|
||||
|
||||
const metaProps: VideoPlayerMeta = {
|
||||
meta: props.meta.meta,
|
||||
captions: [],
|
||||
};
|
||||
let metaSeasonData: MWSeasonWithEpisodeMeta | undefined;
|
||||
if (
|
||||
@ -132,7 +133,11 @@ export function MediaViewPlayer(props: MediaViewPlayerProps) {
|
||||
<html data-full="true" />
|
||||
</Helmet>
|
||||
<VideoPlayer includeSafeArea autoPlay onGoBack={goBack}>
|
||||
<MetaController data={metaProps} seasonData={metaSeasonData} />
|
||||
<MetaController
|
||||
data={metaProps}
|
||||
seasonData={metaSeasonData}
|
||||
linkedCaptions={props.stream.captions}
|
||||
/>
|
||||
<SourceController
|
||||
source={props.stream.streamUrl}
|
||||
type={props.stream.type}
|
||||
|
46
yarn.lock
46
yarn.lock
@ -927,13 +927,6 @@
|
||||
"resolved" "https://registry.npmjs.org/core-js/-/core-js-3.27.1.tgz"
|
||||
"version" "3.27.1"
|
||||
|
||||
"cross-fetch@3.1.5":
|
||||
"integrity" "sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw=="
|
||||
"resolved" "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.5.tgz"
|
||||
"version" "3.1.5"
|
||||
dependencies:
|
||||
"node-fetch" "2.6.7"
|
||||
|
||||
"cross-spawn@^7.0.2":
|
||||
"integrity" "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w=="
|
||||
"resolved" "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz"
|
||||
@ -1087,13 +1080,6 @@
|
||||
"resolved" "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz"
|
||||
"version" "9.2.2"
|
||||
|
||||
"encoding@^0.1.0":
|
||||
"integrity" "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A=="
|
||||
"resolved" "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz"
|
||||
"version" "0.1.13"
|
||||
dependencies:
|
||||
"iconv-lite" "^0.6.2"
|
||||
|
||||
"encoding@^0.1.13":
|
||||
"version" "0.1.13"
|
||||
dependencies:
|
||||
@ -1793,13 +1779,6 @@
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.19.4"
|
||||
|
||||
"i18next-http-backend@^2.1.0":
|
||||
"integrity" "sha512-rTVhhFrpnZJnNvCCdC6RjhFPk0S6mJ2VAix93vbDD19ixlrSJtoNqkk49wvR10PImBSsuGJf35gMQwn2mjer6A=="
|
||||
"resolved" "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-2.1.0.tgz"
|
||||
"version" "2.1.0"
|
||||
dependencies:
|
||||
"cross-fetch" "3.1.5"
|
||||
|
||||
"i18next@^22.4.5", "i18next@>= 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"
|
||||
|
Loading…
Reference in New Issue
Block a user