carousel/react HLS MP4
The typical site. An endless strip: the centered card plays its clip (the site finds it with an IntersectionObserver), the next clips preload, a tap opens the film in the phone's player, desktop falls back to a lightbox, and a boot screen waits for the first card
examples/carousel/react/App.tsx
import { playFullscreen, preload, useVideo, Video } from "@uln/hls/react";
import { useEffect, useRef, useState } from "react";
import { type Film, fetchFilms, saveProgress } from "../../films";
import { useCenteredIndex } from "../useCenteredIndex";
/**
* The typical site: a strip of cards. The centered card plays its clip, the
* next clips download, and a tap opens the film in the phone's own player
* from the moment the clip was showing.
*/
export function App() {
const [films, setFilms] = useState<Film[]>([]);
const strip = useRef<HTMLUListElement>(null);
const centered = useCenteredIndex(strip);
const [lightbox, setLightbox] = useState<{ film: Film; start: number } | null>(null);
// Boot screen until the first card can show something.
const [booted, setBooted] = useState(false);
// The clips the viewer will most likely see next, nearest first.
useEffect(
() => preload([films[centered + 1]?.clip?.url, films[centered - 1]?.clip?.url, films[centered + 2]?.clip?.url]),
[centered, films],
);
// Endless: the first page, then another whenever the end is near.
useEffect(() => {
if (centered < films.length - 3) return;
void fetchFilms(films.length / 10).then((more) => setFilms((current) => [...current, ...more]));
}, [centered, films.length]);
return (
<>
<ul ref={strip} className="strip">
{films.map((f, i) => (
<li key={f.id} className="card">
<Card
film={f}
centered={i === centered}
onReady={i === 0 ? () => setBooted(true) : undefined}
onDesktopOpen={(start) => setLightbox({ film: f, start })}
/>
</li>
))}
</ul>
{!booted && <div className="boot-screen">Loading…</div>}
{lightbox && (
<dialog open className="lightbox">
<Video src={lightbox.film.hls} poster={lightbox.film.poster} playing start={lightbox.start} controls />
<button type="button" onClick={() => setLightbox(null)}>
Close
</button>
</dialog>
)}
</>
);
}
function Card(props: {
film: Film;
centered: boolean;
onReady: (() => void) | undefined;
onDesktopOpen: (start: number) => void;
}) {
const { film, centered, onReady, onDesktopOpen } = props;
const clip = useVideo();
// Where the film is at this moment of the clip.
const filmTime = () => (film.clip ? film.clip.start + clip.currentTime : 0);
return (
<button
type="button"
onClick={() =>
playFullscreen(film.hls, {
start: filmTime(),
onClose: (time) => saveProgress(film.id, time),
fallback: () => onDesktopOpen(filmTime()), // desktop: the site's own lightbox
})
}
>
{film.clip ? (
<Video
src={film.clip.url}
poster={film.poster}
player={clip}
playing={centered}
muted
loop
onReady={onReady}
// Once it plays, get the film ready to open from here.
onPlay={() => preload(film.hls, { start: filmTime() })}
/>
) : (
<img src={film.poster} alt="" onLoad={onReady} />
)}
<h2>{film.title}</h2>
</button>
);
}