mirror of
https://github.com/movie-web/movie-web.git
synced 2024-12-24 19:11:49 +01:00
lots of UI changes for video player
This commit is contained in:
parent
02ef6c5bf1
commit
35c7ac4b8d
@ -13,12 +13,14 @@
|
||||
"i18next-browser-languagedetector": "^7.0.1",
|
||||
"i18next-http-backend": "^2.1.0",
|
||||
"json5": "^2.2.0",
|
||||
"lodash.throttle": "^4.1.1",
|
||||
"nanoid": "^4.0.0",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-i18next": "^12.1.1",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-stickynode": "^4.1.0",
|
||||
"react-transition-group": "^4.4.5",
|
||||
"srt-webvtt": "^2.0.0",
|
||||
"unpacker": "^1.0.1"
|
||||
},
|
||||
@ -46,12 +48,14 @@
|
||||
"@tailwindcss/line-clamp": "^0.4.2",
|
||||
"@types/crypto-js": "^4.1.1",
|
||||
"@types/fscreen": "^1.0.1",
|
||||
"@types/lodash.throttle": "^4.1.7",
|
||||
"@types/node": "^17.0.15",
|
||||
"@types/react": "^17.0.39",
|
||||
"@types/react-dom": "^17.0.11",
|
||||
"@types/react-router": "^5.1.18",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@types/react-stickynode": "^4.0.0",
|
||||
"@types/react-transition-group": "^4.4.5",
|
||||
"@typescript-eslint/eslint-plugin": "^5.13.0",
|
||||
"@typescript-eslint/parser": "^5.13.0",
|
||||
"@vitejs/plugin-react-swc": "^3.0.0",
|
||||
|
@ -1,3 +1,5 @@
|
||||
import { memo } from "react";
|
||||
|
||||
export enum Icons {
|
||||
SEARCH = "search",
|
||||
BOOKMARK = "bookmark",
|
||||
@ -51,11 +53,11 @@ const iconList: Record<Icons, string> = {
|
||||
volume_x: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 576 512"><!--! Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M301.1 34.8C312.6 40 320 51.4 320 64V448c0 12.6-7.4 24-18.9 29.2s-25 3.1-34.4-5.3L131.8 352H64c-35.3 0-64-28.7-64-64V224c0-35.3 28.7-64 64-64h67.8L266.7 40.1c9.4-8.4 22.9-10.4 34.4-5.3zM425 167l55 55 55-55c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9l-55 55 55 55c9.4 9.4 9.4 24.6 0 33.9s-24.6 9.4-33.9 0l-55-55-55 55c-9.4 9.4-24.6 9.4-33.9 0s-9.4-24.6 0-33.9l55-55-55-55c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0z"/></svg>`,
|
||||
};
|
||||
|
||||
export function Icon(props: IconProps) {
|
||||
export const Icon = memo((props: IconProps) => {
|
||||
return (
|
||||
<span
|
||||
dangerouslySetInnerHTML={{ __html: iconList[props.icon] }} // eslint-disable-line react/no-danger
|
||||
className={props.className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
19
src/components/layout/Spinner.css
Normal file
19
src/components/layout/Spinner.css
Normal file
@ -0,0 +1,19 @@
|
||||
.spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 5px solid white;
|
||||
border-bottom-color: transparent;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
box-sizing: border-box;
|
||||
animation: spinner-rotation 800ms linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spinner-rotation {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
5
src/components/layout/Spinner.tsx
Normal file
5
src/components/layout/Spinner.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import "./Spinner.css";
|
||||
|
||||
export function Spinner() {
|
||||
return <div className="spinner" />;
|
||||
}
|
@ -1,35 +1,106 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { CSSTransition } from "react-transition-group";
|
||||
import { BackdropControl } from "./controls/BackdropControl";
|
||||
import { FullscreenControl } from "./controls/FullscreenControl";
|
||||
import { LoadingControl } from "./controls/LoadingControl";
|
||||
import { MiddlePauseControl } from "./controls/MiddlePauseControl";
|
||||
import { PauseControl } from "./controls/PauseControl";
|
||||
import { ProgressControl } from "./controls/ProgressControl";
|
||||
import { TimeControl } from "./controls/TimeControl";
|
||||
import { VolumeControl } from "./controls/VolumeControl";
|
||||
import { VideoPlayerHeader } from "./parts/VideoPlayerHeader";
|
||||
import { useVideoPlayerState } from "./VideoContext";
|
||||
import { VideoPlayer, VideoPlayerProps } from "./VideoPlayer";
|
||||
|
||||
// TODO animate items away when hidden
|
||||
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 />
|
||||
<VolumeControl className="mr-2" />
|
||||
<TimeControl />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DecoratedVideoPlayer(props: VideoPlayerProps) {
|
||||
const top = useRef<HTMLDivElement>(null);
|
||||
const bottom = useRef<HTMLDivElement>(null);
|
||||
const [show, setShow] = useState(false);
|
||||
|
||||
const onBackdropChange = useCallback(
|
||||
(showing: boolean) => {
|
||||
setShow(showing);
|
||||
},
|
||||
[setShow]
|
||||
);
|
||||
|
||||
return (
|
||||
<VideoPlayer autoPlay={props.autoPlay}>
|
||||
<BackdropControl>
|
||||
<BackdropControl onBackdropChange={onBackdropChange}>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<LoadingControl />
|
||||
</div>
|
||||
<div className="pointer-events-auto absolute inset-x-0 bottom-0 flex flex-col px-4 pb-2">
|
||||
<ProgressControl />
|
||||
<div className="flex items-center px-2">
|
||||
<PauseControl />
|
||||
<VolumeControl className="mr-2" />
|
||||
<TimeControl />
|
||||
<div className="flex-1" />
|
||||
<FullscreenControl />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<MiddlePauseControl />
|
||||
</div>
|
||||
<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"
|
||||
>
|
||||
<ProgressControl />
|
||||
<div className="flex items-center">
|
||||
<LeftSideControls />
|
||||
<div className="flex-1" />
|
||||
<FullscreenControl />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pointer-events-auto absolute inset-x-0 top-0 flex flex-col py-6 px-8 pb-2">
|
||||
<VideoPlayerHeader title="Spiderman: Coming House" />
|
||||
</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 title="Spiderman: Coming House" />
|
||||
</div>
|
||||
</CSSTransition>
|
||||
</BackdropControl>
|
||||
{props.children}
|
||||
</VideoPlayer>
|
||||
|
@ -7,24 +7,26 @@ import React, {
|
||||
} from "react";
|
||||
import {
|
||||
initialPlayerState,
|
||||
PlayerState,
|
||||
PlayerContext,
|
||||
useVideoPlayer,
|
||||
} from "./hooks/useVideoPlayer";
|
||||
|
||||
interface VideoPlayerContextType {
|
||||
source: string | null;
|
||||
state: PlayerState;
|
||||
sourceType: "m3u8" | "mp4";
|
||||
state: PlayerContext;
|
||||
}
|
||||
const initial: VideoPlayerContextType = {
|
||||
source: null,
|
||||
sourceType: "mp4",
|
||||
state: initialPlayerState,
|
||||
};
|
||||
|
||||
type VideoPlayerContextAction =
|
||||
| { type: "SET_SOURCE"; url: string }
|
||||
| { type: "SET_SOURCE"; url: string; sourceType: "m3u8" | "mp4" }
|
||||
| {
|
||||
type: "UPDATE_PLAYER";
|
||||
state: PlayerState;
|
||||
state: PlayerContext;
|
||||
};
|
||||
|
||||
function videoPlayerContextReducer(
|
||||
@ -34,6 +36,7 @@ function videoPlayerContextReducer(
|
||||
const video = { ...original };
|
||||
if (action.type === "SET_SOURCE") {
|
||||
video.source = action.url;
|
||||
video.sourceType = action.sourceType;
|
||||
return video;
|
||||
}
|
||||
if (action.type === "UPDATE_PLAYER") {
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { forwardRef, useContext, useRef } from "react";
|
||||
import { forwardRef, useContext, useEffect, useRef } from "react";
|
||||
import { VideoPlayerContext, VideoPlayerContextProvider } from "./VideoContext";
|
||||
|
||||
export interface VideoPlayerProps {
|
||||
@ -11,16 +11,24 @@ const VideoPlayerInternals = forwardRef<
|
||||
{ autoPlay: boolean }
|
||||
>((props, ref) => {
|
||||
const video = useContext(VideoPlayerContext);
|
||||
const didInitialize = useRef<true | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (didInitialize.current) return;
|
||||
if (!video.state.hasInitialized || !video.source) return;
|
||||
video.state.initPlayer(video.source, video.sourceType);
|
||||
didInitialize.current = true;
|
||||
}, [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"
|
||||
>
|
||||
{video.source ? <source src={video.source} type="video/mp4" /> : null}
|
||||
</video>
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@ -31,7 +39,7 @@ export function VideoPlayer(props: VideoPlayerProps) {
|
||||
return (
|
||||
<VideoPlayerContextProvider player={playerRef} wrapper={playerWrapperRef}>
|
||||
<div
|
||||
className="relative aspect-video w-full select-none bg-black"
|
||||
className="relative aspect-video w-full select-none overflow-hidden bg-black"
|
||||
ref={playerWrapperRef}
|
||||
>
|
||||
<VideoPlayerInternals
|
||||
|
@ -1,12 +1,11 @@
|
||||
import React, { useCallback, useRef, useState } from "react";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useVideoPlayerState } from "../VideoContext";
|
||||
|
||||
interface BackdropControlProps {
|
||||
children?: React.ReactNode;
|
||||
onBackdropChange?: (showing: boolean) => void;
|
||||
}
|
||||
|
||||
// TODO add double click to toggle fullscreen
|
||||
|
||||
export function BackdropControl(props: BackdropControlProps) {
|
||||
const { videoState } = useVideoPlayerState();
|
||||
const [moved, setMoved] = useState(false);
|
||||
@ -35,7 +34,19 @@ export function BackdropControl(props: BackdropControlProps) {
|
||||
},
|
||||
[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]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
props.onBackdropChange?.(moved || videoState.isPaused);
|
||||
}, [videoState, moved, props]);
|
||||
const showUI = moved || videoState.isPaused;
|
||||
|
||||
return (
|
||||
@ -45,6 +56,7 @@ export function BackdropControl(props: BackdropControlProps) {
|
||||
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 ${
|
||||
@ -62,7 +74,7 @@ export function BackdropControl(props: BackdropControlProps) {
|
||||
}`}
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-0">
|
||||
{showUI ? props.children : null}
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { Icons } from "@/components/Icon";
|
||||
import { canFullscreen } from "@/utils/detectFeatures";
|
||||
import { useCallback } from "react";
|
||||
import { VideoPlayerIconButton } from "../parts/VideoPlayerIconButton";
|
||||
import { useVideoPlayerState } from "../VideoContext";
|
||||
import { canFullscreen } from "../hooks/fullscreen";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
@ -16,7 +16,7 @@ export function FullscreenControl(props: Props) {
|
||||
else videoState.enterFullscreen();
|
||||
}, [videoState]);
|
||||
|
||||
if (!canFullscreen) return null;
|
||||
if (!canFullscreen()) return null;
|
||||
|
||||
return (
|
||||
<VideoPlayerIconButton
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { Spinner } from "@/components/layout/Spinner";
|
||||
import { useVideoPlayerState } from "../VideoContext";
|
||||
|
||||
export function LoadingControl() {
|
||||
@ -5,5 +6,5 @@ export function LoadingControl() {
|
||||
|
||||
if (!videoState.isLoading) return null;
|
||||
|
||||
return <p>Loading...</p>;
|
||||
return <Spinner />;
|
||||
}
|
||||
|
27
src/components/video/controls/MiddlePauseControl.tsx
Normal file
27
src/components/video/controls/MiddlePauseControl.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
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;
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
@ -3,12 +3,13 @@ import {
|
||||
makePercentageString,
|
||||
useProgressBar,
|
||||
} from "@/hooks/useProgressBar";
|
||||
import { useCallback, useRef } from "react";
|
||||
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) => {
|
||||
@ -20,6 +21,11 @@ export function ProgressControl() {
|
||||
ref,
|
||||
commitTime
|
||||
);
|
||||
useEffect(() => {
|
||||
if (dragRef.current === dragging) return;
|
||||
dragRef.current = dragging;
|
||||
videoState.setSeeking(dragging);
|
||||
}, [dragRef, dragging, videoState]);
|
||||
|
||||
let watchProgress = makePercentageString(
|
||||
makePercentage((videoState.time / videoState.duration) * 100)
|
||||
|
39
src/components/video/controls/ProgressListenerControl.tsx
Normal file
39
src/components/video/controls/ProgressListenerControl.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
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, videoState, props]);
|
||||
|
||||
return null;
|
||||
}
|
@ -3,6 +3,7 @@ import { VideoPlayerDispatchContext } from "../VideoContext";
|
||||
|
||||
interface SourceControlProps {
|
||||
source: string;
|
||||
type: "m3u8" | "mp4";
|
||||
}
|
||||
|
||||
export function SourceControl(props: SourceControlProps) {
|
||||
@ -12,8 +13,9 @@ export function SourceControl(props: SourceControlProps) {
|
||||
dispatch({
|
||||
type: "SET_SOURCE",
|
||||
url: props.source,
|
||||
sourceType: props.type,
|
||||
});
|
||||
}, [props.source, dispatch]);
|
||||
}, [props, dispatch]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
@ -5,6 +5,11 @@ function durationExceedsHour(secs: number): boolean {
|
||||
}
|
||||
|
||||
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 = time % 60;
|
||||
|
||||
@ -14,12 +19,13 @@ function formatSeconds(secs: number, showHours = false): string {
|
||||
time /= 60;
|
||||
const hours = minutes % 60;
|
||||
|
||||
const minuteString = `${Math.round(minutes)
|
||||
if (!showHours)
|
||||
return `${Math.round(minutes).toString()}:${Math.round(seconds)
|
||||
.toString()
|
||||
.padStart(2, "0")}`;
|
||||
return `${Math.round(hours).toString()}:${Math.round(minutes)
|
||||
.toString()
|
||||
.padStart(2)}:${Math.round(seconds).toString().padStart(2, "0")}`;
|
||||
|
||||
if (!showHours) return minuteString;
|
||||
return `${Math.round(hours).toString()}:${minuteString}`;
|
||||
.padStart(2, "0")}:${Math.round(seconds).toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
|
@ -4,15 +4,14 @@ import {
|
||||
makePercentageString,
|
||||
useProgressBar,
|
||||
} from "@/hooks/useProgressBar";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { canChangeVolume } from "@/utils/detectFeatures";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useVideoPlayerState } from "../VideoContext";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// TODO make hoveredOnce false when control bar appears
|
||||
|
||||
export function VolumeControl(props: Props) {
|
||||
const { videoState } = useVideoPlayerState();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
@ -32,6 +31,10 @@ export function VolumeControl(props: Props) {
|
||||
true
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!videoState.leftControlHovering) setHoveredOnce(false);
|
||||
}, [videoState, setHoveredOnce]);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (videoState.volume > 0) {
|
||||
videoState.setVolume(0);
|
||||
@ -41,8 +44,8 @@ export function VolumeControl(props: Props) {
|
||||
}
|
||||
}, [videoState, setStoredVolume, storedVolume]);
|
||||
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
setHoveredOnce(true);
|
||||
const handleMouseEnter = useCallback(async () => {
|
||||
if (await canChangeVolume()) setHoveredOnce(true);
|
||||
}, [setHoveredOnce]);
|
||||
|
||||
let percentage = makePercentage(videoState.volume * 100);
|
||||
@ -59,7 +62,7 @@ export function VolumeControl(props: Props) {
|
||||
<Icon icon={percentage > 0 ? Icons.VOLUME : Icons.VOLUME_X} />
|
||||
</div>
|
||||
<div
|
||||
className={`-ml-2 w-0 overflow-hidden transition-[width,opacity] duration-300 ease-in ${
|
||||
className={`linear -ml-2 w-0 overflow-hidden transition-[width,opacity] duration-300 ${
|
||||
hoveredOnce ? "!w-24 opacity-100" : "w-4 opacity-0"
|
||||
}`}
|
||||
>
|
||||
|
@ -1,5 +1,14 @@
|
||||
import Hls from "hls.js";
|
||||
import {
|
||||
canChangeVolume,
|
||||
canFullscreen,
|
||||
canFullscreenAnyElement,
|
||||
canWebkitFullscreen,
|
||||
} from "@/utils/detectFeatures";
|
||||
import fscreen from "fscreen";
|
||||
import { canFullscreen, isSafari } from "./fullscreen";
|
||||
import React, { RefObject } from "react";
|
||||
import { PlayerState } from "./useVideoPlayer";
|
||||
import { getStoredVolume, setStoredVolume } from "./volumeStore";
|
||||
|
||||
export interface PlayerControls {
|
||||
play(): void;
|
||||
@ -8,6 +17,9 @@ export interface PlayerControls {
|
||||
enterFullscreen(): void;
|
||||
setTime(time: number): void;
|
||||
setVolume(volume: number): void;
|
||||
setSeeking(active: boolean): void;
|
||||
setLeftControlsHover(hovering: boolean): void;
|
||||
initPlayer(sourceUrl: string, sourceType: "m3u8" | "mp4"): void;
|
||||
}
|
||||
|
||||
export const initialControls: PlayerControls = {
|
||||
@ -17,12 +29,20 @@ export const initialControls: PlayerControls = {
|
||||
exitFullscreen: () => null,
|
||||
setTime: () => null,
|
||||
setVolume: () => null,
|
||||
setSeeking: () => null,
|
||||
setLeftControlsHover: () => null,
|
||||
initPlayer: () => null,
|
||||
};
|
||||
|
||||
export function populateControls(
|
||||
player: HTMLVideoElement,
|
||||
wrapper: HTMLDivElement
|
||||
playerEl: HTMLVideoElement,
|
||||
wrapperEl: HTMLDivElement,
|
||||
update: (s: React.SetStateAction<PlayerState>) => void,
|
||||
state: RefObject<PlayerState>
|
||||
): PlayerControls {
|
||||
const player = playerEl;
|
||||
const wrapper = wrapperEl;
|
||||
|
||||
return {
|
||||
play() {
|
||||
player.play();
|
||||
@ -31,12 +51,12 @@ export function populateControls(
|
||||
player.pause();
|
||||
},
|
||||
enterFullscreen() {
|
||||
if (!canFullscreen || fscreen.fullscreenElement) return;
|
||||
if (fscreen.fullscreenEnabled) {
|
||||
if (!canFullscreen() || fscreen.fullscreenElement) return;
|
||||
if (canFullscreenAnyElement()) {
|
||||
fscreen.requestFullscreen(wrapper);
|
||||
return;
|
||||
}
|
||||
if (isSafari) {
|
||||
if (canWebkitFullscreen()) {
|
||||
(player as any).webkitEnterFullscreen();
|
||||
}
|
||||
},
|
||||
@ -48,15 +68,66 @@ export function populateControls(
|
||||
// clamp time between 0 and max duration
|
||||
let time = Math.min(t, player.duration);
|
||||
time = Math.max(0, time);
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
|
||||
if (Number.isNaN(time)) return;
|
||||
|
||||
// update state
|
||||
player.currentTime = time;
|
||||
update((s) => ({ ...s, time }));
|
||||
},
|
||||
setVolume(v) {
|
||||
async setVolume(v) {
|
||||
// clamp time between 0 and 1
|
||||
let volume = Math.min(v, 1);
|
||||
volume = Math.max(0, volume);
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
player.volume = 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 }));
|
||||
},
|
||||
initPlayer(sourceUrl: string, sourceType: "m3u8" | "mp4") {
|
||||
this.setVolume(getStoredVolume());
|
||||
|
||||
if (sourceType === "m3u8") {
|
||||
if (player.canPlayType("application/vnd.apple.mpegurl")) {
|
||||
player.src = sourceUrl;
|
||||
} else {
|
||||
// HLS support
|
||||
if (!Hls.isSupported()) throw new Error("HLS not supported"); // TODO handle errors
|
||||
|
||||
const hls = new Hls();
|
||||
|
||||
hls.on(Hls.Events.ERROR, (event, data) => {
|
||||
// eslint-disable-next-line no-alert
|
||||
if (data.fatal) alert("HLS fatal error");
|
||||
console.error("HLS error", data); // TODO handle errors
|
||||
});
|
||||
|
||||
hls.attachMedia(player);
|
||||
hls.loadSource(sourceUrl);
|
||||
}
|
||||
} else if (sourceType === "mp4") {
|
||||
player.src = sourceUrl;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
@ -1,6 +0,0 @@
|
||||
import fscreen from "fscreen";
|
||||
|
||||
export const isSafari = /^((?!chrome|android).)*safari/i.test(
|
||||
navigator.userAgent
|
||||
);
|
||||
export const canFullscreen = fscreen.fullscreenEnabled || isSafari;
|
@ -1,5 +1,6 @@
|
||||
import { canChangeVolume } from "@/utils/detectFeatures";
|
||||
import fscreen from "fscreen";
|
||||
import React, { MutableRefObject, useEffect, useState } from "react";
|
||||
import React, { MutableRefObject, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
initialControls,
|
||||
PlayerControls,
|
||||
@ -17,9 +18,15 @@ export type PlayerState = {
|
||||
duration: number;
|
||||
volume: number;
|
||||
buffered: number;
|
||||
} & PlayerControls;
|
||||
pausedWhenSeeking: boolean;
|
||||
hasInitialized: boolean;
|
||||
leftControlHovering: boolean;
|
||||
hasPlayedOnce: boolean;
|
||||
};
|
||||
|
||||
export const initialPlayerState: PlayerState = {
|
||||
export type PlayerContext = PlayerState & PlayerControls;
|
||||
|
||||
export const initialPlayerState: PlayerContext = {
|
||||
isPlaying: false,
|
||||
isPaused: true,
|
||||
isFullscreen: false,
|
||||
@ -29,10 +36,14 @@ export const initialPlayerState: PlayerState = {
|
||||
duration: 0,
|
||||
volume: 0,
|
||||
buffered: 0,
|
||||
pausedWhenSeeking: false,
|
||||
hasInitialized: false,
|
||||
leftControlHovering: false,
|
||||
hasPlayedOnce: false,
|
||||
...initialControls,
|
||||
};
|
||||
|
||||
type SetPlayer = (s: React.SetStateAction<PlayerState>) => void;
|
||||
type SetPlayer = (s: React.SetStateAction<PlayerContext>) => void;
|
||||
|
||||
function readState(player: HTMLVideoElement, update: SetPlayer) {
|
||||
const state = {
|
||||
@ -47,8 +58,13 @@ function readState(player: HTMLVideoElement, update: SetPlayer) {
|
||||
state.volume = player.volume;
|
||||
state.buffered = handleBuffered(player.currentTime, player.buffered);
|
||||
state.isLoading = false;
|
||||
state.hasInitialized = true;
|
||||
|
||||
update(state);
|
||||
update((s) => ({
|
||||
...state,
|
||||
pausedWhenSeeking: s.pausedWhenSeeking,
|
||||
hasPlayedOnce: s.hasPlayedOnce,
|
||||
}));
|
||||
}
|
||||
|
||||
function registerListeners(player: HTMLVideoElement, update: SetPlayer) {
|
||||
@ -65,6 +81,7 @@ function registerListeners(player: HTMLVideoElement, update: SetPlayer) {
|
||||
isPaused: false,
|
||||
isPlaying: true,
|
||||
isLoading: false,
|
||||
hasPlayedOnce: true,
|
||||
}));
|
||||
};
|
||||
const seeking = () => {
|
||||
@ -92,11 +109,12 @@ function registerListeners(player: HTMLVideoElement, update: SetPlayer) {
|
||||
duration: player.duration,
|
||||
}));
|
||||
};
|
||||
const volumechange = () => {
|
||||
update((s) => ({
|
||||
...s,
|
||||
volume: player.volume,
|
||||
}));
|
||||
const volumechange = async () => {
|
||||
if (await canChangeVolume())
|
||||
update((s) => ({
|
||||
...s,
|
||||
volume: player.volume,
|
||||
}));
|
||||
};
|
||||
const progress = () => {
|
||||
update((s) => ({
|
||||
@ -135,6 +153,7 @@ export function useVideoPlayer(
|
||||
wrapperRef: MutableRefObject<HTMLDivElement | null>
|
||||
) {
|
||||
const [state, setState] = useState(initialPlayerState);
|
||||
const stateRef = useRef<PlayerState | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const player = ref.current;
|
||||
@ -142,9 +161,16 @@ export function useVideoPlayer(
|
||||
if (player && wrapper) {
|
||||
readState(player, setState);
|
||||
registerListeners(player, setState);
|
||||
setState((s) => ({ ...s, ...populateControls(player, wrapper) }));
|
||||
setState((s) => ({
|
||||
...s,
|
||||
...populateControls(player, wrapper, setState as any, stateRef),
|
||||
}));
|
||||
}
|
||||
}, [ref, wrapperRef]);
|
||||
}, [ref, wrapperRef, stateRef]);
|
||||
|
||||
useEffect(() => {
|
||||
stateRef.current = state;
|
||||
}, [state, stateRef]);
|
||||
|
||||
return {
|
||||
playerState: state,
|
||||
|
25
src/components/video/hooks/volumeStore.ts
Normal file
25
src/components/video/hooks/volumeStore.ts
Normal file
@ -0,0 +1,25 @@
|
||||
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,
|
||||
});
|
||||
}
|
@ -20,8 +20,8 @@ export function useProgressBar(
|
||||
function mouseMove(ev: MouseEvent) {
|
||||
if (!mouseDown || !barRef.current) return;
|
||||
const rect = barRef.current.getBoundingClientRect();
|
||||
const pos = ((ev.pageX - rect.left) / barRef.current.offsetWidth) * 100;
|
||||
setProgress(pos);
|
||||
const pos = (ev.pageX - rect.left) / barRef.current.offsetWidth;
|
||||
setProgress(pos * 100);
|
||||
if (commitImmediately) commit(pos);
|
||||
}
|
||||
|
||||
|
40
src/utils/detectFeatures.ts
Normal file
40
src/utils/detectFeatures.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import fscreen from "fscreen";
|
||||
|
||||
export const isSafari = /^((?!chrome|android).)*safari/i.test(
|
||||
navigator.userAgent
|
||||
);
|
||||
|
||||
let cachedVolumeResult: boolean | null = null;
|
||||
export async function canChangeVolume(): Promise<boolean> {
|
||||
if (cachedVolumeResult === null) {
|
||||
const timeoutPromise = new Promise<false>((resolve) => {
|
||||
setTimeout(() => resolve(false), 1e3);
|
||||
});
|
||||
const promise = new Promise<true>((resolve) => {
|
||||
const video = document.createElement("video");
|
||||
const handler = () => {
|
||||
video.removeEventListener("volumechange", handler);
|
||||
resolve(true);
|
||||
};
|
||||
|
||||
video.addEventListener("volumechange", handler);
|
||||
|
||||
video.volume = 0.5;
|
||||
});
|
||||
|
||||
cachedVolumeResult = await Promise.race([promise, timeoutPromise]);
|
||||
}
|
||||
return cachedVolumeResult;
|
||||
}
|
||||
|
||||
export function canFullscreenAnyElement(): boolean {
|
||||
return fscreen.fullscreenEnabled;
|
||||
}
|
||||
|
||||
export function canWebkitFullscreen(): boolean {
|
||||
return canFullscreenAnyElement() || isSafari;
|
||||
}
|
||||
|
||||
export function canFullscreen(): boolean {
|
||||
return canFullscreenAnyElement() || canWebkitFullscreen();
|
||||
}
|
@ -1,3 +1,4 @@
|
||||
import { ProgressListenerControl } from "@/components/video/controls/ProgressListenerControl";
|
||||
import { SourceControl } from "@/components/video/controls/SourceControl";
|
||||
import { DecoratedVideoPlayer } from "@/components/video/DecoratedVideoPlayer";
|
||||
import { useCallback, useState } from "react";
|
||||
@ -5,21 +6,16 @@ import { useCallback, useState } from "react";
|
||||
// test videos: https://gist.github.com/jsturgis/3b19447b304616f18657
|
||||
|
||||
// TODO video todos:
|
||||
// - improve seekables (if possible)
|
||||
// - error handling
|
||||
// - buffering
|
||||
// - middle pause button
|
||||
// - double click backdrop to toggle fullscreen
|
||||
// - make volume bar collapse when hovering away from left control section
|
||||
// - animate UI when showing/hiding
|
||||
// - shortcuts when player is active
|
||||
// - save volume in localstorage so persists between page reloads
|
||||
// - improve pausing while seeking/buffering
|
||||
// - volume control flashes old value when updating
|
||||
// - progress control flashes old value when updating
|
||||
// - captions
|
||||
// - IOS & IpadOS support: (no volume)
|
||||
// - HLS support: feature detection otherwise use HLS.js
|
||||
// - mobile UI
|
||||
// - safari fullscreen will make video overlap player controls
|
||||
// - safari progress bar is fucked
|
||||
|
||||
// TODO optional todos:
|
||||
// - shortcuts when player is active
|
||||
// - improve seekables (if possible)
|
||||
|
||||
export function TestView() {
|
||||
const [show, setShow] = useState(true);
|
||||
const handleClick = useCallback(() => {
|
||||
@ -33,7 +29,14 @@ export function TestView() {
|
||||
return (
|
||||
<div className="w-[40rem] max-w-full">
|
||||
<DecoratedVideoPlayer>
|
||||
<SourceControl source="http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/Sintel.mp4" />
|
||||
<SourceControl
|
||||
source="http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/Sintel.mp4"
|
||||
type="mp4"
|
||||
/>
|
||||
<ProgressListenerControl
|
||||
startAt={283}
|
||||
onProgress={(a, b) => console.log(a, b)}
|
||||
/>
|
||||
</DecoratedVideoPlayer>
|
||||
</div>
|
||||
);
|
||||
|
63
yarn.lock
63
yarn.lock
@ -10,7 +10,7 @@
|
||||
"core-js-pure" "^3.25.1"
|
||||
"regenerator-runtime" "^0.13.11"
|
||||
|
||||
"@babel/runtime@^7.1.2", "@babel/runtime@^7.10.2", "@babel/runtime@^7.12.13", "@babel/runtime@^7.14.5", "@babel/runtime@^7.18.9", "@babel/runtime@^7.19.4", "@babel/runtime@^7.20.6":
|
||||
"@babel/runtime@^7.1.2", "@babel/runtime@^7.10.2", "@babel/runtime@^7.12.13", "@babel/runtime@^7.14.5", "@babel/runtime@^7.18.9", "@babel/runtime@^7.19.4", "@babel/runtime@^7.20.6", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.7":
|
||||
"integrity" "sha512-Q+8MqP7TiHMWzSfwiJwXCjyf4GYA4Dgw3emg/7xmwsdLJOZUp+nMqcOwOzzYheuM1rhDu8FSj2l0aoMygEuXuA=="
|
||||
"resolved" "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.6.tgz"
|
||||
"version" "7.20.6"
|
||||
@ -287,6 +287,18 @@
|
||||
"resolved" "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz"
|
||||
"version" "0.0.29"
|
||||
|
||||
"@types/lodash.throttle@^4.1.7":
|
||||
"integrity" "sha512-znwGDpjCHQ4FpLLx19w4OXDqq8+OvREa05H89obtSyXyOFKL3dDjCslsmfBz0T2FU8dmf5Wx1QvogbINiGIu9g=="
|
||||
"resolved" "https://registry.npmjs.org/@types/lodash.throttle/-/lodash.throttle-4.1.7.tgz"
|
||||
"version" "4.1.7"
|
||||
dependencies:
|
||||
"@types/lodash" "*"
|
||||
|
||||
"@types/lodash@*":
|
||||
"integrity" "sha512-BdZ5BCCvho3EIXw6wUCXHe7rS53AIDPLE+JzwgT+OsJk53oBfbSmZZ7CX4VaRoN78N+TJpFi9QPlfIVNmJYWxQ=="
|
||||
"resolved" "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.191.tgz"
|
||||
"version" "4.14.191"
|
||||
|
||||
"@types/node@^17.0.15", "@types/node@>= 14":
|
||||
"integrity" "sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw=="
|
||||
"resolved" "https://registry.npmjs.org/@types/node/-/node-17.0.45.tgz"
|
||||
@ -328,6 +340,13 @@
|
||||
dependencies:
|
||||
"@types/react" "*"
|
||||
|
||||
"@types/react-transition-group@^4.4.5":
|
||||
"integrity" "sha512-juKD/eiSM3/xZYzjuzH6ZwpP+/lejltmiS3QEzV/vmb/Q8+HfDmxu+Baga8UEMGBqV88Nbg4l2hY/K2DkyaLLA=="
|
||||
"resolved" "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.5.tgz"
|
||||
"version" "4.4.5"
|
||||
dependencies:
|
||||
"@types/react" "*"
|
||||
|
||||
"@types/react@*", "@types/react@^17", "@types/react@^17.0.39":
|
||||
"integrity" "sha512-vwk8QqVODi0VaZZpDXQCmEmiOuyjEFPY7Ttaw5vjM112LOq37yz1CDJGrRJwA1fYEq4Iitd5rnjd1yWAc/bT+A=="
|
||||
"resolved" "https://registry.npmjs.org/@types/react/-/react-17.0.52.tgz"
|
||||
@ -998,6 +1017,14 @@
|
||||
dependencies:
|
||||
"esutils" "^2.0.2"
|
||||
|
||||
"dom-helpers@^5.0.1":
|
||||
"integrity" "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="
|
||||
"resolved" "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz"
|
||||
"version" "5.2.1"
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.8.7"
|
||||
"csstype" "^3.0.2"
|
||||
|
||||
"electron-to-chromium@^1.4.251":
|
||||
"integrity" "sha512-M8WEXFuKXMYMVr45fo8mq0wUrrJHheiKZf6BArTKk9ZBYCKJEOU5H8cdWgDT+qCVZf7Na4lVUaZsA+h6uA9+PA=="
|
||||
"resolved" "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.284.tgz"
|
||||
@ -1011,6 +1038,13 @@
|
||||
"resolved" "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz"
|
||||
"version" "9.2.2"
|
||||
|
||||
"encoding@^0.1.0":
|
||||
"integrity" "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A=="
|
||||
"resolved" "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz"
|
||||
"version" "0.1.13"
|
||||
dependencies:
|
||||
"iconv-lite" "^0.6.2"
|
||||
|
||||
"encoding@^0.1.13":
|
||||
"version" "0.1.13"
|
||||
dependencies:
|
||||
@ -1725,6 +1759,8 @@
|
||||
"@babel/runtime" "^7.20.6"
|
||||
|
||||
"iconv-lite@^0.6.2":
|
||||
"integrity" "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="
|
||||
"resolved" "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz"
|
||||
"version" "0.6.3"
|
||||
dependencies:
|
||||
"safer-buffer" ">= 2.1.2 < 3.0.0"
|
||||
@ -2123,6 +2159,11 @@
|
||||
"resolved" "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz"
|
||||
"version" "4.6.2"
|
||||
|
||||
"lodash.throttle@^4.1.1":
|
||||
"integrity" "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ=="
|
||||
"resolved" "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz"
|
||||
"version" "4.1.1"
|
||||
|
||||
"lodash@^4.17.15":
|
||||
"integrity" "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
|
||||
"resolved" "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz"
|
||||
@ -2850,7 +2891,7 @@
|
||||
dependencies:
|
||||
"performance-now" "^2.1.0"
|
||||
|
||||
"react-dom@^0.14.2 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0", "react-dom@^16 || ^17 || ^18", "react-dom@^17.0.2":
|
||||
"react-dom@^0.14.2 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0", "react-dom@^16 || ^17 || ^18", "react-dom@^17.0.2", "react-dom@>=16.6.0":
|
||||
"integrity" "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA=="
|
||||
"resolved" "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz"
|
||||
"version" "17.0.2"
|
||||
@ -2911,7 +2952,17 @@
|
||||
"shallowequal" "^1.0.0"
|
||||
"subscribe-ui-event" "^2.0.6"
|
||||
|
||||
"react@^0.14.2 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0", "react@^16 || ^17 || ^18", "react@^17.0.2", "react@>= 16.8.0", "react@>=15", "react@17.0.2":
|
||||
"react-transition-group@^4.4.5":
|
||||
"integrity" "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="
|
||||
"resolved" "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz"
|
||||
"version" "4.4.5"
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.5.5"
|
||||
"dom-helpers" "^5.0.1"
|
||||
"loose-envify" "^1.4.0"
|
||||
"prop-types" "^15.6.2"
|
||||
|
||||
"react@^0.14.2 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0", "react@^16 || ^17 || ^18", "react@^17.0.2", "react@>= 16.8.0", "react@>=15", "react@>=16.6.0", "react@17.0.2":
|
||||
"integrity" "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA=="
|
||||
"resolved" "https://registry.npmjs.org/react/-/react-17.0.2.tgz"
|
||||
"version" "17.0.2"
|
||||
@ -3047,6 +3098,8 @@
|
||||
"queue-microtask" "^1.2.2"
|
||||
|
||||
"safe-buffer@~5.2.0":
|
||||
"integrity" "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="
|
||||
"resolved" "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz"
|
||||
"version" "5.2.1"
|
||||
|
||||
"safe-regex-test@^1.0.0":
|
||||
@ -3059,6 +3112,8 @@
|
||||
"is-regex" "^1.1.4"
|
||||
|
||||
"safer-buffer@>= 2.1.2 < 3.0.0":
|
||||
"integrity" "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
|
||||
"resolved" "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz"
|
||||
"version" "2.1.2"
|
||||
|
||||
"scheduler@^0.20.2":
|
||||
@ -3173,6 +3228,8 @@
|
||||
"minipass" "^3.1.1"
|
||||
|
||||
"string_decoder@^1.1.1":
|
||||
"integrity" "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="
|
||||
"resolved" "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz"
|
||||
"version" "1.3.0"
|
||||
dependencies:
|
||||
"safe-buffer" "~5.2.0"
|
||||
|
Loading…
Reference in New Issue
Block a user