import React, { forwardRef, Ref, useImperativeHandle } from "react"; import { useAnimationFrame } from "../hooks/useAnimationFrame"; export type MediaSyncState = { duration: number; timeSeconds: number; isPlaying: boolean; isSynced: boolean; isStalled: boolean; }; export type MediaSyncProps = { children?: React.ReactNode; // Events onStateUpdate?: (state: MediaSyncState) => any; onPlay?: () => any; onPause?: () => any; }; export type MediaSyncRef = { play: () => void; pause: () => void; seek: (timeSeconds: number) => void; }; const MediaSync = forwardRef((props, ref) => { // Create refs for all media elements const childCount = React.Children.count(props.children); const childRefs = Array(childCount) .fill(null) .map(() => React.useRef(null)); // Playback state const isPlaying = React.useRef(false); const syncStates = React.useRef(Array(childCount).fill(false)); const lastVisibilityState = React.useRef<"visible" | "hidden">("visible"); const lastTime = React.useRef(0); const lastTimeUpdate = React.useRef(Date.now()); // Settings // Trigger sync at 15fps // i.e. media is considered out of sync if time difference > 1/15 second const syncThreshold = React.useRef(1 / 15); // Release sync at 60fps // i.e. media is considered in sync if time difference < 1/60 second const syncedThreshold = React.useRef(1 / 60); // If time difference is > jumpThreshold, seek instead of wait to sync const jumpThreshold = React.useRef(1); // If media isn't playing after stallThreshold milliseconds, // consider the playback to be stalled (e.g. from buffering) const stallThreshold = React.useRef(1000); // Media event handlers const handlePlayState = (event: "play" | "pause", index: number) => { // Ignore event if no state change // e.g. isPlaying is true, and event is play if (isPlaying.current === (event === "play")) return; // Ignore event if media is syncing if (syncStates.current[index]) return; // Ignore event if visibilityState is hidden // and event comes from a video element. This is // because browsers like Chrome will pause a video // when the current tab is not visible. lastVisibilityState.current = document.visibilityState; if ( // lastVisibilityState.current === "hidden" && childRefs[index].current instanceof HTMLVideoElement ) return; // Otherwise, update playback state isPlaying.current = event === "play"; console.log("[MediaSync]", event, "from", index); // Also notify parent if (event === "play") props.onPlay?.(); else props.onPause?.(); }; // Supply functions for this component's refs useImperativeHandle(ref, () => ({ play: () => { isPlaying.current = true; props.onPlay?.(); }, pause: () => { isPlaying.current = false; props.onPause?.(); }, seek: (timeSeconds: number) => { childRefs.forEach(({ current }) => (current.currentTime = timeSeconds)); }, })); useAnimationFrame(() => { // Skip if refs aren't ready yet if (childRefs.findIndex((ref) => !ref.current) > -1) return; // Get timestamps for each media element const timestamps = childRefs.map( ({ current }) => current?.currentTime || 0 ); // Assume the earliest time is the actual time // e.g. video buffers and audio is playing, take the video time // Except when the last visibility state is 'hidden', in which case // take the latest time (usually comes from the audio) const actualTime = lastVisibilityState.current === "visible" ? Math.min(...timestamps) : Math.max(...timestamps); // Check if media is stalled const now = Date.now(); if (actualTime !== lastTime.current) lastTimeUpdate.current = now; lastTime.current = actualTime; const isStalled = now - lastTimeUpdate.current > stallThreshold.current && isPlaying.current; // Update sync states // true -> out of sync, need to be paused if ahead or seeked if behind // false -> in sync // Flip to true when media is ahead by syncThreshold // Only flip back to false when the timestamp === actualTime syncStates.current = timestamps.map( (ts, i) => Math.abs(ts - actualTime) > syncedThreshold.current && (syncStates[i] || Math.abs(ts - actualTime) > syncThreshold.current) ); // Update media playback state childRefs.forEach(({ current }, index) => { if (isPlaying.current && syncStates.current[index]) { // Media is ahead, pause if not yet paused if (current.currentTime > actualTime && !current.paused) current.pause(); // Seek media if time difference is greater than the jump threshold, // or if the media is behind actualTime if ( current.currentTime - actualTime > jumpThreshold.current || current.currentTime < actualTime ) { current.currentTime = actualTime; syncStates.current[index] = false; } } else { // Media is in sync, update playback state to reflect isPlaying if (isPlaying.current && current.paused) current.play(); else if (!isPlaying.current && !current.paused) current.pause(); // Update visibility state lastVisibilityState.current = document.visibilityState; } }); // Update parent with new state props.onStateUpdate?.({ duration: childRefs[0].current?.duration, timeSeconds: actualTime, isPlaying: isPlaying.current, isSynced: !syncStates.current.includes(true), isStalled, }); }, [childRefs]); return ( <> {React.Children.map( props.children, (child: React.ReactElement, idx: number) => { // If child element has an existing ref, link it // with the one from this component. // @ts-ignore if (child.ref) { // @ts-ignore child.ref.current = childRefs[idx].current; } return React.cloneElement(child, { ref: childRefs[idx], onPlay: () => handlePlayState("play", idx), onPause: () => handlePlayState("pause", idx), }); } )} ); }); export default MediaSync;