components/CacheableVideo.jsxjavascript
// allow console for helpful warnings
/* eslint no-console: 0 */
import React, { useEffect, useRef, useState } from "react";
import PropTypes from "prop-types";
/*
A plain <video src="..."> triggers HTTP range requests, which the service
worker's Cache API can't store (it only caches full responses). To make
videos cacheable/offline-capable, we fetch the file ourselves as a normal
full response, turn it into a Blob, and hand the <video> element a local
blob: URL instead of the original src.
*/
/**
* Drop-in replacement for `<video src="...">` that's cacheable by the service
* worker. Renders "Loading video..." until the fetch has completed.
*
* @param {string} src - URL of the video file to fetch and play.
* @param {boolean} [playing] - When given, drives playback: true plays, false
* pauses and rewinds. Lets an always-mounted but hidden video (e.g. the
* screensaver behind opacity: 0) stop decoding while it's not visible.
* @param {...*} props - Passed through to the underlying `<video>` element
* (autoPlay, loop, muted, playsInline, ...).
*/
export const CacheableVideo = ({ src, playing, ...props }) => {
const [videoSrc, setVideoSrc] = useState("");
const videoElementRef = useRef(null);
useEffect(() => {
const video = videoElementRef.current;
if (playing === undefined || !video) return;
if (playing) {
// play() can reject harmlessly, e.g. if the element unmounts mid-call
video.play().catch(() => {});
} else {
video.pause();
video.currentTime = 0;
}
}, [playing, videoSrc]);
// Mirrors videoSrc for the cleanup function below. A ref (not the state
// value) is required there: the effect only depends on `src`, so its
// cleanup closure would otherwise always see the videoSrc from the render
// that created the effect ("" on first mount) instead of the current one,
// and URL.revokeObjectURL would never actually run.
const videoSrcRef = useRef("");
useEffect(() => {
const fetchVideo = async () => {
try {
const response = await fetch(src);
if (!response.ok) throw new Error(`video ${src} fetch failed`);
const blob = await response.blob();
const objectUrl = URL.createObjectURL(blob);
videoSrcRef.current = objectUrl;
setVideoSrc(objectUrl);
} catch (error) {
console.error(`<CacheableVideo>: Failed to fetch video: ${src}`, error);
}
};
fetchVideo();
// Revoke the object URL on unmount / before fetching the next src, so the
// blob doesn't stay in memory for the lifetime of the page.
return () => {
if (videoSrcRef.current) {
URL.revokeObjectURL(videoSrcRef.current);
console.warn(`<CacheableVideo>: Revoked object URL for video: ${videoSrcRef.current}`);
videoSrcRef.current = "";
}
};
}, [src]);
if (!videoSrc) {
return <p>Loading video...</p>; // or a spinner, or nothing
}
return <video ref={videoElementRef} src={videoSrc} {...props}></video>;
};
CacheableVideo.propTypes = {
src: PropTypes.string.isRequired,
playing: PropTypes.bool,
};