From 5d5a72766310bdaa4a2be09cd8f2a9c16e331de3 Mon Sep 17 00:00:00 2001 From: Jelle van Snik Date: Tue, 7 Feb 2023 17:49:05 +0100 Subject: [PATCH] add better popout system Co-authored-by: Jip Frijlink --- src/components/Transition.tsx | 28 ++- src/video/components/VideoPlayer.tsx | 2 + .../actions/SeriesSelectionAction.tsx | 21 ++- .../parts/VideoPlayerIconButton.tsx | 10 +- src/video/components/parts/VideoPopout.tsx | 2 - .../popouts/EpisodeSelectionPopout.tsx | 169 ++++++++++++++++++ src/video/components/popouts/PopoutAnchor.tsx | 41 +++++ .../popouts/PopoutProviderAction.tsx | 70 ++++++++ src/video/components/popouts/Popouts.css | 15 ++ src/video/state/logic/controls.ts | 1 + src/video/state/logic/interface.ts | 2 + src/video/state/types.ts | 1 + 12 files changed, 346 insertions(+), 16 deletions(-) create mode 100644 src/video/components/popouts/EpisodeSelectionPopout.tsx create mode 100644 src/video/components/popouts/PopoutAnchor.tsx create mode 100644 src/video/components/popouts/PopoutProviderAction.tsx create mode 100644 src/video/components/popouts/Popouts.css diff --git a/src/components/Transition.tsx b/src/components/Transition.tsx index 04523ecb..85443461 100644 --- a/src/components/Transition.tsx +++ b/src/components/Transition.tsx @@ -2,11 +2,11 @@ import { ReactNode, useRef } from "react"; import { CSSTransition } from "react-transition-group"; import { CSSTransitionClassNames } from "react-transition-group/CSSTransition"; -type TransitionAnimations = "slide-down" | "slide-up" | "fade"; +type TransitionAnimations = "slide-down" | "slide-up" | "fade" | "fade-inverse"; interface Props { show: boolean; - duration?: number; + durationClass?: string; animation: TransitionAnimations; className?: string; children?: ReactNode; @@ -46,21 +46,37 @@ function getClasses( }; } + if (animation === "fade-inverse") { + return { + enter: `transition-[transform,opacity] duration-${duration} opacity-100`, + enterActive: "!opacity-0", + exit: `transition-[transform,opacity] duration-${duration} opacity-0`, + exitActive: "!opacity-100", + enterDone: "hidden", + }; + } + return {}; } export function Transition(props: Props) { const ref = useRef(null); - const duration = props.duration ?? 200; + const duration = props.durationClass + ? parseInt(props.durationClass.split("-")[1], 10) + : 200; + const classes = getClasses(props.animation, duration); return ( -
+
{props.children}
diff --git a/src/video/components/VideoPlayer.tsx b/src/video/components/VideoPlayer.tsx index 6e52bd61..5a975308 100644 --- a/src/video/components/VideoPlayer.tsx +++ b/src/video/components/VideoPlayer.tsx @@ -25,6 +25,7 @@ import { import { useVideoPlayerDescriptor } from "@/video/state/hooks"; import { useControls } from "@/video/state/logic/controls"; import { ReactNode, useCallback, useState } from "react"; +import { PopoutProviderAction } from "@/video/components/popouts/PopoutProviderAction"; type Props = VideoPlayerBaseProps; @@ -153,6 +154,7 @@ export function VideoPlayer(props: Props) { )}
+ {show ? : null} {props.children} diff --git a/src/video/components/actions/SeriesSelectionAction.tsx b/src/video/components/actions/SeriesSelectionAction.tsx index 7229e7dd..6840584f 100644 --- a/src/video/components/actions/SeriesSelectionAction.tsx +++ b/src/video/components/actions/SeriesSelectionAction.tsx @@ -12,6 +12,8 @@ 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"; +import { PopoutAnchor } from "@/video/components/popouts/PopoutAnchor"; +import { useInterface } from "@/video/state/logic/interface"; interface Props { className?: string; @@ -177,6 +179,7 @@ function PopupEpisodeSelect() { export function SeriesSelectionAction(props: Props) { const descriptor = useVideoPlayerDescriptor(); const meta = useMeta(descriptor); + const videoInterface = useInterface(descriptor); const controls = useControls(descriptor); if (meta?.meta.type !== MWMediaType.SERIES) return null; @@ -184,17 +187,21 @@ export function SeriesSelectionAction(props: Props) { return (
- + controls.openPopout("episodes")} + /> + + {/* - - controls.openPopout("episodes")} - /> + */}
); diff --git a/src/video/components/parts/VideoPlayerIconButton.tsx b/src/video/components/parts/VideoPlayerIconButton.tsx index 4db685da..1d366c6a 100644 --- a/src/video/components/parts/VideoPlayerIconButton.tsx +++ b/src/video/components/parts/VideoPlayerIconButton.tsx @@ -7,6 +7,8 @@ export interface VideoPlayerIconButtonProps { text?: string; className?: string; iconSize?: string; + active?: boolean; + wide?: boolean; } export function VideoPlayerIconButton(props: VideoPlayerIconButtonProps) { @@ -17,7 +19,13 @@ export function VideoPlayerIconButton(props: VideoPlayerIconButtonProps) { onClick={props.onClick} className="group pointer-events-auto p-2 text-white transition-transform duration-100 active:scale-110" > -
+
{props.text ? {props.text} : null}
diff --git a/src/video/components/parts/VideoPopout.tsx b/src/video/components/parts/VideoPopout.tsx index f59fa969..301d0366 100644 --- a/src/video/components/parts/VideoPopout.tsx +++ b/src/video/components/parts/VideoPopout.tsx @@ -9,8 +9,6 @@ interface Props { 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); diff --git a/src/video/components/popouts/EpisodeSelectionPopout.tsx b/src/video/components/popouts/EpisodeSelectionPopout.tsx new file mode 100644 index 00000000..63cc3021 --- /dev/null +++ b/src/video/components/popouts/EpisodeSelectionPopout.tsx @@ -0,0 +1,169 @@ +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 { useControls } from "@/video/state/logic/controls"; + +function PopupSection(props: { + children?: React.ReactNode; + className?: string; +}) { + return ( +
+ {props.children} +
+ ); +} + +export function EpisodeSelectionPopout() { + 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"} +
+ )} +
+ + ); +} diff --git a/src/video/components/popouts/PopoutAnchor.tsx b/src/video/components/popouts/PopoutAnchor.tsx new file mode 100644 index 00000000..4443501a --- /dev/null +++ b/src/video/components/popouts/PopoutAnchor.tsx @@ -0,0 +1,41 @@ +import { getPlayerState } from "@/video/state/cache"; +import { useVideoPlayerDescriptor } from "@/video/state/hooks"; +import { updateInterface } from "@/video/state/logic/interface"; +import { ReactNode, useEffect, useRef } from "react"; + +interface Props { + for: string; + children?: ReactNode; +} + +export function PopoutAnchor(props: Props) { + const ref = useRef(null); + const descriptor = useVideoPlayerDescriptor(); + + useEffect(() => { + if (!ref.current) return; + const state = getPlayerState(descriptor); + + if (state.interface.popout !== props.for) return; + + let handle = -1; + function render() { + if (ref.current) { + const current = JSON.stringify(state.interface.popoutBounds); + const newer = ref.current.getBoundingClientRect(); + if (current !== JSON.stringify(newer)) { + state.interface.popoutBounds = newer; + updateInterface(descriptor, state); + } + } + handle = window.requestAnimationFrame(render); + } + + handle = window.requestAnimationFrame(render); + return () => { + window.cancelAnimationFrame(handle); + }; + }, [descriptor, props]); + + return
{props.children}
; +} diff --git a/src/video/components/popouts/PopoutProviderAction.tsx b/src/video/components/popouts/PopoutProviderAction.tsx new file mode 100644 index 00000000..b602fe71 --- /dev/null +++ b/src/video/components/popouts/PopoutProviderAction.tsx @@ -0,0 +1,70 @@ +import { EpisodeSelectionPopout } from "@/video/components/popouts/EpisodeSelectionPopout"; +import { useVideoPlayerDescriptor } from "@/video/state/hooks"; +import { useControls } from "@/video/state/logic/controls"; +import { useInterface } from "@/video/state/logic/interface"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +import "./Popouts.css"; + +function ShowPopout(props: { popoutId: string }) { + // only updates popout id when a new one is set, so transitions look good + const [popoutId, setPopoutId] = useState(props.popoutId); + useEffect(() => { + if (!props.popoutId) return; + setPopoutId(props.popoutId); + }, [props]); + + if (popoutId === "episodes") return ; + return null; +} + +// TODO use new design for popouts +// TODO improve anti offscreen math +// TODO in and out transition +// TODO attach router history to popout state, so you can use back button to remove popout +export function PopoutProviderAction() { + const descriptor = useVideoPlayerDescriptor(); + const videoInterface = useInterface(descriptor); + const controls = useControls(descriptor); + + const handleClick = useCallback(() => { + controls.closePopout(); + }, [controls]); + + const distanceFromRight = useMemo(() => { + return videoInterface.popoutBounds + ? `${Math.max( + window.innerWidth - + videoInterface.popoutBounds.right - + videoInterface.popoutBounds.width / 2, + 30 + )}px` + : "30px"; + }, [videoInterface]); + const distanceFromBottom = useMemo(() => { + return videoInterface.popoutBounds + ? `${Math.max( + videoInterface.popoutBounds.bottom - + videoInterface.popoutBounds.top + + videoInterface.popoutBounds.height + )}px` + : "30px"; + }, [videoInterface]); + + if (!videoInterface.popout) return null; + + return ( +
+
+
+ +
+
+ ); +} diff --git a/src/video/components/popouts/Popouts.css b/src/video/components/popouts/Popouts.css new file mode 100644 index 00000000..143930dc --- /dev/null +++ b/src/video/components/popouts/Popouts.css @@ -0,0 +1,15 @@ +.popout-wrapper ::-webkit-scrollbar-track { + background-color: transparent; +} + +.popout-wrapper ::-webkit-scrollbar-thumb { + background-color: theme("colors.denim-500"); + border: 5px solid transparent; + border-left: 0; + background-clip: content-box; +} + +.popout-wrapper ::-webkit-scrollbar { + /* For some reason the styles don't get applied without the width */ + width: 13px; +} diff --git a/src/video/state/logic/controls.ts b/src/video/state/logic/controls.ts index 01b03599..0251d3b2 100644 --- a/src/video/state/logic/controls.ts +++ b/src/video/state/logic/controls.ts @@ -59,6 +59,7 @@ export function useControls( }, closePopout() { state.interface.popout = null; + state.interface.popoutBounds = null; updateInterface(descriptor, state); }, setFocused(focused) { diff --git a/src/video/state/logic/interface.ts b/src/video/state/logic/interface.ts index 13c99d02..2f22823f 100644 --- a/src/video/state/logic/interface.ts +++ b/src/video/state/logic/interface.ts @@ -8,6 +8,7 @@ export type VideoInterfaceEvent = { leftControlHovering: boolean; isFocused: boolean; isFullscreen: boolean; + popoutBounds: null | DOMRect; }; function getInterfaceFromState(state: VideoPlayerState): VideoInterfaceEvent { @@ -16,6 +17,7 @@ function getInterfaceFromState(state: VideoPlayerState): VideoInterfaceEvent { leftControlHovering: state.interface.leftControlHovering, isFocused: state.interface.isFocused, isFullscreen: state.interface.isFullscreen, + popoutBounds: state.interface.popoutBounds, }; } diff --git a/src/video/state/types.ts b/src/video/state/types.ts index e56886d6..01238616 100644 --- a/src/video/state/types.ts +++ b/src/video/state/types.ts @@ -23,6 +23,7 @@ export type VideoPlayerState = { popout: string | null; // id of current popout (eg source select, episode select) isFocused: boolean; // is the video player the users focus? (shortcuts only works when its focused) leftControlHovering: boolean; // is the cursor hovered over the left side of player controls + popoutBounds: null | DOMRect; // bounding box of current popout }; // state related to the playing state of the media