1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
|
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<MediaSyncRef, MediaSyncProps>((props, ref) => {
// Create refs for all media elements
const childCount = React.Children.count(props.children);
const childRefs = Array(childCount)
.fill(null)
.map(() => React.useRef<HTMLMediaElement>(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;
|