qhkr2z22o: Prefer hls.js and fall back to the thumbnail when video cannot play

This commit is contained in:
2026-10-19 17:05:16 -06:00
parent ab78a61851
commit fd760e610a
3 changed files with 44 additions and 28 deletions
+32 -23
View File
@@ -9,9 +9,12 @@ export interface VideoPlayerProps {
label: string; label: string;
} }
const UNPLAYABLE = "Video can't be played here.";
/** /**
* Real <video controls>. Native HLS where the browser has it, otherwise hls.js, * Real <video controls>. hls.js (loaded only when a video card mounts) is
* loaded only when a video card mounts. Never autoplays. * preferred; native HLS is used only where hls.js is unsupported. Never
* autoplays. Any failure falls back to the thumbnail with a short note.
*/ */
export function VideoPlayer({ hlsUrl, poster, label }: VideoPlayerProps) { export function VideoPlayer({ hlsUrl, poster, label }: VideoPlayerProps) {
const ref = useRef<HTMLVideoElement>(null); const ref = useRef<HTMLVideoElement>(null);
@@ -22,28 +25,26 @@ export function VideoPlayer({ hlsUrl, poster, label }: VideoPlayerProps) {
if (!video) return; if (!video) return;
let destroy: (() => void) | undefined; let destroy: (() => void) | undefined;
let cancelled = false; let cancelled = false;
const native = supportsNativeHls(video);
if (native) {
if (choosePlayback(hlsUrl, { nativeHls: true, hlsJsSupported: false }) === "native") {
video.src = hlsUrl;
}
return;
}
import("hls.js") import("hls.js")
.then(({ default: Hls }) => { .then(({ default: Hls }) => {
if (cancelled) return; if (cancelled) return;
const mode = choosePlayback(hlsUrl, { nativeHls: false, hlsJsSupported: Hls.isSupported() }); const mode = choosePlayback(hlsUrl, {
if (mode !== "hlsjs") { nativeHls: supportsNativeHls(video),
setFailed(true); hlsJsSupported: Hls.isSupported(),
return;
}
const hls = new Hls();
hls.on(Hls.Events.ERROR, (_e, data) => {
if (data.fatal) setFailed(true);
}); });
hls.loadSource(hlsUrl); if (mode === "hlsjs") {
hls.attachMedia(video); const hls = new Hls();
destroy = () => hls.destroy(); hls.on(Hls.Events.ERROR, (_e, data) => {
if (data.fatal) setFailed(true);
});
hls.loadSource(hlsUrl);
hls.attachMedia(video);
destroy = () => hls.destroy();
} else if (mode === "native") {
video.src = hlsUrl;
} else {
setFailed(true);
}
}) })
.catch(() => setFailed(true)); .catch(() => setFailed(true));
return () => { return () => {
@@ -52,9 +53,16 @@ export function VideoPlayer({ hlsUrl, poster, label }: VideoPlayerProps) {
}; };
}, [hlsUrl]); }, [hlsUrl]);
if (failed && poster) { if (failed) {
// eslint-disable-next-line @next/next/no-img-element return (
return <img src={poster} alt="Video thumbnail" loading="lazy" />; <div>
{poster ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={poster} alt="Video thumbnail" loading="lazy" />
) : null}
<p role="status">{UNPLAYABLE}</p>
</div>
);
} }
return ( return (
<video <video
@@ -64,6 +72,7 @@ export function VideoPlayer({ hlsUrl, poster, label }: VideoPlayerProps) {
preload="none" preload="none"
poster={poster ?? undefined} poster={poster ?? undefined}
aria-label={label} aria-label={label}
onError={() => setFailed(true)}
/> />
); );
} }
+7 -3
View File
@@ -9,11 +9,15 @@ export interface PlaybackEnv {
hlsJsSupported: boolean; hlsJsSupported: boolean;
} }
/** No hlsUrl: thumbnail only. Native HLS wins; hls.js is the fallback. */ /**
* No hlsUrl: thumbnail only. hls.js (Media Source Extensions) is preferred
* because Chromium reports partial native HLS support it cannot honour; native
* HLS is only chosen where hls.js is unsupported (Safari on iOS).
*/
export function choosePlayback(hlsUrl: string | null | undefined, env: PlaybackEnv): Playback { export function choosePlayback(hlsUrl: string | null | undefined, env: PlaybackEnv): Playback {
if (!hlsUrl) return "none"; if (!hlsUrl) return "none";
if (env.nativeHls) return "native"; if (env.hlsJsSupported) return "hlsjs";
return env.hlsJsSupported ? "hlsjs" : "none"; return env.nativeHls ? "native" : "none";
} }
export function supportsNativeHls(video: Pick<HTMLVideoElement, "canPlayType">): boolean { export function supportsNativeHls(video: Pick<HTMLVideoElement, "canPlayType">): boolean {
+5 -2
View File
@@ -19,10 +19,13 @@ describe("choosePlayback", () => {
expect(choosePlayback(null, { nativeHls: true, hlsJsSupported: true })).toBe("none"); expect(choosePlayback(null, { nativeHls: true, hlsJsSupported: true })).toBe("none");
expect(choosePlayback(undefined, { nativeHls: false, hlsJsSupported: true })).toBe("none"); expect(choosePlayback(undefined, { nativeHls: false, hlsJsSupported: true })).toBe("none");
}); });
it("uses native HLS where supported, hls.js otherwise", () => { it("prefers hls.js whenever it is supported, even if native HLS is reported", () => {
expect(choosePlayback(HLS, { nativeHls: true, hlsJsSupported: true })).toBe("native"); expect(choosePlayback(HLS, { nativeHls: true, hlsJsSupported: true })).toBe("hlsjs");
expect(choosePlayback(HLS, { nativeHls: false, hlsJsSupported: true })).toBe("hlsjs"); expect(choosePlayback(HLS, { nativeHls: false, hlsJsSupported: true })).toBe("hlsjs");
}); });
it("uses native HLS only when hls.js is unsupported", () => {
expect(choosePlayback(HLS, { nativeHls: true, hlsJsSupported: false })).toBe("native");
});
it("falls back to none when neither is available", () => { it("falls back to none when neither is available", () => {
expect(choosePlayback(HLS, { nativeHls: false, hlsJsSupported: false })).toBe("none"); expect(choosePlayback(HLS, { nativeHls: false, hlsJsSupported: false })).toBe("none");
}); });