@displayxr/inline3d 1.26.0 → 1.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +134 -0
- package/call.d.ts +374 -0
- package/index.d.ts +17 -1
- package/js/call/capture.js +167 -0
- package/js/call/lift.js +314 -0
- package/js/call/qr.js +283 -0
- package/js/call/sdp.js +86 -0
- package/js/call/signaling.js +398 -0
- package/js/call/transport.js +589 -0
- package/js/call/ui.js +146 -0
- package/js/call/wire.js +428 -0
- package/js/inline3d-buffer-limit.js +198 -0
- package/js/inline3d-call.js +1695 -0
- package/js/inline3d-model-playcanvas.js +6 -0
- package/js/inline3d-model.js +6 -0
- package/js/inline3d-player.js +3 -2
- package/js/inline3d-splat-layer-rig.js +79 -7
- package/js/inline3d-splat-playcanvas.js +94 -17
- package/js/inline3d-splat.js +5 -0
- package/js/inline3d-viewer.js +86 -10
- package/js/inline3d.js +127 -14
- package/model.d.ts +10 -1
- package/package.json +9 -1
- package/player.d.ts +3 -2
- package/splat.d.ts +26 -4
- package/viewer.d.ts +12 -1
|
@@ -0,0 +1,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
|
+
}
|