@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.
- package/CHANGELOG.md +117 -0
- package/README.md +15 -0
- package/index.d.ts +57 -0
- package/js/inline3d-player.js +2219 -0
- package/js/inline3d-splat-layer-rig.js +341 -125
- package/js/inline3d-splat-playcanvas.js +35 -19
- package/js/inline3d-splat.js +8 -1
- package/js/inline3d.js +208 -12
- package/package.json +7 -1
- package/player.d.ts +273 -0
- package/splat.d.ts +38 -7
|
@@ -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
|
+
}
|