loading...
Cover image for A React component that shares video with others.

A React component that shares video with others.

mmcc profile image Matt McClure ・2 min read

As part of our Mux.com refresh, we wanted to demo our API experience via a React-based animation. At the end of it we wanted to show a video playing across multiple devices, which starts to get into weirder territory than you might expect.

It'd be easy to jump to using multiple video elements across the devices. On top of loading the same video multiple times (and the bandwidth that entails), synchronizing the playback gets problematic. Starting them all at the same time is a good start, but what if any of the players is slow to start or rebuffers at any point?

Instead, we decided to keep playing with canvas. We made a React component that plays video in a <video> tag...but actually never displays that video. Instead, it distributes that video content to the array of canvas refs passed to it.

function CanvasPlayer (props) {
  const player = useRef(null);

  const canvases = props.canvases.map((c) => {
    const canvas = c.current;
    const ctx = canvas.getContext('2d');

    return [canvas, ctx];
  });

  const updateCanvases = () => {
    // If the player is empty, we probably reset!
    // In that case, let's clear out the canvases
    if (!player.current) {
      canvases.map(([canvas, ctx]) => {
        ctx.clearRect(0, 0, canvas.width, canvas.height)
      });
    }

    // I don't know how we'd get to this point without
    // player being defined, but... yeah. Here we check
    // to see if the video is actually playing before
    // continuing to paint to the canvases
    if (!player.current || player.current.paused || player.current.ended) {
      return;
    }

    // Paint! Map over each canvas and draw what's currently
    // in the video element.
    canvases.map(([canvas, ctx]) => {
      ctx.drawImage(player.current, 0, 0, canvas.width, canvas.height));
    }

    // Loop that thing.
    window.requestAnimationFrame(updateCanvases);
  };

  // Fired whenever the video element starts playing
  const onPlay = () => {
    updateCanvases();
  };

  useEffect(() => {
    // We're using HLS, so this is just to make sure the player
    // can support it. This isn't necessary if you're just using
    // an mp4 or something.
    let hls;
    if (player.current.canPlayType('application/vnd.apple.mpegurl')) {
      player.current.src = props.src;
      player.current.addEventListener('loadedmetadata', () => {
        player.current.play();
      });
    } else {
      hls = new Hls();
      hls.loadSource(props.src);
      hls.attachMedia(player.current);
      hls.on(Hls.Events.MANIFEST_PARSED, () => {
        player.current.play();
      });
    }

    return () => hls && hls.destroy();
  }, []);

  /* eslint-disable jsx-a11y/media-has-caption */
  return <video style={{ display: 'none' }} ref={player} onPlay={onPlay} {...props} />;
}

All the magic is in the updateCanvases function. While the video is playing it maps over each canvas ref and draws whatever is in the video tag to it.

How it ends up looking

function FunComponent(props) {
  const canvasOne = useRef(null);
  const canvasTwo = useRef(null);

  return (
    <div>
      <SomeComponent>
        <canvas ref={canvasOne} />
      </SomeComponent>
      <OtherComponent>
        <canvas ref={canvasTwo} />
      </OtherComponent>

      <CanvasPlayer
        src={`https://stream.mux.com/${props.playbackID}.m3u8`}
        muted
        canvases={[canvasOne, canvasTwo]}
        loop
      />
    </div>
  )
}

The CanvasPlayer won't actually play anything itself, but it'll distribute the video image around to each of the refs passed to it. This means you could sprinkle a video all around a page if you want but only have to download it once!

Discussion

pic
Editor guide