@cloudnest/redxplyr 1.0.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.
Files changed (127) hide show
  1. package/.editorconfig +10 -0
  2. package/.gitpod.yml +6 -0
  3. package/.node-version +1 -0
  4. package/.prettierrc +7 -0
  5. package/.stickler.yml +5 -0
  6. package/.stylelintrc.json +26 -0
  7. package/CHANGELOG.md +16 -0
  8. package/CONTRIBUTING.md +34 -0
  9. package/CONTROLS.md +49 -0
  10. package/Dockerfile +32 -0
  11. package/LICENSE.md +22 -0
  12. package/README.md +194 -0
  13. package/cspell.json +48 -0
  14. package/dist/redxplyr.css +1 -0
  15. package/dist/redxplyr.js +8801 -0
  16. package/dist/redxplyr.min.js +2 -0
  17. package/dist/redxplyr.min.js.map +1 -0
  18. package/dist/redxplyr.min.mjs +1 -0
  19. package/dist/redxplyr.min.mjs.map +1 -0
  20. package/dist/redxplyr.mjs +8793 -0
  21. package/dist/redxplyr.polyfilled.js +9294 -0
  22. package/dist/redxplyr.polyfilled.min.js +2 -0
  23. package/dist/redxplyr.polyfilled.min.js.map +1 -0
  24. package/dist/redxplyr.polyfilled.min.mjs +1 -0
  25. package/dist/redxplyr.polyfilled.min.mjs.map +1 -0
  26. package/dist/redxplyr.polyfilled.mjs +9286 -0
  27. package/dist/redxplyr.svg +1 -0
  28. package/eslint.config.mjs +39 -0
  29. package/gulpfile.js +8 -0
  30. package/package.json +114 -0
  31. package/pnpm-workspace.yaml +8 -0
  32. package/src/js/captions.js +411 -0
  33. package/src/js/config/defaults.js +459 -0
  34. package/src/js/config/states.js +10 -0
  35. package/src/js/config/types.js +34 -0
  36. package/src/js/console.js +28 -0
  37. package/src/js/controls.js +1870 -0
  38. package/src/js/fullscreen.js +305 -0
  39. package/src/js/html5.js +148 -0
  40. package/src/js/listeners.js +854 -0
  41. package/src/js/media.js +61 -0
  42. package/src/js/plugins/ads.js +647 -0
  43. package/src/js/plugins/preview-thumbnails.js +706 -0
  44. package/src/js/plugins/vimeo.js +443 -0
  45. package/src/js/plugins/youtube.js +451 -0
  46. package/src/js/plyr.d.ts +729 -0
  47. package/src/js/plyr.js +1291 -0
  48. package/src/js/plyr.polyfilled.js +13 -0
  49. package/src/js/source.js +155 -0
  50. package/src/js/storage.js +70 -0
  51. package/src/js/support.js +100 -0
  52. package/src/js/ui.js +297 -0
  53. package/src/js/utils/animation.js +33 -0
  54. package/src/js/utils/arrays.js +23 -0
  55. package/src/js/utils/browser.js +21 -0
  56. package/src/js/utils/elements.js +263 -0
  57. package/src/js/utils/events.js +116 -0
  58. package/src/js/utils/fetch.js +45 -0
  59. package/src/js/utils/i18n.js +47 -0
  60. package/src/js/utils/is.js +81 -0
  61. package/src/js/utils/load-image.js +19 -0
  62. package/src/js/utils/load-script.js +14 -0
  63. package/src/js/utils/load-sprite.js +77 -0
  64. package/src/js/utils/numbers.js +17 -0
  65. package/src/js/utils/objects.js +43 -0
  66. package/src/js/utils/promise.js +14 -0
  67. package/src/js/utils/strings.js +80 -0
  68. package/src/js/utils/style.js +148 -0
  69. package/src/js/utils/time.js +36 -0
  70. package/src/js/utils/urls.js +40 -0
  71. package/src/sass/base.scss +69 -0
  72. package/src/sass/components/badges.scss +12 -0
  73. package/src/sass/components/captions.scss +58 -0
  74. package/src/sass/components/control.scss +52 -0
  75. package/src/sass/components/controls.scss +65 -0
  76. package/src/sass/components/menus.scss +205 -0
  77. package/src/sass/components/poster.scss +27 -0
  78. package/src/sass/components/progress.scss +107 -0
  79. package/src/sass/components/sliders.scss +99 -0
  80. package/src/sass/components/times.scss +20 -0
  81. package/src/sass/components/tooltips.scss +91 -0
  82. package/src/sass/components/volume.scss +18 -0
  83. package/src/sass/lib/animation.scss +31 -0
  84. package/src/sass/lib/css-vars.scss +103 -0
  85. package/src/sass/lib/functions.scss +3 -0
  86. package/src/sass/lib/mixins.scss +82 -0
  87. package/src/sass/plugins/ads.scss +53 -0
  88. package/src/sass/plugins/preview-thumbnails/index.scss +121 -0
  89. package/src/sass/plugins/preview-thumbnails/settings.scss +17 -0
  90. package/src/sass/plyr.scss +46 -0
  91. package/src/sass/settings/badges.scss +7 -0
  92. package/src/sass/settings/breakpoints.scss +9 -0
  93. package/src/sass/settings/captions.scss +10 -0
  94. package/src/sass/settings/colors.scss +18 -0
  95. package/src/sass/settings/controls.scss +30 -0
  96. package/src/sass/settings/cosmetics.scss +5 -0
  97. package/src/sass/settings/helpers.scss +7 -0
  98. package/src/sass/settings/menus.scss +13 -0
  99. package/src/sass/settings/progress.scss +18 -0
  100. package/src/sass/settings/sliders.scss +39 -0
  101. package/src/sass/settings/tooltips.scss +11 -0
  102. package/src/sass/settings/type.scss +16 -0
  103. package/src/sass/states/fullscreen.scss +15 -0
  104. package/src/sass/types/audio.scss +61 -0
  105. package/src/sass/types/video.scss +170 -0
  106. package/src/sass/utils/animation.scss +7 -0
  107. package/src/sass/utils/hidden.scss +28 -0
  108. package/src/sprite/plyr-airplay.svg +8 -0
  109. package/src/sprite/plyr-captions-off.svg +7 -0
  110. package/src/sprite/plyr-captions-on.svg +7 -0
  111. package/src/sprite/plyr-download.svg +8 -0
  112. package/src/sprite/plyr-enter-fullscreen.svg +4 -0
  113. package/src/sprite/plyr-exit-fullscreen.svg +4 -0
  114. package/src/sprite/plyr-fast-forward.svg +3 -0
  115. package/src/sprite/plyr-logo-vimeo.svg +6 -0
  116. package/src/sprite/plyr-logo-youtube.svg +6 -0
  117. package/src/sprite/plyr-muted.svg +8 -0
  118. package/src/sprite/plyr-pause.svg +8 -0
  119. package/src/sprite/plyr-pip.svg +6 -0
  120. package/src/sprite/plyr-play.svg +5 -0
  121. package/src/sprite/plyr-restart.svg +5 -0
  122. package/src/sprite/plyr-rewind.svg +3 -0
  123. package/src/sprite/plyr-settings.svg +5 -0
  124. package/src/sprite/plyr-volume.svg +11 -0
  125. package/tasks/build.js +226 -0
  126. package/tasks/deploy.js +216 -0
  127. package/tasks/utils/publish.js +34 -0
