@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.
- package/README.md +3 -34
- package/dist/client.d.ts +1 -1
- package/dist/client.js +1 -1
- package/dist/database-live.js +2 -2
- package/dist/index.d.ts +1 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -1
- package/dist/index.js.map +1 -1
- package/dist/room-collab-core.d.ts +124 -0
- package/dist/room-collab-core.d.ts.map +1 -0
- package/dist/room-collab-core.js +675 -0
- package/dist/room-collab-core.js.map +1 -0
- package/dist/room-p2p-media.d.ts +60 -0
- package/dist/room-p2p-media.d.ts.map +1 -1
- package/dist/room-p2p-media.js +640 -49
- package/dist/room-p2p-media.js.map +1 -1
- package/dist/room.d.ts +28 -295
- package/dist/room.d.ts.map +1 -1
- package/dist/room.js +153 -463
- package/dist/room.js.map +1 -1
- package/llms.txt +0 -55
- package/package.json +2 -3
package/dist/room-p2p-media.js
CHANGED
|
@@ -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.
|
|
510
|
-
this.
|
|
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.
|
|
518
|
-
this.
|
|
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.
|
|
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
|
-
|
|
544
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
667
|
-
|
|
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 (
|
|
672
|
-
|
|
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
|
-
|
|
682
|
-
peer.
|
|
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
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
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
|
-
|
|
700
|
-
|
|
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
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
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
|
-
|
|
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
|