vidply 1.0.0

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