From 0bed6cab8cc6ff569ba6282649e79ad838d60215 Mon Sep 17 00:00:00 2001 From: IrohDW Date: Wed, 16 Oct 2024 15:47:05 -0600 Subject: [PATCH] Refactor to video player Video player split into multiple components Video player uses React signals for some of its state, this may improve performance. --- .../VideoPlayer/Components/LoadingVideo.tsx | 122 ++ .../Components/VideoControls-State.ts | 393 +++++++ .../VideoPlayer/Components/VideoControls.tsx | 240 ++++ .../common/VideoPlayer/VideoPlayer-State.ts | 273 +++++ .../common/VideoPlayer/VideoPlayer.tsx | 1007 +---------------- 5 files changed, 1081 insertions(+), 954 deletions(-) create mode 100644 src/components/common/VideoPlayer/Components/LoadingVideo.tsx create mode 100644 src/components/common/VideoPlayer/Components/VideoControls-State.ts create mode 100644 src/components/common/VideoPlayer/Components/VideoControls.tsx create mode 100644 src/components/common/VideoPlayer/VideoPlayer-State.ts diff --git a/src/components/common/VideoPlayer/Components/LoadingVideo.tsx b/src/components/common/VideoPlayer/Components/LoadingVideo.tsx new file mode 100644 index 0000000..5552df7 --- /dev/null +++ b/src/components/common/VideoPlayer/Components/LoadingVideo.tsx @@ -0,0 +1,122 @@ +import { Box, CircularProgress, Typography } from "@mui/material"; +import { setVideoPlaying } from "../../../../state/features/globalSlice.ts"; +import { useDispatch } from "react-redux"; +import { PlayArrow } from "@mui/icons-material"; + +export interface LoadingVideoProps { + isLoading: boolean; + resourceStatus: any; + src: any; + startPlay: boolean; + from: any; + togglePlay: (isPlay?: boolean) => void; +} +export const LoadingVideo = ({ + isLoading, + resourceStatus, + src, + startPlay, + from, + togglePlay, +}: LoadingVideoProps) => { + const getDownloadProgress = (current: number, total: number) => { + const progress = (current / total) * 100; + return Number.isNaN(progress) ? "" : progress.toFixed(0) + "%"; + }; + + const dispatch = useDispatch(); + return ( + <> + {isLoading && ( + + + {resourceStatus && ( + + {resourceStatus?.status === "NOT_PUBLISHED" && ( + <>Video file was not published. Please inform the publisher! + )} + {resourceStatus?.status === "REFETCHING" ? ( + <> + <> + {getDownloadProgress( + resourceStatus?.localChunkCount, + resourceStatus?.totalChunkCount + )} + + + <> Refetching in 25 seconds + + ) : resourceStatus?.status === "DOWNLOADED" ? ( + <>Download Completed: building video... + ) : resourceStatus?.status !== "READY" ? ( + <> + {getDownloadProgress( + resourceStatus?.localChunkCount, + resourceStatus?.totalChunkCount + )} + + ) : ( + <>Fetching video... + )} + + )} + + )} + {((!src && !isLoading) || !startPlay) && ( + { + if (from === "create") return; + dispatch(setVideoPlaying(null)); + togglePlay(); + }} + sx={{ + cursor: "pointer", + }} + > + + + )} + + ); +}; diff --git a/src/components/common/VideoPlayer/Components/VideoControls-State.ts b/src/components/common/VideoPlayer/Components/VideoControls-State.ts new file mode 100644 index 0000000..004c576 --- /dev/null +++ b/src/components/common/VideoPlayer/Components/VideoControls-State.ts @@ -0,0 +1,393 @@ +import { Key } from "ts-key-enum"; +import { setVideoPlaying } from "../../../../state/features/globalSlice.ts"; +import { + setReduxPlaybackRate, + setStretchVideoSetting, + setVolumeSetting, +} from "../../../../state/features/persistSlice.ts"; +import { RootState, store } from "../../../../state/store.ts"; +import { + canPlay, + isLoading, + isMuted, + mutedVolume, + playbackRate, + playing, + progress, + startPlay, + useVideoPlayerState, + videoObjectFit, + volume, +} from "../VideoPlayer-State.ts"; +import { useEffect } from "react"; +import { signal, useSignal } from "@preact/signals-react"; +import { useSignals } from "@preact/signals-react/runtime"; +import { VideoPlayerProps } from "../VideoPlayer.tsx"; +import ReactDOM from "react-dom"; +import { useSelector } from "react-redux"; + +export const showControlsFullScreen = signal(true); + +export const useVideoControlsState = ( + props: VideoPlayerProps, + videoRef, + videoPlayerState: ReturnType +) => { + useSignals(); + const { src, getSrc, resourceStatus } = videoPlayerState; + const { identifier, autoPlay } = props; + const persistSelector = store.getState().persist; + + const videoPlaying = useSelector( + (state: RootState) => state.global.videoPlaying + ); + + const minSpeed = 0.25; + const maxSpeed = 4.0; + const speedChange = 0.25; + + const updatePlaybackRate = (newSpeed: number) => { + if (videoRef.current) { + if (newSpeed > maxSpeed || newSpeed < minSpeed) newSpeed = minSpeed; + + videoRef.current.playbackRate = playbackRate.value; + playbackRate.value = newSpeed; + store.dispatch(setReduxPlaybackRate(newSpeed)); + } + }; + + const increaseSpeed = (wrapOverflow = true) => { + const changedSpeed = playbackRate.value + speedChange; + const newSpeed = wrapOverflow + ? changedSpeed + : Math.min(changedSpeed, maxSpeed); + + if (videoRef.current) { + updatePlaybackRate(newSpeed); + } + }; + + const decreaseSpeed = () => { + if (videoRef.current) { + updatePlaybackRate(playbackRate.value - speedChange); + } + }; + + const isFullscreen = useSignal(false); + + const enterFullscreen = () => { + if (!videoRef.current) return; + if (videoRef.current.requestFullscreen) { + videoRef.current.requestFullscreen(); + } + }; + + const exitFullscreen = () => { + if (document.exitFullscreen) { + document.exitFullscreen(); + } + }; + + const toggleFullscreen = () => { + isFullscreen.value ? exitFullscreen() : enterFullscreen(); + }; + const togglePictureInPicture = async () => { + if (!videoRef.current) return; + if (document.pictureInPictureElement === videoRef.current) { + await document.exitPictureInPicture(); + } else { + await videoRef.current.requestPictureInPicture(); + } + }; + + useEffect(() => { + const handleFullscreenChange = () => { + isFullscreen.value = !!document.fullscreenElement; + }; + + document.addEventListener("fullscreenchange", handleFullscreenChange); + return () => { + document.removeEventListener("fullscreenchange", handleFullscreenChange); + }; + }, []); + + 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; + } + + const reloadVideo = async () => { + if (!videoRef.current) return; + const currentTime = videoRef.current.currentTime; + videoRef.current.src = src; + videoRef.current.load(); + videoRef.current.currentTime = currentTime; + + playing.value = true; + togglePlay(); + }; + + const togglePlay = async () => { + if (!videoRef.current) return; + + if (!src || resourceStatus?.status !== "READY") { + const el = document.getElementById("videoWrapper"); + if (el) { + el?.parentElement?.removeChild(el); + } + ReactDOM.flushSync(() => { + isLoading.value = true; + }); + getSrc(); + } + + startPlay.value = true; + playing.value = !playing.value; + + if (playing.value) videoRef.current.pause(); + else await videoRef.current.play(); + }; + + const onVolumeChange = (_: any, value: number | number[]) => { + if (!videoRef.current) return; + const newVolume = value as number; + videoRef.current.volume = newVolume; + volume.value = newVolume; + isMuted.value = false; + store.dispatch(setVolumeSetting(newVolume)); + }; + + useEffect(() => { + playing.value = true; + if (autoPlay && identifier) togglePlay(); + }, [autoPlay, identifier]); + + const mute = () => { + isMuted.value = true; + mutedVolume.value = volume.value; + volume.value = 0; + if (videoRef.current) videoRef.current.volume = 0; + }; + const unMute = () => { + isMuted.value = false; + volume.value = mutedVolume.value; + if (videoRef.current) videoRef.current.volume = mutedVolume.value; + }; + + const toggleMute = () => { + isMuted.value ? unMute() : mute(); + }; + + const changeVolume = (volumeChange: number) => { + if (videoRef.current) { + const minVolume = 0; + const maxVolume = 1; + + let newVolume = volumeChange + volume.value; + + newVolume = Math.max(newVolume, minVolume); + newVolume = Math.min(newVolume, maxVolume); + + isMuted.value = false; + mutedVolume.value = newVolume; + videoRef.current.volume = newVolume; + volume.value = newVolume; + store.dispatch(setVolumeSetting(newVolume)); + } + }; + const setProgressRelative = (secondsChange: number) => { + if (videoRef.current) { + const currentTime = videoRef.current?.currentTime; + const minTime = 0; + const maxTime = videoRef.current?.duration || 100; + + let newTime = currentTime + secondsChange; + newTime = Math.max(newTime, minTime); + newTime = Math.min(newTime, maxTime); + videoRef.current.currentTime = newTime; + progress.value = newTime; + } + }; + + const setProgressAbsolute = (videoPercent: number) => { + if (videoRef.current) { + videoPercent = Math.min(videoPercent, 100); + videoPercent = Math.max(videoPercent, 0); + const finalTime = (videoRef.current?.duration * videoPercent) / 100; + videoRef.current.currentTime = finalTime; + progress.value = finalTime; + } + }; + + const toggleStretchVideoSetting = () => { + const newStretchVideoSetting = + persistSelector.stretchVideoSetting === "contain" ? "fill" : "contain"; + + videoObjectFit.value = newStretchVideoSetting; + store.dispatch(setStretchVideoSetting(newStretchVideoSetting)); + }; + const keyboardShortcutsDown = (e: React.KeyboardEvent) => { + e.preventDefault(); + + switch (e.key) { + case "o": + toggleStretchVideoSetting(); + break; + + case Key.Add: + increaseSpeed(false); + break; + case "+": + increaseSpeed(false); + break; + case ">": + increaseSpeed(false); + break; + + case Key.Subtract: + decreaseSpeed(); + break; + case "-": + decreaseSpeed(); + break; + case "<": + decreaseSpeed(); + break; + + case Key.ArrowLeft: + { + if (e.shiftKey) setProgressRelative(-300); + else if (e.ctrlKey) setProgressRelative(-60); + else if (e.altKey) setProgressRelative(-10); + else setProgressRelative(-5); + } + break; + + case Key.ArrowRight: + { + if (e.shiftKey) setProgressRelative(300); + else if (e.ctrlKey) setProgressRelative(60); + else if (e.altKey) setProgressRelative(10); + else setProgressRelative(5); + } + break; + + case Key.ArrowDown: + changeVolume(-0.05); + break; + case Key.ArrowUp: + changeVolume(0.05); + break; + } + }; + + const keyboardShortcutsUp = (e: React.KeyboardEvent) => { + e.preventDefault(); + + switch (e.key) { + case " ": + togglePlay(); + break; + case "m": + toggleMute(); + break; + + case "f": + enterFullscreen(); + break; + case Key.Escape: + exitFullscreen(); + break; + + case "0": + setProgressAbsolute(0); + break; + case "1": + setProgressAbsolute(10); + break; + case "2": + setProgressAbsolute(20); + break; + case "3": + setProgressAbsolute(30); + break; + case "4": + setProgressAbsolute(40); + break; + case "5": + setProgressAbsolute(50); + break; + case "6": + setProgressAbsolute(60); + break; + case "7": + setProgressAbsolute(70); + break; + case "8": + setProgressAbsolute(80); + break; + case "9": + setProgressAbsolute(90); + break; + } + }; + + const handleCanPlay = () => { + isLoading.value = false; + canPlay.value = true; + updatePlaybackRate(playbackRate.value); + }; + + useEffect(() => { + videoRef.current.volume = volume.value; + if ( + videoPlaying && + videoPlaying.id === identifier && + src && + videoRef?.current + ) { + handleCanPlay(); + + videoRef.current.currentTime = videoPlaying.currentTime; + videoRef.current.play().then(() => { + playing.value = true; + startPlay.value = true; + store.dispatch(setVideoPlaying(null)); + }); + } + }, [videoPlaying, identifier, src]); + + return { + reloadVideo, + togglePlay, + onVolumeChange, + increaseSpeed, + togglePictureInPicture, + toggleFullscreen, + formatTime, + keyboardShortcutsUp, + keyboardShortcutsDown, + handleCanPlay, + toggleMute, + }; +}; diff --git a/src/components/common/VideoPlayer/Components/VideoControls.tsx b/src/components/common/VideoPlayer/Components/VideoControls.tsx new file mode 100644 index 0000000..63995a6 --- /dev/null +++ b/src/components/common/VideoPlayer/Components/VideoControls.tsx @@ -0,0 +1,240 @@ +import { + Fullscreen, + MoreVert as MoreIcon, + Pause, + PictureInPicture, + PlayArrow, + Refresh, + VolumeOff, + VolumeUp, +} from "@mui/icons-material"; +import { IconButton, Menu, MenuItem, Slider, Typography } from "@mui/material"; +import { useSignals } from "@preact/signals-react/runtime"; +import { useEffect } from "react"; +import { + anchorEl, + canPlay, + isMobileView, + isMuted, + playbackRate, + playing, + progress, + useVideoPlayerState, + volume, +} from "../VideoPlayer-State.ts"; +import { ControlsContainer } from "../VideoPlayer-styles.ts"; +import { VideoPlayerProps } from "../VideoPlayer.tsx"; +import { + showControlsFullScreen, + useVideoControlsState, +} from "./VideoControls-State.ts"; + +export interface VideoControlProps { + controlState: ReturnType; + videoState: ReturnType; + props: VideoPlayerProps; + videoRef: any; +} +export const VideoControls = ({ + controlState, + videoState, + props, + videoRef, +}: VideoControlProps) => { + useSignals(); + const { + reloadVideo, + togglePlay, + onVolumeChange, + increaseSpeed, + togglePictureInPicture, + toggleFullscreen, + formatTime, + toggleMute, + } = controlState; + const { onProgressChange, handleMenuOpen, handleMenuClose, toggleRef } = + videoState; + const { from = null } = props; + + useEffect(() => { + const videoWidth = videoRef?.current?.offsetWidth; + if (videoWidth && videoWidth <= 600) { + isMobileView.value = true; + } + }, [canPlay.value]); + + return ( + + {isMobileView.value && canPlay.value && showControlsFullScreen.value ? ( + <> + togglePlay()} + > + {!playing.value ? : } + + + + + + + + + + + + + + increaseSpeed()}> + + Speed: {playbackRate.value}x + + + + + + + + + + + ) : canPlay.value ? ( + <> + togglePlay()} + > + {!playing.value ? : } + + + + + + + {progress && + videoRef.current?.duration && + formatTime(progress.value)} + / + {progress && + videoRef.current?.duration && + formatTime(videoRef.current?.duration)} + + + {isMuted.value ? : } + + + increaseSpeed()} + > + Speed: {playbackRate}x + + + + + + + + + + ) : null} + + ); +}; diff --git a/src/components/common/VideoPlayer/VideoPlayer-State.ts b/src/components/common/VideoPlayer/VideoPlayer-State.ts new file mode 100644 index 0000000..763a77c --- /dev/null +++ b/src/components/common/VideoPlayer/VideoPlayer-State.ts @@ -0,0 +1,273 @@ +import { signal } from "@preact/signals-react"; +import React, { + useContext, + useEffect, + useImperativeHandle, + useMemo, + useRef, +} from "react"; + +import { useDispatch, useSelector } from "react-redux"; +import { setVideoPlaying } from "../../../state/features/globalSlice.ts"; +import { StretchVideoType } from "../../../state/features/persistSlice.ts"; +import { RootState, store } from "../../../state/store.ts"; +import { MyContext } from "../../../wrappers/DownloadWrapper.tsx"; +import { VideoPlayerProps } from "./VideoPlayer.tsx"; +import { useSignals } from "@preact/signals-react/runtime"; + +export const playing = signal(false); + +export const isMuted = signal(false); +export const progress = signal(0); +export const isLoading = signal(false); +export const canPlay = signal(false); +export const startPlay = signal(false); +export const isMobileView = signal(false); + +export const volume = signal(0.5); +export const mutedVolume = signal(0.5); +export const playbackRate = signal(1); +export const anchorEl = signal(null); +export const videoObjectFit = signal("contain"); + +export const useVideoPlayerState = (props: VideoPlayerProps, ref: any) => { + useSignals(); + const persistSelector = useSelector((state: RootState) => state.persist); + volume.value = persistSelector.volume; + mutedVolume.value = persistSelector.volume; + playbackRate.value = persistSelector.playbackRate; + videoObjectFit.value = persistSelector.stretchVideoSetting; + + const { + name, + identifier, + service, + user = "", + jsonId = "", + nextVideo, + onEnd, + } = props; + const dispatch = useDispatch(); + const videoRef = useRef(null); + const containerRef = useRef(null); + + const { downloads } = useSelector((state: RootState) => state.global); + const reDownload = useRef(false); + const reDownloadNextVid = useRef(false); + const isFetchingProperties = useRef(false); + const status = useRef(null); + + const download = useMemo(() => { + if (!downloads || !identifier) return {}; + const findDownload = downloads[identifier]; + + if (!findDownload) return {}; + return findDownload; + }, [downloads, identifier]); + + const src = useMemo(() => { + return download?.url || ""; + }, [download?.url]); + + const resourceStatus = useMemo(() => { + return download?.status || {}; + }, [download]); + + useImperativeHandle(ref, () => ({ + getVideoRef: () => { + return videoRef; + }, + getContainerRef: () => { + return containerRef; + }, + })); + + useEffect(() => { + reDownload.current = false; + reDownloadNextVid.current = false; + isLoading.value = false; + canPlay.value = false; + progress.value = 0; + playing.value = false; + startPlay.value = false; + isFetchingProperties.current = false; + status.current = null; + }, [identifier]); + + const refetch = React.useCallback(async () => { + if (!name || !identifier || !service || isFetchingProperties.current) + return; + try { + isFetchingProperties.current = true; + await qortalRequest({ + action: "GET_QDN_RESOURCE_PROPERTIES", + name, + service, + identifier, + }); + } catch (error) { + console.log(error); + } finally { + isFetchingProperties.current = false; + } + }, [identifier, name, service]); + + const toggleRef = useRef(null); + const { downloadVideo } = useContext(MyContext); + + const onProgressChange = async (_: any, value: number | number[]) => { + if (!videoRef.current) return; + videoRef.current.currentTime = value as number; + progress.value = value as number; + if (!playing) { + await videoRef.current.play(); + playing.value = true; + } + }; + + const handleEnded = () => { + playing.value = false; + if (onEnd) { + onEnd(); + } + }; + + const updateProgress = () => { + if (!videoRef.current) return; + progress.value = videoRef.current.currentTime; + }; + + const getSrc = React.useCallback(async () => { + if (!name || !identifier || !service || !jsonId || !user) return; + try { + downloadVideo({ + name, + service, + identifier, + properties: { + jsonId, + user, + }, + }); + } catch (error) { + console.error(error); + } + }, [identifier, name, service, jsonId, user]); + + useEffect(() => { + const videoElement = videoRef.current; + + const handleLeavePictureInPicture = async (event: any) => { + const target = event?.target; + if (target) { + target.pause(); + if (playing.value) { + playing.value = false; + } + } + }; + + if (videoElement) { + videoElement.addEventListener( + "leavepictureinpicture", + handleLeavePictureInPicture + ); + } + + return () => { + if (videoElement) { + videoElement.removeEventListener( + "leavepictureinpicture", + handleLeavePictureInPicture + ); + } + }; + }, []); + + useEffect(() => { + const videoElement = videoRef.current; + + const minimizeVideo = async () => { + if (!videoElement) return; + + dispatch(setVideoPlaying(videoElement)); + }; + + return () => { + if (videoElement) { + if (videoElement && !videoElement.paused && !videoElement.ended) { + minimizeVideo(); + } + } + }; + }, []); + + const refetchInInterval = () => { + try { + const interval = setInterval(() => { + if (status?.current === "DOWNLOADED") refetch(); + if (status?.current === "READY") clearInterval(interval); + }, 7500); + } catch (error) { + console.log(error); + } + }; + + useEffect(() => { + if (resourceStatus?.status) { + status.current = resourceStatus?.status; + } + if ( + resourceStatus?.status === "DOWNLOADED" && + reDownload?.current === false + ) { + refetchInInterval(); + reDownload.current = true; + } + }, [getSrc, resourceStatus]); + + useEffect(() => { + if (resourceStatus?.status) { + status.current = resourceStatus?.status; + } + if ( + resourceStatus?.status === "READY" && + reDownloadNextVid?.current === false + ) { + if (nextVideo) { + downloadVideo({ + name: nextVideo?.name, + service: nextVideo?.service, + identifier: nextVideo?.identifier, + properties: { + jsonId: nextVideo?.jsonId, + user, + }, + }); + } + reDownloadNextVid.current = true; + } + }, [getSrc, resourceStatus]); + + const handleMenuOpen = (event: any) => { + anchorEl.value = event.currentTarget; + }; + + const handleMenuClose = () => { + anchorEl.value = null; + }; + + return { + containerRef, + resourceStatus, + videoRef, + src, + getSrc, + updateProgress, + handleEnded, + onProgressChange, + handleMenuOpen, + handleMenuClose, + toggleRef, + }; +}; diff --git a/src/components/common/VideoPlayer/VideoPlayer.tsx b/src/components/common/VideoPlayer/VideoPlayer.tsx index eeb21b8..b71e759 100644 --- a/src/components/common/VideoPlayer/VideoPlayer.tsx +++ b/src/components/common/VideoPlayer/VideoPlayer.tsx @@ -1,52 +1,26 @@ -import React, { - MutableRefObject, - useContext, - useEffect, - useImperativeHandle, - useMemo, - useRef, - useState, -} from "react"; -import ReactDOM from "react-dom"; -import { Box, IconButton, Slider } from "@mui/material"; -import { CircularProgress, Typography } from "@mui/material"; -import { Key } from "ts-key-enum"; -import { - PlayArrow, - Pause, - VolumeUp, - Fullscreen, - PictureInPicture, - VolumeOff, -} from "@mui/icons-material"; -import { styled } from "@mui/system"; -import { MyContext } from "../../../wrappers/DownloadWrapper.tsx"; -import { useDispatch, useSelector } from "react-redux"; -import { RootState } from "../../../state/store.ts"; -import { Refresh } from "@mui/icons-material"; - -import { Menu, MenuItem } from "@mui/material"; -import { MoreVert as MoreIcon } from "@mui/icons-material"; -import { setVideoPlaying } from "../../../state/features/globalSlice.ts"; -import { - ControlsContainer, - VideoContainer, - VideoElement, -} from "./VideoPlayer-styles.ts"; +import { useSignals } from "@preact/signals-react/runtime"; import CSS from "csstype"; +import { forwardRef } from "react"; +import { LoadingVideo } from "./Components/LoadingVideo.tsx"; import { - setReduxPlaybackRate, - setStretchVideoSetting, - setVolumeSetting, - StretchVideoType, -} from "../../../state/features/persistSlice.ts"; + showControlsFullScreen, + useVideoControlsState, +} from "./Components/VideoControls-State.ts"; +import { VideoControls } from "./Components/VideoControls.tsx"; +import { + isLoading, + startPlay, + useVideoPlayerState, + videoObjectFit, +} from "./VideoPlayer-State.ts"; +import { VideoContainer, VideoElement } from "./VideoPlayer-styles.ts"; export interface VideoStyles { videoContainer?: CSS.Properties; video?: CSS.Properties; controls?: CSS.Properties; } -interface VideoPlayerProps { +export interface VideoPlayerProps { src?: string; poster?: string; name?: string; @@ -67,654 +41,31 @@ export type refType = { getContainerRef: () => React.MutableRefObject; getVideoRef: () => React.MutableRefObject; }; -export const VideoPlayer = React.forwardRef( - ( - { +export const VideoPlayer = forwardRef( + (props: VideoPlayerProps, ref) => { + useSignals(); + const { poster, - name, identifier, - service, autoplay = true, from = null, videoStyles = {}, - user = "", - jsonId = "", - nextVideo, - onEnd, - autoPlay, - style = {}, - }: VideoPlayerProps, - ref - ) => { - const videoSelector = useSelector((state: RootState) => state.video); - const persistSelector = useSelector((state: RootState) => state.persist); - - const dispatch = useDispatch(); - const videoRef = useRef(null); - const containerRef = useRef(null); - const [playing, setPlaying] = useState(false); - const [volume, setVolume] = useState(persistSelector.volume); - const [mutedVolume, setMutedVolume] = useState(persistSelector.volume); - const [isMuted, setIsMuted] = useState(false); - const [progress, setProgress] = useState(0); - const [isLoading, setIsLoading] = useState(false); - const [canPlay, setCanPlay] = useState(false); - const [startPlay, setStartPlay] = useState(false); - const [isMobileView, setIsMobileView] = useState(false); - const [playbackRate, setPlaybackRate] = useState( - persistSelector.playbackRate - ); - const [anchorEl, setAnchorEl] = useState(null); - const [showControlsFullScreen, setShowControlsFullScreen] = - useState(true); - const [videoObjectFit, setVideoObjectFit] = useState( - persistSelector.stretchVideoSetting - ); - - const videoPlaying = useSelector( - (state: RootState) => state.global.videoPlaying - ); - const { downloads } = useSelector((state: RootState) => state.global); - - const reDownload = useRef(false); - const reDownloadNextVid = useRef(false); - - const isFetchingProperties = useRef(false); - - const status = useRef(null); - - const download = useMemo(() => { - if (!downloads || !identifier) return {}; - const findDownload = downloads[identifier]; - - if (!findDownload) return {}; - return findDownload; - }, [downloads, identifier]); - - const src = useMemo(() => { - return download?.url || ""; - }, [download?.url]); - const resourceStatus = useMemo(() => { - return download?.status || {}; - }, [download]); - - const minSpeed = 0.25; - const maxSpeed = 4.0; - const speedChange = 0.25; - - useImperativeHandle(ref, () => ({ - getVideoRef: () => { - return videoRef; - }, - getContainerRef: () => { - return containerRef; - }, - })); - const updatePlaybackRate = (newSpeed: number) => { - if (videoRef.current) { - if (newSpeed > maxSpeed || newSpeed < minSpeed) newSpeed = minSpeed; - - videoRef.current.playbackRate = playbackRate; - setPlaybackRate(newSpeed); - dispatch(setReduxPlaybackRate(newSpeed)); - } - }; - - const increaseSpeed = (wrapOverflow = true) => { - const changedSpeed = playbackRate + speedChange; - const newSpeed = wrapOverflow - ? changedSpeed - : Math.min(changedSpeed, maxSpeed); - - if (videoRef.current) { - updatePlaybackRate(newSpeed); - } - }; - - const decreaseSpeed = () => { - if (videoRef.current) { - updatePlaybackRate(playbackRate - speedChange); - } - }; - - useEffect(() => { - reDownload.current = false; - reDownloadNextVid.current = false; - setIsLoading(false); - setCanPlay(false); - setProgress(0); - setPlaying(false); - setStartPlay(false); - isFetchingProperties.current = false; - status.current = null; - }, [identifier]); - - useEffect(() => { - if (autoPlay && identifier) { - setStartPlay(true); - setPlaying(true); - togglePlay(true); - } - }, [autoPlay, startPlay, identifier]); - - const refetch = React.useCallback(async () => { - if (!name || !identifier || !service || isFetchingProperties.current) - return; - try { - isFetchingProperties.current = true; - await qortalRequest({ - action: "GET_QDN_RESOURCE_PROPERTIES", - name, - service, - identifier, - }); - } catch (error) { - console.log(error); - } finally { - isFetchingProperties.current = false; - } - }, [identifier, name, service]); - - const toggleRef = useRef(null); - const { downloadVideo } = useContext(MyContext); - - const togglePlay = async (isPlay?: boolean) => { - if (!videoRef.current) return; - - setStartPlay(true); - if (!src || resourceStatus?.status !== "READY") { - const el = document.getElementById("videoWrapper"); - if (el) { - el?.parentElement?.removeChild(el); - } - ReactDOM.flushSync(() => { - setIsLoading(true); - }); - getSrc(); - } - - if (isPlay) setPlaying(true); - else setPlaying(prevState => !prevState); - - if (playing && !isPlay) videoRef.current.pause(); - else await videoRef.current.play(); - }; - - const onVolumeChange = (_: any, value: number | number[]) => { - if (!videoRef.current) return; - const newVolume = value as number; - videoRef.current.volume = newVolume; - setVolume(newVolume); - setIsMuted(false); - dispatch(setVolumeSetting(newVolume)); - }; - - const onProgressChange = async (_: any, value: number | number[]) => { - if (!videoRef.current) return; - videoRef.current.currentTime = value as number; - setProgress(value as number); - if (!playing) { - await videoRef.current.play(); - setPlaying(true); - } - }; - - const handleEnded = () => { - setPlaying(false); - if (onEnd) { - onEnd(); - } - }; - - const updateProgress = () => { - if (!videoRef.current) return; - setProgress(videoRef.current.currentTime); - }; - - const [isFullscreen, setIsFullscreen] = useState(false); - - const enterFullscreen = () => { - if (!videoRef.current) return; - if (videoRef.current.requestFullscreen) { - videoRef.current.requestFullscreen(); - } - }; - - const exitFullscreen = () => { - if (document.exitFullscreen) { - document.exitFullscreen(); - } - }; - - const toggleFullscreen = () => { - isFullscreen ? exitFullscreen() : enterFullscreen(); - }; - const togglePictureInPicture = async () => { - if (!videoRef.current) return; - if (document.pictureInPictureElement === videoRef.current) { - await document.exitPictureInPicture(); - } else { - await videoRef.current.requestPictureInPicture(); - } - }; - - useEffect(() => { - const handleFullscreenChange = () => { - setIsFullscreen(!!document.fullscreenElement); - }; - - document.addEventListener("fullscreenchange", handleFullscreenChange); - return () => { - document.removeEventListener( - "fullscreenchange", - handleFullscreenChange - ); - }; - }, []); - - useEffect(() => { - videoRef.current.volume = volume; - if ( - videoPlaying && - videoPlaying.id === identifier && - src && - videoRef?.current - ) { - handleCanPlay(); - - videoRef.current.currentTime = videoPlaying.currentTime; - videoRef.current.play().then(() => { - setPlaying(true); - setStartPlay(true); - dispatch(setVideoPlaying(null)); - }); - } - }, [videoPlaying, identifier, src]); - - const handleCanPlay = () => { - setIsLoading(false); - setCanPlay(true); - updatePlaybackRate(playbackRate); - }; - - const getSrc = React.useCallback(async () => { - if (!name || !identifier || !service || !jsonId || !user) return; - try { - downloadVideo({ - name, - service, - identifier, - properties: { - jsonId, - user, - }, - }); - } catch (error) { - console.error(error); - } - }, [identifier, name, service, jsonId, user]); - - useEffect(() => { - const videoElement = videoRef.current; - - const handleLeavePictureInPicture = async (event: any) => { - const target = event?.target; - if (target) { - target.pause(); - if (setPlaying) { - setPlaying(false); - } - } - }; - - if (videoElement) { - videoElement.addEventListener( - "leavepictureinpicture", - handleLeavePictureInPicture - ); - } - - return () => { - if (videoElement) { - videoElement.removeEventListener( - "leavepictureinpicture", - handleLeavePictureInPicture - ); - } - }; - }, []); - - useEffect(() => { - const videoElement = videoRef.current; - - const minimizeVideo = async () => { - if (!videoElement) return; - - dispatch(setVideoPlaying(videoElement)); - // const handleClose = () => { - // if (videoElement && videoElement.parentElement) { - // const el = document.getElementById('videoWrapper') - // if (el) { - // el?.parentElement?.removeChild(el) - // } - // } - // } - // const createCloseButton = (): HTMLButtonElement => { - // const closeButton = document.createElement('button') - // closeButton.textContent = 'X' - // closeButton.style.position = 'absolute' - // closeButton.style.top = '0' - // closeButton.style.right = '0' - // closeButton.style.backgroundColor = 'rgba(255, 255, 255, 0.7)' - // closeButton.style.border = 'none' - // closeButton.style.fontWeight = 'bold' - // closeButton.style.fontSize = '1.2rem' - // closeButton.style.cursor = 'pointer' - // closeButton.style.padding = '2px 8px' - // closeButton.style.borderRadius = '0 0 0 4px' - - // closeButton.addEventListener('click', handleClose) - - // return closeButton - // } - // const buttonClose = createCloseButton() - // const videoWrapper = document.createElement('div') - // videoWrapper.id = 'videoWrapper' - // videoWrapper.style.position = 'fixed' - // videoWrapper.style.zIndex = '900000009' - // videoWrapper.style.bottom = '0px' - // videoWrapper.style.right = '0px' - - // videoElement.parentElement?.insertBefore(videoWrapper, videoElement) - // videoWrapper.appendChild(videoElement) - - // videoWrapper.appendChild(buttonClose) - // videoElement.controls = true - // videoElement.style.height = 'auto' - // videoElement.style.width = '300px' - - // document.body.appendChild(videoWrapper) - }; - - return () => { - if (videoElement) { - if (videoElement && !videoElement.paused && !videoElement.ended) { - minimizeVideo(); - } - } - }; - }, []); - - 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; - } - - const reloadVideo = async () => { - if (!videoRef.current) return; - const currentTime = videoRef.current.currentTime; - videoRef.current.src = src; - videoRef.current.load(); - videoRef.current.currentTime = currentTime; - if (playing) await videoRef.current.play(); - setPlaying(true); - }; - - const refetchInInterval = () => { - try { - const interval = setInterval(() => { - if (status?.current === "DOWNLOADED") refetch(); - if (status?.current === "READY") clearInterval(interval); - }, 7500); - } catch (error) { - console.log(error); - } - }; - - useEffect(() => { - if (resourceStatus?.status) { - status.current = resourceStatus?.status; - } - if ( - resourceStatus?.status === "DOWNLOADED" && - reDownload?.current === false - ) { - refetchInInterval(); - reDownload.current = true; - } - }, [getSrc, resourceStatus]); - - useEffect(() => { - if (resourceStatus?.status) { - status.current = resourceStatus?.status; - } - if ( - resourceStatus?.status === "READY" && - reDownloadNextVid?.current === false - ) { - if (nextVideo) { - downloadVideo({ - name: nextVideo?.name, - service: nextVideo?.service, - identifier: nextVideo?.identifier, - properties: { - jsonId: nextVideo?.jsonId, - user, - }, - }); - } - reDownloadNextVid.current = true; - } - }, [getSrc, resourceStatus]); - - const handleMenuOpen = (event: any) => { - setAnchorEl(event.currentTarget); - }; - - const handleMenuClose = () => { - setAnchorEl(null); - }; - - useEffect(() => { - const videoWidth = videoRef?.current?.offsetWidth; - if (videoWidth && videoWidth <= 600) { - setIsMobileView(true); - } - }, [canPlay]); - - const getDownloadProgress = (current: number, total: number) => { - const progress = (current / total) * 100; - return Number.isNaN(progress) ? "" : progress.toFixed(0) + "%"; - }; - const mute = () => { - setIsMuted(true); - setMutedVolume(volume); - setVolume(0); - if (videoRef.current) videoRef.current.volume = 0; - }; - const unMute = () => { - setIsMuted(false); - setVolume(mutedVolume); - if (videoRef.current) videoRef.current.volume = mutedVolume; - }; - - const toggleMute = () => { - isMuted ? unMute() : mute(); - }; - - const changeVolume = (volumeChange: number) => { - if (videoRef.current) { - const minVolume = 0; - const maxVolume = 1; - - let newVolume = volumeChange + volume; - - newVolume = Math.max(newVolume, minVolume); - newVolume = Math.min(newVolume, maxVolume); - - setIsMuted(false); - setMutedVolume(newVolume); - videoRef.current.volume = newVolume; - setVolume(newVolume); - dispatch(setVolumeSetting(newVolume)); - } - }; - const setProgressRelative = (secondsChange: number) => { - if (videoRef.current) { - const currentTime = videoRef.current?.currentTime; - const minTime = 0; - const maxTime = videoRef.current?.duration || 100; - - let newTime = currentTime + secondsChange; - newTime = Math.max(newTime, minTime); - newTime = Math.min(newTime, maxTime); - videoRef.current.currentTime = newTime; - setProgress(newTime); - } - }; - - const setProgressAbsolute = (videoPercent: number) => { - if (videoRef.current) { - videoPercent = Math.min(videoPercent, 100); - videoPercent = Math.max(videoPercent, 0); - const finalTime = (videoRef.current?.duration * videoPercent) / 100; - videoRef.current.currentTime = finalTime; - setProgress(finalTime); - } - }; - - const toggleStretchVideoSetting = () => { - const newStretchVideoSetting = - persistSelector.stretchVideoSetting === "contain" ? "fill" : "contain"; - - setVideoObjectFit(newStretchVideoSetting); - dispatch(setStretchVideoSetting(newStretchVideoSetting)); - }; - const keyboardShortcutsDown = (e: React.KeyboardEvent) => { - e.preventDefault(); - - switch (e.key) { - case "o": - toggleStretchVideoSetting(); - break; - - case Key.Add: - increaseSpeed(false); - break; - case "+": - increaseSpeed(false); - break; - case ">": - increaseSpeed(false); - break; - - case Key.Subtract: - decreaseSpeed(); - break; - case "-": - decreaseSpeed(); - break; - case "<": - decreaseSpeed(); - break; - - case Key.ArrowLeft: - { - if (e.shiftKey) setProgressRelative(-300); - else if (e.ctrlKey) setProgressRelative(-60); - else if (e.altKey) setProgressRelative(-10); - else setProgressRelative(-5); - } - break; - - case Key.ArrowRight: - { - if (e.shiftKey) setProgressRelative(300); - else if (e.ctrlKey) setProgressRelative(60); - else if (e.altKey) setProgressRelative(10); - else setProgressRelative(5); - } - break; - - case Key.ArrowDown: - changeVolume(-0.05); - break; - case Key.ArrowUp: - changeVolume(0.05); - break; - } - }; - - const keyboardShortcutsUp = (e: React.KeyboardEvent) => { - e.preventDefault(); - - switch (e.key) { - case " ": - togglePlay(); - break; - case "m": - toggleMute(); - break; - - case "f": - enterFullscreen(); - break; - case Key.Escape: - exitFullscreen(); - break; - - case "0": - setProgressAbsolute(0); - break; - case "1": - setProgressAbsolute(10); - break; - case "2": - setProgressAbsolute(20); - break; - case "3": - setProgressAbsolute(30); - break; - case "4": - setProgressAbsolute(40); - break; - case "5": - setProgressAbsolute(50); - break; - case "6": - setProgressAbsolute(60); - break; - case "7": - setProgressAbsolute(70); - break; - case "8": - setProgressAbsolute(80); - break; - case "9": - setProgressAbsolute(90); - break; - } - }; + } = props; + const videoState = useVideoPlayerState(props, ref); + const { + containerRef, + resourceStatus, + videoRef, + src, + updateProgress, + handleEnded, + getSrc, + toggleRef, + } = videoState; + + const controlState = useVideoControlsState(props, videoRef, videoState); + const { keyboardShortcutsUp, keyboardShortcutsDown, togglePlay } = + controlState; return ( ( }} ref={containerRef} > - {isLoading && ( - - - {resourceStatus && ( - - {resourceStatus?.status === "NOT_PUBLISHED" && ( - <> - Video file was not published. Please inform the publisher! - - )} - {resourceStatus?.status === "REFETCHING" ? ( - <> - <> - {getDownloadProgress( - resourceStatus?.localChunkCount, - resourceStatus?.totalChunkCount - )} - - - <> Refetching in 25 seconds - - ) : resourceStatus?.status === "DOWNLOADED" ? ( - <>Download Completed: building video... - ) : resourceStatus?.status !== "READY" ? ( - <> - {getDownloadProgress( - resourceStatus?.localChunkCount, - resourceStatus?.totalChunkCount - )} - - ) : ( - <>Fetching video... - )} - - )} - - )} - {((!src && !isLoading) || !startPlay) && ( - { - if (from === "create") return; - dispatch(setVideoPlaying(null)); - togglePlay(); - }} - sx={{ - cursor: "pointer", - }} - > - - - )} - + ( onClick={() => togglePlay()} onEnded={handleEnded} // onLoadedMetadata={handleLoadedMetadata} - onCanPlay={handleCanPlay} + onCanPlay={controlState.handleCanPlay} onMouseEnter={e => { - setShowControlsFullScreen(true); + showControlsFullScreen.value = true; }} onMouseLeave={e => { - setShowControlsFullScreen(false); + showControlsFullScreen.value = false; }} preload="metadata" style={ startPlay ? { ...videoStyles?.video, - objectFit: videoObjectFit, + objectFit: videoObjectFit.value, } : { height: "100%", ...videoStyles } } /> - - - {isMobileView && canPlay && showControlsFullScreen ? ( - <> - togglePlay()} - > - {playing ? : } - - - - - - - - - - - - - - increaseSpeed()}> - - Speed: {playbackRate}x - - - - - - - - - - - ) : canPlay ? ( - <> - togglePlay()} - > - {playing ? : } - - - - - - - {progress && videoRef.current?.duration && formatTime(progress)} - / - {progress && - videoRef.current?.duration && - formatTime(videoRef.current?.duration)} - - - {isMuted ? : } - - - increaseSpeed()} - > - Speed: {playbackRate}x - - - - - - - - - - ) : null} - + ); }