12 changed files with 346 additions and 16 deletions
@ -0,0 +1,169 @@
@@ -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 ( |
||||
<div className={["p-4", props.className || ""].join(" ")}> |
||||
{props.children} |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
export function EpisodeSelectionPopout() { |
||||
const params = useParams<{ |
||||
media: string; |
||||
}>(); |
||||
const descriptor = useVideoPlayerDescriptor(); |
||||
const meta = useMeta(descriptor); |
||||
const controls = useControls(descriptor); |
||||
|
||||
const [isPickingSeason, setIsPickingSeason] = useState<boolean>(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 ( |
||||
<> |
||||
<PopupSection className="flex items-center space-x-3 border-b border-denim-500 font-bold text-white"> |
||||
Pick a season |
||||
</PopupSection> |
||||
<PopupSection className="overflow-y-auto"> |
||||
<div className="space-y-1"> |
||||
{currentSeasonInfo |
||||
? meta?.seasons?.map?.((season) => ( |
||||
<div |
||||
className="text-denim-800 -mx-2 flex items-center space-x-1 rounded p-2 text-white hover:bg-denim-600" |
||||
key={season.id} |
||||
onClick={() => setSeason(season.id)} |
||||
> |
||||
{season.title} |
||||
</div> |
||||
)) |
||||
: "No season"} |
||||
</div> |
||||
</PopupSection> |
||||
</> |
||||
); |
||||
|
||||
return ( |
||||
<> |
||||
<PopupSection className="flex items-center space-x-3 border-b border-denim-500 font-bold text-white"> |
||||
<button |
||||
className="-m-1.5 rounded p-1.5 hover:bg-denim-600" |
||||
onClick={toggleIsPickingSeason} |
||||
type="button" |
||||
> |
||||
<Icon icon={Icons.CHEVRON_LEFT} /> |
||||
</button> |
||||
<span>{currentSeasonInfo?.title || ""}</span> |
||||
</PopupSection> |
||||
<PopupSection className="h-full overflow-y-auto"> |
||||
{loading ? ( |
||||
<div className="flex h-full w-full items-center justify-center"> |
||||
<Loading /> |
||||
</div> |
||||
) : error ? ( |
||||
<div className="flex h-full w-full items-center justify-center"> |
||||
<div className="flex flex-col flex-wrap items-center text-slate-400"> |
||||
<IconPatch |
||||
icon={Icons.EYE_SLASH} |
||||
className="text-xl text-bink-600" |
||||
/> |
||||
<p className="mt-6 w-full text-center"> |
||||
Something went wrong loading the episodes for{" "} |
||||
{currentSeasonInfo?.title?.toLowerCase()} |
||||
</p> |
||||
</div> |
||||
</div> |
||||
) : ( |
||||
<div className="space-y-1"> |
||||
{currentSeasonEpisodes && currentSeasonInfo |
||||
? currentSeasonEpisodes.map((e) => ( |
||||
<div |
||||
className={[ |
||||
"text-denim-800 -mx-2 flex items-center space-x-1 rounded p-2 text-white hover:bg-denim-600", |
||||
meta?.episode?.episodeId === e.id && |
||||
"outline outline-2 outline-denim-700", |
||||
].join(" ")} |
||||
onClick={() => setCurrent(currentSeasonInfo.id, e.id)} |
||||
key={e.id} |
||||
> |
||||
{e.number}. {e.title} |
||||
</div> |
||||
)) |
||||
: "No episodes"} |
||||
</div> |
||||
)} |
||||
</PopupSection> |
||||
</> |
||||
); |
||||
} |
@ -0,0 +1,41 @@
@@ -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<HTMLDivElement>(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 <div ref={ref}>{props.children}</div>; |
||||
} |
@ -0,0 +1,70 @@
@@ -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<string | null>(props.popoutId); |
||||
useEffect(() => { |
||||
if (!props.popoutId) return; |
||||
setPopoutId(props.popoutId); |
||||
}, [props]); |
||||
|
||||
if (popoutId === "episodes") return <EpisodeSelectionPopout />; |
||||
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 ( |
||||
<div className="popout-wrapper pointer-events-auto absolute inset-0"> |
||||
<div onClick={handleClick} className="absolute inset-0" /> |
||||
<div |
||||
className="grid-template-rows-[auto,minmax(0px,1fr)] absolute z-10 grid h-[500px] w-72 rounded-lg bg-denim-300" |
||||
style={{ |
||||
right: distanceFromRight, |
||||
bottom: distanceFromBottom, |
||||
}} |
||||
> |
||||
<ShowPopout popoutId={videoInterface.popout ?? ""} /> |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,15 @@
@@ -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; |
||||
} |
Loading…
Reference in new issue