started on controls

This commit is contained in:
PhilReact 2025-06-10 01:12:38 +03:00
parent 58d56e25dd
commit 1ba3bfca26
10 changed files with 626 additions and 28 deletions

View File

@ -0,0 +1,22 @@
import { Box, Tooltip, TooltipProps } from "@mui/material";
import { PropsWithChildren } from "react";
export interface CustomFontTooltipProps extends TooltipProps {
fontSize?: string;
}
export const CustomFontTooltip = ({
fontSize,
title,
children,
...props
}: PropsWithChildren<CustomFontTooltipProps>) => {
if (!fontSize) fontSize = "160%";
const text = <Box sx={{ fontSize: fontSize }}>{title}</Box>;
// put controls into individual components
return (
<Tooltip title={text} {...props} sx={{ display: "contents", ...props.sx }}>
<div>{children}</div>
</Tooltip>
);
};

View File

@ -0,0 +1,116 @@
import { Box, CircularProgress, Typography } from "@mui/material";
import { PlayArrow } from "@mui/icons-material";
import { Status } from "../../state/publishes";
interface LoadingVideoProps {
status: Status | null
percentLoaded: number
isReady: boolean
isLoading: boolean
togglePlay: ()=> void
}
export const LoadingVideo = ({
status, percentLoaded, isReady, isLoading, togglePlay
}: LoadingVideoProps) => {
const getDownloadProgress = (percentLoaded: number) => {
const progress = percentLoaded;
return Number.isNaN(progress) ? "" : progress.toFixed(0) + "%";
};
return (
<>
{isLoading && status !== 'INITIAL' && (
<Box
position="absolute"
top={0}
left={0}
right={0}
bottom={status === "READY" ? "55px " : 0}
display="flex"
justifyContent="center"
alignItems="center"
zIndex={25}
bgcolor="rgba(0, 0, 0, 0.6)"
sx={{
display: "flex",
flexDirection: "column",
gap: "10px",
height: "100%",
}}
>
<CircularProgress color="secondary" />
{status && (
<Typography
variant="subtitle2"
component="div"
sx={{
color: "white",
fontSize: "15px",
textAlign: "center",
}}
>
{status === "NOT_PUBLISHED" && (
<>Video file was not published. Please inform the publisher!</>
)}
{status === "REFETCHING" ? (
<>
<>
{getDownloadProgress(
percentLoaded
)}
</>
<> Refetching in 25 seconds</>
</>
) : status === "DOWNLOADED" ? (
<>Download Completed: building video...</>
) : status !== "READY" ? (
<>
{getDownloadProgress(
percentLoaded
)}
</>
) : (
<>Fetching video...</>
)}
</Typography>
)}
</Box>
)}
{(status === 'INITIAL') && (
<>
<Box
position="absolute"
top={0}
left={0}
right={0}
bottom={0}
display="flex"
justifyContent="center"
alignItems="center"
zIndex={500}
bgcolor="rgba(0, 0, 0, 0.6)"
onClick={() => {
togglePlay();
}}
sx={{
cursor: "pointer",
}}
>
<PlayArrow
sx={{
width: "50px",
height: "50px",
color: "white",
}}
/>
</Box>
</>
)}
</>
);
};

View File

@ -0,0 +1,69 @@
import { MoreVert as MoreIcon } from "@mui/icons-material";
import { Box, IconButton, Menu, MenuItem } from "@mui/material";
import {
FullscreenButton,
ObjectFitButton,
PictureInPictureButton,
PlaybackRate,
PlayButton,
ProgressSlider,
ReloadButton,
VideoTime,
VolumeControl,
} from "./VideoControls";
export const MobileControlsBar = ({handleMenuOpen, handleMenuClose, anchorEl, controlsHeight}) => {
const controlGroupSX = {
display: "flex",
gap: "5px",
alignItems: "center",
height: controlsHeight,
};
return (
<>
<Box sx={controlGroupSX}>
<PlayButton />
<ReloadButton />
<ProgressSlider />
<VideoTime />
</Box>
<Box sx={controlGroupSX}>
<PlaybackRate />
<FullscreenButton />
<IconButton
edge="end"
color="inherit"
aria-label="menu"
onClick={handleMenuOpen}
sx={{ paddingLeft: "0px", marginRight: "0px" }}
>
<MoreIcon />
</IconButton>
</Box>
<Menu
id="simple-menu"
anchorEl={anchorEl.value}
keepMounted
open={Boolean(anchorEl.value)}
onClose={handleMenuClose}
PaperProps={{
style: {
width: "250px",
},
}}
>
<MenuItem>
<ObjectFitButton />
</MenuItem>
<MenuItem>
<PictureInPictureButton />
</MenuItem>
</Menu>
</>
);
};

