@relaymessenger/livekit 0.1.0-staging.10 → 0.1.0-staging.12
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 +64 -4
- package/dist/engine-werift-video.d.ts +70 -0
- package/dist/engine-werift-video.d.ts.map +1 -0
- package/dist/engine-werift-video.js +594 -0
- package/dist/engine-werift-video.js.map +1 -0
- package/dist/engine-werift.d.ts +29 -0
- package/dist/engine-werift.d.ts.map +1 -1
- package/dist/engine-werift.js +120 -25
- package/dist/engine-werift.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/transport.d.ts +42 -5
- package/dist/transport.d.ts.map +1 -1
- package/dist/transport.js +265 -33
- package/dist/transport.js.map +1 -1
- package/dist/video-frame.d.ts +56 -0
- package/dist/video-frame.d.ts.map +1 -0
- package/dist/video-frame.js +245 -0
- package/dist/video-frame.js.map +1 -0
- package/dist/video-rtp.d.ts +51 -0
- package/dist/video-rtp.d.ts.map +1 -0
- package/dist/video-rtp.js +294 -0
- package/dist/video-rtp.js.map +1 -0
- package/dist/video.d.ts +144 -0
- package/dist/video.d.ts.map +1 -0
- package/dist/video.js +228 -0
- package/dist/video.js.map +1 -0
- package/package.json +6 -3
package/dist/transport.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { RemoteVideoTrack, } from "./video.js";
|
|
2
|
+
export { LocalVideoTrack, RemoteVideoTrack, VideoBufferType, VideoCodec, VideoFrame, VideoRotation, VideoSource, VideoStream, } from "./video.js";
|
|
1
3
|
/** Opus's native rate and Relay's wire channel count. */
|
|
2
4
|
export const DEFAULT_INBOUND_AUDIO = Object.freeze({
|
|
3
5
|
sampleRate: 48_000,
|
|
@@ -41,6 +43,11 @@ export class RelayCallTransportError extends Error {
|
|
|
41
43
|
}
|
|
42
44
|
}
|
|
43
45
|
const DEFAULT_PEER_CONFIG = { iceServers: [], iceTransportPolicy: "all" };
|
|
46
|
+
/**
|
|
47
|
+
* Cloudflare Realtime's echo example builds its peer with exactly this list
|
|
48
|
+
* (cloudflare/realtime-examples echo/index.html `createPeerConnection`).
|
|
49
|
+
*/
|
|
50
|
+
const DEFAULT_ICE_SERVERS = [{ urls: "stun:stun.cloudflare.com:3478" }];
|
|
44
51
|
const loadWebRTCFactory = async (engine) => {
|
|
45
52
|
if (engine === "werift") {
|
|
46
53
|
const { createWeriftWebRTCFactory } = await import("./engine-werift.js");
|
|
@@ -68,7 +75,8 @@ const seconds = (milliseconds) => `${(milliseconds / 1000).toFixed(1)}s`;
|
|
|
68
75
|
const summarizeIce = (diagnostics) => {
|
|
69
76
|
const { local, remote, transitions, connected } = diagnostics;
|
|
70
77
|
const localPart = `local: host ${local.host}, srflx ${local.srflx}, relay ${local.relay}`
|
|
71
|
-
+ (local.other ? `, other ${local.other}` : "")
|
|
78
|
+
+ (local.other ? `, other ${local.other}` : "")
|
|
79
|
+
+ (diagnostics.selectedPair ? `, pair ${diagnostics.selectedPair}` : "");
|
|
72
80
|
const remotePart = remote.length
|
|
73
81
|
? `remote: ${remote.map((candidate) => `${candidate.transport} ${candidate.port}`).join(", ")}`
|
|
74
82
|
: "remote: none";
|
|
@@ -215,6 +223,7 @@ export class RelayCallTransport {
|
|
|
215
223
|
#stallWarned = false;
|
|
216
224
|
#iceLocal = { host: 0, srflx: 0, relay: 0, other: 0 };
|
|
217
225
|
#iceRemote = [];
|
|
226
|
+
#selectedPair;
|
|
218
227
|
#iceTransitions = [];
|
|
219
228
|
#listeners = new Map();
|
|
220
229
|
#factory;
|
|
@@ -225,7 +234,11 @@ export class RelayCallTransport {
|
|
|
225
234
|
#peerConnected = false;
|
|
226
235
|
#audioSource;
|
|
227
236
|
#localTrack;
|
|
237
|
+
/** Set while an offer waits for its first local candidate. */
|
|
238
|
+
#firstLocalCandidate;
|
|
228
239
|
#remoteSink;
|
|
240
|
+
/** The track `#remoteSink` decodes; a repeated `ontrack` for it keeps the sink. */
|
|
241
|
+
#remoteSinkTrack;
|
|
229
242
|
#publishTransceiver;
|
|
230
243
|
#publishFrame;
|
|
231
244
|
#initialAnswerSdp;
|
|
@@ -254,6 +267,17 @@ export class RelayCallTransport {
|
|
|
254
267
|
#ready;
|
|
255
268
|
#handlersAttached = false;
|
|
256
269
|
#closed = false;
|
|
270
|
+
#muted = false;
|
|
271
|
+
/** The published camera: one per call, kept across restarts (PROTOCOL.md section 1). */
|
|
272
|
+
#video;
|
|
273
|
+
#videoTransceiver;
|
|
274
|
+
#remoteVideoTrack;
|
|
275
|
+
/** The engine track the current video receiver reads. */
|
|
276
|
+
#remoteVideoEngineTrack;
|
|
277
|
+
/** An add-track offer is out; a pull offer that crosses it waits for its answer. */
|
|
278
|
+
#addTrackPending = false;
|
|
279
|
+
#deferredOffer;
|
|
280
|
+
#lastAnswerSdp;
|
|
257
281
|
constructor(options) {
|
|
258
282
|
if (!options.callId.trim())
|
|
259
283
|
throw new Error("callId is required.");
|
|
@@ -289,7 +313,7 @@ export class RelayCallTransport {
|
|
|
289
313
|
throw new Error('iceTransportPolicy must be "all" or "relay".');
|
|
290
314
|
}
|
|
291
315
|
this.#iceTransportPolicy = policy;
|
|
292
|
-
const iceServers = options.iceServers ??
|
|
316
|
+
const iceServers = options.iceServers ?? DEFAULT_ICE_SERVERS;
|
|
293
317
|
this.#iceServers = typeof iceServers === "function" ? iceServers : copyIceServers(iceServers);
|
|
294
318
|
if (!Number.isFinite(this.#iceGatheringTimeoutMs) || this.#iceGatheringTimeoutMs <= 0) {
|
|
295
319
|
throw new Error("iceGatheringTimeoutMs must be greater than zero.");
|
|
@@ -383,6 +407,7 @@ export class RelayCallTransport {
|
|
|
383
407
|
local: { ...this.#iceLocal },
|
|
384
408
|
remote: this.#iceRemote.map((candidate) => ({ ...candidate })),
|
|
385
409
|
transitions: this.#iceTransitions.map((transition) => ({ ...transition })),
|
|
410
|
+
selectedPair: this.#selectedPair,
|
|
386
411
|
connected: this.#peerConnected,
|
|
387
412
|
inbound: this.#inboundDiagnostics(),
|
|
388
413
|
outbound: this.#outboundDiagnostics(),
|
|
@@ -566,8 +591,83 @@ export class RelayCallTransport {
|
|
|
566
591
|
waiter.reject(error);
|
|
567
592
|
}
|
|
568
593
|
setMuted(muted) {
|
|
594
|
+
this.#muted = muted;
|
|
569
595
|
this.#room.userUpdate({ muted });
|
|
570
596
|
}
|
|
597
|
+
/**
|
|
598
|
+
* Publish the camera (LiveKit `LocalParticipant.publishTrack`). The first
|
|
599
|
+
* call adds a `video` track to the SFU session with an add-track offer, then
|
|
600
|
+
* announces `userUpdate { video: true }`; later calls, after
|
|
601
|
+
* `unpublishTrack`, only resume sending and announce it again
|
|
602
|
+
* (PROTOCOL.md sections 1-2). Requires `connect()` and the werift engine.
|
|
603
|
+
*/
|
|
604
|
+
async publishTrack(track, options = {}) {
|
|
605
|
+
if (this.#closed || this.#ended)
|
|
606
|
+
throw new Error("Relay Call transport is closed.");
|
|
607
|
+
if (!this.#factory || !this.#reportedConnected)
|
|
608
|
+
throw new Error("Relay Call transport is not connected.");
|
|
609
|
+
if (this.#video) {
|
|
610
|
+
if (this.#video.track !== track)
|
|
611
|
+
throw new RelayCallTransportError("A Relay call publishes one video track.");
|
|
612
|
+
this.#video.sender.setEnabled(true);
|
|
613
|
+
this.#room.userUpdate({ muted: this.#muted, video: true });
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
const factory = this.#factory;
|
|
617
|
+
if (!factory.createVideoSender) {
|
|
618
|
+
throw new RelayCallTransportError('This engine cannot send video; use the "werift" engine.');
|
|
619
|
+
}
|
|
620
|
+
const sender = await factory.createVideoSender(options);
|
|
621
|
+
if (this.#closed || this.#ended) {
|
|
622
|
+
sender.close();
|
|
623
|
+
throw new Error("Relay Call transport is closed.");
|
|
624
|
+
}
|
|
625
|
+
const detach = track.source._attach((frame, timestampUs, rotation) => sender.capture(frame, timestampUs, rotation));
|
|
626
|
+
this.#video = { track, sender, detach };
|
|
627
|
+
await this.#negotiate(async () => {
|
|
628
|
+
// No peer: a restart is pending, and the next peer publishes video with audio.
|
|
629
|
+
const peer = this.#peer;
|
|
630
|
+
if (!peer)
|
|
631
|
+
return;
|
|
632
|
+
this.#addVideoTransceiver(peer);
|
|
633
|
+
await this.#publishLocalAudio(peer, false);
|
|
634
|
+
this.#addTrackPending = this.#peer === peer;
|
|
635
|
+
});
|
|
636
|
+
this.#room.userUpdate({ muted: this.#muted, video: true });
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* Stop sending the camera and announce `userUpdate { video: false }`. The
|
|
640
|
+
* track stays negotiated on the SFU (PROTOCOL.md section 1), so a later
|
|
641
|
+
* `publishTrack` with the same track resumes it without renegotiating.
|
|
642
|
+
*/
|
|
643
|
+
async unpublishTrack(track) {
|
|
644
|
+
if (!this.#video || this.#video.track !== track)
|
|
645
|
+
return;
|
|
646
|
+
this.#video.sender.setEnabled(false);
|
|
647
|
+
if (!this.#closed && !this.#ended)
|
|
648
|
+
this.#room.userUpdate({ muted: this.#muted, video: false });
|
|
649
|
+
}
|
|
650
|
+
/** The person's video track, once it has reached this peer. */
|
|
651
|
+
get remoteVideoTrack() {
|
|
652
|
+
return this.#remoteVideoTrack;
|
|
653
|
+
}
|
|
654
|
+
/** Frame, packet and keyframe counters for both video directions. */
|
|
655
|
+
videoStats() {
|
|
656
|
+
return { outbound: this.#video?.sender.stats(), inbound: this.#remoteVideoTrack?.stats() };
|
|
657
|
+
}
|
|
658
|
+
/** Runs `work` in the negotiation queue and hands its result or error to the caller. */
|
|
659
|
+
#negotiate(work) {
|
|
660
|
+
const run = this.#negotiationTail.then(work);
|
|
661
|
+
this.#negotiationTail = run.then(() => undefined, () => undefined);
|
|
662
|
+
return run;
|
|
663
|
+
}
|
|
664
|
+
#addVideoTransceiver(peer) {
|
|
665
|
+
const video = this.#video;
|
|
666
|
+
if (!video)
|
|
667
|
+
return;
|
|
668
|
+
this.#videoTransceiver = peer.addTransceiver(video.sender.track, { direction: "sendonly" });
|
|
669
|
+
video.sender.bind(this.#videoTransceiver, peer);
|
|
670
|
+
}
|
|
571
671
|
end() {
|
|
572
672
|
this.#room.end();
|
|
573
673
|
}
|
|
@@ -604,6 +704,7 @@ export class RelayCallTransport {
|
|
|
604
704
|
this.#peerConnected = false;
|
|
605
705
|
this.#initialAnswerSdp = undefined;
|
|
606
706
|
this.#publishTransceiver = peer.addTransceiver(track, { direction: "sendonly" });
|
|
707
|
+
this.#addVideoTransceiver(peer);
|
|
607
708
|
this.#observeIce(peer);
|
|
608
709
|
peer.onconnectionstatechange = () => {
|
|
609
710
|
if (this.#peer !== peer)
|
|
@@ -613,24 +714,36 @@ export class RelayCallTransport {
|
|
|
613
714
|
};
|
|
614
715
|
peer.ontrack = (event) => {
|
|
615
716
|
if (this.#peer === peer)
|
|
616
|
-
this.#remoteTrack(event.track);
|
|
717
|
+
this.#remoteTrack(event.track, event.transceiver);
|
|
617
718
|
};
|
|
618
719
|
await this.#publishLocalAudio(peer, restarts > 0);
|
|
619
720
|
}
|
|
721
|
+
/**
|
|
722
|
+
* Sends the offer without waiting for ICE gathering, as Cloudflare's echo
|
|
723
|
+
* example does (`setLocalDescription(offer)`, then `tracks/new` at once): the
|
|
724
|
+
* SFU is ICE-lite and learns this peer's address from its connectivity
|
|
725
|
+
* checks. werift's `setLocalDescription` resolves only after every host,
|
|
726
|
+
* STUN and TURN candidate has settled (peerConnection.js `await
|
|
727
|
+
* this.gatherCandidates()`; ice.js `await Promise.allSettled(candidatePromises)`),
|
|
728
|
+
* which took 6.6 s on a staging call on 2026-09-22, so the offer leaves as
|
|
729
|
+
* soon as the description is applied and the first local candidate exists.
|
|
730
|
+
*/
|
|
620
731
|
async #publishLocalAudio(peer, restart) {
|
|
621
732
|
const offer = await peer.createOffer();
|
|
622
|
-
await peer
|
|
623
|
-
await this.#waitForIceGathering(peer);
|
|
733
|
+
await this.#applyLocalOffer(peer, offer);
|
|
624
734
|
if (this.#peer !== peer)
|
|
625
735
|
return;
|
|
626
736
|
const description = this.#localDescription(peer, "offer");
|
|
627
737
|
const mid = this.#publishTransceiver?.mid;
|
|
628
738
|
if (!mid)
|
|
629
739
|
throw new RelayCallTransportError("Relay audio publication has no WebRTC MID.");
|
|
740
|
+
const videoMid = this.#video ? this.#videoTransceiver?.mid : undefined;
|
|
741
|
+
if (this.#video && !videoMid)
|
|
742
|
+
throw new RelayCallTransportError("Relay video publication has no WebRTC MID.");
|
|
630
743
|
this.#publishFrame = {
|
|
631
744
|
type: "offer",
|
|
632
745
|
session_description: description,
|
|
633
|
-
tracks: [{ mid, name: "audio" }],
|
|
746
|
+
tracks: videoMid ? [{ mid, name: "audio" }, { mid: videoMid, name: "video" }] : [{ mid, name: "audio" }],
|
|
634
747
|
...(restart ? { restart: true } : {}),
|
|
635
748
|
};
|
|
636
749
|
this.#room.send(this.#publishFrame);
|
|
@@ -702,7 +815,7 @@ export class RelayCallTransport {
|
|
|
702
815
|
// Reconnecting the signaling socket replays the exact initial offer. Relay
|
|
703
816
|
// returns its cached answer; applying that answer again in stable state is
|
|
704
817
|
// invalid WebRTC signaling, so recognize and ignore the replay.
|
|
705
|
-
if (peer.signalingState === "stable" && this.#initialAnswerSdp === sdp)
|
|
818
|
+
if (peer.signalingState === "stable" && (this.#initialAnswerSdp === sdp || this.#lastAnswerSdp === sdp))
|
|
706
819
|
return;
|
|
707
820
|
const initial = this.#initialAnswerSdp === undefined;
|
|
708
821
|
if (initial)
|
|
@@ -711,22 +824,35 @@ export class RelayCallTransport {
|
|
|
711
824
|
if (this.#peer !== peer)
|
|
712
825
|
return;
|
|
713
826
|
this.#initialAnswerSdp ??= sdp;
|
|
827
|
+
this.#lastAnswerSdp = sdp;
|
|
714
828
|
if (initial && !this.#peerConnected)
|
|
715
829
|
this.#armConnectTimer(peer);
|
|
830
|
+
if (this.#addTrackPending) {
|
|
831
|
+
this.#addTrackPending = false;
|
|
832
|
+
const deferred = this.#deferredOffer;
|
|
833
|
+
this.#deferredOffer = undefined;
|
|
834
|
+
if (deferred)
|
|
835
|
+
await this.#serverOffer(deferred);
|
|
836
|
+
}
|
|
716
837
|
}
|
|
717
838
|
async #serverOffer(frame) {
|
|
718
839
|
const peer = this.#peer;
|
|
840
|
+
// A pull offer that crosses this participant's add-track offer is for the
|
|
841
|
+
// live session: it is answered once the add-track answer is applied.
|
|
842
|
+
if (peer && this.#addTrackPending) {
|
|
843
|
+
this.#deferredOffer = frame;
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
719
846
|
// A pull offer that arrives while this participant's restart offer is
|
|
720
847
|
// unanswered was sent for the replaced session: the room clears those
|
|
721
848
|
// pulls on restart and pulls again after the new session connects.
|
|
722
849
|
if (!peer || peer.signalingState === "have-local-offer")
|
|
723
850
|
return;
|
|
724
|
-
// `video` m-lines are answered receive-only
|
|
725
|
-
//
|
|
851
|
+
// `video` m-lines are answered receive-only; `#remoteTrack` decodes them
|
|
852
|
+
// when the engine has video.
|
|
726
853
|
await peer.setRemoteDescription(frame.session_description);
|
|
727
854
|
const answer = await peer.createAnswer();
|
|
728
855
|
await peer.setLocalDescription(answer);
|
|
729
|
-
await this.#waitForIceGathering(peer);
|
|
730
856
|
if (this.#peer !== peer)
|
|
731
857
|
return;
|
|
732
858
|
this.#room.send({ type: "answer", session_description: this.#localDescription(peer, "answer") });
|
|
@@ -746,6 +872,7 @@ export class RelayCallTransport {
|
|
|
746
872
|
return;
|
|
747
873
|
this.#peerConnected = true;
|
|
748
874
|
this.#failedAttempts = 0;
|
|
875
|
+
void this.#readSelectedPair(peer);
|
|
749
876
|
// The SFU will not pull a track that has carried no RTP, so the source
|
|
750
877
|
// sends silence from here on until it is closed.
|
|
751
878
|
this.#audioSource?.start?.();
|
|
@@ -860,11 +987,18 @@ export class RelayCallTransport {
|
|
|
860
987
|
this.#peer = undefined;
|
|
861
988
|
this.#peerConnected = false;
|
|
862
989
|
this.#publishTransceiver = undefined;
|
|
990
|
+
this.#videoTransceiver = undefined;
|
|
863
991
|
this.#initialAnswerSdp = undefined;
|
|
992
|
+
this.#lastAnswerSdp = undefined;
|
|
993
|
+
this.#addTrackPending = false;
|
|
994
|
+
this.#deferredOffer = undefined;
|
|
995
|
+
this.#remoteVideoTrack?._detach();
|
|
996
|
+
this.#remoteVideoEngineTrack = undefined;
|
|
864
997
|
if (this.#remoteSink) {
|
|
865
998
|
this.#retiredSinkStats = this.#sinkStats();
|
|
866
999
|
this.#remoteSink.stop();
|
|
867
1000
|
this.#remoteSink = undefined;
|
|
1001
|
+
this.#remoteSinkTrack = undefined;
|
|
868
1002
|
}
|
|
869
1003
|
if (!peer)
|
|
870
1004
|
return;
|
|
@@ -875,12 +1009,29 @@ export class RelayCallTransport {
|
|
|
875
1009
|
peer.oniceconnectionstatechange = null;
|
|
876
1010
|
closePeer(peer);
|
|
877
1011
|
}
|
|
878
|
-
|
|
1012
|
+
/**
|
|
1013
|
+
* werift fires `ontrack` again for every sending m-line on each
|
|
1014
|
+
* `setRemoteDescription` (transceiverManager.js `setRemoteRTP`), so a pull
|
|
1015
|
+
* offer that adds the person's video re-announces the audio track already
|
|
1016
|
+
* decoded. That track keeps its sink, decoder and packet counts; a different
|
|
1017
|
+
* audio track retires the old sink's counts into the call totals, as a restart does.
|
|
1018
|
+
*/
|
|
1019
|
+
#remoteTrack(track, transceiver) {
|
|
1020
|
+
if (track.kind === "video") {
|
|
1021
|
+
this.#subscribeRemoteVideo(track, transceiver);
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
879
1024
|
if (track.kind !== "audio" || !this.#factory)
|
|
880
1025
|
return;
|
|
881
|
-
this.#remoteSink
|
|
1026
|
+
if (this.#remoteSink && this.#remoteSinkTrack === track)
|
|
1027
|
+
return;
|
|
1028
|
+
if (this.#remoteSink) {
|
|
1029
|
+
this.#retiredSinkStats = this.#sinkStats();
|
|
1030
|
+
this.#remoteSink.stop();
|
|
1031
|
+
}
|
|
882
1032
|
const sink = this.#factory.createAudioSink(track, this.#inboundAudio);
|
|
883
1033
|
this.#remoteSink = sink;
|
|
1034
|
+
this.#remoteSinkTrack = track;
|
|
884
1035
|
sink.ondata = (data) => {
|
|
885
1036
|
if (this.#closed || this.#remoteSink !== sink)
|
|
886
1037
|
return;
|
|
@@ -901,6 +1052,27 @@ export class RelayCallTransport {
|
|
|
901
1052
|
}
|
|
902
1053
|
};
|
|
903
1054
|
}
|
|
1055
|
+
/**
|
|
1056
|
+
* One `RemoteVideoTrack` per call; each new session's receiver is attached
|
|
1057
|
+
* to it. werift fires `ontrack` again for every sending m-line on each
|
|
1058
|
+
* `setRemoteDescription` (transceiverManager.js `setRemoteRTP`), so the
|
|
1059
|
+
* track already being decoded keeps its receiver.
|
|
1060
|
+
*/
|
|
1061
|
+
#subscribeRemoteVideo(track, transceiver) {
|
|
1062
|
+
const factory = this.#factory;
|
|
1063
|
+
if (!factory?.createVideoReceiver || this.#closed)
|
|
1064
|
+
return;
|
|
1065
|
+
if (this.#remoteVideoEngineTrack === track)
|
|
1066
|
+
return;
|
|
1067
|
+
this.#remoteVideoEngineTrack = track;
|
|
1068
|
+
const receiver = factory.createVideoReceiver(track, transceiver);
|
|
1069
|
+
const existing = this.#remoteVideoTrack;
|
|
1070
|
+
const remote = existing ?? new RemoteVideoTrack();
|
|
1071
|
+
this.#remoteVideoTrack = remote;
|
|
1072
|
+
remote._attach(receiver);
|
|
1073
|
+
if (!existing)
|
|
1074
|
+
this.#emit("trackSubscribed", remote);
|
|
1075
|
+
}
|
|
904
1076
|
#writeAudio(frame, generation) {
|
|
905
1077
|
const source = this.#audioSource;
|
|
906
1078
|
if (!source || generation !== this.#audioGeneration)
|
|
@@ -927,28 +1099,47 @@ export class RelayCallTransport {
|
|
|
927
1099
|
for (const release of [...this.#playoutWaiters])
|
|
928
1100
|
release();
|
|
929
1101
|
}
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
};
|
|
945
|
-
const cleanup = () => {
|
|
946
|
-
clearTimeout(timeout);
|
|
947
|
-
peer.removeEventListener("icegatheringstatechange", changed);
|
|
948
|
-
};
|
|
949
|
-
peer.addEventListener("icegatheringstatechange", changed);
|
|
950
|
-
changed();
|
|
1102
|
+
/**
|
|
1103
|
+
* Resolves once the offer is applied and has a first local candidate, or
|
|
1104
|
+
* once `setLocalDescription` resolves (W3C engines resolve before gathering),
|
|
1105
|
+
* whichever comes first.
|
|
1106
|
+
*/
|
|
1107
|
+
async #applyLocalOffer(peer, offer) {
|
|
1108
|
+
const applied = peer.setLocalDescription(offer);
|
|
1109
|
+
let settled = false;
|
|
1110
|
+
applied.catch((error) => {
|
|
1111
|
+
if (!settled || this.#peer !== peer)
|
|
1112
|
+
return;
|
|
1113
|
+
const parsed = error instanceof Error ? error : new Error(String(error));
|
|
1114
|
+
this.#rejectReady(parsed);
|
|
1115
|
+
this.#emit("error", parsed);
|
|
951
1116
|
});
|
|
1117
|
+
try {
|
|
1118
|
+
await new Promise((resolve, reject) => {
|
|
1119
|
+
const timeout = setTimeout(() => {
|
|
1120
|
+
cleanup();
|
|
1121
|
+
reject(new RelayCallTransportError("Timed out gathering Relay WebRTC ICE candidates."));
|
|
1122
|
+
}, this.#iceGatheringTimeoutMs);
|
|
1123
|
+
timeout.unref?.();
|
|
1124
|
+
const cleanup = () => {
|
|
1125
|
+
clearTimeout(timeout);
|
|
1126
|
+
if (this.#firstLocalCandidate === done)
|
|
1127
|
+
this.#firstLocalCandidate = undefined;
|
|
1128
|
+
};
|
|
1129
|
+
const done = () => {
|
|
1130
|
+
cleanup();
|
|
1131
|
+
resolve();
|
|
1132
|
+
};
|
|
1133
|
+
this.#firstLocalCandidate = done;
|
|
1134
|
+
applied.then(done, (error) => {
|
|
1135
|
+
cleanup();
|
|
1136
|
+
reject(error);
|
|
1137
|
+
});
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
1140
|
+
finally {
|
|
1141
|
+
settled = true;
|
|
1142
|
+
}
|
|
952
1143
|
}
|
|
953
1144
|
/**
|
|
954
1145
|
* werift emits these as W3C-style handler calls (peerConnection.js:314-341:
|
|
@@ -965,6 +1156,8 @@ export class RelayCallTransport {
|
|
|
965
1156
|
this.#iceLocal[type] += 1;
|
|
966
1157
|
else
|
|
967
1158
|
this.#iceLocal.other += 1;
|
|
1159
|
+
if (this.#peer === peer && peer.localDescription)
|
|
1160
|
+
this.#firstLocalCandidate?.();
|
|
968
1161
|
};
|
|
969
1162
|
peer.onicegatheringstatechange = () => this.#recordTransition("gathering", peer.iceGatheringState);
|
|
970
1163
|
peer.oniceconnectionstatechange = () => {
|
|
@@ -972,6 +1165,35 @@ export class RelayCallTransport {
|
|
|
972
1165
|
this.#recordTransition("ice", peer.iceConnectionState);
|
|
973
1166
|
};
|
|
974
1167
|
}
|
|
1168
|
+
/**
|
|
1169
|
+
* W3C stats path: `transport.selectedCandidatePairId` -> `candidate-pair`
|
|
1170
|
+
* -> `local-candidate.candidateType` (werift transport/dtls.js and
|
|
1171
|
+
* transport/ice.js `getStats`); a nominated, succeeded pair when the
|
|
1172
|
+
* transport names none.
|
|
1173
|
+
*/
|
|
1174
|
+
async #readSelectedPair(peer) {
|
|
1175
|
+
let report;
|
|
1176
|
+
try {
|
|
1177
|
+
report = await peer.getStats?.();
|
|
1178
|
+
}
|
|
1179
|
+
catch {
|
|
1180
|
+
return;
|
|
1181
|
+
}
|
|
1182
|
+
if (!report || this.#peer !== peer)
|
|
1183
|
+
return;
|
|
1184
|
+
const stats = new Map();
|
|
1185
|
+
report.forEach((stat) => stats.set(String(stat.id), stat));
|
|
1186
|
+
const all = [...stats.values()];
|
|
1187
|
+
const selectedId = all.find((stat) => stat.type === "transport" && stat.selectedCandidatePairId)
|
|
1188
|
+
?.selectedCandidatePairId;
|
|
1189
|
+
const pair = selectedId !== undefined
|
|
1190
|
+
? stats.get(String(selectedId))
|
|
1191
|
+
: all.find((stat) => stat.type === "candidate-pair" && stat.nominated === true && stat.state === "succeeded");
|
|
1192
|
+
const local = pair ? stats.get(String(pair.localCandidateId)) : undefined;
|
|
1193
|
+
if (typeof local?.candidateType !== "string")
|
|
1194
|
+
return;
|
|
1195
|
+
this.#selectedPair = `${local.candidateType} ${typeof local.protocol === "string" ? local.protocol : "?"}`;
|
|
1196
|
+
}
|
|
975
1197
|
#recordTransition(kind, state) {
|
|
976
1198
|
this.#iceTransitions.push({ kind, state, atMs: Date.now() - this.#connectStartedAt });
|
|
977
1199
|
}
|
|
@@ -1012,8 +1234,18 @@ export class RelayCallTransport {
|
|
|
1012
1234
|
this.#finalSourceStats ??= this.#audioSource?.stats?.();
|
|
1013
1235
|
this.#remoteSink?.stop();
|
|
1014
1236
|
this.#remoteSink = undefined;
|
|
1237
|
+
this.#remoteSinkTrack = undefined;
|
|
1015
1238
|
this.#localTrack?.stop();
|
|
1016
1239
|
this.#localTrack = undefined;
|
|
1240
|
+
if (this.#video) {
|
|
1241
|
+
this.#video.detach();
|
|
1242
|
+
this.#video.sender.close();
|
|
1243
|
+
}
|
|
1244
|
+
const remoteVideo = this.#remoteVideoTrack;
|
|
1245
|
+
if (remoteVideo) {
|
|
1246
|
+
remoteVideo._end();
|
|
1247
|
+
this.#emit("trackUnsubscribed", remoteVideo);
|
|
1248
|
+
}
|
|
1017
1249
|
if (this.#peer)
|
|
1018
1250
|
closePeer(this.#peer);
|
|
1019
1251
|
this.#peer = undefined;
|