@displayxr/inline3d 1.48.0 → 1.49.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 +55 -0
- package/call.d.ts +107 -7
- package/camera.d.ts +16 -8
- package/dist/call.js +9 -6
- package/js/call/element.js +3 -1
- package/js/call/options.js +2 -0
- package/js/call/share.js +147 -0
- package/js/call/transport.js +150 -9
- package/js/call/ui.js +3 -0
- package/js/call/wire.js +65 -1
- package/js/camera/app-share.js +54 -0
- package/js/camera/capture.js +100 -23
- package/js/inline3d-call.js +361 -36
- package/js/inline3d-camera.js +9 -5
- package/package.json +1 -1
package/js/inline3d-call.js
CHANGED
|
@@ -76,6 +76,14 @@
|
|
|
76
76
|
// visibility (paused), the depth slider drives lift's convergence, and at most `maxLifted`
|
|
77
77
|
// streams run at once.
|
|
78
78
|
//
|
|
79
|
+
// CONTENT (3D app share, docs/call.md § Sharing a 3D app): a participant may share a second
|
|
80
|
+
// video track — a running DisplayXR app's side-by-side views (`openAppShare()`), or any SBS
|
|
81
|
+
// track — with `shareContent()`. It rides its own transceiver (call/transport.js, "THE CONTENT
|
|
82
|
+
// TRACK") and is announced by a `content` message; each receiver shows it in a ContentTile: the
|
|
83
|
+
// remote tile's woven path with `kind: 'content'`, but DISPLAY-REFERRED — the frames already put
|
|
84
|
+
// the sender's display plane at a known disparity (`convergence.frac`, 0 for an app in v1), so
|
|
85
|
+
// that is applied as the shift, nothing is measured, and only the depth slider adds to it.
|
|
86
|
+
//
|
|
79
87
|
// SESSION LOSS: a call must outlive a service restart. A layer that fails to build is retried
|
|
80
88
|
// with backoff (flat in between); a session that ENDS without the page closing the wall is
|
|
81
89
|
// replaced with a fresh `createInline3D()` and every tile re-registers on it — no reload. To tell
|
|
@@ -101,11 +109,14 @@ import { defineCallElement } from './call/element.js';
|
|
|
101
109
|
import { createDisparityTrack, createFocusTracker } from './call/disparity.js';
|
|
102
110
|
import { grabLumaFrame, createConvergenceMeter } from './camera/conv-meter.js';
|
|
103
111
|
import { dxrSignaling } from './call/signaling.js';
|
|
112
|
+
import { describeAppShare, isAppShareDevice } from './call/share.js';
|
|
104
113
|
|
|
105
114
|
// ── the public surface (RFC 0003 §2) ────────────────────────────────────────────────────────
|
|
106
115
|
export { DxrCallElement, defineCallElement, attrsToOpts, CALL_EVENT_PREFIX } from './call/element.js';
|
|
107
116
|
export { dxrSignaling, DXR_SIGNAL_DEFAULT } from './call/signaling.js';
|
|
108
117
|
export { parseInviteLink };
|
|
118
|
+
// 3D screen share (runtime ADR-061): find and open a running DisplayXR app as content.
|
|
119
|
+
export { isAppShareDevice, listShareableApps, watchShareableApps, openAppShare } from './call/share.js';
|
|
109
120
|
|
|
110
121
|
const TAG = '[inline3d/call]';
|
|
111
122
|
const HELLO_WAIT_MS = 2000;
|
|
@@ -169,6 +180,13 @@ class Call {
|
|
|
169
180
|
this.room = o.room === 'auto' ? parseInviteLink(globalThis.location) : o.room;
|
|
170
181
|
this.state = 'idle'; // idle | lobby | joining | in-call | full | left
|
|
171
182
|
this.tiles = new Map();
|
|
183
|
+
// Content tiles (3D app share): one per sharing participant, keyed by its id; `'self'` is the
|
|
184
|
+
// optional preview of what this side shares (`selfContentView`). `contentStreams`: each peer's
|
|
185
|
+
// content track as it arrived (it can come before or after its `content` message).
|
|
186
|
+
this.contentTiles = new Map();
|
|
187
|
+
this.contentStreams = new Map();
|
|
188
|
+
this.content = null; // what THIS side shares: { track, stream, desc, off }
|
|
189
|
+
this._contentWarned = new Set();
|
|
172
190
|
this.listeners = new Map();
|
|
173
191
|
this.local = null; // { stream, video, audio, format, width, height, calibration, owned, label }
|
|
174
192
|
this.muted = false;
|
|
@@ -347,6 +365,7 @@ class Call {
|
|
|
347
365
|
rectified: !!l.calibration?.rectified,
|
|
348
366
|
conv: this.o.sendConvergence && l.format === 'sbs',
|
|
349
367
|
name: this.name,
|
|
368
|
+
content: true, // this SDK carries the content track (3D app share): see call/transport.js
|
|
350
369
|
});
|
|
351
370
|
}
|
|
352
371
|
|
|
@@ -387,9 +406,12 @@ class Call {
|
|
|
387
406
|
room: this.room,
|
|
388
407
|
localStream: this.local.stream,
|
|
389
408
|
sendFormat: this.local.format,
|
|
409
|
+
contentTrack: this.content ? this.content.track : null,
|
|
410
|
+
contentStream: this.content ? this.content.stream : null,
|
|
390
411
|
onPeer: (id) => this._onPeer(id),
|
|
391
412
|
onPeerLeft: (id, reason) => this._onPeerLeft(id, reason),
|
|
392
413
|
onStream: (id, stream) => this.tiles.get(id)?.setStream(stream),
|
|
414
|
+
onContentStream: (id, stream) => this._onContentStream(id, stream),
|
|
393
415
|
onMessage: (id, msg) => this._onMessage(id, msg),
|
|
394
416
|
onPeerState: (id, st) => {
|
|
395
417
|
this.tiles.get(id)?.setConn(st);
|
|
@@ -496,6 +518,8 @@ class Call {
|
|
|
496
518
|
_onPeerLeft(id, reason) {
|
|
497
519
|
const tile = this.tiles.get(id);
|
|
498
520
|
if (!tile) return;
|
|
521
|
+
this._dropRemoteContent(id);
|
|
522
|
+
this.contentStreams.delete(id);
|
|
499
523
|
tile.setConn('left');
|
|
500
524
|
this.emit('peerleft', { id, reason });
|
|
501
525
|
tile.leaveTimer = setTimeout(() => {
|
|
@@ -514,11 +538,19 @@ class Call {
|
|
|
514
538
|
this.transport.send(id, this.stateMsg());
|
|
515
539
|
// A peer that joins mid-call gets the current convergence at once, not at the next heartbeat.
|
|
516
540
|
if (this._convOut.msg && this._convSending()) this.transport.send(id, this._convOut.msg);
|
|
541
|
+
// ...and what this side is sharing, if anything (a late joiner, a rebuilt connection).
|
|
542
|
+
if (this.content) this.transport.send(id, wire.makeContent(this.content.desc));
|
|
517
543
|
tile.onChannelOpen();
|
|
518
544
|
return;
|
|
519
545
|
}
|
|
520
546
|
const h = wire.normalizeHello(msg);
|
|
521
|
-
if (h)
|
|
547
|
+
if (h) {
|
|
548
|
+
tile.setHello(h);
|
|
549
|
+
this._onPeerHello(id, h);
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
const cm = wire.normalizeContent(msg);
|
|
553
|
+
if (cm) return this._onRemoteContent(id, cm);
|
|
522
554
|
const hint = normalizeHint(msg);
|
|
523
555
|
if (hint) return tile.setHint(hint);
|
|
524
556
|
const st = normalizeState(msg);
|
|
@@ -535,6 +567,171 @@ class Call {
|
|
|
535
567
|
}
|
|
536
568
|
}
|
|
537
569
|
|
|
570
|
+
// ── content (3D app share) ──────────────────────────────────────────────────────────────
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* A peer's hello: both sides carry content → the pair gets its content transceiver (the offerer
|
|
574
|
+
* adds it; call/transport.js). A peer that cannot (an older SDK) while this side shares: one
|
|
575
|
+
* `warning { code: 'content-unsupported' }` per peer and share.
|
|
576
|
+
*/
|
|
577
|
+
_onPeerHello(id, h) {
|
|
578
|
+
if (h.content) this.transport?.addContentTransceiver?.(id);
|
|
579
|
+
else this._warnContentPeer(id);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
_warnContentPeer(id) {
|
|
583
|
+
const tile = this.tiles.get(id);
|
|
584
|
+
if (!this.content || !tile || !tile.hello || tile.hello.content || this._contentWarned.has(id)) return;
|
|
585
|
+
this._contentWarned.add(id);
|
|
586
|
+
this.warning('content-unsupported', `participant ${id} is on an older SDK that cannot receive a shared app; they keep seeing your camera only`);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
_onContentStream(id, stream) {
|
|
590
|
+
if (stream) this.contentStreams.set(id, stream);
|
|
591
|
+
else this.contentStreams.delete(id);
|
|
592
|
+
const ct = this.contentTiles.get(id);
|
|
593
|
+
if (ct && stream) ct.setStream(stream);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/** A peer's `content` message (wire.normalizeContent validated it). */
|
|
597
|
+
_onRemoteContent(id, cm) {
|
|
598
|
+
const tile = this.tiles.get(id);
|
|
599
|
+
if (!tile) return;
|
|
600
|
+
const prev = tile.content;
|
|
601
|
+
if (!cm.on) {
|
|
602
|
+
if (prev.on) this._dropRemoteContent(id);
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
tile.content = { on: true, format: cm.format, appName: cm.appName };
|
|
606
|
+
let ct = this.contentTiles.get(id);
|
|
607
|
+
if (!ct) {
|
|
608
|
+
ct = new ContentTile(this, id);
|
|
609
|
+
this.contentTiles.set(id, ct);
|
|
610
|
+
const s = this.contentStreams.get(id);
|
|
611
|
+
if (s) ct.setStream(s);
|
|
612
|
+
}
|
|
613
|
+
ct.setContent(cm);
|
|
614
|
+
this.log('content', { peer: id, on: true, format: cm.format, appName: cm.appName, convergence: cm.convergence });
|
|
615
|
+
if (!prev.on || prev.appName !== cm.appName) this.emit('content', { id, on: true, appName: cm.appName });
|
|
616
|
+
this._layout();
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
_dropRemoteContent(id) {
|
|
620
|
+
const tile = this.tiles.get(id);
|
|
621
|
+
const ct = this.contentTiles.get(id);
|
|
622
|
+
const was = tile ? tile.content : null;
|
|
623
|
+
if (tile) tile.content = { on: false, format: null, appName: null };
|
|
624
|
+
if (ct) {
|
|
625
|
+
ct.destroy();
|
|
626
|
+
this.contentTiles.delete(id);
|
|
627
|
+
}
|
|
628
|
+
if (was && was.on) {
|
|
629
|
+
this.log('content', { peer: id, on: false });
|
|
630
|
+
this.emit('content', { id, on: false, appName: was.appName });
|
|
631
|
+
}
|
|
632
|
+
this._layout();
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Share `src` (a MediaStream or a video MediaStreamTrack — `openAppShare()`'s, or any
|
|
637
|
+
* side-by-side track) as this side's content. `opts` override what the track says about itself
|
|
638
|
+
* (`openAppShare()`'s `descriptor` fits as is). Replaces a previous share. Resolves with what is
|
|
639
|
+
* announced to everyone.
|
|
640
|
+
*/
|
|
641
|
+
async shareContent(src, opts = {}) {
|
|
642
|
+
const isStream = !!(src && typeof src.getVideoTracks === 'function');
|
|
643
|
+
const track = isStream ? src.getVideoTracks()[0] || null : src && src.kind === 'video' ? src : null;
|
|
644
|
+
if (!track) throw new TypeError(`${TAG} shareContent(src) needs a MediaStream with a video track, or a video MediaStreamTrack`);
|
|
645
|
+
if (track.readyState === 'ended') throw Object.assign(new Error(`${TAG} shareContent: that track has ended`), { code: 'content-ended' });
|
|
646
|
+
const stream = isStream ? src : new MediaStream([track]);
|
|
647
|
+
const o = opts && typeof opts === 'object' ? opts : {};
|
|
648
|
+
const auto = describeAppShare(track);
|
|
649
|
+
const app = isAppShareDevice(track);
|
|
650
|
+
const c = o.convergence;
|
|
651
|
+
const desc = {
|
|
652
|
+
format: o.format === 'mono' || o.format === 'sbs' ? o.format : auto.format,
|
|
653
|
+
width: o.width > 0 ? o.width : auto.width,
|
|
654
|
+
height: o.height > 0 ? o.height : auto.height,
|
|
655
|
+
// A DisplayXR app's frames are display-referred (its hint, 0 when the browser has none); any
|
|
656
|
+
// other track's convergence is unknown unless the page says so.
|
|
657
|
+
convergence: c && typeof c === 'object' ? { known: !!c.known, frac: c.known && Number.isFinite(c.frac) ? c.frac : c.known ? 0 : null } : app ? auto.convergence : { known: false, frac: null },
|
|
658
|
+
appName: sanitizeName(o.appName) || auto.appName,
|
|
659
|
+
};
|
|
660
|
+
const announced = wire.makeContent(desc);
|
|
661
|
+
desc.convergence = announced.convergence;
|
|
662
|
+
desc.width = announced.width;
|
|
663
|
+
desc.height = announced.height;
|
|
664
|
+
try {
|
|
665
|
+
track.contentHint = 'detail';
|
|
666
|
+
} catch {
|
|
667
|
+
/* older engines */
|
|
668
|
+
}
|
|
669
|
+
if (this.content && this.content.track !== track) this.content.off();
|
|
670
|
+
const onEnded = () => {
|
|
671
|
+
if (this.content && this.content.track === track) this.stopContent({}, 'ended').catch(() => {});
|
|
672
|
+
};
|
|
673
|
+
if (!this.content || this.content.track !== track) track.addEventListener?.('ended', onEnded);
|
|
674
|
+
const off = () => track.removeEventListener?.('ended', onEnded);
|
|
675
|
+
const was = this.content;
|
|
676
|
+
this.content = { track, stream, desc, off: was && was.track === track ? was.off : off };
|
|
677
|
+
this._contentWarned.clear();
|
|
678
|
+
if (this.transport) {
|
|
679
|
+
await this.transport.replaceTrack('video', track, stream, { slot: 'content' });
|
|
680
|
+
this.transport.broadcast(announced);
|
|
681
|
+
for (const id of this.tiles.keys()) this._warnContentPeer(id);
|
|
682
|
+
}
|
|
683
|
+
this._refreshSelfContent();
|
|
684
|
+
this.log('share-content', { format: desc.format, width: desc.width, height: desc.height, appName: desc.appName, convergence: desc.convergence });
|
|
685
|
+
if (!was || was.desc.appName !== desc.appName) this.emit('content', { id: 'self', on: true, appName: desc.appName });
|
|
686
|
+
return this.contentInfo();
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/** Stop sharing: the content sender goes empty, everyone gets `content { on: false }`. */
|
|
690
|
+
async stopContent({ stopTrack = false } = {}, reason = 'stopped') {
|
|
691
|
+
const c = this.content;
|
|
692
|
+
if (!c) return false;
|
|
693
|
+
this.content = null;
|
|
694
|
+
c.off();
|
|
695
|
+
this._refreshSelfContent();
|
|
696
|
+
if (this.transport) {
|
|
697
|
+
this.transport.broadcast(wire.makeContent({ on: false }));
|
|
698
|
+
await this.transport.replaceTrack('video', null, null, { slot: 'content' });
|
|
699
|
+
}
|
|
700
|
+
if (stopTrack) c.track.stop?.();
|
|
701
|
+
this.log('stop-content', { reason });
|
|
702
|
+
this.emit('content', { id: 'self', on: false, appName: c.desc.appName, reason });
|
|
703
|
+
return true;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/** `handle.content`: what this side shares, or null. */
|
|
707
|
+
contentInfo() {
|
|
708
|
+
const c = this.content;
|
|
709
|
+
if (!c) return null;
|
|
710
|
+
const d = c.desc;
|
|
711
|
+
return Object.freeze({ on: true, format: d.format, width: d.width, height: d.height, appName: d.appName, convergence: Object.freeze({ ...d.convergence }) });
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/** The optional preview of what this side shares (`selfContentView`). */
|
|
715
|
+
_refreshSelfContent() {
|
|
716
|
+
const want = !!(this.content && this.o.selfContentView && this.state !== 'left');
|
|
717
|
+
let ct = this.contentTiles.get('self');
|
|
718
|
+
if (!want) {
|
|
719
|
+
if (ct) {
|
|
720
|
+
ct.destroy();
|
|
721
|
+
this.contentTiles.delete('self');
|
|
722
|
+
this._layout();
|
|
723
|
+
}
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
if (!ct) {
|
|
727
|
+
ct = new ContentTile(this, 'self');
|
|
728
|
+
this.contentTiles.set('self', ct);
|
|
729
|
+
}
|
|
730
|
+
ct.setStream(this.content.stream);
|
|
731
|
+
ct.setContent({ on: true, ...this.content.desc });
|
|
732
|
+
this._layout();
|
|
733
|
+
}
|
|
734
|
+
|
|
538
735
|
/** The signalling connection's state for `handle.signal` + the `signal` event (transitions only). */
|
|
539
736
|
_setSignal(st, err) {
|
|
540
737
|
if (st === this.signalState) return;
|
|
@@ -716,6 +913,7 @@ class Call {
|
|
|
716
913
|
const n = v === null || v === undefined || !Number.isFinite(+v) ? 0 : Math.max(-1, Math.min(1, +v));
|
|
717
914
|
this.depth = n;
|
|
718
915
|
for (const t of this.tiles.values()) t.conv.depth = n;
|
|
916
|
+
for (const t of this.contentTiles.values()) t.conv.depth = n; // added to content's known convergence
|
|
719
917
|
this.liftPool?.setDepth(n); // the same control is lift's convergence on lifted tiles
|
|
720
918
|
if (this.ui?.depth && +this.ui.depth.value !== n) this.ui.depth.value = String(n);
|
|
721
919
|
return n;
|
|
@@ -757,6 +955,16 @@ class Call {
|
|
|
757
955
|
this._resetConvSend();
|
|
758
956
|
for (const t of this.tiles.values()) t.destroy();
|
|
759
957
|
this.tiles.clear();
|
|
958
|
+
for (const t of this.contentTiles.values()) t.destroy();
|
|
959
|
+
this.contentTiles.clear();
|
|
960
|
+
this.contentStreams.clear();
|
|
961
|
+
if (this.content) {
|
|
962
|
+
// Leaving ends the share (the track is the page's; it is left running).
|
|
963
|
+
const c = this.content;
|
|
964
|
+
this.content = null;
|
|
965
|
+
c.off();
|
|
966
|
+
this.emit('content', { id: 'self', on: false, appName: c.desc.appName, reason: 'left' });
|
|
967
|
+
}
|
|
760
968
|
this.liftPool?.releaseAll();
|
|
761
969
|
clearInterval(this._statsTimer);
|
|
762
970
|
this._setSpeaker(null);
|
|
@@ -837,6 +1045,7 @@ class Call {
|
|
|
837
1045
|
// Re-create only what was woven before the session was live (#172); flat tiles are untouched.
|
|
838
1046
|
// A lifted tile is a woven window too (lift's addScene canvas): release + lift again.
|
|
839
1047
|
for (const t of this.tiles.values()) if (t.route === 'woven-sbs' || t.route === 'lifted') t.reroute(true);
|
|
1048
|
+
for (const t of this.contentTiles.values()) if (t.route === 'woven-sbs') t.reroute(true);
|
|
840
1049
|
if (this.self && this.self.route === 'woven-sbs') this.self.reroute(true);
|
|
841
1050
|
this._layout();
|
|
842
1051
|
};
|
|
@@ -865,6 +1074,7 @@ class Call {
|
|
|
865
1074
|
this.weaveLive = false;
|
|
866
1075
|
this.log('session-ended', { byPage: hook.pageClosed });
|
|
867
1076
|
for (const t of this.tiles.values()) t.onWallLost();
|
|
1077
|
+
for (const t of this.contentTiles.values()) t.onWallLost();
|
|
868
1078
|
this.self?.onWallLost();
|
|
869
1079
|
if (hook.pageClosed || this.state === 'left') {
|
|
870
1080
|
this._unhookWall();
|
|
@@ -893,6 +1103,7 @@ class Call {
|
|
|
893
1103
|
this._hookWall(w);
|
|
894
1104
|
this.log('session-recovered', { attempt });
|
|
895
1105
|
for (const t of this.tiles.values()) t.reroute(true);
|
|
1106
|
+
for (const t of this.contentTiles.values()) t.reroute(true);
|
|
896
1107
|
this.self?.reroute(true);
|
|
897
1108
|
}, backoffMs(attempt, { baseMs: 1000, maxMs: 15000 }));
|
|
898
1109
|
}
|
|
@@ -904,6 +1115,7 @@ class Call {
|
|
|
904
1115
|
const tick = (now) => {
|
|
905
1116
|
this._raf = requestAnimationFrame(tick);
|
|
906
1117
|
for (const t of this.tiles.values()) t.paint();
|
|
1118
|
+
for (const t of this.contentTiles.values()) t.paint();
|
|
907
1119
|
if (last && this.liftPool && this.liftPool.anyWeb) this._watchFrames(now - last);
|
|
908
1120
|
last = now;
|
|
909
1121
|
};
|
|
@@ -1053,10 +1265,15 @@ class Call {
|
|
|
1053
1265
|
});
|
|
1054
1266
|
const q = { in: null, out: null };
|
|
1055
1267
|
if (withQuality) Object.assign(q, selectedPath(report));
|
|
1268
|
+
// The content track (3D app share) has its own inbound / outbound video: not the camera's quality.
|
|
1269
|
+
const contentIds = new Set();
|
|
1270
|
+
for (const t of this.contentStreams.get(id)?.getTracks?.() || []) contentIds.add(t.id);
|
|
1271
|
+
if (this.content && this.content.track) contentIds.add(this.content.track.id);
|
|
1056
1272
|
report.forEach((s) => {
|
|
1057
1273
|
if (s.type === 'inbound-rtp' && s.kind === 'audio' && typeof s.audioLevel === 'number') tile.level = Math.max(s.audioLevel, (tile.level || 0) * 0.6);
|
|
1058
1274
|
if (s.type === 'media-source' && s.kind === 'audio' && typeof s.audioLevel === 'number') localLevel = Math.max(localLevel, s.audioLevel);
|
|
1059
1275
|
if (!withQuality) return;
|
|
1276
|
+
if (s.trackIdentifier && contentIds.has(s.trackIdentifier)) return;
|
|
1060
1277
|
if (s.type === 'inbound-rtp' && s.kind === 'video') {
|
|
1061
1278
|
const prev = tile._prevIn;
|
|
1062
1279
|
q.in = {
|
|
@@ -1106,7 +1323,7 @@ class Call {
|
|
|
1106
1323
|
this._selfSpeaking = speaking;
|
|
1107
1324
|
this.transport?.broadcast(this.stateMsg());
|
|
1108
1325
|
this.self?.setSpeaking(speaking);
|
|
1109
|
-
this.emit('speaking', { id: 'self', on: speaking });
|
|
1326
|
+
this.emit('speaking', { id: 'self', on: speaking, kind: 'camera' });
|
|
1110
1327
|
}
|
|
1111
1328
|
}
|
|
1112
1329
|
|
|
@@ -1256,11 +1473,16 @@ class Call {
|
|
|
1256
1473
|
c.classList.toggle('dxr-call-host--lobby', lobby);
|
|
1257
1474
|
show(ui.banner, this.o.chrome && !this.woven);
|
|
1258
1475
|
const n = this.tiles.size;
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1476
|
+
const nc = this.contentTiles.size;
|
|
1477
|
+
ui.grid.dataset.n = String(Math.min(n + nc, 4));
|
|
1478
|
+
ui.grid.dataset.content = nc ? '1' : '0';
|
|
1479
|
+
show(ui.grid, n + nc > 0 && this.state !== 'left');
|
|
1480
|
+
// Shared content is the main tile (it spans the row; the people form a strip under it).
|
|
1481
|
+
// Otherwise, speaker layout: the active speaker (or the first tile) spans the row. layout:'none' positions nothing.
|
|
1482
|
+
const contentMain = nc && this.o.layout !== 'none' ? [...this.contentTiles.values()][0] : null;
|
|
1483
|
+
const main = contentMain ? null : this.o.layout === 'speaker' ? this.speakerId || [...this.tiles.keys()][0] : null;
|
|
1263
1484
|
for (const [id, tile] of this.tiles) tile.el.classList.toggle('dxr-call-tile--main', id === main);
|
|
1485
|
+
for (const ct of this.contentTiles.values()) ct.el.classList.toggle('dxr-call-tile--main', ct === contentMain);
|
|
1264
1486
|
if (!this.o.chrome) return;
|
|
1265
1487
|
this._refreshBar();
|
|
1266
1488
|
queueMicrotask(() => this._fitGrid());
|
|
@@ -1323,6 +1545,9 @@ class Call {
|
|
|
1323
1545
|
hello: this.hello(),
|
|
1324
1546
|
transport: this.transport,
|
|
1325
1547
|
peers: [...this.tiles.values()].map((tile) => tile.diag()),
|
|
1548
|
+
// Content tiles (3D app share), by participant ('self' = the preview of your own share).
|
|
1549
|
+
content: [...this.contentTiles.values()].map((tile) => tile.diag()),
|
|
1550
|
+
sharing: this.contentInfo(),
|
|
1326
1551
|
// The self view's ACTUAL weave state (web#131): route, woven, the fallback reason and the
|
|
1327
1552
|
// tile's firstWoven result — null without a self view.
|
|
1328
1553
|
self: this.self ? this.self.diag() : null,
|
|
@@ -1397,8 +1622,19 @@ class Call {
|
|
|
1397
1622
|
retryCamera: () => call.retryCamera(),
|
|
1398
1623
|
setMono3D: (on) => call.setMono3D(on),
|
|
1399
1624
|
leave: () => call.leave('left'),
|
|
1400
|
-
/**
|
|
1401
|
-
|
|
1625
|
+
/**
|
|
1626
|
+
* A participant's tile element (for `layout: 'none'`), or null; `'self'` is your self view.
|
|
1627
|
+
* `kind: 'content'`: their shared-app tile instead (`'self'`: the preview of yours).
|
|
1628
|
+
*/
|
|
1629
|
+
tile: (peerId, kind = 'camera') =>
|
|
1630
|
+
kind === 'content' ? call.contentTiles.get(peerId)?.el || null : peerId === 'self' ? call.self?.el || null : call.tiles.get(peerId)?.el || null,
|
|
1631
|
+
/** 3D app share: share a track (a running app's views from openAppShare(), or any SBS track). */
|
|
1632
|
+
shareContent: (src, o) => call.shareContent(src, o),
|
|
1633
|
+
stopContent: (o) => call.stopContent(o),
|
|
1634
|
+
/** What you share right now, or null. */
|
|
1635
|
+
get content() {
|
|
1636
|
+
return call.contentInfo();
|
|
1637
|
+
},
|
|
1402
1638
|
/** Your display name (sent in `hello`), or null. */
|
|
1403
1639
|
get name() {
|
|
1404
1640
|
return call.name;
|
|
@@ -1470,9 +1706,9 @@ function observeBox(el, onBox) {
|
|
|
1470
1706
|
return () => ro.disconnect();
|
|
1471
1707
|
}
|
|
1472
1708
|
|
|
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) };
|
|
1709
|
+
/** The `weave` event payload of a settled `firstWoven` / `rewoven()`. `kind`: which tile of `id`. */
|
|
1710
|
+
function weaveEvent(id, res, kind = 'camera') {
|
|
1711
|
+
const ev = { id, state: res.woven ? 'woven' : 'flat', ms: Math.round(res.ms || 0), kind };
|
|
1476
1712
|
if (res.reason) ev.reason = res.reason;
|
|
1477
1713
|
return ev;
|
|
1478
1714
|
}
|
|
@@ -1517,9 +1753,17 @@ function paintFlat(canvas, video, { eyeHalf = null, mirror = false } = {}) {
|
|
|
1517
1753
|
// ── a remote participant ──────────────────────────────────────────────────────────────────
|
|
1518
1754
|
|
|
1519
1755
|
class Tile {
|
|
1520
|
-
|
|
1756
|
+
/**
|
|
1757
|
+
* @param {Call} call
|
|
1758
|
+
* @param {string} id the participant
|
|
1759
|
+
* @param {'camera'|'content'} [kind] `'content'`: a ContentTile (their shared app), see below
|
|
1760
|
+
*/
|
|
1761
|
+
constructor(call, id, kind = 'camera') {
|
|
1521
1762
|
this.call = call;
|
|
1522
1763
|
this.id = id;
|
|
1764
|
+
this.kind = kind;
|
|
1765
|
+
// What this participant shares (their `content` messages): { on, format, appName }.
|
|
1766
|
+
this.content = { on: false, format: null, appName: null };
|
|
1523
1767
|
this.hello = null;
|
|
1524
1768
|
this.format = null; // null until hello or its timeout
|
|
1525
1769
|
this.route = null;
|
|
@@ -1561,7 +1805,8 @@ class Tile {
|
|
|
1561
1805
|
this.liftFails = 0;
|
|
1562
1806
|
this.visible = true;
|
|
1563
1807
|
this.video = Object.assign(document.createElement('video'), { muted: true, playsInline: true, autoplay: true });
|
|
1564
|
-
|
|
1808
|
+
// A content tile is video only: the participant's voice stays on their camera tile.
|
|
1809
|
+
this.audio = kind === 'content' ? null : Object.assign(document.createElement('audio'), { autoplay: true });
|
|
1565
1810
|
this.video.addEventListener('playing', () => this._maybeRoute());
|
|
1566
1811
|
this.video.addEventListener('loadeddata', () => this._maybeRoute());
|
|
1567
1812
|
this.video.addEventListener('resize', () => this._maybeRoute());
|
|
@@ -1578,16 +1823,21 @@ class Tile {
|
|
|
1578
1823
|
this._rwP = null; // the rewoven() promise a `weave` event is waiting on
|
|
1579
1824
|
this._unobserve = observeBox(this.stage, (w, h) => this._onBox(w, h));
|
|
1580
1825
|
// `data-dxr-peer` is the documented hook (layout:'none', handle.tile()); `data-peer` is the 1.29 spelling.
|
|
1581
|
-
|
|
1826
|
+
// `data-dxr-kind`: 'camera' (the person) or 'content' (what they share) — tile(id, kind).
|
|
1827
|
+
const attrs = { class: `dxr-call-tile${kind === 'content' ? ' dxr-call-tile--content' : ''}`, part: 'tile', 'data-dxr-peer': id, 'data-dxr-kind': kind };
|
|
1828
|
+
if (kind !== 'content') attrs['data-peer'] = id;
|
|
1829
|
+
this.el = el('div', attrs, [this.stage, el('div', { class: 'dxr-call-talk' })]);
|
|
1582
1830
|
if (!call.o.ui) {
|
|
1583
1831
|
show(this.badge, false);
|
|
1584
1832
|
}
|
|
1585
|
-
|
|
1833
|
+
// Shared content goes FIRST in the grid (created there: a later move would be a teardown).
|
|
1834
|
+
if (kind === 'content') call.ui.grid.insertBefore(this.el, call.ui.grid.firstChild);
|
|
1835
|
+
else call.ui.grid.appendChild(this.el);
|
|
1586
1836
|
if (call.sinkId) this.applySink();
|
|
1587
1837
|
this._renderBadge();
|
|
1588
1838
|
/** The depth cursor (`cursor: 'depth'`) once its module has loaded; null otherwise. */
|
|
1589
1839
|
this.cursor = null;
|
|
1590
|
-
if (call.o.cursor) {
|
|
1840
|
+
if (call.o.cursor && kind !== 'content') {
|
|
1591
1841
|
call._pairCursorMod = call._pairCursorMod || import('./camera/pair-cursor.js');
|
|
1592
1842
|
call._pairCursorMod.then((m) => {
|
|
1593
1843
|
if (!this.destroyed) this.cursor = new m.PairCursor(this.canvas, call.o.cursor);
|
|
@@ -1611,6 +1861,8 @@ class Tile {
|
|
|
1611
1861
|
cameraOff: this.remote.cameraOff,
|
|
1612
1862
|
speaking: this.speaking,
|
|
1613
1863
|
name: (this.hello && this.hello.name) || null,
|
|
1864
|
+
// What they share (3D app share): `on`, its format and the app's name.
|
|
1865
|
+
content: Object.freeze({ ...this.content }),
|
|
1614
1866
|
});
|
|
1615
1867
|
}
|
|
1616
1868
|
|
|
@@ -1628,13 +1880,14 @@ class Tile {
|
|
|
1628
1880
|
p.then((res) => {
|
|
1629
1881
|
if (this._rwP !== p || this.handle !== handle || !res) return;
|
|
1630
1882
|
this._rwP = null;
|
|
1631
|
-
this.call.emit('weave', weaveEvent(this.id, res));
|
|
1883
|
+
this.call.emit('weave', weaveEvent(this.id, res, this.kind));
|
|
1632
1884
|
});
|
|
1633
1885
|
}
|
|
1634
1886
|
|
|
1635
1887
|
/** Play this participant's audio to the call's output device (`handle.setAudioOutput`). */
|
|
1636
1888
|
applySink() {
|
|
1637
1889
|
const a = this.audio;
|
|
1890
|
+
if (!a) return Promise.resolve(true); // a content tile has no audio of its own
|
|
1638
1891
|
if (typeof a.setSinkId !== 'function') return Promise.resolve(false);
|
|
1639
1892
|
return a.setSinkId(this.call.sinkId || '').then(
|
|
1640
1893
|
() => true,
|
|
@@ -1683,11 +1936,11 @@ class Tile {
|
|
|
1683
1936
|
setStream(stream) {
|
|
1684
1937
|
if (this.video.srcObject !== stream) {
|
|
1685
1938
|
this.video.srcObject = stream;
|
|
1686
|
-
this.audio.srcObject = stream;
|
|
1939
|
+
if (this.audio) this.audio.srcObject = stream;
|
|
1687
1940
|
this._resetAutoConverge(); // a new stream may be a new camera: measure afresh
|
|
1688
1941
|
}
|
|
1689
1942
|
this.video.play().catch(() => {});
|
|
1690
|
-
this.audio
|
|
1943
|
+
this.audio?.play().catch(() => {});
|
|
1691
1944
|
}
|
|
1692
1945
|
|
|
1693
1946
|
onChannelOpen() {
|
|
@@ -1752,7 +2005,7 @@ class Tile {
|
|
|
1752
2005
|
if (on === this.speaking) return;
|
|
1753
2006
|
this.speaking = on;
|
|
1754
2007
|
this.el.classList.toggle('dxr-call-tile--speaking', on);
|
|
1755
|
-
this.call.emit('speaking', { id: this.id, on });
|
|
2008
|
+
this.call.emit('speaking', { id: this.id, on, kind: this.kind });
|
|
1756
2009
|
}
|
|
1757
2010
|
|
|
1758
2011
|
setConn(st) {
|
|
@@ -1804,10 +2057,14 @@ class Tile {
|
|
|
1804
2057
|
reroute(force) {
|
|
1805
2058
|
const call = this.call;
|
|
1806
2059
|
const pool = call.liftPool;
|
|
2060
|
+
const content = this.kind === 'content';
|
|
1807
2061
|
const r =
|
|
1808
2062
|
this.forceFlat && this.format === 'sbs'
|
|
1809
2063
|
? { route: 'flat-left' }
|
|
1810
|
-
:
|
|
2064
|
+
: content
|
|
2065
|
+
? // Shared content: woven when it is a pair on a woven wall, else flat. Never lifted.
|
|
2066
|
+
{ route: this.format === 'sbs' ? (call.woven ? 'woven-sbs' : 'flat-left') : 'flat' }
|
|
2067
|
+
: wire.routeFor({
|
|
1811
2068
|
format: this.format,
|
|
1812
2069
|
woven: call.woven,
|
|
1813
2070
|
mono3D: call.mono3DOn ? 'auto' : 'off',
|
|
@@ -1816,9 +2073,9 @@ class Tile {
|
|
|
1816
2073
|
failed: this.liftFailed,
|
|
1817
2074
|
});
|
|
1818
2075
|
// First mono peer on a woven wall and lift not asked for yet (e.g. setMono3D(true) later).
|
|
1819
|
-
if (this.format !== 'sbs' && call.woven && call.mono3DOn && !call._liftP) call._ensureLift();
|
|
2076
|
+
if (!content && this.format !== 'sbs' && call.woven && call.mono3DOn && !call._liftP) call._ensureLift();
|
|
1820
2077
|
// A mono peer on a 3D display that stays flat because the lift MODULE is missing: say so.
|
|
1821
|
-
if (this.format !== 'sbs' && call.woven && call.liftMissing) call._warnLiftMissing();
|
|
2078
|
+
if (!content && this.format !== 'sbs' && call.woven && call.liftMissing) call._warnLiftMissing();
|
|
1822
2079
|
const key = `${r.route}|${this.format}`;
|
|
1823
2080
|
if (!force && key === this.routedKey) return;
|
|
1824
2081
|
this.routedKey = key;
|
|
@@ -1826,10 +2083,10 @@ class Tile {
|
|
|
1826
2083
|
this.route = r.route;
|
|
1827
2084
|
if (r.route === 'woven-sbs') this._registerWoven();
|
|
1828
2085
|
else if (r.route === 'lifted') this._registerLifted();
|
|
1829
|
-
call.log('route', { peer: this.id, route: r.route, format: this.format, mono3d: r.mono3d || null });
|
|
1830
|
-
call.emit('display', { id: this.id, display: this.display() });
|
|
2086
|
+
call.log('route', { peer: this.id, kind: this.kind, route: r.route, format: this.format, mono3d: r.mono3d || null });
|
|
2087
|
+
call.emit('display', { id: this.id, display: this.display(), kind: this.kind });
|
|
1831
2088
|
// 1.29's 'format' event (route / mono3d detail → diagnostics()): one release of compatibility.
|
|
1832
|
-
if (call.listeners.get('format')?.size) call.emit('format', { id: this.id, format: this.format, route: r.route, mono3d: r.mono3d || null, hello: this.hello });
|
|
2089
|
+
if (!content && call.listeners.get('format')?.size) call.emit('format', { id: this.id, format: this.format, route: r.route, mono3d: r.mono3d || null, hello: this.hello });
|
|
1833
2090
|
this._renderBadge();
|
|
1834
2091
|
}
|
|
1835
2092
|
|
|
@@ -1848,8 +2105,8 @@ class Tile {
|
|
|
1848
2105
|
if (this.handle !== handle) return;
|
|
1849
2106
|
this._fwSettled = true;
|
|
1850
2107
|
show(this.cover, false); // a cut, never a fade
|
|
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));
|
|
2108
|
+
call.log('first-woven', { peer: this.id, kind: this.kind, woven: res.woven, reason: res.reason, ms: Math.round(res.ms) });
|
|
2109
|
+
call.emit('weave', weaveEvent(this.id, res, this.kind));
|
|
1853
2110
|
if (!res.woven && res.reason === 'layer-failed') this._layerFailed();
|
|
1854
2111
|
});
|
|
1855
2112
|
}
|
|
@@ -2021,12 +2278,18 @@ class Tile {
|
|
|
2021
2278
|
c.height = outH;
|
|
2022
2279
|
}
|
|
2023
2280
|
const hover = !!(this.cursor && this.cursor.hovering);
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2281
|
+
if (this.kind === 'content') {
|
|
2282
|
+
// DISPLAY-REFERRED (3D app share): the sender's display plane sits at a known disparity, so
|
|
2283
|
+
// that IS the convergence — applied directly, never measured (the depth slider still adds).
|
|
2284
|
+
this.conv.measuredPx = this.contentFrac() * eyeW;
|
|
2285
|
+
} else {
|
|
2286
|
+
// The sender's measurement, when it sends one (recomputed per paint: a WebRTC resolution change
|
|
2287
|
+
// keeps it right, since it is a fraction of the eye width). Otherwise measure here, as before.
|
|
2288
|
+
const auto = this.call.o.autoConverge;
|
|
2289
|
+
const frac = auto ? this._senderFrac(performance.now()) : null;
|
|
2290
|
+
if (frac !== null) this.conv.measuredPx = frac * eyeW;
|
|
2291
|
+
if (hover || (auto && frac === null)) this._sampleDisparity(v, W, H, hover);
|
|
2292
|
+
}
|
|
2030
2293
|
const shift = this.conv.step(eyeW);
|
|
2031
2294
|
const g = c.getContext('2d');
|
|
2032
2295
|
const rects = [];
|
|
@@ -2123,9 +2386,71 @@ class Tile {
|
|
|
2123
2386
|
this._unobserve?.();
|
|
2124
2387
|
this._unobserve = null;
|
|
2125
2388
|
this.video.srcObject = null;
|
|
2126
|
-
this.audio.srcObject = null;
|
|
2389
|
+
if (this.audio) this.audio.srcObject = null;
|
|
2127
2390
|
this.el.remove();
|
|
2128
2391
|
}
|
|
2392
|
+
|
|
2393
|
+
/** Overridden by ContentTile; a camera tile has no known convergence. */
|
|
2394
|
+
contentFrac() {
|
|
2395
|
+
return 0;
|
|
2396
|
+
}
|
|
2397
|
+
}
|
|
2398
|
+
|
|
2399
|
+
// ── shared content (3D app share) ─────────────────────────────────────────────────────────
|
|
2400
|
+
|
|
2401
|
+
/**
|
|
2402
|
+
* What a participant shares (`shareContent`): a Tile with `kind: 'content'` — the same detached
|
|
2403
|
+
* <video>, woven canvas, cover, layer retries, session recovery and #172 re-registration as a
|
|
2404
|
+
* camera tile, but DISPLAY-REFERRED: `convergence.frac` from the `content` message is the shift
|
|
2405
|
+
* (half of it per eye, like a measured disparity), nothing is sampled, the sender's `conv` does not
|
|
2406
|
+
* apply, and only the depth slider adds to it. An unknown convergence is shown as sent (0). No
|
|
2407
|
+
* audio, no depth cursor, never lifted. `id: 'self'` is the optional preview of your own share.
|
|
2408
|
+
*/
|
|
2409
|
+
class ContentTile extends Tile {
|
|
2410
|
+
constructor(call, id) {
|
|
2411
|
+
super(call, id, 'content');
|
|
2412
|
+
this.desc = null;
|
|
2413
|
+
this.conn = 'connected';
|
|
2414
|
+
this._renderPlate();
|
|
2415
|
+
}
|
|
2416
|
+
|
|
2417
|
+
/** A `content` message (or this side's own descriptor, for the self preview). */
|
|
2418
|
+
setContent(cm) {
|
|
2419
|
+
const changed = this.format !== cm.format;
|
|
2420
|
+
this.desc = cm;
|
|
2421
|
+
this.format = cm.format;
|
|
2422
|
+
this.content = { on: true, format: cm.format, appName: cm.appName || null };
|
|
2423
|
+
if (changed) this._maybeRoute();
|
|
2424
|
+
this._renderBadge();
|
|
2425
|
+
}
|
|
2426
|
+
|
|
2427
|
+
contentFrac() {
|
|
2428
|
+
const c = this.desc && this.desc.convergence;
|
|
2429
|
+
return c && c.known && typeof c.frac === 'number' && Number.isFinite(c.frac) ? c.frac : 0;
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2432
|
+
_renderBadge() {
|
|
2433
|
+
super._renderBadge();
|
|
2434
|
+
const name = this.desc && this.desc.appName;
|
|
2435
|
+
if (name && this.route) this.badge.replaceChildren(el('b', { text: `${name} · ${this.display() === '3D' ? this.call.t('badge3D') : this.call.t('badge2D')}` }));
|
|
2436
|
+
}
|
|
2437
|
+
|
|
2438
|
+
diag() {
|
|
2439
|
+
return {
|
|
2440
|
+
id: this.id,
|
|
2441
|
+
kind: 'content',
|
|
2442
|
+
route: this.route,
|
|
2443
|
+
display: this.display(),
|
|
2444
|
+
format: this.format,
|
|
2445
|
+
appName: this.desc ? this.desc.appName : null,
|
|
2446
|
+
width: this.desc ? this.desc.width : null,
|
|
2447
|
+
height: this.desc ? this.desc.height : null,
|
|
2448
|
+
convergence: this.desc ? this.desc.convergence : null,
|
|
2449
|
+
convergencePx: this.conv.current,
|
|
2450
|
+
samples: this.autoGrabs, // always 0: content is never measured
|
|
2451
|
+
element: this.el,
|
|
2452
|
+
};
|
|
2453
|
+
}
|
|
2129
2454
|
}
|
|
2130
2455
|
|
|
2131
2456
|
// ── the self view ─────────────────────────────────────────────────────────────────────────
|
|
@@ -2208,7 +2533,7 @@ class SelfTile {
|
|
|
2208
2533
|
const fw = st.firstWoven;
|
|
2209
2534
|
let ev = null;
|
|
2210
2535
|
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 };
|
|
2536
|
+
else if (st.route !== 'woven-sbs' && st.reason) ev = { id: 'self', state: 'flat', reason: st.reason, ms: 0, kind: 'camera' };
|
|
2212
2537
|
if (ev) this.call.emit('weave', ev);
|
|
2213
2538
|
}
|
|
2214
2539
|
|