vidply 1.0.5 → 1.0.7

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.
@@ -1,2026 +1,2033 @@
1
- /**
2
- * Control Bar Component
3
- */
4
-
5
- import {DOMUtils} from '../utils/DOMUtils.js';
6
- import {TimeUtils} from '../utils/TimeUtils.js';
7
- import {createIconElement} from '../icons/Icons.js';
8
- import {i18n} from '../i18n/i18n.js';
9
-
10
- export class ControlBar {
11
- constructor(player) {
12
- this.player = player;
13
- this.element = null;
14
- this.controls = {};
15
- this.hideTimeout = null;
16
- this.isDraggingProgress = false;
17
- this.isDraggingVolume = false;
18
-
19
- this.init();
20
- }
21
-
22
- init() {
23
- this.createElement();
24
- this.createControls();
25
- this.attachEvents();
26
- this.setupAutoHide();
27
- }
28
-
29
- // Helper method to check if we're on a mobile device
30
- isMobile() {
31
- return window.innerWidth < 640;
32
- }
33
-
34
- // Smart menu positioning to avoid overflow
35
- positionMenu(menu, button) {
36
- const isMobile = this.isMobile();
37
-
38
- if (isMobile) {
39
- // Use bottom sheet on mobile - already styled via CSS
40
- return;
41
- }
42
-
43
- // Desktop: Smart positioning
44
- setTimeout(() => {
45
- const buttonRect = button.getBoundingClientRect();
46
- const menuRect = menu.getBoundingClientRect();
47
- const viewportWidth = window.innerWidth;
48
- const viewportHeight = window.innerHeight;
49
-
50
- const spaceAbove = buttonRect.top;
51
- const spaceBelow = viewportHeight - buttonRect.bottom;
52
-
53
- // Prefer above, but switch to below if not enough space
54
- if (spaceAbove < menuRect.height + 20 && spaceBelow > spaceAbove) {
55
- menu.style.bottom = 'auto';
56
- menu.style.top = 'calc(100% + 8px)';
57
- menu.classList.add('vidply-menu-below');
58
- }
59
-
60
- // Check horizontal overflow
61
- const menuLeft = buttonRect.left + buttonRect.width / 2 - menuRect.width / 2;
62
- if (menuLeft < 10) {
63
- // Too far left, align to left edge
64
- menu.style.right = 'auto';
65
- menu.style.left = '0';
66
- menu.style.transform = 'translateX(0)';
67
- } else if (menuLeft + menuRect.width > viewportWidth - 10) {
68
- // Too far right, align to right edge
69
- menu.style.left = 'auto';
70
- menu.style.right = '0';
71
- menu.style.transform = 'translateX(0)';
72
- }
73
- }, 0);
74
- }
75
-
76
-
77
- // Helper method to attach close-on-outside-click behavior to menus
78
- attachMenuCloseHandler(menu, button, preventCloseOnInteraction = false) {
79
- // Position menu smartly
80
- this.positionMenu(menu, button);
81
-
82
- setTimeout(() => {
83
- const closeMenu = (e) => {
84
- // If this menu has form controls, don't close when clicking inside
85
- if (preventCloseOnInteraction && menu.contains(e.target)) {
86
- return;
87
- }
88
-
89
- // Check if click is outside menu and button
90
- if (!menu.contains(e.target) && !button.contains(e.target)) {
91
- menu.remove();
92
- document.removeEventListener('click', closeMenu);
93
- document.removeEventListener('keydown', handleEscape);
94
- }
95
- };
96
-
97
- const handleEscape = (e) => {
98
- if (e.key === 'Escape') {
99
- menu.remove();
100
- document.removeEventListener('click', closeMenu);
101
- document.removeEventListener('keydown', handleEscape);
102
- // Return focus to button
103
- button.focus();
104
- }
105
- };
106
-
107
- document.addEventListener('click', closeMenu);
108
- document.addEventListener('keydown', handleEscape);
109
- }, 100);
110
- }
111
-
112
- // Helper method to add keyboard navigation to menus (arrow keys)
113
- attachMenuKeyboardNavigation(menu) {
114
- const menuItems = Array.from(menu.querySelectorAll(`.${this.player.options.classPrefix}-menu-item`));
115
-
116
- if (menuItems.length === 0) return;
117
-
118
- const handleKeyDown = (e) => {
119
- const currentIndex = menuItems.indexOf(document.activeElement);
120
-
121
- switch (e.key) {
122
- case 'ArrowDown':
123
- e.preventDefault();
124
- const nextIndex = (currentIndex + 1) % menuItems.length;
125
- menuItems[nextIndex].focus();
126
- break;
127
-
128
- case 'ArrowUp':
129
- e.preventDefault();
130
- const prevIndex = (currentIndex - 1 + menuItems.length) % menuItems.length;
131
- menuItems[prevIndex].focus();
132
- break;
133
-
134
- case 'Home':
135
- e.preventDefault();
136
- menuItems[0].focus();
137
- break;
138
-
139
- case 'End':
140
- e.preventDefault();
141
- menuItems[menuItems.length - 1].focus();
142
- break;
143
-
144
- case 'Enter':
145
- case ' ':
146
- e.preventDefault();
147
- if (document.activeElement && menuItems.includes(document.activeElement)) {
148
- document.activeElement.click();
149
- }
150
- break;
151
- }
152
- };
153
-
154
- menu.addEventListener('keydown', handleKeyDown);
155
- }
156
-
157
- createElement() {
158
- this.element = DOMUtils.createElement('div', {
159
- className: `${this.player.options.classPrefix}-controls`,
160
- attributes: {
161
- 'role': 'region',
162
- 'aria-label': i18n.t('player.label') + ' controls'
163
- }
164
- });
165
- }
166
-
167
- createControls() {
168
- // Progress bar container
169
- if (this.player.options.progressBar) {
170
- this.createProgressBar();
171
- }
172
-
173
- // Button container
174
- const buttonContainer = DOMUtils.createElement('div', {
175
- className: `${this.player.options.classPrefix}-controls-buttons`
176
- });
177
-
178
- // Left buttons
179
- const leftButtons = DOMUtils.createElement('div', {
180
- className: `${this.player.options.classPrefix}-controls-left`
181
- });
182
-
183
- // Previous track button (if playlist)
184
- if (this.player.playlistManager) {
185
- leftButtons.appendChild(this.createPreviousButton());
186
- }
187
-
188
- // Play/Pause button
189
- if (this.player.options.playPauseButton) {
190
- leftButtons.appendChild(this.createPlayPauseButton());
191
- }
192
-
193
- // Restart button (right beside play button)
194
- leftButtons.appendChild(this.createRestartButton());
195
-
196
- // Next track button (if playlist)
197
- if (this.player.playlistManager) {
198
- leftButtons.appendChild(this.createNextButton());
199
- }
200
-
201
- // Rewind button (not shown in playlist mode)
202
- if (!this.player.playlistManager) {
203
- leftButtons.appendChild(this.createRewindButton());
204
- }
205
-
206
- // Forward button (not shown in playlist mode)
207
- if (!this.player.playlistManager) {
208
- leftButtons.appendChild(this.createForwardButton());
209
- }
210
-
211
- // Volume control
212
- if (this.player.options.volumeControl) {
213
- leftButtons.appendChild(this.createVolumeControl());
214
- }
215
-
216
- // Time display
217
- if (this.player.options.currentTime || this.player.options.duration) {
218
- leftButtons.appendChild(this.createTimeDisplay());
219
- }
220
-
221
- // Right buttons
222
- this.rightButtons = DOMUtils.createElement('div', {
223
- className: `${this.player.options.classPrefix}-controls-right`
224
- });
225
-
226
- // Check for available features
227
- const hasChapters = this.hasChapterTracks();
228
- const hasCaptions = this.hasCaptionTracks();
229
- const hasQualityLevels = this.hasQualityLevels();
230
- const hasAudioDescription = this.hasAudioDescription();
231
-
232
- // Chapters button - only show if chapters are available
233
- if (this.player.options.chaptersButton && hasChapters) {
234
- this.rightButtons.appendChild(this.createChaptersButton());
235
- }
236
-
237
- // Quality button - only show if quality levels are available
238
- if (this.player.options.qualityButton && hasQualityLevels) {
239
- this.rightButtons.appendChild(this.createQualityButton());
240
- }
241
-
242
- // Caption styling button (font, size, color) - only show if captions are available
243
- if (this.player.options.captionStyleButton && hasCaptions) {
244
- this.rightButtons.appendChild(this.createCaptionStyleButton());
245
- }
246
-
247
- // Speed button - always available
248
- if (this.player.options.speedButton) {
249
- this.rightButtons.appendChild(this.createSpeedButton());
250
- }
251
-
252
- // Captions language selector button - only show if captions are available
253
- if (this.player.options.captionsButton && hasCaptions) {
254
- this.rightButtons.appendChild(this.createCaptionsButton());
255
- }
256
-
257
- // Transcript button - only show if captions/subtitles are available
258
- if (this.player.options.transcriptButton && hasCaptions) {
259
- this.rightButtons.appendChild(this.createTranscriptButton());
260
- }
261
-
262
- // Audio Description button - only show if audio description source is available
263
- if (this.player.options.audioDescriptionButton && hasAudioDescription) {
264
- this.rightButtons.appendChild(this.createAudioDescriptionButton());
265
- }
266
-
267
- // Sign Language button - only show if sign language source is available
268
- const hasSignLanguage = this.hasSignLanguage();
269
- if (this.player.options.signLanguageButton && hasSignLanguage) {
270
- this.rightButtons.appendChild(this.createSignLanguageButton());
271
- }
272
-
273
- // PiP button
274
- if (this.player.options.pipButton && 'pictureInPictureEnabled' in document) {
275
- this.rightButtons.appendChild(this.createPipButton());
276
- }
277
-
278
- // Fullscreen button
279
- if (this.player.options.fullscreenButton) {
280
- this.rightButtons.appendChild(this.createFullscreenButton());
281
- }
282
-
283
- buttonContainer.appendChild(leftButtons);
284
- buttonContainer.appendChild(this.rightButtons);
285
- this.element.appendChild(buttonContainer);
286
- }
287
-
288
- // Helper methods to check for available features
289
- hasChapterTracks() {
290
- const textTracks = this.player.element.textTracks;
291
- for (let i = 0; i < textTracks.length; i++) {
292
- if (textTracks[i].kind === 'chapters') {
293
- return true;
294
- }
295
- }
296
- return false;
297
- }
298
-
299
- hasCaptionTracks() {
300
- const textTracks = this.player.element.textTracks;
301
- for (let i = 0; i < textTracks.length; i++) {
302
- if (textTracks[i].kind === 'captions' || textTracks[i].kind === 'subtitles') {
303
- return true;
304
- }
305
- }
306
- return false;
307
- }
308
-
309
- hasQualityLevels() {
310
- // Check if renderer supports quality selection
311
- if (this.player.renderer && this.player.renderer.getQualities) {
312
- const qualities = this.player.renderer.getQualities();
313
- return qualities && qualities.length > 1;
314
- }
315
- return false;
316
- }
317
-
318
- hasAudioDescription() {
319
- return this.player.audioDescriptionSrc && this.player.audioDescriptionSrc.length > 0;
320
- }
321
-
322
- hasSignLanguage() {
323
- return this.player.signLanguageSrc && this.player.signLanguageSrc.length > 0;
324
- }
325
-
326
- createProgressBar() {
327
- const progressContainer = DOMUtils.createElement('div', {
328
- className: `${this.player.options.classPrefix}-progress-container`,
329
- attributes: {
330
- 'role': 'slider',
331
- 'aria-label': i18n.t('player.progress'),
332
- 'aria-valuemin': '0',
333
- 'aria-valuemax': '100',
334
- 'aria-valuenow': '0',
335
- 'tabindex': '0'
336
- }
337
- });
338
-
339
- // Buffered progress
340
- this.controls.buffered = DOMUtils.createElement('div', {
341
- className: `${this.player.options.classPrefix}-progress-buffered`
342
- });
343
-
344
- // Played progress
345
- this.controls.played = DOMUtils.createElement('div', {
346
- className: `${this.player.options.classPrefix}-progress-played`
347
- });
348
-
349
- // Progress handle
350
- this.controls.progressHandle = DOMUtils.createElement('div', {
351
- className: `${this.player.options.classPrefix}-progress-handle`
352
- });
353
-
354
- // Tooltip
355
- this.controls.progressTooltip = DOMUtils.createElement('div', {
356
- className: `${this.player.options.classPrefix}-progress-tooltip`
357
- });
358
-
359
- progressContainer.appendChild(this.controls.buffered);
360
- progressContainer.appendChild(this.controls.played);
361
- this.controls.played.appendChild(this.controls.progressHandle);
362
- progressContainer.appendChild(this.controls.progressTooltip);
363
-
364
- this.controls.progress = progressContainer;
365
- this.element.appendChild(progressContainer);
366
-
367
- // Progress bar events
368
- this.setupProgressBarEvents();
369
- }
370
-
371
- setupProgressBarEvents() {
372
- const progress = this.controls.progress;
373
-
374
- const updateProgress = (clientX) => {
375
- const rect = progress.getBoundingClientRect();
376
- const percent = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
377
- const time = percent * this.player.state.duration;
378
- return {percent, time};
379
- };
380
-
381
- // Mouse events
382
- progress.addEventListener('mousedown', (e) => {
383
- this.isDraggingProgress = true;
384
- const {time} = updateProgress(e.clientX);
385
- this.player.seek(time);
386
- });
387
-
388
- document.addEventListener('mousemove', (e) => {
389
- if (this.isDraggingProgress) {
390
- const {time} = updateProgress(e.clientX);
391
- this.player.seek(time);
392
- }
393
- });
394
-
395
- document.addEventListener('mouseup', () => {
396
- this.isDraggingProgress = false;
397
- });
398
-
399
- // Hover tooltip
400
- progress.addEventListener('mousemove', (e) => {
401
- if (!this.isDraggingProgress) {
402
- const {time} = updateProgress(e.clientX);
403
- this.controls.progressTooltip.textContent = TimeUtils.formatTime(time);
404
- this.controls.progressTooltip.style.left = `${e.clientX - progress.getBoundingClientRect().left}px`;
405
- this.controls.progressTooltip.style.display = 'block';
406
- }
407
- });
408
-
409
- progress.addEventListener('mouseleave', () => {
410
- this.controls.progressTooltip.style.display = 'none';
411
- });
412
-
413
- // Keyboard navigation
414
- progress.addEventListener('keydown', (e) => {
415
- if (e.key === 'ArrowLeft') {
416
- e.preventDefault();
417
- this.player.seekBackward(5);
418
- } else if (e.key === 'ArrowRight') {
419
- e.preventDefault();
420
- this.player.seekForward(5);
421
- }
422
- });
423
-
424
- // Touch events
425
- progress.addEventListener('touchstart', (e) => {
426
- this.isDraggingProgress = true;
427
- const touch = e.touches[0];
428
- const {time} = updateProgress(touch.clientX);
429
- this.player.seek(time);
430
- });
431
-
432
- progress.addEventListener('touchmove', (e) => {
433
- if (this.isDraggingProgress) {
434
- e.preventDefault();
435
- const touch = e.touches[0];
436
- const {time} = updateProgress(touch.clientX);
437
- this.player.seek(time);
438
- }
439
- });
440
-
441
- progress.addEventListener('touchend', () => {
442
- this.isDraggingProgress = false;
443
- });
444
- }
445
-
446
- createPlayPauseButton() {
447
- const button = DOMUtils.createElement('button', {
448
- className: `${this.player.options.classPrefix}-button ${this.player.options.classPrefix}-play-pause`,
449
- attributes: {
450
- 'type': 'button',
451
- 'aria-label': i18n.t('player.play')
452
- }
453
- });
454
-
455
- button.appendChild(createIconElement('play'));
456
-
457
- button.addEventListener('click', () => {
458
- this.player.toggle();
459
- });
460
-
461
- this.controls.playPause = button;
462
- return button;
463
- }
464
-
465
- createRestartButton() {
466
- const button = DOMUtils.createElement('button', {
467
- className: `${this.player.options.classPrefix}-button ${this.player.options.classPrefix}-restart`,
468
- attributes: {
469
- 'type': 'button',
470
- 'aria-label': i18n.t('player.restart')
471
- }
472
- });
473
-
474
- button.appendChild(createIconElement('restart'));
475
-
476
- button.addEventListener('click', () => {
477
- this.player.seek(0);
478
- this.player.play();
479
- });
480
-
481
- return button;
482
- }
483
-
484
- createPreviousButton() {
485
- const button = DOMUtils.createElement('button', {
486
- className: `${this.player.options.classPrefix}-button ${this.player.options.classPrefix}-previous`,
487
- attributes: {
488
- 'type': 'button',
489
- 'aria-label': i18n.t('player.previous')
490
- }
491
- });
492
-
493
- button.appendChild(createIconElement('skipPrevious'));
494
-
495
- button.addEventListener('click', () => {
496
- if (this.player.playlistManager) {
497
- this.player.playlistManager.previous();
498
- }
499
- });
500
-
501
- // Update button state
502
- const updateState = () => {
503
- if (this.player.playlistManager) {
504
- button.disabled = !this.player.playlistManager.hasPrevious() && !this.player.playlistManager.options.loop;
505
- }
506
- };
507
- this.player.on('playlisttrackchange', updateState);
508
- updateState();
509
-
510
- this.controls.previous = button;
511
- return button;
512
- }
513
-
514
- createNextButton() {
515
- const button = DOMUtils.createElement('button', {
516
- className: `${this.player.options.classPrefix}-button ${this.player.options.classPrefix}-next`,
517
- attributes: {
518
- 'type': 'button',
519
- 'aria-label': i18n.t('player.next')
520
- }
521
- });
522
-
523
- button.appendChild(createIconElement('skipNext'));
524
-
525
- button.addEventListener('click', () => {
526
- if (this.player.playlistManager) {
527
- this.player.playlistManager.next();
528
- }
529
- });
530
-
531
- // Update button state
532
- const updateState = () => {
533
- if (this.player.playlistManager) {
534
- button.disabled = !this.player.playlistManager.hasNext() && !this.player.playlistManager.options.loop;
535
- }
536
- };
537
- this.player.on('playlisttrackchange', updateState);
538
- updateState();
539
-
540
- this.controls.next = button;
541
- return button;
542
- }
543
-
544
- createRewindButton() {
545
- const button = DOMUtils.createElement('button', {
546
- className: `${this.player.options.classPrefix}-button ${this.player.options.classPrefix}-rewind`,
547
- attributes: {
548
- 'type': 'button',
549
- 'aria-label': i18n.t('player.rewindSeconds', { seconds: 15 })
550
- }
551
- });
552
-
553
- button.appendChild(createIconElement('rewind'));
554
-
555
- button.addEventListener('click', () => {
556
- this.player.seekBackward(15);
557
- });
558
-
559
- return button;
560
- }
561
-
562
- createForwardButton() {
563
- const button = DOMUtils.createElement('button', {
564
- className: `${this.player.options.classPrefix}-button ${this.player.options.classPrefix}-forward`,
565
- attributes: {
566
- 'type': 'button',
567
- 'aria-label': i18n.t('player.forwardSeconds', { seconds: 15 })
568
- }
569
- });
570
-
571
- button.appendChild(createIconElement('forward'));
572
-
573
- button.addEventListener('click', () => {
574
- this.player.seekForward(15);
575
- });
576
-
577
- return button;
578
- }
579
-
580
- createVolumeControl() {
581
- // Mute/Volume button
582
- const muteButton = DOMUtils.createElement('button', {
583
- className: `${this.player.options.classPrefix}-button ${this.player.options.classPrefix}-mute`,
584
- attributes: {
585
- 'type': 'button',
586
- 'aria-label': i18n.t('player.volume'),
587
- 'aria-haspopup': 'true'
588
- }
589
- });
590
-
591
- muteButton.appendChild(createIconElement('volumeHigh'));
592
-
593
- // Toggle mute on right click, show volume slider on left click
594
- muteButton.addEventListener('contextmenu', (e) => {
595
- e.preventDefault();
596
- this.player.toggleMute();
597
- });
598
-
599
- muteButton.addEventListener('click', () => {
600
- this.showVolumeSlider(muteButton);
601
- });
602
-
603
- this.controls.mute = muteButton;
604
-
605
- return muteButton;
606
- }
607
-
608
- showVolumeSlider(button) {
609
- // Remove existing slider if any
610
- const existingSlider = document.querySelector(`.${this.player.options.classPrefix}-volume-menu`);
611
- if (existingSlider) {
612
- existingSlider.remove();
613
- return;
614
- }
615
-
616
- // Volume menu container
617
- const volumeMenu = DOMUtils.createElement('div', {
618
- className: `${this.player.options.classPrefix}-volume-menu ${this.player.options.classPrefix}-menu`
619
- });
620
-
621
- const volumeSlider = DOMUtils.createElement('div', {
622
- className: `${this.player.options.classPrefix}-volume-slider`,
623
- attributes: {
624
- 'role': 'slider',
625
- 'aria-label': i18n.t('player.volume'),
626
- 'aria-valuemin': '0',
627
- 'aria-valuemax': '100',
628
- 'aria-valuenow': String(Math.round(this.player.state.volume * 100)),
629
- 'tabindex': '0'
630
- }
631
- });
632
-
633
- const volumeTrack = DOMUtils.createElement('div', {
634
- className: `${this.player.options.classPrefix}-volume-track`
635
- });
636
-
637
- const volumeFill = DOMUtils.createElement('div', {
638
- className: `${this.player.options.classPrefix}-volume-fill`
639
- });
640
-
641
- const volumeHandle = DOMUtils.createElement('div', {
642
- className: `${this.player.options.classPrefix}-volume-handle`
643
- });
644
-
645
- volumeTrack.appendChild(volumeFill);
646
- volumeFill.appendChild(volumeHandle);
647
- volumeSlider.appendChild(volumeTrack);
648
- volumeMenu.appendChild(volumeSlider);
649
-
650
- // Volume slider events
651
- const updateVolume = (clientY) => {
652
- const rect = volumeTrack.getBoundingClientRect();
653
- const percent = Math.max(0, Math.min(1, 1 - ((clientY - rect.top) / rect.height)));
654
- this.player.setVolume(percent);
655
- };
656
-
657
- volumeSlider.addEventListener('mousedown', (e) => {
658
- e.stopPropagation();
659
- this.isDraggingVolume = true;
660
- updateVolume(e.clientY);
661
- });
662
-
663
- document.addEventListener('mousemove', (e) => {
664
- if (this.isDraggingVolume) {
665
- updateVolume(e.clientY);
666
- }
667
- });
668
-
669
- document.addEventListener('mouseup', () => {
670
- this.isDraggingVolume = false;
671
- });
672
-
673
- // Keyboard volume control
674
- volumeSlider.addEventListener('keydown', (e) => {
675
- if (e.key === 'ArrowUp') {
676
- e.preventDefault();
677
- this.player.setVolume(Math.min(1, this.player.state.volume + 0.1));
678
- } else if (e.key === 'ArrowDown') {
679
- e.preventDefault();
680
- this.player.setVolume(Math.max(0, this.player.state.volume - 0.1));
681
- }
682
- });
683
-
684
- // Prevent menu from closing when interacting with slider
685
- volumeMenu.addEventListener('click', (e) => {
686
- e.stopPropagation();
687
- });
688
-
689
- // Append menu to button
690
- button.appendChild(volumeMenu);
691
-
692
- this.controls.volumeSlider = volumeSlider;
693
- this.controls.volumeFill = volumeFill;
694
-
695
- // Close menu on outside click
696
- this.attachMenuCloseHandler(volumeMenu, button, true);
697
- }
698
-
699
- createTimeDisplay() {
700
- const container = DOMUtils.createElement('div', {
701
- className: `${this.player.options.classPrefix}-time`,
702
- attributes: {
703
- 'role': 'group',
704
- 'aria-label': i18n.t('time.display')
705
- }
706
- });
707
-
708
- // Current time - visual text hidden, only aria-label announced
709
- this.controls.currentTimeDisplay = DOMUtils.createElement('span', {
710
- className: `${this.player.options.classPrefix}-current-time`,
711
- attributes: {
712
- 'aria-label': i18n.t('time.seconds', { count: 0 })
713
- }
714
- });
715
-
716
- // Create visual text inside, hidden from screen readers
717
- const currentTimeVisual = DOMUtils.createElement('span', {
718
- textContent: '00:00',
719
- attributes: {
720
- 'aria-hidden': 'true'
721
- }
722
- });
723
- this.controls.currentTimeDisplay.appendChild(currentTimeVisual);
724
- this.controls.currentTimeVisual = currentTimeVisual;
725
-
726
- const separator = DOMUtils.createElement('span', {
727
- textContent: ' / ',
728
- attributes: {
729
- 'aria-hidden': 'true'
730
- }
731
- });
732
-
733
- // Duration - visual text hidden, only aria-label announced
734
- this.controls.durationDisplay = DOMUtils.createElement('span', {
735
- className: `${this.player.options.classPrefix}-duration`,
736
- attributes: {
737
- 'aria-label': i18n.t('time.durationPrefix') + i18n.t('time.seconds', { count: 0 })
738
- }
739
- });
740
-
741
- // Create visual text inside, hidden from screen readers
742
- const durationVisual = DOMUtils.createElement('span', {
743
- textContent: '00:00',
744
- attributes: {
745
- 'aria-hidden': 'true'
746
- }
747
- });
748
- this.controls.durationDisplay.appendChild(durationVisual);
749
- this.controls.durationVisual = durationVisual;
750
-
751
- container.appendChild(this.controls.currentTimeDisplay);
752
- container.appendChild(separator);
753
- container.appendChild(this.controls.durationDisplay);
754
-
755
- return container;
756
- }
757
-
758
- createChaptersButton() {
759
- const button = DOMUtils.createElement('button', {
760
- className: `${this.player.options.classPrefix}-button ${this.player.options.classPrefix}-chapters`,
761
- attributes: {
762
- 'type': 'button',
763
- 'aria-label': i18n.t('player.chapters'),
764
- 'aria-haspopup': 'menu'
765
- }
766
- });
767
-
768
- button.appendChild(createIconElement('playlist'));
769
-
770
- button.addEventListener('click', () => {
771
- this.showChaptersMenu(button);
772
- });
773
-
774
- this.controls.chapters = button;
775
- return button;
776
- }
777
-
778
- showChaptersMenu(button) {
779
- // Remove existing menu if any
780
- const existingMenu = document.querySelector(`.${this.player.options.classPrefix}-chapters-menu`);
781
- if (existingMenu) {
782
- existingMenu.remove();
783
- return;
784
- }
785
-
786
- const menu = DOMUtils.createElement('div', {
787
- className: `${this.player.options.classPrefix}-chapters-menu ${this.player.options.classPrefix}-menu`,
788
- attributes: {
789
- 'role': 'menu',
790
- 'aria-label': i18n.t('player.chapters')
791
- }
792
- });
793
-
794
- // Get chapter tracks
795
- const chapterTracks = Array.from(this.player.element.textTracks).filter(
796
- track => track.kind === 'chapters'
797
- );
798
-
799
- if (chapterTracks.length === 0) {
800
- // No chapters available
801
- const noChaptersItem = DOMUtils.createElement('div', {
802
- className: `${this.player.options.classPrefix}-menu-item`,
803
- textContent: i18n.t('player.noChapters'),
804
- style: {opacity: '0.5', cursor: 'default'}
805
- });
806
- menu.appendChild(noChaptersItem);
807
- } else {
808
- const chapterTrack = chapterTracks[0];
809
-
810
- // Ensure track is in 'hidden' mode to load cues
811
- if (chapterTrack.mode === 'disabled') {
812
- chapterTrack.mode = 'hidden';
813
- }
814
-
815
- if (!chapterTrack.cues || chapterTrack.cues.length === 0) {
816
- // Cues not loaded yet - wait for them to load
817
- const loadingItem = DOMUtils.createElement('div', {
818
- className: `${this.player.options.classPrefix}-menu-item`,
819
- textContent: i18n.t('player.loadingChapters'),
820
- style: {opacity: '0.5', cursor: 'default'}
821
- });
822
- menu.appendChild(loadingItem);
823
-
824
- // Listen for track load event
825
- const onTrackLoad = () => {
826
- // Remove loading message and rebuild menu
827
- menu.remove();
828
- this.showChaptersMenu(button);
829
- };
830
-
831
- chapterTrack.addEventListener('load', onTrackLoad, {once: true});
832
-
833
- // Also try again after a short delay as fallback
834
- setTimeout(() => {
835
- if (chapterTrack.cues && chapterTrack.cues.length > 0 && document.contains(menu)) {
836
- menu.remove();
837
- this.showChaptersMenu(button);
838
- }
839
- }, 500);
840
- } else {
841
- // Display chapters
842
- const cues = chapterTrack.cues;
843
- for (let i = 0; i < cues.length; i++) {
844
- const cue = cues[i];
845
- const item = DOMUtils.createElement('button', {
846
- className: `${this.player.options.classPrefix}-menu-item`,
847
- attributes: {
848
- 'type': 'button',
849
- 'role': 'menuitem',
850
- 'tabindex': '-1'
851
- }
852
- });
853
-
854
- const timeLabel = DOMUtils.createElement('span', {
855
- className: `${this.player.options.classPrefix}-chapter-time`,
856
- textContent: TimeUtils.formatTime(cue.startTime)
857
- });
858
-
859
- const titleLabel = DOMUtils.createElement('span', {
860
- className: `${this.player.options.classPrefix}-chapter-title`,
861
- textContent: cue.text
862
- });
863
-
864
- item.appendChild(timeLabel);
865
- item.appendChild(document.createTextNode(' '));
866
- item.appendChild(titleLabel);
867
-
868
- item.addEventListener('click', () => {
869
- this.player.seek(cue.startTime);
870
- menu.remove();
871
- });
872
-
873
- menu.appendChild(item);
874
- }
875
-
876
- // Add keyboard navigation
877
- this.attachMenuKeyboardNavigation(menu);
878
-
879
- // Focus first item
880
- setTimeout(() => {
881
- const firstItem = menu.querySelector(`.${this.player.options.classPrefix}-menu-item`);
882
- if (firstItem) {
883
- firstItem.focus();
884
- }
885
- }, 0);
886
- }
887
- }
888
-
889
- // Append menu directly to button for proper positioning
890
- button.appendChild(menu);
891
-
892
- // Close menu on outside click
893
- this.attachMenuCloseHandler(menu, button);
894
- }
895
-
896
- createQualityButton() {
897
- const button = DOMUtils.createElement('button', {
898
- className: `${this.player.options.classPrefix}-button ${this.player.options.classPrefix}-quality`,
899
- attributes: {
900
- 'type': 'button',
901
- 'aria-label': i18n.t('player.quality'),
902
- 'aria-haspopup': 'menu'
903
- }
904
- });
905
-
906
- button.appendChild(createIconElement('hd'));
907
-
908
- // Add quality indicator text
909
- const qualityText = DOMUtils.createElement('span', {
910
- className: `${this.player.options.classPrefix}-quality-text`,
911
- textContent: ''
912
- });
913
- button.appendChild(qualityText);
914
-
915
- button.addEventListener('click', () => {
916
- this.showQualityMenu(button);
917
- });
918
-
919
- this.controls.quality = button;
920
- this.controls.qualityText = qualityText;
921
-
922
- // Update quality indicator after a short delay to ensure renderer is ready
923
- setTimeout(() => this.updateQualityIndicator(), 500);
924
-
925
- return button;
926
- }
927
-
928
- showQualityMenu(button) {
929
- // Remove existing menu if any
930
- const existingMenu = document.querySelector(`.${this.player.options.classPrefix}-quality-menu`);
931
- if (existingMenu) {
932
- existingMenu.remove();
933
- return;
934
- }
935
-
936
- const menu = DOMUtils.createElement('div', {
937
- className: `${this.player.options.classPrefix}-quality-menu ${this.player.options.classPrefix}-menu`,
938
- attributes: {
939
- 'role': 'menu',
940
- 'aria-label': i18n.t('player.quality')
941
- }
942
- });
943
-
944
- // Check if renderer supports quality selection
945
- if (this.player.renderer && this.player.renderer.getQualities) {
946
- const qualities = this.player.renderer.getQualities();
947
- const currentQuality = this.player.renderer.getCurrentQuality ? this.player.renderer.getCurrentQuality() : -1;
948
- const isHLS = this.player.renderer.hls !== undefined;
949
-
950
- if (qualities.length === 0) {
951
- // No qualities available
952
- const noQualityItem = DOMUtils.createElement('div', {
953
- className: `${this.player.options.classPrefix}-menu-item`,
954
- textContent: i18n.t('player.autoQuality'),
955
- style: {opacity: '0.5', cursor: 'default'}
956
- });
957
- menu.appendChild(noQualityItem);
958
- } else {
959
- let activeItem = null;
960
-
961
- // Auto quality option (only for HLS)
962
- if (isHLS) {
963
- const autoItem = DOMUtils.createElement('button', {
964
- className: `${this.player.options.classPrefix}-menu-item`,
965
- textContent: i18n.t('player.auto'),
966
- attributes: {
967
- 'type': 'button',
968
- 'role': 'menuitem',
969
- 'tabindex': '-1'
970
- }
971
- });
972
-
973
- // Check if auto is currently selected
974
- const isAuto = this.player.renderer.hls && this.player.renderer.hls.currentLevel === -1;
975
- if (isAuto) {
976
- autoItem.classList.add(`${this.player.options.classPrefix}-menu-item-active`);
977
- autoItem.appendChild(createIconElement('check'));
978
- activeItem = autoItem;
979
- }
980
-
981
- autoItem.addEventListener('click', () => {
982
- if (this.player.renderer.switchQuality) {
983
- this.player.renderer.switchQuality(-1); // -1 for auto
984
- }
985
- menu.remove();
986
- });
987
-
988
- menu.appendChild(autoItem);
989
- }
990
-
991
- // Quality options
992
- qualities.forEach(quality => {
993
- const item = DOMUtils.createElement('button', {
994
- className: `${this.player.options.classPrefix}-menu-item`,
995
- textContent: quality.name || `${quality.height}p`,
996
- attributes: {
997
- 'type': 'button',
998
- 'role': 'menuitem',
999
- 'tabindex': '-1'
1000
- }
1001
- });
1002
-
1003
- // Highlight current quality
1004
- if (quality.index === currentQuality) {
1005
- item.classList.add(`${this.player.options.classPrefix}-menu-item-active`);
1006
- item.appendChild(createIconElement('check'));
1007
- activeItem = item;
1008
- }
1009
-
1010
- item.addEventListener('click', () => {
1011
- if (this.player.renderer.switchQuality) {
1012
- this.player.renderer.switchQuality(quality.index);
1013
- }
1014
- menu.remove();
1015
- });
1016
-
1017
- menu.appendChild(item);
1018
- });
1019
-
1020
- // Add keyboard navigation
1021
- this.attachMenuKeyboardNavigation(menu);
1022
-
1023
- // Focus active item or first item
1024
- setTimeout(() => {
1025
- const focusTarget = activeItem || menu.querySelector(`.${this.player.options.classPrefix}-menu-item`);
1026
- if (focusTarget) {
1027
- focusTarget.focus();
1028
- }
1029
- }, 0);
1030
- }
1031
- } else {
1032
- // No quality support
1033
- const noSupportItem = DOMUtils.createElement('div', {
1034
- className: `${this.player.options.classPrefix}-menu-item`,
1035
- textContent: i18n.t('player.noQuality'),
1036
- style: {opacity: '0.5', cursor: 'default'}
1037
- });
1038
- menu.appendChild(noSupportItem);
1039
- }
1040
-
1041
- // Append menu directly to button for proper positioning
1042
- button.appendChild(menu);
1043
-
1044
- // Close menu on outside click
1045
- this.attachMenuCloseHandler(menu, button);
1046
- }
1047
-
1048
- createCaptionStyleButton() {
1049
- const button = DOMUtils.createElement('button', {
1050
- className: `${this.player.options.classPrefix}-button ${this.player.options.classPrefix}-caption-style`,
1051
- attributes: {
1052
- 'type': 'button',
1053
- 'aria-label': i18n.t('player.captionStyling'),
1054
- 'aria-haspopup': 'menu',
1055
- 'title': i18n.t('player.captionStyling')
1056
- }
1057
- });
1058
-
1059
- // Create "Aa" text icon for styling
1060
- const textIcon = DOMUtils.createElement('span', {
1061
- textContent: 'Aa',
1062
- style: {
1063
- fontSize: '14px',
1064
- fontWeight: 'bold'
1065
- }
1066
- });
1067
- button.appendChild(textIcon);
1068
-
1069
- button.addEventListener('click', () => {
1070
- this.showCaptionStyleMenu(button);
1071
- });
1072
-
1073
- this.controls.captionStyle = button;
1074
- return button;
1075
- }
1076
-
1077
- showCaptionStyleMenu(button) {
1078
- // Remove existing menu if any
1079
- const existingMenu = document.querySelector(`.${this.player.options.classPrefix}-caption-style-menu`);
1080
- if (existingMenu) {
1081
- existingMenu.remove();
1082
- return;
1083
- }
1084
-
1085
- const menu = DOMUtils.createElement('div', {
1086
- className: `${this.player.options.classPrefix}-caption-style-menu ${this.player.options.classPrefix}-menu ${this.player.options.classPrefix}-settings-menu`,
1087
- attributes: {
1088
- 'role': 'menu',
1089
- 'aria-label': i18n.t('player.captionStyling')
1090
- }
1091
- });
1092
-
1093
- // Prevent menu from closing when clicking inside
1094
- menu.addEventListener('click', (e) => {
1095
- e.stopPropagation();
1096
- });
1097
-
1098
- // Check if there are any caption tracks
1099
- if (!this.player.captionManager || this.player.captionManager.tracks.length === 0) {
1100
- // Show "No captions available" message
1101
- const noTracksItem = DOMUtils.createElement('div', {
1102
- className: `${this.player.options.classPrefix}-menu-item`,
1103
- textContent: i18n.t('player.noCaptions'),
1104
- style: {opacity: '0.5', cursor: 'default', padding: '12px 16px'}
1105
- });
1106
- menu.appendChild(noTracksItem);
1107
-
1108
- // Append menu to button
1109
- button.appendChild(menu);
1110
-
1111
- // Close menu on outside click
1112
- this.attachMenuCloseHandler(menu, button, true);
1113
- return;
1114
- }
1115
-
1116
- // Font Size
1117
- const fontSizeGroup = this.createStyleControl(
1118
- i18n.t('styleLabels.fontSize'),
1119
- 'captionsFontSize',
1120
- [
1121
- {label: i18n.t('fontSizes.small'), value: '80%'},
1122
- {label: i18n.t('fontSizes.medium'), value: '100%'},
1123
- {label: i18n.t('fontSizes.large'), value: '120%'},
1124
- {label: i18n.t('fontSizes.xlarge'), value: '150%'}
1125
- ]
1126
- );
1127
- menu.appendChild(fontSizeGroup);
1128
-
1129
- // Font Family
1130
- const fontFamilyGroup = this.createStyleControl(
1131
- i18n.t('styleLabels.font'),
1132
- 'captionsFontFamily',
1133
- [
1134
- {label: i18n.t('fontFamilies.sansSerif'), value: 'sans-serif'},
1135
- {label: i18n.t('fontFamilies.serif'), value: 'serif'},
1136
- {label: i18n.t('fontFamilies.monospace'), value: 'monospace'}
1137
- ]
1138
- );
1139
- menu.appendChild(fontFamilyGroup);
1140
-
1141
- // Text Color
1142
- const colorGroup = this.createColorControl(i18n.t('styleLabels.textColor'), 'captionsColor');
1143
- menu.appendChild(colorGroup);
1144
-
1145
- // Background Color
1146
- const bgColorGroup = this.createColorControl(i18n.t('styleLabels.background'), 'captionsBackgroundColor');
1147
- menu.appendChild(bgColorGroup);
1148
-
1149
- // Opacity
1150
- const opacityGroup = this.createOpacityControl(i18n.t('styleLabels.opacity'), 'captionsOpacity');
1151
- menu.appendChild(opacityGroup);
1152
-
1153
- // Set min-width for caption style menu
1154
- menu.style.minWidth = '220px';
1155
-
1156
- // Append menu directly to button for proper positioning
1157
- button.appendChild(menu);
1158
-
1159
- // Close menu on outside click (but not when interacting with controls)
1160
- this.attachMenuCloseHandler(menu, button, true);
1161
-
1162
- // Auto-focus the first select element
1163
- setTimeout(() => {
1164
- const firstSelect = menu.querySelector('select');
1165
- if (firstSelect) {
1166
- firstSelect.focus();
1167
- }
1168
- }, 0);
1169
- }
1170
-
1171
- createStyleControl(label, property, options) {
1172
- const group = DOMUtils.createElement('div', {
1173
- className: `${this.player.options.classPrefix}-style-group`
1174
- });
1175
-
1176
- const labelEl = DOMUtils.createElement('label', {
1177
- textContent: label,
1178
- style: {
1179
- display: 'block',
1180
- fontSize: '12px',
1181
- marginBottom: '4px',
1182
- color: 'rgba(255,255,255,0.7)'
1183
- }
1184
- });
1185
- group.appendChild(labelEl);
1186
-
1187
- const select = DOMUtils.createElement('select', {
1188
- className: `${this.player.options.classPrefix}-style-select`,
1189
- style: {
1190
- width: '100%',
1191
- padding: '6px',
1192
- background: 'var(--vidply-white)',
1193
- border: '1px solid var(--vidply-white-10)',
1194
- borderRadius: '4px',
1195
- color: 'var(--vidply-black)',
1196
- fontSize: '13px'
1197
- }
1198
- });
1199
-
1200
- const currentValue = this.player.options[property];
1201
- options.forEach(opt => {
1202
- const option = DOMUtils.createElement('option', {
1203
- textContent: opt.label,
1204
- attributes: {value: opt.value}
1205
- });
1206
- if (opt.value === currentValue) {
1207
- option.selected = true;
1208
- }
1209
- select.appendChild(option);
1210
- });
1211
-
1212
- // Prevent clicks from closing the menu
1213
- select.addEventListener('mousedown', (e) => {
1214
- e.stopPropagation();
1215
- });
1216
-
1217
- select.addEventListener('click', (e) => {
1218
- e.stopPropagation();
1219
- });
1220
-
1221
- select.addEventListener('change', (e) => {
1222
- e.stopPropagation();
1223
- this.player.options[property] = e.target.value;
1224
- if (this.player.captionManager) {
1225
- this.player.captionManager.setCaptionStyle(
1226
- property.replace('captions', '').charAt(0).toLowerCase() + property.replace('captions', '').slice(1),
1227
- e.target.value
1228
- );
1229
- }
1230
- });
1231
-
1232
- group.appendChild(select);
1233
- return group;
1234
- }
1235
-
1236
- createColorControl(label, property) {
1237
- const group = DOMUtils.createElement('div', {
1238
- className: `${this.player.options.classPrefix}-style-group`
1239
- });
1240
-
1241
- const labelEl = DOMUtils.createElement('label', {
1242
- textContent: label,
1243
- style: {
1244
- display: 'block',
1245
- fontSize: '12px',
1246
- marginBottom: '4px',
1247
- color: 'rgba(255,255,255,0.7)'
1248
- }
1249
- });
1250
- group.appendChild(labelEl);
1251
-
1252
- const input = DOMUtils.createElement('input', {
1253
- attributes: {
1254
- type: 'color',
1255
- value: this.player.options[property]
1256
- },
1257
- style: {
1258
- width: '100%',
1259
- height: '32px',
1260
- padding: '2px',
1261
- background: 'rgba(255,255,255,0.1)',
1262
- border: '1px solid rgba(255,255,255,0.2)',
1263
- borderRadius: '4px',
1264
- cursor: 'pointer'
1265
- }
1266
- });
1267
-
1268
- // Prevent clicks from closing the menu
1269
- input.addEventListener('mousedown', (e) => {
1270
- e.stopPropagation();
1271
- });
1272
-
1273
- input.addEventListener('click', (e) => {
1274
- e.stopPropagation();
1275
- });
1276
-
1277
- input.addEventListener('change', (e) => {
1278
- e.stopPropagation();
1279
- this.player.options[property] = e.target.value;
1280
- if (this.player.captionManager) {
1281
- this.player.captionManager.setCaptionStyle(
1282
- property.replace('captions', '').charAt(0).toLowerCase() + property.replace('captions', '').slice(1),
1283
- e.target.value
1284
- );
1285
- }
1286
- });
1287
-
1288
- group.appendChild(input);
1289
- return group;
1290
- }
1291
-
1292
- createOpacityControl(label, property) {
1293
- const group = DOMUtils.createElement('div', {
1294
- className: `${this.player.options.classPrefix}-style-group`
1295
- });
1296
-
1297
- const labelContainer = DOMUtils.createElement('div', {
1298
- style: {
1299
- display: 'flex',
1300
- justifyContent: 'space-between',
1301
- marginBottom: '4px'
1302
- }
1303
- });
1304
-
1305
- const labelEl = DOMUtils.createElement('label', {
1306
- textContent: label,
1307
- style: {
1308
- fontSize: '12px',
1309
- color: 'rgba(255,255,255,0.7)'
1310
- }
1311
- });
1312
-
1313
- const valueEl = DOMUtils.createElement('span', {
1314
- textContent: Math.round(this.player.options[property] * 100) + '%',
1315
- style: {
1316
- fontSize: '12px',
1317
- color: 'rgba(255,255,255,0.7)'
1318
- }
1319
- });
1320
-
1321
- labelContainer.appendChild(labelEl);
1322
- labelContainer.appendChild(valueEl);
1323
- group.appendChild(labelContainer);
1324
-
1325
- const input = DOMUtils.createElement('input', {
1326
- attributes: {
1327
- type: 'range',
1328
- min: '0',
1329
- max: '1',
1330
- step: '0.1',
1331
- value: String(this.player.options[property])
1332
- },
1333
- style: {
1334
- width: '100%',
1335
- cursor: 'pointer'
1336
- }
1337
- });
1338
-
1339
- // Prevent clicks from closing the menu
1340
- input.addEventListener('mousedown', (e) => {
1341
- e.stopPropagation();
1342
- });
1343
-
1344
- input.addEventListener('click', (e) => {
1345
- e.stopPropagation();
1346
- });
1347
-
1348
- input.addEventListener('input', (e) => {
1349
- e.stopPropagation();
1350
- const value = parseFloat(e.target.value);
1351
- valueEl.textContent = Math.round(value * 100) + '%';
1352
- this.player.options[property] = value;
1353
- if (this.player.captionManager) {
1354
- this.player.captionManager.setCaptionStyle(
1355
- property.replace('captions', '').charAt(0).toLowerCase() + property.replace('captions', '').slice(1),
1356
- value
1357
- );
1358
- }
1359
- });
1360
-
1361
- group.appendChild(input);
1362
- return group;
1363
- }
1364
-
1365
- createSpeedButton() {
1366
- const button = DOMUtils.createElement('button', {
1367
- className: `${this.player.options.classPrefix}-button ${this.player.options.classPrefix}-speed`,
1368
- attributes: {
1369
- 'type': 'button',
1370
- 'aria-label': i18n.t('player.speed'),
1371
- 'aria-haspopup': 'menu'
1372
- }
1373
- });
1374
-
1375
- button.appendChild(createIconElement('speed'));
1376
-
1377
- const speedText = DOMUtils.createElement('span', {
1378
- className: `${this.player.options.classPrefix}-speed-text`,
1379
- textContent: '1x'
1380
- });
1381
- button.appendChild(speedText);
1382
-
1383
- button.addEventListener('click', () => {
1384
- this.showSpeedMenu(button);
1385
- });
1386
-
1387
- this.controls.speed = button;
1388
- this.controls.speedText = speedText;
1389
- return button;
1390
- }
1391
-
1392
- formatSpeedLabel(speed) {
1393
- // Special case: 1x is "Normal" (translated)
1394
- if (speed === 1) {
1395
- return i18n.t('speeds.normal');
1396
- }
1397
-
1398
- // For other speeds, format with locale-specific decimal separator
1399
- const speedStr = speed.toLocaleString(i18n.getLanguage(), {
1400
- minimumFractionDigits: 0,
1401
- maximumFractionDigits: 2
1402
- });
1403
-
1404
- return `${speedStr}×`;
1405
- }
1406
-
1407
- showSpeedMenu(button) {
1408
- // Remove existing menu if any
1409
- const existingMenu = document.querySelector(`.${this.player.options.classPrefix}-speed-menu`);
1410
- if (existingMenu) {
1411
- existingMenu.remove();
1412
- return;
1413
- }
1414
-
1415
- const menu = DOMUtils.createElement('div', {
1416
- className: `${this.player.options.classPrefix}-speed-menu ${this.player.options.classPrefix}-menu`,
1417
- attributes: {
1418
- 'role': 'menu'
1419
- }
1420
- });
1421
-
1422
- const speeds = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
1423
- let activeItem = null;
1424
-
1425
- speeds.forEach(speed => {
1426
- const item = DOMUtils.createElement('button', {
1427
- className: `${this.player.options.classPrefix}-menu-item`,
1428
- textContent: this.formatSpeedLabel(speed),
1429
- attributes: {
1430
- 'type': 'button',
1431
- 'role': 'menuitem',
1432
- 'tabindex': '-1'
1433
- }
1434
- });
1435
-
1436
- if (speed === this.player.state.playbackSpeed) {
1437
- item.classList.add(`${this.player.options.classPrefix}-menu-item-active`);
1438
- item.appendChild(createIconElement('check'));
1439
- activeItem = item;
1440
- }
1441
-
1442
- item.addEventListener('click', () => {
1443
- this.player.setPlaybackSpeed(speed);
1444
- menu.remove();
1445
- });
1446
-
1447
- menu.appendChild(item);
1448
- });
1449
-
1450
- // Append menu directly to button for proper positioning
1451
- button.appendChild(menu);
1452
-
1453
- // Add keyboard navigation
1454
- this.attachMenuKeyboardNavigation(menu);
1455
-
1456
- // Close menu on outside click
1457
- this.attachMenuCloseHandler(menu, button);
1458
-
1459
- // Focus the active item or first item
1460
- setTimeout(() => {
1461
- const focusTarget = activeItem || menu.querySelector(`.${this.player.options.classPrefix}-menu-item`);
1462
- if (focusTarget) {
1463
- focusTarget.focus();
1464
- }
1465
- }, 0);
1466
- }
1467
-
1468
- createCaptionsButton() {
1469
- const button = DOMUtils.createElement('button', {
1470
- className: `${this.player.options.classPrefix}-button ${this.player.options.classPrefix}-captions-button`,
1471
- attributes: {
1472
- 'type': 'button',
1473
- 'aria-label': i18n.t('player.captions'),
1474
- 'aria-pressed': 'false',
1475
- 'aria-haspopup': 'menu'
1476
- }
1477
- });
1478
-
1479
- button.appendChild(createIconElement('captionsOff'));
1480
-
1481
- button.addEventListener('click', () => {
1482
- this.showCaptionsMenu(button);
1483
- });
1484
-
1485
- this.controls.captions = button;
1486
- return button;
1487
- }
1488
-
1489
- showCaptionsMenu(button) {
1490
- // Remove existing menu if any
1491
- const existingMenu = document.querySelector(`.${this.player.options.classPrefix}-captions-menu`);
1492
- if (existingMenu) {
1493
- existingMenu.remove();
1494
- return;
1495
- }
1496
-
1497
- const menu = DOMUtils.createElement('div', {
1498
- className: `${this.player.options.classPrefix}-captions-menu ${this.player.options.classPrefix}-menu`,
1499
- attributes: {
1500
- 'role': 'menu',
1501
- 'aria-label': i18n.t('captions.select')
1502
- }
1503
- });
1504
-
1505
- // Check if there are any caption tracks
1506
- if (!this.player.captionManager || this.player.captionManager.tracks.length === 0) {
1507
- // Show "No captions available" message
1508
- const noTracksItem = DOMUtils.createElement('div', {
1509
- className: `${this.player.options.classPrefix}-menu-item`,
1510
- textContent: i18n.t('player.noCaptions'),
1511
- style: {opacity: '0.5', cursor: 'default'}
1512
- });
1513
- menu.appendChild(noTracksItem);
1514
-
1515
- // Append menu to button
1516
- button.appendChild(menu);
1517
-
1518
- // Close menu on outside click
1519
- this.attachMenuCloseHandler(menu, button);
1520
- return;
1521
- }
1522
-
1523
- let activeItem = null;
1524
-
1525
- // Off option
1526
- const offItem = DOMUtils.createElement('button', {
1527
- className: `${this.player.options.classPrefix}-menu-item`,
1528
- textContent: i18n.t('captions.off'),
1529
- attributes: {
1530
- 'type': 'button',
1531
- 'role': 'menuitem',
1532
- 'tabindex': '-1'
1533
- }
1534
- });
1535
-
1536
- if (!this.player.state.captionsEnabled) {
1537
- offItem.classList.add(`${this.player.options.classPrefix}-menu-item-active`);
1538
- offItem.appendChild(createIconElement('check'));
1539
- activeItem = offItem;
1540
- }
1541
-
1542
- offItem.addEventListener('click', () => {
1543
- this.player.disableCaptions();
1544
- this.updateCaptionsButton();
1545
- menu.remove();
1546
- });
1547
-
1548
- menu.appendChild(offItem);
1549
-
1550
- // Available tracks
1551
- const tracks = this.player.captionManager.getAvailableTracks();
1552
- tracks.forEach(track => {
1553
- const item = DOMUtils.createElement('button', {
1554
- className: `${this.player.options.classPrefix}-menu-item`,
1555
- textContent: track.label,
1556
- attributes: {
1557
- 'type': 'button',
1558
- 'role': 'menuitem',
1559
- 'lang': track.language,
1560
- 'tabindex': '-1'
1561
- }
1562
- });
1563
-
1564
- // Check if this is the current track
1565
- if (this.player.state.captionsEnabled &&
1566
- this.player.captionManager.currentTrack === this.player.captionManager.tracks[track.index]) {
1567
- item.classList.add(`${this.player.options.classPrefix}-menu-item-active`);
1568
- item.appendChild(createIconElement('check'));
1569
- activeItem = item;
1570
- }
1571
-
1572
- item.addEventListener('click', () => {
1573
- this.player.captionManager.switchTrack(track.index);
1574
- this.updateCaptionsButton();
1575
- menu.remove();
1576
- });
1577
-
1578
- menu.appendChild(item);
1579
- });
1580
-
1581
- // Append menu directly to button for proper positioning
1582
- button.appendChild(menu);
1583
-
1584
- // Add keyboard navigation for the menu
1585
- this.attachMenuKeyboardNavigation(menu);
1586
-
1587
- // Close menu on outside click and Escape key
1588
- this.attachMenuCloseHandler(menu, button);
1589
-
1590
- // Focus the active item or the first item
1591
- setTimeout(() => {
1592
- const focusTarget = activeItem || menu.querySelector(`.${this.player.options.classPrefix}-menu-item`);
1593
- if (focusTarget) {
1594
- focusTarget.focus();
1595
- }
1596
- }, 0);
1597
- }
1598
-
1599
- updateCaptionsButton() {
1600
- if (!this.controls.captions) return;
1601
-
1602
- const icon = this.controls.captions.querySelector('.vidply-icon');
1603
- const isEnabled = this.player.state.captionsEnabled;
1604
-
1605
- icon.innerHTML = isEnabled ?
1606
- createIconElement('captions').innerHTML :
1607
- createIconElement('captionsOff').innerHTML;
1608
-
1609
- this.controls.captions.setAttribute('aria-pressed', isEnabled ? 'true' : 'false');
1610
- }
1611
-
1612
- createTranscriptButton() {
1613
- const button = DOMUtils.createElement('button', {
1614
- className: `${this.player.options.classPrefix}-button ${this.player.options.classPrefix}-transcript`,
1615
- attributes: {
1616
- 'type': 'button',
1617
- 'aria-label': i18n.t('player.transcript'),
1618
- 'aria-pressed': 'false'
1619
- }
1620
- });
1621
-
1622
- button.appendChild(createIconElement('transcript'));
1623
-
1624
- button.addEventListener('click', () => {
1625
- if (this.player.transcriptManager) {
1626
- this.player.transcriptManager.toggleTranscript();
1627
- this.updateTranscriptButton();
1628
- }
1629
- });
1630
-
1631
- this.controls.transcript = button;
1632
- return button;
1633
- }
1634
-
1635
- updateTranscriptButton() {
1636
- if (!this.controls.transcript) return;
1637
-
1638
- const isVisible = this.player.transcriptManager && this.player.transcriptManager.isVisible;
1639
- this.controls.transcript.setAttribute('aria-pressed', isVisible ? 'true' : 'false');
1640
- }
1641
-
1642
- createAudioDescriptionButton() {
1643
- const button = DOMUtils.createElement('button', {
1644
- className: `${this.player.options.classPrefix}-button ${this.player.options.classPrefix}-audio-description`,
1645
- attributes: {
1646
- 'type': 'button',
1647
- 'aria-label': i18n.t('player.audioDescription'),
1648
- 'aria-pressed': 'false',
1649
- 'title': i18n.t('player.audioDescription')
1650
- }
1651
- });
1652
-
1653
- button.appendChild(createIconElement('audioDescription'));
1654
-
1655
- button.addEventListener('click', async () => {
1656
- await this.player.toggleAudioDescription();
1657
- this.updateAudioDescriptionButton();
1658
- });
1659
-
1660
- this.controls.audioDescription = button;
1661
- return button;
1662
- }
1663
-
1664
- updateAudioDescriptionButton() {
1665
- if (!this.controls.audioDescription) return;
1666
-
1667
- const icon = this.controls.audioDescription.querySelector('.vidply-icon');
1668
- const isEnabled = this.player.state.audioDescriptionEnabled;
1669
-
1670
- icon.innerHTML = isEnabled ?
1671
- createIconElement('audioDescriptionOn').innerHTML :
1672
- createIconElement('audioDescription').innerHTML;
1673
-
1674
- this.controls.audioDescription.setAttribute('aria-pressed', isEnabled ? 'true' : 'false');
1675
- this.controls.audioDescription.setAttribute('aria-label',
1676
- isEnabled ? i18n.t('audioDescription.disable') : i18n.t('audioDescription.enable')
1677
- );
1678
- }
1679
-
1680
- createSignLanguageButton() {
1681
- const button = DOMUtils.createElement('button', {
1682
- className: `${this.player.options.classPrefix}-button ${this.player.options.classPrefix}-sign-language`,
1683
- attributes: {
1684
- 'type': 'button',
1685
- 'aria-label': i18n.t('player.signLanguage'),
1686
- 'aria-pressed': 'false',
1687
- 'title': i18n.t('player.signLanguage')
1688
- }
1689
- });
1690
-
1691
- button.appendChild(createIconElement('signLanguage'));
1692
-
1693
- button.addEventListener('click', () => {
1694
- this.player.toggleSignLanguage();
1695
- this.updateSignLanguageButton();
1696
- });
1697
-
1698
- this.controls.signLanguage = button;
1699
- return button;
1700
- }
1701
-
1702
- updateSignLanguageButton() {
1703
- if (!this.controls.signLanguage) return;
1704
-
1705
- const icon = this.controls.signLanguage.querySelector('.vidply-icon');
1706
- const isEnabled = this.player.state.signLanguageEnabled;
1707
-
1708
- icon.innerHTML = isEnabled ?
1709
- createIconElement('signLanguageOn').innerHTML :
1710
- createIconElement('signLanguage').innerHTML;
1711
-
1712
- this.controls.signLanguage.setAttribute('aria-pressed', isEnabled ? 'true' : 'false');
1713
- this.controls.signLanguage.setAttribute('aria-label',
1714
- isEnabled ? i18n.t('signLanguage.hide') : i18n.t('signLanguage.show')
1715
- );
1716
- }
1717
-
1718
- createSettingsButton() {
1719
- const button = DOMUtils.createElement('button', {
1720
- className: `${this.player.options.classPrefix}-button ${this.player.options.classPrefix}-settings`,
1721
- attributes: {
1722
- 'type': 'button',
1723
- 'aria-label': i18n.t('player.settings')
1724
- }
1725
- });
1726
-
1727
- button.appendChild(createIconElement('settings'));
1728
-
1729
- button.addEventListener('click', () => {
1730
- this.player.showSettings();
1731
- });
1732
-
1733
- return button;
1734
- }
1735
-
1736
- createPipButton() {
1737
- const button = DOMUtils.createElement('button', {
1738
- className: `${this.player.options.classPrefix}-button ${this.player.options.classPrefix}-pip`,
1739
- attributes: {
1740
- 'type': 'button',
1741
- 'aria-label': i18n.t('player.pip')
1742
- }
1743
- });
1744
-
1745
- button.appendChild(createIconElement('pip'));
1746
-
1747
- button.addEventListener('click', () => {
1748
- this.player.togglePiP();
1749
- });
1750
-
1751
- return button;
1752
- }
1753
-
1754
- createFullscreenButton() {
1755
- const button = DOMUtils.createElement('button', {
1756
- className: `${this.player.options.classPrefix}-button ${this.player.options.classPrefix}-fullscreen`,
1757
- attributes: {
1758
- 'type': 'button',
1759
- 'aria-label': i18n.t('player.fullscreen')
1760
- }
1761
- });
1762
-
1763
- button.appendChild(createIconElement('fullscreen'));
1764
-
1765
- button.addEventListener('click', () => {
1766
- this.player.toggleFullscreen();
1767
- });
1768
-
1769
- this.controls.fullscreen = button;
1770
- return button;
1771
- }
1772
-
1773
- attachEvents() {
1774
- // Update controls based on player state
1775
- this.player.on('play', () => this.updatePlayPauseButton());
1776
- this.player.on('pause', () => this.updatePlayPauseButton());
1777
- this.player.on('timeupdate', () => this.updateProgress());
1778
- this.player.on('loadedmetadata', () => {
1779
- this.updateDuration();
1780
- this.ensureQualityButton();
1781
- this.updateQualityIndicator();
1782
- });
1783
- this.player.on('volumechange', () => this.updateVolumeDisplay());
1784
- this.player.on('progress', () => this.updateBuffered());
1785
- this.player.on('playbackspeedchange', () => this.updateSpeedDisplay());
1786
- this.player.on('fullscreenchange', () => this.updateFullscreenButton());
1787
- this.player.on('captionsenabled', () => this.updateCaptionsButton());
1788
- this.player.on('captionsdisabled', () => this.updateCaptionsButton());
1789
- this.player.on('audiodescriptionenabled', () => this.updateAudioDescriptionButton());
1790
- this.player.on('audiodescriptiondisabled', () => this.updateAudioDescriptionButton());
1791
- this.player.on('signlanguageenabled', () => this.updateSignLanguageButton());
1792
- this.player.on('signlanguagedisabled', () => this.updateSignLanguageButton());
1793
- this.player.on('qualitychange', () => this.updateQualityIndicator());
1794
- this.player.on('hlslevelswitched', () => this.updateQualityIndicator());
1795
- this.player.on('hlsmanifestparsed', () => {
1796
- this.ensureQualityButton();
1797
- this.updateQualityIndicator();
1798
- });
1799
- }
1800
-
1801
- updatePlayPauseButton() {
1802
- if (!this.controls.playPause) return;
1803
-
1804
- const icon = this.controls.playPause.querySelector('.vidply-icon');
1805
- const isPlaying = this.player.state.playing;
1806
-
1807
- icon.innerHTML = isPlaying ?
1808
- createIconElement('pause').innerHTML :
1809
- createIconElement('play').innerHTML;
1810
-
1811
- this.controls.playPause.setAttribute('aria-label',
1812
- isPlaying ? i18n.t('player.pause') : i18n.t('player.play')
1813
- );
1814
- }
1815
-
1816
- updateProgress() {
1817
- if (!this.controls.played) return;
1818
-
1819
- const percent = (this.player.state.currentTime / this.player.state.duration) * 100;
1820
- this.controls.played.style.width = `${percent}%`;
1821
- this.controls.progress.setAttribute('aria-valuenow', String(Math.round(percent)));
1822
-
1823
- if (this.controls.currentTimeVisual) {
1824
- const currentTime = this.player.state.currentTime;
1825
- // Update visual text (hidden from screen readers)
1826
- this.controls.currentTimeVisual.textContent = TimeUtils.formatTime(currentTime);
1827
- // Update aria-label with human-readable format
1828
- this.controls.currentTimeDisplay.setAttribute('aria-label', TimeUtils.formatDuration(currentTime));
1829
- }
1830
- }
1831
-
1832
- updateDuration() {
1833
- if (this.controls.durationVisual) {
1834
- const duration = this.player.state.duration;
1835
- // Update visual text (hidden from screen readers)
1836
- this.controls.durationVisual.textContent = TimeUtils.formatTime(duration);
1837
- // Update aria-label with human-readable format
1838
- this.controls.durationDisplay.setAttribute('aria-label', i18n.t('time.durationPrefix') + TimeUtils.formatDuration(duration));
1839
- }
1840
- }
1841
-
1842
- updateVolumeDisplay() {
1843
- const percent = this.player.state.volume * 100;
1844
-
1845
- // Update volume fill bar if it exists
1846
- if (this.controls.volumeFill) {
1847
- this.controls.volumeFill.style.height = `${percent}%`;
1848
- }
1849
-
1850
- // Update mute button icon (should always work even if slider not shown)
1851
- if (this.controls.mute) {
1852
- const icon = this.controls.mute.querySelector('.vidply-icon');
1853
- if (icon) {
1854
- let iconName;
1855
-
1856
- if (this.player.state.muted || this.player.state.volume === 0) {
1857
- iconName = 'volumeMuted';
1858
- } else if (this.player.state.volume < 0.3) {
1859
- iconName = 'volumeLow';
1860
- } else if (this.player.state.volume < 0.7) {
1861
- iconName = 'volumeMedium';
1862
- } else {
1863
- iconName = 'volumeHigh';
1864
- }
1865
-
1866
- icon.innerHTML = createIconElement(iconName).innerHTML;
1867
-
1868
- this.controls.mute.setAttribute('aria-label',
1869
- this.player.state.muted ? i18n.t('player.unmute') : i18n.t('player.mute')
1870
- );
1871
- }
1872
- }
1873
-
1874
- // Update volume slider attribute if it exists
1875
- if (this.controls.volumeSlider) {
1876
- this.controls.volumeSlider.setAttribute('aria-valuenow', String(Math.round(percent)));
1877
- }
1878
- }
1879
-
1880
- updateBuffered() {
1881
- if (!this.controls.buffered || !this.player.element.buffered || this.player.element.buffered.length === 0) return;
1882
-
1883
- const buffered = this.player.element.buffered.end(this.player.element.buffered.length - 1);
1884
- const percent = (buffered / this.player.state.duration) * 100;
1885
- this.controls.buffered.style.width = `${percent}%`;
1886
- }
1887
-
1888
- updateSpeedDisplay() {
1889
- if (this.controls.speedText) {
1890
- this.controls.speedText.textContent = `${this.player.state.playbackSpeed}x`;
1891
- }
1892
- }
1893
-
1894
- updateFullscreenButton() {
1895
- if (!this.controls.fullscreen) return;
1896
-
1897
- const icon = this.controls.fullscreen.querySelector('.vidply-icon');
1898
- const isFullscreen = this.player.state.fullscreen;
1899
-
1900
- icon.innerHTML = isFullscreen ?
1901
- createIconElement('fullscreenExit').innerHTML :
1902
- createIconElement('fullscreen').innerHTML;
1903
-
1904
- this.controls.fullscreen.setAttribute('aria-label',
1905
- isFullscreen ? i18n.t('player.exitFullscreen') : i18n.t('player.fullscreen')
1906
- );
1907
- }
1908
-
1909
- /**
1910
- * Ensure quality button exists if qualities are available
1911
- * This is called after renderer initialization to dynamically add the button
1912
- */
1913
- ensureQualityButton() {
1914
- // Skip if quality button is disabled
1915
- if (!this.player.options.qualityButton) return;
1916
-
1917
- // Skip if button already exists
1918
- if (this.controls.quality) return;
1919
-
1920
- // Check if qualities are now available
1921
- if (!this.hasQualityLevels()) return;
1922
-
1923
- // Create and insert the quality button before the speed button
1924
- const qualityButton = this.createQualityButton();
1925
-
1926
- // Find the speed button or caption style button to insert before
1927
- const speedButton = this.rightButtons.querySelector(`.${this.player.options.classPrefix}-speed`);
1928
- const captionStyleButton = this.rightButtons.querySelector(`.${this.player.options.classPrefix}-caption-style`);
1929
- const insertBefore = captionStyleButton || speedButton;
1930
-
1931
- if (insertBefore) {
1932
- this.rightButtons.insertBefore(qualityButton, insertBefore);
1933
- } else {
1934
- // If no reference button, add it at the beginning of right buttons
1935
- this.rightButtons.insertBefore(qualityButton, this.rightButtons.firstChild);
1936
- }
1937
-
1938
- this.player.log('Quality button added dynamically', 'info');
1939
- }
1940
-
1941
- updateQualityIndicator() {
1942
- if (!this.controls.qualityText) return;
1943
- if (!this.player.renderer || !this.player.renderer.getQualities) return;
1944
-
1945
- const qualities = this.player.renderer.getQualities();
1946
- if (qualities.length === 0) {
1947
- this.controls.qualityText.textContent = '';
1948
- return;
1949
- }
1950
-
1951
- // Get current quality
1952
- let currentQualityText = '';
1953
-
1954
- // Check if it's HLS with auto mode
1955
- if (this.player.renderer.hls && this.player.renderer.hls.currentLevel === -1) {
1956
- currentQualityText = 'Auto';
1957
- } else if (this.player.renderer.getCurrentQuality) {
1958
- const currentIndex = this.player.renderer.getCurrentQuality();
1959
- const currentQuality = qualities.find(q => q.index === currentIndex);
1960
- if (currentQuality) {
1961
- currentQualityText = currentQuality.height ? `${currentQuality.height}p` : '';
1962
- }
1963
- }
1964
-
1965
- this.controls.qualityText.textContent = currentQualityText;
1966
- }
1967
-
1968
- setupAutoHide() {
1969
- if (this.player.element.tagName !== 'VIDEO') return;
1970
-
1971
- const showControls = () => {
1972
- this.element.classList.add(`${this.player.options.classPrefix}-controls-visible`);
1973
- this.player.container.classList.add(`${this.player.options.classPrefix}-controls-visible`);
1974
- this.player.state.controlsVisible = true;
1975
-
1976
- clearTimeout(this.hideTimeout);
1977
-
1978
- if (this.player.state.playing) {
1979
- this.hideTimeout = setTimeout(() => {
1980
- this.element.classList.remove(`${this.player.options.classPrefix}-controls-visible`);
1981
- this.player.container.classList.remove(`${this.player.options.classPrefix}-controls-visible`);
1982
- this.player.state.controlsVisible = false;
1983
- }, this.player.options.hideControlsDelay);
1984
- }
1985
- };
1986
-
1987
- this.player.container.addEventListener('mousemove', showControls);
1988
- this.player.container.addEventListener('touchstart', showControls);
1989
- this.player.container.addEventListener('click', showControls);
1990
-
1991
- // Show controls on focus
1992
- this.element.addEventListener('focusin', showControls);
1993
-
1994
- // Always show when paused
1995
- this.player.on('pause', () => {
1996
- showControls();
1997
- clearTimeout(this.hideTimeout);
1998
- });
1999
-
2000
- this.player.on('play', () => {
2001
- showControls();
2002
- });
2003
-
2004
- // Initial state
2005
- showControls();
2006
- }
2007
-
2008
- show() {
2009
- this.element.style.display = '';
2010
- }
2011
-
2012
- hide() {
2013
- this.element.style.display = 'none';
2014
- }
2015
-
2016
- destroy() {
2017
- if (this.hideTimeout) {
2018
- clearTimeout(this.hideTimeout);
2019
- }
2020
-
2021
- if (this.element && this.element.parentNode) {
2022
- this.element.parentNode.removeChild(this.element);
2023
- }
2024
- }
2025
- }
2026
-
1
+ /**
2
+ * Control Bar Component
3
+ */
4
+
5
+ import {DOMUtils} from '../utils/DOMUtils.js';
6
+ import {TimeUtils} from '../utils/TimeUtils.js';
7
+ import {createIconElement} from '../icons/Icons.js';
8
+ import {i18n} from '../i18n/i18n.js';
9
+
10
+ export class ControlBar {
11
+ constructor(player) {
12
+ this.player = player;
13
+ this.element = null;
14
+ this.controls = {};
15
+ this.hideTimeout = null;
16
+ this.isDraggingProgress = false;
17
+ this.isDraggingVolume = false;
18
+
19
+ this.init();
20
+ }
21
+
22
+ init() {
23
+ this.createElement();
24
+ this.createControls();
25
+ this.attachEvents();
26
+ this.setupAutoHide();
27
+ }
28
+
29
+ // Helper method to check if we're on a mobile device
30
+ isMobile() {
31
+ return window.innerWidth < 640;
32
+ }
33
+
34
+ // Smart menu positioning to avoid overflow
35
+ positionMenu(menu, button) {
36
+ const isMobile = this.isMobile();
37
+
38
+ if (isMobile) {
39
+ // Use bottom sheet on mobile - already styled via CSS
40
+ return;
41
+ }
42
+
43
+ // Desktop: Smart positioning
44
+ setTimeout(() => {
45
+ const buttonRect = button.getBoundingClientRect();
46
+ const menuRect = menu.getBoundingClientRect();
47
+ const viewportWidth = window.innerWidth;
48
+ const viewportHeight = window.innerHeight;
49
+
50
+ const spaceAbove = buttonRect.top;
51
+ const spaceBelow = viewportHeight - buttonRect.bottom;
52
+
53
+ // Prefer above, but switch to below if not enough space
54
+ if (spaceAbove < menuRect.height + 20 && spaceBelow > spaceAbove) {
55
+ menu.style.bottom = 'auto';
56
+ menu.style.top = 'calc(100% + 8px)';
57
+ menu.classList.add('vidply-menu-below');
58
+ }
59
+
60
+ // Check horizontal overflow
61
+ const menuLeft = buttonRect.left + buttonRect.width / 2 - menuRect.width / 2;
62
+ if (menuLeft < 10) {
63
+ // Too far left, align to left edge
64
+ menu.style.right = 'auto';
65
+ menu.style.left = '0';
66
+ menu.style.transform = 'translateX(0)';
67
+ } else if (menuLeft + menuRect.width > viewportWidth - 10) {
68
+ // Too far right, align to right edge
69
+ menu.style.left = 'auto';
70
+ menu.style.right = '0';
71
+ menu.style.transform = 'translateX(0)';
72
+ }
73
+ }, 0);
74
+ }
75
+
76
+
77
+ // Helper method to attach close-on-outside-click behavior to menus
78
+ attachMenuCloseHandler(menu, button, preventCloseOnInteraction = false) {
79
+ // Position menu smartly
80
+ this.positionMenu(menu, button);
81
+
82
+ setTimeout(() => {
83
+ const closeMenu = (e) => {
84
+ // If this menu has form controls, don't close when clicking inside
85
+ if (preventCloseOnInteraction && menu.contains(e.target)) {
86
+ return;
87
+ }
88
+
89
+ // Check if click is outside menu and button
90
+ if (!menu.contains(e.target) && !button.contains(e.target)) {
91
+ menu.remove();
92
+ document.removeEventListener('click', closeMenu);
93
+ document.removeEventListener('keydown', handleEscape);
94
+ }
95
+ };
96
+
97
+ const handleEscape = (e) => {
98
+ if (e.key === 'Escape') {
99
+ menu.remove();
100
+ document.removeEventListener('click', closeMenu);
101
+ document.removeEventListener('keydown', handleEscape);
102
+ // Return focus to button
103
+ button.focus();
104
+ }
105
+ };
106
+
107
+ document.addEventListener('click', closeMenu);
108
+ document.addEventListener('keydown', handleEscape);
109
+ }, 100);
110
+ }
111
+
112
+ // Helper method to add keyboard navigation to menus (arrow keys)
113
+ attachMenuKeyboardNavigation(menu) {
114
+ const menuItems = Array.from(menu.querySelectorAll(`.${this.player.options.classPrefix}-menu-item`));
115
+
116
+ if (menuItems.length === 0) return;
117
+
118
+ const handleKeyDown = (e) => {
119
+ const currentIndex = menuItems.indexOf(document.activeElement);
120
+
121
+ switch (e.key) {
122
+ case 'ArrowDown':
123
+ e.preventDefault();
124
+ const nextIndex = (currentIndex + 1) % menuItems.length;
125
+ menuItems[nextIndex].focus();
126
+ break;
127
+
128
+ case 'ArrowUp':
129
+ e.preventDefault();
130
+ const prevIndex = (currentIndex - 1 + menuItems.length) % menuItems.length;
131
+ menuItems[prevIndex].focus();
132
+ break;
133
+
134
+ case 'Home':
135
+ e.preventDefault();
136
+ menuItems[0].focus();
137
+ break;
138
+
139
+ case 'End':
140
+ e.preventDefault();
141
+ menuItems[menuItems.length - 1].focus();
142
+ break;
143
+
144
+ case 'Enter':
145
+ case ' ':
146
+ e.preventDefault();
147
+ if (document.activeElement && menuItems.includes(document.activeElement)) {
148
+ document.activeElement.click();
149
+ }
150
+ break;
151
+ }
152
+ };
153
+
154
+ menu.addEventListener('keydown', handleKeyDown);
155
+ }
156
+
157
+ createElement() {
158
+ this.element = DOMUtils.createElement('div', {
159
+ className: `${this.player.options.classPrefix}-controls`,
160
+ attributes: {
161
+ 'role': 'region',
162
+ 'aria-label': i18n.t('player.label') + ' controls'
163
+ }
164
+ });
165
+ }
166
+
167
+ createControls() {
168
+ // Progress bar container
169
+ if (this.player.options.progressBar) {
170
+ this.createProgressBar();
171
+ }
172
+
173
+ // Button container
174
+ const buttonContainer = DOMUtils.createElement('div', {
175
+ className: `${this.player.options.classPrefix}-controls-buttons`
176
+ });
177
+
178
+ // Left buttons
179
+ const leftButtons = DOMUtils.createElement('div', {
180
+ className: `${this.player.options.classPrefix}-controls-left`
181
+ });
182
+
183
+ // Previous track button (if playlist)
184
+ if (this.player.playlistManager) {
185
+ leftButtons.appendChild(this.createPreviousButton());
186
+ }
187
+
188
+ // Play/Pause button
189
+ if (this.player.options.playPauseButton) {
190
+ leftButtons.appendChild(this.createPlayPauseButton());
191
+ }
192
+
193
+ // Restart button (right beside play button)
194
+ leftButtons.appendChild(this.createRestartButton());
195
+
196
+ // Next track button (if playlist)
197
+ if (this.player.playlistManager) {
198
+ leftButtons.appendChild(this.createNextButton());
199
+ }
200
+
201
+ // Rewind button (not shown in playlist mode)
202
+ if (!this.player.playlistManager) {
203
+ leftButtons.appendChild(this.createRewindButton());
204
+ }
205
+
206
+ // Forward button (not shown in playlist mode)
207
+ if (!this.player.playlistManager) {
208
+ leftButtons.appendChild(this.createForwardButton());
209
+ }
210
+
211
+ // Volume control
212
+ if (this.player.options.volumeControl) {
213
+ leftButtons.appendChild(this.createVolumeControl());
214
+ }
215
+
216
+ // Time display
217
+ if (this.player.options.currentTime || this.player.options.duration) {
218
+ leftButtons.appendChild(this.createTimeDisplay());
219
+ }
220
+
221
+ // Right buttons
222
+ this.rightButtons = DOMUtils.createElement('div', {
223
+ className: `${this.player.options.classPrefix}-controls-right`
224
+ });
225
+
226
+ // Check for available features
227
+ const hasChapters = this.hasChapterTracks();
228
+ const hasCaptions = this.hasCaptionTracks();
229
+ const hasQualityLevels = this.hasQualityLevels();
230
+ const hasAudioDescription = this.hasAudioDescription();
231
+
232
+ // Chapters button - only show if chapters are available
233
+ if (this.player.options.chaptersButton && hasChapters) {
234
+ this.rightButtons.appendChild(this.createChaptersButton());
235
+ }
236
+
237
+ // Quality button - only show if quality levels are available
238
+ if (this.player.options.qualityButton && hasQualityLevels) {
239
+ this.rightButtons.appendChild(this.createQualityButton());
240
+ }
241
+
242
+ // Caption styling button (font, size, color) - only show if captions are available
243
+ if (this.player.options.captionStyleButton && hasCaptions) {
244
+ this.rightButtons.appendChild(this.createCaptionStyleButton());
245
+ }
246
+
247
+ // Speed button - always available
248
+ if (this.player.options.speedButton) {
249
+ this.rightButtons.appendChild(this.createSpeedButton());
250
+ }
251
+
252
+ // Captions language selector button - only show if captions are available
253
+ if (this.player.options.captionsButton && hasCaptions) {
254
+ this.rightButtons.appendChild(this.createCaptionsButton());
255
+ }
256
+
257
+ // Transcript button - only show if captions/subtitles are available
258
+ if (this.player.options.transcriptButton && hasCaptions) {
259
+ this.rightButtons.appendChild(this.createTranscriptButton());
260
+ }
261
+
262
+ // Audio Description button - only show if audio description source is available
263
+ if (this.player.options.audioDescriptionButton && hasAudioDescription) {
264
+ this.rightButtons.appendChild(this.createAudioDescriptionButton());
265
+ }
266
+
267
+ // Sign Language button - only show if sign language source is available
268
+ const hasSignLanguage = this.hasSignLanguage();
269
+ if (this.player.options.signLanguageButton && hasSignLanguage) {
270
+ this.rightButtons.appendChild(this.createSignLanguageButton());
271
+ }
272
+
273
+ // PiP button
274
+ if (this.player.options.pipButton && 'pictureInPictureEnabled' in document) {
275
+ this.rightButtons.appendChild(this.createPipButton());
276
+ }
277
+
278
+ // Fullscreen button
279
+ if (this.player.options.fullscreenButton) {
280
+ this.rightButtons.appendChild(this.createFullscreenButton());
281
+ }
282
+
283
+ buttonContainer.appendChild(leftButtons);
284
+ buttonContainer.appendChild(this.rightButtons);
285
+ this.element.appendChild(buttonContainer);
286
+ }
287
+
288
+ // Helper methods to check for available features
289
+ hasChapterTracks() {
290
+ const textTracks = this.player.element.textTracks;
291
+ for (let i = 0; i < textTracks.length; i++) {
292
+ if (textTracks[i].kind === 'chapters') {
293
+ return true;
294
+ }
295
+ }
296
+ return false;
297
+ }
298
+
299
+ hasCaptionTracks() {
300
+ const textTracks = this.player.element.textTracks;
301
+ for (let i = 0; i < textTracks.length; i++) {
302
+ if (textTracks[i].kind === 'captions' || textTracks[i].kind === 'subtitles') {
303
+ return true;
304
+ }
305
+ }
306
+ return false;
307
+ }
308
+
309
+ hasQualityLevels() {
310
+ // Check if renderer supports quality selection
311
+ if (this.player.renderer && this.player.renderer.getQualities) {
312
+ const qualities = this.player.renderer.getQualities();
313
+ return qualities && qualities.length > 1;
314
+ }
315
+ return false;
316
+ }
317
+
318
+ hasAudioDescription() {
319
+ // Check for audio-described video source OR description tracks
320
+ if (this.player.audioDescriptionSrc && this.player.audioDescriptionSrc.length > 0) {
321
+ return true;
322
+ }
323
+
324
+ // Check for description tracks
325
+ const textTracks = Array.from(this.player.element.textTracks || []);
326
+ return textTracks.some(track => track.kind === 'descriptions');
327
+ }
328
+
329
+ hasSignLanguage() {
330
+ return this.player.signLanguageSrc && this.player.signLanguageSrc.length > 0;
331
+ }
332
+
333
+ createProgressBar() {
334
+ const progressContainer = DOMUtils.createElement('div', {
335
+ className: `${this.player.options.classPrefix}-progress-container`,
336
+ attributes: {
337
+ 'role': 'slider',
338
+ 'aria-label': i18n.t('player.progress'),
339
+ 'aria-valuemin': '0',
340
+ 'aria-valuemax': '100',
341
+ 'aria-valuenow': '0',
342
+ 'tabindex': '0'
343
+ }
344
+ });
345
+
346
+ // Buffered progress
347
+ this.controls.buffered = DOMUtils.createElement('div', {
348
+ className: `${this.player.options.classPrefix}-progress-buffered`
349
+ });
350
+
351
+ // Played progress
352
+ this.controls.played = DOMUtils.createElement('div', {
353
+ className: `${this.player.options.classPrefix}-progress-played`
354
+ });
355
+
356
+ // Progress handle
357
+ this.controls.progressHandle = DOMUtils.createElement('div', {
358
+ className: `${this.player.options.classPrefix}-progress-handle`
359
+ });
360
+
361
+ // Tooltip
362
+ this.controls.progressTooltip = DOMUtils.createElement('div', {
363
+ className: `${this.player.options.classPrefix}-progress-tooltip`
364
+ });
365
+
366
+ progressContainer.appendChild(this.controls.buffered);
367
+ progressContainer.appendChild(this.controls.played);
368
+ this.controls.played.appendChild(this.controls.progressHandle);
369
+ progressContainer.appendChild(this.controls.progressTooltip);
370
+
371
+ this.controls.progress = progressContainer;
372
+ this.element.appendChild(progressContainer);
373
+
374
+ // Progress bar events
375
+ this.setupProgressBarEvents();
376
+ }
377
+
378
+ setupProgressBarEvents() {
379
+ const progress = this.controls.progress;
380
+
381
+ const updateProgress = (clientX) => {
382
+ const rect = progress.getBoundingClientRect();
383
+ const percent = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
384
+ const time = percent * this.player.state.duration;
385
+ return {percent, time};
386
+ };
387
+
388
+ // Mouse events
389
+ progress.addEventListener('mousedown', (e) => {
390
+ this.isDraggingProgress = true;
391
+ const {time} = updateProgress(e.clientX);
392
+ this.player.seek(time);
393
+ });
394
+
395
+ document.addEventListener('mousemove', (e) => {
396
+ if (this.isDraggingProgress) {
397
+ const {time} = updateProgress(e.clientX);
398
+ this.player.seek(time);
399
+ }
400
+ });
401
+
402
+ document.addEventListener('mouseup', () => {
403
+ this.isDraggingProgress = false;
404
+ });
405
+
406
+ // Hover tooltip
407
+ progress.addEventListener('mousemove', (e) => {
408
+ if (!this.isDraggingProgress) {
409
+ const {time} = updateProgress(e.clientX);
410
+ this.controls.progressTooltip.textContent = TimeUtils.formatTime(time);
411
+ this.controls.progressTooltip.style.left = `${e.clientX - progress.getBoundingClientRect().left}px`;
412
+ this.controls.progressTooltip.style.display = 'block';
413
+ }
414
+ });
415
+
416
+ progress.addEventListener('mouseleave', () => {
417
+ this.controls.progressTooltip.style.display = 'none';
418
+ });
419
+
420
+ // Keyboard navigation
421
+ progress.addEventListener('keydown', (e) => {
422
+ if (e.key === 'ArrowLeft') {
423
+ e.preventDefault();
424
+ this.player.seekBackward(5);
425
+ } else if (e.key === 'ArrowRight') {
426
+ e.preventDefault();
427
+ this.player.seekForward(5);
428
+ }
429
+ });
430
+
431
+ // Touch events
432
+ progress.addEventListener('touchstart', (e) => {
433
+ this.isDraggingProgress = true;
434
+ const touch = e.touches[0];
435
+ const {time} = updateProgress(touch.clientX);
436
+ this.player.seek(time);
437
+ });
438
+
439
+ progress.addEventListener('touchmove', (e) => {
440
+ if (this.isDraggingProgress) {
441
+ e.preventDefault();
442
+ const touch = e.touches[0];
443
+ const {time} = updateProgress(touch.clientX);
444
+ this.player.seek(time);
445
+ }
446
+ });
447
+
448
+ progress.addEventListener('touchend', () => {
449
+ this.isDraggingProgress = false;
450
+ });
451
+ }
452
+
453
+ createPlayPauseButton() {
454
+ const button = DOMUtils.createElement('button', {
455
+ className: `${this.player.options.classPrefix}-button ${this.player.options.classPrefix}-play-pause`,
456
+ attributes: {
457
+ 'type': 'button',
458
+ 'aria-label': i18n.t('player.play')
459
+ }
460
+ });
461
+
462
+ button.appendChild(createIconElement('play'));
463
+
464
+ button.addEventListener('click', () => {
465
+ this.player.toggle();
466
+ });
467
+
468
+ this.controls.playPause = button;
469
+ return button;
470
+ }
471
+
472
+ createRestartButton() {
473
+ const button = DOMUtils.createElement('button', {
474
+ className: `${this.player.options.classPrefix}-button ${this.player.options.classPrefix}-restart`,
475
+ attributes: {
476
+ 'type': 'button',
477
+ 'aria-label': i18n.t('player.restart')
478
+ }
479
+ });
480
+
481
+ button.appendChild(createIconElement('restart'));
482
+
483
+ button.addEventListener('click', () => {
484
+ this.player.seek(0);
485
+ this.player.play();
486
+ });
487
+
488
+ return button;
489
+ }
490
+
491
+ createPreviousButton() {
492
+ const button = DOMUtils.createElement('button', {
493
+ className: `${this.player.options.classPrefix}-button ${this.player.options.classPrefix}-previous`,
494
+ attributes: {
495
+ 'type': 'button',
496
+ 'aria-label': i18n.t('player.previous')
497
+ }
498
+ });
499
+
500
+ button.appendChild(createIconElement('skipPrevious'));
501
+
502
+ button.addEventListener('click', () => {
503
+ if (this.player.playlistManager) {
504
+ this.player.playlistManager.previous();
505
+ }
506
+ });
507
+
508
+ // Update button state
509
+ const updateState = () => {
510
+ if (this.player.playlistManager) {
511
+ button.disabled = !this.player.playlistManager.hasPrevious() && !this.player.playlistManager.options.loop;
512
+ }
513
+ };
514
+ this.player.on('playlisttrackchange', updateState);
515
+ updateState();
516
+
517
+ this.controls.previous = button;
518
+ return button;
519
+ }
520
+
521
+ createNextButton() {
522
+ const button = DOMUtils.createElement('button', {
523
+ className: `${this.player.options.classPrefix}-button ${this.player.options.classPrefix}-next`,
524
+ attributes: {
525
+ 'type': 'button',
526
+ 'aria-label': i18n.t('player.next')
527
+ }
528
+ });
529
+
530
+ button.appendChild(createIconElement('skipNext'));
531
+
532
+ button.addEventListener('click', () => {
533
+ if (this.player.playlistManager) {
534
+ this.player.playlistManager.next();
535
+ }
536
+ });
537
+
538
+ // Update button state
539
+ const updateState = () => {
540
+ if (this.player.playlistManager) {
541
+ button.disabled = !this.player.playlistManager.hasNext() && !this.player.playlistManager.options.loop;
542
+ }
543
+ };
544
+ this.player.on('playlisttrackchange', updateState);
545
+ updateState();
546
+
547
+ this.controls.next = button;
548
+ return button;
549
+ }
550
+
551
+ createRewindButton() {
552
+ const button = DOMUtils.createElement('button', {
553
+ className: `${this.player.options.classPrefix}-button ${this.player.options.classPrefix}-rewind`,
554
+ attributes: {
555
+ 'type': 'button',
556
+ 'aria-label': i18n.t('player.rewindSeconds', { seconds: 15 })
557
+ }
558
+ });
559
+
560
+ button.appendChild(createIconElement('rewind'));
561
+
562
+ button.addEventListener('click', () => {
563
+ this.player.seekBackward(15);
564
+ });
565
+
566
+ return button;
567
+ }
568
+
569
+ createForwardButton() {
570
+ const button = DOMUtils.createElement('button', {
571
+ className: `${this.player.options.classPrefix}-button ${this.player.options.classPrefix}-forward`,
572
+ attributes: {
573
+ 'type': 'button',
574
+ 'aria-label': i18n.t('player.forwardSeconds', { seconds: 15 })
575
+ }
576
+ });
577
+
578
+ button.appendChild(createIconElement('forward'));
579
+
580
+ button.addEventListener('click', () => {
581
+ this.player.seekForward(15);
582
+ });
583
+
584
+ return button;
585
+ }
586
+
587
+ createVolumeControl() {
588
+ // Mute/Volume button
589
+ const muteButton = DOMUtils.createElement('button', {
590
+ className: `${this.player.options.classPrefix}-button ${this.player.options.classPrefix}-mute`,
591
+ attributes: {
592
+ 'type': 'button',
593
+ 'aria-label': i18n.t('player.volume'),
594
+ 'aria-haspopup': 'true'
595
+ }
596
+ });
597
+
598
+ muteButton.appendChild(createIconElement('volumeHigh'));
599
+
600
+ // Toggle mute on right click, show volume slider on left click
601
+ muteButton.addEventListener('contextmenu', (e) => {
602
+ e.preventDefault();
603
+ this.player.toggleMute();
604
+ });
605
+
606
+ muteButton.addEventListener('click', () => {
607
+ this.showVolumeSlider(muteButton);
608
+ });
609
+
610
+ this.controls.mute = muteButton;
611
+
612
+ return muteButton;
613
+ }
614
+
615
+ showVolumeSlider(button) {
616
+ // Remove existing slider if any
617
+ const existingSlider = document.querySelector(`.${this.player.options.classPrefix}-volume-menu`);
618
+ if (existingSlider) {
619
+ existingSlider.remove();
620
+ return;
621
+ }
622
+
623
+ // Volume menu container
624
+ const volumeMenu = DOMUtils.createElement('div', {
625
+ className: `${this.player.options.classPrefix}-volume-menu ${this.player.options.classPrefix}-menu`
626
+ });
627
+
628
+ const volumeSlider = DOMUtils.createElement('div', {
629
+ className: `${this.player.options.classPrefix}-volume-slider`,
630
+ attributes: {
631
+ 'role': 'slider',
632
+ 'aria-label': i18n.t('player.volume'),
633
+ 'aria-valuemin': '0',
634
+ 'aria-valuemax': '100',
635
+ 'aria-valuenow': String(Math.round(this.player.state.volume * 100)),
636
+ 'tabindex': '0'
637
+ }
638
+ });
639
+
640
+ const volumeTrack = DOMUtils.createElement('div', {
641
+ className: `${this.player.options.classPrefix}-volume-track`
642
+ });
643
+
644
+ const volumeFill = DOMUtils.createElement('div', {
645
+ className: `${this.player.options.classPrefix}-volume-fill`
646
+ });
647
+
648
+ const volumeHandle = DOMUtils.createElement('div', {
649
+ className: `${this.player.options.classPrefix}-volume-handle`
650
+ });
651
+
652
+ volumeTrack.appendChild(volumeFill);
653
+ volumeFill.appendChild(volumeHandle);
654
+ volumeSlider.appendChild(volumeTrack);
655
+ volumeMenu.appendChild(volumeSlider);
656
+
657
+ // Volume slider events
658
+ const updateVolume = (clientY) => {
659
+ const rect = volumeTrack.getBoundingClientRect();
660
+ const percent = Math.max(0, Math.min(1, 1 - ((clientY - rect.top) / rect.height)));
661
+ this.player.setVolume(percent);
662
+ };
663
+
664
+ volumeSlider.addEventListener('mousedown', (e) => {
665
+ e.stopPropagation();
666
+ this.isDraggingVolume = true;
667
+ updateVolume(e.clientY);
668
+ });
669
+
670
+ document.addEventListener('mousemove', (e) => {
671
+ if (this.isDraggingVolume) {
672
+ updateVolume(e.clientY);
673
+ }
674
+ });
675
+
676
+ document.addEventListener('mouseup', () => {
677
+ this.isDraggingVolume = false;
678
+ });
679
+
680
+ // Keyboard volume control
681
+ volumeSlider.addEventListener('keydown', (e) => {
682
+ if (e.key === 'ArrowUp') {
683
+ e.preventDefault();
684
+ this.player.setVolume(Math.min(1, this.player.state.volume + 0.1));
685
+ } else if (e.key === 'ArrowDown') {
686
+ e.preventDefault();
687
+ this.player.setVolume(Math.max(0, this.player.state.volume - 0.1));
688
+ }
689
+ });
690
+
691
+ // Prevent menu from closing when interacting with slider
692
+ volumeMenu.addEventListener('click', (e) => {
693
+ e.stopPropagation();
694
+ });
695
+
696
+ // Append menu to button
697
+ button.appendChild(volumeMenu);
698
+
699
+ this.controls.volumeSlider = volumeSlider;
700
+ this.controls.volumeFill = volumeFill;
701
+
702
+ // Close menu on outside click
703
+ this.attachMenuCloseHandler(volumeMenu, button, true);
704
+ }
705
+
706
+ createTimeDisplay() {
707
+ const container = DOMUtils.createElement('div', {
708
+ className: `${this.player.options.classPrefix}-time`,
709
+ attributes: {
710
+ 'role': 'group',
711
+ 'aria-label': i18n.t('time.display')
712
+ }
713
+ });
714
+
715
+ // Current time - visual text hidden, only aria-label announced
716
+ this.controls.currentTimeDisplay = DOMUtils.createElement('span', {
717
+ className: `${this.player.options.classPrefix}-current-time`,
718
+ attributes: {
719
+ 'aria-label': i18n.t('time.seconds', { count: 0 })
720
+ }
721
+ });
722
+
723
+ // Create visual text inside, hidden from screen readers
724
+ const currentTimeVisual = DOMUtils.createElement('span', {
725
+ textContent: '00:00',
726
+ attributes: {
727
+ 'aria-hidden': 'true'
728
+ }
729
+ });
730
+ this.controls.currentTimeDisplay.appendChild(currentTimeVisual);
731
+ this.controls.currentTimeVisual = currentTimeVisual;
732
+
733
+ const separator = DOMUtils.createElement('span', {
734
+ textContent: ' / ',
735
+ attributes: {
736
+ 'aria-hidden': 'true'
737
+ }
738
+ });
739
+
740
+ // Duration - visual text hidden, only aria-label announced
741
+ this.controls.durationDisplay = DOMUtils.createElement('span', {
742
+ className: `${this.player.options.classPrefix}-duration`,
743
+ attributes: {
744
+ 'aria-label': i18n.t('time.durationPrefix') + i18n.t('time.seconds', { count: 0 })
745
+ }
746
+ });
747
+
748
+ // Create visual text inside, hidden from screen readers
749
+ const durationVisual = DOMUtils.createElement('span', {
750
+ textContent: '00:00',
751
+ attributes: {
752
+ 'aria-hidden': 'true'
753
+ }
754
+ });
755
+ this.controls.durationDisplay.appendChild(durationVisual);
756
+ this.controls.durationVisual = durationVisual;
757
+
758
+ container.appendChild(this.controls.currentTimeDisplay);
759
+ container.appendChild(separator);
760
+ container.appendChild(this.controls.durationDisplay);
761
+
762
+ return container;
763
+ }
764
+
765
+ createChaptersButton() {
766
+ const button = DOMUtils.createElement('button', {
767
+ className: `${this.player.options.classPrefix}-button ${this.player.options.classPrefix}-chapters`,
768
+ attributes: {
769
+ 'type': 'button',
770
+ 'aria-label': i18n.t('player.chapters'),
771
+ 'aria-haspopup': 'menu'
772
+ }
773
+ });
774
+
775
+ button.appendChild(createIconElement('playlist'));
776
+
777
+ button.addEventListener('click', () => {
778
+ this.showChaptersMenu(button);
779
+ });
780
+
781
+ this.controls.chapters = button;
782
+ return button;
783
+ }
784
+
785
+ showChaptersMenu(button) {
786
+ // Remove existing menu if any
787
+ const existingMenu = document.querySelector(`.${this.player.options.classPrefix}-chapters-menu`);
788
+ if (existingMenu) {
789
+ existingMenu.remove();
790
+ return;
791
+ }
792
+
793
+ const menu = DOMUtils.createElement('div', {
794
+ className: `${this.player.options.classPrefix}-chapters-menu ${this.player.options.classPrefix}-menu`,
795
+ attributes: {
796
+ 'role': 'menu',
797
+ 'aria-label': i18n.t('player.chapters')
798
+ }
799
+ });
800
+
801
+ // Get chapter tracks
802
+ const chapterTracks = Array.from(this.player.element.textTracks).filter(
803
+ track => track.kind === 'chapters'
804
+ );
805
+
806
+ if (chapterTracks.length === 0) {
807
+ // No chapters available
808
+ const noChaptersItem = DOMUtils.createElement('div', {
809
+ className: `${this.player.options.classPrefix}-menu-item`,
810
+ textContent: i18n.t('player.noChapters'),
811
+ style: {opacity: '0.5', cursor: 'default'}
812
+ });
813
+ menu.appendChild(noChaptersItem);
814
+ } else {
815
+ const chapterTrack = chapterTracks[0];
816
+
817
+ // Ensure track is in 'hidden' mode to load cues
818
+ if (chapterTrack.mode === 'disabled') {
819
+ chapterTrack.mode = 'hidden';
820
+ }
821
+
822
+ if (!chapterTrack.cues || chapterTrack.cues.length === 0) {
823
+ // Cues not loaded yet - wait for them to load
824
+ const loadingItem = DOMUtils.createElement('div', {
825
+ className: `${this.player.options.classPrefix}-menu-item`,
826
+ textContent: i18n.t('player.loadingChapters'),
827
+ style: {opacity: '0.5', cursor: 'default'}
828
+ });
829
+ menu.appendChild(loadingItem);
830
+
831
+ // Listen for track load event
832
+ const onTrackLoad = () => {
833
+ // Remove loading message and rebuild menu
834
+ menu.remove();
835
+ this.showChaptersMenu(button);
836
+ };
837
+
838
+ chapterTrack.addEventListener('load', onTrackLoad, {once: true});
839
+
840
+ // Also try again after a short delay as fallback
841
+ setTimeout(() => {
842
+ if (chapterTrack.cues && chapterTrack.cues.length > 0 && document.contains(menu)) {
843
+ menu.remove();
844
+ this.showChaptersMenu(button);
845
+ }
846
+ }, 500);
847
+ } else {
848
+ // Display chapters
849
+ const cues = chapterTrack.cues;
850
+ for (let i = 0; i < cues.length; i++) {
851
+ const cue = cues[i];
852
+ const item = DOMUtils.createElement('button', {
853
+ className: `${this.player.options.classPrefix}-menu-item`,
854
+ attributes: {
855
+ 'type': 'button',
856
+ 'role': 'menuitem',
857
+ 'tabindex': '-1'
858
+ }
859
+ });
860
+
861
+ const timeLabel = DOMUtils.createElement('span', {
862
+ className: `${this.player.options.classPrefix}-chapter-time`,
863
+ textContent: TimeUtils.formatTime(cue.startTime)
864
+ });
865
+
866
+ const titleLabel = DOMUtils.createElement('span', {
867
+ className: `${this.player.options.classPrefix}-chapter-title`,
868
+ textContent: cue.text
869
+ });
870
+
871
+ item.appendChild(timeLabel);
872
+ item.appendChild(document.createTextNode(' '));
873
+ item.appendChild(titleLabel);
874
+
875
+ item.addEventListener('click', () => {
876
+ this.player.seek(cue.startTime);
877
+ menu.remove();
878
+ });
879
+
880
+ menu.appendChild(item);
881
+ }
882
+
883
+ // Add keyboard navigation
884
+ this.attachMenuKeyboardNavigation(menu);
885
+
886
+ // Focus first item
887
+ setTimeout(() => {
888
+ const firstItem = menu.querySelector(`.${this.player.options.classPrefix}-menu-item`);
889
+ if (firstItem) {
890
+ firstItem.focus();
891
+ }
892
+ }, 0);
893
+ }
894
+ }
895
+
896
+ // Append menu directly to button for proper positioning
897
+ button.appendChild(menu);
898
+
899
+ // Close menu on outside click
900
+ this.attachMenuCloseHandler(menu, button);
901
+ }
902
+
903
+ createQualityButton() {
904
+ const button = DOMUtils.createElement('button', {
905
+ className: `${this.player.options.classPrefix}-button ${this.player.options.classPrefix}-quality`,
906
+ attributes: {
907
+ 'type': 'button',
908
+ 'aria-label': i18n.t('player.quality'),
909
+ 'aria-haspopup': 'menu'
910
+ }
911
+ });
912
+
913
+ button.appendChild(createIconElement('hd'));
914
+
915
+ // Add quality indicator text
916
+ const qualityText = DOMUtils.createElement('span', {
917
+ className: `${this.player.options.classPrefix}-quality-text`,
918
+ textContent: ''
919
+ });
920
+ button.appendChild(qualityText);
921
+
922
+ button.addEventListener('click', () => {
923
+ this.showQualityMenu(button);
924
+ });
925
+
926
+ this.controls.quality = button;
927
+ this.controls.qualityText = qualityText;
928
+
929
+ // Update quality indicator after a short delay to ensure renderer is ready
930
+ setTimeout(() => this.updateQualityIndicator(), 500);
931
+
932
+ return button;
933
+ }
934
+
935
+ showQualityMenu(button) {
936
+ // Remove existing menu if any
937
+ const existingMenu = document.querySelector(`.${this.player.options.classPrefix}-quality-menu`);
938
+ if (existingMenu) {
939
+ existingMenu.remove();
940
+ return;
941
+ }
942
+
943
+ const menu = DOMUtils.createElement('div', {
944
+ className: `${this.player.options.classPrefix}-quality-menu ${this.player.options.classPrefix}-menu`,
945
+ attributes: {
946
+ 'role': 'menu',
947
+ 'aria-label': i18n.t('player.quality')
948
+ }
949
+ });
950
+
951
+ // Check if renderer supports quality selection
952
+ if (this.player.renderer && this.player.renderer.getQualities) {
953
+ const qualities = this.player.renderer.getQualities();
954
+ const currentQuality = this.player.renderer.getCurrentQuality ? this.player.renderer.getCurrentQuality() : -1;
955
+ const isHLS = this.player.renderer.hls !== undefined;
956
+
957
+ if (qualities.length === 0) {
958
+ // No qualities available
959
+ const noQualityItem = DOMUtils.createElement('div', {
960
+ className: `${this.player.options.classPrefix}-menu-item`,
961
+ textContent: i18n.t('player.autoQuality'),
962
+ style: {opacity: '0.5', cursor: 'default'}
963
+ });
964
+ menu.appendChild(noQualityItem);
965
+ } else {
966
+ let activeItem = null;
967
+
968
+ // Auto quality option (only for HLS)
969
+ if (isHLS) {
970
+ const autoItem = DOMUtils.createElement('button', {
971
+ className: `${this.player.options.classPrefix}-menu-item`,
972
+ textContent: i18n.t('player.auto'),
973
+ attributes: {
974
+ 'type': 'button',
975
+ 'role': 'menuitem',
976
+ 'tabindex': '-1'
977
+ }
978
+ });
979
+
980
+ // Check if auto is currently selected
981
+ const isAuto = this.player.renderer.hls && this.player.renderer.hls.currentLevel === -1;
982
+ if (isAuto) {
983
+ autoItem.classList.add(`${this.player.options.classPrefix}-menu-item-active`);
984
+ autoItem.appendChild(createIconElement('check'));
985
+ activeItem = autoItem;
986
+ }
987
+
988
+ autoItem.addEventListener('click', () => {
989
+ if (this.player.renderer.switchQuality) {
990
+ this.player.renderer.switchQuality(-1); // -1 for auto
991
+ }
992
+ menu.remove();
993
+ });
994
+
995
+ menu.appendChild(autoItem);
996
+ }
997
+
998
+ // Quality options
999
+ qualities.forEach(quality => {
1000
+ const item = DOMUtils.createElement('button', {
1001
+ className: `${this.player.options.classPrefix}-menu-item`,
1002
+ textContent: quality.name || `${quality.height}p`,
1003
+ attributes: {
1004
+ 'type': 'button',
1005
+ 'role': 'menuitem',
1006
+ 'tabindex': '-1'
1007
+ }
1008
+ });
1009
+
1010
+ // Highlight current quality
1011
+ if (quality.index === currentQuality) {
1012
+ item.classList.add(`${this.player.options.classPrefix}-menu-item-active`);
1013
+ item.appendChild(createIconElement('check'));
1014
+ activeItem = item;
1015
+ }
1016
+
1017
+ item.addEventListener('click', () => {
1018
+ if (this.player.renderer.switchQuality) {
1019
+ this.player.renderer.switchQuality(quality.index);
1020
+ }
1021
+ menu.remove();
1022
+ });
1023
+
1024
+ menu.appendChild(item);
1025
+ });
1026
+
1027
+ // Add keyboard navigation
1028
+ this.attachMenuKeyboardNavigation(menu);
1029
+
1030
+ // Focus active item or first item
1031
+ setTimeout(() => {
1032
+ const focusTarget = activeItem || menu.querySelector(`.${this.player.options.classPrefix}-menu-item`);
1033
+ if (focusTarget) {
1034
+ focusTarget.focus();
1035
+ }
1036
+ }, 0);
1037
+ }
1038
+ } else {
1039
+ // No quality support
1040
+ const noSupportItem = DOMUtils.createElement('div', {
1041
+ className: `${this.player.options.classPrefix}-menu-item`,
1042
+ textContent: i18n.t('player.noQuality'),
1043
+ style: {opacity: '0.5', cursor: 'default'}
1044
+ });
1045
+ menu.appendChild(noSupportItem);
1046
+ }
1047
+
1048
+ // Append menu directly to button for proper positioning
1049
+ button.appendChild(menu);
1050
+
1051
+ // Close menu on outside click
1052
+ this.attachMenuCloseHandler(menu, button);
1053
+ }
1054
+
1055
+ createCaptionStyleButton() {
1056
+ const button = DOMUtils.createElement('button', {
1057
+ className: `${this.player.options.classPrefix}-button ${this.player.options.classPrefix}-caption-style`,
1058
+ attributes: {
1059
+ 'type': 'button',
1060
+ 'aria-label': i18n.t('player.captionStyling'),
1061
+ 'aria-haspopup': 'menu',
1062
+ 'title': i18n.t('player.captionStyling')
1063
+ }
1064
+ });
1065
+
1066
+ // Create "Aa" text icon for styling
1067
+ const textIcon = DOMUtils.createElement('span', {
1068
+ textContent: 'Aa',
1069
+ style: {
1070
+ fontSize: '14px',
1071
+ fontWeight: 'bold'
1072
+ }
1073
+ });
1074
+ button.appendChild(textIcon);
1075
+
1076
+ button.addEventListener('click', () => {
1077
+ this.showCaptionStyleMenu(button);
1078
+ });
1079
+
1080
+ this.controls.captionStyle = button;
1081
+ return button;
1082
+ }
1083
+
1084
+ showCaptionStyleMenu(button) {
1085
+ // Remove existing menu if any
1086
+ const existingMenu = document.querySelector(`.${this.player.options.classPrefix}-caption-style-menu`);
1087
+ if (existingMenu) {
1088
+ existingMenu.remove();
1089
+ return;
1090
+ }
1091
+
1092
+ const menu = DOMUtils.createElement('div', {
1093
+ className: `${this.player.options.classPrefix}-caption-style-menu ${this.player.options.classPrefix}-menu ${this.player.options.classPrefix}-settings-menu`,
1094
+ attributes: {
1095
+ 'role': 'menu',
1096
+ 'aria-label': i18n.t('player.captionStyling')
1097
+ }
1098
+ });
1099
+
1100
+ // Prevent menu from closing when clicking inside
1101
+ menu.addEventListener('click', (e) => {
1102
+ e.stopPropagation();
1103
+ });
1104
+
1105
+ // Check if there are any caption tracks
1106
+ if (!this.player.captionManager || this.player.captionManager.tracks.length === 0) {
1107
+ // Show "No captions available" message
1108
+ const noTracksItem = DOMUtils.createElement('div', {
1109
+ className: `${this.player.options.classPrefix}-menu-item`,
1110
+ textContent: i18n.t('player.noCaptions'),
1111
+ style: {opacity: '0.5', cursor: 'default', padding: '12px 16px'}
1112
+ });
1113
+ menu.appendChild(noTracksItem);
1114
+
1115
+ // Append menu to button
1116
+ button.appendChild(menu);
1117
+
1118
+ // Close menu on outside click
1119
+ this.attachMenuCloseHandler(menu, button, true);
1120
+ return;
1121
+ }
1122
+
1123
+ // Font Size
1124
+ const fontSizeGroup = this.createStyleControl(
1125
+ i18n.t('styleLabels.fontSize'),
1126
+ 'captionsFontSize',
1127
+ [
1128
+ {label: i18n.t('fontSizes.small'), value: '87.5%'},
1129
+ {label: i18n.t('fontSizes.normal'), value: '100%'},
1130
+ {label: i18n.t('fontSizes.large'), value: '125%'},
1131
+ {label: i18n.t('fontSizes.xlarge'), value: '150%'}
1132
+ ]
1133
+ );
1134
+ menu.appendChild(fontSizeGroup);
1135
+
1136
+ // Font Family
1137
+ const fontFamilyGroup = this.createStyleControl(
1138
+ i18n.t('styleLabels.font'),
1139
+ 'captionsFontFamily',
1140
+ [
1141
+ {label: i18n.t('fontFamilies.sansSerif'), value: 'sans-serif'},
1142
+ {label: i18n.t('fontFamilies.serif'), value: 'serif'},
1143
+ {label: i18n.t('fontFamilies.monospace'), value: 'monospace'}
1144
+ ]
1145
+ );
1146
+ menu.appendChild(fontFamilyGroup);
1147
+
1148
+ // Text Color
1149
+ const colorGroup = this.createColorControl(i18n.t('styleLabels.textColor'), 'captionsColor');
1150
+ menu.appendChild(colorGroup);
1151
+
1152
+ // Background Color
1153
+ const bgColorGroup = this.createColorControl(i18n.t('styleLabels.background'), 'captionsBackgroundColor');
1154
+ menu.appendChild(bgColorGroup);
1155
+
1156
+ // Opacity
1157
+ const opacityGroup = this.createOpacityControl(i18n.t('styleLabels.opacity'), 'captionsOpacity');
1158
+ menu.appendChild(opacityGroup);
1159
+
1160
+ // Set min-width for caption style menu
1161
+ menu.style.minWidth = '220px';
1162
+
1163
+ // Append menu directly to button for proper positioning
1164
+ button.appendChild(menu);
1165
+
1166
+ // Close menu on outside click (but not when interacting with controls)
1167
+ this.attachMenuCloseHandler(menu, button, true);
1168
+
1169
+ // Auto-focus the first select element
1170
+ setTimeout(() => {
1171
+ const firstSelect = menu.querySelector('select');
1172
+ if (firstSelect) {
1173
+ firstSelect.focus();
1174
+ }
1175
+ }, 0);
1176
+ }
1177
+
1178
+ createStyleControl(label, property, options) {
1179
+ const group = DOMUtils.createElement('div', {
1180
+ className: `${this.player.options.classPrefix}-style-group`
1181
+ });
1182
+
1183
+ const labelEl = DOMUtils.createElement('label', {
1184
+ textContent: label,
1185
+ style: {
1186
+ display: 'block',
1187
+ fontSize: '12px',
1188
+ marginBottom: '4px',
1189
+ color: 'rgba(255,255,255,0.7)'
1190
+ }
1191
+ });
1192
+ group.appendChild(labelEl);
1193
+
1194
+ const select = DOMUtils.createElement('select', {
1195
+ className: `${this.player.options.classPrefix}-style-select`,
1196
+ style: {
1197
+ width: '100%',
1198
+ padding: '6px',
1199
+ background: 'var(--vidply-white)',
1200
+ border: '1px solid var(--vidply-white-10)',
1201
+ borderRadius: '4px',
1202
+ color: 'var(--vidply-black)',
1203
+ fontSize: '13px'
1204
+ }
1205
+ });
1206
+
1207
+ const currentValue = this.player.options[property];
1208
+ options.forEach(opt => {
1209
+ const option = DOMUtils.createElement('option', {
1210
+ textContent: opt.label,
1211
+ attributes: {value: opt.value}
1212
+ });
1213
+ if (opt.value === currentValue) {
1214
+ option.selected = true;
1215
+ }
1216
+ select.appendChild(option);
1217
+ });
1218
+
1219
+ // Prevent clicks from closing the menu
1220
+ select.addEventListener('mousedown', (e) => {
1221
+ e.stopPropagation();
1222
+ });
1223
+
1224
+ select.addEventListener('click', (e) => {
1225
+ e.stopPropagation();
1226
+ });
1227
+
1228
+ select.addEventListener('change', (e) => {
1229
+ e.stopPropagation();
1230
+ this.player.options[property] = e.target.value;
1231
+ if (this.player.captionManager) {
1232
+ this.player.captionManager.setCaptionStyle(
1233
+ property.replace('captions', '').charAt(0).toLowerCase() + property.replace('captions', '').slice(1),
1234
+ e.target.value
1235
+ );
1236
+ }
1237
+ });
1238
+
1239
+ group.appendChild(select);
1240
+ return group;
1241
+ }
1242
+
1243
+ createColorControl(label, property) {
1244
+ const group = DOMUtils.createElement('div', {
1245
+ className: `${this.player.options.classPrefix}-style-group`
1246
+ });
1247
+
1248
+ const labelEl = DOMUtils.createElement('label', {
1249
+ textContent: label,
1250
+ style: {
1251
+ display: 'block',
1252
+ fontSize: '12px',
1253
+ marginBottom: '4px',
1254
+ color: 'rgba(255,255,255,0.7)'
1255
+ }
1256
+ });
1257
+ group.appendChild(labelEl);
1258
+
1259
+ const input = DOMUtils.createElement('input', {
1260
+ attributes: {
1261
+ type: 'color',
1262
+ value: this.player.options[property]
1263
+ },
1264
+ style: {
1265
+ width: '100%',
1266
+ height: '32px',
1267
+ padding: '2px',
1268
+ background: 'rgba(255,255,255,0.1)',
1269
+ border: '1px solid rgba(255,255,255,0.2)',
1270
+ borderRadius: '4px',
1271
+ cursor: 'pointer'
1272
+ }
1273
+ });
1274
+
1275
+ // Prevent clicks from closing the menu
1276
+ input.addEventListener('mousedown', (e) => {
1277
+ e.stopPropagation();
1278
+ });
1279
+
1280
+ input.addEventListener('click', (e) => {
1281
+ e.stopPropagation();
1282
+ });
1283
+
1284
+ input.addEventListener('change', (e) => {
1285
+ e.stopPropagation();
1286
+ this.player.options[property] = e.target.value;
1287
+ if (this.player.captionManager) {
1288
+ this.player.captionManager.setCaptionStyle(
1289
+ property.replace('captions', '').charAt(0).toLowerCase() + property.replace('captions', '').slice(1),
1290
+ e.target.value
1291
+ );
1292
+ }
1293
+ });
1294
+
1295
+ group.appendChild(input);
1296
+ return group;
1297
+ }
1298
+
1299
+ createOpacityControl(label, property) {
1300
+ const group = DOMUtils.createElement('div', {
1301
+ className: `${this.player.options.classPrefix}-style-group`
1302
+ });
1303
+
1304
+ const labelContainer = DOMUtils.createElement('div', {
1305
+ style: {
1306
+ display: 'flex',
1307
+ justifyContent: 'space-between',
1308
+ marginBottom: '4px'
1309
+ }
1310
+ });
1311
+
1312
+ const labelEl = DOMUtils.createElement('label', {
1313
+ textContent: label,
1314
+ style: {
1315
+ fontSize: '12px',
1316
+ color: 'rgba(255,255,255,0.7)'
1317
+ }
1318
+ });
1319
+
1320
+ const valueEl = DOMUtils.createElement('span', {
1321
+ textContent: Math.round(this.player.options[property] * 100) + '%',
1322
+ style: {
1323
+ fontSize: '12px',
1324
+ color: 'rgba(255,255,255,0.7)'
1325
+ }
1326
+ });
1327
+
1328
+ labelContainer.appendChild(labelEl);
1329
+ labelContainer.appendChild(valueEl);
1330
+ group.appendChild(labelContainer);
1331
+
1332
+ const input = DOMUtils.createElement('input', {
1333
+ attributes: {
1334
+ type: 'range',
1335
+ min: '0',
1336
+ max: '1',
1337
+ step: '0.1',
1338
+ value: String(this.player.options[property])
1339
+ },
1340
+ style: {
1341
+ width: '100%',
1342
+ cursor: 'pointer'
1343
+ }
1344
+ });
1345
+
1346
+ // Prevent clicks from closing the menu
1347
+ input.addEventListener('mousedown', (e) => {
1348
+ e.stopPropagation();
1349
+ });
1350
+
1351
+ input.addEventListener('click', (e) => {
1352
+ e.stopPropagation();
1353
+ });
1354
+
1355
+ input.addEventListener('input', (e) => {
1356
+ e.stopPropagation();
1357
+ const value = parseFloat(e.target.value);
1358
+ valueEl.textContent = Math.round(value * 100) + '%';
1359
+ this.player.options[property] = value;
1360
+ if (this.player.captionManager) {
1361
+ this.player.captionManager.setCaptionStyle(
1362
+ property.replace('captions', '').charAt(0).toLowerCase() + property.replace('captions', '').slice(1),
1363
+ value
1364
+ );
1365
+ }
1366
+ });
1367
+
1368
+ group.appendChild(input);
1369
+ return group;
1370
+ }
1371
+
1372
+ createSpeedButton() {
1373
+ const button = DOMUtils.createElement('button', {
1374
+ className: `${this.player.options.classPrefix}-button ${this.player.options.classPrefix}-speed`,
1375
+ attributes: {
1376
+ 'type': 'button',
1377
+ 'aria-label': i18n.t('player.speed'),
1378
+ 'aria-haspopup': 'menu'
1379
+ }
1380
+ });
1381
+
1382
+ button.appendChild(createIconElement('speed'));
1383
+
1384
+ const speedText = DOMUtils.createElement('span', {
1385
+ className: `${this.player.options.classPrefix}-speed-text`,
1386
+ textContent: '1x'
1387
+ });
1388
+ button.appendChild(speedText);
1389
+
1390
+ button.addEventListener('click', () => {
1391
+ this.showSpeedMenu(button);
1392
+ });
1393
+
1394
+ this.controls.speed = button;
1395
+ this.controls.speedText = speedText;
1396
+ return button;
1397
+ }
1398
+
1399
+ formatSpeedLabel(speed) {
1400
+ // Special case: 1x is "Normal" (translated)
1401
+ if (speed === 1) {
1402
+ return i18n.t('speeds.normal');
1403
+ }
1404
+
1405
+ // For other speeds, format with locale-specific decimal separator
1406
+ const speedStr = speed.toLocaleString(i18n.getLanguage(), {
1407
+ minimumFractionDigits: 0,
1408
+ maximumFractionDigits: 2
1409
+ });
1410
+
1411
+ return `${speedStr}×`;
1412
+ }
1413
+
1414
+ showSpeedMenu(button) {
1415
+ // Remove existing menu if any
1416
+ const existingMenu = document.querySelector(`.${this.player.options.classPrefix}-speed-menu`);
1417
+ if (existingMenu) {
1418
+ existingMenu.remove();
1419
+ return;
1420
+ }
1421
+
1422
+ const menu = DOMUtils.createElement('div', {
1423
+ className: `${this.player.options.classPrefix}-speed-menu ${this.player.options.classPrefix}-menu`,
1424
+ attributes: {
1425
+ 'role': 'menu'
1426
+ }
1427
+ });
1428
+
1429
+ const speeds = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
1430
+ let activeItem = null;
1431
+
1432
+ speeds.forEach(speed => {
1433
+ const item = DOMUtils.createElement('button', {
1434
+ className: `${this.player.options.classPrefix}-menu-item`,
1435
+ textContent: this.formatSpeedLabel(speed),
1436
+ attributes: {
1437
+ 'type': 'button',
1438
+ 'role': 'menuitem',
1439
+ 'tabindex': '-1'
1440
+ }
1441
+ });
1442
+
1443
+ if (speed === this.player.state.playbackSpeed) {
1444
+ item.classList.add(`${this.player.options.classPrefix}-menu-item-active`);
1445
+ item.appendChild(createIconElement('check'));
1446
+ activeItem = item;
1447
+ }
1448
+
1449
+ item.addEventListener('click', () => {
1450
+ this.player.setPlaybackSpeed(speed);
1451
+ menu.remove();
1452
+ });
1453
+
1454
+ menu.appendChild(item);
1455
+ });
1456
+
1457
+ // Append menu directly to button for proper positioning
1458
+ button.appendChild(menu);
1459
+
1460
+ // Add keyboard navigation
1461
+ this.attachMenuKeyboardNavigation(menu);
1462
+
1463
+ // Close menu on outside click
1464
+ this.attachMenuCloseHandler(menu, button);
1465
+
1466
+ // Focus the active item or first item
1467
+ setTimeout(() => {
1468
+ const focusTarget = activeItem || menu.querySelector(`.${this.player.options.classPrefix}-menu-item`);
1469
+ if (focusTarget) {
1470
+ focusTarget.focus();
1471
+ }
1472
+ }, 0);
1473
+ }
1474
+
1475
+ createCaptionsButton() {
1476
+ const button = DOMUtils.createElement('button', {
1477
+ className: `${this.player.options.classPrefix}-button ${this.player.options.classPrefix}-captions-button`,
1478
+ attributes: {
1479
+ 'type': 'button',
1480
+ 'aria-label': i18n.t('player.captions'),
1481
+ 'aria-pressed': 'false',
1482
+ 'aria-haspopup': 'menu'
1483
+ }
1484
+ });
1485
+
1486
+ button.appendChild(createIconElement('captionsOff'));
1487
+
1488
+ button.addEventListener('click', () => {
1489
+ this.showCaptionsMenu(button);
1490
+ });
1491
+
1492
+ this.controls.captions = button;
1493
+ return button;
1494
+ }
1495
+
1496
+ showCaptionsMenu(button) {
1497
+ // Remove existing menu if any
1498
+ const existingMenu = document.querySelector(`.${this.player.options.classPrefix}-captions-menu`);
1499
+ if (existingMenu) {
1500
+ existingMenu.remove();
1501
+ return;
1502
+ }
1503
+
1504
+ const menu = DOMUtils.createElement('div', {
1505
+ className: `${this.player.options.classPrefix}-captions-menu ${this.player.options.classPrefix}-menu`,
1506
+ attributes: {
1507
+ 'role': 'menu',
1508
+ 'aria-label': i18n.t('captions.select')
1509
+ }
1510
+ });
1511
+
1512
+ // Check if there are any caption tracks
1513
+ if (!this.player.captionManager || this.player.captionManager.tracks.length === 0) {
1514
+ // Show "No captions available" message
1515
+ const noTracksItem = DOMUtils.createElement('div', {
1516
+ className: `${this.player.options.classPrefix}-menu-item`,
1517
+ textContent: i18n.t('player.noCaptions'),
1518
+ style: {opacity: '0.5', cursor: 'default'}
1519
+ });
1520
+ menu.appendChild(noTracksItem);
1521
+
1522
+ // Append menu to button
1523
+ button.appendChild(menu);
1524
+
1525
+ // Close menu on outside click
1526
+ this.attachMenuCloseHandler(menu, button);
1527
+ return;
1528
+ }
1529
+
1530
+ let activeItem = null;
1531
+
1532
+ // Off option
1533
+ const offItem = DOMUtils.createElement('button', {
1534
+ className: `${this.player.options.classPrefix}-menu-item`,
1535
+ textContent: i18n.t('captions.off'),
1536
+ attributes: {
1537
+ 'type': 'button',
1538
+ 'role': 'menuitem',
1539
+ 'tabindex': '-1'
1540
+ }
1541
+ });
1542
+
1543
+ if (!this.player.state.captionsEnabled) {
1544
+ offItem.classList.add(`${this.player.options.classPrefix}-menu-item-active`);
1545
+ offItem.appendChild(createIconElement('check'));
1546
+ activeItem = offItem;
1547
+ }
1548
+
1549
+ offItem.addEventListener('click', () => {
1550
+ this.player.disableCaptions();
1551
+ this.updateCaptionsButton();
1552
+ menu.remove();
1553
+ });
1554
+
1555
+ menu.appendChild(offItem);
1556
+
1557
+ // Available tracks
1558
+ const tracks = this.player.captionManager.getAvailableTracks();
1559
+ tracks.forEach(track => {
1560
+ const item = DOMUtils.createElement('button', {
1561
+ className: `${this.player.options.classPrefix}-menu-item`,
1562
+ textContent: track.label,
1563
+ attributes: {
1564
+ 'type': 'button',
1565
+ 'role': 'menuitem',
1566
+ 'lang': track.language,
1567
+ 'tabindex': '-1'
1568
+ }
1569
+ });
1570
+
1571
+ // Check if this is the current track
1572
+ if (this.player.state.captionsEnabled &&
1573
+ this.player.captionManager.currentTrack === this.player.captionManager.tracks[track.index]) {
1574
+ item.classList.add(`${this.player.options.classPrefix}-menu-item-active`);
1575
+ item.appendChild(createIconElement('check'));
1576
+ activeItem = item;
1577
+ }
1578
+
1579
+ item.addEventListener('click', () => {
1580
+ this.player.captionManager.switchTrack(track.index);
1581
+ this.updateCaptionsButton();
1582
+ menu.remove();
1583
+ });
1584
+
1585
+ menu.appendChild(item);
1586
+ });
1587
+
1588
+ // Append menu directly to button for proper positioning
1589
+ button.appendChild(menu);
1590
+
1591
+ // Add keyboard navigation for the menu
1592
+ this.attachMenuKeyboardNavigation(menu);
1593
+
1594
+ // Close menu on outside click and Escape key
1595
+ this.attachMenuCloseHandler(menu, button);
1596
+
1597
+ // Focus the active item or the first item
1598
+ setTimeout(() => {
1599
+ const focusTarget = activeItem || menu.querySelector(`.${this.player.options.classPrefix}-menu-item`);
1600
+ if (focusTarget) {
1601
+ focusTarget.focus();
1602
+ }
1603
+ }, 0);
1604
+ }
1605
+
1606
+ updateCaptionsButton() {
1607
+ if (!this.controls.captions) return;
1608
+
1609
+ const icon = this.controls.captions.querySelector('.vidply-icon');
1610
+ const isEnabled = this.player.state.captionsEnabled;
1611
+
1612
+ icon.innerHTML = isEnabled ?
1613
+ createIconElement('captions').innerHTML :
1614
+ createIconElement('captionsOff').innerHTML;
1615
+
1616
+ this.controls.captions.setAttribute('aria-pressed', isEnabled ? 'true' : 'false');
1617
+ }
1618
+
1619
+ createTranscriptButton() {
1620
+ const button = DOMUtils.createElement('button', {
1621
+ className: `${this.player.options.classPrefix}-button ${this.player.options.classPrefix}-transcript`,
1622
+ attributes: {
1623
+ 'type': 'button',
1624
+ 'aria-label': i18n.t('player.transcript'),
1625
+ 'aria-pressed': 'false'
1626
+ }
1627
+ });
1628
+
1629
+ button.appendChild(createIconElement('transcript'));
1630
+
1631
+ button.addEventListener('click', () => {
1632
+ if (this.player.transcriptManager) {
1633
+ this.player.transcriptManager.toggleTranscript();
1634
+ this.updateTranscriptButton();
1635
+ }
1636
+ });
1637
+
1638
+ this.controls.transcript = button;
1639
+ return button;
1640
+ }
1641
+
1642
+ updateTranscriptButton() {
1643
+ if (!this.controls.transcript) return;
1644
+
1645
+ const isVisible = this.player.transcriptManager && this.player.transcriptManager.isVisible;
1646
+ this.controls.transcript.setAttribute('aria-pressed', isVisible ? 'true' : 'false');
1647
+ }
1648
+
1649
+ createAudioDescriptionButton() {
1650
+ const button = DOMUtils.createElement('button', {
1651
+ className: `${this.player.options.classPrefix}-button ${this.player.options.classPrefix}-audio-description`,
1652
+ attributes: {
1653
+ 'type': 'button',
1654
+ 'aria-label': i18n.t('player.audioDescription'),
1655
+ 'aria-pressed': 'false',
1656
+ 'title': i18n.t('player.audioDescription')
1657
+ }
1658
+ });
1659
+
1660
+ button.appendChild(createIconElement('audioDescription'));
1661
+
1662
+ button.addEventListener('click', async () => {
1663
+ await this.player.toggleAudioDescription();
1664
+ this.updateAudioDescriptionButton();
1665
+ });
1666
+
1667
+ this.controls.audioDescription = button;
1668
+ return button;
1669
+ }
1670
+
1671
+ updateAudioDescriptionButton() {
1672
+ if (!this.controls.audioDescription) return;
1673
+
1674
+ const icon = this.controls.audioDescription.querySelector('.vidply-icon');
1675
+ const isEnabled = this.player.state.audioDescriptionEnabled;
1676
+
1677
+ icon.innerHTML = isEnabled ?
1678
+ createIconElement('audioDescriptionOn').innerHTML :
1679
+ createIconElement('audioDescription').innerHTML;
1680
+
1681
+ this.controls.audioDescription.setAttribute('aria-pressed', isEnabled ? 'true' : 'false');
1682
+ this.controls.audioDescription.setAttribute('aria-label',
1683
+ isEnabled ? i18n.t('audioDescription.disable') : i18n.t('audioDescription.enable')
1684
+ );
1685
+ }
1686
+
1687
+ createSignLanguageButton() {
1688
+ const button = DOMUtils.createElement('button', {
1689
+ className: `${this.player.options.classPrefix}-button ${this.player.options.classPrefix}-sign-language`,
1690
+ attributes: {
1691
+ 'type': 'button',
1692
+ 'aria-label': i18n.t('player.signLanguage'),
1693
+ 'aria-pressed': 'false',
1694
+ 'title': i18n.t('player.signLanguage')
1695
+ }
1696
+ });
1697
+
1698
+ button.appendChild(createIconElement('signLanguage'));
1699
+
1700
+ button.addEventListener('click', () => {
1701
+ this.player.toggleSignLanguage();
1702
+ this.updateSignLanguageButton();
1703
+ });
1704
+
1705
+ this.controls.signLanguage = button;
1706
+ return button;
1707
+ }
1708
+
1709
+ updateSignLanguageButton() {
1710
+ if (!this.controls.signLanguage) return;
1711
+
1712
+ const icon = this.controls.signLanguage.querySelector('.vidply-icon');
1713
+ const isEnabled = this.player.state.signLanguageEnabled;
1714
+
1715
+ icon.innerHTML = isEnabled ?
1716
+ createIconElement('signLanguageOn').innerHTML :
1717
+ createIconElement('signLanguage').innerHTML;
1718
+
1719
+ this.controls.signLanguage.setAttribute('aria-pressed', isEnabled ? 'true' : 'false');
1720
+ this.controls.signLanguage.setAttribute('aria-label',
1721
+ isEnabled ? i18n.t('signLanguage.hide') : i18n.t('signLanguage.show')
1722
+ );
1723
+ }
1724
+
1725
+ createSettingsButton() {
1726
+ const button = DOMUtils.createElement('button', {
1727
+ className: `${this.player.options.classPrefix}-button ${this.player.options.classPrefix}-settings`,
1728
+ attributes: {
1729
+ 'type': 'button',
1730
+ 'aria-label': i18n.t('player.settings')
1731
+ }
1732
+ });
1733
+
1734
+ button.appendChild(createIconElement('settings'));
1735
+
1736
+ button.addEventListener('click', () => {
1737
+ this.player.showSettings();
1738
+ });
1739
+
1740
+ return button;
1741
+ }
1742
+
1743
+ createPipButton() {
1744
+ const button = DOMUtils.createElement('button', {
1745
+ className: `${this.player.options.classPrefix}-button ${this.player.options.classPrefix}-pip`,
1746
+ attributes: {
1747
+ 'type': 'button',
1748
+ 'aria-label': i18n.t('player.pip')
1749
+ }
1750
+ });
1751
+
1752
+ button.appendChild(createIconElement('pip'));
1753
+
1754
+ button.addEventListener('click', () => {
1755
+ this.player.togglePiP();
1756
+ });
1757
+
1758
+ return button;
1759
+ }
1760
+
1761
+ createFullscreenButton() {
1762
+ const button = DOMUtils.createElement('button', {
1763
+ className: `${this.player.options.classPrefix}-button ${this.player.options.classPrefix}-fullscreen`,
1764
+ attributes: {
1765
+ 'type': 'button',
1766
+ 'aria-label': i18n.t('player.fullscreen')
1767
+ }
1768
+ });
1769
+
1770
+ button.appendChild(createIconElement('fullscreen'));
1771
+
1772
+ button.addEventListener('click', () => {
1773
+ this.player.toggleFullscreen();
1774
+ });
1775
+
1776
+ this.controls.fullscreen = button;
1777
+ return button;
1778
+ }
1779
+
1780
+ attachEvents() {
1781
+ // Update controls based on player state
1782
+ this.player.on('play', () => this.updatePlayPauseButton());
1783
+ this.player.on('pause', () => this.updatePlayPauseButton());
1784
+ this.player.on('timeupdate', () => this.updateProgress());
1785
+ this.player.on('loadedmetadata', () => {
1786
+ this.updateDuration();
1787
+ this.ensureQualityButton();
1788
+ this.updateQualityIndicator();
1789
+ });
1790
+ this.player.on('volumechange', () => this.updateVolumeDisplay());
1791
+ this.player.on('progress', () => this.updateBuffered());
1792
+ this.player.on('playbackspeedchange', () => this.updateSpeedDisplay());
1793
+ this.player.on('fullscreenchange', () => this.updateFullscreenButton());
1794
+ this.player.on('captionsenabled', () => this.updateCaptionsButton());
1795
+ this.player.on('captionsdisabled', () => this.updateCaptionsButton());
1796
+ this.player.on('audiodescriptionenabled', () => this.updateAudioDescriptionButton());
1797
+ this.player.on('audiodescriptiondisabled', () => this.updateAudioDescriptionButton());
1798
+ this.player.on('signlanguageenabled', () => this.updateSignLanguageButton());
1799
+ this.player.on('signlanguagedisabled', () => this.updateSignLanguageButton());
1800
+ this.player.on('qualitychange', () => this.updateQualityIndicator());
1801
+ this.player.on('hlslevelswitched', () => this.updateQualityIndicator());
1802
+ this.player.on('hlsmanifestparsed', () => {
1803
+ this.ensureQualityButton();
1804
+ this.updateQualityIndicator();
1805
+ });
1806
+ }
1807
+
1808
+ updatePlayPauseButton() {
1809
+ if (!this.controls.playPause) return;
1810
+
1811
+ const icon = this.controls.playPause.querySelector('.vidply-icon');
1812
+ const isPlaying = this.player.state.playing;
1813
+
1814
+ icon.innerHTML = isPlaying ?
1815
+ createIconElement('pause').innerHTML :
1816
+ createIconElement('play').innerHTML;
1817
+
1818
+ this.controls.playPause.setAttribute('aria-label',
1819
+ isPlaying ? i18n.t('player.pause') : i18n.t('player.play')
1820
+ );
1821
+ }
1822
+
1823
+ updateProgress() {
1824
+ if (!this.controls.played) return;
1825
+
1826
+ const percent = (this.player.state.currentTime / this.player.state.duration) * 100;
1827
+ this.controls.played.style.width = `${percent}%`;
1828
+ this.controls.progress.setAttribute('aria-valuenow', String(Math.round(percent)));
1829
+
1830
+ if (this.controls.currentTimeVisual) {
1831
+ const currentTime = this.player.state.currentTime;
1832
+ // Update visual text (hidden from screen readers)
1833
+ this.controls.currentTimeVisual.textContent = TimeUtils.formatTime(currentTime);
1834
+ // Update aria-label with human-readable format
1835
+ this.controls.currentTimeDisplay.setAttribute('aria-label', TimeUtils.formatDuration(currentTime));
1836
+ }
1837
+ }
1838
+
1839
+ updateDuration() {
1840
+ if (this.controls.durationVisual) {
1841
+ const duration = this.player.state.duration;
1842
+ // Update visual text (hidden from screen readers)
1843
+ this.controls.durationVisual.textContent = TimeUtils.formatTime(duration);
1844
+ // Update aria-label with human-readable format
1845
+ this.controls.durationDisplay.setAttribute('aria-label', i18n.t('time.durationPrefix') + TimeUtils.formatDuration(duration));
1846
+ }
1847
+ }
1848
+
1849
+ updateVolumeDisplay() {
1850
+ const percent = this.player.state.volume * 100;
1851
+
1852
+ // Update volume fill bar if it exists
1853
+ if (this.controls.volumeFill) {
1854
+ this.controls.volumeFill.style.height = `${percent}%`;
1855
+ }
1856
+
1857
+ // Update mute button icon (should always work even if slider not shown)
1858
+ if (this.controls.mute) {
1859
+ const icon = this.controls.mute.querySelector('.vidply-icon');
1860
+ if (icon) {
1861
+ let iconName;
1862
+
1863
+ if (this.player.state.muted || this.player.state.volume === 0) {
1864
+ iconName = 'volumeMuted';
1865
+ } else if (this.player.state.volume < 0.3) {
1866
+ iconName = 'volumeLow';
1867
+ } else if (this.player.state.volume < 0.7) {
1868
+ iconName = 'volumeMedium';
1869
+ } else {
1870
+ iconName = 'volumeHigh';
1871
+ }
1872
+
1873
+ icon.innerHTML = createIconElement(iconName).innerHTML;
1874
+
1875
+ this.controls.mute.setAttribute('aria-label',
1876
+ this.player.state.muted ? i18n.t('player.unmute') : i18n.t('player.mute')
1877
+ );
1878
+ }
1879
+ }
1880
+
1881
+ // Update volume slider attribute if it exists
1882
+ if (this.controls.volumeSlider) {
1883
+ this.controls.volumeSlider.setAttribute('aria-valuenow', String(Math.round(percent)));
1884
+ }
1885
+ }
1886
+
1887
+ updateBuffered() {
1888
+ if (!this.controls.buffered || !this.player.element.buffered || this.player.element.buffered.length === 0) return;
1889
+
1890
+ const buffered = this.player.element.buffered.end(this.player.element.buffered.length - 1);
1891
+ const percent = (buffered / this.player.state.duration) * 100;
1892
+ this.controls.buffered.style.width = `${percent}%`;
1893
+ }
1894
+
1895
+ updateSpeedDisplay() {
1896
+ if (this.controls.speedText) {
1897
+ this.controls.speedText.textContent = `${this.player.state.playbackSpeed}x`;
1898
+ }
1899
+ }
1900
+
1901
+ updateFullscreenButton() {
1902
+ if (!this.controls.fullscreen) return;
1903
+
1904
+ const icon = this.controls.fullscreen.querySelector('.vidply-icon');
1905
+ const isFullscreen = this.player.state.fullscreen;
1906
+
1907
+ icon.innerHTML = isFullscreen ?
1908
+ createIconElement('fullscreenExit').innerHTML :
1909
+ createIconElement('fullscreen').innerHTML;
1910
+
1911
+ this.controls.fullscreen.setAttribute('aria-label',
1912
+ isFullscreen ? i18n.t('player.exitFullscreen') : i18n.t('player.fullscreen')
1913
+ );
1914
+ }
1915
+
1916
+ /**
1917
+ * Ensure quality button exists if qualities are available
1918
+ * This is called after renderer initialization to dynamically add the button
1919
+ */
1920
+ ensureQualityButton() {
1921
+ // Skip if quality button is disabled
1922
+ if (!this.player.options.qualityButton) return;
1923
+
1924
+ // Skip if button already exists
1925
+ if (this.controls.quality) return;
1926
+
1927
+ // Check if qualities are now available
1928
+ if (!this.hasQualityLevels()) return;
1929
+
1930
+ // Create and insert the quality button before the speed button
1931
+ const qualityButton = this.createQualityButton();
1932
+
1933
+ // Find the speed button or caption style button to insert before
1934
+ const speedButton = this.rightButtons.querySelector(`.${this.player.options.classPrefix}-speed`);
1935
+ const captionStyleButton = this.rightButtons.querySelector(`.${this.player.options.classPrefix}-caption-style`);
1936
+ const insertBefore = captionStyleButton || speedButton;
1937
+
1938
+ if (insertBefore) {
1939
+ this.rightButtons.insertBefore(qualityButton, insertBefore);
1940
+ } else {
1941
+ // If no reference button, add it at the beginning of right buttons
1942
+ this.rightButtons.insertBefore(qualityButton, this.rightButtons.firstChild);
1943
+ }
1944
+
1945
+ this.player.log('Quality button added dynamically', 'info');
1946
+ }
1947
+
1948
+ updateQualityIndicator() {
1949
+ if (!this.controls.qualityText) return;
1950
+ if (!this.player.renderer || !this.player.renderer.getQualities) return;
1951
+
1952
+ const qualities = this.player.renderer.getQualities();
1953
+ if (qualities.length === 0) {
1954
+ this.controls.qualityText.textContent = '';
1955
+ return;
1956
+ }
1957
+
1958
+ // Get current quality
1959
+ let currentQualityText = '';
1960
+
1961
+ // Check if it's HLS with auto mode
1962
+ if (this.player.renderer.hls && this.player.renderer.hls.currentLevel === -1) {
1963
+ currentQualityText = 'Auto';
1964
+ } else if (this.player.renderer.getCurrentQuality) {
1965
+ const currentIndex = this.player.renderer.getCurrentQuality();
1966
+ const currentQuality = qualities.find(q => q.index === currentIndex);
1967
+ if (currentQuality) {
1968
+ currentQualityText = currentQuality.height ? `${currentQuality.height}p` : '';
1969
+ }
1970
+ }
1971
+
1972
+ this.controls.qualityText.textContent = currentQualityText;
1973
+ }
1974
+
1975
+ setupAutoHide() {
1976
+ if (this.player.element.tagName !== 'VIDEO') return;
1977
+
1978
+ const showControls = () => {
1979
+ this.element.classList.add(`${this.player.options.classPrefix}-controls-visible`);
1980
+ this.player.container.classList.add(`${this.player.options.classPrefix}-controls-visible`);
1981
+ this.player.state.controlsVisible = true;
1982
+
1983
+ clearTimeout(this.hideTimeout);
1984
+
1985
+ if (this.player.state.playing) {
1986
+ this.hideTimeout = setTimeout(() => {
1987
+ this.element.classList.remove(`${this.player.options.classPrefix}-controls-visible`);
1988
+ this.player.container.classList.remove(`${this.player.options.classPrefix}-controls-visible`);
1989
+ this.player.state.controlsVisible = false;
1990
+ }, this.player.options.hideControlsDelay);
1991
+ }
1992
+ };
1993
+
1994
+ this.player.container.addEventListener('mousemove', showControls);
1995
+ this.player.container.addEventListener('touchstart', showControls);
1996
+ this.player.container.addEventListener('click', showControls);
1997
+
1998
+ // Show controls on focus
1999
+ this.element.addEventListener('focusin', showControls);
2000
+
2001
+ // Always show when paused
2002
+ this.player.on('pause', () => {
2003
+ showControls();
2004
+ clearTimeout(this.hideTimeout);
2005
+ });
2006
+
2007
+ this.player.on('play', () => {
2008
+ showControls();
2009
+ });
2010
+
2011
+ // Initial state
2012
+ showControls();
2013
+ }
2014
+
2015
+ show() {
2016
+ this.element.style.display = '';
2017
+ }
2018
+
2019
+ hide() {
2020
+ this.element.style.display = 'none';
2021
+ }
2022
+
2023
+ destroy() {
2024
+ if (this.hideTimeout) {
2025
+ clearTimeout(this.hideTimeout);
2026
+ }
2027
+
2028
+ if (this.element && this.element.parentNode) {
2029
+ this.element.parentNode.removeChild(this.element);
2030
+ }
2031
+ }
2032
+ }
2033
+