View File

@ -0,0 +1,249 @@
import { Box, IconButton, Slider, Typography } from "@mui/material";
export const fontSizeExSmall = "60%";
export const fontSizeSmall = "80%";
import AspectRatioIcon from "@mui/icons-material/AspectRatio";
import {
Fullscreen,
Pause,
PictureInPicture,
PlayArrow,
Refresh,
VolumeOff,
VolumeUp,
} from "@mui/icons-material";
import { formatTime } from "../../utils/time.js";
import { CustomFontTooltip } from "./CustomFontTooltip.js";
const buttonPaddingBig = "6px";
const buttonPaddingSmall = "4px";
export const PlayButton = ({togglePlay, isPlaying , isScreenSmall}: any) => {
return (
<CustomFontTooltip title="Pause/Play (Spacebar)" placement="bottom" arrow>
<IconButton
sx={{
color: "white",
padding: isScreenSmall ? buttonPaddingSmall : buttonPaddingBig,
}}
onClick={() => togglePlay()}
>
{isPlaying ? <Pause /> : <PlayArrow />}
</IconButton>
</CustomFontTooltip>
);
};
export const ReloadButton = ({reloadVideo, isScreenSmall}: any) => {
return (
<CustomFontTooltip title="Reload Video (R)" placement="bottom" arrow>
<IconButton
sx={{
color: "white",
padding: isScreenSmall ? buttonPaddingSmall : buttonPaddingBig,
}}
onClick={reloadVideo}
>
<Refresh />
</IconButton>
</CustomFontTooltip>
);
};
export const ProgressSlider = ({progress, duration, videoRef}: any) => {
const onProgressChange = async (_: any, value: number | number[]) => {
if (!videoRef.current) return;
videoRef.current.currentTime = value as number;
};
return (
<Slider
value={progress}
onChange={onProgressChange}
min={0}
max={duration || 100}
step={0.1}
sx={{
position: "absolute",
bottom: "42px",
color: "#00abff",
padding: "0px",
// prevents the slider from jumping up 20px in certain mobile conditions
"@media (pointer: coarse)": { padding: "0px" },
"& .MuiSlider-thumb": {
backgroundColor: "#fff",
width: "16px",
height: "16px",
},
"& .MuiSlider-thumb::after": { width: "20px", height: "20px" },
"& .MuiSlider-rail": { opacity: 0.5, height: "6px" },
"& .MuiSlider-track": { height: "6px", border: "0px" },
}}
/>
);
};
export const VideoTime = ({videoRef, progress, isScreenSmall}: any) => {
return (
<CustomFontTooltip
title="Seek video in 10% increments (0-9)"
placement="bottom"
arrow
>
<Typography
sx={{
fontSize: isScreenSmall ? fontSizeExSmall : fontSizeSmall,
color: "white",
visibility: !videoRef.current?.duration ? "hidden" : "visible",
whiteSpace: "nowrap",
}}
>
{videoRef.current?.duration ? formatTime(progress) : ""}
{" / "}
{videoRef.current?.duration
? formatTime(videoRef.current?.duration)
: ""}
</Typography>
</CustomFontTooltip>
);
};
const VolumeButton = ({isMuted, toggleMute}: any) => {
return (
<CustomFontTooltip
title="Toggle Mute (M), Raise (UP), Lower (DOWN)"
placement="bottom"
arrow
>
<IconButton
sx={{
color: "white",
}}
onClick={toggleMute}
>
{isMuted ? <VolumeOff /> : <VolumeUp />}
</IconButton>
</CustomFontTooltip>
);
};
const VolumeSlider = ({ width, volume, onVolumeChange }: any) => {
let color = "";
if (volume <= 0.5) color = "green";
else if (volume <= 0.75) color = "yellow";
else color = "red";
return (
<Slider
value={volume}
onChange={onVolumeChange}
min={0}
max={1}
step={0.01}
sx={{
width,
marginRight: "10px",
color,
"& .MuiSlider-thumb": {
backgroundColor: "#fff",
width: "16px",
height: "16px",
},
"& .MuiSlider-thumb::after": { width: "16px", height: "16px" },
"& .MuiSlider-rail": { opacity: 0.5, height: "6px" },
"& .MuiSlider-track": { height: "6px", border: "0px" },
}}
/>
);
};
export const VolumeControl = ({ sliderWidth, onVolumeChange, volume }: any) => {
return (
<Box
sx={{ display: "flex", gap: "5px", alignItems: "center", width: "100%" }}
>
<VolumeButton />
<VolumeSlider width={sliderWidth} onVolumeChange={onVolumeChange} volume={volume} />
</Box>
);
};
export const PlaybackRate = ({playbackRate, increaseSpeed, isScreenSmall}: any) => {
return (
<CustomFontTooltip
title="Video Speed. Increase (+ or >), Decrease (- or <)"
placement="bottom"
arrow
>
<IconButton
sx={{
color: "white",
fontSize: fontSizeSmall,
padding: isScreenSmall ? buttonPaddingSmall : buttonPaddingBig,
}}
onClick={() => increaseSpeed()}
>
{playbackRate}x
</IconButton>
</CustomFontTooltip>
);
};
export const ObjectFitButton = ({toggleObjectFit, isScreenSmall}: any) => {
return (
<CustomFontTooltip title="Toggle Aspect Ratio (O)" placement="bottom" arrow>
<IconButton
sx={{
color: "white",
padding: isScreenSmall ? buttonPaddingSmall : buttonPaddingBig,
}}
onClick={() => toggleObjectFit()}
>
<AspectRatioIcon />
</IconButton>
</CustomFontTooltip>
);
};
export const PictureInPictureButton = ({isFullscreen, toggleRef, togglePictureInPicture, isScreenSmall}: any) => {
return (
<>
{!isFullscreen && (
<CustomFontTooltip
title="Picture in Picture (P)"
placement="bottom"
arrow
>
<IconButton
sx={{
color: "white",
padding: isScreenSmall ? buttonPaddingSmall : buttonPaddingBig,
}}
ref={toggleRef}
onClick={togglePictureInPicture}
>
<PictureInPicture />
</IconButton>
</CustomFontTooltip>
)}
</>
);
};
export const FullscreenButton = ({toggleFullscreen, isScreenSmall}: any) => {
return (
<CustomFontTooltip title="Toggle Fullscreen (F)" placement="bottom" arrow>
<IconButton
sx={{
color: "white",
padding: isScreenSmall ? buttonPaddingSmall : buttonPaddingBig,
}}
onClick={() => toggleFullscreen()}
>
<Fullscreen />
</IconButton>
</CustomFontTooltip>
);
};

