@stream-io/video-client 0.6.6 → 0.6.8

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/src/Call.ts CHANGED
@@ -30,6 +30,7 @@ import {
30
30
  GoLiveRequest,
31
31
  GoLiveResponse,
32
32
  ListRecordingsResponse,
33
+ ListTranscriptionsResponse,
33
34
  MuteUsersRequest,
34
35
  MuteUsersResponse,
35
36
  OwnCapability,
@@ -48,10 +49,13 @@ import {
48
49
  StartHLSBroadcastingResponse,
49
50
  StartRecordingRequest,
50
51
  StartRecordingResponse,
52
+ StartTranscriptionRequest,
53
+ StartTranscriptionResponse,
51
54
  StatsOptions,
52
55
  StopHLSBroadcastingResponse,
53
56
  StopLiveResponse,
54
57
  StopRecordingResponse,
58
+ StopTranscriptionResponse,
55
59
  UnblockUserRequest,
56
60
  UnblockUserResponse,
57
61
  UnpinRequest,
@@ -509,10 +513,10 @@ export class Call {
509
513
 
510
514
  this.clientStore.unregisterCall(this);
511
515
 
512
- this.camera.removeSubscriptions();
513
- this.microphone.removeSubscriptions();
514
- this.screenShare.removeSubscriptions();
515
- this.speaker.removeSubscriptions();
516
+ this.camera.dispose();
517
+ this.microphone.dispose();
518
+ this.screenShare.dispose();
519
+ this.speaker.dispose();
516
520
 
517
521
  const stopOnLeavePromises: Promise<void>[] = [];
518
522
  if (this.camera.stopOnLeave) {
@@ -1582,6 +1586,29 @@ export class Call {
1582
1586
  );
1583
1587
  };
1584
1588
 
1589
+ /**
1590
+ * Starts the transcription of the call.
1591
+ *
1592
+ * @param request the request data.
1593
+ */
1594
+ startTranscription = async (
1595
+ request?: StartTranscriptionRequest,
1596
+ ): Promise<StartTranscriptionResponse> => {
1597
+ return this.streamClient.post<
1598
+ StartTranscriptionResponse,
1599
+ StartTranscriptionRequest
1600
+ >(`${this.streamClientBasePath}/start_transcription`, request);
1601
+ };
1602
+
1603
+ /**
1604
+ * Stops the transcription of the call.
1605
+ */
1606
+ stopTranscription = async (): Promise<StopTranscriptionResponse> => {
1607
+ return this.streamClient.post<StopTranscriptionResponse>(
1608
+ `${this.streamClientBasePath}/stop_transcription`,
1609
+ );
1610
+ };
1611
+
1585
1612
  /**
1586
1613
  * Sends a `call.permission_request` event to all users connected to the call. The call settings object contains infomration about which permissions can be requested during a call (for example a user might be allowed to request permission to publish audio, but not video).
1587
1614
  */
@@ -1859,6 +1886,17 @@ export class Call {
1859
1886
  );
1860
1887
  };
1861
1888
 
1889
+ /**
1890
+ * Retrieves the list of transcriptions for the current call.
1891
+ *
1892
+ * @returns the list of transcriptions.
1893
+ */
1894
+ queryTranscriptions = async (): Promise<ListTranscriptionsResponse> => {
1895
+ return this.streamClient.get<ListTranscriptionsResponse>(
1896
+ `${this.streamClientBasePath}/transcriptions`,
1897
+ );
1898
+ };
1899
+
1862
1900
  /**
1863
1901
  * Retrieve call statistics for a particular call session (historical).
1864
1902
  * Here `callSessionID` is mandatory.
@@ -13,6 +13,8 @@ import type {
13
13
  ListDevicesResponse,
14
14
  QueryCallsRequest,
15
15
  QueryCallsResponse,
16
+ QueryCallStatsRequest,
17
+ QueryCallStatsResponse,
16
18
  } from './gen/coordinator';
17
19
  import {
18
20
  AllClientEvents,
@@ -366,6 +368,19 @@ export class StreamVideoClient {
366
368
  };
367
369
  };
368
370
 
371
+ /**
372
+ * Retrieve the list of available call statistics reports matching a particular condition.
373
+ *
374
+ * @param data Filter and sort conditions for retrieving available call report summaries.
375
+ * @returns List with summary of available call reports matching the condition.
376
+ */
377
+ queryCallStats = async (data: QueryCallStatsRequest = {}) => {
378
+ return this.streamClient.post<
379
+ QueryCallStatsResponse,
380
+ QueryCallStatsRequest
381
+ >(`/call/stats`, data);
382
+ };
383
+
369
384
  /**
370
385
  * Returns a list of available data centers available for hosting calls.
371
386
  */
@@ -1,6 +1,7 @@
1
- import { Observable, Subscription, combineLatest, pairwise } from 'rxjs';
1
+ import { combineLatest, Observable, pairwise } from 'rxjs';
2
2
  import { Call } from '../Call';
3
3
  import { CallingState } from '../store';
4
+ import { createSubscription } from '../store/rxUtils';
4
5
  import { InputMediaDeviceManagerState } from './InputMediaDeviceManagerState';
5
6
  import { isReactNative } from '../helpers/platforms';
6
7
  import { Logger } from '../coordinator/connection/types';
@@ -8,6 +9,8 @@ import { getLogger } from '../logger';
8
9
  import { TrackType } from '../gen/video/sfu/models/models';
9
10
  import { deviceIds$ } from './devices';
10
11
 
12
+ export type MediaStreamFilter = (stream: MediaStream) => Promise<MediaStream>;
13
+
11
14
  export abstract class InputMediaDeviceManager<
12
15
  T extends InputMediaDeviceManagerState<C>,
13
16
  C = MediaTrackConstraints,
@@ -25,8 +28,9 @@ export abstract class InputMediaDeviceManager<
25
28
  */
26
29
  stopOnLeave = true;
27
30
  logger: Logger;
28
- private subscriptions: Subscription[] = [];
31
+ private subscriptions: Function[] = [];
29
32
  private isTrackStoppedDueToTrackEnd = false;
33
+ private filters: MediaStreamFilter[] = [];
30
34
 
31
35
  protected constructor(
32
36
  protected readonly call: Call,
@@ -113,6 +117,24 @@ export abstract class InputMediaDeviceManager<
113
117
  }
114
118
  }
115
119
 
120
+ /**
121
+ * Registers a filter that will be applied to the stream.
122
+ *
123
+ * The registered filter will get the existing stream, and it should return
124
+ * a new stream with the applied filter.
125
+ *
126
+ * @param filter the filter to register.
127
+ * @returns a function that will unregister the filter.
128
+ */
129
+ async registerFilter(filter: MediaStreamFilter) {
130
+ this.filters.push(filter);
131
+ await this.applySettingsToStream();
132
+ return async () => {
133
+ this.filters = this.filters.filter((f) => f !== filter);
134
+ await this.applySettingsToStream();
135
+ };
136
+ }
137
+
116
138
  /**
117
139
  * Will set the default constraints for the device.
118
140
  *
@@ -141,8 +163,13 @@ export abstract class InputMediaDeviceManager<
141
163
  await this.applySettingsToStream();
142
164
  }
143
165
 
144
- removeSubscriptions = () => {
145
- this.subscriptions.forEach((s) => s.unsubscribe());
166
+ /**
167
+ * Disposes the manager.
168
+ *
169
+ * @internal
170
+ */
171
+ dispose = () => {
172
+ this.subscriptions.forEach((s) => s());
146
173
  };
147
174
 
148
175
  protected async applySettingsToStream() {
@@ -229,7 +256,69 @@ export abstract class InputMediaDeviceManager<
229
256
  ...defaultConstraints,
230
257
  deviceId: this.state.selectedDevice,
231
258
  };
232
- stream = await this.getStream(constraints as C);
259
+
260
+ /**
261
+ * Chains two media streams together.
262
+ *
263
+ * In our case, filters MediaStreams are derived from their parent MediaStream.
264
+ * However, once a child filter's track is stopped,
265
+ * the tracks of the parent MediaStream aren't automatically stopped.
266
+ * This leads to a situation where the camera indicator light is still on
267
+ * even though the user stopped publishing video.
268
+ *
269
+ * This function works around this issue by stopping the parent MediaStream's tracks
270
+ * as well once the child filter's tracks are stopped.
271
+ *
272
+ * It works by patching the stop() method of the child filter's tracks to also stop
273
+ * the parent MediaStream's tracks of the same type. Here we assume that
274
+ * the parent MediaStream has only one track of each type.
275
+ *
276
+ * @param parentStream the parent MediaStream. Omit for the root stream.
277
+ */
278
+ const chainWith =
279
+ (parentStream?: Promise<MediaStream>) =>
280
+ async (filterStream: MediaStream): Promise<MediaStream> => {
281
+ if (!parentStream) return filterStream;
282
+ // TODO OL: take care of track.enabled property as well
283
+ const parent = await parentStream;
284
+ filterStream.getTracks().forEach((track) => {
285
+ const originalStop = track.stop;
286
+ track.stop = function stop() {
287
+ originalStop.call(track);
288
+ parent.getTracks().forEach((parentTrack) => {
289
+ if (parentTrack.kind === track.kind) {
290
+ parentTrack.stop();
291
+ }
292
+ });
293
+ };
294
+ });
295
+
296
+ parent.getTracks().forEach((parentTrack) => {
297
+ // When the parent stream abruptly ends, we propagate the event
298
+ // to the filter stream.
299
+ // This usually happens when the camera/microphone permissions
300
+ // are revoked or when the device is disconnected.
301
+ const handleParentTrackEnded = () => {
302
+ filterStream.getTracks().forEach((track) => {
303
+ if (parentTrack.kind !== track.kind) return;
304
+ track.stop();
305
+ track.dispatchEvent(new Event('ended')); // propagate the event
306
+ });
307
+ };
308
+ parentTrack.addEventListener('ended', handleParentTrackEnded);
309
+ this.subscriptions.push(() => {
310
+ parentTrack.removeEventListener('ended', handleParentTrackEnded);
311
+ });
312
+ });
313
+
314
+ return filterStream;
315
+ };
316
+
317
+ // we publish the last MediaStream of the chain
318
+ stream = await this.filters.reduce(
319
+ (parent, filter) => parent.then(filter).then(chainWith(parent)),
320
+ this.getStream(constraints as C),
321
+ );
233
322
  }
