337 lines
8.4 KiB
JavaScript
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;
|