@displayxr/inline3d 1.47.5 → 1.48.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 +143 -1
- package/README.md +7 -1
- package/call.d.ts +98 -8
- package/camera.d.ts +34 -5
- package/dist/call.js +8 -6
- package/docs/content-lifecycle-rules.md +270 -0
- package/docs/woven-canvas-rules.md +25 -13
- package/js/call/element.js +7 -3
- package/js/call/options.js +20 -3
- package/js/call/transport.js +11 -1
- package/js/call/ui.js +8 -1
- package/js/call/wire.js +163 -6
- package/js/camera/capture.js +107 -20
- package/js/camera/conv-meter.js +152 -0
- package/js/inline3d-call.js +392 -71
- package/js/inline3d-camera.js +29 -6
- package/js/inline3d-model-entry.js +3 -0
- package/js/inline3d-model-playcanvas.js +36 -2
- package/js/inline3d-pc-auto-release.js +191 -0
- package/js/inline3d-pc-release.js +107 -0
- package/js/inline3d-pc-shader-cache.js +172 -0
- package/js/inline3d-playcanvas-engine.js +2 -1
- package/js/inline3d-player.js +8 -4
- package/js/inline3d-splat-deferred.js +2 -0
- package/js/inline3d-splat-diag.js +125 -4
- package/js/inline3d-splat-playcanvas.js +40 -3
- package/js/inline3d-splat.js +2 -0
- package/llms.txt +1 -0
- package/model.d.ts +12 -0
- package/package.json +12 -1
- package/signaling/README.md +289 -0
- package/signaling/dev-server.mjs +320 -0
- package/signaling/gate.mjs +112 -0
- package/signaling/keys.mjs +123 -0
- package/signaling/limits.mjs +221 -0
- package/signaling/room.mjs +322 -0
- package/signaling/turn-budget.mjs +254 -0
- package/splat.d.ts +12 -0
package/js/inline3d-call.js
CHANGED
|
@@ -85,7 +85,7 @@
|
|
|
85
85
|
|
|
86
86
|
import { sharedInline3D } from './inline3d.js';
|
|
87
87
|
import * as wire from './call/wire.js';
|
|
88
|
-
import { DEFAULT_MAX_PEERS, HINT_MAX_HZ, newPeerId, parseInviteLink, normalizeHint, normalizeState, rateGate, createConvergence, eyeOutputSize, backoffMs } from './call/wire.js';
|
|
88
|
+
import { DEFAULT_MAX_PEERS, HINT_MAX_HZ, CONV_MAX_HZ, CONV_RECV_MAX_HZ, CONV_HEARTBEAT_MS, CONV_FRESH_MS, APP_MAX_PER_SEC, APP_RECV_MAX_PER_SEC, newPeerId, parseInviteLink, normalizeHint, normalizeState, normalizeConv, makeConv, makeAppMessage, normalizeApp, windowGate, selectedPath, sanitizeName, rateGate, createConvergence, eyeOutputSize, backoffMs } from './call/wire.js';
|
|
89
89
|
import { MeshTransport as MeshTransportImpl, clampMaxPeers as clampMaxPeersImpl } from './call/transport.js';
|
|
90
90
|
import { openMic } from './camera/capture.js';
|
|
91
91
|
import { openCamera, addCameraView, isStereoCamera } from './inline3d-camera.js';
|
|
@@ -98,7 +98,8 @@ import * as signalingMod from './call/signaling.js';
|
|
|
98
98
|
import { injectCallStyle, ICONS, el, show, CALL_ACCENTS as CALL_ACCENTS_IMPL, createStrings, CALL_STRINGS } from './call/ui.js';
|
|
99
99
|
import { normalizeCallOptions as normalizeOptions } from './call/options.js';
|
|
100
100
|
import { defineCallElement } from './call/element.js';
|
|
101
|
-
import {
|
|
101
|
+
import { createDisparityTrack, createFocusTracker } from './call/disparity.js';
|
|
102
|
+
import { grabLumaFrame, createConvergenceMeter } from './camera/conv-meter.js';
|
|
102
103
|
import { dxrSignaling } from './call/signaling.js';
|
|
103
104
|
|
|
104
105
|
// ── the public surface (RFC 0003 §2) ────────────────────────────────────────────────────────
|
|
@@ -119,8 +120,6 @@ const AUTO_CONV_INTERVAL_MS = 200;
|
|
|
119
120
|
const AUTO_CONV_EYE_WIDTH = 240;
|
|
120
121
|
// While a depth cursor hovers a stereo tile, its source is copied this often (the cursor measures it).
|
|
121
122
|
const CURSOR_SAMPLE_MS = 66;
|
|
122
|
-
// WebCodecs pixel formats whose plane 0 is luma (Y): read directly, no colour conversion.
|
|
123
|
-
const Y_PLANE_FORMATS = new Set(['I420', 'I420A', 'I422', 'I444', 'NV12']);
|
|
124
123
|
/**
|
|
125
124
|
* Put a 3D video call in `container`. Resolves once the camera is open and (with `ui:true`) the
|
|
126
125
|
* lobby is showing; with `ui:false` (or `autoJoin:true`) once the call is joined.
|
|
@@ -177,9 +176,25 @@ class Call {
|
|
|
177
176
|
this.depth = 0;
|
|
178
177
|
this.speakerId = null;
|
|
179
178
|
this.transport = null;
|
|
179
|
+
// Headless hooks (docs/call.md § Headless mode): this side's name, the signalling state, the
|
|
180
|
+
// self view switch, the audio output every tile plays to, and the app channel's per-peer gates.
|
|
181
|
+
this.name = o.name;
|
|
182
|
+
this.signalState = null;
|
|
183
|
+
this.selfViewOn = o.selfView;
|
|
184
|
+
this.sinkId = null;
|
|
185
|
+
this._appOut = new Map();
|
|
186
|
+
this._appIn = new Map();
|
|
180
187
|
this._raf = 0;
|
|
181
188
|
this._statsTimer = 0;
|
|
182
189
|
this._hintGate = rateGate(HINT_MAX_HZ);
|
|
190
|
+
// Sender-side auto-convergence (docs/call.md): this side measures its OWN raw camera frames
|
|
191
|
+
// (before encoding) and broadcasts `conv`, so every receiver applies one good measurement
|
|
192
|
+
// instead of each measuring a compressed, possibly downscaled copy. The timer paces it.
|
|
193
|
+
this._convGate = rateGate(CONV_MAX_HZ, 20); // paced by a 5 Hz timer: 20 ms of slack for its jitter (rateGate)
|
|
194
|
+
this._convMeter = createConvergenceMeter({ intervalMs: 0, grab: o._convGrab });
|
|
195
|
+
this._convOut = { frac: null, sentAt: 0, msg: null };
|
|
196
|
+
this._convGen = 0;
|
|
197
|
+
this._convTimer = 0;
|
|
183
198
|
// mono→3D (call/lift.js): the option, whether it is on right now (setMono3D), the resolved
|
|
184
199
|
// lift (null until resolved), the pool of lifted streams, and the web-provider frame watch.
|
|
185
200
|
this.mono3D = o.mono3D;
|
|
@@ -256,7 +271,7 @@ class Call {
|
|
|
256
271
|
let camError = null;
|
|
257
272
|
const given = isStereoCamera(want) ? want : null;
|
|
258
273
|
try {
|
|
259
|
-
cam = given || (await openCamera({ prefer: want, format: format || this.o.format, calibration: this.o.calibration, rectify: this.o.rectify || undefined, log }));
|
|
274
|
+
cam = given || (await openCamera({ prefer: want, format: format || this.o.format, calibration: this.o.calibration, rectify: this.o.rectify || undefined, exclude: this.o.cameraExclude, log }));
|
|
260
275
|
if (given && given.state !== 'live') throw Object.assign(new Error('the camera given is not live'), { code: 'no-camera', skipped: [] });
|
|
261
276
|
} catch (err) {
|
|
262
277
|
camError = err;
|
|
@@ -330,6 +345,8 @@ class Call {
|
|
|
330
345
|
baselineMm: l.calibration?.baselineMm,
|
|
331
346
|
hfovDeg: l.calibration?.hfovDeg,
|
|
332
347
|
rectified: !!l.calibration?.rectified,
|
|
348
|
+
conv: this.o.sendConvergence && l.format === 'sbs',
|
|
349
|
+
name: this.name,
|
|
333
350
|
});
|
|
334
351
|
}
|
|
335
352
|
|
|
@@ -382,6 +399,7 @@ class Call {
|
|
|
382
399
|
onGhost: (gid, on) => this._onGhost(gid, on),
|
|
383
400
|
onSignalingState: (st, err) => {
|
|
384
401
|
this.log('signaling', { state: st });
|
|
402
|
+
this._setSignal(st, err);
|
|
385
403
|
if (st === 'closed' && err) this.error(err.code || 'signaling-closed', err.message, err);
|
|
386
404
|
},
|
|
387
405
|
});
|
|
@@ -397,6 +415,7 @@ class Call {
|
|
|
397
415
|
throw err;
|
|
398
416
|
}
|
|
399
417
|
this._setState('in-call');
|
|
418
|
+
this._setSignal('connected');
|
|
400
419
|
// The grid is sized so the whole call block fits ONE viewport, but only if it starts at the
|
|
401
420
|
// top: a tile below the fold is withheld from the weave (browser#167), so its layer goes
|
|
402
421
|
// live late. Bring the block into view once, on join.
|
|
@@ -409,6 +428,7 @@ class Call {
|
|
|
409
428
|
}
|
|
410
429
|
}
|
|
411
430
|
this._startStats();
|
|
431
|
+
this._startConvSend();
|
|
412
432
|
this._pagehide = () => this.leave('pagehide');
|
|
413
433
|
globalThis.addEventListener?.('pagehide', this._pagehide);
|
|
414
434
|
this.log('joined', { id: this.id, peers: this.tiles.size });
|
|
@@ -492,6 +512,8 @@ class Call {
|
|
|
492
512
|
if (msg.type === '__open') {
|
|
493
513
|
this.transport.send(id, this.hello());
|
|
494
514
|
this.transport.send(id, this.stateMsg());
|
|
515
|
+
// A peer that joins mid-call gets the current convergence at once, not at the next heartbeat.
|
|
516
|
+
if (this._convOut.msg && this._convSending()) this.transport.send(id, this._convOut.msg);
|
|
495
517
|
tile.onChannelOpen();
|
|
496
518
|
return;
|
|
497
519
|
}
|
|
@@ -501,6 +523,167 @@ class Call {
|
|
|
501
523
|
if (hint) return tile.setHint(hint);
|
|
502
524
|
const st = normalizeState(msg);
|
|
503
525
|
if (st) return tile.setRemoteState(st);
|
|
526
|
+
const cv = normalizeConv(msg);
|
|
527
|
+
if (cv) return tile.setConv(cv);
|
|
528
|
+
// An app message (handle.send / broadcast). The ONLY type that reaches the page: everything
|
|
529
|
+
// above is the SDK's own and never surfaces. Rate-capped per peer on receipt too.
|
|
530
|
+
const app = normalizeApp(msg);
|
|
531
|
+
if (app) {
|
|
532
|
+
let gate = this._appIn.get(id);
|
|
533
|
+
if (!gate) this._appIn.set(id, (gate = windowGate(APP_RECV_MAX_PER_SEC)));
|
|
534
|
+
if (gate(performance.now())) this.emit('message', { from: id, data: app.data });
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** The signalling connection's state for `handle.signal` + the `signal` event (transitions only). */
|
|
539
|
+
_setSignal(st, err) {
|
|
540
|
+
if (st === this.signalState) return;
|
|
541
|
+
this.signalState = st;
|
|
542
|
+
const ev = { state: st };
|
|
543
|
+
if (err && err.code) ev.code = err.code;
|
|
544
|
+
this.emit('signal', ev);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// ── headless hooks: names, app messages, devices, the self view ─────────────────────────
|
|
548
|
+
|
|
549
|
+
setName(n) {
|
|
550
|
+
this.name = sanitizeName(n);
|
|
551
|
+
this.transport?.broadcast(this.hello());
|
|
552
|
+
return this.name;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** One app message to one peer: false when not connected, too big, not serialisable, or over its rate. */
|
|
556
|
+
sendApp(peerId, data) {
|
|
557
|
+
if (!this.transport) return false;
|
|
558
|
+
const m = makeAppMessage(data);
|
|
559
|
+
if (!m) return false;
|
|
560
|
+
return this._sendAppTo(peerId, m.json);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/** The same message to every peer; true only if every connected peer got it. */
|
|
564
|
+
broadcastApp(data) {
|
|
565
|
+
if (!this.transport) return false;
|
|
566
|
+
const m = makeAppMessage(data);
|
|
567
|
+
if (!m) return false;
|
|
568
|
+
let any = false;
|
|
569
|
+
let all = true;
|
|
570
|
+
for (const id of this.tiles.keys()) {
|
|
571
|
+
if (this.transport.peerState?.(id) === null) continue; // a ghost / a peer the mesh does not hold
|
|
572
|
+
any = true;
|
|
573
|
+
if (!this._sendAppTo(id, m.json)) all = false;
|
|
574
|
+
}
|
|
575
|
+
return any && all;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
_sendAppTo(id, json) {
|
|
579
|
+
if (!this.tiles.has(id)) return false;
|
|
580
|
+
let gate = this._appOut.get(id);
|
|
581
|
+
if (!gate) this._appOut.set(id, (gate = windowGate(APP_MAX_PER_SEC)));
|
|
582
|
+
if (!gate(performance.now())) return false;
|
|
583
|
+
return this.transport.sendRaw ? this.transport.sendRaw(id, json) : this.transport.send(id, JSON.parse(json));
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** Switch the microphone to `deviceId` (null = the default). False (old mic kept) when it cannot open. */
|
|
587
|
+
async setMic(deviceId) {
|
|
588
|
+
const track = await openMic({ deviceId: deviceId || undefined, log: (t, x) => this.log(t, x) });
|
|
589
|
+
if (!track) return false;
|
|
590
|
+
const l = this.local;
|
|
591
|
+
if (!l) {
|
|
592
|
+
track.stop();
|
|
593
|
+
return false;
|
|
594
|
+
}
|
|
595
|
+
const old = l.audioTrack;
|
|
596
|
+
track.enabled = !this.muted;
|
|
597
|
+
l.audioTrack = track;
|
|
598
|
+
l.stream = new MediaStream([l.videoTrack, track].filter(Boolean));
|
|
599
|
+
if (this.transport) await this.transport.replaceTrack('audio', track, l.stream);
|
|
600
|
+
if (old && old !== track) old.stop();
|
|
601
|
+
this.log('mic', { deviceId: deviceId || null });
|
|
602
|
+
return true;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/** Every participant's audio to `sinkId` (also tiles that join later). True if every tile took it. */
|
|
606
|
+
async setAudioOutput(sinkId) {
|
|
607
|
+
this.sinkId = sinkId || '';
|
|
608
|
+
const results = await Promise.all([...this.tiles.values()].map((t) => t.applySink()));
|
|
609
|
+
return results.every(Boolean);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
setSelfView(on) {
|
|
613
|
+
const want = on === undefined ? !this.selfViewOn : !!on;
|
|
614
|
+
if (want === this.selfViewOn) return want;
|
|
615
|
+
this.selfViewOn = want;
|
|
616
|
+
if (want) {
|
|
617
|
+
if (!this.self) this.self = new SelfTile(this, this.ui.selfSlot);
|
|
618
|
+
show(this.ui.selfSlot, true);
|
|
619
|
+
show(this.self.el, true);
|
|
620
|
+
this.self.update();
|
|
621
|
+
} else if (this.self) {
|
|
622
|
+
// Remove the LAYER first (never display:none over a live registration), then hide the box.
|
|
623
|
+
this.self.detach();
|
|
624
|
+
show(this.self.el, false);
|
|
625
|
+
}
|
|
626
|
+
this.log('self-view', { on: want });
|
|
627
|
+
return want;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// ── sender-side auto-convergence ─────────────────────────────────────────────────────────
|
|
631
|
+
|
|
632
|
+
_startConvSend() {
|
|
633
|
+
this._stopConvSend();
|
|
634
|
+
this._convTimer = setInterval(() => this._tickConv(), AUTO_CONV_INTERVAL_MS);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
_stopConvSend() {
|
|
638
|
+
clearInterval(this._convTimer);
|
|
639
|
+
this._convTimer = 0;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/** Forget the measurement (a new camera, leaving): the next `conv` is a fresh one. */
|
|
643
|
+
_resetConvSend() {
|
|
644
|
+
this._convGen++;
|
|
645
|
+
this._convMeter.reset();
|
|
646
|
+
this._convOut = { frac: null, sentAt: 0, msg: null };
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/** Is this side measuring and sending its convergence right now? */
|
|
650
|
+
_convSending() {
|
|
651
|
+
const l = this.local;
|
|
652
|
+
return !!(this.transport && this.o.sendConvergence && l && l.format === 'sbs' && l.videoTrack && !this.camOff && l.video);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* One sample of this side's raw camera frame (`local.video` plays the camera's own stream: the
|
|
657
|
+
* pre-encode, unmirrored pair; the self view's mirroring is a paint, never on this video). Runs
|
|
658
|
+
* whatever the self view does, including `selfView: false`. Broadcasts `conv` when the gate
|
|
659
|
+
* passes and the value moved by a quarter source pixel, or the heartbeat is due.
|
|
660
|
+
*/
|
|
661
|
+
_tickConv() {
|
|
662
|
+
if (!this._convSending() || this._convMeter.busy) return;
|
|
663
|
+
const v = this.local.video;
|
|
664
|
+
const gen = this._convGen;
|
|
665
|
+
this._convMeter
|
|
666
|
+
.sample(v, performance.now())
|
|
667
|
+
.then((r) => {
|
|
668
|
+
if (!r || gen !== this._convGen || !this._convSending()) return;
|
|
669
|
+
// Gate on the SEND time (what a receiver's own gate sees). A send the gate refuses is not
|
|
670
|
+
// lost: the value still differs from the last one sent, so the next tick sends it.
|
|
671
|
+
const now = performance.now();
|
|
672
|
+
const out = this._convOut;
|
|
673
|
+
const eyeW = (v.videoWidth || 0) / 2;
|
|
674
|
+
const minStep = eyeW > 0 ? 0.25 / eyeW : 0;
|
|
675
|
+
const moved = (r.frac === null) !== (out.frac === null) || (r.frac !== null && Math.abs(r.frac - out.frac) >= minStep);
|
|
676
|
+
const due = !out.msg || now - out.sentAt >= CONV_HEARTBEAT_MS;
|
|
677
|
+
if ((!moved && !due) || !this._convGate(now)) return;
|
|
678
|
+
const msg = makeConv(r.frac, r.ncc);
|
|
679
|
+
this.transport.broadcast(msg);
|
|
680
|
+
this._convOut = { frac: r.frac, sentAt: now, msg };
|
|
681
|
+
if (r.frac !== null && !this._convLogged) {
|
|
682
|
+
this._convLogged = true;
|
|
683
|
+
this.log('send-conv', { frac: Math.round(r.frac * 1e4) / 1e4, disparityPx: Math.round(r.d * 10) / 10, method: r.method, via: r.via, ms: Math.round(r.ms) });
|
|
684
|
+
}
|
|
685
|
+
})
|
|
686
|
+
.catch(() => {});
|
|
504
687
|
}
|
|
505
688
|
|
|
506
689
|
// ── handle ops ─────────────────────────────────────────────────────────────────────────
|
|
@@ -541,6 +724,7 @@ class Call {
|
|
|
541
724
|
async setCamera(idOrStream, { format } = {}) {
|
|
542
725
|
const before = this.local?.format;
|
|
543
726
|
const l = await this._openMedia(idOrStream, format, { keepOnFail: true });
|
|
727
|
+
this._resetConvSend(); // a new camera: measure afresh, before the hello that announces it
|
|
544
728
|
if (this.transport) {
|
|
545
729
|
await this.transport.replaceTrack('video', l.videoTrack, l.stream);
|
|
546
730
|
await this.transport.setSendFormat(l.format);
|
|
@@ -569,6 +753,8 @@ class Call {
|
|
|
569
753
|
globalThis.removeEventListener?.('pagehide', this._pagehide);
|
|
570
754
|
this.transport?.stop();
|
|
571
755
|
this.transport = null;
|
|
756
|
+
this._stopConvSend();
|
|
757
|
+
this._resetConvSend();
|
|
572
758
|
for (const t of this.tiles.values()) t.destroy();
|
|
573
759
|
this.tiles.clear();
|
|
574
760
|
this.liftPool?.releaseAll();
|
|
@@ -585,6 +771,9 @@ class Call {
|
|
|
585
771
|
this.local.audioTrack = null;
|
|
586
772
|
}
|
|
587
773
|
this.self?.detach(); // a rejoin re-attaches it
|
|
774
|
+
this.signalState = null;
|
|
775
|
+
this._appOut.clear();
|
|
776
|
+
this._appIn.clear();
|
|
588
777
|
this._unhookWall();
|
|
589
778
|
this._setState('left');
|
|
590
779
|
this._layout();
|
|
@@ -863,6 +1052,7 @@ class Call {
|
|
|
863
1052
|
if (s.type === 'codec') codecs[s.id] = s.mimeType;
|
|
864
1053
|
});
|
|
865
1054
|
const q = { in: null, out: null };
|
|
1055
|
+
if (withQuality) Object.assign(q, selectedPath(report));
|
|
866
1056
|
report.forEach((s) => {
|
|
867
1057
|
if (s.type === 'inbound-rtp' && s.kind === 'audio' && typeof s.audioLevel === 'number') tile.level = Math.max(s.audioLevel, (tile.level || 0) * 0.6);
|
|
868
1058
|
if (s.type === 'media-source' && s.kind === 'audio' && typeof s.audioLevel === 'number') localLevel = Math.max(localLevel, s.audioLevel);
|
|
@@ -893,7 +1083,7 @@ class Call {
|
|
|
893
1083
|
tile._prevOut = { bytes: s.bytesSent, t: s.timestamp };
|
|
894
1084
|
}
|
|
895
1085
|
});
|
|
896
|
-
if (withQuality && (q.in || q.out)) {
|
|
1086
|
+
if (withQuality && (q.in || q.out || q.path)) {
|
|
897
1087
|
tile.quality = q;
|
|
898
1088
|
this.emit('quality', { id, ...q });
|
|
899
1089
|
}
|
|
@@ -916,6 +1106,7 @@ class Call {
|
|
|
916
1106
|
this._selfSpeaking = speaking;
|
|
917
1107
|
this.transport?.broadcast(this.stateMsg());
|
|
918
1108
|
this.self?.setSpeaking(speaking);
|
|
1109
|
+
this.emit('speaking', { id: 'self', on: speaking });
|
|
919
1110
|
}
|
|
920
1111
|
}
|
|
921
1112
|
|
|
@@ -962,6 +1153,9 @@ class Call {
|
|
|
962
1153
|
const t = this.t;
|
|
963
1154
|
injectCallStyle(c.ownerDocument || document);
|
|
964
1155
|
c.classList.add('dxr-call-host');
|
|
1156
|
+
// layout:'none': the footer and self slot step aside too (display:contents), so the page can
|
|
1157
|
+
// place the self tile ([data-dxr-peer="self"]) — style it in place, never move it.
|
|
1158
|
+
c.classList.toggle('dxr-call-host--none', this.o.layout === 'none');
|
|
965
1159
|
// theme → CSS custom properties on the host (chrome only: no variable can reach a tile).
|
|
966
1160
|
for (const [prop, v] of Object.entries(this.o.cssVars)) c.style.setProperty(prop, v);
|
|
967
1161
|
const ui = (this.ui = {});
|
|
@@ -979,7 +1173,7 @@ class Call {
|
|
|
979
1173
|
show(ui.bar, false);
|
|
980
1174
|
}
|
|
981
1175
|
c.append(ui.banner, ui.panel, ui.grid, ui.invite, ui.foot);
|
|
982
|
-
if (this.
|
|
1176
|
+
if (this.selfViewOn) this.self = new SelfTile(this, ui.selfSlot);
|
|
983
1177
|
else show(ui.selfSlot, false);
|
|
984
1178
|
if (this.o.chrome) this._buildBar();
|
|
985
1179
|
}
|
|
@@ -1203,8 +1397,26 @@ class Call {
|
|
|
1203
1397
|
retryCamera: () => call.retryCamera(),
|
|
1204
1398
|
setMono3D: (on) => call.setMono3D(on),
|
|
1205
1399
|
leave: () => call.leave('left'),
|
|
1206
|
-
/** A
|
|
1207
|
-
tile: (peerId) => call.tiles.get(peerId)?.el || null,
|
|
1400
|
+
/** A participant's tile element (for `layout: 'none'`), or null; `'self'` is your self view. */
|
|
1401
|
+
tile: (peerId) => (peerId === 'self' ? call.self?.el || null : call.tiles.get(peerId)?.el || null),
|
|
1402
|
+
/** Your display name (sent in `hello`), or null. */
|
|
1403
|
+
get name() {
|
|
1404
|
+
return call.name;
|
|
1405
|
+
},
|
|
1406
|
+
setName: (n) => call.setName(n),
|
|
1407
|
+
send: (peerId, data) => call.sendApp(peerId, data),
|
|
1408
|
+
broadcast: (data) => call.broadcastApp(data),
|
|
1409
|
+
/** The signalling connection: 'connected' | 'reconnecting' | 'closed', null before join / after leave. */
|
|
1410
|
+
get signal() {
|
|
1411
|
+
return call.signalState;
|
|
1412
|
+
},
|
|
1413
|
+
setSelfView: (on) => call.setSelfView(on),
|
|
1414
|
+
setMic: (deviceId) => call.setMic(deviceId),
|
|
1415
|
+
setAudioOutput: (sinkId) => call.setAudioOutput(sinkId),
|
|
1416
|
+
/** What this side sends (camera + microphone), read-only; null before a camera / mic opened. */
|
|
1417
|
+
get localStream() {
|
|
1418
|
+
return call.local ? call.local.stream : null;
|
|
1419
|
+
},
|
|
1208
1420
|
diagnostics: () => call.diagnostics(),
|
|
1209
1421
|
on(type, cb) {
|
|
1210
1422
|
if (typeof cb !== 'function') throw new TypeError(`${TAG} on() takes a function`);
|
|
@@ -1247,6 +1459,27 @@ class Call {
|
|
|
1247
1459
|
|
|
1248
1460
|
// ── paint helpers ──────────────────────────────────────────────────────────────────────────
|
|
1249
1461
|
|
|
1462
|
+
/**
|
|
1463
|
+
* Observe a tile's stage box (`ResizeObserver`, where there is one): `onBox(w, h)` with its
|
|
1464
|
+
* `clientWidth` / `clientHeight` on every change. Returns the disconnect, or null without one.
|
|
1465
|
+
*/
|
|
1466
|
+
function observeBox(el, onBox) {
|
|
1467
|
+
if (typeof ResizeObserver !== 'function') return null;
|
|
1468
|
+
const ro = new ResizeObserver(() => onBox(el.clientWidth || 0, el.clientHeight || 0));
|
|
1469
|
+
ro.observe(el);
|
|
1470
|
+
return () => ro.disconnect();
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
/** The `weave` event payload of a settled `firstWoven` / `rewoven()`. */
|
|
1474
|
+
function weaveEvent(id, res) {
|
|
1475
|
+
const ev = { id, state: res.woven ? 'woven' : 'flat', ms: Math.round(res.ms || 0) };
|
|
1476
|
+
if (res.reason) ev.reason = res.reason;
|
|
1477
|
+
return ev;
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
/** `theme.tileAspect: 'fill'`: a stage box's aspect, clamped to what a woven tile may take. */
|
|
1481
|
+
const clampAspect = (w, h) => Math.max(0.3, Math.min(4, w / h));
|
|
1482
|
+
|
|
1250
1483
|
/** Size a FLAT (never woven) canvas to its box × dpr. Returns false if it has no box yet. */
|
|
1251
1484
|
function sizeFlat(canvas) {
|
|
1252
1485
|
const dpr = Math.min(globalThis.devicePixelRatio || 1, 2);
|
|
@@ -1304,6 +1537,7 @@ class Tile {
|
|
|
1304
1537
|
this.autoCanvas = null;
|
|
1305
1538
|
this.autoLocked = false;
|
|
1306
1539
|
this.autoStats = { n: 0, ms: 0, readMs: 0, method: null, via: null };
|
|
1540
|
+
this.autoGrabs = 0; // local copies made: the receiver fallback, or the depth cursor's
|
|
1307
1541
|
this.autoPending = false;
|
|
1308
1542
|
// Bumped on every stream change and on destroy(): an async copyTo() that resolves after either
|
|
1309
1543
|
// belongs to a stream this tile no longer shows, and is dropped (review of #92).
|
|
@@ -1312,6 +1546,10 @@ class Tile {
|
|
|
1312
1546
|
this.destroyed = false;
|
|
1313
1547
|
this.conv.depth = call.depth;
|
|
1314
1548
|
this.hintGate = rateGate(HINT_MAX_HZ);
|
|
1549
|
+
// The sender's own measurement (`conv`, when its hello says `conv: true`): `{ frac, c, at }`,
|
|
1550
|
+
// `at` = when it ARRIVED here. Fresh for CONV_FRESH_MS; after that this side measures again.
|
|
1551
|
+
this.convGate = rateGate(CONV_RECV_MAX_HZ);
|
|
1552
|
+
this.senderConv = null;
|
|
1315
1553
|
this.layerFails = 0;
|
|
1316
1554
|
this.forceFlat = false;
|
|
1317
1555
|
// lifted route (mono→3D): the lift handle once it lands, whether it is showing (live), and a
|
|
@@ -1332,13 +1570,20 @@ class Tile {
|
|
|
1332
1570
|
this.badge = el('div', { class: 'dxr-call-badge', part: 'badge' });
|
|
1333
1571
|
this.plate = el('div', { class: 'dxr-call-state', part: 'plate', text: call.t('connecting') });
|
|
1334
1572
|
this.stage = el('div', { class: 'dxr-call-stage' }, [this.canvas, this.cover, this.badge, this.plate]);
|
|
1335
|
-
|
|
1573
|
+
// 'fill' (theme.tileAspect): no aspect-ratio — the page sizes the stage and the crop follows it.
|
|
1574
|
+
if (call.o.tileAspect !== 'fill') this.stage.style.aspectRatio = String(call.o.tileAspect);
|
|
1575
|
+
/** The stage box `{ w, h }` from the ResizeObserver; null until it first reports (or without one). */
|
|
1576
|
+
this._box = null;
|
|
1577
|
+
this._aspect = null; // 'fill': the stage's own aspect, once measured
|
|
1578
|
+
this._rwP = null; // the rewoven() promise a `weave` event is waiting on
|
|
1579
|
+
this._unobserve = observeBox(this.stage, (w, h) => this._onBox(w, h));
|
|
1336
1580
|
// `data-dxr-peer` is the documented hook (layout:'none', handle.tile()); `data-peer` is the 1.29 spelling.
|
|
1337
1581
|
this.el = el('div', { class: 'dxr-call-tile', part: 'tile', 'data-dxr-peer': id, 'data-peer': id }, [this.stage, el('div', { class: 'dxr-call-talk' })]);
|
|
1338
1582
|
if (!call.o.ui) {
|
|
1339
1583
|
show(this.badge, false);
|
|
1340
1584
|
}
|
|
1341
1585
|
call.ui.grid.appendChild(this.el);
|
|
1586
|
+
if (call.sinkId) this.applySink();
|
|
1342
1587
|
this._renderBadge();
|
|
1343
1588
|
/** The depth cursor (`cursor: 'depth'`) once its module has loaded; null otherwise. */
|
|
1344
1589
|
this.cursor = null;
|
|
@@ -1365,9 +1610,38 @@ class Tile {
|
|
|
1365
1610
|
muted: this.remote.muted,
|
|
1366
1611
|
cameraOff: this.remote.cameraOff,
|
|
1367
1612
|
speaking: this.speaking,
|
|
1613
|
+
name: (this.hello && this.hello.name) || null,
|
|
1614
|
+
});
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
/** The stage box changed: the 'fill' aspect, and a woven tile's `weave` event once it re-joins. */
|
|
1618
|
+
_onBox(w, h) {
|
|
1619
|
+
const prev = this._box;
|
|
1620
|
+
this._box = { w, h };
|
|
1621
|
+
if (w > 0 && h > 0) this._aspect = clampAspect(w, h);
|
|
1622
|
+
if (!prev || !w || !h || (prev.w === w && prev.h === h)) return;
|
|
1623
|
+
const handle = this.handle;
|
|
1624
|
+
if (!handle || !this._fwSettled || typeof handle.rewoven !== 'function') return;
|
|
1625
|
+
const p = handle.rewoven();
|
|
1626
|
+
if (p === this._rwP || !p || typeof p.then !== 'function') return;
|
|
1627
|
+
this._rwP = p;
|
|
1628
|
+
p.then((res) => {
|
|
1629
|
+
if (this._rwP !== p || this.handle !== handle || !res) return;
|
|
1630
|
+
this._rwP = null;
|
|
1631
|
+
this.call.emit('weave', weaveEvent(this.id, res));
|
|
1368
1632
|
});
|
|
1369
1633
|
}
|
|
1370
1634
|
|
|
1635
|
+
/** Play this participant's audio to the call's output device (`handle.setAudioOutput`). */
|
|
1636
|
+
applySink() {
|
|
1637
|
+
const a = this.audio;
|
|
1638
|
+
if (typeof a.setSinkId !== 'function') return Promise.resolve(false);
|
|
1639
|
+
return a.setSinkId(this.call.sinkId || '').then(
|
|
1640
|
+
() => true,
|
|
1641
|
+
() => false
|
|
1642
|
+
);
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1371
1645
|
/** Everything else (UNSTABLE): `handle.diagnostics().peers[i]`. */
|
|
1372
1646
|
diag() {
|
|
1373
1647
|
return {
|
|
@@ -1387,6 +1661,12 @@ class Tile {
|
|
|
1387
1661
|
readMs: this.autoStats.n ? this.autoStats.readMs / this.autoStats.n : null,
|
|
1388
1662
|
method: this.autoStats.method,
|
|
1389
1663
|
via: this.autoStats.via,
|
|
1664
|
+
// Local copies made (the receiver fallback, or the depth cursor's).
|
|
1665
|
+
samples: this.autoGrabs,
|
|
1666
|
+
// Where the applied disparity comes from: 'sender' (its `conv`, fresh), 'local' (measured
|
|
1667
|
+
// here: an older or silent sender), null (autoConverge off, or nothing measured yet).
|
|
1668
|
+
source: !this.call.o.autoConverge ? null : this._senderFrac(performance.now()) !== null ? 'sender' : this.conv.measuredPx !== null ? 'local' : null,
|
|
1669
|
+
senderFrac: this.senderConv ? this.senderConv.frac : null,
|
|
1390
1670
|
},
|
|
1391
1671
|
lift: this.lifted
|
|
1392
1672
|
? {
|
|
@@ -1423,8 +1703,10 @@ class Tile {
|
|
|
1423
1703
|
|
|
1424
1704
|
setHello(h) {
|
|
1425
1705
|
clearTimeout(this.helloTimer);
|
|
1706
|
+
const prevName = this.hello ? this.hello.name || null : null;
|
|
1426
1707
|
this.hello = h;
|
|
1427
1708
|
this.conv.hello = h;
|
|
1709
|
+
if ((h.name || null) !== prevName) this.call.emit('name', { id: this.id, name: h.name || null });
|
|
1428
1710
|
const changed = this.format !== h.format;
|
|
1429
1711
|
this.format = h.format;
|
|
1430
1712
|
this.call.log('hello', { peer: this.id, format: h.format, width: h.width, height: h.height, rectified: h.rectified });
|
|
@@ -1437,6 +1719,28 @@ class Tile {
|
|
|
1437
1719
|
this.conv.subjectZmm = h.subjectZmm;
|
|
1438
1720
|
}
|
|
1439
1721
|
|
|
1722
|
+
/**
|
|
1723
|
+
* A `conv` from the sender (wire.normalizeConv already validated it). Rate-limited here too, so a
|
|
1724
|
+
* chatty or hostile sender cannot drive this tile at frame rate. `d: null` (nothing measured
|
|
1725
|
+
* yet) keeps a previous value alive and is otherwise ignored.
|
|
1726
|
+
*/
|
|
1727
|
+
setConv(cv) {
|
|
1728
|
+
const now = performance.now();
|
|
1729
|
+
if (!this.convGate(now)) return;
|
|
1730
|
+
if (cv.d === null) {
|
|
1731
|
+
if (this.senderConv) this.senderConv.at = now;
|
|
1732
|
+
return;
|
|
1733
|
+
}
|
|
1734
|
+
this.senderConv = { frac: cv.d, c: cv.c === undefined ? null : cv.c, at: now };
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
/** The sender's measured disparity as a fraction of its eye width, when fresh and announced; else null. */
|
|
1738
|
+
_senderFrac(now) {
|
|
1739
|
+
const sc = this.senderConv;
|
|
1740
|
+
if (!sc || !this.hello || !this.hello.conv || now - sc.at >= CONV_FRESH_MS) return null;
|
|
1741
|
+
return sc.frac;
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1440
1744
|
setRemoteState(st) {
|
|
1441
1745
|
this.remote = st;
|
|
1442
1746
|
this._renderBadge();
|
|
@@ -1448,6 +1752,7 @@ class Tile {
|
|
|
1448
1752
|
if (on === this.speaking) return;
|
|
1449
1753
|
this.speaking = on;
|
|
1450
1754
|
this.el.classList.toggle('dxr-call-tile--speaking', on);
|
|
1755
|
+
this.call.emit('speaking', { id: this.id, on });
|
|
1451
1756
|
}
|
|
1452
1757
|
|
|
1453
1758
|
setConn(st) {
|
|
@@ -1533,12 +1838,18 @@ class Tile {
|
|
|
1533
1838
|
if (!this.convCanvas) this.convCanvas = document.createElement('canvas');
|
|
1534
1839
|
this._paintConv(); // the first frame exists before the layer does
|
|
1535
1840
|
show(this.cover, true);
|
|
1536
|
-
|
|
1841
|
+
// tileCover 'auto': the core's rect cover takes over after the first join, across every later
|
|
1842
|
+
// resize / move (layout: 'none' — the page moves the tiles). No cover machinery here.
|
|
1843
|
+
const handle = call.o.tileCover === 'auto' ? call.wall.addImage(this.canvas, this.convCanvas, { rectCover: 'auto' }) : call.wall.addImage(this.canvas, this.convCanvas);
|
|
1537
1844
|
this.handle = handle;
|
|
1845
|
+
this._fwSettled = false;
|
|
1846
|
+
this._rwP = null;
|
|
1538
1847
|
handle.firstWoven?.then((res) => {
|
|
1539
1848
|
if (this.handle !== handle) return;
|
|
1849
|
+
this._fwSettled = true;
|
|
1540
1850
|
show(this.cover, false); // a cut, never a fade
|
|
1541
1851
|
call.log('first-woven', { peer: this.id, woven: res.woven, reason: res.reason, ms: Math.round(res.ms) });
|
|
1852
|
+
call.emit('weave', weaveEvent(this.id, res));
|
|
1542
1853
|
if (!res.woven && res.reason === 'layer-failed') this._layerFailed();
|
|
1543
1854
|
});
|
|
1544
1855
|
}
|
|
@@ -1685,6 +1996,9 @@ class Tile {
|
|
|
1685
1996
|
|
|
1686
1997
|
paint() {
|
|
1687
1998
|
if (!this.route) return;
|
|
1999
|
+
// Hidden (display:none) or collapsed: nothing to see, so no copy, no measurement. Known from the
|
|
2000
|
+
// ResizeObserver; without one (or before it reports) the tile paints as before.
|
|
2001
|
+
if (this._box && (!this._box.w || !this._box.h)) return;
|
|
1688
2002
|
if (this.route !== 'woven-sbs') this.cursor?.idle(); // only a woven pair has depth to measure
|
|
1689
2003
|
if (this.route === 'woven-sbs') this._paintConv();
|
|
1690
2004
|
else if (this.route === 'lifted') {
|
|
@@ -1699,7 +2013,7 @@ class Tile {
|
|
|
1699
2013
|
const H = v.videoHeight;
|
|
1700
2014
|
if (!W || !H || (v.readyState || 0) < 2 || !this.convCanvas) return;
|
|
1701
2015
|
const eyeW = W / 2;
|
|
1702
|
-
const A = this.call.o.tileAspect;
|
|
2016
|
+
const A = this.call.o.tileAspect === 'fill' ? this._aspect || 16 / 9 : this.call.o.tileAspect;
|
|
1703
2017
|
const { w: outW, h: outH } = eyeOutputSize(eyeW, H, A);
|
|
1704
2018
|
const c = this.convCanvas;
|
|
1705
2019
|
if (c.width !== 2 * outW || c.height !== outH) {
|
|
@@ -1707,7 +2021,12 @@ class Tile {
|
|
|
1707
2021
|
c.height = outH;
|
|
1708
2022
|
}
|
|
1709
2023
|
const hover = !!(this.cursor && this.cursor.hovering);
|
|
1710
|
-
|
|
2024
|
+
// The sender's measurement, when it sends one (recomputed per paint: a WebRTC resolution change
|
|
2025
|
+
// keeps it right, since it is a fraction of the eye width). Otherwise measure here, as before.
|
|
2026
|
+
const auto = this.call.o.autoConverge;
|
|
2027
|
+
const frac = auto ? this._senderFrac(performance.now()) : null;
|
|
2028
|
+
if (frac !== null) this.conv.measuredPx = frac * eyeW;
|
|
2029
|
+
if (hover || (auto && frac === null)) this._sampleDisparity(v, W, H, hover);
|
|
1711
2030
|
const shift = this.conv.step(eyeW);
|
|
1712
2031
|
const g = c.getContext('2d');
|
|
1713
2032
|
const rects = [];
|
|
@@ -1729,61 +2048,20 @@ class Tile {
|
|
|
1729
2048
|
// ~15 Hz while a depth cursor hovers: it measures the same copy, under the pointer.
|
|
1730
2049
|
if (this.autoPending || now - this.autoAt < (hover ? CURSOR_SAMPLE_MS : AUTO_CONV_INTERVAL_MS)) return;
|
|
1731
2050
|
this.autoAt = now;
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
// readback, not the matching, was ~25 ms of a ~30 ms measurement).
|
|
1735
|
-
if (typeof VideoFrame === 'function') {
|
|
1736
|
-
let frame = null;
|
|
1737
|
-
try {
|
|
1738
|
-
frame = new VideoFrame(v);
|
|
1739
|
-
} catch {
|
|
1740
|
-
frame = null;
|
|
1741
|
-
}
|
|
1742
|
-
if (frame && Y_PLANE_FORMATS.has(frame.format)) {
|
|
1743
|
-
this.autoPending = true;
|
|
1744
|
-
const gen = this.autoGen;
|
|
1745
|
-
const fw = frame.visibleRect ? frame.visibleRect.width : frame.displayWidth;
|
|
1746
|
-
const fh = frame.visibleRect ? frame.visibleRect.height : frame.displayHeight;
|
|
1747
|
-
const buf = new Uint8Array(frame.allocationSize());
|
|
1748
|
-
frame
|
|
1749
|
-
.copyTo(buf)
|
|
1750
|
-
.then((layout) => {
|
|
1751
|
-
if (gen !== this.autoGen) return; // the stream changed or the tile is gone: stale frame
|
|
1752
|
-
const t0 = performance.now();
|
|
1753
|
-
const f = Math.max(1, Math.ceil(fw / 2 / AUTO_CONV_EYE_WIDTH));
|
|
1754
|
-
const ds = downsampleLuma(buf, layout[0].offset, layout[0].stride, fw, fh, f);
|
|
1755
|
-
this._applyMeasurement(ds.img, ds.w, ds.h, ds.w / fw, now, performance.now() - t0, 'videoframe');
|
|
1756
|
-
})
|
|
1757
|
-
.catch(() => {
|
|
1758
|
-
if (gen === this.autoGen) this._applyMeasurement(null, 0, 0, 1, now, 0, 'videoframe');
|
|
1759
|
-
})
|
|
1760
|
-
.finally(() => {
|
|
1761
|
-
frame.close();
|
|
1762
|
-
this.autoPending = false;
|
|
1763
|
-
});
|
|
1764
|
-
return;
|
|
1765
|
-
}
|
|
1766
|
-
if (frame) frame.close();
|
|
1767
|
-
}
|
|
1768
|
-
// Fallback: a small canvas + getImageData (synchronous readback).
|
|
1769
|
-
const s = Math.min(1, AUTO_CONV_EYE_WIDTH / (W / 2));
|
|
1770
|
-
const w = Math.max(2, Math.round((W * s) / 2) * 2);
|
|
1771
|
-
const h = Math.max(2, Math.round(H * s));
|
|
2051
|
+
this.autoPending = true;
|
|
2052
|
+
const gen = this.autoGen;
|
|
1772
2053
|
if (!this.autoCanvas) this.autoCanvas = document.createElement('canvas');
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
img = null; // a frame that cannot be read (tainted, not decoded yet): skip it
|
|
1785
|
-
}
|
|
1786
|
-
this._applyMeasurement(img, w, h, w / W, now, performance.now() - now, 'canvas');
|
|
2054
|
+
// camera/conv-meter.js: WebCodecs Y plane (async copyTo, no canvas readback) when the decoded
|
|
2055
|
+
// frame has one, else a small canvas + getImageData.
|
|
2056
|
+
grabLumaFrame(v, { eyeMax: AUTO_CONV_EYE_WIDTH, scratch: this.autoCanvas })
|
|
2057
|
+
.then((g) => {
|
|
2058
|
+
if (gen !== this.autoGen) return; // the stream changed or the tile is gone: stale frame
|
|
2059
|
+
if (g) this._applyMeasurement(g.img, g.w, g.h, g.scale, now, g.readMs, g.via);
|
|
2060
|
+
else this._applyMeasurement(null, 0, 0, 1, now, 0, this.autoStats.via);
|
|
2061
|
+
})
|
|
2062
|
+
.finally(() => {
|
|
2063
|
+
this.autoPending = false;
|
|
2064
|
+
});
|
|
1787
2065
|
}
|
|
1788
2066
|
|
|
1789
2067
|
/**
|
|
@@ -1793,12 +2071,16 @@ class Tile {
|
|
|
1793
2071
|
*/
|
|
1794
2072
|
_applyMeasurement(img, w, h, s, at, readMs, via) {
|
|
1795
2073
|
if (this.destroyed) return;
|
|
2074
|
+
this.autoGrabs++;
|
|
1796
2075
|
if (img) this.luma = { img, w, h, scale: s };
|
|
1797
2076
|
if (!this.call.o.autoConverge) return; // the copy was for the depth cursor only
|
|
2077
|
+
if (this._senderFrac(performance.now()) !== null) return; // the sender's measurement wins; the copy was for the cursor
|
|
1798
2078
|
const t = performance.now();
|
|
1799
2079
|
const m = img ? this.autoFocus.measure(img, w, h, at) : null;
|
|
1800
2080
|
const d = this.autoTrack.push(m ? m.d / s : null);
|
|
1801
|
-
|
|
2081
|
+
// A failed measurement holds: the track holds its last value, and with an empty track (the
|
|
2082
|
+
// sender's `conv` just went stale) the sender's last value stays until this side locks.
|
|
2083
|
+
if (d !== null) this.conv.measuredPx = d;
|
|
1802
2084
|
this.autoStats.n++;
|
|
1803
2085
|
this.autoStats.readMs += readMs;
|
|
1804
2086
|
this.autoStats.ms += readMs + (performance.now() - t);
|
|
@@ -1820,6 +2102,7 @@ class Tile {
|
|
|
1820
2102
|
|
|
1821
2103
|
_resetAutoConverge() {
|
|
1822
2104
|
this.autoGen++;
|
|
2105
|
+
this.senderConv = null;
|
|
1823
2106
|
this.luma = null;
|
|
1824
2107
|
this.autoTrack.reset();
|
|
1825
2108
|
this.autoFocus.reset();
|
|
@@ -1837,6 +2120,8 @@ class Tile {
|
|
|
1837
2120
|
clearTimeout(this.leaveTimer);
|
|
1838
2121
|
clearTimeout(this.liftTimer);
|
|
1839
2122
|
this._unregister();
|
|
2123
|
+
this._unobserve?.();
|
|
2124
|
+
this._unobserve = null;
|
|
1840
2125
|
this.video.srcObject = null;
|
|
1841
2126
|
this.audio.srcObject = null;
|
|
1842
2127
|
this.el.remove();
|
|
@@ -1859,9 +2144,13 @@ class SelfTile {
|
|
|
1859
2144
|
this.badge = el('div', { class: 'dxr-call-badge', part: 'badge' });
|
|
1860
2145
|
this.plate = el('div', { class: 'dxr-call-state dxr-call-hidden', part: 'plate' });
|
|
1861
2146
|
this.stage = el('div', { class: 'dxr-call-stage' }, [this.canvas, this.badge, this.plate]);
|
|
1862
|
-
this.stage.style.aspectRatio = String(call.o.tileAspect);
|
|
1863
|
-
|
|
2147
|
+
if (call.o.tileAspect !== 'fill') this.stage.style.aspectRatio = String(call.o.tileAspect);
|
|
2148
|
+
// `data-dxr-peer="self"`: handle.tile('self'), and the page's CSS under layout: 'none'.
|
|
2149
|
+
this.el = el('div', { class: 'dxr-call-tile dxr-call-tile--self', part: 'tile', 'data-dxr-peer': 'self' }, [this.stage, el('div', { class: 'dxr-call-talk' })]);
|
|
1864
2150
|
slot.appendChild(this.el);
|
|
2151
|
+
this._box = null;
|
|
2152
|
+
this._rwP = null;
|
|
2153
|
+
this._unobserve = observeBox(this.stage, (w, h) => this._onBox(w, h));
|
|
1865
2154
|
this.view = null; // the CameraView over the current camera
|
|
1866
2155
|
this.cam = null;
|
|
1867
2156
|
}
|
|
@@ -1884,7 +2173,7 @@ class SelfTile {
|
|
|
1884
2173
|
const l = this.call.local;
|
|
1885
2174
|
const cam = l && l.videoTrack ? l.cam : null;
|
|
1886
2175
|
const wall = this.call.woven ? this.call.wall : { supported: false };
|
|
1887
|
-
if (!cam || cam.state !== 'live') {
|
|
2176
|
+
if (!cam || cam.state !== 'live' || !this.call.selfViewOn) {
|
|
1888
2177
|
this.detach();
|
|
1889
2178
|
return this._badge();
|
|
1890
2179
|
}
|
|
@@ -1899,16 +2188,48 @@ class SelfTile {
|
|
|
1899
2188
|
autoConverge: !!this.call.o.autoConverge, // the call option (default on) governs the self view too
|
|
1900
2189
|
cursor: this.call.o.cursor || undefined, // and so does `cursor: 'depth'`
|
|
1901
2190
|
aspect: this.call.o.tileAspect,
|
|
2191
|
+
...(this.call.o.tileCover === 'auto' ? { rectCover: 'auto' } : {}),
|
|
1902
2192
|
onRouteChange: (route, st) => {
|
|
1903
2193
|
if (this.cam !== cam) return; // a view this tile already replaced
|
|
1904
2194
|
this.call.log('self-route', { route, woven: st.woven, reason: st.reason });
|
|
1905
2195
|
this._badge();
|
|
2196
|
+
this._weave(st);
|
|
1906
2197
|
},
|
|
1907
2198
|
});
|
|
1908
2199
|
}
|
|
1909
2200
|
this._badge();
|
|
1910
2201
|
}
|
|
1911
2202
|
|
|
2203
|
+
/**
|
|
2204
|
+
* The self view's `weave` event (`id: 'self'`): its tile's `firstWoven` settled (woven or not),
|
|
2205
|
+
* or the view dropped flat because the wall said the tile will not weave.
|
|
2206
|
+
*/
|
|
2207
|
+
_weave(st) {
|
|
2208
|
+
const fw = st.firstWoven;
|
|
2209
|
+
let ev = null;
|
|
2210
|
+
if (fw && typeof fw === 'object') ev = weaveEvent('self', fw);
|
|
2211
|
+
else if (st.route !== 'woven-sbs' && st.reason) ev = { id: 'self', state: 'flat', reason: st.reason, ms: 0 };
|
|
2212
|
+
if (ev) this.call.emit('weave', ev);
|
|
2213
|
+
}
|
|
2214
|
+
|
|
2215
|
+
/** The self stage resized: once woven, the `weave` event of its re-join. */
|
|
2216
|
+
_onBox(w, h) {
|
|
2217
|
+
const prev = this._box;
|
|
2218
|
+
this._box = { w, h };
|
|
2219
|
+
if (!prev || !w || !h || (prev.w === w && prev.h === h)) return;
|
|
2220
|
+
const handle = this.view && this.view.woven ? this.view.handle : null;
|
|
2221
|
+
const fw = this.view ? this.view.weaveState().firstWoven : null;
|
|
2222
|
+
if (!handle || !fw || typeof fw !== 'object' || typeof handle.rewoven !== 'function') return;
|
|
2223
|
+
const p = handle.rewoven();
|
|
2224
|
+
if (p === this._rwP || !p || typeof p.then !== 'function') return;
|
|
2225
|
+
this._rwP = p;
|
|
2226
|
+
p.then((res) => {
|
|
2227
|
+
if (this._rwP !== p || !this.view || this.view.handle !== handle || !res) return;
|
|
2228
|
+
this._rwP = null;
|
|
2229
|
+
this.call.emit('weave', weaveEvent('self', res));
|
|
2230
|
+
});
|
|
2231
|
+
}
|
|
2232
|
+
|
|
1912
2233
|
/** 3D only while the view is on the woven route — a layer the wall failed has already taken it flat. */
|
|
1913
2234
|
_badge() {
|
|
1914
2235
|
const t = this.call.t;
|