3
0
mirror of https://github.com/Qortal/q-tube.git synced 2025-02-11 17:55:51 +00:00

Refactor to video player

Video player split into multiple components

Video player uses React signals for some of its state, this may improve performance.
This commit is contained in:
Qortal Dev 2024-10-16 15:47:05 -06:00
parent 302144f251
commit 0bed6cab8c
5 changed files with 1081 additions and 954 deletions

View File

@ -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 && (
<Box
position="absolute"
top={0}
left={0}
right={0}
bottom={resourceStatus?.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" />
{resourceStatus && (
<Typography
variant="subtitle2"
component="div"
sx={{
color: "white",
fontSize: "15px",
textAlign: "center",
}}
>
{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...</>
)}
</Typography>
)}
</Box>
)}
{((!src && !isLoading) || !startPlay) && (
<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={() => {
if (from === "create") return;
dispatch(setVideoPlaying(null));
togglePlay();
}}
sx={{
cursor: "pointer",
}}
>
<PlayArrow
sx={{
width: "50px",
height: "50px",
color: "white",
}}
/>
</Box>
)}
</>
);
};

View File

@ -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<typeof useVideoPlayerState>
) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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,
};
};

View File

@ -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<typeof useVideoControlsState>;
videoState: ReturnType<typeof useVideoPlayerState>;
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 (
<ControlsContainer
style={{ bottom: from === "create" ? "15px" : 0, padding: "0px" }}
display={showControlsFullScreen.value ? "flex" : "none"}
>
{isMobileView.value && canPlay.value && showControlsFullScreen.value ? (
<>
<IconButton
sx={{
color: "rgba(255, 255, 255, 0.7)",
}}
onClick={() => togglePlay()}
>
{!playing.value ? <Pause /> : <PlayArrow />}
</IconButton>
<IconButton
sx={{
color: "rgba(255, 255, 255, 0.7)",
marginLeft: "15px",
}}
onClick={reloadVideo}
>
<Refresh />
</IconButton>
<Slider
value={progress.value}
onChange={onProgressChange}
min={0}
max={videoRef.current?.duration || 100}
sx={{ flexGrow: 1, mx: 2 }}
/>
<IconButton
edge="end"
color="inherit"
aria-label="menu"
onClick={handleMenuOpen}
>
<MoreIcon />
</IconButton>
<Menu
id="simple-menu"
anchorEl={anchorEl.value}
keepMounted
open={Boolean(anchorEl)}
onClose={handleMenuClose}
PaperProps={{
style: {
width: "250px",
},
}}
>
<MenuItem>
<VolumeUp />
<Slider
value={volume.value}
onChange={onVolumeChange}
min={0}
max={1}
step={0.01}
/>
</MenuItem>
<MenuItem onClick={() => increaseSpeed()}>
<Typography
sx={{
color: "rgba(255, 255, 255, 0.7)",
fontSize: "14px",
}}
>
Speed: {playbackRate.value}x
</Typography>
</MenuItem>
<MenuItem onClick={togglePictureInPicture}>
<PictureInPicture />
</MenuItem>
<MenuItem onClick={toggleFullscreen}>
<Fullscreen />
</MenuItem>
</Menu>
</>
) : canPlay.value ? (
<>
<IconButton
sx={{
color: "rgba(255, 255, 255, 0.7)",
}}
onClick={() => togglePlay()}
>
{!playing.value ? <Pause /> : <PlayArrow />}
</IconButton>
<IconButton
sx={{
color: "rgba(255, 255, 255, 0.7)",
marginLeft: "15px",
}}
onClick={reloadVideo}
>
<Refresh />
</IconButton>
<Slider
value={progress.value}
onChange={onProgressChange}
min={0}
max={videoRef.current?.duration || 100}
sx={{ flexGrow: 1, mx: 2 }}
/>
<Typography
sx={{
fontSize: "14px",
marginRight: "5px",
color: "rgba(255, 255, 255, 0.7)",
visibility:
!videoRef.current?.duration || !progress ? "hidden" : "visible",
}}
>
{progress &&
videoRef.current?.duration &&
formatTime(progress.value)}
/
{progress &&
videoRef.current?.duration &&
formatTime(videoRef.current?.duration)}
</Typography>
<IconButton
sx={{
color: "rgba(255, 255, 255, 0.7)",
marginRight: "10px",
}}
onClick={toggleMute}
>
{isMuted.value ? <VolumeOff /> : <VolumeUp />}
</IconButton>
<Slider
value={volume.value}
onChange={onVolumeChange}
min={0}
max={1}
step={0.01}
sx={{
maxWidth: "100px",
}}
/>
<IconButton
sx={{
color: "rgba(255, 255, 255, 0.7)",
fontSize: "14px",
marginLeft: "5px",
}}
onClick={e => increaseSpeed()}
>
Speed: {playbackRate}x
</IconButton>
<IconButton
sx={{
color: "rgba(255, 255, 255, 0.7)",
marginLeft: "15px",
}}
ref={toggleRef}
onClick={togglePictureInPicture}
>
<PictureInPicture />
</IconButton>
<IconButton
sx={{
color: "rgba(255, 255, 255, 0.7)",
}}
onClick={toggleFullscreen}
>
<Fullscreen />
</IconButton>
</>
) : null}
</ControlsContainer>
);
};

View File

@ -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<StretchVideoType>("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<HTMLVideoElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const { downloads } = useSelector((state: RootState) => state.global);
const reDownload = useRef<boolean>(false);
const reDownloadNextVid = useRef<boolean>(false);
const isFetchingProperties = useRef<boolean>(false);
const status = useRef<null | string>(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<any>(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,
};
};

File diff suppressed because it is too large Load Diff