@openplayerjs/player 3.0.0-beta.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/LICENSE +21 -0
- package/README.md +453 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/index.js +2178 -0
- package/dist/index.js.map +1 -0
- package/dist/openplayer.css +1 -0
- package/dist/openplayer.umd.js +2 -0
- package/dist/openplayer.umd.js.map +1 -0
- package/dist/types/a11y.d.ts +7 -0
- package/dist/types/a11y.d.ts.map +1 -0
- package/dist/types/configuration.d.ts +52 -0
- package/dist/types/configuration.d.ts.map +1 -0
- package/dist/types/control.d.ts +21 -0
- package/dist/types/control.d.ts.map +1 -0
- package/dist/types/controls/base.d.ts +27 -0
- package/dist/types/controls/base.d.ts.map +1 -0
- package/dist/types/controls/captions.d.ts +11 -0
- package/dist/types/controls/captions.d.ts.map +1 -0
- package/dist/types/controls/currentTime.d.ts +9 -0
- package/dist/types/controls/currentTime.d.ts.map +1 -0
- package/dist/types/controls/duration.d.ts +9 -0
- package/dist/types/controls/duration.d.ts.map +1 -0
- package/dist/types/controls/fullscreen.d.ts +13 -0
- package/dist/types/controls/fullscreen.d.ts.map +1 -0
- package/dist/types/controls/play.d.ts +9 -0
- package/dist/types/controls/play.d.ts.map +1 -0
- package/dist/types/controls/progress.d.ts +11 -0
- package/dist/types/controls/progress.d.ts.map +1 -0
- package/dist/types/controls/settings.d.ts +20 -0
- package/dist/types/controls/settings.d.ts.map +1 -0
- package/dist/types/controls/time.d.ts +9 -0
- package/dist/types/controls/time.d.ts.map +1 -0
- package/dist/types/controls/volume.d.ts +9 -0
- package/dist/types/controls/volume.d.ts.map +1 -0
- package/dist/types/events.d.ts +4 -0
- package/dist/types/events.d.ts.map +1 -0
- package/dist/types/extend.d.ts +7 -0
- package/dist/types/extend.d.ts.map +1 -0
- package/dist/types/index.d.ts +29 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/overlay.d.ts +11 -0
- package/dist/types/overlay.d.ts.map +1 -0
- package/dist/types/playback.d.ts +4 -0
- package/dist/types/playback.d.ts.map +1 -0
- package/dist/types/settings.d.ts +25 -0
- package/dist/types/settings.d.ts.map +1 -0
- package/dist/types/ui.d.ts +11 -0
- package/dist/types/ui.d.ts.map +1 -0
- package/dist/types/umd.d.ts +55 -0
- package/dist/types/umd.d.ts.map +1 -0
- package/package.json +50 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2178 @@
|
|
|
1
|
+
import { getOverlayManager, EVENT_OPTIONS, isAudio, isMobile, DisposableStore, formatTime, generateISODateTime, offset } from '@openplayerjs/core';
|
|
2
|
+
|
|
3
|
+
const defaultUIConfiguration = {
|
|
4
|
+
step: 0,
|
|
5
|
+
allowSkip: true,
|
|
6
|
+
allowRewind: true,
|
|
7
|
+
};
|
|
8
|
+
const defaultLabels = Object.freeze({
|
|
9
|
+
auto: 'Auto',
|
|
10
|
+
captions: 'CC/Subtitles',
|
|
11
|
+
click: 'Click to unmute',
|
|
12
|
+
container: 'Media player',
|
|
13
|
+
fullscreen: 'Fullscreen',
|
|
14
|
+
levels: 'Quality Levels',
|
|
15
|
+
live: 'Live',
|
|
16
|
+
loading: 'Loading...',
|
|
17
|
+
media: 'Media',
|
|
18
|
+
mute: 'Mute',
|
|
19
|
+
off: 'Off',
|
|
20
|
+
pause: 'Pause',
|
|
21
|
+
play: 'Play',
|
|
22
|
+
progressRail: 'Time Rail',
|
|
23
|
+
progressSlider: 'Time Slider',
|
|
24
|
+
settings: 'Player Settings',
|
|
25
|
+
speed: 'Speed',
|
|
26
|
+
speedNormal: 'Normal',
|
|
27
|
+
tap: 'Tap to unmute',
|
|
28
|
+
toggleCaptions: 'Toggle Captions',
|
|
29
|
+
unmute: 'Unmute',
|
|
30
|
+
volume: 'Volume',
|
|
31
|
+
volumeControl: 'Volume Control',
|
|
32
|
+
volumeSlider: 'Volume Slider',
|
|
33
|
+
});
|
|
34
|
+
function resolveUIConfig(coreOrConfig) {
|
|
35
|
+
const config = coreOrConfig.config ? coreOrConfig.config : coreOrConfig;
|
|
36
|
+
const allowSkip = config.allowSkip ?? defaultUIConfiguration.allowSkip;
|
|
37
|
+
const allowRewind = config.allowRewind ?? defaultUIConfiguration.allowRewind;
|
|
38
|
+
const step = config.step ?? defaultUIConfiguration.step;
|
|
39
|
+
const width = config.width;
|
|
40
|
+
const height = config.height;
|
|
41
|
+
const labels = { ...defaultLabels, ...(config.labels || {}) };
|
|
42
|
+
// Normalize config in-place when called with a Player instance so downstream code can rely on it.
|
|
43
|
+
if (coreOrConfig.config) {
|
|
44
|
+
try {
|
|
45
|
+
config.labels = labels;
|
|
46
|
+
config.allowSkip = allowSkip;
|
|
47
|
+
config.allowRewind = allowRewind;
|
|
48
|
+
config.step = step;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// ignore
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return { allowSkip, allowRewind, step, width, height, labels };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const registry = new Map();
|
|
58
|
+
function parsePlacement(key) {
|
|
59
|
+
if (key === 'main') {
|
|
60
|
+
return { v: 'middle', h: 'center', region: 'main' };
|
|
61
|
+
}
|
|
62
|
+
const parts = key.split('-');
|
|
63
|
+
let v = 'middle';
|
|
64
|
+
let h = 'center';
|
|
65
|
+
for (const part of parts) {
|
|
66
|
+
if (part === 'top' || part === 'bottom')
|
|
67
|
+
v = part;
|
|
68
|
+
if (part === 'left' || part === 'right')
|
|
69
|
+
h = part;
|
|
70
|
+
if (part === 'middle' || part === 'center')
|
|
71
|
+
h = 'center';
|
|
72
|
+
}
|
|
73
|
+
return { v, h };
|
|
74
|
+
}
|
|
75
|
+
function createSection(name) {
|
|
76
|
+
const section = document.createElement('div');
|
|
77
|
+
section.className = `op-controls__layer op-controls-layer__${name}`;
|
|
78
|
+
const left = document.createElement('div');
|
|
79
|
+
left.className = 'op-controls__left';
|
|
80
|
+
const center = document.createElement('div');
|
|
81
|
+
center.className = 'op-controls__middle';
|
|
82
|
+
const right = document.createElement('div');
|
|
83
|
+
right.className = 'op-controls__right';
|
|
84
|
+
section.append(left, center, right);
|
|
85
|
+
return { section, left, center, right };
|
|
86
|
+
}
|
|
87
|
+
function createControlGrid(controlsRoot, mainRoot) {
|
|
88
|
+
const top = createSection('top');
|
|
89
|
+
const middle = createSection('center');
|
|
90
|
+
const bottom = createSection('bottom');
|
|
91
|
+
controlsRoot.append(top.section, middle.section, bottom.section);
|
|
92
|
+
return {
|
|
93
|
+
place(placement, el) {
|
|
94
|
+
// Special "main" region lives in the media container, not in the controls bar.
|
|
95
|
+
const region = placement.region?.trim();
|
|
96
|
+
if (region === 'main' && mainRoot) {
|
|
97
|
+
mainRoot.appendChild(el);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const row = placement.v === 'top' ? top : placement.v === 'middle' ? middle : bottom;
|
|
101
|
+
const col = placement.h === 'left' ? row.left : placement.h === 'center' ? row.center : row.right;
|
|
102
|
+
col.appendChild(el);
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
function registerControl(name, factory) {
|
|
107
|
+
registry.set(name, factory);
|
|
108
|
+
}
|
|
109
|
+
function getControl(name) {
|
|
110
|
+
const factory = registry.get(name);
|
|
111
|
+
return factory?.() || null;
|
|
112
|
+
}
|
|
113
|
+
function buildControls(config) {
|
|
114
|
+
const controls = [];
|
|
115
|
+
Object.entries(config || {}).forEach(([key, names]) => {
|
|
116
|
+
if (!Array.isArray(names))
|
|
117
|
+
return;
|
|
118
|
+
const placement = parsePlacement(key);
|
|
119
|
+
names.forEach((name) => {
|
|
120
|
+
const control = getControl(name);
|
|
121
|
+
if (!control)
|
|
122
|
+
return;
|
|
123
|
+
control.placement = placement;
|
|
124
|
+
controls.push(control);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
return controls;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function getActiveMedia(core) {
|
|
131
|
+
try {
|
|
132
|
+
const hasOverlayMgr = typeof getOverlayManager === 'function';
|
|
133
|
+
if (!hasOverlayMgr)
|
|
134
|
+
return core.media;
|
|
135
|
+
const active = getOverlayManager(core)?.active;
|
|
136
|
+
const v = active?.fullscreenVideoEl;
|
|
137
|
+
return v && typeof v.play === 'function' ? v : core.media;
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return core.media;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
async function togglePlayback(core) {
|
|
144
|
+
const media = getActiveMedia(core);
|
|
145
|
+
const isPlaying = !media.paused && !media.ended;
|
|
146
|
+
if (isPlaying) {
|
|
147
|
+
core.pause();
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
await core.play();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function bindCenterOverlay(core, keyTarget, bindings) {
|
|
154
|
+
let lastNonZeroVolume = core.volume || 1;
|
|
155
|
+
const onKeyboard = () => {
|
|
156
|
+
if (keyTarget.classList.contains('op-player__keyboard--inactive')) {
|
|
157
|
+
keyTarget.classList.remove('op-player__keyboard--inactive');
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
const onPointer = () => {
|
|
161
|
+
if (!keyTarget.classList.contains('op-player__keyboard--inactive')) {
|
|
162
|
+
keyTarget.classList.add('op-player__keyboard--inactive');
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
keyTarget.addEventListener('click', onPointer, EVENT_OPTIONS);
|
|
166
|
+
keyTarget.addEventListener('pointerdown', onPointer, EVENT_OPTIONS);
|
|
167
|
+
keyTarget.addEventListener('pointerleave', onPointer, EVENT_OPTIONS);
|
|
168
|
+
window.addEventListener('click', onPointer, EVENT_OPTIONS);
|
|
169
|
+
window.addEventListener('pointerdown', onPointer, EVENT_OPTIONS);
|
|
170
|
+
window.addEventListener('keydown', onKeyboard, EVENT_OPTIONS);
|
|
171
|
+
keyTarget.addEventListener('keydown', async (e) => {
|
|
172
|
+
const key = e.key;
|
|
173
|
+
onKeyboard();
|
|
174
|
+
const activeEl = document.activeElement;
|
|
175
|
+
if (activeEl &&
|
|
176
|
+
(key === ' ' || key === 'Enter' || key === 'Spacebar') &&
|
|
177
|
+
(activeEl.tagName === 'BUTTON' || activeEl.getAttribute('role') === 'button')) {
|
|
178
|
+
activeEl.click();
|
|
179
|
+
e.preventDefault();
|
|
180
|
+
e.stopPropagation();
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const conf = resolveUIConfig(core).step;
|
|
184
|
+
const step = conf && conf > 0 ? conf : 5;
|
|
185
|
+
switch (key) {
|
|
186
|
+
// Toggle play/pause
|
|
187
|
+
case 'k':
|
|
188
|
+
case 'K':
|
|
189
|
+
case 'Enter':
|
|
190
|
+
case ' ':
|
|
191
|
+
case 'Spacebar': {
|
|
192
|
+
await togglePlayback(core);
|
|
193
|
+
e.preventDefault();
|
|
194
|
+
e.stopPropagation();
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
// End key ends video
|
|
198
|
+
case 'End':
|
|
199
|
+
if (core.duration !== Infinity) {
|
|
200
|
+
core.currentTime = core.duration;
|
|
201
|
+
e.preventDefault();
|
|
202
|
+
e.stopPropagation();
|
|
203
|
+
}
|
|
204
|
+
break;
|
|
205
|
+
// Home key resets progress
|
|
206
|
+
case 'Home':
|
|
207
|
+
core.currentTime = 0;
|
|
208
|
+
e.preventDefault();
|
|
209
|
+
e.stopPropagation();
|
|
210
|
+
break;
|
|
211
|
+
// Volume
|
|
212
|
+
case 'ArrowUp': {
|
|
213
|
+
const upVolume = Math.min(core.volume + 0.1, 1);
|
|
214
|
+
core.volume = upVolume;
|
|
215
|
+
core.muted = !(upVolume > 0);
|
|
216
|
+
if (upVolume > 0)
|
|
217
|
+
lastNonZeroVolume = upVolume;
|
|
218
|
+
const el = getActiveMedia(core);
|
|
219
|
+
if (el && el !== core.media) {
|
|
220
|
+
try {
|
|
221
|
+
el.volume = upVolume;
|
|
222
|
+
el.muted = !(upVolume > 0);
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
// ignore
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
e.preventDefault();
|
|
229
|
+
e.stopPropagation();
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
case 'ArrowDown': {
|
|
233
|
+
const downVolume = Math.max(core.volume - 0.1, 0);
|
|
234
|
+
core.volume = downVolume;
|
|
235
|
+
core.muted = !(downVolume > 0);
|
|
236
|
+
if (downVolume > 0)
|
|
237
|
+
lastNonZeroVolume = downVolume;
|
|
238
|
+
const el = getActiveMedia(core);
|
|
239
|
+
if (el && el !== core.media) {
|
|
240
|
+
try {
|
|
241
|
+
el.volume = downVolume;
|
|
242
|
+
el.muted = !(downVolume > 0);
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
// ignore
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
e.preventDefault();
|
|
249
|
+
e.stopPropagation();
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
// Fullscreen toggle
|
|
253
|
+
case 'f':
|
|
254
|
+
case 'F': {
|
|
255
|
+
const fullscreenTarget = e.target;
|
|
256
|
+
if (fullscreenTarget.requestFullscreen)
|
|
257
|
+
fullscreenTarget.requestFullscreen();
|
|
258
|
+
else if (fullscreenTarget.mozRequestFullScreen)
|
|
259
|
+
fullscreenTarget.mozRequestFullScreen();
|
|
260
|
+
else if (fullscreenTarget.webkitRequestFullScreen)
|
|
261
|
+
fullscreenTarget.webkitRequestFullScreen();
|
|
262
|
+
else if (fullscreenTarget.msRequestFullscreen)
|
|
263
|
+
fullscreenTarget.msRequestFullscreen();
|
|
264
|
+
else if (fullscreenTarget.webkitEnterFullscreen)
|
|
265
|
+
fullscreenTarget.webkitEnterFullscreen();
|
|
266
|
+
e.preventDefault();
|
|
267
|
+
e.stopPropagation();
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
270
|
+
// Mute toggle preserving last volume
|
|
271
|
+
case 'm':
|
|
272
|
+
case 'M': {
|
|
273
|
+
const el = getActiveMedia(core);
|
|
274
|
+
const nextMuted = !core.muted;
|
|
275
|
+
if (nextMuted) {
|
|
276
|
+
// store last non-zero volume before muting
|
|
277
|
+
if (core.volume > 0)
|
|
278
|
+
lastNonZeroVolume = core.volume;
|
|
279
|
+
core.volume = 0;
|
|
280
|
+
core.muted = true;
|
|
281
|
+
if (el && el !== core.media) {
|
|
282
|
+
try {
|
|
283
|
+
el.volume = 0;
|
|
284
|
+
el.muted = true;
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
// ignore
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
else {
|
|
292
|
+
const restore = lastNonZeroVolume > 0 ? lastNonZeroVolume : 1;
|
|
293
|
+
core.volume = restore;
|
|
294
|
+
core.muted = false;
|
|
295
|
+
if (el && el !== core.media) {
|
|
296
|
+
try {
|
|
297
|
+
el.volume = restore;
|
|
298
|
+
el.muted = false;
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
// ignore
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
e.preventDefault();
|
|
306
|
+
e.stopPropagation();
|
|
307
|
+
break;
|
|
308
|
+
}
|
|
309
|
+
// Seek
|
|
310
|
+
case 'J':
|
|
311
|
+
case 'j':
|
|
312
|
+
case 'ArrowLeft':
|
|
313
|
+
if (core.duration !== Infinity) {
|
|
314
|
+
core.currentTime = Math.max(0, core.currentTime - step);
|
|
315
|
+
e.preventDefault();
|
|
316
|
+
e.stopPropagation();
|
|
317
|
+
}
|
|
318
|
+
break;
|
|
319
|
+
case 'L':
|
|
320
|
+
case 'l':
|
|
321
|
+
case 'ArrowRight':
|
|
322
|
+
if (core.duration !== Infinity) {
|
|
323
|
+
core.currentTime = Math.min(core.duration, core.currentTime + step);
|
|
324
|
+
e.preventDefault();
|
|
325
|
+
e.stopPropagation();
|
|
326
|
+
}
|
|
327
|
+
break;
|
|
328
|
+
// Rate
|
|
329
|
+
case '<':
|
|
330
|
+
core.playbackRate = Math.max(core.playbackRate - 0.25, 0.25);
|
|
331
|
+
e.preventDefault();
|
|
332
|
+
e.stopPropagation();
|
|
333
|
+
break;
|
|
334
|
+
case '>':
|
|
335
|
+
core.playbackRate = Math.min(core.playbackRate + 0.25, 2);
|
|
336
|
+
e.preventDefault();
|
|
337
|
+
e.stopPropagation();
|
|
338
|
+
break;
|
|
339
|
+
}
|
|
340
|
+
}, EVENT_OPTIONS);
|
|
341
|
+
core.events.on('waiting', () => {
|
|
342
|
+
bindings?.showLoader(true);
|
|
343
|
+
bindings?.showButton(false);
|
|
344
|
+
});
|
|
345
|
+
core.events.on('seeking', () => {
|
|
346
|
+
bindings?.showLoader(true);
|
|
347
|
+
bindings?.showButton(false);
|
|
348
|
+
});
|
|
349
|
+
core.events.on('seeked', () => {
|
|
350
|
+
bindings?.showLoader(false);
|
|
351
|
+
// After seeking, only show the center button if we are paused.
|
|
352
|
+
// When seeking during playback (common on iOS), keeping the play button visible
|
|
353
|
+
// causes it to linger even after the loader is hidden.
|
|
354
|
+
bindings?.showButton(core.media?.paused ?? false);
|
|
355
|
+
});
|
|
356
|
+
core.events.on('play', () => {
|
|
357
|
+
bindings?.showLoader(false);
|
|
358
|
+
bindings?.showButton(false);
|
|
359
|
+
});
|
|
360
|
+
core.events.on('pause', () => {
|
|
361
|
+
bindings?.showLoader(false);
|
|
362
|
+
bindings?.showButton(true);
|
|
363
|
+
});
|
|
364
|
+
core.events.on('playing', () => {
|
|
365
|
+
bindings?.showLoader(false);
|
|
366
|
+
bindings?.showButton(false);
|
|
367
|
+
});
|
|
368
|
+
core.events.on('ended', () => {
|
|
369
|
+
bindings?.showLoader(false);
|
|
370
|
+
bindings?.showButton(true);
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
let srId$1 = 0;
|
|
375
|
+
function nextId() {
|
|
376
|
+
srId$1 += 1;
|
|
377
|
+
return `op-player-sr-${srId$1}`;
|
|
378
|
+
}
|
|
379
|
+
function ensureSrSpan(host, text) {
|
|
380
|
+
const existing = host.querySelector(':scope > span.op-player__sr-only');
|
|
381
|
+
const span = existing ?? document.createElement('span');
|
|
382
|
+
if (!existing) {
|
|
383
|
+
span.className = 'op-player__sr-only';
|
|
384
|
+
host.appendChild(span);
|
|
385
|
+
}
|
|
386
|
+
span.textContent = text;
|
|
387
|
+
return span;
|
|
388
|
+
}
|
|
389
|
+
function ensureLabelledBy(el, labelText, opts) {
|
|
390
|
+
const parent = el.parentElement ?? opts?.container;
|
|
391
|
+
if (!parent) {
|
|
392
|
+
el.setAttribute('aria-label', labelText);
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
const labelledBy = el.getAttribute('aria-labelledby');
|
|
396
|
+
let span = null;
|
|
397
|
+
if (labelledBy) {
|
|
398
|
+
const found = document.getElementById(labelledBy);
|
|
399
|
+
if (found && found instanceof HTMLSpanElement)
|
|
400
|
+
span = found;
|
|
401
|
+
}
|
|
402
|
+
if (!span) {
|
|
403
|
+
span = document.createElement('span');
|
|
404
|
+
span.className = 'op-player__sr-only';
|
|
405
|
+
span.id = nextId();
|
|
406
|
+
if (el.parentElement === parent && parent.contains(el)) {
|
|
407
|
+
parent.insertBefore(span, el);
|
|
408
|
+
}
|
|
409
|
+
else {
|
|
410
|
+
parent.appendChild(span);
|
|
411
|
+
}
|
|
412
|
+
el.setAttribute('aria-labelledby', span.id);
|
|
413
|
+
}
|
|
414
|
+
span.textContent = labelText;
|
|
415
|
+
el.removeAttribute('aria-label');
|
|
416
|
+
}
|
|
417
|
+
function setA11yLabel(el, labelText, opts) {
|
|
418
|
+
const tag = el.tagName.toLowerCase();
|
|
419
|
+
const isButtonLike = tag === 'button' || el.getAttribute('role') === 'button';
|
|
420
|
+
if (isButtonLike) {
|
|
421
|
+
ensureSrSpan(el, labelText);
|
|
422
|
+
el.removeAttribute('aria-label');
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
ensureLabelledBy(el, labelText, opts);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function createCenterOverlayDom(core) {
|
|
429
|
+
const labels = resolveUIConfig(core).labels;
|
|
430
|
+
const playLabel = labels.play;
|
|
431
|
+
const pauseLabel = labels.pause;
|
|
432
|
+
const loadingLabel = labels.loading;
|
|
433
|
+
const button = document.createElement('button');
|
|
434
|
+
button.className = 'op-player__play';
|
|
435
|
+
button.tabIndex = 0;
|
|
436
|
+
button.type = 'button';
|
|
437
|
+
button.setAttribute('aria-pressed', 'false');
|
|
438
|
+
button.setAttribute('aria-hidden', 'false');
|
|
439
|
+
setA11yLabel(button, playLabel);
|
|
440
|
+
button.setAttribute('aria-keyshortcuts', 'K Enter');
|
|
441
|
+
button.addEventListener('click', async (e) => {
|
|
442
|
+
await togglePlayback(core);
|
|
443
|
+
e.preventDefault();
|
|
444
|
+
e.stopPropagation();
|
|
445
|
+
}, EVENT_OPTIONS);
|
|
446
|
+
const loader = document.createElement('span');
|
|
447
|
+
loader.className = 'op-player__loader';
|
|
448
|
+
loader.tabIndex = -1;
|
|
449
|
+
loader.setAttribute('aria-hidden', 'true');
|
|
450
|
+
loader.setAttribute('role', 'status');
|
|
451
|
+
loader.setAttribute('aria-live', 'polite');
|
|
452
|
+
const loaderText = document.createElement('span');
|
|
453
|
+
loaderText.className = 'op-player__sr-only';
|
|
454
|
+
loaderText.textContent = loadingLabel;
|
|
455
|
+
loader.appendChild(loaderText);
|
|
456
|
+
let flashTimer;
|
|
457
|
+
let isFlashing = false;
|
|
458
|
+
const cancelFlash = () => {
|
|
459
|
+
if (flashTimer) {
|
|
460
|
+
window.clearTimeout(flashTimer);
|
|
461
|
+
flashTimer = undefined;
|
|
462
|
+
}
|
|
463
|
+
isFlashing = false;
|
|
464
|
+
button.classList.remove('op-player__play--flash');
|
|
465
|
+
};
|
|
466
|
+
const showButton = (show) => {
|
|
467
|
+
if (show) {
|
|
468
|
+
if (isFlashing)
|
|
469
|
+
return;
|
|
470
|
+
button.classList.remove('op-player__play--paused');
|
|
471
|
+
button.setAttribute('aria-hidden', 'false');
|
|
472
|
+
button.removeAttribute('inert');
|
|
473
|
+
setA11yLabel(button, playLabel);
|
|
474
|
+
button.inert = false;
|
|
475
|
+
button.tabIndex = 0;
|
|
476
|
+
}
|
|
477
|
+
else {
|
|
478
|
+
if (isFlashing)
|
|
479
|
+
cancelFlash();
|
|
480
|
+
// Avoid aria-hidden on a focused element (Chrome warning).
|
|
481
|
+
const active = document.activeElement;
|
|
482
|
+
if (active && (active === button || button.contains(active))) {
|
|
483
|
+
active.blur?.();
|
|
484
|
+
}
|
|
485
|
+
button.classList.add('op-player__play--paused');
|
|
486
|
+
button.setAttribute('aria-hidden', 'true');
|
|
487
|
+
button.setAttribute('inert', '');
|
|
488
|
+
setA11yLabel(button, pauseLabel);
|
|
489
|
+
button.inert = true;
|
|
490
|
+
button.tabIndex = -1;
|
|
491
|
+
}
|
|
492
|
+
};
|
|
493
|
+
const flashPause = (ms) => {
|
|
494
|
+
cancelFlash();
|
|
495
|
+
isFlashing = true;
|
|
496
|
+
button.classList.remove('op-player__play--paused');
|
|
497
|
+
button.classList.add('op-player__play--flash');
|
|
498
|
+
button.setAttribute('aria-hidden', 'false');
|
|
499
|
+
button.removeAttribute('inert');
|
|
500
|
+
button.inert = false;
|
|
501
|
+
button.tabIndex = -1;
|
|
502
|
+
setA11yLabel(button, pauseLabel);
|
|
503
|
+
flashTimer = window.setTimeout(() => {
|
|
504
|
+
button.classList.remove('op-player__play--flash');
|
|
505
|
+
flashTimer = undefined;
|
|
506
|
+
isFlashing = false;
|
|
507
|
+
showButton(true);
|
|
508
|
+
}, ms);
|
|
509
|
+
};
|
|
510
|
+
const showLoader = (show) => {
|
|
511
|
+
loader.style.display = show ? '' : 'none';
|
|
512
|
+
loader.setAttribute('aria-hidden', show ? 'false' : 'true');
|
|
513
|
+
};
|
|
514
|
+
return { button, loader, showButton, showLoader, flashPause };
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
let srId = 0;
|
|
518
|
+
function labelElement(host, text) {
|
|
519
|
+
const existing = host.querySelector(':scope > span.op-player__sr-only');
|
|
520
|
+
const span = existing ?? document.createElement('span');
|
|
521
|
+
if (!existing) {
|
|
522
|
+
srId += 1;
|
|
523
|
+
span.className = 'op-player__sr-only';
|
|
524
|
+
span.id = `op-player-sr-el-${srId}`;
|
|
525
|
+
host.insertBefore(span, host.firstChild);
|
|
526
|
+
host.setAttribute('aria-labelledby', span.id);
|
|
527
|
+
host.removeAttribute('aria-label');
|
|
528
|
+
}
|
|
529
|
+
span.textContent = text;
|
|
530
|
+
}
|
|
531
|
+
function maybeAutoplayUnmute(core, wrapper) {
|
|
532
|
+
const media = core.media;
|
|
533
|
+
const wantsAutoplay = !!(media.autoplay || media.preload === 'auto');
|
|
534
|
+
if (!wantsAutoplay && !core.canAutoplay && !core.canAutoplayMuted)
|
|
535
|
+
return;
|
|
536
|
+
if (core.canAutoplay && !core.canAutoplayMuted)
|
|
537
|
+
return;
|
|
538
|
+
queueMicrotask(async () => {
|
|
539
|
+
try {
|
|
540
|
+
const restoreVolume = core.volume > 0 ? core.volume : 1;
|
|
541
|
+
core.muted = true;
|
|
542
|
+
core.volume = 0;
|
|
543
|
+
await core.play();
|
|
544
|
+
const labels = resolveUIConfig(core).labels;
|
|
545
|
+
const action = isMobile() ? labels.tap : labels.click;
|
|
546
|
+
const btn = document.createElement('button');
|
|
547
|
+
btn.type = 'button';
|
|
548
|
+
btn.className = 'op-player__unmute';
|
|
549
|
+
btn.textContent = action;
|
|
550
|
+
btn.tabIndex = 0;
|
|
551
|
+
const cleanup = () => {
|
|
552
|
+
try {
|
|
553
|
+
offMuted?.();
|
|
554
|
+
}
|
|
555
|
+
catch {
|
|
556
|
+
// ignore
|
|
557
|
+
}
|
|
558
|
+
try {
|
|
559
|
+
btn.remove();
|
|
560
|
+
}
|
|
561
|
+
catch {
|
|
562
|
+
// ignore
|
|
563
|
+
}
|
|
564
|
+
};
|
|
565
|
+
const offMuted = core.events.on('volumechange', () => {
|
|
566
|
+
if (!core.muted)
|
|
567
|
+
cleanup();
|
|
568
|
+
});
|
|
569
|
+
btn.addEventListener('click', () => {
|
|
570
|
+
// Treat explicit unmute as a user interaction so ad plugins can lift forced mute.
|
|
571
|
+
if (!core.userInteracted) {
|
|
572
|
+
core.userInteracted = true;
|
|
573
|
+
core.emit('player:interacted');
|
|
574
|
+
}
|
|
575
|
+
core.muted = false;
|
|
576
|
+
core.volume = restoreVolume;
|
|
577
|
+
core.play().catch(() => undefined);
|
|
578
|
+
cleanup();
|
|
579
|
+
}, EVENT_OPTIONS);
|
|
580
|
+
wrapper.insertBefore(btn, wrapper.firstChild);
|
|
581
|
+
core.events.on('player:destroy', cleanup);
|
|
582
|
+
}
|
|
583
|
+
catch {
|
|
584
|
+
// ignore autoplay failures
|
|
585
|
+
}
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
function createUI(core, media, controls) {
|
|
589
|
+
const ui = resolveUIConfig(core);
|
|
590
|
+
media.tabIndex = -1;
|
|
591
|
+
const tmpMedia = media;
|
|
592
|
+
const isMediaAudio = isAudio(tmpMedia);
|
|
593
|
+
const placeholder = document.createComment('op-player-placeholder');
|
|
594
|
+
const parent = tmpMedia.parentNode;
|
|
595
|
+
if (parent)
|
|
596
|
+
parent.insertBefore(placeholder, tmpMedia);
|
|
597
|
+
const wrapper = document.createElement('div');
|
|
598
|
+
wrapper.className = `op-player op-player__keyboard--inactive ${isMediaAudio ? 'op-player__audio' : 'op-player__video'}`;
|
|
599
|
+
wrapper.setAttribute('role', 'region');
|
|
600
|
+
wrapper.tabIndex = 0;
|
|
601
|
+
let style = '';
|
|
602
|
+
if (ui.width) {
|
|
603
|
+
const width = typeof ui.width === 'number' ? `${ui.width}px` : ui.width;
|
|
604
|
+
style += `width: ${width} !important;`;
|
|
605
|
+
}
|
|
606
|
+
if (ui.height) {
|
|
607
|
+
const height = typeof ui.height === 'number' ? `${ui.height}px` : ui.height;
|
|
608
|
+
style += `height: ${height} !important;`;
|
|
609
|
+
}
|
|
610
|
+
if (style) {
|
|
611
|
+
wrapper.setAttribute('style', style);
|
|
612
|
+
}
|
|
613
|
+
media.controls = false;
|
|
614
|
+
media.replaceWith(wrapper);
|
|
615
|
+
labelElement(wrapper, ui.labels.container);
|
|
616
|
+
const mediaContainer = document.createElement('div');
|
|
617
|
+
mediaContainer.className = 'op-media';
|
|
618
|
+
mediaContainer.tabIndex = 0;
|
|
619
|
+
mediaContainer.setAttribute('role', 'group');
|
|
620
|
+
mediaContainer.appendChild(tmpMedia);
|
|
621
|
+
labelElement(mediaContainer, ui.labels.media);
|
|
622
|
+
const mainControls = document.createElement('div');
|
|
623
|
+
mainControls.className = 'op-media__main';
|
|
624
|
+
let overlay;
|
|
625
|
+
if (!isMediaAudio) {
|
|
626
|
+
mediaContainer.appendChild(mainControls);
|
|
627
|
+
overlay = createCenterOverlayDom(core);
|
|
628
|
+
mediaContainer.appendChild(overlay.button);
|
|
629
|
+
mediaContainer.appendChild(overlay.loader);
|
|
630
|
+
}
|
|
631
|
+
bindCenterOverlay(core, wrapper, overlay);
|
|
632
|
+
const controlsRoot = document.createElement('div');
|
|
633
|
+
controlsRoot.className = 'op-controls';
|
|
634
|
+
controlsRoot.setAttribute('aria-hidden', 'false');
|
|
635
|
+
if (isMediaAudio) {
|
|
636
|
+
const grid = createControlGrid(controlsRoot);
|
|
637
|
+
wrapper.appendChild(mediaContainer);
|
|
638
|
+
wrapper.appendChild(controlsRoot);
|
|
639
|
+
const createdControls = [];
|
|
640
|
+
controls.forEach((control) => {
|
|
641
|
+
const el = control.create(core);
|
|
642
|
+
el.dataset.controlId = control.id;
|
|
643
|
+
grid.place(control.placement, el);
|
|
644
|
+
createdControls.push(control);
|
|
645
|
+
});
|
|
646
|
+
const ctx = { wrapper, mediaContainer, controlsRoot, placeholder, grid };
|
|
647
|
+
const offDestroy = core.events.on('player:destroy', () => {
|
|
648
|
+
try {
|
|
649
|
+
createdControls.forEach((c) => c.destroy?.());
|
|
650
|
+
}
|
|
651
|
+
catch {
|
|
652
|
+
// ignore
|
|
653
|
+
}
|
|
654
|
+
try {
|
|
655
|
+
wrapper.replaceWith(media);
|
|
656
|
+
}
|
|
657
|
+
catch {
|
|
658
|
+
// ignore
|
|
659
|
+
}
|
|
660
|
+
try {
|
|
661
|
+
placeholder.remove();
|
|
662
|
+
}
|
|
663
|
+
catch {
|
|
664
|
+
// ignore
|
|
665
|
+
}
|
|
666
|
+
try {
|
|
667
|
+
offAddElement();
|
|
668
|
+
offAddControl();
|
|
669
|
+
offDestroy();
|
|
670
|
+
}
|
|
671
|
+
catch {
|
|
672
|
+
// ignore
|
|
673
|
+
}
|
|
674
|
+
});
|
|
675
|
+
const offAddElement = core.events.on('ui:addElement', (payload) => {
|
|
676
|
+
if (!payload?.el)
|
|
677
|
+
return;
|
|
678
|
+
const placement = payload.placement || { v: 'bottom', h: 'right' };
|
|
679
|
+
ctx.grid?.place(placement, payload.el);
|
|
680
|
+
});
|
|
681
|
+
const offAddControl = core.events.on('ui:addControl', (payload) => {
|
|
682
|
+
const control = payload?.control;
|
|
683
|
+
if (!control)
|
|
684
|
+
return;
|
|
685
|
+
const el = control.create(core);
|
|
686
|
+
el.dataset.controlId = control.id;
|
|
687
|
+
ctx.grid?.place(control.placement, el);
|
|
688
|
+
payload.el = el;
|
|
689
|
+
createdControls.push(control);
|
|
690
|
+
});
|
|
691
|
+
maybeAutoplayUnmute(core, wrapper);
|
|
692
|
+
return ctx;
|
|
693
|
+
}
|
|
694
|
+
const mobile = isMobile();
|
|
695
|
+
const POINTER_SHOW_MS = 3000;
|
|
696
|
+
const KEYBOARD_SHOW_MS = 6500;
|
|
697
|
+
let hideTimer;
|
|
698
|
+
let lastInteraction = 'pointer';
|
|
699
|
+
const controlsHaveFocus = () => controlsRoot.contains(document.activeElement);
|
|
700
|
+
const showControls = () => {
|
|
701
|
+
wrapper.classList.remove('op-controls--hidden');
|
|
702
|
+
mediaContainer.classList.remove('op-media--controls-hidden');
|
|
703
|
+
if (hideTimer)
|
|
704
|
+
window.clearTimeout(hideTimer);
|
|
705
|
+
controlsRoot.setAttribute('aria-hidden', 'false');
|
|
706
|
+
};
|
|
707
|
+
const hideControls = () => {
|
|
708
|
+
if (controlsHaveFocus()) {
|
|
709
|
+
if (lastInteraction === 'keyboard') {
|
|
710
|
+
wrapper.focus();
|
|
711
|
+
}
|
|
712
|
+
else {
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
wrapper.classList.add('op-controls--hidden');
|
|
717
|
+
mediaContainer.classList.add('op-media--controls-hidden');
|
|
718
|
+
controlsRoot.setAttribute('aria-hidden', 'true');
|
|
719
|
+
};
|
|
720
|
+
const scheduleHide = (ms) => {
|
|
721
|
+
if (core.media.paused || core.media.ended)
|
|
722
|
+
return;
|
|
723
|
+
if (hideTimer)
|
|
724
|
+
window.clearTimeout(hideTimer);
|
|
725
|
+
hideTimer = window.setTimeout(() => hideControls(), ms ?? POINTER_SHOW_MS);
|
|
726
|
+
};
|
|
727
|
+
const onControlsHover = () => {
|
|
728
|
+
lastInteraction = 'pointer';
|
|
729
|
+
showControls();
|
|
730
|
+
scheduleHide(POINTER_SHOW_MS);
|
|
731
|
+
};
|
|
732
|
+
controlsRoot.addEventListener('focusin', () => {
|
|
733
|
+
lastInteraction = 'keyboard';
|
|
734
|
+
showControls();
|
|
735
|
+
scheduleHide(KEYBOARD_SHOW_MS);
|
|
736
|
+
});
|
|
737
|
+
controlsRoot.addEventListener('focusout', () => {
|
|
738
|
+
window.setTimeout(() => {
|
|
739
|
+
if (!controlsHaveFocus())
|
|
740
|
+
scheduleHide(lastInteraction === 'keyboard' ? KEYBOARD_SHOW_MS : POINTER_SHOW_MS);
|
|
741
|
+
}, 0);
|
|
742
|
+
});
|
|
743
|
+
const isFocusInMediaArea = () => {
|
|
744
|
+
const active = document.activeElement;
|
|
745
|
+
if (!active)
|
|
746
|
+
return false;
|
|
747
|
+
return wrapper.contains(active) && !controlsRoot.contains(active);
|
|
748
|
+
};
|
|
749
|
+
wrapper.addEventListener('focusin', () => {
|
|
750
|
+
if (isFocusInMediaArea()) {
|
|
751
|
+
showControls();
|
|
752
|
+
if (hideTimer)
|
|
753
|
+
window.clearTimeout(hideTimer);
|
|
754
|
+
}
|
|
755
|
+
}, EVENT_OPTIONS);
|
|
756
|
+
wrapper.addEventListener('focusout', () => {
|
|
757
|
+
window.setTimeout(() => {
|
|
758
|
+
if (!wrapper.contains(document.activeElement) && !controlsHaveFocus())
|
|
759
|
+
scheduleHide();
|
|
760
|
+
}, 0);
|
|
761
|
+
}, EVENT_OPTIONS);
|
|
762
|
+
wrapper.addEventListener('keydown', () => {
|
|
763
|
+
lastInteraction = 'keyboard';
|
|
764
|
+
if (isFocusInMediaArea()) {
|
|
765
|
+
showControls();
|
|
766
|
+
if (hideTimer)
|
|
767
|
+
window.clearTimeout(hideTimer);
|
|
768
|
+
}
|
|
769
|
+
else if (controlsHaveFocus()) {
|
|
770
|
+
showControls();
|
|
771
|
+
scheduleHide(KEYBOARD_SHOW_MS);
|
|
772
|
+
}
|
|
773
|
+
}, EVENT_OPTIONS);
|
|
774
|
+
if (mobile) {
|
|
775
|
+
wrapper.addEventListener('pointerdown', () => {
|
|
776
|
+
lastInteraction = 'pointer';
|
|
777
|
+
showControls();
|
|
778
|
+
scheduleHide(POINTER_SHOW_MS);
|
|
779
|
+
}, EVENT_OPTIONS);
|
|
780
|
+
}
|
|
781
|
+
else {
|
|
782
|
+
wrapper.addEventListener('pointermove', onControlsHover, EVENT_OPTIONS);
|
|
783
|
+
wrapper.addEventListener('pointerenter', onControlsHover, EVENT_OPTIONS);
|
|
784
|
+
controlsRoot.addEventListener('pointerenter', onControlsHover, EVENT_OPTIONS);
|
|
785
|
+
controlsRoot.addEventListener('pointermove', onControlsHover, EVENT_OPTIONS);
|
|
786
|
+
controlsRoot.addEventListener('pointerleave', () => scheduleHide(POINTER_SHOW_MS), EVENT_OPTIONS);
|
|
787
|
+
}
|
|
788
|
+
const grid = createControlGrid(controlsRoot, mainControls);
|
|
789
|
+
wrapper.appendChild(mediaContainer);
|
|
790
|
+
wrapper.appendChild(controlsRoot);
|
|
791
|
+
const createdControls = [];
|
|
792
|
+
controls.forEach((control) => {
|
|
793
|
+
const el = control.create(core);
|
|
794
|
+
el.dataset.controlId = control.id;
|
|
795
|
+
grid.place(control.placement, el);
|
|
796
|
+
createdControls.push(control);
|
|
797
|
+
});
|
|
798
|
+
const ctx = { wrapper, mediaContainer, controlsRoot, placeholder, grid };
|
|
799
|
+
maybeAutoplayUnmute(core, wrapper);
|
|
800
|
+
wrapper.addEventListener('click', async (e) => {
|
|
801
|
+
// Linear overlays (e.g. full-screen ads with their own video) own click handling.
|
|
802
|
+
// Non-linear ads (no fullscreenVideoEl) run alongside the content, so we allow pause.
|
|
803
|
+
if (getOverlayManager(core).active?.fullscreenVideoEl)
|
|
804
|
+
return;
|
|
805
|
+
const target = e.target;
|
|
806
|
+
// Clicks inside the controls bar must not toggle playback.
|
|
807
|
+
if (target && controlsRoot.contains(target))
|
|
808
|
+
return;
|
|
809
|
+
// Clicks on interactive elements (buttons, links) are handled by those elements.
|
|
810
|
+
if (target && target !== wrapper && target.closest('button, [role="button"], a'))
|
|
811
|
+
return;
|
|
812
|
+
const isPlaying = !core.media.paused && !core.media.ended;
|
|
813
|
+
if (isPlaying) {
|
|
814
|
+
overlay?.flashPause(350);
|
|
815
|
+
core.pause();
|
|
816
|
+
}
|
|
817
|
+
else {
|
|
818
|
+
await core.play().catch(() => undefined);
|
|
819
|
+
}
|
|
820
|
+
}, EVENT_OPTIONS);
|
|
821
|
+
const offPlaying = core.events.on('playing', () => scheduleHide(POINTER_SHOW_MS));
|
|
822
|
+
const offPause = core.events.on('pause', () => showControls());
|
|
823
|
+
const offEnded = core.events.on('ended', () => showControls());
|
|
824
|
+
const offDestroy = core.events.on('player:destroy', () => {
|
|
825
|
+
try {
|
|
826
|
+
createdControls.forEach((c) => c.destroy?.());
|
|
827
|
+
}
|
|
828
|
+
catch {
|
|
829
|
+
// ignore
|
|
830
|
+
}
|
|
831
|
+
try {
|
|
832
|
+
wrapper.replaceWith(media);
|
|
833
|
+
}
|
|
834
|
+
catch {
|
|
835
|
+
// ignore
|
|
836
|
+
}
|
|
837
|
+
try {
|
|
838
|
+
placeholder.remove();
|
|
839
|
+
}
|
|
840
|
+
catch {
|
|
841
|
+
// ignore
|
|
842
|
+
}
|
|
843
|
+
try {
|
|
844
|
+
offPlaying?.();
|
|
845
|
+
offPause?.();
|
|
846
|
+
offEnded?.();
|
|
847
|
+
offAddElement();
|
|
848
|
+
offAddControl();
|
|
849
|
+
offDestroy();
|
|
850
|
+
}
|
|
851
|
+
catch {
|
|
852
|
+
// ignore
|
|
853
|
+
}
|
|
854
|
+
});
|
|
855
|
+
const offAddElement = core.events.on('ui:addElement', (payload) => {
|
|
856
|
+
if (!payload?.el)
|
|
857
|
+
return;
|
|
858
|
+
const placement = payload.placement || { v: 'bottom', h: 'right' };
|
|
859
|
+
ctx.grid?.place(placement, payload.el);
|
|
860
|
+
});
|
|
861
|
+
const offAddControl = core.events.on('ui:addControl', (payload) => {
|
|
862
|
+
const control = payload?.control;
|
|
863
|
+
if (!control)
|
|
864
|
+
return;
|
|
865
|
+
const el = control.create(core);
|
|
866
|
+
el.dataset.controlId = control.id;
|
|
867
|
+
ctx.grid?.place(control.placement, el);
|
|
868
|
+
payload.el = el;
|
|
869
|
+
createdControls.push(control);
|
|
870
|
+
});
|
|
871
|
+
return ctx;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function extendControls(core) {
|
|
875
|
+
const api = {
|
|
876
|
+
addElement(el, placement = { v: 'bottom', h: 'right' }) {
|
|
877
|
+
if (core.events.listenerCount('ui:addElement') === 0) {
|
|
878
|
+
throw new Error('UI not initialized; cannot addElement');
|
|
879
|
+
}
|
|
880
|
+
core.events.emit('ui:addElement', { el, placement });
|
|
881
|
+
core.emit('controls:changed');
|
|
882
|
+
return el;
|
|
883
|
+
},
|
|
884
|
+
addControl(control) {
|
|
885
|
+
if (core.events.listenerCount('ui:addControl') === 0) {
|
|
886
|
+
throw new Error('UI not initialized; cannot addControl');
|
|
887
|
+
}
|
|
888
|
+
const payload = { control, el: undefined };
|
|
889
|
+
core.events.emit('ui:addControl', payload);
|
|
890
|
+
core.emit('controls:changed');
|
|
891
|
+
return payload.el;
|
|
892
|
+
},
|
|
893
|
+
};
|
|
894
|
+
Object.assign(core, { controls: api });
|
|
895
|
+
return api;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
class BaseControl {
|
|
899
|
+
constructor() {
|
|
900
|
+
Object.defineProperty(this, "core", {
|
|
901
|
+
enumerable: true,
|
|
902
|
+
configurable: true,
|
|
903
|
+
writable: true,
|
|
904
|
+
value: void 0
|
|
905
|
+
});
|
|
906
|
+
Object.defineProperty(this, "overlayMgr", {
|
|
907
|
+
enumerable: true,
|
|
908
|
+
configurable: true,
|
|
909
|
+
writable: true,
|
|
910
|
+
value: void 0
|
|
911
|
+
});
|
|
912
|
+
Object.defineProperty(this, "activeOverlay", {
|
|
913
|
+
enumerable: true,
|
|
914
|
+
configurable: true,
|
|
915
|
+
writable: true,
|
|
916
|
+
value: null
|
|
917
|
+
});
|
|
918
|
+
Object.defineProperty(this, "dispose", {
|
|
919
|
+
enumerable: true,
|
|
920
|
+
configurable: true,
|
|
921
|
+
writable: true,
|
|
922
|
+
value: new DisposableStore()
|
|
923
|
+
});
|
|
924
|
+
}
|
|
925
|
+
onOverlayChanged(_ov) {
|
|
926
|
+
// ignore
|
|
927
|
+
}
|
|
928
|
+
create(core) {
|
|
929
|
+
this.core = core;
|
|
930
|
+
this.overlayMgr = getOverlayManager(core);
|
|
931
|
+
this.activeOverlay = this.overlayMgr.active ?? null;
|
|
932
|
+
this.dispose.add(this.overlayMgr.bus.on('overlay:changed', (ov) => {
|
|
933
|
+
this.activeOverlay = ov;
|
|
934
|
+
this.onOverlayChanged(ov);
|
|
935
|
+
}));
|
|
936
|
+
return this.build();
|
|
937
|
+
}
|
|
938
|
+
destroy() {
|
|
939
|
+
this.dispose.dispose();
|
|
940
|
+
}
|
|
941
|
+
onPlayer(event, cb) {
|
|
942
|
+
return this.dispose.add(this.core.events.on(event, cb));
|
|
943
|
+
}
|
|
944
|
+
listen(target, type, handler, options) {
|
|
945
|
+
return this.dispose.addEventListener(target, type, handler, options);
|
|
946
|
+
}
|
|
947
|
+
resolvePlayerRoot() {
|
|
948
|
+
const media = this.core.media;
|
|
949
|
+
if (!media)
|
|
950
|
+
return document.body;
|
|
951
|
+
return media.closest('.op-player') || media.parentElement || document.body;
|
|
952
|
+
}
|
|
953
|
+
resolveFullscreenContainer() {
|
|
954
|
+
return this.activeOverlay?.fullscreenEl || this.resolvePlayerRoot();
|
|
955
|
+
}
|
|
956
|
+
resolveFullscreenVideoEl() {
|
|
957
|
+
return (this.activeOverlay?.fullscreenVideoEl ||
|
|
958
|
+
(this.core.media ?? null));
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
// Use a symbol to avoid collisions with user-land fields.
|
|
963
|
+
const SETTINGS_REGISTRY_KEY = Symbol.for('openplayerjs.settings.registry');
|
|
964
|
+
class SettingsRegistry {
|
|
965
|
+
constructor() {
|
|
966
|
+
Object.defineProperty(this, "providers", {
|
|
967
|
+
enumerable: true,
|
|
968
|
+
configurable: true,
|
|
969
|
+
writable: true,
|
|
970
|
+
value: new Map()
|
|
971
|
+
});
|
|
972
|
+
}
|
|
973
|
+
register(provider) {
|
|
974
|
+
this.providers.set(provider.id, provider);
|
|
975
|
+
return () => this.providers.delete(provider.id);
|
|
976
|
+
}
|
|
977
|
+
list() {
|
|
978
|
+
return Array.from(this.providers.values()).sort((a, b) => a.label.localeCompare(b.label));
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
function getSettingsRegistry(core) {
|
|
982
|
+
const host = core;
|
|
983
|
+
if (host[SETTINGS_REGISTRY_KEY])
|
|
984
|
+
return host[SETTINGS_REGISTRY_KEY];
|
|
985
|
+
const reg = new SettingsRegistry();
|
|
986
|
+
host[SETTINGS_REGISTRY_KEY] = reg;
|
|
987
|
+
return reg;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
function isRelevantKind(kind) {
|
|
991
|
+
return kind === 'captions' || kind === 'subtitles';
|
|
992
|
+
}
|
|
993
|
+
function trackLabel(t, index) {
|
|
994
|
+
return (t.label && t.label.trim()) || (t.language && t.language.trim().toUpperCase()) || `Track ${index + 1}`;
|
|
995
|
+
}
|
|
996
|
+
function listRelevantTracks(media) {
|
|
997
|
+
const list = media.textTracks ?? null;
|
|
998
|
+
if (!list)
|
|
999
|
+
return [];
|
|
1000
|
+
const out = [];
|
|
1001
|
+
for (let i = 0; i < list.length; i++) {
|
|
1002
|
+
const t = list[i];
|
|
1003
|
+
if (!t)
|
|
1004
|
+
continue;
|
|
1005
|
+
if (!isRelevantKind(String(t.kind)))
|
|
1006
|
+
continue;
|
|
1007
|
+
out.push({ index: i, track: t });
|
|
1008
|
+
}
|
|
1009
|
+
return out;
|
|
1010
|
+
}
|
|
1011
|
+
function getShowingIndex(media) {
|
|
1012
|
+
const tracks = listRelevantTracks(media);
|
|
1013
|
+
for (const x of tracks) {
|
|
1014
|
+
if (x.track.mode === 'showing')
|
|
1015
|
+
return x.index;
|
|
1016
|
+
}
|
|
1017
|
+
return 'off';
|
|
1018
|
+
}
|
|
1019
|
+
function setAllOff(media) {
|
|
1020
|
+
for (const x of listRelevantTracks(media))
|
|
1021
|
+
x.track.mode = 'disabled';
|
|
1022
|
+
}
|
|
1023
|
+
function selectIndex(media, index) {
|
|
1024
|
+
for (const x of listRelevantTracks(media)) {
|
|
1025
|
+
x.track.mode = x.index === index ? 'showing' : 'disabled';
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
class CaptionsControl extends BaseControl {
|
|
1029
|
+
constructor() {
|
|
1030
|
+
super(...arguments);
|
|
1031
|
+
Object.defineProperty(this, "id", {
|
|
1032
|
+
enumerable: true,
|
|
1033
|
+
configurable: true,
|
|
1034
|
+
writable: true,
|
|
1035
|
+
value: 'captions'
|
|
1036
|
+
});
|
|
1037
|
+
Object.defineProperty(this, "placement", {
|
|
1038
|
+
enumerable: true,
|
|
1039
|
+
configurable: true,
|
|
1040
|
+
writable: true,
|
|
1041
|
+
value: { v: 'bottom', h: 'right' }
|
|
1042
|
+
});
|
|
1043
|
+
Object.defineProperty(this, "button", {
|
|
1044
|
+
enumerable: true,
|
|
1045
|
+
configurable: true,
|
|
1046
|
+
writable: true,
|
|
1047
|
+
value: void 0
|
|
1048
|
+
});
|
|
1049
|
+
Object.defineProperty(this, "lastSelectedIndex", {
|
|
1050
|
+
enumerable: true,
|
|
1051
|
+
configurable: true,
|
|
1052
|
+
writable: true,
|
|
1053
|
+
value: null
|
|
1054
|
+
});
|
|
1055
|
+
}
|
|
1056
|
+
build() {
|
|
1057
|
+
const core = this.core;
|
|
1058
|
+
const labels = resolveUIConfig(core).labels;
|
|
1059
|
+
const label = labels.captions;
|
|
1060
|
+
const buttonLabel = labels.toggleCaptions;
|
|
1061
|
+
this.button = document.createElement('button');
|
|
1062
|
+
this.button.type = 'button';
|
|
1063
|
+
this.button.className = 'op-controls__captions';
|
|
1064
|
+
setA11yLabel(this.button, buttonLabel);
|
|
1065
|
+
this.button.setAttribute('aria-pressed', 'false');
|
|
1066
|
+
const refresh = () => {
|
|
1067
|
+
const media = getActiveMedia(core);
|
|
1068
|
+
const tracks = listRelevantTracks(media);
|
|
1069
|
+
this.button.style.display = tracks.length ? '' : 'none';
|
|
1070
|
+
const showing = getShowingIndex(media);
|
|
1071
|
+
const on = showing !== 'off';
|
|
1072
|
+
if (typeof showing === 'number')
|
|
1073
|
+
this.lastSelectedIndex = showing;
|
|
1074
|
+
this.button.classList.toggle('op-controls__captions--on', on);
|
|
1075
|
+
this.button.setAttribute('aria-pressed', on ? 'true' : 'false');
|
|
1076
|
+
};
|
|
1077
|
+
// Toggle only (on/off)
|
|
1078
|
+
this.listen(this.button, 'click', (e) => {
|
|
1079
|
+
const me = e;
|
|
1080
|
+
const media = getActiveMedia(core);
|
|
1081
|
+
const showing = getShowingIndex(media);
|
|
1082
|
+
if (showing === 'off') {
|
|
1083
|
+
const tracks = listRelevantTracks(media);
|
|
1084
|
+
const idx = this.lastSelectedIndex ?? tracks[0]?.index;
|
|
1085
|
+
if (typeof idx === 'number')
|
|
1086
|
+
selectIndex(media, idx);
|
|
1087
|
+
}
|
|
1088
|
+
else {
|
|
1089
|
+
setAllOff(media);
|
|
1090
|
+
}
|
|
1091
|
+
refresh();
|
|
1092
|
+
me.preventDefault();
|
|
1093
|
+
me.stopPropagation();
|
|
1094
|
+
}, EVENT_OPTIONS);
|
|
1095
|
+
const provider = {
|
|
1096
|
+
id: 'captions',
|
|
1097
|
+
label,
|
|
1098
|
+
getSubmenu: () => {
|
|
1099
|
+
const media = getActiveMedia(core);
|
|
1100
|
+
const tracks = listRelevantTracks(media);
|
|
1101
|
+
if (!tracks.length)
|
|
1102
|
+
return null;
|
|
1103
|
+
const showing = getShowingIndex(media);
|
|
1104
|
+
return {
|
|
1105
|
+
id: 'captions',
|
|
1106
|
+
label,
|
|
1107
|
+
items: [
|
|
1108
|
+
{
|
|
1109
|
+
id: 'off',
|
|
1110
|
+
label: labels.off,
|
|
1111
|
+
checked: showing === 'off',
|
|
1112
|
+
onSelect: () => {
|
|
1113
|
+
setAllOff(media);
|
|
1114
|
+
refresh();
|
|
1115
|
+
},
|
|
1116
|
+
},
|
|
1117
|
+
...tracks.map((x) => ({
|
|
1118
|
+
id: String(x.index),
|
|
1119
|
+
label: trackLabel(x.track, x.index),
|
|
1120
|
+
checked: x.index === showing,
|
|
1121
|
+
onSelect: () => {
|
|
1122
|
+
selectIndex(media, x.index);
|
|
1123
|
+
this.lastSelectedIndex = x.index;
|
|
1124
|
+
refresh();
|
|
1125
|
+
},
|
|
1126
|
+
})),
|
|
1127
|
+
],
|
|
1128
|
+
};
|
|
1129
|
+
},
|
|
1130
|
+
};
|
|
1131
|
+
getSettingsRegistry(core).register(provider);
|
|
1132
|
+
this.dispose.add(this.overlayMgr.bus.on('overlay:changed', refresh));
|
|
1133
|
+
this.onPlayer('loadedmetadata', refresh);
|
|
1134
|
+
refresh();
|
|
1135
|
+
return this.button;
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
function createCaptionsControl() {
|
|
1139
|
+
return new CaptionsControl();
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
class CurrentTimeControl extends BaseControl {
|
|
1143
|
+
constructor() {
|
|
1144
|
+
super(...arguments);
|
|
1145
|
+
Object.defineProperty(this, "id", {
|
|
1146
|
+
enumerable: true,
|
|
1147
|
+
configurable: true,
|
|
1148
|
+
writable: true,
|
|
1149
|
+
value: 'currentTime'
|
|
1150
|
+
});
|
|
1151
|
+
Object.defineProperty(this, "placement", {
|
|
1152
|
+
enumerable: true,
|
|
1153
|
+
configurable: true,
|
|
1154
|
+
writable: true,
|
|
1155
|
+
value: { v: 'bottom', h: 'left' }
|
|
1156
|
+
});
|
|
1157
|
+
}
|
|
1158
|
+
build() {
|
|
1159
|
+
const core = this.core;
|
|
1160
|
+
const el = document.createElement('time');
|
|
1161
|
+
el.className = 'op-controls__current';
|
|
1162
|
+
el.setAttribute('role', 'timer');
|
|
1163
|
+
el.setAttribute('aria-live', 'off');
|
|
1164
|
+
el.setAttribute('aria-hidden', 'false');
|
|
1165
|
+
el.setAttribute('datetime', 'PT0M0S');
|
|
1166
|
+
el.innerText = '0:00';
|
|
1167
|
+
const update = () => {
|
|
1168
|
+
if (this.activeOverlay) {
|
|
1169
|
+
el.setAttribute('aria-hidden', 'false');
|
|
1170
|
+
el.innerText = formatTime(this.activeOverlay.value);
|
|
1171
|
+
return;
|
|
1172
|
+
}
|
|
1173
|
+
if (core.isLive) {
|
|
1174
|
+
el.setAttribute('aria-hidden', 'true');
|
|
1175
|
+
return;
|
|
1176
|
+
}
|
|
1177
|
+
const t = core.currentTime;
|
|
1178
|
+
el.setAttribute('aria-hidden', 'false');
|
|
1179
|
+
const currTime = Number.isFinite(t) ? Math.max(0, t) : 0;
|
|
1180
|
+
const formattedTime = formatTime(currTime);
|
|
1181
|
+
el.innerText = formattedTime;
|
|
1182
|
+
el.setAttribute('datetime', generateISODateTime(currTime));
|
|
1183
|
+
};
|
|
1184
|
+
this.onPlayer('timeupdate', () => update());
|
|
1185
|
+
this.onPlayer('seeked', () => update());
|
|
1186
|
+
this.onPlayer('durationchange', () => update());
|
|
1187
|
+
this.overlayMgr.bus.on('overlay:changed', () => update());
|
|
1188
|
+
update();
|
|
1189
|
+
return el;
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
function createCurrentTimeControl() {
|
|
1193
|
+
return new CurrentTimeControl();
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
class DurationControl extends BaseControl {
|
|
1197
|
+
constructor() {
|
|
1198
|
+
super(...arguments);
|
|
1199
|
+
Object.defineProperty(this, "id", {
|
|
1200
|
+
enumerable: true,
|
|
1201
|
+
configurable: true,
|
|
1202
|
+
writable: true,
|
|
1203
|
+
value: 'duration'
|
|
1204
|
+
});
|
|
1205
|
+
Object.defineProperty(this, "placement", {
|
|
1206
|
+
enumerable: true,
|
|
1207
|
+
configurable: true,
|
|
1208
|
+
writable: true,
|
|
1209
|
+
value: { v: 'bottom', h: 'right' }
|
|
1210
|
+
});
|
|
1211
|
+
}
|
|
1212
|
+
build() {
|
|
1213
|
+
const core = this.core;
|
|
1214
|
+
const el = document.createElement('time');
|
|
1215
|
+
el.className = 'op-controls__duration';
|
|
1216
|
+
el.setAttribute('aria-hidden', 'false');
|
|
1217
|
+
el.setAttribute('datetime', 'PT0M0S');
|
|
1218
|
+
const update = () => {
|
|
1219
|
+
if (this.activeOverlay) {
|
|
1220
|
+
el.setAttribute('aria-hidden', 'false');
|
|
1221
|
+
el.innerText = formatTime(this.activeOverlay.duration);
|
|
1222
|
+
return;
|
|
1223
|
+
}
|
|
1224
|
+
const d = core.duration;
|
|
1225
|
+
if (core.isLive || d === Infinity) {
|
|
1226
|
+
el.removeAttribute('datetime');
|
|
1227
|
+
el.textContent = resolveUIConfig(core).labels.live;
|
|
1228
|
+
return;
|
|
1229
|
+
}
|
|
1230
|
+
el.setAttribute('aria-hidden', 'false');
|
|
1231
|
+
const duration = Number.isFinite(d) ? Math.max(0, d) : core.config?.duration || 0;
|
|
1232
|
+
const formattedDuration = formatTime(duration);
|
|
1233
|
+
el.textContent = formattedDuration;
|
|
1234
|
+
el.setAttribute('datetime', generateISODateTime(duration));
|
|
1235
|
+
};
|
|
1236
|
+
this.onPlayer('durationchange', update);
|
|
1237
|
+
this.onPlayer('timeupdate', update);
|
|
1238
|
+
this.overlayMgr.bus.on('overlay:changed', update);
|
|
1239
|
+
update();
|
|
1240
|
+
return el;
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
function createDurationControl() {
|
|
1244
|
+
return new DurationControl();
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
function getFullscreenElement() {
|
|
1248
|
+
const d = document;
|
|
1249
|
+
return (document.fullscreenElement || d.webkitFullscreenElement || d.mozFullScreenElement || d.msFullscreenElement || null);
|
|
1250
|
+
}
|
|
1251
|
+
function requestFullscreen(el) {
|
|
1252
|
+
if (!el)
|
|
1253
|
+
return;
|
|
1254
|
+
if (el.requestFullscreen)
|
|
1255
|
+
return el.requestFullscreen();
|
|
1256
|
+
if (el.mozRequestFullScreen)
|
|
1257
|
+
return el.mozRequestFullScreen();
|
|
1258
|
+
if (el.webkitRequestFullScreen)
|
|
1259
|
+
return el.webkitRequestFullScreen();
|
|
1260
|
+
if (el.msRequestFullscreen)
|
|
1261
|
+
return el.msRequestFullscreen();
|
|
1262
|
+
if (el.webkitEnterFullscreen)
|
|
1263
|
+
return el.webkitEnterFullscreen();
|
|
1264
|
+
}
|
|
1265
|
+
function exitFullscreen() {
|
|
1266
|
+
const d = document;
|
|
1267
|
+
if (document.exitFullscreen)
|
|
1268
|
+
return document.exitFullscreen();
|
|
1269
|
+
if (d.mozCancelFullScreen)
|
|
1270
|
+
return d.mozCancelFullScreen();
|
|
1271
|
+
if (d.webkitCancelFullScreen)
|
|
1272
|
+
return d.webkitCancelFullScreen();
|
|
1273
|
+
if (d.msExitFullscreen)
|
|
1274
|
+
return d.msExitFullscreen();
|
|
1275
|
+
}
|
|
1276
|
+
class FullscreenControl extends BaseControl {
|
|
1277
|
+
constructor() {
|
|
1278
|
+
super(...arguments);
|
|
1279
|
+
Object.defineProperty(this, "id", {
|
|
1280
|
+
enumerable: true,
|
|
1281
|
+
configurable: true,
|
|
1282
|
+
writable: true,
|
|
1283
|
+
value: 'fullscreen'
|
|
1284
|
+
});
|
|
1285
|
+
Object.defineProperty(this, "placement", {
|
|
1286
|
+
enumerable: true,
|
|
1287
|
+
configurable: true,
|
|
1288
|
+
writable: true,
|
|
1289
|
+
value: { v: 'bottom', h: 'right' }
|
|
1290
|
+
});
|
|
1291
|
+
Object.defineProperty(this, "isFullscreen", {
|
|
1292
|
+
enumerable: true,
|
|
1293
|
+
configurable: true,
|
|
1294
|
+
writable: true,
|
|
1295
|
+
value: false
|
|
1296
|
+
});
|
|
1297
|
+
Object.defineProperty(this, "screenW", {
|
|
1298
|
+
enumerable: true,
|
|
1299
|
+
configurable: true,
|
|
1300
|
+
writable: true,
|
|
1301
|
+
value: 0
|
|
1302
|
+
});
|
|
1303
|
+
Object.defineProperty(this, "screenH", {
|
|
1304
|
+
enumerable: true,
|
|
1305
|
+
configurable: true,
|
|
1306
|
+
writable: true,
|
|
1307
|
+
value: 0
|
|
1308
|
+
});
|
|
1309
|
+
}
|
|
1310
|
+
build() {
|
|
1311
|
+
const core = this.core;
|
|
1312
|
+
const labels = resolveUIConfig(core).labels;
|
|
1313
|
+
const btn = document.createElement('button');
|
|
1314
|
+
btn.tabIndex = 0;
|
|
1315
|
+
btn.type = 'button';
|
|
1316
|
+
btn.className = 'op-controls__fullscreen';
|
|
1317
|
+
setA11yLabel(btn, labels.fullscreen);
|
|
1318
|
+
btn.setAttribute('aria-pressed', 'false');
|
|
1319
|
+
const setFullscreenData = (on) => {
|
|
1320
|
+
if (on)
|
|
1321
|
+
btn.classList.add('op-controls__fullscreen--out');
|
|
1322
|
+
else
|
|
1323
|
+
btn.classList.remove('op-controls__fullscreen--out');
|
|
1324
|
+
btn.setAttribute('aria-pressed', on ? 'true' : 'false');
|
|
1325
|
+
};
|
|
1326
|
+
const resize = (width, height) => {
|
|
1327
|
+
const container = this.resolveFullscreenContainer();
|
|
1328
|
+
const video = this.resolveFullscreenVideoEl();
|
|
1329
|
+
if (width) {
|
|
1330
|
+
container.style.width = '100%';
|
|
1331
|
+
if (video)
|
|
1332
|
+
video.style.width = '100%';
|
|
1333
|
+
}
|
|
1334
|
+
else {
|
|
1335
|
+
container.style.removeProperty('width');
|
|
1336
|
+
if (video)
|
|
1337
|
+
video.style.removeProperty('width');
|
|
1338
|
+
}
|
|
1339
|
+
if (height) {
|
|
1340
|
+
container.style.height = '100%';
|
|
1341
|
+
if (video)
|
|
1342
|
+
video.style.height = '100%';
|
|
1343
|
+
}
|
|
1344
|
+
else {
|
|
1345
|
+
container.style.removeProperty('height');
|
|
1346
|
+
if (video)
|
|
1347
|
+
video.style.removeProperty('height');
|
|
1348
|
+
}
|
|
1349
|
+
};
|
|
1350
|
+
const sync = () => {
|
|
1351
|
+
const fsEl = getFullscreenElement();
|
|
1352
|
+
const container = this.resolveFullscreenContainer();
|
|
1353
|
+
const now = !!fsEl && (fsEl === container || fsEl.contains?.(container));
|
|
1354
|
+
setFullscreenData(now);
|
|
1355
|
+
if (now)
|
|
1356
|
+
document.body.classList.add('op-fullscreen__on');
|
|
1357
|
+
else
|
|
1358
|
+
document.body.classList.remove('op-fullscreen__on');
|
|
1359
|
+
resize(now ? this.screenW : undefined, now ? this.screenH : undefined);
|
|
1360
|
+
this.isFullscreen = now;
|
|
1361
|
+
};
|
|
1362
|
+
['fullscreenchange', 'mozfullscreenchange', 'webkitfullscreenchange', 'msfullscreenchange'].forEach((evt) => {
|
|
1363
|
+
this.listen(document, evt, sync, EVENT_OPTIONS);
|
|
1364
|
+
});
|
|
1365
|
+
this.listen(document, 'keydown', (e) => {
|
|
1366
|
+
const ke = e;
|
|
1367
|
+
if (ke.key === 'Escape' && this.isFullscreen)
|
|
1368
|
+
exitFullscreen();
|
|
1369
|
+
}, EVENT_OPTIONS);
|
|
1370
|
+
this.listen(btn, 'click', async (e) => {
|
|
1371
|
+
const me = e;
|
|
1372
|
+
this.screenW = window.screen.width;
|
|
1373
|
+
this.screenH = window.screen.height;
|
|
1374
|
+
if (getFullscreenElement()) {
|
|
1375
|
+
exitFullscreen();
|
|
1376
|
+
return;
|
|
1377
|
+
}
|
|
1378
|
+
const container = this.resolveFullscreenContainer();
|
|
1379
|
+
requestFullscreen(container);
|
|
1380
|
+
try {
|
|
1381
|
+
await window.screen.orientation?.lock('landscape');
|
|
1382
|
+
}
|
|
1383
|
+
catch {
|
|
1384
|
+
// ignore
|
|
1385
|
+
}
|
|
1386
|
+
me.preventDefault();
|
|
1387
|
+
me.stopPropagation();
|
|
1388
|
+
}, EVENT_OPTIONS);
|
|
1389
|
+
sync();
|
|
1390
|
+
return btn;
|
|
1391
|
+
}
|
|
1392
|
+
onOverlayChanged() {
|
|
1393
|
+
if (getFullscreenElement()) {
|
|
1394
|
+
const el = getFullscreenElement();
|
|
1395
|
+
if (el) {
|
|
1396
|
+
document.dispatchEvent(new Event('fullscreenchange'));
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
function createFullscreenControl() {
|
|
1402
|
+
return new FullscreenControl();
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
class PlayControl extends BaseControl {
|
|
1406
|
+
constructor() {
|
|
1407
|
+
super(...arguments);
|
|
1408
|
+
Object.defineProperty(this, "id", {
|
|
1409
|
+
enumerable: true,
|
|
1410
|
+
configurable: true,
|
|
1411
|
+
writable: true,
|
|
1412
|
+
value: 'play'
|
|
1413
|
+
});
|
|
1414
|
+
Object.defineProperty(this, "placement", {
|
|
1415
|
+
enumerable: true,
|
|
1416
|
+
configurable: true,
|
|
1417
|
+
writable: true,
|
|
1418
|
+
value: { v: 'bottom', h: 'left' }
|
|
1419
|
+
});
|
|
1420
|
+
}
|
|
1421
|
+
build() {
|
|
1422
|
+
const core = this.core;
|
|
1423
|
+
const labels = resolveUIConfig(core).labels;
|
|
1424
|
+
const playLabel = labels.play;
|
|
1425
|
+
const pauseLabel = labels.pause;
|
|
1426
|
+
const btn = document.createElement('button');
|
|
1427
|
+
btn.tabIndex = 0;
|
|
1428
|
+
btn.type = 'button';
|
|
1429
|
+
btn.className = 'op-controls__playpause';
|
|
1430
|
+
setA11yLabel(btn, playLabel);
|
|
1431
|
+
btn.setAttribute('aria-pressed', 'false');
|
|
1432
|
+
this.listen(btn, 'click', async (e) => {
|
|
1433
|
+
const me = e;
|
|
1434
|
+
await togglePlayback(core);
|
|
1435
|
+
me.preventDefault();
|
|
1436
|
+
me.stopPropagation();
|
|
1437
|
+
}, EVENT_OPTIONS);
|
|
1438
|
+
const setPlaying = (playing) => {
|
|
1439
|
+
btn.classList.toggle('op-controls__playpause--pause', playing);
|
|
1440
|
+
btn.setAttribute('aria-pressed', playing ? 'true' : 'false');
|
|
1441
|
+
setA11yLabel(btn, playing ? pauseLabel : playLabel);
|
|
1442
|
+
};
|
|
1443
|
+
this.onPlayer('play', () => setPlaying(true));
|
|
1444
|
+
this.onPlayer('pause', () => setPlaying(false));
|
|
1445
|
+
this.onPlayer('playing', () => setPlaying(true));
|
|
1446
|
+
this.onPlayer('pause', () => setPlaying(false));
|
|
1447
|
+
this.onPlayer('ended', () => setPlaying(false));
|
|
1448
|
+
return btn;
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
function createPlayControl() {
|
|
1452
|
+
return new PlayControl();
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
class ProgressControl extends BaseControl {
|
|
1456
|
+
constructor() {
|
|
1457
|
+
super(...arguments);
|
|
1458
|
+
Object.defineProperty(this, "id", {
|
|
1459
|
+
enumerable: true,
|
|
1460
|
+
configurable: true,
|
|
1461
|
+
writable: true,
|
|
1462
|
+
value: 'progress'
|
|
1463
|
+
});
|
|
1464
|
+
Object.defineProperty(this, "placement", {
|
|
1465
|
+
enumerable: true,
|
|
1466
|
+
configurable: true,
|
|
1467
|
+
writable: true,
|
|
1468
|
+
value: { v: 'top', h: 'center' }
|
|
1469
|
+
});
|
|
1470
|
+
Object.defineProperty(this, "repaint", {
|
|
1471
|
+
enumerable: true,
|
|
1472
|
+
configurable: true,
|
|
1473
|
+
writable: true,
|
|
1474
|
+
value: void 0
|
|
1475
|
+
});
|
|
1476
|
+
}
|
|
1477
|
+
build() {
|
|
1478
|
+
const core = this.core;
|
|
1479
|
+
const ui = resolveUIConfig(core);
|
|
1480
|
+
const { allowRewind, allowSkip, labels } = ui;
|
|
1481
|
+
const progressLabel = labels.progressSlider;
|
|
1482
|
+
const railLabel = labels.progressRail;
|
|
1483
|
+
const progress = document.createElement('div');
|
|
1484
|
+
progress.className = 'op-controls__progress';
|
|
1485
|
+
progress.role = 'group';
|
|
1486
|
+
setA11yLabel(progress, progressLabel);
|
|
1487
|
+
const slider = document.createElement('input');
|
|
1488
|
+
slider.type = 'range';
|
|
1489
|
+
slider.role = 'slider';
|
|
1490
|
+
slider.className = 'op-controls__progress--seek';
|
|
1491
|
+
slider.tabIndex = 0;
|
|
1492
|
+
slider.min = '0';
|
|
1493
|
+
slider.step = '0.1';
|
|
1494
|
+
slider.value = '0';
|
|
1495
|
+
setA11yLabel(slider, railLabel, { container: progress });
|
|
1496
|
+
slider.setAttribute('aria-valuemin', '0');
|
|
1497
|
+
slider.setAttribute('aria-valuenow', '0');
|
|
1498
|
+
const buffer = document.createElement('progress');
|
|
1499
|
+
buffer.className = 'op-controls__progress--buffer';
|
|
1500
|
+
buffer.max = 100;
|
|
1501
|
+
buffer.value = 0;
|
|
1502
|
+
const played = document.createElement('progress');
|
|
1503
|
+
played.className = 'op-controls__progress--played';
|
|
1504
|
+
played.max = 100;
|
|
1505
|
+
played.value = 0;
|
|
1506
|
+
progress.appendChild(slider);
|
|
1507
|
+
progress.appendChild(played);
|
|
1508
|
+
progress.appendChild(buffer);
|
|
1509
|
+
let tooltip;
|
|
1510
|
+
if (!isMobile()) {
|
|
1511
|
+
tooltip = document.createElement('span');
|
|
1512
|
+
tooltip.className = 'op-controls__tooltip';
|
|
1513
|
+
tooltip.tabIndex = -1;
|
|
1514
|
+
tooltip.textContent = '00:00';
|
|
1515
|
+
progress.appendChild(tooltip);
|
|
1516
|
+
}
|
|
1517
|
+
const setPressed = (pressed) => {
|
|
1518
|
+
if (pressed)
|
|
1519
|
+
slider.classList.add('op-progress--pressed');
|
|
1520
|
+
else
|
|
1521
|
+
slider.classList.remove('op-progress--pressed');
|
|
1522
|
+
};
|
|
1523
|
+
this.listen(slider, 'pointerdown', () => setPressed(true));
|
|
1524
|
+
this.listen(slider, 'pointerup', () => setPressed(false));
|
|
1525
|
+
this.listen(slider, 'pointercancel', () => setPressed(false));
|
|
1526
|
+
this.listen(slider, 'mouseleave', () => setPressed(false));
|
|
1527
|
+
// iOS Safari may not reliably fire Pointer Events for <input type="range">.
|
|
1528
|
+
// Ensure tap/drag interactions still mark the slider as "pressed" so seeking commits.
|
|
1529
|
+
this.listen(slider, 'touchstart', () => setPressed(true), EVENT_OPTIONS);
|
|
1530
|
+
this.listen(slider, 'touchend', () => setPressed(false), EVENT_OPTIONS);
|
|
1531
|
+
this.listen(slider, 'mousedown', () => setPressed(true), EVENT_OPTIONS);
|
|
1532
|
+
this.listen(slider, 'mouseup', () => setPressed(false), EVENT_OPTIONS);
|
|
1533
|
+
const clearPressed = () => setPressed(false);
|
|
1534
|
+
this.listen(document, 'pointerup', clearPressed, EVENT_OPTIONS);
|
|
1535
|
+
this.listen(document, 'pointercancel', clearPressed, EVENT_OPTIONS);
|
|
1536
|
+
this.listen(document, 'mouseup', clearPressed, EVENT_OPTIONS);
|
|
1537
|
+
this.listen(document, 'touchend', clearPressed, EVENT_OPTIONS);
|
|
1538
|
+
const setSeekEnabled = (enabled) => {
|
|
1539
|
+
slider.disabled = !enabled;
|
|
1540
|
+
slider.classList.toggle('op-progress--disabled', !enabled);
|
|
1541
|
+
progress.setAttribute('aria-disabled', (!enabled).toString());
|
|
1542
|
+
};
|
|
1543
|
+
const getDuration = () => {
|
|
1544
|
+
if (this.activeOverlay)
|
|
1545
|
+
return this.activeOverlay.duration;
|
|
1546
|
+
return core.media?.duration ?? core.duration;
|
|
1547
|
+
};
|
|
1548
|
+
const getValue = () => {
|
|
1549
|
+
if (this.activeOverlay)
|
|
1550
|
+
return this.activeOverlay.value;
|
|
1551
|
+
return core.media?.currentTime ?? core.currentTime;
|
|
1552
|
+
};
|
|
1553
|
+
const getMode = () => (this.activeOverlay ? this.activeOverlay.mode : 'normal');
|
|
1554
|
+
const updateUI = () => {
|
|
1555
|
+
const duration = getDuration();
|
|
1556
|
+
const value = getValue();
|
|
1557
|
+
const mode = getMode();
|
|
1558
|
+
if ((core.isLive || duration === Infinity) && !this.activeOverlay) {
|
|
1559
|
+
progress.setAttribute('aria-hidden', 'true');
|
|
1560
|
+
return;
|
|
1561
|
+
}
|
|
1562
|
+
progress.setAttribute('aria-hidden', 'false');
|
|
1563
|
+
if (Number.isFinite(duration) && duration > 0) {
|
|
1564
|
+
if (!slider.max || slider.max === '0' || parseFloat(slider.max || '-1') !== duration) {
|
|
1565
|
+
slider.max = String(duration);
|
|
1566
|
+
slider.setAttribute('aria-valuemax', String(duration));
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
this.repaint = updateUI;
|
|
1570
|
+
if (this.activeOverlay)
|
|
1571
|
+
setSeekEnabled(this.activeOverlay.canSeek);
|
|
1572
|
+
else
|
|
1573
|
+
setSeekEnabled(!core.isLive && core.media?.duration !== Infinity);
|
|
1574
|
+
if (slider.classList.contains('op-progress--pressed'))
|
|
1575
|
+
return;
|
|
1576
|
+
const d = Number.isFinite(duration) && duration > 0 ? duration : 0;
|
|
1577
|
+
const v = Number.isFinite(value) && value >= 0 ? value : 0;
|
|
1578
|
+
slider.value = String(v);
|
|
1579
|
+
slider.setAttribute('aria-valuenow', String(v));
|
|
1580
|
+
const min = parseFloat(slider.min);
|
|
1581
|
+
const max = parseFloat(slider.max);
|
|
1582
|
+
if (Number.isFinite(min) && Number.isFinite(max) && max > min) {
|
|
1583
|
+
const percentage = mode === 'countdown' ? ((max - v - min) * 100) / (max - min) : ((v - min) * 100) / (max - min);
|
|
1584
|
+
slider.style.backgroundSize = `${percentage}% 100%`;
|
|
1585
|
+
}
|
|
1586
|
+
played.value = !d ? 0 : mode === 'countdown' ? ((d - v) / d) * 100 : (v / d) * 100;
|
|
1587
|
+
if (this.activeOverlay && Number.isFinite(this.activeOverlay.bufferedPct)) {
|
|
1588
|
+
buffer.value = Math.max(0, Math.min(100, this.activeOverlay.bufferedPct));
|
|
1589
|
+
}
|
|
1590
|
+
};
|
|
1591
|
+
const seekFromClientX = (clientX) => {
|
|
1592
|
+
if (this.activeOverlay && !this.activeOverlay.canSeek)
|
|
1593
|
+
return;
|
|
1594
|
+
const duration = getDuration();
|
|
1595
|
+
if ((core.isLive || duration === Infinity) && !this.activeOverlay)
|
|
1596
|
+
return;
|
|
1597
|
+
if (!Number.isFinite(duration) || duration <= 0)
|
|
1598
|
+
return;
|
|
1599
|
+
const rect = progress.getBoundingClientRect();
|
|
1600
|
+
const x = clientX - rect.left;
|
|
1601
|
+
const pct = Math.max(0, Math.min(1, x / rect.width));
|
|
1602
|
+
const val = pct * duration;
|
|
1603
|
+
const currTime = getValue();
|
|
1604
|
+
if ((val < currTime && !allowRewind) || (val > currTime && !allowSkip))
|
|
1605
|
+
return;
|
|
1606
|
+
if (Number.isFinite(val)) {
|
|
1607
|
+
slider.value = String(val);
|
|
1608
|
+
core.currentTime = val;
|
|
1609
|
+
}
|
|
1610
|
+
};
|
|
1611
|
+
// On iOS/Android, taps may land on the <progress> overlays instead of the range input.
|
|
1612
|
+
// Implement rail tap-to-seek at the container level so seeking is reliable.
|
|
1613
|
+
this.listen(progress, 'click', (e) => {
|
|
1614
|
+
const me = e;
|
|
1615
|
+
const t = e.target;
|
|
1616
|
+
if (t && t.closest('input[type="range"]'))
|
|
1617
|
+
return;
|
|
1618
|
+
seekFromClientX(me.clientX);
|
|
1619
|
+
}, EVENT_OPTIONS);
|
|
1620
|
+
this.listen(progress, 'touchstart', (e) => {
|
|
1621
|
+
const te = e;
|
|
1622
|
+
const t = e.target;
|
|
1623
|
+
if (t && t.closest('input[type="range"]'))
|
|
1624
|
+
return;
|
|
1625
|
+
const touch = te.touches && te.touches[0];
|
|
1626
|
+
if (!touch)
|
|
1627
|
+
return;
|
|
1628
|
+
// Prevent the synthetic delayed click from fighting our seek.
|
|
1629
|
+
te.preventDefault();
|
|
1630
|
+
seekFromClientX(touch.clientX);
|
|
1631
|
+
}, EVENT_OPTIONS);
|
|
1632
|
+
this.listen(slider, 'change', (e) => {
|
|
1633
|
+
if (this.activeOverlay && !this.activeOverlay.canSeek)
|
|
1634
|
+
return;
|
|
1635
|
+
const target = e.target;
|
|
1636
|
+
const val = parseFloat(target.value);
|
|
1637
|
+
const currTime = getValue();
|
|
1638
|
+
if ((val < currTime && !allowRewind) || (val > currTime && !allowSkip))
|
|
1639
|
+
return;
|
|
1640
|
+
if (Number.isFinite(val))
|
|
1641
|
+
core.currentTime = val;
|
|
1642
|
+
// Release pressed state after a tap-to-seek interaction.
|
|
1643
|
+
setPressed(false);
|
|
1644
|
+
}, EVENT_OPTIONS);
|
|
1645
|
+
this.listen(slider, 'input', (e) => {
|
|
1646
|
+
const pressed = slider.classList.contains('op-progress--pressed');
|
|
1647
|
+
// On iOS Safari a tap can trigger input/change without Pointer Events; allow seek on mobile.
|
|
1648
|
+
if (!pressed && !isMobile())
|
|
1649
|
+
return;
|
|
1650
|
+
if (this.activeOverlay && !this.activeOverlay.canSeek)
|
|
1651
|
+
return;
|
|
1652
|
+
const target = e.target;
|
|
1653
|
+
const min = parseFloat(target.min);
|
|
1654
|
+
const max = parseFloat(target.max);
|
|
1655
|
+
const val = parseFloat(target.value);
|
|
1656
|
+
const currTime = getValue();
|
|
1657
|
+
if ((val < currTime && !allowRewind) || (val > currTime && !allowSkip))
|
|
1658
|
+
return;
|
|
1659
|
+
if (Number.isFinite(min) && Number.isFinite(max) && max > min) {
|
|
1660
|
+
slider.style.backgroundSize = `${((val - min) * 100) / (max - min)}% 100%`;
|
|
1661
|
+
}
|
|
1662
|
+
const duration = getDuration();
|
|
1663
|
+
const d = Number.isFinite(duration) && duration > 0 ? duration : 0;
|
|
1664
|
+
played.value = d ? (val / d) * 100 : 0;
|
|
1665
|
+
core.currentTime = val;
|
|
1666
|
+
}, EVENT_OPTIONS);
|
|
1667
|
+
this.onPlayer('durationchange', updateUI);
|
|
1668
|
+
this.onPlayer('timeupdate', updateUI);
|
|
1669
|
+
this.onPlayer('waiting', () => {
|
|
1670
|
+
if (!slider.classList.contains('loading'))
|
|
1671
|
+
slider.classList.add('loading');
|
|
1672
|
+
if (slider.classList.contains('error'))
|
|
1673
|
+
slider.classList.remove('error');
|
|
1674
|
+
});
|
|
1675
|
+
this.onPlayer('play', () => {
|
|
1676
|
+
if (slider.classList.contains('loading'))
|
|
1677
|
+
slider.classList.remove('loading');
|
|
1678
|
+
if (slider.classList.contains('error'))
|
|
1679
|
+
slider.classList.remove('error');
|
|
1680
|
+
if (!core.isLive && core.media.duration !== Infinity) {
|
|
1681
|
+
progress.removeAttribute('aria-valuenow');
|
|
1682
|
+
progress.removeAttribute('aria-valuetext');
|
|
1683
|
+
}
|
|
1684
|
+
});
|
|
1685
|
+
this.onPlayer('playing', () => {
|
|
1686
|
+
if (slider.classList.contains('loading'))
|
|
1687
|
+
slider.classList.remove('loading');
|
|
1688
|
+
if (slider.classList.contains('error'))
|
|
1689
|
+
slider.classList.remove('error');
|
|
1690
|
+
});
|
|
1691
|
+
this.onPlayer('ended', () => {
|
|
1692
|
+
slider.style.backgroundSize = '0% 100%';
|
|
1693
|
+
if (slider.max)
|
|
1694
|
+
slider.max = '0';
|
|
1695
|
+
buffer.value = 0;
|
|
1696
|
+
played.value = 0;
|
|
1697
|
+
});
|
|
1698
|
+
this.listen(progress, 'pointermove', (e) => {
|
|
1699
|
+
const me = e;
|
|
1700
|
+
if (isMobile())
|
|
1701
|
+
return;
|
|
1702
|
+
if (this.activeOverlay && !this.activeOverlay.canSeek)
|
|
1703
|
+
return;
|
|
1704
|
+
const duration = getDuration();
|
|
1705
|
+
if ((core.isLive || duration === Infinity) && !this.activeOverlay)
|
|
1706
|
+
return;
|
|
1707
|
+
const x = me.pageX;
|
|
1708
|
+
let pos = x - offset(progress).left;
|
|
1709
|
+
const half = tooltip.offsetWidth / 2;
|
|
1710
|
+
const percentage = pos / progress.offsetWidth;
|
|
1711
|
+
const time = percentage * duration;
|
|
1712
|
+
const root = this.resolvePlayerRoot();
|
|
1713
|
+
const limit = root.offsetWidth - tooltip.offsetWidth;
|
|
1714
|
+
if (pos <= 0 || x - offset(root).left <= half)
|
|
1715
|
+
pos = 0;
|
|
1716
|
+
else if (x - offset(root).left >= limit)
|
|
1717
|
+
pos = limit - offset(slider).left - 10;
|
|
1718
|
+
else
|
|
1719
|
+
pos -= half;
|
|
1720
|
+
if (percentage >= 0 && percentage <= 1)
|
|
1721
|
+
tooltip.classList.add('op-controls__tooltip--visible');
|
|
1722
|
+
else
|
|
1723
|
+
tooltip.classList.remove('op-controls__tooltip--visible');
|
|
1724
|
+
tooltip.style.left = `${pos}px`;
|
|
1725
|
+
tooltip.textContent = Number.isNaN(time) ? '00:00' : formatTime(time);
|
|
1726
|
+
}, EVENT_OPTIONS);
|
|
1727
|
+
this.listen(document, 'pointermove', (e) => {
|
|
1728
|
+
const me = e;
|
|
1729
|
+
if (!me.target.closest('.op-controls__progress')) {
|
|
1730
|
+
tooltip.classList.remove('op-controls__tooltip--visible');
|
|
1731
|
+
}
|
|
1732
|
+
}, EVENT_OPTIONS);
|
|
1733
|
+
updateUI();
|
|
1734
|
+
return progress;
|
|
1735
|
+
}
|
|
1736
|
+
onOverlayChanged() {
|
|
1737
|
+
this.repaint?.();
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
function createProgressControl() {
|
|
1741
|
+
return new ProgressControl();
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
class SettingsControl extends BaseControl {
|
|
1745
|
+
constructor() {
|
|
1746
|
+
super(...arguments);
|
|
1747
|
+
Object.defineProperty(this, "id", {
|
|
1748
|
+
enumerable: true,
|
|
1749
|
+
configurable: true,
|
|
1750
|
+
writable: true,
|
|
1751
|
+
value: 'settings'
|
|
1752
|
+
});
|
|
1753
|
+
Object.defineProperty(this, "placement", {
|
|
1754
|
+
enumerable: true,
|
|
1755
|
+
configurable: true,
|
|
1756
|
+
writable: true,
|
|
1757
|
+
value: { v: 'bottom', h: 'right' }
|
|
1758
|
+
});
|
|
1759
|
+
Object.defineProperty(this, "root", {
|
|
1760
|
+
enumerable: true,
|
|
1761
|
+
configurable: true,
|
|
1762
|
+
writable: true,
|
|
1763
|
+
value: void 0
|
|
1764
|
+
});
|
|
1765
|
+
Object.defineProperty(this, "button", {
|
|
1766
|
+
enumerable: true,
|
|
1767
|
+
configurable: true,
|
|
1768
|
+
writable: true,
|
|
1769
|
+
value: void 0
|
|
1770
|
+
});
|
|
1771
|
+
Object.defineProperty(this, "panel", {
|
|
1772
|
+
enumerable: true,
|
|
1773
|
+
configurable: true,
|
|
1774
|
+
writable: true,
|
|
1775
|
+
value: void 0
|
|
1776
|
+
});
|
|
1777
|
+
Object.defineProperty(this, "view", {
|
|
1778
|
+
enumerable: true,
|
|
1779
|
+
configurable: true,
|
|
1780
|
+
writable: true,
|
|
1781
|
+
value: void 0
|
|
1782
|
+
});
|
|
1783
|
+
Object.defineProperty(this, "isOpen", {
|
|
1784
|
+
enumerable: true,
|
|
1785
|
+
configurable: true,
|
|
1786
|
+
writable: true,
|
|
1787
|
+
value: false
|
|
1788
|
+
});
|
|
1789
|
+
Object.defineProperty(this, "activeSubmenuId", {
|
|
1790
|
+
enumerable: true,
|
|
1791
|
+
configurable: true,
|
|
1792
|
+
writable: true,
|
|
1793
|
+
value: null
|
|
1794
|
+
});
|
|
1795
|
+
}
|
|
1796
|
+
build() {
|
|
1797
|
+
const labels = resolveUIConfig(this.core).labels;
|
|
1798
|
+
this.root = document.createElement('div');
|
|
1799
|
+
this.root.className = 'op-menu--container';
|
|
1800
|
+
this.button = document.createElement('button');
|
|
1801
|
+
this.button.type = 'button';
|
|
1802
|
+
this.button.className = 'op-controls__settings';
|
|
1803
|
+
this.button.setAttribute('aria-haspopup', 'menu');
|
|
1804
|
+
this.button.setAttribute('aria-expanded', 'false');
|
|
1805
|
+
setA11yLabel(this.button, labels.settings);
|
|
1806
|
+
this.panel = document.createElement('div');
|
|
1807
|
+
this.panel.className = 'op-menu';
|
|
1808
|
+
this.panel.setAttribute('role', 'menu');
|
|
1809
|
+
this.panel.style.display = 'none';
|
|
1810
|
+
this.view = document.createElement('div');
|
|
1811
|
+
this.view.className = 'op-menu__submenu';
|
|
1812
|
+
this.panel.appendChild(this.view);
|
|
1813
|
+
this.root.appendChild(this.button);
|
|
1814
|
+
this.root.appendChild(this.panel);
|
|
1815
|
+
this.listen(this.button, 'click', (e) => {
|
|
1816
|
+
const me = e;
|
|
1817
|
+
this.toggle();
|
|
1818
|
+
me.preventDefault();
|
|
1819
|
+
me.stopPropagation();
|
|
1820
|
+
}, EVENT_OPTIONS);
|
|
1821
|
+
this.listen(document, 'click', (e) => {
|
|
1822
|
+
if (!this.isOpen)
|
|
1823
|
+
return;
|
|
1824
|
+
const t = e.target;
|
|
1825
|
+
if (!t.closest('.op-menu--container'))
|
|
1826
|
+
this.close();
|
|
1827
|
+
}, EVENT_OPTIONS);
|
|
1828
|
+
this.listen(document, 'keydown', (e) => {
|
|
1829
|
+
if (!this.isOpen)
|
|
1830
|
+
return;
|
|
1831
|
+
const ke = e;
|
|
1832
|
+
if (ke.key === 'Escape')
|
|
1833
|
+
this.close();
|
|
1834
|
+
}, EVENT_OPTIONS);
|
|
1835
|
+
this.dispose.add(this.overlayMgr.bus.on('overlay:changed', () => {
|
|
1836
|
+
this.activeSubmenuId = null;
|
|
1837
|
+
// Always re-compute availability so the control can hide during ads
|
|
1838
|
+
// and re-appear when content resumes, even if the menu isn't open.
|
|
1839
|
+
this.render();
|
|
1840
|
+
}));
|
|
1841
|
+
getSettingsRegistry(this.core).register({
|
|
1842
|
+
id: 'speed',
|
|
1843
|
+
label: labels.speed,
|
|
1844
|
+
getSubmenu: (core) => {
|
|
1845
|
+
const ov = this.overlayMgr.active;
|
|
1846
|
+
if (ov?.id === 'ads')
|
|
1847
|
+
return null;
|
|
1848
|
+
const rates = [0.5, 0.75, 1, 1.25, 1.5, 2];
|
|
1849
|
+
const current = core.playbackRate || 1;
|
|
1850
|
+
return {
|
|
1851
|
+
id: 'speed',
|
|
1852
|
+
label: labels.speed,
|
|
1853
|
+
items: rates.map((r) => ({
|
|
1854
|
+
id: String(r),
|
|
1855
|
+
label: r === 1 ? labels.speedNormal : `${r}x`,
|
|
1856
|
+
checked: Math.abs(current - r) < 1e-6,
|
|
1857
|
+
onSelect: () => {
|
|
1858
|
+
core.playbackRate = r;
|
|
1859
|
+
},
|
|
1860
|
+
})),
|
|
1861
|
+
};
|
|
1862
|
+
},
|
|
1863
|
+
});
|
|
1864
|
+
// Re-render on readiness (tracks/rates may appear)
|
|
1865
|
+
this.onPlayer('loadedmetadata', () => {
|
|
1866
|
+
if (this.isOpen)
|
|
1867
|
+
this.render();
|
|
1868
|
+
});
|
|
1869
|
+
return this.root;
|
|
1870
|
+
}
|
|
1871
|
+
toggle() {
|
|
1872
|
+
if (this.isOpen)
|
|
1873
|
+
this.close();
|
|
1874
|
+
else
|
|
1875
|
+
this.open();
|
|
1876
|
+
}
|
|
1877
|
+
open() {
|
|
1878
|
+
this.isOpen = true;
|
|
1879
|
+
this.button.setAttribute('aria-expanded', 'true');
|
|
1880
|
+
this.panel.style.display = 'block';
|
|
1881
|
+
this.render();
|
|
1882
|
+
}
|
|
1883
|
+
close() {
|
|
1884
|
+
this.isOpen = false;
|
|
1885
|
+
this.activeSubmenuId = null;
|
|
1886
|
+
this.button.setAttribute('aria-expanded', 'false');
|
|
1887
|
+
this.panel.style.display = 'none';
|
|
1888
|
+
}
|
|
1889
|
+
render() {
|
|
1890
|
+
const reg = getSettingsRegistry(this.core);
|
|
1891
|
+
const providers = reg.list();
|
|
1892
|
+
const available = providers
|
|
1893
|
+
.map((p) => ({ p, submenu: p.getSubmenu(this.core) }))
|
|
1894
|
+
.filter((x) => x.submenu && x.submenu.items.length);
|
|
1895
|
+
// Hide settings if there's nothing to show
|
|
1896
|
+
this.root.style.display = available.length ? '' : 'none';
|
|
1897
|
+
// Clear view
|
|
1898
|
+
while (this.view.firstChild)
|
|
1899
|
+
this.view.removeChild(this.view.firstChild);
|
|
1900
|
+
const active = this.activeSubmenuId
|
|
1901
|
+
? (available.find((x) => x.submenu.id === this.activeSubmenuId)?.submenu ?? null)
|
|
1902
|
+
: null;
|
|
1903
|
+
if (!active) {
|
|
1904
|
+
// Root menu listing submenus
|
|
1905
|
+
for (const { submenu } of available) {
|
|
1906
|
+
this.view.appendChild(this.makeRow(submenu.label, () => {
|
|
1907
|
+
this.activeSubmenuId = submenu.id;
|
|
1908
|
+
this.render();
|
|
1909
|
+
}));
|
|
1910
|
+
}
|
|
1911
|
+
return;
|
|
1912
|
+
}
|
|
1913
|
+
const header = document.createElement('div');
|
|
1914
|
+
header.className = 'op-menu__header';
|
|
1915
|
+
const back = document.createElement('button');
|
|
1916
|
+
back.type = 'button';
|
|
1917
|
+
back.className = 'op-submenu__back';
|
|
1918
|
+
setA11yLabel(back, 'Back');
|
|
1919
|
+
this.listen(back, 'click', (e) => {
|
|
1920
|
+
const me = e;
|
|
1921
|
+
this.activeSubmenuId = null;
|
|
1922
|
+
this.render();
|
|
1923
|
+
me.preventDefault();
|
|
1924
|
+
me.stopPropagation();
|
|
1925
|
+
}, EVENT_OPTIONS);
|
|
1926
|
+
const title = document.createElement('div');
|
|
1927
|
+
title.className = 'op-controls__settings-title';
|
|
1928
|
+
title.textContent = active.label;
|
|
1929
|
+
header.append(back, title);
|
|
1930
|
+
this.view.appendChild(header);
|
|
1931
|
+
for (const item of active.items) {
|
|
1932
|
+
this.view.appendChild(this.makeRow(item.label, () => {
|
|
1933
|
+
if (item.disabled)
|
|
1934
|
+
return;
|
|
1935
|
+
item.onSelect();
|
|
1936
|
+
// Recompute submenu after selection
|
|
1937
|
+
this.render();
|
|
1938
|
+
}, item.checked, item.disabled));
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
makeRow(label, onClick, checked = false, disabled = false) {
|
|
1942
|
+
const btn = document.createElement('button');
|
|
1943
|
+
btn.type = 'button';
|
|
1944
|
+
btn.className = 'op-controls__menu-item';
|
|
1945
|
+
btn.setAttribute('role', 'menuitem');
|
|
1946
|
+
btn.setAttribute('aria-disabled', disabled ? 'true' : 'false');
|
|
1947
|
+
btn.setAttribute('aria-checked', checked ? 'true' : 'false');
|
|
1948
|
+
const text = document.createElement('span');
|
|
1949
|
+
text.className = 'op-controls__menu-item-label';
|
|
1950
|
+
text.textContent = label;
|
|
1951
|
+
const mark = document.createElement('span');
|
|
1952
|
+
mark.className = `op-menu__item-check ${checked ? 'checked' : ''}`;
|
|
1953
|
+
btn.append(mark, text);
|
|
1954
|
+
this.listen(btn, 'click', (e) => {
|
|
1955
|
+
const me = e;
|
|
1956
|
+
onClick();
|
|
1957
|
+
me.preventDefault();
|
|
1958
|
+
me.stopPropagation();
|
|
1959
|
+
}, EVENT_OPTIONS);
|
|
1960
|
+
return btn;
|
|
1961
|
+
}
|
|
1962
|
+
}
|
|
1963
|
+
function createSettingsControl() {
|
|
1964
|
+
return new SettingsControl();
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
class TimeControl extends BaseControl {
|
|
1968
|
+
constructor() {
|
|
1969
|
+
super(...arguments);
|
|
1970
|
+
Object.defineProperty(this, "id", {
|
|
1971
|
+
enumerable: true,
|
|
1972
|
+
configurable: true,
|
|
1973
|
+
writable: true,
|
|
1974
|
+
value: 'time'
|
|
1975
|
+
});
|
|
1976
|
+
Object.defineProperty(this, "placement", {
|
|
1977
|
+
enumerable: true,
|
|
1978
|
+
configurable: true,
|
|
1979
|
+
writable: true,
|
|
1980
|
+
value: { v: 'bottom', h: 'left' }
|
|
1981
|
+
});
|
|
1982
|
+
}
|
|
1983
|
+
build() {
|
|
1984
|
+
const core = this.core;
|
|
1985
|
+
const delimiter = document.createElement('span');
|
|
1986
|
+
delimiter.className = 'op-controls__time-delimiter';
|
|
1987
|
+
delimiter.textContent = '/';
|
|
1988
|
+
const container = document.createElement('span');
|
|
1989
|
+
container.className = 'op-controls-time';
|
|
1990
|
+
const currentTime = new CurrentTimeControl().create(core);
|
|
1991
|
+
const duration = new DurationControl().create(core);
|
|
1992
|
+
container.appendChild(currentTime);
|
|
1993
|
+
container.appendChild(delimiter);
|
|
1994
|
+
container.appendChild(duration);
|
|
1995
|
+
return container;
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
function createTimeControl() {
|
|
1999
|
+
return new TimeControl();
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
class VolumeControl extends BaseControl {
|
|
2003
|
+
constructor() {
|
|
2004
|
+
super(...arguments);
|
|
2005
|
+
Object.defineProperty(this, "id", {
|
|
2006
|
+
enumerable: true,
|
|
2007
|
+
configurable: true,
|
|
2008
|
+
writable: true,
|
|
2009
|
+
value: 'volume'
|
|
2010
|
+
});
|
|
2011
|
+
Object.defineProperty(this, "placement", {
|
|
2012
|
+
enumerable: true,
|
|
2013
|
+
configurable: true,
|
|
2014
|
+
writable: true,
|
|
2015
|
+
value: { v: 'bottom', h: 'right' }
|
|
2016
|
+
});
|
|
2017
|
+
}
|
|
2018
|
+
build() {
|
|
2019
|
+
const core = this.core;
|
|
2020
|
+
const labels = resolveUIConfig(core).labels;
|
|
2021
|
+
const muteLabel = labels.mute;
|
|
2022
|
+
const unmuteLabel = labels.unmute;
|
|
2023
|
+
const volumeLabel = labels.volume;
|
|
2024
|
+
const volumeControlLabel = labels.volumeControl;
|
|
2025
|
+
const volumeSliderLabel = labels.volumeSlider;
|
|
2026
|
+
const wrapper = document.createElement('div');
|
|
2027
|
+
wrapper.className = 'op-controls__volume';
|
|
2028
|
+
wrapper.tabIndex = 0;
|
|
2029
|
+
wrapper.setAttribute('aria-valuemin', '0');
|
|
2030
|
+
wrapper.setAttribute('aria-valuemax', '100');
|
|
2031
|
+
wrapper.setAttribute('aria-valuenow', `${core.volume}`);
|
|
2032
|
+
setA11yLabel(wrapper, volumeControlLabel);
|
|
2033
|
+
wrapper.setAttribute('aria-orientation', 'vertical');
|
|
2034
|
+
wrapper.setAttribute('role', 'slider');
|
|
2035
|
+
const slider = document.createElement('input');
|
|
2036
|
+
slider.className = 'op-controls__volume--input';
|
|
2037
|
+
slider.tabIndex = -1;
|
|
2038
|
+
slider.type = 'range';
|
|
2039
|
+
slider.value = core.volume.toString();
|
|
2040
|
+
slider.min = '0';
|
|
2041
|
+
slider.max = '1';
|
|
2042
|
+
slider.step = '0.1';
|
|
2043
|
+
setA11yLabel(slider, volumeSliderLabel, { container: wrapper });
|
|
2044
|
+
const display = document.createElement('progress');
|
|
2045
|
+
display.className = 'op-controls__volume--display';
|
|
2046
|
+
display.max = 10;
|
|
2047
|
+
display.value = core.volume * 10;
|
|
2048
|
+
wrapper.appendChild(slider);
|
|
2049
|
+
wrapper.appendChild(display);
|
|
2050
|
+
let lastVolume = core.volume;
|
|
2051
|
+
const formatVolume = (vol) => {
|
|
2052
|
+
if (vol >= 1)
|
|
2053
|
+
return 1;
|
|
2054
|
+
if (vol <= 0)
|
|
2055
|
+
return 0;
|
|
2056
|
+
return vol;
|
|
2057
|
+
};
|
|
2058
|
+
const updateSlider = (vol) => {
|
|
2059
|
+
const v = formatVolume(vol);
|
|
2060
|
+
display.value = v * 10;
|
|
2061
|
+
const formattedVol = Math.floor(v * 100);
|
|
2062
|
+
wrapper.setAttribute('aria-valuenow', `${formattedVol}`);
|
|
2063
|
+
wrapper.setAttribute('aria-valuetext', `${volumeLabel}: ${formattedVol}`);
|
|
2064
|
+
};
|
|
2065
|
+
const updateBtn = (vol) => {
|
|
2066
|
+
const v = formatVolume(vol);
|
|
2067
|
+
if (v <= 0.5 && v > 0) {
|
|
2068
|
+
btn.classList.remove('op-controls__mute--muted');
|
|
2069
|
+
btn.classList.add('op-controls__mute--half');
|
|
2070
|
+
}
|
|
2071
|
+
else if (v === 0) {
|
|
2072
|
+
btn.classList.add('op-controls__mute--muted');
|
|
2073
|
+
btn.classList.remove('op-controls__mute--half');
|
|
2074
|
+
}
|
|
2075
|
+
else {
|
|
2076
|
+
btn.classList.remove('op-controls__mute--muted');
|
|
2077
|
+
btn.classList.remove('op-controls__mute--half');
|
|
2078
|
+
}
|
|
2079
|
+
};
|
|
2080
|
+
this.listen(slider, 'input', (e) => {
|
|
2081
|
+
const vol = Number(e.target.value);
|
|
2082
|
+
const v = formatVolume(vol);
|
|
2083
|
+
lastVolume = v;
|
|
2084
|
+
core.volume = v;
|
|
2085
|
+
core.muted = v === 0;
|
|
2086
|
+
const el = getActiveMedia(core);
|
|
2087
|
+
if (el && el !== core.media) {
|
|
2088
|
+
try {
|
|
2089
|
+
el.volume = v;
|
|
2090
|
+
el.muted = v === 0;
|
|
2091
|
+
}
|
|
2092
|
+
catch {
|
|
2093
|
+
// ignore
|
|
2094
|
+
}
|
|
2095
|
+
}
|
|
2096
|
+
updateSlider(v);
|
|
2097
|
+
updateBtn(v);
|
|
2098
|
+
}, EVENT_OPTIONS);
|
|
2099
|
+
const btn = document.createElement('button');
|
|
2100
|
+
btn.tabIndex = 0;
|
|
2101
|
+
btn.type = 'button';
|
|
2102
|
+
btn.title = muteLabel;
|
|
2103
|
+
btn.className = 'op-controls__mute';
|
|
2104
|
+
setA11yLabel(btn, muteLabel);
|
|
2105
|
+
btn.setAttribute('aria-pressed', 'false');
|
|
2106
|
+
this.listen(btn, 'click', (e) => {
|
|
2107
|
+
const me = e;
|
|
2108
|
+
const el = getActiveMedia(core);
|
|
2109
|
+
if (!core.muted) {
|
|
2110
|
+
if (core.volume > 0)
|
|
2111
|
+
lastVolume = core.volume;
|
|
2112
|
+
core.volume = 0;
|
|
2113
|
+
core.muted = true;
|
|
2114
|
+
if (el && el !== core.media) {
|
|
2115
|
+
try {
|
|
2116
|
+
el.volume = 0;
|
|
2117
|
+
el.muted = true;
|
|
2118
|
+
btn.title = muteLabel;
|
|
2119
|
+
setA11yLabel(btn, muteLabel);
|
|
2120
|
+
}
|
|
2121
|
+
catch {
|
|
2122
|
+
// ignore
|
|
2123
|
+
}
|
|
2124
|
+
}
|
|
2125
|
+
}
|
|
2126
|
+
else {
|
|
2127
|
+
const restore = lastVolume > 0 ? lastVolume : 1;
|
|
2128
|
+
core.volume = restore;
|
|
2129
|
+
core.muted = false;
|
|
2130
|
+
if (el && el !== core.media) {
|
|
2131
|
+
try {
|
|
2132
|
+
el.volume = restore;
|
|
2133
|
+
el.muted = false;
|
|
2134
|
+
btn.title = unmuteLabel;
|
|
2135
|
+
setA11yLabel(btn, unmuteLabel);
|
|
2136
|
+
}
|
|
2137
|
+
catch {
|
|
2138
|
+
// ignore
|
|
2139
|
+
}
|
|
2140
|
+
}
|
|
2141
|
+
}
|
|
2142
|
+
me.preventDefault();
|
|
2143
|
+
me.stopPropagation();
|
|
2144
|
+
}, EVENT_OPTIONS);
|
|
2145
|
+
this.onPlayer('volumechange', () => {
|
|
2146
|
+
const muted = core.muted || core.volume === 0;
|
|
2147
|
+
const vol = formatVolume(core.volume);
|
|
2148
|
+
if (vol > 0)
|
|
2149
|
+
lastVolume = vol;
|
|
2150
|
+
slider.value = (muted ? 0 : vol).toString();
|
|
2151
|
+
updateSlider(muted ? 0 : vol);
|
|
2152
|
+
updateBtn(muted ? 0 : vol);
|
|
2153
|
+
btn.setAttribute('aria-pressed', muted ? 'true' : 'false');
|
|
2154
|
+
const el = getActiveMedia(core);
|
|
2155
|
+
if (el && el !== core.media) {
|
|
2156
|
+
try {
|
|
2157
|
+
el.muted = muted;
|
|
2158
|
+
if (!muted)
|
|
2159
|
+
el.volume = vol;
|
|
2160
|
+
}
|
|
2161
|
+
catch {
|
|
2162
|
+
// ignore
|
|
2163
|
+
}
|
|
2164
|
+
}
|
|
2165
|
+
});
|
|
2166
|
+
const container = document.createElement('div');
|
|
2167
|
+
container.className = 'op-controls__volume--container';
|
|
2168
|
+
container.appendChild(btn);
|
|
2169
|
+
container.appendChild(wrapper);
|
|
2170
|
+
return container;
|
|
2171
|
+
}
|
|
2172
|
+
}
|
|
2173
|
+
function createVolumeControl() {
|
|
2174
|
+
return isMobile() ? null : new VolumeControl();
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
export { BaseControl, buildControls, createCaptionsControl, createControlGrid, createCurrentTimeControl, createDurationControl, createFullscreenControl, createPlayControl, createProgressControl, createSettingsControl, createTimeControl, createUI, createVolumeControl, defaultLabels, defaultUIConfiguration, extendControls, registerControl, resolveUIConfig, setA11yLabel };
|
|
2178
|
+
//# sourceMappingURL=index.js.map
|