vidply 1.0.3 → 1.0.4

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,1988 +1,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
- 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
- });
703
-
704
- this.controls.currentTimeDisplay = DOMUtils.createElement('span', {
705
- className: `${this.player.options.classPrefix}-current-time`,
706
- textContent: '00:00'
707
- });
708
-
709
- const separator = DOMUtils.createElement('span', {
710
- textContent: ' / ',
711
- attributes: {
712
- 'aria-hidden': 'true'
713
- }
714
- });
715
-
716
- this.controls.durationDisplay = DOMUtils.createElement('span', {
717
- className: `${this.player.options.classPrefix}-duration`,
718
- textContent: '00:00'
719
- });
720
-
721
- container.appendChild(this.controls.currentTimeDisplay);
722
- container.appendChild(separator);
723
- container.appendChild(this.controls.durationDisplay);
724
-
725
- return container;
726
- }
727
-
728
- createChaptersButton() {
729
- const button = DOMUtils.createElement('button', {
730
- className: `${this.player.options.classPrefix}-button ${this.player.options.classPrefix}-chapters`,
731
- attributes: {
732
- 'type': 'button',
733
- 'aria-label': i18n.t('player.chapters'),
734
- 'aria-haspopup': 'menu'
735
- }
736
- });
737
-
738
- button.appendChild(createIconElement('playlist'));
739
-
740
- button.addEventListener('click', () => {
741
- this.showChaptersMenu(button);
742
- });
743
-
744
- this.controls.chapters = button;
745
- return button;
746
- }
747
-
748
- showChaptersMenu(button) {
749
- // Remove existing menu if any
750
- const existingMenu = document.querySelector(`.${this.player.options.classPrefix}-chapters-menu`);
751
- if (existingMenu) {
752
- existingMenu.remove();
753
- return;
754
- }
755
-
756
- const menu = DOMUtils.createElement('div', {
757
- className: `${this.player.options.classPrefix}-chapters-menu ${this.player.options.classPrefix}-menu`,
758
- attributes: {
759
- 'role': 'menu',
760
- 'aria-label': i18n.t('player.chapters')
761
- }
762
- });
763
-
764
- // Get chapter tracks
765
- const chapterTracks = Array.from(this.player.element.textTracks).filter(
766
- track => track.kind === 'chapters'
767
- );
768
-
769
- if (chapterTracks.length === 0) {
770
- // No chapters available
771
- const noChaptersItem = DOMUtils.createElement('div', {
772
- className: `${this.player.options.classPrefix}-menu-item`,
773
- textContent: i18n.t('player.noChapters'),
774
- style: {opacity: '0.5', cursor: 'default'}
775
- });
776
- menu.appendChild(noChaptersItem);
777
- } else {
778
- const chapterTrack = chapterTracks[0];
779
-
780
- // Ensure track is in 'hidden' mode to load cues
781
- if (chapterTrack.mode === 'disabled') {
782
- chapterTrack.mode = 'hidden';
783
- }
784
-
785
- if (!chapterTrack.cues || chapterTrack.cues.length === 0) {
786
- // Cues not loaded yet - wait for them to load
787
- const loadingItem = DOMUtils.createElement('div', {
788
- className: `${this.player.options.classPrefix}-menu-item`,
789
- textContent: i18n.t('player.loadingChapters'),
790
- style: {opacity: '0.5', cursor: 'default'}
791
- });
792
- menu.appendChild(loadingItem);
793
-
794
- // Listen for track load event
795
- const onTrackLoad = () => {
796
- // Remove loading message and rebuild menu
797
- menu.remove();
798
- this.showChaptersMenu(button);
799
- };
800
-
801
- chapterTrack.addEventListener('load', onTrackLoad, {once: true});
802
-
803
- // Also try again after a short delay as fallback
804
- setTimeout(() => {
805
- if (chapterTrack.cues && chapterTrack.cues.length > 0 && document.contains(menu)) {
806
- menu.remove();
807
- this.showChaptersMenu(button);
808
- }
809
- }, 500);
810
- } else {
811
- // Display chapters
812
- const cues = chapterTrack.cues;
813
- for (let i = 0; i < cues.length; i++) {
814
- const cue = cues[i];
815
- const item = DOMUtils.createElement('button', {
816
- className: `${this.player.options.classPrefix}-menu-item`,
817
- attributes: {
818
- 'type': 'button',
819
- 'role': 'menuitem',
820
- 'tabindex': '-1'
821
- }
822
- });
823
-
824
- const timeLabel = DOMUtils.createElement('span', {
825
- className: `${this.player.options.classPrefix}-chapter-time`,
826
- textContent: TimeUtils.formatTime(cue.startTime)
827
- });
828
-
829
- const titleLabel = DOMUtils.createElement('span', {
830
- className: `${this.player.options.classPrefix}-chapter-title`,
831
- textContent: cue.text
832
- });
833
-
834
- item.appendChild(timeLabel);
835
- item.appendChild(document.createTextNode(' '));
836
- item.appendChild(titleLabel);
837
-
838
- item.addEventListener('click', () => {
839
- this.player.seek(cue.startTime);
840
- menu.remove();
841
- });
842
-
843
- menu.appendChild(item);
844
- }
845
-
846
- // Add keyboard navigation
847
- this.attachMenuKeyboardNavigation(menu);
848
-
849
- // Focus first item
850
- setTimeout(() => {
851
- const firstItem = menu.querySelector(`.${this.player.options.classPrefix}-menu-item`);
852
- if (firstItem) {
853
- firstItem.focus();
854
- }
855
- }, 0);
856
- }
857
- }
858
-
859
- // Append menu directly to button for proper positioning
860
- button.appendChild(menu);
861
-
862
- // Close menu on outside click
863
- this.attachMenuCloseHandler(menu, button);
864
- }
865
-
866
- createQualityButton() {
867
- const button = DOMUtils.createElement('button', {
868
- className: `${this.player.options.classPrefix}-button ${this.player.options.classPrefix}-quality`,
869
- attributes: {
870
- 'type': 'button',
871
- 'aria-label': i18n.t('player.quality'),
872
- 'aria-haspopup': 'menu'
873
- }
874
- });
875
-
876
- button.appendChild(createIconElement('hd'));
877
-
878
- // Add quality indicator text
879
- const qualityText = DOMUtils.createElement('span', {
880
- className: `${this.player.options.classPrefix}-quality-text`,
881
- textContent: ''
882
- });
883
- button.appendChild(qualityText);
884
-
885
- button.addEventListener('click', () => {
886
- this.showQualityMenu(button);
887
- });
888
-
889
- this.controls.quality = button;
890
- this.controls.qualityText = qualityText;
891
-
892
- // Update quality indicator after a short delay to ensure renderer is ready
893
- setTimeout(() => this.updateQualityIndicator(), 500);
894
-
895
- return button;
896
- }
897
-
898
- showQualityMenu(button) {
899
- // Remove existing menu if any
900
- const existingMenu = document.querySelector(`.${this.player.options.classPrefix}-quality-menu`);
901
- if (existingMenu) {
902
- existingMenu.remove();
903
- return;
904
- }
905
-
906
- const menu = DOMUtils.createElement('div', {
907
- className: `${this.player.options.classPrefix}-quality-menu ${this.player.options.classPrefix}-menu`,
908
- attributes: {
909
- 'role': 'menu',
910
- 'aria-label': i18n.t('player.quality')
911
- }
912
- });
913
-
914
- // Check if renderer supports quality selection
915
- if (this.player.renderer && this.player.renderer.getQualities) {
916
- const qualities = this.player.renderer.getQualities();
917
- const currentQuality = this.player.renderer.getCurrentQuality ? this.player.renderer.getCurrentQuality() : -1;
918
- const isHLS = this.player.renderer.hls !== undefined;
919
-
920
- if (qualities.length === 0) {
921
- // No qualities available
922
- const noQualityItem = DOMUtils.createElement('div', {
923
- className: `${this.player.options.classPrefix}-menu-item`,
924
- textContent: i18n.t('player.autoQuality'),
925
- style: {opacity: '0.5', cursor: 'default'}
926
- });
927
- menu.appendChild(noQualityItem);
928
- } else {
929
- let activeItem = null;
930
-
931
- // Auto quality option (only for HLS)
932
- if (isHLS) {
933
- const autoItem = DOMUtils.createElement('button', {
934
- className: `${this.player.options.classPrefix}-menu-item`,
935
- textContent: i18n.t('player.auto'),
936
- attributes: {
937
- 'type': 'button',
938
- 'role': 'menuitem',
939
- 'tabindex': '-1'
940
- }
941
- });
942
-
943
- // Check if auto is currently selected
944
- const isAuto = this.player.renderer.hls && this.player.renderer.hls.currentLevel === -1;
945
- if (isAuto) {
946
- autoItem.classList.add(`${this.player.options.classPrefix}-menu-item-active`);
947
- autoItem.appendChild(createIconElement('check'));
948
- activeItem = autoItem;
949
- }
950
-
951
- autoItem.addEventListener('click', () => {
952
- if (this.player.renderer.switchQuality) {
953
- this.player.renderer.switchQuality(-1); // -1 for auto
954
- }
955
- menu.remove();
956
- });
957
-
958
- menu.appendChild(autoItem);
959
- }
960
-
961
- // Quality options
962
- qualities.forEach(quality => {
963
- const item = DOMUtils.createElement('button', {
964
- className: `${this.player.options.classPrefix}-menu-item`,
965
- textContent: quality.name || `${quality.height}p`,
966
- attributes: {
967
- 'type': 'button',
968
- 'role': 'menuitem',
969
- 'tabindex': '-1'
970
- }
971
- });
972
-
973
- // Highlight current quality
974
- if (quality.index === currentQuality) {
975
- item.classList.add(`${this.player.options.classPrefix}-menu-item-active`);
976
- item.appendChild(createIconElement('check'));
977
- activeItem = item;
978
- }
979
-
980
- item.addEventListener('click', () => {
981
- if (this.player.renderer.switchQuality) {
982
- this.player.renderer.switchQuality(quality.index);
983
- }
984
- menu.remove();
985
- });
986
-
987
- menu.appendChild(item);
988
- });
989
-
990
- // Add keyboard navigation
991
- this.attachMenuKeyboardNavigation(menu);
992
-
993
- // Focus active item or first item
994
- setTimeout(() => {
995
- const focusTarget = activeItem || menu.querySelector(`.${this.player.options.classPrefix}-menu-item`);
996
- if (focusTarget) {
997
- focusTarget.focus();
998
- }
999
- }, 0);
1000
- }
1001
- } else {
1002
- // No quality support
1003
- const noSupportItem = DOMUtils.createElement('div', {
1004
- className: `${this.player.options.classPrefix}-menu-item`,
1005
- textContent: i18n.t('player.noQuality'),
1006
- style: {opacity: '0.5', cursor: 'default'}
1007
- });
1008
- menu.appendChild(noSupportItem);
1009
- }
1010
-
1011
- // Append menu directly to button for proper positioning
1012
- button.appendChild(menu);
1013
-
1014
- // Close menu on outside click
1015
- this.attachMenuCloseHandler(menu, button);
1016
- }
1017
-
1018
- createCaptionStyleButton() {
1019
- const button = DOMUtils.createElement('button', {
1020
- className: `${this.player.options.classPrefix}-button ${this.player.options.classPrefix}-caption-style`,
1021
- attributes: {
1022
- 'type': 'button',
1023
- 'aria-label': i18n.t('player.captionStyling'),
1024
- 'aria-haspopup': 'menu',
1025
- 'title': i18n.t('player.captionStyling')
1026
- }
1027
- });
1028
-
1029
- // Create "Aa" text icon for styling
1030
- const textIcon = DOMUtils.createElement('span', {
1031
- textContent: 'Aa',
1032
- style: {
1033
- fontSize: '14px',
1034
- fontWeight: 'bold'
1035
- }
1036
- });
1037
- button.appendChild(textIcon);
1038
-
1039
- button.addEventListener('click', () => {
1040
- this.showCaptionStyleMenu(button);
1041
- });
1042
-
1043
- this.controls.captionStyle = button;
1044
- return button;
1045
- }
1046
-
1047
- showCaptionStyleMenu(button) {
1048
- // Remove existing menu if any
1049
- const existingMenu = document.querySelector(`.${this.player.options.classPrefix}-caption-style-menu`);
1050
- if (existingMenu) {
1051
- existingMenu.remove();
1052
- return;
1053
- }
1054
-
1055
- const menu = DOMUtils.createElement('div', {
1056
- className: `${this.player.options.classPrefix}-caption-style-menu ${this.player.options.classPrefix}-menu ${this.player.options.classPrefix}-settings-menu`,
1057
- attributes: {
1058
- 'role': 'menu',
1059
- 'aria-label': i18n.t('player.captionStyling')
1060
- }
1061
- });
1062
-
1063
- // Prevent menu from closing when clicking inside
1064
- menu.addEventListener('click', (e) => {
1065
- e.stopPropagation();
1066
- });
1067
-
1068
- // Check if there are any caption tracks
1069
- if (!this.player.captionManager || this.player.captionManager.tracks.length === 0) {
1070
- // Show "No captions available" message
1071
- const noTracksItem = DOMUtils.createElement('div', {
1072
- className: `${this.player.options.classPrefix}-menu-item`,
1073
- textContent: i18n.t('player.noCaptions'),
1074
- style: {opacity: '0.5', cursor: 'default', padding: '12px 16px'}
1075
- });
1076
- menu.appendChild(noTracksItem);
1077
-
1078
- // Append menu to button
1079
- button.appendChild(menu);
1080
-
1081
- // Close menu on outside click
1082
- this.attachMenuCloseHandler(menu, button, true);
1083
- return;
1084
- }
1085
-
1086
- // Font Size
1087
- const fontSizeGroup = this.createStyleControl(
1088
- i18n.t('styleLabels.fontSize'),
1089
- 'captionsFontSize',
1090
- [
1091
- {label: i18n.t('fontSizes.small'), value: '80%'},
1092
- {label: i18n.t('fontSizes.medium'), value: '100%'},
1093
- {label: i18n.t('fontSizes.large'), value: '120%'},
1094
- {label: i18n.t('fontSizes.xlarge'), value: '150%'}
1095
- ]
1096
- );
1097
- menu.appendChild(fontSizeGroup);
1098
-
1099
- // Font Family
1100
- const fontFamilyGroup = this.createStyleControl(
1101
- i18n.t('styleLabels.font'),
1102
- 'captionsFontFamily',
1103
- [
1104
- {label: i18n.t('fontFamilies.sansSerif'), value: 'sans-serif'},
1105
- {label: i18n.t('fontFamilies.serif'), value: 'serif'},
1106
- {label: i18n.t('fontFamilies.monospace'), value: 'monospace'}
1107
- ]
1108
- );
1109
- menu.appendChild(fontFamilyGroup);
1110
-
1111
- // Text Color
1112
- const colorGroup = this.createColorControl(i18n.t('styleLabels.textColor'), 'captionsColor');
1113
- menu.appendChild(colorGroup);
1114
-
1115
- // Background Color
1116
- const bgColorGroup = this.createColorControl(i18n.t('styleLabels.background'), 'captionsBackgroundColor');
1117
- menu.appendChild(bgColorGroup);
1118
-
1119
- // Opacity
1120
- const opacityGroup = this.createOpacityControl(i18n.t('styleLabels.opacity'), 'captionsOpacity');
1121
- menu.appendChild(opacityGroup);
1122
-
1123
- // Set min-width for caption style menu
1124
- menu.style.minWidth = '220px';
1125
-
1126
- // Append menu directly to button for proper positioning
1127
- button.appendChild(menu);
1128
-
1129
- // Close menu on outside click (but not when interacting with controls)
1130
- this.attachMenuCloseHandler(menu, button, true);
1131
-
1132
- // Auto-focus the first select element
1133
- setTimeout(() => {
1134
- const firstSelect = menu.querySelector('select');
1135
- if (firstSelect) {
1136
- firstSelect.focus();
1137
- }
1138
- }, 0);
1139
- }
1140
-
1141
- createStyleControl(label, property, options) {
1142
- const group = DOMUtils.createElement('div', {
1143
- className: `${this.player.options.classPrefix}-style-group`
1144
- });
1145
-
1146
- const labelEl = DOMUtils.createElement('label', {
1147
- textContent: label,
1148
- style: {
1149
- display: 'block',
1150
- fontSize: '12px',
1151
- marginBottom: '4px',
1152
- color: 'rgba(255,255,255,0.7)'
1153
- }
1154
- });
1155
- group.appendChild(labelEl);
1156
-
1157
- const select = DOMUtils.createElement('select', {
1158
- className: `${this.player.options.classPrefix}-style-select`,
1159
- style: {
1160
- width: '100%',
1161
- padding: '6px',
1162
- background: 'var(--vidply-white)',
1163
- border: '1px solid var(--vidply-white-10)',
1164
- borderRadius: '4px',
1165
- color: 'var(--vidply-black)',
1166
- fontSize: '13px'
1167
- }
1168
- });
1169
-
1170
- const currentValue = this.player.options[property];
1171
- options.forEach(opt => {
1172
- const option = DOMUtils.createElement('option', {
1173
- textContent: opt.label,
1174
- attributes: {value: opt.value}
1175
- });
1176
- if (opt.value === currentValue) {
1177
- option.selected = true;
1178
- }
1179
- select.appendChild(option);
1180
- });
1181
-
1182
- // Prevent clicks from closing the menu
1183
- select.addEventListener('mousedown', (e) => {
1184
- e.stopPropagation();
1185
- });
1186
-
1187
- select.addEventListener('click', (e) => {
1188
- e.stopPropagation();
1189
- });
1190
-
1191
- select.addEventListener('change', (e) => {
1192
- e.stopPropagation();
1193
- this.player.options[property] = e.target.value;
1194
- if (this.player.captionManager) {
1195
- this.player.captionManager.setCaptionStyle(
1196
- property.replace('captions', '').charAt(0).toLowerCase() + property.replace('captions', '').slice(1),
1197
- e.target.value
1198
- );
1199
- }
1200
- });
1201
-
1202
- group.appendChild(select);
1203
- return group;
1204
- }
1205
-
1206
- createColorControl(label, property) {
1207
- const group = DOMUtils.createElement('div', {
1208
- className: `${this.player.options.classPrefix}-style-group`
1209
- });
1210
-
1211
- const labelEl = DOMUtils.createElement('label', {
1212
- textContent: label,
1213
- style: {
1214
- display: 'block',
1215
- fontSize: '12px',
1216
- marginBottom: '4px',
1217
- color: 'rgba(255,255,255,0.7)'
1218
- }
1219
- });
1220
- group.appendChild(labelEl);
1221
-
1222
- const input = DOMUtils.createElement('input', {
1223
- attributes: {
1224
- type: 'color',
1225
- value: this.player.options[property]
1226
- },
1227
- style: {
1228
- width: '100%',
1229
- height: '32px',
1230
- padding: '2px',
1231
- background: 'rgba(255,255,255,0.1)',
1232
- border: '1px solid rgba(255,255,255,0.2)',
1233
- borderRadius: '4px',
1234
- cursor: 'pointer'
1235
- }
1236
- });
1237
-
1238
- // Prevent clicks from closing the menu
1239
- input.addEventListener('mousedown', (e) => {
1240
- e.stopPropagation();
1241
- });
1242
-
1243
- input.addEventListener('click', (e) => {
1244
- e.stopPropagation();
1245
- });
1246
-
1247
- input.addEventListener('change', (e) => {
1248
- e.stopPropagation();
1249
- this.player.options[property] = e.target.value;
1250
- if (this.player.captionManager) {
1251
- this.player.captionManager.setCaptionStyle(
1252
- property.replace('captions', '').charAt(0).toLowerCase() + property.replace('captions', '').slice(1),
1253
- e.target.value
1254
- );
1255
- }
1256
- });
1257
-
1258
- group.appendChild(input);
1259
- return group;
1260
- }
1261
-
1262
- createOpacityControl(label, property) {
1263
- const group = DOMUtils.createElement('div', {
1264
- className: `${this.player.options.classPrefix}-style-group`
1265
- });
1266
-
1267
- const labelContainer = DOMUtils.createElement('div', {
1268
- style: {
1269
- display: 'flex',
1270
- justifyContent: 'space-between',
1271
- marginBottom: '4px'
1272
- }
1273
- });
1274
-
1275
- const labelEl = DOMUtils.createElement('label', {
1276
- textContent: label,
1277
- style: {
1278
- fontSize: '12px',
1279
- color: 'rgba(255,255,255,0.7)'
1280
- }
1281
- });
1282
-
1283
- const valueEl = DOMUtils.createElement('span', {
1284
- textContent: Math.round(this.player.options[property] * 100) + '%',
1285
- style: {
1286
- fontSize: '12px',
1287
- color: 'rgba(255,255,255,0.7)'
1288
- }
1289
- });
1290
-
1291
- labelContainer.appendChild(labelEl);
1292
- labelContainer.appendChild(valueEl);
1293
- group.appendChild(labelContainer);
1294
-
1295
- const input = DOMUtils.createElement('input', {
1296
- attributes: {
1297
- type: 'range',
1298
- min: '0',
1299
- max: '1',
1300
- step: '0.1',
1301
- value: String(this.player.options[property])
1302
- },
1303
- style: {
1304
- width: '100%',
1305
- cursor: 'pointer'
1306
- }
1307
- });
1308
-
1309
- // Prevent clicks from closing the menu
1310
- input.addEventListener('mousedown', (e) => {
1311
- e.stopPropagation();
1312
- });
1313
-
1314
- input.addEventListener('click', (e) => {
1315
- e.stopPropagation();
1316
- });
1317
-
1318
- input.addEventListener('input', (e) => {
1319
- e.stopPropagation();
1320
- const value = parseFloat(e.target.value);
1321
- valueEl.textContent = Math.round(value * 100) + '%';
1322
- this.player.options[property] = value;
1323
- if (this.player.captionManager) {
1324
- this.player.captionManager.setCaptionStyle(
1325
- property.replace('captions', '').charAt(0).toLowerCase() + property.replace('captions', '').slice(1),
1326
- value
1327
- );
1328
- }
1329
- });
1330
-
1331
- group.appendChild(input);
1332
- return group;
1333
- }
1334
-
1335
- createSpeedButton() {
1336
- const button = DOMUtils.createElement('button', {
1337
- className: `${this.player.options.classPrefix}-button ${this.player.options.classPrefix}-speed`,
1338
- attributes: {
1339
- 'type': 'button',
1340
- 'aria-label': i18n.t('player.speed'),
1341
- 'aria-haspopup': 'menu'
1342
- }
1343
- });
1344
-
1345
- button.appendChild(createIconElement('speed'));
1346
-
1347
- const speedText = DOMUtils.createElement('span', {
1348
- className: `${this.player.options.classPrefix}-speed-text`,
1349
- textContent: '1x'
1350
- });
1351
- button.appendChild(speedText);
1352
-
1353
- button.addEventListener('click', () => {
1354
- this.showSpeedMenu(button);
1355
- });
1356
-
1357
- this.controls.speed = button;
1358
- this.controls.speedText = speedText;
1359
- return button;
1360
- }
1361
-
1362
- formatSpeedLabel(speed) {
1363
- // Special case: 1x is "Normal" (translated)
1364
- if (speed === 1) {
1365
- return i18n.t('speeds.normal');
1366
- }
1367
-
1368
- // For other speeds, format with locale-specific decimal separator
1369
- const speedStr = speed.toLocaleString(i18n.getLanguage(), {
1370
- minimumFractionDigits: 0,
1371
- maximumFractionDigits: 2
1372
- });
1373
-
1374
- return `${speedStr}×`;
1375
- }
1376
-
1377
- showSpeedMenu(button) {
1378
- // Remove existing menu if any
1379
- const existingMenu = document.querySelector(`.${this.player.options.classPrefix}-speed-menu`);
1380
- if (existingMenu) {
1381
- existingMenu.remove();
1382
- return;
1383
- }
1384
-
1385
- const menu = DOMUtils.createElement('div', {
1386
- className: `${this.player.options.classPrefix}-speed-menu ${this.player.options.classPrefix}-menu`,
1387
- attributes: {
1388
- 'role': 'menu'
1389
- }
1390
- });
1391
-
1392
- const speeds = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
1393
- let activeItem = null;
1394
-
1395
- speeds.forEach(speed => {
1396
- const item = DOMUtils.createElement('button', {
1397
- className: `${this.player.options.classPrefix}-menu-item`,
1398
- textContent: this.formatSpeedLabel(speed),
1399
- attributes: {
1400
- 'type': 'button',
1401
- 'role': 'menuitem',
1402
- 'tabindex': '-1'
1403
- }
1404
- });
1405
-
1406
- if (speed === this.player.state.playbackSpeed) {
1407
- item.classList.add(`${this.player.options.classPrefix}-menu-item-active`);
1408
- item.appendChild(createIconElement('check'));
1409
- activeItem = item;
1410
- }
1411
-
1412
- item.addEventListener('click', () => {
1413
- this.player.setPlaybackSpeed(speed);
1414
- menu.remove();
1415
- });
1416
-
1417
- menu.appendChild(item);
1418
- });
1419
-
1420
- // Append menu directly to button for proper positioning
1421
- button.appendChild(menu);
1422
-
1423
- // Add keyboard navigation
1424
- this.attachMenuKeyboardNavigation(menu);
1425
-
1426
- // Close menu on outside click
1427
- this.attachMenuCloseHandler(menu, button);
1428
-
1429
- // Focus the active item or first item
1430
- setTimeout(() => {
1431
- const focusTarget = activeItem || menu.querySelector(`.${this.player.options.classPrefix}-menu-item`);
1432
- if (focusTarget) {
1433
- focusTarget.focus();
1434
- }
1435
- }, 0);
1436
- }
1437
-
1438
- createCaptionsButton() {
1439
- const button = DOMUtils.createElement('button', {
1440
- className: `${this.player.options.classPrefix}-button ${this.player.options.classPrefix}-captions-button`,
1441
- attributes: {
1442
- 'type': 'button',
1443
- 'aria-label': i18n.t('player.captions'),
1444
- 'aria-pressed': 'false',
1445
- 'aria-haspopup': 'menu'
1446
- }
1447
- });
1448
-
1449
- button.appendChild(createIconElement('captionsOff'));
1450
-
1451
- button.addEventListener('click', () => {
1452
- this.showCaptionsMenu(button);
1453
- });
1454
-
1455
- this.controls.captions = button;
1456
- return button;
1457
- }
1458
-
1459
- showCaptionsMenu(button) {
1460
- // Remove existing menu if any
1461
- const existingMenu = document.querySelector(`.${this.player.options.classPrefix}-captions-menu`);
1462
- if (existingMenu) {
1463
- existingMenu.remove();
1464
- return;
1465
- }
1466
-
1467
- const menu = DOMUtils.createElement('div', {
1468
- className: `${this.player.options.classPrefix}-captions-menu ${this.player.options.classPrefix}-menu`,
1469
- attributes: {
1470
- 'role': 'menu',
1471
- 'aria-label': i18n.t('captions.select')
1472
- }
1473
- });
1474
-
1475
- // Check if there are any caption tracks
1476
- if (!this.player.captionManager || this.player.captionManager.tracks.length === 0) {
1477
- // Show "No captions available" message
1478
- const noTracksItem = DOMUtils.createElement('div', {
1479
- className: `${this.player.options.classPrefix}-menu-item`,
1480
- textContent: i18n.t('player.noCaptions'),
1481
- style: {opacity: '0.5', cursor: 'default'}
1482
- });
1483
- menu.appendChild(noTracksItem);
1484
-
1485
- // Append menu to button
1486
- button.appendChild(menu);
1487
-
1488
- // Close menu on outside click
1489
- this.attachMenuCloseHandler(menu, button);
1490
- return;
1491
- }
1492
-
1493
- let activeItem = null;
1494
-
1495
- // Off option
1496
- const offItem = DOMUtils.createElement('button', {
1497
- className: `${this.player.options.classPrefix}-menu-item`,
1498
- textContent: i18n.t('captions.off'),
1499
- attributes: {
1500
- 'type': 'button',
1501
- 'role': 'menuitem',
1502
- 'tabindex': '-1'
1503
- }
1504
- });
1505
-
1506
- if (!this.player.state.captionsEnabled) {
1507
- offItem.classList.add(`${this.player.options.classPrefix}-menu-item-active`);
1508
- offItem.appendChild(createIconElement('check'));
1509
- activeItem = offItem;
1510
- }
1511
-
1512
- offItem.addEventListener('click', () => {
1513
- this.player.disableCaptions();
1514
- this.updateCaptionsButton();
1515
- menu.remove();
1516
- });
1517
-
1518
- menu.appendChild(offItem);
1519
-
1520
- // Available tracks
1521
- const tracks = this.player.captionManager.getAvailableTracks();
1522
- tracks.forEach(track => {
1523
- const item = DOMUtils.createElement('button', {
1524
- className: `${this.player.options.classPrefix}-menu-item`,
1525
- textContent: track.label,
1526
- attributes: {
1527
- 'type': 'button',
1528
- 'role': 'menuitem',
1529
- 'lang': track.language,
1530
- 'tabindex': '-1'
1531
- }
1532
- });
1533
-
1534
- // Check if this is the current track
1535
- if (this.player.state.captionsEnabled &&
1536
- this.player.captionManager.currentTrack === this.player.captionManager.tracks[track.index]) {
1537
- item.classList.add(`${this.player.options.classPrefix}-menu-item-active`);
1538
- item.appendChild(createIconElement('check'));
1539
- activeItem = item;
1540
- }
1541
-
1542
- item.addEventListener('click', () => {
1543
- this.player.captionManager.switchTrack(track.index);
1544
- this.updateCaptionsButton();
1545
- menu.remove();
1546
- });
1547
-
1548
- menu.appendChild(item);
1549
- });
1550
-
1551
- // Append menu directly to button for proper positioning
1552
- button.appendChild(menu);
1553
-
1554
- // Add keyboard navigation for the menu
1555
- this.attachMenuKeyboardNavigation(menu);
1556
-
1557
- // Close menu on outside click and Escape key
1558
- this.attachMenuCloseHandler(menu, button);
1559
-
1560
- // Focus the active item or the first item
1561
- setTimeout(() => {
1562
- const focusTarget = activeItem || menu.querySelector(`.${this.player.options.classPrefix}-menu-item`);
1563
- if (focusTarget) {
1564
- focusTarget.focus();
1565
- }
1566
- }, 0);
1567
- }
1568
-
1569
- updateCaptionsButton() {
1570
- if (!this.controls.captions) return;
1571
-
1572
- const icon = this.controls.captions.querySelector('.vidply-icon');
1573
- const isEnabled = this.player.state.captionsEnabled;
1574
-
1575
- icon.innerHTML = isEnabled ?
1576
- createIconElement('captions').innerHTML :
1577
- createIconElement('captionsOff').innerHTML;
1578
-
1579
- this.controls.captions.setAttribute('aria-pressed', isEnabled ? 'true' : 'false');
1580
- }
1581
-
1582
- createTranscriptButton() {
1583
- const button = DOMUtils.createElement('button', {
1584
- className: `${this.player.options.classPrefix}-button ${this.player.options.classPrefix}-transcript`,
1585
- attributes: {
1586
- 'type': 'button',
1587
- 'aria-label': i18n.t('player.transcript'),
1588
- 'aria-pressed': 'false'
1589
- }
1590
- });
1591
-
1592
- button.appendChild(createIconElement('transcript'));
1593
-
1594
- button.addEventListener('click', () => {
1595
- if (this.player.transcriptManager) {
1596
- this.player.transcriptManager.toggleTranscript();
1597
- this.updateTranscriptButton();
1598
- }
1599
- });
1600
-
1601
- this.controls.transcript = button;
1602
- return button;
1603
- }
1604
-
1605
- updateTranscriptButton() {
1606
- if (!this.controls.transcript) return;
1607
-
1608
- const isVisible = this.player.transcriptManager && this.player.transcriptManager.isVisible;
1609
- this.controls.transcript.setAttribute('aria-pressed', isVisible ? 'true' : 'false');
1610
- }
1611
-
1612
- createAudioDescriptionButton() {
1613
- const button = DOMUtils.createElement('button', {
1614
- className: `${this.player.options.classPrefix}-button ${this.player.options.classPrefix}-audio-description`,
1615
- attributes: {
1616
- 'type': 'button',
1617
- 'aria-label': i18n.t('player.audioDescription'),
1618
- 'aria-pressed': 'false',
1619
- 'title': i18n.t('player.audioDescription')
1620
- }
1621
- });
1622
-
1623
- button.appendChild(createIconElement('audioDescription'));
1624
-
1625
- button.addEventListener('click', async () => {
1626
- await this.player.toggleAudioDescription();
1627
- this.updateAudioDescriptionButton();
1628
- });
1629
-
1630
- this.controls.audioDescription = button;
1631
- return button;
1632
- }
1633
-
1634
- updateAudioDescriptionButton() {
1635
- if (!this.controls.audioDescription) return;
1636
-
1637
- const icon = this.controls.audioDescription.querySelector('.vidply-icon');
1638
- const isEnabled = this.player.state.audioDescriptionEnabled;
1639
-
1640
- icon.innerHTML = isEnabled ?
1641
- createIconElement('audioDescriptionOn').innerHTML :
1642
- createIconElement('audioDescription').innerHTML;
1643
-
1644
- this.controls.audioDescription.setAttribute('aria-pressed', isEnabled ? 'true' : 'false');
1645
- this.controls.audioDescription.setAttribute('aria-label',
1646
- isEnabled ? i18n.t('audioDescription.disable') : i18n.t('audioDescription.enable')
1647
- );
1648
- }
1649
-
1650
- createSignLanguageButton() {
1651
- const button = DOMUtils.createElement('button', {
1652
- className: `${this.player.options.classPrefix}-button ${this.player.options.classPrefix}-sign-language`,
1653
- attributes: {
1654
- 'type': 'button',
1655
- 'aria-label': i18n.t('player.signLanguage'),
1656
- 'aria-pressed': 'false',
1657
- 'title': i18n.t('player.signLanguage')
1658
- }
1659
- });
1660
-
1661
- button.appendChild(createIconElement('signLanguage'));
1662
-
1663
- button.addEventListener('click', () => {
1664
- this.player.toggleSignLanguage();
1665
- this.updateSignLanguageButton();
1666
- });
1667
-
1668
- this.controls.signLanguage = button;
1669
- return button;
1670
- }
1671
-
1672
- updateSignLanguageButton() {
1673
- if (!this.controls.signLanguage) return;
1674
-
1675
- const icon = this.controls.signLanguage.querySelector('.vidply-icon');
1676
- const isEnabled = this.player.state.signLanguageEnabled;
1677
-
1678
- icon.innerHTML = isEnabled ?
1679
- createIconElement('signLanguageOn').innerHTML :
1680
- createIconElement('signLanguage').innerHTML;
1681
-
1682
- this.controls.signLanguage.setAttribute('aria-pressed', isEnabled ? 'true' : 'false');
1683
- this.controls.signLanguage.setAttribute('aria-label',
1684
- isEnabled ? i18n.t('signLanguage.hide') : i18n.t('signLanguage.show')
1685
- );
1686
- }
1687
-
1688
- createSettingsButton() {
1689
- const button = DOMUtils.createElement('button', {
1690
- className: `${this.player.options.classPrefix}-button ${this.player.options.classPrefix}-settings`,
1691
- attributes: {
1692
- 'type': 'button',
1693
- 'aria-label': i18n.t('player.settings')
1694
- }
1695
- });
1696
-
1697
- button.appendChild(createIconElement('settings'));
1698
-
1699
- button.addEventListener('click', () => {
1700
- this.player.showSettings();
1701
- });
1702
-
1703
- return button;
1704
- }
1705
-
1706
- createPipButton() {
1707
- const button = DOMUtils.createElement('button', {
1708
- className: `${this.player.options.classPrefix}-button ${this.player.options.classPrefix}-pip`,
1709
- attributes: {
1710
- 'type': 'button',
1711
- 'aria-label': i18n.t('player.pip')
1712
- }
1713
- });
1714
-
1715
- button.appendChild(createIconElement('pip'));
1716
-
1717
- button.addEventListener('click', () => {
1718
- this.player.togglePiP();
1719
- });
1720
-
1721
- return button;
1722
- }
1723
-
1724
- createFullscreenButton() {
1725
- const button = DOMUtils.createElement('button', {
1726
- className: `${this.player.options.classPrefix}-button ${this.player.options.classPrefix}-fullscreen`,
1727
- attributes: {
1728
- 'type': 'button',
1729
- 'aria-label': i18n.t('player.fullscreen')
1730
- }
1731
- });
1732
-
1733
- button.appendChild(createIconElement('fullscreen'));
1734
-
1735
- button.addEventListener('click', () => {
1736
- this.player.toggleFullscreen();
1737
- });
1738
-
1739
- this.controls.fullscreen = button;
1740
- return button;
1741
- }
1742
-
1743
- attachEvents() {
1744
- // Update controls based on player state
1745
- this.player.on('play', () => this.updatePlayPauseButton());
1746
- this.player.on('pause', () => this.updatePlayPauseButton());
1747
- this.player.on('timeupdate', () => this.updateProgress());
1748
- this.player.on('loadedmetadata', () => {
1749
- this.updateDuration();
1750
- this.ensureQualityButton();
1751
- this.updateQualityIndicator();
1752
- });
1753
- this.player.on('volumechange', () => this.updateVolumeDisplay());
1754
- this.player.on('progress', () => this.updateBuffered());
1755
- this.player.on('playbackspeedchange', () => this.updateSpeedDisplay());
1756
- this.player.on('fullscreenchange', () => this.updateFullscreenButton());
1757
- this.player.on('captionsenabled', () => this.updateCaptionsButton());
1758
- this.player.on('captionsdisabled', () => this.updateCaptionsButton());
1759
- this.player.on('audiodescriptionenabled', () => this.updateAudioDescriptionButton());
1760
- this.player.on('audiodescriptiondisabled', () => this.updateAudioDescriptionButton());
1761
- this.player.on('signlanguageenabled', () => this.updateSignLanguageButton());
1762
- this.player.on('signlanguagedisabled', () => this.updateSignLanguageButton());
1763
- this.player.on('qualitychange', () => this.updateQualityIndicator());
1764
- this.player.on('hlslevelswitched', () => this.updateQualityIndicator());
1765
- this.player.on('hlsmanifestparsed', () => {
1766
- this.ensureQualityButton();
1767
- this.updateQualityIndicator();
1768
- });
1769
- }
1770
-
1771
- updatePlayPauseButton() {
1772
- if (!this.controls.playPause) return;
1773
-
1774
- const icon = this.controls.playPause.querySelector('.vidply-icon');
1775
- const isPlaying = this.player.state.playing;
1776
-
1777
- icon.innerHTML = isPlaying ?
1778
- createIconElement('pause').innerHTML :
1779
- createIconElement('play').innerHTML;
1780
-
1781
- this.controls.playPause.setAttribute('aria-label',
1782
- isPlaying ? i18n.t('player.pause') : i18n.t('player.play')
1783
- );
1784
- }
1785
-
1786
- updateProgress() {
1787
- if (!this.controls.played) return;
1788
-
1789
- const percent = (this.player.state.currentTime / this.player.state.duration) * 100;
1790
- this.controls.played.style.width = `${percent}%`;
1791
- this.controls.progress.setAttribute('aria-valuenow', String(Math.round(percent)));
1792
-
1793
- if (this.controls.currentTimeDisplay) {
1794
- this.controls.currentTimeDisplay.textContent = TimeUtils.formatTime(this.player.state.currentTime);
1795
- }
1796
- }
1797
-
1798
- updateDuration() {
1799
- if (this.controls.durationDisplay) {
1800
- this.controls.durationDisplay.textContent = TimeUtils.formatTime(this.player.state.duration);
1801
- }
1802
- }
1803
-
1804
- updateVolumeDisplay() {
1805
- const percent = this.player.state.volume * 100;
1806
-
1807
- // Update volume fill bar if it exists
1808
- if (this.controls.volumeFill) {
1809
- this.controls.volumeFill.style.height = `${percent}%`;
1810
- }
1811
-
1812
- // Update mute button icon (should always work even if slider not shown)
1813
- if (this.controls.mute) {
1814
- const icon = this.controls.mute.querySelector('.vidply-icon');
1815
- if (icon) {
1816
- let iconName;
1817
-
1818
- if (this.player.state.muted || this.player.state.volume === 0) {
1819
- iconName = 'volumeMuted';
1820
- } else if (this.player.state.volume < 0.3) {
1821
- iconName = 'volumeLow';
1822
- } else if (this.player.state.volume < 0.7) {
1823
- iconName = 'volumeMedium';
1824
- } else {
1825
- iconName = 'volumeHigh';
1826
- }
1827
-
1828
- icon.innerHTML = createIconElement(iconName).innerHTML;
1829
-
1830
- this.controls.mute.setAttribute('aria-label',
1831
- this.player.state.muted ? i18n.t('player.unmute') : i18n.t('player.mute')
1832
- );
1833
- }
1834
- }
1835
-
1836
- // Update volume slider attribute if it exists
1837
- if (this.controls.volumeSlider) {
1838
- this.controls.volumeSlider.setAttribute('aria-valuenow', String(Math.round(percent)));
1839
- }
1840
- }
1841
-
1842
- updateBuffered() {
1843
- if (!this.controls.buffered || !this.player.element.buffered || this.player.element.buffered.length === 0) return;
1844
-
1845
- const buffered = this.player.element.buffered.end(this.player.element.buffered.length - 1);
1846
- const percent = (buffered / this.player.state.duration) * 100;
1847
- this.controls.buffered.style.width = `${percent}%`;
1848
- }
1849
-
1850
- updateSpeedDisplay() {
1851
- if (this.controls.speedText) {
1852
- this.controls.speedText.textContent = `${this.player.state.playbackSpeed}x`;
1853
- }
1854
- }
1855
-
1856
- updateFullscreenButton() {
1857
- if (!this.controls.fullscreen) return;
1858
-
1859
- const icon = this.controls.fullscreen.querySelector('.vidply-icon');
1860
- const isFullscreen = this.player.state.fullscreen;
1861
-
1862
- icon.innerHTML = isFullscreen ?
1863
- createIconElement('fullscreenExit').innerHTML :
1864
- createIconElement('fullscreen').innerHTML;
1865
-
1866
- this.controls.fullscreen.setAttribute('aria-label',
1867
- isFullscreen ? i18n.t('player.exitFullscreen') : i18n.t('player.fullscreen')
1868
- );
1869
- }
1870
-
1871
- /**
1872
- * Ensure quality button exists if qualities are available
1873
- * This is called after renderer initialization to dynamically add the button
1874
- */
1875
- ensureQualityButton() {
1876
- // Skip if quality button is disabled
1877
- if (!this.player.options.qualityButton) return;
1878
-
1879
- // Skip if button already exists
1880
- if (this.controls.quality) return;
1881
-
1882
- // Check if qualities are now available
1883
- if (!this.hasQualityLevels()) return;
1884
-
1885
- // Create and insert the quality button before the speed button
1886
- const qualityButton = this.createQualityButton();
1887
-
1888
- // Find the speed button or caption style button to insert before
1889
- const speedButton = this.rightButtons.querySelector(`.${this.player.options.classPrefix}-speed`);
1890
- const captionStyleButton = this.rightButtons.querySelector(`.${this.player.options.classPrefix}-caption-style`);
1891
- const insertBefore = captionStyleButton || speedButton;
1892
-
1893
- if (insertBefore) {
1894
- this.rightButtons.insertBefore(qualityButton, insertBefore);
1895
- } else {
1896
- // If no reference button, add it at the beginning of right buttons
1897
- this.rightButtons.insertBefore(qualityButton, this.rightButtons.firstChild);
1898
- }
1899
-
1900
- this.player.log('Quality button added dynamically', 'info');
1901
- }
1902
-
1903
- updateQualityIndicator() {
1904
- if (!this.controls.qualityText) return;
1905
- if (!this.player.renderer || !this.player.renderer.getQualities) return;
1906
-
1907
- const qualities = this.player.renderer.getQualities();
1908
- if (qualities.length === 0) {
1909
- this.controls.qualityText.textContent = '';
1910
- return;
1911
- }
1912
-
1913
- // Get current quality
1914
- let currentQualityText = '';
1915
-
1916
- // Check if it's HLS with auto mode
1917
- if (this.player.renderer.hls && this.player.renderer.hls.currentLevel === -1) {
1918
- currentQualityText = 'Auto';
1919
- } else if (this.player.renderer.getCurrentQuality) {
1920
- const currentIndex = this.player.renderer.getCurrentQuality();
1921
- const currentQuality = qualities.find(q => q.index === currentIndex);
1922
- if (currentQuality) {
1923
- currentQualityText = currentQuality.height ? `${currentQuality.height}p` : '';
1924
- }
1925
- }
1926
-
1927
- this.controls.qualityText.textContent = currentQualityText;
1928
- }
1929
-
1930
- setupAutoHide() {
1931
- if (this.player.element.tagName !== 'VIDEO') return;
1932
-
1933
- const showControls = () => {
1934
- this.element.classList.add(`${this.player.options.classPrefix}-controls-visible`);
1935
- this.player.container.classList.add(`${this.player.options.classPrefix}-controls-visible`);
1936
- this.player.state.controlsVisible = true;
1937
-
1938
- clearTimeout(this.hideTimeout);
1939
-
1940
- if (this.player.state.playing) {
1941
- this.hideTimeout = setTimeout(() => {
1942
- this.element.classList.remove(`${this.player.options.classPrefix}-controls-visible`);
1943
- this.player.container.classList.remove(`${this.player.options.classPrefix}-controls-visible`);
1944
- this.player.state.controlsVisible = false;
1945
- }, this.player.options.hideControlsDelay);
1946
- }
1947
- };
1948
-
1949
- this.player.container.addEventListener('mousemove', showControls);
1950
- this.player.container.addEventListener('touchstart', showControls);
1951
- this.player.container.addEventListener('click', showControls);
1952
-
1953
- // Show controls on focus
1954
- this.element.addEventListener('focusin', showControls);
1955
-
1956
- // Always show when paused
1957
- this.player.on('pause', () => {
1958
- showControls();
1959
- clearTimeout(this.hideTimeout);
1960
- });
1961
-
1962
- this.player.on('play', () => {
1963
- showControls();
1964
- });
1965
-
1966
- // Initial state
1967
- showControls();
1968
- }
1969
-
1970
- show() {
1971
- this.element.style.display = '';
1972
- }
1973
-
1974
- hide() {
1975
- this.element.style.display = 'none';
1976
- }
1977
-
1978
- destroy() {
1979
- if (this.hideTimeout) {
1980
- clearTimeout(this.hideTimeout);
1981
- }
1982
-
1983
- if (this.element && this.element.parentNode) {
1984
- this.element.parentNode.removeChild(this.element);
1985
- }
1986
- }
1987
- }
1988
-
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': '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': '0 seconds'
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': 'Duration: 0 seconds'
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', 'Duration: ' + 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
+