From 63be27b9ae26a973f94615ea289af5f4389a4b85 Mon Sep 17 00:00:00 2001 From: Jelle van Snik Date: Sat, 4 Feb 2023 19:10:19 +0100 Subject: [PATCH] episode selector --- src/video/components/VideoPlayer.tsx | 7 +- .../actions/SeriesSelectionAction.tsx | 201 ++++++++++++++++++ .../components/controllers/MetaController.tsx | 29 ++- .../controllers/SeriesController.tsx | 39 ++++ src/video/components/parts/VideoPopout.tsx | 65 ++++++ src/video/state/logic/controls.ts | 20 +- src/views/TestView.tsx | 10 +- src/views/media/MediaErrorView.tsx | 2 +- src/views/media/MediaView.tsx | 45 ++-- src/views/notfound/NotFoundView.tsx | 2 +- 10 files changed, 383 insertions(+), 37 deletions(-) create mode 100644 src/video/components/actions/SeriesSelectionAction.tsx create mode 100644 src/video/components/controllers/SeriesController.tsx create mode 100644 src/video/components/parts/VideoPopout.tsx diff --git a/src/video/components/VideoPlayer.tsx b/src/video/components/VideoPlayer.tsx index cb441ce8..1ede8c5d 100644 --- a/src/video/components/VideoPlayer.tsx +++ b/src/video/components/VideoPlayer.tsx @@ -10,6 +10,7 @@ import { PageTitleAction } from "@/video/components/actions/PageTitleAction"; import { PauseAction } from "@/video/components/actions/PauseAction"; import { ProgressAction } from "@/video/components/actions/ProgressAction"; import { QualityDisplayAction } from "@/video/components/actions/QualityDisplayAction"; +import { SeriesSelectionAction } from "@/video/components/actions/SeriesSelectionAction"; import { ShowTitleAction } from "@/video/components/actions/ShowTitleAction"; import { SkipTimeAction } from "@/video/components/actions/SkipTimeAction"; import { TimeAction } from "@/video/components/actions/TimeAction"; @@ -118,7 +119,7 @@ export function VideoPlayer(props: Props) {
- {/* */} + {/* */}
@@ -128,8 +129,8 @@ export function VideoPlayer(props: Props) {
- {/* - + + {/* */} diff --git a/src/video/components/actions/SeriesSelectionAction.tsx b/src/video/components/actions/SeriesSelectionAction.tsx new file mode 100644 index 00000000..7229e7dd --- /dev/null +++ b/src/video/components/actions/SeriesSelectionAction.tsx @@ -0,0 +1,201 @@ +import React, { useCallback, useMemo, useState } from "react"; +import { useParams } from "react-router-dom"; +import { Icon, Icons } from "@/components/Icon"; +import { useLoading } from "@/hooks/useLoading"; +import { MWMediaType, MWSeasonWithEpisodeMeta } from "@/backend/metadata/types"; +import { getMetaFromId } from "@/backend/metadata/getmeta"; +import { decodeJWId } from "@/backend/metadata/justwatch"; +import { Loading } from "@/components/layout/Loading"; +import { IconPatch } from "@/components/buttons/IconPatch"; +import { useVideoPlayerDescriptor } from "@/video/state/hooks"; +import { useMeta } from "@/video/state/logic/meta"; +import { VideoPlayerIconButton } from "@/video/components/parts/VideoPlayerIconButton"; +import { useControls } from "@/video/state/logic/controls"; +import { VideoPopout } from "@/video/components/parts/VideoPopout"; + +interface Props { + className?: string; +} + +function PopupSection(props: { + children?: React.ReactNode; + className?: string; +}) { + return ( +
+ {props.children} +
+ ); +} + +function PopupEpisodeSelect() { + const params = useParams<{ + media: string; + }>(); + const descriptor = useVideoPlayerDescriptor(); + const meta = useMeta(descriptor); + const controls = useControls(descriptor); + + const [isPickingSeason, setIsPickingSeason] = useState(false); + const [currentVisibleSeason, setCurrentVisibleSeason] = useState<{ + seasonId: string; + season?: MWSeasonWithEpisodeMeta; + } | null>(null); + const [reqSeasonMeta, loading, error] = useLoading( + (id: string, seasonId: string) => { + return getMetaFromId(MWMediaType.SERIES, id, seasonId); + } + ); + const requestSeason = useCallback( + (sId: string) => { + setCurrentVisibleSeason({ + seasonId: sId, + season: undefined, + }); + setIsPickingSeason(false); + reqSeasonMeta(decodeJWId(params.media)?.id as string, sId).then((v) => { + if (v?.meta.type !== MWMediaType.SERIES) return; + setCurrentVisibleSeason({ + seasonId: sId, + season: v?.meta.seasonData, + }); + }); + }, + [reqSeasonMeta, params.media] + ); + + const currentSeasonId = + currentVisibleSeason?.seasonId ?? meta?.episode?.seasonId; + + const setCurrent = useCallback( + (seasonId: string, episodeId: string) => { + controls.setCurrentEpisode(seasonId, episodeId); + }, + [controls] + ); + + const currentSeasonInfo = useMemo(() => { + return meta?.seasons?.find((season) => season.id === currentSeasonId); + }, [meta, currentSeasonId]); + + const currentSeasonEpisodes = useMemo(() => { + if (currentVisibleSeason?.season) { + return currentVisibleSeason?.season?.episodes; + } + return meta?.seasons?.find?.( + (season) => season && season.id === currentSeasonId + )?.episodes; + }, [meta, currentSeasonId, currentVisibleSeason]); + + const toggleIsPickingSeason = () => { + setIsPickingSeason(!isPickingSeason); + }; + + const setSeason = (id: string) => { + requestSeason(id); + setCurrentVisibleSeason({ seasonId: id }); + }; + + if (isPickingSeason) + return ( + <> + + Pick a season + + +
+ {currentSeasonInfo + ? meta?.seasons?.map?.((season) => ( +
setSeason(season.id)} + > + {season.title} +
+ )) + : "No season"} +
+
+ + ); + + return ( + <> + + + {currentSeasonInfo?.title || ""} + + + {loading ? ( +
+ +
+ ) : error ? ( +
+
+ +

+ Something went wrong loading the episodes for{" "} + {currentSeasonInfo?.title?.toLowerCase()} +

+
+
+ ) : ( +
+ {currentSeasonEpisodes && currentSeasonInfo + ? currentSeasonEpisodes.map((e) => ( +
setCurrent(currentSeasonInfo.id, e.id)} + key={e.id} + > + {e.number}. {e.title} +
+ )) + : "No episodes"} +
+ )} +
+ + ); +} + +export function SeriesSelectionAction(props: Props) { + const descriptor = useVideoPlayerDescriptor(); + const meta = useMeta(descriptor); + const controls = useControls(descriptor); + + if (meta?.meta.type !== MWMediaType.SERIES) return null; + + return ( +
+
+ + + + controls.openPopout("episodes")} + /> +
+
+ ); +} diff --git a/src/video/components/controllers/MetaController.tsx b/src/video/components/controllers/MetaController.tsx index be35a702..103ef0ab 100644 --- a/src/video/components/controllers/MetaController.tsx +++ b/src/video/components/controllers/MetaController.tsx @@ -1,10 +1,33 @@ -import { MWMediaMeta } from "@/backend/metadata/types"; +import { MWSeasonWithEpisodeMeta } from "@/backend/metadata/types"; import { useVideoPlayerDescriptor } from "@/video/state/hooks"; import { useControls } from "@/video/state/logic/controls"; +import { VideoPlayerMeta } from "@/video/state/types"; import { useEffect } from "react"; interface MetaControllerProps { - meta?: MWMediaMeta; + data?: VideoPlayerMeta; + seasonData?: MWSeasonWithEpisodeMeta; +} + +function formatMetadata( + props: MetaControllerProps +): VideoPlayerMeta | undefined { + const seasonsWithEpisodes = props.data?.seasons?.map((v) => { + if (v.id === props.seasonData?.id) + return { + ...v, + episodes: props.seasonData.episodes, + }; + return v; + }); + + if (!props.data) return undefined; + + return { + meta: props.data.meta, + episode: props.data.episode, + seasons: seasonsWithEpisodes, + }; } export function MetaController(props: MetaControllerProps) { @@ -12,7 +35,7 @@ export function MetaController(props: MetaControllerProps) { const controls = useControls(descriptor); useEffect(() => { - controls.setMeta(props.meta); + controls.setMeta(formatMetadata(props)); }, [props, controls]); return null; diff --git a/src/video/components/controllers/SeriesController.tsx b/src/video/components/controllers/SeriesController.tsx new file mode 100644 index 00000000..f38701d5 --- /dev/null +++ b/src/video/components/controllers/SeriesController.tsx @@ -0,0 +1,39 @@ +import { useVideoPlayerDescriptor } from "@/video/state/hooks"; +import { useMeta } from "@/video/state/logic/meta"; +import { useEffect, useRef } from "react"; + +interface SeriesControllerProps { + onSelect?: (state: { episodeId?: string; seasonId?: string }) => void; +} + +export function SeriesController(props: SeriesControllerProps) { + const descriptor = useVideoPlayerDescriptor(); + const meta = useMeta(descriptor); + + const lastState = useRef<{ + episodeId?: string; + seasonId?: string; + } | null>(null); + + useEffect(() => { + const currentState = { + episodeId: meta?.episode?.episodeId, + seasonId: meta?.episode?.seasonId, + }; + if (lastState.current === null) { + lastState.current = currentState; + return; + } + + // when changes are detected, trigger event handler + if ( + currentState.episodeId !== lastState.current?.episodeId || + currentState.seasonId !== lastState.current?.seasonId + ) { + lastState.current = currentState; + props.onSelect?.(currentState); + } + }, [meta, props]); + + return null; +} diff --git a/src/video/components/parts/VideoPopout.tsx b/src/video/components/parts/VideoPopout.tsx new file mode 100644 index 00000000..f59fa969 --- /dev/null +++ b/src/video/components/parts/VideoPopout.tsx @@ -0,0 +1,65 @@ +import { useVideoPlayerDescriptor } from "@/video/state/hooks"; +import { useControls } from "@/video/state/logic/controls"; +import { useInterface } from "@/video/state/logic/interface"; +import { useEffect, useRef } from "react"; + +interface Props { + children?: React.ReactNode; + id?: string; + className?: string; +} + +// TODO store popout in router history so you can press back to yeet +// TODO add transition +export function VideoPopout(props: Props) { + const descriptor = useVideoPlayerDescriptor(); + const videoInterface = useInterface(descriptor); + const controls = useControls(descriptor); + + const popoutRef = useRef(null); + const isOpen = videoInterface.popout === props.id; + + useEffect(() => { + if (!isOpen) return; + const popoutEl = popoutRef.current; + function windowClick(e: MouseEvent) { + const rect = popoutEl?.getBoundingClientRect(); + if (rect) { + if ( + e.pageX >= rect.x && + e.pageX <= rect.x + rect.width && + e.pageY >= rect.y && + e.pageY <= rect.y + rect.height + ) { + // inside bounding box of popout + return; + } + } + + controls.closePopout(); + } + + window.addEventListener("click", windowClick); + return () => { + window.removeEventListener("click", windowClick); + }; + }, [isOpen, controls]); + + return ( +
+
+
+ {isOpen ? props.children : null} +
+
+
+ ); +} diff --git a/src/video/state/logic/controls.ts b/src/video/state/logic/controls.ts index d9378f4d..a5396326 100644 --- a/src/video/state/logic/controls.ts +++ b/src/video/state/logic/controls.ts @@ -1,15 +1,16 @@ -import { MWMediaMeta } from "@/backend/metadata/types"; import { updateInterface } from "@/video/state/logic/interface"; import { updateMeta } from "@/video/state/logic/meta"; +import { VideoPlayerMeta } from "@/video/state/types"; import { getPlayerState } from "../cache"; import { VideoPlayerStateController } from "../providers/providerTypes"; -type ControlMethods = { +export type ControlMethods = { openPopout(id: string): void; closePopout(): void; setLeftControlsHover(hovering: boolean): void; setFocused(focused: boolean): void; - setMeta(meta?: MWMediaMeta): void; + setMeta(data?: VideoPlayerMeta): void; + setCurrentEpisode(sId: string, eId: string): void; }; export function useControls( @@ -65,11 +66,18 @@ export function useControls( if (!meta) { state.meta = null; } else { - state.meta = { - meta, - }; + state.meta = meta; } updateMeta(descriptor, state); }, + setCurrentEpisode(sId, eId) { + if (state.meta) { + state.meta.episode = { + seasonId: sId, + episodeId: eId, + }; + updateMeta(descriptor, state); + } + }, }; } diff --git a/src/views/TestView.tsx b/src/views/TestView.tsx index cc3d00da..5436207b 100644 --- a/src/views/TestView.tsx +++ b/src/views/TestView.tsx @@ -5,8 +5,8 @@ // import { useEffect, useRef } from "react"; import { MWStreamQuality, MWStreamType } from "@/backend/helpers/streams"; -import { MWMediaType } from "@/backend/metadata/types"; -import { MetaController } from "@/video/components/controllers/MetaController"; +// import { MWMediaType } from "@/backend/metadata/types"; +// import { MetaController } from "@/video/components/controllers/MetaController"; import { SourceController } from "@/video/components/controllers/SourceController"; import { VideoPlayer } from "@/video/components/VideoPlayer"; @@ -32,15 +32,15 @@ export function TestView() { source="http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/Sintel.mp4" type={MWStreamType.MP4} /> - + }} */} + {/* /> */} ); } diff --git a/src/views/media/MediaErrorView.tsx b/src/views/media/MediaErrorView.tsx index 0e26a434..47b4863c 100644 --- a/src/views/media/MediaErrorView.tsx +++ b/src/views/media/MediaErrorView.tsx @@ -1,9 +1,9 @@ import { MWMediaMeta } from "@/backend/metadata/types"; import { ErrorMessage } from "@/components/layout/ErrorBoundary"; import { Link } from "@/components/text/Link"; -import { VideoPlayerHeader } from "@/../__old/parts/VideoPlayerHeader"; import { useGoBack } from "@/hooks/useGoBack"; import { conf } from "@/setup/config"; +import { VideoPlayerHeader } from "@/video/components/parts/VideoPlayerHeader"; import { Helmet } from "react-helmet"; export function MediaFetchErrorView() { diff --git a/src/views/media/MediaView.tsx b/src/views/media/MediaView.tsx index a3110af3..9b5747c4 100644 --- a/src/views/media/MediaView.tsx +++ b/src/views/media/MediaView.tsx @@ -7,7 +7,7 @@ import { DetailedMeta, getMetaFromId } from "@/backend/metadata/getmeta"; import { decodeJWId } from "@/backend/metadata/justwatch"; import { Loading } from "@/components/layout/Loading"; import { useLoading } from "@/hooks/useLoading"; -import { MWMediaType } from "@/backend/metadata/types"; +import { MWMediaType, MWSeasonWithEpisodeMeta } from "@/backend/metadata/types"; import { useGoBack } from "@/hooks/useGoBack"; import { IconPatch } from "@/components/buttons/IconPatch"; import { VideoPlayer } from "@/video/components/VideoPlayer"; @@ -16,6 +16,8 @@ import { SourceController } from "@/video/components/controllers/SourceControlle import { Icons } from "@/components/Icon"; import { VideoPlayerHeader } from "@/video/components/parts/VideoPlayerHeader"; import { ProgressListenerController } from "@/video/components/controllers/ProgressListenerController"; +import { VideoPlayerMeta } from "@/video/state/types"; +import { SeriesController } from "@/video/components/controllers/SeriesController"; import { useWatchedItem } from "@/state/watched"; import { MediaFetchErrorView } from "./MediaErrorView"; import { MediaScrapeLog } from "./MediaScrapeLog"; @@ -108,13 +110,29 @@ export function MediaViewPlayer(props: MediaViewPlayerProps) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [props.stream]); + const metaProps: VideoPlayerMeta = { + meta: props.meta.meta, + }; + 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 (
- + - {/* {props.selected.type === MWMediaType.SERIES && - props.meta.meta.type === MWMediaType.SERIES ? ( - - d.seasonId && - d.episodeId && - props.onChangeStream?.(d.seasonId, d.episodeId) - } - seasonData={props.meta.meta.seasonData} - seasons={props.meta.meta.seasons} - /> - ) : null} */} + + d.seasonId && + d.episodeId && + props.onChangeStream?.(d.seasonId, d.episodeId) + } + />
); diff --git a/src/views/notfound/NotFoundView.tsx b/src/views/notfound/NotFoundView.tsx index 55dfed7c..83890e63 100644 --- a/src/views/notfound/NotFoundView.tsx +++ b/src/views/notfound/NotFoundView.tsx @@ -6,8 +6,8 @@ import { Navigation } from "@/components/layout/Navigation"; import { ArrowLink } from "@/components/text/ArrowLink"; import { Title } from "@/components/text/Title"; import { useGoBack } from "@/hooks/useGoBack"; -import { VideoPlayerHeader } from "@/../__old/parts/VideoPlayerHeader"; import { Helmet } from "react-helmet"; +import { VideoPlayerHeader } from "@/video/components/parts/VideoPlayerHeader"; export function NotFoundWrapper(props: { children?: ReactNode;