import PropTypes from "prop-types"; import { createContext, useContext, useEffect, useState, useCallback, } from "react"; // Simple video cache that stores object URLs (from Blobs) class VideoCache { constructor() { this.cache = new Map(); // url -> objectUrl this.loadingPromises = new Map(); // url -> Promise } isCached(url) { return this.cache.has(url); } get(url) { return this.cache.get(url) || null; } set(url, objectUrl) { // Revoke old object URL if overwriting if (this.cache.has(url)) { const oldUrl = this.cache.get(url); if (oldUrl && oldUrl !== objectUrl) { try { URL.revokeObjectURL(oldUrl); } catch { // ignore } } } this.cache.set(url, objectUrl); } remove(url) { if (this.cache.has(url)) { const objectUrl = this.cache.get(url); try { URL.revokeObjectURL(objectUrl); } catch { // ignore } this.cache.delete(url); } this.loadingPromises.delete(url); } clear() { for (const [, objectUrl] of this.cache.entries()) { try { URL.revokeObjectURL(objectUrl); } catch { // ignore } } this.cache.clear(); this.loadingPromises.clear(); } async _fetchToObjectUrl(url, onProgress) { const response = await fetch(url); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } // Stream to track progress when possible const contentLengthHeader = response.headers.get("content-length"); const totalBytes = contentLengthHeader ? parseInt(contentLengthHeader, 10) : null; if (response.body && typeof response.body.getReader === "function") { const reader = response.body.getReader(); const chunks = []; let loadedBytes = 0; // Notify start if (onProgress) { try { onProgress(totalBytes ? 0 : null, { loaded: 0, total: totalBytes }); } catch { // ignore } } let doneReading = false; while (!doneReading) { const { done, value } = await reader.read(); if (done) { doneReading = true; break; } chunks.push(value); loadedBytes += value.length; if (onProgress) { try { const ratio = totalBytes ? Math.min(1, loadedBytes / totalBytes) : null; onProgress(ratio, { loaded: loadedBytes, total: totalBytes }); } catch { // ignore } } } const blob = new Blob(chunks, { type: response.headers.get("content-type") || "application/octet-stream", }); if (onProgress) { try { const finalTotal = totalBytes ?? blob.size; onProgress(1, { loaded: finalTotal, total: finalTotal }); } catch { // ignore } } return URL.createObjectURL(blob); } // Fallback: no streaming support; cannot measure progress const blob = await response.blob(); if (onProgress) { try { onProgress(1, { loaded: blob.size, total: blob.size }); } catch { // ignore } } return URL.createObjectURL(blob); } async loadVideo(primaryUrl, mirrorUrl = null, onProgress) { // Use cache if present const cached = this.get(primaryUrl); if (cached) { return cached; } // Reuse in-flight request if present if (this.loadingPromises.has(primaryUrl)) { return this.loadingPromises.get(primaryUrl); } // Compose a single loading promise and store it to dedupe concurrent loads const loadingPromise = (async () => { // Try mirror first if provided if (mirrorUrl) { try { const objUrl = await this._fetchToObjectUrl(mirrorUrl, onProgress); this.set(primaryUrl, objUrl); // cache under primary URL key return objUrl; } catch { // fall through to primary } } const objUrl = await this._fetchToObjectUrl(primaryUrl, onProgress); this.set(primaryUrl, objUrl); return objUrl; })(); this.loadingPromises.set(primaryUrl, loadingPromise); try { const result = await loadingPromise; return result; } finally { this.loadingPromises.delete(primaryUrl); } } } const videoCache = new VideoCache(); const VideoContext = createContext(); export const useVideoContext = () => useContext(VideoContext); export const VideoProvider = ({ children }) => { // Track basic per-URL loading state and resolved object URL for consumers const [videoStates, setVideoStates] = useState({}); // { [url]: { objectUrl, loadingState, progress?, loadedBytes?, totalBytes? } } const getVideoUrl = useCallback( (url) => { return videoStates[url]?.objectUrl || videoCache.get(url) || null; }, [videoStates] ); const loadVideo = useCallback(async (url, mirrorUrl = null) => { if (!url) return null; // Fast path: cached if (videoCache.isCached(url)) { const objectUrl = videoCache.get(url); setVideoStates((prev) => ({ ...prev, [url]: { objectUrl, loadingState: "loaded", progress: 1, loadedBytes: null, totalBytes: null, }, })); return objectUrl; } // Transition to loading setVideoStates((prev) => ({ ...prev, [url]: { objectUrl: prev[url]?.objectUrl || null, loadingState: "loading", progress: 0, loadedBytes: 0, totalBytes: null, }, })); try { const objectUrl = await videoCache.loadVideo( url, mirrorUrl, (progressRatio, bytes) => { setVideoStates((prev) => ({ ...prev, [url]: { objectUrl: prev[url]?.objectUrl || null, loadingState: "loading", progress: typeof progressRatio === "number" ? progressRatio : prev[url]?.progress ?? null, loadedBytes: typeof bytes?.loaded === "number" ? bytes.loaded : prev[url]?.loadedBytes ?? null, totalBytes: typeof bytes?.total === "number" ? bytes.total : prev[url]?.totalBytes ?? null, }, })); } ); setVideoStates((prev) => ({ ...prev, [url]: { objectUrl, loadingState: "loaded", progress: 1, loadedBytes: prev?.[url]?.loadedBytes ?? null, totalBytes: prev?.[url]?.totalBytes ?? null, }, })); return objectUrl; } catch (error) { setVideoStates((prev) => ({ ...prev, [url]: { objectUrl: null, loadingState: "error", progress: null, loadedBytes: null, totalBytes: null, }, })); return null; } }, []); const removeVideo = useCallback( (url) => { const existing = videoStates[url]?.objectUrl; if (existing) { try { URL.revokeObjectURL(existing); } catch { // ignore } } videoCache.remove(url); setVideoStates((prev) => { const next = { ...prev }; delete next[url]; return next; }); }, [videoStates] ); const clearVideos = useCallback(() => { // Revoke any object URLs held in state, then clear cache Object.values(videoStates).forEach((entry) => { if (entry?.objectUrl) { try { URL.revokeObjectURL(entry.objectUrl); } catch { // ignore } } }); setVideoStates({}); videoCache.clear(); }, [videoStates]); useEffect(() => { return () => { // Avoid setState in unmount cleanup to prevent update depth loops try { videoCache.clear(); } catch { // ignore } }; }, []); return ( {children} ); }; VideoProvider.propTypes = { children: PropTypes.any, }; export default VideoContext;