@stream-io/video-client 1.20.0 → 1.20.2

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.
@@ -137,7 +137,7 @@ export declare class Call {
137
137
  /**
138
138
  * Leave the call and stop the media streams that were published by the call.
139
139
  */
140
- leave: ({ reject, reason, }?: CallLeaveOptions) => Promise<void>;
140
+ leave: ({ reject, reason, message }?: CallLeaveOptions) => Promise<void>;
141
141
  /**
142
142
  * A flag indicating whether the call is "ringing" type of call.
143
143
  */
@@ -20,6 +20,7 @@ export declare class BrowserPermission {
20
20
  }): Promise<boolean>;
21
21
  listen(cb: (state: BrowserPermissionState) => void): () => boolean;
22
22
  asObservable(): import("rxjs").Observable<boolean>;
23
+ asStateObservable(): import("rxjs").Observable<BrowserPermissionState>;
23
24
  getIsPromptingObservable(): import("rxjs").Observable<boolean>;
24
25
  private getStateObservable;
25
26
  private setState;
@@ -1,6 +1,6 @@
1
1
  import { BehaviorSubject, Observable } from 'rxjs';
2
2
  import { RxUtils } from '../store';
3
- import { BrowserPermission } from './BrowserPermission';
3
+ import { BrowserPermission, BrowserPermissionState } from './BrowserPermission';
4
4
  export type InputDeviceStatus = 'enabled' | 'disabled' | undefined;
5
5
  export type TrackDisableMode = 'stop-tracks' | 'disable-tracks';
