@edge-base/web 0.2.6 → 0.2.8

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.
@@ -13,9 +13,22 @@ const DEFAULT_RATE_LIMIT_RETRY_DELAYS_MS = [160, 320, 640];
13
13
  const DEFAULT_MEDIA_HEALTH_CHECK_INTERVAL_MS = 4_000;
14
14
  const DEFAULT_VIDEO_FLOW_GRACE_MS = 8_000;
15
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;
16
22
  function buildTrackKey(memberId, trackId) {
17
23
  return `${memberId}:${trackId}`;
18
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
+ }
19
32
  function buildExactDeviceConstraint(deviceId) {
20
33
  return { deviceId: { exact: deviceId } };
21
34
  }
@@ -108,10 +121,15 @@ export class RoomP2PMediaTransport {
108
121
  localTracks = new Map();
109
122
  peers = new Map();
110
123
  remoteTrackHandlers = [];
124
+ remoteVideoStateHandlers = [];
111
125
  remoteTrackKinds = new Map();
112
126
  emittedRemoteTracks = new Set();
113
127
  pendingRemoteTracks = new Map();
128
+ pendingTrackRemovalTimers = new Map();
129
+ pendingSyncRemovalTimers = new Map();
130
+ pendingVideoPromotionTimers = new Map();
114
131
  pendingIceCandidates = new Map();
132
+ remoteVideoStreamCache = new Map();
115
133
  subscriptions = [];
116
134
  localMemberId = null;
117
135
  connected = false;
@@ -120,6 +138,10 @@ export class RoomP2PMediaTransport {
120
138
  syncAllPeerSendersScheduled = false;
121
139
  syncAllPeerSendersPending = false;
122
140
  healthCheckTimer = null;
141
+ negotiationTail = Promise.resolve();
142
+ remoteVideoStateSignature = '';
143
+ debugEvents = [];
144
+ debugEventCounter = 0;
123
145
  constructor(room, options) {
124
146
  this.room = room;
125
147
  this.options = {
@@ -141,6 +163,12 @@ export class RoomP2PMediaTransport {
141
163
  mediaHealthCheckIntervalMs: options?.mediaHealthCheckIntervalMs ?? DEFAULT_MEDIA_HEALTH_CHECK_INTERVAL_MS,
142
164
  videoFlowGraceMs: options?.videoFlowGraceMs ?? DEFAULT_VIDEO_FLOW_GRACE_MS,
143
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,
144
172
  };
145
173
  }
146
174
  getSessionId() {
@@ -156,6 +184,7 @@ export class RoomP2PMediaTransport {
156
184
  if (this.connected && this.localMemberId) {
157
185
  return this.localMemberId;
158
186
  }
187
+ this.recordDebugEvent('transport:connect');
159
188
  if (payload && typeof payload === 'object' && 'sessionDescription' in payload) {
160
189
  throw new Error('RoomP2PMediaTransport.connect() does not accept sessionDescription; use room.signals through the built-in transport instead.');
161
190
  }
@@ -186,6 +215,7 @@ export class RoomP2PMediaTransport {
186
215
  this.ensurePeer(member.memberId);
187
216
  }
188
217
  }
218
+ this.emitRemoteVideoStateChange(true);
189
219
  }
190
220
  catch (error) {
191
221
  this.rollbackConnectedState();
@@ -196,6 +226,139 @@ export class RoomP2PMediaTransport {
196
226
  async getCapabilities() {
197
227
  return this.collectCapabilities({ includeProviderChecks: true });
198
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
+ }
199
362
  async collectCapabilities(options) {
200
363
  const issues = [];
201
364
  const currentMember = this.room.members.current();
@@ -473,6 +636,67 @@ export class RoomP2PMediaTransport {
473
636
  }
474
637
  });
475
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
+ }
476
700
  destroy() {
477
701
  this.connected = false;
478
702
  this.localMemberId = null;
@@ -495,10 +719,24 @@ export class RoomP2PMediaTransport {
495
719
  clearTimeout(pending.timer);
496
720
  }
497
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();
498
734
  this.pendingIceCandidates.clear();
499
735
  this.remoteTrackKinds.clear();
500
736
  this.emittedRemoteTracks.clear();
501
737
  this.pendingRemoteTracks.clear();
738
+ this.remoteVideoStreamCache.clear();
739
+ this.emitRemoteVideoStateChange(true);
502
740
  }
503
741
  attachRoomSubscriptions() {
504
742
  if (this.subscriptions.length > 0) {
@@ -506,25 +744,29 @@ export class RoomP2PMediaTransport {
506
744
  }
507
745
  this.subscriptions.push(this.room.members.onJoin((member) => {
508
746
  if (member.memberId !== this.localMemberId) {
509
- this.ensurePeer(member.memberId);
510
- this.schedulePeerRecoveryCheck(member.memberId, 'member-join');
747
+ this.cancelPendingSyncRemoval(member.memberId);
748
+ this.ensurePeer(member.memberId, { passive: true });
749
+ this.emitRemoteVideoStateChange();
511
750
  }
512
751
  }), this.room.members.onSync((members) => {
513
752
  const activeMemberIds = new Set();
514
753
  for (const member of members) {
515
754
  if (member.memberId !== this.localMemberId) {
516
755
  activeMemberIds.add(member.memberId);
517
- this.ensurePeer(member.memberId);
518
- this.schedulePeerRecoveryCheck(member.memberId, 'member-sync');
756
+ this.cancelPendingSyncRemoval(member.memberId);
757
+ this.ensurePeer(member.memberId, { passive: true });
519
758
  }
520
759
  }
521
760
  for (const memberId of Array.from(this.peers.keys())) {
522
761
  if (!activeMemberIds.has(memberId)) {
523
- this.removeRemoteMember(memberId);
762
+ this.scheduleSyncRemoval(memberId);
524
763
  }
525
764
  }
765
+ this.emitRemoteVideoStateChange();
526
766
  }), this.room.members.onLeave((member) => {
767
+ this.cancelPendingSyncRemoval(member.memberId);
527
768
  this.removeRemoteMember(member.memberId);
769
+ this.emitRemoteVideoStateChange();
528
770
  }), this.room.signals.on(this.offerEvent, (payload, meta) => {
529
771
  void this.handleDescriptionSignal('offer', payload, meta);
530
772
  }), this.room.signals.on(this.answerEvent, (payload, meta) => {
@@ -533,26 +775,26 @@ export class RoomP2PMediaTransport {
533
775
  void this.handleIceSignal(payload, meta);
534
776
  }), this.room.media.onTrack((track, member) => {
535
777
  if (member.memberId !== this.localMemberId) {
536
- this.ensurePeer(member.memberId);
778
+ this.ensurePeer(member.memberId, { passive: true });
537
779
  this.schedulePeerRecoveryCheck(member.memberId, 'media-track');
538
780
  }
539
781
  this.rememberRemoteTrackKind(track, member);
782
+ this.emitRemoteVideoStateChange();
540
783
  }), this.room.media.onTrackRemoved((track, member) => {
541
784
  if (!track.trackId)
542
785
  return;
543
- const key = buildTrackKey(member.memberId, track.trackId);
544
- this.remoteTrackKinds.delete(key);
545
- this.emittedRemoteTracks.delete(key);
546
- this.pendingRemoteTracks.delete(key);
547
- this.schedulePeerRecoveryCheck(member.memberId, 'media-track-removed');
786
+ this.scheduleTrackRemoval(track, member);
787
+ this.emitRemoteVideoStateChange();
548
788
  }));
549
789
  if (typeof this.room.media.onStateChange === 'function') {
550
790
  this.subscriptions.push(this.room.media.onStateChange((member, state) => {
551
791
  if (member.memberId === this.localMemberId) {
552
792
  return;
553
793
  }
794
+ this.ensurePeer(member.memberId, { passive: true });
554
795
  this.rememberRemoteTrackKindsFromState(member, state);
555
796
  this.schedulePeerRecoveryCheck(member.memberId, 'media-state');
797
+ this.emitRemoteVideoStateChange();
556
798
  }));
557
799
  }
558
800
  }
@@ -601,15 +843,20 @@ export class RoomP2PMediaTransport {
601
843
  const pending = this.pendingRemoteTracks.get(key);
602
844
  if (pending) {
603
845
  this.pendingRemoteTracks.delete(key);
846
+ this.clearPendingVideoPromotionTimer(key);
604
847
  this.emitRemoteTrack(member.memberId, pending.track, pending.stream, track.kind);
605
848
  return;
606
849
  }
607
850
  this.flushPendingRemoteTracks(member.memberId, track.kind);
608
851
  }
609
- ensurePeer(memberId) {
852
+ ensurePeer(memberId, options) {
853
+ const passive = options?.passive === true;
610
854
  const existing = this.peers.get(memberId);
611
855
  if (existing) {
612
- this.syncPeerSenders(existing);
856
+ if (!passive) {
857
+ existing.bootstrapPassive = false;
858
+ this.syncPeerSenders(existing);
859
+ }
613
860
  return existing;
614
861
  }
615
862
  const pc = this.options.peerConnectionFactory(this.options.rtcConfiguration);
@@ -617,6 +864,7 @@ export class RoomP2PMediaTransport {
617
864
  memberId,
618
865
  pc,
619
866
  polite: !!this.localMemberId && this.localMemberId.localeCompare(memberId) > 0,
867
+ bootstrapPassive: passive,
620
868
  makingOffer: false,
621
869
  ignoreOffer: false,
622
870
  isSettingRemoteAnswerPending: false,
@@ -626,6 +874,9 @@ export class RoomP2PMediaTransport {
626
874
  recoveryAttempts: 0,
627
875
  recoveryTimer: null,
628
876
  healthCheckInFlight: false,
877
+ createdAt: Date.now(),
878
+ signalingStateChangedAt: Date.now(),
879
+ hasRemoteDescription: false,
629
880
  remoteVideoFlows: new Map(),
630
881
  };
631
882
  pc.onicecandidate = (event) => {
@@ -636,9 +887,13 @@ export class RoomP2PMediaTransport {
636
887
  this.queueIceCandidate(memberId, serializeCandidate(event.candidate));
637
888
  };
638
889
  pc.onnegotiationneeded = () => {
890
+ if (peer.bootstrapPassive && !peer.hasRemoteDescription && peer.pc.signalingState === 'stable') {
891
+ return;
892
+ }
639
893
  void this.negotiatePeer(peer);
640
894
  };
641
895
  pc.onsignalingstatechange = () => {
896
+ peer.signalingStateChangedAt = Date.now();
642
897
  this.maybeRetryPendingNegotiation(peer);
643
898
  };
644
899
  pc.oniceconnectionstatechange = () => {
@@ -656,49 +911,88 @@ export class RoomP2PMediaTransport {
656
911
  const kind = exactKind ?? fallbackKind ?? normalizeTrackKind(event.track);
657
912
  if (!kind || (!exactKind && !fallbackKind && kind === 'video' && event.track.kind === 'video')) {
658
913
  this.pendingRemoteTracks.set(key, { memberId, track: event.track, stream });
914
+ this.schedulePendingVideoPromotion(memberId, event.track, stream);
659
915
  return;
660
916
  }
917
+ this.clearPendingVideoPromotionTimer(key);
661
918
  this.emitRemoteTrack(memberId, event.track, stream, kind);
662
919
  this.registerPeerRemoteTrack(peer, event.track, kind);
663
920
  this.resetPeerRecovery(peer);
664
921
  };
665
922
  this.peers.set(memberId, peer);
666
- this.syncPeerSenders(peer);
667
- this.schedulePeerRecoveryCheck(memberId, 'peer-created');
923
+ if (!peer.bootstrapPassive) {
924
+ this.syncPeerSenders(peer);
925
+ this.schedulePeerRecoveryCheck(memberId, 'peer-created');
926
+ }
668
927
  return peer;
669
928
  }
670
929
  async negotiatePeer(peer) {
671
- if (!this.connected
672
- || peer.pc.connectionState === 'closed') {
673
- return;
674
- }
675
- if (peer.makingOffer
676
- || peer.isSettingRemoteAnswerPending
677
- || peer.pc.signalingState !== 'stable') {
678
- peer.pendingNegotiation = true;
930
+ if (peer.answeringOffer) {
931
+ peer.pendingNegotiation = false;
679
932
  return;
680
933
  }
681
- try {
682
- peer.pendingNegotiation = false;
683
- peer.makingOffer = true;
684
- await peer.pc.setLocalDescription();
685
- if (!peer.pc.localDescription) {
934
+ const runNegotiation = async () => {
935
+ if (!this.connected || peer.pc.connectionState === 'closed') {
686
936
  return;
687
937
  }
688
- await this.sendSignalWithRetry(peer.memberId, this.offerEvent, {
689
- description: serializeDescription(peer.pc.localDescription),
690
- });
691
- }
692
- catch (error) {
693
- console.warn('[RoomP2PMediaTransport] Failed to negotiate peer offer.', {
694
- memberId: peer.memberId,
695
- signalingState: peer.pc.signalingState,
696
- error,
697
- });
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;
698
978
  }
699
- finally {
700
- peer.makingOffer = false;
979
+ const bootstrapQueue = peer;
980
+ if (bootstrapQueue.bootstrapNegotiationQueued) {
981
+ peer.pendingNegotiation = true;
982
+ return;
701
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;
702
996
  }
703
997
  async handleDescriptionSignal(expectedType, payload, meta) {
704
998
  const senderId = typeof meta.memberId === 'string' && meta.memberId.trim() ? meta.memberId.trim() : '';
@@ -730,17 +1024,30 @@ export class RoomP2PMediaTransport {
730
1024
  }
731
1025
  peer.isSettingRemoteAnswerPending = description.type === 'answer';
732
1026
  await peer.pc.setRemoteDescription(description);
1027
+ peer.hasRemoteDescription = true;
1028
+ peer.bootstrapPassive = false;
733
1029
  peer.isSettingRemoteAnswerPending = false;
734
1030
  await this.flushPendingCandidates(peer);
735
1031
  if (description.type === 'offer') {
736
- this.syncPeerSenders(peer);
737
- await peer.pc.setLocalDescription();
738
- if (!peer.pc.localDescription) {
739
- 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;
740
1050
  }
741
- await this.sendSignalWithRetry(senderId, this.answerEvent, {
742
- description: serializeDescription(peer.pc.localDescription),
743
- });
744
1051
  }
745
1052
  }
746
1053
  catch (error) {
@@ -928,8 +1235,12 @@ export class RoomP2PMediaTransport {
928
1235
  stream,
929
1236
  trackName: track.id,
930
1237
  providerSessionId: memberId,
1238
+ memberId,
931
1239
  participantId: memberId,
932
1240
  userId: participant?.userId,
1241
+ displayName: typeof participant?.state?.displayName === 'string'
1242
+ ? participant.state.displayName
1243
+ : undefined,
933
1244
  };
934
1245
  for (const handler of this.remoteTrackHandlers) {
935
1246
  handler(payload);
@@ -943,6 +1254,7 @@ export class RoomP2PMediaTransport {
943
1254
  this.schedulePeerRecoveryCheck(memberId, 'partial-remote-track');
944
1255
  }
945
1256
  }
1257
+ this.emitRemoteVideoStateChange();
946
1258
  }
947
1259
  resolveFallbackRemoteTrackKind(memberId, track) {
948
1260
  const normalizedKind = normalizeTrackKind(track);
@@ -961,10 +1273,68 @@ export class RoomP2PMediaTransport {
961
1273
  continue;
962
1274
  }
963
1275
  this.pendingRemoteTracks.delete(key);
1276
+ this.clearPendingVideoPromotionTimer(key);
964
1277
  this.emitRemoteTrack(memberId, pending.track, pending.stream, roomKind);
965
1278
  return;
966
1279
  }
967
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
+ }
968
1338
  getPublishedVideoLikeKinds(memberId) {
969
1339
  const mediaMember = this.room.media.list().find((entry) => entry.member.memberId === memberId);
970
1340
  if (!mediaMember) {
@@ -1000,6 +1370,73 @@ export class RoomP2PMediaTransport {
1000
1370
  }
1001
1371
  return publishedKinds.find((kind) => !assignedKinds.has(kind)) ?? null;
1002
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
+ }
1003
1440
  closePeer(memberId) {
1004
1441
  const peer = this.peers.get(memberId);
1005
1442
  if (!peer)
@@ -1008,9 +1445,15 @@ export class RoomP2PMediaTransport {
1008
1445
  this.peers.delete(memberId);
1009
1446
  }
1010
1447
  removeRemoteMember(memberId) {
1448
+ this.cancelPendingSyncRemoval(memberId);
1011
1449
  this.remoteTrackKinds.forEach((_kind, key) => {
1012
1450
  if (key.startsWith(`${memberId}:`)) {
1013
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
+ }
1014
1457
  }
1015
1458
  });
1016
1459
  this.emittedRemoteTracks.forEach((key) => {
@@ -1021,9 +1464,35 @@ export class RoomP2PMediaTransport {
1021
1464
  this.pendingRemoteTracks.forEach((_pending, key) => {
1022
1465
  if (key.startsWith(`${memberId}:`)) {
1023
1466
  this.pendingRemoteTracks.delete(key);
1467
+ this.clearPendingVideoPromotionTimer(key);
1024
1468
  }
1025
1469
  });
1470
+ this.remoteVideoStreamCache.delete(memberId);
1026
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);
1027
1496
  }
1028
1497
  findMember(memberId) {
1029
1498
  return this.room.members.list().find((member) => member.memberId === memberId);
@@ -1047,10 +1516,24 @@ export class RoomP2PMediaTransport {
1047
1516
  clearTimeout(pending.timer);
1048
1517
  }
1049
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();
1050
1531
  this.pendingIceCandidates.clear();
1051
1532
  this.remoteTrackKinds.clear();
1052
1533
  this.emittedRemoteTracks.clear();
1053
1534
  this.pendingRemoteTracks.clear();
1535
+ this.remoteVideoStreamCache.clear();
1536
+ this.emitRemoteVideoStateChange(true);
1054
1537
  }
1055
1538
  destroyPeer(peer) {
1056
1539
  this.clearPeerRecoveryTimer(peer);
@@ -1120,6 +1603,7 @@ export class RoomP2PMediaTransport {
1120
1603
  const handleEnded = () => {
1121
1604
  flow.cleanup();
1122
1605
  peer.remoteVideoFlows.delete(track.id);
1606
+ this.emitRemoteVideoStateChange();
1123
1607
  };
1124
1608
  track.addEventListener('unmute', markHealthy);
1125
1609
  track.addEventListener('ended', handleEnded);
@@ -1128,6 +1612,7 @@ export class RoomP2PMediaTransport {
1128
1612
  track.removeEventListener('ended', handleEnded);
1129
1613
  };
1130
1614
  peer.remoteVideoFlows.set(track.id, flow);
1615
+ this.emitRemoteVideoStateChange();
1131
1616
  }
1132
1617
  async inspectPeerVideoHealth(peer) {
1133
1618
  if (this.hasMissingPublishedMedia(peer.memberId)) {
@@ -1210,6 +1695,16 @@ export class RoomP2PMediaTransport {
1210
1695
  if (publishedAt > 0 && Date.now() - publishedAt > this.options.videoFlowGraceMs) {
1211
1696
  return 'health-video-flow-timeout';
1212
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
+ }
1213
1708
  return null;
1214
1709
  }
1215
1710
  async createUserMediaTrack(kind, constraints) {
@@ -1246,6 +1741,16 @@ export class RoomP2PMediaTransport {
1246
1741
  }
1247
1742
  return this.connect();
1248
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
+ }
1249
1754
  normalizeDescription(payload) {
1250
1755
  if (!payload || typeof payload !== 'object') {
1251
1756
  return null;
@@ -1327,9 +1832,18 @@ export class RoomP2PMediaTransport {
1327
1832
  }
1328
1833
  const connectionState = peer.pc.connectionState;
1329
1834
  const iceConnectionState = peer.pc.iceConnectionState;
1330
- if (connectionState === 'connected'
1835
+ const connectedish = connectionState === 'connected'
1331
1836
  || iceConnectionState === 'connected'
1332
- || 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
+ }
1333
1847
  this.resetPeerRecovery(peer);
1334
1848
  return;
1335
1849
  }
@@ -1346,6 +1860,11 @@ export class RoomP2PMediaTransport {
1346
1860
  if (!peer || !this.connected || peer.pc.connectionState === 'closed') {
1347
1861
  return;
1348
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;
1349
1868
  const healthSensitiveReason = reason.includes('health')
1350
1869
  || reason.includes('stalled')
1351
1870
  || reason.includes('flow');
@@ -1356,6 +1875,12 @@ export class RoomP2PMediaTransport {
1356
1875
  this.resetPeerRecovery(peer);
1357
1876
  return;
1358
1877
  }
1878
+ if (inInitialBootstrapWindow
1879
+ && !healthSensitiveReason
1880
+ && !reason.includes('failed')
1881
+ && !reason.includes('disconnected')) {
1882
+ delayMs = Math.max(delayMs, this.options.initialNegotiationGraceMs - peerAgeMs);
1883
+ }
1359
1884
  this.clearPeerRecoveryTimer(peer);
1360
1885
  peer.recoveryTimer = globalThis.setTimeout(() => {
1361
1886
  peer.recoveryTimer = null;
@@ -1378,6 +1903,34 @@ export class RoomP2PMediaTransport {
1378
1903
  this.resetPeerRecovery(peer);
1379
1904
  return;
1380
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
+ }
1381
1934
  if (peer.recoveryAttempts >= this.options.maxRecoveryAttempts) {
1382
1935
  this.resetPeer(peer.memberId, reason);
1383
1936
  return;
@@ -1471,5 +2024,43 @@ export class RoomP2PMediaTransport {
1471
2024
  const emittedVideoLikeCount = Array.from(emittedKinds).filter((kind) => kind === 'video' || kind === 'screen').length;
1472
2025
  return emittedVideoLikeCount + pendingVideoLikeCount < expectedVideoLikeKinds.length;
1473
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
+ }
1474
2065
  }
1475
2066
  //# sourceMappingURL=room-p2p-media.js.map