@@ -0,0 +1,1870 @@
1
+ // ==========================================================================
2
+ // Plyr controls
3
+ // TODO: This needs to be split into smaller files and cleaned up
4
+ // ==========================================================================
5
+
6
+ import RangeTouch from 'rangetouch';
7
+
8
+ import captions from './captions';
9
+ import html5 from './html5';
10
+ import support from './support';
11
+ import { repaint, transitionEndEvent } from './utils/animation';
12
+ import { dedupe } from './utils/arrays';
13
+ import browser from './utils/browser';
14
+ import {
15
+ createElement,
16
+ emptyElement,
17
+ getAttributesFromSelector,
18
+ getElement,
19
+ getElements,
20
+ hasClass,
21
+ matches,
22
+ removeElement,
23
+ setAttributes,
24
+ setFocus,
25
+ toggleClass,
26
+ toggleHidden,
27
+ } from './utils/elements';
28
+ import { off, on } from './utils/events';
29
+ import i18n from './utils/i18n';
30
+ import is from './utils/is';
31
+ import loadSprite from './utils/load-sprite';
32
+ import { extend } from './utils/objects';
33
+ import { getPercentage, replaceAll, toCamelCase, toTitleCase } from './utils/strings';
34
+ import { formatTime, getHours } from './utils/time';
35
+
36
+ // TODO: Don't export a massive object - break down and create class
37
+ const controls = {
38
+ // Get icon URL
39
+ getIconUrl() {
40
+ const url = new URL(this.config.iconUrl, window.location);
41
+ const host = window.location.host ? window.location.host : window.top.location.host;
42
+ const cors = url.host !== host || (browser.isIE && !window.svg4everybody);
43
+
44
+ return {
45
+ url: this.config.iconUrl,
46
+ cors,
47
+ };
48
+ },
49
+
50
+ // Find the UI controls
51
+ findElements() {
52
+ try {
53
+ this.elements.controls = getElement.call(this, this.config.selectors.controls.wrapper);
54
+
55
+ // Buttons
56
+ this.elements.buttons = {
57
+ play: getElements.call(this, this.config.selectors.buttons.play),
58
+ pause: getElement.call(this, this.config.selectors.buttons.pause),
59
+ restart: getElement.call(this, this.config.selectors.buttons.restart),
60
+ rewind: getElement.call(this, this.config.selectors.buttons.rewind),
61
+ fastForward: getElement.call(this, this.config.selectors.buttons.fastForward),
62
+ mute: getElement.call(this, this.config.selectors.buttons.mute),
63
+ pip: getElement.call(this, this.config.selectors.buttons.pip),
64
+ airplay: getElement.call(this, this.config.selectors.buttons.airplay),
65
+ settings: getElement.call(this, this.config.selectors.buttons.settings),
66
+ captions: getElement.call(this, this.config.selectors.buttons.captions),
67
+ fullscreen: getElement.call(this, this.config.selectors.buttons.fullscreen),
68
+ };
69
+
70
+ // Progress
71
+ this.elements.progress = getElement.call(this, this.config.selectors.progress);
72
+
73
+ // Inputs
74
+ this.elements.inputs = {
75
+ seek: getElement.call(this, this.config.selectors.inputs.seek),
76
+ volume: getElement.call(this, this.config.selectors.inputs.volume),
77
+ };
78
+
79
+ // Display
80
+ this.elements.display = {
81
+ buffer: getElement.call(this, this.config.selectors.display.buffer),
82
+ currentTime: getElement.call(this, this.config.selectors.display.currentTime),
83
+ duration: getElement.call(this, this.config.selectors.display.duration),
84
+ };
85
+
86
+ // Seek tooltip
87
+ if (is.element(this.elements.progress)) {
88
+ this.elements.display.seekTooltip = this.elements.progress.querySelector(`.${this.config.classNames.tooltip}`);
89
+ }
90
+
91
+ return true;
92
+ }
93
+ catch (error) {
94
+ // Log it
95
+ this.debug.warn('It looks like there is a problem with your custom controls HTML', error);
96
+
97
+ // Restore native video controls
98
+ this.toggleNativeControls(true);
99
+
100
+ return false;
101
+ }
102
+ },
103
+
104
+ // Create <svg> icon
105
+ createIcon(type, attributes) {
106
+ const namespace = 'http://www.w3.org/2000/svg';
107
+ const iconUrl = controls.getIconUrl.call(this);
108
+ const iconPath = `${!iconUrl.cors ? iconUrl.url : ''}#${this.config.iconPrefix}`;
109
+ // Create <svg>
110
+ const icon = document.createElementNS(namespace, 'svg');
111
+ setAttributes(
112
+ icon,
113
+ extend(attributes, {
114
+ 'aria-hidden': 'true',
115
+ 'focusable': 'false',
116
+ }),
117
+ );
118
+
119
+ // Create the <use> to reference sprite
120
+ const use = document.createElementNS(namespace, 'use');
121
+ const path = `${iconPath}-${type}`;
122
+
123
+ // Set `href` attributes
124
+ // https://github.com/xgauravyaduvanshii/redxplyr/issues/460
125
+ // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/xlink:href
126
+ if ('href' in use) {
127
+ use.setAttributeNS('http://www.w3.org/1999/xlink', 'href', path);
128
+ }
129
+
130
+ // Always set the older attribute even though it's "deprecated" (it'll be around for ages)
131
+ use.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', path);
132
+
133
+ // Add <use> to <svg>
134
+ icon.appendChild(use);
135
+
136
+ return icon;
137
+ },
138
+
139
+ // Create hidden text label
140
+ createLabel(key, attr = {}) {
141
+ const text = i18n.get(key, this.config);
142
+ const attributes = { ...attr, class: [attr.class, this.config.classNames.hidden].filter(Boolean).join(' ') };
143
+
144
+ return createElement('span', attributes, text);
145
+ },
146
+
147
+ // Create a badge
148
+ createBadge(text) {
149
+ if (is.empty(text)) {
150
+ return null;
151
+ }
152
+
153
+ const badge = createElement('span', {
154
+ class: this.config.classNames.menu.value,
155
+ });
156
+
157
+ badge.appendChild(
158
+ createElement(
159
+ 'span',
160
+ {
161
+ class: this.config.classNames.menu.badge,
162
+ },
163
+ text,
164
+ ),
165
+ );
166
+
167
+ return badge;
168
+ },
169
+
170
+ // Create a <button>
171
+ createButton(buttonType, attr) {
172
+ const attributes = extend({}, attr);
173
+ let type = toCamelCase(buttonType);
174
+
175
+ const props = {
176
+ element: 'button',
177
+ toggle: false,
178
+ label: null,
179
+ icon: null,
180
+ labelPressed: null,
181
+ iconPressed: null,
182
+ };
183
+
184
+ ['element', 'icon', 'label'].forEach((key) => {
185
+ if (Object.keys(attributes).includes(key)) {
186
+ props[key] = attributes[key];
187
+ delete attributes[key];
188
+ }
189
+ });
190
+
191
+ // Default to 'button' type to prevent form submission
192
+ if (props.element === 'button' && !Object.keys(attributes).includes('type')) {
193
+ attributes.type = 'button';
194
+ }
195
+
196
+ // Set class name
197
+ if (Object.keys(attributes).includes('class')) {
198
+ if (!attributes.class.split(' ').includes(this.config.classNames.control)) {
199
+ extend(attributes, {
200
+ class: `${attributes.class} ${this.config.classNames.control}`,
201
+ });
202
+ }
203
+ }
204
+ else {
205
+ attributes.class = this.config.classNames.control;
206
+ }
207
+
208
+ // Large play button
209
+ switch (buttonType) {
210
+ case 'play':
211
+ props.toggle = true;
212
+ props.label = 'play';
213
+ props.labelPressed = 'pause';
214
+ props.icon = 'play';
215
+ props.iconPressed = 'pause';
216
+ break;
217
+
218
+ case 'mute':
219
+ props.toggle = true;
220
+ props.label = 'mute';
221
+ props.labelPressed = 'unmute';
222
+ props.icon = 'volume';
223
+ props.iconPressed = 'muted';
224
+ break;
225
+
226
+ case 'captions':
227
+ props.toggle = true;
228
+ props.label = 'enableCaptions';
229
+ props.labelPressed = 'disableCaptions';
230
+ props.icon = 'captions-off';
231
+ props.iconPressed = 'captions-on';
232
+ break;
233
+
234
+ case 'fullscreen':
235
+ props.toggle = true;
236
+ props.label = 'enterFullscreen';
237
+ props.labelPressed = 'exitFullscreen';
238
+ props.icon = 'enter-fullscreen';
239
+ props.iconPressed = 'exit-fullscreen';
240
+ break;
241
+
242
+ case 'play-large':
243
+ attributes.class += ` ${this.config.classNames.control}--overlaid`;
244
+ type = 'play';
245
+ props.label = 'play';
246
+ props.icon = 'play';
247
+ break;
248
+
249
+ default:
250
+ if (is.empty(props.label)) {
251
+ props.label = type;
252
+ }
253
+ if (is.empty(props.icon)) {
254
+ props.icon = buttonType;
255
+ }
256
+ }
257
+
258
+ const button = createElement(props.element);
259
+
260
+ // Setup toggle icon and labels
261
+ if (props.toggle) {
262
+ // Icon
263
+ button.appendChild(
264
+ controls.createIcon.call(this, props.iconPressed, {
265
+ class: 'icon--pressed',
266
+ }),
267
+ );
268
+ button.appendChild(
269
+ controls.createIcon.call(this, props.icon, {
270
+ class: 'icon--not-pressed',
271
+ }),
272
+ );
273
+
274
+ // Label/Tooltip
275
+ button.appendChild(
276
+ controls.createLabel.call(this, props.labelPressed, {
277
+ class: 'label--pressed',
278
+ }),
279
+ );
280
+ button.appendChild(
281
+ controls.createLabel.call(this, props.label, {
282
+ class: 'label--not-pressed',
283
+ }),
284
+ );
285
+ }
286
+ else {
287
+ button.appendChild(controls.createIcon.call(this, props.icon));
288
+ button.appendChild(controls.createLabel.call(this, props.label));
289
+ }
290
+
291
+ // Merge and set attributes
292
+ extend(attributes, getAttributesFromSelector(this.config.selectors.buttons[type], attributes));
293
+ setAttributes(button, attributes);
294
+
295
+ // We have multiple play buttons
296
+ if (type === 'play') {
297
+ if (!is.array(this.elements.buttons[type])) {
298
+ this.elements.buttons[type] = [];
299
+ }
300
+
301
+ this.elements.buttons[type].push(button);
302
+ }
303
+ else {
304
+ this.elements.buttons[type] = button;
305
+ }
306
+
307
+ return button;
308
+ },
309
+
310
+ // Create an <input type='range'>
311
+ createRange(type, attributes) {
312
+ // Seek input
313
+ const input = createElement(
314
+ 'input',
315
+ extend(
316
+ getAttributesFromSelector(this.config.selectors.inputs[type]),
317
+ {
318
+ 'type': 'range',
319
+ 'min': 0,
320
+ 'max': 100,
321
+ 'step': 0.01,
322
+ 'value': 0,
323
+ 'autocomplete': 'off',
324
+ // A11y fixes for https://github.com/xgauravyaduvanshii/redxplyr/issues/905
325
+ 'role': 'slider',
326
+ 'aria-label': i18n.get(type, this.config),
327
+ 'aria-valuemin': 0,
328
+ 'aria-valuemax': 100,
329
+ 'aria-valuenow': 0,
330
+ },
331
+ attributes,
332
+ ),
333
+ );
334
+
335
+ this.elements.inputs[type] = input;
336
+
337
+ // Set the fill for webkit now
338
+ controls.updateRangeFill.call(this, input);
339
+
340
+ // Improve support on touch devices
341
+ RangeTouch.setup(input);
342
+
343
+ return input;
344
+ },
345
+
346
+ // Create a <progress>
347
+ createProgress(type, attributes) {
348
+ const progress = createElement(
349
+ 'progress',
350
+ extend(
351
+ getAttributesFromSelector(this.config.selectors.display[type]),
352
+ {
353
+ 'min': 0,
354
+ 'max': 100,
355
+ 'value': 0,
356
+ 'role': 'progressbar',
357
+ 'aria-hidden': true,
358
+ },
359
+ attributes,
360
+ ),
361
+ );
362
+
363
+ // Create the label inside
364
+ if (type !== 'volume') {
365
+ progress.appendChild(createElement('span', null, '0'));
366
+
367
+ const suffixKey = {
368
+ played: 'played',
369
+ buffer: 'buffered',
370
+ }[type];
371
+ const suffix = suffixKey ? i18n.get(suffixKey, this.config) : '';
372
+
373
+ progress.textContent = `% ${suffix.toLowerCase()}`;
374
+ }
375
+
376
+ this.elements.display[type] = progress;
377
+
378
+ return progress;
379
+ },
380
+
381
+ // Create time display
382
+ createTime(type, attrs) {
383
+ const attributes = getAttributesFromSelector(this.config.selectors.display[type], attrs);
384
+
385
+ const container = createElement(
386
+ 'div',
387
+ extend(attributes, {
388
+ 'class': `${attributes.class ? attributes.class : ''} ${this.config.classNames.display.time} `.trim(),
389
+ 'aria-label': i18n.get(type, this.config),
390
+ 'role': 'timer',
391
+ }),
392
+ '00:00',
393
+ );
394
+
395
+ // Reference for updates
396
+ this.elements.display[type] = container;
397
+
398
+ return container;
399
+ },
400
+
401
+ // Bind keyboard shortcuts for a menu item
402
+ // We have to bind to keyup otherwise Firefox triggers a click when a keydown event handler shifts focus
403
+ // https://bugzilla.mozilla.org/show_bug.cgi?id=1220143
404
+ bindMenuItemShortcuts(menuItem, type) {
405
+ // Navigate through menus via arrow keys and space
406
+ on.call(
407
+ this,
408
+ menuItem,
409
+ 'keydown keyup',
410
+ (event) => {
411
+ // We only care about space and ⬆️ ⬇️️ ➡️
412
+ if (![' ', 'ArrowUp', 'ArrowDown', 'ArrowRight'].includes(event.key)) {
413
+ return;
414
+ }
415
+
416
+ // Prevent play / seek
417
+ event.preventDefault();
418
+ event.stopPropagation();
419
+
420
+ // We're just here to prevent the keydown bubbling
421
+ if (event.type === 'keydown') {
422
+ return;
423
+ }
424
+
425
+ const isRadioButton = matches(menuItem, '[role="menuitemradio"]');
426
+
427
+ // Show the respective menu
428
+ if (!isRadioButton && [' ', 'ArrowRight'].includes(event.key)) {
429
+ controls.showMenuPanel.call(this, type, true);
430
+ }
431
+ else {
432
+ let target;
433
+
434
+ if (event.key !== ' ') {
435
+ if (event.key === 'ArrowDown' || (isRadioButton && event.key === 'ArrowRight')) {
436
+ target = menuItem.nextElementSibling;
437
+
438
+ if (!is.element(target)) {
439
+ target = menuItem.parentNode.firstElementChild;
440
+ }
441
+ }
442
+ else {
443
+ target = menuItem.previousElementSibling;
444
+
445
+ if (!is.element(target)) {
446
+ target = menuItem.parentNode.lastElementChild;
447
+ }
448
+ }
449
+
450
+ setFocus.call(this, target, true);
451
+ }
452
+ }
453
+ },
454
+ false,
455
+ );
456
+
457
+ // Enter will fire a `click` event but we still need to manage focus
458
+ // So we bind to keyup which fires after and set focus here
459
+ on.call(this, menuItem, 'keyup', (event) => {
460
+ if (event.key !== 'Return') return;
461
+
462
+ controls.focusFirstMenuItem.call(this, null, true);
463
+ });
464
+ },
465
+
466
+ // Create a settings menu item
467
+ createMenuItem({ value, list, type, title, badge = null, checked = false }) {
468
+ const attributes = getAttributesFromSelector(this.config.selectors.inputs[type]);
469
+
470
+ const menuItem = createElement(
471
+ 'button',
472
+ extend(attributes, {
473
+ 'type': 'button',
474
+ 'role': 'menuitemradio',
475
+ 'class': `${this.config.classNames.control} ${attributes.class ? attributes.class : ''}`.trim(),
476
+ 'aria-checked': checked,
477
+ value,
478
+ }),
479
+ );
480
+
481
+ const flex = createElement('span');
482
+
483
+ // We have to set as HTML incase of special characters
484
+ flex.innerHTML = title;
485
+
486
+ if (is.element(badge)) {
487
+ flex.appendChild(badge);
488
+ }
489
+
490
+ menuItem.appendChild(flex);
491
+
492
+ // Replicate radio button behavior
493
+ Object.defineProperty(menuItem, 'checked', {
494
+ enumerable: true,
495
+ get() {
496
+ return menuItem.getAttribute('aria-checked') === 'true';
497
+ },
498
+ set(check) {
499
+ // Ensure exclusivity
500
+ if (check) {
501
+ Array.from(menuItem.parentNode.children)
502
+ .filter(node => matches(node, '[role="menuitemradio"]'))
503
+ .forEach(node => node.setAttribute('aria-checked', 'false'));
504
+ }
505
+
506
+ menuItem.setAttribute('aria-checked', check ? 'true' : 'false');
507
+ },
508
+ });
509
+
510
+ this.listeners.bind(
511
+ menuItem,
512
+ 'click keyup',
513
+ (event) => {
514
+ if (is.keyboardEvent(event) && event.key !== ' ') {
515
+ return;
516
+ }
517
+
518
+ event.preventDefault();
519
+ event.stopPropagation();
520
+
521
+ menuItem.checked = true;
522
+
523
+ switch (type) {
524
+ case 'language':
525
+ this.currentTrack = Number(value);
526
+ break;
527
+
528
+ case 'quality':
529
+ this.quality = value;
530
+ break;
531
+
532
+ case 'speed':
533
+ this.speed = Number.parseFloat(value);
534
+ break;
535
+
536
+ default:
537
+ break;
538
+ }
539
+
540
+ controls.showMenuPanel.call(this, 'home', is.keyboardEvent(event));
541
+ },
542
+ type,
543
+ false,
544
+ );
545
+
546
+ controls.bindMenuItemShortcuts.call(this, menuItem, type);
547
+
548
+ list.appendChild(menuItem);
549
+ },
550
+
551
+ // Format a time for display
552
+ formatTime(time = 0, inverted = false) {
553
+ // Bail if the value isn't a number
554
+ if (!is.number(time)) {
555
+ return time;
556
+ }
557
+
558
+ // Always display hours if duration is over an hour
559
+ const forceHours = getHours(this.duration) > 0;
560
+
561
+ return formatTime(time, forceHours, inverted);
562
+ },
563
+
564
+ // Update the displayed time
565
+ updateTimeDisplay(target = null, time = 0, inverted = false) {
566
+ // Bail if there's no element to display or the value isn't a number
567
+ if (!is.element(target) || !is.number(time)) {
568
+ return;
569
+ }
570
+
571
+ target.textContent = controls.formatTime(time, inverted);
572
+ },
573
+
574
+ // Update volume UI and storage
575
+ updateVolume() {
576
+ if (!this.supported.ui) {
577
+ return;
578
+ }
579
+
580
+ // Update range
581
+ if (is.element(this.elements.inputs.volume)) {
582
+ controls.setRange.call(this, this.elements.inputs.volume, this.muted ? 0 : this.volume);
583
+ }
584
+
585
+ // Update mute state
586
+ if (is.element(this.elements.buttons.mute)) {
587
+ this.elements.buttons.mute.pressed = this.muted || this.volume === 0;
588
+ }
589
+ },
590
+
591
+ // Update seek value and lower fill
592
+ setRange(target, value = 0) {
593
+ if (!is.element(target)) {
594
+ return;
595
+ }
596
+
597
+ target.value = value;
598
+
599
+ // Webkit range fill
600
+ controls.updateRangeFill.call(this, target);
601
+ },
602
+
603
+ // Update <progress> elements
604
+ updateProgress(event) {
605
+ if (!this.supported.ui || !is.event(event)) {
606
+ return;
607
+ }
608
+
609
+ let value = 0;
610
+
611
+ const setProgress = (target, input) => {
612
+ const val = is.number(input) ? input : 0;
613
+ const progress = is.element(target) ? target : this.elements.display.buffer;
614
+
615
+ // Update value and label
616
+ if (is.element(progress)) {
617
+ progress.value = val;
618
+
619
+ // Update text label inside
620
+ const label = progress.getElementsByTagName('span')[0];
621
+ if (is.element(label)) {
622
+ label.childNodes[0].nodeValue = val;
623
+ }
624
+ }
625
+ };
626
+
627
+ if (event) {
628
+ switch (event.type) {
629
+ // Video playing
630
+ case 'timeupdate':
631
+ case 'seeking':
632
+ case 'seeked':
633
+ value = getPercentage(this.currentTime, this.duration);
634
+
635
+ // Set seek range value only if it's a 'natural' time event
636
+ if (event.type === 'timeupdate') {
637
+ controls.setRange.call(this, this.elements.inputs.seek, value);
638
+ }
639
+
640
+ break;
641
+
642
+ // Check buffer status
643
+ case 'playing':
644
+ case 'progress':
645
+ setProgress(this.elements.display.buffer, this.buffered * 100);
646
+
647
+ break;
648
+
649
+ default:
650
+ break;
651
+ }
652
+ }
653
+ },
654
+
655
+ // Webkit polyfill for lower fill range
656
+ updateRangeFill(target) {
657
+ // Get range from event if event passed
658
+ const range = is.event(target) ? target.target : target;
659
+
660
+ // Needs to be a valid <input type='range'>
661
+ if (!is.element(range) || range.getAttribute('type') !== 'range') {
662
+ return;
663
+ }
664
+
665
+ // Set aria values for https://github.com/xgauravyaduvanshii/redxplyr/issues/905
666
+ if (matches(range, this.config.selectors.inputs.seek)) {
667
+ range.setAttribute('aria-valuenow', this.currentTime);
668
+ const currentTime = controls.formatTime(this.currentTime);
669
+ const duration = controls.formatTime(this.duration);
670
+ const format = i18n.get('seekLabel', this.config);
671
+ range.setAttribute(
672
+ 'aria-valuetext',
673
+ format.replace('{currentTime}', currentTime).replace('{duration}', duration),
674
+ );
675
+ }
676
+ else if (matches(range, this.config.selectors.inputs.volume)) {
677
+ const percent = range.value * 100;
678
+ range.setAttribute('aria-valuenow', percent);
679
+ range.setAttribute('aria-valuetext', `${percent.toFixed(1)}%`);
680
+ }
681
+ else {
682
+ range.setAttribute('aria-valuenow', range.value);
683
+ }
684
+
685
+ // WebKit only
686
+ if (!browser.isWebKit && !browser.isIPadOS) {
687
+ return;
688
+ }
689
+
690
+ // Set CSS custom property
691
+ range.style.setProperty('--value', `${(range.value / range.max) * 100}%`);
692
+ },
693
+
694
+ // Update hover tooltip for seeking
695
+ updateSeekTooltip(event) {
696
+ // Bail if setting not true
697
+ if (
698
+ !this.config.tooltips.seek
699
+ || !is.element(this.elements.inputs.seek)
700
+ || !is.element(this.elements.display.seekTooltip)
701
+ || this.duration === 0
702
+ ) {
703
+ return;
704
+ }
705
+
706
+ const tipElement = this.elements.display.seekTooltip;
707
+ const visible = `${this.config.classNames.tooltip}--visible`;
708
+ const toggle = show => toggleClass(tipElement, visible, show);
709
+
710
+ // Hide on touch
711
+ if (this.touch) {
712
+ toggle(false);
713
+ return;
714
+ }
715
+
716
+ // Determine percentage, if already visible
717
+ let percent = 0;
718
+ const clientRect = this.elements.progress.getBoundingClientRect();
719
+
720
+ if (is.event(event)) {
721
+ const scrollLeft = event.pageX - event.clientX;
722
+ percent = (100 / clientRect.width) * (event.pageX - clientRect.left - scrollLeft);
723
+ }
724
+ else if (hasClass(tipElement, visible)) {
725
+ percent = Number.parseFloat(tipElement.style.left, 10);
726
+ }
727
+ else {
728
+ return;
729
+ }
730
+
731
+ // Set bounds
732
+ if (percent < 0) {
733
+ percent = 0;
734
+ }
735
+ else if (percent > 100) {
736
+ percent = 100;
737
+ }
738
+
739
+ const time = (this.duration / 100) * percent;
740
+
741
+ // Display the time a click would seek to
742
+ tipElement.textContent = controls.formatTime(time);
743
+
744
+ // Get marker point for time
745
+ const point = this.config.markers?.points?.find(({ time: t }) => t === Math.round(time));
746
+
747
+ // Append the point label to the tooltip
748
+ if (point) {
749
+ tipElement.insertAdjacentHTML('afterbegin', `${point.label}<br>`);
750
+ }
751
+
752
+ // Set position
753
+ tipElement.style.left = `${percent}%`;
754
+
755
+ // Show/hide the tooltip
756
+ // If the event is a moues in/out and percentage is inside bounds
757
+ if (is.event(event) && ['mouseenter', 'mouseleave'].includes(event.type)) {
758
+ toggle(event.type === 'mouseenter');
759
+ }
760
+ },
761
+
762
+ // Handle time change event
763
+ timeUpdate(event) {
764
+ // Only invert if only one time element is displayed and used for both duration and currentTime
765
+ const invert = !is.element(this.elements.display.duration) && this.config.invertTime;
766
+
767
+ // Duration
768
+ controls.updateTimeDisplay.call(
769
+ this,
770
+ this.elements.display.currentTime,
771
+ invert ? this.duration - this.currentTime : this.currentTime,
772
+ invert,
773
+ );
774
+
775
+ // Ignore updates while seeking
776
+ if (event && event.type === 'timeupdate' && this.media.seeking) {
777
+ return;
778
+ }
779
+
780
+ // Playing progress
781
+ controls.updateProgress.call(this, event);
782
+ },
783
+
784
+ // Show the duration on metadataloaded or durationchange events
785
+ durationUpdate() {
786
+ // Bail if no UI or durationchange event triggered after playing/seek when invertTime is false
787
+ if (!this.supported.ui || (!this.config.invertTime && this.currentTime)) {
788
+ return;
789
+ }
790
+
791
+ // If duration is the 2**32 (shaka), Infinity (HLS), DASH-IF (Number.MAX_SAFE_INTEGER || Number.MAX_VALUE) indicating live we hide the currentTime and progressbar.
792
+ // https://github.com/video-dev/hls.js/blob/5820d29d3c4c8a46e8b75f1e3afa3e68c1a9a2db/src/controller/buffer-controller.js#L415
793
+ // https://github.com/google/shaka-player/blob/4d889054631f4e1cf0fbd80ddd2b71887c02e232/lib/media/streaming_engine.js#L1062
794
+ // https://github.com/Dash-Industry-Forum/dash.js/blob/69859f51b969645b234666800d4cb596d89c602d/src/dash/models/DashManifestModel.js#L338
795
+ if (this.duration >= 2 ** 32) {
796
+ toggleHidden(this.elements.display.currentTime, true);
797
+ toggleHidden(this.elements.progress, true);
798
+ return;
799
+ }
800
+
801
+ // Update ARIA values
802
+ if (is.element(this.elements.inputs.seek)) {
803
+ this.elements.inputs.seek.setAttribute('aria-valuemax', this.duration);
804
+ }
805
+
806
+ // If there's a spot to display duration
807
+ const hasDuration = is.element(this.elements.display.duration);
808
+
809
+ // If there's only one time display, display duration there
810
+ if (!hasDuration && this.config.displayDuration && this.paused) {
811
+ controls.updateTimeDisplay.call(this, this.elements.display.currentTime, this.duration);
812
+ }
813
+
814
+ // If there's a duration element, update content
815
+ if (hasDuration) {
816
+ controls.updateTimeDisplay.call(this, this.elements.display.duration, this.duration);
817
+ }
818
+
819
+ if (this.config.markers.enabled) {
820
+ controls.setMarkers.call(this);
821
+ }
822
+
823
+ // Update the tooltip (if visible)
824
+ controls.updateSeekTooltip.call(this);
825
+ },
826
+
827
+ // Hide/show a tab
828
+ toggleMenuButton(setting, toggle) {
829
+ toggleHidden(this.elements.settings.buttons[setting], !toggle);
830
+ },
831
+
832
+ // Update the selected setting
833
+ updateSetting(setting, container, input) {
834
+ const pane = this.elements.settings.panels[setting];
835
+ let value = null;
836
+ let list = container;
837
+
838
+ if (setting === 'captions') {
839
+ value = this.currentTrack;
840
+ }
841
+ else {
842
+ value = !is.empty(input) ? input : this[setting];
843
+
844
+ // Get default
845
+ if (is.empty(value)) {
846
+ value = this.config[setting].default;
847
+ }
848
+
849
+ // Unsupported value
850
+ if (!is.empty(this.options[setting]) && !this.options[setting].includes(value)) {
851
+ this.debug.warn(`Unsupported value of '${value}' for ${setting}`);
852
+ return;
853
+ }
854
+
855
+ // Disabled value
856
+ if (!this.config[setting].options.includes(value)) {
857
+ this.debug.warn(`Disabled value of '${value}' for ${setting}`);
858
+ return;
859
+ }
860
+ }
861
+
862
+ // Get the list if we need to
863
+ if (!is.element(list)) {
864
+ list = pane && pane.querySelector('[role="menu"]');
865
+ }
866
+
867
+ // If there's no list it means it's not been rendered...
868
+ if (!is.element(list)) {
869
+ return;
870
+ }
871
+
872
+ // Update the label
873
+ const label = this.elements.settings.buttons[setting].querySelector(`.${this.config.classNames.menu.value}`);
874
+ label.innerHTML = controls.getLabel.call(this, setting, value);
875
+
876
+ // Find the radio option and check it
877
+ const target = list && list.querySelector(`[value="${value}"]`);
878
+
879
+ if (is.element(target)) {
880
+ target.checked = true;
881
+ }
882
+ },
883
+
884
+ // Translate a value into a nice label
885
+ getLabel(setting, value) {
886
+ switch (setting) {
887
+ case 'speed':
888
+ return value === 1 ? i18n.get('normal', this.config) : `${value}&times;`;
889
+
890
+ case 'quality':
891
+ if (is.number(value)) {
892
+ const label = i18n.get(`qualityLabel.${value}`, this.config);
893
+
894
+ if (!label.length) {
895
+ return `${value}p`;
896
+ }
897
+
898
+ return label;
899
+ }
900
+
901
+ return toTitleCase(value);
902
+
903
+ case 'captions':
904
+ return captions.getLabel.call(this);
905
+
906
+ default:
907
+ return null;
908
+ }
909
+ },
910
+
911
+ // Set the quality menu
912
+ setQualityMenu(options) {
913
+ // Menu required
914
+ if (!is.element(this.elements.settings.panels.quality)) {
915
+ return;
916
+ }
917
+
918
+ const type = 'quality';
919
+ const list = this.elements.settings.panels.quality.querySelector('[role="menu"]');
920
+
921
+ // Set options if passed and filter based on uniqueness and config
922
+ if (is.array(options)) {
923
+ this.options.quality = dedupe(options).filter(quality => this.config.quality.options.includes(quality));
924
+ }
925
+
926
+ // Toggle the pane and tab
927
+ const toggle = !is.empty(this.options.quality) && this.options.quality.length > 1;
928
+ controls.toggleMenuButton.call(this, type, toggle);
929
+
930
+ // Empty the menu
931
+ emptyElement(list);
932
+
933
+ // Check if we need to toggle the parent
934
+ controls.checkMenu.call(this);
935
+
936
+ // If we're hiding, nothing more to do
937
+ if (!toggle) {
938
+ return;
939
+ }
940
+
941
+ // Get the badge HTML for HD, 4K etc
942
+ const getBadge = (quality) => {
943
+ const label = i18n.get(`qualityBadge.${quality}`, this.config);
944
+
945
+ if (!label.length) {
946
+ return null;
947
+ }
948
+
949
+ return controls.createBadge.call(this, label);
950
+ };
951
+
952
+ // Sort options by the config and then render options
953
+ this.options.quality
954
+ .sort((a, b) => {
955
+ const sorting = this.config.quality.options;
956
+ return sorting.indexOf(a) > sorting.indexOf(b) ? 1 : -1;
957
+ })
958
+ .forEach((quality) => {
959
+ controls.createMenuItem.call(this, {
960
+ value: quality,
961
+ list,
962
+ type,
963
+ title: controls.getLabel.call(this, 'quality', quality),
964
+ badge: getBadge(quality),
965
+ });
966
+ });
967
+
968
+ controls.updateSetting.call(this, type, list);
969
+ },
970
+
971
+ // Set the looping options
972
+ /* setLoopMenu() {
973
+ // Menu required
974
+ if (!is.element(this.elements.settings.panels.loop)) {
975
+ return;
976
+ }
977
+
978
+ const options = ['start', 'end', 'all', 'reset'];
979
+ const list = this.elements.settings.panels.loop.querySelector('[role="menu"]');
980
+
981
+ // Show the pane and tab
982
+ toggleHidden(this.elements.settings.buttons.loop, false);
983
+ toggleHidden(this.elements.settings.panels.loop, false);
984
+
985
+ // Toggle the pane and tab
986
+ const toggle = !is.empty(this.loop.options);
987
+ controls.toggleMenuButton.call(this, 'loop', toggle);
988
+
989
+ // Empty the menu
990
+ emptyElement(list);
991
+
992
+ options.forEach(option => {
993
+ const item = createElement('li');
994
+
995
+ const button = createElement(
996
+ 'button',
997
+ extend(getAttributesFromSelector(this.config.selectors.buttons.loop), {
998
+ type: 'button',
999
+ class: this.config.classNames.control,
1000
+ 'data-plyr-loop-action': option,
1001
+ }),
1002
+ i18n.get(option, this.config)
1003
+ );
1004
+
1005
+ if (['start', 'end'].includes(option)) {
1006
+ const badge = controls.createBadge.call(this, '00:00');
1007
+ button.appendChild(badge);
1008
+ }
1009
+
1010
+ item.appendChild(button);
1011
+ list.appendChild(item);
1012
+ });
1013
+ }, */
1014
+
1015
+ // Get current selected caption language
1016
+ // TODO: rework this to user the getter in the API?
1017
+
1018
+ // Set a list of available captions languages
1019
+ setCaptionsMenu() {
1020
+ // Menu required
1021
+ if (!is.element(this.elements.settings.panels.captions)) {
1022
+ return;
1023
+ }
1024
+
1025
+ // TODO: Captions or language? Currently it's mixed
1026
+ const type = 'captions';
1027
+ const list = this.elements.settings.panels.captions.querySelector('[role="menu"]');
1028
+ const tracks = captions.getTracks.call(this);
1029
+ const toggle = Boolean(tracks.length);
1030
+
1031
+ // Toggle the pane and tab
1032
+ controls.toggleMenuButton.call(this, type, toggle);
1033
+
1034
+ // Empty the menu
1035
+ emptyElement(list);
1036
+
1037
+ // Check if we need to toggle the parent
1038
+ controls.checkMenu.call(this);
1039
+
1040
+ // If there's no captions, bail
1041
+ if (!toggle) {
1042
+ return;
1043
+ }
1044
+
1045
+ // Generate options data
1046
+ const options = tracks.map((track, value) => ({
1047
+ value,
1048
+ checked: this.captions.toggled && this.currentTrack === value,
1049
+ title: captions.getLabel.call(this, track),
1050
+ badge: track.language && controls.createBadge.call(this, track.language.toUpperCase()),
1051
+ list,
1052
+ type: 'language',
1053
+ }));
1054
+
1055
+ // Add the "Disabled" option to turn off captions
1056
+ options.unshift({
1057
+ value: -1,
1058
+ checked: !this.captions.toggled,
1059
+ title: i18n.get('disabled', this.config),
1060
+ list,
1061
+ type: 'language',
1062
+ });
1063
+
1064
+ // Generate options
1065
+ options.forEach(controls.createMenuItem.bind(this));
1066
+
1067
+ controls.updateSetting.call(this, type, list);
1068
+ },
1069
+
1070
+ // Set a list of available captions languages
1071
+ setSpeedMenu() {
1072
+ // Menu required
1073
+ if (!is.element(this.elements.settings.panels.speed)) {
1074
+ return;
1075
+ }
1076
+
1077
+ const type = 'speed';
1078
+ const list = this.elements.settings.panels.speed.querySelector('[role="menu"]');
1079
+
1080
+ // Filter out invalid speeds
1081
+ this.options.speed = this.options.speed.filter(o => o >= this.minimumSpeed && o <= this.maximumSpeed);
1082
+
1083
+ // Toggle the pane and tab
1084
+ const toggle = !is.empty(this.options.speed) && this.options.speed.length > 1;
1085
+ controls.toggleMenuButton.call(this, type, toggle);
1086
+
1087
+ // Empty the menu
1088
+ emptyElement(list);
1089
+
1090
+ // Check if we need to toggle the parent
1091
+ controls.checkMenu.call(this);
1092
+
1093
+ // If we're hiding, nothing more to do
1094
+ if (!toggle) {
1095
+ return;
1096
+ }
1097
+
1098
+ // Create items
1099
+ this.options.speed.forEach((speed) => {
1100
+ controls.createMenuItem.call(this, {
1101
+ value: speed,
1102
+ list,
1103
+ type,
1104
+ title: controls.getLabel.call(this, 'speed', speed),
1105
+ });
1106
+ });
1107
+
1108
+ controls.updateSetting.call(this, type, list);
1109
+ },
1110
+
1111
+ // Check if we need to hide/show the settings menu
1112
+ checkMenu() {
1113
+ const { buttons } = this.elements.settings;
1114
+ const visible = !is.empty(buttons) && Object.values(buttons).some(button => !button.hidden);
1115
+
1116
+ toggleHidden(this.elements.settings.menu, !visible);
1117
+ },
1118
+
1119
+ // Focus the first menu item in a given (or visible) menu
1120
+ focusFirstMenuItem(pane, focusVisible = false) {
1121
+ if (this.elements.settings.popup.hidden) {
1122
+ return;
1123
+ }
1124
+
1125
+ let target = pane;
1126
+
1127
+ if (!is.element(target)) {
1128
+ target = Object.values(this.elements.settings.panels).find(p => !p.hidden);
1129
+ }
1130
+
1131
+ const firstItem = target.querySelector('[role^="menuitem"]');
1132
+
1133
+ setFocus.call(this, firstItem, focusVisible);
1134
+ },
1135
+
1136
+ // Show/hide menu
1137
+ toggleMenu(input) {
1138
+ const { popup } = this.elements.settings;
1139
+ const button = this.elements.buttons.settings;
1140
+
1141
+ // Menu and button are required
1142
+ if (!is.element(popup) || !is.element(button)) {
1143
+ return;
1144
+ }
1145
+
1146
+ // True toggle by default
1147
+ const { hidden } = popup;
1148
+ let show = hidden;
1149
+
1150
+ if (is.boolean(input)) {
1151
+ show = input;
1152
+ }
1153
+ else if (is.keyboardEvent(input) && input.key === 'Escape') {
1154
+ show = false;
1155
+ }
1156
+ else if (is.event(input)) {
1157
+ // If Plyr is in a shadowDOM, the event target is set to the component, instead of the
1158
+ // Element in the shadowDOM. The path, if available, is complete.
1159
+ const target = is.function(input.composedPath) ? input.composedPath()[0] : input.target;
1160
+ const isMenuItem = popup.contains(target);
1161
+
1162
+ // If the click was inside the menu or if the click
1163
+ // wasn't the button or menu item and we're trying to
1164
+ // show the menu (a doc click shouldn't show the menu)
1165
+ if (isMenuItem || (!isMenuItem && input.target !== button && show)) {
1166
+ return;
1167
+ }
1168
+ }
1169
+
1170
+ // Set button attributes
1171
+ button.setAttribute('aria-expanded', show);
1172
+
1173
+ // Show the actual popup
1174
+ toggleHidden(popup, !show);
1175
+
1176
+ // Add class hook
1177
+ toggleClass(this.elements.container, this.config.classNames.menu.open, show);
1178
+
1179
+ // Focus the first item if key interaction
1180
+ if (show && is.keyboardEvent(input)) {
1181
+ controls.focusFirstMenuItem.call(this, null, true);
1182
+ }
1183
+ else if (!show && !hidden) {
1184
+ // If closing, re-focus the button
1185
+ setFocus.call(this, button, is.keyboardEvent(input));
1186
+ }
1187
+ },
1188
+
1189
+ // Get the natural size of a menu panel
1190
+ getMenuSize(tab) {
1191
+ const clone = tab.cloneNode(true);
1192
+ clone.style.position = 'absolute';
1193
+ clone.style.opacity = 0;
1194
+ clone.removeAttribute('hidden');
1195
+
1196
+ // Append to parent so we get the "real" size
1197
+ tab.parentNode.appendChild(clone);
1198
+
1199
+ // Get the sizes before we remove
1200
+ const width = clone.scrollWidth;
1201
+ const height = clone.scrollHeight;
1202
+
1203
+ // Remove from the DOM
1204
+ removeElement(clone);
1205
+
1206
+ return {
1207
+ width,
1208
+ height,
1209
+ };
1210
+ },
1211
+
1212
+ // Show a panel in the menu
1213
+ showMenuPanel(type = '', focusVisible = false) {
1214
+ const target = this.elements.container.querySelector(`#plyr-settings-${this.id}-${type}`);
1215
+
1216
+ // Nothing to show, bail
1217
+ if (!is.element(target)) {
1218
+ return;
1219
+ }
1220
+
1221
+ // Hide all other panels
1222
+ const container = target.parentNode;
1223
+ const current = Array.from(container.children).find(node => !node.hidden);
1224
+
1225
+ // If we can do fancy animations, we'll animate the height/width
1226
+ if (support.transitions && !support.reducedMotion) {
1227
+ // Set the current width as a base
1228
+ container.style.width = `${current.scrollWidth}px`;
1229
+ container.style.height = `${current.scrollHeight}px`;
1230
+
1231
+ // Get potential sizes
1232
+ const size = controls.getMenuSize.call(this, target);
1233
+
1234
+ // Restore auto height/width
1235
+ const restore = (event) => {
1236
+ // We're only bothered about height and width on the container
1237
+ if (event.target !== container || !['width', 'height'].includes(event.propertyName)) {
1238
+ return;
1239
+ }
1240
+
1241
+ // Revert back to auto
1242
+ container.style.width = '';
1243
+ container.style.height = '';
1244
+
1245
+ // Only listen once
1246
+ off.call(this, container, transitionEndEvent, restore);
1247
+ };
1248
+
1249
+ // Listen for the transition finishing and restore auto height/width
1250
+ on.call(this, container, transitionEndEvent, restore);
1251
+
1252
+ // Set dimensions to target
1253
+ container.style.width = `${size.width}px`;
1254
+ container.style.height = `${size.height}px`;
1255
+ }
1256
+
1257
+ // Set attributes on current tab
1258
+ toggleHidden(current, true);
1259
+
1260
+ // Set attributes on target
1261
+ toggleHidden(target, false);
1262
+
1263
+ // Focus the first item
1264
+ controls.focusFirstMenuItem.call(this, target, focusVisible);
1265
+ },
1266
+
1267
+ // Set the download URL
1268
+ setDownloadUrl() {
1269
+ const button = this.elements.buttons.download;
1270
+
1271
+ // Bail if no button
1272
+ if (!is.element(button)) {
1273
+ return;
1274
+ }
1275
+
1276
+ // Set attribute
1277
+ button.setAttribute('href', this.download);
1278
+ },
1279
+
1280
+ // Build the default HTML
1281
+ create(data) {
1282
+ const {
1283
+ bindMenuItemShortcuts,
1284
+ createButton,
1285
+ createProgress,
1286
+ createRange,
1287
+ createTime,
1288
+ setQualityMenu,
1289
+ setSpeedMenu,
1290
+ showMenuPanel,
1291
+ } = controls;
1292
+ this.elements.controls = null;
1293
+
1294
+ // Larger overlaid play button
1295
+ if (is.array(this.config.controls) && this.config.controls.includes('play-large')) {
1296
+ this.elements.container.appendChild(createButton.call(this, 'play-large'));
1297
+ }
1298
+
1299
+ // Create the container
1300
+ const container = createElement('div', getAttributesFromSelector(this.config.selectors.controls.wrapper));
1301
+ this.elements.controls = container;
1302
+
1303
+ // Default item attributes
1304
+ const defaultAttributes = { class: 'plyr__controls__item' };
1305
+
1306
+ // Loop through controls in order
1307
+ dedupe(is.array(this.config.controls) ? this.config.controls : []).forEach((control) => {
1308
+ // Restart button
1309
+ if (control === 'restart') {
1310
+ container.appendChild(createButton.call(this, 'restart', defaultAttributes));
1311
+ }
1312
+
1313
+ // Rewind button
1314
+ if (control === 'rewind') {
1315
+ container.appendChild(createButton.call(this, 'rewind', defaultAttributes));
1316
+ }
1317
+
1318
+ // Play/Pause button
1319
+ if (control === 'play') {
1320
+ container.appendChild(createButton.call(this, 'play', defaultAttributes));
1321
+ }
1322
+
1323
+ // Fast forward button
1324
+ if (control === 'fast-forward') {
1325
+ container.appendChild(createButton.call(this, 'fast-forward', defaultAttributes));
1326
+ }
1327
+
1328
+ // Progress
1329
+ if (control === 'progress') {
1330
+ const progressContainer = createElement('div', {
1331
+ class: `${defaultAttributes.class} plyr__progress__container`,
1332
+ });
1333
+
1334
+ const progress = createElement('div', getAttributesFromSelector(this.config.selectors.progress));
1335
+
1336
+ // Seek range slider
1337
+ progress.appendChild(
1338
+ createRange.call(this, 'seek', {
1339
+ id: `plyr-seek-${data.id}`,
1340
+ }),
1341
+ );
1342
+
1343
+ // Buffer progress
1344
+ progress.appendChild(createProgress.call(this, 'buffer'));
1345
+
1346
+ // TODO: Add loop display indicator
1347
+
1348
+ // Seek tooltip
1349
+ if (this.config.tooltips.seek) {
1350
+ const tooltip = createElement(
1351
+ 'span',
1352
+ {
1353
+ class: this.config.classNames.tooltip,
1354
+ },
1355
+ '00:00',
1356
+ );
1357
+
1358
+ progress.appendChild(tooltip);
1359
+ this.elements.display.seekTooltip = tooltip;
1360
+ }
1361
+
1362
+ this.elements.progress = progress;
1363
+ progressContainer.appendChild(this.elements.progress);
1364
+ container.appendChild(progressContainer);
1365
+ }
1366
+
1367
+ // Media current time display
1368
+ if (control === 'current-time') {
1369
+ container.appendChild(createTime.call(this, 'currentTime', defaultAttributes));
1370
+ }
1371
+
1372
+ // Media duration display
1373
+ if (control === 'duration') {
1374
+ container.appendChild(createTime.call(this, 'duration', defaultAttributes));
1375
+ }
1376
+
1377
+ // Volume controls
1378
+ if (control === 'mute' || control === 'volume') {
1379
+ let { volume } = this.elements;
1380
+
1381
+ // Create the volume container if needed
1382
+ if (!is.element(volume) || !container.contains(volume)) {
1383
+ volume = createElement(
1384
+ 'div',
1385
+ extend({}, defaultAttributes, {
1386
+ class: `${defaultAttributes.class} plyr__volume`.trim(),
1387
+ }),
1388
+ );
1389
+
1390
+ this.elements.volume = volume;
1391
+
1392
+ container.appendChild(volume);
1393
+ }
1394
+
1395
+ // Toggle mute button
1396
+ if (control === 'mute') {
1397
+ volume.appendChild(createButton.call(this, 'mute'));
1398
+ }
1399
+
1400
+ // Volume range control
1401
+ // Ignored on iOS as it's handled globally
1402
+ // https://developer.apple.com/library/safari/documentation/AudioVideo/Conceptual/Using_HTML5_Audio_Video/Device-SpecificConsiderations/Device-SpecificConsiderations.html
1403
+ if (control === 'volume' && !browser.isIos && !browser.isIPadOS) {
1404
+ // Set the attributes
1405
+ const attributes = {
1406
+ max: 1,
1407
+ step: 0.05,
1408
+ value: this.config.volume,
1409
+ };
1410
+
1411
+ // Create the volume range slider
1412
+ volume.appendChild(
1413
+ createRange.call(
1414
+ this,
1415
+ 'volume',
1416
+ extend(attributes, {
1417
+ id: `plyr-volume-${data.id}`,
1418
+ }),
1419
+ ),
1420
+ );
1421
+ }
1422
+ }
1423
+
1424
+ // Toggle captions button
1425
+ if (control === 'captions') {
1426
+ container.appendChild(createButton.call(this, 'captions', defaultAttributes));
1427
+ }
1428
+
1429
+ // Settings button / menu
1430
+ if (control === 'settings' && !is.empty(this.config.settings)) {
1431
+ const wrapper = createElement(
1432
+ 'div',
1433
+ extend({}, defaultAttributes, {
1434
+ class: `${defaultAttributes.class} plyr__menu`.trim(),
1435
+ hidden: '',
1436
+ }),
1437
+ );
1438
+
1439
+ wrapper.appendChild(
1440
+ createButton.call(this, 'settings', {
1441
+ 'aria-haspopup': true,
1442
+ 'aria-controls': `plyr-settings-${data.id}`,
1443
+ 'aria-expanded': false,
1444
+ }),
1445
+ );
1446
+
1447
+ const popup = createElement('div', {
1448
+ class: 'plyr__menu__container',
1449
+ id: `plyr-settings-${data.id}`,
1450
+ hidden: '',
1451
+ });
1452
+
1453
+ const inner = createElement('div');
1454
+
1455
+ const home = createElement('div', {
1456
+ id: `plyr-settings-${data.id}-home`,
1457
+ });
1458
+
1459
+ // Create the menu
1460
+ const menu = createElement('div', {
1461
+ role: 'menu',
1462
+ });
1463
+
1464
+ home.appendChild(menu);
1465
+ inner.appendChild(home);
1466
+ this.elements.settings.panels.home = home;
1467
+
1468
+ // Build the menu items
1469
+ this.config.settings.forEach((type) => {
1470
+ // TODO: bundle this with the createMenuItem helper and bindings
1471
+ const menuItem = createElement(
1472
+ 'button',
1473
+ extend(getAttributesFromSelector(this.config.selectors.buttons.settings), {
1474
+ 'type': 'button',
1475
+ 'class': `${this.config.classNames.control} ${this.config.classNames.control}--forward`,
1476
+ 'role': 'menuitem',
1477
+ 'aria-haspopup': true,
1478
+ 'hidden': '',
1479
+ }),
1480
+ );
1481
+
1482
+ // Bind menu shortcuts for keyboard users
1483
+ bindMenuItemShortcuts.call(this, menuItem, type);
1484
+
1485
+ // Show menu on click
1486
+ on.call(this, menuItem, 'click', () => {
1487
+ showMenuPanel.call(this, type, false);
1488
+ });
1489
+
1490
+ const flex = createElement('span', null, i18n.get(type, this.config));
1491
+
1492
+ const value = createElement('span', {
1493
+ class: this.config.classNames.menu.value,
1494
+ });
1495
+
1496
+ // Speed contains HTML entities
1497
+ value.innerHTML = data[type];
1498
+
1499
+ flex.appendChild(value);
1500
+ menuItem.appendChild(flex);
1501
+ menu.appendChild(menuItem);
1502
+
1503
+ // Build the panes
1504
+ const pane = createElement('div', {
1505
+ id: `plyr-settings-${data.id}-${type}`,
1506
+ hidden: '',
1507
+ });
1508
+
1509
+ // Back button
1510
+ const backButton = createElement('button', {
1511
+ type: 'button',
1512
+ class: `${this.config.classNames.control} ${this.config.classNames.control}--back`,
1513
+ });
1514
+
1515
+ // Visible label
1516
+ backButton.appendChild(
1517
+ createElement(
1518
+ 'span',
1519
+ {
1520
+ 'aria-hidden': true,
1521
+ },
1522
+ i18n.get(type, this.config),
1523
+ ),
1524
+ );
1525
+
1526
+ // Screen reader label
1527
+ backButton.appendChild(
1528
+ createElement(
1529
+ 'span',
1530
+ {
1531
+ class: this.config.classNames.hidden,
1532
+ },
1533
+ i18n.get('menuBack', this.config),
1534
+ ),
1535
+ );
1536
+
1537
+ // Go back via keyboard
1538
+ on.call(
1539
+ this,
1540
+ pane,
1541
+ 'keydown',
1542
+ (event) => {
1543
+ if (event.key !== 'ArrowLeft') return;
1544
+
1545
+ // Prevent seek
1546
+ event.preventDefault();
1547
+ event.stopPropagation();
1548
+
1549
+ // Show the respective menu
1550
+ showMenuPanel.call(this, 'home', true);
1551
+ },
1552
+ false,
1553
+ );
1554
+
1555
+ // Go back via button click
1556
+ on.call(this, backButton, 'click', () => {
1557
+ showMenuPanel.call(this, 'home', false);
1558
+ });
1559
+
1560
+ // Add to pane
1561
+ pane.appendChild(backButton);
1562
+
1563
+ // Menu
1564
+ pane.appendChild(
1565
+ createElement('div', {
1566
+ role: 'menu',
1567
+ }),
1568
+ );
1569
+
1570
+ inner.appendChild(pane);
1571
+
1572
+ this.elements.settings.buttons[type] = menuItem;
1573
+ this.elements.settings.panels[type] = pane;
1574
+ });
1575
+
1576
+ popup.appendChild(inner);
1577
+ wrapper.appendChild(popup);
1578
+ container.appendChild(wrapper);
1579
+
1580
+ this.elements.settings.popup = popup;
1581
+ this.elements.settings.menu = wrapper;
1582
+ }
1583
+
1584
+ // Picture in picture button
1585
+ if (control === 'pip' && support.pip) {
1586
+ container.appendChild(createButton.call(this, 'pip', defaultAttributes));
1587
+ }
1588
+
1589
+ // Airplay button
1590
+ if (control === 'airplay' && support.airplay) {
1591
+ container.appendChild(createButton.call(this, 'airplay', defaultAttributes));
1592
+ }
1593
+
1594
+ // Download button
1595
+ if (control === 'download') {
1596
+ const attributes = extend({}, defaultAttributes, {
1597
+ element: 'a',
1598
+ href: this.download,
1599
+ target: '_blank',
1600
+ });
1601
+
1602
+ // Set download attribute for HTML5 only
1603
+ if (this.isHTML5) {
1604
+ attributes.download = '';
1605
+ }
1606
+
1607
+ const { download } = this.config.urls;
1608
+
1609
+ if (!is.url(download) && this.isEmbed) {
1610
+ extend(attributes, {
1611
+ icon: `logo-${this.provider}`,
1612
+ label: this.provider,
1613
+ });
1614
+ }
1615
+
1616
+ container.appendChild(createButton.call(this, 'download', attributes));
1617
+ }
1618
+
1619
+ // Toggle fullscreen button
1620
+ if (control === 'fullscreen') {
1621
+ container.appendChild(createButton.call(this, 'fullscreen', defaultAttributes));
1622
+ }
1623
+ });
1624
+
1625
+ // Set available quality levels
1626
+ if (this.isHTML5) {
1627
+ setQualityMenu.call(this, html5.getQualityOptions.call(this));
1628
+ }
1629
+
1630
+ setSpeedMenu.call(this);
1631
+
1632
+ return container;
1633
+ },
1634
+
1635
+ // Insert controls
1636
+ inject() {
1637
+ // Sprite
1638
+ if (this.config.loadSprite) {
1639
+ const icon = controls.getIconUrl.call(this);
1640
+
1641
+ // Only load external sprite using AJAX
1642
+ if (icon.cors) {
1643
+ loadSprite(icon.url, 'sprite-plyr');
1644
+ }
1645
+ }
1646
+
1647
+ // Create a unique ID
1648
+ this.id = Math.floor(Math.random() * 10000);
1649
+
1650
+ // Null by default
1651
+ let container = null;
1652
+ this.elements.controls = null;
1653
+
1654
+ // Set template properties
1655
+ const props = {
1656
+ id: this.id,
1657
+ seektime: this.config.seekTime,
1658
+ title: this.config.title,
1659
+ };
1660
+ let update = true;
1661
+
1662
+ // If function, run it and use output
1663
+ if (is.function(this.config.controls)) {
1664
+ this.config.controls = this.config.controls.call(this, props);
1665
+ }
1666
+
1667
+ // Convert falsy controls to empty array (primarily for empty strings)
1668
+ if (!this.config.controls) {
1669
+ this.config.controls = [];
1670
+ }
1671
+
1672
+ if (is.element(this.config.controls) || is.string(this.config.controls)) {
1673
+ // HTMLElement or Non-empty string passed as the option
1674
+ container = this.config.controls;
1675
+ }
1676
+ else {
1677
+ // Create controls
1678
+ container = controls.create.call(this, {
1679
+ id: this.id,
1680
+ seektime: this.config.seekTime,
1681
+ speed: this.speed,
1682
+ quality: this.quality,
1683
+ captions: captions.getLabel.call(this),
1684
+ // TODO: Looping
1685
+ // loop: 'None',
1686
+ });
1687
+ update = false;
1688
+ }
1689
+
1690
+ // Replace props with their value
1691
+ const replace = (input) => {
1692
+ let result = input;
1693
+
1694
+ Object.entries(props).forEach(([key, value]) => {
1695
+ result = replaceAll(result, `{${key}}`, value);
1696
+ });
1697
+
1698
+ return result;
1699
+ };
1700
+
1701
+ // Update markup
1702
+ if (update) {
1703
+ if (is.string(this.config.controls)) {
1704
+ container = replace(container);
1705
+ }
1706
+ }
1707
+
1708
+ // Controls container
1709
+ let target;
1710
+
1711
+ // Inject to custom location
1712
+ if (is.string(this.config.selectors.controls.container)) {
1713
+ target = document.querySelector(this.config.selectors.controls.container);
1714
+ }
1715
+
1716
+ // Inject into the container by default
1717
+ if (!is.element(target)) {
1718
+ target = this.elements.container;
1719
+ }
1720
+
1721
+ // Inject controls HTML (needs to be before captions, hence "afterbegin")
1722
+ const insertMethod = is.element(container) ? 'insertAdjacentElement' : 'insertAdjacentHTML';
1723
+ target[insertMethod]('afterbegin', container);
1724
+
1725
+ // Find the elements if need be
1726
+ if (!is.element(this.elements.controls)) {
1727
+ controls.findElements.call(this);
1728
+ }
1729
+
1730
+ // Add pressed property to buttons
1731
+ if (!is.empty(this.elements.buttons)) {
1732
+ const addProperty = (button) => {
1733
+ const className = this.config.classNames.controlPressed;
1734
+ button.setAttribute('aria-pressed', 'false');
1735
+
1736
+ Object.defineProperty(button, 'pressed', {
1737
+ configurable: true,
1738
+ enumerable: true,
1739
+ get() {
1740
+ return hasClass(button, className);
1741
+ },
1742
+ set(pressed = false) {
1743
+ toggleClass(button, className, pressed);
1744
+ button.setAttribute('aria-pressed', pressed ? 'true' : 'false');
1745
+ },
1746
+ });
1747
+ };
1748
+
1749
+ // Toggle classname when pressed property is set
1750
+ Object.values(this.elements.buttons)
1751
+ .filter(Boolean)
1752
+ .forEach((button) => {
1753
+ if (is.array(button) || is.nodeList(button)) {
1754
+ Array.from(button).filter(Boolean).forEach(addProperty);
1755
+ }
1756
+ else {
1757
+ addProperty(button);
1758
+ }
1759
+ });
1760
+ }
1761
+
1762
+ // Edge sometimes doesn't finish the paint so force a repaint
1763
+ if (browser.isEdge) {
1764
+ repaint(target);
1765
+ }
1766
+
1767
+ // Setup tooltips
1768
+ if (this.config.tooltips.controls) {
1769
+ const { classNames, selectors } = this.config;
1770
+ const selector = `${selectors.controls.wrapper} ${selectors.labels} .${classNames.hidden}`;
1771
+ const labels = getElements.call(this, selector);
1772
+
1773
+ Array.from(labels).forEach((label) => {
1774
+ toggleClass(label, this.config.classNames.hidden, false);
1775
+ toggleClass(label, this.config.classNames.tooltip, true);
1776
+ });
1777
+ }
1778
+ },
1779
+
1780
+ // Set media metadata
1781
+ setMediaMetadata() {
1782
+ try {
1783
+ if ('mediaSession' in navigator) {
1784
+ navigator.mediaSession.metadata = new window.MediaMetadata({
1785
+ title: this.config.mediaMetadata.title,
1786
+ artist: this.config.mediaMetadata.artist,
1787
+ album: this.config.mediaMetadata.album,
1788
+ artwork: this.config.mediaMetadata.artwork,
1789
+ });
1790
+ }
1791
+ }
1792
+ catch {
1793
+ // Do nothing
1794
+ }
1795
+ },
1796
+
1797
+ // Add markers
1798
+ setMarkers() {
1799
+ if (!this.duration || this.elements.markers) return;
1800
+
1801
+ // Get valid points
1802
+ const points = this.config.markers?.points?.filter(({ time }) => time > 0 && time < this.duration);
1803
+ if (!points?.length) return;
1804
+
1805
+ const containerFragment = document.createDocumentFragment();
1806
+ const pointsFragment = document.createDocumentFragment();
1807
+ let tipElement = null;
1808
+ const tipVisible = `${this.config.classNames.tooltip}--visible`;
1809
+ const toggleTip = show => toggleClass(tipElement, tipVisible, show);
1810
+
1811
+ // Inject markers to progress container
1812
+ points.forEach((point) => {
1813
+ const markerElement = createElement(
1814
+ 'span',
1815
+ {
1816
+ class: this.config.classNames.marker,
1817
+ },
1818
+ '',
1819
+ );
1820
+
1821
+ const left = `${(point.time / this.duration) * 100}%`;
1822
+
1823
+ if (tipElement) {
1824
+ // Show on hover
1825
+ markerElement.addEventListener('mouseenter', () => {
1826
+ if (point.label) return;
1827
+ tipElement.style.left = left;
1828
+ tipElement.innerHTML = point.label;
1829
+ toggleTip(true);
1830
+ });
1831
+
1832
+ // Hide on leave
1833
+ markerElement.addEventListener('mouseleave', () => {
1834
+ toggleTip(false);
1835
+ });
1836
+ }
1837
+
1838
+ markerElement.addEventListener('click', () => {
1839
+ this.currentTime = point.time;
1840
+ });
1841
+
1842
+ markerElement.style.left = left;
1843
+ pointsFragment.appendChild(markerElement);
1844
+ });
1845
+
1846
+ containerFragment.appendChild(pointsFragment);
1847
+
1848
+ // Inject a tooltip if needed
1849
+ if (!this.config.tooltips.seek) {
1850
+ tipElement = createElement(
1851
+ 'span',
1852
+ {
1853
+ class: this.config.classNames.tooltip,
1854
+ },
1855
+ '',
1856
+ );
1857
+
1858
+ containerFragment.appendChild(tipElement);
1859
+ }
1860
+
1861
+ this.elements.markers = {
1862
+ points: pointsFragment,
1863
+ tip: tipElement,
1864
+ };
1865
+
1866
+ this.elements.progress.appendChild(containerFragment);
1867
+ },
1868
+ };
1869
+
1870
+ export default controls;