@edge-base/web 0.2.9 → 0.3.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/dist/auth.d.ts +6 -0
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +9 -1
- package/dist/auth.js.map +1 -1
- package/dist/client.js +1 -1
- package/dist/client.js.map +1 -1
- package/dist/database-live.js +2 -2
- package/dist/token-manager.d.ts +4 -0
- package/dist/token-manager.d.ts.map +1 -1
- package/dist/token-manager.js +88 -13
- package/dist/token-manager.js.map +1 -1
- package/package.json +2 -2
- package/dist/room-cloudflare-media.d.ts +0 -116
- package/dist/room-cloudflare-media.d.ts.map +0 -1
- package/dist/room-cloudflare-media.js +0 -517
- package/dist/room-cloudflare-media.js.map +0 -1
- package/dist/room-p2p-media.d.ts +0 -217
- package/dist/room-p2p-media.d.ts.map +0 -1
- package/dist/room-p2p-media.js +0 -2066
- package/dist/room-p2p-media.js.map +0 -1
- package/dist/room-realtime-media.d.ts +0 -96
- package/dist/room-realtime-media.d.ts.map +0 -1
- package/dist/room-realtime-media.js +0 -417
- package/dist/room-realtime-media.js.map +0 -1
package/dist/room-p2p-media.js
DELETED
|
@@ -1,2066 +0,0 @@
|
|
|
1
|
-
import { EdgeBaseError } from '@edge-base/core';
|
|
2
|
-
import { createSubscription } from './room.js';
|
|
3
|
-
const DEFAULT_SIGNAL_PREFIX = 'edgebase.media.p2p';
|
|
4
|
-
const DEFAULT_ICE_SERVERS = [
|
|
5
|
-
{ urls: 'stun:stun.l.google.com:19302' },
|
|
6
|
-
];
|
|
7
|
-
const DEFAULT_MEMBER_READY_TIMEOUT_MS = 10_000;
|
|
8
|
-
const DEFAULT_MISSING_MEDIA_GRACE_MS = 1_200;
|
|
9
|
-
const DEFAULT_DISCONNECTED_RECOVERY_DELAY_MS = 1_800;
|
|
10
|
-
const DEFAULT_MAX_RECOVERY_ATTEMPTS = 2;
|
|
11
|
-
const DEFAULT_ICE_BATCH_DELAY_MS = 40;
|
|
12
|
-
const DEFAULT_RATE_LIMIT_RETRY_DELAYS_MS = [160, 320, 640];
|
|
13
|
-
const DEFAULT_MEDIA_HEALTH_CHECK_INTERVAL_MS = 4_000;
|
|
14
|
-
const DEFAULT_VIDEO_FLOW_GRACE_MS = 8_000;
|
|
15
|
-
const DEFAULT_VIDEO_FLOW_STALL_GRACE_MS = 12_000;
|
|
16
|
-
const DEFAULT_INITIAL_NEGOTIATION_GRACE_MS = 5_000;
|
|
17
|
-
const DEFAULT_STUCK_SIGNALING_GRACE_MS = 2_500;
|
|
18
|
-
const DEFAULT_NEGOTIATION_QUEUE_SPACING_MS = 180;
|
|
19
|
-
const DEFAULT_SYNC_REMOVAL_GRACE_MS = 9_000;
|
|
20
|
-
const DEFAULT_TRACK_REMOVAL_GRACE_MS = 2_600;
|
|
21
|
-
const DEFAULT_PENDING_VIDEO_PROMOTION_GRACE_MS = 900;
|
|
22
|
-
function buildTrackKey(memberId, trackId) {
|
|
23
|
-
return `${memberId}:${trackId}`;
|
|
24
|
-
}
|
|
25
|
-
function isMediaStreamTrackLike(value) {
|
|
26
|
-
return Boolean(value
|
|
27
|
-
&& typeof value === 'object'
|
|
28
|
-
&& 'id' in value
|
|
29
|
-
&& 'kind' in value
|
|
30
|
-
&& 'readyState' in value);
|
|
31
|
-
}
|
|
32
|
-
function buildExactDeviceConstraint(deviceId) {
|
|
33
|
-
return { deviceId: { exact: deviceId } };
|
|
34
|
-
}
|
|
35
|
-
function normalizeTrackKind(track) {
|
|
36
|
-
if (track.kind === 'audio')
|
|
37
|
-
return 'audio';
|
|
38
|
-
if (track.kind === 'video')
|
|
39
|
-
return 'video';
|
|
40
|
-
return null;
|
|
41
|
-
}
|
|
42
|
-
function serializeDescription(description) {
|
|
43
|
-
return {
|
|
44
|
-
type: description.type,
|
|
45
|
-
sdp: description.sdp ?? undefined,
|
|
46
|
-
};
|
|
47
|
-
}
|
|
48
|
-
function serializeCandidate(candidate) {
|
|
49
|
-
if ('toJSON' in candidate && typeof candidate.toJSON === 'function') {
|
|
50
|
-
return candidate.toJSON();
|
|
51
|
-
}
|
|
52
|
-
return candidate;
|
|
53
|
-
}
|
|
54
|
-
function normalizeIceServerUrls(urls) {
|
|
55
|
-
if (Array.isArray(urls)) {
|
|
56
|
-
return urls.filter((value) => typeof value === 'string' && value.trim().length > 0);
|
|
57
|
-
}
|
|
58
|
-
if (typeof urls === 'string' && urls.trim().length > 0) {
|
|
59
|
-
return [urls];
|
|
60
|
-
}
|
|
61
|
-
return [];
|
|
62
|
-
}
|
|
63
|
-
function normalizeIceServers(iceServers) {
|
|
64
|
-
if (!Array.isArray(iceServers)) {
|
|
65
|
-
return [];
|
|
66
|
-
}
|
|
67
|
-
const normalized = [];
|
|
68
|
-
for (const server of iceServers) {
|
|
69
|
-
const urls = normalizeIceServerUrls(server?.urls);
|
|
70
|
-
if (urls.length === 0) {
|
|
71
|
-
continue;
|
|
72
|
-
}
|
|
73
|
-
normalized.push({
|
|
74
|
-
urls: urls.length === 1 ? urls[0] : urls,
|
|
75
|
-
username: typeof server.username === 'string' ? server.username : undefined,
|
|
76
|
-
credential: typeof server.credential === 'string' ? server.credential : undefined,
|
|
77
|
-
});
|
|
78
|
-
}
|
|
79
|
-
return normalized;
|
|
80
|
-
}
|
|
81
|
-
function getPublishedKindsFromState(state) {
|
|
82
|
-
if (!state) {
|
|
83
|
-
return [];
|
|
84
|
-
}
|
|
85
|
-
const publishedKinds = [];
|
|
86
|
-
if (state.audio?.published)
|
|
87
|
-
publishedKinds.push('audio');
|
|
88
|
-
if (state.video?.published)
|
|
89
|
-
publishedKinds.push('video');
|
|
90
|
-
if (state.screen?.published)
|
|
91
|
-
publishedKinds.push('screen');
|
|
92
|
-
return publishedKinds;
|
|
93
|
-
}
|
|
94
|
-
function isStableAnswerError(error) {
|
|
95
|
-
const message = typeof error === 'object' && error && 'message' in error
|
|
96
|
-
? String(error.message ?? '')
|
|
97
|
-
: '';
|
|
98
|
-
return (message.includes('Called in wrong state: stable')
|
|
99
|
-
|| message.includes('Failed to set remote answer sdp')
|
|
100
|
-
|| (message.includes('setRemoteDescription') && message.includes('stable')));
|
|
101
|
-
}
|
|
102
|
-
function isRateLimitError(error) {
|
|
103
|
-
const message = typeof error === 'object' && error && 'message' in error
|
|
104
|
-
? String(error.message ?? '')
|
|
105
|
-
: String(error ?? '');
|
|
106
|
-
return message.toLowerCase().includes('rate limited');
|
|
107
|
-
}
|
|
108
|
-
function sameIceServer(candidate, urls) {
|
|
109
|
-
const candidateUrls = normalizeIceServerUrls(candidate.urls);
|
|
110
|
-
return candidateUrls.length === urls.length && candidateUrls.every((url, index) => url === urls[index]);
|
|
111
|
-
}
|
|
112
|
-
function getErrorMessage(error) {
|
|
113
|
-
if (error instanceof Error && typeof error.message === 'string' && error.message.length > 0) {
|
|
114
|
-
return error.message;
|
|
115
|
-
}
|
|
116
|
-
return 'Unknown room media error.';
|
|
117
|
-
}
|
|
118
|
-
export class RoomP2PMediaTransport {
|
|
119
|
-
room;
|
|
120
|
-
options;
|
|
121
|
-
localTracks = new Map();
|
|
122
|
-
peers = new Map();
|
|
123
|
-
remoteTrackHandlers = [];
|
|
124
|
-
remoteVideoStateHandlers = [];
|
|
125
|
-
remoteTrackKinds = new Map();
|
|
126
|
-
emittedRemoteTracks = new Set();
|
|
127
|
-
pendingRemoteTracks = new Map();
|
|
128
|
-
pendingTrackRemovalTimers = new Map();
|
|
129
|
-
pendingSyncRemovalTimers = new Map();
|
|
130
|
-
pendingVideoPromotionTimers = new Map();
|
|
131
|
-
pendingIceCandidates = new Map();
|
|
132
|
-
remoteVideoStreamCache = new Map();
|
|
133
|
-
subscriptions = [];
|
|
134
|
-
localMemberId = null;
|
|
135
|
-
connected = false;
|
|
136
|
-
iceServersResolved = false;
|
|
137
|
-
localUpdateBatchDepth = 0;
|
|
138
|
-
syncAllPeerSendersScheduled = false;
|
|
139
|
-
syncAllPeerSendersPending = false;
|
|
140
|
-
healthCheckTimer = null;
|
|
141
|
-
negotiationTail = Promise.resolve();
|
|
142
|
-
remoteVideoStateSignature = '';
|
|
143
|
-
debugEvents = [];
|
|
144
|
-
debugEventCounter = 0;
|
|
145
|
-
constructor(room, options) {
|
|
146
|
-
this.room = room;
|
|
147
|
-
this.options = {
|
|
148
|
-
rtcConfiguration: {
|
|
149
|
-
...options?.rtcConfiguration,
|
|
150
|
-
iceServers: options?.rtcConfiguration?.iceServers && options.rtcConfiguration.iceServers.length > 0
|
|
151
|
-
? options.rtcConfiguration.iceServers
|
|
152
|
-
: DEFAULT_ICE_SERVERS,
|
|
153
|
-
},
|
|
154
|
-
peerConnectionFactory: options?.peerConnectionFactory
|
|
155
|
-
?? ((configuration) => new RTCPeerConnection(configuration)),
|
|
156
|
-
mediaDevices: options?.mediaDevices
|
|
157
|
-
?? (typeof navigator !== 'undefined' ? navigator.mediaDevices : undefined),
|
|
158
|
-
signalPrefix: options?.signalPrefix ?? DEFAULT_SIGNAL_PREFIX,
|
|
159
|
-
turnCredentialTtlSeconds: options?.turnCredentialTtlSeconds ?? 3600,
|
|
160
|
-
missingMediaGraceMs: options?.missingMediaGraceMs ?? DEFAULT_MISSING_MEDIA_GRACE_MS,
|
|
161
|
-
disconnectedRecoveryDelayMs: options?.disconnectedRecoveryDelayMs ?? DEFAULT_DISCONNECTED_RECOVERY_DELAY_MS,
|
|
162
|
-
maxRecoveryAttempts: options?.maxRecoveryAttempts ?? DEFAULT_MAX_RECOVERY_ATTEMPTS,
|
|
163
|
-
mediaHealthCheckIntervalMs: options?.mediaHealthCheckIntervalMs ?? DEFAULT_MEDIA_HEALTH_CHECK_INTERVAL_MS,
|
|
164
|
-
videoFlowGraceMs: options?.videoFlowGraceMs ?? DEFAULT_VIDEO_FLOW_GRACE_MS,
|
|
165
|
-
videoFlowStallGraceMs: options?.videoFlowStallGraceMs ?? DEFAULT_VIDEO_FLOW_STALL_GRACE_MS,
|
|
166
|
-
initialNegotiationGraceMs: options?.initialNegotiationGraceMs ?? DEFAULT_INITIAL_NEGOTIATION_GRACE_MS,
|
|
167
|
-
stuckSignalingGraceMs: options?.stuckSignalingGraceMs ?? DEFAULT_STUCK_SIGNALING_GRACE_MS,
|
|
168
|
-
negotiationQueueSpacingMs: options?.negotiationQueueSpacingMs ?? DEFAULT_NEGOTIATION_QUEUE_SPACING_MS,
|
|
169
|
-
syncRemovalGraceMs: options?.syncRemovalGraceMs ?? DEFAULT_SYNC_REMOVAL_GRACE_MS,
|
|
170
|
-
trackRemovalGraceMs: options?.trackRemovalGraceMs ?? DEFAULT_TRACK_REMOVAL_GRACE_MS,
|
|
171
|
-
pendingVideoPromotionGraceMs: options?.pendingVideoPromotionGraceMs ?? DEFAULT_PENDING_VIDEO_PROMOTION_GRACE_MS,
|
|
172
|
-
};
|
|
173
|
-
}
|
|
174
|
-
getSessionId() {
|
|
175
|
-
return this.localMemberId;
|
|
176
|
-
}
|
|
177
|
-
getPeerConnection() {
|
|
178
|
-
if (this.peers.size !== 1) {
|
|
179
|
-
return null;
|
|
180
|
-
}
|
|
181
|
-
return this.peers.values().next().value?.pc ?? null;
|
|
182
|
-
}
|
|
183
|
-
async connect(payload) {
|
|
184
|
-
if (this.connected && this.localMemberId) {
|
|
185
|
-
return this.localMemberId;
|
|
186
|
-
}
|
|
187
|
-
this.recordDebugEvent('transport:connect');
|
|
188
|
-
if (payload && typeof payload === 'object' && 'sessionDescription' in payload) {
|
|
189
|
-
throw new Error('RoomP2PMediaTransport.connect() does not accept sessionDescription; use room.signals through the built-in transport instead.');
|
|
190
|
-
}
|
|
191
|
-
const capabilities = await this.collectCapabilities({ includeProviderChecks: false });
|
|
192
|
-
const fatalIssue = capabilities.issues.find((issue) => issue.fatal);
|
|
193
|
-
if (fatalIssue) {
|
|
194
|
-
const error = new EdgeBaseError(400, fatalIssue.message, { preflight: { code: fatalIssue.code, message: fatalIssue.message } }, 'room-media-preflight-failed');
|
|
195
|
-
Object.assign(error, {
|
|
196
|
-
provider: capabilities.provider,
|
|
197
|
-
issue: fatalIssue,
|
|
198
|
-
capabilities,
|
|
199
|
-
});
|
|
200
|
-
throw error;
|
|
201
|
-
}
|
|
202
|
-
const currentMember = await this.waitForCurrentMember();
|
|
203
|
-
if (!currentMember) {
|
|
204
|
-
throw new Error('Join the room before connecting a P2P media transport.');
|
|
205
|
-
}
|
|
206
|
-
this.localMemberId = currentMember.memberId;
|
|
207
|
-
await this.resolveRtcConfiguration();
|
|
208
|
-
this.connected = true;
|
|
209
|
-
this.hydrateRemoteTrackKinds();
|
|
210
|
-
this.attachRoomSubscriptions();
|
|
211
|
-
this.startHealthChecks();
|
|
212
|
-
try {
|
|
213
|
-
for (const member of this.room.members.list()) {
|
|
214
|
-
if (member.memberId !== this.localMemberId) {
|
|
215
|
-
this.ensurePeer(member.memberId);
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
this.emitRemoteVideoStateChange(true);
|
|
219
|
-
}
|
|
220
|
-
catch (error) {
|
|
221
|
-
this.rollbackConnectedState();
|
|
222
|
-
throw error;
|
|
223
|
-
}
|
|
224
|
-
return this.localMemberId;
|
|
225
|
-
}
|
|
226
|
-
async getCapabilities() {
|
|
227
|
-
return this.collectCapabilities({ includeProviderChecks: true });
|
|
228
|
-
}
|
|
229
|
-
getUsableRemoteVideoStream(memberId) {
|
|
230
|
-
const now = Date.now();
|
|
231
|
-
const peer = this.peers.get(memberId);
|
|
232
|
-
const connectedish = peer
|
|
233
|
-
? peer.pc.connectionState === 'connected'
|
|
234
|
-
|| peer.pc.iceConnectionState === 'connected'
|
|
235
|
-
|| peer.pc.iceConnectionState === 'completed'
|
|
236
|
-
: false;
|
|
237
|
-
const mediaMembers = this.room.media.list?.() ?? [];
|
|
238
|
-
const mediaMember = mediaMembers.find((entry) => entry.member.memberId === memberId);
|
|
239
|
-
const publishedKinds = this.getPublishedVideoLikeKinds(memberId);
|
|
240
|
-
const stillPublished = publishedKinds.length > 0
|
|
241
|
-
|| Boolean(mediaMember?.state?.video?.published || mediaMember?.state?.screen?.published)
|
|
242
|
-
|| Boolean(mediaMember?.tracks.some((track) => (track.kind === 'video' || track.kind === 'screen') && track.trackId));
|
|
243
|
-
const flow = peer
|
|
244
|
-
? Array.from(peer.remoteVideoFlows.values())
|
|
245
|
-
.filter((entry) => isMediaStreamTrackLike(entry.track) && entry.track.readyState === 'live')
|
|
246
|
-
.sort((a, b) => Number((b.lastHealthyAt ?? 0) > 0) - Number((a.lastHealthyAt ?? 0) > 0)
|
|
247
|
-
|| (b.lastHealthyAt ?? 0) - (a.lastHealthyAt ?? 0)
|
|
248
|
-
|| (b.receivedAt ?? 0) - (a.receivedAt ?? 0))[0] ?? null
|
|
249
|
-
: null;
|
|
250
|
-
const track = flow?.track;
|
|
251
|
-
const graceMs = Math.max(this.options.videoFlowGraceMs, this.options.videoFlowStallGraceMs);
|
|
252
|
-
const connectedTrackGraceMs = Math.max(graceMs, this.options.videoFlowStallGraceMs + 6_000);
|
|
253
|
-
const lastObservedAt = Math.max(flow?.receivedAt ?? 0, flow?.lastHealthyAt ?? 0);
|
|
254
|
-
const isRecentLiveFlow = isMediaStreamTrackLike(track)
|
|
255
|
-
&& track.readyState === 'live'
|
|
256
|
-
&& now - (flow?.receivedAt ?? 0) <= graceMs;
|
|
257
|
-
const isLiveConnectedFlow = isMediaStreamTrackLike(track)
|
|
258
|
-
&& track.readyState === 'live'
|
|
259
|
-
&& connectedish
|
|
260
|
-
&& stillPublished
|
|
261
|
-
&& lastObservedAt > 0
|
|
262
|
-
&& now - lastObservedAt <= connectedTrackGraceMs;
|
|
263
|
-
const isHealthyFlow = isMediaStreamTrackLike(track)
|
|
264
|
-
&& track.readyState === 'live'
|
|
265
|
-
&& (((flow?.lastHealthyAt ?? 0) > 0) || track.muted === false || isRecentLiveFlow || isLiveConnectedFlow);
|
|
266
|
-
const cached = this.remoteVideoStreamCache.get(memberId);
|
|
267
|
-
if (!isHealthyFlow || !isMediaStreamTrackLike(track)) {
|
|
268
|
-
const pending = this.getPendingRemoteVideoTrack(memberId);
|
|
269
|
-
if (pending) {
|
|
270
|
-
this.remoteVideoStreamCache.set(memberId, {
|
|
271
|
-
trackId: pending.track.id,
|
|
272
|
-
stream: pending.stream,
|
|
273
|
-
lastUsableAt: now,
|
|
274
|
-
});
|
|
275
|
-
return pending.stream;
|
|
276
|
-
}
|
|
277
|
-
if (cached) {
|
|
278
|
-
const cachedTrack = cached.stream.getVideoTracks?.()[0]
|
|
279
|
-
?? cached.stream.getTracks?.()[0]
|
|
280
|
-
?? null;
|
|
281
|
-
const cachedTrackStillLive = isMediaStreamTrackLike(cachedTrack)
|
|
282
|
-
? cachedTrack.readyState === 'live'
|
|
283
|
-
: true;
|
|
284
|
-
if (cachedTrackStillLive && now - cached.lastUsableAt <= graceMs) {
|
|
285
|
-
return cached.stream;
|
|
286
|
-
}
|
|
287
|
-
if (cachedTrackStillLive && connectedish && stillPublished && now - cached.lastUsableAt <= connectedTrackGraceMs) {
|
|
288
|
-
return cached.stream;
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
this.remoteVideoStreamCache.delete(memberId);
|
|
292
|
-
return null;
|
|
293
|
-
}
|
|
294
|
-
if (cached?.trackId === track.id) {
|
|
295
|
-
cached.lastUsableAt = now;
|
|
296
|
-
return cached.stream;
|
|
297
|
-
}
|
|
298
|
-
const stream = new MediaStream([track]);
|
|
299
|
-
this.remoteVideoStreamCache.set(memberId, {
|
|
300
|
-
trackId: track.id,
|
|
301
|
-
stream,
|
|
302
|
-
lastUsableAt: now,
|
|
303
|
-
});
|
|
304
|
-
return stream;
|
|
305
|
-
}
|
|
306
|
-
getUsableRemoteVideoEntries() {
|
|
307
|
-
const candidateIds = new Set();
|
|
308
|
-
for (const memberId of this.peers.keys())
|
|
309
|
-
candidateIds.add(memberId);
|
|
310
|
-
for (const pending of this.pendingRemoteTracks.values()) {
|
|
311
|
-
if (pending?.memberId)
|
|
312
|
-
candidateIds.add(pending.memberId);
|
|
313
|
-
}
|
|
314
|
-
for (const memberId of this.remoteVideoStreamCache.keys())
|
|
315
|
-
candidateIds.add(memberId);
|
|
316
|
-
for (const mediaMember of this.room.media.list?.() ?? []) {
|
|
317
|
-
if (mediaMember?.member?.memberId)
|
|
318
|
-
candidateIds.add(mediaMember.member.memberId);
|
|
319
|
-
}
|
|
320
|
-
const mediaMembers = this.room.media.list?.() ?? [];
|
|
321
|
-
return Array.from(candidateIds).map((memberId) => {
|
|
322
|
-
const stream = this.getUsableRemoteVideoStream(memberId);
|
|
323
|
-
const trackId = stream?.getVideoTracks?.()[0]?.id
|
|
324
|
-
?? stream?.getTracks?.()[0]?.id
|
|
325
|
-
?? null;
|
|
326
|
-
const participant = this.findMember(memberId);
|
|
327
|
-
const displayName = typeof participant?.state?.displayName === 'string'
|
|
328
|
-
? participant.state.displayName
|
|
329
|
-
: undefined;
|
|
330
|
-
const published = this.getPublishedVideoLikeKinds(memberId).length > 0
|
|
331
|
-
|| mediaMembers.some((entry) => {
|
|
332
|
-
if (entry?.member?.memberId !== memberId)
|
|
333
|
-
return false;
|
|
334
|
-
return Boolean(entry?.state?.video?.published
|
|
335
|
-
|| entry?.state?.screen?.published
|
|
336
|
-
|| entry?.tracks?.some((track) => (track.kind === 'video' || track.kind === 'screen') && track.trackId));
|
|
337
|
-
});
|
|
338
|
-
return {
|
|
339
|
-
memberId,
|
|
340
|
-
userId: participant?.userId,
|
|
341
|
-
displayName,
|
|
342
|
-
stream,
|
|
343
|
-
trackId,
|
|
344
|
-
published,
|
|
345
|
-
isCameraOff: !(published || stream instanceof MediaStream),
|
|
346
|
-
};
|
|
347
|
-
});
|
|
348
|
-
}
|
|
349
|
-
getRemoteVideoStates() {
|
|
350
|
-
const now = Date.now();
|
|
351
|
-
return this.getUsableRemoteVideoEntries().map((entry) => ({
|
|
352
|
-
participantId: entry.memberId,
|
|
353
|
-
updatedAt: now,
|
|
354
|
-
...entry,
|
|
355
|
-
}));
|
|
356
|
-
}
|
|
357
|
-
getActiveRemoteMemberIds() {
|
|
358
|
-
return this.getRemoteVideoStates()
|
|
359
|
-
.filter((entry) => entry.stream instanceof MediaStream || entry.published)
|
|
360
|
-
.map((entry) => entry.memberId);
|
|
361
|
-
}
|
|
362
|
-
async collectCapabilities(options) {
|
|
363
|
-
const issues = [];
|
|
364
|
-
const currentMember = this.room.members.current();
|
|
365
|
-
const roomIssueFatal = !currentMember;
|
|
366
|
-
let room = {
|
|
367
|
-
ok: true,
|
|
368
|
-
type: 'room_connect_ready',
|
|
369
|
-
category: 'ready',
|
|
370
|
-
message: 'Room WebSocket preflight passed',
|
|
371
|
-
};
|
|
372
|
-
if (typeof this.room.checkConnection === 'function') {
|
|
373
|
-
try {
|
|
374
|
-
room = await this.room.checkConnection();
|
|
375
|
-
}
|
|
376
|
-
catch (error) {
|
|
377
|
-
issues.push({
|
|
378
|
-
code: 'room_connect_check_failed',
|
|
379
|
-
category: 'room',
|
|
380
|
-
message: `Room connect-check failed: ${getErrorMessage(error)}`,
|
|
381
|
-
fatal: roomIssueFatal,
|
|
382
|
-
});
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
if (!room.ok) {
|
|
386
|
-
issues.push({
|
|
387
|
-
code: room.type,
|
|
388
|
-
category: 'room',
|
|
389
|
-
message: room.message,
|
|
390
|
-
fatal: roomIssueFatal,
|
|
391
|
-
});
|
|
392
|
-
}
|
|
393
|
-
if (!currentMember) {
|
|
394
|
-
issues.push({
|
|
395
|
-
code: 'room_member_not_joined',
|
|
396
|
-
category: 'room',
|
|
397
|
-
message: 'Join the room before connecting a P2P media transport.',
|
|
398
|
-
fatal: true,
|
|
399
|
-
});
|
|
400
|
-
}
|
|
401
|
-
const browser = {
|
|
402
|
-
mediaDevices: !!this.options.mediaDevices,
|
|
403
|
-
getUserMedia: typeof this.options.mediaDevices?.getUserMedia === 'function',
|
|
404
|
-
getDisplayMedia: typeof this.options.mediaDevices?.getDisplayMedia === 'function',
|
|
405
|
-
enumerateDevices: typeof this.options.mediaDevices?.enumerateDevices === 'function',
|
|
406
|
-
rtcPeerConnection: typeof this.options.peerConnectionFactory === 'function'
|
|
407
|
-
|| typeof RTCPeerConnection !== 'undefined',
|
|
408
|
-
};
|
|
409
|
-
if (!browser.rtcPeerConnection) {
|
|
410
|
-
issues.push({
|
|
411
|
-
code: 'webrtc_unavailable',
|
|
412
|
-
category: 'browser',
|
|
413
|
-
message: 'RTCPeerConnection is not available in this environment.',
|
|
414
|
-
fatal: true,
|
|
415
|
-
});
|
|
416
|
-
}
|
|
417
|
-
if (!browser.getUserMedia) {
|
|
418
|
-
issues.push({
|
|
419
|
-
code: 'media_devices_get_user_media_unavailable',
|
|
420
|
-
category: 'browser',
|
|
421
|
-
message: 'getUserMedia() is not available; local audio/video capture will be unavailable.',
|
|
422
|
-
fatal: false,
|
|
423
|
-
});
|
|
424
|
-
}
|
|
425
|
-
if (!browser.getDisplayMedia) {
|
|
426
|
-
issues.push({
|
|
427
|
-
code: 'media_devices_get_display_media_unavailable',
|
|
428
|
-
category: 'browser',
|
|
429
|
-
message: 'getDisplayMedia() is not available; screen sharing will be unavailable.',
|
|
430
|
-
fatal: false,
|
|
431
|
-
});
|
|
432
|
-
}
|
|
433
|
-
let turn;
|
|
434
|
-
const loadIceServers = this.room.media.realtime?.iceServers;
|
|
435
|
-
if (options.includeProviderChecks && typeof loadIceServers === 'function') {
|
|
436
|
-
turn = {
|
|
437
|
-
requested: true,
|
|
438
|
-
available: false,
|
|
439
|
-
iceServerCount: 0,
|
|
440
|
-
};
|
|
441
|
-
try {
|
|
442
|
-
const response = await loadIceServers({ ttl: this.options.turnCredentialTtlSeconds });
|
|
443
|
-
const servers = normalizeIceServers(response?.iceServers);
|
|
444
|
-
turn.available = servers.length > 0;
|
|
445
|
-
turn.iceServerCount = servers.length;
|
|
446
|
-
if (!turn.available) {
|
|
447
|
-
issues.push({
|
|
448
|
-
code: 'turn_credentials_unavailable',
|
|
449
|
-
category: 'provider',
|
|
450
|
-
message: 'No TURN credentials were returned; the transport will fall back to its configured ICE servers.',
|
|
451
|
-
fatal: false,
|
|
452
|
-
});
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
catch (error) {
|
|
456
|
-
turn.error = getErrorMessage(error);
|
|
457
|
-
issues.push({
|
|
458
|
-
code: 'turn_credentials_failed',
|
|
459
|
-
category: 'provider',
|
|
460
|
-
message: `Failed to resolve TURN credentials: ${turn.error}`,
|
|
461
|
-
fatal: false,
|
|
462
|
-
});
|
|
463
|
-
}
|
|
464
|
-
}
|
|
465
|
-
return {
|
|
466
|
-
provider: 'p2p',
|
|
467
|
-
canConnect: !issues.some((issue) => issue.fatal),
|
|
468
|
-
issues,
|
|
469
|
-
room,
|
|
470
|
-
joined: !!currentMember,
|
|
471
|
-
currentMemberId: currentMember?.memberId ?? null,
|
|
472
|
-
sessionId: this.getSessionId(),
|
|
473
|
-
browser,
|
|
474
|
-
turn,
|
|
475
|
-
};
|
|
476
|
-
}
|
|
477
|
-
async waitForCurrentMember(timeoutMs = DEFAULT_MEMBER_READY_TIMEOUT_MS) {
|
|
478
|
-
const startedAt = Date.now();
|
|
479
|
-
while (Date.now() - startedAt < timeoutMs) {
|
|
480
|
-
const member = this.room.members.current();
|
|
481
|
-
if (member) {
|
|
482
|
-
return member;
|
|
483
|
-
}
|
|
484
|
-
await new Promise((resolve) => globalThis.setTimeout(resolve, 50));
|
|
485
|
-
}
|
|
486
|
-
return this.room.members.current();
|
|
487
|
-
}
|
|
488
|
-
async resolveRtcConfiguration() {
|
|
489
|
-
if (this.iceServersResolved) {
|
|
490
|
-
return;
|
|
491
|
-
}
|
|
492
|
-
const loadIceServers = this.room.media.realtime?.iceServers;
|
|
493
|
-
if (typeof loadIceServers !== 'function') {
|
|
494
|
-
this.iceServersResolved = true;
|
|
495
|
-
return;
|
|
496
|
-
}
|
|
497
|
-
try {
|
|
498
|
-
const response = await loadIceServers({ ttl: this.options.turnCredentialTtlSeconds });
|
|
499
|
-
const realtimeIceServers = normalizeIceServers(response?.iceServers);
|
|
500
|
-
if (realtimeIceServers.length === 0) {
|
|
501
|
-
return;
|
|
502
|
-
}
|
|
503
|
-
const fallbackIceServers = normalizeIceServers(DEFAULT_ICE_SERVERS);
|
|
504
|
-
const mergedIceServers = [
|
|
505
|
-
...realtimeIceServers,
|
|
506
|
-
...fallbackIceServers.filter((server) => {
|
|
507
|
-
const urls = normalizeIceServerUrls(server.urls);
|
|
508
|
-
return !realtimeIceServers.some((candidate) => sameIceServer(candidate, urls));
|
|
509
|
-
}),
|
|
510
|
-
];
|
|
511
|
-
this.options.rtcConfiguration = {
|
|
512
|
-
...this.options.rtcConfiguration,
|
|
513
|
-
iceServers: mergedIceServers,
|
|
514
|
-
};
|
|
515
|
-
this.iceServersResolved = true;
|
|
516
|
-
}
|
|
517
|
-
catch (error) {
|
|
518
|
-
console.warn('[RoomP2PMediaTransport] Failed to load TURN / ICE credentials. Falling back to default STUN.', error);
|
|
519
|
-
}
|
|
520
|
-
}
|
|
521
|
-
async enableAudio(constraints = true) {
|
|
522
|
-
const track = await this.createUserMediaTrack('audio', constraints);
|
|
523
|
-
if (!track) {
|
|
524
|
-
throw new Error('P2P transport could not create a local audio track.');
|
|
525
|
-
}
|
|
526
|
-
const providerSessionId = await this.ensureConnectedMemberId();
|
|
527
|
-
this.rememberLocalTrack('audio', track, track.getSettings().deviceId, true);
|
|
528
|
-
await this.withRateLimitRetry('enable audio', () => this.room.media.audio.enable?.({
|
|
529
|
-
trackId: track.id,
|
|
530
|
-
deviceId: track.getSettings().deviceId,
|
|
531
|
-
providerSessionId,
|
|
532
|
-
}) ?? Promise.resolve());
|
|
533
|
-
this.requestSyncAllPeerSenders();
|
|
534
|
-
return track;
|
|
535
|
-
}
|
|
536
|
-
async enableVideo(constraints = true) {
|
|
537
|
-
const track = await this.createUserMediaTrack('video', constraints);
|
|
538
|
-
if (!track) {
|
|
539
|
-
throw new Error('P2P transport could not create a local video track.');
|
|
540
|
-
}
|
|
541
|
-
const providerSessionId = await this.ensureConnectedMemberId();
|
|
542
|
-
this.rememberLocalTrack('video', track, track.getSettings().deviceId, true);
|
|
543
|
-
await this.withRateLimitRetry('enable video', () => this.room.media.video.enable?.({
|
|
544
|
-
trackId: track.id,
|
|
545
|
-
deviceId: track.getSettings().deviceId,
|
|
546
|
-
providerSessionId,
|
|
547
|
-
}) ?? Promise.resolve());
|
|
548
|
-
this.requestSyncAllPeerSenders();
|
|
549
|
-
return track;
|
|
550
|
-
}
|
|
551
|
-
async startScreenShare(constraints = { video: true, audio: false }) {
|
|
552
|
-
const devices = this.options.mediaDevices;
|
|
553
|
-
if (!devices?.getDisplayMedia) {
|
|
554
|
-
throw new Error('Screen sharing is not available in this environment.');
|
|
555
|
-
}
|
|
556
|
-
const stream = await devices.getDisplayMedia(constraints);
|
|
557
|
-
const track = stream.getVideoTracks()[0] ?? null;
|
|
558
|
-
if (!track) {
|
|
559
|
-
throw new Error('P2P transport could not create a screen-share track.');
|
|
560
|
-
}
|
|
561
|
-
track.addEventListener('ended', () => {
|
|
562
|
-
void this.stopScreenShare();
|
|
563
|
-
}, { once: true });
|
|
564
|
-
const providerSessionId = await this.ensureConnectedMemberId();
|
|
565
|
-
this.rememberLocalTrack('screen', track, track.getSettings().deviceId, true);
|
|
566
|
-
await this.withRateLimitRetry('start screen share', () => this.room.media.screen.start?.({
|
|
567
|
-
trackId: track.id,
|
|
568
|
-
deviceId: track.getSettings().deviceId,
|
|
569
|
-
providerSessionId,
|
|
570
|
-
}) ?? Promise.resolve());
|
|
571
|
-
this.requestSyncAllPeerSenders();
|
|
572
|
-
return track;
|
|
573
|
-
}
|
|
574
|
-
async disableAudio() {
|
|
575
|
-
this.releaseLocalTrack('audio');
|
|
576
|
-
this.requestSyncAllPeerSenders();
|
|
577
|
-
await this.withRateLimitRetry('disable audio', () => this.room.media.audio.disable());
|
|
578
|
-
}
|
|
579
|
-
async disableVideo() {
|
|
580
|
-
this.releaseLocalTrack('video');
|
|
581
|
-
this.requestSyncAllPeerSenders();
|
|
582
|
-
await this.withRateLimitRetry('disable video', () => this.room.media.video.disable());
|
|
583
|
-
}
|
|
584
|
-
async stopScreenShare() {
|
|
585
|
-
this.releaseLocalTrack('screen');
|
|
586
|
-
this.requestSyncAllPeerSenders();
|
|
587
|
-
await this.withRateLimitRetry('stop screen share', () => this.room.media.screen.stop());
|
|
588
|
-
}
|
|
589
|
-
async setMuted(kind, muted) {
|
|
590
|
-
const localTrack = this.localTracks.get(kind)?.track;
|
|
591
|
-
if (localTrack) {
|
|
592
|
-
localTrack.enabled = !muted;
|
|
593
|
-
}
|
|
594
|
-
if (kind === 'audio') {
|
|
595
|
-
await this.withRateLimitRetry('set audio muted', () => this.room.media.audio.setMuted?.(muted) ?? Promise.resolve());
|
|
596
|
-
}
|
|
597
|
-
else {
|
|
598
|
-
await this.withRateLimitRetry('set video muted', () => this.room.media.video.setMuted?.(muted) ?? Promise.resolve());
|
|
599
|
-
}
|
|
600
|
-
}
|
|
601
|
-
async batchLocalUpdates(callback) {
|
|
602
|
-
this.localUpdateBatchDepth += 1;
|
|
603
|
-
try {
|
|
604
|
-
return await callback();
|
|
605
|
-
}
|
|
606
|
-
finally {
|
|
607
|
-
this.localUpdateBatchDepth = Math.max(0, this.localUpdateBatchDepth - 1);
|
|
608
|
-
if (this.localUpdateBatchDepth === 0 && this.syncAllPeerSendersPending) {
|
|
609
|
-
this.syncAllPeerSendersPending = false;
|
|
610
|
-
this.requestSyncAllPeerSenders();
|
|
611
|
-
}
|
|
612
|
-
}
|
|
613
|
-
}
|
|
614
|
-
async switchDevices(payload) {
|
|
615
|
-
if (payload.audioInputId && this.localTracks.has('audio')) {
|
|
616
|
-
const nextAudioTrack = await this.createUserMediaTrack('audio', buildExactDeviceConstraint(payload.audioInputId));
|
|
617
|
-
if (nextAudioTrack) {
|
|
618
|
-
this.rememberLocalTrack('audio', nextAudioTrack, payload.audioInputId, true);
|
|
619
|
-
}
|
|
620
|
-
}
|
|
621
|
-
if (payload.videoInputId && this.localTracks.has('video')) {
|
|
622
|
-
const nextVideoTrack = await this.createUserMediaTrack('video', buildExactDeviceConstraint(payload.videoInputId));
|
|
623
|
-
if (nextVideoTrack) {
|
|
624
|
-
this.rememberLocalTrack('video', nextVideoTrack, payload.videoInputId, true);
|
|
625
|
-
}
|
|
626
|
-
}
|
|
627
|
-
this.syncAllPeerSenders();
|
|
628
|
-
await this.room.media.devices.switch(payload);
|
|
629
|
-
}
|
|
630
|
-
onRemoteTrack(handler) {
|
|
631
|
-
this.remoteTrackHandlers.push(handler);
|
|
632
|
-
return createSubscription(() => {
|
|
633
|
-
const index = this.remoteTrackHandlers.indexOf(handler);
|
|
634
|
-
if (index >= 0) {
|
|
635
|
-
this.remoteTrackHandlers.splice(index, 1);
|
|
636
|
-
}
|
|
637
|
-
});
|
|
638
|
-
}
|
|
639
|
-
onRemoteVideoStateChange(handler) {
|
|
640
|
-
this.remoteVideoStateHandlers.push(handler);
|
|
641
|
-
try {
|
|
642
|
-
handler(this.getRemoteVideoStates());
|
|
643
|
-
}
|
|
644
|
-
catch {
|
|
645
|
-
// Ignore eager remote video state handler failures.
|
|
646
|
-
}
|
|
647
|
-
return createSubscription(() => {
|
|
648
|
-
const index = this.remoteVideoStateHandlers.indexOf(handler);
|
|
649
|
-
if (index >= 0) {
|
|
650
|
-
this.remoteVideoStateHandlers.splice(index, 1);
|
|
651
|
-
}
|
|
652
|
-
});
|
|
653
|
-
}
|
|
654
|
-
getDebugSnapshot() {
|
|
655
|
-
return {
|
|
656
|
-
localMemberId: this.localMemberId ?? null,
|
|
657
|
-
connected: Boolean(this.connected),
|
|
658
|
-
iceServersResolved: Boolean(this.iceServersResolved),
|
|
659
|
-
localTracks: Array.from(this.localTracks.entries()).map(([kind, localTrack]) => ({
|
|
660
|
-
kind,
|
|
661
|
-
trackId: localTrack.track?.id ?? null,
|
|
662
|
-
readyState: localTrack.track?.readyState ?? null,
|
|
663
|
-
enabled: localTrack.track?.enabled ?? null,
|
|
664
|
-
})),
|
|
665
|
-
peers: Array.from(this.peers.values()).map((peer) => ({
|
|
666
|
-
memberId: peer.memberId,
|
|
667
|
-
polite: peer.polite,
|
|
668
|
-
makingOffer: peer.makingOffer,
|
|
669
|
-
ignoreOffer: peer.ignoreOffer,
|
|
670
|
-
pendingNegotiation: peer.pendingNegotiation,
|
|
671
|
-
recoveryAttempts: peer.recoveryAttempts,
|
|
672
|
-
signalingState: peer.pc?.signalingState ?? null,
|
|
673
|
-
connectionState: peer.pc?.connectionState ?? null,
|
|
674
|
-
iceConnectionState: peer.pc?.iceConnectionState ?? null,
|
|
675
|
-
senderKinds: Array.from(peer.senders.keys()),
|
|
676
|
-
senderTrackIds: Array.from(peer.senders.values()).map((sender) => sender.track?.id ?? null),
|
|
677
|
-
receiverTrackIds: peer.pc?.getReceivers?.().map((receiver) => receiver.track?.id ?? null) ?? [],
|
|
678
|
-
receiverTrackKinds: peer.pc?.getReceivers?.().map((receiver) => receiver.track?.kind ?? null) ?? [],
|
|
679
|
-
pendingCandidates: peer.pendingCandidates?.length ?? 0,
|
|
680
|
-
remoteVideoFlows: Array.from(peer.remoteVideoFlows.values()).map((flow) => ({
|
|
681
|
-
trackId: flow.track?.id ?? null,
|
|
682
|
-
readyState: flow.track?.readyState ?? null,
|
|
683
|
-
muted: flow.track?.muted ?? null,
|
|
684
|
-
receivedAt: flow.receivedAt ?? null,
|
|
685
|
-
lastHealthyAt: flow.lastHealthyAt ?? null,
|
|
686
|
-
})),
|
|
687
|
-
})),
|
|
688
|
-
pendingRemoteTracks: Array.from(this.pendingRemoteTracks.values()).map((pending) => ({
|
|
689
|
-
memberId: pending.memberId,
|
|
690
|
-
trackId: pending.track?.id ?? null,
|
|
691
|
-
trackKind: pending.track?.kind ?? null,
|
|
692
|
-
readyState: pending.track?.readyState ?? null,
|
|
693
|
-
muted: pending.track?.muted ?? null,
|
|
694
|
-
})),
|
|
695
|
-
remoteTrackKinds: Array.from(this.remoteTrackKinds.entries()),
|
|
696
|
-
emittedRemoteTracks: Array.from(this.emittedRemoteTracks.values()),
|
|
697
|
-
recentEvents: this.debugEvents.slice(-120),
|
|
698
|
-
};
|
|
699
|
-
}
|
|
700
|
-
destroy() {
|
|
701
|
-
this.connected = false;
|
|
702
|
-
this.localMemberId = null;
|
|
703
|
-
if (this.healthCheckTimer != null) {
|
|
704
|
-
globalThis.clearInterval(this.healthCheckTimer);
|
|
705
|
-
this.healthCheckTimer = null;
|
|
706
|
-
}
|
|
707
|
-
for (const subscription of this.subscriptions.splice(0)) {
|
|
708
|
-
subscription.unsubscribe();
|
|
709
|
-
}
|
|
710
|
-
for (const peer of this.peers.values()) {
|
|
711
|
-
this.destroyPeer(peer);
|
|
712
|
-
}
|
|
713
|
-
this.peers.clear();
|
|
714
|
-
for (const kind of Array.from(this.localTracks.keys())) {
|
|
715
|
-
this.releaseLocalTrack(kind);
|
|
716
|
-
}
|
|
717
|
-
for (const pending of this.pendingIceCandidates.values()) {
|
|
718
|
-
if (pending.timer) {
|
|
719
|
-
clearTimeout(pending.timer);
|
|
720
|
-
}
|
|
721
|
-
}
|
|
722
|
-
for (const timer of this.pendingTrackRemovalTimers.values()) {
|
|
723
|
-
globalThis.clearTimeout(timer);
|
|
724
|
-
}
|
|
725
|
-
for (const timer of this.pendingSyncRemovalTimers.values()) {
|
|
726
|
-
globalThis.clearTimeout(timer);
|
|
727
|
-
}
|
|
728
|
-
for (const timer of this.pendingVideoPromotionTimers.values()) {
|
|
729
|
-
globalThis.clearTimeout(timer);
|
|
730
|
-
}
|
|
731
|
-
this.pendingTrackRemovalTimers.clear();
|
|
732
|
-
this.pendingSyncRemovalTimers.clear();
|
|
733
|
-
this.pendingVideoPromotionTimers.clear();
|
|
734
|
-
this.pendingIceCandidates.clear();
|
|
735
|
-
this.remoteTrackKinds.clear();
|
|
736
|
-
this.emittedRemoteTracks.clear();
|
|
737
|
-
this.pendingRemoteTracks.clear();
|
|
738
|
-
this.remoteVideoStreamCache.clear();
|
|
739
|
-
this.emitRemoteVideoStateChange(true);
|
|
740
|
-
}
|
|
741
|
-
attachRoomSubscriptions() {
|
|
742
|
-
if (this.subscriptions.length > 0) {
|
|
743
|
-
return;
|
|
744
|
-
}
|
|
745
|
-
this.subscriptions.push(this.room.members.onJoin((member) => {
|
|
746
|
-
if (member.memberId !== this.localMemberId) {
|
|
747
|
-
this.cancelPendingSyncRemoval(member.memberId);
|
|
748
|
-
this.ensurePeer(member.memberId, { passive: true });
|
|
749
|
-
this.emitRemoteVideoStateChange();
|
|
750
|
-
}
|
|
751
|
-
}), this.room.members.onSync((members) => {
|
|
752
|
-
const activeMemberIds = new Set();
|
|
753
|
-
for (const member of members) {
|
|
754
|
-
if (member.memberId !== this.localMemberId) {
|
|
755
|
-
activeMemberIds.add(member.memberId);
|
|
756
|
-
this.cancelPendingSyncRemoval(member.memberId);
|
|
757
|
-
this.ensurePeer(member.memberId, { passive: true });
|
|
758
|
-
}
|
|
759
|
-
}
|
|
760
|
-
for (const memberId of Array.from(this.peers.keys())) {
|
|
761
|
-
if (!activeMemberIds.has(memberId)) {
|
|
762
|
-
this.scheduleSyncRemoval(memberId);
|
|
763
|
-
}
|
|
764
|
-
}
|
|
765
|
-
this.emitRemoteVideoStateChange();
|
|
766
|
-
}), this.room.members.onLeave((member) => {
|
|
767
|
-
this.cancelPendingSyncRemoval(member.memberId);
|
|
768
|
-
this.removeRemoteMember(member.memberId);
|
|
769
|
-
this.emitRemoteVideoStateChange();
|
|
770
|
-
}), this.room.signals.on(this.offerEvent, (payload, meta) => {
|
|
771
|
-
void this.handleDescriptionSignal('offer', payload, meta);
|
|
772
|
-
}), this.room.signals.on(this.answerEvent, (payload, meta) => {
|
|
773
|
-
void this.handleDescriptionSignal('answer', payload, meta);
|
|
774
|
-
}), this.room.signals.on(this.iceEvent, (payload, meta) => {
|
|
775
|
-
void this.handleIceSignal(payload, meta);
|
|
776
|
-
}), this.room.media.onTrack((track, member) => {
|
|
777
|
-
if (member.memberId !== this.localMemberId) {
|
|
778
|
-
this.ensurePeer(member.memberId, { passive: true });
|
|
779
|
-
this.schedulePeerRecoveryCheck(member.memberId, 'media-track');
|
|
780
|
-
}
|
|
781
|
-
this.rememberRemoteTrackKind(track, member);
|
|
782
|
-
this.emitRemoteVideoStateChange();
|
|
783
|
-
}), this.room.media.onTrackRemoved((track, member) => {
|
|
784
|
-
if (!track.trackId)
|
|
785
|
-
return;
|
|
786
|
-
this.scheduleTrackRemoval(track, member);
|
|
787
|
-
this.emitRemoteVideoStateChange();
|
|
788
|
-
}));
|
|
789
|
-
if (typeof this.room.media.onStateChange === 'function') {
|
|
790
|
-
this.subscriptions.push(this.room.media.onStateChange((member, state) => {
|
|
791
|
-
if (member.memberId === this.localMemberId) {
|
|
792
|
-
return;
|
|
793
|
-
}
|
|
794
|
-
this.ensurePeer(member.memberId, { passive: true });
|
|
795
|
-
this.rememberRemoteTrackKindsFromState(member, state);
|
|
796
|
-
this.schedulePeerRecoveryCheck(member.memberId, 'media-state');
|
|
797
|
-
this.emitRemoteVideoStateChange();
|
|
798
|
-
}));
|
|
799
|
-
}
|
|
800
|
-
}
|
|
801
|
-
hydrateRemoteTrackKinds() {
|
|
802
|
-
this.remoteTrackKinds.clear();
|
|
803
|
-
this.emittedRemoteTracks.clear();
|
|
804
|
-
this.pendingRemoteTracks.clear();
|
|
805
|
-
for (const mediaMember of this.room.media.list()) {
|
|
806
|
-
for (const track of mediaMember.tracks) {
|
|
807
|
-
this.rememberRemoteTrackKind(track, mediaMember.member);
|
|
808
|
-
}
|
|
809
|
-
this.rememberRemoteTrackKindsFromState(mediaMember.member, mediaMember.state);
|
|
810
|
-
}
|
|
811
|
-
}
|
|
812
|
-
rememberRemoteTrackKindsFromState(member, state) {
|
|
813
|
-
if (member.memberId === this.localMemberId || !state) {
|
|
814
|
-
return;
|
|
815
|
-
}
|
|
816
|
-
const mediaKinds = ['audio', 'video', 'screen'];
|
|
817
|
-
for (const kind of mediaKinds) {
|
|
818
|
-
const kindState = state[kind];
|
|
819
|
-
if (!kindState?.published) {
|
|
820
|
-
continue;
|
|
821
|
-
}
|
|
822
|
-
if (typeof kindState.trackId === 'string' && kindState.trackId) {
|
|
823
|
-
this.rememberRemoteTrackKind({
|
|
824
|
-
kind,
|
|
825
|
-
trackId: kindState.trackId,
|
|
826
|
-
muted: kindState.muted === true,
|
|
827
|
-
deviceId: kindState.deviceId,
|
|
828
|
-
publishedAt: kindState.publishedAt,
|
|
829
|
-
adminDisabled: kindState.adminDisabled,
|
|
830
|
-
providerSessionId: kindState.providerSessionId,
|
|
831
|
-
}, member);
|
|
832
|
-
continue;
|
|
833
|
-
}
|
|
834
|
-
this.flushPendingRemoteTracks(member.memberId, kind);
|
|
835
|
-
}
|
|
836
|
-
}
|
|
837
|
-
rememberRemoteTrackKind(track, member) {
|
|
838
|
-
if (!track.trackId || member.memberId === this.localMemberId) {
|
|
839
|
-
return;
|
|
840
|
-
}
|
|
841
|
-
const key = buildTrackKey(member.memberId, track.trackId);
|
|
842
|
-
this.remoteTrackKinds.set(key, track.kind);
|
|
843
|
-
const pending = this.pendingRemoteTracks.get(key);
|
|
844
|
-
if (pending) {
|
|
845
|
-
this.pendingRemoteTracks.delete(key);
|
|
846
|
-
this.clearPendingVideoPromotionTimer(key);
|
|
847
|
-
this.emitRemoteTrack(member.memberId, pending.track, pending.stream, track.kind);
|
|
848
|
-
return;
|
|
849
|
-
}
|
|
850
|
-
this.flushPendingRemoteTracks(member.memberId, track.kind);
|
|
851
|
-
}
|
|
852
|
-
ensurePeer(memberId, options) {
|
|
853
|
-
const passive = options?.passive === true;
|
|
854
|
-
const existing = this.peers.get(memberId);
|
|
855
|
-
if (existing) {
|
|
856
|
-
if (!passive) {
|
|
857
|
-
existing.bootstrapPassive = false;
|
|
858
|
-
this.syncPeerSenders(existing);
|
|
859
|
-
}
|
|
860
|
-
return existing;
|
|
861
|
-
}
|
|
862
|
-
const pc = this.options.peerConnectionFactory(this.options.rtcConfiguration);
|
|
863
|
-
const peer = {
|
|
864
|
-
memberId,
|
|
865
|
-
pc,
|
|
866
|
-
polite: !!this.localMemberId && this.localMemberId.localeCompare(memberId) > 0,
|
|
867
|
-
bootstrapPassive: passive,
|
|
868
|
-
makingOffer: false,
|
|
869
|
-
ignoreOffer: false,
|
|
870
|
-
isSettingRemoteAnswerPending: false,
|
|
871
|
-
pendingCandidates: [],
|
|
872
|
-
senders: new Map(),
|
|
873
|
-
pendingNegotiation: false,
|
|
874
|
-
recoveryAttempts: 0,
|
|
875
|
-
recoveryTimer: null,
|
|
876
|
-
healthCheckInFlight: false,
|
|
877
|
-
createdAt: Date.now(),
|
|
878
|
-
signalingStateChangedAt: Date.now(),
|
|
879
|
-
hasRemoteDescription: false,
|
|
880
|
-
remoteVideoFlows: new Map(),
|
|
881
|
-
};
|
|
882
|
-
pc.onicecandidate = (event) => {
|
|
883
|
-
if (!event.candidate) {
|
|
884
|
-
void this.flushPendingIceCandidates(memberId);
|
|
885
|
-
return;
|
|
886
|
-
}
|
|
887
|
-
this.queueIceCandidate(memberId, serializeCandidate(event.candidate));
|
|
888
|
-
};
|
|
889
|
-
pc.onnegotiationneeded = () => {
|
|
890
|
-
if (peer.bootstrapPassive && !peer.hasRemoteDescription && peer.pc.signalingState === 'stable') {
|
|
891
|
-
return;
|
|
892
|
-
}
|
|
893
|
-
void this.negotiatePeer(peer);
|
|
894
|
-
};
|
|
895
|
-
pc.onsignalingstatechange = () => {
|
|
896
|
-
peer.signalingStateChangedAt = Date.now();
|
|
897
|
-
this.maybeRetryPendingNegotiation(peer);
|
|
898
|
-
};
|
|
899
|
-
pc.oniceconnectionstatechange = () => {
|
|
900
|
-
this.handlePeerConnectivityChange(peer, 'ice');
|
|
901
|
-
};
|
|
902
|
-
pc.onconnectionstatechange = () => {
|
|
903
|
-
this.handlePeerConnectivityChange(peer, 'connection');
|
|
904
|
-
this.maybeRetryPendingNegotiation(peer);
|
|
905
|
-
};
|
|
906
|
-
pc.ontrack = (event) => {
|
|
907
|
-
const stream = event.streams[0] ?? new MediaStream([event.track]);
|
|
908
|
-
const key = buildTrackKey(memberId, event.track.id);
|
|
909
|
-
const exactKind = this.remoteTrackKinds.get(key);
|
|
910
|
-
const fallbackKind = exactKind ? null : this.resolveFallbackRemoteTrackKind(memberId, event.track);
|
|
911
|
-
const kind = exactKind ?? fallbackKind ?? normalizeTrackKind(event.track);
|
|
912
|
-
if (!kind || (!exactKind && !fallbackKind && kind === 'video' && event.track.kind === 'video')) {
|
|
913
|
-
this.pendingRemoteTracks.set(key, { memberId, track: event.track, stream });
|
|
914
|
-
this.schedulePendingVideoPromotion(memberId, event.track, stream);
|
|
915
|
-
return;
|
|
916
|
-
}
|
|
917
|
-
this.clearPendingVideoPromotionTimer(key);
|
|
918
|
-
this.emitRemoteTrack(memberId, event.track, stream, kind);
|
|
919
|
-
this.registerPeerRemoteTrack(peer, event.track, kind);
|
|
920
|
-
this.resetPeerRecovery(peer);
|
|
921
|
-
};
|
|
922
|
-
this.peers.set(memberId, peer);
|
|
923
|
-
if (!peer.bootstrapPassive) {
|
|
924
|
-
this.syncPeerSenders(peer);
|
|
925
|
-
this.schedulePeerRecoveryCheck(memberId, 'peer-created');
|
|
926
|
-
}
|
|
927
|
-
return peer;
|
|
928
|
-
}
|
|
929
|
-
async negotiatePeer(peer) {
|
|
930
|
-
if (peer.answeringOffer) {
|
|
931
|
-
peer.pendingNegotiation = false;
|
|
932
|
-
return;
|
|
933
|
-
}
|
|
934
|
-
const runNegotiation = async () => {
|
|
935
|
-
if (!this.connected || peer.pc.connectionState === 'closed') {
|
|
936
|
-
return;
|
|
937
|
-
}
|
|
938
|
-
if (peer.makingOffer
|
|
939
|
-
|| peer.isSettingRemoteAnswerPending
|
|
940
|
-
|| peer.pc.signalingState !== 'stable') {
|
|
941
|
-
peer.pendingNegotiation = true;
|
|
942
|
-
return;
|
|
943
|
-
}
|
|
944
|
-
try {
|
|
945
|
-
peer.pendingNegotiation = false;
|
|
946
|
-
peer.makingOffer = true;
|
|
947
|
-
await peer.pc.setLocalDescription();
|
|
948
|
-
const localDescription = peer.pc.localDescription;
|
|
949
|
-
const signalingState = peer.pc.signalingState;
|
|
950
|
-
if (!localDescription) {
|
|
951
|
-
return;
|
|
952
|
-
}
|
|
953
|
-
if (localDescription.type !== 'offer'
|
|
954
|
-
|| signalingState !== 'have-local-offer') {
|
|
955
|
-
return;
|
|
956
|
-
}
|
|
957
|
-
await this.sendSignalWithRetry(peer.memberId, this.offerEvent, {
|
|
958
|
-
description: serializeDescription(localDescription),
|
|
959
|
-
});
|
|
960
|
-
}
|
|
961
|
-
catch (error) {
|
|
962
|
-
console.warn('[RoomP2PMediaTransport] Failed to negotiate peer offer.', {
|
|
963
|
-
memberId: peer.memberId,
|
|
964
|
-
signalingState: peer.pc.signalingState,
|
|
965
|
-
error,
|
|
966
|
-
});
|
|
967
|
-
}
|
|
968
|
-
finally {
|
|
969
|
-
peer.makingOffer = false;
|
|
970
|
-
this.maybeRetryPendingNegotiation(peer);
|
|
971
|
-
}
|
|
972
|
-
};
|
|
973
|
-
const shouldSerializeBootstrap = !peer.hasRemoteDescription
|
|
974
|
-
&& (peer.pc.connectionState === 'new' || peer.pc.connectionState === 'connecting');
|
|
975
|
-
if (!shouldSerializeBootstrap) {
|
|
976
|
-
await runNegotiation();
|
|
977
|
-
return;
|
|
978
|
-
}
|
|
979
|
-
const bootstrapQueue = peer;
|
|
980
|
-
if (bootstrapQueue.bootstrapNegotiationQueued) {
|
|
981
|
-
peer.pendingNegotiation = true;
|
|
982
|
-
return;
|
|
983
|
-
}
|
|
984
|
-
bootstrapQueue.bootstrapNegotiationQueued = true;
|
|
985
|
-
const queuedRun = this.negotiationTail
|
|
986
|
-
.catch(() => { })
|
|
987
|
-
.then(async () => {
|
|
988
|
-
await runNegotiation();
|
|
989
|
-
await new Promise((resolve) => globalThis.setTimeout(resolve, this.options.negotiationQueueSpacingMs));
|
|
990
|
-
})
|
|
991
|
-
.finally(() => {
|
|
992
|
-
bootstrapQueue.bootstrapNegotiationQueued = false;
|
|
993
|
-
});
|
|
994
|
-
this.negotiationTail = queuedRun;
|
|
995
|
-
await queuedRun;
|
|
996
|
-
}
|
|
997
|
-
async handleDescriptionSignal(expectedType, payload, meta) {
|
|
998
|
-
const senderId = typeof meta.memberId === 'string' && meta.memberId.trim() ? meta.memberId.trim() : '';
|
|
999
|
-
if (!senderId || senderId === this.localMemberId) {
|
|
1000
|
-
return;
|
|
1001
|
-
}
|
|
1002
|
-
const description = this.normalizeDescription(payload);
|
|
1003
|
-
if (!description || description.type !== expectedType) {
|
|
1004
|
-
return;
|
|
1005
|
-
}
|
|
1006
|
-
const peer = this.ensurePeer(senderId);
|
|
1007
|
-
const readyForOffer = !peer.makingOffer && (peer.pc.signalingState === 'stable' || peer.isSettingRemoteAnswerPending);
|
|
1008
|
-
const offerCollision = description.type === 'offer' && !readyForOffer;
|
|
1009
|
-
peer.ignoreOffer = !peer.polite && offerCollision;
|
|
1010
|
-
if (peer.ignoreOffer) {
|
|
1011
|
-
return;
|
|
1012
|
-
}
|
|
1013
|
-
try {
|
|
1014
|
-
if (description.type === 'answer'
|
|
1015
|
-
&& peer.pc.signalingState === 'stable'
|
|
1016
|
-
&& !peer.isSettingRemoteAnswerPending) {
|
|
1017
|
-
return;
|
|
1018
|
-
}
|
|
1019
|
-
if (description.type === 'offer'
|
|
1020
|
-
&& offerCollision
|
|
1021
|
-
&& peer.polite
|
|
1022
|
-
&& peer.pc.signalingState !== 'stable') {
|
|
1023
|
-
await peer.pc.setLocalDescription({ type: 'rollback' });
|
|
1024
|
-
}
|
|
1025
|
-
peer.isSettingRemoteAnswerPending = description.type === 'answer';
|
|
1026
|
-
await peer.pc.setRemoteDescription(description);
|
|
1027
|
-
peer.hasRemoteDescription = true;
|
|
1028
|
-
peer.bootstrapPassive = false;
|
|
1029
|
-
peer.isSettingRemoteAnswerPending = false;
|
|
1030
|
-
await this.flushPendingCandidates(peer);
|
|
1031
|
-
if (description.type === 'offer') {
|
|
1032
|
-
peer.answeringOffer = true;
|
|
1033
|
-
try {
|
|
1034
|
-
this.syncPeerSenders(peer);
|
|
1035
|
-
await peer.pc.setLocalDescription();
|
|
1036
|
-
const localDescription = peer.pc.localDescription;
|
|
1037
|
-
if (!localDescription) {
|
|
1038
|
-
return;
|
|
1039
|
-
}
|
|
1040
|
-
if (localDescription.type !== 'answer') {
|
|
1041
|
-
return;
|
|
1042
|
-
}
|
|
1043
|
-
await this.sendSignalWithRetry(senderId, this.answerEvent, {
|
|
1044
|
-
description: serializeDescription(localDescription),
|
|
1045
|
-
});
|
|
1046
|
-
}
|
|
1047
|
-
finally {
|
|
1048
|
-
peer.answeringOffer = false;
|
|
1049
|
-
peer.pendingNegotiation = false;
|
|
1050
|
-
}
|
|
1051
|
-
}
|
|
1052
|
-
}
|
|
1053
|
-
catch (error) {
|
|
1054
|
-
if (description.type === 'answer' && peer.pc.signalingState === 'stable' && isStableAnswerError(error)) {
|
|
1055
|
-
return;
|
|
1056
|
-
}
|
|
1057
|
-
console.warn('[RoomP2PMediaTransport] Failed to apply remote session description.', {
|
|
1058
|
-
memberId: senderId,
|
|
1059
|
-
expectedType,
|
|
1060
|
-
signalingState: peer.pc.signalingState,
|
|
1061
|
-
error,
|
|
1062
|
-
});
|
|
1063
|
-
peer.isSettingRemoteAnswerPending = false;
|
|
1064
|
-
}
|
|
1065
|
-
finally {
|
|
1066
|
-
this.maybeRetryPendingNegotiation(peer);
|
|
1067
|
-
}
|
|
1068
|
-
}
|
|
1069
|
-
async handleIceSignal(payload, meta) {
|
|
1070
|
-
const senderId = typeof meta.memberId === 'string' && meta.memberId.trim() ? meta.memberId.trim() : '';
|
|
1071
|
-
if (!senderId || senderId === this.localMemberId) {
|
|
1072
|
-
return;
|
|
1073
|
-
}
|
|
1074
|
-
const candidates = this.normalizeCandidates(payload);
|
|
1075
|
-
if (candidates.length === 0) {
|
|
1076
|
-
return;
|
|
1077
|
-
}
|
|
1078
|
-
const peer = this.ensurePeer(senderId);
|
|
1079
|
-
if (!peer.pc.remoteDescription) {
|
|
1080
|
-
peer.pendingCandidates.push(...candidates);
|
|
1081
|
-
return;
|
|
1082
|
-
}
|
|
1083
|
-
for (const candidate of candidates) {
|
|
1084
|
-
try {
|
|
1085
|
-
await peer.pc.addIceCandidate(candidate);
|
|
1086
|
-
}
|
|
1087
|
-
catch (error) {
|
|
1088
|
-
console.warn('[RoomP2PMediaTransport] Failed to add ICE candidate.', {
|
|
1089
|
-
memberId: senderId,
|
|
1090
|
-
error,
|
|
1091
|
-
});
|
|
1092
|
-
if (!peer.ignoreOffer) {
|
|
1093
|
-
peer.pendingCandidates.push(candidate);
|
|
1094
|
-
}
|
|
1095
|
-
}
|
|
1096
|
-
}
|
|
1097
|
-
}
|
|
1098
|
-
async flushPendingCandidates(peer) {
|
|
1099
|
-
if (!peer.pc.remoteDescription || peer.pendingCandidates.length === 0) {
|
|
1100
|
-
return;
|
|
1101
|
-
}
|
|
1102
|
-
const pending = [...peer.pendingCandidates];
|
|
1103
|
-
peer.pendingCandidates.length = 0;
|
|
1104
|
-
for (const candidate of pending) {
|
|
1105
|
-
try {
|
|
1106
|
-
await peer.pc.addIceCandidate(candidate);
|
|
1107
|
-
}
|
|
1108
|
-
catch (error) {
|
|
1109
|
-
console.warn('[RoomP2PMediaTransport] Failed to flush pending ICE candidate.', {
|
|
1110
|
-
memberId: peer.memberId,
|
|
1111
|
-
error,
|
|
1112
|
-
});
|
|
1113
|
-
if (!peer.ignoreOffer) {
|
|
1114
|
-
peer.pendingCandidates.push(candidate);
|
|
1115
|
-
}
|
|
1116
|
-
}
|
|
1117
|
-
}
|
|
1118
|
-
}
|
|
1119
|
-
queueIceCandidate(memberId, candidate) {
|
|
1120
|
-
let pending = this.pendingIceCandidates.get(memberId);
|
|
1121
|
-
if (!pending) {
|
|
1122
|
-
pending = {
|
|
1123
|
-
candidates: [],
|
|
1124
|
-
timer: null,
|
|
1125
|
-
flushing: false,
|
|
1126
|
-
};
|
|
1127
|
-
this.pendingIceCandidates.set(memberId, pending);
|
|
1128
|
-
}
|
|
1129
|
-
pending.candidates.push(candidate);
|
|
1130
|
-
if (pending.timer || pending.flushing) {
|
|
1131
|
-
return;
|
|
1132
|
-
}
|
|
1133
|
-
pending.timer = globalThis.setTimeout(() => {
|
|
1134
|
-
pending.timer = null;
|
|
1135
|
-
void this.flushPendingIceCandidates(memberId);
|
|
1136
|
-
}, DEFAULT_ICE_BATCH_DELAY_MS);
|
|
1137
|
-
}
|
|
1138
|
-
async flushPendingIceCandidates(memberId) {
|
|
1139
|
-
const pending = this.pendingIceCandidates.get(memberId);
|
|
1140
|
-
if (!pending || pending.flushing) {
|
|
1141
|
-
return;
|
|
1142
|
-
}
|
|
1143
|
-
if (pending.timer) {
|
|
1144
|
-
clearTimeout(pending.timer);
|
|
1145
|
-
pending.timer = null;
|
|
1146
|
-
}
|
|
1147
|
-
if (pending.candidates.length === 0) {
|
|
1148
|
-
this.pendingIceCandidates.delete(memberId);
|
|
1149
|
-
return;
|
|
1150
|
-
}
|
|
1151
|
-
const batch = pending.candidates.splice(0);
|
|
1152
|
-
pending.flushing = true;
|
|
1153
|
-
try {
|
|
1154
|
-
await this.sendSignalWithRetry(memberId, this.iceEvent, { candidates: batch });
|
|
1155
|
-
}
|
|
1156
|
-
finally {
|
|
1157
|
-
pending.flushing = false;
|
|
1158
|
-
if (pending.candidates.length > 0) {
|
|
1159
|
-
pending.timer = globalThis.setTimeout(() => {
|
|
1160
|
-
pending.timer = null;
|
|
1161
|
-
void this.flushPendingIceCandidates(memberId);
|
|
1162
|
-
}, 0);
|
|
1163
|
-
}
|
|
1164
|
-
else {
|
|
1165
|
-
this.pendingIceCandidates.delete(memberId);
|
|
1166
|
-
}
|
|
1167
|
-
}
|
|
1168
|
-
}
|
|
1169
|
-
requestSyncAllPeerSenders() {
|
|
1170
|
-
if (this.localUpdateBatchDepth > 0) {
|
|
1171
|
-
this.syncAllPeerSendersPending = true;
|
|
1172
|
-
return;
|
|
1173
|
-
}
|
|
1174
|
-
if (this.syncAllPeerSendersScheduled) {
|
|
1175
|
-
this.syncAllPeerSendersPending = true;
|
|
1176
|
-
return;
|
|
1177
|
-
}
|
|
1178
|
-
this.syncAllPeerSendersScheduled = true;
|
|
1179
|
-
queueMicrotask(() => {
|
|
1180
|
-
this.syncAllPeerSendersScheduled = false;
|
|
1181
|
-
this.syncAllPeerSendersPending = false;
|
|
1182
|
-
this.syncAllPeerSenders();
|
|
1183
|
-
});
|
|
1184
|
-
}
|
|
1185
|
-
syncAllPeerSenders() {
|
|
1186
|
-
for (const peer of this.peers.values()) {
|
|
1187
|
-
this.syncPeerSenders(peer);
|
|
1188
|
-
}
|
|
1189
|
-
}
|
|
1190
|
-
syncPeerSenders(peer) {
|
|
1191
|
-
const activeKinds = new Set();
|
|
1192
|
-
let changed = false;
|
|
1193
|
-
for (const [kind, localTrack] of this.localTracks.entries()) {
|
|
1194
|
-
activeKinds.add(kind);
|
|
1195
|
-
const sender = peer.senders.get(kind);
|
|
1196
|
-
if (sender) {
|
|
1197
|
-
if (sender.track !== localTrack.track) {
|
|
1198
|
-
void sender.replaceTrack(localTrack.track);
|
|
1199
|
-
changed = true;
|
|
1200
|
-
}
|
|
1201
|
-
continue;
|
|
1202
|
-
}
|
|
1203
|
-
const addedSender = peer.pc.addTrack(localTrack.track, new MediaStream([localTrack.track]));
|
|
1204
|
-
peer.senders.set(kind, addedSender);
|
|
1205
|
-
changed = true;
|
|
1206
|
-
}
|
|
1207
|
-
for (const [kind, sender] of Array.from(peer.senders.entries())) {
|
|
1208
|
-
if (activeKinds.has(kind)) {
|
|
1209
|
-
continue;
|
|
1210
|
-
}
|
|
1211
|
-
try {
|
|
1212
|
-
peer.pc.removeTrack(sender);
|
|
1213
|
-
}
|
|
1214
|
-
catch {
|
|
1215
|
-
// Ignore duplicate removals during shutdown.
|
|
1216
|
-
}
|
|
1217
|
-
peer.senders.delete(kind);
|
|
1218
|
-
changed = true;
|
|
1219
|
-
}
|
|
1220
|
-
if (changed) {
|
|
1221
|
-
void this.negotiatePeer(peer);
|
|
1222
|
-
}
|
|
1223
|
-
}
|
|
1224
|
-
emitRemoteTrack(memberId, track, stream, kind) {
|
|
1225
|
-
const key = buildTrackKey(memberId, track.id);
|
|
1226
|
-
if (this.emittedRemoteTracks.has(key)) {
|
|
1227
|
-
return;
|
|
1228
|
-
}
|
|
1229
|
-
this.emittedRemoteTracks.add(key);
|
|
1230
|
-
this.remoteTrackKinds.set(key, kind);
|
|
1231
|
-
const participant = this.findMember(memberId);
|
|
1232
|
-
const payload = {
|
|
1233
|
-
kind,
|
|
1234
|
-
track,
|
|
1235
|
-
stream,
|
|
1236
|
-
trackName: track.id,
|
|
1237
|
-
providerSessionId: memberId,
|
|
1238
|
-
memberId,
|
|
1239
|
-
participantId: memberId,
|
|
1240
|
-
userId: participant?.userId,
|
|
1241
|
-
displayName: typeof participant?.state?.displayName === 'string'
|
|
1242
|
-
? participant.state.displayName
|
|
1243
|
-
: undefined,
|
|
1244
|
-
};
|
|
1245
|
-
for (const handler of this.remoteTrackHandlers) {
|
|
1246
|
-
handler(payload);
|
|
1247
|
-
}
|
|
1248
|
-
const peer = this.peers.get(memberId);
|
|
1249
|
-
if (peer) {
|
|
1250
|
-
if (!this.hasMissingPublishedMedia(memberId)) {
|
|
1251
|
-
this.resetPeerRecovery(peer);
|
|
1252
|
-
}
|
|
1253
|
-
else {
|
|
1254
|
-
this.schedulePeerRecoveryCheck(memberId, 'partial-remote-track');
|
|
1255
|
-
}
|
|
1256
|
-
}
|
|
1257
|
-
this.emitRemoteVideoStateChange();
|
|
1258
|
-
}
|
|
1259
|
-
resolveFallbackRemoteTrackKind(memberId, track) {
|
|
1260
|
-
const normalizedKind = normalizeTrackKind(track);
|
|
1261
|
-
if (!normalizedKind) {
|
|
1262
|
-
return null;
|
|
1263
|
-
}
|
|
1264
|
-
if (normalizedKind === 'audio') {
|
|
1265
|
-
return 'audio';
|
|
1266
|
-
}
|
|
1267
|
-
return this.getNextUnassignedPublishedVideoLikeKind(memberId);
|
|
1268
|
-
}
|
|
1269
|
-
flushPendingRemoteTracks(memberId, roomKind) {
|
|
1270
|
-
const expectedTrackKind = roomKind === 'audio' ? 'audio' : 'video';
|
|
1271
|
-
for (const [key, pending] of this.pendingRemoteTracks.entries()) {
|
|
1272
|
-
if (pending.memberId !== memberId || pending.track.kind !== expectedTrackKind) {
|
|
1273
|
-
continue;
|
|
1274
|
-
}
|
|
1275
|
-
this.pendingRemoteTracks.delete(key);
|
|
1276
|
-
this.clearPendingVideoPromotionTimer(key);
|
|
1277
|
-
this.emitRemoteTrack(memberId, pending.track, pending.stream, roomKind);
|
|
1278
|
-
return;
|
|
1279
|
-
}
|
|
1280
|
-
}
|
|
1281
|
-
hasReplacementTrack(memberId, removedTrackId) {
|
|
1282
|
-
const peer = this.peers.get(memberId);
|
|
1283
|
-
const hasLiveTrackedReplacement = Array.from(peer?.remoteVideoFlows?.values() ?? []).some((flow) => {
|
|
1284
|
-
const track = flow?.track;
|
|
1285
|
-
return isMediaStreamTrackLike(track) && track.id !== removedTrackId && track.readyState === 'live';
|
|
1286
|
-
});
|
|
1287
|
-
if (hasLiveTrackedReplacement) {
|
|
1288
|
-
return true;
|
|
1289
|
-
}
|
|
1290
|
-
return Array.from(this.pendingRemoteTracks.values()).some((pending) => pending.memberId === memberId
|
|
1291
|
-
&& pending.track.kind === 'video'
|
|
1292
|
-
&& pending.track.id !== removedTrackId
|
|
1293
|
-
&& pending.track.readyState === 'live');
|
|
1294
|
-
}
|
|
1295
|
-
isRoomTrackStillPublished(memberId, removedTrack) {
|
|
1296
|
-
const mediaMember = this.room.media.list().find((entry) => entry.member.memberId === memberId);
|
|
1297
|
-
if (!mediaMember) {
|
|
1298
|
-
return false;
|
|
1299
|
-
}
|
|
1300
|
-
const kind = removedTrack.kind;
|
|
1301
|
-
if (kind === 'audio' || kind === 'video' || kind === 'screen') {
|
|
1302
|
-
const kindState = mediaMember.state?.[kind];
|
|
1303
|
-
if (kindState?.published) {
|
|
1304
|
-
if (!removedTrack.trackId || kindState.trackId !== removedTrack.trackId) {
|
|
1305
|
-
return true;
|
|
1306
|
-
}
|
|
1307
|
-
}
|
|
1308
|
-
}
|
|
1309
|
-
return mediaMember.tracks.some((track) => track.kind === removedTrack.kind
|
|
1310
|
-
&& Boolean(track.trackId)
|
|
1311
|
-
&& (!removedTrack.trackId || track.trackId !== removedTrack.trackId));
|
|
1312
|
-
}
|
|
1313
|
-
scheduleTrackRemoval(track, member) {
|
|
1314
|
-
if (!track.trackId || !member.memberId) {
|
|
1315
|
-
return;
|
|
1316
|
-
}
|
|
1317
|
-
const key = buildTrackKey(member.memberId, track.trackId);
|
|
1318
|
-
const existingTimer = this.pendingTrackRemovalTimers.get(key);
|
|
1319
|
-
if (existingTimer) {
|
|
1320
|
-
globalThis.clearTimeout(existingTimer);
|
|
1321
|
-
}
|
|
1322
|
-
this.pendingTrackRemovalTimers.set(key, globalThis.setTimeout(() => {
|
|
1323
|
-
this.pendingTrackRemovalTimers.delete(key);
|
|
1324
|
-
const replacementTrack = (track.kind === 'video' || track.kind === 'screen')
|
|
1325
|
-
&& this.hasReplacementTrack(member.memberId, track.trackId);
|
|
1326
|
-
const stillPublished = this.isRoomTrackStillPublished(member.memberId, track);
|
|
1327
|
-
if (replacementTrack || stillPublished) {
|
|
1328
|
-
return;
|
|
1329
|
-
}
|
|
1330
|
-
this.remoteTrackKinds.delete(key);
|
|
1331
|
-
this.emittedRemoteTracks.delete(key);
|
|
1332
|
-
this.pendingRemoteTracks.delete(key);
|
|
1333
|
-
this.clearPendingVideoPromotionTimer(key);
|
|
1334
|
-
this.schedulePeerRecoveryCheck(member.memberId, 'media-track-removed');
|
|
1335
|
-
this.emitRemoteVideoStateChange();
|
|
1336
|
-
}, this.options.trackRemovalGraceMs));
|
|
1337
|
-
}
|
|
1338
|
-
getPublishedVideoLikeKinds(memberId) {
|
|
1339
|
-
const mediaMember = this.room.media.list().find((entry) => entry.member.memberId === memberId);
|
|
1340
|
-
if (!mediaMember) {
|
|
1341
|
-
return [];
|
|
1342
|
-
}
|
|
1343
|
-
const publishedKinds = new Set();
|
|
1344
|
-
for (const track of mediaMember.tracks) {
|
|
1345
|
-
if ((track.kind === 'video' || track.kind === 'screen') && track.trackId) {
|
|
1346
|
-
publishedKinds.add(track.kind);
|
|
1347
|
-
}
|
|
1348
|
-
}
|
|
1349
|
-
for (const kind of getPublishedKindsFromState(mediaMember.state)) {
|
|
1350
|
-
if (kind === 'video' || kind === 'screen') {
|
|
1351
|
-
publishedKinds.add(kind);
|
|
1352
|
-
}
|
|
1353
|
-
}
|
|
1354
|
-
return Array.from(publishedKinds);
|
|
1355
|
-
}
|
|
1356
|
-
getNextUnassignedPublishedVideoLikeKind(memberId) {
|
|
1357
|
-
const publishedKinds = this.getPublishedVideoLikeKinds(memberId);
|
|
1358
|
-
if (publishedKinds.length === 0) {
|
|
1359
|
-
return null;
|
|
1360
|
-
}
|
|
1361
|
-
const assignedKinds = new Set();
|
|
1362
|
-
for (const key of this.emittedRemoteTracks) {
|
|
1363
|
-
if (!key.startsWith(`${memberId}:`)) {
|
|
1364
|
-
continue;
|
|
1365
|
-
}
|
|
1366
|
-
const kind = this.remoteTrackKinds.get(key);
|
|
1367
|
-
if (kind === 'video' || kind === 'screen') {
|
|
1368
|
-
assignedKinds.add(kind);
|
|
1369
|
-
}
|
|
1370
|
-
}
|
|
1371
|
-
return publishedKinds.find((kind) => !assignedKinds.has(kind)) ?? null;
|
|
1372
|
-
}
|
|
1373
|
-
resolveDeferredVideoKind(memberId) {
|
|
1374
|
-
const publishedKinds = this.getPublishedVideoLikeKinds(memberId);
|
|
1375
|
-
const assignedKinds = new Set();
|
|
1376
|
-
for (const key of this.emittedRemoteTracks) {
|
|
1377
|
-
if (!key.startsWith(`${memberId}:`)) {
|
|
1378
|
-
continue;
|
|
1379
|
-
}
|
|
1380
|
-
const kind = this.remoteTrackKinds.get(key);
|
|
1381
|
-
if (kind === 'video' || kind === 'screen') {
|
|
1382
|
-
assignedKinds.add(kind);
|
|
1383
|
-
}
|
|
1384
|
-
}
|
|
1385
|
-
if (publishedKinds.length === 1) {
|
|
1386
|
-
return publishedKinds[0];
|
|
1387
|
-
}
|
|
1388
|
-
if (publishedKinds.length > 1) {
|
|
1389
|
-
if (assignedKinds.size === 1) {
|
|
1390
|
-
const [kind] = Array.from(assignedKinds.values());
|
|
1391
|
-
if (publishedKinds.includes(kind)) {
|
|
1392
|
-
return kind;
|
|
1393
|
-
}
|
|
1394
|
-
}
|
|
1395
|
-
return null;
|
|
1396
|
-
}
|
|
1397
|
-
if (assignedKinds.size === 1) {
|
|
1398
|
-
return Array.from(assignedKinds.values())[0];
|
|
1399
|
-
}
|
|
1400
|
-
return null;
|
|
1401
|
-
}
|
|
1402
|
-
schedulePendingVideoPromotion(memberId, track, stream) {
|
|
1403
|
-
const key = buildTrackKey(memberId, track.id);
|
|
1404
|
-
if (this.pendingVideoPromotionTimers.has(key)) {
|
|
1405
|
-
return;
|
|
1406
|
-
}
|
|
1407
|
-
this.pendingVideoPromotionTimers.set(key, globalThis.setTimeout(() => {
|
|
1408
|
-
this.pendingVideoPromotionTimers.delete(key);
|
|
1409
|
-
const pending = this.pendingRemoteTracks.get(key);
|
|
1410
|
-
if (!pending) {
|
|
1411
|
-
return;
|
|
1412
|
-
}
|
|
1413
|
-
if (!isMediaStreamTrackLike(pending.track) || pending.track.readyState !== 'live') {
|
|
1414
|
-
this.pendingRemoteTracks.delete(key);
|
|
1415
|
-
this.emitRemoteVideoStateChange();
|
|
1416
|
-
return;
|
|
1417
|
-
}
|
|
1418
|
-
const promotedKind = this.resolveDeferredVideoKind(memberId);
|
|
1419
|
-
if (!promotedKind) {
|
|
1420
|
-
return;
|
|
1421
|
-
}
|
|
1422
|
-
const peer = this.peers.get(memberId);
|
|
1423
|
-
this.pendingRemoteTracks.delete(key);
|
|
1424
|
-
this.emitRemoteTrack(memberId, pending.track, pending.stream, promotedKind);
|
|
1425
|
-
if (peer) {
|
|
1426
|
-
this.registerPeerRemoteTrack(peer, pending.track, promotedKind);
|
|
1427
|
-
this.resetPeerRecovery(peer);
|
|
1428
|
-
}
|
|
1429
|
-
this.emitRemoteVideoStateChange();
|
|
1430
|
-
}, this.options.pendingVideoPromotionGraceMs));
|
|
1431
|
-
}
|
|
1432
|
-
clearPendingVideoPromotionTimer(key) {
|
|
1433
|
-
const timer = this.pendingVideoPromotionTimers.get(key);
|
|
1434
|
-
if (!timer) {
|
|
1435
|
-
return;
|
|
1436
|
-
}
|
|
1437
|
-
globalThis.clearTimeout(timer);
|
|
1438
|
-
this.pendingVideoPromotionTimers.delete(key);
|
|
1439
|
-
}
|
|
1440
|
-
closePeer(memberId) {
|
|
1441
|
-
const peer = this.peers.get(memberId);
|
|
1442
|
-
if (!peer)
|
|
1443
|
-
return;
|
|
1444
|
-
this.destroyPeer(peer);
|
|
1445
|
-
this.peers.delete(memberId);
|
|
1446
|
-
}
|
|
1447
|
-
removeRemoteMember(memberId) {
|
|
1448
|
-
this.cancelPendingSyncRemoval(memberId);
|
|
1449
|
-
this.remoteTrackKinds.forEach((_kind, key) => {
|
|
1450
|
-
if (key.startsWith(`${memberId}:`)) {
|
|
1451
|
-
this.remoteTrackKinds.delete(key);
|
|
1452
|
-
const timer = this.pendingTrackRemovalTimers.get(key);
|
|
1453
|
-
if (timer) {
|
|
1454
|
-
globalThis.clearTimeout(timer);
|
|
1455
|
-
this.pendingTrackRemovalTimers.delete(key);
|
|
1456
|
-
}
|
|
1457
|
-
}
|
|
1458
|
-
});
|
|
1459
|
-
this.emittedRemoteTracks.forEach((key) => {
|
|
1460
|
-
if (key.startsWith(`${memberId}:`)) {
|
|
1461
|
-
this.emittedRemoteTracks.delete(key);
|
|
1462
|
-
}
|
|
1463
|
-
});
|
|
1464
|
-
this.pendingRemoteTracks.forEach((_pending, key) => {
|
|
1465
|
-
if (key.startsWith(`${memberId}:`)) {
|
|
1466
|
-
this.pendingRemoteTracks.delete(key);
|
|
1467
|
-
this.clearPendingVideoPromotionTimer(key);
|
|
1468
|
-
}
|
|
1469
|
-
});
|
|
1470
|
-
this.remoteVideoStreamCache.delete(memberId);
|
|
1471
|
-
this.closePeer(memberId);
|
|
1472
|
-
this.emitRemoteVideoStateChange();
|
|
1473
|
-
}
|
|
1474
|
-
scheduleSyncRemoval(memberId) {
|
|
1475
|
-
if (!memberId || memberId === this.localMemberId || this.pendingSyncRemovalTimers.has(memberId)) {
|
|
1476
|
-
return;
|
|
1477
|
-
}
|
|
1478
|
-
this.pendingSyncRemovalTimers.set(memberId, globalThis.setTimeout(() => {
|
|
1479
|
-
this.pendingSyncRemovalTimers.delete(memberId);
|
|
1480
|
-
const stillActive = this.room.members.list().some((member) => member.memberId === memberId);
|
|
1481
|
-
const hasMedia = this.room.media.list().some((entry) => entry.member.memberId === memberId);
|
|
1482
|
-
if (stillActive || hasMedia) {
|
|
1483
|
-
return;
|
|
1484
|
-
}
|
|
1485
|
-
this.removeRemoteMember(memberId);
|
|
1486
|
-
this.emitRemoteVideoStateChange();
|
|
1487
|
-
}, this.options.syncRemovalGraceMs));
|
|
1488
|
-
}
|
|
1489
|
-
cancelPendingSyncRemoval(memberId) {
|
|
1490
|
-
const timer = this.pendingSyncRemovalTimers.get(memberId);
|
|
1491
|
-
if (!timer) {
|
|
1492
|
-
return;
|
|
1493
|
-
}
|
|
1494
|
-
globalThis.clearTimeout(timer);
|
|
1495
|
-
this.pendingSyncRemovalTimers.delete(memberId);
|
|
1496
|
-
}
|
|
1497
|
-
findMember(memberId) {
|
|
1498
|
-
return this.room.members.list().find((member) => member.memberId === memberId);
|
|
1499
|
-
}
|
|
1500
|
-
rollbackConnectedState() {
|
|
1501
|
-
this.connected = false;
|
|
1502
|
-
this.localMemberId = null;
|
|
1503
|
-
if (this.healthCheckTimer != null) {
|
|
1504
|
-
globalThis.clearInterval(this.healthCheckTimer);
|
|
1505
|
-
this.healthCheckTimer = null;
|
|
1506
|
-
}
|
|
1507
|
-
for (const subscription of this.subscriptions.splice(0)) {
|
|
1508
|
-
subscription.unsubscribe();
|
|
1509
|
-
}
|
|
1510
|
-
for (const peer of this.peers.values()) {
|
|
1511
|
-
this.destroyPeer(peer);
|
|
1512
|
-
}
|
|
1513
|
-
this.peers.clear();
|
|
1514
|
-
for (const pending of this.pendingIceCandidates.values()) {
|
|
1515
|
-
if (pending.timer) {
|
|
1516
|
-
clearTimeout(pending.timer);
|
|
1517
|
-
}
|
|
1518
|
-
}
|
|
1519
|
-
for (const timer of this.pendingTrackRemovalTimers.values()) {
|
|
1520
|
-
globalThis.clearTimeout(timer);
|
|
1521
|
-
}
|
|
1522
|
-
for (const timer of this.pendingSyncRemovalTimers.values()) {
|
|
1523
|
-
globalThis.clearTimeout(timer);
|
|
1524
|
-
}
|
|
1525
|
-
for (const timer of this.pendingVideoPromotionTimers.values()) {
|
|
1526
|
-
globalThis.clearTimeout(timer);
|
|
1527
|
-
}
|
|
1528
|
-
this.pendingTrackRemovalTimers.clear();
|
|
1529
|
-
this.pendingSyncRemovalTimers.clear();
|
|
1530
|
-
this.pendingVideoPromotionTimers.clear();
|
|
1531
|
-
this.pendingIceCandidates.clear();
|
|
1532
|
-
this.remoteTrackKinds.clear();
|
|
1533
|
-
this.emittedRemoteTracks.clear();
|
|
1534
|
-
this.pendingRemoteTracks.clear();
|
|
1535
|
-
this.remoteVideoStreamCache.clear();
|
|
1536
|
-
this.emitRemoteVideoStateChange(true);
|
|
1537
|
-
}
|
|
1538
|
-
destroyPeer(peer) {
|
|
1539
|
-
this.clearPeerRecoveryTimer(peer);
|
|
1540
|
-
for (const flow of peer.remoteVideoFlows.values()) {
|
|
1541
|
-
flow.cleanup();
|
|
1542
|
-
}
|
|
1543
|
-
peer.remoteVideoFlows.clear();
|
|
1544
|
-
peer.pc.onicecandidate = null;
|
|
1545
|
-
peer.pc.onnegotiationneeded = null;
|
|
1546
|
-
peer.pc.onsignalingstatechange = null;
|
|
1547
|
-
peer.pc.oniceconnectionstatechange = null;
|
|
1548
|
-
peer.pc.onconnectionstatechange = null;
|
|
1549
|
-
peer.pc.ontrack = null;
|
|
1550
|
-
try {
|
|
1551
|
-
peer.pc.close();
|
|
1552
|
-
}
|
|
1553
|
-
catch {
|
|
1554
|
-
// Ignore duplicate closes.
|
|
1555
|
-
}
|
|
1556
|
-
}
|
|
1557
|
-
startHealthChecks() {
|
|
1558
|
-
if (this.healthCheckTimer != null) {
|
|
1559
|
-
return;
|
|
1560
|
-
}
|
|
1561
|
-
this.healthCheckTimer = globalThis.setInterval(() => {
|
|
1562
|
-
void this.runHealthChecks();
|
|
1563
|
-
}, this.options.mediaHealthCheckIntervalMs);
|
|
1564
|
-
}
|
|
1565
|
-
async runHealthChecks() {
|
|
1566
|
-
if (!this.connected) {
|
|
1567
|
-
return;
|
|
1568
|
-
}
|
|
1569
|
-
for (const peer of this.peers.values()) {
|
|
1570
|
-
if (peer.healthCheckInFlight || peer.pc.connectionState === 'closed') {
|
|
1571
|
-
continue;
|
|
1572
|
-
}
|
|
1573
|
-
peer.healthCheckInFlight = true;
|
|
1574
|
-
try {
|
|
1575
|
-
const issue = await this.inspectPeerVideoHealth(peer);
|
|
1576
|
-
if (issue) {
|
|
1577
|
-
this.schedulePeerRecoveryCheck(peer.memberId, issue, 0);
|
|
1578
|
-
}
|
|
1579
|
-
}
|
|
1580
|
-
finally {
|
|
1581
|
-
peer.healthCheckInFlight = false;
|
|
1582
|
-
}
|
|
1583
|
-
}
|
|
1584
|
-
}
|
|
1585
|
-
registerPeerRemoteTrack(peer, track, kind) {
|
|
1586
|
-
if (kind !== 'video' && kind !== 'screen') {
|
|
1587
|
-
return;
|
|
1588
|
-
}
|
|
1589
|
-
if (peer.remoteVideoFlows.has(track.id)) {
|
|
1590
|
-
return;
|
|
1591
|
-
}
|
|
1592
|
-
const flow = {
|
|
1593
|
-
track,
|
|
1594
|
-
receivedAt: Date.now(),
|
|
1595
|
-
lastHealthyAt: track.muted ? 0 : Date.now(),
|
|
1596
|
-
lastBytesReceived: null,
|
|
1597
|
-
lastFramesDecoded: null,
|
|
1598
|
-
cleanup: () => { },
|
|
1599
|
-
};
|
|
1600
|
-
const markHealthy = () => {
|
|
1601
|
-
flow.lastHealthyAt = Date.now();
|
|
1602
|
-
};
|
|
1603
|
-
const handleEnded = () => {
|
|
1604
|
-
flow.cleanup();
|
|
1605
|
-
peer.remoteVideoFlows.delete(track.id);
|
|
1606
|
-
this.emitRemoteVideoStateChange();
|
|
1607
|
-
};
|
|
1608
|
-
track.addEventListener('unmute', markHealthy);
|
|
1609
|
-
track.addEventListener('ended', handleEnded);
|
|
1610
|
-
flow.cleanup = () => {
|
|
1611
|
-
track.removeEventListener('unmute', markHealthy);
|
|
1612
|
-
track.removeEventListener('ended', handleEnded);
|
|
1613
|
-
};
|
|
1614
|
-
peer.remoteVideoFlows.set(track.id, flow);
|
|
1615
|
-
this.emitRemoteVideoStateChange();
|
|
1616
|
-
}
|
|
1617
|
-
async inspectPeerVideoHealth(peer) {
|
|
1618
|
-
if (this.hasMissingPublishedMedia(peer.memberId)) {
|
|
1619
|
-
return 'health-missing-published-media';
|
|
1620
|
-
}
|
|
1621
|
-
const mediaMember = this.room.media.list().find((entry) => entry.member.memberId === peer.memberId);
|
|
1622
|
-
const publishedVideoState = mediaMember?.state?.video;
|
|
1623
|
-
const publishedScreenState = mediaMember?.state?.screen;
|
|
1624
|
-
const publishedAt = Math.max(publishedVideoState?.publishedAt ?? 0, publishedScreenState?.publishedAt ?? 0);
|
|
1625
|
-
const expectsVideoFlow = Boolean(publishedVideoState?.published
|
|
1626
|
-
|| publishedScreenState?.published
|
|
1627
|
-
|| mediaMember?.tracks.some((track) => (track.kind === 'video' || track.kind === 'screen') && track.trackId));
|
|
1628
|
-
if (!expectsVideoFlow) {
|
|
1629
|
-
return null;
|
|
1630
|
-
}
|
|
1631
|
-
const videoReceivers = peer.pc
|
|
1632
|
-
.getReceivers()
|
|
1633
|
-
.filter((receiver) => receiver.track?.kind === 'video');
|
|
1634
|
-
if (videoReceivers.length === 0) {
|
|
1635
|
-
const firstObservedAt = Math.max(publishedAt, ...Array.from(peer.remoteVideoFlows.values()).map((flow) => flow.receivedAt));
|
|
1636
|
-
if (firstObservedAt > 0 && Date.now() - firstObservedAt > this.options.videoFlowGraceMs) {
|
|
1637
|
-
return 'health-no-video-receiver';
|
|
1638
|
-
}
|
|
1639
|
-
return null;
|
|
1640
|
-
}
|
|
1641
|
-
let sawHealthyFlow = false;
|
|
1642
|
-
let lastObservedAt = publishedAt;
|
|
1643
|
-
for (const receiver of videoReceivers) {
|
|
1644
|
-
const track = receiver.track;
|
|
1645
|
-
if (!track) {
|
|
1646
|
-
continue;
|
|
1647
|
-
}
|
|
1648
|
-
const flow = peer.remoteVideoFlows.get(track.id);
|
|
1649
|
-
if (!flow) {
|
|
1650
|
-
continue;
|
|
1651
|
-
}
|
|
1652
|
-
lastObservedAt = Math.max(lastObservedAt, flow.receivedAt, flow.lastHealthyAt);
|
|
1653
|
-
if (!track.muted) {
|
|
1654
|
-
flow.lastHealthyAt = Math.max(flow.lastHealthyAt, Date.now());
|
|
1655
|
-
}
|
|
1656
|
-
try {
|
|
1657
|
-
const stats = await receiver.getStats();
|
|
1658
|
-
for (const report of stats.values()) {
|
|
1659
|
-
if (report.type !== 'inbound-rtp' || report.kind !== 'video') {
|
|
1660
|
-
continue;
|
|
1661
|
-
}
|
|
1662
|
-
const bytesReceived = typeof report.bytesReceived === 'number' ? report.bytesReceived : null;
|
|
1663
|
-
const framesDecoded = typeof report.framesDecoded === 'number' ? report.framesDecoded : null;
|
|
1664
|
-
const bytesIncreased = bytesReceived != null
|
|
1665
|
-
&& flow.lastBytesReceived != null
|
|
1666
|
-
&& bytesReceived > flow.lastBytesReceived;
|
|
1667
|
-
const framesIncreased = framesDecoded != null
|
|
1668
|
-
&& flow.lastFramesDecoded != null
|
|
1669
|
-
&& framesDecoded > flow.lastFramesDecoded;
|
|
1670
|
-
if ((bytesReceived != null && bytesReceived > 0) || (framesDecoded != null && framesDecoded > 0)) {
|
|
1671
|
-
flow.lastHealthyAt = Math.max(flow.lastHealthyAt, Date.now());
|
|
1672
|
-
}
|
|
1673
|
-
if (bytesIncreased || framesIncreased) {
|
|
1674
|
-
flow.lastHealthyAt = Date.now();
|
|
1675
|
-
}
|
|
1676
|
-
flow.lastBytesReceived = bytesReceived;
|
|
1677
|
-
flow.lastFramesDecoded = framesDecoded;
|
|
1678
|
-
break;
|
|
1679
|
-
}
|
|
1680
|
-
}
|
|
1681
|
-
catch {
|
|
1682
|
-
// Ignore stats read failures and rely on track state.
|
|
1683
|
-
}
|
|
1684
|
-
if (flow.lastHealthyAt > 0) {
|
|
1685
|
-
sawHealthyFlow = true;
|
|
1686
|
-
}
|
|
1687
|
-
lastObservedAt = Math.max(lastObservedAt, flow.lastHealthyAt);
|
|
1688
|
-
}
|
|
1689
|
-
if (sawHealthyFlow) {
|
|
1690
|
-
return null;
|
|
1691
|
-
}
|
|
1692
|
-
if (lastObservedAt > 0 && Date.now() - lastObservedAt > this.options.videoFlowStallGraceMs) {
|
|
1693
|
-
return 'health-stalled-video-flow';
|
|
1694
|
-
}
|
|
1695
|
-
if (publishedAt > 0 && Date.now() - publishedAt > this.options.videoFlowGraceMs) {
|
|
1696
|
-
return 'health-video-flow-timeout';
|
|
1697
|
-
}
|
|
1698
|
-
const signalingState = peer.pc.signalingState;
|
|
1699
|
-
if (signalingState !== 'stable' && signalingState !== 'closed') {
|
|
1700
|
-
const connectionLooksHealthy = peer.pc.connectionState === 'connected'
|
|
1701
|
-
|| peer.pc.iceConnectionState === 'connected'
|
|
1702
|
-
|| peer.pc.iceConnectionState === 'completed';
|
|
1703
|
-
const signalingAgeMs = Date.now() - (peer.signalingStateChangedAt || peer.createdAt || Date.now());
|
|
1704
|
-
if (connectionLooksHealthy && signalingAgeMs > this.options.stuckSignalingGraceMs) {
|
|
1705
|
-
return `health-stuck-${signalingState}`;
|
|
1706
|
-
}
|
|
1707
|
-
}
|
|
1708
|
-
return null;
|
|
1709
|
-
}
|
|
1710
|
-
async createUserMediaTrack(kind, constraints) {
|
|
1711
|
-
const devices = this.options.mediaDevices;
|
|
1712
|
-
if (!devices?.getUserMedia || constraints === false) {
|
|
1713
|
-
return null;
|
|
1714
|
-
}
|
|
1715
|
-
const stream = await devices.getUserMedia(kind === 'audio'
|
|
1716
|
-
? { audio: constraints, video: false }
|
|
1717
|
-
: { audio: false, video: constraints });
|
|
1718
|
-
return kind === 'audio' ? stream.getAudioTracks()[0] ?? null : stream.getVideoTracks()[0] ?? null;
|
|
1719
|
-
}
|
|
1720
|
-
rememberLocalTrack(kind, track, deviceId, stopOnCleanup) {
|
|
1721
|
-
this.releaseLocalTrack(kind);
|
|
1722
|
-
this.localTracks.set(kind, {
|
|
1723
|
-
kind,
|
|
1724
|
-
track,
|
|
1725
|
-
deviceId,
|
|
1726
|
-
stopOnCleanup,
|
|
1727
|
-
});
|
|
1728
|
-
}
|
|
1729
|
-
releaseLocalTrack(kind) {
|
|
1730
|
-
const local = this.localTracks.get(kind);
|
|
1731
|
-
if (!local)
|
|
1732
|
-
return;
|
|
1733
|
-
if (local.stopOnCleanup) {
|
|
1734
|
-
local.track.stop();
|
|
1735
|
-
}
|
|
1736
|
-
this.localTracks.delete(kind);
|
|
1737
|
-
}
|
|
1738
|
-
async ensureConnectedMemberId() {
|
|
1739
|
-
if (this.localMemberId) {
|
|
1740
|
-
return this.localMemberId;
|
|
1741
|
-
}
|
|
1742
|
-
return this.connect();
|
|
1743
|
-
}
|
|
1744
|
-
getPendingRemoteVideoTrack(memberId) {
|
|
1745
|
-
for (const pending of this.pendingRemoteTracks.values()) {
|
|
1746
|
-
if (pending.memberId === memberId
|
|
1747
|
-
&& pending.track.kind === 'video'
|
|
1748
|
-
&& pending.track.readyState === 'live') {
|
|
1749
|
-
return { track: pending.track, stream: pending.stream };
|
|
1750
|
-
}
|
|
1751
|
-
}
|
|
1752
|
-
return null;
|
|
1753
|
-
}
|
|
1754
|
-
normalizeDescription(payload) {
|
|
1755
|
-
if (!payload || typeof payload !== 'object') {
|
|
1756
|
-
return null;
|
|
1757
|
-
}
|
|
1758
|
-
const raw = payload.description;
|
|
1759
|
-
if (!raw || typeof raw.type !== 'string') {
|
|
1760
|
-
return null;
|
|
1761
|
-
}
|
|
1762
|
-
return {
|
|
1763
|
-
type: raw.type,
|
|
1764
|
-
sdp: typeof raw.sdp === 'string' ? raw.sdp : undefined,
|
|
1765
|
-
};
|
|
1766
|
-
}
|
|
1767
|
-
normalizeCandidates(payload) {
|
|
1768
|
-
if (!payload || typeof payload !== 'object') {
|
|
1769
|
-
return [];
|
|
1770
|
-
}
|
|
1771
|
-
const batch = payload.candidates;
|
|
1772
|
-
if (Array.isArray(batch)) {
|
|
1773
|
-
return batch.filter((candidate) => !!candidate && typeof candidate.candidate === 'string');
|
|
1774
|
-
}
|
|
1775
|
-
const raw = payload.candidate;
|
|
1776
|
-
if (!raw || typeof raw.candidate !== 'string') {
|
|
1777
|
-
return [];
|
|
1778
|
-
}
|
|
1779
|
-
return [raw];
|
|
1780
|
-
}
|
|
1781
|
-
async sendSignalWithRetry(memberId, event, payload) {
|
|
1782
|
-
await this.withRateLimitRetry(`signal ${event}`, () => this.room.signals.sendTo(memberId, event, payload));
|
|
1783
|
-
}
|
|
1784
|
-
async withRateLimitRetry(label, action) {
|
|
1785
|
-
let lastError;
|
|
1786
|
-
for (let attempt = 0; attempt <= DEFAULT_RATE_LIMIT_RETRY_DELAYS_MS.length; attempt += 1) {
|
|
1787
|
-
try {
|
|
1788
|
-
return await action();
|
|
1789
|
-
}
|
|
1790
|
-
catch (error) {
|
|
1791
|
-
lastError = error;
|
|
1792
|
-
if (!isRateLimitError(error) || attempt === DEFAULT_RATE_LIMIT_RETRY_DELAYS_MS.length) {
|
|
1793
|
-
throw error;
|
|
1794
|
-
}
|
|
1795
|
-
const delayMs = DEFAULT_RATE_LIMIT_RETRY_DELAYS_MS[attempt];
|
|
1796
|
-
console.warn('[RoomP2PMediaTransport] Rate limited room operation. Retrying.', {
|
|
1797
|
-
label,
|
|
1798
|
-
attempt: attempt + 1,
|
|
1799
|
-
delayMs,
|
|
1800
|
-
});
|
|
1801
|
-
await new Promise((resolve) => globalThis.setTimeout(resolve, delayMs));
|
|
1802
|
-
}
|
|
1803
|
-
}
|
|
1804
|
-
throw lastError;
|
|
1805
|
-
}
|
|
1806
|
-
get offerEvent() {
|
|
1807
|
-
return `${this.options.signalPrefix}.offer`;
|
|
1808
|
-
}
|
|
1809
|
-
get answerEvent() {
|
|
1810
|
-
return `${this.options.signalPrefix}.answer`;
|
|
1811
|
-
}
|
|
1812
|
-
get iceEvent() {
|
|
1813
|
-
return `${this.options.signalPrefix}.ice`;
|
|
1814
|
-
}
|
|
1815
|
-
maybeRetryPendingNegotiation(peer) {
|
|
1816
|
-
if (!peer.pendingNegotiation
|
|
1817
|
-
|| !this.connected
|
|
1818
|
-
|| peer.pc.connectionState === 'closed'
|
|
1819
|
-
|| peer.makingOffer
|
|
1820
|
-
|| peer.isSettingRemoteAnswerPending
|
|
1821
|
-
|| peer.pc.signalingState !== 'stable') {
|
|
1822
|
-
return;
|
|
1823
|
-
}
|
|
1824
|
-
peer.pendingNegotiation = false;
|
|
1825
|
-
queueMicrotask(() => {
|
|
1826
|
-
void this.negotiatePeer(peer);
|
|
1827
|
-
});
|
|
1828
|
-
}
|
|
1829
|
-
handlePeerConnectivityChange(peer, source) {
|
|
1830
|
-
if (!this.connected || peer.pc.connectionState === 'closed') {
|
|
1831
|
-
return;
|
|
1832
|
-
}
|
|
1833
|
-
const connectionState = peer.pc.connectionState;
|
|
1834
|
-
const iceConnectionState = peer.pc.iceConnectionState;
|
|
1835
|
-
const connectedish = connectionState === 'connected'
|
|
1836
|
-
|| iceConnectionState === 'connected'
|
|
1837
|
-
|| iceConnectionState === 'completed';
|
|
1838
|
-
if (connectedish) {
|
|
1839
|
-
const unstableSignaling = peer.pc.signalingState !== 'stable';
|
|
1840
|
-
const missingPublishedMedia = this.hasMissingPublishedMedia(peer.memberId);
|
|
1841
|
-
const allRemoteVideoFlowsUnhealthy = peer.remoteVideoFlows.size > 0
|
|
1842
|
-
&& Array.from(peer.remoteVideoFlows.values()).every((flow) => (flow.lastHealthyAt ?? 0) <= 0);
|
|
1843
|
-
if (unstableSignaling || missingPublishedMedia || allRemoteVideoFlowsUnhealthy) {
|
|
1844
|
-
this.schedulePeerRecoveryCheck(peer.memberId, `${source}-connected-but-incomplete`, Math.max(1_200, this.options.missingMediaGraceMs));
|
|
1845
|
-
return;
|
|
1846
|
-
}
|
|
1847
|
-
this.resetPeerRecovery(peer);
|
|
1848
|
-
return;
|
|
1849
|
-
}
|
|
1850
|
-
if (connectionState === 'failed' || iceConnectionState === 'failed') {
|
|
1851
|
-
this.schedulePeerRecoveryCheck(peer.memberId, `${source}-failed`, 0);
|
|
1852
|
-
return;
|
|
1853
|
-
}
|
|
1854
|
-
if (connectionState === 'disconnected' || iceConnectionState === 'disconnected') {
|
|
1855
|
-
this.schedulePeerRecoveryCheck(peer.memberId, `${source}-disconnected`, this.options.disconnectedRecoveryDelayMs);
|
|
1856
|
-
}
|
|
1857
|
-
}
|
|
1858
|
-
schedulePeerRecoveryCheck(memberId, reason, delayMs = this.options.missingMediaGraceMs) {
|
|
1859
|
-
const peer = this.peers.get(memberId);
|
|
1860
|
-
if (!peer || !this.connected || peer.pc.connectionState === 'closed') {
|
|
1861
|
-
return;
|
|
1862
|
-
}
|
|
1863
|
-
const peerAgeMs = Date.now() - peer.createdAt;
|
|
1864
|
-
const inInitialBootstrapWindow = !peer.hasRemoteDescription
|
|
1865
|
-
&& peer.pc.connectionState === 'new'
|
|
1866
|
-
&& peer.pc.iceConnectionState === 'new'
|
|
1867
|
-
&& peerAgeMs < this.options.initialNegotiationGraceMs;
|
|
1868
|
-
const healthSensitiveReason = reason.includes('health')
|
|
1869
|
-
|| reason.includes('stalled')
|
|
1870
|
-
|| reason.includes('flow');
|
|
1871
|
-
if (!this.hasMissingPublishedMedia(memberId)
|
|
1872
|
-
&& !healthSensitiveReason
|
|
1873
|
-
&& !reason.includes('failed')
|
|
1874
|
-
&& !reason.includes('disconnected')) {
|
|
1875
|
-
this.resetPeerRecovery(peer);
|
|
1876
|
-
return;
|
|
1877
|
-
}
|
|
1878
|
-
if (inInitialBootstrapWindow
|
|
1879
|
-
&& !healthSensitiveReason
|
|
1880
|
-
&& !reason.includes('failed')
|
|
1881
|
-
&& !reason.includes('disconnected')) {
|
|
1882
|
-
delayMs = Math.max(delayMs, this.options.initialNegotiationGraceMs - peerAgeMs);
|
|
1883
|
-
}
|
|
1884
|
-
this.clearPeerRecoveryTimer(peer);
|
|
1885
|
-
peer.recoveryTimer = globalThis.setTimeout(() => {
|
|
1886
|
-
peer.recoveryTimer = null;
|
|
1887
|
-
void this.recoverPeer(peer, reason);
|
|
1888
|
-
}, Math.max(0, delayMs));
|
|
1889
|
-
}
|
|
1890
|
-
async recoverPeer(peer, reason) {
|
|
1891
|
-
if (!this.connected || peer.pc.connectionState === 'closed') {
|
|
1892
|
-
return;
|
|
1893
|
-
}
|
|
1894
|
-
const stillMissingPublishedMedia = this.hasMissingPublishedMedia(peer.memberId);
|
|
1895
|
-
const connectivityIssue = peer.pc.connectionState === 'failed'
|
|
1896
|
-
|| peer.pc.connectionState === 'disconnected'
|
|
1897
|
-
|| peer.pc.iceConnectionState === 'failed'
|
|
1898
|
-
|| peer.pc.iceConnectionState === 'disconnected';
|
|
1899
|
-
const healthIssue = !stillMissingPublishedMedia && !connectivityIssue
|
|
1900
|
-
? await this.inspectPeerVideoHealth(peer)
|
|
1901
|
-
: null;
|
|
1902
|
-
if (!stillMissingPublishedMedia && !connectivityIssue && !healthIssue) {
|
|
1903
|
-
this.resetPeerRecovery(peer);
|
|
1904
|
-
return;
|
|
1905
|
-
}
|
|
1906
|
-
if (healthIssue === 'health-stuck-have-local-offer'
|
|
1907
|
-
&& (peer.pc.connectionState === 'connected'
|
|
1908
|
-
|| peer.pc.iceConnectionState === 'connected'
|
|
1909
|
-
|| peer.pc.iceConnectionState === 'completed')) {
|
|
1910
|
-
try {
|
|
1911
|
-
await peer.pc.setLocalDescription({ type: 'rollback' });
|
|
1912
|
-
peer.pendingNegotiation = true;
|
|
1913
|
-
peer.ignoreOffer = false;
|
|
1914
|
-
this.maybeRetryPendingNegotiation(peer);
|
|
1915
|
-
this.schedulePeerRecoveryCheck(peer.memberId, `${reason}:post-rollback`, 1_200);
|
|
1916
|
-
return;
|
|
1917
|
-
}
|
|
1918
|
-
catch (error) {
|
|
1919
|
-
console.warn('[RoomP2PMediaTransport] Failed to roll back stale local offer.', {
|
|
1920
|
-
memberId: peer.memberId,
|
|
1921
|
-
reason,
|
|
1922
|
-
error,
|
|
1923
|
-
});
|
|
1924
|
-
}
|
|
1925
|
-
}
|
|
1926
|
-
if (healthIssue
|
|
1927
|
-
&& healthIssue.startsWith('health-stuck-')
|
|
1928
|
-
&& (peer.pc.connectionState === 'connected'
|
|
1929
|
-
|| peer.pc.iceConnectionState === 'connected'
|
|
1930
|
-
|| peer.pc.iceConnectionState === 'completed')) {
|
|
1931
|
-
this.resetPeer(peer.memberId, `${reason}:${healthIssue}`);
|
|
1932
|
-
return;
|
|
1933
|
-
}
|
|
1934
|
-
if (peer.recoveryAttempts >= this.options.maxRecoveryAttempts) {
|
|
1935
|
-
this.resetPeer(peer.memberId, reason);
|
|
1936
|
-
return;
|
|
1937
|
-
}
|
|
1938
|
-
peer.recoveryAttempts += 1;
|
|
1939
|
-
this.requestIceRestart(peer, reason);
|
|
1940
|
-
}
|
|
1941
|
-
requestIceRestart(peer, reason) {
|
|
1942
|
-
try {
|
|
1943
|
-
if (typeof peer.pc.restartIce === 'function') {
|
|
1944
|
-
peer.pc.restartIce();
|
|
1945
|
-
}
|
|
1946
|
-
}
|
|
1947
|
-
catch (error) {
|
|
1948
|
-
console.warn('[RoomP2PMediaTransport] Failed to request ICE restart.', {
|
|
1949
|
-
memberId: peer.memberId,
|
|
1950
|
-
reason,
|
|
1951
|
-
error,
|
|
1952
|
-
});
|
|
1953
|
-
}
|
|
1954
|
-
peer.pendingNegotiation = true;
|
|
1955
|
-
this.maybeRetryPendingNegotiation(peer);
|
|
1956
|
-
}
|
|
1957
|
-
resetPeer(memberId, reason) {
|
|
1958
|
-
const existing = this.peers.get(memberId);
|
|
1959
|
-
if (existing) {
|
|
1960
|
-
this.destroyPeer(existing);
|
|
1961
|
-
this.peers.delete(memberId);
|
|
1962
|
-
}
|
|
1963
|
-
const replacement = this.ensurePeer(memberId);
|
|
1964
|
-
replacement.recoveryAttempts = 0;
|
|
1965
|
-
replacement.pendingNegotiation = true;
|
|
1966
|
-
this.maybeRetryPendingNegotiation(replacement);
|
|
1967
|
-
this.schedulePeerRecoveryCheck(memberId, `${reason}:after-reset`);
|
|
1968
|
-
}
|
|
1969
|
-
resetPeerRecovery(peer) {
|
|
1970
|
-
peer.recoveryAttempts = 0;
|
|
1971
|
-
peer.pendingNegotiation = false;
|
|
1972
|
-
this.clearPeerRecoveryTimer(peer);
|
|
1973
|
-
}
|
|
1974
|
-
clearPeerRecoveryTimer(peer) {
|
|
1975
|
-
if (peer.recoveryTimer != null) {
|
|
1976
|
-
globalThis.clearTimeout(peer.recoveryTimer);
|
|
1977
|
-
peer.recoveryTimer = null;
|
|
1978
|
-
}
|
|
1979
|
-
}
|
|
1980
|
-
hasMissingPublishedMedia(memberId) {
|
|
1981
|
-
const mediaMember = this.room.media.list().find((entry) => entry.member.memberId === memberId);
|
|
1982
|
-
if (!mediaMember) {
|
|
1983
|
-
return false;
|
|
1984
|
-
}
|
|
1985
|
-
const publishedKinds = new Set();
|
|
1986
|
-
for (const track of mediaMember.tracks) {
|
|
1987
|
-
if (track.trackId) {
|
|
1988
|
-
publishedKinds.add(track.kind);
|
|
1989
|
-
}
|
|
1990
|
-
}
|
|
1991
|
-
for (const kind of getPublishedKindsFromState(mediaMember.state)) {
|
|
1992
|
-
publishedKinds.add(kind);
|
|
1993
|
-
}
|
|
1994
|
-
const emittedKinds = new Set();
|
|
1995
|
-
for (const key of this.emittedRemoteTracks) {
|
|
1996
|
-
if (!key.startsWith(`${memberId}:`)) {
|
|
1997
|
-
continue;
|
|
1998
|
-
}
|
|
1999
|
-
const kind = this.remoteTrackKinds.get(key);
|
|
2000
|
-
if (kind) {
|
|
2001
|
-
emittedKinds.add(kind);
|
|
2002
|
-
}
|
|
2003
|
-
}
|
|
2004
|
-
let pendingAudioCount = 0;
|
|
2005
|
-
let pendingVideoLikeCount = 0;
|
|
2006
|
-
for (const pending of this.pendingRemoteTracks.values()) {
|
|
2007
|
-
if (pending.memberId !== memberId) {
|
|
2008
|
-
continue;
|
|
2009
|
-
}
|
|
2010
|
-
if (pending.track.kind === 'audio') {
|
|
2011
|
-
pendingAudioCount += 1;
|
|
2012
|
-
}
|
|
2013
|
-
else if (pending.track.kind === 'video') {
|
|
2014
|
-
pendingVideoLikeCount += 1;
|
|
2015
|
-
}
|
|
2016
|
-
}
|
|
2017
|
-
if (publishedKinds.has('audio') && !emittedKinds.has('audio') && pendingAudioCount === 0) {
|
|
2018
|
-
return true;
|
|
2019
|
-
}
|
|
2020
|
-
const expectedVideoLikeKinds = Array.from(publishedKinds).filter((kind) => kind === 'video' || kind === 'screen');
|
|
2021
|
-
if (expectedVideoLikeKinds.length === 0) {
|
|
2022
|
-
return false;
|
|
2023
|
-
}
|
|
2024
|
-
const emittedVideoLikeCount = Array.from(emittedKinds).filter((kind) => kind === 'video' || kind === 'screen').length;
|
|
2025
|
-
return emittedVideoLikeCount + pendingVideoLikeCount < expectedVideoLikeKinds.length;
|
|
2026
|
-
}
|
|
2027
|
-
emitRemoteVideoStateChange(force = false) {
|
|
2028
|
-
if (this.remoteVideoStateHandlers.length === 0 && !force) {
|
|
2029
|
-
return;
|
|
2030
|
-
}
|
|
2031
|
-
const entries = this.getRemoteVideoStates();
|
|
2032
|
-
const signature = JSON.stringify(entries.map((entry) => ({
|
|
2033
|
-
memberId: entry.memberId,
|
|
2034
|
-
userId: entry.userId ?? null,
|
|
2035
|
-
displayName: entry.displayName ?? null,
|
|
2036
|
-
trackId: entry.trackId ?? null,
|
|
2037
|
-
published: entry.published,
|
|
2038
|
-
isCameraOff: entry.isCameraOff,
|
|
2039
|
-
hasStream: entry.stream instanceof MediaStream,
|
|
2040
|
-
})));
|
|
2041
|
-
if (!force && signature === this.remoteVideoStateSignature) {
|
|
2042
|
-
return;
|
|
2043
|
-
}
|
|
2044
|
-
this.remoteVideoStateSignature = signature;
|
|
2045
|
-
for (const handler of this.remoteVideoStateHandlers) {
|
|
2046
|
-
try {
|
|
2047
|
-
handler(entries.map((entry) => ({ ...entry })));
|
|
2048
|
-
}
|
|
2049
|
-
catch {
|
|
2050
|
-
// Ignore remote video state handler failures.
|
|
2051
|
-
}
|
|
2052
|
-
}
|
|
2053
|
-
}
|
|
2054
|
-
recordDebugEvent(type, details = {}) {
|
|
2055
|
-
this.debugEvents.push({
|
|
2056
|
-
id: ++this.debugEventCounter,
|
|
2057
|
-
at: Date.now(),
|
|
2058
|
-
type,
|
|
2059
|
-
details,
|
|
2060
|
-
});
|
|
2061
|
-
if (this.debugEvents.length > 200) {
|
|
2062
|
-
this.debugEvents.splice(0, this.debugEvents.length - 200);
|
|
2063
|
-
}
|
|
2064
|
-
}
|
|
2065
|
-
}
|
|
2066
|
-
//# sourceMappingURL=room-p2p-media.js.map
|