@edge-base/web 0.2.4 → 0.2.6

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.
@@ -1,9 +1,18 @@
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 = [
4
5
  { urls: 'stun:stun.l.google.com:19302' },
5
6
  ];
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;
7
16
  function buildTrackKey(memberId, trackId) {
8
17
  return `${memberId}:${trackId}`;
9
18
  }
@@ -29,6 +38,70 @@ function serializeCandidate(candidate) {
29
38
  }
30
39
  return candidate;
31
40
  }
41
+ function normalizeIceServerUrls(urls) {
42
+ if (Array.isArray(urls)) {
43
+ return urls.filter((value) => typeof value === 'string' && value.trim().length > 0);
44
+ }
45
+ if (typeof urls === 'string' && urls.trim().length > 0) {
46
+ return [urls];
47
+ }
48
+ return [];
49
+ }
50
+ function normalizeIceServers(iceServers) {
51
+ if (!Array.isArray(iceServers)) {
52
+ return [];
53
+ }
54
+ const normalized = [];
55
+ for (const server of iceServers) {
56
+ const urls = normalizeIceServerUrls(server?.urls);
57
+ if (urls.length === 0) {
58
+ continue;
59
+ }
60
+ normalized.push({
61
+ urls: urls.length === 1 ? urls[0] : urls,
62
+ username: typeof server.username === 'string' ? server.username : undefined,
63
+ credential: typeof server.credential === 'string' ? server.credential : undefined,
64
+ });
65
+ }
66
+ return normalized;
67
+ }
68
+ function getPublishedKindsFromState(state) {
69
+ if (!state) {
70
+ return [];
71
+ }
72
+ const publishedKinds = [];
73
+ if (state.audio?.published)
74
+ publishedKinds.push('audio');
75
+ if (state.video?.published)
76
+ publishedKinds.push('video');
77
+ if (state.screen?.published)
78
+ publishedKinds.push('screen');
79
+ return publishedKinds;
80
+ }
81
+ function isStableAnswerError(error) {
82
+ const message = typeof error === 'object' && error && 'message' in error
83
+ ? String(error.message ?? '')
84
+ : '';
85
+ return (message.includes('Called in wrong state: stable')
86
+ || message.includes('Failed to set remote answer sdp')
87
+ || (message.includes('setRemoteDescription') && message.includes('stable')));
88
+ }
89
+ function isRateLimitError(error) {
90
+ const message = typeof error === 'object' && error && 'message' in error
91
+ ? String(error.message ?? '')
92
+ : String(error ?? '');
93
+ return message.toLowerCase().includes('rate limited');
94
+ }
95
+ function sameIceServer(candidate, urls) {
96
+ const candidateUrls = normalizeIceServerUrls(candidate.urls);
97
+ return candidateUrls.length === urls.length && candidateUrls.every((url, index) => url === urls[index]);
98
+ }
99
+ function getErrorMessage(error) {
100
+ if (error instanceof Error && typeof error.message === 'string' && error.message.length > 0) {
101
+ return error.message;
102
+ }
103
+ return 'Unknown room media error.';
104
+ }
32
105
  export class RoomP2PMediaTransport {
33
106
  room;
34
107
  options;
@@ -38,9 +111,15 @@ export class RoomP2PMediaTransport {
38
111
  remoteTrackKinds = new Map();
39
112
  emittedRemoteTracks = new Set();
40
113
  pendingRemoteTracks = new Map();
114
+ pendingIceCandidates = new Map();
41
115
  subscriptions = [];
42
116
  localMemberId = null;
43
117
  connected = false;
118
+ iceServersResolved = false;
119
+ localUpdateBatchDepth = 0;
120
+ syncAllPeerSendersScheduled = false;
121
+ syncAllPeerSendersPending = false;
122
+ healthCheckTimer = null;
44
123
  constructor(room, options) {
45
124
  this.room = room;
46
125
  this.options = {
@@ -55,6 +134,13 @@ export class RoomP2PMediaTransport {
55
134
  mediaDevices: options?.mediaDevices
56
135
  ?? (typeof navigator !== 'undefined' ? navigator.mediaDevices : undefined),
57
136
  signalPrefix: options?.signalPrefix ?? DEFAULT_SIGNAL_PREFIX,
137
+ turnCredentialTtlSeconds: options?.turnCredentialTtlSeconds ?? 3600,
138
+ missingMediaGraceMs: options?.missingMediaGraceMs ?? DEFAULT_MISSING_MEDIA_GRACE_MS,
139
+ disconnectedRecoveryDelayMs: options?.disconnectedRecoveryDelayMs ?? DEFAULT_DISCONNECTED_RECOVERY_DELAY_MS,
140
+ maxRecoveryAttempts: options?.maxRecoveryAttempts ?? DEFAULT_MAX_RECOVERY_ATTEMPTS,
141
+ mediaHealthCheckIntervalMs: options?.mediaHealthCheckIntervalMs ?? DEFAULT_MEDIA_HEALTH_CHECK_INTERVAL_MS,
142
+ videoFlowGraceMs: options?.videoFlowGraceMs ?? DEFAULT_VIDEO_FLOW_GRACE_MS,
143
+ videoFlowStallGraceMs: options?.videoFlowStallGraceMs ?? DEFAULT_VIDEO_FLOW_STALL_GRACE_MS,
58
144
  };
59
145
  }
60
146
  getSessionId() {
@@ -73,14 +159,27 @@ export class RoomP2PMediaTransport {
73
159
  if (payload && typeof payload === 'object' && 'sessionDescription' in payload) {
74
160
  throw new Error('RoomP2PMediaTransport.connect() does not accept sessionDescription; use room.signals through the built-in transport instead.');
75
161
  }
162
+ const capabilities = await this.collectCapabilities({ includeProviderChecks: false });
163
+ const fatalIssue = capabilities.issues.find((issue) => issue.fatal);
164
+ if (fatalIssue) {
165
+ const error = new EdgeBaseError(400, fatalIssue.message, { preflight: { code: fatalIssue.code, message: fatalIssue.message } }, 'room-media-preflight-failed');
166
+ Object.assign(error, {
167
+ provider: capabilities.provider,
168
+ issue: fatalIssue,
169
+ capabilities,
170
+ });
171
+ throw error;
172
+ }
76
173
  const currentMember = await this.waitForCurrentMember();
77
174
  if (!currentMember) {
78
175
  throw new Error('Join the room before connecting a P2P media transport.');
79
176
  }
80
177
  this.localMemberId = currentMember.memberId;
178
+ await this.resolveRtcConfiguration();
81
179
  this.connected = true;
82
180
  this.hydrateRemoteTrackKinds();
83
181
  this.attachRoomSubscriptions();
182
+ this.startHealthChecks();
84
183
  try {
85
184
  for (const member of this.room.members.list()) {
86
185
  if (member.memberId !== this.localMemberId) {
@@ -94,6 +193,124 @@ export class RoomP2PMediaTransport {
94
193
  }
95
194
  return this.localMemberId;
96
195
  }
196
+ async getCapabilities() {
197
+ return this.collectCapabilities({ includeProviderChecks: true });
198
+ }
199
+ async collectCapabilities(options) {
200
+ const issues = [];
201
+ const currentMember = this.room.members.current();
202
+ const roomIssueFatal = !currentMember;
203
+ let room = {
204
+ ok: true,
205
+ type: 'room_connect_ready',
206
+ category: 'ready',
207
+ message: 'Room WebSocket preflight passed',
208
+ };
209
+ if (typeof this.room.checkConnection === 'function') {
210
+ try {
211
+ room = await this.room.checkConnection();
212
+ }
213
+ catch (error) {
214
+ issues.push({
215
+ code: 'room_connect_check_failed',
216
+ category: 'room',
217
+ message: `Room connect-check failed: ${getErrorMessage(error)}`,
218
+ fatal: roomIssueFatal,
219
+ });
220
+ }
221
+ }
222
+ if (!room.ok) {
223
+ issues.push({
224
+ code: room.type,
225
+ category: 'room',
226
+ message: room.message,
227
+ fatal: roomIssueFatal,
228
+ });
229
+ }
230
+ if (!currentMember) {
231
+ issues.push({
232
+ code: 'room_member_not_joined',
233
+ category: 'room',
234
+ message: 'Join the room before connecting a P2P media transport.',
235
+ fatal: true,
236
+ });
237
+ }
238
+ const browser = {
239
+ mediaDevices: !!this.options.mediaDevices,
240
+ getUserMedia: typeof this.options.mediaDevices?.getUserMedia === 'function',
241
+ getDisplayMedia: typeof this.options.mediaDevices?.getDisplayMedia === 'function',
242
+ enumerateDevices: typeof this.options.mediaDevices?.enumerateDevices === 'function',
243
+ rtcPeerConnection: typeof this.options.peerConnectionFactory === 'function'
244
+ || typeof RTCPeerConnection !== 'undefined',
245
+ };
246
+ if (!browser.rtcPeerConnection) {
247
+ issues.push({
248
+ code: 'webrtc_unavailable',
249
+ category: 'browser',
250
+ message: 'RTCPeerConnection is not available in this environment.',
251
+ fatal: true,
252
+ });
253
+ }
254
+ if (!browser.getUserMedia) {
255
+ issues.push({
256
+ code: 'media_devices_get_user_media_unavailable',
257
+ category: 'browser',
258
+ message: 'getUserMedia() is not available; local audio/video capture will be unavailable.',
259
+ fatal: false,
260
+ });
261
+ }
262
+ if (!browser.getDisplayMedia) {
263
+ issues.push({
264
+ code: 'media_devices_get_display_media_unavailable',
265
+ category: 'browser',
266
+ message: 'getDisplayMedia() is not available; screen sharing will be unavailable.',
267
+ fatal: false,
268
+ });
269
+ }
270
+ let turn;
271
+ const loadIceServers = this.room.media.realtime?.iceServers;
272
+ if (options.includeProviderChecks && typeof loadIceServers === 'function') {
273
+ turn = {
274
+ requested: true,
275
+ available: false,
276
+ iceServerCount: 0,
277
+ };
278
+ try {
279
+ const response = await loadIceServers({ ttl: this.options.turnCredentialTtlSeconds });
280
+ const servers = normalizeIceServers(response?.iceServers);
281
+ turn.available = servers.length > 0;
282
+ turn.iceServerCount = servers.length;
283
+ if (!turn.available) {
284
+ issues.push({
285
+ code: 'turn_credentials_unavailable',
286
+ category: 'provider',
287
+ message: 'No TURN credentials were returned; the transport will fall back to its configured ICE servers.',
288
+ fatal: false,
289
+ });
290
+ }
291
+ }
292
+ catch (error) {
293
+ turn.error = getErrorMessage(error);
294
+ issues.push({
295
+ code: 'turn_credentials_failed',
296
+ category: 'provider',
297
+ message: `Failed to resolve TURN credentials: ${turn.error}`,
298
+ fatal: false,
299
+ });
300
+ }
301
+ }
302
+ return {
303
+ provider: 'p2p',
304
+ canConnect: !issues.some((issue) => issue.fatal),
305
+ issues,
306
+ room,
307
+ joined: !!currentMember,
308
+ currentMemberId: currentMember?.memberId ?? null,
309
+ sessionId: this.getSessionId(),
310
+ browser,
311
+ turn,
312
+ };
313
+ }
97
314
  async waitForCurrentMember(timeoutMs = DEFAULT_MEMBER_READY_TIMEOUT_MS) {
98
315
  const startedAt = Date.now();
99
316
  while (Date.now() - startedAt < timeoutMs) {
@@ -105,6 +322,39 @@ export class RoomP2PMediaTransport {
105
322
  }
106
323
  return this.room.members.current();
107
324
  }
325
+ async resolveRtcConfiguration() {
326
+ if (this.iceServersResolved) {
327
+ return;
328
+ }
329
+ const loadIceServers = this.room.media.realtime?.iceServers;
330
+ if (typeof loadIceServers !== 'function') {
331
+ this.iceServersResolved = true;
332
+ return;
333
+ }
334
+ try {
335
+ const response = await loadIceServers({ ttl: this.options.turnCredentialTtlSeconds });
336
+ const realtimeIceServers = normalizeIceServers(response?.iceServers);
337
+ if (realtimeIceServers.length === 0) {
338
+ return;
339
+ }
340
+ const fallbackIceServers = normalizeIceServers(DEFAULT_ICE_SERVERS);
341
+ const mergedIceServers = [
342
+ ...realtimeIceServers,
343
+ ...fallbackIceServers.filter((server) => {
344
+ const urls = normalizeIceServerUrls(server.urls);
345
+ return !realtimeIceServers.some((candidate) => sameIceServer(candidate, urls));
346
+ }),
347
+ ];
348
+ this.options.rtcConfiguration = {
349
+ ...this.options.rtcConfiguration,
350
+ iceServers: mergedIceServers,
351
+ };
352
+ this.iceServersResolved = true;
353
+ }
354
+ catch (error) {
355
+ console.warn('[RoomP2PMediaTransport] Failed to load TURN / ICE credentials. Falling back to default STUN.', error);
356
+ }
357
+ }
108
358
  async enableAudio(constraints = true) {
109
359
  const track = await this.createUserMediaTrack('audio', constraints);
110
360
  if (!track) {
@@ -112,12 +362,12 @@ export class RoomP2PMediaTransport {
112
362
  }
113
363
  const providerSessionId = await this.ensureConnectedMemberId();
114
364
  this.rememberLocalTrack('audio', track, track.getSettings().deviceId, true);
115
- await this.room.media.audio.enable?.({
365
+ await this.withRateLimitRetry('enable audio', () => this.room.media.audio.enable?.({
116
366
  trackId: track.id,
117
367
  deviceId: track.getSettings().deviceId,
118
368
  providerSessionId,
119
- });
120
- this.syncAllPeerSenders();
369
+ }) ?? Promise.resolve());
370
+ this.requestSyncAllPeerSenders();
121
371
  return track;
122
372
  }
123
373
  async enableVideo(constraints = true) {
@@ -127,12 +377,12 @@ export class RoomP2PMediaTransport {
127
377
  }
128
378
  const providerSessionId = await this.ensureConnectedMemberId();
129
379
  this.rememberLocalTrack('video', track, track.getSettings().deviceId, true);
130
- await this.room.media.video.enable?.({
380
+ await this.withRateLimitRetry('enable video', () => this.room.media.video.enable?.({
131
381
  trackId: track.id,
132
382
  deviceId: track.getSettings().deviceId,
133
383
  providerSessionId,
134
- });
135
- this.syncAllPeerSenders();
384
+ }) ?? Promise.resolve());
385
+ this.requestSyncAllPeerSenders();
136
386
  return track;
137
387
  }
138
388
  async startScreenShare(constraints = { video: true, audio: false }) {
@@ -150,28 +400,28 @@ export class RoomP2PMediaTransport {
150
400
  }, { once: true });
151
401
  const providerSessionId = await this.ensureConnectedMemberId();
152
402
  this.rememberLocalTrack('screen', track, track.getSettings().deviceId, true);
153
- await this.room.media.screen.start?.({
403
+ await this.withRateLimitRetry('start screen share', () => this.room.media.screen.start?.({
154
404
  trackId: track.id,
155
405
  deviceId: track.getSettings().deviceId,
156
406
  providerSessionId,
157
- });
158
- this.syncAllPeerSenders();
407
+ }) ?? Promise.resolve());
408
+ this.requestSyncAllPeerSenders();
159
409
  return track;
160
410
  }
161
411
  async disableAudio() {
162
412
  this.releaseLocalTrack('audio');
163
- this.syncAllPeerSenders();
164
- await this.room.media.audio.disable();
413
+ this.requestSyncAllPeerSenders();
414
+ await this.withRateLimitRetry('disable audio', () => this.room.media.audio.disable());
165
415
  }
166
416
  async disableVideo() {
167
417
  this.releaseLocalTrack('video');
168
- this.syncAllPeerSenders();
169
- await this.room.media.video.disable();
418
+ this.requestSyncAllPeerSenders();
419
+ await this.withRateLimitRetry('disable video', () => this.room.media.video.disable());
170
420
  }
171
421
  async stopScreenShare() {
172
422
  this.releaseLocalTrack('screen');
173
- this.syncAllPeerSenders();
174
- await this.room.media.screen.stop();
423
+ this.requestSyncAllPeerSenders();
424
+ await this.withRateLimitRetry('stop screen share', () => this.room.media.screen.stop());
175
425
  }
176
426
  async setMuted(kind, muted) {
177
427
  const localTrack = this.localTracks.get(kind)?.track;
@@ -179,10 +429,23 @@ export class RoomP2PMediaTransport {
179
429
  localTrack.enabled = !muted;
180
430
  }
181
431
  if (kind === 'audio') {
182
- await this.room.media.audio.setMuted?.(muted);
432
+ await this.withRateLimitRetry('set audio muted', () => this.room.media.audio.setMuted?.(muted) ?? Promise.resolve());
183
433
  }
184
434
  else {
185
- await this.room.media.video.setMuted?.(muted);
435
+ await this.withRateLimitRetry('set video muted', () => this.room.media.video.setMuted?.(muted) ?? Promise.resolve());
436
+ }
437
+ }
438
+ async batchLocalUpdates(callback) {
439
+ this.localUpdateBatchDepth += 1;
440
+ try {
441
+ return await callback();
442
+ }
443
+ finally {
444
+ this.localUpdateBatchDepth = Math.max(0, this.localUpdateBatchDepth - 1);
445
+ if (this.localUpdateBatchDepth === 0 && this.syncAllPeerSendersPending) {
446
+ this.syncAllPeerSendersPending = false;
447
+ this.requestSyncAllPeerSenders();
448
+ }
186
449
  }
187
450
  }
188
451
  async switchDevices(payload) {
@@ -213,6 +476,10 @@ export class RoomP2PMediaTransport {
213
476
  destroy() {
214
477
  this.connected = false;
215
478
  this.localMemberId = null;
479
+ if (this.healthCheckTimer != null) {
480
+ globalThis.clearInterval(this.healthCheckTimer);
481
+ this.healthCheckTimer = null;
482
+ }
216
483
  for (const subscription of this.subscriptions.splice(0)) {
217
484
  subscription.unsubscribe();
218
485
  }
@@ -223,6 +490,12 @@ export class RoomP2PMediaTransport {
223
490
  for (const kind of Array.from(this.localTracks.keys())) {
224
491
  this.releaseLocalTrack(kind);
225
492
  }
493
+ for (const pending of this.pendingIceCandidates.values()) {
494
+ if (pending.timer) {
495
+ clearTimeout(pending.timer);
496
+ }
497
+ }
498
+ this.pendingIceCandidates.clear();
226
499
  this.remoteTrackKinds.clear();
227
500
  this.emittedRemoteTracks.clear();
228
501
  this.pendingRemoteTracks.clear();
@@ -234,6 +507,7 @@ export class RoomP2PMediaTransport {
234
507
  this.subscriptions.push(this.room.members.onJoin((member) => {
235
508
  if (member.memberId !== this.localMemberId) {
236
509
  this.ensurePeer(member.memberId);
510
+ this.schedulePeerRecoveryCheck(member.memberId, 'member-join');
237
511
  }
238
512
  }), this.room.members.onSync((members) => {
239
513
  const activeMemberIds = new Set();
@@ -241,6 +515,7 @@ export class RoomP2PMediaTransport {
241
515
  if (member.memberId !== this.localMemberId) {
242
516
  activeMemberIds.add(member.memberId);
243
517
  this.ensurePeer(member.memberId);
518
+ this.schedulePeerRecoveryCheck(member.memberId, 'member-sync');
244
519
  }
245
520
  }
246
521
  for (const memberId of Array.from(this.peers.keys())) {
@@ -259,6 +534,7 @@ export class RoomP2PMediaTransport {
259
534
  }), this.room.media.onTrack((track, member) => {
260
535
  if (member.memberId !== this.localMemberId) {
261
536
  this.ensurePeer(member.memberId);
537
+ this.schedulePeerRecoveryCheck(member.memberId, 'media-track');
262
538
  }
263
539
  this.rememberRemoteTrackKind(track, member);
264
540
  }), this.room.media.onTrackRemoved((track, member) => {
@@ -268,7 +544,17 @@ export class RoomP2PMediaTransport {
268
544
  this.remoteTrackKinds.delete(key);
269
545
  this.emittedRemoteTracks.delete(key);
270
546
  this.pendingRemoteTracks.delete(key);
547
+ this.schedulePeerRecoveryCheck(member.memberId, 'media-track-removed');
271
548
  }));
549
+ if (typeof this.room.media.onStateChange === 'function') {
550
+ this.subscriptions.push(this.room.media.onStateChange((member, state) => {
551
+ if (member.memberId === this.localMemberId) {
552
+ return;
553
+ }
554
+ this.rememberRemoteTrackKindsFromState(member, state);
555
+ this.schedulePeerRecoveryCheck(member.memberId, 'media-state');
556
+ }));
557
+ }
272
558
  }
273
559
  hydrateRemoteTrackKinds() {
274
560
  this.remoteTrackKinds.clear();
@@ -278,6 +564,32 @@ export class RoomP2PMediaTransport {
278
564
  for (const track of mediaMember.tracks) {
279
565
  this.rememberRemoteTrackKind(track, mediaMember.member);
280
566
  }
567
+ this.rememberRemoteTrackKindsFromState(mediaMember.member, mediaMember.state);
568
+ }
569
+ }
570
+ rememberRemoteTrackKindsFromState(member, state) {
571
+ if (member.memberId === this.localMemberId || !state) {
572
+ return;
573
+ }
574
+ const mediaKinds = ['audio', 'video', 'screen'];
575
+ for (const kind of mediaKinds) {
576
+ const kindState = state[kind];
577
+ if (!kindState?.published) {
578
+ continue;
579
+ }
580
+ if (typeof kindState.trackId === 'string' && kindState.trackId) {
581
+ this.rememberRemoteTrackKind({
582
+ kind,
583
+ trackId: kindState.trackId,
584
+ muted: kindState.muted === true,
585
+ deviceId: kindState.deviceId,
586
+ publishedAt: kindState.publishedAt,
587
+ adminDisabled: kindState.adminDisabled,
588
+ providerSessionId: kindState.providerSessionId,
589
+ }, member);
590
+ continue;
591
+ }
592
+ this.flushPendingRemoteTracks(member.memberId, kind);
281
593
  }
282
594
  }
283
595
  rememberRemoteTrackKind(track, member) {
@@ -310,17 +622,32 @@ export class RoomP2PMediaTransport {
310
622
  isSettingRemoteAnswerPending: false,
311
623
  pendingCandidates: [],
312
624
  senders: new Map(),
625
+ pendingNegotiation: false,
626
+ recoveryAttempts: 0,
627
+ recoveryTimer: null,
628
+ healthCheckInFlight: false,
629
+ remoteVideoFlows: new Map(),
313
630
  };
314
631
  pc.onicecandidate = (event) => {
315
- if (!event.candidate)
632
+ if (!event.candidate) {
633
+ void this.flushPendingIceCandidates(memberId);
316
634
  return;
317
- void this.room.signals.sendTo(memberId, this.iceEvent, {
318
- candidate: serializeCandidate(event.candidate),
319
- });
635
+ }
636
+ this.queueIceCandidate(memberId, serializeCandidate(event.candidate));
320
637
  };
321
638
  pc.onnegotiationneeded = () => {
322
639
  void this.negotiatePeer(peer);
323
640
  };
641
+ pc.onsignalingstatechange = () => {
642
+ this.maybeRetryPendingNegotiation(peer);
643
+ };
644
+ pc.oniceconnectionstatechange = () => {
645
+ this.handlePeerConnectivityChange(peer, 'ice');
646
+ };
647
+ pc.onconnectionstatechange = () => {
648
+ this.handlePeerConnectivityChange(peer, 'connection');
649
+ this.maybeRetryPendingNegotiation(peer);
650
+ };
324
651
  pc.ontrack = (event) => {
325
652
  const stream = event.streams[0] ?? new MediaStream([event.track]);
326
653
  const key = buildTrackKey(memberId, event.track.id);
@@ -332,26 +659,33 @@ export class RoomP2PMediaTransport {
332
659
  return;
333
660
  }
334
661
  this.emitRemoteTrack(memberId, event.track, stream, kind);
662
+ this.registerPeerRemoteTrack(peer, event.track, kind);
663
+ this.resetPeerRecovery(peer);
335
664
  };
336
665
  this.peers.set(memberId, peer);
337
666
  this.syncPeerSenders(peer);
667
+ this.schedulePeerRecoveryCheck(memberId, 'peer-created');
338
668
  return peer;
339
669
  }
340
670
  async negotiatePeer(peer) {
341
671
  if (!this.connected
342
- || peer.pc.connectionState === 'closed'
343
- || peer.makingOffer
672
+ || peer.pc.connectionState === 'closed') {
673
+ return;
674
+ }
675
+ if (peer.makingOffer
344
676
  || peer.isSettingRemoteAnswerPending
345
677
  || peer.pc.signalingState !== 'stable') {
678
+ peer.pendingNegotiation = true;
346
679
  return;
347
680
  }
348
681
  try {
682
+ peer.pendingNegotiation = false;
349
683
  peer.makingOffer = true;
350
684
  await peer.pc.setLocalDescription();
351
685
  if (!peer.pc.localDescription) {
352
686
  return;
353
687
  }
354
- await this.room.signals.sendTo(peer.memberId, this.offerEvent, {
688
+ await this.sendSignalWithRetry(peer.memberId, this.offerEvent, {
355
689
  description: serializeDescription(peer.pc.localDescription),
356
690
  });
357
691
  }
@@ -383,6 +717,17 @@ export class RoomP2PMediaTransport {
383
717
  return;
384
718
  }
385
719
  try {
720
+ if (description.type === 'answer'
721
+ && peer.pc.signalingState === 'stable'
722
+ && !peer.isSettingRemoteAnswerPending) {
723
+ return;
724
+ }
725
+ if (description.type === 'offer'
726
+ && offerCollision
727
+ && peer.polite
728
+ && peer.pc.signalingState !== 'stable') {
729
+ await peer.pc.setLocalDescription({ type: 'rollback' });
730
+ }
386
731
  peer.isSettingRemoteAnswerPending = description.type === 'answer';
387
732
  await peer.pc.setRemoteDescription(description);
388
733
  peer.isSettingRemoteAnswerPending = false;
@@ -393,12 +738,15 @@ export class RoomP2PMediaTransport {
393
738
  if (!peer.pc.localDescription) {
394
739
  return;
395
740
  }
396
- await this.room.signals.sendTo(senderId, this.answerEvent, {
741
+ await this.sendSignalWithRetry(senderId, this.answerEvent, {
397
742
  description: serializeDescription(peer.pc.localDescription),
398
743
  });
399
744
  }
400
745
  }
401
746
  catch (error) {
747
+ if (description.type === 'answer' && peer.pc.signalingState === 'stable' && isStableAnswerError(error)) {
748
+ return;
749
+ }
402
750
  console.warn('[RoomP2PMediaTransport] Failed to apply remote session description.', {
403
751
  memberId: senderId,
404
752
  expectedType,
@@ -407,31 +755,36 @@ export class RoomP2PMediaTransport {
407
755
  });
408
756
  peer.isSettingRemoteAnswerPending = false;
409
757
  }
758
+ finally {
759
+ this.maybeRetryPendingNegotiation(peer);
760
+ }
410
761
  }
411
762
  async handleIceSignal(payload, meta) {
412
763
  const senderId = typeof meta.memberId === 'string' && meta.memberId.trim() ? meta.memberId.trim() : '';
413
764
  if (!senderId || senderId === this.localMemberId) {
414
765
  return;
415
766
  }
416
- const candidate = this.normalizeCandidate(payload);
417
- if (!candidate) {
767
+ const candidates = this.normalizeCandidates(payload);
768
+ if (candidates.length === 0) {
418
769
  return;
419
770
  }
420
771
  const peer = this.ensurePeer(senderId);
421
772
  if (!peer.pc.remoteDescription) {
422
- peer.pendingCandidates.push(candidate);
773
+ peer.pendingCandidates.push(...candidates);
423
774
  return;
424
775
  }
425
- try {
426
- await peer.pc.addIceCandidate(candidate);
427
- }
428
- catch (error) {
429
- console.warn('[RoomP2PMediaTransport] Failed to add ICE candidate.', {
430
- memberId: senderId,
431
- error,
432
- });
433
- if (!peer.ignoreOffer) {
434
- peer.pendingCandidates.push(candidate);
776
+ for (const candidate of candidates) {
777
+ try {
778
+ await peer.pc.addIceCandidate(candidate);
779
+ }
780
+ catch (error) {
781
+ console.warn('[RoomP2PMediaTransport] Failed to add ICE candidate.', {
782
+ memberId: senderId,
783
+ error,
784
+ });
785
+ if (!peer.ignoreOffer) {
786
+ peer.pendingCandidates.push(candidate);
787
+ }
435
788
  }
436
789
  }
437
790
  }
@@ -456,6 +809,72 @@ export class RoomP2PMediaTransport {
456
809
  }
457
810
  }
458
811
  }
812
+ queueIceCandidate(memberId, candidate) {
813
+ let pending = this.pendingIceCandidates.get(memberId);
814
+ if (!pending) {
815
+ pending = {
816
+ candidates: [],
817
+ timer: null,
818
+ flushing: false,
819
+ };
820
+ this.pendingIceCandidates.set(memberId, pending);
821
+ }
822
+ pending.candidates.push(candidate);
823
+ if (pending.timer || pending.flushing) {
824
+ return;
825
+ }
826
+ pending.timer = globalThis.setTimeout(() => {
827
+ pending.timer = null;
828
+ void this.flushPendingIceCandidates(memberId);
829
+ }, DEFAULT_ICE_BATCH_DELAY_MS);
830
+ }
831
+ async flushPendingIceCandidates(memberId) {
832
+ const pending = this.pendingIceCandidates.get(memberId);
833
+ if (!pending || pending.flushing) {
834
+ return;
835
+ }
836
+ if (pending.timer) {
837
+ clearTimeout(pending.timer);
838
+ pending.timer = null;
839
+ }
840
+ if (pending.candidates.length === 0) {
841
+ this.pendingIceCandidates.delete(memberId);
842
+ return;
843
+ }
844
+ const batch = pending.candidates.splice(0);
845
+ pending.flushing = true;
846
+ try {
847
+ await this.sendSignalWithRetry(memberId, this.iceEvent, { candidates: batch });
848
+ }
849
+ finally {
850
+ pending.flushing = false;
851
+ if (pending.candidates.length > 0) {
852
+ pending.timer = globalThis.setTimeout(() => {
853
+ pending.timer = null;
854
+ void this.flushPendingIceCandidates(memberId);
855
+ }, 0);
856
+ }
857
+ else {
858
+ this.pendingIceCandidates.delete(memberId);
859
+ }
860
+ }
861
+ }
862
+ requestSyncAllPeerSenders() {
863
+ if (this.localUpdateBatchDepth > 0) {
864
+ this.syncAllPeerSendersPending = true;
865
+ return;
866
+ }
867
+ if (this.syncAllPeerSendersScheduled) {
868
+ this.syncAllPeerSendersPending = true;
869
+ return;
870
+ }
871
+ this.syncAllPeerSendersScheduled = true;
872
+ queueMicrotask(() => {
873
+ this.syncAllPeerSendersScheduled = false;
874
+ this.syncAllPeerSendersPending = false;
875
+ this.syncAllPeerSenders();
876
+ });
877
+ }
459
878
  syncAllPeerSenders() {
460
879
  for (const peer of this.peers.values()) {
461
880
  this.syncPeerSenders(peer);
@@ -515,6 +934,15 @@ export class RoomP2PMediaTransport {
515
934
  for (const handler of this.remoteTrackHandlers) {
516
935
  handler(payload);
517
936
  }
937
+ const peer = this.peers.get(memberId);
938
+ if (peer) {
939
+ if (!this.hasMissingPublishedMedia(memberId)) {
940
+ this.resetPeerRecovery(peer);
941
+ }
942
+ else {
943
+ this.schedulePeerRecoveryCheck(memberId, 'partial-remote-track');
944
+ }
945
+ }
518
946
  }
519
947
  resolveFallbackRemoteTrackKind(memberId, track) {
520
948
  const normalizedKind = normalizeTrackKind(track);
@@ -548,6 +976,11 @@ export class RoomP2PMediaTransport {
548
976
  publishedKinds.add(track.kind);
549
977
  }
550
978
  }
979
+ for (const kind of getPublishedKindsFromState(mediaMember.state)) {
980
+ if (kind === 'video' || kind === 'screen') {
981
+ publishedKinds.add(kind);
982
+ }
983
+ }
551
984
  return Array.from(publishedKinds);
552
985
  }
553
986
  getNextUnassignedPublishedVideoLikeKind(memberId) {
@@ -598,6 +1031,10 @@ export class RoomP2PMediaTransport {
598
1031
  rollbackConnectedState() {
599
1032
  this.connected = false;
600
1033
  this.localMemberId = null;
1034
+ if (this.healthCheckTimer != null) {
1035
+ globalThis.clearInterval(this.healthCheckTimer);
1036
+ this.healthCheckTimer = null;
1037
+ }
601
1038
  for (const subscription of this.subscriptions.splice(0)) {
602
1039
  subscription.unsubscribe();
603
1040
  }
@@ -605,13 +1042,27 @@ export class RoomP2PMediaTransport {
605
1042
  this.destroyPeer(peer);
606
1043
  }
607
1044
  this.peers.clear();
1045
+ for (const pending of this.pendingIceCandidates.values()) {
1046
+ if (pending.timer) {
1047
+ clearTimeout(pending.timer);
1048
+ }
1049
+ }
1050
+ this.pendingIceCandidates.clear();
608
1051
  this.remoteTrackKinds.clear();
609
1052
  this.emittedRemoteTracks.clear();
610
1053
  this.pendingRemoteTracks.clear();
611
1054
  }
612
1055
  destroyPeer(peer) {
1056
+ this.clearPeerRecoveryTimer(peer);
1057
+ for (const flow of peer.remoteVideoFlows.values()) {
1058
+ flow.cleanup();
1059
+ }
1060
+ peer.remoteVideoFlows.clear();
613
1061
  peer.pc.onicecandidate = null;
614
1062
  peer.pc.onnegotiationneeded = null;
1063
+ peer.pc.onsignalingstatechange = null;
1064
+ peer.pc.oniceconnectionstatechange = null;
1065
+ peer.pc.onconnectionstatechange = null;
615
1066
  peer.pc.ontrack = null;
616
1067
  try {
617
1068
  peer.pc.close();
@@ -620,6 +1071,147 @@ export class RoomP2PMediaTransport {
620
1071
  // Ignore duplicate closes.
621
1072
  }
622
1073
  }
1074
+ startHealthChecks() {
1075
+ if (this.healthCheckTimer != null) {
1076
+ return;
1077
+ }
1078
+ this.healthCheckTimer = globalThis.setInterval(() => {
1079
+ void this.runHealthChecks();
1080
+ }, this.options.mediaHealthCheckIntervalMs);
1081
+ }
1082
+ async runHealthChecks() {
1083
+ if (!this.connected) {
1084
+ return;
1085
+ }
1086
+ for (const peer of this.peers.values()) {
1087
+ if (peer.healthCheckInFlight || peer.pc.connectionState === 'closed') {
1088
+ continue;
1089
+ }
1090
+ peer.healthCheckInFlight = true;
1091
+ try {
1092
+ const issue = await this.inspectPeerVideoHealth(peer);
1093
+ if (issue) {
1094
+ this.schedulePeerRecoveryCheck(peer.memberId, issue, 0);
1095
+ }
1096
+ }
1097
+ finally {
1098
+ peer.healthCheckInFlight = false;
1099
+ }
1100
+ }
1101
+ }
1102
+ registerPeerRemoteTrack(peer, track, kind) {
1103
+ if (kind !== 'video' && kind !== 'screen') {
1104
+ return;
1105
+ }
1106
+ if (peer.remoteVideoFlows.has(track.id)) {
1107
+ return;
1108
+ }
1109
+ const flow = {
1110
+ track,
1111
+ receivedAt: Date.now(),
1112
+ lastHealthyAt: track.muted ? 0 : Date.now(),
1113
+ lastBytesReceived: null,
1114
+ lastFramesDecoded: null,
1115
+ cleanup: () => { },
1116
+ };
1117
+ const markHealthy = () => {
1118
+ flow.lastHealthyAt = Date.now();
1119
+ };
1120
+ const handleEnded = () => {
1121
+ flow.cleanup();
1122
+ peer.remoteVideoFlows.delete(track.id);
1123
+ };
1124
+ track.addEventListener('unmute', markHealthy);
1125
+ track.addEventListener('ended', handleEnded);
1126
+ flow.cleanup = () => {
1127
+ track.removeEventListener('unmute', markHealthy);
1128
+ track.removeEventListener('ended', handleEnded);
1129
+ };
1130
+ peer.remoteVideoFlows.set(track.id, flow);
1131
+ }
1132
+ async inspectPeerVideoHealth(peer) {
1133
+ if (this.hasMissingPublishedMedia(peer.memberId)) {
1134
+ return 'health-missing-published-media';
1135
+ }
1136
+ const mediaMember = this.room.media.list().find((entry) => entry.member.memberId === peer.memberId);
1137
+ const publishedVideoState = mediaMember?.state?.video;
1138
+ const publishedScreenState = mediaMember?.state?.screen;
1139
+ const publishedAt = Math.max(publishedVideoState?.publishedAt ?? 0, publishedScreenState?.publishedAt ?? 0);
1140
+ const expectsVideoFlow = Boolean(publishedVideoState?.published
1141
+ || publishedScreenState?.published
1142
+ || mediaMember?.tracks.some((track) => (track.kind === 'video' || track.kind === 'screen') && track.trackId));
1143
+ if (!expectsVideoFlow) {
1144
+ return null;
1145
+ }
1146
+ const videoReceivers = peer.pc
1147
+ .getReceivers()
1148
+ .filter((receiver) => receiver.track?.kind === 'video');
1149
+ if (videoReceivers.length === 0) {
1150
+ const firstObservedAt = Math.max(publishedAt, ...Array.from(peer.remoteVideoFlows.values()).map((flow) => flow.receivedAt));
1151
+ if (firstObservedAt > 0 && Date.now() - firstObservedAt > this.options.videoFlowGraceMs) {
1152
+ return 'health-no-video-receiver';
1153
+ }
1154
+ return null;
1155
+ }
1156
+ let sawHealthyFlow = false;
1157
+ let lastObservedAt = publishedAt;
1158
+ for (const receiver of videoReceivers) {
1159
+ const track = receiver.track;
1160
+ if (!track) {
1161
+ continue;
1162
+ }
1163
+ const flow = peer.remoteVideoFlows.get(track.id);
1164
+ if (!flow) {
1165
+ continue;
1166
+ }
1167
+ lastObservedAt = Math.max(lastObservedAt, flow.receivedAt, flow.lastHealthyAt);
1168
+ if (!track.muted) {
1169
+ flow.lastHealthyAt = Math.max(flow.lastHealthyAt, Date.now());
1170
+ }
1171
+ try {
1172
+ const stats = await receiver.getStats();
1173
+ for (const report of stats.values()) {
1174
+ if (report.type !== 'inbound-rtp' || report.kind !== 'video') {
1175
+ continue;
1176
+ }
1177
+ const bytesReceived = typeof report.bytesReceived === 'number' ? report.bytesReceived : null;
1178
+ const framesDecoded = typeof report.framesDecoded === 'number' ? report.framesDecoded : null;
1179
+ const bytesIncreased = bytesReceived != null
1180
+ && flow.lastBytesReceived != null
1181
+ && bytesReceived > flow.lastBytesReceived;
1182
+ const framesIncreased = framesDecoded != null
1183
+ && flow.lastFramesDecoded != null
1184
+ && framesDecoded > flow.lastFramesDecoded;
1185
+ if ((bytesReceived != null && bytesReceived > 0) || (framesDecoded != null && framesDecoded > 0)) {
1186
+ flow.lastHealthyAt = Math.max(flow.lastHealthyAt, Date.now());
1187
+ }
1188
+ if (bytesIncreased || framesIncreased) {
1189
+ flow.lastHealthyAt = Date.now();
1190
+ }
1191
+ flow.lastBytesReceived = bytesReceived;
1192
+ flow.lastFramesDecoded = framesDecoded;
1193
+ break;
1194
+ }
1195
+ }
1196
+ catch {
1197
+ // Ignore stats read failures and rely on track state.
1198
+ }
1199
+ if (flow.lastHealthyAt > 0) {
1200
+ sawHealthyFlow = true;
1201
+ }
1202
+ lastObservedAt = Math.max(lastObservedAt, flow.lastHealthyAt);
1203
+ }
1204
+ if (sawHealthyFlow) {
1205
+ return null;
1206
+ }
1207
+ if (lastObservedAt > 0 && Date.now() - lastObservedAt > this.options.videoFlowStallGraceMs) {
1208
+ return 'health-stalled-video-flow';
1209
+ }
1210
+ if (publishedAt > 0 && Date.now() - publishedAt > this.options.videoFlowGraceMs) {
1211
+ return 'health-video-flow-timeout';
1212
+ }
1213
+ return null;
1214
+ }
623
1215
  async createUserMediaTrack(kind, constraints) {
624
1216
  const devices = this.options.mediaDevices;
625
1217
  if (!devices?.getUserMedia || constraints === false) {
@@ -667,15 +1259,44 @@ export class RoomP2PMediaTransport {
667
1259
  sdp: typeof raw.sdp === 'string' ? raw.sdp : undefined,
668
1260
  };
669
1261
  }
670
- normalizeCandidate(payload) {
1262
+ normalizeCandidates(payload) {
671
1263
  if (!payload || typeof payload !== 'object') {
672
- return null;
1264
+ return [];
1265
+ }
1266
+ const batch = payload.candidates;
1267
+ if (Array.isArray(batch)) {
1268
+ return batch.filter((candidate) => !!candidate && typeof candidate.candidate === 'string');
673
1269
  }
674
1270
  const raw = payload.candidate;
675
1271
  if (!raw || typeof raw.candidate !== 'string') {
676
- return null;
1272
+ return [];
1273
+ }
1274
+ return [raw];
1275
+ }
1276
+ async sendSignalWithRetry(memberId, event, payload) {
1277
+ await this.withRateLimitRetry(`signal ${event}`, () => this.room.signals.sendTo(memberId, event, payload));
1278
+ }
1279
+ async withRateLimitRetry(label, action) {
1280
+ let lastError;
1281
+ for (let attempt = 0; attempt <= DEFAULT_RATE_LIMIT_RETRY_DELAYS_MS.length; attempt += 1) {
1282
+ try {
1283
+ return await action();
1284
+ }
1285
+ catch (error) {
1286
+ lastError = error;
1287
+ if (!isRateLimitError(error) || attempt === DEFAULT_RATE_LIMIT_RETRY_DELAYS_MS.length) {
1288
+ throw error;
1289
+ }
1290
+ const delayMs = DEFAULT_RATE_LIMIT_RETRY_DELAYS_MS[attempt];
1291
+ console.warn('[RoomP2PMediaTransport] Rate limited room operation. Retrying.', {
1292
+ label,
1293
+ attempt: attempt + 1,
1294
+ delayMs,
1295
+ });
1296
+ await new Promise((resolve) => globalThis.setTimeout(resolve, delayMs));
1297
+ }
677
1298
  }
678
- return raw;
1299
+ throw lastError;
679
1300
  }
680
1301
  get offerEvent() {
681
1302
  return `${this.options.signalPrefix}.offer`;
@@ -686,5 +1307,169 @@ export class RoomP2PMediaTransport {
686
1307
  get iceEvent() {
687
1308
  return `${this.options.signalPrefix}.ice`;
688
1309
  }
1310
+ maybeRetryPendingNegotiation(peer) {
1311
+ if (!peer.pendingNegotiation
1312
+ || !this.connected
1313
+ || peer.pc.connectionState === 'closed'
1314
+ || peer.makingOffer
1315
+ || peer.isSettingRemoteAnswerPending
1316
+ || peer.pc.signalingState !== 'stable') {
1317
+ return;
1318
+ }
1319
+ peer.pendingNegotiation = false;
1320
+ queueMicrotask(() => {
1321
+ void this.negotiatePeer(peer);
1322
+ });
1323
+ }
1324
+ handlePeerConnectivityChange(peer, source) {
1325
+ if (!this.connected || peer.pc.connectionState === 'closed') {
1326
+ return;
1327
+ }
1328
+ const connectionState = peer.pc.connectionState;
1329
+ const iceConnectionState = peer.pc.iceConnectionState;
1330
+ if (connectionState === 'connected'
1331
+ || iceConnectionState === 'connected'
1332
+ || iceConnectionState === 'completed') {
1333
+ this.resetPeerRecovery(peer);
1334
+ return;
1335
+ }
1336
+ if (connectionState === 'failed' || iceConnectionState === 'failed') {
1337
+ this.schedulePeerRecoveryCheck(peer.memberId, `${source}-failed`, 0);
1338
+ return;
1339
+ }
1340
+ if (connectionState === 'disconnected' || iceConnectionState === 'disconnected') {
1341
+ this.schedulePeerRecoveryCheck(peer.memberId, `${source}-disconnected`, this.options.disconnectedRecoveryDelayMs);
1342
+ }
1343
+ }
1344
+ schedulePeerRecoveryCheck(memberId, reason, delayMs = this.options.missingMediaGraceMs) {
1345
+ const peer = this.peers.get(memberId);
1346
+ if (!peer || !this.connected || peer.pc.connectionState === 'closed') {
1347
+ return;
1348
+ }
1349
+ const healthSensitiveReason = reason.includes('health')
1350
+ || reason.includes('stalled')
1351
+ || reason.includes('flow');
1352
+ if (!this.hasMissingPublishedMedia(memberId)
1353
+ && !healthSensitiveReason
1354
+ && !reason.includes('failed')
1355
+ && !reason.includes('disconnected')) {
1356
+ this.resetPeerRecovery(peer);
1357
+ return;
1358
+ }
1359
+ this.clearPeerRecoveryTimer(peer);
1360
+ peer.recoveryTimer = globalThis.setTimeout(() => {
1361
+ peer.recoveryTimer = null;
1362
+ void this.recoverPeer(peer, reason);
1363
+ }, Math.max(0, delayMs));
1364
+ }
1365
+ async recoverPeer(peer, reason) {
1366
+ if (!this.connected || peer.pc.connectionState === 'closed') {
1367
+ return;
1368
+ }
1369
+ const stillMissingPublishedMedia = this.hasMissingPublishedMedia(peer.memberId);
1370
+ const connectivityIssue = peer.pc.connectionState === 'failed'
1371
+ || peer.pc.connectionState === 'disconnected'
1372
+ || peer.pc.iceConnectionState === 'failed'
1373
+ || peer.pc.iceConnectionState === 'disconnected';
1374
+ const healthIssue = !stillMissingPublishedMedia && !connectivityIssue
1375
+ ? await this.inspectPeerVideoHealth(peer)
1376
+ : null;
1377
+ if (!stillMissingPublishedMedia && !connectivityIssue && !healthIssue) {
1378
+ this.resetPeerRecovery(peer);
1379
+ return;
1380
+ }
1381
+ if (peer.recoveryAttempts >= this.options.maxRecoveryAttempts) {
1382
+ this.resetPeer(peer.memberId, reason);
1383
+ return;
1384
+ }
1385
+ peer.recoveryAttempts += 1;
1386
+ this.requestIceRestart(peer, reason);
1387
+ }
1388
+ requestIceRestart(peer, reason) {
1389
+ try {
1390
+ if (typeof peer.pc.restartIce === 'function') {
1391
+ peer.pc.restartIce();
1392
+ }
1393
+ }
1394
+ catch (error) {
1395
+ console.warn('[RoomP2PMediaTransport] Failed to request ICE restart.', {
1396
+ memberId: peer.memberId,
1397
+ reason,
1398
+ error,
1399
+ });
1400
+ }
1401
+ peer.pendingNegotiation = true;
1402
+ this.maybeRetryPendingNegotiation(peer);
1403
+ }
1404
+ resetPeer(memberId, reason) {
1405
+ const existing = this.peers.get(memberId);
1406
+ if (existing) {
1407
+ this.destroyPeer(existing);
1408
+ this.peers.delete(memberId);
1409
+ }
1410
+ const replacement = this.ensurePeer(memberId);
1411
+ replacement.recoveryAttempts = 0;
1412
+ replacement.pendingNegotiation = true;
1413
+ this.maybeRetryPendingNegotiation(replacement);
1414
+ this.schedulePeerRecoveryCheck(memberId, `${reason}:after-reset`);
1415
+ }
1416
+ resetPeerRecovery(peer) {
1417
+ peer.recoveryAttempts = 0;
1418
+ peer.pendingNegotiation = false;
1419
+ this.clearPeerRecoveryTimer(peer);
1420
+ }
1421
+ clearPeerRecoveryTimer(peer) {
1422
+ if (peer.recoveryTimer != null) {
1423
+ globalThis.clearTimeout(peer.recoveryTimer);
1424
+ peer.recoveryTimer = null;
1425
+ }
1426
+ }
1427
+ hasMissingPublishedMedia(memberId) {
1428
+ const mediaMember = this.room.media.list().find((entry) => entry.member.memberId === memberId);
1429
+ if (!mediaMember) {
1430
+ return false;
1431
+ }
1432
+ const publishedKinds = new Set();
1433
+ for (const track of mediaMember.tracks) {
1434
+ if (track.trackId) {
1435
+ publishedKinds.add(track.kind);
1436
+ }
1437
+ }
1438
+ for (const kind of getPublishedKindsFromState(mediaMember.state)) {
1439
+ publishedKinds.add(kind);
1440
+ }
1441
+ const emittedKinds = new Set();
1442
+ for (const key of this.emittedRemoteTracks) {
1443
+ if (!key.startsWith(`${memberId}:`)) {
1444
+ continue;
1445
+ }
1446
+ const kind = this.remoteTrackKinds.get(key);
1447
+ if (kind) {
1448
+ emittedKinds.add(kind);
1449
+ }
1450
+ }
1451
+ let pendingAudioCount = 0;
1452
+ let pendingVideoLikeCount = 0;
1453
+ for (const pending of this.pendingRemoteTracks.values()) {
1454
+ if (pending.memberId !== memberId) {
1455
+ continue;
1456
+ }
1457
+ if (pending.track.kind === 'audio') {
1458
+ pendingAudioCount += 1;
1459
+ }
1460
+ else if (pending.track.kind === 'video') {
1461
+ pendingVideoLikeCount += 1;
1462
+ }
1463
+ }
1464
+ if (publishedKinds.has('audio') && !emittedKinds.has('audio') && pendingAudioCount === 0) {
1465
+ return true;
1466
+ }
1467
+ const expectedVideoLikeKinds = Array.from(publishedKinds).filter((kind) => kind === 'video' || kind === 'screen');
1468
+ if (expectedVideoLikeKinds.length === 0) {
1469
+ return false;
1470
+ }
1471
+ const emittedVideoLikeCount = Array.from(emittedKinds).filter((kind) => kind === 'video' || kind === 'screen').length;
1472
+ return emittedVideoLikeCount + pendingVideoLikeCount < expectedVideoLikeKinds.length;
1473
+ }
689
1474
  }
690
1475
  //# sourceMappingURL=room-p2p-media.js.map