Move episodes over into new popout

Co-authored-by: mrjvs <mistrjvs@gmail.com>
This commit is contained in:
Jip Fr 2023-02-28 21:32:03 +01:00
parent cc51559c29
commit b9a9db348b
10 changed files with 164 additions and 278 deletions

View File

@ -5,7 +5,7 @@ export function createFloatingAnchorEvent(id: string): string {
} }
interface Props { interface Props {
for: string; id: string;
children?: ReactNode; children?: ReactNode;
} }
@ -26,9 +26,9 @@ export function FloatingAnchor(props: Props) {
const newerStr = JSON.stringify(newer); const newerStr = JSON.stringify(newer);
if (current !== newerStr) { if (current !== newerStr) {
old.current = newerStr; old.current = newerStr;
const evtStr = createFloatingAnchorEvent(props.for); const evtStr = createFloatingAnchorEvent(props.id);
(window as any)[evtStr] = newer; (window as any)[evtStr] = newer;
const evObj = new CustomEvent(createFloatingAnchorEvent(props.for), { const evObj = new CustomEvent(createFloatingAnchorEvent(props.id), {
detail: newer, detail: newer,
}); });
document.dispatchEvent(evObj); document.dispatchEvent(evObj);

View File

@ -7,7 +7,7 @@ import { ReactNode, useCallback, useEffect, useRef } from "react";
interface FloatingCardProps { interface FloatingCardProps {
children?: ReactNode; children?: ReactNode;
onClose?: () => void; onClose?: () => void;
id: string; for: string;
} }
interface RootFloatingCardProps extends FloatingCardProps { interface RootFloatingCardProps extends FloatingCardProps {
@ -27,7 +27,7 @@ function CardBase(props: { children: ReactNode }) {
const getNewHeight = useCallback(() => { const getNewHeight = useCallback(() => {
if (!ref.current) return; if (!ref.current) return;
const children = ref.current.querySelectorAll( const children = ref.current.querySelectorAll(
":scope > *[data-floating-page='true']" ":scope *[data-floating-page='true']"
); );
if (children.length === 0) { if (children.length === 0) {
height.start(0); height.start(0);
@ -54,7 +54,7 @@ function CardBase(props: { children: ReactNode }) {
observer.observe(ref.current, { observer.observe(ref.current, {
attributes: false, attributes: false,
childList: true, childList: true,
subtree: false, subtree: true,
}); });
return () => { return () => {
observer.disconnect(); observer.disconnect();
@ -90,12 +90,17 @@ export function FloatingCard(props: RootFloatingCardProps) {
); );
return ( return (
<FloatingCardAnchorPosition id={props.id} className={props.className}> <FloatingCardAnchorPosition id={props.for} className={props.className}>
{content} {content}
</FloatingCardAnchorPosition> </FloatingCardAnchorPosition>
); );
} }
export function PopoutFloatingCard(props: FloatingCardProps) { export function PopoutFloatingCard(props: FloatingCardProps) {
return <FloatingCard className="rounded-md bg-ash-400 p-2" {...props} />; return (
<FloatingCard
className="overflow-hidden rounded-md bg-ash-300"
{...props}
/>
);
} }

View File

@ -36,7 +36,7 @@ export function FloatingContainer(props: Props) {
return createPortal( return createPortal(
<Transition show={props.show} animation="none"> <Transition show={props.show} animation="none">
<div className="pointer-events-auto fixed inset-0"> <div className="popout-wrapper pointer-events-auto fixed inset-0 select-none">
<Transition animation="fade" isChild> <Transition animation="fade" isChild>
<div <div
onClick={click} onClick={click}

View File

@ -6,28 +6,23 @@ interface Props {
children?: ReactNode; children?: ReactNode;
show?: boolean; show?: boolean;
className?: string; className?: string;
height: number; height?: number;
width: number; width?: number;
} }
export function FloatingView(props: Props) { export function FloatingView(props: Props) {
const { isMobile } = useIsMobile(); const { isMobile } = useIsMobile();
if (!props.show) return null; const width = !isMobile ? `${props.width}px` : "100%";
return (
<div
className={[props.className ?? "", "absolute"].join(" ")}
data-floating-page="true"
style={{
height: `${props.height}px`,
width: !isMobile ? `${props.width}px` : "100%",
}}
>
{props.children}
</div>
);
return ( return (
<Transition animation="slide-up" show={props.show}> <Transition animation="slide-up" show={props.show}>
<div data-floating-page="true" className={props.className}> <div
className={[props.className ?? "", "absolute left-0 top-0"].join(" ")}
data-floating-page="true"
style={{
height: props.height ? `${props.height}px` : undefined,
width: props.width ? width : undefined,
}}
>
{props.children} {props.children}
</div> </div>
</Transition> </Transition>

View File

@ -10,7 +10,6 @@ interface MobilePositionProps {
export function FloatingCardMobilePosition(props: MobilePositionProps) { export function FloatingCardMobilePosition(props: MobilePositionProps) {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const height = 500;
const closing = useRef<boolean>(false); const closing = useRef<boolean>(false);
const [cardRect, setCardRect] = useState<DOMRect | null>(null); const [cardRect, setCardRect] = useState<DOMRect | null>(null);
const [{ y }, api] = useSpring(() => ({ const [{ y }, api] = useSpring(() => ({
@ -24,6 +23,7 @@ export function FloatingCardMobilePosition(props: MobilePositionProps) {
const bind = useDrag( const bind = useDrag(
({ last, velocity: [, vy], direction: [, dy], movement: [, my] }) => { ({ last, velocity: [, vy], direction: [, dy], movement: [, my] }) => {
if (closing.current) return; if (closing.current) return;
const height = cardRect?.height ?? 0;
if (last) { if (last) {
// if past half height downwards // if past half height downwards
// OR Y velocity is past 0.5 AND going down AND 20 pixels below start position // OR Y velocity is past 0.5 AND going down AND 20 pixels below start position
@ -84,7 +84,7 @@ export function FloatingCardMobilePosition(props: MobilePositionProps) {
}} }}
{...bind()} {...bind()}
> >
<div className="mx-auto my-2 mb-4 h-1 w-12 rounded-full bg-ash-500 bg-opacity-30" /> <div className="mx-auto my-2 mb-2 h-1 w-12 rounded-full bg-ash-500 bg-opacity-30" />
{props.children} {props.children}
<div className="h-[200px]" /> <div className="h-[200px]" />
</animated.div> </animated.div>

View File

@ -4,9 +4,9 @@ import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useMeta } from "@/video/state/logic/meta"; import { useMeta } from "@/video/state/logic/meta";
import { VideoPlayerIconButton } from "@/video/components/parts/VideoPlayerIconButton"; import { VideoPlayerIconButton } from "@/video/components/parts/VideoPlayerIconButton";
import { useControls } from "@/video/state/logic/controls"; import { useControls } from "@/video/state/logic/controls";
import { PopoutAnchor } from "@/video/components/popouts/PopoutAnchor";
import { useInterface } from "@/video/state/logic/interface"; import { useInterface } from "@/video/state/logic/interface";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { FloatingAnchor } from "@/components/popout/FloatingAnchor";
interface Props { interface Props {
className?: string; className?: string;
@ -24,7 +24,7 @@ export function SeriesSelectionAction(props: Props) {
return ( return (
<div className={props.className}> <div className={props.className}>
<div className="relative"> <div className="relative">
<PopoutAnchor for="episodes"> <FloatingAnchor id="episodes">
<VideoPlayerIconButton <VideoPlayerIconButton
active={videoInterface.popout === "episodes"} active={videoInterface.popout === "episodes"}
icon={Icons.EPISODES} icon={Icons.EPISODES}
@ -32,7 +32,7 @@ export function SeriesSelectionAction(props: Props) {
wide wide
onClick={() => controls.openPopout("episodes")} onClick={() => controls.openPopout("episodes")}
/> />
</PopoutAnchor> </FloatingAnchor>
</div> </div>
</div> </div>
); );

View File

@ -12,6 +12,7 @@ import { useMeta } from "@/video/state/logic/meta";
import { useControls } from "@/video/state/logic/controls"; import { useControls } from "@/video/state/logic/controls";
import { useWatchedContext } from "@/state/watched"; import { useWatchedContext } from "@/state/watched";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { FloatingView } from "@/components/popout/FloatingView";
import { PopoutListEntry, PopoutSection } from "./PopoutUtils"; import { PopoutListEntry, PopoutSection } from "./PopoutUtils";
export function EpisodeSelectionPopout() { export function EpisodeSelectionPopout() {
@ -99,110 +100,112 @@ export function EpisodeSelectionPopout() {
}, [isPickingSeason]); }, [isPickingSeason]);
return ( return (
<> <FloatingView show height={300} width={500}>
<PopoutSection className="bg-ash-100 font-bold text-white"> <div className="grid h-full grid-rows-[auto,minmax(0,1fr)]">
<div className="relative flex items-center"> <PopoutSection className="bg-ash-100 font-bold text-white">
<button <div className="relative flex items-center">
className={[ <button
"-m-1.5 rounded-lg p-1.5 transition-opacity duration-100 hover:bg-ash-200", className={[
isPickingSeason ? "pointer-events-none opacity-0" : "opacity-1", "-m-1.5 rounded-lg p-1.5 transition-opacity duration-100 hover:bg-ash-200",
].join(" ")} isPickingSeason ? "pointer-events-none opacity-0" : "opacity-1",
onClick={toggleIsPickingSeason} ].join(" ")}
type="button" onClick={toggleIsPickingSeason}
> type="button"
<Icon icon={Icons.CHEVRON_LEFT} /> >
</button> <Icon icon={Icons.CHEVRON_LEFT} />
<span </button>
className={[ <span
titlePositionClass, className={[
!isPickingSeason ? "opacity-1" : "opacity-0", titlePositionClass,
].join(" ")} !isPickingSeason ? "opacity-1" : "opacity-0",
> ].join(" ")}
{currentSeasonInfo?.title || ""} >
</span> {currentSeasonInfo?.title || ""}
<span </span>
className={[ <span
titlePositionClass, className={[
isPickingSeason ? "opacity-1" : "opacity-0", titlePositionClass,
].join(" ")} isPickingSeason ? "opacity-1" : "opacity-0",
> ].join(" ")}
{t("videoPlayer.popouts.seasons")} >
</span> {t("videoPlayer.popouts.seasons")}
</div> </span>
</PopoutSection> </div>
<div className="relative grid h-full grid-rows-[minmax(1px,1fr)]">
<PopoutSection
className={[
"absolute inset-0 z-30 overflow-y-auto border-ash-400 bg-ash-100 transition-[max-height,padding] duration-200",
isPickingSeason
? "max-h-full border-t"
: "max-h-0 overflow-hidden py-0",
].join(" ")}
>
{currentSeasonInfo
? meta?.seasons?.map?.((season) => (
<PopoutListEntry
key={season.id}
active={meta?.episode?.seasonId === season.id}
onClick={() => setSeason(season.id)}
isOnDarkBackground
>
{season.title}
</PopoutListEntry>
))
: "No season"}
</PopoutSection> </PopoutSection>
<PopoutSection className="relative h-full overflow-y-auto"> <div className="relative grid h-full grid-rows-[minmax(1px,1fr)]">
{loading ? ( <PopoutSection
<div className="flex h-full w-full items-center justify-center"> className={[
<Loading /> "absolute inset-0 z-30 overflow-y-auto border-ash-400 bg-ash-100 transition-[max-height,padding] duration-200",
</div> isPickingSeason
) : error ? ( ? "max-h-full border-t"
<div className="flex h-full w-full items-center justify-center"> : "max-h-0 overflow-hidden py-0",
<div className="flex flex-col flex-wrap items-center text-slate-400"> ].join(" ")}
<IconPatch >
icon={Icons.EYE_SLASH} {currentSeasonInfo
className="text-xl text-bink-600" ? meta?.seasons?.map?.((season) => (
/> <PopoutListEntry
<p className="mt-6 w-full text-center"> key={season.id}
{t("videoPLayer.popouts.errors.loadingWentWrong", { active={meta?.episode?.seasonId === season.id}
seasonTitle: currentSeasonInfo?.title?.toLowerCase(), onClick={() => setSeason(season.id)}
})} isOnDarkBackground
</p> >
{season.title}
</PopoutListEntry>
))
: "No season"}
</PopoutSection>
<PopoutSection className="relative h-full overflow-y-auto">
{loading ? (
<div className="flex h-full w-full items-center justify-center">
<Loading />
</div> </div>
</div> ) : error ? (
) : ( <div className="flex h-full w-full items-center justify-center">
<div> <div className="flex flex-col flex-wrap items-center text-slate-400">
{currentSeasonEpisodes && currentSeasonInfo <IconPatch
? currentSeasonEpisodes.map((e) => ( icon={Icons.EYE_SLASH}
<PopoutListEntry className="text-xl text-bink-600"
key={e.id} />
active={e.id === meta?.episode?.episodeId} <p className="mt-6 w-full text-center">
onClick={() => { {t("videoPLayer.popouts.errors.loadingWentWrong", {
if (e.id === meta?.episode?.episodeId) seasonTitle: currentSeasonInfo?.title?.toLowerCase(),
controls.closePopout(); })}
else setCurrent(currentSeasonInfo.id, e.id); </p>
}} </div>
percentageCompleted={ </div>
watched.items.find( ) : (
(item) => <div>
item.item?.series?.seasonId === {currentSeasonEpisodes && currentSeasonInfo
currentSeasonInfo.id && ? currentSeasonEpisodes.map((e) => (
item.item?.series?.episodeId === e.id <PopoutListEntry
)?.percentage key={e.id}
} active={e.id === meta?.episode?.episodeId}
> onClick={() => {
{t("videoPlayer.popouts.episode", { if (e.id === meta?.episode?.episodeId)
index: e.number, controls.closePopout();
title: e.title, else setCurrent(currentSeasonInfo.id, e.id);
})} }}
</PopoutListEntry> percentageCompleted={
)) watched.items.find(
: "No episodes"} (item) =>
</div> item.item?.series?.seasonId ===
)} currentSeasonInfo.id &&
</PopoutSection> item.item?.series?.episodeId === e.id
)?.percentage
}
>
{t("videoPlayer.popouts.episode", {
index: e.number,
title: e.title,
})}
</PopoutListEntry>
))
: "No episodes"}
</div>
)}
</PopoutSection>
</div>
</div> </div>
</> </FloatingView>
); );
} }

View File

@ -1,6 +1,3 @@
import { useDrag } from "@use-gesture/react";
import { a, useSpring, config, easings } from "@react-spring/web";
import { Transition } from "@/components/Transition";
import { useSyncPopouts } from "@/video/components/hooks/useSyncPopouts"; import { useSyncPopouts } from "@/video/components/hooks/useSyncPopouts";
import { EpisodeSelectionPopout } from "@/video/components/popouts/EpisodeSelectionPopout"; import { EpisodeSelectionPopout } from "@/video/components/popouts/EpisodeSelectionPopout";
import { SourceSelectionPopout } from "@/video/components/popouts/SourceSelectionPopout"; import { SourceSelectionPopout } from "@/video/components/popouts/SourceSelectionPopout";
@ -8,130 +5,35 @@ import { CaptionSelectionPopout } from "@/video/components/popouts/CaptionSelect
import { SettingsPopout } from "@/video/components/popouts/SettingsPopout"; import { SettingsPopout } from "@/video/components/popouts/SettingsPopout";
import { useVideoPlayerDescriptor } from "@/video/state/hooks"; import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useControls } from "@/video/state/logic/controls"; import { useControls } from "@/video/state/logic/controls";
import { useIsMobile } from "@/hooks/useIsMobile"; import { useInterface } from "@/video/state/logic/interface";
import { import { useCallback } from "react";
useInterface, import { PopoutFloatingCard } from "@/components/popout/FloatingCard";
VideoInterfaceEvent, import { FloatingContainer } from "@/components/popout/FloatingContainer";
} from "@/video/state/logic/interface";
import { useCallback, useEffect, useRef, useState } from "react";
import "./Popouts.css"; import "./Popouts.css";
function ShowPopout(props: { popoutId: string | null }) { function ShowPopout(props: { popoutId: string | null; onClose: () => void }) {
// only updates popout id when a new one is set, so transitions look good const popoutMap = {
const [popoutId, setPopoutId] = useState<string | null>(props.popoutId); source: <SourceSelectionPopout />,
useEffect(() => { captions: <CaptionSelectionPopout />,
if (!props.popoutId) return; settings: <SettingsPopout />,
setPopoutId(props.popoutId); episodes: <EpisodeSelectionPopout />,
}, [props]); };
if (popoutId === "episodes") return <EpisodeSelectionPopout />;
if (popoutId === "source") return <SourceSelectionPopout />;
if (popoutId === "captions") return <CaptionSelectionPopout />;
if (popoutId === "settings") return <SettingsPopout />;
return (
<div className="flex w-full items-center justify-center p-10">
Unknown popout
</div>
);
}
function MobilePopoutContainer(props: {
videoInterface: VideoInterfaceEvent;
onClose: () => void;
}) {
const ref = useRef<HTMLDivElement>(null);
const height = 500;
const closing = useRef<boolean>(false);
const [{ y }, api] = useSpring(() => ({
y: 0,
onRest() {
if (!closing.current) return;
props.onClose();
},
}));
const bind = useDrag(
({ last, velocity: [, vy], direction: [, dy], movement: [, my] }) => {
if (closing.current) return;
if (last) {
if (my > height * 0.5 || (vy > 0.5 && dy > 0)) {
api.start({
y: height * 1.2,
immediate: false,
config: { ...config.wobbly, velocity: vy, clamp: true },
});
closing.current = true;
} else {
api.start({
y: 0,
immediate: false,
config: config.wobbly,
});
}
} else {
api.start({ y: my, immediate: true });
}
},
{
from: () => [0, y.get()],
filterTaps: true,
bounds: { top: 0 },
rubberband: true,
}
);
return ( return (
<a.div <>
ref={ref} {Object.entries(popoutMap).map(([id, el]) => (
className="absolute inset-x-0 -bottom-[200px] z-10 mx-auto grid h-[700px] max-w-[400px] touch-none grid-rows-[auto,minmax(0,1fr)] overflow-hidden rounded-t-lg bg-ash-200" <FloatingContainer
style={{ key={id}
y, show={props.popoutId === id}
}} onClose={props.onClose}
{...bind()} >
> <PopoutFloatingCard for={id} onClose={props.onClose}>
<div className="mx-auto mt-3 -mb-3 h-1 w-12 rounded-full bg-ash-500 bg-opacity-30" /> {el}
<ShowPopout popoutId={props.videoInterface.popout} /> </PopoutFloatingCard>
</a.div> </FloatingContainer>
); ))}
} </>
function DesktopPopoutContainer(props: {
videoInterface: VideoInterfaceEvent;
}) {
const ref = useRef<HTMLDivElement>(null);
const [right, setRight] = useState<number>(0);
const [bottom, setBottom] = useState<number>(0);
const [width, setWidth] = useState<number>(0);
const calculateAndSetCoords = useCallback((rect: DOMRect, w: number) => {
const buttonCenter = rect.left + rect.width / 2;
setBottom(rect ? rect.height + 30 : 30);
setRight(Math.max(window.innerWidth - buttonCenter - w / 2, 30));
}, []);
useEffect(() => {
if (!props.videoInterface.popoutBounds) return;
calculateAndSetCoords(props.videoInterface.popoutBounds, width);
}, [props.videoInterface.popoutBounds, calculateAndSetCoords, width]);
useEffect(() => {
const rect = ref.current?.getBoundingClientRect();
setWidth(rect?.width ?? 0);
}, []);
return (
<a.div
ref={ref}
className="absolute z-10 grid h-[500px] w-80 touch-none grid-rows-[auto,minmax(0,1fr)] overflow-hidden rounded-lg bg-ash-200"
style={{
right: `${right}px`,
bottom: `${bottom}px`,
}}
>
<ShowPopout popoutId={props.videoInterface.popout} />
</a.div>
); );
} }
@ -139,30 +41,11 @@ export function PopoutProviderAction() {
const descriptor = useVideoPlayerDescriptor(); const descriptor = useVideoPlayerDescriptor();
const videoInterface = useInterface(descriptor); const videoInterface = useInterface(descriptor);
const controls = useControls(descriptor); const controls = useControls(descriptor);
const { isMobile } = useIsMobile(false);
useSyncPopouts(descriptor); useSyncPopouts(descriptor);
const handleClick = useCallback(() => { const onClose = useCallback(() => {
controls.closePopout(); controls.closePopout();
}, [controls]); }, [controls]);
return ( return <ShowPopout popoutId={videoInterface.popout} onClose={onClose} />;
<Transition
show={!!videoInterface.popout}
animation="slide-up"
className="h-full"
>
<div className="popout-wrapper pointer-events-auto absolute inset-0">
<div onClick={handleClick} className="absolute inset-0" />
{isMobile ? (
<MobilePopoutContainer
videoInterface={videoInterface}
onClose={handleClick}
/>
) : (
<DesktopPopoutContainer videoInterface={videoInterface} />
)}
</div>
</Transition>
);
} }

View File

@ -143,7 +143,7 @@ export function PopoutListEntry(props: PopoutListEntryTypes) {
isOnDarkBackground={props.isOnDarkBackground} isOnDarkBackground={props.isOnDarkBackground}
active={props.active} active={props.active}
onClick={props.onClick} onClick={props.onClick}
noChevron={!props.loading && !props.errored} noChevron={props.loading || props.errored}
right={ right={
<> <>
{props.errored && ( {props.errored && (

View File

@ -32,7 +32,7 @@ export function TestView() {
return ( return (
<div className="relative h-[800px] w-full rounded border border-white"> <div className="relative h-[800px] w-full rounded border border-white">
<FloatingContainer show={show} onClose={() => setShow(false)}> <FloatingContainer show={show} onClose={() => setShow(false)}>
<PopoutFloatingCard id="test" onClose={() => setShow(false)}> <PopoutFloatingCard for="test" onClose={() => setShow(false)}>
<FloatingView <FloatingView
show={page === "main"} show={page === "main"}
height={400} height={400}
@ -58,7 +58,7 @@ export function TestView() {
left: `${left}px`, left: `${left}px`,
}} }}
> >
<FloatingAnchor for="test"> <FloatingAnchor id="test">
<div <div
className="h-8 w-8 bg-white" className="h-8 w-8 bg-white"
onClick={() => setShow((v) => !v)} onClick={() => setShow((v) => !v)}