6
6
  export declare abstract class InputMediaDeviceManagerState<C = MediaTrackConstraints> {
@@ -37,9 +37,14 @@ export declare abstract class InputMediaDeviceManagerState<C = MediaTrackConstra
37
37
  defaultConstraints$: Observable<C | undefined>;
38
38
  /**
39
39
  * An observable that will emit `true` if browser/system permission
40
- * is granted, `false` otherwise.
40
+ * is granted (or at least hasn't been denied), `false` otherwise.
41
41
  */
42
42
  hasBrowserPermission$: Observable<boolean>;
43
+ /**
44
+ * An observable that emits with browser permission state changes.
45
+ * Gives more granular visiblity than hasBrowserPermission$.
46
+ */
47
+ browserPermissionState$: Observable<BrowserPermissionState>;
43
48
  /**
44
49
  * An observable that emits `true` when SDK is prompting for browser permission
45
50
  * (i.e. browser's UI for allowing or disallowing device access is visible)
@@ -3489,6 +3489,7 @@ export declare const FrameRecordingSettingsRequestQualityEnum: {
3489
3489
  readonly _720P: "720p";
3490
3490
  readonly _1080P: "1080p";
3491
3491
  readonly _1440P: "1440p";
3492
+ readonly _2160P: "2160p";
3492
3493
  };
3493
3494
  export type FrameRecordingSettingsRequestQualityEnum = (typeof FrameRecordingSettingsRequestQualityEnum)[keyof typeof FrameRecordingSettingsRequestQualityEnum];
3494
3495
  /**
@@ -5216,11 +5217,13 @@ export declare const RTMPBroadcastRequestQualityEnum: {
5216
5217
  readonly _720P: "720p";
5217
5218
  readonly _1080P: "1080p";
5218
5219
  readonly _1440P: "1440p";
5220
+ readonly _2160P: "2160p";
5219
5221
  readonly PORTRAIT_360X640: "portrait-360x640";
5220
5222
  readonly PORTRAIT_480X854: "portrait-480x854";
5221
5223
  readonly PORTRAIT_720X1280: "portrait-720x1280";
5222
5224
  readonly PORTRAIT_1080X1920: "portrait-1080x1920";
5223
5225
  readonly PORTRAIT_1440X2560: "portrait-1440x2560";
5226
+ readonly PORTRAIT_2160X3840: "portrait-2160x3840";
5224
5227
  };
5225
5228
  export type RTMPBroadcastRequestQualityEnum = (typeof RTMPBroadcastRequestQualityEnum)[keyof typeof RTMPBroadcastRequestQualityEnum];
5226
5229
  /**
@@ -5264,11 +5267,13 @@ export declare const RTMPSettingsRequestQualityEnum: {
5264
5267
  readonly _720P: "720p";
5265
5268
  readonly _1080P: "1080p";
5266
5269
  readonly _1440P: "1440p";
5270
+ readonly _2160P: "2160p";
5267
5271
  readonly PORTRAIT_360X640: "portrait-360x640";
5268
5272
  readonly PORTRAIT_480X854: "portrait-480x854";
5269
5273
  readonly PORTRAIT_720X1280: "portrait-720x1280";
5270
5274
  readonly PORTRAIT_1080X1920: "portrait-1080x1920";
5271
5275
  readonly PORTRAIT_1440X2560: "portrait-1440x2560";
5276
+ readonly PORTRAIT_2160X3840: "portrait-2160x3840";
5272
5277
  };
5273
5278
  export type RTMPSettingsRequestQualityEnum = (typeof RTMPSettingsRequestQualityEnum)[keyof typeof RTMPSettingsRequestQualityEnum];
5274
5279
  /**
@@ -5366,11 +5371,13 @@ export declare const RecordSettingsRequestQualityEnum: {
5366
5371
  readonly _720P: "720p";
5367
5372
  readonly _1080P: "1080p";
5368
5373
  readonly _1440P: "1440p";
5374
+ readonly _2160P: "2160p";
5369
5375
  readonly PORTRAIT_360X640: "portrait-360x640";
5370
5376
  readonly PORTRAIT_480X854: "portrait-480x854";
5371
5377
  readonly PORTRAIT_720X1280: "portrait-720x1280";
5372
5378
  readonly PORTRAIT_1080X1920: "portrait-1080x1920";
5373
5379
  readonly PORTRAIT_1440X2560: "portrait-1440x2560";
5380
+ readonly PORTRAIT_2160X3840: "portrait-2160x3840";
5374
5381
  };
5375
5382
  export type RecordSettingsRequestQualityEnum = (typeof RecordSettingsRequestQualityEnum)[keyof typeof RecordSettingsRequestQualityEnum];
5376
5383
  /**
@@ -4,6 +4,7 @@ import type { StreamClient } from './coordinator/connection/client';
4
4
  import type { Comparator } from './sorting';
5
5
  import type { StreamVideoWriteableStateStore } from './store';
6
6
  import { AxiosError } from 'axios';
7
+ import { RejectReason } from './coordinator/connection/types';
7
8
  export type StreamReaction = Pick<ReactionResponse, 'type' | 'emoji_code' | 'custom'>;
8
9
  export declare enum VisibilityState {
9
10
  UNKNOWN = "UNKNOWN",
@@ -182,9 +183,13 @@ export type CallLeaveOptions = {
182
183
  reject?: boolean;
183
184
  /**
184
185
  * The reason for leaving the call.
185
- * This will be sent to the backend and will be visible in the logs.
186
+ * This will be sent as the `reason` field in the `call.rejected` event.
186
187
  */
187
- reason?: string;
188
+ reason?: RejectReason;
189
+ /**
190
+ * You can provide extra information about why the call is being left and/or rejected, used for logging purposes.
191
+ */
192
+ message?: string;
188
193
  };
189
194
  /**
190
195
  * The options to pass to {@link Call} constructor.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stream-io/video-client",
3
- "version": "1.20.0",
3
+ "version": "1.20.2",
4
4
  "main": "dist/index.cjs.js",
5
5
  "module": "dist/index.es.js",
6
6
  "browser": "dist/index.browser.es.js",
package/src/Call.ts CHANGED
@@ -374,7 +374,7 @@ export class Call {
374
374
  const currentUserId = this.currentUserId;
375
375
  if (currentUserId && blockedUserIds.includes(currentUserId)) {
376
376
  this.logger('info', 'Leaving call because of being blocked');
377
- await this.leave({ reason: 'user blocked' }).catch((err) => {
377
+ await this.leave({ message: 'user blocked' }).catch((err) => {
378
378
  this.logger('error', 'Error leaving call after being blocked', err);
379
379
  });
380
380
  }
@@ -555,10 +555,7 @@ export class Call {
555
555
  /**
556
556
  * Leave the call and stop the media streams that were published by the call.
557
557
  */
