@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.
@@ -0,0 +1,1695 @@
1
+ // inline3d-call.js — a 3D video call in any page, in one call.
2
+ //
3
+ // PREVIEW tier. Not covered by the SDK's 1.x semver promise — see docs/sdk-stability.md.
4
+ // Implements P1 of docs/rfcs/0002-video-call.md.
5
+ //
6
+ // import { createInline3D } from '@displayxr/inline3d';
7
+ // import { addCall, dxrSignaling } from '@displayxr/inline3d/call';
8
+ //
9
+ // const wall = await createInline3D();
10
+ // const call = await addCall(wall, document.getElementById('call'), {
11
+ // signaling: dxrSignaling(), // hosted DisplayXR server; or dxrSignaling('wss://your.server')
12
+ // });
13
+ // call.on('peer', ({ id }) => console.log('joined', id));
14
+ //
15
+ // WHAT IT OWNS. Unlike the other modules this one takes a CONTAINER, not a canvas: a call has a
16
+ // variable number of participants, so it creates one persistent canvas per remote participant
17
+ // (plus the self view) inside `container`, lays them out, and draws its own chrome around them.
18
+ //
19
+ // ── RENDERING, AND WHY THE SBS PATH GOES THROUGH addImage ──────────────────────────────────────
20
+ //
21
+ // Each remote participant is ONE detached `<video srcObject>` (never in the DOM: an in-DOM video
22
+ // is a second candidate the browser's join fallback could weave instead of the canvas — P0) and
23
+ // ONE woven canvas. The route is RFC §3's table (call/wire.js routeFor):
24
+ //
25
+ // sbs on a woven wall → woven, with a CONVERGENCE SHIFT
26
+ // sbs on a 2D wall → the left eye, flat
27
+ // mono on a woven wall → LIFTED: one lift() stream per peer (`mono3D`, P2 — see below)
28
+ // mono on a 2D wall → flat
29
+ //
30
+ // The convergence shift is a per-eye horizontal CROP OFFSET, `f_px·baseline/(2·subjectZ)`, from
31
+ // the sender's `hello` + `hint`, low-passed. `wall.addVideo()` draws the whole video frame and
32
+ // `Inline3D._paint` refuses anything that is not a ready <video> on that path (its readyState
33
+ // gate), so the shifted pair is painted into an intermediate SBS canvas this module owns, and that
34
+ // canvas is handed to `wall.addImage(tileCanvas, convCanvas)` — which the core documents as "a
35
+ // canvas you own, repainted every frame". The SDK therefore still owns the tile's backing buffer
36
+ // (sizing, dpr, lazy lifecycle, mono fallback, tracking-loss easing); this module only decides
37
+ // what the pair IS. No core change. The intermediate canvas is cropped to the tile's aspect, so
38
+ // the woven buffer's per-eye aspect always equals the stage box — never `object-fit` a woven
39
+ // canvas (P0: it letterboxes the SBS quad).
40
+ //
41
+ // A tile registers with the wall ONCE per (peer, format): routing waits for both the stream to
42
+ // play and the `hello` (or its 2 s timeout), so hello and 'playing' arriving in either order never
43
+ // remove + re-add a layer (P0).
44
+ //
45
+ // TRACKING LOSS is the core's: with `createInline3D({ untrackedFallback: 'mono' })` every image
46
+ // window — these tiles included — eases to its left eye while nobody is tracked, exactly as the
47
+ // player's SBS path does. Nothing here asks for it.
48
+ //
49
+ // MONO→3D (P2, RFC §5): a mono peer on a woven wall is handed to `lift()`
50
+ // (`@displayxr/inline3d/lift`, loaded lazily — never a hard dependency; call/lift.js) with
51
+ // `{ mode: 'live', wall }`, so lift's canvas is one more window on the call's own wall and follows
52
+ // the same #172 rule as the SBS tiles: lift AT ONCE (in an all-mono call lift's window is the only
53
+ // layer, and the inline session does not tick until a layer exists — waiting for live frames
54
+ // would deadlock), then release + lift again ONCE when the weave session goes live.
55
+ // DEVIATION from the detached-<video> rule, for lifted tiles only: lift() floats its canvas over
56
+ // the ELEMENT's rect and (native provider) the browser converts that element in place, so a lifted
57
+ // tile's <video> is mounted inside its stage. lift hides it (`visibility:hidden`, the #168 guard)
58
+ // while its own canvas covers it on the web path; the browser weaves it itself on the native one.
59
+ // Leaving the lifted route detaches it again. Priority follows the active speaker (high) and
60
+ // visibility (paused), the depth slider drives lift's convergence, and at most `maxLifted`
61
+ // streams run at once.
62
+ //
63
+ // SESSION LOSS: a call must outlive a service restart. A layer that fails to build is retried
64
+ // with backoff (flat in between); a session that ENDS without the page closing the wall is
65
+ // replaced with a fresh `createInline3D()` and every tile re-registers on it — no reload. To tell
66
+ // the two apart the module wraps `wall.close` for the call's lifetime (a page close runs the
67
+ // SDK's teardown synchronously, so no listener can distinguish them afterwards); `leave()`
68
+ // restores it.
69
+
70
+ import { createInline3D } from './inline3d.js';
71
+ import {
72
+ WIRE_VERSION,
73
+ CALL_SDK,
74
+ DEFAULT_MAX_PEERS,
75
+ HINT_MAX_HZ,
76
+ newRoomId,
77
+ newPeerId,
78
+ isValidRoomId,
79
+ parseInviteLink,
80
+ buildInviteLink,
81
+ normalizeHello,
82
+ makeHello,
83
+ normalizeHint,
84
+ normalizeState,
85
+ rateGate,
86
+ routeFor,
87
+ badgeFor,
88
+ createConvergence,
89
+ eyeCropRect,
90
+ eyeOutputSize,
91
+ mirrorSwapOps,
92
+ backoffMs,
93
+ createLiveGate,
94
+ } from './call/wire.js';
95
+ import { MeshTransport, clampMaxPeers } from './call/transport.js';
96
+ import { openCamera, openMic } from './call/capture.js';
97
+ import { drawQr } from './call/qr.js';
98
+ import { injectCallStyle, ICONS, el, show, resolveCallAccent, CALL_ACCENTS } from './call/ui.js';
99
+ import { normalizeMono3D, resolveLift, createLiftPool, createFrameWatch } from './call/lift.js';
100
+
101
+ import { dxrSignaling } from './call/signaling.js';
102
+ export { dxrSignaling, peerjsCloud, SIGNAL_PROTOCOL, roomKey, DXR_SIGNAL_DEFAULT } from './call/signaling.js';
103
+ export {
104
+ WIRE_VERSION,
105
+ CALL_SDK,
106
+ newRoomId,
107
+ isValidRoomId,
108
+ parseInviteLink,
109
+ buildInviteLink,
110
+ normalizeHello,
111
+ makeHello,
112
+ routeFor,
113
+ badgeFor,
114
+ CALL_ACCENTS,
115
+ };
116
+ export { createLiveGate } from './call/wire.js';
117
+ export { convergenceShiftPx, lowPass, clampShift, eyeCropRect, mirrorSwapOps, mirrorSwapPixels, maxBitrateKbps } from './call/wire.js';
118
+ export { preferVideoCodecs, sortCodecCapabilities, VIDEO_CODEC_ORDER } from './call/sdp.js';
119
+ export { MeshTransport, clampMaxPeers } from './call/transport.js';
120
+ export { qrEncode } from './call/qr.js';
121
+ export {
122
+ normalizeMono3D,
123
+ resolveLift,
124
+ createLiftPool,
125
+ createFrameWatch,
126
+ liftConvergenceFor,
127
+ liftPriorityFor,
128
+ setLiftPriority,
129
+ defaultLiftSpecifier,
130
+ LIFT_PRIORITY,
131
+ } from './call/lift.js';
132
+
133
+ const TAG = '[inline3d/call]';
134
+ const HELLO_WAIT_MS = 2000;
135
+ const LEFT_TILE_MS = 2500;
136
+ const STATS_TICK_MS = 500;
137
+ const SPEAKING_LEVEL = 0.02;
138
+ const LAYER_RETRIES = 4;
139
+ const LIFT_RETRIES = 2;
140
+ const DEFAULT_BROWSER_URL = 'https://github.com/DisplayXR/displayxr-browser';
141
+ export const PLATE_TEXT = Object.freeze({
142
+ unreachable: "Can't reach this participant — the network needs a relay (TURN)",
143
+ cameraBusy: 'Camera busy — in use by another app (e.g. eye tracking)',
144
+ });
145
+
146
+ /**
147
+ * Apply defaults and validate. Pure (no DOM, no network) — see test/call.test.mjs.
148
+ * @param {object} [opts]
149
+ */
150
+ export function normalizeCallOptions(opts = {}) {
151
+ const ui = opts.ui === undefined ? true : !!opts.ui;
152
+ const aspect = typeof opts.tileAspect === 'number' && opts.tileAspect > 0.3 && opts.tileAspect < 4 ? opts.tileAspect : 16 / 9;
153
+ let room = opts.room === undefined || opts.room === null ? 'auto' : opts.room;
154
+ if (room !== 'auto' && !isValidRoomId(room)) {
155
+ const fromLink = parseInviteLink(room);
156
+ if (!fromLink) throw new Error(`@displayxr/inline3d/call: room "${room}" is not a valid room id (16-64 base64url chars) or invite link`);
157
+ room = fromLink;
158
+ }
159
+ return {
160
+ room,
161
+ // Default: the hosted DisplayXR signalling server (which also mints TURN credentials).
162
+ signaling: opts.signaling || dxrSignaling(),
163
+ iceServers: Array.isArray(opts.iceServers) ? opts.iceServers : undefined,
164
+ camera: opts.camera === undefined ? 'auto' : opts.camera,
165
+ format: opts.format === 'sbs' ? 'sbs' : opts.format === 'mono' ? 'mono' : undefined,
166
+ calibration: opts.calibration && typeof opts.calibration === 'object' ? { ...opts.calibration } : {},
167
+ rectify: typeof opts.rectify === 'function' ? opts.rectify : null,
168
+ audio: opts.audio === undefined ? true : !!opts.audio,
169
+ mono3D: normalizeMono3D(opts.mono3D),
170
+ maxPeers: clampMaxPeers(opts.maxPeers === undefined ? DEFAULT_MAX_PEERS : opts.maxPeers),
171
+ // Extra lift() options for lifted tiles (models, ort, quality, providers). The call's own
172
+ // keys (mode, wall, ui, convergence, priority) always win.
173
+ liftOptions: opts.liftOptions && typeof opts.liftOptions === 'object' ? { ...opts.liftOptions } : null,
174
+ // Concurrent lifted tiles. Default = maxPeers (4): every mono peer can be lifted.
175
+ maxLifted: Number.isFinite(+opts.maxLifted) && opts.maxLifted !== null ? Math.max(0, Math.min(DEFAULT_MAX_PEERS, Math.floor(+opts.maxLifted))) : DEFAULT_MAX_PEERS,
176
+ layout: opts.layout === 'speaker' ? 'speaker' : 'grid',
177
+ ui,
178
+ autoJoin: opts.autoJoin === undefined ? !ui : !!opts.autoJoin,
179
+ selfView: opts.selfView === undefined ? true : !!opts.selfView,
180
+ tileAspect: aspect,
181
+ accent: typeof opts.accent === 'string' && opts.accent ? resolveCallAccent(opts.accent) : null,
182
+ inviteBase: typeof opts.inviteBase === 'string' ? opts.inviteBase : null,
183
+ updateUrl: opts.updateUrl === undefined ? ui : !!opts.updateUrl,
184
+ browserUrl: typeof opts.browserUrl === 'string' ? opts.browserUrl : DEFAULT_BROWSER_URL,
185
+ recoverSession: opts.recoverSession === undefined ? true : !!opts.recoverSession,
186
+ scrollIntoView: opts.scrollIntoView === undefined ? true : !!opts.scrollIntoView,
187
+ wallOptions: opts.wallOptions && typeof opts.wallOptions === 'object' ? opts.wallOptions : {},
188
+ log: typeof opts.log === 'function' ? opts.log : opts.debug ? (tag, obj) => console.log(`${TAG} ${tag} ${JSON.stringify(obj)}`) : null,
189
+ };
190
+ }
191
+
192
+ /**
193
+ * Put a 3D video call in `container`. Resolves once the camera is open and (with `ui:true`) the
194
+ * lobby is showing; with `ui:false` (or `autoJoin:true`) once the call is joined.
195
+ *
196
+ * @param {any} wall a `createInline3D()` result (may be `{supported:false}`) or null
197
+ * @param {HTMLElement} container
198
+ * @param {object} opts see call.d.ts
199
+ */
200
+ export async function addCall(wall, container, opts = {}) {
201
+ if (!container || typeof container.appendChild !== 'function') throw new TypeError('@displayxr/inline3d/call: addCall(wall, container, opts) needs a container element');
202
+ const o = normalizeCallOptions(opts);
203
+ if (!o.signaling || typeof o.signaling.join !== 'function') {
204
+ throw new TypeError('@displayxr/inline3d/call: opts.signaling must be a SignalingAdapter — dxrSignaling([url]), peerjsCloud() (demo only), or your own');
205
+ }
206
+ const call = new Call(wall, container, o);
207
+ await call._init();
208
+ return call.handle;
209
+ }
210
+
211
+ class Call {
212
+ constructor(wall, container, o) {
213
+ this.o = o;
214
+ this.container = container;
215
+ this.wall = wall || null;
216
+ this.wallLive = !!(wall && wall.supported);
217
+ this.weaveLive = false; // see createLiveGate / #172: no woven registration before this
218
+ this.camStatus = 'pending'; // 'ok' | 'busy' | 'none' | 'pending'
219
+ this.id = newPeerId();
220
+ this.room = o.room === 'auto' ? parseInviteLink(globalThis.location) : o.room;
221
+ this.state = 'idle'; // idle | lobby | joining | in-call | full | left
222
+ this.tiles = new Map();
223
+ this.listeners = new Map();
224
+ this.local = null; // { stream, video, audio, format, width, height, calibration, owned, label }
225
+ this.muted = false;
226
+ this.camOff = false;
227
+ this.depth = 0;
228
+ this.speakerId = null;
229
+ this.transport = null;
230
+ this._raf = 0;
231
+ this._statsTimer = 0;
232
+ this._hintGate = rateGate(HINT_MAX_HZ);
233
+ // mono→3D (call/lift.js): the option, whether it is on right now (setMono3D), the resolved
234
+ // lift (null until resolved), the pool of lifted streams, and the web-provider frame watch.
235
+ this.mono3D = o.mono3D;
236
+ this.mono3DOn = o.mono3D !== 'off';
237
+ this.liftApi = null;
238
+ this.liftPool = null;
239
+ this._liftP = null;
240
+ this.liftProven = false;
241
+ this._frameWatch = createFrameWatch();
242
+ this.handle = this._makeHandle();
243
+ if (this.wallLive) this._hookWall(this.wall);
244
+ }
245
+
246
+ get woven() {
247
+ // NOT gated on weaveLive: the inline session does not tick until a layer exists, so waiting
248
+ // for live frames before the first registration deadlocks (seen on a real panel). Tiles
249
+ // register at once; _watchLive re-registers them once when the session goes live (#172).
250
+ return !!(this.wall && this.wall.supported && this.wallLive);
251
+ }
252
+
253
+ log(tag, obj = {}) {
254
+ if (this.o.log) this.o.log(tag, obj);
255
+ }
256
+
257
+ emit(type, payload) {
258
+ const set = this.listeners.get(type);
259
+ if (!set) return;
260
+ for (const cb of [...set]) {
261
+ try {
262
+ cb(payload);
263
+ } catch (err) {
264
+ console.error(`${TAG} '${type}' listener threw`, err);
265
+ }
266
+ }
267
+ }
268
+
269
+ error(code, message, err) {
270
+ this.log('error', { code, message });
271
+ if (!this.listeners.get('error')?.size) console.warn(`${TAG} ${code}: ${message}`);
272
+ this.emit('error', { code, message, error: err || null });
273
+ }
274
+
275
+ // ── setup ────────────────────────────────────────────────────────────────────────────────
276
+
277
+ async _init() {
278
+ this._buildDom();
279
+ // The lobby's pre-call hint wants to know what a mono peer will look like; nothing to lift on
280
+ // a 2D wall (mono peers are flat there by the table).
281
+ if (this.wallLive) this._ensureLift();
282
+ await this._openMedia(this.o.camera);
283
+ this._startLoop();
284
+ if (this.o.autoJoin) await this.join();
285
+ else this._setState('lobby');
286
+ }
287
+
288
+ async _openMedia(want, format, { keepOnFail = false } = {}) {
289
+ const log = (t, x) => this.log(t, x);
290
+ let cam = null;
291
+ let camError = null;
292
+ try {
293
+ cam = await openCamera(want, { format: format || this.o.format, calibration: this.o.calibration, log });
294
+ } catch (err) {
295
+ camError = err;
296
+ // No camera is never fatal: the call goes on audio-only, the self view and every receiver
297
+ // say why, and the user can retry (the tracker may let go) or pick another camera.
298
+ const busy = err.code === 'camera-busy';
299
+ this.error(busy ? 'camera-busy' : err.code || 'no-camera', busy ? `${PLATE_TEXT.cameraBusy}. Joining audio-only.` : `no camera: ${err.message}. Joining audio-only.`, err);
300
+ if (keepOnFail && this.local && this.local.videoTrack) return this.local; // keep the working one
301
+ this.camStatus = busy ? 'busy' : 'none';
302
+ }
303
+ if (cam) this.camStatus = 'ok';
304
+ if (cam && cam.format === 'sbs' && this.o.rectify) {
305
+ // P2 seam: a calibrated rectification step (plug-in or runtime supplied). Its output is a
306
+ // rectified SBS stream; the hello then says so.
307
+ try {
308
+ const out = await this.o.rectify(cam.stream, { width: cam.width, height: cam.height, deviceId: cam.deviceId, label: cam.label });
309
+ if (out && typeof out.getVideoTracks === 'function') {
310
+ cam.stream = out;
311
+ cam.calibration = { ...cam.calibration, rectified: true };
312
+ }
313
+ } catch (err) {
314
+ this.error('rectify-failed', `rectify() threw — sending the raw pair: ${err.message}`, err);
315
+ }
316
+ }
317
+ let audioTrack = this.local ? this.local.audioTrack : null;
318
+ if (this.o.audio && !audioTrack) audioTrack = await openMic({ log });
319
+ if (audioTrack) audioTrack.enabled = !this.muted;
320
+ const videoTrack = cam ? cam.stream.getVideoTracks()[0] || null : null;
321
+ if (videoTrack) {
322
+ // Resolution over frame rate: halving an SBS frame's width halves each eye (RFC §2).
323
+ try {
324
+ videoTrack.contentHint = 'detail';
325
+ } catch {
326
+ /* older engines */
327
+ }
328
+ videoTrack.enabled = !this.camOff;
329
+ }
330
+ const stream = new MediaStream([videoTrack, audioTrack].filter(Boolean));
331
+ const video = this.local?.video || Object.assign(document.createElement('video'), { muted: true, playsInline: true, autoplay: true });
332
+ video.srcObject = cam ? cam.stream : null;
333
+ video.play().catch(() => {});
334
+ const prev = this.local;
335
+ this.local = {
336
+ stream,
337
+ camStream: cam ? cam.stream : null,
338
+ video,
339
+ videoTrack,
340
+ audioTrack,
341
+ format: cam ? cam.format : 'mono',
342
+ width: cam ? cam.width : 0,
343
+ height: cam ? cam.height : 0,
344
+ calibration: cam ? cam.calibration : {},
345
+ owned: cam ? cam.owned : false,
346
+ label: cam ? cam.label : '',
347
+ // Kept on the error path too: which cameras were held, and why, is the one clue a
348
+ // 'camera-busy' / 'no-camera' report carries.
349
+ skipped: cam ? cam.skipped : camError?.skipped || [],
350
+ };
351
+ this.log('camera', { format: this.local.format, width: this.local.width, height: this.local.height, label: this.local.label, skipped: this.local.skipped.length });
352
+ if (prev && prev.owned && prev.camStream && prev.camStream !== this.local.camStream) prev.camStream.getVideoTracks().forEach((t) => t.stop());
353
+ this._refreshSelf();
354
+ this._refreshLobby();
355
+ return this.local;
356
+ }
357
+
358
+ hello() {
359
+ const l = this.local || {};
360
+ return makeHello({
361
+ format: l.format,
362
+ width: l.width,
363
+ height: l.height,
364
+ baselineMm: l.calibration?.baselineMm,
365
+ hfovDeg: l.calibration?.hfovDeg,
366
+ rectified: !!l.calibration?.rectified,
367
+ });
368
+ }
369
+
370
+ stateMsg() {
371
+ // An audio-only participant (busy / no camera) reads as camera-off: receivers show a plate,
372
+ // never a black or 0x0 tile.
373
+ return { type: 'state', muted: this.muted, cameraOff: this.camOff || !this.local?.videoTrack, speaking: !!this._selfSpeaking };
374
+ }
375
+
376
+ // ── joining ──────────────────────────────────────────────────────────────────────────────
377
+
378
+ async join() {
379
+ if (this.state === 'joining' || this.state === 'in-call') return this.handle;
380
+ if (this.state === 'left' && !this.local?.videoTrack) await this._openMedia(this.o.camera);
381
+ if (!this.room) {
382
+ this.room = newRoomId();
383
+ this.log('room-created', {});
384
+ }
385
+ if (this.o.updateUrl && globalThis.history && globalThis.location) {
386
+ try {
387
+ history.replaceState(history.state, '', buildInviteLink(location.href, this.room));
388
+ } catch {
389
+ /* sandboxed iframe */
390
+ }
391
+ }
392
+ this._setState('joining');
393
+ const t = new MeshTransport({
394
+ signaling: this.o.signaling,
395
+ id: this.id,
396
+ maxPeers: this.o.maxPeers,
397
+ iceServers: this.o.iceServers,
398
+ log: (tag, obj) => this.log(tag, obj),
399
+ });
400
+ this.transport = t;
401
+ try {
402
+ await t.start({
403
+ room: this.room,
404
+ localStream: this.local.stream,
405
+ sendFormat: this.local.format,
406
+ onPeer: (id) => this._onPeer(id),
407
+ onPeerLeft: (id, reason) => this._onPeerLeft(id, reason),
408
+ onStream: (id, stream) => this.tiles.get(id)?.setStream(stream),
409
+ onMessage: (id, msg) => this._onMessage(id, msg),
410
+ onPeerState: (id, st) => {
411
+ this.tiles.get(id)?.setConn(st);
412
+ this.emit('state', { id, state: st });
413
+ },
414
+ onRefused: (id) => this.log('refused-peer', { id }),
415
+ onGhost: (gid, on) => this._onGhost(gid, on),
416
+ onSignalingState: (st, err) => {
417
+ this.log('signaling', { state: st });
418
+ if (st === 'closed' && err) this.error(err.code || 'signaling-closed', err.message, err);
419
+ },
420
+ });
421
+ } catch (err) {
422
+ this.transport = null;
423
+ if (err && err.code === 'room-full') {
424
+ this._setState('full');
425
+ this.error('room-full', err.message, err);
426
+ } else {
427
+ this._setState('lobby');
428
+ this.error(err.code || 'join-failed', `could not join: ${err.message}`, err);
429
+ }
430
+ throw err;
431
+ }
432
+ this._setState('in-call');
433
+ // The grid is sized so the whole call block fits ONE viewport, but only if it starts at the
434
+ // top: a tile below the fold is withheld from the weave (browser#167), so its layer goes
435
+ // live late. Bring the block into view once, on join.
436
+ if (this.o.ui && this.o.scrollIntoView) {
437
+ try {
438
+ this.container.scrollIntoView?.({ block: 'start', behavior: 'instant' });
439
+ this._fitGrid();
440
+ } catch {
441
+ /* ignore */
442
+ }
443
+ }
444
+ this._startStats();
445
+ this._pagehide = () => this.leave();
446
+ globalThis.addEventListener?.('pagehide', this._pagehide);
447
+ this.log('joined', { id: this.id, peers: this.tiles.size });
448
+ this.emit('joined', { room: this.room, id: this.id });
449
+ return this.handle;
450
+ }
451
+
452
+ _onPeer(id) {
453
+ if (this.tiles.has(id)) return;
454
+ const tile = new Tile(this, id);
455
+ this.tiles.set(id, tile);
456
+ this._layout();
457
+ this.emit('peer', { id });
458
+ }
459
+
460
+ /**
461
+ * A participant the signalling layer knows exists but cannot reach at all (no id to connect to
462
+ * yet — see peerjsCloud). Shown as a tile in the 'unreachable' state so the page never claims
463
+ * you are alone; removed when the adapter reaches it (or finds the slot free).
464
+ */
465
+ _onGhost(gid, on) {
466
+ const t = this.tiles.get(gid);
467
+ if (on && !t) {
468
+ const tile = new Tile(this, gid);
469
+ tile.ghost = true;
470
+ this.tiles.set(gid, tile);
471
+ tile.setConn('unreachable');
472
+ this._layout();
473
+ } else if (!on && t && t.ghost) {
474
+ t.destroy();
475
+ this.tiles.delete(gid);
476
+ this._layout();
477
+ }
478
+ }
479
+
480
+ _onPeerLeft(id, reason) {
481
+ const tile = this.tiles.get(id);
482
+ if (!tile) return;
483
+ tile.setConn('left');
484
+ this.emit('peerleft', { id, reason });
485
+ tile.leaveTimer = setTimeout(() => {
486
+ tile.destroy();
487
+ if (this.tiles.get(id) === tile) this.tiles.delete(id);
488
+ if (this.speakerId === id) this._setSpeaker(null);
489
+ this._layout();
490
+ }, LEFT_TILE_MS);
491
+ }
492
+
493
+ _onMessage(id, msg) {
494
+ const tile = this.tiles.get(id);
495
+ if (!tile || !msg) return;
496
+ if (msg.type === '__open') {
497
+ this.transport.send(id, this.hello());
498
+ this.transport.send(id, this.stateMsg());
499
+ tile.onChannelOpen();
500
+ return;
501
+ }
502
+ const h = normalizeHello(msg);
503
+ if (h) return tile.setHello(h);
504
+ const hint = normalizeHint(msg);
505
+ if (hint) return tile.setHint(hint);
506
+ const st = normalizeState(msg);
507
+ if (st) return tile.setRemoteState(st);
508
+ }
509
+
510
+ // ── handle ops ─────────────────────────────────────────────────────────────────────────
511
+
512
+ inviteLink() {
513
+ if (!this.room) return null;
514
+ const base = this.o.inviteBase || (globalThis.location ? location.href : '');
515
+ return buildInviteLink(base, this.room);
516
+ }
517
+
518
+ mute(on) {
519
+ this.muted = on === undefined ? !this.muted : !!on;
520
+ if (this.local?.audioTrack) this.local.audioTrack.enabled = !this.muted;
521
+ this.transport?.broadcast(this.stateMsg());
522
+ this._refreshBar();
523
+ this.log('mute', { muted: this.muted });
524
+ return this.muted;
525
+ }
526
+
527
+ cameraOff(on) {
528
+ this.camOff = on === undefined ? !this.camOff : !!on;
529
+ if (this.local?.videoTrack) this.local.videoTrack.enabled = !this.camOff;
530
+ this.transport?.broadcast(this.stateMsg());
531
+ this._refreshBar();
532
+ this._refreshSelf();
533
+ return this.camOff;
534
+ }
535
+
536
+ setDepth(v) {
537
+ const n = v === null || v === undefined || !Number.isFinite(+v) ? 0 : Math.max(-1, Math.min(1, +v));
538
+ this.depth = n;
539
+ for (const t of this.tiles.values()) t.conv.depth = n;
540
+ this.liftPool?.setDepth(n); // the same control is lift's convergence on lifted tiles
541
+ if (this.ui?.depth && +this.ui.depth.value !== n) this.ui.depth.value = String(n);
542
+ return n;
543
+ }
544
+
545
+ async setCamera(idOrStream, { format } = {}) {
546
+ const before = this.local?.format;
547
+ const l = await this._openMedia(idOrStream, format, { keepOnFail: true });
548
+ if (this.transport) {
549
+ await this.transport.replaceTrack('video', l.videoTrack, l.stream);
550
+ await this.transport.setSendFormat(l.format);
551
+ this.transport.broadcast(this.hello());
552
+ this.transport.broadcast(this.stateMsg());
553
+ }
554
+ if (before !== l.format) this.log('send-format', { format: l.format });
555
+ return { format: l.format, width: l.width, height: l.height, label: l.label };
556
+ }
557
+
558
+ /** Try the configured camera again (e.g. after the eye tracker released it). */
559
+ retryCamera() {
560
+ return this.setCamera(this.o.camera, { format: this.o.format });
561
+ }
562
+
563
+ sendHint(subjectZmm) {
564
+ if (!this._hintGate(performance.now())) return false;
565
+ const h = normalizeHint({ type: 'hint', subjectZmm });
566
+ if (!h) return false;
567
+ this.transport?.broadcast(h);
568
+ return true;
569
+ }
570
+
571
+ leave() {
572
+ if (this.state === 'left') return;
573
+ globalThis.removeEventListener?.('pagehide', this._pagehide);
574
+ this.transport?.stop();
575
+ this.transport = null;
576
+ for (const t of this.tiles.values()) t.destroy();
577
+ this.tiles.clear();
578
+ this.liftPool?.releaseAll();
579
+ clearInterval(this._statsTimer);
580
+ this._setSpeaker(null);
581
+ if (this.local) {
582
+ this.local.stream.getTracks().forEach((t) => t.stop());
583
+ if (this.local.owned && this.local.camStream) this.local.camStream.getTracks().forEach((t) => t.stop());
584
+ this.local.videoTrack = null;
585
+ this.local.audioTrack = null;
586
+ }
587
+ if (this.self) {
588
+ this.self.unregister();
589
+ this.self.route = null; // a rejoin re-registers it
590
+ }
591
+ this._unhookWall();
592
+ this._setState('left');
593
+ this._layout();
594
+ this.emit('left', { room: this.room });
595
+ }
596
+
597
+ // ── the wall: session loss and recovery ───────────────────────────────────────────────
598
+
599
+ _hookWall(w) {
600
+ if (!w || !w.session || typeof w.session.addEventListener !== 'function') return;
601
+ const orig = w.close;
602
+ this._wallHook = { w, orig, pageClosed: false };
603
+ const hook = this._wallHook;
604
+ w.close = (...a) => {
605
+ hook.pageClosed = true;
606
+ return orig.apply(w, a);
607
+ };
608
+ hook.onEnd = () => this._sessionEnded(hook);
609
+ w.session.addEventListener('end', hook.onEnd);
610
+ this._watchLive(w);
611
+ }
612
+
613
+ /**
614
+ * displayxr-browser-pvt#172: a woven layer registered BEFORE the weave session is live can be
615
+ * fed the whole SBS frame per eye (L|R|L|R, flat) until it is re-created. Tiles register
616
+ * immediately (the session only starts ticking once a layer exists — gating the first
617
+ * registration on live frames deadlocks), and when the first run of live stereo frames lands,
618
+ * every woven tile registered before it is re-created ONCE. Uses the session's own frames, read
619
+ * through the frozen `wall.session` / `wall.refSpace` fields — no core change.
620
+ */
621
+ _watchLive(w) {
622
+ this.weaveLive = false;
623
+ const session = w.session;
624
+ if (!session || typeof session.requestAnimationFrame !== 'function') {
625
+ this.weaveLive = true; // nothing to wait on
626
+ return;
627
+ }
628
+ const gate = createLiveGate();
629
+ const t0 = performance.now();
630
+ const onFrame = (_t, f) => {
631
+ if (w !== this.wall || !this.wallLive || this.state === 'left') return;
632
+ let n = null;
633
+ if (w.refSpace) {
634
+ try {
635
+ const pose = f.getViewerPose(w.refSpace);
636
+ n = pose && pose.views ? pose.views.length : 0;
637
+ } catch {
638
+ n = 0;
639
+ }
640
+ }
641
+ if (!gate.feed(n)) {
642
+ try {
643
+ session.requestAnimationFrame(onFrame);
644
+ } catch {
645
+ /* session ending: _sessionEnded handles it */
646
+ }
647
+ return;
648
+ }
649
+ this.weaveLive = true;
650
+ this.log('weave-live', { ms: Math.round(performance.now() - t0) });
651
+ // Re-create only what was woven before the session was live (#172); flat tiles are untouched.
652
+ // A lifted tile is a woven window too (lift's addScene canvas): release + lift again.
653
+ for (const t of this.tiles.values()) if (t.route === 'woven-sbs' || t.route === 'lifted') t.reroute(true);
654
+ if (this.self && this.self.route === 'woven-sbs') this.self.reroute(true);
655
+ this._layout();
656
+ };
657
+ try {
658
+ session.requestAnimationFrame(onFrame);
659
+ } catch {
660
+ this.weaveLive = true;
661
+ }
662
+ }
663
+
664
+ _unhookWall() {
665
+ const h = this._wallHook;
666
+ if (!h) return;
667
+ this._wallHook = null;
668
+ if (h.w.close !== h.orig) h.w.close = h.orig;
669
+ try {
670
+ h.w.session.removeEventListener('end', h.onEnd);
671
+ } catch {
672
+ /* ignore */
673
+ }
674
+ }
675
+
676
+ _sessionEnded(hook) {
677
+ if (this._wallHook !== hook) return;
678
+ this.wallLive = false;
679
+ this.weaveLive = false;
680
+ this.log('session-ended', { byPage: hook.pageClosed });
681
+ for (const t of this.tiles.values()) t.onWallLost();
682
+ this.self?.onWallLost();
683
+ if (hook.pageClosed || !this.o.recoverSession || this.state === 'left') {
684
+ this._unhookWall();
685
+ return;
686
+ }
687
+ this.error('session-ended', 'the inline-3D session ended; tiles are flat while it is re-opened');
688
+ this._unhookWall();
689
+ this._recoverWall(0);
690
+ }
691
+
692
+ _recoverWall(attempt) {
693
+ if (this.state === 'left' || attempt > 10) return;
694
+ setTimeout(async () => {
695
+ if (this.state === 'left') return;
696
+ let w = null;
697
+ try {
698
+ w = await createInline3D(this.o.wallOptions);
699
+ } catch {
700
+ w = null;
701
+ }
702
+ if (!w || !w.supported) return this._recoverWall(attempt + 1);
703
+ this.wall = w;
704
+ this.wallLive = true;
705
+ this._hookWall(w);
706
+ this.log('session-recovered', { attempt });
707
+ for (const t of this.tiles.values()) t.reroute(true);
708
+ this.self?.reroute(true);
709
+ this.emit('session', { wall: w });
710
+ }, backoffMs(attempt, { baseMs: 1000, maxMs: 15000 }));
711
+ }
712
+
713
+ // ── paint loop + stats ─────────────────────────────────────────────────────────────────
714
+
715
+ _startLoop() {
716
+ let last = 0;
717
+ const tick = (now) => {
718
+ this._raf = requestAnimationFrame(tick);
719
+ this.self?.paint();
720
+ for (const t of this.tiles.values()) t.paint();
721
+ if (last && this.liftPool && this.liftPool.anyWeb) this._watchFrames(now - last);
722
+ last = now;
723
+ };
724
+ this._raf = requestAnimationFrame(tick);
725
+ }
726
+
727
+ /**
728
+ * Budget: lifted tiles on the WEB provider run in this page's frame budget. When frames degrade
729
+ * (~< 20 fps for 3 s) say so once — a log line and a `quality` event with `lift` — so the page
730
+ * can drop to fewer lifts or call `setMono3D(false)`. The native provider runs in the service.
731
+ */
732
+ _watchFrames(dt) {
733
+ const ev = this._frameWatch.feed(dt);
734
+ if (!ev) return;
735
+ const detail = { degraded: ev === 'degraded', frameMs: Math.round(this._frameWatch.frameMs * 10) / 10, tiles: this.liftPool.size };
736
+ this.log('lift-frame-time', detail);
737
+ if (ev === 'degraded') console.warn(`${TAG} lifted tiles are slowing the page (~${Math.round(1000 / detail.frameMs)} fps with ${detail.tiles} lifted) — setMono3D(false) shows mono peers flat`);
738
+ this.emit('quality', { id: null, in: null, out: null, lift: detail });
739
+ }
740
+
741
+ // ── mono→3D ────────────────────────────────────────────────────────────────────────────
742
+
743
+ /** Resolve `mono3D` to a lift function once (lazily); reroute the mono tiles when it lands. */
744
+ _ensureLift() {
745
+ if (!this.mono3DOn) return Promise.resolve(null);
746
+ if (!this._liftP) {
747
+ const log = (t, x) => this.log(t, x);
748
+ const lo = this.o.liftOptions;
749
+ // liftCapabilities probes the same model source the lifts will use.
750
+ const capsOpts = { webFallback: true, ...(lo && lo.models ? { models: lo.models } : {}) };
751
+ this._liftP = resolveLift(this.mono3D, { log, capsOpts }).then((r) => {
752
+ this.liftApi = r;
753
+ if (r.lift) {
754
+ this.liftPool = createLiftPool({ lift: r.lift, max: this.o.maxLifted, log, options: this.o.liftOptions });
755
+ this.liftPool.setDepth(this.depth);
756
+ this.liftPool.setSpeaker(this.speakerId);
757
+ if (!r.caps && r.capabilities) {
758
+ Promise.resolve()
759
+ .then(() => r.capabilities(capsOpts))
760
+ .then((c) => {
761
+ r.caps = c || null;
762
+ this._refreshLobby();
763
+ })
764
+ .catch(() => {});
765
+ }
766
+ } else if (r.reason !== 'off') {
767
+ console.info(`${TAG} mono→3D unavailable (${r.reason}) — mono participants are shown flat`);
768
+ }
769
+ this._rerouteMono();
770
+ this._refreshLobby();
771
+ return r;
772
+ });
773
+ }
774
+ return this._liftP;
775
+ }
776
+
777
+ /** Re-run the routing table for every mono tile (lift resolved, a slot freed, setMono3D). */
778
+ _rerouteMono() {
779
+ for (const t of this.tiles.values()) if (t.format && t.format !== 'sbs') t.reroute(false);
780
+ }
781
+
782
+ setMono3D(on) {
783
+ const want = on === undefined ? !this.mono3DOn : !!on;
784
+ if (want === this.mono3DOn) return want;
785
+ this.mono3DOn = want;
786
+ if (want && this.mono3D === 'off') this.mono3D = 'auto';
787
+ this._frameWatch.reset();
788
+ this.log('mono3d', { on: want });
789
+ this._rerouteMono(); // off: every lift is released, the tiles go flat
790
+ if (want && this.wallLive) this._ensureLift();
791
+ this._refreshLobby();
792
+ return want;
793
+ }
794
+
795
+ /** What the lobby says a mono participant will look like here. */
796
+ _liftHint() {
797
+ if (!this.wallLive) return '';
798
+ if (!this.mono3DOn) return 'Mono cameras: 2D (2D→3D is off).';
799
+ const r = this.liftApi;
800
+ if (!r) return 'Mono cameras: checking 2D→3D…';
801
+ if (!r.lift) return 'Mono cameras: 2D (no 2D→3D provider here).';
802
+ const c = r.caps;
803
+ if (c && c.native) return `Mono cameras: 2D→3D (native${c.provider ? `, ${c.provider}` : ''}).`;
804
+ if (c && c.webFallback && c.webFallback.webgpu) return 'Mono cameras: 2D→3D (in this page, WebGPU).';
805
+ return this.liftProven ? 'Mono cameras: 2D→3D.' : 'Mono cameras: 2D→3D (confirmed on the first mono participant).';
806
+ }
807
+
808
+ _mono3DInfo() {
809
+ const r = this.liftApi;
810
+ const c = r && r.caps;
811
+ return Object.freeze({
812
+ on: this.mono3DOn,
813
+ state: !this.mono3DOn ? 'off' : !r ? (this._liftP ? 'loading' : 'idle') : r.lift ? 'ready' : 'unavailable',
814
+ reason: r ? r.reason : null,
815
+ native: c ? !!c.native : null,
816
+ provider: (c && c.provider) || null,
817
+ lifted: this.liftPool ? this.liftPool.size : 0,
818
+ max: this.o.maxLifted,
819
+ proven: this.liftProven,
820
+ });
821
+ }
822
+
823
+ _startStats() {
824
+ clearInterval(this._statsTimer);
825
+ let n = 0;
826
+ this._statsTimer = setInterval(() => this._stats(++n % 4 === 0), STATS_TICK_MS);
827
+ }
828
+
829
+ async _stats(withQuality) {
830
+ if (!this.transport) return;
831
+ let best = null;
832
+ let localLevel = 0;
833
+ for (const [id, tile] of this.tiles) {
834
+ const pc = this.transport.peerConnection(id);
835
+ if (!pc) continue;
836
+ let report;
837
+ try {
838
+ report = await pc.getStats();
839
+ } catch {
840
+ continue;
841
+ }
842
+ const codecs = {};
843
+ report.forEach((s) => {
844
+ if (s.type === 'codec') codecs[s.id] = s.mimeType;
845
+ });
846
+ const q = { in: null, out: null };
847
+ report.forEach((s) => {
848
+ if (s.type === 'inbound-rtp' && s.kind === 'audio' && typeof s.audioLevel === 'number') tile.level = Math.max(s.audioLevel, (tile.level || 0) * 0.6);
849
+ if (s.type === 'media-source' && s.kind === 'audio' && typeof s.audioLevel === 'number') localLevel = Math.max(localLevel, s.audioLevel);
850
+ if (!withQuality) return;
851
+ if (s.type === 'inbound-rtp' && s.kind === 'video') {
852
+ const prev = tile._prevIn;
853
+ q.in = {
854
+ width: s.frameWidth || 0,
855
+ height: s.frameHeight || 0,
856
+ fps: s.framesPerSecond || 0,
857
+ codec: codecs[s.codecId] || null,
858
+ kbps: prev ? Math.round(((s.bytesReceived - prev.bytes) * 8) / (s.timestamp - prev.t)) : null,
859
+ dropped: s.framesDropped || 0,
860
+ decoder: s.decoderImplementation || null,
861
+ };
862
+ tile._prevIn = { bytes: s.bytesReceived, t: s.timestamp };
863
+ } else if (s.type === 'outbound-rtp' && s.kind === 'video') {
864
+ const prev = tile._prevOut;
865
+ q.out = {
866
+ width: s.frameWidth || 0,
867
+ height: s.frameHeight || 0,
868
+ fps: s.framesPerSecond || 0,
869
+ codec: codecs[s.codecId] || null,
870
+ kbps: prev ? Math.round(((s.bytesSent - prev.bytes) * 8) / (s.timestamp - prev.t)) : null,
871
+ limitation: s.qualityLimitationReason || null,
872
+ encoder: s.encoderImplementation || null,
873
+ };
874
+ tile._prevOut = { bytes: s.bytesSent, t: s.timestamp };
875
+ }
876
+ });
877
+ if (withQuality && (q.in || q.out)) {
878
+ tile.quality = q;
879
+ this.emit('quality', { id, ...q });
880
+ }
881
+ const lvl = tile.remote.muted ? 0 : tile.level || 0;
882
+ tile.setSpeaking(lvl > SPEAKING_LEVEL || tile.remote.speaking);
883
+ if (lvl > SPEAKING_LEVEL && (!best || lvl > best.lvl)) best = { id, lvl };
884
+ }
885
+ // Active speaker with hysteresis: a challenger must lead two ticks running.
886
+ const cand = best ? best.id : null;
887
+ if (cand && cand !== this.speakerId) {
888
+ this._challengeTicks = this._challenger === cand ? (this._challengeTicks || 0) + 1 : 1;
889
+ this._challenger = cand;
890
+ if (this._challengeTicks >= 2) this._setSpeaker(cand);
891
+ } else {
892
+ this._challenger = null;
893
+ this._challengeTicks = 0;
894
+ }
895
+ const speaking = !this.muted && localLevel > SPEAKING_LEVEL;
896
+ if (speaking !== !!this._selfSpeaking) {
897
+ this._selfSpeaking = speaking;
898
+ this.transport?.broadcast(this.stateMsg());
899
+ this.self?.setSpeaking(speaking);
900
+ }
901
+ }
902
+
903
+ _setSpeaker(id) {
904
+ if (id === this.speakerId) return;
905
+ this.speakerId = id;
906
+ this._challengeTicks = 0;
907
+ this.liftPool?.setSpeaker(id); // the active speaker's lifted stream is converted every round
908
+ this.emit('speaker', { id });
909
+ if (this.o.layout === 'speaker') this._layout();
910
+ }
911
+
912
+ // ── chrome ─────────────────────────────────────────────────────────────────────────────
913
+
914
+ /**
915
+ * Size the grid so the whole call block (banner, tiles, self view, bar) fits the viewport from
916
+ * where the block actually sits: `--dxr-call-fit` = viewport height − the block's visible top −
917
+ * everything in the block that is not the grid. The CSS falls back to `100vh − reserve` when
918
+ * this has not run. A tile below the fold is withheld from the weave (browser#167).
919
+ */
920
+ _fitGrid() {
921
+ const ui = this.ui;
922
+ if (!ui || !this.o.ui || typeof globalThis.innerHeight !== 'number') return;
923
+ const host = this.container.getBoundingClientRect?.();
924
+ const grid = ui.grid.getBoundingClientRect?.();
925
+ if (!host || !grid || !grid.height) return;
926
+ const fit = Math.max(160, Math.floor(globalThis.innerHeight - Math.max(0, host.top) - (host.height - grid.height) - 8));
927
+ const prev = parseFloat(this.container.style.getPropertyValue('--dxr-call-fit')) || 0;
928
+ if (Math.abs(fit - prev) > 2) this.container.style.setProperty('--dxr-call-fit', `${fit}px`);
929
+ if (!this._fitHooked) {
930
+ this._fitHooked = true;
931
+ globalThis.addEventListener?.('resize', () => this._fitGrid());
932
+ }
933
+ }
934
+
935
+ _setState(s) {
936
+ this.state = s;
937
+ this.log('state', { state: s });
938
+ this._layout();
939
+ }
940
+
941
+ _buildDom() {
942
+ const c = this.container;
943
+ injectCallStyle(c.ownerDocument || document);
944
+ c.classList.add('dxr-call-host');
945
+ if (this.o.accent) c.style.setProperty('--dxr-accent', this.o.accent);
946
+ const ui = (this.ui = {});
947
+ ui.banner = el('div', { class: 'dxr-call-banner dxr-call-hidden' }, [
948
+ el('span', { text: 'You are seeing this call in 2D.' }),
949
+ el('a', { href: this.o.browserUrl, target: '_blank', rel: 'noopener', text: 'View in 3D with DisplayXR Browser' }),
950
+ ]);
951
+ ui.grid = el('div', { class: 'dxr-call-grid', 'data-layout': this.o.layout, 'data-n': '0' });
952
+ ui.panel = el('div', { class: 'dxr-call-panel dxr-call-hidden' });
953
+ ui.selfSlot = el('div', { class: 'dxr-call-self' });
954
+ ui.bar = el('div', { class: 'dxr-call-bar' });
955
+ ui.foot = el('div', { class: 'dxr-call-foot' }, [ui.selfSlot, ui.bar]);
956
+ ui.invite = el('div', { class: 'dxr-call-panel dxr-call-hidden' });
957
+ if (!this.o.ui) {
958
+ show(ui.bar, false);
959
+ }
960
+ c.append(ui.banner, ui.panel, ui.grid, ui.invite, ui.foot);
961
+ if (this.o.selfView) this.self = new SelfTile(this, ui.selfSlot);
962
+ else show(ui.selfSlot, false);
963
+ if (this.o.ui) this._buildBar();
964
+ }
965
+
966
+ _buildBar() {
967
+ const ui = this.ui;
968
+ const ib = (label, icon, onclick, extra = '') => el('button', { class: `dxr-call-ib ${extra}`, type: 'button', 'aria-label': label, title: label, html: icon, onclick });
969
+ ui.mic = ib('Mute microphone', ICONS.mic, () => this.mute());
970
+ ui.cam = ib('Turn camera off', ICONS.cam, () => (this.local?.videoTrack ? this.cameraOff() : this.retryCamera().catch(() => {})));
971
+ ui.depth = el('input', { type: 'range', min: '-1', max: '1', step: '0.05', value: '0', 'aria-label': 'Depth' });
972
+ ui.depth.addEventListener('input', () => this.setDepth(+ui.depth.value));
973
+ ui.depth.addEventListener('dblclick', () => this.setDepth(0));
974
+ ui.inviteBtn = ib('Invite', ICONS.link, () => {
975
+ this._showInvite = !this._showInvite;
976
+ this._layout();
977
+ });
978
+ ui.leave = ib('Leave call', ICONS.leave, () => this.leave(), 'dxr-call-ib--leave');
979
+ ui.bar.append(ui.mic, ui.cam, el('label', { class: 'dxr-call-depth' }, ['Depth', ui.depth]), ui.inviteBtn, ui.leave);
980
+ }
981
+
982
+ _refreshBar() {
983
+ const ui = this.ui;
984
+ if (!ui || !ui.mic) return;
985
+ ui.mic.innerHTML = this.muted ? ICONS.micOff : ICONS.mic;
986
+ ui.mic.setAttribute('aria-pressed', String(this.muted));
987
+ ui.mic.title = this.muted ? 'Unmute microphone' : 'Mute microphone';
988
+ const noCam = !this.local?.videoTrack;
989
+ ui.cam.innerHTML = this.camOff || noCam ? ICONS.camOff : ICONS.cam;
990
+ ui.cam.setAttribute('aria-pressed', String(this.camOff || noCam));
991
+ ui.cam.title = noCam ? (this.camStatus === 'busy' ? `${PLATE_TEXT.cameraBusy} — click to retry` : 'No camera — click to retry') : this.camOff ? 'Turn camera on' : 'Turn camera off';
992
+ }
993
+
994
+ _refreshSelf() {
995
+ this.self?.update();
996
+ }
997
+
998
+ _refreshLobby() {
999
+ if (this.state === 'lobby' || this.state === 'idle') this._layout();
1000
+ }
1001
+
1002
+ _renderInvite(target, title, note) {
1003
+ target.replaceChildren();
1004
+ const link = this.inviteLink();
1005
+ if (!link) return;
1006
+ const input = el('input', { type: 'text', readOnly: true, value: link, 'aria-label': 'Invite link' });
1007
+ input.addEventListener('focus', () => input.select());
1008
+ const copy = el('button', { class: 'dxr-call-btn dxr-call-btn--primary', type: 'button', text: 'Copy link' });
1009
+ copy.addEventListener('click', async () => {
1010
+ try {
1011
+ await navigator.clipboard.writeText(link);
1012
+ copy.textContent = 'Copied';
1013
+ } catch {
1014
+ input.select();
1015
+ copy.textContent = 'Press Ctrl/Cmd+C';
1016
+ }
1017
+ setTimeout(() => (copy.textContent = 'Copy link'), 1800);
1018
+ });
1019
+ const qr = el('canvas', { 'aria-label': 'QR code of the invite link', role: 'img' });
1020
+ try {
1021
+ drawQr(qr, link, { px: 4 });
1022
+ } catch {
1023
+ qr.style.display = 'none'; // a link too long for a version-10 code: the copy button stays
1024
+ }
1025
+ target.append(
1026
+ el('h3', { text: title }),
1027
+ el('p', { text: note }),
1028
+ el('div', { class: 'dxr-call-invite' }, [el('div', { class: 'dxr-call-row', style: 'flex:1 1 280px' }, [input, copy]), qr])
1029
+ );
1030
+ }
1031
+
1032
+ _layout() {
1033
+ const ui = this.ui;
1034
+ if (!ui) return;
1035
+ const c = this.container;
1036
+ const lobby = this.o.ui && (this.state === 'lobby' || this.state === 'idle' || this.state === 'full' || this.state === 'left');
1037
+ c.classList.toggle('dxr-call-host--lobby', lobby);
1038
+ show(ui.banner, this.o.ui && !this.woven);
1039
+ const n = this.tiles.size;
1040
+ ui.grid.dataset.n = String(Math.min(n, 4));
1041
+ show(ui.grid, n > 0 && this.state !== 'left');
1042
+ // Speaker layout: the active speaker (or the first tile) spans the row.
1043
+ const main = this.o.layout === 'speaker' ? this.speakerId || [...this.tiles.keys()][0] : null;
1044
+ for (const [id, t] of this.tiles) t.el.classList.toggle('dxr-call-tile--main', id === main);
1045
+ if (!this.o.ui) return;
1046
+ this._refreshBar();
1047
+ queueMicrotask(() => this._fitGrid());
1048
+ const p = ui.panel;
1049
+ if (lobby) {
1050
+ show(p, true);
1051
+ p.replaceChildren();
1052
+ const l = this.local;
1053
+ const joining = !!this.room;
1054
+ const title =
1055
+ this.state === 'full' ? 'This call is full' : this.state === 'left' ? 'You left the call' : joining ? 'Join the 3D call' : 'Start a 3D call';
1056
+ const kind = this.camStatus === 'busy' ? 'busy (joining audio-only)' : !l || !l.videoTrack ? 'none (joining audio-only)' : l.format === 'sbs' ? `sending 3D (side-by-side, ${l.width}×${l.height})` : `sending 2D (${l.width}×${l.height})`;
1057
+ const text =
1058
+ this.state === 'full'
1059
+ ? `It already has ${this.o.maxPeers} participants.`
1060
+ : `Check your framing below. Camera: ${l?.label || 'default'} — ${kind}.${this._liftHint() ? ` ${this._liftHint()}` : ''}`;
1061
+ const go = el('button', {
1062
+ class: 'dxr-call-btn dxr-call-btn--primary',
1063
+ type: 'button',
1064
+ text: this.state === 'left' ? 'Rejoin' : joining ? 'Join call' : 'Start 3D call',
1065
+ });
1066
+ go.addEventListener('click', () => this.join().catch(() => {}));
1067
+ const sel = el('select', { class: 'dxr-call-select', 'aria-label': 'Camera' });
1068
+ this._fillCameras(sel);
1069
+ sel.addEventListener('change', () => this.setCamera(sel.value).catch((e) => this.error('camera-failed', e.message, e)));
1070
+ const retry = !l || !l.videoTrack ? el('button', { class: 'dxr-call-btn', type: 'button', text: 'Retry camera' }) : null;
1071
+ retry?.addEventListener('click', () => this.retryCamera().catch(() => {}));
1072
+ p.append(el('h3', { text: title }), el('p', { text }), el('div', { class: 'dxr-call-row' }, [this.state === 'full' ? null : go, retry, sel]));
1073
+ } else if (this.state === 'in-call' && n === 0) {
1074
+ show(p, true);
1075
+ this._renderInvite(p, 'Waiting for others', 'Share this link (or scan the code). Anyone who opens it joins — keep it private.');
1076
+ } else if (this.state === 'joining') {
1077
+ show(p, true);
1078
+ p.replaceChildren(el('p', { text: 'Joining…' }));
1079
+ } else show(p, false);
1080
+ const inviteOpen = this.state === 'in-call' && n > 0 && this._showInvite;
1081
+ if (inviteOpen && !ui.invite.childElementCount) this._renderInvite(ui.invite, 'Invite', 'Anyone with this link can join.');
1082
+ show(ui.invite, inviteOpen);
1083
+ if (!inviteOpen) ui.invite.replaceChildren();
1084
+ ui.inviteBtn?.setAttribute('aria-pressed', String(!!inviteOpen));
1085
+ }
1086
+
1087
+ async _fillCameras(sel) {
1088
+ let cams = [];
1089
+ try {
1090
+ cams = (await navigator.mediaDevices.enumerateDevices()).filter((d) => d.kind === 'videoinput');
1091
+ } catch {
1092
+ /* ignore */
1093
+ }
1094
+ const cur = this.local?.videoTrack?.getSettings?.().deviceId;
1095
+ sel.replaceChildren(...cams.map((d, i) => el('option', { value: d.deviceId, text: d.label || `Camera ${i + 1}` })));
1096
+ if (cur) sel.value = cur;
1097
+ show(sel, cams.length > 1);
1098
+ }
1099
+
1100
+ _makeHandle() {
1101
+ const call = this;
1102
+ return {
1103
+ get room() {
1104
+ return call.room;
1105
+ },
1106
+ get id() {
1107
+ return call.id;
1108
+ },
1109
+ get state() {
1110
+ return call.state;
1111
+ },
1112
+ get wall() {
1113
+ return call.wall;
1114
+ },
1115
+ get format() {
1116
+ return call.local ? call.local.format : null;
1117
+ },
1118
+ get muted() {
1119
+ return call.muted;
1120
+ },
1121
+ get depth() {
1122
+ return call.depth;
1123
+ },
1124
+ get speaker() {
1125
+ return call.speakerId;
1126
+ },
1127
+ /** 'ok' | 'busy' (held by another app, e.g. eye tracking) | 'none' | 'pending'. */
1128
+ get camera() {
1129
+ return call.camStatus;
1130
+ },
1131
+ /** mono→3D: on/off, whether a lift provider resolved, native or web, and how many are lifted. */
1132
+ get mono3D() {
1133
+ return call._mono3DInfo();
1134
+ },
1135
+ /** Read-only snapshot of the remote participants. */
1136
+ get peers() {
1137
+ return Object.freeze([...call.tiles.values()].map((t) => t.info()));
1138
+ },
1139
+ join: () => call.join(),
1140
+ inviteLink: () => call.inviteLink(),
1141
+ mute: (on) => call.mute(on),
1142
+ cameraOff: (on) => call.cameraOff(on),
1143
+ setCamera: (idOrStream, o) => call.setCamera(idOrStream, o),
1144
+ setDepth: (v) => call.setDepth(v),
1145
+ retryCamera: () => call.retryCamera(),
1146
+ sendHint: (z) => call.sendHint(z),
1147
+ setMono3D: (on) => call.setMono3D(on),
1148
+ leave: () => call.leave(),
1149
+ on(type, cb) {
1150
+ if (typeof cb !== 'function') throw new TypeError(`${TAG} on() takes a function`);
1151
+ if (!call.listeners.has(type)) call.listeners.set(type, new Set());
1152
+ call.listeners.get(type).add(cb);
1153
+ return () => call.listeners.get(type)?.delete(cb);
1154
+ },
1155
+ off(type, cb) {
1156
+ call.listeners.get(type)?.delete(cb);
1157
+ },
1158
+ /** Diagnostics: the transport (mesh) and a way to simulate a dropped connection. */
1159
+ _debug: {
1160
+ get transport() {
1161
+ return call.transport;
1162
+ },
1163
+ kill: (id) => call.transport?._debugKill(id),
1164
+ },
1165
+ };
1166
+ }
1167
+ }
1168
+
1169
+ // ── paint helpers ──────────────────────────────────────────────────────────────────────────
1170
+
1171
+ /** Size a FLAT (never woven) canvas to its box × dpr. Returns false if it has no box yet. */
1172
+ function sizeFlat(canvas) {
1173
+ const dpr = Math.min(globalThis.devicePixelRatio || 1, 2);
1174
+ const w = Math.round((canvas.clientWidth || 0) * dpr);
1175
+ const h = Math.round((canvas.clientHeight || 0) * dpr);
1176
+ if (!w || !h) return false;
1177
+ if (canvas.width !== w || canvas.height !== h) {
1178
+ canvas.width = w;
1179
+ canvas.height = h;
1180
+ }
1181
+ return true;
1182
+ }
1183
+
1184
+ /**
1185
+ * Paint one eye (sbs) or the whole frame (mono) of `video` FLAT into `canvas`, cropped to cover.
1186
+ * `mirror` flips it (the self view). `eyeHalf` 0/1 picks the eye of an SBS frame; null = mono.
1187
+ */
1188
+ function paintFlat(canvas, video, { eyeHalf = null, mirror = false } = {}) {
1189
+ if (!sizeFlat(canvas)) return;
1190
+ const g = canvas.getContext('2d');
1191
+ const W = video.videoWidth;
1192
+ const H = video.videoHeight;
1193
+ if (!W || !H || (video.readyState || 0) < 2) return;
1194
+ const eyeW = eyeHalf === null ? W : W / 2;
1195
+ const r = eyeCropRect(eyeW, H, canvas.width / canvas.height, 0, 0);
1196
+ g.save();
1197
+ if (mirror) {
1198
+ g.translate(canvas.width, 0);
1199
+ g.scale(-1, 1);
1200
+ }
1201
+ g.drawImage(video, (eyeHalf || 0) * eyeW + r.sx, r.sy, r.sw, r.sh, 0, 0, canvas.width, canvas.height);
1202
+ g.restore();
1203
+ }
1204
+
1205
+ // ── a remote participant ──────────────────────────────────────────────────────────────────
1206
+
1207
+ class Tile {
1208
+ constructor(call, id) {
1209
+ this.call = call;
1210
+ this.id = id;
1211
+ this.hello = null;
1212
+ this.format = null; // null until hello or its timeout
1213
+ this.route = null;
1214
+ this.routedKey = null;
1215
+ this.handle = null;
1216
+ this.conn = 'connecting';
1217
+ this.remote = { muted: false, cameraOff: false, speaking: false };
1218
+ this.level = 0;
1219
+ this.speaking = false;
1220
+ this.quality = null;
1221
+ this.conv = createConvergence();
1222
+ this.conv.depth = call.depth;
1223
+ this.hintGate = rateGate(HINT_MAX_HZ);
1224
+ this.layerFails = 0;
1225
+ this.forceFlat = false;
1226
+ // lifted route (mono→3D): the lift handle once it lands, whether it is showing (live), and a
1227
+ // per-tile failure latch with bounded retries.
1228
+ this.lifted = false;
1229
+ this.liftHandle = null;
1230
+ this.liftLive = false;
1231
+ this.liftFailed = false;
1232
+ this.liftFails = 0;
1233
+ this.visible = true;
1234
+ this.video = Object.assign(document.createElement('video'), { muted: true, playsInline: true, autoplay: true });
1235
+ this.audio = Object.assign(document.createElement('audio'), { autoplay: true });
1236
+ this.video.addEventListener('playing', () => this._maybeRoute());
1237
+ this.video.addEventListener('loadeddata', () => this._maybeRoute());
1238
+ this.video.addEventListener('resize', () => this._maybeRoute());
1239
+ this.canvas = el('canvas');
1240
+ this.cover = el('div', { class: 'dxr-call-cover dxr-call-hidden' });
1241
+ this.badge = el('div', { class: 'dxr-call-badge' });
1242
+ this.plate = el('div', { class: 'dxr-call-state', text: 'Connecting…' });
1243
+ this.stage = el('div', { class: 'dxr-call-stage' }, [this.canvas, this.cover, this.badge, this.plate]);
1244
+ this.stage.style.aspectRatio = String(call.o.tileAspect);
1245
+ this.el = el('div', { class: 'dxr-call-tile', 'data-peer': id }, [this.stage, el('div', { class: 'dxr-call-talk' })]);
1246
+ if (!call.o.ui) {
1247
+ show(this.badge, false);
1248
+ }
1249
+ call.ui.grid.appendChild(this.el);
1250
+ this._renderBadge();
1251
+ }
1252
+
1253
+ info() {
1254
+ return Object.freeze({
1255
+ id: this.id,
1256
+ format: this.format || 'mono',
1257
+ route: this.route,
1258
+ state: this.conn,
1259
+ muted: this.remote.muted,
1260
+ cameraOff: this.remote.cameraOff,
1261
+ speaking: this.speaking,
1262
+ rectified: !!(this.hello && this.hello.rectified),
1263
+ hello: this.hello,
1264
+ quality: this.quality,
1265
+ convergencePx: this.conv.current,
1266
+ lift: this.lifted
1267
+ ? Object.freeze({
1268
+ live: this.liftLive,
1269
+ native: !!(this.liftHandle && this.liftHandle.native),
1270
+ priority: this.call.liftPool ? this.call.liftPool.priority(this.id) : null,
1271
+ state: this.liftHandle ? this.liftHandle.state || null : 'pending',
1272
+ })
1273
+ : null,
1274
+ });
1275
+ }
1276
+
1277
+ setStream(stream) {
1278
+ if (this.video.srcObject !== stream) {
1279
+ this.video.srcObject = stream;
1280
+ this.audio.srcObject = stream;
1281
+ }
1282
+ this.video.play().catch(() => {});
1283
+ this.audio.play().catch(() => {});
1284
+ }
1285
+
1286
+ onChannelOpen() {
1287
+ clearTimeout(this.helloTimer);
1288
+ // A peer that never says hello (a foreign client) is mono — RFC §2.
1289
+ this.helloTimer = setTimeout(() => {
1290
+ if (!this.format) {
1291
+ this.format = 'mono';
1292
+ this._maybeRoute();
1293
+ }
1294
+ }, HELLO_WAIT_MS);
1295
+ }
1296
+
1297
+ setHello(h) {
1298
+ clearTimeout(this.helloTimer);
1299
+ this.hello = h;
1300
+ this.conv.hello = h;
1301
+ const changed = this.format !== h.format;
1302
+ this.format = h.format;
1303
+ this.call.log('hello', { peer: this.id, format: h.format, width: h.width, height: h.height, rectified: h.rectified });
1304
+ if (changed) this._maybeRoute();
1305
+ else this._renderBadge();
1306
+ }
1307
+
1308
+ setHint(h) {
1309
+ if (!this.hintGate(performance.now())) return;
1310
+ this.conv.subjectZmm = h.subjectZmm;
1311
+ }
1312
+
1313
+ setRemoteState(st) {
1314
+ this.remote = st;
1315
+ this._renderBadge();
1316
+ this._renderPlate();
1317
+ this._updateVisible();
1318
+ }
1319
+
1320
+ setSpeaking(on) {
1321
+ if (on === this.speaking) return;
1322
+ this.speaking = on;
1323
+ this.el.classList.toggle('dxr-call-tile--speaking', on);
1324
+ }
1325
+
1326
+ setConn(st) {
1327
+ if (st === 'unreachable' && this.conn !== 'unreachable') {
1328
+ this.call.error('unreachable', `${PLATE_TEXT.unreachable}. Still retrying in the background.`, Object.assign(new Error('unreachable'), { peer: this.id }));
1329
+ }
1330
+ this.conn = st;
1331
+ if (st === 'connected' && this.leaveTimer) {
1332
+ clearTimeout(this.leaveTimer);
1333
+ this.leaveTimer = null;
1334
+ }
1335
+ this._renderPlate();
1336
+ }
1337
+
1338
+ _renderPlate() {
1339
+ const t =
1340
+ this.conn === 'left'
1341
+ ? 'Left the call'
1342
+ : this.conn === 'unreachable'
1343
+ ? PLATE_TEXT.unreachable
1344
+ : this.conn === 'reconnecting'
1345
+ ? 'Reconnecting…'
1346
+ : this.conn !== 'connected'
1347
+ ? 'Connecting…'
1348
+ : this.remote.cameraOff
1349
+ ? 'Camera off'
1350
+ : null;
1351
+ this.plate.textContent = t || '';
1352
+ show(this.plate, !!t);
1353
+ }
1354
+
1355
+ _renderBadge() {
1356
+ const b = badgeFor(this.route);
1357
+ // A lifted tile whose lift is still loading reads "2D→3D…" (it is painted flat meanwhile).
1358
+ const text = !this.route ? '…' : this.route === 'lifted' && !this.liftLive ? `${b}…` : b;
1359
+ this.badge.replaceChildren(el('b', { text }));
1360
+ this.badge.dataset.route = this.route || '';
1361
+ if (this.remote.muted) this.badge.insertAdjacentHTML('beforeend', ICONS.mutedSmall);
1362
+ }
1363
+
1364
+ /** Route once the stream plays AND the format is known (hello or its timeout). */
1365
+ _maybeRoute() {
1366
+ if (!this.format || (this.video.readyState || 0) < 2 || !this.video.videoWidth) return;
1367
+ this.reroute(false);
1368
+ }
1369
+
1370
+ reroute(force) {
1371
+ const call = this.call;
1372
+ const pool = call.liftPool;
1373
+ const r =
1374
+ this.forceFlat && this.format === 'sbs'
1375
+ ? { route: 'flat-left' }
1376
+ : routeFor({
1377
+ format: this.format,
1378
+ woven: call.woven,
1379
+ mono3D: call.mono3DOn ? 'auto' : 'off',
1380
+ lift: !!pool,
1381
+ overBudget: pool ? !pool.canAcquire(this.id) : false,
1382
+ failed: this.liftFailed,
1383
+ });
1384
+ // First mono peer on a woven wall and lift not asked for yet (e.g. setMono3D(true) later).
1385
+ if (this.format !== 'sbs' && call.woven && call.mono3DOn && !call._liftP) call._ensureLift();
1386
+ const key = `${r.route}|${this.format}`;
1387
+ if (!force && key === this.routedKey) return;
1388
+ this.routedKey = key;
1389
+ this._unregister();
1390
+ this.route = r.route;
1391
+ if (r.route === 'woven-sbs') this._registerWoven();
1392
+ else if (r.route === 'lifted') this._registerLifted();
1393
+ call.log('route', { peer: this.id, route: r.route, format: this.format, mono3d: r.mono3d || null });
1394
+ call.emit('format', { id: this.id, format: this.format, route: r.route, mono3d: r.mono3d || null, hello: this.hello });
1395
+ this._renderBadge();
1396
+ }
1397
+
1398
+ _registerWoven() {
1399
+ const call = this.call;
1400
+ if (!this.convCanvas) this.convCanvas = document.createElement('canvas');
1401
+ this._paintConv(); // the first frame exists before the layer does
1402
+ show(this.cover, true);
1403
+ const handle = call.wall.addImage(this.canvas, this.convCanvas);
1404
+ this.handle = handle;
1405
+ handle.firstWoven?.then((res) => {
1406
+ if (this.handle !== handle) return;
1407
+ show(this.cover, false); // a cut, never a fade
1408
+ call.log('first-woven', { peer: this.id, woven: res.woven, reason: res.reason, ms: Math.round(res.ms) });
1409
+ if (!res.woven && res.reason === 'layer-failed') this._layerFailed();
1410
+ });
1411
+ }
1412
+
1413
+ /**
1414
+ * The lifted route: lift(video, { mode: 'live', wall }) through the call's pool. The video is
1415
+ * mounted in the stage (lift() overlays the element's rect; see the header), the flat canvas
1416
+ * keeps painting until lift reports `live`, then hides so lift's canvas (web) or the browser's
1417
+ * in-place conversion of the video (native) is what shows.
1418
+ */
1419
+ _registerLifted() {
1420
+ const call = this.call;
1421
+ const pool = call.liftPool;
1422
+ this.lifted = true;
1423
+ this.liftLive = false;
1424
+ this.video.classList.add('dxr-call-liftsrc');
1425
+ this.canvas.classList.add('dxr-call-liftflat');
1426
+ if (this.video.parentNode !== this.stage) this.stage.insertBefore(this.video, this.stage.firstChild);
1427
+ this._observeVisible();
1428
+ pool.acquire(this.id, this.video, { wall: call.wall, visible: this.visible, onError: (err) => this._liftError(err) }).then((h) => {
1429
+ if (!h || !this.lifted || pool.handle(this.id) !== h) return;
1430
+ this.liftHandle = h;
1431
+ const onState = (st) => {
1432
+ if (st === 'live') this._liftShowing(true);
1433
+ else if (st === 'error' || st === 'disposed') this._liftShowing(false);
1434
+ };
1435
+ this._liftOff = [
1436
+ h.on?.('statechange', (d) => onState(d && d.state)),
1437
+ h.on?.('error', (d) => {
1438
+ if (d && d.fatal) this._liftError(d.error);
1439
+ }),
1440
+ ];
1441
+ // Until lift is live its canvas has nothing to draw: keep it hidden and the flat tile up.
1442
+ if (h.state === 'live') this._liftShowing(true);
1443
+ else if (h.canvas && h.canvas.style) h.canvas.style.visibility = 'hidden';
1444
+ call.log('lifted', { peer: this.id, native: !!h.native, woven: !!h.woven, state: h.state || null, provider: (h.stats && h.stats.provider) || null });
1445
+ });
1446
+ }
1447
+
1448
+ _liftShowing(on) {
1449
+ if (on === this.liftLive) return;
1450
+ this.liftLive = on;
1451
+ const h = this.liftHandle;
1452
+ if (on) {
1453
+ if (h && h.canvas && h.canvas.style && !h.native) h.canvas.style.visibility = '';
1454
+ this.canvas.style.visibility = 'hidden';
1455
+ this.liftFails = 0;
1456
+ if (!this.call.liftProven) {
1457
+ this.call.liftProven = true;
1458
+ this.call._refreshLobby();
1459
+ }
1460
+ } else this.canvas.style.visibility = '';
1461
+ this.call.log('lift-live', { peer: this.id, live: on });
1462
+ this._renderBadge();
1463
+ }
1464
+
1465
+ /** lift() failed (or its handle hit a fatal error): flat now, retried with backoff a couple of times. */
1466
+ _liftError(err) {
1467
+ if (!this.lifted) return;
1468
+ this.call.log('lift-error', { peer: this.id, message: String((err && err.message) || err).slice(0, 200) });
1469
+ this.liftFailed = true;
1470
+ this.reroute(true);
1471
+ if (this.liftFails++ >= LIFT_RETRIES) return;
1472
+ clearTimeout(this.liftTimer);
1473
+ this.liftTimer = setTimeout(() => {
1474
+ this.liftFailed = false;
1475
+ this.reroute(false);
1476
+ }, backoffMs(this.liftFails - 1, { baseMs: 3000, maxMs: 30000 }));
1477
+ }
1478
+
1479
+ /** Offscreen (or camera-off) lifted tiles are PAUSED — the provider keeps their last frame. */
1480
+ _observeVisible() {
1481
+ if (this._io || typeof IntersectionObserver !== 'function') return;
1482
+ this._io = new IntersectionObserver((entries) => {
1483
+ const e = entries[entries.length - 1];
1484
+ this._onscreen = !!(e && e.isIntersecting);
1485
+ this._updateVisible();
1486
+ });
1487
+ this._io.observe(this.stage);
1488
+ }
1489
+
1490
+ _updateVisible() {
1491
+ const v = this._onscreen !== false && !this.remote.cameraOff;
1492
+ if (v === this.visible) return;
1493
+ this.visible = v;
1494
+ this.call.liftPool?.setVisible(this.id, v);
1495
+ }
1496
+
1497
+ _layerFailed() {
1498
+ this.forceFlat = true;
1499
+ this.reroute(true);
1500
+ if (this.layerFails++ >= LAYER_RETRIES) return;
1501
+ const delay = backoffMs(this.layerFails - 1, { baseMs: 1500, maxMs: 20000 });
1502
+ this.call.log('layer-retry', { peer: this.id, attempt: this.layerFails, inMs: delay });
1503
+ clearTimeout(this.layerTimer);
1504
+ this.layerTimer = setTimeout(() => {
1505
+ this.forceFlat = false;
1506
+ if (this.call.woven) this.reroute(true);
1507
+ }, delay);
1508
+ }
1509
+
1510
+ onWallLost() {
1511
+ // The layer died with the session; the core already painted this canvas flat. Stop treating
1512
+ // it as woven until a new wall arrives (Call._recoverWall → reroute(true)).
1513
+ this.handle = null;
1514
+ this.reroute(true);
1515
+ }
1516
+
1517
+ _unregister() {
1518
+ show(this.cover, false);
1519
+ if (this.lifted) {
1520
+ this.lifted = false;
1521
+ for (const off of this._liftOff || []) if (typeof off === 'function') off();
1522
+ this._liftOff = null;
1523
+ this.call.liftPool?.release(this.id);
1524
+ // A lift slot just freed: a mono tile held flat by the budget may take it (after this
1525
+ // tile's own reroute / removal has settled).
1526
+ queueMicrotask(() => this.call.state !== 'left' && this.call._rerouteMono());
1527
+ this.liftHandle = null;
1528
+ this.liftLive = false;
1529
+ this.canvas.style.visibility = '';
1530
+ this.canvas.classList.remove('dxr-call-liftflat');
1531
+ if (this._io) {
1532
+ this._io.disconnect();
1533
+ this._io = null;
1534
+ }
1535
+ // Back to a DETACHED video. Removing a playing media element from the document pauses it
1536
+ // (a queued microtask), so play it again after that.
1537
+ if (this.video.parentNode) {
1538
+ this.video.remove();
1539
+ this.video.classList.remove('dxr-call-liftsrc');
1540
+ setTimeout(() => this.video.srcObject && this.video.play().catch(() => {}), 0);
1541
+ }
1542
+ }
1543
+ if (this.handle) {
1544
+ try {
1545
+ this.handle.remove();
1546
+ } catch {
1547
+ /* the session may be gone */
1548
+ }
1549
+ this.handle = null;
1550
+ }
1551
+ }
1552
+
1553
+ paint() {
1554
+ if (!this.route) return;
1555
+ if (this.route === 'woven-sbs') this._paintConv();
1556
+ else if (this.route === 'lifted') {
1557
+ if (!this.liftLive) paintFlat(this.canvas, this.video); // flat until lift is showing
1558
+ } else paintFlat(this.canvas, this.video, { eyeHalf: this.route === 'flat-left' ? 0 : null });
1559
+ }
1560
+
1561
+ /** The convergence-shifted pair, into the canvas the wall repaints the tile from. */
1562
+ _paintConv() {
1563
+ const v = this.video;
1564
+ const W = v.videoWidth;
1565
+ const H = v.videoHeight;
1566
+ if (!W || !H || (v.readyState || 0) < 2 || !this.convCanvas) return;
1567
+ const eyeW = W / 2;
1568
+ const A = this.call.o.tileAspect;
1569
+ const { w: outW, h: outH } = eyeOutputSize(eyeW, H, A);
1570
+ const c = this.convCanvas;
1571
+ if (c.width !== 2 * outW || c.height !== outH) {
1572
+ c.width = 2 * outW;
1573
+ c.height = outH;
1574
+ }
1575
+ const shift = this.conv.step(eyeW);
1576
+ const g = c.getContext('2d');
1577
+ for (const eye of [0, 1]) {
1578
+ const r = eyeCropRect(eyeW, H, A, shift, eye);
1579
+ g.drawImage(v, eye * eyeW + r.sx, r.sy, r.sw, r.sh, eye * outW, 0, outW, outH);
1580
+ }
1581
+ }
1582
+
1583
+ destroy() {
1584
+ clearTimeout(this.helloTimer);
1585
+ clearTimeout(this.layerTimer);
1586
+ clearTimeout(this.leaveTimer);
1587
+ clearTimeout(this.liftTimer);
1588
+ this._unregister();
1589
+ this.video.srcObject = null;
1590
+ this.audio.srcObject = null;
1591
+ this.el.remove();
1592
+ }
1593
+ }
1594
+
1595
+ // ── the self view ─────────────────────────────────────────────────────────────────────────
1596
+
1597
+ class SelfTile {
1598
+ constructor(call, slot) {
1599
+ this.call = call;
1600
+ this.canvas = el('canvas');
1601
+ this.badge = el('div', { class: 'dxr-call-badge' });
1602
+ this.plate = el('div', { class: 'dxr-call-state dxr-call-hidden' });
1603
+ this.stage = el('div', { class: 'dxr-call-stage' }, [this.canvas, this.badge, this.plate]);
1604
+ this.stage.style.aspectRatio = String(call.o.tileAspect);
1605
+ this.el = el('div', { class: 'dxr-call-tile dxr-call-tile--self' }, [this.stage, el('div', { class: 'dxr-call-talk' })]);
1606
+ slot.appendChild(this.el);
1607
+ this.route = null;
1608
+ this.handle = null;
1609
+ this.mirror = null;
1610
+ }
1611
+
1612
+ update() {
1613
+ const l = this.call.local;
1614
+ const noCam = !l || !l.videoTrack;
1615
+ this.plate.textContent = noCam ? (this.call.camStatus === 'busy' ? PLATE_TEXT.cameraBusy : this.call.camStatus === 'pending' ? '' : 'No camera') : this.call.camOff ? 'Camera off' : '';
1616
+ show(this.plate, noCam || this.call.camOff);
1617
+ this.reroute(false);
1618
+ }
1619
+
1620
+ reroute(force) {
1621
+ const l = this.call.local;
1622
+ // Local SBS on a woven wall → woven mirror-and-swap preview; anything else is flat.
1623
+ const route = l && l.format === 'sbs' && this.call.woven && l.videoTrack ? 'woven-sbs' : l && l.format === 'sbs' ? 'flat-left' : 'flat';
1624
+ if (!force && route === this.route) return this._badge();
1625
+ this.unregister();
1626
+ this.route = route;
1627
+ if (route === 'woven-sbs') {
1628
+ if (!this.mirror) this.mirror = document.createElement('canvas');
1629
+ this._paintMirror();
1630
+ this.handle = this.call.wall.addImage(this.canvas, this.mirror);
1631
+ }
1632
+ this._badge();
1633
+ }
1634
+
1635
+ _badge() {
1636
+ this.badge.replaceChildren(el('b', { text: `You · ${this.route === 'woven-sbs' ? '3D' : '2D'}` }));
1637
+ }
1638
+
1639
+ onWallLost() {
1640
+ this.handle = null;
1641
+ this.reroute(true);
1642
+ }
1643
+
1644
+ unregister() {
1645
+ if (this.handle) {
1646
+ try {
1647
+ this.handle.remove();
1648
+ } catch {
1649
+ /* ignore */
1650
+ }
1651
+ this.handle = null;
1652
+ }
1653
+ }
1654
+
1655
+ setSpeaking(on) {
1656
+ this.el.classList.toggle('dxr-call-tile--speaking', on);
1657
+ }
1658
+
1659
+ paint() {
1660
+ const l = this.call.local;
1661
+ if (!l || !l.video || this.call.state === 'left') return;
1662
+ if (this.route === 'woven-sbs') this._paintMirror();
1663
+ else paintFlat(this.canvas, l.video, { eyeHalf: this.route === 'flat-left' ? 0 : null, mirror: true });
1664
+ }
1665
+
1666
+ /**
1667
+ * The MIRRORED stereo preview: each half mirrored AND the halves swapped (wire.js
1668
+ * mirrorSwapOps). Mirroring each half in place would invert every disparity. The stream that is
1669
+ * SENT is never touched — only this canvas.
1670
+ */
1671
+ _paintMirror() {
1672
+ const v = this.call.local.video;
1673
+ const W = v.videoWidth;
1674
+ const H = v.videoHeight;
1675
+ if (!W || !H || (v.readyState || 0) < 2) return;
1676
+ const A = this.call.o.tileAspect;
1677
+ const eyeW = W / 2;
1678
+ const { w: outW, h: outH } = eyeOutputSize(eyeW, H, A);
1679
+ const c = this.mirror;
1680
+ if (c.width !== 2 * outW || c.height !== outH) {
1681
+ c.width = 2 * outW;
1682
+ c.height = outH;
1683
+ }
1684
+ const g = c.getContext('2d');
1685
+ const r = eyeCropRect(eyeW, H, A, 0, 0);
1686
+ for (const op of mirrorSwapOps(W, H)) {
1687
+ const dx = op.dx === 0 ? 0 : outW;
1688
+ g.save();
1689
+ g.translate(dx + outW, 0);
1690
+ g.scale(-1, 1);
1691
+ g.drawImage(v, op.sx + r.sx, r.sy, r.sw, r.sh, 0, 0, outW, outH);
1692
+ g.restore();
1693
+ }
1694
+ }
1695
+ }