mirror of
https://github.com/movie-web/movie-web.git
synced 2024-11-13 10:15:11 +01:00
implement video player into the media page
This commit is contained in:
parent
d07a611c35
commit
562ab8fa49
61
src/components/player/hooks/usePlayerMeta.ts
Normal file
61
src/components/player/hooks/usePlayerMeta.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import { DetailedMeta } from "@/backend/metadata/getmeta";
|
||||||
|
import { MWMediaType } from "@/backend/metadata/types/mw";
|
||||||
|
import { usePlayer } from "@/components/player/hooks/usePlayer";
|
||||||
|
import { PlayerMeta, metaToScrapeMedia } from "@/stores/player/slices/source";
|
||||||
|
|
||||||
|
export function usePlayerMeta() {
|
||||||
|
const { setMeta, setScrapeStatus } = usePlayer();
|
||||||
|
const [meta, _setPlayerMeta] = useState<PlayerMeta | null>(null);
|
||||||
|
const scrapeMedia = useMemo(
|
||||||
|
() => (meta ? metaToScrapeMedia(meta) : null),
|
||||||
|
[meta]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setPlayerMeta = useCallback(
|
||||||
|
(m: DetailedMeta, episodeId?: string) => {
|
||||||
|
let playerMeta: PlayerMeta;
|
||||||
|
if (m.meta.type === MWMediaType.SERIES) {
|
||||||
|
const ep = m.meta.seasonData.episodes.find((v) => v.id === episodeId);
|
||||||
|
if (!ep) return false;
|
||||||
|
playerMeta = {
|
||||||
|
type: "show",
|
||||||
|
releaseYear: +(m.meta.year ?? 0),
|
||||||
|
title: m.meta.title,
|
||||||
|
tmdbId: m.tmdbId ?? "",
|
||||||
|
imdbId: m.imdbId,
|
||||||
|
episode: {
|
||||||
|
number: ep.number,
|
||||||
|
title: ep.title,
|
||||||
|
tmdbId: ep.id,
|
||||||
|
},
|
||||||
|
season: {
|
||||||
|
number: m.meta.seasonData.number,
|
||||||
|
title: m.meta.seasonData.title,
|
||||||
|
tmdbId: m.meta.seasonData.id,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
playerMeta = {
|
||||||
|
type: "movie",
|
||||||
|
releaseYear: +(m.meta.year ?? 0),
|
||||||
|
title: m.meta.title,
|
||||||
|
tmdbId: m.tmdbId ?? "",
|
||||||
|
imdbId: m.imdbId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
_setPlayerMeta(playerMeta);
|
||||||
|
setMeta(playerMeta);
|
||||||
|
setScrapeStatus();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
[_setPlayerMeta, setMeta, setScrapeStatus]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
playerMeta: meta,
|
||||||
|
setPlayerMeta,
|
||||||
|
scrapeMedia,
|
||||||
|
};
|
||||||
|
}
|
@ -1,123 +1,72 @@
|
|||||||
import { useEffect, useMemo } from "react";
|
import { RunOutput } from "@movie-web/providers";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import { useAsync } from "react-use";
|
||||||
|
|
||||||
import { MWStreamType } from "@/backend/helpers/streams";
|
import { MWStreamType } from "@/backend/helpers/streams";
|
||||||
import { BrandPill } from "@/components/layout/BrandPill";
|
import { getMetaFromId } from "@/backend/metadata/getmeta";
|
||||||
import { Player } from "@/components/player";
|
import { decodeTMDBId } from "@/backend/metadata/tmdb";
|
||||||
import { AutoPlayStart } from "@/components/player/atoms";
|
|
||||||
import { usePlayer } from "@/components/player/hooks/usePlayer";
|
import { usePlayer } from "@/components/player/hooks/usePlayer";
|
||||||
import { useShouldShowControls } from "@/components/player/hooks/useShouldShowControls";
|
import { usePlayerMeta } from "@/components/player/hooks/usePlayerMeta";
|
||||||
|
import { PlayerPart } from "@/pages/parts/player/PlayerPart";
|
||||||
import { ScrapingPart } from "@/pages/parts/player/ScrapingPart";
|
import { ScrapingPart } from "@/pages/parts/player/ScrapingPart";
|
||||||
import {
|
import { playerStatus } from "@/stores/player/slices/source";
|
||||||
PlayerMeta,
|
|
||||||
metaToScrapeMedia,
|
|
||||||
playerStatus,
|
|
||||||
} from "@/stores/player/slices/source";
|
|
||||||
|
|
||||||
export function PlayerView() {
|
export function PlayerView() {
|
||||||
const { status, setScrapeStatus, playMedia, setMeta } = usePlayer();
|
const params = useParams<{
|
||||||
const { showTargets, showTouchTargets } = useShouldShowControls();
|
media: string;
|
||||||
|
episode?: string;
|
||||||
|
season?: string;
|
||||||
|
}>();
|
||||||
|
const { status, playMedia } = usePlayer();
|
||||||
|
const { setPlayerMeta, scrapeMedia } = usePlayerMeta();
|
||||||
|
|
||||||
const meta = useMemo<PlayerMeta>(
|
const { loading, error } = useAsync(async () => {
|
||||||
() => ({
|
const data = decodeTMDBId(params.media);
|
||||||
type: "show",
|
if (!data) return;
|
||||||
title: "Normal People",
|
|
||||||
releaseYear: 2020,
|
const meta = await getMetaFromId(data.type, data.id, params.season);
|
||||||
tmdbId: "89905",
|
if (!meta) return;
|
||||||
episode: { number: 12, tmdbId: "2207576", title: "Episode 12" },
|
|
||||||
season: { number: 1, tmdbId: "125160", title: "Season 1" },
|
setPlayerMeta(meta);
|
||||||
}),
|
}, []);
|
||||||
[]
|
|
||||||
|
const playAfterScrape = useCallback(
|
||||||
|
(out: RunOutput | null) => {
|
||||||
|
if (out?.stream.type !== "file") return;
|
||||||
|
const qualities = Object.keys(out.stream.qualities).sort(
|
||||||
|
(a, b) => Number(b) - Number(a)
|
||||||
|
) as (keyof typeof out.stream.qualities)[];
|
||||||
|
|
||||||
|
let file;
|
||||||
|
for (const quality of qualities) {
|
||||||
|
if (out.stream.qualities[quality]?.url) {
|
||||||
|
file = out.stream.qualities[quality];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
playMedia({
|
||||||
|
type: MWStreamType.MP4,
|
||||||
|
url: file.url,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[playMedia]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setMeta(meta);
|
|
||||||
}, [setMeta, meta]);
|
|
||||||
const scrapeMedia = useMemo(() => metaToScrapeMedia(meta), [meta]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Player.Container onLoad={setScrapeStatus}>
|
<PlayerPart>
|
||||||
{status === playerStatus.SCRAPING ? (
|
{status === playerStatus.IDLE ? (
|
||||||
<ScrapingPart
|
<div className="flex items-center justify-center">
|
||||||
media={scrapeMedia}
|
{loading ? <p>loading meta...</p> : null}
|
||||||
onGetStream={(out) => {
|
{error ? <p>failed to load meta!</p> : null}
|
||||||
if (out?.stream.type !== "file") return;
|
</div>
|
||||||
console.log(out.stream.qualities);
|
|
||||||
const qualities = Object.keys(out.stream.qualities).sort(
|
|
||||||
(a, b) => Number(b) - Number(a)
|
|
||||||
) as (keyof typeof out.stream.qualities)[];
|
|
||||||
|
|
||||||
let file;
|
|
||||||
for (const quality of qualities) {
|
|
||||||
if (out.stream.qualities[quality]?.url) {
|
|
||||||
console.log(quality);
|
|
||||||
file = out.stream.qualities[quality];
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
playMedia({
|
|
||||||
type: MWStreamType.MP4,
|
|
||||||
url: file.url,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
{status === playerStatus.SCRAPING && scrapeMedia ? (
|
||||||
<Player.BlackOverlay show={showTargets} />
|
<ScrapingPart media={scrapeMedia} onGetStream={playAfterScrape} />
|
||||||
|
) : null}
|
||||||
<Player.CenterControls>
|
</PlayerPart>
|
||||||
<Player.LoadingSpinner />
|
|
||||||
<AutoPlayStart />
|
|
||||||
</Player.CenterControls>
|
|
||||||
|
|
||||||
<Player.CenterMobileControls
|
|
||||||
className="text-white"
|
|
||||||
show={showTouchTargets}
|
|
||||||
>
|
|
||||||
<Player.SkipBackward iconSizeClass="text-3xl" />
|
|
||||||
<Player.Pause iconSizeClass="text-5xl" />
|
|
||||||
<Player.SkipForward iconSizeClass="text-3xl" />
|
|
||||||
</Player.CenterMobileControls>
|
|
||||||
|
|
||||||
<Player.TopControls show={showTargets}>
|
|
||||||
<div className="grid grid-cols-[1fr,auto] xl:grid-cols-3 items-center">
|
|
||||||
<div className="flex space-x-3 items-center">
|
|
||||||
<Player.BackLink />
|
|
||||||
<span className="text mx-3 text-type-secondary">/</span>
|
|
||||||
<Player.Title />
|
|
||||||
<Player.BookmarkButton />
|
|
||||||
</div>
|
|
||||||
<div className="text-center hidden xl:flex justify-center items-center">
|
|
||||||
<Player.EpisodeTitle />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-end">
|
|
||||||
<BrandPill />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Player.TopControls>
|
|
||||||
|
|
||||||
<Player.BottomControls show={showTargets}>
|
|
||||||
<Player.ProgressBar />
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<Player.LeftSideControls className="hidden lg:flex">
|
|
||||||
<Player.Pause />
|
|
||||||
<Player.SkipBackward />
|
|
||||||
<Player.SkipForward />
|
|
||||||
<Player.Volume />
|
|
||||||
<Player.Time />
|
|
||||||
</Player.LeftSideControls>
|
|
||||||
<Player.LeftSideControls className="flex lg:hidden">
|
|
||||||
{/* Do mobile controls here :) */}
|
|
||||||
<Player.Time />
|
|
||||||
</Player.LeftSideControls>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<Player.Settings />
|
|
||||||
<Player.Fullscreen />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Player.BottomControls>
|
|
||||||
</Player.Container>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,25 +0,0 @@
|
|||||||
import { Helmet } from "react-helmet";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import { VideoPlayerHeader } from "@/_oldvideo/components/parts/VideoPlayerHeader";
|
|
||||||
import { ErrorMessage } from "@/components/layout/ErrorBoundary";
|
|
||||||
import { useGoBack } from "@/hooks/useGoBack";
|
|
||||||
|
|
||||||
export function MediaFetchErrorView() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const goBack = useGoBack();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex-1">
|
|
||||||
<Helmet>
|
|
||||||
<title>{t("media.errors.failedMeta")}</title>
|
|
||||||
</Helmet>
|
|
||||||
<div className="fixed inset-x-0 top-0 px-8 py-6">
|
|
||||||
<VideoPlayerHeader onClick={goBack} />
|
|
||||||
</div>
|
|
||||||
<ErrorMessage>
|
|
||||||
<p className="my-6 max-w-lg">{t("media.errors.mediaFailed")}</p>
|
|
||||||
</ErrorMessage>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,69 +0,0 @@
|
|||||||
import { Icon, Icons } from "@/components/Icon";
|
|
||||||
import { ProgressRing } from "@/components/layout/ProgressRing";
|
|
||||||
import { ScrapeEventLog } from "@/hooks/useScrape";
|
|
||||||
|
|
||||||
interface MediaScrapeLogProps {
|
|
||||||
events: ScrapeEventLog[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MediaScrapePillProps {
|
|
||||||
event: ScrapeEventLog;
|
|
||||||
}
|
|
||||||
|
|
||||||
function MediaScrapePillSkeleton() {
|
|
||||||
return <div className="h-9 w-[220px] rounded-full bg-slate-800 opacity-50" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function MediaScrapePill({ event }: MediaScrapePillProps) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-9 w-[220px] items-center rounded-full bg-slate-800 p-3 text-denim-700">
|
|
||||||
<div className="mr-2 flex w-[18px] items-center justify-center">
|
|
||||||
{!event.errored ? (
|
|
||||||
<ProgressRing
|
|
||||||
className="h-[18px] w-[18px] text-bink-700"
|
|
||||||
percentage={event.percentage}
|
|
||||||
radius={40}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Icon icon={Icons.X} className="text-[0.85em] text-rose-400" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 overflow-hidden">
|
|
||||||
<p
|
|
||||||
className={`overflow-hidden text-ellipsis whitespace-nowrap ${
|
|
||||||
event.errored ? "text-rose-400" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{event.id}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MediaScrapeLog(props: MediaScrapeLogProps) {
|
|
||||||
return (
|
|
||||||
<div className="relative h-16 w-[400px] overflow-hidden">
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
|
||||||
<div className="relative flex h-full w-[220px] items-center">
|
|
||||||
<div
|
|
||||||
className="absolute inset-y-0 left-0 flex items-center gap-[16px] transition-transform duration-200"
|
|
||||||
style={{
|
|
||||||
transform: `translateX(${
|
|
||||||
-1 * (220 + 16) * props.events.length
|
|
||||||
}px)`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MediaScrapePillSkeleton />
|
|
||||||
{props.events.map((v) => (
|
|
||||||
<MediaScrapePill event={v} key={v.eventId} />
|
|
||||||
))}
|
|
||||||
<MediaScrapePillSkeleton />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="absolute inset-y-0 left-0 w-40 bg-gradient-to-r from-denim-100 to-transparent" />
|
|
||||||
<div className="absolute inset-y-0 right-0 w-40 bg-gradient-to-l from-denim-100 to-transparent" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,276 +0,0 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import { Helmet } from "react-helmet";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useHistory, useParams } from "react-router-dom";
|
|
||||||
|
|
||||||
import { MetaController } from "@/_oldvideo/components/controllers/MetaController";
|
|
||||||
import { ProgressListenerController } from "@/_oldvideo/components/controllers/ProgressListenerController";
|
|
||||||
import { SeriesController } from "@/_oldvideo/components/controllers/SeriesController";
|
|
||||||
import { SourceController } from "@/_oldvideo/components/controllers/SourceController";
|
|
||||||
import { VideoPlayerHeader } from "@/_oldvideo/components/parts/VideoPlayerHeader";
|
|
||||||
import { VideoPlayer } from "@/_oldvideo/components/VideoPlayer";
|
|
||||||
import { VideoPlayerMeta } from "@/_oldvideo/state/types";
|
|
||||||
import { MWStream } from "@/backend/helpers/streams";
|
|
||||||
import { DetailedMeta, getMetaFromId } from "@/backend/metadata/getmeta";
|
|
||||||
import { decodeTMDBId } from "@/backend/metadata/tmdb";
|
|
||||||
import {
|
|
||||||
MWMediaType,
|
|
||||||
MWSeasonWithEpisodeMeta,
|
|
||||||
} from "@/backend/metadata/types/mw";
|
|
||||||
import { IconPatch } from "@/components/buttons/IconPatch";
|
|
||||||
import { Icons } from "@/components/Icon";
|
|
||||||
import { Loading } from "@/components/layout/Loading";
|
|
||||||
import { useGoBack } from "@/hooks/useGoBack";
|
|
||||||
import { useLoading } from "@/hooks/useLoading";
|
|
||||||
import { SelectedMediaData, useScrape } from "@/hooks/useScrape";
|
|
||||||
import { ErrorWrapperPart } from "@/pages/parts/errors/ErrorWrapperPart";
|
|
||||||
import { MediaNotFoundPart } from "@/pages/parts/errors/MediaNotFoundPart";
|
|
||||||
import { useWatchedItem } from "@/state/watched";
|
|
||||||
|
|
||||||
import { MediaFetchErrorView } from "./MediaErrorView";
|
|
||||||
import { MediaScrapeLog } from "./MediaScrapeLog";
|
|
||||||
|
|
||||||
function MediaViewLoading(props: { onGoBack(): void }) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative flex flex-1 items-center justify-center">
|
|
||||||
<Helmet>
|
|
||||||
<title>{t("videoPlayer.loading")}</title>
|
|
||||||
</Helmet>
|
|
||||||
<div className="absolute inset-x-0 top-0 px-8 py-6">
|
|
||||||
<VideoPlayerHeader onClick={props.onGoBack} />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-center">
|
|
||||||
<Loading className="mb-4" />
|
|
||||||
<p className="mb-8 text-denim-700">
|
|
||||||
{t("videoPlayer.findingBestVideo")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MediaViewScrapingProps {
|
|
||||||
onStream(stream: MWStream): void;
|
|
||||||
onGoBack(): void;
|
|
||||||
meta: DetailedMeta;
|
|
||||||
selected: SelectedMediaData;
|
|
||||||
}
|
|
||||||
function MediaViewScraping(props: MediaViewScrapingProps) {
|
|
||||||
const { eventLog, stream, pending } = useScrape(props.meta, props.selected);
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (stream) {
|
|
||||||
props.onStream(stream);
|
|
||||||
}
|
|
||||||
}, [stream, props]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative flex flex-1 items-center justify-center">
|
|
||||||
<Helmet>
|
|
||||||
<title>{props.meta.meta.title}</title>
|
|
||||||
</Helmet>
|
|
||||||
<div className="absolute inset-x-0 top-0 px-8 py-6">
|
|
||||||
<VideoPlayerHeader onClick={props.onGoBack} media={props.meta.meta} />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-center transition-opacity duration-200">
|
|
||||||
{pending ? (
|
|
||||||
<>
|
|
||||||
<Loading />
|
|
||||||
<p className="mb-8 text-denim-700">
|
|
||||||
{t("videoPlayer.findingBestVideo")}
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<IconPatch icon={Icons.EYE_SLASH} className="mb-8 text-bink-700" />
|
|
||||||
<p className="mb-8 text-denim-700">{t("videoPlayer.noVideos")}</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
className={`flex flex-col items-center transition-opacity duration-200 ${
|
|
||||||
pending ? "opacity-100" : "opacity-0"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<MediaScrapeLog events={eventLog} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MediaViewPlayerProps {
|
|
||||||
meta: DetailedMeta;
|
|
||||||
stream: MWStream;
|
|
||||||
selected: SelectedMediaData;
|
|
||||||
onChangeStream: (sId: string, eId: string) => void;
|
|
||||||
}
|
|
||||||
export function MediaViewPlayer(props: MediaViewPlayerProps) {
|
|
||||||
const goBack = useGoBack();
|
|
||||||
const { updateProgress, watchedItem } = useWatchedItem(
|
|
||||||
props.meta,
|
|
||||||
props.selected.episode
|
|
||||||
);
|
|
||||||
const firstStartTime = useRef(watchedItem?.progress);
|
|
||||||
useEffect(() => {
|
|
||||||
firstStartTime.current = watchedItem?.progress;
|
|
||||||
// only want it to change when stream changes
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [props.stream]);
|
|
||||||
|
|
||||||
const metaProps: VideoPlayerMeta = {
|
|
||||||
meta: props.meta,
|
|
||||||
captions: [],
|
|
||||||
};
|
|
||||||
let metaSeasonData: MWSeasonWithEpisodeMeta | undefined;
|
|
||||||
if (
|
|
||||||
props.selected.type === MWMediaType.SERIES &&
|
|
||||||
props.meta.meta.type === MWMediaType.SERIES
|
|
||||||
) {
|
|
||||||
metaProps.episode = {
|
|
||||||
seasonId: props.selected.season,
|
|
||||||
episodeId: props.selected.episode,
|
|
||||||
};
|
|
||||||
metaProps.seasons = props.meta.meta.seasons;
|
|
||||||
metaSeasonData = props.meta.meta.seasonData;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed left-0 top-0 h-[100dvh] w-screen">
|
|
||||||
<Helmet>
|
|
||||||
<html data-full="true" />
|
|
||||||
</Helmet>
|
|
||||||
<VideoPlayer includeSafeArea autoPlay onGoBack={goBack}>
|
|
||||||
<MetaController
|
|
||||||
data={metaProps}
|
|
||||||
seasonData={metaSeasonData}
|
|
||||||
linkedCaptions={props.stream.captions}
|
|
||||||
/>
|
|
||||||
<SourceController
|
|
||||||
source={props.stream.streamUrl}
|
|
||||||
type={props.stream.type}
|
|
||||||
quality={props.stream.quality}
|
|
||||||
embedId={props.stream.embedId}
|
|
||||||
providerId={props.stream.providerId}
|
|
||||||
captions={props.stream.captions}
|
|
||||||
/>
|
|
||||||
<ProgressListenerController
|
|
||||||
startAt={firstStartTime.current}
|
|
||||||
onProgress={updateProgress}
|
|
||||||
/>
|
|
||||||
<SeriesController
|
|
||||||
onSelect={(d) =>
|
|
||||||
d.seasonId &&
|
|
||||||
d.episodeId &&
|
|
||||||
props.onChangeStream?.(d.seasonId, d.episodeId)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</VideoPlayer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MediaView() {
|
|
||||||
const params = useParams<{
|
|
||||||
media: string;
|
|
||||||
episode?: string;
|
|
||||||
season?: string;
|
|
||||||
}>();
|
|
||||||
const goBack = useGoBack();
|
|
||||||
const history = useHistory();
|
|
||||||
|
|
||||||
const [meta, setMeta] = useState<DetailedMeta | null>(null);
|
|
||||||
const [selected, setSelected] = useState<SelectedMediaData | null>(null);
|
|
||||||
const [exec, loading, error] = useLoading(
|
|
||||||
async (mediaParams: string, seasonId?: string) => {
|
|
||||||
const data = decodeTMDBId(mediaParams);
|
|
||||||
if (!data) return null;
|
|
||||||
return getMetaFromId(data.type, data.id, seasonId);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
// TODO get stream from someplace that actually gets updated
|
|
||||||
const [stream, setStream] = useState<MWStream | null>(null);
|
|
||||||
|
|
||||||
const lastSearchValue = useRef<(string | undefined)[] | null>(null);
|
|
||||||
useEffect(() => {
|
|
||||||
const newValue = [params.media, params.season, params.episode];
|
|
||||||
const lastVal = lastSearchValue.current;
|
|
||||||
|
|
||||||
const isSame =
|
|
||||||
lastVal?.[0] === newValue[0] &&
|
|
||||||
(lastVal?.[1] === newValue[1] || !lastVal?.[1]) &&
|
|
||||||
(lastVal?.[2] === newValue[2] || !lastVal?.[2]);
|
|
||||||
|
|
||||||
lastSearchValue.current = newValue;
|
|
||||||
if (isSame && lastVal !== null) return;
|
|
||||||
|
|
||||||
setMeta(null);
|
|
||||||
setStream(null);
|
|
||||||
setSelected(null);
|
|
||||||
exec(params.media, params.season).then((v) => {
|
|
||||||
setMeta(v ?? null);
|
|
||||||
setStream(null);
|
|
||||||
if (v) {
|
|
||||||
if (v.meta.type !== MWMediaType.SERIES) {
|
|
||||||
setSelected({
|
|
||||||
type: v.meta.type,
|
|
||||||
season: undefined,
|
|
||||||
episode: undefined,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const season = params.season ?? v.meta.seasonData.id;
|
|
||||||
const episode = params.episode ?? v.meta.seasonData.episodes[0].id;
|
|
||||||
setSelected({
|
|
||||||
type: MWMediaType.SERIES,
|
|
||||||
season,
|
|
||||||
episode,
|
|
||||||
});
|
|
||||||
if (season !== params.season || episode !== params.episode)
|
|
||||||
history.replace(
|
|
||||||
`/media/${encodeURIComponent(params.media)}/${encodeURIComponent(
|
|
||||||
season
|
|
||||||
)}/${encodeURIComponent(episode)}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else setSelected(null);
|
|
||||||
});
|
|
||||||
}, [exec, history, params]);
|
|
||||||
|
|
||||||
if (loading) return <MediaViewLoading onGoBack={goBack} />;
|
|
||||||
if (error) return <MediaFetchErrorView />;
|
|
||||||
if (!meta || !selected)
|
|
||||||
return (
|
|
||||||
<ErrorWrapperPart video>
|
|
||||||
<MediaNotFoundPart />
|
|
||||||
</ErrorWrapperPart>
|
|
||||||
);
|
|
||||||
|
|
||||||
// scraping view will start scraping and return with onStream
|
|
||||||
if (!stream)
|
|
||||||
return (
|
|
||||||
<MediaViewScraping
|
|
||||||
meta={meta}
|
|
||||||
selected={selected}
|
|
||||||
onGoBack={goBack}
|
|
||||||
onStream={setStream}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
// show stream once we have a stream
|
|
||||||
return (
|
|
||||||
<MediaViewPlayer
|
|
||||||
meta={meta}
|
|
||||||
stream={stream}
|
|
||||||
selected={selected}
|
|
||||||
onChangeStream={(sId, eId) => {
|
|
||||||
history.replace(
|
|
||||||
`/media/${encodeURIComponent(params.media)}/${encodeURIComponent(
|
|
||||||
sId
|
|
||||||
)}/${encodeURIComponent(eId)}`
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
73
src/pages/parts/player/PlayerPart.tsx
Normal file
73
src/pages/parts/player/PlayerPart.tsx
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
|
import { BrandPill } from "@/components/layout/BrandPill";
|
||||||
|
import { Player } from "@/components/player";
|
||||||
|
import { useShouldShowControls } from "@/components/player/hooks/useShouldShowControls";
|
||||||
|
|
||||||
|
export interface PlayerPartProps {
|
||||||
|
children?: ReactNode;
|
||||||
|
onLoad?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlayerPart(props: PlayerPartProps) {
|
||||||
|
const { showTargets, showTouchTargets } = useShouldShowControls();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Player.Container onLoad={props.onLoad}>
|
||||||
|
{props.children}
|
||||||
|
<Player.BlackOverlay show={showTargets} />
|
||||||
|
|
||||||
|
<Player.CenterControls>
|
||||||
|
<Player.LoadingSpinner />
|
||||||
|
<Player.AutoPlayStart />
|
||||||
|
</Player.CenterControls>
|
||||||
|
|
||||||
|
<Player.CenterMobileControls
|
||||||
|
className="text-white"
|
||||||
|
show={showTouchTargets}
|
||||||
|
>
|
||||||
|
<Player.SkipBackward iconSizeClass="text-3xl" />
|
||||||
|
<Player.Pause iconSizeClass="text-5xl" />
|
||||||
|
<Player.SkipForward iconSizeClass="text-3xl" />
|
||||||
|
</Player.CenterMobileControls>
|
||||||
|
|
||||||
|
<Player.TopControls show={showTargets}>
|
||||||
|
<div className="grid grid-cols-[1fr,auto] xl:grid-cols-3 items-center">
|
||||||
|
<div className="flex space-x-3 items-center">
|
||||||
|
<Player.BackLink />
|
||||||
|
<span className="text mx-3 text-type-secondary">/</span>
|
||||||
|
<Player.Title />
|
||||||
|
<Player.BookmarkButton />
|
||||||
|
</div>
|
||||||
|
<div className="text-center hidden xl:flex justify-center items-center">
|
||||||
|
<Player.EpisodeTitle />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-end">
|
||||||
|
<BrandPill />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Player.TopControls>
|
||||||
|
|
||||||
|
<Player.BottomControls show={showTargets}>
|
||||||
|
<Player.ProgressBar />
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<Player.LeftSideControls className="hidden lg:flex">
|
||||||
|
<Player.Pause />
|
||||||
|
<Player.SkipBackward />
|
||||||
|
<Player.SkipForward />
|
||||||
|
<Player.Volume />
|
||||||
|
<Player.Time />
|
||||||
|
</Player.LeftSideControls>
|
||||||
|
<Player.LeftSideControls className="flex lg:hidden">
|
||||||
|
{/* Do mobile controls here :) */}
|
||||||
|
<Player.Time />
|
||||||
|
</Player.LeftSideControls>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Player.Settings />
|
||||||
|
<Player.Fullscreen />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Player.BottomControls>
|
||||||
|
</Player.Container>
|
||||||
|
);
|
||||||
|
}
|
@ -15,7 +15,7 @@ import { AboutPage } from "@/pages/About";
|
|||||||
import { DmcaPage } from "@/pages/Dmca";
|
import { DmcaPage } from "@/pages/Dmca";
|
||||||
import { NotFoundPage } from "@/pages/errors/NotFoundPage";
|
import { NotFoundPage } from "@/pages/errors/NotFoundPage";
|
||||||
import { HomePage } from "@/pages/HomePage";
|
import { HomePage } from "@/pages/HomePage";
|
||||||
import { MediaView } from "@/pages/media/MediaView";
|
import { PlayerView } from "@/pages/PlayerView";
|
||||||
import { Layout } from "@/setup/Layout";
|
import { Layout } from "@/setup/Layout";
|
||||||
import { BookmarkContextProvider } from "@/state/bookmark";
|
import { BookmarkContextProvider } from "@/state/bookmark";
|
||||||
import { SettingsProvider } from "@/state/settings";
|
import { SettingsProvider } from "@/state/settings";
|
||||||
@ -66,24 +66,26 @@ function App() {
|
|||||||
<Route exact path="/s/:query">
|
<Route exact path="/s/:query">
|
||||||
<QuickSearch />
|
<QuickSearch />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
{/* pages */}
|
|
||||||
<Route exact path="/media/:media">
|
|
||||||
<LegacyUrlView>
|
|
||||||
<MediaView />
|
|
||||||
</LegacyUrlView>
|
|
||||||
</Route>
|
|
||||||
<Route exact path="/media/:media/:season/:episode">
|
|
||||||
<LegacyUrlView>
|
|
||||||
<MediaView />
|
|
||||||
</LegacyUrlView>
|
|
||||||
</Route>
|
|
||||||
<Route exact path="/search/:type/:query?">
|
|
||||||
<Redirect to="/browse/:query" />
|
|
||||||
</Route>
|
|
||||||
<Route exact path="/search/:type">
|
<Route exact path="/search/:type">
|
||||||
<Redirect to="/browse" />
|
<Redirect to="/browse" />
|
||||||
</Route>
|
</Route>
|
||||||
|
<Route exact path="/search/:type/:query?">
|
||||||
|
{({ match }) => {
|
||||||
|
if (match?.params.query)
|
||||||
|
return <Redirect to={`/browse/${match?.params.query}`} />;
|
||||||
|
return <Redirect to="/browse" />;
|
||||||
|
}}
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
{/* pages */}
|
||||||
|
<Route
|
||||||
|
exact
|
||||||
|
path={["/media/:media", "/media/:media/:season/:episode"]}
|
||||||
|
>
|
||||||
|
<LegacyUrlView>
|
||||||
|
<PlayerView />
|
||||||
|
</LegacyUrlView>
|
||||||
|
</Route>
|
||||||
<Route
|
<Route
|
||||||
exact
|
exact
|
||||||
path={["/browse/:query?", "/"]}
|
path={["/browse/:query?", "/"]}
|
||||||
|
Loading…
Reference in New Issue
Block a user