@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.
@@ -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) return tile.setHello(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
- ui.grid.dataset.n = String(Math.min(n, 4));
1260
- show(ui.grid, n > 0 && this.state !== 'left');
1261
- // Speaker layout: the active speaker (or the first tile) spans the row. layout:'none' positions nothing.
1262
- const main = this.o.layout === 'speaker' ? this.speakerId || [...this.tiles.keys()][0] : null;
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
- /** 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),
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
- constructor(call, id) {
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
- this.audio = Object.assign(document.createElement('audio'), { autoplay: true });
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
- 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' })]);
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
- call.ui.grid.appendChild(this.el);
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.play().catch(() => {});
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
- : wire.routeFor({
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
- // 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);
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