View File

@ -0,0 +1,74 @@
import { Box } from "@mui/material";
import { ControlsContainer } from "./VideoPlayer-styles";
// import { MobileControlsBar } from "./MobileControlsBar";
import {
FullscreenButton,
ObjectFitButton,
PictureInPictureButton,
PlaybackRate,
PlayButton,
ProgressSlider,
ReloadButton,
VideoTime,
VolumeControl,
} from "./VideoControls";
import { Ref } from "react";
interface VideoControlsBarProps {
canPlay: boolean
isScreenSmall: boolean
controlsHeight?: string
videoRef:Ref<HTMLVideoElement>;
progress: number;
duration: number
isPlaying: boolean;
togglePlay: ()=> void;
reloadVideo: ()=> void;
volume: number
onVolumeChange: (_: any, val: number)=> void
}
export const VideoControlsBar = ({reloadVideo, onVolumeChange, volume, isPlaying, canPlay, isScreenSmall, controlsHeight, videoRef, duration, progress, togglePlay}: VideoControlsBarProps) => {
const showMobileControls = isScreenSmall && canPlay;
const controlGroupSX = {
display: "flex",
gap: "5px",
alignItems: "center",
height: controlsHeight,
};
return (
<ControlsContainer
style={{
padding: "0px",
height: controlsHeight,
}}
>
{showMobileControls ? (
null
// <MobileControlsBar />
) : canPlay ? (
<>
<Box sx={controlGroupSX}>
<PlayButton isPlaying={isPlaying} togglePlay={togglePlay}/>
<ReloadButton reloadVideo={reloadVideo} />
<ProgressSlider videoRef={videoRef} progress={progress} duration={duration} />
<VolumeControl onVolumeChange={onVolumeChange} volume={volume} sliderWidth={"100px"} />
<VideoTime videoRef={videoRef} progress={progress}/>
</Box>
<Box sx={controlGroupSX}>
<PlaybackRate />
<ObjectFitButton />
<PictureInPictureButton />
<FullscreenButton />
</Box>
</>
) : null}
</ControlsContainer>
);
};

