@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.
@@ -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.ensurePeer(member.memberId);
374
- this.schedulePeerRecoveryCheck(member.memberId, 'member-join');
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.ensurePeer(member.memberId);
382
- this.schedulePeerRecoveryCheck(member.memberId, 'member-sync');
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.removeRemoteMember(memberId);
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
- const key = buildTrackKey(member.memberId, track.trackId);
408
- this.remoteTrackKinds.delete(key);
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
- this.syncPeerSenders(existing);
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
- this.syncPeerSenders(peer);
531
- this.schedulePeerRecoveryCheck(memberId, 'peer-created');
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 (!this.connected
536
- || peer.pc.connectionState === 'closed') {
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
- try {
546
- peer.pendingNegotiation = false;
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
- await this.sendSignalWithRetry(peer.memberId, this.offerEvent, {
553
- description: serializeDescription(peer.pc.localDescription),
554
- });
555
- }
556
- catch (error) {
557
- console.warn('[RoomP2PMediaTransport] Failed to negotiate peer offer.', {
558
- memberId: peer.memberId,
559
- signalingState: peer.pc.signalingState,
560
- error,
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
- finally {
564
- peer.makingOffer = false;
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
- this.syncPeerSenders(peer);
601
- await peer.pc.setLocalDescription();
602
- if (!peer.pc.localDescription) {
603
- return;
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
- if (connectionState === 'connected'
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