@displayxr/inline3d 1.23.0 → 1.25.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/js/inline3d.js CHANGED
@@ -130,6 +130,34 @@ const DISPLAY_MODE_METHODS = ['getDisplayInfo', 'getRenderingModes', 'requestRen
130
130
  // mode or hardware-state change even while its tile's layer is closed (lazy mode), and one
131
131
  // subscription covers every window in the document.
132
132
  const DISPLAY_EVENTS = ['renderingmodechange', 'hardwaredisplaystatechange'];
133
+ // TRACKING STATE — is anyone in the display's 3D zone right now?
134
+ //
135
+ // `session.trackingState` is a DOMString ('tracking' | 'searching' | 'unknown', filled in on every
136
+ // frame) and `trackingstatechange` is a PLAIN Event with no payload, so the state is always READ
137
+ // off the session and never parsed out of the event. It comes from the DisplayXR runtime, which
138
+ // may hand the decision to the vendor plug-in (DisplayXR Browser patch 0195).
139
+ //
140
+ // 'searching' means the runtime's derived isTracking went false: nobody is in the display's
141
+ // supported 3D zone, or the display is in an untracked / 2D mode. It does NOT necessarily mean the
142
+ // tracker lost a face that is still there, and a page must not word its UI as if it did.
143
+ //
144
+ // An older browser has NEITHER the attribute NOR the event. That is not warning-worthy — it is
145
+ // every release up to and including 1.0.5 — so the SDK reports 'unknown', silently, and every
146
+ // consumer of this surface treats 'unknown' as "no opinion".
147
+ const TRACKING_EVENT = 'trackingstatechange';
148
+ const TRACKING_STATES = ['unknown', 'tracking', 'searching'];
149
+ // Everything on()/off() accept. `onDisplayModeChange` deliberately still fans out over
150
+ // DISPLAY_EVENTS alone: pages pass it one callback shaped for a mode event.
151
+ const MANAGER_EVENTS = [...DISPLAY_EVENTS, TRACKING_EVENT];
152
+ /** This session's tracking state, or null when the browser does not expose one at all. */
153
+ function readTrackingState(session) {
154
+ try {
155
+ const v = session ? session.trackingState : undefined;
156
+ return TRACKING_STATES.includes(v) ? v : null;
157
+ } catch {
158
+ return null; // an attribute that refuses to be read is not a capability
159
+ }
160
+ }
133
161
  const hasDisplayModes = () => {
134
162
  if (!hasLayer()) return false;
135
163
  try {
@@ -514,7 +542,20 @@ function noteRigWinsOverHeight() {
514
542
  * keeps the eye set coherent either way — and a mode change the page did NOT request
515
543
  * (another tab, the shell, a panel opening flat) always snaps, because there is nothing
516
544
  * to ramp from. Read the live state on `wall.modeSwitch`.
517
- * @returns {Promise<Inline3D | {supported:false, error?:Error}>} the manager, which also carries
545
+ * @param {'none'|'mono'} [opts.untrackedFallback='none'] What the SDK does to the image and video
546
+ * windows it owns the pixels of while NOBODY IS TRACKED (`wall.trackingState ===
547
+ * 'searching'`). WHOSE JOB THIS IS depends on the display's eye-tracking mode (runtime
548
+ * spec docs/specs/vendor/eye-tracking-modes.md). MANAGED — the default, and Leia's — the
549
+ * vendor owns tracking loss: it eases the eyes together and only reports 'searching' once
550
+ * the display is already 2D, so leave this at `'none'` (a flatten here would be a second
551
+ * transition). MANUAL — the vendor does nothing and the app handles it: set `'mono'`.
552
+ * `'none'` (default) changes nothing. `'mono'` eases each image/video window to its LEFT
553
+ * EYE IN BOTH HALVES of its side-by-side buffer on 'searching' (still a valid pair for the
554
+ * layer; no buffer reallocation) and back on 'tracking', over the mode switch's duration;
555
+ * 'unknown' leaves it where it is. Scene windows are never touched: the page owns those
556
+ * pixels — listen for `trackingstatechange` and do the same for a scene.
557
+ * @returns {Promise<Inline3D | {supported:false, trackingState:'unknown', error?:Error}>} the
558
+ * manager, which also carries
518
559
  * the display API (`getDisplayInfo` / `getRenderingModes` / `requestRenderingMode` /
519
560
  * `setStereoEnabled`, `on`/`off`) and `undock` — `{model, splat}` on a browser with
520
561
  * `XRDisplayLayer.undock`, `null` on one without.
@@ -526,15 +567,18 @@ export async function createInline3D(opts = {}) {
526
567
  rootMargin = '50% 0px',
527
568
  autoChrome = true,
528
569
  modeSwitch = null,
570
+ untrackedFallback = 'none',
529
571
  } = opts;
530
- if (!inline3DAvailable()) return { supported: false };
572
+ // The unsupported shapes carry `trackingState` too, so a page can read it without branching on
573
+ // `supported` first. There is no on() here to pair with it: nothing would ever fire.
574
+ if (!inline3DAvailable()) return { supported: false, trackingState: 'unknown' };
531
575
  let session;
532
576
  try {
533
577
  // requestSession is Blink-local and resolves immediately when the feature is present —
534
578
  // the correct detection path (see inline3DAvailable's note on isSessionSupported).
535
579
  session = await navigator.xr.requestSession('inline-3d');
536
580
  } catch (e) {
537
- return { supported: false, error: e };
581
+ return { supported: false, trackingState: 'unknown', error: e };
538
582
  }
539
583
  let refSpace = null;
540
584
  try {
@@ -542,7 +586,7 @@ export async function createInline3D(opts = {}) {
542
586
  } catch {
543
587
  /* rAF still fires without a ref space; views are just null (fine for image/video). */
544
588
  }
545
- return new Inline3D(session, refSpace, { lazy, rootMargin, autoChrome, modeSwitch });
589
+ return new Inline3D(session, refSpace, { lazy, rootMargin, autoChrome, modeSwitch, untrackedFallback });
546
590
  }
547
591
 
548
592
  /**
@@ -594,7 +638,11 @@ function chromeTextPlates(root) {
594
638
  }
595
639
 
596
640
  class Inline3D {
597
- constructor(session, refSpace, { lazy, rootMargin, autoChrome = true, modeSwitch = null }) {
641
+ constructor(
642
+ session,
643
+ refSpace,
644
+ { lazy, rootMargin, autoChrome = true, modeSwitch = null, untrackedFallback = 'none' }
645
+ ) {
598
646
  this.supported = true;
599
647
  this.session = session;
600
648
  this.refSpace = refSpace;
@@ -651,6 +699,25 @@ class Inline3D {
651
699
  this._msArmedUp = false; // a ->3D request went out; the up-ramp waits for the panel to say 3D
652
700
  this._msLastMs = null; // wall clock of the previous advance (null = the ramp has not ticked)
653
701
  this._msTick = null; // the frames-stopped fallback timer; see _armModeSwitchTick
702
+ // ── tracking state (see TRACKING_EVENT) ───────────────────────────────────────────
703
+ // Read once here so `wall.trackingState` is honest before any event arrives. A page can open
704
+ // with nobody in front of the display, and that is a starting value, not a CHANGE, so nothing
705
+ // is emitted for it. A browser without the attribute reads null and stays 'unknown'.
706
+ this._trackingSupported = readTrackingState(session) !== null;
707
+ this._trackingState = this._trackingSupported ? readTrackingState(session) : 'unknown';
708
+ // What to do to image/video windows while nobody is tracked. 'none' is the default so every
709
+ // page that predates the option renders bit-identically (and MANAGED displays need nothing).
710
+ this._untrackedFallback = untrackedFallback === 'mono' ? 'mono' : 'none';
711
+ // ── untrackedFallback:'mono' — baked stereo eased to flat (see _trackBakedStereo) ───────
712
+ // An image/video tile's stereo is in its source pixels, so neither the rig collapse above nor
713
+ // the runtime's own easing of the views can reach it. 0..1 = how much of that baked disparity
714
+ // to show, eased toward the target the tracking state sets, over the mode switch's duration.
715
+ this._bakedStereo = 1;
716
+ this._bakedTarget = 1;
717
+ this._bakedAt = null; // frame time (ms) of the last ease step
718
+ this._bakedEaseS =
719
+ (Number.isFinite(msOpts.durationMs) ? Math.max(0, msOpts.durationMs) : MODE_SWITCH_DEFAULT_DURATION_MS) / 1000;
720
+ this._applyUntrackedTarget(); // a page that opens with nobody tracked starts heading flat
654
721
  // Undock capabilities, refreshed off the first live layer (see _refreshUndock). Null is the
655
722
  // load-bearing value: it means this browser has no XRDisplayLayer.undock at all.
656
723
  this.undock = hasUndock() ? { model: false, splat: false } : null;
@@ -680,6 +747,7 @@ class Inline3D {
680
747
  liveManager = this;
681
748
  session.addEventListener('end', () => this._teardown());
682
749
  this._bindDisplayEvents();
750
+ this._bindTrackingEvents();
683
751
  this._scanChrome(); // page chrome usually exists before the session does
684
752
  this._bindLifecycle();
685
753
  this._armDprWatch();
@@ -901,6 +969,7 @@ class Inline3D {
901
969
  * 3D per-pixel, so exclude()/unexclude() are stored-and-ignored (see _applyExclusion).
902
970
  */
903
971
  _handle(canvas, win) {
972
+ const mgr = this; // for the getters below: an object-literal getter's `this` is the handle
904
973
  return {
905
974
  remove: () => this._remove(canvas),
906
975
  exclude: (el) => {
@@ -940,6 +1009,10 @@ class Inline3D {
940
1009
  * asked for. During the eased 2D<->3D transition the same applies with a FRACTION in place
941
1010
  * of the 0: what leaves for the layer is your rig scaled by `wall.modeSwitch.factor`.
942
1011
  */
1012
+ /** The manager's `trackingState`, on the tile handle too ('tracking' | 'searching' | 'unknown'). */
1013
+ get trackingState() {
1014
+ return mgr._trackingState;
1015
+ },
943
1016
  setViewRig: (rig) => {
944
1017
  win.viewRig = rig || null;
945
1018
  return this._pushViewRig(win);
@@ -1136,6 +1209,22 @@ class Inline3D {
1136
1209
  * The hardware display state as last REPORTED by `hardwaredisplaystatechange` — `'2d'`,
1137
1210
  * `'3d'`, or `null` when the browser has not said yet. Never what was last requested.
1138
1211
  */
1212
+ /**
1213
+ * Is anyone being tracked in front of this display right now?
1214
+ *
1215
+ * `'tracking'` a viewer is in the display's 3D zone.
1216
+ * `'searching'` the runtime's derived isTracking is FALSE — nobody is in the zone, or the
1217
+ * display is in an untracked / 2D mode. NOT "the tracker glitched".
1218
+ * `'unknown'` this browser has no tracking-state surface, or the session has ended.
1219
+ *
1220
+ * Mirrors `session.trackingState` as last read. Changes arrive as
1221
+ * `on('trackingstatechange', (state, ev) => …)`. Pages use it for "step back into view" hints;
1222
+ * `untrackedFallback: 'mono'` uses it to flatten image/video windows.
1223
+ */
1224
+ get trackingState() {
1225
+ return this._trackingState;
1226
+ }
1227
+
1139
1228
  get hardwareDisplayState() {
1140
1229
  return this._hardwareDisplayState;
1141
1230
  }
@@ -1711,13 +1800,20 @@ class Inline3D {
1711
1800
  });
1712
1801
  }
1713
1802
 
1714
- /** Deliver one normalised display event to every listener. A throwing page handler is contained. */
1715
- _emitDisplay(ev) {
1803
+ /**
1804
+ * Deliver one normalised event to every listener. A throwing page handler is contained.
1805
+ *
1806
+ * `args` overrides what the callback is CALLED with (the event object still selects the listener
1807
+ * set): `trackingstatechange` leads with the state string, because a page listening to it wants
1808
+ * the one value and not a wrapper.
1809
+ */
1810
+ _emitDisplay(ev, args) {
1716
1811
  const set = this._displayListeners.get(ev.type);
1717
1812
  if (!set) return;
1813
+ const argv = args || [ev];
1718
1814
  for (const cb of [...set]) {
1719
1815
  try {
1720
- cb(ev);
1816
+ cb(...argv);
1721
1817
  } catch (err) {
1722
1818
  console.error(`[inline3d] ${ev.type} listener threw`, err);
1723
1819
  }
@@ -1725,14 +1821,18 @@ class Inline3D {
1725
1821
  }
1726
1822
 
1727
1823
  /**
1728
- * Listen for `renderingmodechange` / `hardwaredisplaystatechange` on this manager. See the
1729
- * handle's `on()` doc for the payload shapes. Returns an unsubscribe function.
1824
+ * Listen for `renderingmodechange` / `hardwaredisplaystatechange` / `trackingstatechange` on
1825
+ * this manager. See the handle's `on()` doc for the payload shapes — the tracking callback is
1826
+ * handed the STATE STRING first: `(state, { type, state })`. Returns an unsubscribe function.
1827
+ *
1828
+ * Subscribing to `trackingstatechange` on a browser with no tracking state is legal and silent:
1829
+ * the listener is kept, nothing fires it, and `trackingState` stays 'unknown'.
1730
1830
  */
1731
1831
  on(type, cb) {
1732
1832
  if (typeof cb !== 'function') throw new TypeError('[inline3d] on() takes (type, function).');
1733
- if (!DISPLAY_EVENTS.includes(type)) {
1833
+ if (!MANAGER_EVENTS.includes(type)) {
1734
1834
  throw new TypeError(
1735
- `[inline3d] on() knows ${DISPLAY_EVENTS.join(' / ')}, got ${JSON.stringify(type)}.`
1835
+ `[inline3d] on() knows ${MANAGER_EVENTS.join(' / ')}, got ${JSON.stringify(type)}.`
1736
1836
  );
1737
1837
  }
1738
1838
  let set = this._displayListeners.get(type);
@@ -2380,6 +2480,17 @@ class Inline3D {
2380
2480
  return;
2381
2481
  }
2382
2482
  const halfDst = c.width / 2;
2483
+ // How much of the source's baked disparity to show: the eased 2D<->3D ramp (the mode) and
2484
+ // the runtime's tracking state (_trackBakedStereo), whichever is flatter. Below 1 the RIGHT half becomes
2485
+ // the left eye with the right eye blended over it, so 0 is the left eye in both halves —
2486
+ // flat, and still a valid SBS pair for the live layer (a 1:1 mono buffer here would be woven
2487
+ // as a squeezed pair). See _trackBakedStereo.
2488
+ const stereo = Math.min(this._stereoFactor, this._bakedStereo);
2489
+ if (stereo < 1) {
2490
+ drawEye(ctx, src, 0, 0, srcW / 2, srcH, 0, 0, halfDst, c.height, win.cornerRadius, win.feather); // L
2491
+ drawEyeBlend(ctx, src, srcW / 2, srcH, halfDst, c.height, stereo, win.cornerRadius, win.feather); // L+R
2492
+ return;
2493
+ }
2383
2494
  // A single stretched draw maps SBS source → SBS buffer (left→left, right→right); the
2384
2495
  // per-eye path is only needed to bake decoration (rounded corners / edge feather), which
2385
2496
  // MUST be applied to each eye separately — see drawEye/featherEye.
@@ -2391,6 +2502,68 @@ class Inline3D {
2391
2502
  }
2392
2503
  }
2393
2504
 
2505
+ /**
2506
+ * Subscribe to `trackingstatechange`, once per manager, and only where the ATTRIBUTE exists: the
2507
+ * event is payload-free, so a browser that fired it without `session.trackingState` would leave
2508
+ * nothing to read. Independent of `_bindDisplayEvents` — a browser may ship them in either order.
2509
+ */
2510
+ _bindTrackingEvents() {
2511
+ const session = this.session;
2512
+ if (!session || typeof session.addEventListener !== 'function') return;
2513
+ if (!this._trackingSupported) return;
2514
+ session.addEventListener(TRACKING_EVENT, () => {
2515
+ this._setTrackingState(readTrackingState(session) || 'unknown'); // READ, never parse
2516
+ });
2517
+ }
2518
+
2519
+ /**
2520
+ * Adopt a new tracking state: act on it first (the fallback target, so a listener sees the state
2521
+ * it is told about already in effect), then tell the page. On CHANGE only — a repeated state is
2522
+ * not an event, and the value read at construction is a starting value, not a change.
2523
+ */
2524
+ _setTrackingState(state) {
2525
+ const next = TRACKING_STATES.includes(state) ? state : 'unknown';
2526
+ if (next === this._trackingState) return;
2527
+ this._trackingState = next;
2528
+ this._applyUntrackedTarget();
2529
+ const ev = { type: TRACKING_EVENT, state: next };
2530
+ this._emitDisplay(ev, [next, ev]);
2531
+ }
2532
+
2533
+ /**
2534
+ * Where baked stereo is heading under `untrackedFallback`. `'none'`: always the full pair.
2535
+ * `'mono'`: `'searching'` heads to flat, `'tracking'` back; `'unknown'` (no display yet, the
2536
+ * first frame, a session that ended) leaves it where it is — never read as flat.
2537
+ */
2538
+ _applyUntrackedTarget() {
2539
+ if (this._untrackedFallback !== 'mono') {
2540
+ this._bakedTarget = 1;
2541
+ return;
2542
+ }
2543
+ if (this._trackingState === 'searching') this._bakedTarget = 0;
2544
+ else if (this._trackingState === 'tracking') this._bakedTarget = 1;
2545
+ }
2546
+
2547
+ /**
2548
+ * One frame of the baked-stereo ease (untrackedFallback:'mono'): toward the target the tracking
2549
+ * state set, at a rate that
2550
+ * crosses 0..1 in the mode switch's duration, so an untracked flatten looks like a page-requested
2551
+ * 2D switch. Scene tiles are not involved — they render from the views, which the runtime
2552
+ * eases itself.
2553
+ */
2554
+ _trackBakedStereo(t) {
2555
+ // `trackingState` is filled in on every frame; reading it here as well as on the event keeps
2556
+ // the state right even if an event was coalesced away. Emits only on a real change.
2557
+ if (this._trackingSupported) this._setTrackingState(readTrackingState(this.session) || 'unknown');
2558
+ const dt = this._bakedAt === null || !Number.isFinite(t) ? 0 : Math.max(0, (t - this._bakedAt) / 1000);
2559
+ this._bakedAt = Number.isFinite(t) ? t : this._bakedAt;
2560
+ const cur = this._bakedStereo;
2561
+ const tgt = this._bakedTarget;
2562
+ if (cur === tgt) return;
2563
+ const step = this._bakedEaseS > 0 ? dt / this._bakedEaseS : 1;
2564
+ this._bakedStereo = tgt > cur ? Math.min(tgt, cur + step) : Math.max(tgt, cur - step);
2565
+ }
2566
+
2394
2567
  /**
2395
2568
  * Re-commit the pixels the canvas already holds, unchanged — the cheapest "last decoded frame"
2396
2569
  * there is, because the last decoded frame is already in the backing store.
@@ -2447,6 +2620,7 @@ class Inline3D {
2447
2620
  this._advanceModeSwitch();
2448
2621
  const pose = this.refSpace ? f.getViewerPose(this.refSpace) : null;
2449
2622
  const views = pose ? pose.views : null;
2623
+ this._trackBakedStereo(t);
2450
2624
  for (const win of this._windows.values()) {
2451
2625
  if (!win.layer) continue;
2452
2626
  if (win.kind === 'scene') {
@@ -2669,6 +2843,10 @@ class Inline3D {
2669
2843
  this._settleFirstWoven(win, false, 'session-ended');
2670
2844
  }
2671
2845
  this._windows.clear();
2846
+ // Nobody is tracked through a session that has ended: say so ONCE, before the listeners are
2847
+ // dropped, so a page that gated its UI on 'tracking' is released rather than left latched. A
2848
+ // browser that never reported one is already 'unknown' and this is silent.
2849
+ this._setTrackingState('unknown');
2672
2850
  // Page listeners go with the session that fed them: a manager whose session has ended will
2673
2851
  // never emit again, and holding the callbacks would keep the page's closures alive.
2674
2852
  this._displayListeners.clear();
@@ -2711,6 +2889,24 @@ function drawEye(ctx, src, sx, sy, sw, sh, dx, dy, dw, dh, radius, feather) {
2711
2889
  }
2712
2890
  }
2713
2891
 
2892
+ // The right half of a partly flattened SBS paint: the LEFT eye, with the right eye over it at
2893
+ // `amount` (0 = left only). Clipped and feathered once, as one eye, for the same reason drawEye is.
2894
+ function drawEyeBlend(ctx, src, halfSrc, srcH, dx, dh, amount, radius, feather) {
2895
+ ctx.save();
2896
+ if (radius > 0 && ctx.roundRect) {
2897
+ ctx.beginPath();
2898
+ ctx.roundRect(dx, 0, dx, dh, radius);
2899
+ ctx.clip();
2900
+ }
2901
+ ctx.drawImage(src, 0, 0, halfSrc, srcH, dx, 0, dx, dh);
2902
+ if (amount > 0) {
2903
+ ctx.globalAlpha = amount;
2904
+ ctx.drawImage(src, halfSrc, 0, halfSrc, srcH, dx, 0, dx, dh);
2905
+ }
2906
+ ctx.restore();
2907
+ if (feather > 0) featherEye(ctx, dx, 0, dx, dh, feather);
2908
+ }
2909
+
2714
2910
  // Fade this EYE's outer edges to transparent, so the 3D window dissolves into the page
2715
2911
  // instead of ending at a hard rectangle. Same spirit as the runtime feathering a 3D zone's
2716
2912
  // edge — but note that is the hardware WISH MASK (it drives the hardware display state, never
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@displayxr/inline3d",
3
- "version": "1.23.0",
3
+ "version": "1.25.0",
4
4
  "description": "Turn any HTML <canvas> into a glasses-free-3D window on a DisplayXR display, inside an ordinary web page. Dependency-free; progressive enhancement (falls back to plain 2D everywhere else).",
5
5
  "type": "module",
6
6
  "types": "./index.d.ts",
@@ -32,6 +32,10 @@
32
32
  "import": "./js/inline3d-model.js"
33
33
  },
34
34
  "./package.json": "./package.json",
35
+ "./player": {
36
+ "types": "./player.d.ts",
37
+ "import": "./js/inline3d-player.js"
38
+ },
35
39
  "./undock": {
36
40
  "types": "./index.d.ts",
37
41
  "import": "./js/inline3d-undock.js"
@@ -55,16 +59,19 @@
55
59
  "js/inline3d-splat-diag.js",
56
60
  "js/inline3d-splat-video.js",
57
61
  "js/inline3d-splat-layer-rig.js",
62
+ "js/inline3d-splat-rig-map.js",
58
63
  "js/inline3d-model.js",
59
64
  "js/inline3d-model-entry.js",
60
65
  "js/inline3d-model-playcanvas.js",
61
66
  "js/inline3d-pc-look.js",
62
67
  "js/inline3d-playcanvas-engine.js",
68
+ "js/inline3d-player.js",
63
69
  "index.d.ts",
64
70
  "three.d.ts",
65
71
  "viewer.d.ts",
66
72
  "splat.d.ts",
67
73
  "model.d.ts",
74
+ "player.d.ts",
68
75
  "CHANGELOG.md",
69
76
  "README.md",
70
77
  "LICENSE",
package/player.d.ts ADDED
@@ -0,0 +1,273 @@
1
+ // Type definitions for @displayxr/inline3d/player.
2
+ // PREVIEW tier — not covered by the 1.x semver promise. See docs/sdk-stability.md.
3
+ //
4
+ // v1 slice of docs/rfcs/0001-media-player.md. Narrower than the RFC's full plan — no `'tb'`/
5
+ // `'auto'` format, no sidecar/filename format detection, no `opts.group` playback policy, no
6
+ // `opts.fit`/letterboxing — see the report that shipped this file for the reasoning. Widening
7
+ // any of those later is additive (new optional fields), so this stays forward-compatible.
8
+
9
+ /** `'sbs'` side-by-side, `'tb'` top/bottom (left eye on top) — ./splat setVideo's names — or `'mono'`. */
10
+ export type PlayerFormat = 'sbs' | 'tb' | 'mono';
11
+
12
+ /** A source: a URL or Blob, or candidates best-first for {@link pickSource}. */
13
+ export type PlayerSource = string | Blob | Array<string | Blob | { src: string | Blob; type?: string }>;
14
+ export type PlayerControls = 'sdk' | 'none';
15
+
16
+ export interface PlayerOptions {
17
+ /**
18
+ * `'sbs'` (default) is a real stereo pair, woven via `wall.addVideo()` — every fallback
19
+ * `addVideo` already has (including the browser-unsupported / off-screen-lazy one) applies for
20
+ * free. `'mono'` is genuinely flat content: painted full-frame, never split into eyes, on a
21
+ * small paint loop this module owns (NOT the same code path as an unsupported wall — see
22
+ * js/inline3d-player.js's module doc comment).
23
+ */
24
+ format?: PlayerFormat;
25
+ /** Painted before the first frame, and again on the video's `error` event. */
26
+ poster?: string;
27
+ autoplay?: boolean;
28
+ /** Default true — required for autoplay in every browser that has one. */
29
+ muted?: boolean;
30
+ loop?: boolean;
31
+ /** Default `'sdk'`. `'none'` leaves chrome to the page (handle + events + its own overlays). */
32
+ controls?: PlayerControls;
33
+ /**
34
+ * Space/K play-pause, ←/→ seek ±5 s, J/L seek ±10 s, M mute. Bound to the canvas and its
35
+ * transport, never `document`, so a page with several players drives only the focused one.
36
+ * Default true.
37
+ */
38
+ keyboard?: boolean;
39
+ /**
40
+ * What `setSource()` does by default — the `./splat` vocabulary, the part a video can mean.
41
+ * `'crossfade'` is opt-in HERE: it creates the mixer canvas that runs it (one extra full-frame
42
+ * draw per painted frame), so a player that never asks keeps the byte-identical
43
+ * `addVideo(canvas, video)` paint path and pays nothing. Default `'cut'`.
44
+ *
45
+ * The crossfade runs from the outgoing title's LAST FRAME to the incoming one (`./splat`'s
46
+ * `outgoing: 'frozen'`) — not a blend of two decoding streams. On an SBS video that is not
47
+ * the "tracking paused" look it is on a splat: the frame's disparity is baked in either way.
48
+ * `./splat`'s other transitions (`flip`, `wavefront`, particles, sequences) move a photo's
49
+ * gaussians and are refused by name.
50
+ */
51
+ transition?: PlayerTransition;
52
+ /** The crossfade's length, ms. Default 600. */
53
+ durationMs?: number;
54
+ /** A `./splat` easing name, or `(x) => y` on [0, 1]. Default `'easeInOutSine'`. */
55
+ easing?: PlayerEasing;
56
+ /** LEGACY (the 1.10 spelling, kept as `./splat` keeps it): `> 0` = `transition: 'crossfade'` of this length. */
57
+ fadeMs?: number;
58
+ /**
59
+ * Accent colour for the transport — a named preset (`'azure'` default, `'violet'`, `'magenta'`,
60
+ * `'sunset'`, `'amber'`, `'lime'`, `'mint'`, `'ice'`) or any CSS colour. Written to the
61
+ * `--dxr-accent` custom property on the host, so it re-skins the scrub fill, the knob, the play
62
+ * orb, the focus rings and the spinner in one value.
63
+ */
64
+ accent?: PlayerAccent | (string & {});
65
+ /**
66
+ * Show a small pill in the control row (`true` → "3D", or your own short string). Opt-in and
67
+ * page-driven on purpose: the module will NOT infer it from `wall.supported`, because a
68
+ * supported wall whose tile is scrolled out of view — or whose panel is in a 2D mode — is not
69
+ * showing 3D at that moment, and a badge that says otherwise is worse than no badge.
70
+ */
71
+ badge3d?: boolean | string;
72
+ /**
73
+ * The transport's look: `'classic'` (default) — a full-width bottom band; `'dock'` — a floating
74
+ * rounded dock with lit round buttons and an accent orb for play.
75
+ * `'bars'` — one row inside the bottom black bar: back / play / elapsed / scrub / remaining.
76
+ * `'call'` — the name and a timer bar on top, the controls at the bottom, and a 2.39:1 `band`
77
+ * by default. With a `band`, every skin puts its controls INSIDE the black bars when the bottom
78
+ * bar is tall enough, and nothing sits over the picture. Same controls, CSS only. Both
79
+ * keep every glow inside the overlay's own box (a shadow outside it would be woven).
80
+ */
81
+ skin?: 'classic' | 'dock' | 'bars' | 'call';
82
+ /**
83
+ * How an eye image meets a tile of a different aspect — the same names and meaning as `./splat`
84
+ * `setVideo`'s `fit`. `'contain'`: the whole eye image, transparent bars where the aspects
85
+ * differ (the page shows through). `'cover'`: the tile is full and the overflow is cut. Unset:
86
+ * stretched to the tile (the 1.x behaviour). On the woven path it costs one extra draw per frame.
87
+ */
88
+ fit?: 'contain' | 'cover';
89
+ /**
90
+ * A letterbox "band" slot: the picture is fitted into a centred band of this aspect inside the
91
+ * tile (e.g. `2.39` or `'2.39:1'` for a scope band in a 16:9 tile); the rest stays clear.
92
+ * Implies `fit: 'contain'` unless `fit` says otherwise.
93
+ */
94
+ band?: number | string;
95
+ /**
96
+ * What the poster image is: `'mono'` (one image for both eyes, the default), or a stereo still
97
+ * laid out like the video (`'sbs'` / `'tb'`), painted eye by eye so it is 3D before the first frame.
98
+ */
99
+ posterFormat?: 'mono' | 'sbs' | 'tb';
100
+ /**
101
+ * A playlist. With `titles` and no `src`, the first title loads. Entries may be bare sources.
102
+ * Drive it with `play(id)`, `next()`, `back()`; `'titlechange'` fires on each switch.
103
+ */
104
+ titles?: ReadonlyArray<PlayerSource | { id?: string; src: PlayerSource; title?: string; poster?: string }>;
105
+ /** `next()` past the last title wraps to the first (and `back()` from the first to the last). Default false. */
106
+ loopList?: boolean;
107
+ /** A title that ends moves on to the next one and plays it. Default false. */
108
+ autoAdvance?: boolean;
109
+ /**
110
+ * Players sharing a group decode one at a time: starting one pauses every other player in the
111
+ * group. For a shelf of previews that each play muted and looped. Any string; unset = no group.
112
+ */
113
+ group?: string;
114
+ /** Transport scale — `'s'` (0.84×), `'m'` (default), `'l'` (1.28×). Icons, fonts and hit targets scale together. */
115
+ size?: 's' | 'm' | 'l';
116
+ /** A now-playing line over the top of the tile, fading with the transport. `setSource(src, { title })` changes it. */
117
+ title?: string;
118
+ /** −10 s / +10 s buttons beside play. Default true (hidden on tiles narrower than 420 px). J / L work either way. */
119
+ skipButtons?: boolean;
120
+ /**
121
+ * A fullscreen button (and the F key). The canvas's CONTAINER goes fullscreen, so the transport
122
+ * comes with it and the tile letterboxes on black. Default true; absent where the browser has no
123
+ * Fullscreen API.
124
+ */
125
+ fullscreen?: boolean;
126
+ /** Default: `'anonymous'` iff `src` is a cross-origin URL; unset (browser default) otherwise. */
127
+ crossOrigin?: 'anonymous' | 'use-credentials';
128
+ /** Per-eye buffer resolution in px. `'sbs'` + supported wall only — see {@link TileOptions}. */
129
+ width?: number;
130
+ height?: number;
131
+ /** `'sbs'` + supported wall only. */
132
+ cornerRadius?: number;
133
+ feather?: number;
134
+ /** Element whose visibility gates the lazy create/close lifecycle (`'sbs'` path only). */
135
+ observe?: Element;
136
+ }
137
+
138
+ export type PlayerEvent = 'play' | 'pause' | 'ended' | 'timeupdate' | 'ready' | 'error' | 'titlechange';
139
+
140
+ /** One playlist entry (RFC 0001 Addendum A4). */
141
+ export interface PlayerTitle {
142
+ /** Stable id for `play(id)`; defaults to the entry's index as a string. */
143
+ readonly id: string;
144
+ readonly src: PlayerSource;
145
+ /** Shown in the title line while this title plays. */
146
+ readonly title?: string;
147
+ readonly poster?: string;
148
+ }
149
+
150
+ /** What {@link addPlayer} returns. Deliberately the `<video>` element's own vocabulary. */
151
+ export interface PlayerHandle {
152
+ /** The hidden `<video>` this handle owns — escape hatch for anything not covered above. */
153
+ readonly video: HTMLVideoElement;
154
+
155
+ /** Resume; or, with an id (or an index into `titles`), switch to that title and play it. */
156
+ play(id?: string | number): Promise<void>;
157
+ pause(): void;
158
+ /** Play if paused or ended, else pause. */
159
+ toggle(): Promise<void>;
160
+ /** The playlist, read-only. */
161
+ readonly titles: ReadonlyArray<PlayerTitle>;
162
+ /** The title playing now, or null (no playlist, or a source that is not one of its titles). */
163
+ readonly current: PlayerTitle | null;
164
+ /** Replace the playlist; the current title stays current if the new list has its id. */
165
+ setTitles(list: PlayerOptions['titles']): void;
166
+ /** The next title, playing. At the end: nothing, unless `loopList`. */
167
+ next(): Promise<void>;
168
+ /** A remote's "previous": restart if more than 3 s in, else the previous title. */
169
+ back(): Promise<void>;
170
+ /** Clamped to `[0, duration]` (or `[0, t]` before `duration` is known). */
171
+ seek(t: number): void;
172
+ currentTime: number;
173
+ readonly duration: number;
174
+ readonly paused: boolean;
175
+ readonly ended: boolean;
176
+ volume: number;
177
+ muted: boolean;
178
+
179
+ /**
180
+ * Swap the source in place (and its poster, if given). Each field of `opts` overrides the
181
+ * player's own for this one swap — `{ durationMs: 1200 }` lengthens its crossfade,
182
+ * `{ transition: 'cut' }` skips it. A crossfade cannot be switched ON here for a player built
183
+ * with `transition: 'cut'`: the mixer would have to become the woven window's paint source
184
+ * mid-flight, which rebuilds the weave layer (the blink a crossfade removes). That swap is a
185
+ * cut, and a console warning says so once.
186
+ *
187
+ * With a cut the old frame holds until the new source reaches `readyState >= 2`. Throws, and
188
+ * changes nothing, on an unknown transition or easing.
189
+ */
190
+ setSource(src: PlayerSource, opts?: PlayerSourceOptions): void;
191
+
192
+ /**
193
+ * Re-skin the SDK transport live — any of `accent` (a CSS colour; `''` = the default), `size`,
194
+ * `skin`. Invalid values warn and are ignored; a no-op with `controls: 'none'`.
195
+ */
196
+ setAppearance(a: { accent?: PlayerAccent | (string & {}); size?: 's' | 'm' | 'l'; skin?: 'classic' | 'dock' | 'bars' | 'call' }): void;
197
+
198
+ /** Mark a 2D element painted over this window so the weave leaves it crisp — `'sbs'` + a
199
+ * supported wall only; a no-op everywhere else (there is no weave to protect it from). */
200
+ exclude(el: Element): void;
201
+ unexclude(el: Element): void;
202
+
203
+ /** Stop the paint loop / weave window, tear down the transport, release the `<video>`. */
204
+ remove(): void;
205
+
206
+ /** Returns an unsubscribe function, like {@link Inline3D.on}. */
207
+ on(event: PlayerEvent, fn: (payload?: unknown) => void): () => void;
208
+ off(event: PlayerEvent, fn: (payload?: unknown) => void): void;
209
+ }
210
+
211
+ /**
212
+ * Load a media title into an inline-3D window with real transport, in one call. Safe with a
213
+ * `null`/unsupported `wall` — it renders flat 2D instead, so pages need no branch.
214
+ * @param wall the manager from `createInline3D()`, or null/unsupported.
215
+ * @param canvas a 2D canvas ALREADY inside a container element — `controls:'sdk'` attaches the
216
+ * transport as a sibling of the canvas, inside `canvas.parentElement`.
217
+ * @param src the video URL, or a Blob/File (an object URL is created for you).
218
+ */
219
+ export function addPlayer(
220
+ wall: object | null | undefined,
221
+ canvas: HTMLCanvasElement,
222
+ /** `null` with `opts.titles` loads the first title. */
223
+ src: PlayerSource | null,
224
+ opts?: PlayerOptions,
225
+ ): PlayerHandle;
226
+
227
+ /** The `setSource()` transitions a video can mean. */
228
+ export type PlayerTransition = 'cut' | 'crossfade';
229
+
230
+ /** `./splat`'s easing names (the same curves), or a function on [0, 1]. */
231
+ export type PlayerEasing =
232
+ | 'linear'
233
+ | 'easeInQuad'
234
+ | 'easeOutQuad'
235
+ | 'easeInOutQuad'
236
+ | 'easeInCubic'
237
+ | 'easeOutCubic'
238
+ | 'easeInOutCubic'
239
+ | 'easeInOutSine'
240
+ | ((x: number) => number);
241
+
242
+ /** Per-call {@link PlayerHandle.setSource} options; each overrides the player's own. */
243
+ export interface PlayerSourceOptions {
244
+ poster?: string;
245
+ /** Replace the now-playing line ('' clears it). */
246
+ title?: string;
247
+ transition?: PlayerTransition;
248
+ durationMs?: number;
249
+ easing?: PlayerEasing;
250
+ /** Only `'frozen'` — the player dissolves from the outgoing title's last frame. */
251
+ outgoing?: 'frozen';
252
+ /** LEGACY: `> 0` = a crossfade of this length, `0` = a cut. `durationMs` wins when both are given. */
253
+ fadeMs?: number;
254
+ }
255
+
256
+ /** The resolved form `resolveTransition()` returns. */
257
+ export interface ResolvedPlayerTransition {
258
+ type: PlayerTransition;
259
+ /** 0 for a cut. */
260
+ durationMs: number;
261
+ easing: PlayerEasing;
262
+ ease: (x: number) => number;
263
+ }
264
+
265
+ /** The named accent presets `accent` accepts (any CSS colour works too). */
266
+ export type PlayerAccent = 'azure' | 'violet' | 'magenta' | 'sunset' | 'amber' | 'lime' | 'mint' | 'ice';
267
+
268
+ /**
269
+ * Pick the first source this browser can play from candidates listed best first; each is a URL, or
270
+ * `{ src, type }` with a FULL `canPlayType` string (`'video/webm; codecs="vp9, opus"'`). The
271
+ * DisplayXR Browser has no H.264/AAC, and a codec-less `'video/mp4'` still answers 'maybe'.
272
+ */
273
+ export function pickSource(candidates: PlayerSource, canPlayType?: (type: string) => string): string | Blob | undefined;