234
323
  if (this.call.state.callingState === CallingState.JOINED) {
235
324
  await this.publishStream(stream);
@@ -268,51 +357,54 @@ export abstract class InputMediaDeviceManager<
268
357
 
269
358
  private handleDisconnectedOrReplacedDevices() {
270
359
  this.subscriptions.push(
271
- combineLatest([
272
- deviceIds$!.pipe(pairwise()),
273
- this.state.selectedDevice$,
274
- ]).subscribe(async ([[prevDevices, currentDevices], deviceId]) => {
275
- if (!deviceId) {
276
- return;
277
- }
278
- if (this.enablePromise) {
279
- await this.enablePromise;
280
- }
281
- if (this.disablePromise) {
282
- await this.disablePromise;
283
- }
284
-
285
- let isDeviceDisconnected = false;
286
- let isDeviceReplaced = false;
287
- const currentDevice = this.findDeviceInList(currentDevices, deviceId);
288
- const prevDevice = this.findDeviceInList(prevDevices, deviceId);
289
- if (!currentDevice && prevDevice) {
290
- isDeviceDisconnected = true;
291
- } else if (
292
- currentDevice &&
293
- prevDevice &&
294
- currentDevice.deviceId === prevDevice.deviceId &&
295
- currentDevice.groupId !== prevDevice.groupId
296
- ) {
297
- isDeviceReplaced = true;
298
- }
299
-
300
- if (isDeviceDisconnected) {
301
- await this.disable();
302
- this.select(undefined);
303
- }
304
- if (isDeviceReplaced) {
305
- if (
306
- this.isTrackStoppedDueToTrackEnd &&
307
- this.state.status === 'disabled'
360
+ createSubscription(
361
+ combineLatest([
362
+ deviceIds$!.pipe(pairwise()),
363
+ this.state.selectedDevice$,
364
+ ]),
365
+ async ([[prevDevices, currentDevices], deviceId]) => {
366
+ if (!deviceId) {
367
+ return;
368
+ }
369
+ if (this.enablePromise) {
370
+ await this.enablePromise;
371
+ }
372
+ if (this.disablePromise) {
373
+ await this.disablePromise;
374
+ }
375
+
376
+ let isDeviceDisconnected = false;
377
+ let isDeviceReplaced = false;
378
+ const currentDevice = this.findDeviceInList(currentDevices, deviceId);
379
+ const prevDevice = this.findDeviceInList(prevDevices, deviceId);
380
+ if (!currentDevice && prevDevice) {
381
+ isDeviceDisconnected = true;
382
+ } else if (
383
+ currentDevice &&
384
+ prevDevice &&
385
+ currentDevice.deviceId === prevDevice.deviceId &&
386
+ currentDevice.groupId !== prevDevice.groupId
308
387
  ) {
309
- await this.enable();
310
- this.isTrackStoppedDueToTrackEnd = false;
311
- } else {
312
- await this.applySettingsToStream();
388
+ isDeviceReplaced = true;
389
+ }
390
+
391
+ if (isDeviceDisconnected) {
392
+ await this.disable();
393
+ this.select(undefined);
394
+ }
395
+ if (isDeviceReplaced) {
396
+ if (
397
+ this.isTrackStoppedDueToTrackEnd &&
398
+ this.state.status === 'disabled'
399
+ ) {
400
+ await this.enable();
401
+ this.isTrackStoppedDueToTrackEnd = false;
402
+ } else {
403
+ await this.applySettingsToStream();
404
+ }
313
405
  }
314
- }
315
- }),
406
+ },
407
+ ),
316
408
  );
317
409
  }
318
410
 
@@ -63,7 +63,12 @@ export class SpeakerManager {
63
63
  this.state.setDevice(deviceId);
64
64
  }
65
65
 
66
- removeSubscriptions = () => {
66
+ /**
67
+ * Disposes the manager.
68
+ *
69
+ * @internal
70
+ */
71
+ dispose = () => {
67
72
  this.subscriptions.forEach((s) => s.unsubscribe());
68
73
  };
69
74
 
@@ -0,0 +1,115 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { of } from 'rxjs';
3
+ import { Call } from '../../Call';
4
+ import { StreamClient } from '../../coordinator/connection/client';
5
+ import { StreamVideoWriteableStateStore } from '../../store';
6
+ import { InputMediaDeviceManagerState } from '../InputMediaDeviceManagerState';
7
+ import { InputMediaDeviceManager } from '../InputMediaDeviceManager';
8
+ import { mockVideoDevices, mockVideoStream } from './mocks';
9
+ import { TrackType } from '../../gen/video/sfu/models/models';
10
+
11
+ import '../../rtc/__tests__/mocks/webrtc.mocks';
12
+
13
+ class TestInputMediaDeviceManagerState extends InputMediaDeviceManagerState {
14
+ public getDeviceIdFromStream = vi.fn(
15
+ (stream) => stream.getTracks()[0].getSettings().deviceId,
16
+ );
17
+ }
18
+
19
+ class TestInputMediaDeviceManager extends InputMediaDeviceManager<TestInputMediaDeviceManagerState> {
20
+ public getDevices = vi.fn(() => of(mockVideoDevices));
21
+ public getStream = vi.fn(() => Promise.resolve(mockVideoStream()));
22
+ public publishStream = vi.fn();
23
+ public stopPublishStream = vi.fn();
24
+ public getTracks = () => this.state.mediaStream?.getTracks() ?? [];
25
+
26
+ constructor(call: Call) {
27
+ super(
28
+ call,
29
+ new TestInputMediaDeviceManagerState(
30
+ 'stop-tracks',
31
+ 'camera' as PermissionName,
32
+ ),
33
+ TrackType.VIDEO,
34
+ );
35
+ }
36
+ }
37
+
38
+ describe('MediaStream Filters', () => {
39
+ let manager: TestInputMediaDeviceManager;
40
+
41
+ beforeEach(() => {
42
+ manager = new TestInputMediaDeviceManager(
43
+ new Call({
44
+ id: '',
45
+ type: '',
46
+ streamClient: new StreamClient('abc123'),
47
+ clientStore: new StreamVideoWriteableStateStore(),
48
+ }),
49
+ );
50
+ });
51
+
52
+ it('should support registering and unregistering of filters', async () => {
53
+ const mediaStream = new MediaStream();
54
+ const track = new MediaStreamTrack();
55
+ vi.spyOn(mediaStream, 'getTracks').mockReturnValue([track]);
56
+ vi.spyOn(track, 'getSettings').mockReturnValue({ deviceId: '123' });
57
+ const filter = vi.fn().mockReturnValue(mediaStream);
58
+ const unregister = await manager.registerFilter(filter);
59
+ await manager.enable();
60
+
61
+ expect(filter).toHaveBeenCalled();
62
+
63
+ filter.mockClear();
64
+ await unregister();
65
+ await manager.enable();
66
+
67
+ expect(filter).not.toHaveBeenCalled();
68
+ });
69
+
70
+ it('should chain media streams together', async () => {
71
+ const createMediaStream = () => {
72
+ let onEndedCapture: EventListener | null = null;
73
+ const mediaStream = new MediaStream();
74
+ const track = new MediaStreamTrack();
75
+ vi.spyOn(mediaStream, 'getTracks').mockReturnValue([track]);
76
+ vi.spyOn(track, 'getSettings').mockReturnValue({ deviceId: '123' });
77
+ vi.spyOn(track, 'addEventListener').mockImplementation((_, fn) => {
78
+ onEndedCapture = fn as EventListener;
79
+ });
80
+ track.dispatchEvent = (event: Event) => {
81
+ if (onEndedCapture && event.type === 'ended') {
82
+ onEndedCapture(event);
83
+ }
84
+ return true;
85
+ };
86
+ return mediaStream;
87
+ };
88
+
89
+ const rootMediaStream = createMediaStream();
90
+ const filterMediaStream = createMediaStream();
91
+ const filter1 = vi.fn().mockReturnValue(rootMediaStream);
92
+ const filter2 = vi.fn().mockReturnValue(filterMediaStream);
93
+ await manager.registerFilter(filter1);
94
+ await manager.registerFilter(filter2);
95
+ await manager.enable();
96
+
97
+ expect(filter1).toHaveBeenCalled();
98
+ expect(filter2).toHaveBeenCalled();
99
+
100
+ // stopping should bubble up: filter -> root
101
+ const rootSpy = vi.spyOn(rootMediaStream.getTracks()[0], 'stop');
102
+ expect(rootSpy).not.toHaveBeenCalled();
103
+ filterMediaStream.getTracks()[0].stop();
104
+ expect(rootSpy).toHaveBeenCalled();
105
+
106
+ // stopping should not bubble down: root -> filter
107
+ const filterSpy = vi.spyOn(filterMediaStream.getTracks()[0], 'stop');
108
+ rootMediaStream.getTracks()[0].stop();
109
+ expect(filterSpy).not.toHaveBeenCalled();
110
+
111
+ // simulate an abrupt ending of the track
112
+ rootMediaStream.getTracks()[0].dispatchEvent(new Event('ended'));
113
+ expect(filterSpy).toHaveBeenCalled();
114
+ });
115
+ });