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