@displayxr/inline3d 1.27.0 → 1.29.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.
@@ -192,7 +192,8 @@ export function normalizePlayerOptions(opts = {}) {
192
192
  // A letterboxed "band" slot: the picture is fitted into a centred band of this aspect inside
193
193
  // the tile (e.g. 2.39 for a scope band in a 16:9 tile), and the rest of the tile is left
194
194
  // clear. Implies fit 'contain' unless `fit` says otherwise. A number or 'W:H' / 'W/H'.
195
- band: parseAspect(opts.band) ?? (opts.skin === 'call' ? CALL_DEFAULT_BAND : null),
195
+ // `band: null` is an explicit "no band" (it turns off the 'call' skin's default); undefined takes the default.
196
+ band: opts.band === null ? null : parseAspect(opts.band) ?? (opts.skin === 'call' ? CALL_DEFAULT_BAND : null),
196
197
  // What the poster image IS: 'mono' (one image, both eyes — the 1.x behaviour), or a stereo
197
198
  // still laid out like the video ('sbs' / 'tb'), painted eye by eye.
198
199
  posterFormat: pickEnum(opts.posterFormat, VALID_POSTER_FORMATS, 'mono', 'posterFormat'),
@@ -227,6 +228,10 @@ export function normalizePlayerOptions(opts = {}) {
227
228
  // A fullscreen button (and F). The CONTAINER goes fullscreen, not the canvas, so the
228
229
  // transport — its sibling overlays — comes along.
229
230
  fullscreen: opts.fullscreen === undefined ? true : !!opts.fullscreen,
231
+ // Cover the canvas across a fullscreen change until handle.rewoven() (woven-canvas rule 11).
232
+ // Opt-in: on the Leia panel (2026-09-26, 0195 test build, blind A/B) fullscreen showed no raw
233
+ // side-by-side pair either way, and the cover added ~1.2 s with no picture.
234
+ fullscreenCover: !!opts.fullscreenCover,
230
235
  crossOrigin: opts.crossOrigin,
231
236
  width: opts.width,
232
237
  height: opts.height,
@@ -346,7 +351,8 @@ function drawFittedEye(ctx, src, srcW, srcH, layout, eye, dx, dy, dw, dh, fit, b
346
351
  * Pick the first source this browser can play, from candidates listed BEST FIRST. Each candidate
347
352
  * is a URL string, or `{ src, type }` where `type` is a full `canPlayType` string — e.g.
348
353
  * `'video/webm; codecs="vp9, opus"'`. Codec-less types answer 'maybe' for anything in the
349
- * container, which is why the full string matters: the DisplayXR Browser has no H.264 / AAC, and
354
+ * container, which is why the full string matters: only DisplayXR Browser 1.0.6+ plays H.264 /
355
+ * AAC (through the OS decoders, and not on a machine without hardware H.264 decode), and
350
356
  * `'video/mp4'` alone still says 'maybe'. A 'probably' wins over an earlier 'maybe'. A candidate
351
357
  * without a type is taken as-is if nothing typed was 'probably'. Warns and returns the first when
352
358
  * nothing is playable, so the `error` event (and the poster) still happens the usual way.
@@ -382,7 +388,7 @@ export function pickSource(candidates, canPlayType) {
382
388
  if (maybe) return maybe.src;
383
389
  console.warn(
384
390
  '[inline3d/player] none of the candidate sources is playable here — trying the first. The ' +
385
- 'DisplayXR Browser plays VP9/AV1 + Opus in WebM, not H.264/AAC.',
391
+ 'VP9/AV1 + Opus in WebM plays everywhere; H.264/AAC MP4 needs DisplayXR Browser 1.0.6+.',
386
392
  list.map((c) => c.type || '(untyped)')
387
393
  );
388
394
  return list[0].src;
@@ -771,6 +777,7 @@ const PLAYER_CSS = `
771
777
  letterboxing INSIDE the canvas would put each eye's content where the other eye is sampled. */
772
778
  .dxr-player-host:fullscreen{background:#000;display:flex;align-items:center;justify-content:center;}
773
779
  .dxr-player-host:fullscreen>canvas{flex:none;}
780
+ .dxr-player-fscover{position:absolute;inset:0;background:#000;pointer-events:none;}
774
781
 
775
782
  .dxr-player-badge3d{font-size:10px;font-weight:700;letter-spacing:.9px;padding:3px 7px;
776
783
  border-radius:5px;border:1px solid rgba(255,255,255,.28);opacity:.82;flex:none;}
@@ -999,6 +1006,16 @@ const BACK_TRACK_ICON = svg(
999
1006
  '<rect x="5" y="5.5" width="2.4" height="13" rx="1"/>' +
1000
1007
  '<path d="M19 6.3v11.4a.8.8 0 0 1-1.25.66L9.9 12.66a.8.8 0 0 1 0-1.32l7.85-5.7A.8.8 0 0 1 19 6.3Z"/>'
1001
1008
  );
1009
+ /** `?dxrdiag=a,b` on the page URL names a kill switch for an A/B on the panel. */
1010
+ function diagSwitch(name) {
1011
+ try {
1012
+ const q = new URLSearchParams(globalThis.location?.search || '');
1013
+ return q.getAll('dxrdiag').some((v) => v.split(',').includes(name));
1014
+ } catch {
1015
+ return false;
1016
+ }
1017
+ }
1018
+
1002
1019
  const fsElement = () => (typeof document !== 'undefined' ? document.fullscreenElement : null);
1003
1020
 
1004
1021
  function volumeIcon(video) {
@@ -1050,9 +1067,12 @@ function readBuffered(video) {
1050
1067
  * buffering spinner — each its own `data-inline3d-overlay`, each a PARTIAL region of the tile
1051
1068
  * (constraint 1 at the top of this section). Returns the bar element and a cleanup.
1052
1069
  */
1053
- function buildTransportBar(container, canvas, video, { keyboard, accent, badge3d, title, skipButtons, fullscreen, skin, size, band = null, onBack = null }) {
1070
+ function buildTransportBar(container, canvas, video, { keyboard, accent, badge3d, title, skipButtons, fullscreen, skin, size, band = null, onBack = null, rewoven = null, fullscreenCover = false }) {
1054
1071
  ensureStyle();
1055
- if (getComputedStyle(container).position === 'static') container.style.position = 'relative';
1072
+ // Both marks below are undone in cleanup(), and only if the bar made them: under attachPlayer the
1073
+ // container and canvas are the app's, and detach() must leave them as it found them.
1074
+ const setPosition = getComputedStyle(container).position === 'static';
1075
+ if (setPosition) container.style.position = 'relative';
1056
1076
  container.classList.add('dxr-player-host');
1057
1077
  /** Skin, size and accent are all host state — one class or one property, inherited by every overlay. */
1058
1078
  let currentSkin = skin || 'classic';
@@ -1261,7 +1281,8 @@ function buildTransportBar(container, canvas, video, { keyboard, accent, badge3d
1261
1281
  }
1262
1282
 
1263
1283
  // Focusable so keyboard control works without first clicking a button.
1264
- if (!canvas.hasAttribute('tabindex')) canvas.tabIndex = 0;
1284
+ const setTabIndex = !canvas.hasAttribute('tabindex');
1285
+ if (setTabIndex) canvas.tabIndex = 0;
1265
1286
 
1266
1287
  let scrubbing = false;
1267
1288
  let pipTimer = 0;
@@ -1336,9 +1357,34 @@ function buildTransportBar(container, canvas, video, { keyboard, accent, badge3d
1336
1357
  canvas.style.width = `${Math.round(w)}px`;
1337
1358
  canvas.style.height = `${Math.round(w / fsSaved.aspect)}px`;
1338
1359
  }
1360
+ // Entering or leaving fullscreen moves the canvas's rect, and the browser re-registers a moved
1361
+ // rect with the same identity gap as a fresh canvas (woven-canvas rule 11): until the join lands
1362
+ // it shows the page's own raster, the squeezed SBS pair. So cover the canvas across the change,
1363
+ // hard cut, and release on handle.rewoven(). Inserted straight after the canvas, so every
1364
+ // later sibling (the controls) still paints above it. Only with `fullscreenCover: true`.
1365
+ // `?dxrdiag=nofscover` turns it off anyway, for an A/B.
1366
+ let fsCover = null;
1367
+ let fsCoverToken = 0;
1368
+ function coverUntilRewoven() {
1369
+ if (!rewoven || !fullscreenCover || diagSwitch('nofscover')) return;
1370
+ const p = rewoven();
1371
+ if (!p) return; // flat path: nothing is woven, nothing to hide
1372
+ const token = ++fsCoverToken;
1373
+ if (!fsCover) {
1374
+ fsCover = document.createElement('div');
1375
+ fsCover.className = 'dxr-player-fscover';
1376
+ canvas.after(fsCover);
1377
+ }
1378
+ p.then(() => {
1379
+ if (token !== fsCoverToken || !fsCover) return;
1380
+ fsCover.remove();
1381
+ fsCover = null;
1382
+ });
1383
+ }
1339
1384
  function syncFullscreen() {
1340
1385
  if (!fsBtn) return;
1341
1386
  const on = fsElement() === container;
1387
+ if (on !== !!fsSaved) coverUntilRewoven();
1342
1388
  if (on && !fsSaved) {
1343
1389
  const r = canvas.getBoundingClientRect();
1344
1390
  fsSaved = { width: canvas.style.width, height: canvas.style.height, aspect: r.height ? r.width / r.height : 16 / 9 };
@@ -1504,6 +1550,8 @@ function buildTransportBar(container, canvas, video, { keyboard, accent, badge3d
1504
1550
 
1505
1551
  return {
1506
1552
  el: bar,
1553
+ /** Every overlay this bar put over the canvas (surface mode excludes each on the handle). */
1554
+ overlays: [titleEl, bar, centre, pip, spinner],
1507
1555
  setTitle,
1508
1556
  applyAppearance,
1509
1557
  /** Called by addPlayer on setSource() so the chrome resets with the new title. */
@@ -1535,6 +1583,11 @@ function buildTransportBar(container, canvas, video, { keyboard, accent, badge3d
1535
1583
  container.style.removeProperty('--dxr-bar-bottom');
1536
1584
  container.style.removeProperty('--dxr-accent');
1537
1585
  for (const el of [titleEl, bar, centre, pip, spinner]) el.remove();
1586
+ fsCoverToken++;
1587
+ fsCover?.remove();
1588
+ fsCover = null;
1589
+ if (setPosition) container.style.removeProperty('position');
1590
+ if (setTabIndex) canvas.removeAttribute('tabindex');
1538
1591
  },
1539
1592
  };
1540
1593
  }
@@ -1800,78 +1853,27 @@ function driveMixer(video, stages) {
1800
1853
  };
1801
1854
  }
1802
1855
 
1803
- // ── addPlayer ────────────────────────────────────────────────────────────────────────────────
1804
-
1805
- /**
1806
- * Load a media title into an inline-3D window with real transport, in one call. Safe with an
1807
- * unsupported/absent `wall` — it renders flat 2D instead (see the module doc comment), so pages
1808
- * need no branch.
1809
- *
1810
- * @param {object|null|undefined} wall the manager from `createInline3D()`, or null/unsupported.
1811
- * @param {HTMLCanvasElement} canvas a 2D canvas ALREADY inside a container element — the SDK
1812
- * transport is a sibling of the canvas, inside `canvas.parentElement` (required for
1813
- * `controls:'sdk'`, and for the box the transport is anchored to).
1814
- * @param {string|Blob|Array} src the video URL (or a Blob/File), or candidates best-first for
1815
- * pickSource() — `[{ src, type: 'video/webm; codecs="vp9, opus"' }, …]`.
1816
- * @param {object} [opts]
1817
- * @param {'sbs'|'mono'} [opts.format='sbs'] `'sbs'` is a real stereo pair, woven via
1818
- * `wall.addVideo()`. `'mono'` is genuinely flat content, painted full-frame — see the
1819
- * module doc comment for why this is NOT the same code path as an unsupported browser.
1820
- * @param {string} [opts.poster] painted before the first frame, and again on `error`.
1821
- * @param {boolean} [opts.autoplay=false]
1822
- * @param {boolean} [opts.muted=true] autoplay needs this; unmute from the transport or `M`.
1823
- * @param {boolean} [opts.loop=false]
1824
- * @param {'sdk'|'none'} [opts.controls='sdk'] `'none'` leaves chrome to the page (handle +
1825
- * events + its own `data-inline3d-overlay` elements).
1826
- * @param {boolean} [opts.keyboard=true] Space/K play-pause, ←/→ ±5s, J/L ±10s, M mute — bound
1827
- * to the canvas/its controls, not `document`, so multiple players don't fight.
1828
- * @param {'cut'|'crossfade'} [opts.transition='cut'] what setSource() does by default.
1829
- * `'crossfade'` creates the mixer (one extra full-frame draw per painted frame) — see the
1830
- * dissolve section; `./splat`'s other transitions are refused by name.
1831
- * @param {number} [opts.durationMs=600] the crossfade's length.
1832
- * @param {string|function} [opts.easing='easeInOutSine'] a `./splat` easing name or `(x) => y`.
1833
- * @param {number|string} [opts.band] a letterbox slot: fit the picture into a centred band of
1834
- * this aspect (2.39, '2.39:1', '21/9') inside the tile; implies fit 'contain'.
1835
- * @param {Array} [opts.titles] a playlist: `[{ id, src, title?, poster? }, …]` (RFC 0001 A4).
1836
- * @param {boolean} [opts.loopList=false] `next()` past the last title wraps to the first.
1837
- * @param {string} [opts.group] players sharing a group decode one at a time: a play pauses the others.
1838
- * @param {boolean} [opts.autoAdvance=false] a title that ends moves on to the next and plays it.
1839
- * @param {'mono'|'sbs'|'tb'} [opts.posterFormat='mono'] a stereo poster still is painted eye by eye.
1840
- * @param {'contain'|'cover'} [opts.fit] ./splat setVideo's fit: 'contain' letterboxes each eye
1841
- * (transparent bars), 'cover' fills the tile and crops. Unset: stretched to the tile.
1842
- * @param {number} [opts.fadeMs] LEGACY alias (1.10): `> 0` = `transition:'crossfade'` of that length.
1843
- * @param {'anonymous'|'use-credentials'} [opts.crossOrigin] default: `'anonymous'` iff `src` is
1844
- * a cross-origin URL, unset otherwise.
1845
- * @param {number} [opts.width] [opts.height] [opts.cornerRadius] [opts.feather] forwarded to
1846
- * `wall.addVideo()` on the `'sbs'` + supported-wall path only (TileOptions).
1847
- * @param {Element} [opts.observe] forwarded to `wall.addVideo()` (lazy visibility gate).
1848
- * @returns {object} a PlayerHandle — see player.d.ts.
1849
- */
1850
- export function addPlayer(wall, canvas, src, opts = {}) {
1851
- const o = normalizePlayerOptions(opts);
1852
- // The playlist. With `titles` and no `src`, the first title is loaded; with both, `src` is loaded
1853
- // and becomes the current title if it is one of the list's (by identity or URL).
1854
- let titles = o.titles;
1855
- let currentIdx = -1;
1856
- if ((src === undefined || src === null) && titles.length) {
1857
- currentIdx = 0;
1858
- src = titles[0].src;
1859
- if (!o.title && titles[0].title) o.title = titles[0].title;
1860
- if (!o.poster && titles[0].poster) o.poster = titles[0].poster;
1861
- } else if (titles.length) {
1862
- currentIdx = titles.findIndex((t) => t.src === src);
1863
- }
1864
- const container = canvas.parentElement;
1865
-
1866
- const video = document.createElement('video');
1867
- video.playsInline = true;
1868
- video.preload = 'metadata';
1869
- video.muted = o.muted;
1870
- video.loop = o.loop;
1871
- src = pickSource(src);
1872
- const cross = resolveCrossOrigin(src, o.crossOrigin);
1873
- if (cross) video.crossOrigin = cross;
1856
+ // ── the player core: events, playlist, transport — the same code for every surface ────────────
1857
+ //
1858
+ // RFC 0001 Addendum A: one player core on one of two SURFACE ADAPTERS. `addPlayer` draws through
1859
+ // a woven window of its own (`wall.addVideo`); `attachPlayer` borrows an existing splat handle's
1860
+ // video slot (`handle.setVideo`). What differs between them is only how a source reaches the
1861
+ // pixels, so that is all a surface supplies:
1862
+ //
1863
+ // setSource(src, sOpts, tr) load `src` (already resolved to a transition `tr`) and show it
1864
+ // onError() the element errored (the canvas surface repaints its poster)
1865
+ // exclude(el) / unexclude(el) keep a page element out of the weave
1866
+ // teardown() stop the surface's loops (before the controls come down)
1867
+ // afterControls() release the woven surface (after the controls came down)
1868
+ // release() free the decoder(s); absent = the core frees `video` itself
1869
+ //
1870
+ // `video` is whatever the transport binds to: the <video> itself, or (surface mode) a proxy that
1871
+ // always answers for the element currently in use. `ui` is filled in by the caller once the SDK
1872
+ // transport exists (setTitle, resync, applyAppearance, cleanup); every use of it is optional.
1874
1873
 
1874
+ function createPlayerCore(o, video, init, surface, ui) {
1875
+ let titles = init.titles;
1876
+ let currentIdx = init.currentIdx;
1875
1877
  const listeners = new Map();
1876
1878
  function on(event, fn) {
1877
1879
  if (!listeners.has(event)) listeners.set(event, new Set());
@@ -1921,110 +1923,9 @@ export function addPlayer(wall, canvas, src, opts = {}) {
1921
1923
  video.addEventListener('loadedmetadata', () => emit('ready'));
1922
1924
  video.addEventListener('error', () => {
1923
1925
  emit('error', video.error);
1924
- paintPosterNow();
1926
+ surface.onError?.();
1925
1927
  });
1926
1928
 
1927
- let posterImg = null;
1928
- function loadPoster(url) {
1929
- if (!url) {
1930
- posterImg = null;
1931
- return;
1932
- }
1933
- const img = new Image();
1934
- img.decoding = 'async';
1935
- img.onload = () => {
1936
- posterImg = img;
1937
- paintPosterNow();
1938
- };
1939
- img.onerror = () => {
1940
- posterImg = null;
1941
- };
1942
- img.src = url;
1943
- }
1944
-
1945
- const wantWeave = (o.format === 'sbs' || o.format === 'tb') && !!(wall && wall.supported);
1946
- const look = { fit: o.fit, band: o.band, posterFormat: o.posterFormat };
1947
- let innerHandle = null;
1948
- let ownLoop = null;
1949
- let posterPoll = null;
1950
- let mixerLoop = null;
1951
- // Opt-in: no fade asked for at construction => no mixer, and the woven path stays the
1952
- // byte-identical `addVideo(canvas, video)` it is today. See the dissolve section above.
1953
- const dissolve = o.transition.type === 'crossfade' ? createDissolve(video) : null;
1954
- // The woven path's fit stage sits after the mixer (it fits whatever the mixer composed).
1955
- // It is also what repacks a top/bottom source into the SBS pair the SDK weaves, and what lays a
1956
- // band slot out — so it exists for any of fit, band or 'tb'.
1957
- const fitter =
1958
- wantWeave && (o.fit || o.band || o.format === 'tb')
1959
- ? createFitter(() => (dissolve ? dissolve.el : video), canvas, o.fit, o.format, o.band)
1960
- : null;
1961
-
1962
- function paintPosterNow() {
1963
- if (!posterImg) return;
1964
- if (wantWeave) paintPosterSBS(canvas, posterImg, look);
1965
- else ownLoop?.forceRepaint();
1966
- }
1967
-
1968
- if (wantWeave) {
1969
- innerHandle = wall.addVideo(canvas, fitter ? fitter.el : dissolve ? dissolve.el : video, {
1970
- width: o.width,
1971
- height: o.height,
1972
- cornerRadius: o.cornerRadius,
1973
- feather: o.feather,
1974
- ...(o.observe ? { observe: o.observe } : {}),
1975
- });
1976
- if (dissolve || fitter) mixerLoop = driveMixer(video, [dissolve, fitter].filter(Boolean));
1977
- posterPoll = startPosterPoll(canvas, () => posterImg, () => video.readyState >= 2, look);
1978
- } else {
1979
- // The flat loop paints the mixer itself rather than running a second loop beside it.
1980
- ownLoop = attachFlatPaint(canvas, video, {
1981
- mode: o.format === 'mono' ? 'mono' : 'sbs-fallback',
1982
- layout: o.format === 'tb' ? 'tb' : 'sbs',
1983
- getPoster: () => posterImg,
1984
- dissolve,
1985
- fit: o.fit,
1986
- band: o.band,
1987
- posterFormat: o.posterFormat,
1988
- });
1989
- }
1990
-
1991
- loadPoster(o.poster);
1992
- video.src = resolveSrcUrl(src);
1993
- video.load();
1994
- if (o.autoplay) video.play().catch(() => {});
1995
-
1996
- let bar = null;
1997
- let cleanupBar = null;
1998
- let resyncBar = null;
1999
- let setBarTitle = null;
2000
- let applyBarAppearance = null;
2001
- if (o.controls === 'sdk') {
2002
- if (container) {
2003
- const built = buildTransportBar(container, canvas, video, {
2004
- keyboard: o.keyboard,
2005
- accent: o.accent,
2006
- badge3d: o.badge3d,
2007
- title: o.title,
2008
- skipButtons: o.skipButtons,
2009
- fullscreen: o.fullscreen,
2010
- skin: o.skin,
2011
- size: o.size,
2012
- band: o.band,
2013
- onBack: () => handle.back(),
2014
- });
2015
- bar = built.el;
2016
- cleanupBar = built.cleanup;
2017
- resyncBar = built.resync;
2018
- setBarTitle = built.setTitle;
2019
- applyBarAppearance = built.applyAppearance;
2020
- } else {
2021
- console.warn(
2022
- '[inline3d/player] controls:"sdk" needs canvas.parentElement to attach the transport ' +
2023
- '(the overlay must be a sibling of the canvas) — skipping SDK chrome for this player.'
2024
- );
2025
- }
2026
- }
2027
-
2028
1929
  function findTitle(id) {
2029
1930
  if (typeof id === 'number') return Number.isInteger(id) && id >= 0 && id < titles.length ? id : -1;
2030
1931
  return titles.findIndex((t) => t.id === String(id));
@@ -2144,31 +2045,7 @@ export function addPlayer(wall, canvas, src, opts = {}) {
2144
2045
  }
2145
2046
  // Resolve (and validate) BEFORE touching anything: a refused option leaves the player as it was.
2146
2047
  const tr = resolveTransition(sOpts, o.transition);
2147
- if (tr.type === 'crossfade' && !dissolve) noteNoMixer();
2148
- // Snapshot BEFORE the src is repointed — once `load()` runs, the old frame is gone.
2149
- if (dissolve && tr.type === 'crossfade' && dissolve.capture()) dissolve.arm(tr.durationMs, tr.ease);
2150
- video.pause();
2151
- if (sOpts.poster !== undefined) loadPoster(sOpts.poster);
2152
- if (sOpts.title !== undefined) setBarTitle?.(sOpts.title);
2153
- newSrc = pickSource(newSrc);
2154
- const nextCross = resolveCrossOrigin(newSrc, o.crossOrigin);
2155
- if (nextCross) video.crossOrigin = nextCross;
2156
- video.src = resolveSrcUrl(newSrc);
2157
- video.load();
2158
- // The chrome is bound to this same <video>, so its listeners survive the swap — but the
2159
- // values they last rendered belong to the OLD title (a 24 s duration, a full scrub bar).
2160
- // Nothing re-fires them until the new metadata lands, so reset them now rather than show
2161
- // the previous title's numbers over the new one's first frames.
2162
- resyncBar?.();
2163
- // The poster is the right thing on screen again until the new source has a frame — but
2164
- // NOT when a dissolve is running: the mixer is holding the outgoing title's last frame
2165
- // there on purpose, and painting the poster over it is the hard cut this option exists
2166
- // to remove.
2167
- posterPoll?.stop();
2168
- if (wantWeave && !dissolve?.active) {
2169
- posterPoll = startPosterPoll(canvas, () => posterImg, () => video.readyState >= 2, look);
2170
- }
2171
- if (o.autoplay) video.play().catch(() => {});
2048
+ surface.setSource(newSrc, sOpts, tr);
2172
2049
  },
2173
2050
  /**
2174
2051
  * Re-skin the SDK transport live: any of `accent` (a CSS colour; '' = the default),
@@ -2187,33 +2064,587 @@ export function addPlayer(wall, canvas, src, opts = {}) {
2187
2064
  if (VALID_SKINS.has(a.skin)) next.skin = o.skin = a.skin;
2188
2065
  else console.warn(`[inline3d/player] invalid skin "${a.skin}" — ignored`);
2189
2066
  }
2190
- applyBarAppearance?.(next);
2067
+ ui.applyAppearance?.(next);
2191
2068
  },
2192
2069
  exclude(el) {
2193
- innerHandle?.exclude(el);
2070
+ surface.exclude?.(el);
2194
2071
  },
2195
2072
  unexclude(el) {
2196
- innerHandle?.unexclude(el);
2073
+ surface.unexclude?.(el);
2197
2074
  },
2198
2075
  remove() {
2199
2076
  leaveGroup();
2200
- ownLoop?.stop();
2201
- mixerLoop?.stop();
2202
- posterPoll?.stop();
2203
- cleanupBar?.();
2204
- innerHandle?.remove();
2205
- try {
2206
- video.pause();
2207
- } catch {
2208
- /* ignore */
2077
+ surface.teardown?.();
2078
+ ui.cleanup?.();
2079
+ surface.afterControls?.();
2080
+ if (surface.release) surface.release();
2081
+ else {
2082
+ try {
2083
+ video.pause();
2084
+ } catch {
2085
+ /* ignore */
2086
+ }
2087
+ video.removeAttribute('src');
2088
+ video.load();
2209
2089
  }
2210
- video.removeAttribute('src');
2211
- video.load();
2212
2090
  listeners.clear();
2213
2091
  },
2214
2092
  on,
2215
2093
  off,
2216
2094
  };
2217
2095
 
2096
+ return { handle, emit };
2097
+ }
2098
+
2099
+ // ── addPlayer ────────────────────────────────────────────────────────────────────────────────
2100
+
2101
+ /**
2102
+ * Load a media title into an inline-3D window with real transport, in one call. Safe with an
2103
+ * unsupported/absent `wall` — it renders flat 2D instead (see the module doc comment), so pages
2104
+ * need no branch.
2105
+ *
2106
+ * @param {object|null|undefined} wall the manager from `createInline3D()`, or null/unsupported.
2107
+ * @param {HTMLCanvasElement} canvas a 2D canvas ALREADY inside a container element — the SDK
2108
+ * transport is a sibling of the canvas, inside `canvas.parentElement` (required for
2109
+ * `controls:'sdk'`, and for the box the transport is anchored to).
2110
+ * @param {string|Blob|Array} src the video URL (or a Blob/File), or candidates best-first for
2111
+ * pickSource() — `[{ src, type: 'video/webm; codecs="vp9, opus"' }, …]`.
2112
+ * @param {object} [opts]
2113
+ * @param {'sbs'|'mono'} [opts.format='sbs'] `'sbs'` is a real stereo pair, woven via
2114
+ * `wall.addVideo()`. `'mono'` is genuinely flat content, painted full-frame — see the
2115
+ * module doc comment for why this is NOT the same code path as an unsupported browser.
2116
+ * @param {string} [opts.poster] painted before the first frame, and again on `error`.
2117
+ * @param {boolean} [opts.autoplay=false]
2118
+ * @param {boolean} [opts.muted=true] autoplay needs this; unmute from the transport or `M`.
2119
+ * @param {boolean} [opts.loop=false]
2120
+ * @param {'sdk'|'none'} [opts.controls='sdk'] `'none'` leaves chrome to the page (handle +
2121
+ * events + its own `data-inline3d-overlay` elements).
2122
+ * @param {boolean} [opts.keyboard=true] Space/K play-pause, ←/→ ±5s, J/L ±10s, M mute — bound
2123
+ * to the canvas/its controls, not `document`, so multiple players don't fight.
2124
+ * @param {'cut'|'crossfade'} [opts.transition='cut'] what setSource() does by default.
2125
+ * `'crossfade'` creates the mixer (one extra full-frame draw per painted frame) — see the
2126
+ * dissolve section; `./splat`'s other transitions are refused by name.
2127
+ * @param {number} [opts.durationMs=600] the crossfade's length.
2128
+ * @param {string|function} [opts.easing='easeInOutSine'] a `./splat` easing name or `(x) => y`.
2129
+ * @param {number|string} [opts.band] a letterbox slot: fit the picture into a centred band of
2130
+ * this aspect (2.39, '2.39:1', '21/9') inside the tile; implies fit 'contain'.
2131
+ * @param {Array} [opts.titles] a playlist: `[{ id, src, title?, poster? }, …]` (RFC 0001 A4).
2132
+ * @param {boolean} [opts.loopList=false] `next()` past the last title wraps to the first.
2133
+ * @param {string} [opts.group] players sharing a group decode one at a time: a play pauses the others.
2134
+ * @param {boolean} [opts.autoAdvance=false] a title that ends moves on to the next and plays it.
2135
+ * @param {'mono'|'sbs'|'tb'} [opts.posterFormat='mono'] a stereo poster still is painted eye by eye.
2136
+ * @param {'contain'|'cover'} [opts.fit] ./splat setVideo's fit: 'contain' letterboxes each eye
2137
+ * (transparent bars), 'cover' fills the tile and crops. Unset: stretched to the tile.
2138
+ * @param {number} [opts.fadeMs] LEGACY alias (1.10): `> 0` = `transition:'crossfade'` of that length.
2139
+ * @param {'anonymous'|'use-credentials'} [opts.crossOrigin] default: `'anonymous'` iff `src` is
2140
+ * a cross-origin URL, unset otherwise.
2141
+ * @param {number} [opts.width] [opts.height] [opts.cornerRadius] [opts.feather] forwarded to
2142
+ * `wall.addVideo()` on the `'sbs'` + supported-wall path only (TileOptions).
2143
+ * @param {Element} [opts.observe] forwarded to `wall.addVideo()` (lazy visibility gate).
2144
+ * @returns {object} a PlayerHandle — see player.d.ts.
2145
+ */
2146
+ export function addPlayer(wall, canvas, src, opts = {}) {
2147
+ const o = normalizePlayerOptions(opts);
2148
+ // The playlist. With `titles` and no `src`, the first title is loaded; with both, `src` is loaded
2149
+ // and becomes the current title if it is one of the list's (by identity or URL).
2150
+ let titles = o.titles;
2151
+ let currentIdx = -1;
2152
+ if ((src === undefined || src === null) && titles.length) {
2153
+ currentIdx = 0;
2154
+ src = titles[0].src;
2155
+ if (!o.title && titles[0].title) o.title = titles[0].title;
2156
+ if (!o.poster && titles[0].poster) o.poster = titles[0].poster;
2157
+ } else if (titles.length) {
2158
+ currentIdx = titles.findIndex((t) => t.src === src);
2159
+ }
2160
+ const container = canvas.parentElement;
2161
+
2162
+ const video = document.createElement('video');
2163
+ video.playsInline = true;
2164
+ video.preload = 'metadata';
2165
+ video.muted = o.muted;
2166
+ video.loop = o.loop;
2167
+ src = pickSource(src);
2168
+ const cross = resolveCrossOrigin(src, o.crossOrigin);
2169
+ if (cross) video.crossOrigin = cross;
2170
+
2171
+ // The SDK transport's hooks, filled in once it is built below (the core reads them lazily).
2172
+ const ui = {};
2173
+ const { handle } = createPlayerCore(o, video, { titles, currentIdx }, {
2174
+ onError: () => paintPosterNow(),
2175
+ setSource: canvasSetSource,
2176
+ exclude: (el) => innerHandle?.exclude(el),
2177
+ unexclude: (el) => innerHandle?.unexclude(el),
2178
+ teardown() {
2179
+ ownLoop?.stop();
2180
+ mixerLoop?.stop();
2181
+ posterPoll?.stop();
2182
+ },
2183
+ afterControls: () => innerHandle?.remove(),
2184
+ }, ui);
2185
+
2186
+ /** The canvas surface's half of setSource (the core has already resolved `tr`). */
2187
+ function canvasSetSource(newSrc, sOpts, tr) {
2188
+ if (tr.type === 'crossfade' && !dissolve) noteNoMixer();
2189
+ // Snapshot BEFORE the src is repointed — once `load()` runs, the old frame is gone.
2190
+ if (dissolve && tr.type === 'crossfade' && dissolve.capture()) dissolve.arm(tr.durationMs, tr.ease);
2191
+ video.pause();
2192
+ if (sOpts.poster !== undefined) loadPoster(sOpts.poster);
2193
+ if (sOpts.title !== undefined) ui.setTitle?.(sOpts.title);
2194
+ newSrc = pickSource(newSrc);
2195
+ const nextCross = resolveCrossOrigin(newSrc, o.crossOrigin);
2196
+ if (nextCross) video.crossOrigin = nextCross;
2197
+ video.src = resolveSrcUrl(newSrc);
2198
+ video.load();
2199
+ // The chrome is bound to this same <video>, so its listeners survive the swap — but the
2200
+ // values they last rendered belong to the OLD title (a 24 s duration, a full scrub bar).
2201
+ // Nothing re-fires them until the new metadata lands, so reset them now rather than show
2202
+ // the previous title's numbers over the new one's first frames.
2203
+ ui.resync?.();
2204
+ // The poster is the right thing on screen again until the new source has a frame — but
2205
+ // NOT when a dissolve is running: the mixer is holding the outgoing title's last frame
2206
+ // there on purpose, and painting the poster over it is the hard cut this option exists
2207
+ // to remove.
2208
+ posterPoll?.stop();
2209
+ if (wantWeave && !dissolve?.active) {
2210
+ posterPoll = startPosterPoll(canvas, () => posterImg, () => video.readyState >= 2, look);
2211
+ }
2212
+ if (o.autoplay) video.play().catch(() => {});
2213
+ }
2214
+
2215
+ let posterImg = null;
2216
+ function loadPoster(url) {
2217
+ if (!url) {
2218
+ posterImg = null;
2219
+ return;
2220
+ }
2221
+ const img = new Image();
2222
+ img.decoding = 'async';
2223
+ img.onload = () => {
2224
+ posterImg = img;
2225
+ paintPosterNow();
2226
+ };
2227
+ img.onerror = () => {
2228
+ posterImg = null;
2229
+ };
2230
+ img.src = url;
2231
+ }
2232
+
2233
+ const wantWeave = (o.format === 'sbs' || o.format === 'tb') && !!(wall && wall.supported);
2234
+ const look = { fit: o.fit, band: o.band, posterFormat: o.posterFormat };
2235
+ let innerHandle = null;
2236
+ let ownLoop = null;
2237
+ let posterPoll = null;
2238
+ let mixerLoop = null;
2239
+ // Opt-in: no fade asked for at construction => no mixer, and the woven path stays the
2240
+ // byte-identical `addVideo(canvas, video)` it is today. See the dissolve section above.
2241
+ const dissolve = o.transition.type === 'crossfade' ? createDissolve(video) : null;
2242
+ // The woven path's fit stage sits after the mixer (it fits whatever the mixer composed).
2243
+ // It is also what repacks a top/bottom source into the SBS pair the SDK weaves, and what lays a
2244
+ // band slot out — so it exists for any of fit, band or 'tb'.
2245
+ const fitter =
2246
+ wantWeave && (o.fit || o.band || o.format === 'tb')
2247
+ ? createFitter(() => (dissolve ? dissolve.el : video), canvas, o.fit, o.format, o.band)
2248
+ : null;
2249
+
2250
+ function paintPosterNow() {
2251
+ if (!posterImg) return;
2252
+ if (wantWeave) paintPosterSBS(canvas, posterImg, look);
2253
+ else ownLoop?.forceRepaint();
2254
+ }
2255
+
2256
+ if (wantWeave) {
2257
+ innerHandle = wall.addVideo(canvas, fitter ? fitter.el : dissolve ? dissolve.el : video, {
2258
+ width: o.width,
2259
+ height: o.height,
2260
+ cornerRadius: o.cornerRadius,
2261
+ feather: o.feather,
2262
+ ...(o.observe ? { observe: o.observe } : {}),
2263
+ });
2264
+ if (dissolve || fitter) mixerLoop = driveMixer(video, [dissolve, fitter].filter(Boolean));
2265
+ posterPoll = startPosterPoll(canvas, () => posterImg, () => video.readyState >= 2, look);
2266
+ } else {
2267
+ // The flat loop paints the mixer itself rather than running a second loop beside it.
2268
+ ownLoop = attachFlatPaint(canvas, video, {
2269
+ mode: o.format === 'mono' ? 'mono' : 'sbs-fallback',
2270
+ layout: o.format === 'tb' ? 'tb' : 'sbs',
2271
+ getPoster: () => posterImg,
2272
+ dissolve,
2273
+ fit: o.fit,
2274
+ band: o.band,
2275
+ posterFormat: o.posterFormat,
2276
+ });
2277
+ }
2278
+
2279
+ loadPoster(o.poster);
2280
+ video.src = resolveSrcUrl(src);
2281
+ video.load();
2282
+ if (o.autoplay) video.play().catch(() => {});
2283
+
2284
+ if (o.controls === 'sdk') {
2285
+ if (container) {
2286
+ const built = buildTransportBar(container, canvas, video, {
2287
+ keyboard: o.keyboard,
2288
+ accent: o.accent,
2289
+ badge3d: o.badge3d,
2290
+ title: o.title,
2291
+ skipButtons: o.skipButtons,
2292
+ fullscreen: o.fullscreen,
2293
+ skin: o.skin,
2294
+ size: o.size,
2295
+ band: o.band,
2296
+ onBack: () => handle.back(),
2297
+ rewoven: innerHandle && typeof innerHandle.rewoven === 'function' ? () => innerHandle.rewoven() : null,
2298
+ fullscreenCover: o.fullscreenCover,
2299
+ });
2300
+ ui.el = built.el;
2301
+ ui.cleanup = built.cleanup;
2302
+ ui.resync = built.resync;
2303
+ ui.setTitle = built.setTitle;
2304
+ ui.applyAppearance = built.applyAppearance;
2305
+ } else {
2306
+ console.warn(
2307
+ '[inline3d/player] controls:"sdk" needs canvas.parentElement to attach the transport ' +
2308
+ '(the overlay must be a sibling of the canvas) — skipping SDK chrome for this player.'
2309
+ );
2310
+ }
2311
+ }
2312
+
2313
+ return handle;
2314
+ }
2315
+
2316
+ // ── attachPlayer: the player on an existing splat handle (surface mode) ───────────────────────
2317
+ //
2318
+ // RFC 0001 Addendum A. The same core as addPlayer, on a surface that owns NO canvas: it borrows
2319
+ // the handle's video slot (`handle.setVideo`, ./splat engine:'playcanvas'). One persistent woven
2320
+ // canvas, never a second one (woven-canvas rules 2 and 3).
2321
+ //
2322
+ // TWO ELEMENTS, ALTERNATING (A2). A source swap makes a new <video> and hands it to setVideo,
2323
+ // which keeps the plane on the old element until the new one's first frame — so the plane never
2324
+ // shows an element that is loading. (Swapping `src` in place on one element would: while it
2325
+ // reloads its videoWidth is 0, and the plane re-sizes and re-textures to nothing.) The old
2326
+ // element is paused at once and released only after the swap has landed.
2327
+ //
2328
+ // THE TRANSPORT BINDS TO A PROXY, not to either element: it answers for, and forwards the media
2329
+ // events of, whichever element is current. So buildTransportBar, joinGroup and the core are the
2330
+ // same code as addPlayer's, unaware that the element underneath changes.
2331
+
2332
+ /** The media events the proxy forwards from the current element (all the player listens for). */
2333
+ const PROXY_EVENTS = [
2334
+ 'play', 'pause', 'playing', 'ended', 'timeupdate', 'durationchange', 'loadedmetadata', 'loadeddata',
2335
+ 'canplay', 'progress', 'seeking', 'seeked', 'waiting', 'volumechange', 'error',
2336
+ ];
2337
+
2338
+ /**
2339
+ * A stand-in <video> for the transport: every read, write and call goes to the current element,
2340
+ * and the current element's media events are re-dispatched from here. `use(el)` switches
2341
+ * elements; events from an element that is no longer current are dropped. Exported for tests.
2342
+ */
2343
+ export function createVideoProxy() {
2344
+ const et = new EventTarget();
2345
+ let el = null;
2346
+ // Bound per element, so an event from an element that is no longer current is dropped even
2347
+ // if it was queued before the switch.
2348
+ const forwarders = new Map();
2349
+ const rw = (k) => ({
2350
+ get: () => (el ? el[k] : undefined),
2351
+ set: (v) => {
2352
+ if (el) el[k] = v;
2353
+ },
2354
+ enumerable: true,
2355
+ });
2356
+ const ro = (k, dflt) => ({ get: () => (el ? el[k] : dflt), enumerable: true });
2357
+ const proxy = {
2358
+ /** The element the proxy answers for now. */
2359
+ get element() {
2360
+ return el;
2361
+ },
2362
+ use(next) {
2363
+ if (next === el) return;
2364
+ if (el) {
2365
+ const f = forwarders.get(el);
2366
+ for (const t of PROXY_EVENTS) el.removeEventListener(t, f);
2367
+ forwarders.delete(el);
2368
+ }
2369
+ el = next || null;
2370
+ if (el) {
2371
+ const mine = el;
2372
+ const f = (e) => {
2373
+ if (mine === el) et.dispatchEvent(new Event(e.type));
2374
+ };
2375
+ forwarders.set(el, f);
2376
+ for (const t of PROXY_EVENTS) el.addEventListener(t, f);
2377
+ }
2378
+ },
2379
+ addEventListener: (t, fn, o) => et.addEventListener(t, fn, o),
2380
+ removeEventListener: (t, fn, o) => et.removeEventListener(t, fn, o),
2381
+ play: () => (el ? el.play() : Promise.resolve()),
2382
+ pause: () => el?.pause(),
2383
+ load: () => el?.load(),
2384
+ removeAttribute: (n) => el?.removeAttribute(n),
2385
+ requestVideoFrameCallback: (cb) => el?.requestVideoFrameCallback?.(cb),
2386
+ cancelVideoFrameCallback: (h) => el?.cancelVideoFrameCallback?.(h),
2387
+ };
2388
+ Object.defineProperties(proxy, {
2389
+ currentTime: rw('currentTime'),
2390
+ volume: rw('volume'),
2391
+ muted: rw('muted'),
2392
+ loop: rw('loop'),
2393
+ src: rw('src'),
2394
+ crossOrigin: rw('crossOrigin'),
2395
+ preload: rw('preload'),
2396
+ playsInline: rw('playsInline'),
2397
+ paused: ro('paused', true),
2398
+ ended: ro('ended', false),
2399
+ duration: ro('duration', NaN),
2400
+ readyState: ro('readyState', 0),
2401
+ videoWidth: ro('videoWidth', 0),
2402
+ videoHeight: ro('videoHeight', 0),
2403
+ error: ro('error', null),
2404
+ buffered: ro('buffered', undefined),
2405
+ });
2406
+ return proxy;
2407
+ }
2408
+
2409
+ let warnedSurfaceIgnored = false;
2410
+ /** Options that mean something only on a surface the player owns (A1, A4's parity table). */
2411
+ const SURFACE_IGNORED = ['poster', 'posterFormat', 'width', 'height', 'cornerRadius', 'feather', 'observe'];
2412
+
2413
+ /**
2414
+ * Play titles on an EXISTING splat handle's video slot, with the same transport, playlist and
2415
+ * events as addPlayer — and no canvas of its own (RFC 0001 Addendum A).
2416
+ *
2417
+ * @param {object} splat an `addSplat(…, { engine: 'playcanvas' })` handle (anything with
2418
+ * `setVideo`; `canvas`, `videoElement` and `exclude` are used when present).
2419
+ * @param {string|Blob|Array} src as addPlayer; or omit it and pass `opts.titles`.
2420
+ * @param {object} [opts] addPlayer's options, except: `fullscreen` defaults to false (it would
2421
+ * fullscreen the whole app's canvas); `transition: 'crossfade'` is ./splat setVideo's GPU
2422
+ * crossfade (same durationMs / easing); `band` is setVideo's letterbox slot (controls in the
2423
+ * bars); `poster`, `posterFormat` and the tile options are ignored (warns once) — the scene
2424
+ * is the poster. `format` ('sbs' | 'tb' | 'mono') and `fit` ('contain' default | 'cover')
2425
+ * go to setVideo.
2426
+ * @param {Element} [opts.chromeContainer] where the controls go. Default: the canvas's parent.
2427
+ * @returns {object} a PlayerHandle (player.d.ts) plus `detach()` and the `'detached'` event.
2428
+ */
2429
+ /** How long attachPlayer waits for a superseded element to leave the plane before it stops looking. */
2430
+ const KEEP_ON_PLANE_MAX_MS = 60_000;
2431
+
2432
+ export function attachPlayer(splat, src, opts = {}) {
2433
+ if (!splat || typeof splat.setVideo !== 'function') {
2434
+ throw new TypeError(
2435
+ "@displayxr/inline3d/player: attachPlayer(handle, …) needs an addSplat(…, { engine: 'playcanvas' }) handle (one with setVideo)."
2436
+ );
2437
+ }
2438
+ const given = opts || {};
2439
+ const o = normalizePlayerOptions({ ...given, fullscreen: given.fullscreen === undefined ? false : given.fullscreen });
2440
+ const ignored = SURFACE_IGNORED.filter((k) => given[k] !== undefined && given[k] !== null);
2441
+ if (ignored.length && !warnedSurfaceIgnored) {
2442
+ warnedSurfaceIgnored = true;
2443
+ console.warn(`[inline3d/player] attachPlayer ignores ${ignored.join(', ')} (the handle's own tile; the scene is the poster).`);
2444
+ }
2445
+ let titles = o.titles;
2446
+ let currentIdx = -1;
2447
+ if ((src === undefined || src === null) && titles.length) {
2448
+ currentIdx = 0;
2449
+ src = titles[0].src;
2450
+ if (!o.title && titles[0].title) o.title = titles[0].title;
2451
+ } else if (titles.length) {
2452
+ currentIdx = titles.findIndex((t) => t.src === src);
2453
+ }
2454
+ const canvas = splat.canvas || null;
2455
+ const container = given.chromeContainer || (canvas && canvas.parentElement) || null;
2456
+ // `band` is ./splat setVideo's letterbox slot (A5.2): the plane's window is the canvas box, so the
2457
+ // bar's controls-in-bars layout (band below) lands on the same bars.
2458
+ const setVideoOpts = { format: o.format, fit: o.fit || 'contain', autoplay: false, ...(o.band ? { band: o.band } : {}) };
2459
+
2460
+ function makeElement(s, like) {
2461
+ const v = document.createElement('video');
2462
+ v.playsInline = true;
2463
+ v.preload = 'auto'; // setVideo swaps at the first frame, so the frame must come without play()
2464
+ v.muted = like ? like.muted : o.muted;
2465
+ v.loop = like ? like.loop : o.loop;
2466
+ if (like) v.volume = like.volume;
2467
+ const picked = pickSource(s);
2468
+ const cross = resolveCrossOrigin(picked, o.crossOrigin);
2469
+ if (cross) v.crossOrigin = cross;
2470
+ v.src = resolveSrcUrl(picked);
2471
+ return v;
2472
+ }
2473
+ function releaseElement(v) {
2474
+ try {
2475
+ v.pause();
2476
+ v.removeAttribute('src');
2477
+ v.load();
2478
+ } catch {
2479
+ /* already gone */
2480
+ }
2481
+ }
2482
+
2483
+ const proxy = createVideoProxy();
2484
+ let shown = null; // the element the plane shows (setVideo resolved for it)
2485
+ let pending = null; // ours, handed to setVideo, before its first frame
2486
+ let seq = 0;
2487
+ let state = 'attached'; // 'attached' | 'detached'
2488
+ const retired = new Set(); // paused, kept until the swap that replaces them lands
2489
+
2490
+ /** `tr`: the core's resolved transition; a crossfade is ./splat setVideo's GPU dissolve (A5.1). */
2491
+ function show(el, tr = null) {
2492
+ const mine = ++seq;
2493
+ pending = el;
2494
+ let p;
2495
+ try {
2496
+ const fade = tr && tr.type === 'crossfade' && tr.durationMs > 0;
2497
+ p = splat.setVideo(el, fade ? { ...setVideoOpts, transition: 'crossfade', durationMs: tr.durationMs, easing: tr.ease } : setVideoOpts);
2498
+ } catch (err) {
2499
+ pending = null;
2500
+ queueMicrotask(() => emit('error', err));
2501
+ return false;
2502
+ }
2503
+ Promise.resolve(p).then(
2504
+ () => {
2505
+ if (mine !== seq || state !== 'attached') return;
2506
+ pending = null;
2507
+ shown = el;
2508
+ for (const r of retired) if (r !== el) releaseElement(r);
2509
+ retired.clear();
2510
+ },
2511
+ (err) => {
2512
+ if (mine !== seq || state !== 'attached') return; // our own newer call, or detach()
2513
+ pending = null;
2514
+ if (err && err.name === 'AbortError') lost('superseded');
2515
+ else emit('error', err);
2516
+ }
2517
+ );
2518
+ return true;
2519
+ }
2520
+
2521
+ // Nothing reports another setVideo taking the slot once ours is on, so look: twice a second and
2522
+ // on the transport's own events. Only while ours is on and no swap of ours is in flight.
2523
+ function checkSlot() {
2524
+ if (state !== 'attached' || !shown || pending || !('videoElement' in splat)) return;
2525
+ const now = splat.videoElement;
2526
+ if (now !== shown) lost(now ? 'superseded' : 'released');
2527
+ }
2528
+ const slotTimer = setInterval(checkSlot, 500);
2529
+
2530
+ const ui = {};
2531
+ let overlays = [];
2532
+ const { handle, emit } = createPlayerCore(o, proxy, { titles, currentIdx }, {
2533
+ setSource(newSrc, sOpts, tr) {
2534
+ if (state !== 'attached') return;
2535
+ if (sOpts.title !== undefined) ui.setTitle?.(sOpts.title);
2536
+ const prev = proxy.element;
2537
+ const next = makeElement(newSrc, prev);
2538
+ // Hand it to setVideo FIRST: it throws while a splat setSource is in flight, and then the
2539
+ // transport must stay on the element the plane is still showing.
2540
+ if (!show(next, tr)) {
2541
+ releaseElement(next);
2542
+ return;
2543
+ }
2544
+ if (prev) {
2545
+ prev.pause();
2546
+ retired.add(prev);
2547
+ }
2548
+ proxy.use(next);
2549
+ ui.resync?.();
2550
+ if (o.autoplay) next.play().catch(() => {});
2551
+ },
2552
+ exclude: (el) => splat.exclude?.(el),
2553
+ unexclude: (el) => splat.unexclude?.(el),
2554
+ teardown() {
2555
+ clearInterval(slotTimer);
2556
+ for (const el of overlays) splat.unexclude?.(el);
2557
+ },
2558
+ afterControls() {
2559
+ // Give the slot back only if it is still ours (shown, or ours pending): never clear a video
2560
+ // someone else put there.
2561
+ const now = 'videoElement' in splat ? splat.videoElement : shown;
2562
+ const ours = state === 'attached' && (pending || (shown && now === shown));
2563
+ state = 'detached';
2564
+ if (ours) splat.setVideo(null);
2565
+ },
2566
+ release() {
2567
+ // ./splat keeps its on-screen video until a replacement's first frame, so when another
2568
+ // setVideo supersedes a swap of ours the plane is still showing `shown`. Freeing it now
2569
+ // would drop videoWidth to 0 and blank the plane until the replacement lands (or for good,
2570
+ // if it never does). Pause it and hand it back only once the slot has moved on.
2571
+ const onPlane = 'videoElement' in splat ? splat.videoElement : null;
2572
+ for (const r of retired) if (r !== onPlane) releaseElement(r);
2573
+ retired.clear();
2574
+ const cur = proxy.element;
2575
+ for (const v of new Set([shown, pending, cur])) {
2576
+ if (!v) continue;
2577
+ if (v === onPlane) keepUntilOffPlane(v);
2578
+ else releaseElement(v);
2579
+ }
2580
+ proxy.use(null);
2581
+ shown = pending = null;
2582
+ },
2583
+ }, ui);
2584
+
2585
+ // Bounded: if the replacement never lands (its load failed and the app never retried), the
2586
+ // element stays on the plane for good, so stop polling after KEEP_ON_PLANE_MAX_MS and leave it
2587
+ // paused rather than free it — freeing it is the very blank plane this exists to avoid. A later
2588
+ // splat.remove() / setVideo still disposes of it through ./splat.
2589
+ function keepUntilOffPlane(v) {
2590
+ v.pause();
2591
+ const until = Date.now() + KEEP_ON_PLANE_MAX_MS;
2592
+ const t = setInterval(() => {
2593
+ const onPlane = splat.videoElement === v;
2594
+ if (onPlane && Date.now() < until) return;
2595
+ clearInterval(t);
2596
+ if (!onPlane) releaseElement(v);
2597
+ }, 250);
2598
+ }
2599
+
2600
+ /** The slot went to someone else: stop driving the handle, say so once, tear down our side. */
2601
+ function lost(reason) {
2602
+ if (state !== 'attached') return;
2603
+ state = 'detached';
2604
+ emit('detached', { reason });
2605
+ handle.remove();
2606
+ }
2607
+ for (const t of ['play', 'pause', 'timeupdate']) proxy.addEventListener(t, checkSlot);
2608
+
2609
+ if (o.controls === 'sdk') {
2610
+ if (container && canvas) {
2611
+ const built = buildTransportBar(container, canvas, proxy, {
2612
+ keyboard: o.keyboard,
2613
+ accent: o.accent,
2614
+ badge3d: o.badge3d,
2615
+ title: o.title,
2616
+ skipButtons: o.skipButtons,
2617
+ fullscreen: o.fullscreen,
2618
+ skin: o.skin,
2619
+ size: o.size,
2620
+ band: o.band,
2621
+ onBack: () => handle.back(),
2622
+ });
2623
+ ui.el = built.el;
2624
+ ui.cleanup = built.cleanup;
2625
+ ui.resync = built.resync;
2626
+ ui.setTitle = built.setTitle;
2627
+ ui.applyAppearance = built.applyAppearance;
2628
+ // Legacy browsers: the core's overlay scan finds these under the canvas's parent; exclude
2629
+ // them on the handle too, so it does not depend on where the page nested its canvas (A3).
2630
+ overlays = built.overlays || [];
2631
+ for (const el of overlays) splat.exclude?.(el);
2632
+ } else {
2633
+ console.warn(
2634
+ "[inline3d/player] attachPlayer: controls:'sdk' needs the handle's canvas in a container " +
2635
+ '(or opts.chromeContainer) — skipping SDK chrome.'
2636
+ );
2637
+ }
2638
+ }
2639
+
2640
+ const first = makeElement(src, null);
2641
+ proxy.use(first);
2642
+ if (show(first) && o.autoplay) first.play().catch(() => {});
2643
+
2644
+ /** Give the slot back (setVideo(null): the scene, pose, lens and rig as they were) and tear down. */
2645
+ handle.detach = () => {
2646
+ handle.remove();
2647
+ return Promise.resolve();
2648
+ };
2218
2649
  return handle;
2219
2650
  }