Skip to content

Instantly share code, notes, and snippets.

@sannajammeh
Last active April 24, 2024 08:10
Show Gist options
  • Save sannajammeh/fb12965fb7826baadbb12f33d9a2903f to your computer and use it in GitHub Desktop.
Save sannajammeh/fb12965fb7826baadbb12f33d9a2903f to your computer and use it in GitHub Desktop.
Next.js Loading HLS Video's the performant way.

Loading HLS.js & playing HLS performant in Next.js

This gist shows how you take advantage of Streaming SSR in React 18 & Next.js to reduce bundle size & not impact CLS.

Motivations

  • The hls.js lib is too big. (Adding at best 254kb gzip and at worst +300kb gzip to your bundle size).
  • Streaming SSR is more performant than Next.js' native dynamic object.
  • Reduce bundle size without impacting CLS.

Workflow

  1. Create an hls video player component.
  2. Expose and bind react refs.
  3. Instantiate hls.js inside this component.
  4. Parent component imports using next/dynamic with suspense:true
  5. Render fallback as video poster url.
  6. Native browser preload poster based on media query.
  7. Enforce aspect ratio using CSS to avoid CLS shift.

Waterfall

  1. Resources load (styles, js & PRELOADED images).
  2. During resource load (before app.js mount), HLSPlayer.tsx async loading is dispatched.
  3. Body renders (with preloaded images if ready). === Video poster is available on browser paint.
  4. App hydrates (if network is fast, HLS is ready otherwise poster will still show)
  5. Ref callbacks dispatched

Questions

  • Why not lazy import hls.js inside useEffect?
    • Loading will be dispatched after hydration of app.js ~ 120ms
    • Time saved for Safari is not worth it. (Use hydration + canPlayType setState call render safari only video comp without HLS.js to improve safari load times). Can default to this for even greater perf.
import React, {
forwardRef,
useEffect,
useImperativeHandle,
useRef,
} from "react";
import Hls from "hls.js/dist/hls.light"; // Use light build of hls.
interface Props extends React.HTMLProps<HTMLVideoElement> {
manifest: string;
}
const HLSPlayer = forwardRef<HTMLVideoElement, Props>(
({ manifest, ...props }, ref) => {
const videoRef = useRef<HTMLVideoElement>(null);
useImperativeHandle(ref, () => videoRef.current!); // Expose internal ref to forwardedRef. (Allows for callback & regular useRef)
useEffect(() => {
const src = manifest;
const { current: video } = videoRef;
if (!video) return;
let hls: Hls | null;
if (video.canPlayType("application/vnd.apple.mpegurl")) { // Safari
video.src = src;
} else if (Hls.isSupported()) {
const hls = new Hls();
hls.loadSource(src);
hls.attachMedia(video);
}
return () => hls?.destroy();
}, [manifest]);
return <video {...props} ref={videoRef} />;
}
);
HLSPlayer.displayName = "HLSPlayer";
export default HLSPlayer;
import dynamic from "next/dynamic";
import Head from "next/head";
import React, { Suspense, useEffect, useRef, useState } from "react";
const HLSPlayer = dynamic(() => import("@components/HLSPlayer"), {
suspense: true,
});
const Parent = () => {
const [video, setVideo] = useState<HTMLVideoElement | null>(null); // use callback state instead of ref due to hydration of SSR stream
useEffect(() => {
// On render of video element -> set video poster to avoid flash (can also run transparent gif on video as poster & skip this step)
const mediaQueryList = window.matchMedia("(max-width: 600px)");
if (mediaQueryList.matches) {
video.poster = THUMBNAIL_MOBILE;
} else {
video.poster = THUMBNAIL_DESKTOP;
}
}, [video]);
return (
<>
<Head>
<link rel="preconnect" href="https://stream.mux.com" /> {/* Preconnect to your HLS service of choice */}
{/* Preload thumbnails based on device width */}
<link
rel="preload"
href={THUMBNAIL_MOBILE}
as="image"
type="image/jpeg"
media="(max-width: 600px)"
/>
<link
rel="preload"
href={THUMBNAIL_DESKTOP}
as="image"
type="image/jpeg"
media="(min-width: 601px)"
/>
</Head>
<div className="w-full aspect-video relative">
<Suspense fallback={<VideoFallback />}> {/* Render video fallback with preloaded poster */}
<HLSPlayer
className="rounded-lg w-full aspect-video object-contain relative z-10 video"
playsInline
controls
manifest={MANIFEST}
poster={THUMBNAIL_MOBILE}
ref={setVideo}
/>
</Suspense>
</div>
</>
);
};
export default Parent;
// Auto switch video url using native CSS (server rendered also) to correct preloaded poster
const VideoFallback = () => {
return (
<>
<div className="video-fallback rounded-lg w-full aspect-video object-contain relative z-10" />
<style jsx>
{`
@media screen and (max-width: 600px) {
.video-fallback {
background-image: url(${THUMBNAIL_MOBILE});
background-size: cover;
background-position: center;
}
}
@media screen and (min-width: 601px) {
.video-fallback {
background-image: url(${THUMBNAIL_DESKTOP});
background-size: cover;
background-position: center;
}
}
`}
</style>
</>
);
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment