diff --git a/package-lock.json b/package-lock.json index 512575f..db937f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 1685613..f736d6e 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/components/VideoPlayer/VideoPlayer-styles.ts b/src/components/VideoPlayer/VideoPlayer-styles.ts new file mode 100644 index 0000000..5508987 --- /dev/null +++ b/src/components/VideoPlayer/VideoPlayer-styles.ts @@ -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); +`; diff --git a/src/components/VideoPlayer/VideoPlayer.tsx b/src/components/VideoPlayer/VideoPlayer.tsx new file mode 100644 index 0000000..83411bc --- /dev/null +++ b/src/components/VideoPlayer/VideoPlayer.tsx @@ -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; + retryAttempts?: number; + showControls?: boolean; + poster?: string; + autoPlay?: boolean; + onEnded?: (e: React.SyntheticEvent)=> 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 | null>(null); + const [videoObjectFit] = useState("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; + 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) => { + 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)=> { + if(onEnded){ + onEnded(e) + } + }, [onEnded]) + + return ( + + {/* */} + togglePlay()} + onEnded={handleEnded} + onCanPlay={() => { + setIsLoading(false); + }} + preload="metadata" + style={videoStylesVideo} + onPlay={onPlay} + onPause={onPause} + onVolumeChange={onVolumeChangeHandler} + /> + {/* {showControls && } */} + + ); +}; diff --git a/src/components/VideoPlayer/useVideoPlayerController.tsx b/src/components/VideoPlayer/useVideoPlayerController.tsx new file mode 100644 index 0000000..47fcbba --- /dev/null +++ b/src/components/VideoPlayer/useVideoPlayerController.tsx @@ -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; + 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; + 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; + 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; + if (!ref.current) return; + if (ref.current) ref.current.volume = newVolume; + }, + [] + ); + + const toggleMute = useCallback(() => { + const ref = videoRef as React.RefObject; + if (!ref.current) return; + + ref.current.muted = !ref.current.muted; + }, []); + + const changeVolume = useCallback( + (delta: number) => { + const ref = videoRef as React.RefObject; + 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; + 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; + + 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; + 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; + 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, + }; +}; diff --git a/src/components/VideoPlayer/useVideoPlayerHotKeys.tsx b/src/components/VideoPlayer/useVideoPlayerHotKeys.tsx new file mode 100644 index 0000000..b06e653 --- /dev/null +++ b/src/components/VideoPlayer/useVideoPlayerHotKeys.tsx @@ -0,0 +1,144 @@ +import { useEffect, useCallback } from 'react'; +import { Key } from 'ts-key-enum'; + +interface UseVideoControls { + reloadVideo: () => void; + togglePlay: () => void; + setAlwaysShowControls: React.Dispatch>; + 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 +}; diff --git a/src/context/GlobalProvider.tsx b/src/context/GlobalProvider.tsx index ed8e90a..67b6b74 100644 --- a/src/context/GlobalProvider.tsx +++ b/src/context/GlobalProvider.tsx @@ -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; -lists: ReturnType; -appInfo: ReturnType; -identifierOperations: ReturnType -persistentOperations: ReturnType -indexOperations: ReturnType + auth: ReturnType; + lists: ReturnType; + appInfo: ReturnType; + identifierOperations: ReturnType; + persistentOperations: ReturnType; + indexOperations: ReturnType; } - // ✅ 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(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 ( - - - + + {children} ); diff --git a/src/index.ts b/src/index.ts index 0d61bdb..53409d5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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' - diff --git a/src/state/video.ts b/src/state/video.ts new file mode 100644 index 0000000..ecf7326 --- /dev/null +++ b/src/state/video.ts @@ -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 = {}; + +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()( + 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; + setProgress: (id: string, time: number) => void; + getProgress: (id: string) => number; + clearOldProgress: () => Promise; +}; + +export const useProgressStore = create()( + 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 }), + } + ) +);