diff --git a/src/components/Transition.tsx b/src/components/Transition.tsx new file mode 100644 index 00000000..04523ecb --- /dev/null +++ b/src/components/Transition.tsx @@ -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(null); + const duration = props.duration ?? 200; + + return ( + +
+ {props.children} +
+
+ ); +} diff --git a/src/video/components/VideoPlayer.tsx b/src/video/components/VideoPlayer.tsx new file mode 100644 index 00000000..404db24b --- /dev/null +++ b/src/video/components/VideoPlayer.tsx @@ -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 ( +
+ {props.children} +
+ ); +} + +function LeftSideControls() { + const descriptor = useVideoPlayerDescriptor(); + const controls = useControls(descriptor); + + const handleMouseEnter = useCallback(() => { + controls.setLeftControlsHover(true); + }, [controls]); + const handleMouseLeave = useCallback(() => { + controls.setLeftControlsHover(false); + }, [controls]); + + return ( + <> +
+ + + {/* */} + +
+ {/* */} + + ); +} + +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 ( + + {/* */} + {/* */} + + + + + + + + {isMobile ? ( + + + + ) : ( + "" + )} + + {/* */} + + +
+ {isMobile && } + +
+
+ {isMobile ? ( +
+
+
+ {/* */} + {/* */} +
+ +
+ ) : ( + <> + +
+ {/* + + + + */} + + + )} +
+ + + {props.children} + {/* */} + + ); +} diff --git a/src/video/components/VideoPlayerBase.tsx b/src/video/components/VideoPlayerBase.tsx index 3fe23298..2dd6ced0 100644 --- a/src/video/components/VideoPlayerBase.tsx +++ b/src/video/components/VideoPlayerBase.tsx @@ -1,3 +1,5 @@ +import { WrapperRegisterInternal } from "@/video/components/internal/WrapperRegisterInternal"; +import { useRef } from "react"; import { VideoPlayerContextProvider } from "../state/hooks"; import { VideoElementInternal } from "./internal/VideoElementInternal"; @@ -6,14 +8,19 @@ export interface VideoPlayerBaseProps { } export function VideoPlayerBase(props: VideoPlayerBaseProps) { + const ref = useRef(null); // TODO error boundary // TODO move error boundary to only decorated, shouldn't have styling // TODO internal controls return ( -
+
+
{props.children}
diff --git a/src/video/components/actions/BackdropAction.tsx b/src/video/components/actions/BackdropAction.tsx new file mode 100644 index 00000000..c76fa456 --- /dev/null +++ b/src/video/components/actions/BackdropAction.tsx @@ -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 | null>(null); + const clickareaRef = useRef(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) => { + 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) => { + if (!clickareaRef.current || clickareaRef.current !== e.target) return; + + if (!videoInterface.isFullscreen) controls.enterFullscreen(); + else controls.exitFullscreen(); + }, + [controls, videoInterface] + ); + + const lastBackdropValue = useRef(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 ( +
+
+
+
+
+ {props.children} +
+
+ ); +} diff --git a/src/video/components/actions/FullscreenAction.tsx b/src/video/components/actions/FullscreenAction.tsx new file mode 100644 index 00000000..8d5a5f3c --- /dev/null +++ b/src/video/components/actions/FullscreenAction.tsx @@ -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 ( + + ); +} diff --git a/src/video/components/actions/MobileCenterAction.tsx b/src/video/components/actions/MobileCenterAction.tsx new file mode 100644 index 00000000..5a3a1912 --- /dev/null +++ b/src/video/components/actions/MobileCenterAction.tsx @@ -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 ( +
+ + + +
+ ); +} diff --git a/src/video/components/internal/WrapperRegisterInternal.tsx b/src/video/components/internal/WrapperRegisterInternal.tsx new file mode 100644 index 00000000..d8bb1351 --- /dev/null +++ b/src/video/components/internal/WrapperRegisterInternal.tsx @@ -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; +} diff --git a/src/video/state/events.ts b/src/video/state/events.ts index 96660fbc..b47b7d15 100644 --- a/src/video/state/events.ts +++ b/src/video/state/events.ts @@ -1,4 +1,8 @@ -export type VideoPlayerEvent = "mediaplaying" | "source" | "progress"; +export type VideoPlayerEvent = + | "mediaplaying" + | "source" + | "progress" + | "interface"; function createEventString(id: string, event: VideoPlayerEvent): string { return `_vid:::${id}:::${event}`; diff --git a/src/video/state/init.ts b/src/video/state/init.ts index e9f60396..ffe66a70 100644 --- a/src/video/state/init.ts +++ b/src/video/state/init.ts @@ -27,6 +27,7 @@ function initPlayer(): VideoPlayerState { canAirplay: false, stateProvider: null, source: null, + wrapperElement: null, }; } diff --git a/src/video/state/logic/controls.ts b/src/video/state/logic/controls.ts index b3c69df7..1044d85d 100644 --- a/src/video/state/logic/controls.ts +++ b/src/video/state/logic/controls.ts @@ -1,10 +1,21 @@ +import { updateInterface } from "@/video/state/logic/interface"; import { getPlayerState } from "../cache"; import { VideoPlayerStateController } from "../providers/providerTypes"; -export function useControls(descriptor: string): VideoPlayerStateController { +type ControlMethods = { + openPopout(id: string): void; + closePopout(): void; + setLeftControlsHover(hovering: boolean): void; + setFocused(focused: boolean): void; +}; + +export function useControls( + descriptor: string +): VideoPlayerStateController & ControlMethods { const state = getPlayerState(descriptor); return { + // state provider controls pause() { state.stateProvider?.pause(); }, @@ -20,5 +31,29 @@ export function useControls(descriptor: string): VideoPlayerStateController { setTime(time) { state.stateProvider?.setTime(time); }, + exitFullscreen() { + state.stateProvider?.exitFullscreen(); + }, + enterFullscreen() { + state.stateProvider?.enterFullscreen(); + }, + + // other controls + setLeftControlsHover(hovering) { + state.leftControlHovering = hovering; + updateInterface(descriptor, state); + }, + openPopout(id: string) { + state.popout = id; + updateInterface(descriptor, state); + }, + closePopout() { + state.popout = null; + updateInterface(descriptor, state); + }, + setFocused(focused) { + state.isFocused = focused; + updateInterface(descriptor, state); + }, }; } diff --git a/src/video/state/logic/interface.ts b/src/video/state/logic/interface.ts new file mode 100644 index 00000000..bd912b18 --- /dev/null +++ b/src/video/state/logic/interface.ts @@ -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( + descriptor, + "interface", + getInterfaceFromState(state) + ); +} + +export function useInterface(descriptor: string): VideoInterfaceEvent { + const state = getPlayerState(descriptor); + const [data, setData] = useState( + getInterfaceFromState(state) + ); + + useEffect(() => { + function update(payload: CustomEvent) { + setData(payload.detail); + } + listenEvent(descriptor, "interface", update); + return () => { + unlistenEvent(descriptor, "interface", update); + }; + }, [descriptor]); + + return data; +} diff --git a/src/video/state/providers/providerTypes.ts b/src/video/state/providers/providerTypes.ts index 3ad5c503..04cf3651 100644 --- a/src/video/state/providers/providerTypes.ts +++ b/src/video/state/providers/providerTypes.ts @@ -12,6 +12,8 @@ export type VideoPlayerStateController = { setSource: (source: VideoPlayerSource) => void; setTime(time: number): void; setSeeking(active: boolean): void; + exitFullscreen(): void; + enterFullscreen(): void; }; export type VideoPlayerStateProvider = VideoPlayerStateController & { diff --git a/src/video/state/providers/videoStateProvider.ts b/src/video/state/providers/videoStateProvider.ts index 4a0f5ed6..ddc9b437 100644 --- a/src/video/state/providers/videoStateProvider.ts +++ b/src/video/state/providers/videoStateProvider.ts @@ -1,5 +1,12 @@ import Hls from "hls.js"; +import fscreen from "fscreen"; +import { + canFullscreen, + canFullscreenAnyElement, + canWebkitFullscreen, +} from "@/utils/detectFeatures"; import { MWStreamType } from "@/backend/helpers/streams"; +import { updateInterface } from "@/video/state/logic/interface"; import { getPlayerState } from "../cache"; import { updateMediaPlaying } from "../logic/mediaplaying"; import { VideoPlayerStateProvider } from "./providerTypes"; @@ -20,6 +27,21 @@ export function createVideoStateProvider( pause() { player.pause(); }, + exitFullscreen() { + if (!fscreen.fullscreenElement) return; + fscreen.exitFullscreen(); + }, + enterFullscreen() { + if (!canFullscreen() || fscreen.fullscreenElement) return; + if (canFullscreenAnyElement()) { + if (state.wrapperElement) + fscreen.requestFullscreen(state.wrapperElement); + return; + } + if (canWebkitFullscreen()) { + (player as any).webkitEnterFullscreen(); + } + }, setTime(t) { // clamp time between 0 and max duration let time = Math.min(t, player.duration); @@ -127,6 +149,10 @@ export function createVideoStateProvider( state.isFirstLoading = false; updateMediaPlaying(descriptor, state); }; + const fullscreenchange = () => { + state.isFullscreen = !!document.fullscreenElement; + updateInterface(descriptor, state); + }; player.addEventListener("pause", pause); player.addEventListener("playing", playing); @@ -137,6 +163,7 @@ export function createVideoStateProvider( player.addEventListener("timeupdate", timeupdate); player.addEventListener("loadedmetadata", loadedmetadata); player.addEventListener("canplay", canplay); + fscreen.addEventListener("fullscreenchange", fullscreenchange); return { destroy: () => { player.removeEventListener("pause", pause); @@ -148,6 +175,7 @@ export function createVideoStateProvider( player.removeEventListener("progress", progress); player.removeEventListener("waiting", waiting); player.removeEventListener("canplay", canplay); + fscreen.removeEventListener("fullscreenchange", fullscreenchange); }, }; }, diff --git a/src/video/state/types.ts b/src/video/state/types.ts index 37309734..e2ecb3a0 100644 --- a/src/video/state/types.ts +++ b/src/video/state/types.ts @@ -43,4 +43,5 @@ export type VideoPlayerState = { url: string; type: MWStreamType; }; + wrapperElement: HTMLDivElement | null; }; diff --git a/src/views/TestView.tsx b/src/views/TestView.tsx index b3eb7551..eddbd3bc 100644 --- a/src/views/TestView.tsx +++ b/src/views/TestView.tsx @@ -12,6 +12,7 @@ import { ProgressAction } from "@/video/components/actions/ProgressAction"; import { SkipTimeAction } from "@/video/components/actions/SkipTimeAction"; import { TimeAction } from "@/video/components/actions/TimeAction"; import { SourceController } from "@/video/components/controllers/SourceController"; +import { VideoPlayer } from "@/video/components/VideoPlayer"; import { VideoPlayerBase } from "@/video/components/VideoPlayerBase"; // function ChromeCastButton() { @@ -30,18 +31,12 @@ import { VideoPlayerBase } from "@/video/components/VideoPlayerBase"; export function TestView() { return ( - - + - - - - - - + ); }