add back standard video UI

This commit is contained in:
Jelle van Snik 2023-02-04 01:01:54 +01:00
parent a0c24209bb
commit 27ef9be6b1
15 changed files with 508 additions and 11 deletions

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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<HTMLDivElement>(null);
// TODO error boundary
// TODO move error boundary to only decorated, <VideoPlayer /> shouldn't have styling
// TODO internal controls
return (
<VideoPlayerContextProvider>
<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]">
<div
ref={ref}
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]"
>
<VideoElementInternal />
<WrapperRegisterInternal wrapper={ref.current} />
<div className="absolute inset-0">{props.children}</div>
</div>
</VideoPlayerContextProvider>

View File

@ -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>
);
}

View File

@ -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}
/>
);
}

View File

@ -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>
);
}

View File

@ -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;
}

View File

@ -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}`;

View File

@ -27,6 +27,7 @@ function initPlayer(): VideoPlayerState {
canAirplay: false,
stateProvider: null,
source: null,
wrapperElement: null,
};
}

View File

@ -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);
},
};
}

View File

@ -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;
}

View File

@ -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 & {

View File

@ -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);
},
};
},

View File

@ -43,4 +43,5 @@ export type VideoPlayerState = {
url: string;
type: MWStreamType;
};
wrapperElement: HTMLDivElement | null;
};

View File

@ -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 (
<VideoPlayerBase>
<PauseAction />
<VideoPlayer>
<SourceController
quality={MWStreamQuality.QUNKNOWN}
source="http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/Sintel.mp4"
type={MWStreamType.MP4}
/>
<MiddlePauseAction />
<ProgressAction />
<LoadingAction />
<TimeAction />
<SkipTimeAction />
</VideoPlayerBase>
</VideoPlayer>
);
}