@stream-io/video-client 1.60.0 → 1.61.1
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 +17 -0
- package/dist/index.browser.es.js +264 -57
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +264 -56
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.es.js +264 -57
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +11 -5
- package/dist/src/rtc/Publisher.d.ts +18 -0
- package/dist/src/rtc/TransceiverCache.d.ts +10 -0
- package/dist/src/rtc/e2ee/E2EEManager.d.ts +10 -1
- package/dist/src/types.d.ts +30 -1
- package/index.ts +1 -0
- package/package.json +1 -1
- package/src/Call.ts +180 -48
- package/src/__tests__/Call.lifecycle.test.ts +479 -9
- package/src/__tests__/Call.ringingLifecycle.test.ts +242 -0
- package/src/reporting/ClientEventReporter.ts +7 -2
- package/src/reporting/__tests__/ClientEventReporter.test.ts +2 -2
- package/src/rtc/BasePeerConnection.ts +6 -1
- package/src/rtc/Publisher.ts +66 -12
- package/src/rtc/TransceiverCache.ts +17 -0
- package/src/rtc/__tests__/Publisher.test.ts +259 -0
- package/src/rtc/__tests__/mocks/webrtc.mocks.ts +1 -0
- package/src/rtc/e2ee/E2EEManager.ts +10 -1
- package/src/rtc/e2ee/EncryptionManager.ts +1 -1
- package/src/rtc/e2ee/SPEC.md +1 -1
- package/src/types.ts +37 -1
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment happy-dom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import '../rtc/__tests__/mocks/webrtc.mocks';
|
|
6
|
+
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
8
|
+
import { Call } from '../Call';
|
|
9
|
+
import { CallingState } from '../store';
|
|
10
|
+
import { StreamClient } from '../coordinator/connection/client';
|
|
11
|
+
import { ClientEventReporter } from '../reporting';
|
|
12
|
+
import { generateUUIDv4 } from '../coordinator/connection/utils';
|
|
13
|
+
import { StreamVideoWriteableStateStore } from '../store';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The core's whole part in the React Native ringing lifecycle: await RN's
|
|
17
|
+
* preparation inside the join, tell RN when a ringing join failed terminally or
|
|
18
|
+
* the call ended, and stop a join that a `leave()` overtook. What preparation
|
|
19
|
+
* and release actually do is RN's, and is tested there.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const createCall = (ringing: boolean) => {
|
|
23
|
+
const streamClient = new StreamClient('abc');
|
|
24
|
+
return new Call({
|
|
25
|
+
type: 'test',
|
|
26
|
+
id: generateUUIDv4(),
|
|
27
|
+
ringing,
|
|
28
|
+
streamClient,
|
|
29
|
+
clientEventReporter: new ClientEventReporter({
|
|
30
|
+
streamClient,
|
|
31
|
+
enabled: false,
|
|
32
|
+
}),
|
|
33
|
+
clientStore: new StreamVideoWriteableStateStore(),
|
|
34
|
+
});
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const install = (overrides: Record<string, unknown> = {}) => {
|
|
38
|
+
const beforeJoin = vi.fn(() => Promise.resolve());
|
|
39
|
+
const onJoinFailed = vi.fn(() => Promise.resolve());
|
|
40
|
+
const onLeave = vi.fn();
|
|
41
|
+
const callingX = { joinCall: vi.fn(), endCall: vi.fn() };
|
|
42
|
+
globalThis.streamRNVideoSDK = {
|
|
43
|
+
ringingCallLifecycle: { beforeJoin, onJoinFailed, onLeave },
|
|
44
|
+
callingX,
|
|
45
|
+
// `leave()` reaches this unconditionally; the globals object is all-or-nothing
|
|
46
|
+
callManager: { setup: vi.fn(), start: vi.fn(), stop: vi.fn() },
|
|
47
|
+
...overrides,
|
|
48
|
+
} as any;
|
|
49
|
+
return { beforeJoin, onJoinFailed, onLeave, callingX };
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
describe('ringing call lifecycle integration', () => {
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
globalThis.streamRNVideoSDK = undefined;
|
|
55
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
|
56
|
+
headers: { get: () => 'AMS1-P2' },
|
|
57
|
+
} as Response);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
globalThis.streamRNVideoSDK = undefined;
|
|
62
|
+
vi.restoreAllMocks();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('prepares a ringing call before it joins', async () => {
|
|
66
|
+
const { beforeJoin, callingX } = install();
|
|
67
|
+
const call = createCall(true);
|
|
68
|
+
const doJoin = vi.spyOn(call as any, 'doJoin').mockResolvedValue(undefined);
|
|
69
|
+
|
|
70
|
+
await call.join();
|
|
71
|
+
|
|
72
|
+
expect(beforeJoin).toHaveBeenCalledTimes(1);
|
|
73
|
+
expect(beforeJoin).toHaveBeenCalledBefore(callingX.joinCall);
|
|
74
|
+
expect(doJoin).toHaveBeenCalledTimes(1);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('leaves ordinary calls to join themselves', async () => {
|
|
78
|
+
const { beforeJoin, onJoinFailed } = install();
|
|
79
|
+
const call = createCall(false);
|
|
80
|
+
vi.spyOn(call as any, 'doJoin').mockResolvedValue(undefined);
|
|
81
|
+
|
|
82
|
+
await call.join();
|
|
83
|
+
|
|
84
|
+
expect(beforeJoin).not.toHaveBeenCalled();
|
|
85
|
+
expect(onJoinFailed).not.toHaveBeenCalled();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('does not join when preparation fails, and reports the failure', async () => {
|
|
89
|
+
const { beforeJoin, onJoinFailed } = install();
|
|
90
|
+
beforeJoin.mockRejectedValue(new Error('no key'));
|
|
91
|
+
const call = createCall(true);
|
|
92
|
+
const doJoin = vi.spyOn(call as any, 'doJoin').mockResolvedValue(undefined);
|
|
93
|
+
|
|
94
|
+
await expect(call.join()).rejects.toThrow('no key');
|
|
95
|
+
|
|
96
|
+
expect(doJoin).not.toHaveBeenCalled();
|
|
97
|
+
expect(onJoinFailed).toHaveBeenCalledWith(call);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('reports a terminal join failure, keeping the original error', async () => {
|
|
101
|
+
const { onJoinFailed } = install();
|
|
102
|
+
const call = createCall(true);
|
|
103
|
+
vi.spyOn(call as any, 'doJoin').mockRejectedValue(new Error('sfu down'));
|
|
104
|
+
|
|
105
|
+
await expect(call.join({ maxJoinRetries: 1 })).rejects.toThrow('sfu down');
|
|
106
|
+
|
|
107
|
+
expect(onJoinFailed).toHaveBeenCalledWith(call);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('refuses a duplicate join on a live call without tearing it down', async () => {
|
|
111
|
+
const { beforeJoin, onJoinFailed, onLeave } = install();
|
|
112
|
+
const call = createCall(true);
|
|
113
|
+
vi.spyOn(call as any, 'doJoin').mockImplementation(async () => {
|
|
114
|
+
call.state.setCallingState(CallingState.JOINED);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
await call.join();
|
|
118
|
+
await expect(call.join()).rejects.toThrow('Illegal State');
|
|
119
|
+
|
|
120
|
+
expect(beforeJoin).toHaveBeenCalledTimes(1);
|
|
121
|
+
// the guard sits ahead of the failure boundary, so nothing is released
|
|
122
|
+
expect(onJoinFailed).not.toHaveBeenCalled();
|
|
123
|
+
expect(onLeave).not.toHaveBeenCalled();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('tells the owner when a ringing call ends', async () => {
|
|
127
|
+
const { onLeave } = install();
|
|
128
|
+
const call = createCall(true);
|
|
129
|
+
|
|
130
|
+
await call.leave();
|
|
131
|
+
|
|
132
|
+
expect(onLeave).toHaveBeenCalledWith(call);
|
|
133
|
+
expect(call.state.callingState).toBe(CallingState.LEFT);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('does not tell the owner about an ordinary call', async () => {
|
|
137
|
+
const { onLeave } = install();
|
|
138
|
+
|
|
139
|
+
await createCall(false).leave();
|
|
140
|
+
|
|
141
|
+
expect(onLeave).not.toHaveBeenCalled();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('keeps an app-owned manager across leave, as setE2EEManager promises', async () => {
|
|
145
|
+
install();
|
|
146
|
+
const call = createCall(true);
|
|
147
|
+
const manager = { encrypt: vi.fn(), decrypt: vi.fn() } as any;
|
|
148
|
+
call.setE2EEManager(manager);
|
|
149
|
+
|
|
150
|
+
await call.leave();
|
|
151
|
+
|
|
152
|
+
expect(call.e2eeManager).toBe(manager);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('abandons a join that a leave overtook during native registration', async () => {
|
|
156
|
+
let finishRegistration: () => void = () => {};
|
|
157
|
+
const { callingX, onLeave, onJoinFailed } = install();
|
|
158
|
+
callingX.joinCall.mockImplementation(
|
|
159
|
+
() => new Promise<void>((resolve) => (finishRegistration = resolve)),
|
|
160
|
+
);
|
|
161
|
+
const call = createCall(true);
|
|
162
|
+
const doJoin = vi.spyOn(call as any, 'doJoin').mockResolvedValue(undefined);
|
|
163
|
+
|
|
164
|
+
const joining = call.join();
|
|
165
|
+
await vi.waitFor(() => expect(callingX.joinCall).toHaveBeenCalled());
|
|
166
|
+
await call.leave({ reject: false });
|
|
167
|
+
finishRegistration();
|
|
168
|
+
|
|
169
|
+
// A superseding leave is not a join failure: the join settles quietly rather
|
|
170
|
+
// than rejecting, and the error path stays shut.
|
|
171
|
+
await expect(joining).resolves.toBeUndefined();
|
|
172
|
+
expect(onJoinFailed).not.toHaveBeenCalled();
|
|
173
|
+
expect(callingX.endCall).not.toHaveBeenCalledWith(call, 'error');
|
|
174
|
+
// Release still happens - `leave()` itself fires it, which is why the join
|
|
175
|
+
// does not need to.
|
|
176
|
+
expect(onLeave).toHaveBeenCalledTimes(1);
|
|
177
|
+
// `doJoin` captures the generation itself, so this check has to be here
|
|
178
|
+
expect(doJoin).not.toHaveBeenCalled();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('does not resume the retry loop after a leave during backoff', async () => {
|
|
182
|
+
vi.useFakeTimers();
|
|
183
|
+
try {
|
|
184
|
+
install();
|
|
185
|
+
const call = createCall(true);
|
|
186
|
+
const doJoin = vi
|
|
187
|
+
.spyOn(call as any, 'doJoin')
|
|
188
|
+
.mockRejectedValue(new Error('recoverable'));
|
|
189
|
+
|
|
190
|
+
const joining = call.join().catch(() => {});
|
|
191
|
+
await vi.waitFor(() => expect(doJoin).toHaveBeenCalledTimes(1));
|
|
192
|
+
await call.leave({ reject: false });
|
|
193
|
+
await vi.advanceTimersByTimeAsync(30_000);
|
|
194
|
+
await joining;
|
|
195
|
+
|
|
196
|
+
expect(doJoin).toHaveBeenCalledTimes(1);
|
|
197
|
+
} finally {
|
|
198
|
+
vi.useRealTimers();
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('prepares an outgoing call that this join makes ringing', async () => {
|
|
203
|
+
const { beforeJoin } = install();
|
|
204
|
+
// a fresh instance, not ringing until its first join says so
|
|
205
|
+
const call = createCall(false);
|
|
206
|
+
vi.spyOn(call as any, 'doJoin').mockResolvedValue(undefined);
|
|
207
|
+
|
|
208
|
+
await call.join({ ring: true });
|
|
209
|
+
|
|
210
|
+
// reading `call.ringing` before applying `options.ring` would skip
|
|
211
|
+
// preparation entirely on this path
|
|
212
|
+
expect(beforeJoin).toHaveBeenCalledTimes(1);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('stops a join that a leave overtook during preparation', async () => {
|
|
216
|
+
let releaseHook: () => void = () => {};
|
|
217
|
+
const { beforeJoin, onLeave, onJoinFailed } = install();
|
|
218
|
+
beforeJoin.mockImplementation(
|
|
219
|
+
() => new Promise<void>((resolve) => (releaseHook = resolve)),
|
|
220
|
+
);
|
|
221
|
+
const call = createCall(true);
|
|
222
|
+
const doJoin = vi.spyOn(call as any, 'doJoin').mockResolvedValue(undefined);
|
|
223
|
+
|
|
224
|
+
const joining = call.join();
|
|
225
|
+
await vi.waitFor(() => expect(beforeJoin).toHaveBeenCalled());
|
|
226
|
+
await call.leave({ reject: false });
|
|
227
|
+
releaseHook();
|
|
228
|
+
|
|
229
|
+
await expect(joining).resolves.toBeUndefined();
|
|
230
|
+
expect(onJoinFailed).not.toHaveBeenCalled();
|
|
231
|
+
// Whatever `beforeJoin` installed is released exactly once, by the `leave()`
|
|
232
|
+
// that overtook the join rather than by the join itself.
|
|
233
|
+
expect(onLeave).toHaveBeenCalledTimes(1);
|
|
234
|
+
expect(doJoin).not.toHaveBeenCalled();
|
|
235
|
+
expect(call.state.callingState).toBe(CallingState.LEFT);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('is inert when no React Native globals are registered', async () => {
|
|
239
|
+
const call = createCall(true);
|
|
240
|
+
await expect(call.leave()).resolves.not.toThrow();
|
|
241
|
+
});
|
|
242
|
+
});
|
|
@@ -597,11 +597,16 @@ export class ClientEventReporter {
|
|
|
597
597
|
}
|
|
598
598
|
|
|
599
599
|
if (event.state === 'failed') {
|
|
600
|
+
const iceConnected =
|
|
601
|
+
event.iceConnectionState === 'connected' ||
|
|
602
|
+
event.iceConnectionState === 'completed';
|
|
600
603
|
this.emitPeerConnectionFailure(
|
|
601
604
|
cid,
|
|
602
605
|
role,
|
|
603
|
-
'DTLS_CONNECTIVITY_FAILED',
|
|
604
|
-
|
|
606
|
+
iceConnected ? 'DTLS_CONNECTIVITY_FAILED' : 'ICE_CONNECTIVITY_FAILED',
|
|
607
|
+
iceConnected
|
|
608
|
+
? 'DTLS connectivity checks failed'
|
|
609
|
+
: 'ICE connectivity checks failed',
|
|
605
610
|
);
|
|
606
611
|
return;
|
|
607
612
|
}
|
|
@@ -755,7 +755,7 @@ describe('ClientEventReporter', () => {
|
|
|
755
755
|
});
|
|
756
756
|
});
|
|
757
757
|
|
|
758
|
-
it('reports
|
|
758
|
+
it('reports an ICE failure when the peer connection fails before ICE connected', async () => {
|
|
759
759
|
reporter.startCorrelation(cid, 'first-attempt');
|
|
760
760
|
reportPeerConnectionState(
|
|
761
761
|
PeerType.PUBLISHER_UNSPECIFIED,
|
|
@@ -779,7 +779,7 @@ describe('ClientEventReporter', () => {
|
|
|
779
779
|
expect(completed[0]).toMatchObject({
|
|
780
780
|
outcome: 'failure',
|
|
781
781
|
peer_connection: 'publish',
|
|
782
|
-
retry_failure_code: '
|
|
782
|
+
retry_failure_code: 'ICE_CONNECTIVITY_FAILED',
|
|
783
783
|
ice_state: 'NOT_CONNECTED',
|
|
784
784
|
});
|
|
785
785
|
});
|
|
@@ -344,9 +344,14 @@ export abstract class BasePeerConnection {
|
|
|
344
344
|
|
|
345
345
|
// we can't recover from a failed connection state (contrary to ICE)
|
|
346
346
|
if (state === 'failed') {
|
|
347
|
+
const reason = this.iceHasEverConnected
|
|
348
|
+
? ReconnectReason.CONNECTION_FAILED
|
|
349
|
+
: ReconnectReason.ICE_NEVER_CONNECTED;
|
|
350
|
+
clearTimeout(this.preConnectStuckTimeout);
|
|
351
|
+
this.preConnectStuckTimeout = undefined;
|
|
347
352
|
this.onReconnectionNeeded?.(
|
|
348
353
|
WebsocketReconnectStrategy.REJOIN,
|
|
349
|
-
|
|
354
|
+
reason,
|
|
350
355
|
this.peerType,
|
|
351
356
|
);
|
|
352
357
|
return;
|
package/src/rtc/Publisher.ts
CHANGED
|
@@ -150,7 +150,9 @@ export class Publisher extends BasePeerConnection {
|
|
|
150
150
|
const sendEncodings = isSvcCodec(publishOption.codec?.name)
|
|
151
151
|
? toSvcEncodings(encodings)
|
|
152
152
|
: encodings;
|
|
153
|
-
|
|
153
|
+
// An E2EE sender must never have media before its transform is attached,
|
|
154
|
+
// including when another track or an ICE restart negotiates this PC.
|
|
155
|
+
const transceiver = this.pc.addTransceiver(this.e2ee ? track.kind : track, {
|
|
154
156
|
direction: 'sendonly',
|
|
155
157
|
sendEncodings,
|
|
156
158
|
});
|
|
@@ -161,23 +163,75 @@ export class Publisher extends BasePeerConnection {
|
|
|
161
163
|
this.trackIdToTrackType.set(track.id, trackType);
|
|
162
164
|
this.logger.debug(`Added ${TrackType[trackType]} transceiver`);
|
|
163
165
|
|
|
164
|
-
const params = transceiver.sender.getParameters();
|
|
165
|
-
params.degradationPreference =
|
|
166
|
-
toRTCDegradationPreference(publishOption.degradationPreference) ??
|
|
167
|
-
'maintain-framerate';
|
|
168
|
-
await transceiver.sender.setParameters(params);
|
|
169
166
|
if (this.e2ee) {
|
|
170
|
-
this.
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
TrackType[publishOption.trackType],
|
|
174
|
-
);
|
|
175
|
-
this.logger.debug('E2EE encryptor attached to sender');
|
|
167
|
+
await this.initEncryptedSender(transceiver, track, publishOption);
|
|
168
|
+
} else {
|
|
169
|
+
await this.setDegradationPreference(transceiver.sender, publishOption);
|
|
176
170
|
}
|
|
177
171
|
|
|
178
172
|
await this.negotiate();
|
|
179
173
|
};
|
|
180
174
|
|
|
175
|
+
/**
|
|
176
|
+
* Attaches the encryptor, applies sender parameters, then attaches the track.
|
|
177
|
+
*
|
|
178
|
+
* `encrypt` must run before the first await: Chrome short-circuits a sender
|
|
179
|
+
* whose encoded streams aren't created in the task that created it, and the
|
|
180
|
+
* track goes on last so the sender has no media until both steps succeed.
|
|
181
|
+
*/
|
|
182
|
+
private initEncryptedSender = async (
|
|
183
|
+
transceiver: RTCRtpTransceiver,
|
|
184
|
+
track: MediaStreamTrack,
|
|
185
|
+
publishOption: PublishOption,
|
|
186
|
+
) => {
|
|
187
|
+
try {
|
|
188
|
+
const { sender } = transceiver;
|
|
189
|
+
const codec = publishOption.codec?.name.toLowerCase();
|
|
190
|
+
this.e2ee!.encrypt(sender, codec, TrackType[publishOption.trackType]);
|
|
191
|
+
this.logger.debug('E2EE encryptor attached to sender');
|
|
192
|
+
await this.setDegradationPreference(sender, publishOption);
|
|
193
|
+
await sender.replaceTrack(track);
|
|
194
|
+
} catch (err) {
|
|
195
|
+
this.retireTransceiver(transceiver, track);
|
|
196
|
+
throw err;
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
private setDegradationPreference = async (
|
|
201
|
+
sender: RTCRtpSender,
|
|
202
|
+
publishOption: PublishOption,
|
|
203
|
+
) => {
|
|
204
|
+
const params = sender.getParameters();
|
|
205
|
+
params.degradationPreference =
|
|
206
|
+
toRTCDegradationPreference(publishOption.degradationPreference) ??
|
|
207
|
+
'maintain-framerate';
|
|
208
|
+
await sender.setParameters(params);
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Discards a sender whose E2EE initialization failed.
|
|
213
|
+
*
|
|
214
|
+
* A partially initialized sender may carry no transform, so publish() must
|
|
215
|
+
* not reuse it: retiring it from the cache is what keeps a later publish or
|
|
216
|
+
* ICE restart from putting cleartext on the wire. Cleanup cannot throw, so
|
|
217
|
+
* the initialization failure is what reaches the caller.
|
|
218
|
+
*/
|
|
219
|
+
private retireTransceiver = (
|
|
220
|
+
transceiver: RTCRtpTransceiver,
|
|
221
|
+
track: MediaStreamTrack,
|
|
222
|
+
) => {
|
|
223
|
+
this.transceiverCache.remove(transceiver);
|
|
224
|
+
this.trackIdToTrackType.delete(track.id);
|
|
225
|
+
// on React Native a clone shares its native source, so stopping it here
|
|
226
|
+
// would stop the track the caller passed in. dispose() releases it.
|
|
227
|
+
if (!isReactNative()) this.stopTrack(track);
|
|
228
|
+
try {
|
|
229
|
+
transceiver.stop();
|
|
230
|
+
} catch (err) {
|
|
231
|
+
this.logger.debug('Failed to stop a retired transceiver', err);
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
|
|
181
235
|
/**
|
|
182
236
|
* Updates the transceiver with the given track and track type.
|
|
183
237
|
*/
|
|
@@ -21,6 +21,23 @@ export class TransceiverCache {
|
|
|
21
21
|
this.transceiverOrder.push(bundle.transceiver);
|
|
22
22
|
};
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Retires a failed sender from the reusable cache.
|
|
26
|
+
*
|
|
27
|
+
* A transceiver that never reached an m-section also leaves the creation
|
|
28
|
+
* history: once stopped, it is left out of every later offer, so counting
|
|
29
|
+
* it would shift the `mid` index of every transceiver added after it. One
|
|
30
|
+
* that is already associated keeps its slot, since its m-section survives
|
|
31
|
+
* as a rejected one.
|
|
32
|
+
*/
|
|
33
|
+
remove = (transceiver: RTCRtpTransceiver) => {
|
|
34
|
+
const index = this.cache.findIndex((b) => b.transceiver === transceiver);
|
|
35
|
+
if (index !== -1) this.cache.splice(index, 1);
|
|
36
|
+
if (transceiver.mid) return;
|
|
37
|
+
const orderIndex = this.transceiverOrder.indexOf(transceiver);
|
|
38
|
+
if (orderIndex !== -1) this.transceiverOrder.splice(orderIndex, 1);
|
|
39
|
+
};
|
|
40
|
+
|
|
24
41
|
/**
|
|
25
42
|
* Gets the transceiver for the given publish option.
|
|
26
43
|
*/
|