View File

@ -1,16 +1,11 @@
import {
Ref,
RefObject,
useCallback,
useMemo,
useRef,
useState,
} from "react";
import { Ref, RefObject, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { QortalGetMetadata } from "../../types/interfaces/resources";
import { VideoContainer, VideoElement } from "./VideoPlayer-styles";
import { useVideoPlayerHotKeys } from "./useVideoPlayerHotKeys";
import { useProgressStore, useVideoStore } from "../../state/video";
import { useVideoPlayerController } from "./useVideoPlayerController";
import { LoadingVideo } from "./LoadingVideo";
import { VideoControlsBar } from "./VideoControlsBar";
type StretchVideoType = "contain" | "fill" | "cover" | "none" | "scale-down";
@ -21,7 +16,7 @@ export interface VideoPlayerProps {
showControls?: boolean;
poster?: string;
autoPlay?: boolean;
onEnded?: (e: React.SyntheticEvent<HTMLVideoElement, Event>)=> void
onEnded?: (e: React.SyntheticEvent<HTMLVideoElement, Event>) => void;
}
const videoStyles = {
@ -35,16 +30,22 @@ export const VideoPlayer = ({
showControls,
poster,
autoPlay,
onEnded
onEnded,
}: VideoPlayerProps) => {
const containerRef = useRef<RefObject<HTMLDivElement> | null>(null);
const [videoObjectFit] = useState<StretchVideoType>("contain");
const [isPlaying, setIsPlaying] = useState(false);
const [volume, setVolume] = useState(0);
const { volume, setVolume } = useVideoStore((state) => ({
volume: state.playbackSettings.volume,
setVolume: state.setVolume,
}));
const [isMuted, setIsMuted] = useState(false);
const { setProgress } = useProgressStore();
const [localProgress, setLocalProgress] = useState(0)
const [duration, setDuration] = useState(0)
const [isLoading, setIsLoading] = useState(true);
const [isLoading, setIsLoading] = useState(false);
const {
reloadVideo,
@ -67,7 +68,13 @@ export const VideoPlayer = ({
startPlay,
setProgressAbsolute,
setAlwaysShowControls,
} = useVideoPlayerController({ autoPlay, videoRef, qortalVideoResource, retryAttempts });
status, percentLoaded
} = useVideoPlayerController({
autoPlay,
videoRef,
qortalVideoResource,
retryAttempts
});
const hotkeyHandlers = useMemo(
() => ({
@ -109,8 +116,18 @@ export const VideoPlayer = ({
if (!ref.current || !videoLocation) return;
if (typeof ref.current.currentTime === "number") {
setProgress(videoLocation, ref.current.currentTime);
setLocalProgress(ref.current.currentTime)
}
};
useEffect(() => {
const ref = videoRef as React.RefObject<HTMLVideoElement>;
if (!ref.current) return;
if (ref.current) {
ref.current.volume = volume;
}
// Only run on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const onPlay = useCallback(() => {
setIsPlaying(true);
@ -152,11 +169,37 @@ export const VideoPlayer = ({
};
}, [videoObjectFit, showControls, isFullscreen]);
const handleEnded = useCallback((e: React.SyntheticEvent<HTMLVideoElement, Event>)=> {
if(onEnded){
onEnded(e)
}
}, [onEnded])
const handleEnded = useCallback(
(e: React.SyntheticEvent<HTMLVideoElement, Event>) => {
if (onEnded) {
onEnded(e);
}
},
[onEnded]
);
const handleCanPlay = useCallback(()=> {
setIsLoading(false);
}, [setIsLoading])
useEffect(() => {
const ref = videoRef as React.RefObject<HTMLVideoElement>;
if (!ref.current) return;
const video = ref.current;
if (!video) return;
const handleLoadedMetadata = () => {
if(video?.duration){
setDuration(video.duration)
}
};
video.addEventListener('loadedmetadata', handleLoadedMetadata);
return () => {
video.removeEventListener('loadedmetadata', handleLoadedMetadata);
};
}, []);
return (
<VideoContainer
@ -166,7 +209,7 @@ export const VideoPlayer = ({
onMouseLeave={handleMouseLeave}
ref={containerRef}
>
{/* <LoadingVideo /> */}
<LoadingVideo togglePlay={togglePlay} isReady={isReady} status={status} percentLoaded={percentLoaded} isLoading={isLoading} />
<VideoElement
id={qortalVideoResource?.identifier}
ref={videoRef}
@ -175,18 +218,16 @@ export const VideoPlayer = ({
poster={startPlay ? "" : poster}
onTimeUpdate={updateProgress}
autoPlay={autoPlay}
onClick={() => togglePlay()}
onEnded={handleEnded}
onCanPlay={() => {
setIsLoading(false);
}}
onClick={togglePlay}
onEnded={handleEnded}
onCanPlay={handleCanPlay}
preload="metadata"
style={videoStylesVideo}
onPlay={onPlay}
onPause={onPause}
onVolumeChange={onVolumeChangeHandler}
/>
{/* {showControls && <VideoControlsBar />} */}
<VideoControlsBar onVolumeChange={onVolumeChange} volume={volume} togglePlay={togglePlay} reloadVideo={hotkeyHandlers.reloadVideo} isPlaying={isPlaying} canPlay={true} isScreenSmall={false} controlsHeight={controlsHeight} videoRef={videoRef} duration={duration} progress={localProgress} />
</VideoContainer>
);
};

View File

@ -42,7 +42,7 @@ export const useVideoPlayerController = (props: UseVideoControls) => {
const { playbackSettings, setPlaybackRate, setVolume } = useVideoStore();
const { getProgress } = useProgressStore();
const { isReady, resourceUrl } = useResourceStatus({
const { isReady, resourceUrl, status, percentLoaded } = useResourceStatus({
resource: !startedFetch ? null : qortalVideoResource,
retryAttempts,
});
@ -194,7 +194,7 @@ export const useVideoPlayerController = (props: UseVideoControls) => {
ref.current.load();
ref.current.currentTime = currentTime;
ref.current.play();
}, []);
}, [isReady, resourceUrl]);
useEffect(() => {
if (autoPlay) togglePlay();
@ -227,5 +227,6 @@ export const useVideoPlayerController = (props: UseVideoControls) => {
isReady,
resourceUrl,
startPlay,
status, percentLoaded
};
};

View File

@ -200,7 +200,7 @@ export const useResourceStatus = ({
const resourceUrl = resource ? `/arbitrary/${resource.service}/${resource.name}/${resource.identifier}` : null;
return useMemo(() => ({
status: status?.status || "SEARCHING",
status: status?.status || "INITIAL",
localChunkCount: status?.localChunkCount || 0,
totalChunkCount: status?.totalChunkCount || 0,
percentLoaded: status?.percentLoaded || 0,

View File

@ -21,6 +21,7 @@ export type Status =
| 'FAILED_TO_DOWNLOAD'
| 'REFETCHING'
| 'SEARCHING'
| 'INITIAL'
export interface ResourceStatus {
status: Status

View File

@ -25,4 +25,29 @@ export function formatTimestamp(timestamp: number): string {
export function oneMonthAgo(){
const oneMonthAgoTimestamp = dayjs().subtract(1, "month").valueOf();
return oneMonthAgoTimestamp
}
export function formatTime(seconds: number): string {
seconds = Math.floor(seconds);
const minutes: number | string = Math.floor(seconds / 60);
let hours: number | string = Math.floor(minutes / 60);
let remainingSeconds: number | string = seconds % 60;
let remainingMinutes: number | string = minutes % 60;
if (remainingSeconds < 10) {
remainingSeconds = "0" + remainingSeconds;
}
if (remainingMinutes < 10) {
remainingMinutes = "0" + remainingMinutes;
}
if (hours === 0) {
hours = "";
} else {
hours = hours + ":";
}
return hours + remainingMinutes + ":" + remainingSeconds;
}