33 changed files with 0 additions and 2225 deletions
@ -1,184 +0,0 @@ |
|||||||
import { DetailedMeta } from "@/backend/metadata/getmeta"; |
|
||||||
import { useIsMobile } from "@/hooks/useIsMobile"; |
|
||||||
import { useCallback, useRef, useState } from "react"; |
|
||||||
import { CSSTransition } from "react-transition-group"; |
|
||||||
import { AirplayControl } from "./controls/AirplayControl"; |
|
||||||
import { BackdropControl } from "./controls/BackdropControl"; |
|
||||||
import { ChromeCastControl } from "./controls/ChromeCastControl"; |
|
||||||
import { FullscreenControl } from "./controls/FullscreenControl"; |
|
||||||
import { LoadingControl } from "./controls/LoadingControl"; |
|
||||||
import { MiddlePauseControl } from "./controls/MiddlePauseControl"; |
|
||||||
import { MobileCenterControl } from "./controls/MobileCenterControl"; |
|
||||||
import { PageTitleControl } from "./controls/PageTitleControl"; |
|
||||||
import { PauseControl } from "./controls/PauseControl"; |
|
||||||
import { ProgressControl } from "./controls/ProgressControl"; |
|
||||||
import { QualityDisplayControl } from "./controls/QualityDisplayControl"; |
|
||||||
import { SeriesSelectionControl } from "./controls/SeriesSelectionControl"; |
|
||||||
import { ShowTitleControl } from "./controls/ShowTitleControl"; |
|
||||||
import { SkipTime } from "./controls/SkipTime"; |
|
||||||
import { SourceSelectionControl } from "./controls/SourceSelectionControl"; |
|
||||||
import { TimeControl } from "./controls/TimeControl"; |
|
||||||
import { VolumeControl } from "./controls/VolumeControl"; |
|
||||||
import { VideoPlayerError } from "./parts/VideoPlayerError"; |
|
||||||
import { VideoPlayerHeader } from "./parts/VideoPlayerHeader"; |
|
||||||
import { useVideoPlayerState } from "./VideoContext"; |
|
||||||
import { VideoPlayer, VideoPlayerProps } from "./VideoPlayer"; |
|
||||||
|
|
||||||
interface DecoratedVideoPlayerProps { |
|
||||||
media?: DetailedMeta; |
|
||||||
onGoBack?: () => void; |
|
||||||
} |
|
||||||
|
|
||||||
function LeftSideControls() { |
|
||||||
const { videoState } = useVideoPlayerState(); |
|
||||||
|
|
||||||
const handleMouseEnter = useCallback(() => { |
|
||||||
videoState.setLeftControlsHover(true); |
|
||||||
}, [videoState]); |
|
||||||
const handleMouseLeave = useCallback(() => { |
|
||||||
videoState.setLeftControlsHover(false); |
|
||||||
}, [videoState]); |
|
||||||
|
|
||||||
return ( |
|
||||||
<> |
|
||||||
<div |
|
||||||
className="flex items-center px-2" |
|
||||||
onMouseLeave={handleMouseLeave} |
|
||||||
onMouseEnter={handleMouseEnter} |
|
||||||
> |
|
||||||
<PauseControl /> |
|
||||||
<TimeControl /> |
|
||||||
<VolumeControl className="mr-2" /> |
|
||||||
<SkipTime /> |
|
||||||
</div> |
|
||||||
<ShowTitleControl /> |
|
||||||
</> |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
export function DecoratedVideoPlayer( |
|
||||||
props: VideoPlayerProps & DecoratedVideoPlayerProps |
|
||||||
) { |
|
||||||
const top = useRef<HTMLDivElement>(null); |
|
||||||
const center = useRef<HTMLDivElement>(null); |
|
||||||
const bottom = useRef<HTMLDivElement>(null); |
|
||||||
const [show, setShow] = useState(false); |
|
||||||
const { isMobile } = useIsMobile(); |
|
||||||
|
|
||||||
const onBackdropChange = useCallback( |
|
||||||
(showing: boolean) => { |
|
||||||
setShow(showing); |
|
||||||
}, |
|
||||||
[setShow] |
|
||||||
); |
|
||||||
|
|
||||||
return ( |
|
||||||
<VideoPlayer autoPlay={props.autoPlay}> |
|
||||||
<PageTitleControl media={props.media?.meta} /> |
|
||||||
<VideoPlayerError media={props.media?.meta} onGoBack={props.onGoBack}> |
|
||||||
<BackdropControl onBackdropChange={onBackdropChange}> |
|
||||||
<div className="absolute inset-0 flex items-center justify-center"> |
|
||||||
<LoadingControl /> |
|
||||||
</div> |
|
||||||
<div className="absolute inset-0 flex items-center justify-center"> |
|
||||||
<MiddlePauseControl /> |
|
||||||
</div> |
|
||||||
{isMobile ? ( |
|
||||||
<CSSTransition |
|
||||||
nodeRef={center} |
|
||||||
in={show} |
|
||||||
timeout={200} |
|
||||||
classNames={{ |
|
||||||
exit: "transition-[transform,opacity] duration-200 opacity-100", |
|
||||||
exitActive: "!opacity-0", |
|
||||||
exitDone: "hidden", |
|
||||||
enter: "transition-[transform,opacity] duration-200 opacity-0", |
|
||||||
enterActive: "!opacity-100", |
|
||||||
}} |
|
||||||
> |
|
||||||
<div |
|
||||||
ref={center} |
|
||||||
className="absolute inset-0 flex items-center justify-center" |
|
||||||
> |
|
||||||
<MobileCenterControl /> |
|
||||||
</div> |
|
||||||
</CSSTransition> |
|
||||||
) : ( |
|
||||||
"" |
|
||||||
)} |
|
||||||
<CSSTransition |
|
||||||
nodeRef={top} |
|
||||||
in={show} |
|
||||||
timeout={200} |
|
||||||
classNames={{ |
|
||||||
exit: "transition-[transform,opacity] translate-y-0 duration-200 opacity-100", |
|
||||||
exitActive: "!-translate-y-4 !opacity-0", |
|
||||||
exitDone: "hidden", |
|
||||||
enter: |
|
||||||
"transition-[transform,opacity] -translate-y-4 duration-200 opacity-0", |
|
||||||
enterActive: "!translate-y-0 !opacity-100", |
|
||||||
}} |
|
||||||
> |
|
||||||
<div |
|
||||||
ref={top} |
|
||||||
className="pointer-events-auto absolute inset-x-0 top-0 flex flex-col py-6 px-8 pb-2" |
|
||||||
> |
|
||||||
<VideoPlayerHeader |
|
||||||
media={props.media?.meta} |
|
||||||
onClick={props.onGoBack} |
|
||||||
isMobile={isMobile} |
|
||||||
/> |
|
||||||
</div> |
|
||||||
</CSSTransition> |
|
||||||
<CSSTransition |
|
||||||
nodeRef={bottom} |
|
||||||
in={show} |
|
||||||
timeout={200} |
|
||||||
classNames={{ |
|
||||||
exit: "transition-[transform,opacity] translate-y-0 duration-200 opacity-100", |
|
||||||
exitActive: "!translate-y-4 !opacity-0", |
|
||||||
exitDone: "hidden", |
|
||||||
enter: |
|
||||||
"transition-[transform,opacity] translate-y-4 duration-200 opacity-0", |
|
||||||
enterActive: "!translate-y-0 !opacity-100", |
|
||||||
}} |
|
||||||
> |
|
||||||
<div |
|
||||||
ref={bottom} |
|
||||||
className="pointer-events-auto absolute inset-x-0 bottom-0 flex flex-col px-4 pb-2 [margin-bottom:env(safe-area-inset-bottom)]" |
|
||||||
> |
|
||||||
<div className="flex w-full items-center space-x-3"> |
|
||||||
{isMobile && <SkipTime noDuration />} |
|
||||||
<ProgressControl /> |
|
||||||
</div> |
|
||||||
<div className="flex items-center"> |
|
||||||
{isMobile ? ( |
|
||||||
<div className="grid w-full grid-cols-[56px,1fr,56px] items-center"> |
|
||||||
<div /> |
|
||||||
<div className="flex items-center justify-center"> |
|
||||||
<SeriesSelectionControl /> |
|
||||||
<SourceSelectionControl media={props.media} /> |
|
||||||
</div> |
|
||||||
<FullscreenControl /> |
|
||||||
</div> |
|
||||||
) : ( |
|
||||||
<> |
|
||||||
<LeftSideControls /> |
|
||||||
<div className="flex-1" /> |
|
||||||
<QualityDisplayControl /> |
|
||||||
<SeriesSelectionControl /> |
|
||||||
<SourceSelectionControl media={props.media} /> |
|
||||||
<AirplayControl /> |
|
||||||
<ChromeCastControl /> |
|
||||||
<FullscreenControl /> |
|
||||||
</> |
|
||||||
)} |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
</CSSTransition> |
|
||||||
</BackdropControl> |
|
||||||
{props.children} |
|
||||||
</VideoPlayerError> |
|
||||||
</VideoPlayer> |
|
||||||
); |
|
||||||
} |
|
||||||
@ -1,98 +0,0 @@ |
|||||||
import { MWStreamQuality, MWStreamType } from "@/backend/helpers/streams"; |
|
||||||
import React, { |
|
||||||
createContext, |
|
||||||
MutableRefObject, |
|
||||||
useContext, |
|
||||||
useEffect, |
|
||||||
useReducer, |
|
||||||
} from "react"; |
|
||||||
import { |
|
||||||
initialPlayerState, |
|
||||||
PlayerContext, |
|
||||||
useVideoPlayer, |
|
||||||
} from "./hooks/useVideoPlayer"; |
|
||||||
|
|
||||||
interface VideoPlayerContextType { |
|
||||||
source: string | null; |
|
||||||
sourceType: MWStreamType; |
|
||||||
quality: MWStreamQuality; |
|
||||||
state: PlayerContext; |
|
||||||
} |
|
||||||
const initial: VideoPlayerContextType = { |
|
||||||
source: null, |
|
||||||
sourceType: MWStreamType.MP4, |
|
||||||
quality: MWStreamQuality.QUNKNOWN, |
|
||||||
state: initialPlayerState, |
|
||||||
}; |
|
||||||
|
|
||||||
type VideoPlayerContextAction = |
|
||||||
| { |
|
||||||
type: "SET_SOURCE"; |
|
||||||
url: string; |
|
||||||
sourceType: MWStreamType; |
|
||||||
quality: MWStreamQuality; |
|
||||||
} |
|
||||||
| { |
|
||||||
type: "UPDATE_PLAYER"; |
|
||||||
state: PlayerContext; |
|
||||||
}; |
|
||||||
|
|
||||||
function videoPlayerContextReducer( |
|
||||||
original: VideoPlayerContextType, |
|
||||||
action: VideoPlayerContextAction |
|
||||||
): VideoPlayerContextType { |
|
||||||
const video = { ...original }; |
|
||||||
if (action.type === "SET_SOURCE") { |
|
||||||
video.source = action.url; |
|
||||||
video.sourceType = action.sourceType; |
|
||||||
video.quality = action.quality; |
|
||||||
return video; |
|
||||||
} |
|
||||||
if (action.type === "UPDATE_PLAYER") { |
|
||||||
video.state = action.state; |
|
||||||
return video; |
|
||||||
} |
|
||||||
|
|
||||||
return original; |
|
||||||
} |
|
||||||
|
|
||||||
export const VideoPlayerContext = |
|
||||||
createContext<VideoPlayerContextType>(initial); |
|
||||||
export const VideoPlayerDispatchContext = createContext< |
|
||||||
React.Dispatch<VideoPlayerContextAction> |
|
||||||
>(null as any); |
|
||||||
|
|
||||||
export function VideoPlayerContextProvider(props: { |
|
||||||
children: React.ReactNode; |
|
||||||
player: MutableRefObject<HTMLVideoElement | null>; |
|
||||||
wrapper: MutableRefObject<HTMLDivElement | null>; |
|
||||||
}) { |
|
||||||
const { playerState } = useVideoPlayer(props.player, props.wrapper); |
|
||||||
const [videoData, dispatch] = useReducer<typeof videoPlayerContextReducer>( |
|
||||||
videoPlayerContextReducer, |
|
||||||
initial |
|
||||||
); |
|
||||||
|
|
||||||
useEffect(() => { |
|
||||||
dispatch({ |
|
||||||
type: "UPDATE_PLAYER", |
|
||||||
state: playerState, |
|
||||||
}); |
|
||||||
}, [playerState]); |
|
||||||
|
|
||||||
return ( |
|
||||||
<VideoPlayerContext.Provider value={videoData}> |
|
||||||
<VideoPlayerDispatchContext.Provider value={dispatch}> |
|
||||||
{props.children} |
|
||||||
</VideoPlayerDispatchContext.Provider> |
|
||||||
</VideoPlayerContext.Provider> |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
export function useVideoPlayerState() { |
|
||||||
const { state } = useContext(VideoPlayerContext); |
|
||||||
|
|
||||||
return { |
|
||||||
videoState: state, |
|
||||||
}; |
|
||||||
} |
|
||||||
@ -1,69 +0,0 @@ |
|||||||
import { useGoBack } from "@/hooks/useGoBack"; |
|
||||||
import { useVolumeControl } from "@/hooks/useVolumeToggle"; |
|
||||||
import { forwardRef, useContext, useEffect, useRef } from "react"; |
|
||||||
import { VideoErrorBoundary } from "./parts/VideoErrorBoundary"; |
|
||||||
import { |
|
||||||
useVideoPlayerState, |
|
||||||
VideoPlayerContext, |
|
||||||
VideoPlayerContextProvider, |
|
||||||
} from "./VideoContext"; |
|
||||||
|
|
||||||
export interface VideoPlayerProps { |
|
||||||
autoPlay?: boolean; |
|
||||||
children?: React.ReactNode; |
|
||||||
} |
|
||||||
|
|
||||||
const VideoPlayerInternals = forwardRef< |
|
||||||
HTMLVideoElement, |
|
||||||
{ autoPlay: boolean } |
|
||||||
>((props, ref) => { |
|
||||||
const video = useContext(VideoPlayerContext); |
|
||||||
const didInitialize = useRef<{ source: string | null } | null>(null); |
|
||||||
const { videoState } = useVideoPlayerState(); |
|
||||||
const { toggleVolume } = useVolumeControl(); |
|
||||||
|
|
||||||
useEffect(() => { |
|
||||||
const value = { source: video.source }; |
|
||||||
const hasChanged = value.source !== didInitialize.current?.source; |
|
||||||
if (!hasChanged) return; |
|
||||||
if (!video.state.hasInitialized || !video.source) return; |
|
||||||
video.state.initPlayer(video.source, video.sourceType); |
|
||||||
didInitialize.current = value; |
|
||||||
}, [didInitialize, video]); |
|
||||||
|
|
||||||
// muted attribute is required for safari, as they cant change the volume itself
|
|
||||||
return ( |
|
||||||
<video |
|
||||||
ref={ref} |
|
||||||
autoPlay={props.autoPlay} |
|
||||||
muted={video.state.volume === 0} |
|
||||||
playsInline |
|
||||||
className="h-full w-full" |
|
||||||
/> |
|
||||||
); |
|
||||||
}); |
|
||||||
|
|
||||||
export function VideoPlayer(props: VideoPlayerProps) { |
|
||||||
const playerRef = useRef<HTMLVideoElement | null>(null); |
|
||||||
const playerWrapperRef = useRef<HTMLDivElement | null>(null); |
|
||||||
const goBack = useGoBack(); |
|
||||||
|
|
||||||
// TODO move error boundary to only decorated, <VideoPlayer /> shouldn't have styling
|
|
||||||
|
|
||||||
return ( |
|
||||||
<VideoPlayerContextProvider player={playerRef} wrapper={playerWrapperRef}> |
|
||||||
<div |
|
||||||
className="is-video-player relative h-full w-full select-none overflow-hidden bg-black [border-left:env(safe-area-inset-left)_solid_transparent] [border-right:env(safe-area-inset-right)_solid_transparent]" |
|
||||||
ref={playerWrapperRef} |
|
||||||
> |
|
||||||
<VideoErrorBoundary onGoBack={goBack}> |
|
||||||
<VideoPlayerInternals |
|
||||||
autoPlay={props.autoPlay ?? false} |
|
||||||
ref={playerRef} |
|
||||||
/> |
|
||||||
<div className="absolute inset-0">{props.children}</div> |
|
||||||
</VideoErrorBoundary> |
|
||||||
</div> |
|
||||||
</VideoPlayerContextProvider> |
|
||||||
); |
|
||||||
} |
|
||||||
@ -1,26 +0,0 @@ |
|||||||
import { Icons } from "@/components/Icon"; |
|
||||||
import { useCallback } from "react"; |
|
||||||
import { VideoPlayerIconButton } from "../parts/VideoPlayerIconButton"; |
|
||||||
import { useVideoPlayerState } from "../VideoContext"; |
|
||||||
|
|
||||||
interface Props { |
|
||||||
className?: string; |
|
||||||
} |
|
||||||
|
|
||||||
export function AirplayControl(props: Props) { |
|
||||||
const { videoState } = useVideoPlayerState(); |
|
||||||
|
|
||||||
const handleClick = useCallback(() => { |
|
||||||
videoState.startAirplay(); |
|
||||||
}, [videoState]); |
|
||||||
|
|
||||||
if (!videoState.canAirplay) return null; |
|
||||||
|
|
||||||
return ( |
|
||||||
<VideoPlayerIconButton |
|
||||||
className={props.className} |
|
||||||
onClick={handleClick} |
|
||||||
icon={Icons.AIRPLAY} |
|
||||||
/> |
|
||||||
); |
|
||||||
} |
|
||||||
@ -1,89 +0,0 @@ |
|||||||
import React, { useCallback, useEffect, useRef, useState } from "react"; |
|
||||||
import { useVideoPlayerState } from "../VideoContext"; |
|
||||||
|
|
||||||
interface BackdropControlProps { |
|
||||||
children?: React.ReactNode; |
|
||||||
onBackdropChange?: (showing: boolean) => void; |
|
||||||
} |
|
||||||
|
|
||||||
export function BackdropControl(props: BackdropControlProps) { |
|
||||||
const { videoState } = useVideoPlayerState(); |
|
||||||
const [moved, setMoved] = useState(false); |
|
||||||
const timeout = useRef<ReturnType<typeof setTimeout> | null>(null); |
|
||||||
const clickareaRef = useRef<HTMLDivElement>(null); |
|
||||||
|
|
||||||
const handleMouseMove = useCallback(() => { |
|
||||||
if (!moved) setMoved(true); |
|
||||||
if (timeout.current) clearTimeout(timeout.current); |
|
||||||
timeout.current = setTimeout(() => { |
|
||||||
if (moved) setMoved(false); |
|
||||||
timeout.current = null; |
|
||||||
}, 3000); |
|
||||||
}, [setMoved, moved]); |
|
||||||
|
|
||||||
const handleMouseLeave = useCallback(() => { |
|
||||||
setMoved(false); |
|
||||||
}, [setMoved]); |
|
||||||
|
|
||||||
const handleClick = useCallback( |
|
||||||
(e: React.MouseEvent<HTMLDivElement>) => { |
|
||||||
if (!clickareaRef.current || clickareaRef.current !== e.target) return; |
|
||||||
|
|
||||||
if (videoState.popout !== null) return; |
|
||||||
|
|
||||||
if (videoState.isPlaying) videoState.pause(); |
|
||||||
else videoState.play(); |
|
||||||
}, |
|
||||||
[videoState, clickareaRef] |
|
||||||
); |
|
||||||
const handleDoubleClick = useCallback( |
|
||||||
(e: React.MouseEvent<HTMLDivElement>) => { |
|
||||||
if (!clickareaRef.current || clickareaRef.current !== e.target) return; |
|
||||||
|
|
||||||
if (!videoState.isFullscreen) videoState.enterFullscreen(); |
|
||||||
else videoState.exitFullscreen(); |
|
||||||
}, |
|
||||||
[videoState, clickareaRef] |
|
||||||
); |
|
||||||
|
|
||||||
const lastBackdropValue = useRef<boolean | null>(null); |
|
||||||
useEffect(() => { |
|
||||||
const currentValue = moved || videoState.isPaused; |
|
||||||
if (currentValue !== lastBackdropValue.current) { |
|
||||||
lastBackdropValue.current = currentValue; |
|
||||||
if (!currentValue) videoState.closePopout(); |
|
||||||
props.onBackdropChange?.(currentValue); |
|
||||||
} |
|
||||||
}, [videoState, moved, props]); |
|
||||||
const showUI = moved || videoState.isPaused; |
|
||||||
|
|
||||||
return ( |
|
||||||
<div |
|
||||||
className={`absolute inset-0 ${!showUI ? "cursor-none" : ""}`} |
|
||||||
onMouseMove={handleMouseMove} |
|
||||||
onMouseLeave={handleMouseLeave} |
|
||||||
ref={clickareaRef} |
|
||||||
onClick={handleClick} |
|
||||||
onDoubleClick={handleDoubleClick} |
|
||||||
> |
|
||||||
<div |
|
||||||
className={`pointer-events-none absolute inset-0 bg-black bg-opacity-20 transition-opacity duration-200 ${ |
|
||||||
!showUI ? "!opacity-0" : "" |
|
||||||
}`}
|
|
||||||
/> |
|
||||||
<div |
|
||||||
className={`pointer-events-none absolute inset-x-0 bottom-0 h-[20%] bg-gradient-to-t from-black to-transparent transition-opacity duration-200 ${ |
|
||||||
!showUI ? "!opacity-0" : "" |
|
||||||
}`}
|
|
||||||
/> |
|
||||||
<div |
|
||||||
className={`pointer-events-none absolute inset-x-0 top-0 h-[20%] bg-gradient-to-b from-black to-transparent transition-opacity duration-200 ${ |
|
||||||
!showUI ? "!opacity-0" : "" |
|
||||||
}`}
|
|
||||||
/> |
|
||||||
<div className="pointer-events-none absolute inset-0"> |
|
||||||
{props.children} |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
); |
|
||||||
} |
|
||||||
@ -1,15 +0,0 @@ |
|||||||
declare global { |
|
||||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
|
||||||
namespace JSX { |
|
||||||
interface IntrinsicElements { |
|
||||||
"google-cast-launcher": React.DetailedHTMLProps< |
|
||||||
React.HTMLAttributes<HTMLElement>, |
|
||||||
HTMLElement |
|
||||||
>; |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
export function ChromeCastControl() { |
|
||||||
return <google-cast-launcher />; |
|
||||||
} |
|
||||||
@ -1,28 +0,0 @@ |
|||||||
import { Icons } from "@/components/Icon"; |
|
||||||
import { canFullscreen } from "@/utils/detectFeatures"; |
|
||||||
import { useCallback } from "react"; |
|
||||||
import { VideoPlayerIconButton } from "../parts/VideoPlayerIconButton"; |
|
||||||
import { useVideoPlayerState } from "../VideoContext"; |
|
||||||
|
|
||||||
interface Props { |
|
||||||
className?: string; |
|
||||||
} |
|
||||||
|
|
||||||
export function FullscreenControl(props: Props) { |
|
||||||
const { videoState } = useVideoPlayerState(); |
|
||||||
|
|
||||||
const handleClick = useCallback(() => { |
|
||||||
if (videoState.isFullscreen) videoState.exitFullscreen(); |
|
||||||
else videoState.enterFullscreen(); |
|
||||||
}, [videoState]); |
|
||||||
|
|
||||||
if (!canFullscreen()) return null; |
|
||||||
|
|
||||||
return ( |
|
||||||
<VideoPlayerIconButton |
|
||||||
className={props.className} |
|
||||||
onClick={handleClick} |
|
||||||
icon={videoState.isFullscreen ? Icons.COMPRESS : Icons.EXPAND} |
|
||||||
/> |
|
||||||
); |
|
||||||
} |
|
||||||
@ -1,12 +0,0 @@ |
|||||||
import { Spinner } from "@/components/layout/Spinner"; |
|
||||||
import { useVideoPlayerState } from "../VideoContext"; |
|
||||||
|
|
||||||
export function LoadingControl() { |
|
||||||
const { videoState } = useVideoPlayerState(); |
|
||||||
|
|
||||||
const isLoading = videoState.isFirstLoading || videoState.isLoading; |
|
||||||
|
|
||||||
if (!isLoading) return null; |
|
||||||
|
|
||||||
return <Spinner />; |
|
||||||
} |
|
||||||
@ -1,28 +0,0 @@ |
|||||||
import { Icon, Icons } from "@/components/Icon"; |
|
||||||
import { useCallback } from "react"; |
|
||||||
import { useVideoPlayerState } from "../VideoContext"; |
|
||||||
|
|
||||||
export function MiddlePauseControl() { |
|
||||||
const { videoState } = useVideoPlayerState(); |
|
||||||
|
|
||||||
const handleClick = useCallback(() => { |
|
||||||
if (videoState?.isPlaying) videoState.pause(); |
|
||||||
else videoState.play(); |
|
||||||
}, [videoState]); |
|
||||||
|
|
||||||
if (videoState.hasPlayedOnce) return null; |
|
||||||
if (videoState.isPlaying) return null; |
|
||||||
if (videoState.isFirstLoading) return null; |
|
||||||
|
|
||||||
return ( |
|
||||||
<div |
|
||||||
onClick={handleClick} |
|
||||||
className="group pointer-events-auto flex h-16 w-16 items-center justify-center rounded-full bg-denim-400 text-white transition-[background-color,transform] hover:scale-125 hover:bg-denim-500 active:scale-100" |
|
||||||
> |
|
||||||
<Icon |
|
||||||
icon={Icons.PLAY} |
|
||||||
className="text-2xl transition-transform group-hover:scale-125" |
|
||||||
/> |
|
||||||
</div> |
|
||||||
); |
|
||||||
} |
|
||||||
@ -1,20 +0,0 @@ |
|||||||
import { useVideoPlayerState } from "../VideoContext"; |
|
||||||
import { PauseControl } from "./PauseControl"; |
|
||||||
import { SkipTimeBackward, SkipTimeForward } from "./TimeControl"; |
|
||||||
|
|
||||||
export function MobileCenterControl() { |
|
||||||
const { videoState } = useVideoPlayerState(); |
|
||||||
|
|
||||||
const isLoading = videoState.isFirstLoading || videoState.isLoading; |
|
||||||
|
|
||||||
return ( |
|
||||||
<div className="flex items-center space-x-8"> |
|
||||||
<SkipTimeBackward /> |
|
||||||
<PauseControl |
|
||||||
iconSize="text-5xl" |
|
||||||
className={isLoading ? "pointer-events-none opacity-0" : ""} |
|
||||||
/> |
|
||||||
<SkipTimeForward /> |
|
||||||
</div> |
|
||||||
); |
|
||||||
} |
|
||||||
@ -1,23 +0,0 @@ |
|||||||
import { MWMediaMeta } from "@/backend/metadata/types"; |
|
||||||
import { Helmet } from "react-helmet"; |
|
||||||
import { useCurrentSeriesEpisodeInfo } from "../hooks/useCurrentSeriesEpisodeInfo"; |
|
||||||
|
|
||||||
interface PageTitleControlProps { |
|
||||||
media?: MWMediaMeta; |
|
||||||
} |
|
||||||
|
|
||||||
export function PageTitleControl(props: PageTitleControlProps) { |
|
||||||
const { isSeries, humanizedEpisodeId } = useCurrentSeriesEpisodeInfo(); |
|
||||||
|
|
||||||
if (!props.media) return null; |
|
||||||
|
|
||||||
const title = isSeries |
|
||||||
? `${props.media.title} - ${humanizedEpisodeId}` |
|
||||||
: props.media.title; |
|
||||||
|
|
||||||
return ( |
|
||||||
<Helmet> |
|
||||||
<title>{title}</title> |
|
||||||
</Helmet> |
|
||||||
); |
|
||||||
} |
|
||||||
@ -1,30 +0,0 @@ |
|||||||
import { Icons } from "@/components/Icon"; |
|
||||||
import { useCallback } from "react"; |
|
||||||
import { VideoPlayerIconButton } from "../parts/VideoPlayerIconButton"; |
|
||||||
import { useVideoPlayerState } from "../VideoContext"; |
|
||||||
|
|
||||||
interface Props { |
|
||||||
className?: string; |
|
||||||
iconSize?: string; |
|
||||||
} |
|
||||||
|
|
||||||
export function PauseControl(props: Props) { |
|
||||||
const { videoState } = useVideoPlayerState(); |
|
||||||
|
|
||||||
const handleClick = useCallback(() => { |
|
||||||
if (videoState?.isPlaying) videoState.pause(); |
|
||||||
else videoState.play(); |
|
||||||
}, [videoState]); |
|
||||||
|
|
||||||
const icon = |
|
||||||
videoState.isPlaying || videoState.isSeeking ? Icons.PAUSE : Icons.PLAY; |
|
||||||
|
|
||||||
return ( |
|
||||||
<VideoPlayerIconButton |
|
||||||
iconSize={props.iconSize} |
|
||||||
className={props.className} |
|
||||||
icon={icon} |
|
||||||
onClick={handleClick} |
|
||||||
/> |
|
||||||
); |
|
||||||
} |
|
||||||
@ -1,77 +0,0 @@ |
|||||||
import { |
|
||||||
makePercentage, |
|
||||||
makePercentageString, |
|
||||||
useProgressBar, |
|
||||||
} from "@/hooks/useProgressBar"; |
|
||||||
import { useCallback, useEffect, useRef } from "react"; |
|
||||||
import { useVideoPlayerState } from "../VideoContext"; |
|
||||||
|
|
||||||
export function ProgressControl() { |
|
||||||
const { videoState } = useVideoPlayerState(); |
|
||||||
const ref = useRef<HTMLDivElement>(null); |
|
||||||
const dragRef = useRef<boolean>(false); |
|
||||||
|
|
||||||
const commitTime = useCallback( |
|
||||||
(percentage) => { |
|
||||||
videoState.setTime(percentage * videoState.duration); |
|
||||||
}, |
|
||||||
[videoState] |
|
||||||
); |
|
||||||
const { dragging, dragPercentage, dragMouseDown } = useProgressBar( |
|
||||||
ref, |
|
||||||
commitTime |
|
||||||
); |
|
||||||
|
|
||||||
// TODO make dragging update timer
|
|
||||||
useEffect(() => { |
|
||||||
if (dragRef.current === dragging) return; |
|
||||||
dragRef.current = dragging; |
|
||||||
videoState.setSeeking(dragging); |
|
||||||
}, [dragRef, dragging, videoState]); |
|
||||||
|
|
||||||
let watchProgress = makePercentageString( |
|
||||||
makePercentage((videoState.time / videoState.duration) * 100) |
|
||||||
); |
|
||||||
if (dragging) |
|
||||||
watchProgress = makePercentageString(makePercentage(dragPercentage)); |
|
||||||
|
|
||||||
const bufferProgress = makePercentageString( |
|
||||||
makePercentage((videoState.buffered / videoState.duration) * 100) |
|
||||||
); |
|
||||||
|
|
||||||
return ( |
|
||||||
<div className="group pointer-events-auto w-full cursor-pointer rounded-full px-2"> |
|
||||||
<div |
|
||||||
ref={ref} |
|
||||||
className="-my-3 flex h-8 items-center" |
|
||||||
onMouseDown={dragMouseDown} |
|
||||||
onTouchStart={dragMouseDown} |
|
||||||
> |
|
||||||
<div |
|
||||||
className={`relative h-1 flex-1 rounded-full bg-gray-500 bg-opacity-50 transition-[height] duration-100 group-hover:h-2 ${ |
|
||||||
dragging ? "!h-2" : "" |
|
||||||
}`}
|
|
||||||
> |
|
||||||
<div |
|
||||||
className="absolute inset-y-0 left-0 flex items-center justify-end rounded-full bg-gray-300 bg-opacity-20" |
|
||||||
style={{ |
|
||||||
width: bufferProgress, |
|
||||||
}} |
|
||||||
/> |
|
||||||
<div |
|
||||||
className="absolute inset-y-0 left-0 flex items-center justify-end rounded-full bg-bink-600" |
|
||||||
style={{ |
|
||||||
width: watchProgress, |
|
||||||
}} |
|
||||||
> |
|
||||||
<div |
|
||||||
className={`absolute h-1 w-1 translate-x-1/2 rounded-full bg-white opacity-0 transition-[transform,opacity] group-hover:scale-[400%] group-hover:opacity-100 ${ |
|
||||||
dragging ? "!scale-[400%] !opacity-100" : "" |
|
||||||
}`}
|
|
||||||
/> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
); |
|
||||||
} |
|
||||||
@ -1,41 +0,0 @@ |
|||||||
import { useEffect, useMemo, useRef } from "react"; |
|
||||||
import throttle from "lodash.throttle"; |
|
||||||
import { useVideoPlayerState } from "../VideoContext"; |
|
||||||
|
|
||||||
interface Props { |
|
||||||
startAt?: number; |
|
||||||
onProgress?: (time: number, duration: number) => void; |
|
||||||
} |
|
||||||
|
|
||||||
export function ProgressListenerControl(props: Props) { |
|
||||||
const { videoState } = useVideoPlayerState(); |
|
||||||
const didInitialize = useRef<true | null>(null); |
|
||||||
|
|
||||||
// time updates (throttled)
|
|
||||||
const updateTime = useMemo( |
|
||||||
() => throttle((a: number, b: number) => props.onProgress?.(a, b), 1000), |
|
||||||
[props] |
|
||||||
); |
|
||||||
useEffect(() => { |
|
||||||
if (!videoState.isPlaying) return; |
|
||||||
if (videoState.duration === 0 || videoState.time === 0) return; |
|
||||||
updateTime(videoState.time, videoState.duration); |
|
||||||
}, [videoState, updateTime]); |
|
||||||
useEffect(() => { |
|
||||||
return () => { |
|
||||||
updateTime.cancel(); |
|
||||||
}; |
|
||||||
}, [updateTime]); |
|
||||||
|
|
||||||
// initialize
|
|
||||||
useEffect(() => { |
|
||||||
if (didInitialize.current) return; |
|
||||||
if (!videoState.hasInitialized || Number.isNaN(videoState.duration)) return; |
|
||||||
if (props.startAt !== undefined) { |
|
||||||
videoState.setTime(props.startAt); |
|
||||||
} |
|
||||||
didInitialize.current = true; |
|
||||||
}, [didInitialize, props, videoState]); |
|
||||||
|
|
||||||
return null; |
|
||||||
} |
|
||||||
@ -1,14 +0,0 @@ |
|||||||
import { useContext } from "react"; |
|
||||||
import { VideoPlayerContext } from "../VideoContext"; |
|
||||||
|
|
||||||
export function QualityDisplayControl() { |
|
||||||
const videoPlayerContext = useContext(VideoPlayerContext); |
|
||||||
|
|
||||||
return ( |
|
||||||
<div className="rounded-md bg-denim-300 py-1 px-2 transition-colors"> |
|
||||||
<p className="text-center text-xs font-bold text-slate-300 transition-colors"> |
|
||||||
{videoPlayerContext.quality} |
|
||||||
</p> |
|
||||||
</div> |
|
||||||
); |
|
||||||
} |
|
||||||
@ -1,194 +0,0 @@ |
|||||||
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 { useVideoPlayerState } from "../VideoContext"; |
|
||||||
import { VideoPlayerIconButton } from "../parts/VideoPlayerIconButton"; |
|
||||||
import { VideoPopout } from "../parts/VideoPopout"; |
|
||||||
|
|
||||||
interface Props { |
|
||||||
className?: string; |
|
||||||
} |
|
||||||
|
|
||||||
function PopupSection(props: { |
|
||||||
children?: React.ReactNode; |
|
||||||
className?: string; |
|
||||||
}) { |
|
||||||
return ( |
|
||||||
<div className={["p-4", props.className || ""].join(" ")}> |
|
||||||
{props.children} |
|
||||||
</div> |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
function PopupEpisodeSelect() { |
|
||||||
const params = useParams<{ |
|
||||||
media: string; |
|
||||||
}>(); |
|
||||||
const { videoState } = useVideoPlayerState(); |
|
||||||
const [isPickingSeason, setIsPickingSeason] = useState<boolean>(false); |
|
||||||
const { current, seasons } = videoState.seasonData; |
|
||||||
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 ?? current?.seasonId; |
|
||||||
|
|
||||||
const setCurrent = useCallback( |
|
||||||
(seasonId: string, episodeId: string) => { |
|
||||||
videoState.setCurrentEpisode(seasonId, episodeId); |
|
||||||
}, |
|
||||||
[videoState] |
|
||||||
); |
|
||||||
|
|
||||||
const currentSeasonInfo = useMemo(() => { |
|
||||||
return seasons?.find((season) => season.id === currentSeasonId); |
|
||||||
}, [seasons, currentSeasonId]); |
|
||||||
|
|
||||||
const currentSeasonEpisodes = useMemo(() => { |
|
||||||
if (currentVisibleSeason?.season) { |
|
||||||
return currentVisibleSeason?.season?.episodes; |
|
||||||
} |
|
||||||
return videoState?.seasonData.seasons?.find?.( |
|
||||||
(season) => season && season.id === currentSeasonId |
|
||||||
)?.episodes; |
|
||||||
}, [videoState, 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 |
|
||||||
? videoState?.seasonData?.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="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", |
|
||||||
current?.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> |
|
||||||
</> |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
export function SeriesSelectionControl(props: Props) { |
|
||||||
const { videoState } = useVideoPlayerState(); |
|
||||||
|
|
||||||
if (!videoState.seasonData.isSeries) return null; |
|
||||||
|
|
||||||
return ( |
|
||||||
<div className={props.className}> |
|
||||||
<div className="relative"> |
|
||||||
<VideoPopout |
|
||||||
id="episodes" |
|
||||||
className="grid grid-rows-[auto,minmax(0,1fr)]" |
|
||||||
> |
|
||||||
<PopupEpisodeSelect /> |
|
||||||
</VideoPopout> |
|
||||||
<VideoPlayerIconButton |
|
||||||
icon={Icons.EPISODES} |
|
||||||
text="Episodes" |
|
||||||
onClick={() => videoState.openPopout("episodes")} |
|
||||||
/> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
); |
|
||||||
} |
|
||||||
@ -1,69 +0,0 @@ |
|||||||
import { |
|
||||||
MWSeasonMeta, |
|
||||||
MWSeasonWithEpisodeMeta, |
|
||||||
} from "@/backend/metadata/types"; |
|
||||||
import { useEffect, useRef } from "react"; |
|
||||||
import { PlayerContext } from "../hooks/useVideoPlayer"; |
|
||||||
import { useVideoPlayerState } from "../VideoContext"; |
|
||||||
|
|
||||||
interface ShowControlProps { |
|
||||||
series?: { |
|
||||||
episodeId: string; |
|
||||||
seasonId: string; |
|
||||||
}; |
|
||||||
seasons: MWSeasonMeta[]; |
|
||||||
seasonData: MWSeasonWithEpisodeMeta; |
|
||||||
onSelect?: (state: { episodeId?: string; seasonId?: string }) => void; |
|
||||||
} |
|
||||||
|
|
||||||
function setVideoShowState(videoState: PlayerContext, props: ShowControlProps) { |
|
||||||
const seasonsWithEpisodes = props.seasons.map((v) => { |
|
||||||
if (v.id === props.seasonData.id) |
|
||||||
return { |
|
||||||
...v, |
|
||||||
episodes: props.seasonData.episodes, |
|
||||||
}; |
|
||||||
return v; |
|
||||||
}); |
|
||||||
|
|
||||||
videoState.setShowData({ |
|
||||||
current: props.series, |
|
||||||
isSeries: !!props.series, |
|
||||||
seasons: seasonsWithEpisodes, |
|
||||||
}); |
|
||||||
} |
|
||||||
|
|
||||||
export function ShowControl(props: ShowControlProps) { |
|
||||||
const { videoState } = useVideoPlayerState(); |
|
||||||
const lastState = useRef<{ |
|
||||||
episodeId?: string; |
|
||||||
seasonId?: string; |
|
||||||
} | null>({ |
|
||||||
episodeId: props.series?.episodeId, |
|
||||||
seasonId: props.series?.seasonId, |
|
||||||
}); |
|
||||||
|
|
||||||
const hasInitialized = useRef(false); |
|
||||||
useEffect(() => { |
|
||||||
if (hasInitialized.current) return; |
|
||||||
if (!videoState.hasInitialized) return; |
|
||||||
setVideoShowState(videoState, props); |
|
||||||
hasInitialized.current = true; |
|
||||||
}, [props, videoState]); |
|
||||||
|
|
||||||
useEffect(() => { |
|
||||||
const currentState = { |
|
||||||
episodeId: videoState.seasonData.current?.episodeId, |
|
||||||
seasonId: videoState.seasonData.current?.seasonId, |
|
||||||
}; |
|
||||||
if ( |
|
||||||
currentState.episodeId !== lastState.current?.episodeId || |
|
||||||
currentState.seasonId !== lastState.current?.seasonId |
|
||||||
) { |
|
||||||
lastState.current = currentState; |
|
||||||
props.onSelect?.(currentState); |
|
||||||
} |
|
||||||
}, [videoState, props]); |
|
||||||
|
|
||||||
return null; |
|
||||||
} |
|
||||||
@ -1,15 +0,0 @@ |
|||||||
import { useCurrentSeriesEpisodeInfo } from "../hooks/useCurrentSeriesEpisodeInfo"; |
|
||||||
|
|
||||||
export function ShowTitleControl() { |
|
||||||
const { isSeries, currentEpisodeInfo, humanizedEpisodeId } = |
|
||||||
useCurrentSeriesEpisodeInfo(); |
|
||||||
|
|
||||||
if (!isSeries) return null; |
|
||||||
|
|
||||||
return ( |
|
||||||
<p className="ml-8 select-none space-x-2 text-white"> |
|
||||||
<span>{humanizedEpisodeId}</span> |
|
||||||
<span className="opacity-50">{currentEpisodeInfo?.title}</span> |
|
||||||
</p> |
|
||||||
); |
|
||||||
} |
|
||||||
@ -1,47 +0,0 @@ |
|||||||
import { useVideoPlayerState } from "../VideoContext"; |
|
||||||
|
|
||||||
function durationExceedsHour(secs: number): boolean { |
|
||||||
return secs > 60 * 60; |
|
||||||
} |
|
||||||
|
|
||||||
function formatSeconds(secs: number, showHours = false): string { |
|
||||||
if (Number.isNaN(secs)) { |
|
||||||
if (showHours) return "0:00:00"; |
|
||||||
return "0:00"; |
|
||||||
} |
|
||||||
|
|
||||||
let time = secs; |
|
||||||
const seconds = Math.floor(time % 60); |
|
||||||
|
|
||||||
time /= 60; |
|
||||||
const minutes = Math.floor(time % 60); |
|
||||||
|
|
||||||
time /= 60; |
|
||||||
const hours = Math.floor(time); |
|
||||||
|
|
||||||
const paddedSecs = seconds.toString().padStart(2, "0"); |
|
||||||
const paddedMins = minutes.toString().padStart(2, "0"); |
|
||||||
|
|
||||||
if (!showHours) return [paddedMins, paddedSecs].join(":"); |
|
||||||
return [hours, paddedMins, paddedSecs].join(":"); |
|
||||||
} |
|
||||||
|
|
||||||
interface Props { |
|
||||||
className?: string; |
|
||||||
noDuration?: boolean; |
|
||||||
} |
|
||||||
|
|
||||||
export function SkipTime(props: Props) { |
|
||||||
const { videoState } = useVideoPlayerState(); |
|
||||||
const hasHours = durationExceedsHour(videoState.duration); |
|
||||||
const time = formatSeconds(videoState.time, hasHours); |
|
||||||
const duration = formatSeconds(videoState.duration, hasHours); |
|
||||||
|
|
||||||
return ( |
|
||||||
<div className={props.className}> |
|
||||||
<p className="select-none text-white"> |
|
||||||
{time} {props.noDuration ? "" : `/ ${duration}`} |
|
||||||
</p> |
|
||||||
</div> |
|
||||||
); |
|
||||||
} |
|
||||||
@ -1,27 +0,0 @@ |
|||||||
import { MWStreamQuality, MWStreamType } from "@/backend/helpers/streams"; |
|
||||||
import { useContext, useEffect, useRef } from "react"; |
|
||||||
import { VideoPlayerDispatchContext } from "../VideoContext"; |
|
||||||
|
|
||||||
interface SourceControlProps { |
|
||||||
source: string; |
|
||||||
type: MWStreamType; |
|
||||||
quality: MWStreamQuality; |
|
||||||
} |
|
||||||
|
|
||||||
export function SourceControl(props: SourceControlProps) { |
|
||||||
const dispatch = useContext(VideoPlayerDispatchContext); |
|
||||||
const didInitialize = useRef(false); |
|
||||||
|
|
||||||
useEffect(() => { |
|
||||||
if (didInitialize.current) return; |
|
||||||
dispatch({ |
|
||||||
type: "SET_SOURCE", |
|
||||||
url: props.source, |
|
||||||
sourceType: props.type, |
|
||||||
quality: props.quality, |
|
||||||
}); |
|
||||||
didInitialize.current = true; |
|
||||||
}, [props, dispatch]); |
|
||||||
|
|
||||||
return null; |
|
||||||
} |
|
||||||
@ -1,185 +0,0 @@ |
|||||||
import { useParams } from "react-router-dom"; |
|
||||||
import { useCallback, useContext, useMemo, useState } from "react"; |
|
||||||
import { Icon, Icons } from "@/components/Icon"; |
|
||||||
import { getProviders } from "@/backend/helpers/register"; |
|
||||||
import { useLoading } from "@/hooks/useLoading"; |
|
||||||
import { DetailedMeta } from "@/backend/metadata/getmeta"; |
|
||||||
import { MWMediaType } from "@/backend/metadata/types"; |
|
||||||
import { MWProviderScrapeResult } from "@/backend/helpers/provider"; |
|
||||||
import { runProvider } from "@/backend/helpers/run"; |
|
||||||
import { IconPatch } from "@/components/buttons/IconPatch"; |
|
||||||
import { Loading } from "@/components/layout/Loading"; |
|
||||||
import { |
|
||||||
useVideoPlayerState, |
|
||||||
VideoPlayerDispatchContext, |
|
||||||
} from "../VideoContext"; |
|
||||||
import { VideoPlayerIconButton } from "../parts/VideoPlayerIconButton"; |
|
||||||
import { VideoPopout } from "../parts/VideoPopout"; |
|
||||||
|
|
||||||
interface Props { |
|
||||||
className?: string; |
|
||||||
media?: DetailedMeta; |
|
||||||
} |
|
||||||
|
|
||||||
function PopoutSourceSelect(props: { media: DetailedMeta }) { |
|
||||||
const dispatch = useContext(VideoPlayerDispatchContext); |
|
||||||
const providers = useMemo( |
|
||||||
() => getProviders().filter((v) => v.type.includes(props.media.meta.type)), |
|
||||||
[props] |
|
||||||
); |
|
||||||
const { episode, season } = useParams<{ episode: string; season: string }>(); |
|
||||||
const [selected, setSelected] = useState<string | null>(null); |
|
||||||
const selectedProvider = useMemo( |
|
||||||
() => providers.find((v) => v.id === selected), |
|
||||||
[selected, providers] |
|
||||||
); |
|
||||||
|
|
||||||
const [scrapeData, setScrapeData] = useState<MWProviderScrapeResult | null>( |
|
||||||
null |
|
||||||
); |
|
||||||
const [scrapeProvider, loadingProvider, errorProvider] = useLoading( |
|
||||||
async (providerId: string) => { |
|
||||||
const theProvider = providers.find((v) => v.id === providerId); |
|
||||||
if (!theProvider) throw new Error("Invalid provider"); |
|
||||||
return runProvider(theProvider, { |
|
||||||
media: props.media, |
|
||||||
progress: () => {}, |
|
||||||
type: props.media.meta.type, |
|
||||||
episode: (props.media.meta.type === MWMediaType.SERIES |
|
||||||
? episode |
|
||||||
: undefined) as any, |
|
||||||
season: (props.media.meta.type === MWMediaType.SERIES |
|
||||||
? season |
|
||||||
: undefined) as any, |
|
||||||
}); |
|
||||||
} |
|
||||||
); |
|
||||||
|
|
||||||
// TODO add embed support
|
|
||||||
// TODO restore startAt when changing source
|
|
||||||
// TODO auto choose when only one option
|
|
||||||
// TODO close when selecting item
|
|
||||||
// TODO show currently selected provider
|
|
||||||
// TODO clear error state when switching
|
|
||||||
// const [scrapeEmbed, embedLoading, embedError] = useLoading(
|
|
||||||
// async (embed: MWEmbed) => {
|
|
||||||
// if (!embed.type) throw new Error("Invalid embed type");
|
|
||||||
// const theScraper = getEmbedScraperByType(embed.type);
|
|
||||||
// if (!theScraper) throw new Error("Invalid scraper");
|
|
||||||
// return runEmbedScraper(theScraper, {
|
|
||||||
// progress: () => {},
|
|
||||||
// url: embed.url,
|
|
||||||
// });
|
|
||||||
// }
|
|
||||||
// );
|
|
||||||
|
|
||||||
const selectProvider = useCallback( |
|
||||||
(id: string) => { |
|
||||||
scrapeProvider(id).then((v) => { |
|
||||||
if (!v) throw new Error("No scrape result"); |
|
||||||
setScrapeData(v); |
|
||||||
}); |
|
||||||
setSelected(id); |
|
||||||
}, |
|
||||||
[setSelected, scrapeProvider] |
|
||||||
); |
|
||||||
|
|
||||||
if (!selectedProvider) |
|
||||||
return ( |
|
||||||
<> |
|
||||||
<div className="flex items-center space-x-3 border-b border-denim-500 p-4 font-bold text-white"> |
|
||||||
<span>Select video source</span> |
|
||||||
</div> |
|
||||||
<div className="overflow-y-auto p-4"> |
|
||||||
<div className="space-y-1"> |
|
||||||
{providers.map((e) => ( |
|
||||||
<div |
|
||||||
className="text-denim-800 -mx-2 flex items-center space-x-1 rounded p-2 text-white hover:bg-denim-600" |
|
||||||
onClick={() => selectProvider(e.id)} |
|
||||||
key={e.id} |
|
||||||
> |
|
||||||
{e.displayName} |
|
||||||
</div> |
|
||||||
))} |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
</> |
|
||||||
); |
|
||||||
|
|
||||||
return ( |
|
||||||
<> |
|
||||||
<div className="flex items-center space-x-3 border-b border-denim-500 p-4 font-bold text-white"> |
|
||||||
<button |
|
||||||
className="-m-1.5 rounded p-1.5 hover:bg-denim-600" |
|
||||||
onClick={() => setSelected(null)} |
|
||||||
type="button" |
|
||||||
> |
|
||||||
<Icon icon={Icons.CHEVRON_LEFT} /> |
|
||||||
</button> |
|
||||||
<span>{selectedProvider.displayName}</span> |
|
||||||
</div> |
|
||||||
<div className="overflow-y-auto p-4 text-white"> |
|
||||||
{loadingProvider ? ( |
|
||||||
<div className="flex h-full w-full items-center justify-center"> |
|
||||||
<Loading /> |
|
||||||
</div> |
|
||||||
) : errorProvider ? ( |
|
||||||
<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 streams. |
|
||||||
</p> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
) : scrapeData ? ( |
|
||||||
<div> |
|
||||||
{scrapeData.stream ? ( |
|
||||||
<div |
|
||||||
className="text-denim-800 -mx-2 flex items-center space-x-1 rounded p-2 text-white hover:bg-denim-600" |
|
||||||
onClick={() => |
|
||||||
scrapeData.stream && |
|
||||||
dispatch({ |
|
||||||
url: scrapeData.stream.streamUrl, |
|
||||||
quality: scrapeData.stream.quality, |
|
||||||
sourceType: scrapeData.stream.type, |
|
||||||
type: "SET_SOURCE", |
|
||||||
}) |
|
||||||
} |
|
||||||
> |
|
||||||
{selectedProvider.displayName} |
|
||||||
</div> |
|
||||||
) : null} |
|
||||||
</div> |
|
||||||
) : null} |
|
||||||
</div> |
|
||||||
</> |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
export function SourceSelectionControl(props: Props) { |
|
||||||
const { videoState } = useVideoPlayerState(); |
|
||||||
|
|
||||||
if (!props.media) return null; |
|
||||||
|
|
||||||
return ( |
|
||||||
<div className={props.className}> |
|
||||||
<div className="relative"> |
|
||||||
<VideoPopout |
|
||||||
id="source" |
|
||||||
className="grid grid-rows-[auto,minmax(0,1fr)]" |
|
||||||
> |
|
||||||
<PopoutSourceSelect media={props.media} /> |
|
||||||
</VideoPopout> |
|
||||||
<VideoPlayerIconButton |
|
||||||
icon={Icons.FILE} |
|
||||||
text="Video source" |
|
||||||
onClick={() => videoState.openPopout("source")} |
|
||||||
/> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
); |
|
||||||
} |
|
||||||
@ -1,42 +0,0 @@ |
|||||||
import { Icons } from "@/components/Icon"; |
|
||||||
import { VideoPlayerIconButton } from "../parts/VideoPlayerIconButton"; |
|
||||||
import { useVideoPlayerState } from "../VideoContext"; |
|
||||||
|
|
||||||
interface Props { |
|
||||||
className?: string; |
|
||||||
} |
|
||||||
|
|
||||||
export function SkipTimeBackward() { |
|
||||||
const { videoState } = useVideoPlayerState(); |
|
||||||
|
|
||||||
const skipBackward = () => { |
|
||||||
videoState.setTime(videoState.time - 10); |
|
||||||
}; |
|
||||||
|
|
||||||
return ( |
|
||||||
<VideoPlayerIconButton icon={Icons.SKIP_BACKWARD} onClick={skipBackward} /> |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
export function SkipTimeForward() { |
|
||||||
const { videoState } = useVideoPlayerState(); |
|
||||||
|
|
||||||
const skipForward = () => { |
|
||||||
videoState.setTime(videoState.time + 10); |
|
||||||
}; |
|
||||||
|
|
||||||
return ( |
|
||||||
<VideoPlayerIconButton icon={Icons.SKIP_FORWARD} onClick={skipForward} /> |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
export function TimeControl(props: Props) { |
|
||||||
return ( |
|
||||||
<div className={props.className}> |
|
||||||
<div className="flex select-none items-center text-white"> |
|
||||||
<SkipTimeBackward /> |
|
||||||
<SkipTimeForward /> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
); |
|
||||||
} |
|
||||||
@ -1,86 +0,0 @@ |
|||||||
import { Icon, Icons } from "@/components/Icon"; |
|
||||||
import { |
|
||||||
makePercentage, |
|
||||||
makePercentageString, |
|
||||||
useProgressBar, |
|
||||||
} from "@/hooks/useProgressBar"; |
|
||||||
import { useVolumeControl } from "@/hooks/useVolumeToggle"; |
|
||||||
import { canChangeVolume } from "@/utils/detectFeatures"; |
|
||||||
import { useCallback, useEffect, useRef, useState } from "react"; |
|
||||||
import { useVideoPlayerState } from "../VideoContext"; |
|
||||||
|
|
||||||
interface Props { |
|
||||||
className?: string; |
|
||||||
} |
|
||||||
|
|
||||||
export function VolumeControl(props: Props) { |
|
||||||
const { videoState } = useVideoPlayerState(); |
|
||||||
const ref = useRef<HTMLDivElement>(null); |
|
||||||
const { setStoredVolume, toggleVolume } = useVolumeControl(); |
|
||||||
const [hoveredOnce, setHoveredOnce] = useState(false); |
|
||||||
|
|
||||||
const commitVolume = useCallback( |
|
||||||
(percentage) => { |
|
||||||
videoState.setVolume(percentage); |
|
||||||
setStoredVolume(percentage); |
|
||||||
}, |
|
||||||
[videoState, setStoredVolume] |
|
||||||
); |
|
||||||
const { dragging, dragPercentage, dragMouseDown } = useProgressBar( |
|
||||||
ref, |
|
||||||
commitVolume, |
|
||||||
true |
|
||||||
); |
|
||||||
|
|
||||||
useEffect(() => { |
|
||||||
if (!videoState.leftControlHovering) setHoveredOnce(false); |
|
||||||
}, [videoState, setHoveredOnce]); |
|
||||||
|
|
||||||
const handleClick = useCallback(() => { |
|
||||||
toggleVolume(); |
|
||||||
}, [toggleVolume]); |
|
||||||
|
|
||||||
const handleMouseEnter = useCallback(async () => { |
|
||||||
if (await canChangeVolume()) setHoveredOnce(true); |
|
||||||
}, [setHoveredOnce]); |
|
||||||
|
|
||||||
let percentage = makePercentage(videoState.volume * 100); |
|
||||||
if (dragging) percentage = makePercentage(dragPercentage); |
|
||||||
const percentageString = makePercentageString(percentage); |
|
||||||
|
|
||||||
return ( |
|
||||||
<div className={props.className}> |
|
||||||
<div |
|
||||||
className="pointer-events-auto flex cursor-pointer items-center" |
|
||||||
onMouseEnter={handleMouseEnter} |
|
||||||
> |
|
||||||
<div className="px-4 text-2xl text-white" onClick={handleClick}> |
|
||||||
<Icon icon={percentage > 0 ? Icons.VOLUME : Icons.VOLUME_X} /> |
|
||||||
</div> |
|
||||||
<div |
|
||||||
className={`linear -ml-2 w-0 overflow-hidden transition-[width,opacity] duration-300 ${ |
|
||||||
hoveredOnce || dragging ? "!w-24 opacity-100" : "w-4 opacity-0" |
|
||||||
}`}
|
|
||||||
> |
|
||||||
<div |
|
||||||
ref={ref} |
|
||||||
className="flex h-10 w-20 items-center px-2" |
|
||||||
onMouseDown={dragMouseDown} |
|
||||||
onTouchStart={dragMouseDown} |
|
||||||
> |
|
||||||
<div className="relative h-1 flex-1 rounded-full bg-gray-500 bg-opacity-50"> |
|
||||||
<div |
|
||||||
className="absolute inset-y-0 left-0 flex items-center justify-end rounded-full bg-bink-500" |
|
||||||
style={{ |
|
||||||
width: percentageString, |
|
||||||
}} |
|
||||||
> |
|
||||||
<div className="absolute h-3 w-3 translate-x-1/2 rounded-full bg-white" /> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
); |
|
||||||
} |
|
||||||
@ -1,204 +0,0 @@ |
|||||||
import Hls from "hls.js"; |
|
||||||
import { |
|
||||||
canChangeVolume, |
|
||||||
canFullscreen, |
|
||||||
canFullscreenAnyElement, |
|
||||||
canWebkitFullscreen, |
|
||||||
} from "@/utils/detectFeatures"; |
|
||||||
import { MWStreamType } from "@/backend/helpers/streams"; |
|
||||||
import fscreen from "fscreen"; |
|
||||||
import React, { RefObject } from "react"; |
|
||||||
import { PlayerState } from "./useVideoPlayer"; |
|
||||||
import { getStoredVolume, setStoredVolume } from "./volumeStore"; |
|
||||||
|
|
||||||
interface ShowData { |
|
||||||
current?: { |
|
||||||
episodeId: string; |
|
||||||
seasonId: string; |
|
||||||
}; |
|
||||||
isSeries: boolean; |
|
||||||
seasons?: { |
|
||||||
id: string; |
|
||||||
number: number; |
|
||||||
title: string; |
|
||||||
episodes?: { |
|
||||||
id: string; |
|
||||||
number: number; |
|
||||||
title: string; |
|
||||||
}[]; |
|
||||||
}[]; |
|
||||||
} |
|
||||||
|
|
||||||
export interface PlayerControls { |
|
||||||
play(): void; |
|
||||||
pause(): void; |
|
||||||
exitFullscreen(): void; |
|
||||||
enterFullscreen(): void; |
|
||||||
setTime(time: number): void; |
|
||||||
setVolume(volume: number): void; |
|
||||||
setSeeking(active: boolean): void; |
|
||||||
setLeftControlsHover(hovering: boolean): void; |
|
||||||
initPlayer(sourceUrl: string, sourceType: MWStreamType): void; |
|
||||||
setShowData(data: ShowData): void; |
|
||||||
setCurrentEpisode(sId: string, eId: string): void; |
|
||||||
startAirplay(): void; |
|
||||||
openPopout(id: string): void; |
|
||||||
closePopout(): void; |
|
||||||
} |
|
||||||
|
|
||||||
export const initialControls: PlayerControls = { |
|
||||||
play: () => null, |
|
||||||
pause: () => null, |
|
||||||
enterFullscreen: () => null, |
|
||||||
exitFullscreen: () => null, |
|
||||||
setTime: () => null, |
|
||||||
setVolume: () => null, |
|
||||||
setSeeking: () => null, |
|
||||||
setLeftControlsHover: () => null, |
|
||||||
initPlayer: () => null, |
|
||||||
setShowData: () => null, |
|
||||||
startAirplay: () => null, |
|
||||||
setCurrentEpisode: () => null, |
|
||||||
openPopout: () => null, |
|
||||||
closePopout: () => null, |
|
||||||
}; |
|
||||||
|
|
||||||
export function populateControls( |
|
||||||
playerEl: HTMLVideoElement, |
|
||||||
wrapperEl: HTMLDivElement, |
|
||||||
update: (s: React.SetStateAction<PlayerState>) => void, |
|
||||||
state: RefObject<PlayerState> |
|
||||||
): PlayerControls { |
|
||||||
const player = playerEl; |
|
||||||
const wrapper = wrapperEl; |
|
||||||
|
|
||||||
return { |
|
||||||
play() { |
|
||||||
player.play(); |
|
||||||
}, |
|
||||||
pause() { |
|
||||||
player.pause(); |
|
||||||
}, |
|
||||||
enterFullscreen() { |
|
||||||
if (!canFullscreen() || fscreen.fullscreenElement) return; |
|
||||||
if (canFullscreenAnyElement()) { |
|
||||||
fscreen.requestFullscreen(wrapper); |
|
||||||
return; |
|
||||||
} |
|
||||||
if (canWebkitFullscreen()) { |
|
||||||
(player as any).webkitEnterFullscreen(); |
|
||||||
} |
|
||||||
}, |
|
||||||
exitFullscreen() { |
|
||||||
if (!fscreen.fullscreenElement) return; |
|
||||||
fscreen.exitFullscreen(); |
|
||||||
}, |
|
||||||
setTime(t) { |
|
||||||
// clamp time between 0 and max duration
|
|
||||||
let time = Math.min(t, player.duration); |
|
||||||
time = Math.max(0, time); |
|
||||||
|
|
||||||
if (Number.isNaN(time)) return; |
|
||||||
|
|
||||||
// update state
|
|
||||||
player.currentTime = time; |
|
||||||
update((s) => ({ ...s, time })); |
|
||||||
}, |
|
||||||
async setVolume(v) { |
|
||||||
// clamp time between 0 and 1
|
|
||||||
let volume = Math.min(v, 1); |
|
||||||
volume = Math.max(0, volume); |
|
||||||
|
|
||||||
// update state
|
|
||||||
if (await canChangeVolume()) player.volume = volume; |
|
||||||
update((s) => ({ ...s, volume })); |
|
||||||
|
|
||||||
// update localstorage
|
|
||||||
setStoredVolume(volume); |
|
||||||
}, |
|
||||||
setSeeking(active) { |
|
||||||
const currentState = state.current; |
|
||||||
if (!currentState) return; |
|
||||||
|
|
||||||
// if it was playing when starting to seek, play again
|
|
||||||
if (!active) { |
|
||||||
if (!currentState.pausedWhenSeeking) this.play(); |
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
// when seeking we pause the video
|
|
||||||
update((s) => ({ ...s, pausedWhenSeeking: s.isPaused })); |
|
||||||
this.pause(); |
|
||||||
}, |
|
||||||
setLeftControlsHover(hovering) { |
|
||||||
update((s) => ({ ...s, leftControlHovering: hovering })); |
|
||||||
}, |
|
||||||
openPopout(id: string) { |
|
||||||
update((s) => ({ ...s, popout: id })); |
|
||||||
}, |
|
||||||
closePopout() { |
|
||||||
update((s) => ({ ...s, popout: null })); |
|
||||||
}, |
|
||||||
setShowData(data) { |
|
||||||
update((s) => ({ ...s, seasonData: data })); |
|
||||||
}, |
|
||||||
setCurrentEpisode(sId: string, eId: string) { |
|
||||||
update((s) => ({ |
|
||||||
...s, |
|
||||||
seasonData: { |
|
||||||
...s.seasonData, |
|
||||||
current: { |
|
||||||
seasonId: sId, |
|
||||||
episodeId: eId, |
|
||||||
}, |
|
||||||
}, |
|
||||||
})); |
|
||||||
}, |
|
||||||
startAirplay() { |
|
||||||
const videoPlayer = player as any; |
|
||||||
if (videoPlayer.webkitShowPlaybackTargetPicker) |
|
||||||
videoPlayer.webkitShowPlaybackTargetPicker(); |
|
||||||
}, |
|
||||||
initPlayer(sourceUrl: string, sourceType: MWStreamType) { |
|
||||||
this.setVolume(getStoredVolume()); |
|
||||||
|
|
||||||
if (sourceType === MWStreamType.HLS) { |
|
||||||
if (player.canPlayType("application/vnd.apple.mpegurl")) { |
|
||||||
player.src = sourceUrl; |
|
||||||
} else { |
|
||||||
// HLS support
|
|
||||||
if (!Hls.isSupported()) { |
|
||||||
update((s) => ({ |
|
||||||
...s, |
|
||||||
error: { |
|
||||||
name: `Not supported`, |
|
||||||
description: "Your browser does not support HLS video", |
|
||||||
}, |
|
||||||
})); |
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
const hls = new Hls({ enableWorker: false }); |
|
||||||
|
|
||||||
hls.on(Hls.Events.ERROR, (event, data) => { |
|
||||||
if (data.fatal) { |
|
||||||
update((s) => ({ |
|
||||||
...s, |
|
||||||
error: { |
|
||||||
name: `error ${data.details}`, |
|
||||||
description: data.error?.message ?? "Something went wrong", |
|
||||||
}, |
|
||||||
})); |
|
||||||
} |
|
||||||
console.error("HLS error", data); |
|
||||||
}); |
|
||||||
|
|
||||||
hls.attachMedia(player); |
|
||||||
hls.loadSource(sourceUrl); |
|
||||||
} |
|
||||||
} else if (sourceType === MWStreamType.MP4) { |
|
||||||
player.src = sourceUrl; |
|
||||||
} |
|
||||||
}, |
|
||||||
}; |
|
||||||
} |
|
||||||
@ -1,33 +0,0 @@ |
|||||||
import { useMemo } from "react"; |
|
||||||
import { useVideoPlayerState } from "../VideoContext"; |
|
||||||
|
|
||||||
export function useCurrentSeriesEpisodeInfo() { |
|
||||||
const { videoState } = useVideoPlayerState(); |
|
||||||
|
|
||||||
const { current, seasons } = videoState.seasonData; |
|
||||||
|
|
||||||
const currentSeasonInfo = useMemo(() => { |
|
||||||
return seasons?.find((season) => season.id === current?.seasonId); |
|
||||||
}, [seasons, current]); |
|
||||||
|
|
||||||
const currentEpisodeInfo = useMemo(() => { |
|
||||||
return currentSeasonInfo?.episodes?.find( |
|
||||||
(episode) => episode.id === current?.episodeId |
|
||||||
); |
|
||||||
}, [currentSeasonInfo, current]); |
|
||||||
|
|
||||||
const isSeries = Boolean( |
|
||||||
videoState.seasonData.isSeries && videoState.seasonData.current |
|
||||||
); |
|
||||||
|
|
||||||
if (!isSeries) return { isSeries: false }; |
|
||||||
|
|
||||||
const humanizedEpisodeId = `S${currentSeasonInfo?.number} E${currentEpisodeInfo?.number}`; |
|
||||||
|
|
||||||
return { |
|
||||||
isSeries: true, |
|
||||||
humanizedEpisodeId, |
|
||||||
currentSeasonInfo, |
|
||||||
currentEpisodeInfo, |
|
||||||
}; |
|
||||||
} |
|
||||||
@ -1,262 +0,0 @@ |
|||||||
import { canChangeVolume } from "@/utils/detectFeatures"; |
|
||||||
import fscreen from "fscreen"; |
|
||||||
import React, { MutableRefObject, useEffect, useRef, useState } from "react"; |
|
||||||
import { |
|
||||||
initialControls, |
|
||||||
PlayerControls, |
|
||||||
populateControls, |
|
||||||
} from "./controlVideo"; |
|
||||||
import { handleBuffered } from "./utils"; |
|
||||||
|
|
||||||
export type PlayerState = { |
|
||||||
isPlaying: boolean; |
|
||||||
isPaused: boolean; |
|
||||||
isSeeking: boolean; |
|
||||||
isLoading: boolean; |
|
||||||
isFirstLoading: boolean; |
|
||||||
isFullscreen: boolean; |
|
||||||
time: number; |
|
||||||
duration: number; |
|
||||||
volume: number; |
|
||||||
buffered: number; |
|
||||||
pausedWhenSeeking: boolean; |
|
||||||
hasInitialized: boolean; |
|
||||||
leftControlHovering: boolean; |
|
||||||
hasPlayedOnce: boolean; |
|
||||||
popout: string | null; |
|
||||||
isFocused: boolean; |
|
||||||
seasonData: { |
|
||||||
isSeries: boolean; |
|
||||||
current?: { |
|
||||||
episodeId: string; |
|
||||||
seasonId: string; |
|
||||||
}; |
|
||||||
seasons?: { |
|
||||||
id: string; |
|
||||||
number: number; |
|
||||||
title: string; |
|
||||||
episodes?: { id: string; number: number; title: string }[]; |
|
||||||
}[]; |
|
||||||
}; |
|
||||||
error: null | { |
|
||||||
name: string; |
|
||||||
description: string; |
|
||||||
}; |
|
||||||
canAirplay: boolean; |
|
||||||
}; |
|
||||||
|
|
||||||
export type PlayerContext = PlayerState & PlayerControls; |
|
||||||
|
|
||||||
export const initialPlayerState: PlayerContext = { |
|
||||||
isPlaying: false, |
|
||||||
isPaused: true, |
|
||||||
isFullscreen: false, |
|
||||||
isFocused: false, |
|
||||||
isLoading: false, |
|
||||||
isSeeking: false, |
|
||||||
isFirstLoading: true, |
|
||||||
time: 0, |
|
||||||
duration: 0, |
|
||||||
volume: 0, |
|
||||||
buffered: 0, |
|
||||||
pausedWhenSeeking: false, |
|
||||||
hasInitialized: false, |
|
||||||
leftControlHovering: false, |
|
||||||
hasPlayedOnce: false, |
|
||||||
error: null, |
|
||||||
popout: null, |
|
||||||
seasonData: { |
|
||||||
isSeries: false, |
|
||||||
}, |
|
||||||
canAirplay: false, |
|
||||||
...initialControls, |
|
||||||
}; |
|
||||||
|
|
||||||
type SetPlayer = (s: React.SetStateAction<PlayerContext>) => void; |
|
||||||
|
|
||||||
function readState(player: HTMLVideoElement, update: SetPlayer) { |
|
||||||
const state = { |
|
||||||
...initialPlayerState, |
|
||||||
}; |
|
||||||
state.isPaused = player.paused; |
|
||||||
state.isPlaying = !player.paused; |
|
||||||
state.isFullscreen = !!document.fullscreenElement; |
|
||||||
state.isSeeking = player.seeking; |
|
||||||
state.time = player.currentTime; |
|
||||||
state.duration = player.duration; |
|
||||||
state.volume = player.volume; |
|
||||||
state.buffered = handleBuffered(player.currentTime, player.buffered); |
|
||||||
state.isLoading = false; |
|
||||||
state.hasInitialized = true; |
|
||||||
state.error = null; |
|
||||||
|
|
||||||
update((s) => ({ |
|
||||||
...state, |
|
||||||
pausedWhenSeeking: s.pausedWhenSeeking, |
|
||||||
hasPlayedOnce: s.hasPlayedOnce, |
|
||||||
isFirstLoading: s.isFirstLoading, |
|
||||||
})); |
|
||||||
} |
|
||||||
|
|
||||||
function registerListeners(player: HTMLVideoElement, update: SetPlayer) { |
|
||||||
const pause = () => { |
|
||||||
update((s) => ({ |
|
||||||
...s, |
|
||||||
isPaused: true, |
|
||||||
isPlaying: false, |
|
||||||
})); |
|
||||||
}; |
|
||||||
const playing = () => { |
|
||||||
update((s) => ({ |
|
||||||
...s, |
|
||||||
isPaused: false, |
|
||||||
isPlaying: true, |
|
||||||
isLoading: false, |
|
||||||
hasPlayedOnce: true, |
|
||||||
})); |
|
||||||
}; |
|
||||||
const seeking = () => { |
|
||||||
update((s) => ({ ...s, isSeeking: true })); |
|
||||||
}; |
|
||||||
const seeked = () => { |
|
||||||
update((s) => ({ ...s, isSeeking: false })); |
|
||||||
}; |
|
||||||
const waiting = () => { |
|
||||||
update((s) => ({ ...s, isLoading: true })); |
|
||||||
}; |
|
||||||
const fullscreenchange = () => { |
|
||||||
update((s) => ({ ...s, isFullscreen: !!document.fullscreenElement })); |
|
||||||
}; |
|
||||||
const timeupdate = () => { |
|
||||||
update((s) => ({ |
|
||||||
...s, |
|
||||||
duration: player.duration, |
|
||||||
time: player.currentTime, |
|
||||||
})); |
|
||||||
}; |
|
||||||
const loadedmetadata = () => { |
|
||||||
update((s) => ({ |
|
||||||
...s, |
|
||||||
duration: player.duration, |
|
||||||
})); |
|
||||||
}; |
|
||||||
const volumechange = async () => { |
|
||||||
if (await canChangeVolume()) |
|
||||||
update((s) => ({ |
|
||||||
...s, |
|
||||||
volume: player.volume, |
|
||||||
})); |
|
||||||
}; |
|
||||||
const progress = () => { |
|
||||||
update((s) => ({ |
|
||||||
...s, |
|
||||||
buffered: handleBuffered(player.currentTime, player.buffered), |
|
||||||
})); |
|
||||||
}; |
|
||||||
const canplay = () => { |
|
||||||
update((s) => ({ |
|
||||||
...s, |
|
||||||
isFirstLoading: false, |
|
||||||
})); |
|
||||||
}; |
|
||||||
const error = () => { |
|
||||||
console.error("Native video player threw error", player.error); |
|
||||||
update((s) => ({ |
|
||||||
...s, |
|
||||||
error: player.error |
|
||||||
? { |
|
||||||
description: player.error.message, |
|
||||||
name: `Error ${player.error.code}`, |
|
||||||
} |
|
||||||
: null, |
|
||||||
})); |
|
||||||
}; |
|
||||||
const canAirplay = (e: any) => { |
|
||||||
if (e.availability === "available") { |
|
||||||
update((s) => ({ |
|
||||||
...s, |
|
||||||
canAirplay: true, |
|
||||||
})); |
|
||||||
} |
|
||||||
}; |
|
||||||
const isFocused = (evt: any) => { |
|
||||||
update((s) => ({ |
|
||||||
...s, |
|
||||||
isFocused: evt.type !== "mouseleave", |
|
||||||
})); |
|
||||||
}; |
|
||||||
|
|
||||||
const playerWrapper = player.closest(".is-video-player"); |
|
||||||
if (!playerWrapper) return; |
|
||||||
|
|
||||||
playerWrapper.addEventListener("click", isFocused); |
|
||||||
playerWrapper.addEventListener("mouseenter", isFocused); |
|
||||||
playerWrapper.addEventListener("mouseleave", isFocused); |
|
||||||
player.addEventListener("pause", pause); |
|
||||||
player.addEventListener("playing", playing); |
|
||||||
player.addEventListener("seeking", seeking); |
|
||||||
player.addEventListener("seeked", seeked); |
|
||||||
fscreen.addEventListener("fullscreenchange", fullscreenchange); |
|
||||||
player.addEventListener("timeupdate", timeupdate); |
|
||||||
player.addEventListener("loadedmetadata", loadedmetadata); |
|
||||||
player.addEventListener("volumechange", volumechange); |
|
||||||
player.addEventListener("progress", progress); |
|
||||||
player.addEventListener("waiting", waiting); |
|
||||||
player.addEventListener("canplay", canplay); |
|
||||||
player.addEventListener("error", error); |
|
||||||
player.addEventListener( |
|
||||||
"webkitplaybacktargetavailabilitychanged", |
|
||||||
canAirplay |
|
||||||
); |
|
||||||
|
|
||||||
return () => { |
|
||||||
playerWrapper.removeEventListener("click", isFocused); |
|
||||||
playerWrapper.removeEventListener("mouseenter", isFocused); |
|
||||||
playerWrapper.removeEventListener("mouseleave", isFocused); |
|
||||||
player.removeEventListener("pause", pause); |
|
||||||
player.removeEventListener("playing", playing); |
|
||||||
player.removeEventListener("seeking", seeking); |
|
||||||
player.removeEventListener("seeked", seeked); |
|
||||||
fscreen.removeEventListener("fullscreenchange", fullscreenchange); |
|
||||||
player.removeEventListener("timeupdate", timeupdate); |
|
||||||
player.removeEventListener("loadedmetadata", loadedmetadata); |
|
||||||
player.removeEventListener("volumechange", volumechange); |
|
||||||
player.removeEventListener("progress", progress); |
|
||||||
player.removeEventListener("waiting", waiting); |
|
||||||
player.removeEventListener("canplay", canplay); |
|
||||||
player.removeEventListener("error", error); |
|
||||||
player.removeEventListener( |
|
||||||
"webkitplaybacktargetavailabilitychanged", |
|
||||||
canAirplay |
|
||||||
); |
|
||||||
}; |
|
||||||
} |
|
||||||
|
|
||||||
export function useVideoPlayer( |
|
||||||
ref: MutableRefObject<HTMLVideoElement | null>, |
|
||||||
wrapperRef: MutableRefObject<HTMLDivElement | null> |
|
||||||
) { |
|
||||||
const [state, setState] = useState(initialPlayerState); |
|
||||||
const stateRef = useRef<PlayerState | null>(null); |
|
||||||
|
|
||||||
useEffect(() => { |
|
||||||
const player = ref.current; |
|
||||||
const wrapper = wrapperRef.current; |
|
||||||
if (player && wrapper) { |
|
||||||
readState(player, setState); |
|
||||||
registerListeners(player, setState); |
|
||||||
setState((s) => ({ |
|
||||||
...s, |
|
||||||
...populateControls(player, wrapper, setState as any, stateRef), |
|
||||||
})); |
|
||||||
} |
|
||||||
}, [ref, wrapperRef, stateRef]); |
|
||||||
|
|
||||||
useEffect(() => { |
|
||||||
stateRef.current = state; |
|
||||||
}, [state, stateRef]); |
|
||||||
|
|
||||||
return { |
|
||||||
playerState: state, |
|
||||||
}; |
|
||||||
} |
|
||||||
@ -1,8 +0,0 @@ |
|||||||
export function handleBuffered(time: number, buffered: TimeRanges): number { |
|
||||||
for (let i = 0; i < buffered.length; i += 1) { |
|
||||||
if (buffered.start(buffered.length - 1 - i) < time) { |
|
||||||
return buffered.end(buffered.length - 1 - i); |
|
||||||
} |
|
||||||
} |
|
||||||
return 0; |
|
||||||
} |
|
||||||
@ -1,25 +0,0 @@ |
|||||||
import { versionedStoreBuilder } from "@/utils/storage"; |
|
||||||
|
|
||||||
export const volumeStore = versionedStoreBuilder() |
|
||||||
.setKey("mw-volume") |
|
||||||
.addVersion({ |
|
||||||
version: 0, |
|
||||||
create() { |
|
||||||
return { |
|
||||||
volume: 1, |
|
||||||
}; |
|
||||||
}, |
|
||||||
}) |
|
||||||
.build(); |
|
||||||
|
|
||||||
export function getStoredVolume(): number { |
|
||||||
const store = volumeStore.get(); |
|
||||||
return store.volume; |
|
||||||
} |
|
||||||
|
|
||||||
export function setStoredVolume(volume: number) { |
|
||||||
const store = volumeStore.get(); |
|
||||||
store.save({ |
|
||||||
volume, |
|
||||||
}); |
|
||||||
} |
|
||||||
@ -1,83 +0,0 @@ |
|||||||
import { MWMediaMeta } from "@/backend/metadata/types"; |
|
||||||
import { ErrorMessage } from "@/components/layout/ErrorBoundary"; |
|
||||||
import { Link } from "@/components/text/Link"; |
|
||||||
import { conf } from "@/setup/config"; |
|
||||||
import { Component, ReactNode } from "react"; |
|
||||||
import { VideoPlayerHeader } from "./VideoPlayerHeader"; |
|
||||||
|
|
||||||
interface ErrorBoundaryState { |
|
||||||
hasError: boolean; |
|
||||||
error?: { |
|
||||||
name: string; |
|
||||||
description: string; |
|
||||||
path: string; |
|
||||||
}; |
|
||||||
} |
|
||||||
|
|
||||||
interface VideoErrorBoundaryProps { |
|
||||||
children?: ReactNode; |
|
||||||
media?: MWMediaMeta; |
|
||||||
onGoBack?: () => void; |
|
||||||
} |
|
||||||
|
|
||||||
export class VideoErrorBoundary extends Component< |
|
||||||
VideoErrorBoundaryProps, |
|
||||||
ErrorBoundaryState |
|
||||||
> { |
|
||||||
constructor(props: VideoErrorBoundaryProps) { |
|
||||||
super(props); |
|
||||||
this.state = { |
|
||||||
hasError: false, |
|
||||||
}; |
|
||||||
} |
|
||||||
|
|
||||||
static getDerivedStateFromError() { |
|
||||||
return { |
|
||||||
hasError: true, |
|
||||||
}; |
|
||||||
} |
|
||||||
|
|
||||||
componentDidCatch(error: any, errorInfo: any) { |
|
||||||
console.error("Render error caught", error, errorInfo); |
|
||||||
if (error instanceof Error) { |
|
||||||
const realError: Error = error as Error; |
|
||||||
this.setState((s) => ({ |
|
||||||
...s, |
|
||||||
hasError: true, |
|
||||||
error: { |
|
||||||
name: realError.name, |
|
||||||
description: realError.message, |
|
||||||
path: errorInfo.componentStack.split("\n")[1], |
|
||||||
}, |
|
||||||
})); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
render() { |
|
||||||
if (!this.state.hasError) return this.props.children; |
|
||||||
|
|
||||||
// TODO make responsive, needs to work in tiny player
|
|
||||||
|
|
||||||
return ( |
|
||||||
<div className="absolute inset-0 bg-denim-100"> |
|
||||||
<div className="pointer-events-auto absolute inset-x-0 top-0 flex flex-col py-6 px-8 pb-2"> |
|
||||||
<VideoPlayerHeader |
|
||||||
media={this.props.media} |
|
||||||
onClick={this.props.onGoBack} |
|
||||||
/> |
|
||||||
</div> |
|
||||||
<ErrorMessage error={this.state.error} localSize> |
|
||||||
The video player encounted a fatal error, please report it to the{" "} |
|
||||||
<Link url={conf().DISCORD_LINK} newTab> |
|
||||||
Discord server |
|
||||||
</Link>{" "} |
|
||||||
or on{" "} |
|
||||||
<Link url={conf().GITHUB_LINK} newTab> |
|
||||||
GitHub |
|
||||||
</Link> |
|
||||||
. |
|
||||||
</ErrorMessage> |
|
||||||
</div> |
|
||||||
); |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,36 +0,0 @@ |
|||||||
import { MWMediaMeta } from "@/backend/metadata/types"; |
|
||||||
import { IconPatch } from "@/components/buttons/IconPatch"; |
|
||||||
import { Icons } from "@/components/Icon"; |
|
||||||
import { Title } from "@/components/text/Title"; |
|
||||||
import { ReactNode } from "react"; |
|
||||||
import { useVideoPlayerState } from "../VideoContext"; |
|
||||||
import { VideoPlayerHeader } from "./VideoPlayerHeader"; |
|
||||||
|
|
||||||
interface VideoPlayerErrorProps { |
|
||||||
media?: MWMediaMeta; |
|
||||||
onGoBack?: () => void; |
|
||||||
children?: ReactNode; |
|
||||||
} |
|
||||||
|
|
||||||
export function VideoPlayerError(props: VideoPlayerErrorProps) { |
|
||||||
const { videoState } = useVideoPlayerState(); |
|
||||||
|
|
||||||
const err = videoState.error; |
|
||||||
|
|
||||||
if (!err) return props.children as any; |
|
||||||
|
|
||||||
return ( |
|
||||||
<div> |
|
||||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-denim-100"> |
|
||||||
<IconPatch icon={Icons.WARNING} className="mb-6 text-red-400" /> |
|
||||||
<Title>Failed to load media</Title> |
|
||||||
<p className="my-6 max-w-lg"> |
|
||||||
{err.name}: {err.description} |
|
||||||
</p> |
|
||||||
</div> |
|
||||||
<div className="pointer-events-auto absolute inset-x-0 top-0 flex flex-col py-6 px-8 pb-2"> |
|
||||||
<VideoPlayerHeader media={props.media} onClick={props.onGoBack} /> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
); |
|
||||||
} |
|
||||||
@ -1,68 +0,0 @@ |
|||||||
import { MWMediaMeta } from "@/backend/metadata/types"; |
|
||||||
import { IconPatch } from "@/components/buttons/IconPatch"; |
|
||||||
import { Icon, Icons } from "@/components/Icon"; |
|
||||||
import { BrandPill } from "@/components/layout/BrandPill"; |
|
||||||
import { |
|
||||||
getIfBookmarkedFromPortable, |
|
||||||
useBookmarkContext, |
|
||||||
} from "@/state/bookmark"; |
|
||||||
import { AirplayControl } from "../controls/AirplayControl"; |
|
||||||
import { ChromeCastControl } from "../controls/ChromeCastControl"; |
|
||||||
|
|
||||||
interface VideoPlayerHeaderProps { |
|
||||||
media?: MWMediaMeta; |
|
||||||
onClick?: () => void; |
|
||||||
isMobile?: boolean; |
|
||||||
} |
|
||||||
|
|
||||||
export function VideoPlayerHeader(props: VideoPlayerHeaderProps) { |
|
||||||
const { bookmarkStore, setItemBookmark } = useBookmarkContext(); |
|
||||||
const isBookmarked = props.media |
|
||||||
? getIfBookmarkedFromPortable(bookmarkStore.bookmarks, props.media) |
|
||||||
: false; |
|
||||||
const showDivider = props.media && props.onClick; |
|
||||||
return ( |
|
||||||
<div className="flex items-center"> |
|
||||||
<div className="flex flex-1 items-center"> |
|
||||||
<p className="flex items-center"> |
|
||||||
{props.onClick ? ( |
|
||||||
<span |
|
||||||
onClick={props.onClick} |
|
||||||
className="flex cursor-pointer items-center py-1 text-white opacity-50 transition-opacity hover:opacity-100" |
|
||||||
> |
|
||||||
<Icon className="mr-2" icon={Icons.ARROW_LEFT} /> |
|
||||||
<span>Back to home</span> |
|
||||||
</span> |
|
||||||
) : null} |
|
||||||
{showDivider ? ( |
|
||||||
<span className="mx-4 h-6 w-[1.5px] rotate-[30deg] bg-white opacity-50" /> |
|
||||||
) : null} |
|
||||||
{props.media ? ( |
|
||||||
<span className="flex items-center text-white"> |
|
||||||
<span>{props.media.title}</span> |
|
||||||
</span> |
|
||||||
) : null} |
|
||||||
</p> |
|
||||||
{props.media && ( |
|
||||||
<IconPatch |
|
||||||
clickable |
|
||||||
transparent |
|
||||||
icon={isBookmarked ? Icons.BOOKMARK : Icons.BOOKMARK_OUTLINE} |
|
||||||
className="ml-2 text-white" |
|
||||||
onClick={() => |
|
||||||
props.media && setItemBookmark(props.media, !isBookmarked) |
|
||||||
} |
|
||||||
/> |
|
||||||
)} |
|
||||||
</div> |
|
||||||
{props.isMobile ? ( |
|
||||||
<> |
|
||||||
<AirplayControl /> |
|
||||||
<ChromeCastControl /> |
|
||||||
</> |
|
||||||
) : ( |
|
||||||
<BrandPill /> |
|
||||||
)} |
|
||||||
</div> |
|
||||||
); |
|
||||||
} |
|
||||||
@ -1,27 +0,0 @@ |
|||||||
import { Icon, Icons } from "@/components/Icon"; |
|
||||||
import React from "react"; |
|
||||||
|
|
||||||
export interface VideoPlayerIconButtonProps { |
|
||||||
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void; |
|
||||||
icon: Icons; |
|
||||||
text?: string; |
|
||||||
className?: string; |
|
||||||
iconSize?: string; |
|
||||||
} |
|
||||||
|
|
||||||
export function VideoPlayerIconButton(props: VideoPlayerIconButtonProps) { |
|
||||||
return ( |
|
||||||
<div className={props.className}> |
|
||||||
<button |
|
||||||
type="button" |
|
||||||
onClick={props.onClick} |
|
||||||
className="group pointer-events-auto p-2 text-white transition-transform duration-100 active:scale-110" |
|
||||||
> |
|
||||||
<div className="flex items-center justify-center rounded-full bg-white bg-opacity-0 p-2 transition-colors duration-100 group-hover:bg-opacity-20"> |
|
||||||
<Icon icon={props.icon} className={props.iconSize ?? "text-2xl"} /> |
|
||||||
{props.text ? <span className="ml-2">{props.text}</span> : null} |
|
||||||
</div> |
|
||||||
</button> |
|
||||||
</div> |
|
||||||
); |
|
||||||
} |
|
||||||
@ -1,60 +0,0 @@ |
|||||||
import { useEffect, useRef } from "react"; |
|
||||||
import { useVideoPlayerState } from "../VideoContext"; |
|
||||||
|
|
||||||
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 { videoState } = useVideoPlayerState(); |
|
||||||
const popoutRef = useRef<HTMLDivElement>(null); |
|
||||||
const isOpen = videoState.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; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
videoState.closePopout(); |
|
||||||
} |
|
||||||
|
|
||||||
window.addEventListener("click", windowClick); |
|
||||||
return () => { |
|
||||||
window.removeEventListener("click", windowClick); |
|
||||||
}; |
|
||||||
}, [isOpen, videoState]); |
|
||||||
|
|
||||||
return ( |
|
||||||
<div |
|
||||||
className={[ |
|
||||||
"is-popout absolute inset-x-0 h-0", |
|
||||||
!isOpen ? "hidden" : "", |
|
||||||
].join(" ")} |
|
||||||
> |
|
||||||
<div className="absolute bottom-10 right-0 h-96 w-72 rounded-lg bg-denim-400"> |
|
||||||
<div |
|
||||||
ref={popoutRef} |
|
||||||
className={["h-full w-full", props.className].join(" ")} |
|
||||||
> |
|
||||||
{isOpen ? props.children : null} |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
); |
|
||||||
} |
|
||||||
Loading…
Reference in new issue