15 changed files with 508 additions and 11 deletions
@ -0,0 +1,68 @@ |
|||||||
|
import { ReactNode, useRef } from "react"; |
||||||
|
import { CSSTransition } from "react-transition-group"; |
||||||
|
import { CSSTransitionClassNames } from "react-transition-group/CSSTransition"; |
||||||
|
|
||||||
|
type TransitionAnimations = "slide-down" | "slide-up" | "fade"; |
||||||
|
|
||||||
|
interface Props { |
||||||
|
show: boolean; |
||||||
|
duration?: number; |
||||||
|
animation: TransitionAnimations; |
||||||
|
className?: string; |
||||||
|
children?: ReactNode; |
||||||
|
} |
||||||
|
|
||||||
|
function getClasses( |
||||||
|
animation: TransitionAnimations, |
||||||
|
duration: number |
||||||
|
): CSSTransitionClassNames { |
||||||
|
if (animation === "slide-down") { |
||||||
|
return { |
||||||
|
exit: `transition-[transform,opacity] translate-y-0 duration-${duration} opacity-100`, |
||||||
|
exitActive: "!-translate-y-4 !opacity-0", |
||||||
|
exitDone: "hidden", |
||||||
|
enter: `transition-[transform,opacity] -translate-y-4 duration-${duration} opacity-0`, |
||||||
|
enterActive: "!translate-y-0 !opacity-100", |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
if (animation === "slide-up") { |
||||||
|
return { |
||||||
|
exit: `transition-[transform,opacity] translate-y-0 duration-${duration} opacity-100`, |
||||||
|
exitActive: "!translate-y-4 !opacity-0", |
||||||
|
exitDone: "hidden", |
||||||
|
enter: `transition-[transform,opacity] translate-y-4 duration-${duration} opacity-0`, |
||||||
|
enterActive: "!translate-y-0 !opacity-100", |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
if (animation === "fade") { |
||||||
|
return { |
||||||
|
exit: `transition-[transform,opacity] duration-${duration} opacity-100`, |
||||||
|
exitActive: "!opacity-0", |
||||||
|
exitDone: "hidden", |
||||||
|
enter: `transition-[transform,opacity] duration-${duration} opacity-0`, |
||||||
|
enterActive: "!opacity-100", |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
return {}; |
||||||
|
} |
||||||
|
|
||||||
|
export function Transition(props: Props) { |
||||||
|
const ref = useRef<HTMLDivElement>(null); |
||||||
|
const duration = props.duration ?? 200; |
||||||
|
|
||||||
|
return ( |
||||||
|
<CSSTransition |
||||||
|
nodeRef={ref} |
||||||
|
in={props.show} |
||||||
|
timeout={200} |
||||||
|
classNames={getClasses(props.animation, duration)} |
||||||
|
> |
||||||
|
<div ref={ref} className={props.className}> |
||||||
|
{props.children} |
||||||
|
</div> |
||||||
|
</CSSTransition> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,140 @@ |
|||||||
|
import { Transition } from "@/components/Transition"; |
||||||
|
import { useIsMobile } from "@/hooks/useIsMobile"; |
||||||
|
import { BackdropAction } from "@/video/components/actions/BackdropAction"; |
||||||
|
import { FullscreenAction } from "@/video/components/actions/FullscreenAction"; |
||||||
|
import { LoadingAction } from "@/video/components/actions/LoadingAction"; |
||||||
|
import { MiddlePauseAction } from "@/video/components/actions/MiddlePauseAction"; |
||||||
|
import { MobileCenterAction } from "@/video/components/actions/MobileCenterAction"; |
||||||
|
import { PauseAction } from "@/video/components/actions/PauseAction"; |
||||||
|
import { ProgressAction } from "@/video/components/actions/ProgressAction"; |
||||||
|
import { SkipTimeAction } from "@/video/components/actions/SkipTimeAction"; |
||||||
|
import { TimeAction } from "@/video/components/actions/TimeAction"; |
||||||
|
import { |
||||||
|
VideoPlayerBase, |
||||||
|
VideoPlayerBaseProps, |
||||||
|
} from "@/video/components/VideoPlayerBase"; |
||||||
|
import { useVideoPlayerDescriptor } from "@/video/state/hooks"; |
||||||
|
import { useControls } from "@/video/state/logic/controls"; |
||||||
|
import { ReactNode, useCallback, useState } from "react"; |
||||||
|
|
||||||
|
function CenterPosition(props: { children: ReactNode }) { |
||||||
|
return ( |
||||||
|
<div className="absolute inset-0 flex items-center justify-center"> |
||||||
|
{props.children} |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
function LeftSideControls() { |
||||||
|
const descriptor = useVideoPlayerDescriptor(); |
||||||
|
const controls = useControls(descriptor); |
||||||
|
|
||||||
|
const handleMouseEnter = useCallback(() => { |
||||||
|
controls.setLeftControlsHover(true); |
||||||
|
}, [controls]); |
||||||
|
const handleMouseLeave = useCallback(() => { |
||||||
|
controls.setLeftControlsHover(false); |
||||||
|
}, [controls]); |
||||||
|
|
||||||
|
return ( |
||||||
|
<> |
||||||
|
<div |
||||||
|
className="flex items-center px-2" |
||||||
|
onMouseLeave={handleMouseLeave} |
||||||
|
onMouseEnter={handleMouseEnter} |
||||||
|
> |
||||||
|
<PauseAction /> |
||||||
|
<SkipTimeAction /> |
||||||
|
{/* <VolumeControl className="mr-2" /> */} |
||||||
|
<TimeAction /> |
||||||
|
</div> |
||||||
|
{/* <ShowTitleControl /> */} |
||||||
|
</> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
export function VideoPlayer(props: VideoPlayerBaseProps) { |
||||||
|
const [show, setShow] = useState(false); |
||||||
|
const { isMobile } = useIsMobile(); |
||||||
|
|
||||||
|
const onBackdropChange = useCallback( |
||||||
|
(showing: boolean) => { |
||||||
|
setShow(showing); |
||||||
|
}, |
||||||
|
[setShow] |
||||||
|
); |
||||||
|
|
||||||
|
// TODO autoplay
|
||||||
|
// TODO meta data
|
||||||
|
return ( |
||||||
|
<VideoPlayerBase> |
||||||
|
{/* <PageTitleControl media={props.media?.meta} /> */} |
||||||
|
{/* <VideoPlayerError media={props.media?.meta} onGoBack={props.onGoBack}> */} |
||||||
|
<BackdropAction onBackdropChange={onBackdropChange}> |
||||||
|
<CenterPosition> |
||||||
|
<LoadingAction /> |
||||||
|
</CenterPosition> |
||||||
|
<CenterPosition> |
||||||
|
<MiddlePauseAction /> |
||||||
|
</CenterPosition> |
||||||
|
{isMobile ? ( |
||||||
|
<Transition |
||||||
|
animation="fade" |
||||||
|
show={show} |
||||||
|
className="absolute inset-0 flex items-center justify-center" |
||||||
|
> |
||||||
|
<MobileCenterAction /> |
||||||
|
</Transition> |
||||||
|
) : ( |
||||||
|
"" |
||||||
|
)} |
||||||
|
<Transition |
||||||
|
animation="slide-down" |
||||||
|
show={show} |
||||||
|
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} |
||||||
|
/> */} |
||||||
|
</Transition> |
||||||
|
<Transition |
||||||
|
animation="slide-up" |
||||||
|
show={show} |
||||||
|
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 && <TimeAction noDuration />} |
||||||
|
<ProgressAction /> |
||||||
|
</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> |
||||||
|
<FullscreenAction /> |
||||||
|
</div> |
||||||
|
) : ( |
||||||
|
<> |
||||||
|
<LeftSideControls /> |
||||||
|
<div className="flex-1" /> |
||||||
|
{/* <QualityDisplayControl /> |
||||||
|
<SeriesSelectionControl /> |
||||||
|
<SourceSelectionControl media={props.media} /> |
||||||
|
<AirplayControl /> |
||||||
|
<ChromeCastControl /> */} |
||||||
|
<FullscreenAction /> |
||||||
|
</> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
</Transition> |
||||||
|
</BackdropAction> |
||||||
|
{props.children} |
||||||
|
{/* </VideoPlayerError> */} |
||||||
|
</VideoPlayerBase> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,96 @@ |
|||||||
|
import { useVideoPlayerDescriptor } from "@/video/state/hooks"; |
||||||
|
import { useControls } from "@/video/state/logic/controls"; |
||||||
|
import { useInterface } from "@/video/state/logic/interface"; |
||||||
|
import { useMediaPlaying } from "@/video/state/logic/mediaplaying"; |
||||||
|
import React, { useCallback, useEffect, useRef, useState } from "react"; |
||||||
|
|
||||||
|
interface BackdropActionProps { |
||||||
|
children?: React.ReactNode; |
||||||
|
onBackdropChange?: (showing: boolean) => void; |
||||||
|
} |
||||||
|
|
||||||
|
export function BackdropAction(props: BackdropActionProps) { |
||||||
|
const descriptor = useVideoPlayerDescriptor(); |
||||||
|
const controls = useControls(descriptor); |
||||||
|
const mediaPlaying = useMediaPlaying(descriptor); |
||||||
|
const videoInterface = useInterface(descriptor); |
||||||
|
|
||||||
|
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 (videoInterface.popout !== null) return; |
||||||
|
|
||||||
|
if (mediaPlaying.isPlaying) controls.pause(); |
||||||
|
else controls.play(); |
||||||
|
}, |
||||||
|
[controls, mediaPlaying, videoInterface] |
||||||
|
); |
||||||
|
const handleDoubleClick = useCallback( |
||||||
|
(e: React.MouseEvent<HTMLDivElement>) => { |
||||||
|
if (!clickareaRef.current || clickareaRef.current !== e.target) return; |
||||||
|
|
||||||
|
if (!videoInterface.isFullscreen) controls.enterFullscreen(); |
||||||
|
else controls.exitFullscreen(); |
||||||
|
}, |
||||||
|
[controls, videoInterface] |
||||||
|
); |
||||||
|
|
||||||
|
const lastBackdropValue = useRef<boolean | null>(null); |
||||||
|
useEffect(() => { |
||||||
|
const currentValue = moved || mediaPlaying.isPaused; |
||||||
|
if (currentValue !== lastBackdropValue.current) { |
||||||
|
lastBackdropValue.current = currentValue; |
||||||
|
if (!currentValue) controls.closePopout(); |
||||||
|
props.onBackdropChange?.(currentValue); |
||||||
|
} |
||||||
|
}, [controls, moved, mediaPlaying, props]); |
||||||
|
const showUI = moved || mediaPlaying.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> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,32 @@ |
|||||||
|
import { Icons } from "@/components/Icon"; |
||||||
|
import { canFullscreen } from "@/utils/detectFeatures"; |
||||||
|
import { useVideoPlayerDescriptor } from "@/video/state/hooks"; |
||||||
|
import { useControls } from "@/video/state/logic/controls"; |
||||||
|
import { useInterface } from "@/video/state/logic/interface"; |
||||||
|
import { useCallback } from "react"; |
||||||
|
import { VideoPlayerIconButton } from "../parts/VideoPlayerIconButton"; |
||||||
|
|
||||||
|
interface Props { |
||||||
|
className?: string; |
||||||
|
} |
||||||
|
|
||||||
|
export function FullscreenAction(props: Props) { |
||||||
|
const descriptor = useVideoPlayerDescriptor(); |
||||||
|
const videoInterface = useInterface(descriptor); |
||||||
|
const controls = useControls(descriptor); |
||||||
|
|
||||||
|
const handleClick = useCallback(() => { |
||||||
|
if (videoInterface.isFullscreen) controls.exitFullscreen(); |
||||||
|
else controls.enterFullscreen(); |
||||||
|
}, [controls, videoInterface]); |
||||||
|
|
||||||
|
if (!canFullscreen()) return null; |
||||||
|
|
||||||
|
return ( |
||||||
|
<VideoPlayerIconButton |
||||||
|
className={props.className} |
||||||
|
onClick={handleClick} |
||||||
|
icon={videoInterface.isFullscreen ? Icons.COMPRESS : Icons.EXPAND} |
||||||
|
/> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,25 @@ |
|||||||
|
import { PauseAction } from "@/video/components/actions/PauseAction"; |
||||||
|
import { |
||||||
|
SkipTimeBackwardAction, |
||||||
|
SkipTimeForwardAction, |
||||||
|
} from "@/video/components/actions/SkipTimeAction"; |
||||||
|
import { useVideoPlayerDescriptor } from "@/video/state/hooks"; |
||||||
|
import { useMediaPlaying } from "@/video/state/logic/mediaplaying"; |
||||||
|
|
||||||
|
export function MobileCenterAction() { |
||||||
|
const descriptor = useVideoPlayerDescriptor(); |
||||||
|
const mediaPlaying = useMediaPlaying(descriptor); |
||||||
|
|
||||||
|
const isLoading = mediaPlaying.isFirstLoading || mediaPlaying.isLoading; |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="flex items-center space-x-8"> |
||||||
|
<SkipTimeBackwardAction /> |
||||||
|
<PauseAction |
||||||
|
iconSize="text-5xl" |
||||||
|
className={isLoading ? "pointer-events-none opacity-0" : ""} |
||||||
|
/> |
||||||
|
<SkipTimeForwardAction /> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,16 @@ |
|||||||
|
import { getPlayerState } from "@/video/state/cache"; |
||||||
|
import { useVideoPlayerDescriptor } from "@/video/state/hooks"; |
||||||
|
import { useEffect } from "react"; |
||||||
|
|
||||||
|
export function WrapperRegisterInternal(props: { |
||||||
|
wrapper: HTMLDivElement | null; |
||||||
|
}) { |
||||||
|
const descriptor = useVideoPlayerDescriptor(); |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
const state = getPlayerState(descriptor); |
||||||
|
state.wrapperElement = props.wrapper; |
||||||
|
}, [props.wrapper, descriptor]); |
||||||
|
|
||||||
|
return null; |
||||||
|
} |
@ -0,0 +1,47 @@ |
|||||||
|
import { useEffect, useState } from "react"; |
||||||
|
import { getPlayerState } from "../cache"; |
||||||
|
import { listenEvent, sendEvent, unlistenEvent } from "../events"; |
||||||
|
import { VideoPlayerState } from "../types"; |
||||||
|
|
||||||
|
export type VideoInterfaceEvent = { |
||||||
|
popout: string | null; |
||||||
|
leftControlHovering: boolean; |
||||||
|
isFocused: boolean; |
||||||
|
isFullscreen: boolean; |
||||||
|
}; |
||||||
|
|
||||||
|
function getInterfaceFromState(state: VideoPlayerState): VideoInterfaceEvent { |
||||||
|
return { |
||||||
|
popout: state.popout, |
||||||
|
leftControlHovering: state.leftControlHovering, |
||||||
|
isFocused: state.isFocused, |
||||||
|
isFullscreen: state.isFullscreen, |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
export function updateInterface(descriptor: string, state: VideoPlayerState) { |
||||||
|
sendEvent<VideoInterfaceEvent>( |
||||||
|
descriptor, |
||||||
|
"interface", |
||||||
|
getInterfaceFromState(state) |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
export function useInterface(descriptor: string): VideoInterfaceEvent { |
||||||
|
const state = getPlayerState(descriptor); |
||||||
|
const [data, setData] = useState<VideoInterfaceEvent>( |
||||||
|
getInterfaceFromState(state) |
||||||
|
); |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
function update(payload: CustomEvent<VideoInterfaceEvent>) { |
||||||
|
setData(payload.detail); |
||||||
|
} |
||||||
|
listenEvent(descriptor, "interface", update); |
||||||
|
return () => { |
||||||
|
unlistenEvent(descriptor, "interface", update); |
||||||
|
}; |
||||||
|
}, [descriptor]); |
||||||
|
|
||||||
|
return data; |
||||||
|
} |
Loading…
Reference in new issue