started implementing videoplayer

This commit is contained in:
PhilReact 2025-06-09 18:55:22 +03:00
parent 93313b50ca
commit 58d56e25dd
9 changed files with 789 additions and 33 deletions

18
package-lock.json generated
View File

@ -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",

View File

@ -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": {

View 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);
`;

View 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>
);
};

View 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,
};
};

View 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
};

View File

@ -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>
);

View File

@ -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
View 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 }),
}
)
);