2026-tombutcher-ui/src/contexts/VideoContext.jsx
2025-11-09 18:00:07 +00:00

337 lines
8.4 KiB
JavaScript

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<string>
}
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 (
<VideoContext.Provider
value={{
videoStates, // map-like object keyed by URL
getVideoUrl, // returns object URL or null
loadVideo, // async, returns object URL or null
removeVideo,
clearVideos,
}}
>
{children}
</VideoContext.Provider>
);
};
VideoProvider.propTypes = {
children: PropTypes.any,
};
export default VideoContext;