Merge pull request #1310 from ONLYOFFICE/feature/viewer-player-refactoring-and-optimization

Feature/viewer player refactoring and optimization
This commit is contained in:
Alexey Safronov 2023-03-21 15:04:40 +04:00 committed by GitHub
commit f0ca5c6856
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 2016 additions and 1366 deletions

View File

@ -82,3 +82,28 @@ export const findNearestIndex = (
export const isSeparator = (arg: ContextMenuModel): arg is SeparatorType => {
return arg?.isSeparator !== undefined && arg.isSeparator;
};
export const convertToTwoDigitString = (time: number): string => {
return time < 10 ? `0${time}` : time.toString();
};
export const formatTime = (time: number): string => {
if (isNullOrUndefined(time) || isNaN(time) || time <= 0) return "00:00";
let seconds: number = Math.floor(time % 60);
let minutes: number = Math.floor(time / 60) % 60;
let hours: number = Math.floor(time / 3600);
const convertedHours = convertToTwoDigitString(hours);
const convertedMinutes = convertToTwoDigitString(minutes);
const convertedSeconds = convertToTwoDigitString(seconds);
if (hours === 0) {
return `${convertedMinutes}:${convertedSeconds}`;
}
return `${convertedHours}:${convertedMinutes}:${convertedSeconds}`;
};
export const compareTo = (a: number, b: number) => {
return Math.trunc(a) > Math.trunc(b);
};

View File

@ -65,6 +65,12 @@ function MediaViewer({
}
}, [props.playlist.length]);
useEffect(() => {
return () => {
props.onClose();
};
}, []);
useEffect(() => {
const { playlist, files, setBufferSelection } = props;
@ -331,15 +337,6 @@ function MediaViewer({
break;
case KeyboardEventKeys.Space:
const videoPlayElement = document.getElementsByClassName(
"video-play"
)?.[0] as HTMLElement | undefined;
videoPlayElement?.click();
break;
case KeyboardEventKeys.Escape:
if (!props.deleteDialogVisible) props.onClose();
break;

View File

@ -23,7 +23,7 @@ import {
ImperativeHandle,
ToolbarItemType,
} from "../ImageViewerToolbar/ImageViewerToolbar.props";
import { ToolbarActionType, KeyboardEventKeys } from "../../helpers";
import { ToolbarActionType, KeyboardEventKeys, compareTo } from "../../helpers";
const MaxScale = 5;
const MinScale = 0.5;
@ -190,10 +190,6 @@ function ImageViewer({
setIsLoading(false);
}
const compareTo = (a: number, b: number) => {
return Math.trunc(a) > Math.trunc(b);
};
const getSizeByAngle = (
width: number,
height: number,

View File

@ -0,0 +1,6 @@
interface PlayerBigPlayButtonProps {
onClick: VoidFunction;
visible: boolean;
}
export default PlayerBigPlayButtonProps;

View File

@ -0,0 +1,11 @@
import styled from "styled-components";
const WrapperPlayerBigPlayButton = styled.div`
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
cursor: pointer;
`;
export default WrapperPlayerBigPlayButton;

View File

@ -0,0 +1,16 @@
import React from "react";
import BigIconPlay from "PUBLIC_DIR/images/media.bgplay.react.svg";
import WrapperPlayerBigPlayButton from "./PlayerBigPlayButton.styled";
import PlayerBigPlayButtonProps from "./PlayerBigPlayButton.props";
function PlayerBigPlayButton({ visible, onClick }: PlayerBigPlayButtonProps) {
if (!visible) return <></>;
return (
<WrapperPlayerBigPlayButton>
<BigIconPlay onClick={onClick} />
</WrapperPlayerBigPlayButton>
);
}
export default PlayerBigPlayButton;

View File

@ -0,0 +1,12 @@
interface PlayerDesktopContextMenuProps {
generateContextMenu: (
isOpen: boolean,
right?: string,
bottom?: string
) => JSX.Element;
isPreviewFile: boolean;
hideContextMenu: boolean;
onDownloadClick: VoidFunction;
}
export default PlayerDesktopContextMenuProps;

View File

@ -0,0 +1,40 @@
import styled from "styled-components";
export const PlayerDesktopContextMenuWrapper = styled.div`
position: relative;
display: flex;
justify-content: center;
align-items: center;
min-width: 48px;
height: 48px;
&:hover {
cursor: pointer;
}
& > svg {
padding-left: 19px;
padding-bottom: 3px;
width: 18px;
height: 20px;
path {
fill: #fff;
}
rect {
stroke: #fff;
}
}
`;
export const DownloadIconWrapper = styled.div`
display: flex;
justify-content: center;
align-items: center;
min-width: 48px;
height: 48px;
&:hover {
cursor: pointer;
}
`;

View File

@ -0,0 +1,61 @@
import React, { memo, useEffect, useMemo, useRef, useState } from "react";
import {
DownloadIconWrapper,
PlayerDesktopContextMenuWrapper,
} from "./PlayerDesktopContextMenu.styled";
import PlayerDesktopContextMenuProps from "./PlayerDesktopContextMenu.props";
import MediaContextMenu from "PUBLIC_DIR/images/vertical-dots.react.svg";
import DownloadReactSvgUrl from "PUBLIC_DIR/images/download.react.svg";
const ContextRight = "9";
const ContextBottom = "48";
function PlayerDesktopContextMenu({
isPreviewFile,
hideContextMenu,
onDownloadClick,
generateContextMenu,
}: PlayerDesktopContextMenuProps) {
const ref = useRef<HTMLDivElement>(null);
const [isOpenContext, setIsOpenContext] = useState<boolean>(false);
const context = useMemo(
() => generateContextMenu(isOpenContext, ContextRight, ContextBottom),
[generateContextMenu, isOpenContext]
);
const toggleContext = () => setIsOpenContext((pre) => !pre);
useEffect(() => {
const listener = (event: MouseEvent | TouchEvent) => {
if (!ref.current || ref.current.contains(event.target as Node)) {
return;
}
setIsOpenContext(false);
};
document.addEventListener("mousedown", listener);
return () => {
document.removeEventListener("mousedown", listener);
};
}, []);
if (hideContextMenu) {
return (
<DownloadIconWrapper onClick={onDownloadClick}>
<DownloadReactSvgUrl />
</DownloadIconWrapper>
);
}
if (isPreviewFile) return <></>;
return (
<PlayerDesktopContextMenuWrapper ref={ref} onClick={toggleContext}>
<MediaContextMenu />
{context}
</PlayerDesktopContextMenuWrapper>
);
}
export default memo(PlayerDesktopContextMenu);

View File

@ -0,0 +1,34 @@
import { mobile } from "@docspace/components/utils/device";
import React, { forwardRef, useState } from "react";
import styled from "styled-components";
import { formatTime } from "../../helpers";
const PlayerDurationWrapper = styled.div`
width: 102px;
color: #fff;
user-select: none;
font-size: 12px;
font-weight: 700;
margin-left: 10px;
@media ${mobile} {
margin-left: 0;
}
`;
type PlayerDurationProps = {
currentTime: number;
duration: number;
};
function PlayerDuration({ currentTime, duration }: PlayerDurationProps) {
return (
<PlayerDurationWrapper>
<time>{formatTime(currentTime)}</time> /{" "}
<time>{formatTime(duration)}</time>
</PlayerDurationWrapper>
);
}
export default PlayerDuration;

View File

@ -0,0 +1,7 @@
interface PlayerFullSceenProps {
isAudio: boolean;
isFullScreen: boolean;
onClick: VoidFunction;
}
export default PlayerFullSceenProps;

View File

@ -0,0 +1,15 @@
import styled from "styled-components";
export const PlayerFullSceenWrapper = styled.div`
display: flex;
justify-content: center;
align-items: center;
min-width: 48px;
height: 48px;
padding-left: 10px;
&:hover {
cursor: pointer;
}
`;

View File

@ -0,0 +1,23 @@
import React, { memo } from "react";
import PlayerFullSceenProps from "./PlayerFullScreen.props";
import { PlayerFullSceenWrapper } from "./PlayerFullScreen.styled";
import IconFullScreen from "PUBLIC_DIR/images/videoplayer.full.react.svg";
import IconExitFullScreen from "PUBLIC_DIR/images/videoplayer.exit.react.svg";
function PlayerFullScreen({
isAudio,
onClick,
isFullScreen,
}: PlayerFullSceenProps) {
if (isAudio) return <></>;
return (
<PlayerFullSceenWrapper onClick={onClick}>
{isFullScreen ? <IconExitFullScreen /> : <IconFullScreen />}
</PlayerFullSceenWrapper>
);
}
export default memo(PlayerFullScreen);

View File

@ -0,0 +1,8 @@
import { ContextMenuModel } from "./../../types/index";
interface PlayerMessageErrorProps {
errorTitle: string;
model: ContextMenuModel[];
onMaskClick: VoidFunction;
}
export default PlayerMessageErrorProps;

View File

@ -0,0 +1,65 @@
import styled from "styled-components";
export const StyledMediaError = styled.div`
position: fixed;
z-index: 1006;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
display: flex;
justify-content: center;
align-items: center;
width: 267px;
height: 56px;
background: rgba(0, 0, 0, 0.7);
opacity: 1;
border-radius: 20px;
`;
export const StyledErrorToolbar = styled.div`
position: fixed;
bottom: 24px;
left: 50%;
z-index: 1006;
transform: translateX(-50%);
display: flex;
justify-content: center;
align-items: center;
padding: 10px 24px;
border-radius: 18px;
background: rgba(0, 0, 0, 0.4);
&:hover {
background: rgba(0, 0, 0, 0.8);
}
svg {
path {
fill: white;
}
}
rect: {
stroke: white;
}
.toolbar-item {
display: flex;
justify-content: center;
align-items: center;
height: 48px;
width: 48px;
&:hover {
cursor: pointer;
}
}
`;

View File

@ -0,0 +1,54 @@
import React from "react";
import { isMobile } from "react-device-detect";
import { ReactSVG } from "react-svg";
import Text from "@docspace/components/text";
import PlayerMessageErrorProps from "./PlayerMessageError.props";
import {
StyledErrorToolbar,
StyledMediaError,
} from "./PlayerMessageError.styled";
import { isSeparator } from "../../helpers";
function PlayerMessageError({
errorTitle,
model,
onMaskClick,
}: PlayerMessageErrorProps) {
const items = !isMobile
? model.filter((el) => el.key !== "rename")
: model.filter((el) => el.key === "delete" || el.key === "download");
return (
<div>
<StyledMediaError>
{/*@ts-ignore*/}
<Text
fontSize="15px"
color={"#fff"}
textAlign="center"
className="title"
>
{errorTitle}
</Text>
</StyledMediaError>
<StyledErrorToolbar>
{items.map((item) => {
if (item.disabled || isSeparator(item)) return;
const onClick = () => {
onMaskClick();
item.onClick();
};
return (
<div className="toolbar-item" key={item.key} onClick={onClick}>
<ReactSVG src={item.icon} />
</div>
);
})}
</StyledErrorToolbar>
</div>
);
}
export default PlayerMessageError;

View File

@ -0,0 +1,35 @@
import React, { memo } from "react";
import IconPlay from "PUBLIC_DIR/images/videoplayer.play.react.svg";
import IconStop from "PUBLIC_DIR/images/videoplayer.stop.react.svg";
import styled from "styled-components";
type PlayerPlayButtonProps = {
isPlaying: boolean;
onClick: VoidFunction;
};
const WrapperPlayerPlayButton = styled.div`
display: flex;
justify-content: center;
align-items: center;
width: 48px;
height: 48px;
margin-left: -10px;
cursor: pointer;
`;
function PlayerPlayButton({ isPlaying, onClick }: PlayerPlayButtonProps) {
const onTouchStart = (event: React.TouchEvent<HTMLDivElement>) => {
event.stopPropagation();
};
return (
<WrapperPlayerPlayButton onClick={onClick} onTouchStart={onTouchStart}>
{isPlaying ? <IconStop /> : <IconPlay />}
</WrapperPlayerPlayButton>
);
}
export default memo(PlayerPlayButton);

View File

@ -0,0 +1,48 @@
import React from "react";
import Icon05x from "PUBLIC_DIR/images/media.viewer05x.react.svg";
import Icon1x from "PUBLIC_DIR/images/media.viewer1x.react.svg";
import Icon15x from "PUBLIC_DIR/images/media.viewer15x.react.svg";
import Icon2x from "PUBLIC_DIR/images/media.viewer2x.react.svg";
import { SpeedRecord, SpeedType } from "./PlayerSpeedControl.props";
export enum SpeedIndex {
Speed_X05 = 0,
Speed_X10 = 1,
Speed_X15 = 2,
Speed_X20 = 3,
}
export const speedIcons = [<Icon05x />, <Icon1x />, <Icon15x />, <Icon2x />];
export const speeds: SpeedType = ["X0.5", "X1", "X1.5", "X2"];
export const speedRecord: SpeedRecord<SpeedType> = {
"X0.5": 0.5,
X1: 1,
"X1.5": 1.5,
X2: 2,
};
export const DefaultIndexSpeed = SpeedIndex.Speed_X10;
export const MillisecondShowSpeedToast = 2000;
/**
*The function returns the following index based on the logic from the layout
*https://www.figma.com/file/T49yt13Eiu7nzvj4ymfssV/DocSpace-1.0.0?node-id=34536-418523&t=Yv2Rp3stGISIQNcm-0
*/
export const getNextIndexSpeed = (currentSpeedIndex: number) => {
switch (currentSpeedIndex) {
case SpeedIndex.Speed_X10:
return SpeedIndex.Speed_X05;
case SpeedIndex.Speed_X05:
return SpeedIndex.Speed_X15;
case SpeedIndex.Speed_X15:
return SpeedIndex.Speed_X20;
case SpeedIndex.Speed_X20:
return SpeedIndex.Speed_X10;
default:
return DefaultIndexSpeed;
}
};

View File

@ -0,0 +1,11 @@
export interface PlayerSpeedControlProps {
handleSpeedChange: (speed: number) => void;
onMouseLeave: VoidFunction;
src?: string;
}
export type SpeedType = ["X0.5", "X1", "X1.5", "X2"];
export type SpeedRecord<T extends SpeedType> = {
[Key in T[number]]: number;
};

View File

@ -0,0 +1,89 @@
import styled from "styled-components";
export const SpeedControlWrapper = styled.div`
position: relative;
display: flex;
justify-content: center;
align-items: center;
width: 48px;
height: 48px;
&:hover {
cursor: pointer;
}
svg {
path {
fill: #fff;
}
}
rect {
stroke: #fff;
}
`;
export const DropDown = styled.div`
display: flex;
flex-direction: column;
align-items: center;
height: 120px;
width: 48px;
padding: 4px 0px;
position: absolute;
bottom: 48px;
z-index: 50;
color: #fff;
background: #333;
text-align: center;
border-radius: 7px 7px 0px 0px;
`;
export const DropDownItem = styled.div`
display: flex;
align-items: center;
justify-content: center;
height: 30px;
width: 48px;
&:hover {
cursor: pointer;
background: #222;
}
`;
export const ToastSpeed = styled.div`
position: fixed;
top: 50%;
left: 50%;
display: flex;
justify-content: center;
align-items: center;
width: 72px;
height: 56px;
border-radius: 9px;
visibility: visible;
transform: translate(-50%, -50%);
background-color: rgba(51, 51, 51, 0.65);
svg {
width: 46px;
height: 46px;
path {
fill: #fff;
}
}
rect {
stroke: #fff;
}
`;

View File

@ -0,0 +1,108 @@
import React, { memo, useEffect, useRef, useState } from "react";
import { isMobileOnly } from "react-device-detect";
import {
DropDown,
DropDownItem,
SpeedControlWrapper,
ToastSpeed,
} from "./PlayerSpeedControl.styled";
import { PlayerSpeedControlProps } from "./PlayerSpeedControl.props";
import {
DefaultIndexSpeed,
getNextIndexSpeed,
MillisecondShowSpeedToast,
speedIcons,
speedRecord,
speeds,
} from "./PlayerSpeedControl.helper";
function PlayerSpeedControl({
handleSpeedChange,
onMouseLeave,
src,
}: PlayerSpeedControlProps) {
const ref = useRef<HTMLDivElement>(null);
const timerRef = useRef<NodeJS.Timeout>();
const [currentIndexSpeed, setCurrentIndexSpeed] = useState<number>(
DefaultIndexSpeed
);
const [isOpenSpeedContextMenu, setIsOpenSpeedContextMenu] = useState<boolean>(
false
);
const [speedToastVisible, setSpeedToastVisible] = useState<boolean>(false);
useEffect(() => {
setCurrentIndexSpeed(DefaultIndexSpeed);
}, [src]);
useEffect(() => {
const listener = (event: MouseEvent | TouchEvent) => {
if (!ref.current || ref.current.contains(event.target as Node)) {
return;
}
setIsOpenSpeedContextMenu(false);
};
document.addEventListener("mousedown", listener);
return () => {
document.removeEventListener("mousedown", listener);
clearTimeout(timerRef.current);
};
}, []);
const toggle = () => {
if (isMobileOnly) {
const nextIndexSpeed = getNextIndexSpeed(currentIndexSpeed);
setCurrentIndexSpeed(nextIndexSpeed);
const newSpeed = speedRecord[speeds[nextIndexSpeed]];
handleSpeedChange(newSpeed);
setSpeedToastVisible(true);
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
setSpeedToastVisible(false);
}, MillisecondShowSpeedToast);
} else {
setIsOpenSpeedContextMenu((prev) => !prev);
}
};
return (
<>
{speedToastVisible && (
<ToastSpeed>{speedIcons[currentIndexSpeed]}</ToastSpeed>
)}
<SpeedControlWrapper ref={ref} onClick={toggle}>
{speedIcons[currentIndexSpeed]}
{isOpenSpeedContextMenu && (
<DropDown onMouseLeave={onMouseLeave}>
{speeds.map((speed, index) => (
<DropDownItem
key={speed}
onClick={() => {
setCurrentIndexSpeed(index);
handleSpeedChange(speedRecord[speed]);
onMouseLeave();
}}
>
{speed}
</DropDownItem>
))}
</DropDown>
)}
</SpeedControlWrapper>
</>
);
}
export default memo(PlayerSpeedControl);

View File

@ -0,0 +1,9 @@
interface PlayerTimelineProps {
value: number;
duration: number;
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
onMouseEnter: VoidFunction;
onMouseLeave: VoidFunction;
}
export default PlayerTimelineProps;

View File

@ -0,0 +1,181 @@
import { isMobile } from "react-device-detect";
import styled, { css } from "styled-components";
export const HoverProgress = styled.div`
display: none;
position: absolute;
left: 2px;
height: 6px;
border-radius: 5px;
background-color: rgba(255, 255, 255, 0.3);
`;
const mobileCss = css`
margin-top: 16px;
input[type="range"]::-webkit-slider-thumb {
appearance: none;
-moz-appearance: none;
-webkit-appearance: none;
visibility: visible;
opacity: 1;
background: #fff;
height: 10px;
width: 10px;
border-radius: 50%;
cursor: pointer;
}
input[type="range"]::-moz-range-thumb {
appearance: none;
-moz-appearance: none;
-webkit-appearance: none;
visibility: visible;
opacity: 1;
background: #fff;
height: 10px;
width: 10px;
border-radius: 50%;
cursor: pointer;
border: none;
}
input[type="range"]::-ms-fill-upper {
appearance: none;
-moz-appearance: none;
-webkit-appearance: none;
visibility: visible;
opacity: 1;
background: #fff;
height: 10px;
width: 10px;
border-radius: 50%;
cursor: pointer;
}
`;
export const PlayerTimelineWrapper = styled.div`
position: relative;
display: flex;
align-items: center;
margin-top: 92px;
height: 4px;
width: 100%;
cursor: pointer;
time {
display: none;
position: absolute;
left: 50%;
top: -25px;
font-size: 13px;
color: #fff;
pointer-events: none;
transform: translateX(-50%);
}
@media (hover: hover) {
&:hover {
/* height: 6px; */
input {
height: 6px;
}
${HoverProgress} {
display: block;
}
transition: 0.1s height ease-in;
}
&:hover time {
display: block;
}
}
input {
width: 100%;
height: 4px;
outline: none;
appearance: none;
-moz-appearance: none;
-webkit-appearance: none;
border-radius: 5px;
background: rgba(255, 255, 255, 0.3);
background-image: linear-gradient(#fff, #fff);
background-repeat: no-repeat;
z-index: 1;
&:hover {
cursor: pointer;
}
}
input[type="range"]::-webkit-slider-thumb {
appearance: none;
-moz-appearance: none;
-webkit-appearance: none;
visibility: hidden;
opacity: 0;
background: #fff;
}
input[type="range"]::-moz-range-thumb {
appearance: none;
-moz-appearance: none;
-webkit-appearance: none;
visibility: hidden;
opacity: 0;
background: #fff;
}
input[type="range"]::-ms-fill-upper {
appearance: none;
-moz-appearance: none;
-webkit-appearance: none;
visibility: hidden;
opacity: 0;
background: #fff;
}
&:hover {
input[type="range"]::-webkit-slider-thumb {
visibility: visible;
height: 12px;
width: 12px;
opacity: 1 !important;
border-radius: 50%;
cursor: pointer;
}
input[type="range"]::-moz-range-thumb {
visibility: visible;
height: 12px;
width: 12px;
opacity: 1 !important;
border-radius: 50%;
cursor: pointer;
border: none;
}
input[type="range"]::-ms-fill-upper {
visibility: visible;
height: 12px;
width: 12px;
opacity: 1 !important;
border-radius: 50%;
cursor: pointer;
}
}
${isMobile && mobileCss}
`;

View File

@ -0,0 +1,117 @@
import React, { useRef } from "react";
import { formatTime } from "../../helpers";
import PlayerTimelineProps from "./PlayerTimeline.props";
import { HoverProgress, PlayerTimelineWrapper } from "./PlayerTimeline.styled";
function PlayerTimeline({
value,
duration,
onChange,
onMouseEnter,
onMouseLeave,
}: PlayerTimelineProps) {
const timelineTooltipRef = useRef<HTMLTimeElement>(null);
const timelineRef = useRef<HTMLDivElement>(null);
const hoverProgressRef = useRef<HTMLDivElement>(null);
const setTimeoutTimelineTooltipRef = useRef<NodeJS.Timeout>();
const showTimelineTooltip = () => {
if (!timelineTooltipRef.current) return;
const callback = () => {
if (timelineTooltipRef.current) {
timelineTooltipRef.current.style.removeProperty("display");
setTimeoutTimelineTooltipRef.current = undefined;
}
};
if (setTimeoutTimelineTooltipRef.current) {
clearTimeout(setTimeoutTimelineTooltipRef.current);
setTimeoutTimelineTooltipRef.current = setTimeout(callback, 500);
} else {
timelineTooltipRef.current.style.display = "block";
setTimeoutTimelineTooltipRef.current = setTimeout(callback, 500);
}
};
const handleOnChange = (event: React.ChangeEvent<HTMLInputElement>) => {
if (!timelineTooltipRef.current || !timelineRef.current) return;
const { clientWidth } = timelineRef.current;
const percent = Number(event.target.value) / 100;
const offsetX = clientWidth * percent;
const time = Math.floor(percent * duration);
const left =
offsetX < 20
? 20
: offsetX > clientWidth - 20
? clientWidth - 20
: offsetX;
timelineTooltipRef.current.style.left = `${left}px`;
timelineTooltipRef.current.innerText = formatTime(time);
showTimelineTooltip();
onChange(event);
};
const handleMouseMove = (
event: React.MouseEvent<HTMLDivElement, MouseEvent>
) => {
if (
!timelineTooltipRef.current ||
!timelineRef.current ||
!hoverProgressRef.current
)
return;
const { clientWidth } = timelineRef.current;
const { max, min } = Math;
const offsetX = min(max(event.nativeEvent.offsetX, 0), clientWidth);
const percent = Math.floor((offsetX / clientWidth) * duration);
hoverProgressRef.current.style.width = `${offsetX}px`;
const left =
offsetX < 20
? 20
: offsetX > clientWidth - 20
? clientWidth - 20
: offsetX;
timelineTooltipRef.current.style.left = `${left}px`;
timelineTooltipRef.current.innerText = formatTime(percent);
};
return (
<PlayerTimelineWrapper
ref={timelineRef}
onMouseMove={handleMouseMove}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
<time ref={timelineTooltipRef}>00:00</time>
<HoverProgress ref={hoverProgressRef} />
<input
min="0"
max="100"
step="any"
type="range"
value={value}
onChange={handleOnChange}
style={{
backgroundSize: `${value}% 100%`,
}}
/>
</PlayerTimelineWrapper>
);
}
export default PlayerTimeline;

View File

@ -0,0 +1,147 @@
import { tablet } from "@docspace/components/utils/device";
import { isMobile } from "react-device-detect";
import styled, { css } from "styled-components";
export const PlayerVolumeControlWrapper = styled.div`
display: flex;
align-items: center;
margin-left: 10px;
`;
export const IconWrapper = styled.div`
display: flex;
align-items: center;
cursor: pointer;
`;
const mobilecss = css`
input[type="range"]::-webkit-slider-thumb {
appearance: none;
-moz-appearance: none;
-webkit-appearance: none;
visibility: visible;
opacity: 1;
background: #fff;
height: 10px;
width: 10px;
border-radius: 50%;
cursor: pointer;
}
input[type="range"]::-moz-range-thumb {
appearance: none;
-moz-appearance: none;
-webkit-appearance: none;
visibility: visible;
opacity: 1;
background: #fff;
height: 10px;
width: 10px;
border-radius: 50%;
cursor: pointer;
}
input[type="range"]::-ms-fill-upper {
appearance: none;
-moz-appearance: none;
-webkit-appearance: none;
visibility: visible;
opacity: 1;
background: #fff;
height: 10px;
width: 10px;
border-radius: 50%;
cursor: pointer;
}
`;
export const VolumeWrapper = styled.div`
width: 123px;
height: 28px;
display: flex;
align-items: center;
padding-left: 9px;
input {
margin-right: 15px;
width: 80%;
height: 4px;
appearance: none;
-moz-appearance: none;
-webkit-appearance: none;
border-radius: 5px;
background: rgba(255, 255, 255, 0.3);
background-image: linear-gradient(#fff, #fff);
background-repeat: no-repeat;
&:hover {
cursor: pointer;
}
@media ${tablet} {
width: 63%;
}
}
input[type="range"]::-webkit-slider-thumb {
appearance: none;
-moz-appearance: none;
-webkit-appearance: none;
visibility: hidden;
opacity: 0;
background: #fff;
}
input[type="range"]::-moz-range-thumb {
appearance: none;
-moz-appearance: none;
-webkit-appearance: none;
visibility: hidden;
opacity: 0;
background: #fff;
}
input[type="range"]::-ms-fill-upper {
appearance: none;
-moz-appearance: none;
-webkit-appearance: none;
visibility: hidden;
opacity: 0;
background: #fff;
}
&:hover {
input[type="range"]::-webkit-slider-thumb {
visibility: visible;
height: 10px;
width: 10px;
opacity: 1 !important;
border-radius: 50%;
cursor: pointer;
}
input[type="range"]::-moz-range-thumb {
visibility: visible;
height: 10px;
width: 10px;
opacity: 1 !important;
border-radius: 50%;
cursor: pointer;
border: none;
}
input[type="range"]::-ms-fill-upper {
visibility: visible;
height: 10px;
width: 10px;
opacity: 1 !important;
border-radius: 50%;
cursor: pointer;
}
}
${isMobile && mobilecss}
`;

View File

@ -0,0 +1,53 @@
import React, { memo } from "react";
import {
IconWrapper,
PlayerVolumeControlWrapper,
VolumeWrapper,
} from "./PlayerVolumeControl.styled";
import IconVolumeMax from "PUBLIC_DIR/images/media.volumemax.react.svg";
import IconVolumeMuted from "PUBLIC_DIR/images/media.volumeoff.react.svg";
import IconVolumeMin from "PUBLIC_DIR/images/media.volumemin.react.svg";
type PlayerVolumeControlProps = {
volume: number;
isMuted: boolean;
toggleVolumeMute: VoidFunction;
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
};
function PlayerVolumeControl({
volume,
isMuted,
onChange,
toggleVolumeMute,
}: PlayerVolumeControlProps) {
return (
<PlayerVolumeControlWrapper>
<IconWrapper onClick={toggleVolumeMute}>
{isMuted ? (
<IconVolumeMuted />
) : volume >= 50 ? (
<IconVolumeMax />
) : (
<IconVolumeMin />
)}
</IconWrapper>
<VolumeWrapper>
<input
style={{
backgroundSize: `${volume}% 100%`,
}}
type="range"
min="0"
max="100"
value={volume}
onChange={onChange}
/>
</VolumeWrapper>
</PlayerVolumeControlWrapper>
);
}
export default memo(PlayerVolumeControl);

View File

@ -1,5 +1,5 @@
import ReactDOM from "react-dom";
import { isMobileOnly, isMobile } from "react-device-detect";
import { isMobile } from "react-device-detect";
import React, { useRef, useState, useEffect, useCallback } from "react";
import ContextMenu from "@docspace/components/context-menu";
@ -11,7 +11,7 @@ import PrevButton from "../PrevButton";
import ImageViewer from "../ImageViewer";
import MobileDetails from "../MobileDetails";
import DesktopDetails from "../DesktopDetails";
import ViewerPlayer from "../ViewerPlayer/viewer-player";
import ViewerPlayer from "../ViewerPlayer";
import type ViewerProps from "./Viewer.props";
@ -22,15 +22,15 @@ function Viewer(props: ViewerProps) {
const [panelVisible, setPanelVisible] = useState<boolean>(true);
const [isOpenContextMenu, setIsOpenContextMenu] = useState<boolean>(false);
const [isError, setIsError] = useState<boolean>(false);
const [isPlay, setIsPlay] = useState<boolean | null>(null);
const [imageTimer, setImageTimer] = useState<NodeJS.Timeout>();
const panelVisibleRef = useRef<boolean>(false);
const panelToolbarRef = useRef<boolean>(false);
const contextMenuRef = useRef<ContextMenu>(null);
const videoElementRef = useRef<HTMLVideoElement>(null);
const [isFullscreen, setIsFullScreen] = useState<boolean>(false);
useEffect(() => {
@ -43,27 +43,10 @@ function Viewer(props: ViewerProps) {
}, []);
useEffect(() => {
if ((!isPlay || isOpenContextMenu) && (!props.isImage || isOpenContextMenu))
return clearTimeout(timerIDRef.current);
}, [isPlay, isOpenContextMenu, props.isImage]);
const resetToolbarVisibleTimer = () => {
if (panelVisibleRef.current) {
if (isOpenContextMenu) {
clearTimeout(timerIDRef.current);
timerIDRef.current = setTimeout(() => {
panelVisibleRef.current = false;
setPanelVisible(false);
}, 2500);
} else {
setPanelVisible(true);
panelVisibleRef.current = true;
timerIDRef.current = setTimeout(() => {
panelVisibleRef.current = false;
setPanelVisible(false);
}, 2500);
}
};
}, [isOpenContextMenu]);
useEffect(() => {
if (isMobile) return;
@ -79,20 +62,44 @@ function Viewer(props: ViewerProps) {
};
}, [setImageTimer, setPanelVisible]);
useEffect(() => {
document.addEventListener("touchstart", onTouch);
const resetToolbarVisibleTimer = () => {
if (panelToolbarRef.current) return;
return () => document.removeEventListener("touchstart", onTouch);
}, [setPanelVisible]);
if (panelVisibleRef.current && panelVisible) {
clearTimeout(timerIDRef.current);
timerIDRef.current = setTimeout(() => {
panelVisibleRef.current = false;
setPanelVisible(false);
}, 2500);
} else {
setPanelVisible(true);
clearTimeout(timerIDRef.current);
panelVisibleRef.current = true;
const onTouch = useCallback(
(e: TouchEvent, canTouch?: boolean) => {
if (e.target === videoElementRef.current || canTouch) {
setPanelVisible((visible) => !visible);
}
},
[setPanelVisible]
);
timerIDRef.current = setTimeout(() => {
panelVisibleRef.current = false;
setPanelVisible(false);
}, 2500);
}
};
const removeToolbarVisibleTimer = () => {
clearTimeout(timerIDRef.current);
panelVisibleRef.current = false;
panelToolbarRef.current = true;
};
const removePanelVisibleTimeout = () => {
clearTimeout(timerIDRef.current);
panelVisibleRef.current = true;
panelToolbarRef.current = false;
setPanelVisible(true);
};
const restartToolbarVisibleTimer = () => {
panelToolbarRef.current = false;
resetToolbarVisibleTimer();
};
const nextClick = () => {
clearTimeout(imageTimer);
@ -117,6 +124,22 @@ function Viewer(props: ViewerProps) {
setIsOpenContextMenu(false);
}, [setIsOpenContextMenu]);
const handleMaskClick = () => {
if (isFullscreen) {
if (document.exitFullscreen) {
document.exitFullscreen();
} else if (document["webkitExitFullscreen"]) {
document["webkitExitFullscreen"]();
} else if (document["mozCancelFullScreen"]) {
document["mozCancelFullScreen"]();
} else if (document["msExitFullscreen"]) {
document["msExitFullscreen"]();
}
}
props.onMaskClick();
};
const mobileDetails = (
<MobileDetails
onHide={onHide}
@ -124,22 +147,20 @@ function Viewer(props: ViewerProps) {
title={props.title}
ref={contextMenuRef}
icon={props.headerIcon}
onMaskClick={props.onMaskClick}
onMaskClick={handleMaskClick}
contextModel={props.contextModel}
onContextMenu={onMobileContextMenu}
isPreviewFile={props.isPreviewFile}
/>
);
const displayUI = (isMobileOnly && props.isAudio) || panelVisible;
const isNotFirstElement = props.playlistPos !== 0;
const isNotLastElement = props.playlistPos < props.playlist.length - 1;
return (
<StyledViewerContainer visible={props.visible}>
{!isFullscreen && !isMobile && panelVisible && (
<DesktopDetails title={props.title} onMaskClick={props.onMaskClick} />
<DesktopDetails title={props.title} onMaskClick={handleMaskClick} />
)}
{props.playlist.length > 1 && !isFullscreen && !isMobile && (
@ -170,28 +191,31 @@ function Viewer(props: ViewerProps) {
: (props.isVideo || props.isAudio) &&
ReactDOM.createPortal(
<ViewerPlayer
{...props}
onNextClick={nextClick}
onPrevClick={prevClick}
isError={isError}
src={props.fileUrl}
isAudio={props.isAudio}
isVideo={props.isVideo}
panelVisible={panelVisible}
audioIcon={props.audioIcon}
contextModel={props.contextModel}
isFullScreen={isFullscreen}
errorTitle={props.errorTitle}
mobileDetails={mobileDetails}
displayUI={displayUI}
isLastImage={!isNotLastElement}
isFistImage={!isNotFirstElement}
isPreviewFile={props.isPreviewFile}
isOpenContextMenu={isOpenContextMenu}
onTouch={onTouch}
title={props.title}
setIsPlay={setIsPlay}
setIsOpenContextMenu={setIsOpenContextMenu}
isPlay={isPlay}
onMaskClick={props.onMaskClick}
setPanelVisible={setPanelVisible}
generateContextMenu={props.generateContextMenu}
setIsFullScreen={setIsFullScreen}
setIsError={setIsError}
videoRef={videoElementRef}
video={props.playlist[props.playlistPos]}
activeIndex={props.playlistPos}
onMask={handleMaskClick}
onPrev={props.onPrevClick}
onNext={props.onNextClick}
setPanelVisible={setPanelVisible}
setIsFullScreen={setIsFullScreen}
contextModel={props.contextModel}
onDownloadClick={props.onDownloadClick}
generateContextMenu={props.generateContextMenu}
removeToolbarVisibleTimer={removeToolbarVisibleTimer}
removePanelVisibleTimeout={removePanelVisibleTimeout}
restartToolbarVisibleTimer={restartToolbarVisibleTimer}
/>,
containerRef.current
)}

View File

@ -11,6 +11,8 @@ const StyledLoaderWrapper = styled.div`
display: flex;
justify-content: center;
align-items: center;
background-color: rgba(0, 0, 0, 0.4);
`;
const StyledLoader = styled.div`

View File

@ -0,0 +1,37 @@
import { ContextMenuModel } from "../../types";
interface ViewerPlayerProps {
src?: string;
isAudio: boolean;
isVideo: boolean;
isError: boolean;
audioIcon: string;
errorTitle: string;
isLastImage: boolean;
isFistImage: boolean;
isFullScreen: boolean;
panelVisible: boolean;
isPreviewFile: boolean;
isOpenContextMenu: boolean;
mobileDetails: JSX.Element;
onMask: VoidFunction;
onPrev: VoidFunction;
onNext: VoidFunction;
onDownloadClick: VoidFunction;
contextModel: () => ContextMenuModel[];
removeToolbarVisibleTimer: VoidFunction;
removePanelVisibleTimeout: VoidFunction;
restartToolbarVisibleTimer: VoidFunction;
setIsError: React.Dispatch<React.SetStateAction<boolean>>;
setPanelVisible: React.Dispatch<React.SetStateAction<boolean>>;
setIsFullScreen: React.Dispatch<React.SetStateAction<boolean>>;
generateContextMenu: (
isOpen: boolean,
right?: string,
bottom?: string
) => JSX.Element;
}
export default ViewerPlayerProps;

View File

@ -0,0 +1,91 @@
import { isMobile, isMobileOnly } from "react-device-detect";
import styled, { css } from "styled-components";
import { animated } from "@react-spring/web";
export const ContainerPlayer = styled.div<{ $isFullScreen: boolean }>`
position: fixed;
inset: 0;
z-index: 305;
background-color: ${(props) =>
props.$isFullScreen ? "#000" : "rgba(55, 55, 55, 0.6)"};
touch-action: none;
`;
export const VideoWrapper = styled(animated.div)<{ $visible: boolean }>`
inset: 0;
visibility: ${(props) => (props.$visible ? "visible" : "hidden")};
opacity: ${(props) => (props.$visible ? 1 : 0)};
height: 100%;
width: 100%;
touch-action: none;
.audio-container {
width: 190px;
height: 190px;
display: flex;
justify-content: center;
align-items: center;
background: rgba(0, 0, 0, 0.7);
border-radius: 20px;
}
`;
const StyledMobilePlayerControls = css`
background-color: rgba(0, 0, 0, 0.8);
height: 80px;
`;
export const StyledPlayerControls = styled.div<{ $isShow: boolean }>`
position: fixed;
right: 0px;
bottom: 0px;
left: 0px;
z-index: 307;
display: flex;
width: 100%;
height: 188px;
visibility: ${(props) => (props.$isShow ? "visible" : "hidden")};
opacity: ${(props) => (props.$isShow ? "1" : "0")};
background: linear-gradient(
rgba(0, 0, 0, 0) 0%,
rgba(0, 0, 0, 0.64) 48.44%,
rgba(0, 0, 0, 0.89) 100%
);
${isMobile && StyledMobilePlayerControls}
`;
export const ControlContainer = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 30px;
& > div {
display: flex;
align-items: center;
justify-content: space-between;
}
${isMobile &&
css`
margin-top: 8px;
.player_right-control {
margin-right: -8px;
}
`}
`;
export const PlayerControlsWrapper = styled.div`
padding: 0 30px;
width: 100%;
${isMobileOnly &&
css`
padding: 0 15px;
`}
`;

View File

@ -0,0 +1,624 @@
import lodash from "lodash";
import { useGesture } from "@use-gesture/react";
import { useSpring, animated } from "@react-spring/web";
import { isMobile, isDesktop } from "react-device-detect";
import React, {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import ViewerPlayerProps from "./ViewerPlayer.props";
import {
ContainerPlayer,
ControlContainer,
PlayerControlsWrapper,
StyledPlayerControls,
VideoWrapper,
} from "./ViewerPlayer.styled";
import PlayerBigPlayButton from "../PlayerBigPlayButton";
import ViewerLoader from "../ViewerLoader";
import PlayerPlayButton from "../PlayerPlayButton";
import PlayerDuration from "../PlayerDuration/inxed";
import PlayerVolumeControl from "../PlayerVolumeControl";
import PlayerTimeline from "../PlayerTimeline";
import PlayerSpeedControl from "../PlayerSpeedControl";
import PlayerFullScreen from "../PlayerFullScreen";
import PlayerDesktopContextMenu from "../PlayerDesktopContextMenu";
import { compareTo, KeyboardEventKeys } from "../../helpers";
import PlayerMessageError from "../PlayerMessageError";
const VolumeLocalStorageKey = "player-volume";
const defaultVolume = 100;
const audioWidth = 190;
const audioHeight = 190;
function ViewerPlayer({
src,
isAudio,
isVideo,
isError,
audioIcon,
errorTitle,
isLastImage,
isFistImage,
isFullScreen,
panelVisible,
mobileDetails,
isPreviewFile,
isOpenContextMenu,
onMask,
onNext,
onPrev,
setIsError,
contextModel,
setPanelVisible,
setIsFullScreen,
onDownloadClick,
generateContextMenu,
removeToolbarVisibleTimer,
removePanelVisibleTimeout,
restartToolbarVisibleTimer,
}: ViewerPlayerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const playerWrapperRef = useRef<HTMLDivElement>(null);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [isPlaying, setIsPlaying] = useState<boolean>(false);
const [isWaiting, setIsWaiting] = useState<boolean>(false);
const [isMuted, setIsMuted] = useState<boolean>(() => {
const valueStorage = localStorage.getItem(VolumeLocalStorageKey);
if (!valueStorage) return false;
return valueStorage === "0";
});
const [timeline, setTimeline] = useState<number>(0);
const [duration, setDuration] = useState<number>(0);
const [currentTime, setCurrentTime] = useState<number>(0);
const [volume, setVolume] = useState<number>(() => {
const valueStorage = localStorage.getItem(VolumeLocalStorageKey);
if (!valueStorage) return defaultVolume;
return JSON.parse(valueStorage);
});
const [style, api] = useSpring(() => ({
width: 0,
height: 0,
x: 0,
y: 0,
opacity: 1,
}));
useEffect(() => {
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, [isFullScreen, isLoading]);
useLayoutEffect(() => {
setIsLoading(true);
resetState();
}, [src]);
useEffect(() => {
if (!isOpenContextMenu && isPlaying) {
restartToolbarVisibleTimer();
}
}, [isOpenContextMenu]);
useEffect(() => {
window.addEventListener("fullscreenchange", onExitFullScreen, {
capture: true,
});
return () =>
window.removeEventListener("fullscreenchange", onExitFullScreen, {
capture: true,
});
}, []);
useEffect(() => {
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("keydown", onKeyDown);
};
}, [isPlaying]);
const calculateAdjustImage = (point: { x: number; y: number }) => {
if (!playerWrapperRef.current || !containerRef.current) return point;
let playerBounds = playerWrapperRef.current.getBoundingClientRect();
const containerBounds = containerRef.current.getBoundingClientRect();
const originalWidth = playerWrapperRef.current.clientWidth;
const widthOverhang = (playerBounds.width - originalWidth) / 2;
const originalHeight = playerWrapperRef.current.clientHeight;
const heightOverhang = (playerBounds.height - originalHeight) / 2;
const isWidthOutContainer = playerBounds.width >= containerBounds.width;
const isHeightOutContainer = playerBounds.height >= containerBounds.height;
if (
compareTo(playerBounds.left, containerBounds.left) &&
isWidthOutContainer
) {
point.x = widthOverhang;
} else if (
compareTo(containerBounds.right, playerBounds.right) &&
isWidthOutContainer
) {
point.x = containerBounds.width - playerBounds.width + widthOverhang;
} else if (!isWidthOutContainer) {
point.x =
(containerBounds.width - playerBounds.width) / 2 + widthOverhang;
}
if (
compareTo(playerBounds.top, containerBounds.top) &&
isHeightOutContainer
) {
point.y = heightOverhang;
} else if (
compareTo(containerBounds.bottom, playerBounds.bottom) &&
isHeightOutContainer
) {
point.y = containerBounds.height - playerBounds.height + heightOverhang;
} else if (!isHeightOutContainer) {
point.y =
(containerBounds.height - playerBounds.height) / 2 + heightOverhang;
}
return point;
};
useGesture(
{
onDrag: ({ offset: [dx, dy], movement: [mdx, mdy], memo, first }) => {
if (isDesktop) return;
if (first) {
memo = style.y.get();
}
api.start({
x:
(isFistImage && mdx > 0) || (isLastImage && mdx < 0) || isFullScreen
? style.x.get()
: dx,
y: dy >= memo ? dy : style.y.get(),
opacity: mdy > 0 ? Math.max(1 - mdy / 120, 0) : style.opacity.get(),
immediate: true,
});
return memo;
},
onDragEnd: ({ movement: [mdx, mdy] }) => {
if (isDesktop) return;
if (!isFullScreen) {
if (mdx < -style.width.get() / 4) {
return onNext();
} else if (mdx > style.width.get() / 4) {
return onPrev();
}
}
if (mdy > 120) {
return onMask();
}
const newPoint = calculateAdjustImage({
x: style.x.get(),
y: style.y.get(),
});
api.start({
...newPoint,
opacity: 1,
});
},
onClick: ({ dragging, event }) => {
if (isDesktop && event.target === containerRef.current) return onMask();
if (
dragging ||
!isMobile ||
isAudio ||
event.target !== containerRef.current
)
return;
if (panelVisible) {
removeToolbarVisibleTimer();
setPanelVisible(false);
} else {
isPlaying && restartToolbarVisibleTimer();
setPanelVisible(true);
}
},
},
{
drag: {
from: () => [style.x.get(), style.y.get()],
axis: "lock",
},
target: containerRef,
}
);
const onKeyDown = (event: KeyboardEvent) => {
if (event.code === KeyboardEventKeys.Space) {
togglePlay();
}
};
const onExitFullScreen = () => {
if (!document.fullscreenElement) {
setIsFullScreen(false);
}
};
const resetState = () => {
setTimeline(0);
setDuration(0);
setCurrentTime(0);
setIsPlaying(false);
removePanelVisibleTimeout();
};
const getVideoWidthHeight = (video: HTMLVideoElement): [number, number] => {
const maxWidth = window.innerWidth;
const maxHeight = window.innerHeight;
const elementWidth = isAudio ? audioWidth : video.videoWidth;
const elementHeight = isAudio ? audioHeight : video.videoHeight;
let width =
elementWidth > maxWidth
? maxWidth
: isFullScreen
? Math.max(maxWidth, elementWidth)
: Math.min(maxWidth, elementWidth);
let height = (width / elementWidth) * elementHeight;
if (height > maxHeight) {
height = maxHeight;
width = (height / elementHeight) * elementWidth;
}
return [width, height];
};
const getVideoPosition = (
width: number,
height: number
): [number, number] => {
let left = (window.innerWidth - width) / 2;
let top = (window.innerHeight - height) / 2;
return [left, top];
};
const setSizeAndPosition = (target: HTMLVideoElement) => {
const [width, height] = getVideoWidthHeight(target);
const [x, y] = getVideoPosition(width, height);
api.start({
x,
y,
width,
height,
immediate: true,
});
};
const handleResize = (event: any) => {
const target = videoRef.current;
if (!target || isLoading) return;
setSizeAndPosition(target);
};
const handleLoadedMetaDataVideo = (
event: React.SyntheticEvent<HTMLVideoElement, Event>
) => {
const target = event.target as HTMLVideoElement;
setSizeAndPosition(target);
target.volume = volume / 100;
target.muted = isMuted;
target.playbackRate = 1;
setDuration(target.duration);
setIsLoading(false);
};
const togglePlay = useCallback(() => {
if (!videoRef.current) return;
if (isMobile && !isPlaying && isVideo) {
restartToolbarVisibleTimer();
}
if (isPlaying) {
videoRef.current.pause();
setIsPlaying(false);
setPanelVisible(true);
isMobile && removeToolbarVisibleTimer();
} else {
videoRef.current.play();
setIsPlaying(true);
}
}, [isPlaying, isVideo]);
const handleBigPlayButtonClick = () => {
togglePlay();
};
const handleTimeUpdate = () => {
if (!videoRef.current || isLoading) return;
const { currentTime, duration } = videoRef.current;
const percent = (currentTime / duration) * 100;
setTimeline(percent);
setCurrentTime(currentTime);
};
const handleChangeTimeLine = (event: React.ChangeEvent<HTMLInputElement>) => {
if (!videoRef.current) return;
const percent = Number(event.target.value);
const newCurrentTime = (percent / 100) * videoRef.current.duration;
setTimeline(percent);
setCurrentTime(newCurrentTime);
videoRef.current.currentTime = newCurrentTime;
};
const handleClickVideo = () => {
if (isMobile) {
if (!isPlaying) {
return setPanelVisible((prev) => !prev);
}
if (panelVisible) {
videoRef.current?.pause();
setIsPlaying(false);
return removeToolbarVisibleTimer();
}
return isPlaying && restartToolbarVisibleTimer();
}
togglePlay();
};
const handleVideoEnded = () => {
setIsPlaying(false);
if (isMobile) removePanelVisibleTimeout();
};
const handleVolumeChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
if (!videoRef.current) return;
const newVolume = Number(event.target.value);
localStorage.setItem(VolumeLocalStorageKey, event.target.value);
if (newVolume === 0) {
setIsMuted(true);
videoRef.current.muted = true;
}
if (isMuted && newVolume > 0) {
setIsMuted(false);
videoRef.current.muted = false;
}
videoRef.current.volume = newVolume / 100;
setVolume(newVolume);
},
[isMuted]
);
const handleSpeedChange = useCallback((speed: number) => {
if (!videoRef.current) return;
videoRef.current.playbackRate = speed;
}, []);
const toggleVolumeMute = useCallback(() => {
if (!videoRef.current) return;
const volume = videoRef.current.volume * 100 || defaultVolume;
if (isMuted) {
setIsMuted(false);
setVolume(volume);
videoRef.current.volume = volume / 100;
videoRef.current.muted = false;
localStorage.setItem(VolumeLocalStorageKey, volume.toString());
} else {
setIsMuted(true);
setVolume(0);
videoRef.current.muted = true;
localStorage.setItem(VolumeLocalStorageKey, "0");
}
}, [isMuted]);
const toggleVideoFullscreen = useCallback(() => {
if (!videoRef.current) return;
if (isFullScreen) {
if (document.exitFullscreen) {
document.exitFullscreen();
} else if (document["webkitExitFullscreen"]) {
document["webkitExitFullscreen"]();
} else if (document["mozCancelFullScreen"]) {
document["mozCancelFullScreen"]();
} else if (document["msExitFullscreen"]) {
document["msExitFullscreen"]();
}
} else {
if (document.documentElement.requestFullscreen) {
document.documentElement.requestFullscreen();
} else if (document.documentElement["mozRequestFullScreen"]) {
document.documentElement["mozRequestFullScreen"]();
} else if (document.documentElement["webkitRequestFullScreen"]) {
document.documentElement["webkitRequestFullScreen"]();
} else if (document.documentElement["webkitEnterFullScreen"]) {
document.documentElement["webkitEnterFullScreen"]();
}
}
setIsFullScreen((pre) => !pre);
}, [isFullScreen]);
const onMouseEnter = () => {
if (isMobile) return;
removeToolbarVisibleTimer();
};
const onMouseLeave = () => {
if (isMobile) return;
restartToolbarVisibleTimer();
};
const onTouchStart = () => {
if (isPlaying && isVideo) restartToolbarVisibleTimer();
};
const onTouchMove = () => {
if (isPlaying && isVideo) restartToolbarVisibleTimer();
};
const model = useMemo(contextModel, [contextModel]);
const hideContextMenu = useMemo(
() => model.filter((item) => !item.disabled).length <= 1,
[model]
);
return (
<>
{isMobile && panelVisible && mobileDetails}
<ContainerPlayer ref={containerRef} $isFullScreen={isFullScreen}>
<VideoWrapper
$visible={!isLoading}
style={style}
ref={playerWrapperRef}
>
<animated.video
style={lodash.omit(style, ["x", "y"])}
src={`${src}#t=0.001`}
playsInline
ref={videoRef}
hidden={isAudio}
preload="metadata"
onClick={handleClickVideo}
onEnded={handleVideoEnded}
onTimeUpdate={handleTimeUpdate}
onPlaying={() => setIsWaiting(false)}
onWaiting={() => setIsWaiting(true)}
onError={() => {
console.error("video error");
setIsError(true);
}}
onLoadedMetadata={handleLoadedMetaDataVideo}
/>
<PlayerBigPlayButton
onClick={handleBigPlayButtonClick}
visible={!isPlaying && isVideo && !isError}
/>
{isAudio && (
<div className="audio-container">
<img src={audioIcon} />
</div>
)}
</VideoWrapper>
<ViewerLoader isLoading={isLoading || (isWaiting && isPlaying)} />
</ContainerPlayer>
{isError ? (
<PlayerMessageError
model={model}
onMaskClick={onMask}
errorTitle={errorTitle}
/>
) : (
<StyledPlayerControls
$isShow={panelVisible && !isLoading}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
>
<PlayerControlsWrapper>
<PlayerTimeline
value={timeline}
duration={duration}
onChange={handleChangeTimeLine}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
/>
<ControlContainer>
<div
className="player_left-control"
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
<PlayerPlayButton isPlaying={isPlaying} onClick={togglePlay} />
<PlayerDuration currentTime={currentTime} duration={duration} />
{!isMobile && (
<PlayerVolumeControl
volume={volume}
isMuted={isMuted}
onChange={handleVolumeChange}
toggleVolumeMute={toggleVolumeMute}
/>
)}
</div>
<div
className="player_right-control"
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
<PlayerSpeedControl
src={src}
onMouseLeave={onMouseLeave}
handleSpeedChange={handleSpeedChange}
/>
<PlayerFullScreen
isAudio={isAudio}
isFullScreen={isFullScreen}
onClick={toggleVideoFullscreen}
/>
{isDesktop && (
<PlayerDesktopContextMenu
isPreviewFile={isPreviewFile}
hideContextMenu={hideContextMenu}
onDownloadClick={onDownloadClick}
generateContextMenu={generateContextMenu}
/>
)}
</div>
</ControlContainer>
</PlayerControlsWrapper>
</StyledPlayerControls>
)}
</>
);
}
export default ViewerPlayer;

View File

@ -1,98 +0,0 @@
import * as React from "react";
import styled from "styled-components";
import Text from "@docspace/components/text";
import { isMobile } from "react-device-detect";
import { ReactSVG } from "react-svg";
const StyledMediaError = styled.div`
background: rgba(0, 0, 0, 0.7);
width: ${(props) => props.width + "px"};
height: 56px;
position: absolute;
opacity: 1;
z-index: 1006;
display: flex;
justify-content: center;
align-items: center;
border-radius: 20px;
`;
const StyledErrorToolbar = styled.div`
padding: 10px 24px;
bottom: 24px;
z-index: 1006;
position: fixed;
display: flex;
justify-content: center;
align-items: center;
border-radius: 18px;
background: rgba(0, 0, 0, 0.4);
&:hover {
background: rgba(0, 0, 0, 0.8);
}
.toolbar-item {
display: flex;
justify-content: center;
align-items: center;
height: 48px;
width: 48px;
&:hover {
cursor: pointer;
}
}
`;
export const MediaError = ({
width,
height,
onMaskClick,
model,
errorTitle,
}) => {
let errorLeft = (window.innerWidth - width) / 2 + "px";
let errorTop = (window.innerHeight - height) / 2 + "px";
const items = !isMobile
? model.filter((el) => el.key !== "rename")
: model.filter((el) => el.key === "delete" || el.key === "download");
return (
<>
<StyledMediaError
width={width}
height={height}
style={{
left: `${errorLeft}`,
top: `${errorTop}`,
}}
>
<Text
fontSize="15px"
color={"#fff"}
textAlign="center"
className="title"
>
{errorTitle}
</Text>
</StyledMediaError>
<StyledErrorToolbar>
{items.map((item) => {
if (item.disabled) return;
const onClick = () => {
onMaskClick();
item.onClick && item.onClick();
};
return (
<div className="toolbar-item" key={item.key} onClick={onClick}>
<ReactSVG src={item.icon} />
</div>
);
})}
</StyledErrorToolbar>
</>
);
};