15 changed files with 508 additions and 11 deletions
@ -0,0 +1,68 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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