@edge-base/web 0.2.5 → 0.2.7
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/README.md +15 -31
- package/dist/auth-refresh.d.ts.map +1 -1
- package/dist/auth-refresh.js +6 -5
- package/dist/auth-refresh.js.map +1 -1
- package/dist/client.d.ts +25 -9
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +23 -1
- package/dist/client.js.map +1 -1
- package/dist/database-live.d.ts.map +1 -1
- package/dist/database-live.js +5 -5
- package/dist/database-live.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -1
- package/dist/index.js.map +1 -1
- package/dist/room-cloudflare-media.d.ts +6 -1
- package/dist/room-cloudflare-media.d.ts.map +1 -1
- package/dist/room-cloudflare-media.js +113 -1
- package/dist/room-cloudflare-media.js.map +1 -1
- package/dist/room-p2p-media.d.ts +64 -1
- package/dist/room-p2p-media.d.ts.map +1 -1
- package/dist/room-p2p-media.js +776 -49
- package/dist/room-p2p-media.js.map +1 -1
- package/dist/room.d.ts +59 -260
- package/dist/room.d.ts.map +1 -1
- package/dist/room.js +241 -469
- package/dist/room.js.map +1 -1
- package/dist/token-manager.d.ts +5 -1
- package/dist/token-manager.d.ts.map +1 -1
- package/dist/token-manager.js +38 -30
- package/dist/token-manager.js.map +1 -1
- package/llms.txt +9 -57
- package/package.json +2 -3
package/dist/room-p2p-media.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { EdgeBaseError } from '@edge-base/core';
|
|
1
2
|
import { createSubscription } from './room.js';
|
|
2
3
|
const DEFAULT_SIGNAL_PREFIX = 'edgebase.media.p2p';
|
|
3
4
|
const DEFAULT_ICE_SERVERS = [
|
|
@@ -12,9 +13,22 @@ const DEFAULT_RATE_LIMIT_RETRY_DELAYS_MS = [160, 320, 640];
|
|
|
12
13
|
const DEFAULT_MEDIA_HEALTH_CHECK_INTERVAL_MS = 4_000;
|
|
13
14
|
const DEFAULT_VIDEO_FLOW_GRACE_MS = 8_000;
|
|
14
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;
|
|
15
22
|
function buildTrackKey(memberId, trackId) {
|
|
16
23
|
return `${memberId}:${trackId}`;
|
|
17
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
|
+
}
|
|
18
32
|
function buildExactDeviceConstraint(deviceId) {
|
|
19
33
|
return { deviceId: { exact: deviceId } };
|
|
20
34
|
}
|
|
@@ -95,16 +109,27 @@ function sameIceServer(candidate, urls) {
|
|
|
95
109
|
const candidateUrls = normalizeIceServerUrls(candidate.urls);
|
|
96
110
|
return candidateUrls.length === urls.length && candidateUrls.every((url, index) => url === urls[index]);
|
|
97
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
|
+
}
|
|
98
118
|
export class RoomP2PMediaTransport {
|
|
99
119
|
room;
|
|
100
120
|
options;
|
|
101
121
|
localTracks = new Map();
|
|
102
122
|
peers = new Map();
|
|
103
123
|
remoteTrackHandlers = [];
|
|
124
|
+
remoteVideoStateHandlers = [];
|
|
104
125
|
remoteTrackKinds = new Map();
|
|
105
126
|
emittedRemoteTracks = new Set();
|
|
106
127
|
pendingRemoteTracks = new Map();
|
|
128
|
+
pendingTrackRemovalTimers = new Map();
|
|
129
|
+
pendingSyncRemovalTimers = new Map();
|
|
130
|
+
pendingVideoPromotionTimers = new Map();
|
|
107
131
|
pendingIceCandidates = new Map();
|
|
132
|
+
remoteVideoStreamCache = new Map();
|
|
108
133
|
subscriptions = [];
|
|
109
134
|
localMemberId = null;
|
|
110
135
|
connected = false;
|
|
@@ -113,6 +138,10 @@ export class RoomP2PMediaTransport {
|
|
|
113
138
|
syncAllPeerSendersScheduled = false;
|
|
114
139
|
syncAllPeerSendersPending = false;
|
|
115
140
|
healthCheckTimer = null;
|
|
141
|
+
negotiationTail = Promise.resolve();
|
|
142
|
+
remoteVideoStateSignature = '';
|
|
143
|
+
debugEvents = [];
|
|
144
|
+
debugEventCounter = 0;
|
|
116
145
|
constructor(room, options) {
|
|
117
146
|
this.room = room;
|
|
118
147
|
this.options = {
|
|
@@ -134,6 +163,12 @@ export class RoomP2PMediaTransport {
|
|
|
134
163
|
mediaHealthCheckIntervalMs: options?.mediaHealthCheckIntervalMs ?? DEFAULT_MEDIA_HEALTH_CHECK_INTERVAL_MS,
|
|
135
164
|
videoFlowGraceMs: options?.videoFlowGraceMs ?? DEFAULT_VIDEO_FLOW_GRACE_MS,
|
|
136
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,
|
|
137
172
|
};
|
|
138
173
|
}
|
|
139
174
|
getSessionId() {
|
|
@@ -149,9 +184,21 @@ export class RoomP2PMediaTransport {
|
|
|
149
184
|
if (this.connected && this.localMemberId) {
|
|
150
185
|
return this.localMemberId;
|
|
151
186
|
}
|
|
187
|
+
this.recordDebugEvent('transport:connect');
|
|
152
188
|
if (payload && typeof payload === 'object' && 'sessionDescription' in payload) {
|
|
153
189
|
throw new Error('RoomP2PMediaTransport.connect() does not accept sessionDescription; use room.signals through the built-in transport instead.');
|
|
154
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
|
+
}
|
|
155
202
|
const currentMember = await this.waitForCurrentMember();
|
|
156
203
|
if (!currentMember) {
|
|
157
204
|
throw new Error('Join the room before connecting a P2P media transport.');
|
|
@@ -168,6 +215,7 @@ export class RoomP2PMediaTransport {
|
|
|
168
215
|
this.ensurePeer(member.memberId);
|
|
169
216
|
}
|
|
170
217
|
}
|
|
218
|
+
this.emitRemoteVideoStateChange(true);
|
|
171
219
|
}
|
|
172
220
|
catch (error) {
|
|
173
221
|
this.rollbackConnectedState();
|
|
@@ -175,6 +223,257 @@ export class RoomP2PMediaTransport {
|
|
|
175
223
|
}
|
|
176
224
|
return this.localMemberId;
|
|
177
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
|
+
}
|
|
178
477
|
async waitForCurrentMember(timeoutMs = DEFAULT_MEMBER_READY_TIMEOUT_MS) {
|
|
179
478
|
const startedAt = Date.now();
|
|
180
479
|
while (Date.now() - startedAt < timeoutMs) {
|
|
@@ -337,6 +636,67 @@ export class RoomP2PMediaTransport {
|
|
|
337
636
|
}
|
|
338
637
|
});
|
|
339
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
|
+
}
|
|
340
700
|
destroy() {
|
|
341
701
|
this.connected = false;
|
|
342
702
|
this.localMemberId = null;
|
|
@@ -359,10 +719,24 @@ export class RoomP2PMediaTransport {
|
|
|
359
719
|
clearTimeout(pending.timer);
|
|
360
720
|
}
|
|
361
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();
|
|
362
734
|
this.pendingIceCandidates.clear();
|
|
363
735
|
this.remoteTrackKinds.clear();
|
|
364
736
|
this.emittedRemoteTracks.clear();
|
|
365
737
|
this.pendingRemoteTracks.clear();
|
|
738
|
+
this.remoteVideoStreamCache.clear();
|
|
739
|
+
this.emitRemoteVideoStateChange(true);
|
|
366
740
|
}
|
|
367
741
|
attachRoomSubscriptions() {
|
|
368
742
|
if (this.subscriptions.length > 0) {
|
|
@@ -370,25 +744,29 @@ export class RoomP2PMediaTransport {
|
|
|
370
744
|
}
|
|
371
745
|
this.subscriptions.push(this.room.members.onJoin((member) => {
|
|
372
746
|
if (member.memberId !== this.localMemberId) {
|
|
373
|
-
this.
|
|
374
|
-
this.
|
|
747
|
+
this.cancelPendingSyncRemoval(member.memberId);
|
|
748
|
+
this.ensurePeer(member.memberId, { passive: true });
|
|
749
|
+
this.emitRemoteVideoStateChange();
|
|
375
750
|
}
|
|
376
751
|
}), this.room.members.onSync((members) => {
|
|
377
752
|
const activeMemberIds = new Set();
|
|
378
753
|
for (const member of members) {
|
|
379
754
|
if (member.memberId !== this.localMemberId) {
|
|
380
755
|
activeMemberIds.add(member.memberId);
|
|
381
|
-
this.
|
|
382
|
-
this.
|
|
756
|
+
this.cancelPendingSyncRemoval(member.memberId);
|
|
757
|
+
this.ensurePeer(member.memberId, { passive: true });
|
|
383
758
|
}
|
|
384
759
|
}
|
|
385
760
|
for (const memberId of Array.from(this.peers.keys())) {
|
|
386
761
|
if (!activeMemberIds.has(memberId)) {
|
|
387
|
-
this.
|
|
762
|
+
this.scheduleSyncRemoval(memberId);
|
|
388
763
|
}
|
|
389
764
|
}
|
|
765
|
+
this.emitRemoteVideoStateChange();
|
|
390
766
|
}), this.room.members.onLeave((member) => {
|
|
767
|
+
this.cancelPendingSyncRemoval(member.memberId);
|
|
391
768
|
this.removeRemoteMember(member.memberId);
|
|
769
|
+
this.emitRemoteVideoStateChange();
|
|
392
770
|
}), this.room.signals.on(this.offerEvent, (payload, meta) => {
|
|
393
771
|
void this.handleDescriptionSignal('offer', payload, meta);
|
|
394
772
|
}), this.room.signals.on(this.answerEvent, (payload, meta) => {
|
|
@@ -397,26 +775,26 @@ export class RoomP2PMediaTransport {
|
|
|
397
775
|
void this.handleIceSignal(payload, meta);
|
|
398
776
|
}), this.room.media.onTrack((track, member) => {
|
|
399
777
|
if (member.memberId !== this.localMemberId) {
|
|
400
|
-
this.ensurePeer(member.memberId);
|
|
778
|
+
this.ensurePeer(member.memberId, { passive: true });
|
|
401
779
|
this.schedulePeerRecoveryCheck(member.memberId, 'media-track');
|
|
402
780
|
}
|
|
403
781
|
this.rememberRemoteTrackKind(track, member);
|
|
782
|
+
this.emitRemoteVideoStateChange();
|
|
404
783
|
}), this.room.media.onTrackRemoved((track, member) => {
|
|
405
784
|
if (!track.trackId)
|
|
406
785
|
return;
|
|
407
|
-
|
|
408
|
-
this.
|
|
409
|
-
this.emittedRemoteTracks.delete(key);
|
|
410
|
-
this.pendingRemoteTracks.delete(key);
|
|
411
|
-
this.schedulePeerRecoveryCheck(member.memberId, 'media-track-removed');
|
|
786
|
+
this.scheduleTrackRemoval(track, member);
|
|
787
|
+
this.emitRemoteVideoStateChange();
|
|
412
788
|
}));
|
|
413
789
|
if (typeof this.room.media.onStateChange === 'function') {
|
|
414
790
|
this.subscriptions.push(this.room.media.onStateChange((member, state) => {
|
|
415
791
|
if (member.memberId === this.localMemberId) {
|
|
416
792
|
return;
|
|
417
793
|
}
|
|
794
|
+
this.ensurePeer(member.memberId, { passive: true });
|
|
418
795
|
this.rememberRemoteTrackKindsFromState(member, state);
|
|
419
796
|
this.schedulePeerRecoveryCheck(member.memberId, 'media-state');
|
|
797
|
+
this.emitRemoteVideoStateChange();
|
|
420
798
|
}));
|
|
421
799
|
}
|
|
422
800
|
}
|
|
@@ -465,15 +843,20 @@ export class RoomP2PMediaTransport {
|
|
|
465
843
|
const pending = this.pendingRemoteTracks.get(key);
|
|
466
844
|
if (pending) {
|
|
467
845
|
this.pendingRemoteTracks.delete(key);
|
|
846
|
+
this.clearPendingVideoPromotionTimer(key);
|
|
468
847
|
this.emitRemoteTrack(member.memberId, pending.track, pending.stream, track.kind);
|
|
469
848
|
return;
|
|
470
849
|
}
|
|
471
850
|
this.flushPendingRemoteTracks(member.memberId, track.kind);
|
|
472
851
|
}
|
|
473
|
-
ensurePeer(memberId) {
|
|
852
|
+
ensurePeer(memberId, options) {
|
|
853
|
+
const passive = options?.passive === true;
|
|
474
854
|
const existing = this.peers.get(memberId);
|
|
475
855
|
if (existing) {
|
|
476
|
-
|
|
856
|
+
if (!passive) {
|
|
857
|
+
existing.bootstrapPassive = false;
|
|
858
|
+
this.syncPeerSenders(existing);
|
|
859
|
+
}
|
|
477
860
|
return existing;
|
|
478
861
|
}
|
|
479
862
|
const pc = this.options.peerConnectionFactory(this.options.rtcConfiguration);
|
|
@@ -481,6 +864,7 @@ export class RoomP2PMediaTransport {
|
|
|
481
864
|
memberId,
|
|
482
865
|
pc,
|
|
483
866
|
polite: !!this.localMemberId && this.localMemberId.localeCompare(memberId) > 0,
|
|
867
|
+
bootstrapPassive: passive,
|
|
484
868
|
makingOffer: false,
|
|
485
869
|
ignoreOffer: false,
|
|
486
870
|
isSettingRemoteAnswerPending: false,
|
|
@@ -490,6 +874,9 @@ export class RoomP2PMediaTransport {
|
|
|
490
874
|
recoveryAttempts: 0,
|
|
491
875
|
recoveryTimer: null,
|
|
492
876
|
healthCheckInFlight: false,
|
|
877
|
+
createdAt: Date.now(),
|
|
878
|
+
signalingStateChangedAt: Date.now(),
|
|
879
|
+
hasRemoteDescription: false,
|
|
493
880
|
remoteVideoFlows: new Map(),
|
|
494
881
|
};
|
|
495
882
|
pc.onicecandidate = (event) => {
|
|
@@ -500,9 +887,13 @@ export class RoomP2PMediaTransport {
|
|
|
500
887
|
this.queueIceCandidate(memberId, serializeCandidate(event.candidate));
|
|
501
888
|
};
|
|
502
889
|
pc.onnegotiationneeded = () => {
|
|
890
|
+
if (peer.bootstrapPassive && !peer.hasRemoteDescription && peer.pc.signalingState === 'stable') {
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
503
893
|
void this.negotiatePeer(peer);
|
|
504
894
|
};
|
|
505
895
|
pc.onsignalingstatechange = () => {
|
|
896
|
+
peer.signalingStateChangedAt = Date.now();
|
|
506
897
|
this.maybeRetryPendingNegotiation(peer);
|
|
507
898
|
};
|
|
508
899
|
pc.oniceconnectionstatechange = () => {
|
|
@@ -520,49 +911,88 @@ export class RoomP2PMediaTransport {
|
|
|
520
911
|
const kind = exactKind ?? fallbackKind ?? normalizeTrackKind(event.track);
|
|
521
912
|
if (!kind || (!exactKind && !fallbackKind && kind === 'video' && event.track.kind === 'video')) {
|
|
522
913
|
this.pendingRemoteTracks.set(key, { memberId, track: event.track, stream });
|
|
914
|
+
this.schedulePendingVideoPromotion(memberId, event.track, stream);
|
|
523
915
|
return;
|
|
524
916
|
}
|
|
917
|
+
this.clearPendingVideoPromotionTimer(key);
|
|
525
918
|
this.emitRemoteTrack(memberId, event.track, stream, kind);
|
|
526
919
|
this.registerPeerRemoteTrack(peer, event.track, kind);
|
|
527
920
|
this.resetPeerRecovery(peer);
|
|
528
921
|
};
|
|
529
922
|
this.peers.set(memberId, peer);
|
|
530
|
-
|
|
531
|
-
|
|
923
|
+
if (!peer.bootstrapPassive) {
|
|
924
|
+
this.syncPeerSenders(peer);
|
|
925
|
+
this.schedulePeerRecoveryCheck(memberId, 'peer-created');
|
|
926
|
+
}
|
|
532
927
|
return peer;
|
|
533
928
|
}
|
|
534
929
|
async negotiatePeer(peer) {
|
|
535
|
-
if (
|
|
536
|
-
|
|
537
|
-
return;
|
|
538
|
-
}
|
|
539
|
-
if (peer.makingOffer
|
|
540
|
-
|| peer.isSettingRemoteAnswerPending
|
|
541
|
-
|| peer.pc.signalingState !== 'stable') {
|
|
542
|
-
peer.pendingNegotiation = true;
|
|
930
|
+
if (peer.answeringOffer) {
|
|
931
|
+
peer.pendingNegotiation = false;
|
|
543
932
|
return;
|
|
544
933
|
}
|
|
545
|
-
|
|
546
|
-
peer.
|
|
547
|
-
peer.makingOffer = true;
|
|
548
|
-
await peer.pc.setLocalDescription();
|
|
549
|
-
if (!peer.pc.localDescription) {
|
|
934
|
+
const runNegotiation = async () => {
|
|
935
|
+
if (!this.connected || peer.pc.connectionState === 'closed') {
|
|
550
936
|
return;
|
|
551
937
|
}
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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;
|
|
562
978
|
}
|
|
563
|
-
|
|
564
|
-
|
|
979
|
+
const bootstrapQueue = peer;
|
|
980
|
+
if (bootstrapQueue.bootstrapNegotiationQueued) {
|
|
981
|
+
peer.pendingNegotiation = true;
|
|
982
|
+
return;
|
|
565
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;
|
|
566
996
|
}
|
|
567
997
|
async handleDescriptionSignal(expectedType, payload, meta) {
|
|
568
998
|
const senderId = typeof meta.memberId === 'string' && meta.memberId.trim() ? meta.memberId.trim() : '';
|
|
@@ -594,17 +1024,30 @@ export class RoomP2PMediaTransport {
|
|
|
594
1024
|
}
|
|
595
1025
|
peer.isSettingRemoteAnswerPending = description.type === 'answer';
|
|
596
1026
|
await peer.pc.setRemoteDescription(description);
|
|
1027
|
+
peer.hasRemoteDescription = true;
|
|
1028
|
+
peer.bootstrapPassive = false;
|
|
597
1029
|
peer.isSettingRemoteAnswerPending = false;
|
|
598
1030
|
await this.flushPendingCandidates(peer);
|
|
599
1031
|
if (description.type === 'offer') {
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
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;
|
|
604
1050
|
}
|
|
605
|
-
await this.sendSignalWithRetry(senderId, this.answerEvent, {
|
|
606
|
-
description: serializeDescription(peer.pc.localDescription),
|
|
607
|
-
});
|
|
608
1051
|
}
|
|
609
1052
|
}
|
|
610
1053
|
catch (error) {
|
|
@@ -792,8 +1235,12 @@ export class RoomP2PMediaTransport {
|
|
|
792
1235
|
stream,
|
|
793
1236
|
trackName: track.id,
|
|
794
1237
|
providerSessionId: memberId,
|
|
1238
|
+
memberId,
|
|
795
1239
|
participantId: memberId,
|
|
796
1240
|
userId: participant?.userId,
|
|
1241
|
+
displayName: typeof participant?.state?.displayName === 'string'
|
|
1242
|
+
? participant.state.displayName
|
|
1243
|
+
: undefined,
|
|
797
1244
|
};
|
|
798
1245
|
for (const handler of this.remoteTrackHandlers) {
|
|
799
1246
|
handler(payload);
|
|
@@ -807,6 +1254,7 @@ export class RoomP2PMediaTransport {
|
|
|
807
1254
|
this.schedulePeerRecoveryCheck(memberId, 'partial-remote-track');
|
|
808
1255
|
}
|
|
809
1256
|
}
|
|
1257
|
+
this.emitRemoteVideoStateChange();
|
|
810
1258
|
}
|
|
811
1259
|
resolveFallbackRemoteTrackKind(memberId, track) {
|
|
812
1260
|
const normalizedKind = normalizeTrackKind(track);
|
|
@@ -825,10 +1273,68 @@ export class RoomP2PMediaTransport {
|
|
|
825
1273
|
continue;
|
|
826
1274
|
}
|
|
827
1275
|
this.pendingRemoteTracks.delete(key);
|
|
1276
|
+
this.clearPendingVideoPromotionTimer(key);
|
|
828
1277
|
this.emitRemoteTrack(memberId, pending.track, pending.stream, roomKind);
|
|
829
1278
|
return;
|
|
830
1279
|
}
|
|
831
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
|
+
}
|
|
832
1338
|
getPublishedVideoLikeKinds(memberId) {
|
|
833
1339
|
const mediaMember = this.room.media.list().find((entry) => entry.member.memberId === memberId);
|
|
834
1340
|
if (!mediaMember) {
|
|
@@ -864,6 +1370,73 @@ export class RoomP2PMediaTransport {
|
|
|
864
1370
|
}
|
|
865
1371
|
return publishedKinds.find((kind) => !assignedKinds.has(kind)) ?? null;
|
|
866
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
|
+
}
|
|
867
1440
|
closePeer(memberId) {
|
|
868
1441
|
const peer = this.peers.get(memberId);
|
|
869
1442
|
if (!peer)
|
|
@@ -872,9 +1445,15 @@ export class RoomP2PMediaTransport {
|
|
|
872
1445
|
this.peers.delete(memberId);
|
|
873
1446
|
}
|
|
874
1447
|
removeRemoteMember(memberId) {
|
|
1448
|
+
this.cancelPendingSyncRemoval(memberId);
|
|
875
1449
|
this.remoteTrackKinds.forEach((_kind, key) => {
|
|
876
1450
|
if (key.startsWith(`${memberId}:`)) {
|
|
877
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
|
+
}
|
|
878
1457
|
}
|
|
879
1458
|
});
|
|
880
1459
|
this.emittedRemoteTracks.forEach((key) => {
|
|
@@ -885,9 +1464,35 @@ export class RoomP2PMediaTransport {
|
|
|
885
1464
|
this.pendingRemoteTracks.forEach((_pending, key) => {
|
|
886
1465
|
if (key.startsWith(`${memberId}:`)) {
|
|
887
1466
|
this.pendingRemoteTracks.delete(key);
|
|
1467
|
+
this.clearPendingVideoPromotionTimer(key);
|
|
888
1468
|
}
|
|
889
1469
|
});
|
|
1470
|
+
this.remoteVideoStreamCache.delete(memberId);
|
|
890
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);
|
|
891
1496
|
}
|
|
892
1497
|
findMember(memberId) {
|
|
893
1498
|
return this.room.members.list().find((member) => member.memberId === memberId);
|
|
@@ -911,10 +1516,24 @@ export class RoomP2PMediaTransport {
|
|
|
911
1516
|
clearTimeout(pending.timer);
|
|
912
1517
|
}
|
|
913
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();
|
|
914
1531
|
this.pendingIceCandidates.clear();
|
|
915
1532
|
this.remoteTrackKinds.clear();
|
|
916
1533
|
this.emittedRemoteTracks.clear();
|
|
917
1534
|
this.pendingRemoteTracks.clear();
|
|
1535
|
+
this.remoteVideoStreamCache.clear();
|
|
1536
|
+
this.emitRemoteVideoStateChange(true);
|
|
918
1537
|
}
|
|
919
1538
|
destroyPeer(peer) {
|
|
920
1539
|
this.clearPeerRecoveryTimer(peer);
|
|
@@ -984,6 +1603,7 @@ export class RoomP2PMediaTransport {
|
|
|
984
1603
|
const handleEnded = () => {
|
|
985
1604
|
flow.cleanup();
|
|
986
1605
|
peer.remoteVideoFlows.delete(track.id);
|
|
1606
|
+
this.emitRemoteVideoStateChange();
|
|
987
1607
|
};
|
|
988
1608
|
track.addEventListener('unmute', markHealthy);
|
|
989
1609
|
track.addEventListener('ended', handleEnded);
|
|
@@ -992,6 +1612,7 @@ export class RoomP2PMediaTransport {
|
|
|
992
1612
|
track.removeEventListener('ended', handleEnded);
|
|
993
1613
|
};
|
|
994
1614
|
peer.remoteVideoFlows.set(track.id, flow);
|
|
1615
|
+
this.emitRemoteVideoStateChange();
|
|
995
1616
|
}
|
|
996
1617
|
async inspectPeerVideoHealth(peer) {
|
|
997
1618
|
if (this.hasMissingPublishedMedia(peer.memberId)) {
|
|
@@ -1074,6 +1695,16 @@ export class RoomP2PMediaTransport {
|
|
|
1074
1695
|
if (publishedAt > 0 && Date.now() - publishedAt > this.options.videoFlowGraceMs) {
|
|
1075
1696
|
return 'health-video-flow-timeout';
|
|
1076
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
|
+
}
|
|
1077
1708
|
return null;
|
|
1078
1709
|
}
|
|
1079
1710
|
async createUserMediaTrack(kind, constraints) {
|
|
@@ -1110,6 +1741,16 @@ export class RoomP2PMediaTransport {
|
|
|
1110
1741
|
}
|
|
1111
1742
|
return this.connect();
|
|
1112
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
|
+
}
|
|
1113
1754
|
normalizeDescription(payload) {
|
|
1114
1755
|
if (!payload || typeof payload !== 'object') {
|
|
1115
1756
|
return null;
|
|
@@ -1191,9 +1832,18 @@ export class RoomP2PMediaTransport {
|
|
|
1191
1832
|
}
|
|
1192
1833
|
const connectionState = peer.pc.connectionState;
|
|
1193
1834
|
const iceConnectionState = peer.pc.iceConnectionState;
|
|
1194
|
-
|
|
1835
|
+
const connectedish = connectionState === 'connected'
|
|
1195
1836
|
|| iceConnectionState === 'connected'
|
|
1196
|
-
|| iceConnectionState === 'completed'
|
|
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
|
+
}
|
|
1197
1847
|
this.resetPeerRecovery(peer);
|
|
1198
1848
|
return;
|
|
1199
1849
|
}
|
|
@@ -1210,6 +1860,11 @@ export class RoomP2PMediaTransport {
|
|
|
1210
1860
|
if (!peer || !this.connected || peer.pc.connectionState === 'closed') {
|
|
1211
1861
|
return;
|
|
1212
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;
|
|
1213
1868
|
const healthSensitiveReason = reason.includes('health')
|
|
1214
1869
|
|| reason.includes('stalled')
|
|
1215
1870
|
|| reason.includes('flow');
|
|
@@ -1220,6 +1875,12 @@ export class RoomP2PMediaTransport {
|
|
|
1220
1875
|
this.resetPeerRecovery(peer);
|
|
1221
1876
|
return;
|
|
1222
1877
|
}
|
|
1878
|
+
if (inInitialBootstrapWindow
|
|
1879
|
+
&& !healthSensitiveReason
|
|
1880
|
+
&& !reason.includes('failed')
|
|
1881
|
+
&& !reason.includes('disconnected')) {
|
|
1882
|
+
delayMs = Math.max(delayMs, this.options.initialNegotiationGraceMs - peerAgeMs);
|
|
1883
|
+
}
|
|
1223
1884
|
this.clearPeerRecoveryTimer(peer);
|
|
1224
1885
|
peer.recoveryTimer = globalThis.setTimeout(() => {
|
|
1225
1886
|
peer.recoveryTimer = null;
|
|
@@ -1242,6 +1903,34 @@ export class RoomP2PMediaTransport {
|
|
|
1242
1903
|
this.resetPeerRecovery(peer);
|
|
1243
1904
|
return;
|
|
1244
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
|
+
}
|
|
1245
1934
|
if (peer.recoveryAttempts >= this.options.maxRecoveryAttempts) {
|
|
1246
1935
|
this.resetPeer(peer.memberId, reason);
|
|
1247
1936
|
return;
|
|
@@ -1335,5 +2024,43 @@ export class RoomP2PMediaTransport {
|
|
|
1335
2024
|
const emittedVideoLikeCount = Array.from(emittedKinds).filter((kind) => kind === 'video' || kind === 'screen').length;
|
|
1336
2025
|
return emittedVideoLikeCount + pendingVideoLikeCount < expectedVideoLikeKinds.length;
|
|
1337
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
|
+
}
|
|
1338
2065
|
}
|
|
1339
2066
|
//# sourceMappingURL=room-p2p-media.js.map
|