'use client'; import { create } from 'zustand'; import { devtools, persist } from 'zustand/middleware'; import { useShallow } from 'zustand/react/shallow'; import { createLogger } from '@djangocfg/ui-core/lib'; const cacheDebug = createLogger('MediaCache'); // Types interface BlobUrlEntry { url: string; refCount: number; createdAt: number; } interface ImageDimensions { width: number; height: number; } interface VideoMetadata { duration: number; width: number; height: number; codec?: string; } interface EffectConfig { opacity: number; scale: number; blur: string; } /** Video player settings (persisted) */ interface VideoPlayerSettings { volume: number; isLooping: boolean; } // Stream URL TTL (30 seconds - shorter to avoid stale session/token issues) const STREAM_URL_TTL = 30 * 1000; interface MediaCacheState { // Blob URL cache (shared by Image, Audio, Video) blobUrls: Map; // Image-specific imageDimensions: Map; // Audio-specific audioPlaybackPositions: Map; audioEffectConfigs: Map; // Video-specific videoStreamUrls: Map; videoPosterUrls: Map; videoPlaybackPositions: Map; videoMetadata: Map; videoPlayerSettings: VideoPlayerSettings; } interface MediaCacheActions { // Blob URL management (shared) getOrCreateBlobUrl: ( key: string, content: ArrayBuffer, mimeType: string ) => string; releaseBlobUrl: (key: string) => void; hasBlobUrl: (key: string) => boolean; // Image actions cacheDimensions: (src: string, dims: ImageDimensions) => void; getDimensions: (src: string) => ImageDimensions | null; // Audio actions saveAudioPosition: (uri: string, time: number) => void; getAudioPosition: (uri: string) => number | null; getEffectConfig: (key: string) => EffectConfig | null; cacheEffectConfig: (key: string, config: EffectConfig) => void; // Video actions getOrCreateStreamUrl: ( sessionId: string, path: string, generator: (sessionId: string, path: string) => string ) => string; getPosterUrl: (title: string) => string | null; cachePosterUrl: (title: string, url: string) => void; saveVideoPosition: (key: string, time: number) => void; getVideoPosition: (key: string) => number | null; cacheVideoMetadata: (url: string, meta: VideoMetadata) => void; getVideoMetadata: (url: string) => VideoMetadata | null; invalidateSession: (sessionId: string) => void; getVideoPlayerSettings: () => VideoPlayerSettings; saveVideoPlayerSettings: (settings: Partial) => void; // Global clearCache: () => void; getCacheStats: () => { blobUrls: number; dimensions: number; audioPositions: number; videoPositions: number; }; } type MediaCacheStore = MediaCacheState & MediaCacheActions; // Initial state const DEFAULT_VIDEO_SETTINGS: VideoPlayerSettings = { volume: 1, isLooping: false, }; const initialState: MediaCacheState = { blobUrls: new Map(), imageDimensions: new Map(), audioPlaybackPositions: new Map(), audioEffectConfigs: new Map(), videoStreamUrls: new Map(), videoPosterUrls: new Map(), videoPlaybackPositions: new Map(), videoMetadata: new Map(), videoPlayerSettings: DEFAULT_VIDEO_SETTINGS, }; export const useMediaCacheStore = create()( devtools( persist( (set, get) => ({ ...initialState, // ========== Blob URL Management ========== getOrCreateBlobUrl: (key, content, mimeType) => { const existing = get().blobUrls.get(key); if (existing) { // Increment ref count cacheDebug.debug(`Blob URL reused: ${key}`, { refCount: existing.refCount + 1 }); set( (state) => ({ blobUrls: new Map(state.blobUrls).set(key, { ...existing, refCount: existing.refCount + 1, }), }), false, 'blobUrl/incrementRef' ); return existing.url; } // Create new blob URL const blob = new Blob([content], { type: mimeType }); const url = URL.createObjectURL(blob); const sizeMB = (content.byteLength / 1024 / 1024).toFixed(2); cacheDebug.debug(`Blob URL created: ${key}`, { mimeType, size: `${sizeMB}MB` }); set( (state) => ({ blobUrls: new Map(state.blobUrls).set(key, { url, refCount: 1, createdAt: Date.now(), }), }), false, 'blobUrl/create' ); return url; }, releaseBlobUrl: (key) => { const entry = get().blobUrls.get(key); if (!entry) return; if (entry.refCount <= 1) { // Last reference - revoke and remove cacheDebug.debug(`Blob URL revoked: ${key}`); URL.revokeObjectURL(entry.url); set( (state) => { const newMap = new Map(state.blobUrls); newMap.delete(key); return { blobUrls: newMap }; }, false, 'blobUrl/revoke' ); } else { // Decrement ref count cacheDebug.debug(`Blob URL released: ${key}`, { refCount: entry.refCount - 1 }); set( (state) => ({ blobUrls: new Map(state.blobUrls).set(key, { ...entry, refCount: entry.refCount - 1, }), }), false, 'blobUrl/decrementRef' ); } }, hasBlobUrl: (key) => get().blobUrls.has(key), // ========== Image Cache ========== cacheDimensions: (src, dims) => { set( (state) => ({ imageDimensions: new Map(state.imageDimensions).set(src, dims), }), false, 'image/cacheDimensions' ); }, getDimensions: (src) => get().imageDimensions.get(src) ?? null, // ========== Audio Cache ========== saveAudioPosition: (uri, time) => { set( (state) => ({ audioPlaybackPositions: new Map(state.audioPlaybackPositions).set( uri, time ), }), false, 'audio/savePosition' ); }, getAudioPosition: (uri) => get().audioPlaybackPositions.get(uri) ?? null, getEffectConfig: (key) => get().audioEffectConfigs.get(key) ?? null, cacheEffectConfig: (key, config) => { set( (state) => ({ audioEffectConfigs: new Map(state.audioEffectConfigs).set( key, config ), }), false, 'audio/cacheEffectConfig' ); }, // ========== Video Cache ========== getOrCreateStreamUrl: (sessionId, path, generator) => { const key = `${sessionId}:${path}`; const cached = get().videoStreamUrls.get(key); // Return if fresh if (cached && Date.now() - cached.timestamp < STREAM_URL_TTL) { return cached.url; } // Generate and cache const url = generator(sessionId, path); set( (state) => ({ videoStreamUrls: new Map(state.videoStreamUrls).set(key, { url, timestamp: Date.now(), }), }), false, 'video/cacheStreamUrl' ); return url; }, getPosterUrl: (title) => get().videoPosterUrls.get(title) ?? null, cachePosterUrl: (title, url) => { set( (state) => ({ videoPosterUrls: new Map(state.videoPosterUrls).set(title, url), }), false, 'video/cachePosterUrl' ); }, saveVideoPosition: (key, time) => { set( (state) => ({ videoPlaybackPositions: new Map(state.videoPlaybackPositions).set( key, time ), }), false, 'video/savePosition' ); }, getVideoPosition: (key) => get().videoPlaybackPositions.get(key) ?? null, cacheVideoMetadata: (url, meta) => { set( (state) => ({ videoMetadata: new Map(state.videoMetadata).set(url, meta), }), false, 'video/cacheMetadata' ); }, getVideoMetadata: (url) => get().videoMetadata.get(url) ?? null, invalidateSession: (sessionId) => { set( (state) => { const newUrls = new Map(state.videoStreamUrls); for (const [key] of newUrls) { if (key.startsWith(`${sessionId}:`)) { newUrls.delete(key); } } return { videoStreamUrls: newUrls }; }, false, 'video/invalidateSession' ); }, getVideoPlayerSettings: () => get().videoPlayerSettings, saveVideoPlayerSettings: (settings) => { set( (state) => ({ videoPlayerSettings: { ...state.videoPlayerSettings, ...settings }, }), false, 'video/savePlayerSettings' ); }, // ========== Global ========== clearCache: () => { const stats = get().getCacheStats(); cacheDebug.info('Clearing cache', stats); // Revoke all blob URLs before clearing get().blobUrls.forEach(({ url }) => URL.revokeObjectURL(url)); set(initialState, false, 'clearCache'); }, getCacheStats: () => ({ blobUrls: get().blobUrls.size, dimensions: get().imageDimensions.size, audioPositions: get().audioPlaybackPositions.size, videoPositions: get().videoPlaybackPositions.size, }), }), { name: 'media-cache-storage', // Only persist playback positions, poster URLs, and player settings partialize: (state) => ({ audioPlaybackPositions: Array.from( state.audioPlaybackPositions.entries() ), videoPlaybackPositions: Array.from( state.videoPlaybackPositions.entries() ), videoPosterUrls: Array.from(state.videoPosterUrls.entries()), videoPlayerSettings: state.videoPlayerSettings, }), // Rehydrate Maps from arrays onRehydrateStorage: () => (state) => { if (state) { // Type assertion for persisted data const persistedAudio = state.audioPlaybackPositions as unknown; const persistedVideo = state.videoPlaybackPositions as unknown; const persistedPosters = state.videoPosterUrls as unknown; const persistedSettings = state.videoPlayerSettings as unknown; state.audioPlaybackPositions = new Map( Array.isArray(persistedAudio) ? (persistedAudio as [string, number][]) : [] ); state.videoPlaybackPositions = new Map( Array.isArray(persistedVideo) ? (persistedVideo as [string, number][]) : [] ); state.videoPosterUrls = new Map( Array.isArray(persistedPosters) ? (persistedPosters as [string, string][]) : [] ); // Merge persisted settings with defaults (handles missing fields) state.videoPlayerSettings = { ...DEFAULT_VIDEO_SETTINGS, ...(persistedSettings && typeof persistedSettings === 'object' ? (persistedSettings as Partial) : {}), }; } }, } ), { name: 'MediaCacheStore' } ) ); // ========== Selective Hooks ========== /** * Hook for image-related cache operations * Uses useShallow to prevent infinite re-renders */ export const useImageCache = () => useMediaCacheStore( useShallow((state) => ({ getOrCreateBlobUrl: state.getOrCreateBlobUrl, releaseBlobUrl: state.releaseBlobUrl, hasBlobUrl: state.hasBlobUrl, cacheDimensions: state.cacheDimensions, getDimensions: state.getDimensions, })) ); /** * Hook for audio-related cache operations * Uses useShallow to prevent infinite re-renders */ export const useAudioCache = () => useMediaCacheStore( useShallow((state) => ({ getOrCreateBlobUrl: state.getOrCreateBlobUrl, releaseBlobUrl: state.releaseBlobUrl, hasBlobUrl: state.hasBlobUrl, saveAudioPosition: state.saveAudioPosition, getAudioPosition: state.getAudioPosition, getEffectConfig: state.getEffectConfig, cacheEffectConfig: state.cacheEffectConfig, })) ); /** * Hook for video-related cache operations * Uses useShallow to prevent infinite re-renders */ export const useVideoCache = () => useMediaCacheStore( useShallow((state) => ({ getOrCreateBlobUrl: state.getOrCreateBlobUrl, releaseBlobUrl: state.releaseBlobUrl, hasBlobUrl: state.hasBlobUrl, getOrCreateStreamUrl: state.getOrCreateStreamUrl, getPosterUrl: state.getPosterUrl, cachePosterUrl: state.cachePosterUrl, saveVideoPosition: state.saveVideoPosition, getVideoPosition: state.getVideoPosition, cacheVideoMetadata: state.cacheVideoMetadata, getVideoMetadata: state.getVideoMetadata, invalidateSession: state.invalidateSession, getVideoPlayerSettings: state.getVideoPlayerSettings, saveVideoPlayerSettings: state.saveVideoPlayerSettings, })) ); /** * Hook for video player settings only * Returns current settings and save function */ export const useVideoPlayerSettings = () => useMediaCacheStore( useShallow((state) => ({ settings: state.videoPlayerSettings, saveSettings: state.saveVideoPlayerSettings, })) ); /** * Hook for blob URL cleanup on unmount */ export function useBlobUrlCleanup(key: string | null) { const releaseBlobUrl = useMediaCacheStore((s) => s.releaseBlobUrl); // Using inline effect to avoid importing useEffect in store if (typeof window !== 'undefined') { // eslint-disable-next-line react-hooks/rules-of-hooks const { useEffect } = require('react'); // eslint-disable-next-line react-hooks/rules-of-hooks useEffect(() => { return () => { if (key) { releaseBlobUrl(key); } }; }, [key, releaseBlobUrl]); } } // ========== Utilities ========== /** * Generate a cache key from ArrayBuffer content * Uses first and last 1KB + length for fast hashing */ export function generateContentKey(content: ArrayBuffer): string { const arr = new Uint8Array(content); const len = arr.length; // Take first 1KB const start = arr.slice(0, Math.min(1024, len)); // Take last 1KB const end = arr.slice(Math.max(0, len - 1024)); // Simple hash from bytes let hash = len; for (let i = 0; i < start.length; i++) { hash = ((hash << 5) - hash + start[i]) | 0; } for (let i = 0; i < end.length; i++) { hash = ((hash << 5) - hash + end[i]) | 0; } return `blob-${len}-${hash.toString(16)}`; }