@displayxr/inline3d 1.24.0 → 1.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2219 @@
1
+ // inline3d-player.js — a media player as an inline-3D window, in one call.
2
+ //
3
+ // PREVIEW tier. Not covered by the SDK's 1.x semver promise — see docs/sdk-stability.md.
4
+ // Implements the v1 slice of docs/rfcs/0001-media-player.md. Two things the RFC names live
5
+ // elsewhere or not yet: `untrackedFallback` is a CORE option (`createInline3D`, see the tracking
6
+ // note below), not a player one; `opts.group` (one active player per group) is not built yet.
7
+ //
8
+ // import { createInline3D } from '@displayxr/inline3d';
9
+ // import { addPlayer } from '@displayxr/inline3d/player';
10
+ //
11
+ // const wall = await createInline3D();
12
+ // const p = addPlayer(wall, canvas, 'title_2x1.webm', { poster: 'title-poster.jpg' });
13
+ // p.on('ready', () => p.play());
14
+ //
15
+ // Built ON `wall.addVideo()`: for `format:'sbs'` on a supported wall, this module creates and
16
+ // owns a hidden <video> and hands it straight to `wall.addVideo(canvas, video, opts)` — the
17
+ // stereo paint (the per-frame drawImage), the SBS buffer sizing, and the display-mode plumbing
18
+ // are `addVideo`'s, unchanged. This file adds exactly two things on top: a small canvas-owned
19
+ // paint loop for the cases `addVideo` cannot cover (below), and the SDK-drawn transport chrome.
20
+ //
21
+ // ── TRACKING LOSS, AND WHY THE "MONO" PATH IS ITS OWN LOOP ─────────────────────────────────
22
+ //
23
+ // Tracking loss is handled by the CORE, not here. Since DisplayXR Browser patch 0195 the session
24
+ // carries the runtime's tracking state (`XRSession.trackingState`: 'tracking' | 'searching' |
25
+ // 'unknown', plus `trackingstatechange`), mirrored as `wall.trackingState` and
26
+ // `wall.on('trackingstatechange')`. With `createInline3D({ untrackedFallback: 'mono' })` the
27
+ // manager eases every image/video tile to flat on 'searching' and back on 'tracking'
28
+ // (Inline3D#_trackBakedStereo) — the left eye in both halves of the SBS buffer, which stays a
29
+ // valid pair for the live layer. A `format:'sbs'` player is `addVideo` underneath, so it gets that
30
+ // for free; nothing in this file asks for it. The option is OFF by default because on a MANAGED
31
+ // display (Leia) the vendor already goes 2D before it reports 'searching'; it is how a page on a
32
+ // MANUAL display does its part.
33
+ //
34
+ // The other mono path in the core is `win.sbs` + `Inline3D._paintMono()`: a 1:1, left-eye-only
35
+ // frame for a window whose weave layer is NOT live (off-screen in lazy mode, an unsupported
36
+ // browser, a layer that failed to construct). Neither of the two can carry `format:'mono'`'s job:
37
+ // content that is genuinely flat and should never be split into eyes, whether or not the browser
38
+ // can weave.
39
+ //
40
+ // So: `format:'sbs'` calls `wall.addVideo()` unchanged and inherits both. `format:'mono'` — and
41
+ // `format:'sbs'` on an absent/unsupported wall, where `wall.addVideo` does not exist to call —
42
+ // run a small paint loop this module owns, with the same visual convention (flat, 1:1; the left
43
+ // eye for content that IS a stereo pair but cannot be woven; the WHOLE frame for content that is
44
+ // genuinely 2D). Feeding a deliberately-flat source through `addVideo` as a fake zero-disparity
45
+ // SBS pair was considered and rejected for v1: it would mean re-encoding the source through a
46
+ // canvas-captured MediaStream into a second hidden `<video>`, which is out of scope here.
47
+
48
+ /** @typedef {'sbs'|'mono'} PlayerFormat */
49
+
50
+ // 'tb' (top/bottom: left eye on top) matches ./splat setVideo's format vocabulary.
51
+ const VALID_FORMATS = new Set(['sbs', 'tb', 'mono']);
52
+ const VALID_POSTER_FORMATS = new Set(['sbs', 'tb', 'mono']);
53
+ // 'bars' and 'call' are the Show Spatial team's layouts: 'bars' puts back / play / elapsed / scrub /
54
+ // remaining in one row inside the bottom black bar; 'call' puts the name and a timer bar on top and
55
+ // the controls at the bottom, around a 2.39:1 band.
56
+ const VALID_SKINS = new Set(['classic', 'dock', 'bars', 'call']);
57
+ // A black bar must be at least this tall (CSS px, before `size` zoom) for the controls to live in
58
+ // it; a thinner one falls back to the overlay at the bottom of the tile.
59
+ const MIN_BAR_PX = 44;
60
+ // 'call' is a picture in a scope band.
61
+ const CALL_DEFAULT_BAND = 2.39;
62
+ const VALID_SIZES = new Set(['s', 'm', 'l']);
63
+ // ./splat setVideo's `fit` values, same names and meaning (inline3d-splat-video.js VIDEO_FITS).
64
+ const VALID_FITS = new Set(['contain', 'cover']);
65
+ // What each size multiplies the transport by. Applied with CSS `zoom` on each overlay's CONTENT
66
+ // (never the overlay boxes' own positioning), so every icon, font and hit target scales together.
67
+ /**
68
+ * Named accents: `accent: 'sunset'` or any CSS colour. Each is light enough that the dock's dark
69
+ * play glyph (#07111d) stays legible on it, and saturated enough to read as the brand colour on a
70
+ * black tile.
71
+ */
72
+ export const PLAYER_ACCENTS = Object.freeze({
73
+ azure: '#4da3ff',
74
+ violet: '#9b7bff',
75
+ magenta: '#ff4fa3',
76
+ sunset: '#ff7a45',
77
+ amber: '#ffc23d',
78
+ lime: '#9be15d',
79
+ mint: '#35e0b0',
80
+ ice: '#8fe3ff',
81
+ });
82
+ /** A named accent to its colour; anything else is passed through as a CSS colour. */
83
+ export const resolveAccent = (a) => (typeof a === 'string' && PLAYER_ACCENTS[a.toLowerCase()]) || a;
84
+ export const PLAYER_SIZE_SCALE = Object.freeze({ s: 0.84, m: 1, l: 1.28 });
85
+ const VALID_CONTROLS = new Set(['sdk', 'none']);
86
+
87
+ function pickEnum(value, allowed, fallback, label) {
88
+ if (value === undefined) return fallback;
89
+ if (allowed.has(value)) return value;
90
+ console.warn(`[inline3d/player] invalid ${label} "${value}" — using "${fallback}"`);
91
+ return fallback;
92
+ }
93
+
94
+ // ── setSource transitions: the splat module's vocabulary, the subset a video can mean ───────────
95
+ //
96
+ // `./splat` (1.10-1.17) settled the house spelling: `transition`, `durationMs`, `easing`,
97
+ // `outgoing`, with `fadeMs` as the legacy alias for a crossfade. The player takes the same names
98
+ // so a page that swaps splats and videos writes one options object. What it takes of them is
99
+ // what a flat SBS frame can mean: a CUT and a CROSSFADE. `flip`, `wavefront`, the particle and
100
+ // sequence transitions all move or re-light a 3D photo's gaussians; a video frame has none, so
101
+ // they are refused BY NAME rather than silently turned into a crossfade.
102
+ //
103
+ // `outgoing` is 'frozen' only. The dissolve runs from the outgoing title's LAST frame (one
104
+ // <video>, see the dissolve section), which is the splat module's 'frozen'. The splat docs warn
105
+ // that frozen "reads as tracking pausing" on a tracked panel — true of a splat, which re-renders
106
+ // per head pose, and not of an SBS video, whose disparity is baked into the frame either way.
107
+ // 'live' would need two decoding <video>s; refused rather than pretended.
108
+
109
+ /** Named easings — the same names and curves as `./splat` (inline3d-splat-effects.js EASINGS). */
110
+ export const PLAYER_EASINGS = Object.freeze({
111
+ linear: (x) => x,
112
+ easeInQuad: (x) => x * x,
113
+ easeOutQuad: (x) => 1 - (1 - x) * (1 - x),
114
+ easeInOutQuad: (x) => (x < 0.5 ? 2 * x * x : 1 - Math.pow(-2 * x + 2, 2) / 2),
115
+ easeInCubic: (x) => x * x * x,
116
+ easeOutCubic: (x) => 1 - Math.pow(1 - x, 3),
117
+ easeInOutCubic: (x) => (x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2),
118
+ easeInOutSine: (x) => -(Math.cos(Math.PI * x) - 1) / 2,
119
+ });
120
+ const PLAYER_TRANSITIONS = ['cut', 'crossfade'];
121
+ /** `./splat`'s other transitions — named in the refusal so the page knows it was not a typo. */
122
+ const SPLAT_ONLY_TRANSITIONS = ['flip', 'wavefront', 'reassemble', 'swarm', 'burst', 'shimmer-cross', 'dust', 'sequence'];
123
+ export const DEFAULT_CROSSFADE_MS = 600;
124
+ const DEFAULT_TRANSITION_EASING = 'easeInOutSine';
125
+
126
+ /**
127
+ * Resolve setSource transition options into `{ type, durationMs, easing, ease }`. Pure.
128
+ *
129
+ * `base` is what construction resolved: a per-call option overrides it field by field, so
130
+ * `setSource(src, { durationMs: 1200 })` lengthens the player's crossfade without restating it.
131
+ * Precedence for the duration: `durationMs`, then the legacy `fadeMs`, then `base`, then 600 ms.
132
+ * A bare `fadeMs > 0` still means a crossfade and `fadeMs: 0` a cut, exactly as in 1.10.
133
+ *
134
+ * @param {object} [opts]
135
+ * @param {object} [base]
136
+ */
137
+ export function resolveTransition(opts = {}, base = null) {
138
+ const t = opts.transition;
139
+ if (t !== undefined && (typeof t !== 'string' || !PLAYER_TRANSITIONS.includes(t))) {
140
+ const name = typeof t === 'string' ? t : t && typeof t === 'object' ? t.type || 'object' : String(t);
141
+ const splatOnly = typeof t === 'object' || SPLAT_ONLY_TRANSITIONS.includes(name);
142
+ throw new Error(
143
+ `@displayxr/inline3d/player: transition '${name}' is not a player transition` +
144
+ (splatOnly ? " — it is one of ./splat's, which move a 3D photo's gaussians; a video frame has none." : '.') +
145
+ ` Known: ${PLAYER_TRANSITIONS.join(', ')}.`
146
+ );
147
+ }
148
+ if (opts.outgoing !== undefined && opts.outgoing !== 'frozen') {
149
+ throw new Error(
150
+ `@displayxr/inline3d/player: outgoing '${opts.outgoing}' is not supported — the player dissolves ` +
151
+ "from the outgoing title's last frame ('frozen'), with one <video>."
152
+ );
153
+ }
154
+ const e = opts.easing;
155
+ if (e !== undefined && typeof e !== 'function' && !PLAYER_EASINGS[e]) {
156
+ throw new Error(
157
+ `@displayxr/inline3d/player: unknown easing '${e}'. Known: ${Object.keys(PLAYER_EASINGS).join(', ')}, or a function.`
158
+ );
159
+ }
160
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? Math.max(0, v) : undefined);
161
+ const fade = num(opts.fadeMs);
162
+ const dur = num(opts.durationMs);
163
+ let type = t || (fade !== undefined ? (fade > 0 ? 'crossfade' : 'cut') : base ? base.type : 'cut');
164
+ const baseDur = base && base.durationMs > 0 ? base.durationMs : DEFAULT_CROSSFADE_MS;
165
+ const durationMs = dur !== undefined ? dur : fade > 0 ? fade : baseDur;
166
+ // A crossfade of no length IS a cut; say so in the result rather than run a zero-length ramp.
167
+ if (type === 'crossfade' && durationMs <= 0) type = 'cut';
168
+ const easing = e !== undefined ? e : base ? base.easing : DEFAULT_TRANSITION_EASING;
169
+ const ease = typeof easing === 'function' ? easing : PLAYER_EASINGS[easing];
170
+ return { type, durationMs: type === 'cut' ? 0 : durationMs, easing, ease };
171
+ }
172
+
173
+ /**
174
+ * Apply defaults and validate enum options. Pure (no DOM), so it is unit-testable without a
175
+ * browser — see test/player-options.test.mjs.
176
+ * @param {object} [opts]
177
+ */
178
+ export function normalizePlayerOptions(opts = {}) {
179
+ return {
180
+ format: pickEnum(opts.format, VALID_FORMATS, 'sbs', 'format'),
181
+ controls: pickEnum(opts.controls, VALID_CONTROLS, 'sdk', 'controls'),
182
+ // The transport's look. 'classic': a full-width bottom band. 'dock': a floating rounded dock
183
+ // with lit round buttons. Same controls, same overlay rules, CSS only.
184
+ skin: pickEnum(opts.skin, VALID_SKINS, 'classic', 'skin'),
185
+ // Transport scale: 's' | 'm' | 'l' (see PLAYER_SIZE_SCALE).
186
+ size: pickEnum(typeof opts.size === 'string' ? opts.size.toLowerCase() : opts.size, VALID_SIZES, 'm', 'size'),
187
+ // How an eye image meets a tile of a different aspect — ./splat setVideo's vocabulary.
188
+ // 'contain': the whole eye image, transparent bars where the aspects differ (the page shows
189
+ // through). 'cover': the tile is full and the overflow is cut. Unset (null): stretched to the
190
+ // tile, the 1.x behaviour, so a page that never asks keeps its pixels.
191
+ fit: opts.fit === undefined || opts.fit === null ? null : pickEnum(opts.fit, VALID_FITS, null, 'fit'),
192
+ // A letterboxed "band" slot: the picture is fitted into a centred band of this aspect inside
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
+ // 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),
196
+ // What the poster image IS: 'mono' (one image, both eyes — the 1.x behaviour), or a stereo
197
+ // still laid out like the video ('sbs' / 'tb'), painted eye by eye.
198
+ posterFormat: pickEnum(opts.posterFormat, VALID_POSTER_FORMATS, 'mono', 'posterFormat'),
199
+ poster: opts.poster || null,
200
+ autoplay: !!opts.autoplay,
201
+ muted: opts.muted === undefined ? true : !!opts.muted,
202
+ loop: !!opts.loop,
203
+ keyboard: opts.keyboard === undefined ? true : !!opts.keyboard,
204
+ // The playlist (RFC 0001 Addendum A4): titles, and what happens at a title's end and the list's.
205
+ titles: normalizeTitles(opts.titles),
206
+ loopList: !!opts.loopList,
207
+ // One decoding player per group: previews on a shelf share a group, and starting one pauses
208
+ // the rest, so only one video decodes at a time. Any string; unset = no group.
209
+ group: typeof opts.group === 'string' && opts.group ? opts.group : null,
210
+ autoAdvance: !!opts.autoAdvance,
211
+ fadeMs: typeof opts.fadeMs === 'number' && opts.fadeMs > 0 ? opts.fadeMs : 0,
212
+ // The resolved setSource transition (see resolveTransition). `fadeMs` above stays for 1.10
213
+ // callers reading it back; this is what the player actually runs.
214
+ transition: resolveTransition(opts),
215
+ // Chrome skin. `accent` is written to the transport's `--dxr-accent` custom property, so a
216
+ // page brands the player without forking its CSS; anything CSS accepts as a colour works.
217
+ accent: typeof opts.accent === 'string' && opts.accent ? opts.accent : null,
218
+ // A small "3D" pill in the control row. Opt-in, and the PAGE decides — the module will not
219
+ // infer it from `wall.supported`, because a supported wall whose tile is scrolled away, or
220
+ // whose panel sits in a 2D mode, is not showing 3D at that moment and the badge would lie.
221
+ badge3d: opts.badge3d === undefined ? false : opts.badge3d,
222
+ // A now-playing line over the top of the tile, fading with the transport. Page-supplied text
223
+ // (a catalogue knows the title; the file name is not one).
224
+ title: typeof opts.title === 'string' && opts.title ? opts.title : null,
225
+ // -10 s / +10 s buttons beside play (J / L on the keyboard either way).
226
+ skipButtons: opts.skipButtons === undefined ? true : !!opts.skipButtons,
227
+ // A fullscreen button (and F). The CONTAINER goes fullscreen, not the canvas, so the
228
+ // transport — its sibling overlays — comes along.
229
+ fullscreen: opts.fullscreen === undefined ? true : !!opts.fullscreen,
230
+ crossOrigin: opts.crossOrigin,
231
+ width: opts.width,
232
+ height: opts.height,
233
+ cornerRadius: opts.cornerRadius,
234
+ feather: opts.feather,
235
+ observe: opts.observe,
236
+ };
237
+ }
238
+
239
+ /**
240
+ * A playlist, validated. Pure. Each entry is `{ id, src, title?, poster? }`; a bare source
241
+ * (string, Blob or candidates array) is accepted as `{ src }`. A missing `id` becomes the entry's
242
+ * index as a string; a duplicate or missing `src` is a page bug and throws at the call.
243
+ * @param {Array|undefined|null} list
244
+ * @returns {ReadonlyArray<{id:string, src:any, title?:string, poster?:string}>}
245
+ */
246
+ export function normalizeTitles(list) {
247
+ if (list === undefined || list === null) return Object.freeze([]);
248
+ if (!Array.isArray(list)) throw new TypeError('@displayxr/inline3d/player: titles must be an array.');
249
+ const seen = new Set();
250
+ const out = list.map((t, i) => {
251
+ const e = t && typeof t === 'object' && !Array.isArray(t) && !(typeof Blob !== 'undefined' && t instanceof Blob) ? t : { src: t };
252
+ if (e.src === undefined || e.src === null || e.src === '') {
253
+ throw new TypeError(`@displayxr/inline3d/player: titles[${i}] has no src.`);
254
+ }
255
+ const id = e.id === undefined || e.id === null ? String(i) : String(e.id);
256
+ if (seen.has(id)) throw new TypeError(`@displayxr/inline3d/player: duplicate title id "${id}".`);
257
+ seen.add(id);
258
+ const entry = { id, src: e.src };
259
+ if (typeof e.title === 'string') entry.title = e.title;
260
+ if (typeof e.poster === 'string') entry.poster = e.poster;
261
+ return Object.freeze(entry);
262
+ });
263
+ return Object.freeze(out);
264
+ }
265
+
266
+ /**
267
+ * The index `next()` goes to, or -1 when there is none (the end of the list without `loopList`).
268
+ * Pure. From no current title it starts at the first.
269
+ */
270
+ export function nextIndex(i, n, loopList) {
271
+ if (!(n > 0)) return -1;
272
+ if (i < 0) return 0;
273
+ if (i + 1 < n) return i + 1;
274
+ return loopList ? 0 : -1;
275
+ }
276
+
277
+ /**
278
+ * What `back()` does, like a media remote's "previous": more than `restartAfterS` into the title
279
+ * restarts it; otherwise it goes to the previous title (wrapping only with `loopList`), and at
280
+ * the first title it restarts. Pure. Returns `{ restart: true }` or `{ index }`.
281
+ */
282
+ export function backTarget(currentTime, i, n, loopList, restartAfterS = 3) {
283
+ if (!(n > 0) || i < 0) return { restart: true };
284
+ if (currentTime > restartAfterS) return { restart: true };
285
+ if (i > 0) return { index: i - 1 };
286
+ return loopList && n > 1 ? { index: n - 1 } : { restart: true };
287
+ }
288
+
289
+ /**
290
+ * An aspect ratio from a number or a 'W:H' / 'W/H' string; null when absent or invalid. Pure.
291
+ * @param {number|string|undefined|null} a
292
+ */
293
+ export function parseAspect(a) {
294
+ if (a === undefined || a === null || a === '') return null;
295
+ if (typeof a === 'number') return Number.isFinite(a) && a > 0 ? a : null;
296
+ if (typeof a === 'string') {
297
+ const m = a.trim().match(/^(\d+(?:\.\d+)?)\s*[:/x]\s*(\d+(?:\.\d+)?)$/i);
298
+ if (m) {
299
+ const v = Number(m[1]) / Number(m[2]);
300
+ return Number.isFinite(v) && v > 0 ? v : null;
301
+ }
302
+ const n = Number(a);
303
+ return Number.isFinite(n) && n > 0 ? n : null;
304
+ }
305
+ return null;
306
+ }
307
+
308
+ /**
309
+ * The rect of one eye inside a stereo frame. Pure. 'sbs': left/right halves; 'tb': top/bottom
310
+ * halves (left eye on top, as ./splat setVideo reads it); 'mono': the whole frame for both.
311
+ * @param {'sbs'|'tb'|'mono'} layout @param {number} w @param {number} h @param {0|1} eye
312
+ */
313
+ export function eyeRect(layout, w, h, eye) {
314
+ if (layout === 'sbs') return { x: eye ? w / 2 : 0, y: 0, w: w / 2, h };
315
+ if (layout === 'tb') return { x: 0, y: eye ? h / 2 : 0, w, h: h / 2 };
316
+ return { x: 0, y: 0, w, h };
317
+ }
318
+
319
+ /**
320
+ * The centred band of aspect `band` inside a `w`×`h` box (full width if the band is wider than
321
+ * the box, full height if narrower); the whole box when `band` is unset. Pure.
322
+ */
323
+ export function bandBox(w, h, band) {
324
+ if (!(band > 0) || !(w > 0 && h > 0)) return { x: 0, y: 0, w, h };
325
+ const a = w / h;
326
+ if (band >= a) {
327
+ const bh = w / band;
328
+ return { x: 0, y: (h - bh) / 2, w, h: bh };
329
+ }
330
+ const bw = h * band;
331
+ return { x: (w - bw) / 2, y: 0, w: bw, h };
332
+ }
333
+
334
+ /**
335
+ * Draw one eye of `src` (laid out as `layout`) into a destination box, fitted. `band` narrows the
336
+ * box to a centred letterbox slot first; a band with no `fit` means 'contain'.
337
+ */
338
+ function drawFittedEye(ctx, src, srcW, srcH, layout, eye, dx, dy, dw, dh, fit, band) {
339
+ const e = eyeRect(layout, srcW, srcH, eye);
340
+ const b = bandBox(dw, dh, band);
341
+ const r = fitRect(e.w, e.h, b.w, b.h, fit || (band ? 'contain' : null));
342
+ ctx.drawImage(src, e.x + r.sx, e.y + r.sy, r.sw, r.sh, dx + b.x + r.dx, dy + b.y + r.dy, r.dw, r.dh);
343
+ }
344
+
345
+ /**
346
+ * Pick the first source this browser can play, from candidates listed BEST FIRST. Each candidate
347
+ * is a URL string, or `{ src, type }` where `type` is a full `canPlayType` string — e.g.
348
+ * `'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
350
+ * `'video/mp4'` alone still says 'maybe'. A 'probably' wins over an earlier 'maybe'. A candidate
351
+ * without a type is taken as-is if nothing typed was 'probably'. Warns and returns the first when
352
+ * nothing is playable, so the `error` event (and the poster) still happens the usual way.
353
+ *
354
+ * @param {string|Blob|Array<string|Blob|{src:string|Blob,type?:string}>} candidates
355
+ * @param {(type:string) => string} [canPlayType] injectable for tests; defaults to a <video>'s
356
+ */
357
+ export function pickSource(candidates, canPlayType) {
358
+ if (!Array.isArray(candidates)) return candidates;
359
+ const list = candidates.map((c) => (c && typeof c === 'object' && !(typeof Blob !== 'undefined' && c instanceof Blob) ? c : { src: c }));
360
+ if (!list.length) return undefined;
361
+ const probe =
362
+ canPlayType ||
363
+ ((t) => {
364
+ try {
365
+ return typeof document !== 'undefined' ? document.createElement('video').canPlayType(t) : '';
366
+ } catch {
367
+ return '';
368
+ }
369
+ });
370
+ let maybe = null;
371
+ let untyped = null;
372
+ for (const c of list) {
373
+ if (!c.type) {
374
+ if (!untyped) untyped = c;
375
+ continue;
376
+ }
377
+ const ans = probe(c.type);
378
+ if (ans === 'probably') return c.src;
379
+ if (ans === 'maybe' && !maybe) maybe = c;
380
+ }
381
+ if (untyped) return untyped.src;
382
+ if (maybe) return maybe.src;
383
+ console.warn(
384
+ '[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.',
386
+ list.map((c) => c.type || '(untyped)')
387
+ );
388
+ return list[0].src;
389
+ }
390
+
391
+ /**
392
+ * The source and destination rects that fit a `sw`×`sh` image into a `dw`×`dh` box. Pure.
393
+ * `'contain'` letterboxes the destination (the whole source shows); `'cover'` crops the source
394
+ * (the whole box fills); anything else stretches. Returns `{ sx, sy, sw, sh, dx, dy, dw, dh }`
395
+ * in the same order `drawImage`'s 9-argument form takes them.
396
+ * @param {number} sw @param {number} sh @param {number} dw @param {number} dh
397
+ * @param {'contain'|'cover'|null} fit
398
+ */
399
+ export function fitRect(sw, sh, dw, dh, fit) {
400
+ const full = { sx: 0, sy: 0, sw, sh, dx: 0, dy: 0, dw, dh };
401
+ if (!(sw > 0 && sh > 0 && dw > 0 && dh > 0) || (fit !== 'contain' && fit !== 'cover')) return full;
402
+ const sa = sw / sh;
403
+ const da = dw / dh;
404
+ if (Math.abs(sa - da) < 1e-6) return full;
405
+ if (fit === 'contain') {
406
+ if (sa > da) {
407
+ const h = dw / sa;
408
+ return { sx: 0, sy: 0, sw, sh, dx: 0, dy: (dh - h) / 2, dw, dh: h };
409
+ }
410
+ const w = dh * sa;
411
+ return { sx: 0, sy: 0, sw, sh, dx: (dw - w) / 2, dy: 0, dw: w, dh };
412
+ }
413
+ if (sa > da) {
414
+ const w = sh * da;
415
+ return { sx: (sw - w) / 2, sy: 0, sw: w, sh, dx: 0, dy: 0, dw, dh };
416
+ }
417
+ const h = sw / da;
418
+ return { sx: 0, sy: (sh - h) / 2, sw, sh: h, dx: 0, dy: 0, dw, dh };
419
+ }
420
+
421
+ /**
422
+ * Format a time in seconds as `M:SS`, or `H:MM:SS` past an hour. Pure. NaN/negative/Infinity
423
+ * (duration before metadata loads, a live stream) render as `0:00` rather than `NaN:NaN`.
424
+ * @param {number} seconds
425
+ */
426
+ export function formatTime(seconds) {
427
+ if (!Number.isFinite(seconds) || seconds < 0) return '0:00';
428
+ const total = Math.floor(seconds);
429
+ const s = total % 60;
430
+ const m = Math.floor(total / 60) % 60;
431
+ const h = Math.floor(total / 3600);
432
+ const pad = (n) => String(n).padStart(2, '0');
433
+ return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
434
+ }
435
+
436
+ /**
437
+ * The keyboard map: Space/K play-pause, arrows seek ±5 s, J/L seek ±10 s, M mute. Pure — takes
438
+ * `KeyboardEvent.key`, returns an action name or null for an unmapped key (so the caller does
439
+ * nothing, in particular never calls `preventDefault()`, and Tab / other keys behave normally).
440
+ * @param {string} key
441
+ */
442
+ export function mapKeyToAction(key) {
443
+ switch (key) {
444
+ case ' ':
445
+ case 'Spacebar':
446
+ case 'Space':
447
+ case 'k':
448
+ case 'K':
449
+ return 'toggle';
450
+ case 'ArrowLeft':
451
+ return 'seek-5';
452
+ case 'ArrowRight':
453
+ return 'seek+5';
454
+ case 'j':
455
+ case 'J':
456
+ return 'seek-10';
457
+ case 'l':
458
+ case 'L':
459
+ return 'seek+10';
460
+ case 'm':
461
+ case 'M':
462
+ return 'mute';
463
+ case 'f':
464
+ case 'F':
465
+ return 'fullscreen';
466
+ default:
467
+ return null;
468
+ }
469
+ }
470
+
471
+ function resolveSrcUrl(src) {
472
+ if (typeof src === 'string') return src;
473
+ if (typeof Blob !== 'undefined' && src instanceof Blob) return URL.createObjectURL(src);
474
+ return src;
475
+ }
476
+
477
+ function resolveCrossOrigin(src, explicit) {
478
+ if (explicit !== undefined) return explicit || null;
479
+ if (typeof src !== 'string' || typeof location === 'undefined') return null;
480
+ try {
481
+ const url = new URL(src, location.href);
482
+ if (url.origin !== location.origin) return 'anonymous';
483
+ } catch {
484
+ /* relative or unparsable — same-origin */
485
+ }
486
+ return null;
487
+ }
488
+
489
+ /**
490
+ * Paint the poster into BOTH halves of an already-sized SBS buffer, eye by eye. A 'mono' poster
491
+ * is the same image in both (zero disparity, flat — the 1.x behaviour); an 'sbs' / 'tb' still
492
+ * gives each eye its own half, so the poster is 3D before the first frame. Fitted like the video.
493
+ */
494
+ function paintPosterSBS(canvas, img, look = {}) {
495
+ const w = canvas.width;
496
+ const h = canvas.height;
497
+ if (!w || !h) return false;
498
+ const ctx = canvas.getContext('2d');
499
+ const halfW = w / 2;
500
+ const iw = img.naturalWidth || img.width;
501
+ const ih = img.naturalHeight || img.height;
502
+ ctx.clearRect(0, 0, w, h);
503
+ if (!iw || !ih || (!look.fit && !look.band && (look.posterFormat || 'mono') === 'mono')) {
504
+ ctx.drawImage(img, 0, 0, halfW, h);
505
+ ctx.drawImage(img, halfW, 0, halfW, h);
506
+ return true;
507
+ }
508
+ const layout = look.posterFormat || 'mono';
509
+ drawFittedEye(ctx, img, iw, ih, layout, 0, 0, 0, halfW, h, look.fit, look.band);
510
+ drawFittedEye(ctx, img, iw, ih, layout, 1, halfW, 0, halfW, h, look.fit, look.band);
511
+ return true;
512
+ }
513
+
514
+ /**
515
+ * Paint the poster into a LIVE, woven SBS window until the video has a real frame to show.
516
+ *
517
+ * Runs until the FIRST FRAME, not until a timer expires. The earlier version gave up after
518
+ * ~3 s of rAF, which is the wrong shape twice over: `preload:'metadata'` + no autoplay is the
519
+ * default here, so a title that is never played never reaches `readyState >= 2` at all and the
520
+ * tile went black the moment the timer ran out; and a lazy tile that is still 0×0 at 3 s (it
521
+ * hasn't scrolled into view yet) had nothing painted into it by the time it activated.
522
+ *
523
+ * It is a 4 Hz `setTimeout`, not rAF, and that is deliberate: the SDK's own paint re-commits
524
+ * whatever the canvas already holds while a video is below `readyState 2`
525
+ * (`_recommitLastFrame`), so the poster only has to be (re)committed when the buffer is
526
+ * resized or replaced — 60 Hz would buy nothing and cost a full-resolution SBS draw per frame
527
+ * on a tile that is, by definition, showing a still.
528
+ */
529
+ function startPosterPoll(canvas, getPoster, isVideoReady, look) {
530
+ let timer = 0;
531
+ let stopped = false;
532
+ function tick() {
533
+ if (stopped || isVideoReady()) return;
534
+ const img = getPoster();
535
+ if (img) paintPosterSBS(canvas, img, look);
536
+ timer = setTimeout(tick, 250);
537
+ }
538
+ tick();
539
+ return {
540
+ stop() {
541
+ stopped = true;
542
+ clearTimeout(timer);
543
+ },
544
+ };
545
+ }
546
+
547
+ /**
548
+ * The paint loop this module owns for `format:'mono'`, and for `format:'sbs'` on an
549
+ * absent/unsupported wall (see the module doc comment for why). Sizes the canvas to its CSS
550
+ * box × devicePixelRatio (capped at 2, the same convention `addImage`/`addVideo` use), redraws
551
+ * on box changes, and — once the video is decoding — paints only on a NEW frame via
552
+ * `requestVideoFrameCallback` where available, falling back to an every-frame `drawImage` loop
553
+ * where it is not.
554
+ */
555
+ function attachFlatPaint(canvas, video, { mode, getPoster, dissolve, fit = null, layout = 'sbs', band = null, posterFormat = 'mono' }) {
556
+ const ctx = canvas.getContext('2d');
557
+ let stopped = false;
558
+ let rafId = 0;
559
+ let rvfcId = 0;
560
+ let ro = null;
561
+
562
+ function sizeCanvas() {
563
+ const dpr = Math.min((typeof window !== 'undefined' && window.devicePixelRatio) || 1, 2);
564
+ const rect = canvas.getBoundingClientRect
565
+ ? canvas.getBoundingClientRect()
566
+ : { width: canvas.clientWidth, height: canvas.clientHeight };
567
+ const w = Math.max(1, Math.round((rect.width || canvas.clientWidth || 300) * dpr));
568
+ const h = Math.max(1, Math.round((rect.height || canvas.clientHeight || 150) * dpr));
569
+ if (canvas.width !== w) canvas.width = w;
570
+ if (canvas.height !== h) canvas.height = h;
571
+ }
572
+
573
+ const nowMs = () =>
574
+ typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now();
575
+
576
+ function draw() {
577
+ if (stopped) return;
578
+ const w = canvas.width;
579
+ const h = canvas.height;
580
+ if (!w || !h) return;
581
+ // With a dissolve armed the mixer is the source of truth: it holds either the live frame,
582
+ // the frozen outgoing one, or the blend of the two. Its dimensions match the video's, so
583
+ // the SBS half-crop below is the same arithmetic either way.
584
+ if (dissolve) dissolve.paint(nowMs());
585
+ const src = dissolve ? dissolve.el : video;
586
+ const ready = (src.readyState || 0) >= 2 && (src.videoWidth || src.width);
587
+ if (ready) {
588
+ const vw = src.videoWidth || src.width;
589
+ const vh = src.videoHeight || src.height;
590
+ ctx.clearRect(0, 0, w, h);
591
+ // One eye (the left one) for a stereo source shown flat, the whole frame for genuinely flat
592
+ // content — then fitted into the canvas (and its band) the way the options ask.
593
+ drawFittedEye(ctx, src, vw, vh, mode === 'mono' ? 'mono' : layout, 0, 0, 0, w, h, fit, band);
594
+ return;
595
+ }
596
+ const poster = getPoster();
597
+ if (poster) {
598
+ ctx.clearRect(0, 0, w, h);
599
+ const pw = poster.naturalWidth || poster.width;
600
+ const ph = poster.naturalHeight || poster.height;
601
+ if (pw && ph) drawFittedEye(ctx, poster, pw, ph, posterFormat, 0, 0, 0, w, h, fit, band);
602
+ }
603
+ }
604
+
605
+ function loop() {
606
+ if (stopped) return;
607
+ draw();
608
+ // A running dissolve advances on frames the video does not produce, so it needs rAF.
609
+ if (!dissolve?.active && video.readyState >= 2 &&
610
+ typeof video.requestVideoFrameCallback === 'function') {
611
+ rvfcId = video.requestVideoFrameCallback(loop);
612
+ } else {
613
+ rafId = requestAnimationFrame(loop);
614
+ }
615
+ }
616
+
617
+ sizeCanvas();
618
+ if (typeof ResizeObserver === 'function') {
619
+ ro = new ResizeObserver(() => {
620
+ sizeCanvas();
621
+ draw();
622
+ });
623
+ ro.observe(canvas);
624
+ }
625
+ loop();
626
+
627
+ return {
628
+ stop() {
629
+ stopped = true;
630
+ if (rafId) cancelAnimationFrame(rafId);
631
+ if (rvfcId && typeof video.cancelVideoFrameCallback === 'function') {
632
+ video.cancelVideoFrameCallback(rvfcId);
633
+ }
634
+ ro?.disconnect();
635
+ },
636
+ forceRepaint() {
637
+ sizeCanvas();
638
+ draw();
639
+ },
640
+ };
641
+ }
642
+
643
+ // ── SDK-drawn transport chrome ──────────────────────────────────────────────────────────────
644
+ //
645
+ // THREE HARD CONSTRAINTS shape everything below; they are the overlay contract from
646
+ // docs/authoring-inline-3d.md § "2D overlays ON a 3D window", not taste:
647
+ //
648
+ // 1. EVERY overlay is a PARTIAL region of the tile, never the whole thing. A legacy
649
+ // (pre-draw-order-occlusion) browser excludes an overlay by geometrically matching its rect
650
+ // to a composited quad at >=70% area overlap — a full-tile plate matches the CANVAS's own
651
+ // quad, so the canvas leaves the weave input and the tile presents its raw side-by-side
652
+ // pair: two squished halves, no 3D. That is why the "scrim" here is a bottom band with a
653
+ // capped height, and NOT the full-height gradient a 2D player would reach for. Same reason
654
+ // the centre badge is a small circle rather than a full-tile click-catcher.
655
+ // 2. NO `backdrop-filter`, anywhere. Exclusion needs the element as an isolated composited
656
+ // resource; `backdrop-filter` is defined as a function of what is behind it, so there is
657
+ // nothing to hand the compositor and the element either weaves anyway or drops out. The
658
+ // near-solid background below is the documented substitute for frosted glass.
659
+ // 3. Promotion is `will-change: transform`, never a CSS `filter` — a filter's render surface is
660
+ // flattened away in the weave path.
661
+ //
662
+ // Everything visual is driven off CSS custom properties (`--dxr-accent`, `--dxr-p`, `--dxr-b`)
663
+ // so the per-frame work is two property writes, not a rebuilt gradient string, and so a page can
664
+ // re-skin the transport without forking it.
665
+
666
+ let styleInjected = false;
667
+ const PLAYER_STYLE_ID = 'dxr-player-style';
668
+ const PLAYER_CSS = `
669
+ /* The tokens live on the HOST, not on the bar: the centre badge, the key pip and the spinner are
670
+ SIBLINGS of the bar (each has to be its own partial overlay — constraint 1), so tokens declared
671
+ on the bar would not reach them and they would render with an invalid background/accent. */
672
+ .dxr-player-host{--dxr-accent:#4da3ff;--dxr-ink:#fff;--dxr-shell:rgba(10,11,15,.92);}
673
+ .dxr-player{position:absolute;left:0;right:0;bottom:0;z-index:2;box-sizing:border-box;
674
+ padding:34px 12px 10px 12px;color:var(--dxr-ink);
675
+ font:13px/1.35 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
676
+ /* A BOUNDED bottom band — see constraint 1. The gradient fades out well before the top of the
677
+ tile, so the excluded rect stays a partial region at any tile height.
678
+ The ramp is front-loaded on purpose: the scrub row sits at the TOP of this band, and a
679
+ gentle linear fade leaves it sitting on near-transparent pixels — over bright or captioned
680
+ content (this sample's clip has burned-in credits exactly there) the track and the knob
681
+ stop being readable. Most of the darkening is therefore spent in the first 60%, and only
682
+ the last third is allowed to go sheer. */
683
+ background:linear-gradient(to top,rgba(8,9,12,.96) 0%,rgba(8,9,12,.92) 30%,
684
+ rgba(8,9,12,.80) 52%,rgba(8,9,12,.55) 70%,rgba(8,9,12,.22) 87%,rgba(8,9,12,0) 100%);
685
+ max-height:42%;
686
+ text-shadow:0 1px 2px rgba(0,0,0,.55);
687
+ opacity:1;transform:translateY(0);transition:opacity .22s ease,transform .22s ease;
688
+ will-change:transform;}
689
+ .dxr-player--hidden{opacity:0;transform:translateY(8px);pointer-events:none;}
690
+ .dxr-player *{box-sizing:border-box;}
691
+
692
+ /* ── scrub row ── */
693
+ .dxr-player-scrubwrap{--dxr-p:0%;--dxr-b:0%;position:relative;height:16px;margin:0 2px 2px;
694
+ display:flex;align-items:center;cursor:pointer;touch-action:none;}
695
+ .dxr-player-scrubwrap::before{content:"";position:absolute;left:0;right:0;height:4px;
696
+ border-radius:99px;background:
697
+ linear-gradient(to right,rgba(255,255,255,.34) 0 var(--dxr-b),transparent var(--dxr-b)),
698
+ rgba(255,255,255,.18);
699
+ transition:height .14s ease;}
700
+ .dxr-player-scrubwrap::after{content:"";position:absolute;left:0;height:4px;width:var(--dxr-p);
701
+ border-radius:99px;background:var(--dxr-accent);transition:height .14s ease;}
702
+ .dxr-player-scrubwrap:hover::before,.dxr-player-scrubwrap--active::before,
703
+ .dxr-player-scrubwrap:focus-within::before{height:6px;}
704
+ .dxr-player-scrubwrap:hover::after,.dxr-player-scrubwrap--active::after,
705
+ .dxr-player-scrubwrap:focus-within::after{height:6px;}
706
+ /* The real <input type=range> stays in the DOM, transparent, on top: native keyboard handling,
707
+ native AT semantics, native drag — we only take over how it LOOKS. */
708
+ .dxr-player-scrub{position:absolute;inset:0;width:100%;height:100%;margin:0;padding:0;
709
+ opacity:0;cursor:pointer;appearance:none;-webkit-appearance:none;background:transparent;}
710
+ .dxr-player-scrub::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:16px;
711
+ height:16px;border-radius:50%;background:#000;}
712
+ .dxr-player-scrub::-moz-range-thumb{width:16px;height:16px;border:0;border-radius:50%;
713
+ background:#000;}
714
+ .dxr-player-knob{position:absolute;left:var(--dxr-p);top:50%;width:13px;height:13px;
715
+ margin-left:-6.5px;margin-top:-6.5px;border-radius:50%;background:var(--dxr-accent);
716
+ box-shadow:0 1px 4px rgba(0,0,0,.55);pointer-events:none;
717
+ transform:scale(0);transition:transform .14s ease;}
718
+ .dxr-player-scrubwrap:hover .dxr-player-knob,.dxr-player-scrubwrap--active .dxr-player-knob,
719
+ .dxr-player-scrub:focus-visible~.dxr-player-knob{transform:scale(1);}
720
+ .dxr-player-scrub:focus-visible~.dxr-player-knob{box-shadow:0 0 0 3px rgba(77,163,255,.45),
721
+ 0 1px 4px rgba(0,0,0,.55);}
722
+ .dxr-player-tip{position:absolute;bottom:20px;left:var(--dxr-tip,0%);transform:translateX(-50%);
723
+ padding:3px 7px;border-radius:5px;background:var(--dxr-shell);font-variant-numeric:tabular-nums;
724
+ font-size:12px;line-height:1;white-space:nowrap;pointer-events:none;opacity:0;
725
+ transition:opacity .12s ease;}
726
+ .dxr-player-scrubwrap:hover .dxr-player-tip,
727
+ .dxr-player-scrubwrap--active .dxr-player-tip{opacity:1;}
728
+
729
+ /* ── control row ── */
730
+ .dxr-player-row{display:flex;align-items:center;gap:4px;min-height:32px;}
731
+ .dxr-player-btn{appearance:none;-webkit-appearance:none;border:0;background:transparent;
732
+ color:inherit;width:32px;height:32px;padding:6px;border-radius:7px;cursor:pointer;flex:none;
733
+ display:inline-flex;align-items:center;justify-content:center;
734
+ transition:background-color .14s ease,transform .14s ease;}
735
+ .dxr-player-btn svg{width:100%;height:100%;fill:currentColor;display:block;}
736
+ .dxr-player-btn:hover{background:rgba(255,255,255,.16);}
737
+ .dxr-player-btn:active{transform:scale(.92);}
738
+ .dxr-player-btn:focus-visible{outline:2px solid var(--dxr-accent);outline-offset:2px;}
739
+ .dxr-player-vol{display:flex;align-items:center;flex:none;}
740
+ .dxr-player-volslider{width:0;opacity:0;margin:0;height:4px;border-radius:99px;flex:none;
741
+ appearance:none;-webkit-appearance:none;cursor:pointer;
742
+ background:linear-gradient(to right,var(--dxr-accent) 0 var(--dxr-v,100%),
743
+ rgba(255,255,255,.22) var(--dxr-v,100%));
744
+ transition:width .18s ease,opacity .18s ease,margin .18s ease;}
745
+ .dxr-player-vol:hover .dxr-player-volslider,.dxr-player-vol:focus-within .dxr-player-volslider,
746
+ .dxr-player-volslider:focus-visible{width:62px;opacity:1;margin:0 8px 0 2px;}
747
+ .dxr-player-volslider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:11px;
748
+ height:11px;border-radius:50%;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.5);}
749
+ .dxr-player-volslider::-moz-range-thumb{width:11px;height:11px;border:0;border-radius:50%;
750
+ background:#fff;}
751
+ .dxr-player-volslider:focus-visible{outline:2px solid var(--dxr-accent);outline-offset:3px;}
752
+ .dxr-player-clock{margin-left:6px;font-variant-numeric:tabular-nums;letter-spacing:.2px;
753
+ white-space:nowrap;}
754
+ .dxr-player-clock b{font-weight:600;}
755
+ .dxr-player-clock span{opacity:.62;}
756
+ .dxr-player-spacer{flex:1 1 auto;}
757
+ .dxr-player-skip{padding:5px;}
758
+
759
+ /* ── now-playing line: a BOUNDED top band, its own partial overlay (constraint 1) ── */
760
+ .dxr-player-title{position:absolute;left:0;right:0;top:0;z-index:2;box-sizing:border-box;
761
+ padding:12px 16px 30px;max-height:30%;overflow:hidden;color:var(--dxr-ink);pointer-events:none;
762
+ font:600 15px/1.3 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;letter-spacing:.1px;
763
+ white-space:nowrap;text-overflow:ellipsis;text-shadow:0 1px 3px rgba(0,0,0,.6);
764
+ background:linear-gradient(to bottom,rgba(8,9,12,.78) 0%,rgba(8,9,12,.5) 55%,rgba(8,9,12,0) 100%);
765
+ opacity:1;transform:translateY(0);transition:opacity .22s ease,transform .22s ease;
766
+ will-change:transform;}
767
+ .dxr-player-title--hidden{opacity:0;transform:translateY(-8px);}
768
+
769
+ /* Fullscreen: the host fills the screen, black; the canvas itself is sized to fit (in JS, at the
770
+ tile's own aspect) — never object-fit, because the weave reads the element's whole rect, so
771
+ letterboxing INSIDE the canvas would put each eye's content where the other eye is sampled. */
772
+ .dxr-player-host:fullscreen{background:#000;display:flex;align-items:center;justify-content:center;}
773
+ .dxr-player-host:fullscreen>canvas{flex:none;}
774
+
775
+ .dxr-player-badge3d{font-size:10px;font-weight:700;letter-spacing:.9px;padding:3px 7px;
776
+ border-radius:5px;border:1px solid rgba(255,255,255,.28);opacity:.82;flex:none;}
777
+
778
+ /* ── centre affordances (small, partial — constraint 1) ── */
779
+ .dxr-player-centre{position:absolute;left:50%;top:50%;width:66px;height:66px;margin:-33px 0 0 -33px;
780
+ z-index:2;border:0;border-radius:50%;padding:19px;cursor:pointer;color:#fff;
781
+ background:var(--dxr-shell);box-shadow:0 4px 18px rgba(0,0,0,.45);
782
+ display:inline-flex;align-items:center;justify-content:center;
783
+ transition:opacity .2s ease,transform .2s ease;will-change:transform;}
784
+ .dxr-player-centre svg{width:100%;height:100%;fill:currentColor;display:block;}
785
+ .dxr-player-centre:hover{transform:scale(1.07);}
786
+ .dxr-player-centre:focus-visible{outline:2px solid var(--dxr-accent);outline-offset:3px;}
787
+ .dxr-player-centre--hidden{opacity:0;transform:scale(.8);pointer-events:none;}
788
+ .dxr-player-pip{position:absolute;left:50%;top:50%;width:60px;height:60px;margin:-30px 0 0 -30px;
789
+ z-index:2;border-radius:50%;padding:16px;color:#fff;background:rgba(10,11,15,.7);
790
+ display:flex;align-items:center;justify-content:center;pointer-events:none;opacity:0;}
791
+ .dxr-player-pip svg{width:100%;height:100%;fill:currentColor;display:block;}
792
+ .dxr-player-pip--on{animation:dxr-pip .5s ease forwards;}
793
+ @keyframes dxr-pip{0%{opacity:0;transform:scale(.72)}22%{opacity:1;transform:scale(1)}
794
+ 100%{opacity:0;transform:scale(1.25)}}
795
+ .dxr-player-spin{position:absolute;left:50%;top:50%;width:40px;height:40px;margin:-20px 0 0 -20px;
796
+ z-index:2;border-radius:50%;border:3px solid rgba(255,255,255,.22);
797
+ border-top-color:var(--dxr-accent);pointer-events:none;opacity:0;
798
+ transition:opacity .18s ease;}
799
+ .dxr-player-spin--on{opacity:1;animation:dxr-spin .9s linear infinite;}
800
+ @keyframes dxr-spin{to{transform:rotate(360deg)}}
801
+
802
+ .dxr-player-host--idle{cursor:none;}
803
+ /* ── skin: 'dock' ──────────────────────────────────────────────────────────────────────────────
804
+ Every glow stays INSIDE its element's own box. An overlay is excluded from the weave by its
805
+ rect; a shadow painted outside that rect is page content OVER the woven canvas, so it would be
806
+ woven with it — a soft double-imaged smudge around the dock. Hence: no outer box-shadow on the
807
+ dock, and the buttons' halos are inset or sit within the dock's padding. */
808
+ .dxr-player-host--dock{--dxr-accent-hi:color-mix(in srgb,var(--dxr-accent) 62%,#fff);
809
+ --dxr-accent-lo:color-mix(in srgb,var(--dxr-accent) 72%,#000);}
810
+ .dxr-player-host--dock .dxr-player{left:12px;right:12px;bottom:12px;padding:10px 12px 8px;
811
+ border-radius:18px;max-height:none;text-shadow:none;
812
+ background:linear-gradient(180deg,rgba(44,48,62,.95) 0%,rgba(20,22,30,.96) 55%,rgba(12,13,18,.97) 100%);
813
+ border:1px solid rgba(255,255,255,.09);
814
+ box-shadow:inset 0 1px 0 rgba(255,255,255,.10),inset 0 -1px 0 rgba(0,0,0,.4);}
815
+ .dxr-player-host--dock .dxr-player--hidden{transform:translateY(10px) scale(.985);}
816
+ .dxr-player-host--dock .dxr-player-row{gap:6px;min-height:40px;}
817
+ .dxr-player-host--dock .dxr-player-btn{width:34px;height:34px;padding:7px;border-radius:50%;
818
+ background:radial-gradient(circle at 32% 26%,rgba(255,255,255,.22),rgba(255,255,255,.05) 58%,rgba(255,255,255,.02));
819
+ box-shadow:inset 0 0 0 1px rgba(255,255,255,.12),inset 0 1px 0 rgba(255,255,255,.18),
820
+ inset 0 -2px 4px rgba(0,0,0,.35);
821
+ transition:box-shadow .16s ease,transform .12s ease,background .16s ease,color .16s ease;}
822
+ .dxr-player-host--dock .dxr-player-btn:hover{background:radial-gradient(circle at 32% 26%,
823
+ rgba(255,255,255,.30),rgba(255,255,255,.08) 58%,rgba(255,255,255,.03));
824
+ box-shadow:inset 0 0 0 1.5px var(--dxr-accent),inset 0 0 10px color-mix(in srgb,var(--dxr-accent) 45%,transparent),
825
+ inset 0 1px 0 rgba(255,255,255,.2);color:#fff;}
826
+ .dxr-player-host--dock .dxr-player-btn:active{transform:scale(.9);}
827
+ .dxr-player-host--dock .dxr-player-skip{padding:6px;}
828
+ /* The primary: a lit accent orb. */
829
+ .dxr-player-host--dock .dxr-player-play{width:42px;height:42px;padding:10px;color:#07111d;
830
+ background:radial-gradient(circle at 34% 26%,var(--dxr-accent-hi),var(--dxr-accent) 55%,var(--dxr-accent-lo));
831
+ box-shadow:inset 0 1px 1px rgba(255,255,255,.55),inset 0 -3px 6px rgba(0,0,0,.28),
832
+ inset 0 0 0 1px rgba(255,255,255,.18);}
833
+ .dxr-player-host--dock .dxr-player-play:hover{color:#07111d;
834
+ background:radial-gradient(circle at 34% 26%,#fff,var(--dxr-accent-hi) 40%,var(--dxr-accent) 85%);
835
+ box-shadow:inset 0 1px 1px rgba(255,255,255,.7),inset 0 -3px 6px rgba(0,0,0,.22),
836
+ inset 0 0 0 1px rgba(255,255,255,.3);}
837
+ /* Scrub: a lit fill and a knob with a ring, inside the dock. */
838
+ .dxr-player-host--dock .dxr-player-scrubwrap{margin:0 4px 6px;}
839
+ .dxr-player-host--dock .dxr-player-scrubwrap::before{height:5px;
840
+ box-shadow:inset 0 1px 2px rgba(0,0,0,.5);}
841
+ .dxr-player-host--dock .dxr-player-scrubwrap::after{height:5px;
842
+ background:linear-gradient(90deg,var(--dxr-accent-lo),var(--dxr-accent) 60%,var(--dxr-accent-hi));
843
+ box-shadow:0 0 6px color-mix(in srgb,var(--dxr-accent) 70%,transparent);}
844
+ .dxr-player-host--dock .dxr-player-knob{width:15px;height:15px;margin-left:-7.5px;margin-top:-7.5px;
845
+ background:radial-gradient(circle at 35% 30%,#fff,#dfe8f5 60%,#b9c6d8);
846
+ box-shadow:0 0 0 3px color-mix(in srgb,var(--dxr-accent) 55%,transparent),0 1px 3px rgba(0,0,0,.6);}
847
+ .dxr-player-host--dock .dxr-player-tip{bottom:22px;border-radius:7px;
848
+ background:linear-gradient(180deg,rgba(52,57,72,.98),rgba(24,26,34,.98));
849
+ box-shadow:inset 0 0 0 1px rgba(255,255,255,.1);}
850
+ .dxr-player-host--dock .dxr-player-clock b{color:#fff;}
851
+ .dxr-player-host--dock .dxr-player-badge3d{border:0;opacity:1;color:#07111d;
852
+ background:linear-gradient(180deg,var(--dxr-accent-hi),var(--dxr-accent));}
853
+ /* Centre: a larger lit orb, ring included in its own box. */
854
+ .dxr-player-host--dock .dxr-player-centre{width:78px;height:78px;margin:-39px 0 0 -39px;padding:22px;
855
+ color:#07111d;
856
+ background:radial-gradient(circle at 34% 26%,var(--dxr-accent-hi),var(--dxr-accent) 55%,var(--dxr-accent-lo));
857
+ box-shadow:inset 0 0 0 4px rgba(255,255,255,.14),inset 0 2px 2px rgba(255,255,255,.5),
858
+ inset 0 -5px 10px rgba(0,0,0,.3);}
859
+ .dxr-player-host--dock .dxr-player-centre svg{transform:translateX(2px);}
860
+ .dxr-player-host--dock .dxr-player-title{left:12px;right:12px;top:12px;padding:9px 14px;
861
+ border-radius:14px;max-height:none;width:max-content;max-width:calc(100% - 24px);text-shadow:none;
862
+ background:linear-gradient(180deg,rgba(44,48,62,.92),rgba(16,18,24,.94));
863
+ box-shadow:inset 0 0 0 1px rgba(255,255,255,.08);}
864
+ .dxr-player-host--dock .dxr-player-title--hidden{transform:translateY(-10px);}
865
+
866
+ /* ── controls INSIDE the black bars (a band leaves bars above and below the picture) ─────────────
867
+ The host's --dxr-bar-top / --dxr-bar-bottom are the bars' heights, measured from the tile and
868
+ the band. Nothing sits over the picture: the centre button and the key pip stand down. */
869
+ .dxr-player-back,.dxr-player-remain,.dxr-player-timer{display:none;}
870
+ .dxr-player-host--inbars .dxr-player{top:auto;bottom:0;height:var(--dxr-bar-bottom);max-height:none;
871
+ display:flex;flex-direction:column;justify-content:center;padding-top:6px;padding-bottom:6px;
872
+ background:transparent;}
873
+ .dxr-player-host--inbars .dxr-player-title{top:0;height:var(--dxr-bar-top);max-height:none;
874
+ display:flex;align-items:center;padding-top:0;padding-bottom:0;background:transparent;}
875
+ .dxr-player-host--inbars .dxr-player-centre,.dxr-player-host--inbars .dxr-player-pip{display:none;}
876
+ .dxr-player-host--inbars.dxr-player-host--dock .dxr-player{bottom:8px;height:calc(var(--dxr-bar-bottom) - 16px);}
877
+
878
+ /* ── skin: 'bars' — one row in the bottom bar: back / play / elapsed / scrub / remaining ────────── */
879
+ .dxr-player-host--bars .dxr-player{padding:4px 14px;text-shadow:none;
880
+ background:linear-gradient(to top,rgba(0,0,0,.92),rgba(0,0,0,.92));max-height:none;}
881
+ .dxr-player-host--bars.dxr-player-host--inbars .dxr-player{background:transparent;}
882
+ .dxr-player-host--bars .dxr-player-back{display:inline-flex;}
883
+ .dxr-player-host--bars .dxr-player-skip,.dxr-player-host--bars .dxr-player-centre,
884
+ .dxr-player-host--bars .dxr-player-pip{display:none;}
885
+ .dxr-player-host--bars .dxr-player-row{gap:8px;}
886
+ .dxr-player-host--bars .dxr-player-row .dxr-player-scrubwrap{flex:1 1 auto;margin:0 6px;}
887
+ .dxr-player-host--bars .dxr-player-clock span{display:none;}
888
+ .dxr-player-host--bars .dxr-player-remain{display:block;font-variant-numeric:tabular-nums;opacity:.72;
889
+ white-space:nowrap;}
890
+ .dxr-player-host--bars .dxr-player-spacer{display:none;}
891
+
892
+ /* ── skin: 'call' — name + timer bar on top, controls at the bottom, picture in a 2.39:1 band ── */
893
+ .dxr-player-host--call .dxr-player-title{display:flex;flex-direction:column;justify-content:center;gap:6px;
894
+ background:transparent;text-shadow:none;}
895
+ .dxr-player-host--call .dxr-player-timer{display:block;height:3px;border-radius:99px;
896
+ align-self:center;width:min(60%,520px);
897
+ background:linear-gradient(to right,var(--dxr-accent) 0 var(--dxr-p,0%),rgba(255,255,255,.22) var(--dxr-p,0%));}
898
+ .dxr-player-host--call .dxr-player-scrubwrap,.dxr-player-host--call .dxr-player-skip,
899
+ .dxr-player-host--call .dxr-player-centre,.dxr-player-host--call .dxr-player-pip{display:none;}
900
+ .dxr-player-host--call .dxr-player{background:transparent;text-shadow:none;}
901
+
902
+ /* ── size: 's' | 'm' | 'l' — "zoom" on overlay CONTENT only ──────────────────────────────────────
903
+ The bar's row and scrub, and each centre affordance's glyph box, scale as a unit; the overlay
904
+ boxes keep their own anchoring (bottom band / centred), so nothing drifts off-centre. */
905
+ .dxr-player-host{--dxr-z:1;}
906
+ .dxr-player-host--size-s{--dxr-z:${PLAYER_SIZE_SCALE.s};}
907
+ .dxr-player-host--size-l{--dxr-z:${PLAYER_SIZE_SCALE.l};}
908
+ .dxr-player-row,.dxr-player-scrubwrap,.dxr-player-title{zoom:var(--dxr-z);}
909
+ .dxr-player-centre,.dxr-player-pip,.dxr-player-spin{zoom:var(--dxr-z);}
910
+
911
+ /* ── anti-aliased round edges ─────────────────────────────────────────────────────────────────
912
+ An overlay is re-composited on its own; there the rounded CLIP of a border-radius edge can come
913
+ out stair-stepped. So the round controls draw their edge themselves: a mask that feathers the
914
+ last ~1.5 px to transparent. The edge is then in the element's own pixels, AA however it is
915
+ composited. (A mask is not a backdrop effect — it depends on nothing behind the element.) */
916
+ .dxr-player-host--dock .dxr-player-btn,.dxr-player-host--dock .dxr-player-centre,
917
+ .dxr-player-centre,.dxr-player-pip,.dxr-player-knob{
918
+ -webkit-mask:radial-gradient(circle closest-side,#000 calc(100% - 1.6px),transparent 100%);
919
+ mask:radial-gradient(circle closest-side,#000 calc(100% - 1.6px),transparent 100%);}
920
+
921
+ @media (prefers-reduced-motion:reduce){
922
+ .dxr-player,.dxr-player-title,.dxr-player-btn,.dxr-player-centre,.dxr-player-knob,.dxr-player-tip,
923
+ .dxr-player-volslider,.dxr-player-scrubwrap::before,.dxr-player-scrubwrap::after,
924
+ .dxr-player-spin{transition:none!important;}
925
+ .dxr-player-pip--on{animation-duration:.01ms;}
926
+ .dxr-player-spin--on{animation:none;}
927
+ }
928
+ @media (max-width:420px){
929
+ .dxr-player{padding:20px 8px 8px;}
930
+ .dxr-player-clock{font-size:12px;}
931
+ .dxr-player-skip{display:none;}
932
+ .dxr-player-title{font-size:13px;padding:8px 10px 22px;}
933
+ .dxr-player-vol:hover .dxr-player-volslider{width:44px;}
934
+ }
935
+ `;
936
+
937
+ function ensureStyle() {
938
+ if (styleInjected || typeof document === 'undefined') return;
939
+ styleInjected = true;
940
+ if (document.getElementById(PLAYER_STYLE_ID)) return;
941
+ const style = document.createElement('style');
942
+ style.id = PLAYER_STYLE_ID;
943
+ style.textContent = PLAYER_CSS;
944
+ document.head.appendChild(style);
945
+ }
946
+
947
+ const svg = (body) =>
948
+ `<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">${body}</svg>`;
949
+
950
+ const PLAY_ICON = svg(
951
+ '<path d="M8 5.2v13.6a1 1 0 0 0 1.53.85l10.2-6.8a1 1 0 0 0 0-1.7L9.53 4.35A1 1 0 0 0 8 5.2Z"/>'
952
+ );
953
+ const PAUSE_ICON = svg(
954
+ '<rect x="6" y="4.2" width="4.2" height="15.6" rx="1.6"/>' +
955
+ '<rect x="13.8" y="4.2" width="4.2" height="15.6" rx="1.6"/>'
956
+ );
957
+ const REPLAY_ICON = svg(
958
+ '<path d="M12 5V2.6a.5.5 0 0 0-.82-.38L7.3 5.35a.5.5 0 0 0 0 .77l3.88 3.13A.5.5 0 0 0 12 8.87V7a5 5 0 1 1-5 5 1 1 0 1 0-2 0 7 7 0 1 0 7-7Z"/>'
959
+ );
960
+ // Three volume states, so the button reports level as well as mute — a speaker with no waves at
961
+ // volume 0 is a different thing from a muted speaker, and a player that conflates them makes
962
+ // "why is there no sound" unanswerable from the chrome.
963
+ const VOL_HIGH_ICON = svg(
964
+ '<path d="M4 9.2h3.3L12 5.1a.6.6 0 0 1 1 .46v12.88a.6.6 0 0 1-1 .46L7.3 14.8H4a.8.8 0 0 1-.8-.8v-4a.8.8 0 0 1 .8-.8Z"/>' +
965
+ '<path d="M15.8 9a4 4 0 0 1 0 6M18.3 6.4a7.4 7.4 0 0 1 0 11.2" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" fill="none"/>'
966
+ );
967
+ const VOL_LOW_ICON = svg(
968
+ '<path d="M4 9.2h3.3L12 5.1a.6.6 0 0 1 1 .46v12.88a.6.6 0 0 1-1 .46L7.3 14.8H4a.8.8 0 0 1-.8-.8v-4a.8.8 0 0 1 .8-.8Z"/>' +
969
+ '<path d="M15.8 9a4 4 0 0 1 0 6" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" fill="none"/>'
970
+ );
971
+ const MUTE_ICON = svg(
972
+ '<path d="M4 9.2h3.3L12 5.1a.6.6 0 0 1 1 .46v12.88a.6.6 0 0 1-1 .46L7.3 14.8H4a.8.8 0 0 1-.8-.8v-4a.8.8 0 0 1 .8-.8Z"/>' +
973
+ '<path d="m16.2 9.4 4.4 4.4M20.6 9.4l-4.4 4.4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" fill="none"/>'
974
+ );
975
+ const FWD_ICON = svg(
976
+ '<path d="M12 5V2.6a.5.5 0 0 1 .82-.38l3.88 3.13a.5.5 0 0 1 0 .77l-3.88 3.13A.5.5 0 0 1 12 8.87V7a5 5 0 1 0 5 5 1 1 0 1 1 2 0 7 7 0 1 1-7-7Z"/>'
977
+ );
978
+ const BACK_ICON = REPLAY_ICON;
979
+ const FS_ENTER_ICON = svg(
980
+ '<path d="M4 9V5a1 1 0 0 1 1-1h4M15 4h4a1 1 0 0 1 1 1v4M20 15v4a1 1 0 0 1-1 1h-4M9 20H5a1 1 0 0 1-1-1v-4" ' +
981
+ 'stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>'
982
+ );
983
+ const FS_EXIT_ICON = svg(
984
+ '<path d="M9 4v4a1 1 0 0 1-1 1H4M20 9h-4a1 1 0 0 1-1-1V4M15 20v-4a1 1 0 0 1 1-1h4M4 15h4a1 1 0 0 1 1 1v4" ' +
985
+ 'stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>'
986
+ );
987
+ // Skip icons: plain rewind / fast-forward glyphs — no seconds label (text inside a 34 px button
988
+ // reads as clutter; the aria-label and the ±10 s pip carry the amount).
989
+ const skipIcon = (dir) =>
990
+ svg(
991
+ dir < 0
992
+ ? '<path d="M11.2 6.3v11.4a.8.8 0 0 1-1.25.66L2.6 12.66a.8.8 0 0 1 0-1.32l7.35-5.7a.8.8 0 0 1 1.25.66Z"/>' +
993
+ '<path d="M21 6.3v11.4a.8.8 0 0 1-1.25.66l-7.35-5.7a.8.8 0 0 1 0-1.32l7.35-5.7A.8.8 0 0 1 21 6.3Z"/>'
994
+ : '<path d="M12.8 6.3v11.4a.8.8 0 0 0 1.25.66l7.35-5.7a.8.8 0 0 0 0-1.32l-7.35-5.7a.8.8 0 0 0-1.25.66Z"/>' +
995
+ '<path d="M3 6.3v11.4a.8.8 0 0 0 1.25.66l7.35-5.7a.8.8 0 0 0 0-1.32L4.25 5.64A.8.8 0 0 0 3 6.3Z"/>'
996
+ );
997
+ // Previous-track: a bar and a left-pointing triangle (no text in any button).
998
+ const BACK_TRACK_ICON = svg(
999
+ '<rect x="5" y="5.5" width="2.4" height="13" rx="1"/>' +
1000
+ '<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
+ );
1002
+ const fsElement = () => (typeof document !== 'undefined' ? document.fullscreenElement : null);
1003
+
1004
+ function volumeIcon(video) {
1005
+ if (video.muted || video.volume === 0) return MUTE_ICON;
1006
+ return video.volume < 0.5 ? VOL_LOW_ICON : VOL_HIGH_ICON;
1007
+ }
1008
+
1009
+ const pct = (n) => `${Math.max(0, Math.min(100, n * 100)).toFixed(3)}%`;
1010
+
1011
+ /**
1012
+ * How much of the source is buffered AHEAD OF the playhead, as a 0..1 fraction of duration.
1013
+ * Pure, and exported for the unit tests: it takes the two numbers a `TimeRanges` yields rather
1014
+ * than the object, so it can be checked without a `<video>`.
1015
+ *
1016
+ * Reported as "the end of the range CONTAINING the playhead", not "the end of the last range" —
1017
+ * after a seek into an unbuffered region the last range is behind you, and painting it as
1018
+ * buffered-ahead is the lie that makes a scrub bar look full while the player stalls.
1019
+ *
1020
+ * @param {Array<[number, number]>} ranges [start, end] pairs, in seconds
1021
+ * @param {number} currentTime
1022
+ * @param {number} duration
1023
+ */
1024
+ export function bufferedFraction(ranges, currentTime, duration) {
1025
+ if (!Number.isFinite(duration) || duration <= 0) return 0;
1026
+ for (const [start, end] of ranges || []) {
1027
+ if (currentTime >= start - 0.25 && currentTime <= end) {
1028
+ return Math.max(0, Math.min(1, end / duration));
1029
+ }
1030
+ }
1031
+ return 0;
1032
+ }
1033
+
1034
+ function readBuffered(video) {
1035
+ const out = [];
1036
+ const b = video.buffered;
1037
+ if (!b) return out;
1038
+ for (let i = 0; i < b.length; i++) {
1039
+ try {
1040
+ out.push([b.start(i), b.end(i)]);
1041
+ } catch {
1042
+ /* an index that went away between length and start(): ignore this range */
1043
+ }
1044
+ }
1045
+ return out;
1046
+ }
1047
+
1048
+ /**
1049
+ * Build the SDK-drawn transport: a bottom band, a centre play badge, a keyboard-action pip and a
1050
+ * buffering spinner — each its own `data-inline3d-overlay`, each a PARTIAL region of the tile
1051
+ * (constraint 1 at the top of this section). Returns the bar element and a cleanup.
1052
+ */
1053
+ function buildTransportBar(container, canvas, video, { keyboard, accent, badge3d, title, skipButtons, fullscreen, skin, size, band = null, onBack = null }) {
1054
+ ensureStyle();
1055
+ if (getComputedStyle(container).position === 'static') container.style.position = 'relative';
1056
+ container.classList.add('dxr-player-host');
1057
+ /** Skin, size and accent are all host state — one class or one property, inherited by every overlay. */
1058
+ let currentSkin = skin || 'classic';
1059
+ // The first applyAppearance runs before the bar's elements exist; layout waits for them.
1060
+ let layoutReady = false;
1061
+ function applyAppearance(a) {
1062
+ if (a.skin !== undefined) {
1063
+ currentSkin = a.skin;
1064
+ for (const k of ['dock', 'bars', 'call']) container.classList.toggle(`dxr-player-host--${k}`, a.skin === k);
1065
+ if (layoutReady) layoutForSkin();
1066
+ }
1067
+ if (a.size !== undefined) {
1068
+ for (const k of VALID_SIZES) container.classList.toggle(`dxr-player-host--size-${k}`, k === a.size && k !== 'm');
1069
+ if (layoutReady) placeInBars();
1070
+ }
1071
+ if (a.accent !== undefined) {
1072
+ if (a.accent) container.style.setProperty('--dxr-accent', resolveAccent(a.accent));
1073
+ else container.style.removeProperty('--dxr-accent');
1074
+ }
1075
+ }
1076
+ applyAppearance({ skin, size, accent: accent || undefined });
1077
+
1078
+ const bar = document.createElement('div');
1079
+ bar.className = 'dxr-player';
1080
+ bar.setAttribute('data-inline3d-overlay', '');
1081
+ bar.setAttribute('role', 'group');
1082
+ bar.setAttribute('aria-label', 'Video player controls');
1083
+
1084
+ // ── scrub ──
1085
+ const scrubWrap = document.createElement('div');
1086
+ scrubWrap.className = 'dxr-player-scrubwrap';
1087
+
1088
+ const seek = document.createElement('input');
1089
+ seek.type = 'range';
1090
+ seek.className = 'dxr-player-scrub';
1091
+ seek.min = '0';
1092
+ seek.max = '0';
1093
+ seek.step = '0.05';
1094
+ seek.value = '0';
1095
+ seek.setAttribute('aria-label', 'Seek');
1096
+
1097
+ const knob = document.createElement('div');
1098
+ knob.className = 'dxr-player-knob';
1099
+ const tip = document.createElement('div');
1100
+ tip.className = 'dxr-player-tip';
1101
+ tip.textContent = '0:00';
1102
+ scrubWrap.append(seek, knob, tip);
1103
+
1104
+ // ── controls ──
1105
+ const row = document.createElement('div');
1106
+ row.className = 'dxr-player-row';
1107
+
1108
+ const playBtn = document.createElement('button');
1109
+ playBtn.type = 'button';
1110
+ playBtn.className = 'dxr-player-btn dxr-player-play';
1111
+ playBtn.setAttribute('aria-label', 'Play');
1112
+ playBtn.innerHTML = PLAY_ICON;
1113
+
1114
+ const volGroup = document.createElement('div');
1115
+ volGroup.className = 'dxr-player-vol';
1116
+ const muteBtn = document.createElement('button');
1117
+ muteBtn.type = 'button';
1118
+ muteBtn.className = 'dxr-player-btn dxr-player-mute';
1119
+ muteBtn.innerHTML = volumeIcon(video);
1120
+ muteBtn.setAttribute('aria-label', video.muted ? 'Unmute' : 'Mute');
1121
+ const vol = document.createElement('input');
1122
+ vol.type = 'range';
1123
+ vol.className = 'dxr-player-volslider';
1124
+ vol.min = '0';
1125
+ vol.max = '1';
1126
+ vol.step = '0.01';
1127
+ vol.value = String(video.muted ? 0 : video.volume);
1128
+ vol.setAttribute('aria-label', 'Volume');
1129
+ volGroup.append(muteBtn, vol);
1130
+
1131
+ const clock = document.createElement('div');
1132
+ clock.className = 'dxr-player-clock';
1133
+ clock.setAttribute('aria-hidden', 'true');
1134
+ clock.innerHTML = '<b>0:00</b><span> / 0:00</span>';
1135
+
1136
+ const spacer = document.createElement('div');
1137
+ spacer.className = 'dxr-player-spacer';
1138
+
1139
+ function skipBtn(delta) {
1140
+ const b = document.createElement('button');
1141
+ b.type = 'button';
1142
+ b.className = 'dxr-player-btn dxr-player-skip';
1143
+ b.setAttribute('aria-label', delta < 0 ? `Back ${-delta} seconds` : `Forward ${delta} seconds`);
1144
+ b.innerHTML = skipIcon(delta);
1145
+ b.addEventListener('click', () => skipBy(delta));
1146
+ return b;
1147
+ }
1148
+ // 'bars' only: back (a remote's previous — restart, or the previous title) and the time left.
1149
+ const backBtn = document.createElement('button');
1150
+ backBtn.type = 'button';
1151
+ backBtn.className = 'dxr-player-btn dxr-player-back';
1152
+ backBtn.setAttribute('aria-label', 'Back');
1153
+ backBtn.innerHTML = BACK_TRACK_ICON;
1154
+ backBtn.addEventListener('click', () => (onBack ? onBack() : (video.currentTime = 0)));
1155
+ const remain = document.createElement('div');
1156
+ remain.className = 'dxr-player-remain';
1157
+ remain.setAttribute('aria-hidden', 'true');
1158
+ remain.textContent = '-0:00';
1159
+
1160
+ row.append(backBtn, playBtn);
1161
+ if (skipButtons) row.append(skipBtn(-10), skipBtn(10));
1162
+ row.append(volGroup, clock, spacer);
1163
+ if (badge3d) {
1164
+ const badge = document.createElement('span');
1165
+ badge.className = 'dxr-player-badge3d';
1166
+ badge.textContent = badge3d === true ? '3D' : String(badge3d);
1167
+ badge.setAttribute('aria-label', 'Glasses-free 3D');
1168
+ row.append(badge);
1169
+ }
1170
+
1171
+ let fsBtn = null;
1172
+ const canFullscreen = fullscreen && typeof container.requestFullscreen === 'function';
1173
+ if (canFullscreen) {
1174
+ fsBtn = document.createElement('button');
1175
+ fsBtn.type = 'button';
1176
+ fsBtn.className = 'dxr-player-btn dxr-player-fs';
1177
+ fsBtn.setAttribute('aria-label', 'Full screen');
1178
+ fsBtn.innerHTML = FS_ENTER_ICON;
1179
+ fsBtn.addEventListener('click', () => toggleFullscreen());
1180
+ row.append(fsBtn);
1181
+ }
1182
+
1183
+ bar.append(scrubWrap, row);
1184
+
1185
+ // Always created (it is how setSource can add a title later); empty = hidden, no band drawn.
1186
+ // The name, plus the thin timer bar the 'call' skin shows under it.
1187
+ const titleEl = document.createElement('div');
1188
+ titleEl.className = 'dxr-player-title';
1189
+ titleEl.setAttribute('data-inline3d-overlay', '');
1190
+ const titleName = document.createElement('span');
1191
+ const timer = document.createElement('div');
1192
+ timer.className = 'dxr-player-timer';
1193
+ timer.setAttribute('aria-hidden', 'true');
1194
+ titleEl.append(titleName, timer);
1195
+ function setTitle(t) {
1196
+ titleName.textContent = t || '';
1197
+ titleEl.style.display = t ? '' : 'none';
1198
+ }
1199
+ setTitle(title);
1200
+
1201
+ // The scrub bar is its own row in most skins, and inline between the two clocks in 'bars'.
1202
+ function layoutForSkin() {
1203
+ if (currentSkin === 'bars') {
1204
+ if (scrubWrap.parentNode !== row) clock.after(scrubWrap);
1205
+ if (remain.parentNode !== row) scrubWrap.after(remain);
1206
+ } else {
1207
+ if (scrubWrap.parentNode !== bar) bar.insertBefore(scrubWrap, row);
1208
+ remain.remove();
1209
+ }
1210
+ }
1211
+ layoutForSkin();
1212
+ layoutReady = true;
1213
+
1214
+ // Where the black bars are. A band narrower than the tile leaves bars above and below the
1215
+ // picture; when the bottom one is tall enough the controls move into it (and the title into the
1216
+ // top one), so nothing sits over the picture. Re-measured whenever the tile's box changes.
1217
+ function placeInBars() {
1218
+ const w = container.clientWidth;
1219
+ const h = container.clientHeight;
1220
+ if (!band || !(w > 0 && h > 0)) {
1221
+ container.classList.remove('dxr-player-host--inbars');
1222
+ return;
1223
+ }
1224
+ const b = bandBox(w, h, band);
1225
+ const top = Math.max(0, b.y);
1226
+ const bottom = Math.max(0, h - (b.y + b.h));
1227
+ container.style.setProperty('--dxr-bar-top', `${top}px`);
1228
+ container.style.setProperty('--dxr-bar-bottom', `${bottom}px`);
1229
+ const z = Number(getComputedStyle(container).getPropertyValue('--dxr-z')) || 1;
1230
+ container.classList.toggle('dxr-player-host--inbars', bottom >= MIN_BAR_PX * z);
1231
+ }
1232
+ let barRO = null;
1233
+ if (typeof ResizeObserver === 'function') {
1234
+ barRO = new ResizeObserver(placeInBars);
1235
+ barRO.observe(container);
1236
+ }
1237
+ placeInBars();
1238
+
1239
+ // ── centre affordances ──
1240
+ const centre = document.createElement('button');
1241
+ centre.type = 'button';
1242
+ centre.className = 'dxr-player-centre';
1243
+ centre.setAttribute('data-inline3d-overlay', '');
1244
+ centre.setAttribute('aria-label', 'Play');
1245
+ centre.innerHTML = PLAY_ICON;
1246
+
1247
+ const pip = document.createElement('div');
1248
+ pip.className = 'dxr-player-pip';
1249
+ pip.setAttribute('data-inline3d-overlay', '');
1250
+ pip.setAttribute('aria-hidden', 'true');
1251
+
1252
+ const spinner = document.createElement('div');
1253
+ spinner.className = 'dxr-player-spin';
1254
+ spinner.setAttribute('data-inline3d-overlay', '');
1255
+ spinner.setAttribute('aria-hidden', 'true');
1256
+
1257
+ const anchor = canvas.nextSibling;
1258
+ for (const el of [titleEl, bar, centre, pip, spinner]) {
1259
+ if (anchor) container.insertBefore(el, anchor);
1260
+ else container.appendChild(el);
1261
+ }
1262
+
1263
+ // Focusable so keyboard control works without first clicking a button.
1264
+ if (!canvas.hasAttribute('tabindex')) canvas.tabIndex = 0;
1265
+
1266
+ let scrubbing = false;
1267
+ let pipTimer = 0;
1268
+
1269
+ function flashPip(icon) {
1270
+ pip.innerHTML = icon;
1271
+ pip.classList.remove('dxr-player-pip--on');
1272
+ void pip.offsetWidth; // restart the animation
1273
+ pip.classList.add('dxr-player-pip--on');
1274
+ clearTimeout(pipTimer);
1275
+ pipTimer = setTimeout(() => pip.classList.remove('dxr-player-pip--on'), 520);
1276
+ }
1277
+
1278
+ function syncPlayIcon() {
1279
+ const playing = !video.paused && !video.ended;
1280
+ playBtn.innerHTML = playing ? PAUSE_ICON : video.ended ? REPLAY_ICON : PLAY_ICON;
1281
+ playBtn.setAttribute('aria-label', playing ? 'Pause' : video.ended ? 'Replay' : 'Play');
1282
+ centre.innerHTML = video.ended ? REPLAY_ICON : PLAY_ICON;
1283
+ centre.setAttribute('aria-label', video.ended ? 'Replay' : 'Play');
1284
+ centre.classList.toggle('dxr-player-centre--hidden', playing);
1285
+ }
1286
+ function syncVolume() {
1287
+ muteBtn.innerHTML = volumeIcon(video);
1288
+ muteBtn.setAttribute('aria-label', video.muted ? 'Unmute' : 'Mute');
1289
+ const level = video.muted ? 0 : video.volume;
1290
+ vol.value = String(level);
1291
+ vol.style.setProperty('--dxr-v', pct(level));
1292
+ }
1293
+ function syncDuration() {
1294
+ if (Number.isFinite(video.duration) && video.duration > 0) {
1295
+ seek.max = String(video.duration);
1296
+ clock.querySelector('span').textContent = ` / ${formatTime(video.duration)}`;
1297
+ }
1298
+ }
1299
+ function syncTime() {
1300
+ const d = video.duration;
1301
+ const t = video.currentTime || 0;
1302
+ if (!scrubbing) {
1303
+ seek.value = String(t);
1304
+ if (Number.isFinite(d) && d > 0) scrubWrap.style.setProperty('--dxr-p', pct(t / d));
1305
+ }
1306
+ scrubWrap.style.setProperty('--dxr-b', pct(bufferedFraction(readBuffered(video), t, d)));
1307
+ clock.querySelector('b').textContent = formatTime(t);
1308
+ seek.setAttribute('aria-valuetext', `${formatTime(t)} of ${formatTime(d)}`);
1309
+ if (Number.isFinite(d) && d > 0) {
1310
+ remain.textContent = `-${formatTime(Math.max(0, d - t))}`;
1311
+ titleEl.style.setProperty('--dxr-p', pct(t / d)); // the 'call' timer bar
1312
+ }
1313
+ }
1314
+ function togglePlay() {
1315
+ if (video.paused || video.ended) video.play().catch(() => {});
1316
+ else video.pause();
1317
+ }
1318
+ function skipBy(delta) {
1319
+ const d = video.duration;
1320
+ const t = (video.currentTime || 0) + delta;
1321
+ video.currentTime = Math.max(0, Number.isFinite(d) ? Math.min(d, t) : t);
1322
+ flashPip(skipIcon(delta));
1323
+ }
1324
+ function toggleFullscreen() {
1325
+ if (!canFullscreen) return;
1326
+ if (fsElement() === container) document.exitFullscreen?.().catch(() => {});
1327
+ else container.requestFullscreen().catch(() => {});
1328
+ }
1329
+ // The canvas's inline size before fullscreen, restored on the way out; and its aspect, kept.
1330
+ let fsSaved = null;
1331
+ function fitFullscreen() {
1332
+ if (!fsSaved) return;
1333
+ const W = container.clientWidth;
1334
+ const H = container.clientHeight;
1335
+ const w = Math.min(W, H * fsSaved.aspect);
1336
+ canvas.style.width = `${Math.round(w)}px`;
1337
+ canvas.style.height = `${Math.round(w / fsSaved.aspect)}px`;
1338
+ }
1339
+ function syncFullscreen() {
1340
+ if (!fsBtn) return;
1341
+ const on = fsElement() === container;
1342
+ if (on && !fsSaved) {
1343
+ const r = canvas.getBoundingClientRect();
1344
+ fsSaved = { width: canvas.style.width, height: canvas.style.height, aspect: r.height ? r.width / r.height : 16 / 9 };
1345
+ fitFullscreen();
1346
+ window.addEventListener('resize', fitFullscreen);
1347
+ } else if (!on && fsSaved) {
1348
+ window.removeEventListener('resize', fitFullscreen);
1349
+ canvas.style.width = fsSaved.width;
1350
+ canvas.style.height = fsSaved.height;
1351
+ fsSaved = null;
1352
+ }
1353
+ fsBtn.innerHTML = on ? FS_EXIT_ICON : FS_ENTER_ICON;
1354
+ fsBtn.setAttribute('aria-label', on ? 'Exit full screen' : 'Full screen');
1355
+ }
1356
+ if (canFullscreen) document.addEventListener('fullscreenchange', syncFullscreen);
1357
+
1358
+ playBtn.addEventListener('click', togglePlay);
1359
+ centre.addEventListener('click', togglePlay);
1360
+ muteBtn.addEventListener('click', () => {
1361
+ video.muted = !video.muted;
1362
+ if (!video.muted && video.volume === 0) video.volume = 0.5;
1363
+ });
1364
+ vol.addEventListener('input', () => {
1365
+ const v = Number(vol.value);
1366
+ video.volume = v;
1367
+ video.muted = v === 0;
1368
+ });
1369
+
1370
+ function ratioFromEvent(e) {
1371
+ const rect = scrubWrap.getBoundingClientRect();
1372
+ if (!rect.width) return 0;
1373
+ return Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
1374
+ }
1375
+ scrubWrap.addEventListener('pointermove', (e) => {
1376
+ const r = ratioFromEvent(e);
1377
+ scrubWrap.style.setProperty('--dxr-tip', pct(r));
1378
+ if (Number.isFinite(video.duration)) tip.textContent = formatTime(r * video.duration);
1379
+ });
1380
+ seek.addEventListener('pointerdown', () => {
1381
+ scrubbing = true;
1382
+ scrubWrap.classList.add('dxr-player-scrubwrap--active');
1383
+ });
1384
+ seek.addEventListener('input', () => {
1385
+ const t = Number(seek.value) || 0;
1386
+ video.currentTime = t;
1387
+ if (Number.isFinite(video.duration) && video.duration > 0) {
1388
+ scrubWrap.style.setProperty('--dxr-p', pct(t / video.duration));
1389
+ }
1390
+ });
1391
+ function endScrub() {
1392
+ scrubbing = false;
1393
+ scrubWrap.classList.remove('dxr-player-scrubwrap--active');
1394
+ }
1395
+ seek.addEventListener('change', endScrub);
1396
+ seek.addEventListener('pointerup', endScrub);
1397
+ seek.addEventListener('pointercancel', endScrub);
1398
+ seek.addEventListener('blur', endScrub);
1399
+
1400
+ // The spinner means "playback is stalled waiting for data", which a PAUSED player never is.
1401
+ // Gating on that is not cosmetic: `waiting` fires when you seek into an unbuffered region, and
1402
+ // if the video is paused there is then no `playing` to follow — only `seeked` — so a spinner
1403
+ // cleared solely by `playing`/`canplay` latches on forever behind the centre badge.
1404
+ const onWaiting = () => {
1405
+ if (!video.paused) spinner.classList.add('dxr-player-spin--on');
1406
+ };
1407
+ const onPlayingOrStall = () => spinner.classList.remove('dxr-player-spin--on');
1408
+
1409
+ video.addEventListener('play', syncPlayIcon);
1410
+ video.addEventListener('pause', syncPlayIcon);
1411
+ video.addEventListener('ended', syncPlayIcon);
1412
+ video.addEventListener('volumechange', syncVolume);
1413
+ video.addEventListener('loadedmetadata', syncDuration);
1414
+ video.addEventListener('durationchange', syncDuration);
1415
+ video.addEventListener('timeupdate', syncTime);
1416
+ video.addEventListener('progress', syncTime);
1417
+ video.addEventListener('seeking', syncTime);
1418
+ video.addEventListener('waiting', onWaiting);
1419
+ video.addEventListener('playing', onPlayingOrStall);
1420
+ video.addEventListener('canplay', onPlayingOrStall);
1421
+ video.addEventListener('seeked', onPlayingOrStall);
1422
+ video.addEventListener('pause', onPlayingOrStall);
1423
+ video.addEventListener('ended', onPlayingOrStall);
1424
+ video.addEventListener('error', onPlayingOrStall);
1425
+
1426
+ // Auto-hide: 3 s idle while playing. Never while paused, scrubbing, or the pointer is over the
1427
+ // chrome itself — a bar that vanishes under the cursor you are aiming with is the classic
1428
+ // version of this bug.
1429
+ let hideTimer = 0;
1430
+ let pointerInChrome = false;
1431
+ function show() {
1432
+ bar.classList.remove('dxr-player--hidden');
1433
+ titleEl.classList.remove('dxr-player-title--hidden');
1434
+ container.classList.remove('dxr-player-host--idle');
1435
+ clearTimeout(hideTimer);
1436
+ if (!video.paused && !video.ended) arm();
1437
+ }
1438
+ function arm() {
1439
+ clearTimeout(hideTimer);
1440
+ hideTimer = setTimeout(() => {
1441
+ if (scrubbing || pointerInChrome || video.paused || video.ended) return;
1442
+ bar.classList.add('dxr-player--hidden');
1443
+ titleEl.classList.add('dxr-player-title--hidden');
1444
+ container.classList.add('dxr-player-host--idle');
1445
+ }, 3000);
1446
+ }
1447
+ const onChromeEnter = () => {
1448
+ pointerInChrome = true;
1449
+ show();
1450
+ };
1451
+ const onChromeLeave = () => {
1452
+ pointerInChrome = false;
1453
+ arm();
1454
+ };
1455
+ bar.addEventListener('pointerenter', onChromeEnter);
1456
+ bar.addEventListener('pointerleave', onChromeLeave);
1457
+ container.addEventListener('pointermove', show);
1458
+ container.addEventListener('pointerenter', show);
1459
+ container.addEventListener('focusin', show);
1460
+ video.addEventListener('pause', show);
1461
+ video.addEventListener('ended', show);
1462
+ video.addEventListener('play', arm);
1463
+
1464
+ function onKeydown(e) {
1465
+ if (!keyboard) return;
1466
+ const action = mapKeyToAction(e.key);
1467
+ if (!action) return;
1468
+ e.preventDefault();
1469
+ show();
1470
+ switch (action) {
1471
+ case 'toggle':
1472
+ togglePlay();
1473
+ flashPip(video.paused ? PAUSE_ICON : PLAY_ICON);
1474
+ break;
1475
+ case 'seek-5':
1476
+ video.currentTime = Math.max(0, video.currentTime - 5);
1477
+ flashPip(BACK_ICON);
1478
+ break;
1479
+ case 'seek+5':
1480
+ video.currentTime = video.currentTime + 5;
1481
+ flashPip(FWD_ICON);
1482
+ break;
1483
+ case 'seek-10':
1484
+ skipBy(-10);
1485
+ break;
1486
+ case 'seek+10':
1487
+ skipBy(10);
1488
+ break;
1489
+ case 'fullscreen':
1490
+ toggleFullscreen();
1491
+ break;
1492
+ case 'mute':
1493
+ video.muted = !video.muted;
1494
+ flashPip(volumeIcon(video));
1495
+ break;
1496
+ }
1497
+ }
1498
+ container.addEventListener('keydown', onKeydown);
1499
+
1500
+ syncPlayIcon();
1501
+ syncVolume();
1502
+ syncDuration();
1503
+ syncTime();
1504
+
1505
+ return {
1506
+ el: bar,
1507
+ setTitle,
1508
+ applyAppearance,
1509
+ /** Called by addPlayer on setSource() so the chrome resets with the new title. */
1510
+ resync() {
1511
+ scrubWrap.style.setProperty('--dxr-p', '0%');
1512
+ scrubWrap.style.setProperty('--dxr-b', '0%');
1513
+ seek.value = '0';
1514
+ seek.max = '0';
1515
+ clock.innerHTML = '<b>0:00</b><span> / 0:00</span>';
1516
+ syncPlayIcon();
1517
+ syncVolume();
1518
+ show();
1519
+ },
1520
+ cleanup() {
1521
+ clearTimeout(hideTimer);
1522
+ clearTimeout(pipTimer);
1523
+ container.removeEventListener('pointermove', show);
1524
+ container.removeEventListener('pointerenter', show);
1525
+ container.removeEventListener('focusin', show);
1526
+ container.removeEventListener('keydown', onKeydown);
1527
+ if (canFullscreen) document.removeEventListener('fullscreenchange', syncFullscreen);
1528
+ window.removeEventListener('resize', fitFullscreen);
1529
+ if (fsElement() === container) document.exitFullscreen?.().catch(() => {});
1530
+ barRO?.disconnect();
1531
+ container.classList.remove('dxr-player-host', 'dxr-player-host--idle', 'dxr-player-host--dock',
1532
+ 'dxr-player-host--bars', 'dxr-player-host--call', 'dxr-player-host--inbars',
1533
+ 'dxr-player-host--size-s', 'dxr-player-host--size-l');
1534
+ container.style.removeProperty('--dxr-bar-top');
1535
+ container.style.removeProperty('--dxr-bar-bottom');
1536
+ container.style.removeProperty('--dxr-accent');
1537
+ for (const el of [titleEl, bar, centre, pip, spinner]) el.remove();
1538
+ },
1539
+ };
1540
+ }
1541
+
1542
+ // ── groups: one decoding player at a time ─────────────────────────────────────────────────
1543
+ // A shelf of previews each playing muted and looped is N decoders at once — on a panel PC that is
1544
+ // the cost that shows. Players that share `opts.group` take turns: a `play` in the group pauses
1545
+ // every other member. Module-level because the group spans players, not one player.
1546
+ const playerGroups = new Map(); // group name -> Set of <video>
1547
+ function joinGroup(name, video) {
1548
+ if (!name) return () => {};
1549
+ let set = playerGroups.get(name);
1550
+ if (!set) playerGroups.set(name, (set = new Set()));
1551
+ set.add(video);
1552
+ const onPlay = () => {
1553
+ for (const other of set) if (other !== video && !other.paused) other.pause();
1554
+ };
1555
+ video.addEventListener('play', onPlay);
1556
+ return () => {
1557
+ video.removeEventListener('play', onPlay);
1558
+ set.delete(video);
1559
+ if (!set.size) playerGroups.delete(name);
1560
+ };
1561
+ }
1562
+ /** How many players are in a group right now (for tests and diagnostics). */
1563
+ export function groupSize(name) {
1564
+ return playerGroups.get(name)?.size || 0;
1565
+ }
1566
+
1567
+ let notedNoMixer = false;
1568
+ function noteNoMixer() {
1569
+ if (notedNoMixer) return;
1570
+ notedNoMixer = true;
1571
+ console.warn(
1572
+ "[inline3d/player] setSource(…, { transition: 'crossfade' }) on a player built without one — " +
1573
+ "this swap is a CUT. Pass transition:'crossfade' to addPlayer() to create the mixer; it " +
1574
+ 'cannot be created mid-flight without rebuilding the weave layer (the blink a crossfade removes).'
1575
+ );
1576
+ }
1577
+
1578
+ // ── setSource() cross-dissolve (opts.fadeMs) ────────────────────────────────────────────────
1579
+ //
1580
+ // WHAT THIS ACTUALLY IS, so nobody reads more into the option name than it delivers: a dissolve
1581
+ // from the outgoing title's LAST FRAME to the incoming title, not a blend of two simultaneously
1582
+ // decoding streams. That distinction is invisible on screen — the outgoing title is being
1583
+ // replaced, so nothing is lost by freezing it — and it costs one `<video>` instead of two, which
1584
+ // is what keeps the handle, the event plumbing and the whole transport bound to a single
1585
+ // element. A genuine two-stream blend would mean a second decode, a second audio track to
1586
+ // arbitrate, and every getter on the handle growing an "and which video do you mean" branch.
1587
+ //
1588
+ // HOW IT REACHES THE WOVEN PATH. `wall.addVideo(canvas, video)` stores the source and paints it
1589
+ // every frame; there is no setter to swap it afterwards, and removing + re-adding the window
1590
+ // would tear down and rebuild the weave layer — a visible blink, which is exactly what the fade
1591
+ // exists to avoid. But the SDK's paint reads
1592
+ //
1593
+ // const srcW = src.videoWidth || src.naturalWidth || src.width;
1594
+ //
1595
+ // so ANY drawable with a width works as a source, and its readiness gate is
1596
+ // `(src.readyState || 0) < 2`. A canvas therefore stands in for the `<video>` provided it
1597
+ // answers `readyState` — hence the expando below. So when a fade is asked for, `addVideo` is
1598
+ // handed a MIXER canvas that this module paints, and the stereo paint downstream of it is
1599
+ // unchanged: the mixer is the same pixels in the same layout, one composite earlier.
1600
+ //
1601
+ // IT IS OPT-IN, AND THAT IS THE POINT. The mixer costs one extra full-frame `drawImage` per
1602
+ // painted frame, so it is only created when the player was built with `transition: 'crossfade'`
1603
+ // (or the legacy `fadeMs > 0`). Every player that does not ask keeps the byte-identical
1604
+ // `addVideo(canvas, video)` path it has today and pays nothing. A per-call setSource option can
1605
+ // therefore change a crossfade's duration or easing, or skip it, but cannot switch one on —
1606
+ // there would be no mixer to run it through.
1607
+
1608
+ /**
1609
+ * The dissolve's alpha ramp: how much of the INCOMING title to composite over the frozen
1610
+ * outgoing frame. Pure, and exported so the ramp is testable without a canvas.
1611
+ *
1612
+ * Clamped at both ends, and a non-positive/non-finite duration collapses to a hard cut (1)
1613
+ * rather than dividing by zero and painting NaN alpha — which Chromium treats as "leave
1614
+ * globalAlpha alone", i.e. a stuck half-dissolved frame.
1615
+ *
1616
+ * @param {number} elapsedMs time since the incoming title's first frame
1617
+ * @param {number} fadeMs
1618
+ * @returns {number} 0..1
1619
+ */
1620
+ export function dissolveAlpha(elapsedMs, fadeMs) {
1621
+ if (!Number.isFinite(fadeMs) || fadeMs <= 0) return 1;
1622
+ if (!Number.isFinite(elapsedMs) || elapsedMs <= 0) return 0;
1623
+ return Math.max(0, Math.min(1, elapsedMs / fadeMs));
1624
+ }
1625
+
1626
+ /**
1627
+ * The mixer. Owns a canvas that can stand in for the `<video>` as a paint source, plus a freeze
1628
+ * buffer holding the outgoing title's last frame.
1629
+ *
1630
+ * @param {HTMLVideoElement} video
1631
+ */
1632
+ function createDissolve(video) {
1633
+ const mix = document.createElement('canvas');
1634
+ const mctx = mix.getContext('2d');
1635
+ const freeze = document.createElement('canvas');
1636
+ const fctx = freeze.getContext('2d');
1637
+ let hasFreeze = false;
1638
+ let fadeMs = 0;
1639
+ let ease = PLAYER_EASINGS.linear;
1640
+ let startedAt = 0;
1641
+
1642
+ // The SDK's video branch gates on `(src.readyState || 0) < 2` and re-commits the last frame
1643
+ // below it. Answering that question honestly is what lets a canvas be a drop-in source: 0
1644
+ // while there is genuinely nothing to show, 4 once the mixer holds a frame — including while
1645
+ // it holds only the FROZEN one, which is the whole reason the tile does not go black between
1646
+ // two titles.
1647
+ mix.readyState = 0;
1648
+
1649
+ function size(w, h) {
1650
+ if (mix.width !== w) mix.width = w;
1651
+ if (mix.height !== h) mix.height = h;
1652
+ }
1653
+ const videoLive = () => video.readyState >= 2 && video.videoWidth > 0;
1654
+
1655
+ return {
1656
+ el: mix,
1657
+
1658
+ /**
1659
+ * Snapshot what is on screen, BEFORE `video.src` is pointed at the new title. Returns
1660
+ * whether there was anything to snapshot — a player swapped before its first frame has
1661
+ * nothing to dissolve from, and says so rather than fading from a blank buffer.
1662
+ */
1663
+ capture() {
1664
+ if (!videoLive()) return false;
1665
+ freeze.width = video.videoWidth;
1666
+ freeze.height = video.videoHeight;
1667
+ fctx.drawImage(video, 0, 0);
1668
+ hasFreeze = true;
1669
+ return true;
1670
+ },
1671
+
1672
+ /** Arm the ramp. The clock does NOT start here — it starts at the incoming first frame. */
1673
+ arm(ms, easeFn) {
1674
+ fadeMs = Number.isFinite(ms) && ms > 0 ? ms : 0;
1675
+ ease = typeof easeFn === 'function' ? easeFn : PLAYER_EASINGS.linear;
1676
+ startedAt = 0;
1677
+ },
1678
+
1679
+ /** True while the mixer still has work only rAF can drive (a ramp, or a held freeze). */
1680
+ get active() {
1681
+ return (hasFreeze && fadeMs > 0) || (hasFreeze && !videoLive());
1682
+ },
1683
+
1684
+ paint(now) {
1685
+ if (!videoLive()) {
1686
+ // Between titles: hold the outgoing frame rather than blank the tile.
1687
+ if (!hasFreeze) {
1688
+ mix.readyState = 0;
1689
+ return;
1690
+ }
1691
+ size(freeze.width, freeze.height);
1692
+ mctx.globalAlpha = 1;
1693
+ mctx.drawImage(freeze, 0, 0);
1694
+ mix.readyState = 4;
1695
+ return;
1696
+ }
1697
+ size(video.videoWidth, video.videoHeight);
1698
+ mctx.globalAlpha = 1;
1699
+ if (!hasFreeze || fadeMs <= 0) {
1700
+ mctx.drawImage(video, 0, 0, mix.width, mix.height);
1701
+ mix.readyState = 4;
1702
+ hasFreeze = false;
1703
+ return;
1704
+ }
1705
+ if (!startedAt) startedAt = now; // first frame of the incoming title
1706
+ const lin = dissolveAlpha(now - startedAt, fadeMs);
1707
+ // The curve shapes the alpha; the END is decided on the linear clock, so a page easing
1708
+ // that overshoots or never quite reaches 1 cannot leave the freeze held forever.
1709
+ const a = lin >= 1 ? 1 : Math.max(0, Math.min(1, ease(lin)));
1710
+ mctx.drawImage(freeze, 0, 0, mix.width, mix.height);
1711
+ mctx.globalAlpha = a;
1712
+ mctx.drawImage(video, 0, 0, mix.width, mix.height);
1713
+ mctx.globalAlpha = 1;
1714
+ mix.readyState = 4;
1715
+ if (lin >= 1) {
1716
+ hasFreeze = false;
1717
+ fadeMs = 0;
1718
+ }
1719
+ },
1720
+ };
1721
+ }
1722
+
1723
+ /**
1724
+ * Keep the mixer painted for the WOVEN path, where the SDK reads the canvas on its own loop and
1725
+ * this module only has to make sure there is something current in it.
1726
+ *
1727
+ * Paints on new video frames via `requestVideoFrameCallback` in the steady state, and switches
1728
+ * to rAF while a dissolve is running — a ramp has to advance on frames the video does not
1729
+ * produce, which is precisely the case when the incoming title has not started decoding yet.
1730
+ */
1731
+ /**
1732
+ * The woven path's `fit`. The SDK stretches whatever source it is handed onto the tile's SBS
1733
+ * buffer, so fitting has to happen one step earlier: this owns a canvas laid out as an SBS pair
1734
+ * whose per-eye aspect is the TILE's, and fits each eye of the upstream source (the <video>, or
1735
+ * the crossfade mixer) into its half. The SDK's stretch of that canvas is then an identity in
1736
+ * aspect. It answers `readyState` like the mixer does, which is what makes a canvas a valid
1737
+ * `addVideo` source. Opt-in: only created when `fit` is set.
1738
+ *
1739
+ * @param {() => (HTMLVideoElement|HTMLCanvasElement)} upstream
1740
+ * @param {HTMLCanvasElement} tile the woven canvas, for its CSS aspect
1741
+ * @param {'contain'|'cover'} fit
1742
+ */
1743
+ function createFitter(upstream, tile, fit, layout = 'sbs', band = null) {
1744
+ const el = document.createElement('canvas');
1745
+ const ctx = el.getContext('2d');
1746
+ el.readyState = 0;
1747
+ return {
1748
+ el,
1749
+ active: false,
1750
+ paint() {
1751
+ const src = upstream();
1752
+ const vw = src.videoWidth || src.width;
1753
+ const vh = src.videoHeight || src.height;
1754
+ if ((src.readyState || 0) < 2 || !vw || !vh) return; // keep the last fitted frame
1755
+ const tw = tile.clientWidth || tile.width || 16;
1756
+ const th = tile.clientHeight || tile.height || 9;
1757
+ const e = eyeRect(layout, vw, vh, 0);
1758
+ const eyeH = Math.max(1, Math.round(e.h));
1759
+ const eyeW = Math.max(1, Math.round(eyeH * (tw / th)));
1760
+ if (el.width !== eyeW * 2) el.width = eyeW * 2;
1761
+ if (el.height !== eyeH) el.height = eyeH;
1762
+ ctx.clearRect(0, 0, el.width, el.height);
1763
+ drawFittedEye(ctx, src, vw, vh, layout, 0, 0, 0, eyeW, eyeH, fit, band); // left eye
1764
+ drawFittedEye(ctx, src, vw, vh, layout, 1, eyeW, 0, eyeW, eyeH, fit, band); // right eye
1765
+ el.readyState = 4;
1766
+ },
1767
+ };
1768
+ }
1769
+
1770
+ /** Drive one or more source stages (the crossfade mixer, the fitter) in order, per video frame. */
1771
+ function driveMixer(video, stages) {
1772
+ const list = Array.isArray(stages) ? stages : [stages];
1773
+ let stopped = false;
1774
+ let rafId = 0;
1775
+ let rvfcId = 0;
1776
+ const nowMs = () =>
1777
+ typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now();
1778
+
1779
+ function tick() {
1780
+ if (stopped) return;
1781
+ const now = nowMs();
1782
+ for (const st of list) st.paint(now);
1783
+ const canUseRvfc =
1784
+ !list.some((st) => st.active) &&
1785
+ video.readyState >= 2 &&
1786
+ typeof video.requestVideoFrameCallback === 'function';
1787
+ if (canUseRvfc) rvfcId = video.requestVideoFrameCallback(tick);
1788
+ else rafId = requestAnimationFrame(tick);
1789
+ }
1790
+ tick();
1791
+
1792
+ return {
1793
+ stop() {
1794
+ stopped = true;
1795
+ if (rafId) cancelAnimationFrame(rafId);
1796
+ if (rvfcId && typeof video.cancelVideoFrameCallback === 'function') {
1797
+ video.cancelVideoFrameCallback(rvfcId);
1798
+ }
1799
+ },
1800
+ };
1801
+ }
1802
+
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;
1874
+
1875
+ const listeners = new Map();
1876
+ function on(event, fn) {
1877
+ if (!listeners.has(event)) listeners.set(event, new Set());
1878
+ listeners.get(event).add(fn);
1879
+ // 'ended' is a STATE as much as an event: a clip that finished before the page attached its
1880
+ // listener (a short clip, a slow page, a tab that was in the background) would otherwise
1881
+ // never report it. Attaching to an already-ended player calls back once, asynchronously.
1882
+ if (event === 'ended' && video.ended) {
1883
+ queueMicrotask(() => {
1884
+ if (listeners.get(event)?.has(fn)) {
1885
+ try {
1886
+ fn();
1887
+ } catch (err) {
1888
+ console.error('[inline3d/player] listener for "ended" threw', err);
1889
+ }
1890
+ }
1891
+ });
1892
+ }
1893
+ return () => listeners.get(event)?.delete(fn);
1894
+ }
1895
+ function off(event, fn) {
1896
+ listeners.get(event)?.delete(fn);
1897
+ }
1898
+ function emit(event, payload) {
1899
+ for (const fn of listeners.get(event) || []) {
1900
+ try {
1901
+ fn(payload);
1902
+ } catch (err) {
1903
+ console.error(`[inline3d/player] listener for "${event}" threw`, err);
1904
+ }
1905
+ }
1906
+ }
1907
+
1908
+ const leaveGroup = joinGroup(o.group, video);
1909
+ video.addEventListener('play', () => emit('play'));
1910
+ video.addEventListener('pause', () => emit('pause'));
1911
+ video.addEventListener('ended', () => {
1912
+ emit('ended');
1913
+ // `autoAdvance`: a title that ends moves on to the next one and plays it. The end of the list
1914
+ // stops there unless `loopList`. (A single title with `loop: true` never ends at all.)
1915
+ if (o.autoAdvance && titles.length) {
1916
+ const n = nextIndex(currentIdx, titles.length, o.loopList);
1917
+ if (n >= 0) selectTitle(n, true);
1918
+ }
1919
+ });
1920
+ video.addEventListener('timeupdate', () => emit('timeupdate'));
1921
+ video.addEventListener('loadedmetadata', () => emit('ready'));
1922
+ video.addEventListener('error', () => {
1923
+ emit('error', video.error);
1924
+ paintPosterNow();
1925
+ });
1926
+
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
+ function findTitle(id) {
2029
+ if (typeof id === 'number') return Number.isInteger(id) && id >= 0 && id < titles.length ? id : -1;
2030
+ return titles.findIndex((t) => t.id === String(id));
2031
+ }
2032
+ /** Make title `i` current: load it through setSource (its title line and poster ride along). */
2033
+ function selectTitle(i, andPlay) {
2034
+ const t = titles[i];
2035
+ currentIdx = i;
2036
+ const sOpts = {};
2037
+ if (t.title !== undefined) sOpts.title = t.title;
2038
+ if (t.poster !== undefined) sOpts.poster = t.poster;
2039
+ handle.setSource(t.src, sOpts);
2040
+ emit('titlechange', t);
2041
+ return andPlay ? video.play().catch(() => {}) : Promise.resolve();
2042
+ }
2043
+
2044
+ const handle = {
2045
+ get video() {
2046
+ return video;
2047
+ },
2048
+ /**
2049
+ * `play()` resumes. `play(id)` switches to that title (an id, or an index into `titles`) and
2050
+ * plays it — the switch goes through `setSource`, so the player's transition applies.
2051
+ */
2052
+ play(id) {
2053
+ if (id === undefined) return video.play();
2054
+ const i = findTitle(id);
2055
+ if (i < 0) return Promise.reject(new RangeError(`@displayxr/inline3d/player: no title "${id}".`));
2056
+ return selectTitle(i, true);
2057
+ },
2058
+ pause() {
2059
+ video.pause();
2060
+ },
2061
+ toggle() {
2062
+ if (video.paused || video.ended) return video.play();
2063
+ video.pause();
2064
+ return Promise.resolve();
2065
+ },
2066
+ /** The playlist, read-only. Set it with `opts.titles` or `setTitles()`. */
2067
+ get titles() {
2068
+ return titles;
2069
+ },
2070
+ /** The title playing now, or null (no playlist, or a source that is not one of its titles). */
2071
+ get current() {
2072
+ return currentIdx >= 0 ? titles[currentIdx] : null;
2073
+ },
2074
+ /**
2075
+ * Replace the playlist. The current title stays current if the new list has its id; nothing
2076
+ * is reloaded.
2077
+ */
2078
+ setTitles(list) {
2079
+ const prev = currentIdx >= 0 ? titles[currentIdx].id : null;
2080
+ titles = normalizeTitles(list);
2081
+ currentIdx = prev === null ? -1 : titles.findIndex((t) => t.id === prev);
2082
+ },
2083
+ /** The next title, playing. At the end of the list: nothing, unless `loopList`. */
2084
+ next() {
2085
+ const i = nextIndex(currentIdx, titles.length, o.loopList);
2086
+ return i >= 0 ? selectTitle(i, true) : Promise.resolve();
2087
+ },
2088
+ /** A remote's "previous": restart if more than 3 s in, else the previous title. */
2089
+ back() {
2090
+ const b = backTarget(video.currentTime || 0, currentIdx, titles.length, o.loopList);
2091
+ if (b.restart) {
2092
+ video.currentTime = 0;
2093
+ return video.play();
2094
+ }
2095
+ return selectTitle(b.index, true);
2096
+ },
2097
+ seek(t) {
2098
+ const max = Number.isFinite(video.duration) ? video.duration : Math.max(t, 0);
2099
+ video.currentTime = Math.max(0, Math.min(t, max));
2100
+ },
2101
+ get currentTime() {
2102
+ return video.currentTime;
2103
+ },
2104
+ set currentTime(t) {
2105
+ video.currentTime = t;
2106
+ },
2107
+ get duration() {
2108
+ return video.duration;
2109
+ },
2110
+ get paused() {
2111
+ return video.paused;
2112
+ },
2113
+ get ended() {
2114
+ return video.ended;
2115
+ },
2116
+ get volume() {
2117
+ return video.volume;
2118
+ },
2119
+ set volume(v) {
2120
+ video.volume = v;
2121
+ },
2122
+ get muted() {
2123
+ return video.muted;
2124
+ },
2125
+ set muted(m) {
2126
+ video.muted = m;
2127
+ },
2128
+ /**
2129
+ * Swap the source in place. `sOpts` (transition / durationMs / easing / the legacy fadeMs)
2130
+ * overrides the player's own transition field by field for this one swap — see
2131
+ * resolveTransition. A crossfade dissolves from the outgoing title's last frame (see the
2132
+ * dissolve section: not a blend of two live streams, deliberately). It cannot be switched ON
2133
+ * here for a player built without one, because the mixer that runs it only exists from
2134
+ * construction, and creating it lazily would mean swapping the woven window's paint source
2135
+ * mid-flight — a layer rebuild, i.e. the visible blink the crossfade exists to remove.
2136
+ *
2137
+ * A cut holds the old frame until the new source reaches `readyState >= 2`.
2138
+ */
2139
+ setSource(newSrc, sOpts = {}) {
2140
+ // A page calling setSource directly with a playlist URL keeps `current` in step; any other
2141
+ // source is "not one of the titles".
2142
+ if (titles.length && !titles.some((t, k) => k === currentIdx && t.src === newSrc)) {
2143
+ currentIdx = titles.findIndex((t) => t.src === newSrc);
2144
+ }
2145
+ // Resolve (and validate) BEFORE touching anything: a refused option leaves the player as it was.
2146
+ 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(() => {});
2172
+ },
2173
+ /**
2174
+ * Re-skin the SDK transport live: any of `accent` (a CSS colour; '' = the default),
2175
+ * `size` ('s' | 'm' | 'l'), `skin` ('classic' | 'dock'). Invalid values warn and are ignored.
2176
+ * A no-op with `controls: 'none'`.
2177
+ */
2178
+ setAppearance(a = {}) {
2179
+ const next = {};
2180
+ if (a.accent !== undefined) next.accent = typeof a.accent === 'string' ? a.accent : '';
2181
+ if (a.size !== undefined) {
2182
+ const v = typeof a.size === 'string' ? a.size.toLowerCase() : a.size;
2183
+ if (VALID_SIZES.has(v)) next.size = o.size = v;
2184
+ else console.warn(`[inline3d/player] invalid size "${a.size}" — ignored`);
2185
+ }
2186
+ if (a.skin !== undefined) {
2187
+ if (VALID_SKINS.has(a.skin)) next.skin = o.skin = a.skin;
2188
+ else console.warn(`[inline3d/player] invalid skin "${a.skin}" — ignored`);
2189
+ }
2190
+ applyBarAppearance?.(next);
2191
+ },
2192
+ exclude(el) {
2193
+ innerHandle?.exclude(el);
2194
+ },
2195
+ unexclude(el) {
2196
+ innerHandle?.unexclude(el);
2197
+ },
2198
+ remove() {
2199
+ leaveGroup();
2200
+ ownLoop?.stop();
2201
+ mixerLoop?.stop();
2202
+ posterPoll?.stop();
2203
+ cleanupBar?.();
2204
+ innerHandle?.remove();
2205
+ try {
2206
+ video.pause();
2207
+ } catch {
2208
+ /* ignore */
2209
+ }
2210
+ video.removeAttribute('src');
2211
+ video.load();
2212
+ listeners.clear();
2213
+ },
2214
+ on,
2215
+ off,
2216
+ };
2217
+
2218
+ return handle;
2219
+ }