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