@displayxr/inline3d 1.26.0 → 1.28.0
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/CHANGELOG.md +134 -0
- package/call.d.ts +374 -0
- package/index.d.ts +17 -1
- package/js/call/capture.js +167 -0
- package/js/call/lift.js +314 -0
- package/js/call/qr.js +283 -0
- package/js/call/sdp.js +86 -0
- package/js/call/signaling.js +398 -0
- package/js/call/transport.js +589 -0
- package/js/call/ui.js +146 -0
- package/js/call/wire.js +428 -0
- package/js/inline3d-buffer-limit.js +198 -0
- package/js/inline3d-call.js +1695 -0
- package/js/inline3d-model-playcanvas.js +6 -0
- package/js/inline3d-model.js +6 -0
- package/js/inline3d-player.js +3 -2
- package/js/inline3d-splat-layer-rig.js +79 -7
- package/js/inline3d-splat-playcanvas.js +94 -17
- package/js/inline3d-splat.js +5 -0
- package/js/inline3d-viewer.js +86 -10
- package/js/inline3d.js +127 -14
- package/model.d.ts +10 -1
- package/package.json +9 -1
- package/player.d.ts +3 -2
- package/splat.d.ts +26 -4
- package/viewer.d.ts +12 -1
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
// call/transport.js — the `Transport` seam and its P1 implementation, a WebRTC full mesh.
|
|
2
|
+
//
|
|
3
|
+
// A Transport moves media and small reliable messages between this page and up to N−1 others. The
|
|
4
|
+
// call module only ever talks to this interface, so an SFU adapter (RFC §2, P3) can replace the
|
|
5
|
+
// mesh without the module changing:
|
|
6
|
+
//
|
|
7
|
+
// transport.start({ room, localStream, onPeer, onPeerLeft, onStream, onMessage, onPeerState })
|
|
8
|
+
// transport.send(peerId, msg) / broadcast(msg)
|
|
9
|
+
// transport.replaceVideoTrack(track) / setSendFormat(format)
|
|
10
|
+
// transport.peerConnection(peerId) // for getStats (null on an SFU)
|
|
11
|
+
// transport.stop()
|
|
12
|
+
//
|
|
13
|
+
// ── the mesh, pair by pair ───────────────────────────────────────────────────────────────────
|
|
14
|
+
//
|
|
15
|
+
// ROLES ARE DETERMINISTIC: of each pair, the peer with the lexically smaller id is the OFFERER. It
|
|
16
|
+
// builds the RTCPeerConnection, adds sendrecv audio + video transceivers and a negotiated data
|
|
17
|
+
// channel (id 0, so both ends create it without an in-band handshake), and sends the offer. The
|
|
18
|
+
// answerer builds its connection when the offer arrives and attaches its tracks to the offered
|
|
19
|
+
// transceivers. Nobody ever offers at the same time as the other side, so there is no glare to
|
|
20
|
+
// resolve, and every renegotiation (ICE restart, rebuild) is the offerer's job — an answerer that
|
|
21
|
+
// sees trouble just asks for one.
|
|
22
|
+
//
|
|
23
|
+
// GENERATIONS: every signal carries the connection generation it belongs to. A rebuild bumps it,
|
|
24
|
+
// and a late ICE candidate or answer from a dead connection is dropped instead of poisoning the
|
|
25
|
+
// new one.
|
|
26
|
+
//
|
|
27
|
+
// RECOVERY LADDER on a pair that stops working: 'disconnected' for 4 s, or 'failed' → ICE restart
|
|
28
|
+
// on the SAME connection (cheap: keeps the transceivers, renegotiates candidates) → if that has
|
|
29
|
+
// not connected within 10 s, tear down and rebuild with exponential backoff (1 s, 2 s, 4 s … 30 s,
|
|
30
|
+
// jittered). A peer that ALSO left the signalling room is dropped for good instead.
|
|
31
|
+
|
|
32
|
+
import { preferVideoCodecs, sortCodecCapabilities } from './sdp.js';
|
|
33
|
+
import { backoffMs, maxBitrateKbps, DEFAULT_MAX_PEERS, MESH_HARD_CAP } from './wire.js';
|
|
34
|
+
|
|
35
|
+
export const DEFAULT_ICE_SERVERS = Object.freeze([{ urls: 'stun:stun.l.google.com:19302' }]);
|
|
36
|
+
const DISCONNECTED_GRACE_MS = 4000;
|
|
37
|
+
const ICE_RESTART_TIMEOUT_MS = 10000;
|
|
38
|
+
const CONNECT_TIMEOUT_MS = 20000;
|
|
39
|
+
const ANSWERER_WAIT_MS = 8000;
|
|
40
|
+
/**
|
|
41
|
+
* A peer known to exist (signalling presence) with no working connection for this long is
|
|
42
|
+
* UNREACHABLE: almost always a network that needs a relay (TURN) — ICE sits in 'checking' or
|
|
43
|
+
* fails on every attempt. The state is sticky until a connection comes up; the recovery ladder
|
|
44
|
+
* keeps retrying underneath it the whole time.
|
|
45
|
+
*/
|
|
46
|
+
export const UNREACHABLE_MS = 10000;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Clamp the requested room size to what a full mesh can carry.
|
|
50
|
+
* @param {number} [n]
|
|
51
|
+
*/
|
|
52
|
+
export function clampMaxPeers(n) {
|
|
53
|
+
const v = Number.isInteger(n) ? n : DEFAULT_MAX_PEERS;
|
|
54
|
+
return Math.max(2, Math.min(MESH_HARD_CAP, v));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Of a pair, who offers: the lexically smaller id. Pure. */
|
|
58
|
+
export function isOfferer(myId, theirId) {
|
|
59
|
+
return myId < theirId;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export class MeshTransport {
|
|
63
|
+
/**
|
|
64
|
+
* @param {object} o
|
|
65
|
+
* @param {any} o.signaling a SignalingAdapter
|
|
66
|
+
* @param {string} o.id this peer's id
|
|
67
|
+
* @param {number} [o.maxPeers] room size INCLUDING this peer; clamped to 2..4
|
|
68
|
+
* @param {RTCIceServer[]} [o.iceServers] overrides what the signalling server hands out
|
|
69
|
+
* @param {any} [o.RTCPeerConnection] injectable for tests
|
|
70
|
+
* @param {(tag: string, obj: object) => void} [o.log]
|
|
71
|
+
*/
|
|
72
|
+
constructor({ signaling, id, maxPeers, iceServers, RTCPeerConnection: PC, log, unreachableMs } = {}) {
|
|
73
|
+
this.unreachableMs = unreachableMs > 0 ? unreachableMs : UNREACHABLE_MS;
|
|
74
|
+
this.signaling = signaling;
|
|
75
|
+
this.id = id;
|
|
76
|
+
this.maxPeers = clampMaxPeers(maxPeers);
|
|
77
|
+
this.pageIce = iceServers || null;
|
|
78
|
+
this.iceServers = iceServers || DEFAULT_ICE_SERVERS;
|
|
79
|
+
this.PC = PC || globalThis.RTCPeerConnection;
|
|
80
|
+
this.log = log || (() => {});
|
|
81
|
+
/** @type {Map<string, any>} */
|
|
82
|
+
this.peers = new Map();
|
|
83
|
+
this.session = null;
|
|
84
|
+
this.ready = false;
|
|
85
|
+
this._pending = [];
|
|
86
|
+
this.stopped = false;
|
|
87
|
+
this.sendFormat = 'mono';
|
|
88
|
+
this.localStream = null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Remote peers currently in the mesh (not counting this one). */
|
|
92
|
+
get size() {
|
|
93
|
+
return this.peers.size;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async start({ room, localStream, sendFormat, onPeer, onPeerLeft, onStream, onMessage, onPeerState, onRefused, onSignalingState, onGhost }) {
|
|
97
|
+
this.localStream = localStream;
|
|
98
|
+
this.sendFormat = sendFormat || 'mono';
|
|
99
|
+
this.cb = { onPeer, onPeerLeft, onStream, onMessage, onPeerState, onRefused, onSignalingState, onGhost };
|
|
100
|
+
// Hooks can fire before join() resolves (the PeerJS adapter discovers peers while joining);
|
|
101
|
+
// queue them so every one runs against a fully-initialised transport.
|
|
102
|
+
const later = (fn) => (...a) => (this.ready ? fn(...a) : this._pending.push(() => fn(...a)));
|
|
103
|
+
this.session = await this.signaling.join(room, {
|
|
104
|
+
id: this.id,
|
|
105
|
+
maxPeers: this.maxPeers,
|
|
106
|
+
onPeerJoined: later((pid) => this._onPeerJoined(pid)),
|
|
107
|
+
onPeerLeft: later((pid) => this._onPeerLeftSignaling(pid)),
|
|
108
|
+
onSignal: later((from, data) => this._onSignal(from, data)),
|
|
109
|
+
onDisconnect: (err) => onSignalingState?.(err ? 'closed' : 'reconnecting', err),
|
|
110
|
+
// A participant the signalling layer knows EXISTS but cannot even exchange signalling with
|
|
111
|
+
// (the PeerJS adapter: a taken slot whose data connection never opens). No connection can be
|
|
112
|
+
// attempted, so it is reported as a ghost the UI can show as unreachable.
|
|
113
|
+
onPeerUnreachable: later((gid) => onGhost?.(gid, true)),
|
|
114
|
+
onPeerReachable: later((gid) => onGhost?.(gid, false)),
|
|
115
|
+
onReconnect: later((ids) => {
|
|
116
|
+
onSignalingState?.('connected');
|
|
117
|
+
for (const pid of ids) this._onPeerJoined(pid);
|
|
118
|
+
}),
|
|
119
|
+
});
|
|
120
|
+
if (!this.pageIce && this.session.iceServers && this.session.iceServers.length) {
|
|
121
|
+
// Relays from the signalling adapter (short-lived TURN from dxr-signal, or PeerJS's own), plus public STUN.
|
|
122
|
+
this.iceServers = [...DEFAULT_ICE_SERVERS, ...this.session.iceServers];
|
|
123
|
+
}
|
|
124
|
+
// The client enforces the cap too: a server that allows more than this page asked for does
|
|
125
|
+
// not get to grow the mesh. The newest arrivals are the ones refused.
|
|
126
|
+
const existing = this.session.peers || [];
|
|
127
|
+
if (existing.length >= this.maxPeers) {
|
|
128
|
+
this.session.leave();
|
|
129
|
+
const e = new Error(`@displayxr/inline3d/call: this call is full (${this.maxPeers} participants)`);
|
|
130
|
+
/** @type {any} */ (e).code = 'room-full';
|
|
131
|
+
throw e;
|
|
132
|
+
}
|
|
133
|
+
this.ready = true;
|
|
134
|
+
for (const pid of existing) this._onPeerJoined(pid);
|
|
135
|
+
const q = this._pending;
|
|
136
|
+
this._pending = [];
|
|
137
|
+
for (const fn of q) fn();
|
|
138
|
+
return this.session;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── presence ────────────────────────────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
_onPeerJoined(pid) {
|
|
144
|
+
if (this.stopped || pid === this.id) return;
|
|
145
|
+
const rec = this.peers.get(pid);
|
|
146
|
+
if (rec) {
|
|
147
|
+
rec.absent = false; // back in the signalling room (a reconnect); media may never have stopped
|
|
148
|
+
if (rec.state !== 'connected' && isOfferer(this.id, pid)) this._rebuild(rec, 'rejoined');
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (this.peers.size >= this.maxPeers - 1) {
|
|
152
|
+
this.log('refused', { peer: pid, reason: 'maxPeers', maxPeers: this.maxPeers });
|
|
153
|
+
this.cb.onRefused?.(pid);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const r = this._newRecord(pid);
|
|
157
|
+
this.cb.onPeer?.(pid);
|
|
158
|
+
if (r.offerer) this._build(r, 1);
|
|
159
|
+
else r.waitTimer = setTimeout(() => this._askRestart(r), ANSWERER_WAIT_MS);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
_onPeerLeftSignaling(pid) {
|
|
163
|
+
const rec = this.peers.get(pid);
|
|
164
|
+
if (!rec) return;
|
|
165
|
+
rec.absent = true;
|
|
166
|
+
// Media still flowing peer-to-peer outlives a signalling blip; a dead pair does not.
|
|
167
|
+
if (rec.state !== 'connected') this._drop(rec, 'left');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
_newRecord(pid) {
|
|
171
|
+
const rec = {
|
|
172
|
+
id: pid,
|
|
173
|
+
offerer: isOfferer(this.id, pid),
|
|
174
|
+
pc: null,
|
|
175
|
+
builtAt: 0,
|
|
176
|
+
offerAt: 0,
|
|
177
|
+
dc: null,
|
|
178
|
+
gen: 0,
|
|
179
|
+
state: 'new',
|
|
180
|
+
stream: null,
|
|
181
|
+
attempts: 0,
|
|
182
|
+
iceRestarted: false,
|
|
183
|
+
absent: false,
|
|
184
|
+
pendingCandidates: [],
|
|
185
|
+
timers: new Set(),
|
|
186
|
+
waitTimer: null,
|
|
187
|
+
unreachable: false,
|
|
188
|
+
unreachTimer: null,
|
|
189
|
+
};
|
|
190
|
+
this.peers.set(pid, rec);
|
|
191
|
+
this._armUnreachable(rec);
|
|
192
|
+
return rec;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ── connection lifecycle ────────────────────────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
_build(rec, gen) {
|
|
198
|
+
this._closePc(rec);
|
|
199
|
+
rec.gen = gen;
|
|
200
|
+
rec.builtAt = Date.now();
|
|
201
|
+
rec.iceRestarted = false;
|
|
202
|
+
rec.pendingCandidates = [];
|
|
203
|
+
rec.stream = null; // a new connection's tracks arrive in a new stream (a fresh srcObject)
|
|
204
|
+
const pc = new this.PC({ iceServers: this.iceServers });
|
|
205
|
+
rec.pc = pc;
|
|
206
|
+
const dc = pc.createDataChannel('dxr-call', { negotiated: true, id: 0, ordered: true });
|
|
207
|
+
this._wireDc(rec, dc);
|
|
208
|
+
if (rec.offerer) {
|
|
209
|
+
const tracks = this.localStream ? this.localStream.getTracks() : [];
|
|
210
|
+
const audio = tracks.find((t) => t.kind === 'audio');
|
|
211
|
+
const video = tracks.find((t) => t.kind === 'video');
|
|
212
|
+
const streams = this.localStream ? [this.localStream] : [];
|
|
213
|
+
pc.addTransceiver(audio || 'audio', { direction: 'sendrecv', streams });
|
|
214
|
+
const vt = pc.addTransceiver(video || 'video', { direction: 'sendrecv', streams });
|
|
215
|
+
this._preferCodecs(vt);
|
|
216
|
+
}
|
|
217
|
+
pc.onicecandidate = (e) => {
|
|
218
|
+
if (e.candidate) this._signal(rec, { kind: 'candidate', candidate: e.candidate.toJSON ? e.candidate.toJSON() : e.candidate });
|
|
219
|
+
};
|
|
220
|
+
pc.ontrack = (e) => {
|
|
221
|
+
if (!rec.stream) rec.stream = new MediaStream();
|
|
222
|
+
if (!rec.stream.getTracks().includes(e.track)) {
|
|
223
|
+
for (const t of rec.stream.getTracks()) if (t.kind === e.track.kind) rec.stream.removeTrack(t);
|
|
224
|
+
rec.stream.addTrack(e.track);
|
|
225
|
+
}
|
|
226
|
+
this.cb.onStream?.(rec.id, rec.stream);
|
|
227
|
+
};
|
|
228
|
+
pc.onconnectionstatechange = () => this._onConnState(rec, pc);
|
|
229
|
+
this._arm(rec, CONNECT_TIMEOUT_MS, () => {
|
|
230
|
+
if (rec.pc === pc && pc.connectionState !== 'connected') this._fail(rec, 'connect-timeout');
|
|
231
|
+
});
|
|
232
|
+
if (rec.offerer) this._offer(rec).catch((err) => this.log('offer-error', { peer: rec.id, err: String(err) }));
|
|
233
|
+
this._setState(rec, rec.attempts > 0 ? 'reconnecting' : 'connecting');
|
|
234
|
+
return pc;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async _offer(rec, { iceRestart = false } = {}) {
|
|
238
|
+
const pc = rec.pc;
|
|
239
|
+
const offer = await pc.createOffer(iceRestart ? { iceRestart: true } : undefined);
|
|
240
|
+
if (!this._canPreferCodecs) offer.sdp = preferVideoCodecs(offer.sdp);
|
|
241
|
+
await pc.setLocalDescription(offer);
|
|
242
|
+
this._signal(rec, { kind: 'offer', sdp: pc.localDescription.sdp });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async _onSignal(from, data) {
|
|
246
|
+
if (this.stopped || !data || typeof data !== 'object') return;
|
|
247
|
+
let rec = this.peers.get(from);
|
|
248
|
+
if (!rec) {
|
|
249
|
+
// An offer from someone we have not seen join (the join event can trail the offer).
|
|
250
|
+
if (data.kind !== 'offer') return;
|
|
251
|
+
this._onPeerJoined(from);
|
|
252
|
+
rec = this.peers.get(from);
|
|
253
|
+
if (!rec) return; // refused by the cap
|
|
254
|
+
}
|
|
255
|
+
const gen = data.gen | 0;
|
|
256
|
+
try {
|
|
257
|
+
switch (data.kind) {
|
|
258
|
+
case 'offer': {
|
|
259
|
+
if (rec.offerer) return; // never happens with deterministic roles; ignore rather than glare
|
|
260
|
+
clearTimeout(rec.waitTimer);
|
|
261
|
+
if (gen < rec.gen) return;
|
|
262
|
+
rec.offerAt = Date.now();
|
|
263
|
+
if (gen > rec.gen || !rec.pc) this._build(rec, gen);
|
|
264
|
+
const pc = rec.pc;
|
|
265
|
+
await pc.setRemoteDescription({ type: 'offer', sdp: data.sdp });
|
|
266
|
+
this._attachAnswererTracks(rec);
|
|
267
|
+
const answer = await pc.createAnswer();
|
|
268
|
+
if (!this._canPreferCodecs) answer.sdp = preferVideoCodecs(answer.sdp);
|
|
269
|
+
await pc.setLocalDescription(answer);
|
|
270
|
+
this._signal(rec, { kind: 'answer', sdp: pc.localDescription.sdp });
|
|
271
|
+
this._flushCandidates(rec);
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
case 'answer':
|
|
275
|
+
if (!rec.offerer || gen !== rec.gen || !rec.pc || rec.pc.signalingState !== 'have-local-offer') return;
|
|
276
|
+
await rec.pc.setRemoteDescription({ type: 'answer', sdp: data.sdp });
|
|
277
|
+
this._flushCandidates(rec);
|
|
278
|
+
break;
|
|
279
|
+
case 'candidate':
|
|
280
|
+
if (gen !== rec.gen || !rec.pc) return;
|
|
281
|
+
if (rec.pc.remoteDescription) await rec.pc.addIceCandidate(data.candidate).catch(() => {});
|
|
282
|
+
else rec.pendingCandidates.push(data.candidate);
|
|
283
|
+
break;
|
|
284
|
+
case 'restart':
|
|
285
|
+
// The answerer lost (or never got) its connection, and only it can know: this side's
|
|
286
|
+
// own view can read 'connected' for ~10 s after the far end vanished (until ICE consent
|
|
287
|
+
// fails). So the ask is authoritative — rebuild now; an ICE restart cannot help a peer
|
|
288
|
+
// whose connection is gone. The one exception is a fresh build already in flight.
|
|
289
|
+
if (!rec.offerer) return;
|
|
290
|
+
if (rec.pc && rec.state !== 'connected' && Date.now() - rec.builtAt < 5000) return;
|
|
291
|
+
this._rebuild(rec, 'peer-asked');
|
|
292
|
+
break;
|
|
293
|
+
case 'bye':
|
|
294
|
+
this._drop(rec, 'left');
|
|
295
|
+
break;
|
|
296
|
+
}
|
|
297
|
+
} catch (err) {
|
|
298
|
+
this.log('signal-error', { peer: from, kind: data.kind, err: String(err) });
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
_flushCandidates(rec) {
|
|
303
|
+
const list = rec.pendingCandidates;
|
|
304
|
+
rec.pendingCandidates = [];
|
|
305
|
+
for (const c of list) rec.pc.addIceCandidate(c).catch(() => {});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Answerer side: put our tracks on the transceivers the offer created, and make them sendrecv. */
|
|
309
|
+
_attachAnswererTracks(rec) {
|
|
310
|
+
const pc = rec.pc;
|
|
311
|
+
const tracks = this.localStream ? this.localStream.getTracks() : [];
|
|
312
|
+
for (const tr of pc.getTransceivers()) {
|
|
313
|
+
const kind = tr.receiver && tr.receiver.track ? tr.receiver.track.kind : null;
|
|
314
|
+
if (!kind || tr.sender.track) continue;
|
|
315
|
+
const t = tracks.find((x) => x.kind === kind);
|
|
316
|
+
if (t) {
|
|
317
|
+
tr.sender.replaceTrack(t).catch(() => {});
|
|
318
|
+
if (this.localStream && tr.sender.setStreams) tr.sender.setStreams(this.localStream);
|
|
319
|
+
}
|
|
320
|
+
tr.direction = 'sendrecv';
|
|
321
|
+
if (kind === 'video') this._preferCodecs(tr);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
_preferCodecs(transceiver) {
|
|
326
|
+
const caps = globalThis.RTCRtpReceiver && RTCRtpReceiver.getCapabilities && RTCRtpReceiver.getCapabilities('video');
|
|
327
|
+
this._canPreferCodecs = !!(transceiver && transceiver.setCodecPreferences && caps);
|
|
328
|
+
if (!this._canPreferCodecs) return;
|
|
329
|
+
try {
|
|
330
|
+
transceiver.setCodecPreferences(sortCodecCapabilities(caps.codecs));
|
|
331
|
+
} catch {
|
|
332
|
+
this._canPreferCodecs = false; // fall back to munging the SDP
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
_onConnState(rec, pc) {
|
|
337
|
+
if (rec.pc !== pc) return;
|
|
338
|
+
const s = pc.connectionState;
|
|
339
|
+
this.log('pc-state', { peer: rec.id, state: s, gen: rec.gen });
|
|
340
|
+
if (s === 'connected') {
|
|
341
|
+
this._clearTimers(rec);
|
|
342
|
+
rec.attempts = 0;
|
|
343
|
+
rec.iceRestarted = false;
|
|
344
|
+
this._setState(rec, 'connected');
|
|
345
|
+
this.tuneSenders();
|
|
346
|
+
} else if (s === 'disconnected') {
|
|
347
|
+
this._setState(rec, 'reconnecting');
|
|
348
|
+
this._arm(rec, DISCONNECTED_GRACE_MS, () => {
|
|
349
|
+
if (rec.pc === pc && pc.connectionState !== 'connected') this._fail(rec, 'disconnected');
|
|
350
|
+
});
|
|
351
|
+
} else if (s === 'failed') {
|
|
352
|
+
this._fail(rec, 'failed');
|
|
353
|
+
} else if (s === 'closed' && !this.stopped && this.peers.get(rec.id) === rec) {
|
|
354
|
+
this._fail(rec, 'closed');
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** The recovery ladder: ICE restart first, then a rebuild with backoff (offerer only). */
|
|
359
|
+
_fail(rec, why) {
|
|
360
|
+
if (this.stopped || this.peers.get(rec.id) !== rec) return;
|
|
361
|
+
if (rec.absent) return this._drop(rec, 'left');
|
|
362
|
+
this._setState(rec, 'reconnecting');
|
|
363
|
+
if (!rec.offerer) return this._askRestart(rec);
|
|
364
|
+
const pc = rec.pc;
|
|
365
|
+
if (pc && !rec.iceRestarted && pc.connectionState !== 'closed' && pc.signalingState !== 'closed') {
|
|
366
|
+
rec.iceRestarted = true;
|
|
367
|
+
this.log('ice-restart', { peer: rec.id, why, gen: rec.gen });
|
|
368
|
+
this._offer(rec, { iceRestart: true }).catch(() => this._rebuild(rec, 'ice-restart-failed'));
|
|
369
|
+
this._arm(rec, ICE_RESTART_TIMEOUT_MS, () => {
|
|
370
|
+
if (rec.pc === pc && pc.connectionState !== 'connected') this._rebuild(rec, 'ice-restart-timeout');
|
|
371
|
+
});
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
this._rebuild(rec, why);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
_rebuild(rec, why) {
|
|
378
|
+
if (this.stopped || this.peers.get(rec.id) !== rec) return;
|
|
379
|
+
this._clearTimers(rec);
|
|
380
|
+
this._closePc(rec);
|
|
381
|
+
this._setState(rec, 'reconnecting');
|
|
382
|
+
const delay = backoffMs(rec.attempts++);
|
|
383
|
+
this.log('redial', { peer: rec.id, why, attempt: rec.attempts, inMs: delay });
|
|
384
|
+
this._arm(rec, delay, () => this._build(rec, rec.gen + 1));
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
_askRestart(rec) {
|
|
388
|
+
if (this.stopped || this.peers.get(rec.id) !== rec || rec.offerer) return;
|
|
389
|
+
const askedAt = Date.now();
|
|
390
|
+
this._signal(rec, { kind: 'restart' });
|
|
391
|
+
// Ask again, backing off, only while no offer has answered the last ask — a connection the
|
|
392
|
+
// offerer is already rebuilding must be given its chance (its own timeout covers it).
|
|
393
|
+
this._arm(rec, Math.max(3000, backoffMs(rec.attempts++)), () => {
|
|
394
|
+
if (rec.state !== 'connected' && !(rec.offerAt >= askedAt)) this._askRestart(rec);
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
_drop(rec, reason) {
|
|
399
|
+
if (this.peers.get(rec.id) !== rec) return;
|
|
400
|
+
this._clearTimers(rec);
|
|
401
|
+
clearTimeout(rec.unreachTimer);
|
|
402
|
+
clearTimeout(rec.waitTimer);
|
|
403
|
+
this._closePc(rec);
|
|
404
|
+
this.peers.delete(rec.id);
|
|
405
|
+
this._setState(rec, 'left');
|
|
406
|
+
this.cb.onPeerLeft?.(rec.id, reason);
|
|
407
|
+
this.tuneSenders();
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
_closePc(rec) {
|
|
411
|
+
if (rec.dc) {
|
|
412
|
+
try {
|
|
413
|
+
rec.dc.close();
|
|
414
|
+
} catch {
|
|
415
|
+
/* ignore */
|
|
416
|
+
}
|
|
417
|
+
rec.dc = null;
|
|
418
|
+
}
|
|
419
|
+
if (rec.pc) {
|
|
420
|
+
const pc = rec.pc;
|
|
421
|
+
rec.pc = null; // before close(): its 'closed' state change must not re-enter _fail
|
|
422
|
+
try {
|
|
423
|
+
pc.close();
|
|
424
|
+
} catch {
|
|
425
|
+
/* ignore */
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
_arm(rec, ms, fn) {
|
|
431
|
+
const t = setTimeout(() => {
|
|
432
|
+
rec.timers.delete(t);
|
|
433
|
+
fn();
|
|
434
|
+
}, ms);
|
|
435
|
+
rec.timers.add(t);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
_clearTimers(rec) {
|
|
439
|
+
for (const t of rec.timers) clearTimeout(t);
|
|
440
|
+
rec.timers.clear();
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** Start the unreachable clock (idempotent); a connection clears it (see _setState). */
|
|
444
|
+
_armUnreachable(rec) {
|
|
445
|
+
if (rec.unreachTimer || rec.unreachable) return;
|
|
446
|
+
rec.unreachTimer = setTimeout(() => {
|
|
447
|
+
rec.unreachTimer = null;
|
|
448
|
+
if (this.stopped || this.peers.get(rec.id) !== rec || rec.state === 'connected') return;
|
|
449
|
+
rec.unreachable = true;
|
|
450
|
+
this.log('unreachable', { peer: rec.id, afterMs: this.unreachableMs, pc: rec.pc ? rec.pc.iceConnectionState : null });
|
|
451
|
+
this._setState(rec, 'unreachable');
|
|
452
|
+
}, this.unreachableMs);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
_setState(rec, state) {
|
|
456
|
+
if (state === 'connected') {
|
|
457
|
+
clearTimeout(rec.unreachTimer);
|
|
458
|
+
rec.unreachTimer = null;
|
|
459
|
+
rec.unreachable = false;
|
|
460
|
+
} else if (state === 'connecting' || state === 'reconnecting') {
|
|
461
|
+
// Sticky: retries continue underneath, but the page keeps being told the truth.
|
|
462
|
+
if (rec.unreachable) state = 'unreachable';
|
|
463
|
+
else this._armUnreachable(rec);
|
|
464
|
+
}
|
|
465
|
+
if (rec.state === state) return;
|
|
466
|
+
rec.state = state;
|
|
467
|
+
this.cb.onPeerState?.(rec.id, state);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
_signal(rec, data) {
|
|
471
|
+
this.session?.send(rec.id, { ...data, gen: rec.gen });
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// ── data channel ────────────────────────────────────────────────────────────────────────
|
|
475
|
+
|
|
476
|
+
_wireDc(rec, dc) {
|
|
477
|
+
rec.dc = dc;
|
|
478
|
+
dc.onopen = () => this.cb.onMessage?.(rec.id, { type: '__open' });
|
|
479
|
+
dc.onmessage = (e) => {
|
|
480
|
+
let msg;
|
|
481
|
+
try {
|
|
482
|
+
msg = JSON.parse(e.data);
|
|
483
|
+
} catch {
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
if (msg && msg.type === 'bye') return this._drop(rec, 'left');
|
|
487
|
+
this.cb.onMessage?.(rec.id, msg);
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
send(pid, msg) {
|
|
492
|
+
const rec = this.peers.get(pid);
|
|
493
|
+
if (rec && rec.dc && rec.dc.readyState === 'open') {
|
|
494
|
+
rec.dc.send(JSON.stringify(msg));
|
|
495
|
+
return true;
|
|
496
|
+
}
|
|
497
|
+
return false;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
broadcast(msg) {
|
|
501
|
+
for (const pid of this.peers.keys()) this.send(pid, msg);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
peerConnection(pid) {
|
|
505
|
+
const rec = this.peers.get(pid);
|
|
506
|
+
return rec ? rec.pc : null;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
peerState(pid) {
|
|
510
|
+
const rec = this.peers.get(pid);
|
|
511
|
+
return rec ? rec.state : null;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// ── sending ─────────────────────────────────────────────────────────────────────────────
|
|
515
|
+
|
|
516
|
+
/** Swap the outgoing video (setCamera). No renegotiation: replaceTrack on every sender. */
|
|
517
|
+
async replaceTrack(kind, track, stream) {
|
|
518
|
+
if (stream) this.localStream = stream;
|
|
519
|
+
for (const rec of this.peers.values()) {
|
|
520
|
+
if (!rec.pc) continue;
|
|
521
|
+
for (const tr of rec.pc.getTransceivers()) {
|
|
522
|
+
const k = tr.receiver && tr.receiver.track ? tr.receiver.track.kind : null;
|
|
523
|
+
if (k === kind) await tr.sender.replaceTrack(track).catch(() => {});
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
await this.tuneSenders();
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
setSendFormat(format) {
|
|
530
|
+
this.sendFormat = format;
|
|
531
|
+
return this.tuneSenders();
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Hold resolution, drop frame rate under pressure (a halved SBS width halves each eye), and
|
|
536
|
+
* share the uplink across the mesh: `maxBitrate` falls as peers join.
|
|
537
|
+
*/
|
|
538
|
+
async tuneSenders() {
|
|
539
|
+
const n = this.peers.size;
|
|
540
|
+
const kbps = maxBitrateKbps(this.sendFormat, n);
|
|
541
|
+
for (const rec of this.peers.values()) {
|
|
542
|
+
if (!rec.pc) continue;
|
|
543
|
+
for (const s of rec.pc.getSenders()) {
|
|
544
|
+
if (!s.track || s.track.kind !== 'video') continue;
|
|
545
|
+
try {
|
|
546
|
+
const p = s.getParameters();
|
|
547
|
+
p.degradationPreference = 'maintain-resolution';
|
|
548
|
+
if (!p.encodings || !p.encodings.length) p.encodings = [{}];
|
|
549
|
+
p.encodings[0].maxBitrate = kbps * 1000;
|
|
550
|
+
await s.setParameters(p);
|
|
551
|
+
} catch (err) {
|
|
552
|
+
this.log('setParameters-error', { peer: rec.id, err: String(err) });
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
return kbps;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// ── teardown ────────────────────────────────────────────────────────────────────────────
|
|
560
|
+
|
|
561
|
+
stop() {
|
|
562
|
+
if (this.stopped) return;
|
|
563
|
+
this.broadcast({ type: 'bye' });
|
|
564
|
+
this.stopped = true;
|
|
565
|
+
for (const rec of this.peers.values()) {
|
|
566
|
+
this._clearTimers(rec);
|
|
567
|
+
clearTimeout(rec.waitTimer);
|
|
568
|
+
clearTimeout(rec.unreachTimer);
|
|
569
|
+
this._signal(rec, { kind: 'bye' });
|
|
570
|
+
this._closePc(rec);
|
|
571
|
+
}
|
|
572
|
+
this.peers.clear();
|
|
573
|
+
try {
|
|
574
|
+
this.session?.leave();
|
|
575
|
+
} catch {
|
|
576
|
+
/* ignore */
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** Test/diagnostic hook: kill one pair's connection as a network failure would. */
|
|
581
|
+
_debugKill(pid) {
|
|
582
|
+
const rec = this.peers.get(pid);
|
|
583
|
+
if (!rec || !rec.pc) return false;
|
|
584
|
+
// close() fires no connectionstatechange, so walk the ladder by hand, exactly as 'failed' would.
|
|
585
|
+
this._closePc(rec);
|
|
586
|
+
this._fail(rec, 'debug-kill');
|
|
587
|
+
return true;
|
|
588
|
+
}
|
|
589
|
+
}
|