@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.
- package/CHANGELOG.md +14 -0
- package/dist/index.browser.es.js +157 -128
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +157 -128
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +157 -128
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +1 -1
- package/dist/src/devices/BrowserPermission.d.ts +1 -0
- package/dist/src/devices/InputMediaDeviceManagerState.d.ts +7 -2
- package/dist/src/gen/coordinator/index.d.ts +7 -0
- package/dist/src/types.d.ts +7 -2
- package/package.json +1 -1
- package/src/Call.ts +12 -10
- package/src/__tests__/Call.autodrop.test.ts +101 -0
- package/src/devices/BrowserPermission.ts +4 -0
- package/src/devices/InputMediaDeviceManager.ts +135 -123
- package/src/devices/InputMediaDeviceManagerState.ts +12 -2
- package/src/devices/__tests__/mocks.ts +1 -0
- package/src/events/__tests__/call.test.ts +5 -1
- package/src/events/call.ts +8 -4
- package/src/events/internal.ts +1 -1
- package/src/gen/coordinator/index.ts +7 -0
- package/src/stats/SfuStatsReporter.ts +4 -4
- package/src/store/stateStore.ts +1 -1
- package/src/types.ts +8 -2
package/dist/src/Call.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/src/types.d.ts
CHANGED
|
@@ -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
|
|
186
|
+
* This will be sent as the `reason` field in the `call.rejected` event.
|
|
186
187
|
*/
|
|
187
|
-
reason?:
|
|
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
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({
|
|
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(
|
|
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({
|
|
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({
|
|
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
|
+
});
|
|
@@ -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
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
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
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
(
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
.
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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).
|
|
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 () => {
|