558
- leave = async ({
559
- reject,
560
- reason = 'user is leaving the call',
561
- }: CallLeaveOptions = {}) => {
558
+ leave = async ({ reject, reason, message }: CallLeaveOptions = {}) => {
562
559
  await withoutConcurrency(this.joinLeaveConcurrencyTag, async () => {
563
560
  const callingState = this.state.callingState;
564
561
  if (callingState === CallingState.LEFT) {
@@ -578,7 +575,7 @@ export class Call {
578
575
 
579
576
  if (callingState === CallingState.RINGING && reject !== false) {
580
577
  if (reject) {
581
- await this.reject('decline');
578
+ await this.reject(reason ?? 'decline');
582
579
  } else {
583
580
  // if reject was undefined, we still have to cancel the call automatically
584
581
  // when I am the creator and everyone else left the call
@@ -601,7 +598,9 @@ export class Call {
601
598
  this.publisher?.dispose();
602
599
  this.publisher = undefined;
603
600
 
604
- await this.sfuClient?.leaveAndClose(reason);
601
+ await this.sfuClient?.leaveAndClose(
602
+ message ?? reason ?? 'user is leaving the call',
603
+ );
605
604
  this.sfuClient = undefined;
606
605
  this.dynascaleManager.setSfuClient(undefined);
607
606
 
@@ -1534,7 +1533,7 @@ export class Call {
1534
1533
  const { reconnectStrategy: strategy, error } = e;
1535
1534
  if (strategy === WebsocketReconnectStrategy.UNSPECIFIED) return;
1536
1535
  if (strategy === WebsocketReconnectStrategy.DISCONNECT) {
1537
- this.leave({ reason: 'SFU instructed to disconnect' }).catch((err) => {
1536
+ this.leave({ message: 'SFU instructed to disconnect' }).catch((err) => {
1538
1537
  this.logger('warn', `Can't leave call after disconnect request`, err);
1539
1538
  });
1540
1539
  } else {
@@ -2339,12 +2338,15 @@ export class Call {
2339
2338
 
2340
2339
  // 0 means no auto-drop
2341
2340
  if (timeoutInMs <= 0) return;
2342
-
2343
2341
  this.dropTimeout = setTimeout(() => {
2344
2342
  // the call might have stopped ringing by this point,
2345
2343
  // e.g. it was already accepted and joined
2346
2344
  if (this.state.callingState !== CallingState.RINGING) return;
2347
- this.leave({ reject: true, reason: 'timeout' }).catch((err) => {
2345
+ this.leave({
2346
+ reject: true,
2347
+ reason: 'timeout',
2348
+ message: `ringing timeout - ${this.isCreatedByMe ? 'no one accepted' : `user didn't interact with incoming call screen`}`,
2349
+ }).catch((err) => {
2348
2350
  this.logger('error', 'Failed to drop call', err);
2349
2351
  });
2350
2352
  }, timeoutInMs);
@@ -0,0 +1,101 @@
1
+ import '../rtc/__tests__/mocks/webrtc.mocks';
2
+
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { Call } from '../Call';
5
+ import { StreamClient } from '../coordinator/connection/client';
6
+ import { generateUUIDv4 } from '../coordinator/connection/utils';
7
+ import { CallingState, StreamVideoWriteableStateStore } from '../store';
8
+
9
+ describe('Auto drop ringing calls', () => {
10
+ let call: Call;
11
+ const userId = 'jane';
12
+
13
+ beforeEach(async () => {
14
+ vi.useFakeTimers();
15
+
16
+ const clientStore = new StreamVideoWriteableStateStore();
17
+ call = new Call({
18
+ type: 'test',
19
+ id: generateUUIDv4(),
20
+ streamClient: new StreamClient('abc'),
21
+ clientStore: clientStore,
22
+ });
23
+
24
+ // @ts-expect-error mocking only what we need for the test
25
+ clientStore.connectedUserSubject.next({
26
+ id: userId,
27
+ });
28
+
29
+ call.state['callingStateSubject'].next(CallingState.RINGING);
30
+
31
+ vi.spyOn(call, 'leave').mockImplementation(async () => {
32
+ console.log(`TEST: leave() called`);
33
+ });
34
+ });
35
+
36
+ it('caller should drop ringing calls after a timeout if no one accepted', async () => {
37
+ call.state['settingsSubject'].next({
38
+ // @ts-expect-error mocking only what we need for the test, we use fake timers, so undefined for timeout works
39
+ ring: {},
40
+ // @ts-expect-error mocking only what we need for the test
41
+ screensharing: {
42
+ enabled: false,
43
+ target_resolution: {
44
+ width: 100,
45
+ height: 100,
46
+ },
47
+ },
48
+ });
49
+
50
+ // @ts-expect-error mocking only what we need for the test
51
+ call.state['createdBySubject'].next({
52
+ id: userId,
53
+ });
54
+
55
+ // black-box test, calling private method
56
+ call['scheduleAutoDrop']();
57
+
58
+ await vi.runAllTimersAsync();
59
+
60
+ expect(call.leave).toHaveBeenCalledWith({
61
+ reject: true,
62
+ reason: 'timeout',
63
+ message: `ringing timeout - no one accepted`,
64
+ });
65
+ });
66
+
67
+ it(`callee should drop ringing calls after a timeout if user didn't interact with incoming call screen`, async () => {
68
+ call.state['settingsSubject'].next({
69
+ // @ts-expect-error mocking only what we need for the test, we use fake timers, so undefined for timeout works
70
+ ring: {},
71
+ // @ts-expect-error mocking only what we need for the test
72
+ screensharing: {
73
+ enabled: false,
74
+ target_resolution: {
75
+ width: 100,
76
+ height: 100,
77
+ },
78
+ },
79
+ });
80
+
81
+ // @ts-expect-error mocking only what we need for the test
82
+ call.state['createdBySubject'].next({
83
+ id: 'not-' + userId,
84
+ });
85
+
86
+ // black-box test, calling private method
87
+ call['scheduleAutoDrop']();
88
+
89
+ await vi.runAllTimersAsync();
90
+
91
+ expect(call.leave).toHaveBeenCalledWith({
92
+ reject: true,
93
+ reason: 'timeout',
94
+ message: `ringing timeout - user didn't interact with incoming call screen`,
95
+ });
96
+ });
97
+
98
+ afterEach(() => {
99
+ vi.useRealTimers();
100
+ });
101
+ });
@@ -139,6 +139,10 @@ export class BrowserPermission {
139
139
  );
140
140
  }
141
141
 
142
+ asStateObservable() {
143
+ return this.getStateObservable();
144
+ }
145
+
142
146
  getIsPromptingObservable() {
143
147
  return this.getStateObservable().pipe(
144
148
  map((state) => state === 'prompting'),
@@ -8,7 +8,7 @@ import { isReactNative } from '../helpers/platforms';
8
8
  import { Logger } from '../coordinator/connection/types';
9
9
  import { getLogger } from '../logger';
10
10
  import { TrackType } from '../gen/video/sfu/models/models';
11
- import { deviceIds$ } from './devices';
11
+ import { deviceIds$, disposeOfMediaStream } from './devices';
12
12
  import {
13
13
  settled,
14
14
  withCancellation,
@@ -307,134 +307,146 @@ export abstract class InputMediaDeviceManager<
307
307
  this.logger('debug', 'Starting stream');
308
308
  let stream: MediaStream;
309
309
  let rootStream: Promise<MediaStream> | undefined;
310
- if (
311
- this.state.mediaStream &&
312
- this.getTracks().every((t) => t.readyState === 'live')
313
- ) {
314
- stream = this.state.mediaStream;
315
- this.enableTracks();
316
- } else {
317
- const defaultConstraints = this.state.defaultConstraints;
318
- const constraints: MediaTrackConstraints = {
319
- ...defaultConstraints,
320
- deviceId: this.state.selectedDevice
321
- ? { exact: this.state.selectedDevice }
322
- : undefined,
323
- };
324
-
325
- /**
326
- * Chains two media streams together.
327
- *
328
- * In our case, filters MediaStreams are derived from their parent MediaStream.
329
- * However, once a child filter's track is stopped,
330
- * the tracks of the parent MediaStream aren't automatically stopped.
331
- * This leads to a situation where the camera indicator light is still on
332
- * even though the user stopped publishing video.
333
- *
334
- * This function works around this issue by stopping the parent MediaStream's tracks
335
- * as well once the child filter's tracks are stopped.
336
- *
337
- * It works by patching the stop() method of the child filter's tracks to also stop
338
- * the parent MediaStream's tracks of the same type. Here we assume that
339
- * the parent MediaStream has only one track of each type.
340
- *
341
- * @param parentStream the parent MediaStream. Omit for the root stream.
342
- */
343
- const chainWith =
344
- (parentStream?: Promise<MediaStream>) =>
345
- async (filterStream: MediaStream): Promise<MediaStream> => {
346
- if (!parentStream) return filterStream;
347
- // TODO OL: take care of track.enabled property as well
348
- const parent = await parentStream;
349
- filterStream.getTracks().forEach((track) => {
350
- const originalStop = track.stop;
351
- track.stop = function stop() {
352
- originalStop.call(track);
353
- parent.getTracks().forEach((parentTrack) => {
354
- if (parentTrack.kind === track.kind) {
355
- parentTrack.stop();
356
- }
357
- });
358
- };
359
- });
360
310
 
361
- parent.getTracks().forEach((parentTrack) => {
362
- // When the parent stream abruptly ends, we propagate the event
363
- // to the filter stream.
364
- // This usually happens when the camera/microphone permissions
365
- // are revoked or when the device is disconnected.
366
- const handleParentTrackEnded = () => {
367
- filterStream.getTracks().forEach((track) => {
368
- if (parentTrack.kind !== track.kind) return;
369
- track.stop();
370
- track.dispatchEvent(new Event('ended')); // propagate the event
311
+ try {
312
+ if (
313
+ this.state.mediaStream &&
314
+ this.getTracks().every((t) => t.readyState === 'live')
315
+ ) {
316
+ stream = this.state.mediaStream;
317
+ this.enableTracks();
318
+ } else {
319
+ const defaultConstraints = this.state.defaultConstraints;
320
+ const constraints: MediaTrackConstraints = {
321
+ ...defaultConstraints,
322
+ deviceId: this.state.selectedDevice
323
+ ? { exact: this.state.selectedDevice }
324
+ : undefined,
325
+ };
326
+
327
+ /**
328
+ * Chains two media streams together.
329
+ *
330
+ * In our case, filters MediaStreams are derived from their parent MediaStream.
331
+ * However, once a child filter's track is stopped,
332
+ * the tracks of the parent MediaStream aren't automatically stopped.
333
+ * This leads to a situation where the camera indicator light is still on
334
+ * even though the user stopped publishing video.
335
+ *
336
+ * This function works around this issue by stopping the parent MediaStream's tracks
337
+ * as well once the child filter's tracks are stopped.
338
+ *
339
+ * It works by patching the stop() method of the child filter's tracks to also stop
340
+ * the parent MediaStream's tracks of the same type. Here we assume that
341
+ * the parent MediaStream has only one track of each type.
342
+ *
343
+ * @param parentStream the parent MediaStream. Omit for the root stream.
344
+ */
345
+ const chainWith =
346
+ (parentStream?: Promise<MediaStream>) =>
347
+ async (filterStream: MediaStream): Promise<MediaStream> => {
348
+ if (!parentStream) return filterStream;
349
+ // TODO OL: take care of track.enabled property as well
350
+ const parent = await parentStream;
351
+ filterStream.getTracks().forEach((track) => {
352
+ const originalStop = track.stop;
353
+ track.stop = function stop() {
354
+ originalStop.call(track);
355
+ parent.getTracks().forEach((parentTrack) => {
356
+ if (parentTrack.kind === track.kind) {
357
+ parentTrack.stop();
358
+ }
359
+ });
360
+ };
361
+ });
362
+
363
+ parent.getTracks().forEach((parentTrack) => {
364
+ // When the parent stream abruptly ends, we propagate the event
365
+ // to the filter stream.
366
+ // This usually happens when the camera/microphone permissions
367
+ // are revoked or when the device is disconnected.
368
+ const handleParentTrackEnded = () => {
369
+ filterStream.getTracks().forEach((track) => {
370
+ if (parentTrack.kind !== track.kind) return;
371
+ track.stop();
372
+ track.dispatchEvent(new Event('ended')); // propagate the event
373
+ });
374
+ };
375
+ parentTrack.addEventListener('ended', handleParentTrackEnded);
376
+ this.subscriptions.push(() => {
377
+ parentTrack.removeEventListener(
378
+ 'ended',
379
+ handleParentTrackEnded,
380
+ );
371
381
  });
372
- };
373
- parentTrack.addEventListener('ended', handleParentTrackEnded);
374
- this.subscriptions.push(() => {
375
- parentTrack.removeEventListener('ended', handleParentTrackEnded);
376
382
  });
377
- });
378
383
 
379
- return filterStream;
384
+ return filterStream;
385
+ };
386
+
387
+ // the rootStream represents the stream coming from the actual device
388
+ // e.g. camera or microphone stream
389
+ rootStream = this.getStream(constraints as C);
390
+ // we publish the last MediaStream of the chain
391
+ stream = await this.filters.reduce(
392
+ (parent, entry) =>
393
+ parent
394
+ .then((inputStream) => {
395
+ const { stop, output } = entry.start(inputStream);
396
+ entry.stop = stop;
397
+ return output;
398
+ })
399
+ .then(chainWith(parent), (error) => {
400
+ this.logger(
401
+ 'warn',
402
+ 'Filter failed to start and will be ignored',
403
+ error,
404
+ );
405
+ return parent;
406
+ }),
407
+ rootStream,
408
+ );
409
+ }
410
+ if (this.call.state.callingState === CallingState.JOINED) {
411
+ await this.publishStream(stream);
412
+ }
413
+ if (this.state.mediaStream !== stream) {
414
+ this.state.setMediaStream(stream, await rootStream);
415
+ const handleTrackEnded = async () => {
416
+ await this.statusChangeSettled();
417
+ if (this.enabled) {
418
+ this.isTrackStoppedDueToTrackEnd = true;
419
+ setTimeout(() => {
420
+ this.isTrackStoppedDueToTrackEnd = false;
421
+ }, 2000);
422
+ await this.disable();
423
+ }
380
424
  };
381
-
382
- // the rootStream represents the stream coming from the actual device
383
- // e.g. camera or microphone stream
384
- rootStream = this.getStream(constraints as C);
385
- // we publish the last MediaStream of the chain
386
- stream = await this.filters.reduce(
387
- (parent, entry) =>
388
- parent
389
- .then((inputStream) => {
390
- const { stop, output } = entry.start(inputStream);
391
- entry.stop = stop;
392
- return output;
393
- })
394
- .then(chainWith(parent), (error) => {
395
- this.logger(
396
- 'warn',
397
- 'Filter failed to start and will be ignored',
398
- error,
399
- );
400
- return parent;
401
- }),
402
- rootStream,
403
- );
404
- }
405
- if (this.call.state.callingState === CallingState.JOINED) {
406
- await this.publishStream(stream);
407
- }
408
- if (this.state.mediaStream !== stream) {
409
- this.state.setMediaStream(stream, await rootStream);
410
- const handleTrackEnded = async () => {
411
- await this.statusChangeSettled();
412
- if (this.enabled) {
413
- this.isTrackStoppedDueToTrackEnd = true;
414
- setTimeout(() => {
415
- this.isTrackStoppedDueToTrackEnd = false;
416
- }, 2000);
417
- await this.disable();
418
- }
419
- };
420
- const createTrackMuteHandler = (muted: boolean) => () => {
421
- if (!isMobile() || this.trackType !== TrackType.VIDEO) return;
422
- this.call.notifyTrackMuteState(muted, this.trackType).catch((err) => {
423
- this.logger('warn', 'Error while notifying track mute state', err);
424
- });
425
- };
426
- stream.getTracks().forEach((track) => {
427
- const muteHandler = createTrackMuteHandler(true);
428
- const unmuteHandler = createTrackMuteHandler(false);
429
- track.addEventListener('mute', muteHandler);
430
- track.addEventListener('unmute', unmuteHandler);
431
- track.addEventListener('ended', handleTrackEnded);
432
- this.subscriptions.push(() => {
433
- track.removeEventListener('mute', muteHandler);
434
- track.removeEventListener('unmute', unmuteHandler);
435
- track.removeEventListener('ended', handleTrackEnded);
425
+ const createTrackMuteHandler = (muted: boolean) => () => {
426
+ if (!isMobile() || this.trackType !== TrackType.VIDEO) return;
427
+ this.call.notifyTrackMuteState(muted, this.trackType).catch((err) => {
428
+ this.logger('warn', 'Error while notifying track mute state', err);
429
+ });
430
+ };
431
+ stream.getTracks().forEach((track) => {
432
+ const muteHandler = createTrackMuteHandler(true);
433
+ const unmuteHandler = createTrackMuteHandler(false);
434
+ track.addEventListener('mute', muteHandler);
435
+ track.addEventListener('unmute', unmuteHandler);
436
+ track.addEventListener('ended', handleTrackEnded);
437
+ this.subscriptions.push(() => {
438
+ track.removeEventListener('mute', muteHandler);
439
+ track.removeEventListener('unmute', unmuteHandler);
440
+ track.removeEventListener('ended', handleTrackEnded);
441
+ });
436
442
  });
437
- });
443
+ }
444
+ } catch (err) {
445
+ if (rootStream) {
446
+ disposeOfMediaStream(await rootStream);
447
+ }
448
+
449
+ throw err;
438
450
  }
439
451
  }
440
452
 
@@ -6,7 +6,7 @@ import {
6
6
  shareReplay,
7
7
  } from 'rxjs';
8
8
  import { RxUtils } from '../store';
9
- import { BrowserPermission } from './BrowserPermission';
9
+ import { BrowserPermission, BrowserPermissionState } from './BrowserPermission';
10
10
 
11
11
  export type InputDeviceStatus = 'enabled' | 'disabled' | undefined;
12
12
  export type TrackDisableMode = 'stop-tracks' | 'disable-tracks';
@@ -63,10 +63,16 @@ export abstract class InputMediaDeviceManagerState<C = MediaTrackConstraints> {
63
63
 
64
64
  /**
65
65
  * An observable that will emit `true` if browser/system permission
66
- * is granted, `false` otherwise.
66
+ * is granted (or at least hasn't been denied), `false` otherwise.
67
67
  */
68
68
  hasBrowserPermission$: Observable<boolean>;
69
69
 
70
+ /**
71
+ * An observable that emits with browser permission state changes.
72
+ * Gives more granular visiblity than hasBrowserPermission$.
73
+ */
74
+ browserPermissionState$: Observable<BrowserPermissionState>;
75
+
70
76
  /**
71
77
  * An observable that emits `true` when SDK is prompting for browser permission
72
78
  * (i.e. browser's UI for allowing or disallowing device access is visible)
@@ -88,6 +94,10 @@ export abstract class InputMediaDeviceManagerState<C = MediaTrackConstraints> {
88
94
  ? permission.asObservable().pipe(shareReplay(1))
89
95
  : of(true);
90
96
 
97
+ this.browserPermissionState$ = permission
98
+ ? permission.asStateObservable().pipe(shareReplay(1))
99
+ : of('prompt');
100
+
91
101
  this.isPromptingPermission$ = permission
92
102
  ? permission.getIsPromptingObservable().pipe(shareReplay(1))
93
103
  : of(false);
@@ -221,5 +221,6 @@ export const emitDeviceIds = (values: MediaDeviceInfo[]) => {
221
221
 
222
222
  export const mockBrowserPermission = {
223
223
  asObservable: () => of(true),
224
+ asStateObservable: () => of('prompt'),
224
225
  getIsPromptingObservable: () => of(false),
225
226
  } as BrowserPermission;
@@ -114,7 +114,11 @@ describe('Call ringing events', () => {
114
114
  },
115
115
  },
116
116
  });
117
- expect(call.leave).toHaveBeenCalled();
117
+ expect(call.leave).toHaveBeenCalledWith({
118
+ reject: true,
119
+ reason: 'cancel',
120
+ message: 'ring: everyone rejected',
121
+ });
118
122
  });
119
123
 
120
124
  it(`caller will not leave the call if only one callee rejects`, async () => {