mirror of
https://github.com/Qortal/qapp-core.git
synced 2025-06-15 09:51:21 +00:00
started implementing videoplayer
This commit is contained in:
parent
93313b50ca
commit
58d56e25dd
18
package-lock.json
generated
18
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "qapp-core",
|
"name": "qapp-core",
|
||||||
"version": "1.0.22",
|
"version": "1.0.31",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "qapp-core",
|
"name": "qapp-core",
|
||||||
"version": "1.0.22",
|
"version": "1.0.31",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-virtual": "^3.13.2",
|
"@tanstack/react-virtual": "^3.13.2",
|
||||||
@ -17,10 +17,12 @@
|
|||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"dexie": "^4.0.11",
|
"dexie": "^4.0.11",
|
||||||
"dompurify": "^3.2.4",
|
"dompurify": "^3.2.4",
|
||||||
|
"idb-keyval": "^6.2.2",
|
||||||
"react-dropzone": "^14.3.8",
|
"react-dropzone": "^14.3.8",
|
||||||
"react-hot-toast": "^2.5.2",
|
"react-hot-toast": "^2.5.2",
|
||||||
"react-intersection-observer": "^9.16.0",
|
"react-intersection-observer": "^9.16.0",
|
||||||
"short-unique-id": "^5.2.0",
|
"short-unique-id": "^5.2.0",
|
||||||
|
"ts-key-enum": "^3.0.13",
|
||||||
"zustand": "^4.3.2"
|
"zustand": "^4.3.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@ -2189,6 +2191,12 @@
|
|||||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/idb-keyval": {
|
||||||
|
"version": "6.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.2.tgz",
|
||||||
|
"integrity": "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/ieee754": {
|
"node_modules/ieee754": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||||
@ -3336,6 +3344,12 @@
|
|||||||
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
|
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/ts-key-enum": {
|
||||||
|
"version": "3.0.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/ts-key-enum/-/ts-key-enum-3.0.13.tgz",
|
||||||
|
"integrity": "sha512-2J5QVm+HLfToI6IpJQADzYYWvJQ0P9i8qV0loewy5UQ9PBcwILkw09te/+NPu6aw8OXIn9jVYOT6xCBZnGg8Yg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tslib": {
|
"node_modules/tslib": {
|
||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
|
@ -31,10 +31,12 @@
|
|||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"dexie": "^4.0.11",
|
"dexie": "^4.0.11",
|
||||||
"dompurify": "^3.2.4",
|
"dompurify": "^3.2.4",
|
||||||
|
"idb-keyval": "^6.2.2",
|
||||||
"react-dropzone": "^14.3.8",
|
"react-dropzone": "^14.3.8",
|
||||||
"react-hot-toast": "^2.5.2",
|
"react-hot-toast": "^2.5.2",
|
||||||
"react-intersection-observer": "^9.16.0",
|
"react-intersection-observer": "^9.16.0",
|
||||||
"short-unique-id": "^5.2.0",
|
"short-unique-id": "^5.2.0",
|
||||||
|
"ts-key-enum": "^3.0.13",
|
||||||
"zustand": "^4.3.2"
|
"zustand": "^4.3.2"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
|
29
src/components/VideoPlayer/VideoPlayer-styles.ts
Normal file
29
src/components/VideoPlayer/VideoPlayer-styles.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { styled } from "@mui/system";
|
||||||
|
import { Box } from "@mui/material";
|
||||||
|
|
||||||
|
export const VideoContainer = styled(Box)(({ theme }) => ({
|
||||||
|
position: "relative",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
margin: 0,
|
||||||
|
padding: 0,
|
||||||
|
"&:focus": { outline: "none" },
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const VideoElement = styled("video")(({ theme }) => ({
|
||||||
|
width: "100%",
|
||||||
|
background: "rgb(33, 33, 33)",
|
||||||
|
"&:focus": { outline: "none" },
|
||||||
|
}));
|
||||||
|
//1075 x 604
|
||||||
|
export const ControlsContainer = styled(Box)`
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
background-color: rgba(0, 0, 0, 0.6);
|
||||||
|
`;
|
192
src/components/VideoPlayer/VideoPlayer.tsx
Normal file
192
src/components/VideoPlayer/VideoPlayer.tsx
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
import {
|
||||||
|
Ref,
|
||||||
|
RefObject,
|
||||||
|
useCallback,
|
||||||
|
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";
|
||||||
|
|
||||||
|
type StretchVideoType = "contain" | "fill" | "cover" | "none" | "scale-down";
|
||||||
|
|
||||||
|
export interface VideoPlayerProps {
|
||||||
|
qortalVideoResource: QortalGetMetadata;
|
||||||
|
videoRef: Ref<HTMLVideoElement>;
|
||||||
|
retryAttempts?: number;
|
||||||
|
showControls?: boolean;
|
||||||
|
poster?: string;
|
||||||
|
autoPlay?: boolean;
|
||||||
|
onEnded?: (e: React.SyntheticEvent<HTMLVideoElement, Event>)=> void
|
||||||
|
}
|
||||||
|
|
||||||
|
const videoStyles = {
|
||||||
|
videoContainer: { aspectRatio: "16 / 9" },
|
||||||
|
video: { aspectRatio: "16 / 9" },
|
||||||
|
};
|
||||||
|
export const VideoPlayer = ({
|
||||||
|
videoRef,
|
||||||
|
qortalVideoResource,
|
||||||
|
retryAttempts,
|
||||||
|
showControls,
|
||||||
|
poster,
|
||||||
|
autoPlay,
|
||||||
|
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 [isMuted, setIsMuted] = useState(false);
|
||||||
|
const { setProgress } = useProgressStore();
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
reloadVideo,
|
||||||
|
togglePlay,
|
||||||
|
onVolumeChange,
|
||||||
|
increaseSpeed,
|
||||||
|
decreaseSpeed,
|
||||||
|
toggleMute,
|
||||||
|
showControlsFullScreen,
|
||||||
|
setShowControlsFullScreen,
|
||||||
|
isFullscreen,
|
||||||
|
toggleObjectFit,
|
||||||
|
controlsHeight,
|
||||||
|
setProgressRelative,
|
||||||
|
toggleAlwaysShowControls,
|
||||||
|
changeVolume,
|
||||||
|
startedFetch,
|
||||||
|
isReady,
|
||||||
|
resourceUrl,
|
||||||
|
startPlay,
|
||||||
|
setProgressAbsolute,
|
||||||
|
setAlwaysShowControls,
|
||||||
|
} = useVideoPlayerController({ autoPlay, videoRef, qortalVideoResource, retryAttempts });
|
||||||
|
|
||||||
|
const hotkeyHandlers = useMemo(
|
||||||
|
() => ({
|
||||||
|
reloadVideo,
|
||||||
|
togglePlay,
|
||||||
|
setProgressRelative,
|
||||||
|
toggleObjectFit,
|
||||||
|
toggleAlwaysShowControls,
|
||||||
|
increaseSpeed,
|
||||||
|
decreaseSpeed,
|
||||||
|
changeVolume,
|
||||||
|
toggleMute,
|
||||||
|
setProgressAbsolute,
|
||||||
|
setAlwaysShowControls,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
reloadVideo,
|
||||||
|
togglePlay,
|
||||||
|
setProgressRelative,
|
||||||
|
toggleObjectFit,
|
||||||
|
toggleAlwaysShowControls,
|
||||||
|
increaseSpeed,
|
||||||
|
decreaseSpeed,
|
||||||
|
changeVolume,
|
||||||
|
toggleMute,
|
||||||
|
setProgressAbsolute,
|
||||||
|
setAlwaysShowControls,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const videoLocation = useMemo(() => {
|
||||||
|
if (!qortalVideoResource) return null;
|
||||||
|
return `${qortalVideoResource.service}-${qortalVideoResource.name}-${qortalVideoResource.identifier}`;
|
||||||
|
}, [qortalVideoResource]);
|
||||||
|
useVideoPlayerHotKeys(hotkeyHandlers);
|
||||||
|
|
||||||
|
const updateProgress = () => {
|
||||||
|
const ref = videoRef as React.RefObject<HTMLVideoElement>;
|
||||||
|
if (!ref.current || !videoLocation) return;
|
||||||
|
if (typeof ref.current.currentTime === "number") {
|
||||||
|
setProgress(videoLocation, ref.current.currentTime);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPlay = useCallback(() => {
|
||||||
|
setIsPlaying(true);
|
||||||
|
}, [setIsPlaying]);
|
||||||
|
|
||||||
|
const onPause = useCallback(() => {
|
||||||
|
setIsPlaying(false);
|
||||||
|
}, [setIsPlaying]);
|
||||||
|
const onVolumeChangeHandler = useCallback(
|
||||||
|
(e: React.SyntheticEvent<HTMLVideoElement, Event>) => {
|
||||||
|
const video = e.currentTarget;
|
||||||
|
setVolume(video.volume);
|
||||||
|
setIsMuted(video.muted);
|
||||||
|
},
|
||||||
|
[setIsMuted, setVolume]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMouseEnter = useCallback(() => {
|
||||||
|
setShowControlsFullScreen(true);
|
||||||
|
}, [setShowControlsFullScreen]);
|
||||||
|
|
||||||
|
const handleMouseLeave = useCallback(() => {
|
||||||
|
setShowControlsFullScreen(false);
|
||||||
|
}, [setShowControlsFullScreen]);
|
||||||
|
|
||||||
|
const videoStylesContainer = useMemo(() => {
|
||||||
|
return {
|
||||||
|
cursor: !showControlsFullScreen && isFullscreen ? "none" : "auto",
|
||||||
|
...videoStyles?.videoContainer,
|
||||||
|
};
|
||||||
|
}, [showControlsFullScreen, isFullscreen]);
|
||||||
|
|
||||||
|
const videoStylesVideo = useMemo(() => {
|
||||||
|
return {
|
||||||
|
...videoStyles?.video,
|
||||||
|
objectFit: videoObjectFit,
|
||||||
|
backgroundColor: "#000000",
|
||||||
|
height: isFullscreen && showControls ? "calc(100vh - 40px)" : "100%",
|
||||||
|
};
|
||||||
|
}, [videoObjectFit, showControls, isFullscreen]);
|
||||||
|
|
||||||
|
const handleEnded = useCallback((e: React.SyntheticEvent<HTMLVideoElement, Event>)=> {
|
||||||
|
if(onEnded){
|
||||||
|
onEnded(e)
|
||||||
|
}
|
||||||
|
}, [onEnded])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VideoContainer
|
||||||
|
tabIndex={0}
|
||||||
|
style={videoStylesContainer}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
ref={containerRef}
|
||||||
|
>
|
||||||
|
{/* <LoadingVideo /> */}
|
||||||
|
<VideoElement
|
||||||
|
id={qortalVideoResource?.identifier}
|
||||||
|
ref={videoRef}
|
||||||
|
tabIndex={0}
|
||||||
|
src={isReady && startPlay ? resourceUrl || undefined : undefined}
|
||||||
|
poster={startPlay ? "" : poster}
|
||||||
|
onTimeUpdate={updateProgress}
|
||||||
|
autoPlay={autoPlay}
|
||||||
|
onClick={() => togglePlay()}
|
||||||
|
onEnded={handleEnded}
|
||||||
|
onCanPlay={() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
}}
|
||||||
|
preload="metadata"
|
||||||
|
style={videoStylesVideo}
|
||||||
|
onPlay={onPlay}
|
||||||
|
onPause={onPause}
|
||||||
|
onVolumeChange={onVolumeChangeHandler}
|
||||||
|
/>
|
||||||
|
{/* {showControls && <VideoControlsBar />} */}
|
||||||
|
</VideoContainer>
|
||||||
|
);
|
||||||
|
};
|
231
src/components/VideoPlayer/useVideoPlayerController.tsx
Normal file
231
src/components/VideoPlayer/useVideoPlayerController.tsx
Normal file
@ -0,0 +1,231 @@
|
|||||||
|
import {
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
RefObject,
|
||||||
|
useMemo,
|
||||||
|
useCallback,
|
||||||
|
Ref,
|
||||||
|
useRef,
|
||||||
|
useImperativeHandle,
|
||||||
|
} from "react";
|
||||||
|
import { Key } from "ts-key-enum";
|
||||||
|
import { useProgressStore, useVideoStore } from "../../state/video";
|
||||||
|
import { VideoPlayerProps } from "./VideoPlayer";
|
||||||
|
import { QortalGetMetadata } from "../../types/interfaces/resources";
|
||||||
|
import { useResourceStatus } from "../../hooks/useResourceStatus";
|
||||||
|
|
||||||
|
const controlsHeight = "42px";
|
||||||
|
const minSpeed = 0.25;
|
||||||
|
const maxSpeed = 4.0;
|
||||||
|
const speedChange = 0.25;
|
||||||
|
|
||||||
|
interface UseVideoControls {
|
||||||
|
videoRef: Ref<HTMLVideoElement>;
|
||||||
|
autoPlay?: boolean;
|
||||||
|
qortalVideoResource: QortalGetMetadata;
|
||||||
|
retryAttempts?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useVideoPlayerController = (props: UseVideoControls) => {
|
||||||
|
const { autoPlay, videoRef, qortalVideoResource, retryAttempts } = props;
|
||||||
|
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
const [videoObjectFit, setVideoObjectFit] = useState<"contain" | "fill">(
|
||||||
|
"contain"
|
||||||
|
);
|
||||||
|
const [showControlsFullScreen, setShowControlsFullScreen] = useState(true);
|
||||||
|
const [alwaysShowControls, setAlwaysShowControls] = useState(false);
|
||||||
|
const [startPlay, setStartPlay] = useState(false);
|
||||||
|
const [startedFetch, setStartedFetch] = useState(false);
|
||||||
|
const startedFetchRef = useRef(false);
|
||||||
|
|
||||||
|
const { playbackSettings, setPlaybackRate, setVolume } = useVideoStore();
|
||||||
|
const { getProgress } = useProgressStore();
|
||||||
|
|
||||||
|
const { isReady, resourceUrl } = useResourceStatus({
|
||||||
|
resource: !startedFetch ? null : qortalVideoResource,
|
||||||
|
retryAttempts,
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const videoLocation = useMemo(() => {
|
||||||
|
if (!qortalVideoResource) return null;
|
||||||
|
return `${qortalVideoResource.service}-${qortalVideoResource.name}-${qortalVideoResource.identifier}`;
|
||||||
|
}, [qortalVideoResource]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (videoLocation) {
|
||||||
|
const ref = videoRef as React.RefObject<HTMLVideoElement>;
|
||||||
|
if (!ref.current) return;
|
||||||
|
|
||||||
|
const savedProgress = getProgress(videoLocation);
|
||||||
|
if (typeof savedProgress === "number") {
|
||||||
|
ref.current.currentTime = savedProgress;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [videoLocation, getProgress]);
|
||||||
|
|
||||||
|
const [playbackRate, _setLocalPlaybackRate] = useState(
|
||||||
|
playbackSettings.playbackRate
|
||||||
|
);
|
||||||
|
|
||||||
|
const updatePlaybackRate = useCallback(
|
||||||
|
(newSpeed: number) => {
|
||||||
|
const ref = videoRef as React.RefObject<HTMLVideoElement>;
|
||||||
|
if (!ref.current) return;
|
||||||
|
|
||||||
|
if (newSpeed > maxSpeed || newSpeed < minSpeed) newSpeed = minSpeed;
|
||||||
|
ref.current.playbackRate = newSpeed;
|
||||||
|
_setLocalPlaybackRate(newSpeed);
|
||||||
|
setPlaybackRate(newSpeed);
|
||||||
|
},
|
||||||
|
[setPlaybackRate, _setLocalPlaybackRate]
|
||||||
|
);
|
||||||
|
|
||||||
|
const increaseSpeed = useCallback(
|
||||||
|
(wrapOverflow = true) => {
|
||||||
|
const changedSpeed = playbackRate + speedChange;
|
||||||
|
const newSpeed = wrapOverflow
|
||||||
|
? changedSpeed
|
||||||
|
: Math.min(changedSpeed, maxSpeed);
|
||||||
|
updatePlaybackRate(newSpeed);
|
||||||
|
},
|
||||||
|
[updatePlaybackRate, playbackRate]
|
||||||
|
);
|
||||||
|
|
||||||
|
const decreaseSpeed = useCallback(() => {
|
||||||
|
updatePlaybackRate(playbackRate - speedChange);
|
||||||
|
}, [updatePlaybackRate, playbackRate]);
|
||||||
|
|
||||||
|
const toggleAlwaysShowControls = useCallback(() => {
|
||||||
|
setAlwaysShowControls((prev) => !prev);
|
||||||
|
}, [setAlwaysShowControls]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleFullscreenChange = () => {
|
||||||
|
setIsFullscreen(!!document.fullscreenElement);
|
||||||
|
};
|
||||||
|
document.addEventListener("fullscreenchange", handleFullscreenChange);
|
||||||
|
return () =>
|
||||||
|
document.removeEventListener("fullscreenchange", handleFullscreenChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onVolumeChange = useCallback(
|
||||||
|
(_: any, value: number | number[]) => {
|
||||||
|
const newVolume = value as number;
|
||||||
|
const ref = videoRef as React.RefObject<HTMLVideoElement>;
|
||||||
|
if (!ref.current) return;
|
||||||
|
if (ref.current) ref.current.volume = newVolume;
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleMute = useCallback(() => {
|
||||||
|
const ref = videoRef as React.RefObject<HTMLVideoElement>;
|
||||||
|
if (!ref.current) return;
|
||||||
|
|
||||||
|
ref.current.muted = !ref.current.muted;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const changeVolume = useCallback(
|
||||||
|
(delta: number) => {
|
||||||
|
const ref = videoRef as React.RefObject<HTMLVideoElement>;
|
||||||
|
if (!ref.current) return;
|
||||||
|
|
||||||
|
// Get current volume directly from video element
|
||||||
|
const currentVolume = ref.current.volume;
|
||||||
|
let newVolume = Math.max(0, Math.min(currentVolume + delta, 1));
|
||||||
|
newVolume = +newVolume.toFixed(2);
|
||||||
|
|
||||||
|
ref.current.volume = newVolume;
|
||||||
|
ref.current.muted = false;
|
||||||
|
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
const setProgressRelative = useCallback((seconds: number) => {
|
||||||
|
const ref = videoRef as React.RefObject<HTMLVideoElement>;
|
||||||
|
const current = ref.current.currentTime;
|
||||||
|
const duration = ref.current.duration || 100;
|
||||||
|
const newTime = Math.max(0, Math.min(current + seconds, duration));
|
||||||
|
ref.current.currentTime = newTime;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setProgressAbsolute = useCallback((percent: number) => {
|
||||||
|
const ref = videoRef as React.RefObject<HTMLVideoElement>;
|
||||||
|
|
||||||
|
if (!ref.current) return;
|
||||||
|
const finalTime =
|
||||||
|
(ref.current.duration * Math.min(100, Math.max(0, percent))) / 100;
|
||||||
|
ref.current.currentTime = finalTime;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleObjectFit = useCallback(() => {
|
||||||
|
setVideoObjectFit(videoObjectFit === "contain" ? "fill" : "contain");
|
||||||
|
}, [setVideoObjectFit]);
|
||||||
|
|
||||||
|
const togglePlay = useCallback(async () => {
|
||||||
|
const ref = videoRef as React.RefObject<HTMLVideoElement>;
|
||||||
|
if (!ref.current) return;
|
||||||
|
if (!startedFetchRef.current) {
|
||||||
|
setStartedFetch(true);
|
||||||
|
startedFetchRef.current = true;
|
||||||
|
setStartPlay(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isReady && ref.current) {
|
||||||
|
if (ref.current.paused) {
|
||||||
|
ref.current.play();
|
||||||
|
} else {
|
||||||
|
ref.current.pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}, [ setStartedFetch, isReady]);
|
||||||
|
|
||||||
|
const reloadVideo = useCallback(async () => {
|
||||||
|
const ref = videoRef as React.RefObject<HTMLVideoElement>;
|
||||||
|
if (!ref?.current || !isReady || !resourceUrl) return;
|
||||||
|
const currentTime = ref.current.currentTime;
|
||||||
|
ref.current.src = resourceUrl;
|
||||||
|
ref.current.load();
|
||||||
|
ref.current.currentTime = currentTime;
|
||||||
|
ref.current.play();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoPlay) togglePlay();
|
||||||
|
}, [autoPlay]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isReady) {
|
||||||
|
togglePlay();
|
||||||
|
}
|
||||||
|
}, [togglePlay, isReady]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
reloadVideo,
|
||||||
|
togglePlay,
|
||||||
|
onVolumeChange,
|
||||||
|
increaseSpeed,
|
||||||
|
decreaseSpeed,
|
||||||
|
toggleMute,
|
||||||
|
showControlsFullScreen,
|
||||||
|
isFullscreen,
|
||||||
|
toggleObjectFit,
|
||||||
|
controlsHeight,
|
||||||
|
setProgressRelative,
|
||||||
|
toggleAlwaysShowControls,
|
||||||
|
changeVolume,
|
||||||
|
setProgressAbsolute,
|
||||||
|
setShowControlsFullScreen,
|
||||||
|
setAlwaysShowControls,
|
||||||
|
startedFetch,
|
||||||
|
isReady,
|
||||||
|
resourceUrl,
|
||||||
|
startPlay,
|
||||||
|
};
|
||||||
|
};
|
144
src/components/VideoPlayer/useVideoPlayerHotKeys.tsx
Normal file
144
src/components/VideoPlayer/useVideoPlayerHotKeys.tsx
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
import { useEffect, useCallback } from 'react';
|
||||||
|
import { Key } from 'ts-key-enum';
|
||||||
|
|
||||||
|
interface UseVideoControls {
|
||||||
|
reloadVideo: () => void;
|
||||||
|
togglePlay: () => void;
|
||||||
|
setAlwaysShowControls: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
setProgressRelative: (seconds: number) => void;
|
||||||
|
toggleObjectFit: () => void;
|
||||||
|
toggleAlwaysShowControls: () => void;
|
||||||
|
increaseSpeed: (wrapOverflow?: boolean) => void;
|
||||||
|
decreaseSpeed: () => void;
|
||||||
|
changeVolume: (delta: number) => void;
|
||||||
|
toggleMute: () => void;
|
||||||
|
setProgressAbsolute: (percent: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useVideoPlayerHotKeys = (props: UseVideoControls) => {
|
||||||
|
const {
|
||||||
|
reloadVideo,
|
||||||
|
togglePlay,
|
||||||
|
setProgressRelative,
|
||||||
|
toggleObjectFit,
|
||||||
|
toggleAlwaysShowControls,
|
||||||
|
increaseSpeed,
|
||||||
|
decreaseSpeed,
|
||||||
|
changeVolume,
|
||||||
|
toggleMute,
|
||||||
|
setProgressAbsolute,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
const tag = target.tagName.toUpperCase();
|
||||||
|
const role = target.getAttribute("role");
|
||||||
|
const isTypingOrInteractive =
|
||||||
|
["INPUT", "TEXTAREA", "SELECT", "BUTTON"].includes(tag) ||
|
||||||
|
target.isContentEditable ||
|
||||||
|
role === "button";
|
||||||
|
|
||||||
|
if (isTypingOrInteractive) return;
|
||||||
|
e.preventDefault()
|
||||||
|
const key = e.key;
|
||||||
|
const mod = (s: number) => setProgressRelative(s);
|
||||||
|
|
||||||
|
switch (key) {
|
||||||
|
case "o":
|
||||||
|
toggleObjectFit();
|
||||||
|
break;
|
||||||
|
case "c":
|
||||||
|
toggleAlwaysShowControls();
|
||||||
|
break;
|
||||||
|
case Key.Add:
|
||||||
|
case "+":
|
||||||
|
case ">":
|
||||||
|
increaseSpeed(false);
|
||||||
|
break;
|
||||||
|
case Key.Subtract:
|
||||||
|
case "-":
|
||||||
|
case "<":
|
||||||
|
decreaseSpeed();
|
||||||
|
break;
|
||||||
|
case Key.ArrowLeft:
|
||||||
|
if (e.shiftKey) mod(-300);
|
||||||
|
else if (e.ctrlKey) mod(-60);
|
||||||
|
else if (e.altKey) mod(-10);
|
||||||
|
else mod(-5);
|
||||||
|
break;
|
||||||
|
case Key.ArrowRight:
|
||||||
|
if (e.shiftKey) mod(300);
|
||||||
|
else if (e.ctrlKey) mod(60);
|
||||||
|
else if (e.altKey) mod(10);
|
||||||
|
else mod(5);
|
||||||
|
break;
|
||||||
|
case Key.ArrowDown:
|
||||||
|
changeVolume(-0.05);
|
||||||
|
break;
|
||||||
|
case Key.ArrowUp:
|
||||||
|
changeVolume(0.05);
|
||||||
|
break;
|
||||||
|
case " ":
|
||||||
|
e.preventDefault(); // prevent scrolling
|
||||||
|
togglePlay();
|
||||||
|
break;
|
||||||
|
case "m":
|
||||||
|
toggleMute();
|
||||||
|
break;
|
||||||
|
case "r":
|
||||||
|
reloadVideo();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
reloadVideo,
|
||||||
|
togglePlay,
|
||||||
|
setProgressRelative,
|
||||||
|
toggleObjectFit,
|
||||||
|
toggleAlwaysShowControls,
|
||||||
|
increaseSpeed,
|
||||||
|
decreaseSpeed,
|
||||||
|
changeVolume,
|
||||||
|
toggleMute,
|
||||||
|
setProgressAbsolute,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [handleKeyDown]);
|
||||||
|
|
||||||
|
// Optional: return if you still want manual use
|
||||||
|
return null
|
||||||
|
};
|
@ -1,4 +1,10 @@
|
|||||||
import React, { createContext, CSSProperties, useContext, useMemo } from "react";
|
import React, {
|
||||||
|
createContext,
|
||||||
|
CSSProperties,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
} from "react";
|
||||||
import { useAuth, UseAuthProps } from "../hooks/useAuth";
|
import { useAuth, UseAuthProps } from "../hooks/useAuth";
|
||||||
import { useResources } from "../hooks/useResources";
|
import { useResources } from "../hooks/useResources";
|
||||||
import { useAppInfo } from "../hooks/useAppInfo";
|
import { useAppInfo } from "../hooks/useAppInfo";
|
||||||
@ -7,22 +13,18 @@ import { Toaster } from "react-hot-toast";
|
|||||||
import { usePersistentStore } from "../hooks/usePersistentStore";
|
import { usePersistentStore } from "../hooks/usePersistentStore";
|
||||||
import { IndexManager } from "../components/IndexManager/IndexManager";
|
import { IndexManager } from "../components/IndexManager/IndexManager";
|
||||||
import { useIndexes } from "../hooks/useIndexes";
|
import { useIndexes } from "../hooks/useIndexes";
|
||||||
|
import { useProgressStore } from "../state/video";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ✅ Define Global Context Type
|
// ✅ Define Global Context Type
|
||||||
interface GlobalContextType {
|
interface GlobalContextType {
|
||||||
auth: ReturnType<typeof useAuth>;
|
auth: ReturnType<typeof useAuth>;
|
||||||
lists: ReturnType<typeof useResources>;
|
lists: ReturnType<typeof useResources>;
|
||||||
appInfo: ReturnType<typeof useAppInfo>;
|
appInfo: ReturnType<typeof useAppInfo>;
|
||||||
identifierOperations: ReturnType<typeof useIdentifiers>
|
identifierOperations: ReturnType<typeof useIdentifiers>;
|
||||||
persistentOperations: ReturnType<typeof usePersistentStore>
|
persistentOperations: ReturnType<typeof usePersistentStore>;
|
||||||
indexOperations: ReturnType<typeof useIndexes>
|
indexOperations: ReturnType<typeof useIndexes>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ✅ Define Config Type for Hook Options
|
// ✅ Define Config Type for Hook Options
|
||||||
interface GlobalProviderProps {
|
interface GlobalProviderProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@ -30,41 +32,63 @@ interface GlobalProviderProps {
|
|||||||
/** Authentication settings. */
|
/** Authentication settings. */
|
||||||
auth?: UseAuthProps;
|
auth?: UseAuthProps;
|
||||||
appName: string;
|
appName: string;
|
||||||
publicSalt: string
|
publicSalt: string;
|
||||||
};
|
};
|
||||||
toastStyle?: CSSProperties
|
toastStyle?: CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ Create Context with Proper Type
|
// ✅ Create Context with Proper Type
|
||||||
const GlobalContext = createContext<GlobalContextType | null>(null);
|
const GlobalContext = createContext<GlobalContextType | null>(null);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 🔹 Global Provider (Handles Multiple Hooks)
|
// 🔹 Global Provider (Handles Multiple Hooks)
|
||||||
export const GlobalProvider = ({ children, config, toastStyle = {} }: GlobalProviderProps) => {
|
export const GlobalProvider = ({
|
||||||
|
children,
|
||||||
|
config,
|
||||||
|
toastStyle = {},
|
||||||
|
}: GlobalProviderProps) => {
|
||||||
// ✅ Call hooks and pass in options dynamically
|
// ✅ Call hooks and pass in options dynamically
|
||||||
const auth = useAuth(config?.auth || {});
|
const auth = useAuth(config?.auth || {});
|
||||||
|
|
||||||
const appInfo = useAppInfo(config.appName, config?.publicSalt)
|
const appInfo = useAppInfo(config.appName, config?.publicSalt);
|
||||||
const lists = useResources()
|
const lists = useResources();
|
||||||
const identifierOperations = useIdentifiers(config.publicSalt, config.appName)
|
const identifierOperations = useIdentifiers(
|
||||||
const persistentOperations = usePersistentStore(config.publicSalt, config.appName)
|
config.publicSalt,
|
||||||
const indexOperations = useIndexes()
|
config.appName
|
||||||
|
);
|
||||||
|
const persistentOperations = usePersistentStore(
|
||||||
|
config.publicSalt,
|
||||||
|
config.appName
|
||||||
|
);
|
||||||
|
const indexOperations = useIndexes();
|
||||||
// ✅ Merge all hooks into a single `contextValue`
|
// ✅ Merge all hooks into a single `contextValue`
|
||||||
const contextValue = useMemo(() => ({ auth, lists, appInfo, identifierOperations, persistentOperations, indexOperations }), [auth, lists, appInfo, identifierOperations, persistentOperations]);
|
const contextValue = useMemo(
|
||||||
|
() => ({
|
||||||
|
auth,
|
||||||
|
lists,
|
||||||
|
appInfo,
|
||||||
|
identifierOperations,
|
||||||
|
persistentOperations,
|
||||||
|
indexOperations,
|
||||||
|
}),
|
||||||
|
[auth, lists, appInfo, identifierOperations, persistentOperations]
|
||||||
|
);
|
||||||
|
const { clearOldProgress } = useProgressStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
clearOldProgress();
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GlobalContext.Provider value={contextValue}>
|
<GlobalContext.Provider value={contextValue}>
|
||||||
<Toaster
|
<Toaster
|
||||||
position="top-center"
|
position="top-center"
|
||||||
toastOptions={{
|
toastOptions={{
|
||||||
duration: 4000,
|
duration: 4000,
|
||||||
style: toastStyle
|
style: toastStyle,
|
||||||
}}
|
}}
|
||||||
containerStyle={{zIndex: 999999}}
|
containerStyle={{ zIndex: 999999 }}
|
||||||
/>
|
/>
|
||||||
<IndexManager username={auth?.name} />
|
<IndexManager username={auth?.name} />
|
||||||
|
|
||||||
{children}
|
{children}
|
||||||
</GlobalContext.Provider>
|
</GlobalContext.Provider>
|
||||||
|
@ -5,6 +5,7 @@ export { Spacer } from './common/Spacer';
|
|||||||
export { useModal } from './hooks/useModal';
|
export { useModal } from './hooks/useModal';
|
||||||
export { AudioPlayerControls , OnTrackChangeMeta, AudioPlayerProps, AudioPlayerHandle} from './components/AudioPlayer/AudioPlayerControls';
|
export { AudioPlayerControls , OnTrackChangeMeta, AudioPlayerProps, AudioPlayerHandle} from './components/AudioPlayer/AudioPlayerControls';
|
||||||
export { useAudioPlayerHotkeys } from './components/AudioPlayer/useAudioPlayerHotkeys';
|
export { useAudioPlayerHotkeys } from './components/AudioPlayer/useAudioPlayerHotkeys';
|
||||||
|
export { VideoPlayer } from './components/VideoPlayer/VideoPlayer';
|
||||||
import './index.css'
|
import './index.css'
|
||||||
export { executeEvent, subscribeToEvent, unsubscribeFromEvent } from './utils/events';
|
export { executeEvent, subscribeToEvent, unsubscribeFromEvent } from './utils/events';
|
||||||
export { formatBytes, formatDuration } from './utils/numbers';
|
export { formatBytes, formatDuration } from './utils/numbers';
|
||||||
@ -32,4 +33,3 @@ export {Service, QortalGetMetadata} from './types/interfaces/resources'
|
|||||||
export {ListItem} from './state/cache'
|
export {ListItem} from './state/cache'
|
||||||
export {SymmetricKeys} from './utils/encryption'
|
export {SymmetricKeys} from './utils/encryption'
|
||||||
export {LoaderListStatus} from './common/ListLoader'
|
export {LoaderListStatus} from './common/ListLoader'
|
||||||
|
|
||||||
|
120
src/state/video.ts
Normal file
120
src/state/video.ts
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
import { get as idbGet, set as idbSet, del as idbDel, keys as idbKeys } from 'idb-keyval';
|
||||||
|
|
||||||
|
const EXPIRY_DURATION = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||||
|
const PROGRESS_UPDATE_INTERVAL = 5 * 1000;
|
||||||
|
const lastSavedTimestamps: Record<string, number> = {};
|
||||||
|
|
||||||
|
const indexedDBWithExpiry = {
|
||||||
|
getItem: async (key: string) => {
|
||||||
|
const value = await idbGet(key);
|
||||||
|
if (!value) return null;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const expired =
|
||||||
|
typeof value === 'object' &&
|
||||||
|
value !== null &&
|
||||||
|
'expiresAt' in value &&
|
||||||
|
typeof value.expiresAt === 'number' &&
|
||||||
|
now > value.expiresAt;
|
||||||
|
|
||||||
|
return expired ? null : value.data ?? value;
|
||||||
|
},
|
||||||
|
setItem: async (key: string, value: any) => {
|
||||||
|
await idbSet(key, {
|
||||||
|
data: value,
|
||||||
|
expiresAt: Date.now() + EXPIRY_DURATION,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
removeItem: async (key: string) => {
|
||||||
|
await idbDel(key);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
type PlaybackSettings = {
|
||||||
|
playbackRate: number;
|
||||||
|
volume: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PlaybackStore = {
|
||||||
|
playbackSettings: PlaybackSettings;
|
||||||
|
setPlaybackRate: (rate: number) => void;
|
||||||
|
setVolume: (volume: number) => void;
|
||||||
|
getPersistedPlaybackRate: () => number;
|
||||||
|
getPersistedVolume: () => number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useVideoStore = create<PlaybackStore>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
playbackSettings: {
|
||||||
|
playbackRate: 1.0,
|
||||||
|
volume: 1.0,
|
||||||
|
},
|
||||||
|
setPlaybackRate: (rate) =>
|
||||||
|
set((state) => ({
|
||||||
|
playbackSettings: { ...state.playbackSettings, playbackRate: rate },
|
||||||
|
})),
|
||||||
|
setVolume: (volume) =>
|
||||||
|
set((state) => ({
|
||||||
|
playbackSettings: { ...state.playbackSettings, volume },
|
||||||
|
})),
|
||||||
|
getPersistedPlaybackRate: () => get().playbackSettings.playbackRate,
|
||||||
|
getPersistedVolume: () => get().playbackSettings.volume,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'video-playback-settings',
|
||||||
|
partialize: (state) => ({ playbackSettings: state.playbackSettings }),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
type ProgressStore = {
|
||||||
|
progressMap: Record<string, number>;
|
||||||
|
setProgress: (id: string, time: number) => void;
|
||||||
|
getProgress: (id: string) => number;
|
||||||
|
clearOldProgress: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useProgressStore = create<ProgressStore>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
progressMap: {},
|
||||||
|
setProgress: (id, time) => {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - (lastSavedTimestamps[id] || 0) >= PROGRESS_UPDATE_INTERVAL) {
|
||||||
|
lastSavedTimestamps[id] = now;
|
||||||
|
set((state) => ({
|
||||||
|
progressMap: {
|
||||||
|
...state.progressMap,
|
||||||
|
[id]: time,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getProgress: (id) => get().progressMap[id] || 0,
|
||||||
|
clearOldProgress: async () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const allKeys = await idbKeys();
|
||||||
|
for (const key of allKeys) {
|
||||||
|
const value = await idbGet(key as string);
|
||||||
|
if (
|
||||||
|
typeof value === 'object' &&
|
||||||
|
value !== null &&
|
||||||
|
'expiresAt' in value &&
|
||||||
|
typeof value.expiresAt === 'number' &&
|
||||||
|
now > value.expiresAt
|
||||||
|
) {
|
||||||
|
await idbDel(key as string);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'video-progress-map',
|
||||||
|
storage: indexedDBWithExpiry,
|
||||||
|
partialize: (state) => ({ progressMap: state.progressMap }),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
Loading…
x
Reference in New Issue
Block a user