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.
@@ -1,1134 +1,1186 @@
1
- /**
2
- * VidPly - Universal Video Player
3
- * Main Player Class
4
- */
5
-
6
- import {EventEmitter} from '../utils/EventEmitter.js';
7
- import {DOMUtils} from '../utils/DOMUtils.js';
8
- import {ControlBar} from '../controls/ControlBar.js';
9
- import {CaptionManager} from '../controls/CaptionManager.js';
10
- import {KeyboardManager} from '../controls/KeyboardManager.js';
11
- import {TranscriptManager} from '../controls/TranscriptManager.js';
12
- import {HTML5Renderer} from '../renderers/HTML5Renderer.js';
13
- import {YouTubeRenderer} from '../renderers/YouTubeRenderer.js';
14
- import {VimeoRenderer} from '../renderers/VimeoRenderer.js';
15
- import {HLSRenderer} from '../renderers/HLSRenderer.js';
16
- import {createPlayOverlay} from '../icons/Icons.js';
17
- import {i18n} from '../i18n/i18n.js';
18
-
19
- export class Player extends EventEmitter {
20
- constructor(element, options = {}) {
21
- super();
22
-
23
- this.element = typeof element === 'string' ? document.querySelector(element) : element;
24
- if (!this.element) {
25
- throw new Error('VidPly: Element not found');
26
- }
27
-
28
- // Auto-create media element if a non-media element is provided
29
- if (this.element.tagName !== 'VIDEO' && this.element.tagName !== 'AUDIO') {
30
- const mediaType = options.mediaType || 'video';
31
- const mediaElement = document.createElement(mediaType);
32
-
33
- // Copy attributes from the div to the media element
34
- Array.from(this.element.attributes).forEach(attr => {
35
- if (attr.name !== 'id' && attr.name !== 'class' && !attr.name.startsWith('data-')) {
36
- mediaElement.setAttribute(attr.name, attr.value);
37
- }
38
- });
39
-
40
- // Copy any track elements from the div
41
- const tracks = this.element.querySelectorAll('track');
42
- tracks.forEach(track => {
43
- mediaElement.appendChild(track.cloneNode(true));
44
- });
45
-
46
- // Clear the div and insert the media element
47
- this.element.innerHTML = '';
48
- this.element.appendChild(mediaElement);
49
-
50
- // Update element reference to the actual media element
51
- this.element = mediaElement;
52
- }
53
-
54
- // Default options
55
- this.options = {
56
- // Display
57
- width: null,
58
- height: null,
59
- poster: null,
60
- responsive: true,
61
- fillContainer: false,
62
-
63
- // Playback
64
- autoplay: false,
65
- loop: false,
66
- muted: false,
67
- volume: 0.8,
68
- playbackSpeed: 1.0,
69
- preload: 'metadata',
70
- startTime: 0,
71
-
72
- // Controls
73
- controls: true,
74
- hideControlsDelay: 3000,
75
- playPauseButton: true,
76
- progressBar: true,
77
- currentTime: true,
78
- duration: true,
79
- volumeControl: true,
80
- muteButton: true,
81
- chaptersButton: true,
82
- qualityButton: true,
83
- captionStyleButton: true,
84
- speedButton: true,
85
- captionsButton: true,
86
- transcriptButton: true,
87
- fullscreenButton: true,
88
- pipButton: true,
89
-
90
- // Seeking
91
- seekInterval: 10,
92
- seekIntervalLarge: 30,
93
-
94
- // Captions
95
- captions: true,
96
- captionsDefault: false,
97
- captionsFontSize: '100%',
98
- captionsFontFamily: 'sans-serif',
99
- captionsColor: '#FFFFFF',
100
- captionsBackgroundColor: '#000000',
101
- captionsOpacity: 0.8,
102
-
103
- // Audio Description
104
- audioDescription: true,
105
- audioDescriptionSrc: null, // URL to audio-described version
106
- audioDescriptionButton: true,
107
-
108
- // Sign Language
109
- signLanguage: true,
110
- signLanguageSrc: null, // URL to sign language video
111
- signLanguageButton: true,
112
- signLanguagePosition: 'bottom-right', // Position: 'bottom-right', 'bottom-left', 'top-right', 'top-left'
113
-
114
- // Transcripts
115
- transcript: false,
116
- transcriptPosition: 'external',
117
- transcriptContainer: null,
118
-
119
- // Keyboard
120
- keyboard: true,
121
- keyboardShortcuts: {
122
- 'play-pause': [' ', 'p', 'k'],
123
- 'volume-up': ['ArrowUp'],
124
- 'volume-down': ['ArrowDown'],
125
- 'seek-forward': ['ArrowRight'],
126
- 'seek-backward': ['ArrowLeft'],
127
- 'mute': ['m'],
128
- 'fullscreen': ['f'],
129
- 'captions': ['c'],
130
- 'caption-style-menu': ['a'],
131
- 'speed-up': ['>'],
132
- 'speed-down': ['<'],
133
- 'speed-menu': ['s'],
134
- 'quality-menu': ['q'],
135
- 'chapters-menu': ['j'],
136
- 'transcript-toggle': ['t']
137
- },
138
-
139
- // Accessibility
140
- ariaLabels: {},
141
- screenReaderAnnouncements: true,
142
- highContrast: false,
143
- focusHighlight: true,
144
-
145
- // Languages
146
- language: 'en',
147
- languages: ['en'],
148
-
149
- // Advanced
150
- debug: false,
151
- classPrefix: 'vidply',
152
- iconType: 'svg',
153
- pauseOthersOnPlay: true,
154
-
155
- // Callbacks
156
- onReady: null,
157
- onPlay: null,
158
- onPause: null,
159
- onEnded: null,
160
- onTimeUpdate: null,
161
- onVolumeChange: null,
162
- onError: null,
163
-
164
- ...options
165
- };
166
-
167
- // State
168
- this.state = {
169
- ready: false,
170
- playing: false,
171
- paused: true,
172
- ended: false,
173
- buffering: false,
174
- seeking: false,
175
- muted: this.options.muted,
176
- volume: this.options.volume,
177
- currentTime: 0,
178
- duration: 0,
179
- playbackSpeed: this.options.playbackSpeed,
180
- fullscreen: false,
181
- pip: false,
182
- captionsEnabled: this.options.captionsDefault,
183
- currentCaption: null,
184
- controlsVisible: true,
185
- audioDescriptionEnabled: false,
186
- signLanguageEnabled: false
187
- };
188
-
189
- // Store original source for toggling
190
- this.originalSrc = null;
191
- this.audioDescriptionSrc = this.options.audioDescriptionSrc;
192
- this.signLanguageSrc = this.options.signLanguageSrc;
193
- this.signLanguageVideo = null;
194
-
195
- // Components
196
- this.container = null;
197
- this.renderer = null;
198
- this.controlBar = null;
199
- this.captionManager = null;
200
- this.keyboardManager = null;
201
- this.settingsDialog = null;
202
-
203
- // Initialize
204
- this.init();
205
- }
206
-
207
- async init() {
208
- try {
209
- this.log('Initializing VidPly player');
210
-
211
- // Auto-detect language from HTML lang attribute if not explicitly set
212
- if (!this.options.language || this.options.language === 'en') {
213
- const htmlLang = this.detectHtmlLanguage();
214
- if (htmlLang) {
215
- this.options.language = htmlLang;
216
- this.log(`Auto-detected language from HTML: ${htmlLang}`);
217
- }
218
- }
219
-
220
- // Set language
221
- i18n.setLanguage(this.options.language);
222
-
223
- // Create container
224
- this.createContainer();
225
-
226
- // Detect and initialize renderer (only if source exists)
227
- const src = this.element.src || this.element.querySelector('source')?.src;
228
- if (src) {
229
- await this.initializeRenderer();
230
- } else {
231
- this.log('No initial source - waiting for playlist or manual load');
232
- }
233
-
234
- // Create controls
235
- if (this.options.controls) {
236
- this.controlBar = new ControlBar(this);
237
- this.videoWrapper.appendChild(this.controlBar.element);
238
- }
239
-
240
- // Initialize captions
241
- if (this.options.captions) {
242
- this.captionManager = new CaptionManager(this);
243
- }
244
-
245
- // Initialize transcript
246
- if (this.options.transcript || this.options.transcriptButton) {
247
- this.transcriptManager = new TranscriptManager(this);
248
- }
249
-
250
- // Initialize keyboard controls
251
- if (this.options.keyboard) {
252
- this.keyboardManager = new KeyboardManager(this);
253
- }
254
-
255
- // Setup responsive handlers
256
- this.setupResponsiveHandlers();
257
-
258
- // Set initial state
259
- if (this.options.startTime > 0) {
260
- this.seek(this.options.startTime);
261
- }
262
-
263
- if (this.options.muted) {
264
- this.mute();
265
- }
266
-
267
- if (this.options.volume !== 0.8) {
268
- this.setVolume(this.options.volume);
269
- }
270
-
271
- // Mark as ready
272
- this.state.ready = true;
273
- this.emit('ready');
274
-
275
- if (this.options.onReady) {
276
- this.options.onReady.call(this);
277
- }
278
-
279
- // Autoplay if enabled
280
- if (this.options.autoplay) {
281
- this.play();
282
- }
283
-
284
- this.log('Player initialized successfully');
285
- } catch (error) {
286
- this.handleError(error);
287
- }
288
- }
289
-
290
- /**
291
- * Detect language from HTML lang attribute
292
- * @returns {string|null} Language code if available in translations, null otherwise
293
- */
294
- detectHtmlLanguage() {
295
- // Try to get lang from html element
296
- const htmlLang = document.documentElement.lang || document.documentElement.getAttribute('lang');
297
-
298
- if (!htmlLang) {
299
- return null;
300
- }
301
-
302
- // Normalize the language code (e.g., "en-US" -> "en", "de-DE" -> "de")
303
- const normalizedLang = htmlLang.toLowerCase().split('-')[0];
304
-
305
- // Check if this language is available in our translations
306
- const availableLanguages = ['en', 'de', 'es', 'fr', 'ja'];
307
-
308
- if (availableLanguages.includes(normalizedLang)) {
309
- return normalizedLang;
310
- }
311
-
312
- // Language not available, will fallback to English
313
- this.log(`Language "${htmlLang}" not available, using English as fallback`);
314
- return 'en';
315
- }
316
-
317
- createContainer() {
318
- // Create main container
319
- this.container = DOMUtils.createElement('div', {
320
- className: `${this.options.classPrefix}-player`,
321
- attributes: {
322
- 'role': 'region',
323
- 'aria-label': i18n.t('player.label'),
324
- 'tabindex': '0'
325
- }
326
- });
327
-
328
- // Add media type class
329
- const mediaType = this.element.tagName.toLowerCase();
330
- this.container.classList.add(`${this.options.classPrefix}-${mediaType}`);
331
-
332
- // Add responsive class
333
- if (this.options.responsive) {
334
- this.container.classList.add(`${this.options.classPrefix}-responsive`);
335
- }
336
-
337
- // Create video wrapper (for proper positioning of controls)
338
- this.videoWrapper = DOMUtils.createElement('div', {
339
- className: `${this.options.classPrefix}-video-wrapper`
340
- });
341
-
342
- // Wrap original element
343
- this.element.parentNode.insertBefore(this.container, this.element);
344
- this.container.appendChild(this.videoWrapper);
345
- this.videoWrapper.appendChild(this.element);
346
-
347
- // Hide native controls and set dimensions
348
- this.element.controls = false;
349
- this.element.removeAttribute('controls');
350
- this.element.setAttribute('tabindex', '-1'); // Remove from tab order
351
- this.element.style.width = '100%';
352
- this.element.style.height = '100%';
353
-
354
- // Set dimensions
355
- if (this.options.width) {
356
- this.container.style.width = typeof this.options.width === 'number'
357
- ? `${this.options.width}px`
358
- : this.options.width;
359
- }
360
-
361
- if (this.options.height) {
362
- this.container.style.height = typeof this.options.height === 'number'
363
- ? `${this.options.height}px`
364
- : this.options.height;
365
- }
366
-
367
- // Set poster
368
- if (this.options.poster && this.element.tagName === 'VIDEO') {
369
- this.element.poster = this.options.poster;
370
- }
371
-
372
- // Create centered play button overlay (only for video)
373
- if (this.element.tagName === 'VIDEO') {
374
- this.createPlayButtonOverlay();
375
- }
376
-
377
- // Make video/audio element clickable to toggle play/pause
378
- this.element.style.cursor = 'pointer';
379
- this.element.addEventListener('click', (e) => {
380
- // Prevent if clicking on native controls (shouldn't happen but just in case)
381
- if (e.target === this.element) {
382
- this.toggle();
383
- }
384
- });
385
- }
386
-
387
- createPlayButtonOverlay() {
388
- // Create complete SVG play button from Icons.js
389
- this.playButtonOverlay = createPlayOverlay();
390
-
391
- // Add click handler
392
- this.playButtonOverlay.addEventListener('click', () => {
393
- this.toggle();
394
- });
395
-
396
- // Add to video wrapper
397
- this.videoWrapper.appendChild(this.playButtonOverlay);
398
-
399
- // Show/hide based on play state
400
- this.on('play', () => {
401
- this.playButtonOverlay.style.opacity = '0';
402
- this.playButtonOverlay.style.pointerEvents = 'none';
403
- });
404
-
405
- this.on('pause', () => {
406
- this.playButtonOverlay.style.opacity = '1';
407
- this.playButtonOverlay.style.pointerEvents = 'auto';
408
- });
409
-
410
- this.on('ended', () => {
411
- this.playButtonOverlay.style.opacity = '1';
412
- this.playButtonOverlay.style.pointerEvents = 'auto';
413
- });
414
- }
415
-
416
- async initializeRenderer() {
417
- const src = this.element.src || this.element.querySelector('source')?.src;
418
-
419
- if (!src) {
420
- throw new Error('No media source found');
421
- }
422
-
423
- // Store original source for audio description toggling
424
- if (!this.originalSrc) {
425
- this.originalSrc = src;
426
- }
427
-
428
- // Detect media type
429
- let renderer;
430
-
431
- if (src.includes('youtube.com') || src.includes('youtu.be')) {
432
- renderer = YouTubeRenderer;
433
- } else if (src.includes('vimeo.com')) {
434
- renderer = VimeoRenderer;
435
- } else if (src.includes('.m3u8')) {
436
- renderer = HLSRenderer;
437
- } else {
438
- renderer = HTML5Renderer;
439
- }
440
-
441
- this.log(`Using ${renderer.name} renderer`);
442
- this.renderer = new renderer(this);
443
- await this.renderer.init();
444
- }
445
-
446
- /**
447
- * Load new media source (for playlists)
448
- * @param {Object} config - Media configuration
449
- * @param {string} config.src - Media source URL
450
- * @param {string} config.type - Media MIME type
451
- * @param {string} [config.poster] - Poster image URL
452
- * @param {Array} [config.tracks] - Text tracks (captions, chapters, etc.)
453
- */
454
- async load(config) {
455
- try {
456
- this.log('Loading new media:', config.src);
457
-
458
- // Pause current playback
459
- if (this.renderer) {
460
- this.pause();
461
- }
462
-
463
- // Clear existing text tracks
464
- const existingTracks = this.element.querySelectorAll('track');
465
- existingTracks.forEach(track => track.remove());
466
-
467
- // Update media element
468
- this.element.src = config.src;
469
-
470
- if (config.type) {
471
- this.element.type = config.type;
472
- }
473
-
474
- if (config.poster && this.element.tagName === 'VIDEO') {
475
- this.element.poster = config.poster;
476
- }
477
-
478
- // Add new text tracks
479
- if (config.tracks && config.tracks.length > 0) {
480
- config.tracks.forEach(trackConfig => {
481
- const track = document.createElement('track');
482
- track.src = trackConfig.src;
483
- track.kind = trackConfig.kind || 'captions';
484
- track.srclang = trackConfig.srclang || 'en';
485
- track.label = trackConfig.label || trackConfig.srclang;
486
-
487
- if (trackConfig.default) {
488
- track.default = true;
489
- }
490
-
491
- this.element.appendChild(track);
492
- });
493
- }
494
-
495
- // Check if we need to change renderer type
496
- const shouldChangeRenderer = this.shouldChangeRenderer(config.src);
497
-
498
- // Destroy old renderer if changing types
499
- if (shouldChangeRenderer && this.renderer) {
500
- this.renderer.destroy();
501
- this.renderer = null;
502
- }
503
-
504
- // Initialize or reinitialize renderer
505
- if (!this.renderer || shouldChangeRenderer) {
506
- await this.initializeRenderer();
507
- } else {
508
- // Just reload the current renderer with the updated element
509
- this.renderer.media = this.element; // Update media reference
510
- this.element.load();
511
- }
512
-
513
- // Reinitialize caption manager to pick up new tracks
514
- if (this.captionManager) {
515
- this.captionManager.destroy();
516
- this.captionManager = new CaptionManager(this);
517
- }
518
-
519
- // Reinitialize transcript manager to pick up new tracks
520
- if (this.transcriptManager) {
521
- const wasVisible = this.transcriptManager.isVisible;
522
- this.transcriptManager.destroy();
523
- this.transcriptManager = new TranscriptManager(this);
524
-
525
- // Restore visibility state if transcript was open
526
- if (wasVisible) {
527
- this.transcriptManager.showTranscript();
528
- }
529
- }
530
-
531
- // Update control bar to show/hide feature buttons based on new tracks
532
- if (this.controlBar) {
533
- this.updateControlBar();
534
- }
535
-
536
- this.emit('sourcechange', config);
537
- this.log('Media loaded successfully');
538
-
539
- } catch (error) {
540
- this.handleError(error);
541
- }
542
- }
543
-
544
- /**
545
- * Check if we need to change renderer type
546
- * @param {string} src - New source URL
547
- * @returns {boolean}
548
- */
549
- /**
550
- * Update control bar to refresh button visibility based on available features
551
- */
552
- updateControlBar() {
553
- if (!this.controlBar) return;
554
-
555
- const controlBar = this.controlBar;
556
-
557
- // Clear existing controls content
558
- controlBar.element.innerHTML = '';
559
-
560
- // Recreate controls with updated feature detection
561
- controlBar.createControls();
562
-
563
- // Reattach events for the new controls
564
- controlBar.attachEvents();
565
- controlBar.setupAutoHide();
566
- }
567
-
568
- shouldChangeRenderer(src) {
569
- if (!this.renderer) return true;
570
-
571
- const isYouTube = src.includes('youtube.com') || src.includes('youtu.be');
572
- const isVimeo = src.includes('vimeo.com');
573
- const isHLS = src.includes('.m3u8');
574
-
575
- const currentRendererName = this.renderer.constructor.name;
576
-
577
- if (isYouTube && currentRendererName !== 'YouTubeRenderer') return true;
578
- if (isVimeo && currentRendererName !== 'VimeoRenderer') return true;
579
- if (isHLS && currentRendererName !== 'HLSRenderer') return true;
580
- if (!isYouTube && !isVimeo && !isHLS && currentRendererName !== 'HTML5Renderer') return true;
581
-
582
- return false;
583
- }
584
-
585
- // Playback controls
586
- play() {
587
- if (this.renderer) {
588
- this.renderer.play();
589
- }
590
- }
591
-
592
- pause() {
593
- if (this.renderer) {
594
- this.renderer.pause();
595
- }
596
- }
597
-
598
- stop() {
599
- this.pause();
600
- this.seek(0);
601
- }
602
-
603
- toggle() {
604
- if (this.state.playing) {
605
- this.pause();
606
- } else {
607
- this.play();
608
- }
609
- }
610
-
611
- seek(time) {
612
- if (this.renderer) {
613
- this.renderer.seek(time);
614
- }
615
- }
616
-
617
- seekForward(interval = this.options.seekInterval) {
618
- this.seek(Math.min(this.state.currentTime + interval, this.state.duration));
619
- }
620
-
621
- seekBackward(interval = this.options.seekInterval) {
622
- this.seek(Math.max(this.state.currentTime - interval, 0));
623
- }
624
-
625
- // Volume controls
626
- setVolume(volume) {
627
- const newVolume = Math.max(0, Math.min(1, volume));
628
- if (this.renderer) {
629
- this.renderer.setVolume(newVolume);
630
- }
631
- this.state.volume = newVolume;
632
-
633
- if (newVolume > 0 && this.state.muted) {
634
- this.state.muted = false;
635
- }
636
- }
637
-
638
- getVolume() {
639
- return this.state.volume;
640
- }
641
-
642
- mute() {
643
- if (this.renderer) {
644
- this.renderer.setMuted(true);
645
- }
646
- this.state.muted = true;
647
- this.emit('volumechange');
648
- }
649
-
650
- unmute() {
651
- if (this.renderer) {
652
- this.renderer.setMuted(false);
653
- }
654
- this.state.muted = false;
655
- this.emit('volumechange');
656
- }
657
-
658
- toggleMute() {
659
- if (this.state.muted) {
660
- this.unmute();
661
- } else {
662
- this.mute();
663
- }
664
- }
665
-
666
- // Playback speed
667
- setPlaybackSpeed(speed) {
668
- const newSpeed = Math.max(0.25, Math.min(2, speed));
669
- if (this.renderer) {
670
- this.renderer.setPlaybackSpeed(newSpeed);
671
- }
672
- this.state.playbackSpeed = newSpeed;
673
- this.emit('playbackspeedchange', newSpeed);
674
- }
675
-
676
- getPlaybackSpeed() {
677
- return this.state.playbackSpeed;
678
- }
679
-
680
- // Fullscreen
681
- enterFullscreen() {
682
- const elem = this.container;
683
-
684
- if (elem.requestFullscreen) {
685
- elem.requestFullscreen();
686
- } else if (elem.webkitRequestFullscreen) {
687
- elem.webkitRequestFullscreen();
688
- } else if (elem.mozRequestFullScreen) {
689
- elem.mozRequestFullScreen();
690
- } else if (elem.msRequestFullscreen) {
691
- elem.msRequestFullscreen();
692
- }
693
-
694
- this.state.fullscreen = true;
695
- this.container.classList.add(`${this.options.classPrefix}-fullscreen`);
696
- this.emit('fullscreenchange', true);
697
- }
698
-
699
- exitFullscreen() {
700
- if (document.exitFullscreen) {
701
- document.exitFullscreen();
702
- } else if (document.webkitExitFullscreen) {
703
- document.webkitExitFullscreen();
704
- } else if (document.mozCancelFullScreen) {
705
- document.mozCancelFullScreen();
706
- } else if (document.msExitFullscreen) {
707
- document.msExitFullscreen();
708
- }
709
-
710
- this.state.fullscreen = false;
711
- this.container.classList.remove(`${this.options.classPrefix}-fullscreen`);
712
- this.emit('fullscreenchange', false);
713
- }
714
-
715
- toggleFullscreen() {
716
- if (this.state.fullscreen) {
717
- this.exitFullscreen();
718
- } else {
719
- this.enterFullscreen();
720
- }
721
- }
722
-
723
- // Picture-in-Picture
724
- enterPiP() {
725
- if (this.element.requestPictureInPicture) {
726
- this.element.requestPictureInPicture();
727
- this.state.pip = true;
728
- this.emit('pipchange', true);
729
- }
730
- }
731
-
732
- exitPiP() {
733
- if (document.pictureInPictureElement) {
734
- document.exitPictureInPicture();
735
- this.state.pip = false;
736
- this.emit('pipchange', false);
737
- }
738
- }
739
-
740
- togglePiP() {
741
- if (this.state.pip) {
742
- this.exitPiP();
743
- } else {
744
- this.enterPiP();
745
- }
746
- }
747
-
748
- // Captions
749
- enableCaptions() {
750
- if (this.captionManager) {
751
- this.captionManager.enable();
752
- this.state.captionsEnabled = true;
753
- }
754
- }
755
-
756
- disableCaptions() {
757
- if (this.captionManager) {
758
- this.captionManager.disable();
759
- this.state.captionsEnabled = false;
760
- }
761
- }
762
-
763
- toggleCaptions() {
764
- if (this.state.captionsEnabled) {
765
- this.disableCaptions();
766
- } else {
767
- this.enableCaptions();
768
- }
769
- }
770
-
771
- // Audio Description
772
- async enableAudioDescription() {
773
- if (!this.audioDescriptionSrc) {
774
- console.warn('VidPly: No audio description source provided');
775
- return;
776
- }
777
-
778
- // Store current playback state
779
- const currentTime = this.state.currentTime;
780
- const wasPlaying = this.state.playing;
781
-
782
- // Switch to audio-described version
783
- this.element.src = this.audioDescriptionSrc;
784
-
785
- // Wait for new source to load
786
- await new Promise((resolve) => {
787
- const onLoadedMetadata = () => {
788
- this.element.removeEventListener('loadedmetadata', onLoadedMetadata);
789
- resolve();
790
- };
791
- this.element.addEventListener('loadedmetadata', onLoadedMetadata);
792
- });
793
-
794
- // Restore playback position
795
- this.seek(currentTime);
796
-
797
- if (wasPlaying) {
798
- this.play();
799
- }
800
-
801
- this.state.audioDescriptionEnabled = true;
802
- this.emit('audiodescriptionenabled');
803
- }
804
-
805
- async disableAudioDescription() {
806
- if (!this.originalSrc) {
807
- return;
808
- }
809
-
810
- // Store current playback state
811
- const currentTime = this.state.currentTime;
812
- const wasPlaying = this.state.playing;
813
-
814
- // Switch back to original version
815
- this.element.src = this.originalSrc;
816
-
817
- // Wait for new source to load
818
- await new Promise((resolve) => {
819
- const onLoadedMetadata = () => {
820
- this.element.removeEventListener('loadedmetadata', onLoadedMetadata);
821
- resolve();
822
- };
823
- this.element.addEventListener('loadedmetadata', onLoadedMetadata);
824
- });
825
-
826
- // Restore playback position
827
- this.seek(currentTime);
828
-
829
- if (wasPlaying) {
830
- this.play();
831
- }
832
-
833
- this.state.audioDescriptionEnabled = false;
834
- this.emit('audiodescriptiondisabled');
835
- }
836
-
837
- async toggleAudioDescription() {
838
- if (this.state.audioDescriptionEnabled) {
839
- await this.disableAudioDescription();
840
- } else {
841
- await this.enableAudioDescription();
842
- }
843
- }
844
-
845
- // Sign Language
846
- enableSignLanguage() {
847
- if (!this.signLanguageSrc) {
848
- console.warn('No sign language video source provided');
849
- return;
850
- }
851
-
852
- if (this.signLanguageVideo) {
853
- // Already exists, just show it
854
- this.signLanguageVideo.style.display = 'block';
855
- this.state.signLanguageEnabled = true;
856
- this.emit('signlanguageenabled');
857
- return;
858
- }
859
-
860
- // Create sign language video element
861
- this.signLanguageVideo = document.createElement('video');
862
- this.signLanguageVideo.className = 'vidply-sign-language-video';
863
- this.signLanguageVideo.src = this.signLanguageSrc;
864
- this.signLanguageVideo.setAttribute('aria-label', i18n.t('player.signLanguage'));
865
-
866
- // Set position based on options
867
- const position = this.options.signLanguagePosition || 'bottom-right';
868
- this.signLanguageVideo.classList.add(`vidply-sign-position-${position}`);
869
-
870
- // Sync with main video
871
- this.signLanguageVideo.muted = true; // Sign language video should be muted
872
- this.signLanguageVideo.currentTime = this.state.currentTime;
873
- if (!this.state.paused) {
874
- this.signLanguageVideo.play();
875
- }
876
-
877
- // Add to video wrapper (so it overlays the video, not the entire container)
878
- this.videoWrapper.appendChild(this.signLanguageVideo);
879
-
880
- // Create bound handlers to store references for cleanup
881
- this.signLanguageHandlers = {
882
- play: () => {
883
- if (this.signLanguageVideo) {
884
- this.signLanguageVideo.play();
885
- }
886
- },
887
- pause: () => {
888
- if (this.signLanguageVideo) {
889
- this.signLanguageVideo.pause();
890
- }
891
- },
892
- timeupdate: () => {
893
- if (this.signLanguageVideo && Math.abs(this.signLanguageVideo.currentTime - this.state.currentTime) > 0.5) {
894
- this.signLanguageVideo.currentTime = this.state.currentTime;
895
- }
896
- },
897
- ratechange: () => {
898
- if (this.signLanguageVideo) {
899
- this.signLanguageVideo.playbackRate = this.state.playbackSpeed;
900
- }
901
- }
902
- };
903
-
904
- // Sync playback
905
- this.on('play', this.signLanguageHandlers.play);
906
- this.on('pause', this.signLanguageHandlers.pause);
907
- this.on('timeupdate', this.signLanguageHandlers.timeupdate);
908
- this.on('ratechange', this.signLanguageHandlers.ratechange);
909
-
910
- this.state.signLanguageEnabled = true;
911
- this.emit('signlanguageenabled');
912
- }
913
-
914
- disableSignLanguage() {
915
- if (this.signLanguageVideo) {
916
- this.signLanguageVideo.style.display = 'none';
917
- }
918
- this.state.signLanguageEnabled = false;
919
- this.emit('signlanguagedisabled');
920
- }
921
-
922
- toggleSignLanguage() {
923
- if (this.state.signLanguageEnabled) {
924
- this.disableSignLanguage();
925
- } else {
926
- this.enableSignLanguage();
927
- }
928
- }
929
-
930
- cleanupSignLanguage() {
931
- // Remove event listeners
932
- if (this.signLanguageHandlers) {
933
- this.off('play', this.signLanguageHandlers.play);
934
- this.off('pause', this.signLanguageHandlers.pause);
935
- this.off('timeupdate', this.signLanguageHandlers.timeupdate);
936
- this.off('ratechange', this.signLanguageHandlers.ratechange);
937
- this.signLanguageHandlers = null;
938
- }
939
-
940
- // Remove video element
941
- if (this.signLanguageVideo && this.signLanguageVideo.parentNode) {
942
- this.signLanguageVideo.pause();
943
- this.signLanguageVideo.src = '';
944
- this.signLanguageVideo.parentNode.removeChild(this.signLanguageVideo);
945
- this.signLanguageVideo = null;
946
- }
947
- }
948
-
949
- // Settings
950
- // Settings dialog removed - using individual control buttons instead
951
- showSettings() {
952
- console.warn('[VidPly] Settings dialog has been removed. Use individual control buttons (speed, captions, etc.)');
953
- }
954
-
955
- hideSettings() {
956
- // No-op - settings dialog removed
957
- }
958
-
959
- // Utility methods
960
- getCurrentTime() {
961
- return this.state.currentTime;
962
- }
963
-
964
- getDuration() {
965
- return this.state.duration;
966
- }
967
-
968
- isPlaying() {
969
- return this.state.playing;
970
- }
971
-
972
- isPaused() {
973
- return this.state.paused;
974
- }
975
-
976
- isEnded() {
977
- return this.state.ended;
978
- }
979
-
980
- isMuted() {
981
- return this.state.muted;
982
- }
983
-
984
- isFullscreen() {
985
- return this.state.fullscreen;
986
- }
987
-
988
- // Error handling
989
- handleError(error) {
990
- this.log('Error:', error, 'error');
991
- this.emit('error', error);
992
-
993
- if (this.options.onError) {
994
- this.options.onError.call(this, error);
995
- }
996
- }
997
-
998
- // Logging
999
- log(message, type = 'log') {
1000
- if (this.options.debug) {
1001
- console[type](`[VidPly]`, message);
1002
- }
1003
- }
1004
-
1005
- // Setup responsive handlers
1006
- setupResponsiveHandlers() {
1007
- // Use ResizeObserver for efficient resize tracking
1008
- if (typeof ResizeObserver !== 'undefined') {
1009
- this.resizeObserver = new ResizeObserver((entries) => {
1010
- for (const entry of entries) {
1011
- const width = entry.contentRect.width;
1012
-
1013
- // Update control bar for viewport
1014
- if (this.controlBar && typeof this.controlBar.updateControlsForViewport === 'function') {
1015
- this.controlBar.updateControlsForViewport(width);
1016
- }
1017
-
1018
- // Update transcript positioning
1019
- if (this.transcriptManager && this.transcriptManager.isVisible) {
1020
- this.transcriptManager.positionTranscript();
1021
- }
1022
- }
1023
- });
1024
-
1025
- this.resizeObserver.observe(this.container);
1026
- } else {
1027
- // Fallback to window resize event
1028
- this.resizeHandler = () => {
1029
- const width = this.container.clientWidth;
1030
-
1031
- if (this.controlBar && typeof this.controlBar.updateControlsForViewport === 'function') {
1032
- this.controlBar.updateControlsForViewport(width);
1033
- }
1034
-
1035
- if (this.transcriptManager && this.transcriptManager.isVisible) {
1036
- this.transcriptManager.positionTranscript();
1037
- }
1038
- };
1039
-
1040
- window.addEventListener('resize', this.resizeHandler);
1041
- }
1042
-
1043
- // Also listen for orientation changes on mobile
1044
- if (window.matchMedia) {
1045
- this.orientationHandler = (e) => {
1046
- // Wait for layout to settle
1047
- setTimeout(() => {
1048
- if (this.transcriptManager && this.transcriptManager.isVisible) {
1049
- this.transcriptManager.positionTranscript();
1050
- }
1051
- }, 100);
1052
- };
1053
-
1054
- const orientationQuery = window.matchMedia('(orientation: portrait)');
1055
- if (orientationQuery.addEventListener) {
1056
- orientationQuery.addEventListener('change', this.orientationHandler);
1057
- } else if (orientationQuery.addListener) {
1058
- // Fallback for older browsers
1059
- orientationQuery.addListener(this.orientationHandler);
1060
- }
1061
-
1062
- this.orientationQuery = orientationQuery;
1063
- }
1064
- }
1065
-
1066
- // Cleanup
1067
- destroy() {
1068
- this.log('Destroying player');
1069
-
1070
- if (this.renderer) {
1071
- this.renderer.destroy();
1072
- }
1073
-
1074
- if (this.controlBar) {
1075
- this.controlBar.destroy();
1076
- }
1077
-
1078
- if (this.captionManager) {
1079
- this.captionManager.destroy();
1080
- }
1081
-
1082
- if (this.keyboardManager) {
1083
- this.keyboardManager.destroy();
1084
- }
1085
-
1086
- if (this.transcriptManager) {
1087
- this.transcriptManager.destroy();
1088
- }
1089
-
1090
- // Cleanup sign language video and listeners
1091
- this.cleanupSignLanguage();
1092
-
1093
- // Cleanup play overlay button
1094
- if (this.playButtonOverlay && this.playButtonOverlay.parentNode) {
1095
- this.playButtonOverlay.remove();
1096
- this.playButtonOverlay = null;
1097
- }
1098
-
1099
- // Cleanup resize observer
1100
- if (this.resizeObserver) {
1101
- this.resizeObserver.disconnect();
1102
- this.resizeObserver = null;
1103
- }
1104
-
1105
- // Cleanup window resize handler
1106
- if (this.resizeHandler) {
1107
- window.removeEventListener('resize', this.resizeHandler);
1108
- this.resizeHandler = null;
1109
- }
1110
-
1111
- // Cleanup orientation change handler
1112
- if (this.orientationQuery && this.orientationHandler) {
1113
- if (this.orientationQuery.removeEventListener) {
1114
- this.orientationQuery.removeEventListener('change', this.orientationHandler);
1115
- } else if (this.orientationQuery.removeListener) {
1116
- this.orientationQuery.removeListener(this.orientationHandler);
1117
- }
1118
- this.orientationQuery = null;
1119
- this.orientationHandler = null;
1120
- }
1121
-
1122
- // Remove container
1123
- if (this.container && this.container.parentNode) {
1124
- this.container.parentNode.insertBefore(this.element, this.container);
1125
- this.container.parentNode.removeChild(this.container);
1126
- }
1127
-
1128
- this.removeAllListeners();
1129
- }
1130
- }
1131
-
1132
- // Static instances tracker for pause others functionality
1133
- Player.instances = [];
1134
-
1
+ /**
2
+ * VidPly - Universal Video Player
3
+ * Main Player Class
4
+ */
5
+
6
+ import {EventEmitter} from '../utils/EventEmitter.js';
7
+ import {DOMUtils} from '../utils/DOMUtils.js';
8
+ import {ControlBar} from '../controls/ControlBar.js';
9
+ import {CaptionManager} from '../controls/CaptionManager.js';
10
+ import {KeyboardManager} from '../controls/KeyboardManager.js';
11
+ import {TranscriptManager} from '../controls/TranscriptManager.js';
12
+ import {HTML5Renderer} from '../renderers/HTML5Renderer.js';
13
+ import {YouTubeRenderer} from '../renderers/YouTubeRenderer.js';
14
+ import {VimeoRenderer} from '../renderers/VimeoRenderer.js';
15
+ import {HLSRenderer} from '../renderers/HLSRenderer.js';
16
+ import {createPlayOverlay} from '../icons/Icons.js';
17
+ import {i18n} from '../i18n/i18n.js';
18
+
19
+ export class Player extends EventEmitter {
20
+ constructor(element, options = {}) {
21
+ super();
22
+
23
+ this.element = typeof element === 'string' ? document.querySelector(element) : element;
24
+ if (!this.element) {
25
+ throw new Error('VidPly: Element not found');
26
+ }
27
+
28
+ // Auto-create media element if a non-media element is provided
29
+ if (this.element.tagName !== 'VIDEO' && this.element.tagName !== 'AUDIO') {
30
+ const mediaType = options.mediaType || 'video';
31
+ const mediaElement = document.createElement(mediaType);
32
+
33
+ // Copy attributes from the div to the media element
34
+ Array.from(this.element.attributes).forEach(attr => {
35
+ if (attr.name !== 'id' && attr.name !== 'class' && !attr.name.startsWith('data-')) {
36
+ mediaElement.setAttribute(attr.name, attr.value);
37
+ }
38
+ });
39
+
40
+ // Copy any track elements from the div
41
+ const tracks = this.element.querySelectorAll('track');
42
+ tracks.forEach(track => {
43
+ mediaElement.appendChild(track.cloneNode(true));
44
+ });
45
+
46
+ // Clear the div and insert the media element
47
+ this.element.innerHTML = '';
48
+ this.element.appendChild(mediaElement);
49
+
50
+ // Update element reference to the actual media element
51
+ this.element = mediaElement;
52
+ }
53
+
54
+ // Default options
55
+ this.options = {
56
+ // Display
57
+ width: null,
58
+ height: null,
59
+ poster: null,
60
+ responsive: true,
61
+ fillContainer: false,
62
+
63
+ // Playback
64
+ autoplay: false,
65
+ loop: false,
66
+ muted: false,
67
+ volume: 0.8,
68
+ playbackSpeed: 1.0,
69
+ preload: 'metadata',
70
+ startTime: 0,
71
+ playsInline: true, // Enable inline playback on iOS (prevents native fullscreen)
72
+
73
+ // Controls
74
+ controls: true,
75
+ hideControlsDelay: 3000,
76
+ playPauseButton: true,
77
+ progressBar: true,
78
+ currentTime: true,
79
+ duration: true,
80
+ volumeControl: true,
81
+ muteButton: true,
82
+ chaptersButton: true,
83
+ qualityButton: true,
84
+ captionStyleButton: true,
85
+ speedButton: true,
86
+ captionsButton: true,
87
+ transcriptButton: true,
88
+ fullscreenButton: true,
89
+ pipButton: true,
90
+
91
+ // Seeking
92
+ seekInterval: 10,
93
+ seekIntervalLarge: 30,
94
+
95
+ // Captions
96
+ captions: true,
97
+ captionsDefault: false,
98
+ captionsFontSize: '100%',
99
+ captionsFontFamily: 'sans-serif',
100
+ captionsColor: '#FFFFFF',
101
+ captionsBackgroundColor: '#000000',
102
+ captionsOpacity: 0.8,
103
+
104
+ // Audio Description
105
+ audioDescription: true,
106
+ audioDescriptionSrc: null, // URL to audio-described version
107
+ audioDescriptionButton: true,
108
+
109
+ // Sign Language
110
+ signLanguage: true,
111
+ signLanguageSrc: null, // URL to sign language video
112
+ signLanguageButton: true,
113
+ signLanguagePosition: 'bottom-right', // Position: 'bottom-right', 'bottom-left', 'top-right', 'top-left'
114
+
115
+ // Transcripts
116
+ transcript: false,
117
+ transcriptPosition: 'external',
118
+ transcriptContainer: null,
119
+
120
+ // Keyboard
121
+ keyboard: true,
122
+ keyboardShortcuts: {
123
+ 'play-pause': [' ', 'p', 'k'],
124
+ 'volume-up': ['ArrowUp'],
125
+ 'volume-down': ['ArrowDown'],
126
+ 'seek-forward': ['ArrowRight'],
127
+ 'seek-backward': ['ArrowLeft'],
128
+ 'mute': ['m'],
129
+ 'fullscreen': ['f'],
130
+ 'captions': ['c'],
131
+ 'caption-style-menu': ['a'],
132
+ 'speed-up': ['>'],
133
+ 'speed-down': ['<'],
134
+ 'speed-menu': ['s'],
135
+ 'quality-menu': ['q'],
136
+ 'chapters-menu': ['j'],
137
+ 'transcript-toggle': ['t']
138
+ },
139
+
140
+ // Accessibility
141
+ ariaLabels: {},
142
+ screenReaderAnnouncements: true,
143
+ highContrast: false,
144
+ focusHighlight: true,
145
+
146
+ // Languages
147
+ language: 'en',
148
+ languages: ['en'],
149
+
150
+ // Advanced
151
+ debug: false,
152
+ classPrefix: 'vidply',
153
+ iconType: 'svg',
154
+ pauseOthersOnPlay: true,
155
+
156
+ // Callbacks
157
+ onReady: null,
158
+ onPlay: null,
159
+ onPause: null,
160
+ onEnded: null,
161
+ onTimeUpdate: null,
162
+ onVolumeChange: null,
163
+ onError: null,
164
+
165
+ ...options
166
+ };
167
+
168
+ // State
169
+ this.state = {
170
+ ready: false,
171
+ playing: false,
172
+ paused: true,
173
+ ended: false,
174
+ buffering: false,
175
+ seeking: false,
176
+ muted: this.options.muted,
177
+ volume: this.options.volume,
178
+ currentTime: 0,
179
+ duration: 0,
180
+ playbackSpeed: this.options.playbackSpeed,
181
+ fullscreen: false,
182
+ pip: false,
183
+ captionsEnabled: this.options.captionsDefault,
184
+ currentCaption: null,
185
+ controlsVisible: true,
186
+ audioDescriptionEnabled: false,
187
+ signLanguageEnabled: false
188
+ };
189
+
190
+ // Store original source for toggling
191
+ this.originalSrc = null;
192
+ this.audioDescriptionSrc = this.options.audioDescriptionSrc;
193
+ this.signLanguageSrc = this.options.signLanguageSrc;
194
+ this.signLanguageVideo = null;
195
+
196
+ // Components
197
+ this.container = null;
198
+ this.renderer = null;
199
+ this.controlBar = null;
200
+ this.captionManager = null;
201
+ this.keyboardManager = null;
202
+ this.settingsDialog = null;
203
+
204
+ // Initialize
205
+ this.init();
206
+ }
207
+
208
+ async init() {
209
+ try {
210
+ this.log('Initializing VidPly player');
211
+
212
+ // Auto-detect language from HTML lang attribute if not explicitly set
213
+ if (!this.options.language || this.options.language === 'en') {
214
+ const htmlLang = this.detectHtmlLanguage();
215
+ if (htmlLang) {
216
+ this.options.language = htmlLang;
217
+ this.log(`Auto-detected language from HTML: ${htmlLang}`);
218
+ }
219
+ }
220
+
221
+ // Set language
222
+ i18n.setLanguage(this.options.language);
223
+
224
+ // Create container
225
+ this.createContainer();
226
+
227
+ // Detect and initialize renderer (only if source exists)
228
+ const src = this.element.src || this.element.querySelector('source')?.src;
229
+ if (src) {
230
+ await this.initializeRenderer();
231
+ } else {
232
+ this.log('No initial source - waiting for playlist or manual load');
233
+ }
234
+
235
+ // Create controls
236
+ if (this.options.controls) {
237
+ this.controlBar = new ControlBar(this);
238
+ this.videoWrapper.appendChild(this.controlBar.element);
239
+ }
240
+
241
+ // Initialize captions
242
+ if (this.options.captions) {
243
+ this.captionManager = new CaptionManager(this);
244
+ }
245
+
246
+ // Initialize transcript
247
+ if (this.options.transcript || this.options.transcriptButton) {
248
+ this.transcriptManager = new TranscriptManager(this);
249
+ }
250
+
251
+ // Initialize keyboard controls
252
+ if (this.options.keyboard) {
253
+ this.keyboardManager = new KeyboardManager(this);
254
+ }
255
+
256
+ // Setup responsive handlers
257
+ this.setupResponsiveHandlers();
258
+
259
+ // Set initial state
260
+ if (this.options.startTime > 0) {
261
+ this.seek(this.options.startTime);
262
+ }
263
+
264
+ if (this.options.muted) {
265
+ this.mute();
266
+ }
267
+
268
+ if (this.options.volume !== 0.8) {
269
+ this.setVolume(this.options.volume);
270
+ }
271
+
272
+ // Mark as ready
273
+ this.state.ready = true;
274
+ this.emit('ready');
275
+
276
+ if (this.options.onReady) {
277
+ this.options.onReady.call(this);
278
+ }
279
+
280
+ // Autoplay if enabled
281
+ if (this.options.autoplay) {
282
+ this.play();
283
+ }
284
+
285
+ this.log('Player initialized successfully');
286
+ } catch (error) {
287
+ this.handleError(error);
288
+ }
289
+ }
290
+
291
+ /**
292
+ * Detect language from HTML lang attribute
293
+ * @returns {string|null} Language code if available in translations, null otherwise
294
+ */
295
+ detectHtmlLanguage() {
296
+ // Try to get lang from html element
297
+ const htmlLang = document.documentElement.lang || document.documentElement.getAttribute('lang');
298
+
299
+ if (!htmlLang) {
300
+ return null;
301
+ }
302
+
303
+ // Normalize the language code (e.g., "en-US" -> "en", "de-DE" -> "de")
304
+ const normalizedLang = htmlLang.toLowerCase().split('-')[0];
305
+
306
+ // Check if this language is available in our translations
307
+ const availableLanguages = ['en', 'de', 'es', 'fr', 'ja'];
308
+
309
+ if (availableLanguages.includes(normalizedLang)) {
310
+ return normalizedLang;
311
+ }
312
+
313
+ // Language not available, will fallback to English
314
+ this.log(`Language "${htmlLang}" not available, using English as fallback`);
315
+ return 'en';
316
+ }
317
+
318
+ createContainer() {
319
+ // Create main container
320
+ this.container = DOMUtils.createElement('div', {
321
+ className: `${this.options.classPrefix}-player`,
322
+ attributes: {
323
+ 'role': 'region',
324
+ 'aria-label': i18n.t('player.label'),
325
+ 'tabindex': '0'
326
+ }
327
+ });
328
+
329
+ // Add media type class
330
+ const mediaType = this.element.tagName.toLowerCase();
331
+ this.container.classList.add(`${this.options.classPrefix}-${mediaType}`);
332
+
333
+ // Add responsive class
334
+ if (this.options.responsive) {
335
+ this.container.classList.add(`${this.options.classPrefix}-responsive`);
336
+ }
337
+
338
+ // Create video wrapper (for proper positioning of controls)
339
+ this.videoWrapper = DOMUtils.createElement('div', {
340
+ className: `${this.options.classPrefix}-video-wrapper`
341
+ });
342
+
343
+ // Wrap original element
344
+ this.element.parentNode.insertBefore(this.container, this.element);
345
+ this.container.appendChild(this.videoWrapper);
346
+ this.videoWrapper.appendChild(this.element);
347
+
348
+ // Hide native controls and set dimensions
349
+ this.element.controls = false;
350
+ this.element.removeAttribute('controls');
351
+ this.element.setAttribute('tabindex', '-1'); // Remove from tab order
352
+ this.element.style.width = '100%';
353
+ this.element.style.height = '100%';
354
+
355
+ // Enable inline playback on iOS (prevents native fullscreen)
356
+ // This allows custom controls to work on iOS devices
357
+ if (this.element.tagName === 'VIDEO' && this.options.playsInline) {
358
+ this.element.setAttribute('playsinline', '');
359
+ this.element.setAttribute('webkit-playsinline', ''); // For older iOS versions
360
+ this.element.playsInline = true; // Property version
361
+ }
362
+
363
+ // Set dimensions
364
+ if (this.options.width) {
365
+ this.container.style.width = typeof this.options.width === 'number'
366
+ ? `${this.options.width}px`
367
+ : this.options.width;
368
+ }
369
+
370
+ if (this.options.height) {
371
+ this.container.style.height = typeof this.options.height === 'number'
372
+ ? `${this.options.height}px`
373
+ : this.options.height;
374
+ }
375
+
376
+ // Set poster
377
+ if (this.options.poster && this.element.tagName === 'VIDEO') {
378
+ this.element.poster = this.options.poster;
379
+ }
380
+
381
+ // Create centered play button overlay (only for video)
382
+ if (this.element.tagName === 'VIDEO') {
383
+ this.createPlayButtonOverlay();
384
+ }
385
+
386
+ // Make video/audio element clickable to toggle play/pause
387
+ this.element.style.cursor = 'pointer';
388
+ this.element.addEventListener('click', (e) => {
389
+ // Prevent if clicking on native controls (shouldn't happen but just in case)
390
+ if (e.target === this.element) {
391
+ this.toggle();
392
+ }
393
+ });
394
+ }
395
+
396
+ createPlayButtonOverlay() {
397
+ // Create complete SVG play button from Icons.js
398
+ this.playButtonOverlay = createPlayOverlay();
399
+
400
+ // Add click handler
401
+ this.playButtonOverlay.addEventListener('click', () => {
402
+ this.toggle();
403
+ });
404
+
405
+ // Add to video wrapper
406
+ this.videoWrapper.appendChild(this.playButtonOverlay);
407
+
408
+ // Show/hide based on play state
409
+ this.on('play', () => {
410
+ this.playButtonOverlay.style.opacity = '0';
411
+ this.playButtonOverlay.style.pointerEvents = 'none';
412
+ });
413
+
414
+ this.on('pause', () => {
415
+ this.playButtonOverlay.style.opacity = '1';
416
+ this.playButtonOverlay.style.pointerEvents = 'auto';
417
+ });
418
+
419
+ this.on('ended', () => {
420
+ this.playButtonOverlay.style.opacity = '1';
421
+ this.playButtonOverlay.style.pointerEvents = 'auto';
422
+ });
423
+ }
424
+
425
+ async initializeRenderer() {
426
+ const src = this.element.src || this.element.querySelector('source')?.src;
427
+
428
+ if (!src) {
429
+ throw new Error('No media source found');
430
+ }
431
+
432
+ // Store original source for audio description toggling
433
+ if (!this.originalSrc) {
434
+ this.originalSrc = src;
435
+ }
436
+
437
+ // Detect media type
438
+ let renderer;
439
+
440
+ if (src.includes('youtube.com') || src.includes('youtu.be')) {
441
+ renderer = YouTubeRenderer;
442
+ } else if (src.includes('vimeo.com')) {
443
+ renderer = VimeoRenderer;
444
+ } else if (src.includes('.m3u8')) {
445
+ renderer = HLSRenderer;
446
+ } else {
447
+ renderer = HTML5Renderer;
448
+ }
449
+
450
+ this.log(`Using ${renderer.name} renderer`);
451
+ this.renderer = new renderer(this);
452
+ await this.renderer.init();
453
+ }
454
+
455
+ /**
456
+ * Load new media source (for playlists)
457
+ * @param {Object} config - Media configuration
458
+ * @param {string} config.src - Media source URL
459
+ * @param {string} config.type - Media MIME type
460
+ * @param {string} [config.poster] - Poster image URL
461
+ * @param {Array} [config.tracks] - Text tracks (captions, chapters, etc.)
462
+ */
463
+ async load(config) {
464
+ try {
465
+ this.log('Loading new media:', config.src);
466
+
467
+ // Pause current playback
468
+ if (this.renderer) {
469
+ this.pause();
470
+ }
471
+
472
+ // Clear existing text tracks
473
+ const existingTracks = this.element.querySelectorAll('track');
474
+ existingTracks.forEach(track => track.remove());
475
+
476
+ // Update media element
477
+ this.element.src = config.src;
478
+
479
+ if (config.type) {
480
+ this.element.type = config.type;
481
+ }
482
+
483
+ if (config.poster && this.element.tagName === 'VIDEO') {
484
+ this.element.poster = config.poster;
485
+ }
486
+
487
+ // Add new text tracks
488
+ if (config.tracks && config.tracks.length > 0) {
489
+ config.tracks.forEach(trackConfig => {
490
+ const track = document.createElement('track');
491
+ track.src = trackConfig.src;
492
+ track.kind = trackConfig.kind || 'captions';
493
+ track.srclang = trackConfig.srclang || 'en';
494
+ track.label = trackConfig.label || trackConfig.srclang;
495
+
496
+ if (trackConfig.default) {
497
+ track.default = true;
498
+ }
499
+
500
+ this.element.appendChild(track);
501
+ });
502
+ }
503
+
504
+ // Check if we need to change renderer type
505
+ const shouldChangeRenderer = this.shouldChangeRenderer(config.src);
506
+
507
+ // Destroy old renderer if changing types
508
+ if (shouldChangeRenderer && this.renderer) {
509
+ this.renderer.destroy();
510
+ this.renderer = null;
511
+ }
512
+
513
+ // Initialize or reinitialize renderer
514
+ if (!this.renderer || shouldChangeRenderer) {
515
+ await this.initializeRenderer();
516
+ } else {
517
+ // Just reload the current renderer with the updated element
518
+ this.renderer.media = this.element; // Update media reference
519
+ this.element.load();
520
+ }
521
+
522
+ // Reinitialize caption manager to pick up new tracks
523
+ if (this.captionManager) {
524
+ this.captionManager.destroy();
525
+ this.captionManager = new CaptionManager(this);
526
+ }
527
+
528
+ // Reinitialize transcript manager to pick up new tracks
529
+ if (this.transcriptManager) {
530
+ const wasVisible = this.transcriptManager.isVisible;
531
+ this.transcriptManager.destroy();
532
+ this.transcriptManager = new TranscriptManager(this);
533
+
534
+ // Restore visibility state if transcript was open
535
+ if (wasVisible) {
536
+ this.transcriptManager.showTranscript();
537
+ }
538
+ }
539
+
540
+ // Update control bar to show/hide feature buttons based on new tracks
541
+ if (this.controlBar) {
542
+ this.updateControlBar();
543
+ }
544
+
545
+ this.emit('sourcechange', config);
546
+ this.log('Media loaded successfully');
547
+
548
+ } catch (error) {
549
+ this.handleError(error);
550
+ }
551
+ }
552
+
553
+ /**
554
+ * Check if we need to change renderer type
555
+ * @param {string} src - New source URL
556
+ * @returns {boolean}
557
+ */
558
+ /**
559
+ * Update control bar to refresh button visibility based on available features
560
+ */
561
+ updateControlBar() {
562
+ if (!this.controlBar) return;
563
+
564
+ const controlBar = this.controlBar;
565
+
566
+ // Clear existing controls content
567
+ controlBar.element.innerHTML = '';
568
+
569
+ // Recreate controls with updated feature detection
570
+ controlBar.createControls();
571
+
572
+ // Reattach events for the new controls
573
+ controlBar.attachEvents();
574
+ controlBar.setupAutoHide();
575
+ }
576
+
577
+ shouldChangeRenderer(src) {
578
+ if (!this.renderer) return true;
579
+
580
+ const isYouTube = src.includes('youtube.com') || src.includes('youtu.be');
581
+ const isVimeo = src.includes('vimeo.com');
582
+ const isHLS = src.includes('.m3u8');
583
+
584
+ const currentRendererName = this.renderer.constructor.name;
585
+
586
+ if (isYouTube && currentRendererName !== 'YouTubeRenderer') return true;
587
+ if (isVimeo && currentRendererName !== 'VimeoRenderer') return true;
588
+ if (isHLS && currentRendererName !== 'HLSRenderer') return true;
589
+ if (!isYouTube && !isVimeo && !isHLS && currentRendererName !== 'HTML5Renderer') return true;
590
+
591
+ return false;
592
+ }
593
+
594
+ // Playback controls
595
+ play() {
596
+ if (this.renderer) {
597
+ this.renderer.play();
598
+ }
599
+ }
600
+
601
+ pause() {
602
+ if (this.renderer) {
603
+ this.renderer.pause();
604
+ }
605
+ }
606
+
607
+ stop() {
608
+ this.pause();
609
+ this.seek(0);
610
+ }
611
+
612
+ toggle() {
613
+ if (this.state.playing) {
614
+ this.pause();
615
+ } else {
616
+ this.play();
617
+ }
618
+ }
619
+
620
+ seek(time) {
621
+ if (this.renderer) {
622
+ this.renderer.seek(time);
623
+ }
624
+ }
625
+
626
+ seekForward(interval = this.options.seekInterval) {
627
+ this.seek(Math.min(this.state.currentTime + interval, this.state.duration));
628
+ }
629
+
630
+ seekBackward(interval = this.options.seekInterval) {
631
+ this.seek(Math.max(this.state.currentTime - interval, 0));
632
+ }
633
+
634
+ // Volume controls
635
+ setVolume(volume) {
636
+ const newVolume = Math.max(0, Math.min(1, volume));
637
+ if (this.renderer) {
638
+ this.renderer.setVolume(newVolume);
639
+ }
640
+ this.state.volume = newVolume;
641
+
642
+ if (newVolume > 0 && this.state.muted) {
643
+ this.state.muted = false;
644
+ }
645
+ }
646
+
647
+ getVolume() {
648
+ return this.state.volume;
649
+ }
650
+
651
+ mute() {
652
+ if (this.renderer) {
653
+ this.renderer.setMuted(true);
654
+ }
655
+ this.state.muted = true;
656
+ this.emit('volumechange');
657
+ }
658
+
659
+ unmute() {
660
+ if (this.renderer) {
661
+ this.renderer.setMuted(false);
662
+ }
663
+ this.state.muted = false;
664
+ this.emit('volumechange');
665
+ }
666
+
667
+ toggleMute() {
668
+ if (this.state.muted) {
669
+ this.unmute();
670
+ } else {
671
+ this.mute();
672
+ }
673
+ }
674
+
675
+ // Playback speed
676
+ setPlaybackSpeed(speed) {
677
+ const newSpeed = Math.max(0.25, Math.min(2, speed));
678
+ if (this.renderer) {
679
+ this.renderer.setPlaybackSpeed(newSpeed);
680
+ }
681
+ this.state.playbackSpeed = newSpeed;
682
+ this.emit('playbackspeedchange', newSpeed);
683
+ }
684
+
685
+ getPlaybackSpeed() {
686
+ return this.state.playbackSpeed;
687
+ }
688
+
689
+ // Fullscreen
690
+ enterFullscreen() {
691
+ const elem = this.container;
692
+
693
+ if (elem.requestFullscreen) {
694
+ elem.requestFullscreen();
695
+ } else if (elem.webkitRequestFullscreen) {
696
+ elem.webkitRequestFullscreen();
697
+ } else if (elem.mozRequestFullScreen) {
698
+ elem.mozRequestFullScreen();
699
+ } else if (elem.msRequestFullscreen) {
700
+ elem.msRequestFullscreen();
701
+ }
702
+
703
+ this.state.fullscreen = true;
704
+ this.container.classList.add(`${this.options.classPrefix}-fullscreen`);
705
+ this.emit('fullscreenchange', true);
706
+ }
707
+
708
+ exitFullscreen() {
709
+ if (document.exitFullscreen) {
710
+ document.exitFullscreen();
711
+ } else if (document.webkitExitFullscreen) {
712
+ document.webkitExitFullscreen();
713
+ } else if (document.mozCancelFullScreen) {
714
+ document.mozCancelFullScreen();
715
+ } else if (document.msExitFullscreen) {
716
+ document.msExitFullscreen();
717
+ }
718
+
719
+ this.state.fullscreen = false;
720
+ this.container.classList.remove(`${this.options.classPrefix}-fullscreen`);
721
+ this.emit('fullscreenchange', false);
722
+ }
723
+
724
+ toggleFullscreen() {
725
+ if (this.state.fullscreen) {
726
+ this.exitFullscreen();
727
+ } else {
728
+ this.enterFullscreen();
729
+ }
730
+ }
731
+
732
+ // Picture-in-Picture
733
+ enterPiP() {
734
+ if (this.element.requestPictureInPicture) {
735
+ this.element.requestPictureInPicture();
736
+ this.state.pip = true;
737
+ this.emit('pipchange', true);
738
+ }
739
+ }
740
+
741
+ exitPiP() {
742
+ if (document.pictureInPictureElement) {
743
+ document.exitPictureInPicture();
744
+ this.state.pip = false;
745
+ this.emit('pipchange', false);
746
+ }
747
+ }
748
+
749
+ togglePiP() {
750
+ if (this.state.pip) {
751
+ this.exitPiP();
752
+ } else {
753
+ this.enterPiP();
754
+ }
755
+ }
756
+
757
+ // Captions
758
+ enableCaptions() {
759
+ if (this.captionManager) {
760
+ this.captionManager.enable();
761
+ this.state.captionsEnabled = true;
762
+ }
763
+ }
764
+
765
+ disableCaptions() {
766
+ if (this.captionManager) {
767
+ this.captionManager.disable();
768
+ this.state.captionsEnabled = false;
769
+ }
770
+ }
771
+
772
+ toggleCaptions() {
773
+ if (this.state.captionsEnabled) {
774
+ this.disableCaptions();
775
+ } else {
776
+ this.enableCaptions();
777
+ }
778
+ }
779
+
780
+ // Audio Description
781
+ async enableAudioDescription() {
782
+ if (!this.audioDescriptionSrc) {
783
+ console.warn('VidPly: No audio description source provided');
784
+ return;
785
+ }
786
+
787
+ // Store current playback state
788
+ const currentTime = this.state.currentTime;
789
+ const wasPlaying = this.state.playing;
790
+
791
+ // Switch to audio-described version
792
+ this.element.src = this.audioDescriptionSrc;
793
+
794
+ // Wait for new source to load
795
+ await new Promise((resolve) => {
796
+ const onLoadedMetadata = () => {
797
+ this.element.removeEventListener('loadedmetadata', onLoadedMetadata);
798
+ resolve();
799
+ };
800
+ this.element.addEventListener('loadedmetadata', onLoadedMetadata);
801
+ });
802
+
803
+ // Restore playback position
804
+ this.seek(currentTime);
805
+
806
+ if (wasPlaying) {
807
+ this.play();
808
+ }
809
+
810
+ this.state.audioDescriptionEnabled = true;
811
+ this.emit('audiodescriptionenabled');
812
+ }
813
+
814
+ async disableAudioDescription() {
815
+ if (!this.originalSrc) {
816
+ return;
817
+ }
818
+
819
+ // Store current playback state
820
+ const currentTime = this.state.currentTime;
821
+ const wasPlaying = this.state.playing;
822
+
823
+ // Switch back to original version
824
+ this.element.src = this.originalSrc;
825
+
826
+ // Wait for new source to load
827
+ await new Promise((resolve) => {
828
+ const onLoadedMetadata = () => {
829
+ this.element.removeEventListener('loadedmetadata', onLoadedMetadata);
830
+ resolve();
831
+ };
832
+ this.element.addEventListener('loadedmetadata', onLoadedMetadata);
833
+ });
834
+
835
+ // Restore playback position
836
+ this.seek(currentTime);
837
+
838
+ if (wasPlaying) {
839
+ this.play();
840
+ }
841
+
842
+ this.state.audioDescriptionEnabled = false;
843
+ this.emit('audiodescriptiondisabled');
844
+ }
845
+
846
+ async toggleAudioDescription() {
847
+ if (this.state.audioDescriptionEnabled) {
848
+ await this.disableAudioDescription();
849
+ } else {
850
+ await this.enableAudioDescription();
851
+ }
852
+ }
853
+
854
+ // Sign Language
855
+ enableSignLanguage() {
856
+ if (!this.signLanguageSrc) {
857
+ console.warn('No sign language video source provided');
858
+ return;
859
+ }
860
+
861
+ if (this.signLanguageVideo) {
862
+ // Already exists, just show it
863
+ this.signLanguageVideo.style.display = 'block';
864
+ this.state.signLanguageEnabled = true;
865
+ this.emit('signlanguageenabled');
866
+ return;
867
+ }
868
+
869
+ // Create sign language video element
870
+ this.signLanguageVideo = document.createElement('video');
871
+ this.signLanguageVideo.className = 'vidply-sign-language-video';
872
+ this.signLanguageVideo.src = this.signLanguageSrc;
873
+ this.signLanguageVideo.setAttribute('aria-label', i18n.t('player.signLanguage'));
874
+
875
+ // Set position based on options
876
+ const position = this.options.signLanguagePosition || 'bottom-right';
877
+ this.signLanguageVideo.classList.add(`vidply-sign-position-${position}`);
878
+
879
+ // Sync with main video
880
+ this.signLanguageVideo.muted = true; // Sign language video should be muted
881
+ this.signLanguageVideo.currentTime = this.state.currentTime;
882
+ if (!this.state.paused) {
883
+ this.signLanguageVideo.play();
884
+ }
885
+
886
+ // Add to video wrapper (so it overlays the video, not the entire container)
887
+ this.videoWrapper.appendChild(this.signLanguageVideo);
888
+
889
+ // Create bound handlers to store references for cleanup
890
+ this.signLanguageHandlers = {
891
+ play: () => {
892
+ if (this.signLanguageVideo) {
893
+ this.signLanguageVideo.play();
894
+ }
895
+ },
896
+ pause: () => {
897
+ if (this.signLanguageVideo) {
898
+ this.signLanguageVideo.pause();
899
+ }
900
+ },
901
+ timeupdate: () => {
902
+ if (this.signLanguageVideo && Math.abs(this.signLanguageVideo.currentTime - this.state.currentTime) > 0.5) {
903
+ this.signLanguageVideo.currentTime = this.state.currentTime;
904
+ }
905
+ },
906
+ ratechange: () => {
907
+ if (this.signLanguageVideo) {
908
+ this.signLanguageVideo.playbackRate = this.state.playbackSpeed;
909
+ }
910
+ }
911
+ };
912
+
913
+ // Sync playback
914
+ this.on('play', this.signLanguageHandlers.play);
915
+ this.on('pause', this.signLanguageHandlers.pause);
916
+ this.on('timeupdate', this.signLanguageHandlers.timeupdate);
917
+ this.on('ratechange', this.signLanguageHandlers.ratechange);
918
+
919
+ this.state.signLanguageEnabled = true;
920
+ this.emit('signlanguageenabled');
921
+ }
922
+
923
+ disableSignLanguage() {
924
+ if (this.signLanguageVideo) {
925
+ this.signLanguageVideo.style.display = 'none';
926
+ }
927
+ this.state.signLanguageEnabled = false;
928
+ this.emit('signlanguagedisabled');
929
+ }
930
+
931
+ toggleSignLanguage() {
932
+ if (this.state.signLanguageEnabled) {
933
+ this.disableSignLanguage();
934
+ } else {
935
+ this.enableSignLanguage();
936
+ }
937
+ }
938
+
939
+ cleanupSignLanguage() {
940
+ // Remove event listeners
941
+ if (this.signLanguageHandlers) {
942
+ this.off('play', this.signLanguageHandlers.play);
943
+ this.off('pause', this.signLanguageHandlers.pause);
944
+ this.off('timeupdate', this.signLanguageHandlers.timeupdate);
945
+ this.off('ratechange', this.signLanguageHandlers.ratechange);
946
+ this.signLanguageHandlers = null;
947
+ }
948
+
949
+ // Remove video element
950
+ if (this.signLanguageVideo && this.signLanguageVideo.parentNode) {
951
+ this.signLanguageVideo.pause();
952
+ this.signLanguageVideo.src = '';
953
+ this.signLanguageVideo.parentNode.removeChild(this.signLanguageVideo);
954
+ this.signLanguageVideo = null;
955
+ }
956
+ }
957
+
958
+ // Settings
959
+ // Settings dialog removed - using individual control buttons instead
960
+ showSettings() {
961
+ console.warn('[VidPly] Settings dialog has been removed. Use individual control buttons (speed, captions, etc.)');
962
+ }
963
+
964
+ hideSettings() {
965
+ // No-op - settings dialog removed
966
+ }
967
+
968
+ // Utility methods
969
+ getCurrentTime() {
970
+ return this.state.currentTime;
971
+ }
972
+
973
+ getDuration() {
974
+ return this.state.duration;
975
+ }
976
+
977
+ isPlaying() {
978
+ return this.state.playing;
979
+ }
980
+
981
+ isPaused() {
982
+ return this.state.paused;
983
+ }
984
+
985
+ isEnded() {
986
+ return this.state.ended;
987
+ }
988
+
989
+ isMuted() {
990
+ return this.state.muted;
991
+ }
992
+
993
+ isFullscreen() {
994
+ return this.state.fullscreen;
995
+ }
996
+
997
+ // Error handling
998
+ handleError(error) {
999
+ this.log('Error:', error, 'error');
1000
+ this.emit('error', error);
1001
+
1002
+ if (this.options.onError) {
1003
+ this.options.onError.call(this, error);
1004
+ }
1005
+ }
1006
+
1007
+ // Logging
1008
+ log(message, type = 'log') {
1009
+ if (this.options.debug) {
1010
+ console[type](`[VidPly]`, message);
1011
+ }
1012
+ }
1013
+
1014
+ // Setup responsive handlers
1015
+ setupResponsiveHandlers() {
1016
+ // Use ResizeObserver for efficient resize tracking
1017
+ if (typeof ResizeObserver !== 'undefined') {
1018
+ this.resizeObserver = new ResizeObserver((entries) => {
1019
+ for (const entry of entries) {
1020
+ const width = entry.contentRect.width;
1021
+
1022
+ // Update control bar for viewport
1023
+ if (this.controlBar && typeof this.controlBar.updateControlsForViewport === 'function') {
1024
+ this.controlBar.updateControlsForViewport(width);
1025
+ }
1026
+
1027
+ // Update transcript positioning
1028
+ if (this.transcriptManager && this.transcriptManager.isVisible) {
1029
+ this.transcriptManager.positionTranscript();
1030
+ }
1031
+ }
1032
+ });
1033
+
1034
+ this.resizeObserver.observe(this.container);
1035
+ } else {
1036
+ // Fallback to window resize event
1037
+ this.resizeHandler = () => {
1038
+ const width = this.container.clientWidth;
1039
+
1040
+ if (this.controlBar && typeof this.controlBar.updateControlsForViewport === 'function') {
1041
+ this.controlBar.updateControlsForViewport(width);
1042
+ }
1043
+
1044
+ if (this.transcriptManager && this.transcriptManager.isVisible) {
1045
+ this.transcriptManager.positionTranscript();
1046
+ }
1047
+ };
1048
+
1049
+ window.addEventListener('resize', this.resizeHandler);
1050
+ }
1051
+
1052
+ // Also listen for orientation changes on mobile
1053
+ if (window.matchMedia) {
1054
+ this.orientationHandler = (e) => {
1055
+ // Wait for layout to settle
1056
+ setTimeout(() => {
1057
+ if (this.transcriptManager && this.transcriptManager.isVisible) {
1058
+ this.transcriptManager.positionTranscript();
1059
+ }
1060
+ }, 100);
1061
+ };
1062
+
1063
+ const orientationQuery = window.matchMedia('(orientation: portrait)');
1064
+ if (orientationQuery.addEventListener) {
1065
+ orientationQuery.addEventListener('change', this.orientationHandler);
1066
+ } else if (orientationQuery.addListener) {
1067
+ // Fallback for older browsers
1068
+ orientationQuery.addListener(this.orientationHandler);
1069
+ }
1070
+
1071
+ this.orientationQuery = orientationQuery;
1072
+ }
1073
+
1074
+ // Listen for native fullscreen change events (e.g., when user presses ESC)
1075
+ this.fullscreenChangeHandler = () => {
1076
+ const isFullscreen = !!(
1077
+ document.fullscreenElement ||
1078
+ document.webkitFullscreenElement ||
1079
+ document.mozFullScreenElement ||
1080
+ document.msFullscreenElement
1081
+ );
1082
+
1083
+ // Only update if state has changed
1084
+ if (this.state.fullscreen !== isFullscreen) {
1085
+ this.state.fullscreen = isFullscreen;
1086
+
1087
+ if (isFullscreen) {
1088
+ this.container.classList.add(`${this.options.classPrefix}-fullscreen`);
1089
+ } else {
1090
+ this.container.classList.remove(`${this.options.classPrefix}-fullscreen`);
1091
+ }
1092
+
1093
+ this.emit('fullscreenchange', isFullscreen);
1094
+
1095
+ // Update fullscreen button icon
1096
+ if (this.controlBar) {
1097
+ this.controlBar.updateFullscreenButton();
1098
+ }
1099
+ }
1100
+ };
1101
+
1102
+ // Add listeners for all vendor-prefixed fullscreenchange events
1103
+ document.addEventListener('fullscreenchange', this.fullscreenChangeHandler);
1104
+ document.addEventListener('webkitfullscreenchange', this.fullscreenChangeHandler);
1105
+ document.addEventListener('mozfullscreenchange', this.fullscreenChangeHandler);
1106
+ document.addEventListener('MSFullscreenChange', this.fullscreenChangeHandler);
1107
+ }
1108
+
1109
+ // Cleanup
1110
+ destroy() {
1111
+ this.log('Destroying player');
1112
+
1113
+ if (this.renderer) {
1114
+ this.renderer.destroy();
1115
+ }
1116
+
1117
+ if (this.controlBar) {
1118
+ this.controlBar.destroy();
1119
+ }
1120
+
1121
+ if (this.captionManager) {
1122
+ this.captionManager.destroy();
1123
+ }
1124
+
1125
+ if (this.keyboardManager) {
1126
+ this.keyboardManager.destroy();
1127
+ }
1128
+
1129
+ if (this.transcriptManager) {
1130
+ this.transcriptManager.destroy();
1131
+ }
1132
+
1133
+ // Cleanup sign language video and listeners
1134
+ this.cleanupSignLanguage();
1135
+
1136
+ // Cleanup play overlay button
1137
+ if (this.playButtonOverlay && this.playButtonOverlay.parentNode) {
1138
+ this.playButtonOverlay.remove();
1139
+ this.playButtonOverlay = null;
1140
+ }
1141
+
1142
+ // Cleanup resize observer
1143
+ if (this.resizeObserver) {
1144
+ this.resizeObserver.disconnect();
1145
+ this.resizeObserver = null;
1146
+ }
1147
+
1148
+ // Cleanup window resize handler
1149
+ if (this.resizeHandler) {
1150
+ window.removeEventListener('resize', this.resizeHandler);
1151
+ this.resizeHandler = null;
1152
+ }
1153
+
1154
+ // Cleanup orientation change handler
1155
+ if (this.orientationQuery && this.orientationHandler) {
1156
+ if (this.orientationQuery.removeEventListener) {
1157
+ this.orientationQuery.removeEventListener('change', this.orientationHandler);
1158
+ } else if (this.orientationQuery.removeListener) {
1159
+ this.orientationQuery.removeListener(this.orientationHandler);
1160
+ }
1161
+ this.orientationQuery = null;
1162
+ this.orientationHandler = null;
1163
+ }
1164
+
1165
+ // Cleanup fullscreen change handler
1166
+ if (this.fullscreenChangeHandler) {
1167
+ document.removeEventListener('fullscreenchange', this.fullscreenChangeHandler);
1168
+ document.removeEventListener('webkitfullscreenchange', this.fullscreenChangeHandler);
1169
+ document.removeEventListener('mozfullscreenchange', this.fullscreenChangeHandler);
1170
+ document.removeEventListener('MSFullscreenChange', this.fullscreenChangeHandler);
1171
+ this.fullscreenChangeHandler = null;
1172
+ }
1173
+
1174
+ // Remove container
1175
+ if (this.container && this.container.parentNode) {
1176
+ this.container.parentNode.insertBefore(this.element, this.container);
1177
+ this.container.parentNode.removeChild(this.container);
1178
+ }
1179
+
1180
+ this.removeAllListeners();
1181
+ }
1182
+ }
1183
+
1184
+ // Static instances tracker for pause others functionality
1185
+ Player.instances = [];
1186
+