vidply 1.0.0

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