@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.
- package/CHANGELOG.md +7 -0
- package/dist/index.browser.es.js +474 -152
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +473 -150
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.es.js +474 -152
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +50 -10
- package/dist/src/helpers/DynascaleManager.d.ts +70 -0
- package/dist/src/helpers/__tests__/DynascaleManager.test.d.ts +4 -0
- package/dist/src/types.d.ts +4 -5
- package/dist/version.d.ts +1 -1
- package/index.ts +2 -1
- package/package.json +8 -7
- package/src/Call.ts +134 -24
- package/src/events/__tests__/participant.test.ts +4 -1
- package/src/events/participant.ts +4 -1
- package/src/helpers/DynascaleManager.ts +345 -0
- package/src/helpers/__tests__/DynascaleManager.test.ts +391 -0
- package/src/sorting/__tests__/participant-data.ts +24 -6
- package/src/sorting/presets.ts +2 -2
- package/src/store/__tests__/CallState.test.ts +3 -3
- package/src/store/rxUtils.ts +10 -1
- package/src/types.ts +5 -5
|
@@ -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
|
+
}
|