From 1ba3bfca260557e402f6a8d42873255926c7b592 Mon Sep 17 00:00:00 2001 From: PhilReact Date: Tue, 10 Jun 2025 01:12:38 +0300 Subject: [PATCH] started on controls --- .../VideoPlayer/CustomFontTooltip.tsx | 22 ++ src/components/VideoPlayer/LoadingVideo.tsx | 116 ++++++++ .../VideoPlayer/MobileControlsBar.tsx | 69 +++++ src/components/VideoPlayer/VideoControls.tsx | 249 ++++++++++++++++++ .../VideoPlayer/VideoControlsBar.tsx | 74 ++++++ src/components/VideoPlayer/VideoPlayer.tsx | 91 +++++-- .../VideoPlayer/useVideoPlayerController.tsx | 5 +- src/hooks/useResourceStatus.tsx | 2 +- src/state/publishes.ts | 1 + src/utils/time.ts | 25 ++ 10 files changed, 626 insertions(+), 28 deletions(-) create mode 100644 src/components/VideoPlayer/CustomFontTooltip.tsx create mode 100644 src/components/VideoPlayer/LoadingVideo.tsx create mode 100644 src/components/VideoPlayer/MobileControlsBar.tsx create mode 100644 src/components/VideoPlayer/VideoControls.tsx create mode 100644 src/components/VideoPlayer/VideoControlsBar.tsx diff --git a/src/components/VideoPlayer/CustomFontTooltip.tsx b/src/components/VideoPlayer/CustomFontTooltip.tsx new file mode 100644 index 0000000..d4c76e9 --- /dev/null +++ b/src/components/VideoPlayer/CustomFontTooltip.tsx @@ -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) => { + if (!fontSize) fontSize = "160%"; + const text = {title}; + + // put controls into individual components + return ( + +
{children}
+
+ ); +}; diff --git a/src/components/VideoPlayer/LoadingVideo.tsx b/src/components/VideoPlayer/LoadingVideo.tsx new file mode 100644 index 0000000..de1d3e6 --- /dev/null +++ b/src/components/VideoPlayer/LoadingVideo.tsx @@ -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' && ( + + + {status && ( + + {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... + )} + + )} + + )} + + {(status === 'INITIAL') && ( + <> + { + togglePlay(); + }} + sx={{ + cursor: "pointer", + }} + > + + + + )} + + ); +}; diff --git a/src/components/VideoPlayer/MobileControlsBar.tsx b/src/components/VideoPlayer/MobileControlsBar.tsx new file mode 100644 index 0000000..94e924c --- /dev/null +++ b/src/components/VideoPlayer/MobileControlsBar.tsx @@ -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 ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/src/components/VideoPlayer/VideoControls.tsx b/src/components/VideoPlayer/VideoControls.tsx new file mode 100644 index 0000000..601963b --- /dev/null +++ b/src/components/VideoPlayer/VideoControls.tsx @@ -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 ( + + togglePlay()} + > + {isPlaying ? : } + + + ); +}; + +export const ReloadButton = ({reloadVideo, isScreenSmall}: any) => { + return ( + + + + + + ); +}; + +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 ( + + ); +}; + +export const VideoTime = ({videoRef, progress, isScreenSmall}: any) => { + + return ( + + + {videoRef.current?.duration ? formatTime(progress) : ""} + {" / "} + {videoRef.current?.duration + ? formatTime(videoRef.current?.duration) + : ""} + + + ); +}; + +const VolumeButton = ({isMuted, toggleMute}: any) => { + return ( + + + {isMuted ? : } + + + ); +}; + +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 ( + + ); +}; + +export const VolumeControl = ({ sliderWidth, onVolumeChange, volume }: any) => { + return ( + + + + + ); +}; + +export const PlaybackRate = ({playbackRate, increaseSpeed, isScreenSmall}: any) => { + return ( + + increaseSpeed()} + > + {playbackRate}x + + + ); +}; + +export const ObjectFitButton = ({toggleObjectFit, isScreenSmall}: any) => { + return ( + + toggleObjectFit()} + > + + + + ); +}; + +export const PictureInPictureButton = ({isFullscreen, toggleRef, togglePictureInPicture, isScreenSmall}: any) => { + + return ( + <> + {!isFullscreen && ( + + + + + + )} + + ); +}; + +export const FullscreenButton = ({toggleFullscreen, isScreenSmall}: any) => { + + return ( + + toggleFullscreen()} + > + + + + ); +}; diff --git a/src/components/VideoPlayer/VideoControlsBar.tsx b/src/components/VideoPlayer/VideoControlsBar.tsx new file mode 100644 index 0000000..4ce5b38 --- /dev/null +++ b/src/components/VideoPlayer/VideoControlsBar.tsx @@ -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; + 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 ( + + {showMobileControls ? ( + null + // + ) : canPlay ? ( + <> + + + + + + + + + + + + + + + + + + ) : null} + + ); +}; diff --git a/src/components/VideoPlayer/VideoPlayer.tsx b/src/components/VideoPlayer/VideoPlayer.tsx index 83411bc..beeb50d 100644 --- a/src/components/VideoPlayer/VideoPlayer.tsx +++ b/src/components/VideoPlayer/VideoPlayer.tsx @@ -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)=> void + onEnded?: (e: React.SyntheticEvent) => void; } const videoStyles = { @@ -35,16 +30,22 @@ export const VideoPlayer = ({ showControls, poster, autoPlay, - onEnded + onEnded, }: VideoPlayerProps) => { const containerRef = useRef | null>(null); const [videoObjectFit] = useState("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; + 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)=> { - if(onEnded){ - onEnded(e) - } - }, [onEnded]) + const handleEnded = useCallback( + (e: React.SyntheticEvent) => { + if (onEnded) { + onEnded(e); + } + }, + [onEnded] + ); + + const handleCanPlay = useCallback(()=> { + setIsLoading(false); + }, [setIsLoading]) + + useEffect(() => { + const ref = videoRef as React.RefObject; + 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 ( - {/* */} + togglePlay()} - onEnded={handleEnded} - onCanPlay={() => { - setIsLoading(false); - }} + onClick={togglePlay} + onEnded={handleEnded} + onCanPlay={handleCanPlay} preload="metadata" style={videoStylesVideo} onPlay={onPlay} onPause={onPause} onVolumeChange={onVolumeChangeHandler} /> - {/* {showControls && } */} + ); }; diff --git a/src/components/VideoPlayer/useVideoPlayerController.tsx b/src/components/VideoPlayer/useVideoPlayerController.tsx index 47fcbba..708afb8 100644 --- a/src/components/VideoPlayer/useVideoPlayerController.tsx +++ b/src/components/VideoPlayer/useVideoPlayerController.tsx @@ -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 }; }; diff --git a/src/hooks/useResourceStatus.tsx b/src/hooks/useResourceStatus.tsx index 65cfc64..ad9d1f9 100644 --- a/src/hooks/useResourceStatus.tsx +++ b/src/hooks/useResourceStatus.tsx @@ -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, diff --git a/src/state/publishes.ts b/src/state/publishes.ts index b431814..87e32be 100644 --- a/src/state/publishes.ts +++ b/src/state/publishes.ts @@ -21,6 +21,7 @@ export type Status = | 'FAILED_TO_DOWNLOAD' | 'REFETCHING' | 'SEARCHING' +| 'INITIAL' export interface ResourceStatus { status: Status diff --git a/src/utils/time.ts b/src/utils/time.ts index 68c87a4..c41170b 100644 --- a/src/utils/time.ts +++ b/src/utils/time.ts @@ -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; } \ No newline at end of file