mirror of
https://github.com/Qortal/qapp-core.git
synced 2025-06-14 17:41:20 +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",
|
||||
"version": "1.0.22",
|
||||
"version": "1.0.31",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "qapp-core",
|
||||
"version": "1.0.22",
|
||||
"version": "1.0.31",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/react-virtual": "^3.13.2",
|
||||
@ -17,10 +17,12 @@
|
||||
"dayjs": "^1.11.13",
|
||||
"dexie": "^4.0.11",
|
||||
"dompurify": "^3.2.4",
|
||||
"idb-keyval": "^6.2.2",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-hot-toast": "^2.5.2",
|
||||
"react-intersection-observer": "^9.16.0",
|
||||
"short-unique-id": "^5.2.0",
|
||||
"ts-key-enum": "^3.0.13",
|
||||
"zustand": "^4.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
@ -2189,6 +2191,12 @@
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"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": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
@ -3336,6 +3344,12 @@
|
||||
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
|
||||
"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": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
|
@ -31,10 +31,12 @@
|
||||
"dayjs": "^1.11.13",
|
||||
"dexie": "^4.0.11",
|
||||
"dompurify": "^3.2.4",
|
||||
"idb-keyval": "^6.2.2",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-hot-toast": "^2.5.2",
|
||||
"react-intersection-observer": "^9.16.0",
|
||||
"short-unique-id": "^5.2.0",
|
||||
"ts-key-enum": "^3.0.13",
|
||||
"zustand": "^4.3.2"
|
||||
},
|
||||
"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 { useResources } from "../hooks/useResources";
|
||||
import { useAppInfo } from "../hooks/useAppInfo";
|
||||
@ -7,22 +13,18 @@ import { Toaster } from "react-hot-toast";
|
||||
import { usePersistentStore } from "../hooks/usePersistentStore";
|
||||
import { IndexManager } from "../components/IndexManager/IndexManager";
|
||||
import { useIndexes } from "../hooks/useIndexes";
|
||||
|
||||
|
||||
|
||||
|
||||
import { useProgressStore } from "../state/video";
|
||||
|
||||
// ✅ Define Global Context Type
|
||||
interface GlobalContextType {
|
||||
auth: ReturnType<typeof useAuth>;
|
||||
lists: ReturnType<typeof useResources>;
|
||||
appInfo: ReturnType<typeof useAppInfo>;
|
||||
identifierOperations: ReturnType<typeof useIdentifiers>
|
||||
persistentOperations: ReturnType<typeof usePersistentStore>
|
||||
indexOperations: ReturnType<typeof useIndexes>
|
||||
auth: ReturnType<typeof useAuth>;
|
||||
lists: ReturnType<typeof useResources>;
|
||||
appInfo: ReturnType<typeof useAppInfo>;
|
||||
identifierOperations: ReturnType<typeof useIdentifiers>;
|
||||
persistentOperations: ReturnType<typeof usePersistentStore>;
|
||||
indexOperations: ReturnType<typeof useIndexes>;
|
||||
}
|
||||
|
||||
|
||||
// ✅ Define Config Type for Hook Options
|
||||
interface GlobalProviderProps {
|
||||
children: React.ReactNode;
|
||||
@ -30,42 +32,64 @@ interface GlobalProviderProps {
|
||||
/** Authentication settings. */
|
||||
auth?: UseAuthProps;
|
||||
appName: string;
|
||||
publicSalt: string
|
||||
publicSalt: string;
|
||||
};
|
||||
toastStyle?: CSSProperties
|
||||
toastStyle?: CSSProperties;
|
||||
}
|
||||
|
||||
// ✅ Create Context with Proper Type
|
||||
const GlobalContext = createContext<GlobalContextType | null>(null);
|
||||
|
||||
|
||||
|
||||
// 🔹 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
|
||||
const auth = useAuth(config?.auth || {});
|
||||
|
||||
const appInfo = useAppInfo(config.appName, config?.publicSalt)
|
||||
const lists = useResources()
|
||||
const identifierOperations = useIdentifiers(config.publicSalt, config.appName)
|
||||
const persistentOperations = usePersistentStore(config.publicSalt, config.appName)
|
||||
const indexOperations = useIndexes()
|
||||
// ✅ Merge all hooks into a single `contextValue`
|
||||
const contextValue = useMemo(() => ({ auth, lists, appInfo, identifierOperations, persistentOperations, indexOperations }), [auth, lists, appInfo, identifierOperations, persistentOperations]);
|
||||
|
||||
const appInfo = useAppInfo(config.appName, config?.publicSalt);
|
||||
const lists = useResources();
|
||||
const identifierOperations = useIdentifiers(
|
||||
config.publicSalt,
|
||||
config.appName
|
||||
);
|
||||
const persistentOperations = usePersistentStore(
|
||||
config.publicSalt,
|
||||
config.appName
|
||||
);
|
||||
const indexOperations = useIndexes();
|
||||
// ✅ Merge all hooks into a single `contextValue`
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
auth,
|
||||
lists,
|
||||
appInfo,
|
||||
identifierOperations,
|
||||
persistentOperations,
|
||||
indexOperations,
|
||||
}),
|
||||
[auth, lists, appInfo, identifierOperations, persistentOperations]
|
||||
);
|
||||
const { clearOldProgress } = useProgressStore();
|
||||
|
||||
useEffect(() => {
|
||||
clearOldProgress();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<GlobalContext.Provider value={contextValue}>
|
||||
<Toaster
|
||||
<Toaster
|
||||
position="top-center"
|
||||
toastOptions={{
|
||||
duration: 4000,
|
||||
style: toastStyle
|
||||
style: toastStyle,
|
||||
}}
|
||||
containerStyle={{zIndex: 999999}}
|
||||
containerStyle={{ zIndex: 999999 }}
|
||||
/>
|
||||
<IndexManager username={auth?.name} />
|
||||
|
||||
<IndexManager username={auth?.name} />
|
||||
|
||||
{children}
|
||||
</GlobalContext.Provider>
|
||||
);
|
||||
|
@ -5,6 +5,7 @@ export { Spacer } from './common/Spacer';
|
||||
export { useModal } from './hooks/useModal';
|
||||
export { AudioPlayerControls , OnTrackChangeMeta, AudioPlayerProps, AudioPlayerHandle} from './components/AudioPlayer/AudioPlayerControls';
|
||||
export { useAudioPlayerHotkeys } from './components/AudioPlayer/useAudioPlayerHotkeys';
|
||||
export { VideoPlayer } from './components/VideoPlayer/VideoPlayer';
|
||||
import './index.css'
|
||||
export { executeEvent, subscribeToEvent, unsubscribeFromEvent } from './utils/events';
|
||||
export { formatBytes, formatDuration } from './utils/numbers';
|
||||
@ -32,4 +33,3 @@ export {Service, QortalGetMetadata} from './types/interfaces/resources'
|
||||
export {ListItem} from './state/cache'
|
||||
export {SymmetricKeys} from './utils/encryption'
|
||||
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