@uln/hls 0.3.1 / examples / carousel/react

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>
  );
}