@stream-io/video-client 0.3.9 → 0.3.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,345 @@
1
+ import { Call } from '../Call';
2
+ import {
3
+ DebounceType,
4
+ StreamVideoLocalParticipant,
5
+ StreamVideoParticipant,
6
+ VideoTrackType,
7
+ VisibilityState,
8
+ } from '../types';
9
+ import { TrackType, VideoDimension } from '../gen/video/sfu/models/models';
10
+ import {
11
+ distinctUntilChanged,
12
+ distinctUntilKeyChanged,
13
+ map,
14
+ takeWhile,
15
+ } from 'rxjs';
16
+ import { ViewportTracker } from './ViewportTracker';
17
+ import { getLogger } from '../logger';
18
+
19
+ const DEFAULT_VIEWPORT_VISIBILITY_STATE: Record<
20
+ VideoTrackType,
21
+ VisibilityState
22
+ > = {
23
+ videoTrack: VisibilityState.UNKNOWN,
24
+ screenShareTrack: VisibilityState.UNKNOWN,
25
+ } as const;
26
+
27
+ /**
28
+ * A manager class that handles dynascale related tasks like:
29
+ *
30
+ * - binding video elements to session ids
31
+ * - binding audio elements to session ids
32
+ * - tracking element visibility
33
+ * - updating subscriptions based on viewport visibility
34
+ * - updating subscriptions based on video element dimensions
35
+ * - updating subscriptions based on published tracks
36
+ */
37
+ export class DynascaleManager {
38
+ /**
39
+ * The viewport tracker instance.
40
+ */
41
+ readonly viewportTracker = new ViewportTracker();
42
+
43
+ private logger = getLogger(['DynascaleManager']);
44
+ private call: Call;
45
+
46
+ /**
47
+ * Creates a new DynascaleManager instance.
48
+ *
49
+ * @param call the call to manage.
50
+ */
51
+ constructor(call: Call) {
52
+ this.call = call;
53
+ }
54
+
55
+ /**
56
+ * Will begin tracking the given element for visibility changes within the
57
+ * configured viewport element (`call.setViewport`).
58
+ *
59
+ * @param element the element to track.
60
+ * @param sessionId the session id.
61
+ * @param trackType the kind of video.
62
+ * @returns Untrack.
63
+ */
64
+ trackElementVisibility = <T extends HTMLElement>(
65
+ element: T,
66
+ sessionId: string,
67
+ trackType: VideoTrackType,
68
+ ) => {
69
+ const cleanup = this.viewportTracker.observe(element, (entry) => {
70
+ this.call.state.updateParticipant(sessionId, (participant) => {
71
+ const previousVisibilityState =
72
+ participant.viewportVisibilityState ??
73
+ DEFAULT_VIEWPORT_VISIBILITY_STATE;
74
+
75
+ // observer triggers when the element is "moved" to be a fullscreen element
76
+ // keep it VISIBLE if that happens to prevent fullscreen with placeholder
77
+ const isVisible =
78
+ entry.isIntersecting || document.fullscreenElement === element
79
+ ? VisibilityState.VISIBLE
80
+ : VisibilityState.INVISIBLE;
81
+ return {
82
+ ...participant,
83
+ viewportVisibilityState: {
84
+ ...previousVisibilityState,
85
+ [trackType]: isVisible,
86
+ },
87
+ };
88
+ });
89
+ });
90
+
91
+ return () => {
92
+ cleanup();
93
+ // reset visibility state to UNKNOWN upon cleanup
94
+ // so that the layouts that are not actively observed
95
+ // can still function normally (runtime layout switching)
96
+ this.call.state.updateParticipant(sessionId, (participant) => {
97
+ const previousVisibilityState =
98
+ participant.viewportVisibilityState ??
99
+ DEFAULT_VIEWPORT_VISIBILITY_STATE;
100
+ return {
101
+ ...participant,
102
+ viewportVisibilityState: {
103
+ ...previousVisibilityState,
104
+ [trackType]: VisibilityState.UNKNOWN,
105
+ },
106
+ };
107
+ });
108
+ };
109
+ };
110
+
111
+ /**
112
+ * Sets the viewport element to track bound video elements for visibility.
113
+ *
114
+ * @param element the viewport element.
115
+ */
116
+ setViewport = <T extends HTMLElement>(element: T) => {
117
+ return this.viewportTracker.setViewport(element);
118
+ };
119
+
120
+ /**
121
+ * Binds a DOM <video> element to the given session id.
122
+ * This method will make sure that the video element will play
123
+ * the correct video stream for the given session id.
124
+ *
125
+ * Under the hood, it would also keep track of the video element dimensions
126
+ * and update the subscription accordingly in order to optimize the bandwidth.
127
+ *
128
+ * If a "viewport" is configured, the video element will be automatically
129
+ * tracked for visibility and the subscription will be updated accordingly.
130
+ *
131
+ * @param videoElement the video element to bind to.
132
+ * @param sessionId the session id.
133
+ * @param trackType the kind of video.
134
+ */
135
+ bindVideoElement = (
136
+ videoElement: HTMLVideoElement,
137
+ sessionId: string,
138
+ trackType: VideoTrackType,
139
+ ) => {
140
+ const boundParticipant =
141
+ this.call.state.findParticipantBySessionId(sessionId);
142
+ if (!boundParticipant) return;
143
+
144
+ const requestTrackWithDimensions = (
145
+ debounceType: DebounceType,
146
+ dimension: VideoDimension | undefined,
147
+ ) => {
148
+ this.call.updateSubscriptionsPartial(
149
+ trackType,
150
+ { [sessionId]: { dimension } },
151
+ debounceType,
152
+ );
153
+ };
154
+
155
+ const participant$ = this.call.state.participants$.pipe(
156
+ map(
157
+ (participants) =>
158
+ participants.find(
159
+ (participant) => participant.sessionId === sessionId,
160
+ ) as StreamVideoLocalParticipant | StreamVideoParticipant,
161
+ ),
162
+ takeWhile((participant) => !!participant),
163
+ distinctUntilChanged(),
164
+ );
165
+
166
+ // keep copy for resize observer handler
167
+ let viewportVisibilityState: VisibilityState | undefined;
168
+ const viewportVisibilityStateSubscription =
169
+ boundParticipant.isLocalParticipant
170
+ ? null
171
+ : participant$
172
+ .pipe(
173
+ map((p) => p.viewportVisibilityState?.[trackType]),
174
+ distinctUntilChanged(),
175
+ )
176
+ .subscribe((nextViewportVisibilityState) => {
177
+ // skip initial trigger
178
+ if (!viewportVisibilityState) {
179
+ viewportVisibilityState =
180
+ nextViewportVisibilityState ?? VisibilityState.UNKNOWN;
181
+ return;
182
+ }
183
+ viewportVisibilityState =
184
+ nextViewportVisibilityState ?? VisibilityState.UNKNOWN;
185
+
186
+ if (nextViewportVisibilityState === VisibilityState.INVISIBLE) {
187
+ return requestTrackWithDimensions(
188
+ DebounceType.MEDIUM,
189
+ undefined,
190
+ );
191
+ }
192
+
193
+ requestTrackWithDimensions(DebounceType.MEDIUM, {
194
+ width: videoElement.clientWidth,
195
+ height: videoElement.clientHeight,
196
+ });
197
+ });
198
+
199
+ let lastDimensions: string | undefined;
200
+ const resizeObserver = boundParticipant.isLocalParticipant
201
+ ? null
202
+ : new ResizeObserver(() => {
203
+ const currentDimensions = `${videoElement.clientWidth},${videoElement.clientHeight}`;
204
+
205
+ // skip initial trigger
206
+ if (!lastDimensions) {
207
+ lastDimensions = currentDimensions;
208
+ return;
209
+ }
210
+
211
+ if (
212
+ lastDimensions === currentDimensions ||
213
+ viewportVisibilityState === VisibilityState.INVISIBLE
214
+ ) {
215
+ return;
216
+ }
217
+
218
+ requestTrackWithDimensions(DebounceType.SLOW, {
219
+ width: videoElement.clientWidth,
220
+ height: videoElement.clientHeight,
221
+ });
222
+ lastDimensions = currentDimensions;
223
+ });
224
+ resizeObserver?.observe(videoElement);
225
+
226
+ const publishedTracksSubscription = boundParticipant.isLocalParticipant
227
+ ? null
228
+ : participant$
229
+ .pipe(
230
+ distinctUntilKeyChanged('publishedTracks'),
231
+ map((p) =>
232
+ p.publishedTracks.includes(
233
+ trackType === 'videoTrack'
234
+ ? TrackType.VIDEO
235
+ : TrackType.SCREEN_SHARE,
236
+ ),
237
+ ),
238
+ distinctUntilChanged(),
239
+ )
240
+ .subscribe((isPublishing) => {
241
+ if (isPublishing) {
242
+ // the participant just started to publish a track
243
+ requestTrackWithDimensions(DebounceType.IMMEDIATE, {
244
+ width: videoElement.clientWidth,
245
+ height: videoElement.clientHeight,
246
+ });
247
+ } else {
248
+ // the participant just stopped publishing a track
249
+ requestTrackWithDimensions(DebounceType.IMMEDIATE, undefined);
250
+ }
251
+ });
252
+
253
+ const streamSubscription = participant$
254
+ .pipe(
255
+ distinctUntilKeyChanged(
256
+ trackType === 'videoTrack' ? 'videoStream' : 'screenShareStream',
257
+ ),
258
+ )
259
+ .subscribe((p) => {
260
+ const source =
261
+ trackType === 'videoTrack' ? p.videoStream : p.screenShareStream;
262
+ if (videoElement.srcObject === source) return;
263
+ setTimeout(() => {
264
+ videoElement.srcObject = source ?? null;
265
+ if (videoElement.srcObject) {
266
+ videoElement.play().catch((e) => {
267
+ this.logger('warn', `Failed to play stream`, e);
268
+ });
269
+ }
270
+ }, 0);
271
+ });
272
+ videoElement.playsInline = true;
273
+ videoElement.autoplay = true;
274
+
275
+ // explicitly marking the element as muted will allow autoplay to work
276
+ // without prior user interaction:
277
+ // https://developer.mozilla.org/en-US/docs/Web/Media/Autoplay_guide
278
+ videoElement.muted = true;
279
+
280
+ return () => {
281
+ viewportVisibilityStateSubscription?.unsubscribe();
282
+ publishedTracksSubscription?.unsubscribe();
283
+ streamSubscription.unsubscribe();
284
+ resizeObserver?.disconnect();
285
+ };
286
+ };
287
+
288
+ /**
289
+ * Binds a DOM <audio> element to the given session id.
290
+ *
291
+ * This method will make sure that the audio element will
292
+ * play the correct audio stream for the given session id.
293
+ *
294
+ * @param audioElement the audio element to bind to.
295
+ * @param sessionId the session id.
296
+ * @returns a cleanup function that will unbind the audio element.
297
+ */
298
+ bindAudioElement = (audioElement: HTMLAudioElement, sessionId: string) => {
299
+ const participant = this.call.state.findParticipantBySessionId(sessionId);
300
+ if (!participant || participant.isLocalParticipant) return;
301
+
302
+ const participant$ = this.call.state.participants$.pipe(
303
+ map(
304
+ (participants) =>
305
+ participants.find((p) => p.sessionId === sessionId) as
306
+ | StreamVideoLocalParticipant
307
+ | StreamVideoParticipant,
308
+ ),
309
+ takeWhile((p) => !!p),
310
+ distinctUntilChanged(),
311
+ );
312
+
313
+ const updateMediaStreamSubscription = participant$
314
+ .pipe(distinctUntilKeyChanged('audioStream'))
315
+ .subscribe((p) => {
316
+ const source = p.audioStream;
317
+ if (audioElement.srcObject === source) return;
318
+
319
+ setTimeout(() => {
320
+ audioElement.srcObject = source ?? null;
321
+ if (audioElement.srcObject) {
322
+ audioElement.play().catch((e) => {
323
+ this.logger('warn', `Failed to play stream`, e);
324
+ });
325
+ }
326
+ });
327
+ });
328
+
329
+ const sinkIdSubscription = this.call.state.localParticipant$.subscribe(
330
+ (p) => {
331
+ if (p && p.audioOutputDeviceId && 'setSinkId' in audioElement) {
332
+ // @ts-expect-error setSinkId is not yet in the lib
333
+ audioElement.setSinkId(p.audioOutputDeviceId);
334
+ }
335
+ },
336
+ );
337
+
338
+ audioElement.autoplay = true;
339
+
340
+ return () => {
341
+ sinkIdSubscription.unsubscribe();
342
+ updateMediaStreamSubscription.unsubscribe();
343
+ };
344
+ };
345
+ }