@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.
- package/CHANGELOG.md +224 -0
- package/README.md +6 -3
- package/call-full.d.ts +21 -0
- package/call.d.ts +454 -0
- package/dist/call.js +81 -0
- package/index.d.ts +34 -2
- package/js/call/capture.js +212 -0
- package/js/call/disparity.js +432 -0
- package/js/call/element.js +217 -0
- package/js/call/lift.js +323 -0
- package/js/call/qr.js +283 -0
- package/js/call/sdp.js +86 -0
- package/js/call/signaling.js +203 -0
- package/js/call/transport.js +589 -0
- package/js/call/ui.js +146 -0
- package/js/call/wire.js +434 -0
- package/js/inline3d-buffer-limit.js +198 -0
- package/js/inline3d-call-full.js +48 -0
- package/js/inline3d-call.js +1901 -0
- package/js/inline3d-model-playcanvas.js +6 -0
- package/js/inline3d-model.js +6 -0
- package/js/inline3d-player.js +649 -218
- package/js/inline3d-splat-playcanvas.js +134 -18
- package/js/inline3d-splat-video.js +328 -29
- package/js/inline3d-splat.js +5 -0
- package/js/inline3d-viewer.js +86 -10
- package/js/inline3d.js +257 -15
- package/js/lift/index.js +15 -0
- package/model.d.ts +10 -1
- package/package.json +29 -4
- package/player.d.ts +49 -4
- package/splat.d.ts +52 -3
- package/viewer.d.ts +12 -1
package/js/inline3d-player.js
CHANGED
|
@@ -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:
|
|
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:
|
|
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
|
-
'
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
// ──
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2067
|
+
ui.applyAppearance?.(next);
|
|
2191
2068
|
},
|
|
2192
2069
|
exclude(el) {
|
|
2193
|
-
|
|
2070
|
+
surface.exclude?.(el);
|
|
2194
2071
|
},
|
|
2195
2072
|
unexclude(el) {
|
|
2196
|
-
|
|
2073
|
+
surface.unexclude?.(el);
|
|
2197
2074
|
},
|
|
2198
2075
|
remove() {
|
|
2199
2076
|
leaveGroup();
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
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
|
}
|