vidply 1.0.5 → 1.0.7

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,1616 @@
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
+ import {StorageManager} from '../utils/StorageManager.js';
19
+
20
+ export class Player extends EventEmitter {
21
+ constructor(element, options = {}) {
22
+ super();
23
+
24
+ this.element = typeof element === 'string' ? document.querySelector(element) : element;
25
+ if (!this.element) {
26
+ throw new Error('VidPly: Element not found');
27
+ }
28
+
29
+ // Auto-create media element if a non-media element is provided
30
+ if (this.element.tagName !== 'VIDEO' && this.element.tagName !== 'AUDIO') {
31
+ const mediaType = options.mediaType || 'video';
32
+ const mediaElement = document.createElement(mediaType);
33
+
34
+ // Copy attributes from the div to the media element
35
+ Array.from(this.element.attributes).forEach(attr => {
36
+ if (attr.name !== 'id' && attr.name !== 'class' && !attr.name.startsWith('data-')) {
37
+ mediaElement.setAttribute(attr.name, attr.value);
38
+ }
39
+ });
40
+
41
+ // Copy any track elements from the div
42
+ const tracks = this.element.querySelectorAll('track');
43
+ tracks.forEach(track => {
44
+ mediaElement.appendChild(track.cloneNode(true));
45
+ });
46
+
47
+ // Clear the div and insert the media element
48
+ this.element.innerHTML = '';
49
+ this.element.appendChild(mediaElement);
50
+
51
+ // Update element reference to the actual media element
52
+ this.element = mediaElement;
53
+ }
54
+
55
+ // Default options
56
+ this.options = {
57
+ // Display
58
+ width: null,
59
+ height: null,
60
+ poster: null,
61
+ responsive: true,
62
+ fillContainer: false,
63
+
64
+ // Playback
65
+ autoplay: false,
66
+ loop: false,
67
+ muted: false,
68
+ volume: 0.8,
69
+ playbackSpeed: 1.0,
70
+ preload: 'metadata',
71
+ startTime: 0,
72
+ playsInline: true, // Enable inline playback on iOS (prevents native fullscreen)
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: false,
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'],
128
+ 'seek-backward': ['ArrowLeft'],
129
+ 'mute': ['m'],
130
+ 'fullscreen': ['f'],
131
+ 'captions': ['c'],
132
+ 'caption-style-menu': ['a'],
133
+ 'speed-up': ['>'],
134
+ 'speed-down': ['<'],
135
+ 'speed-menu': ['s'],
136
+ 'quality-menu': ['q'],
137
+ 'chapters-menu': ['j'],
138
+ 'transcript-toggle': ['t']
139
+ },
140
+
141
+ // Accessibility
142
+ ariaLabels: {},
143
+ screenReaderAnnouncements: true,
144
+ highContrast: false,
145
+ focusHighlight: true,
146
+
147
+ // Languages
148
+ language: 'en',
149
+ languages: ['en'],
150
+
151
+ // Advanced
152
+ debug: false,
153
+ classPrefix: 'vidply',
154
+ iconType: 'svg',
155
+ pauseOthersOnPlay: true,
156
+
157
+ // Callbacks
158
+ onReady: null,
159
+ onPlay: null,
160
+ onPause: null,
161
+ onEnded: null,
162
+ onTimeUpdate: null,
163
+ onVolumeChange: null,
164
+ onError: null,
165
+
166
+ ...options
167
+ };
168
+
169
+ // Storage manager
170
+ this.storage = new StorageManager('vidply');
171
+
172
+ // Load saved player preferences
173
+ const savedPrefs = this.storage.getPlayerPreferences();
174
+ if (savedPrefs) {
175
+ if (savedPrefs.volume !== undefined) this.options.volume = savedPrefs.volume;
176
+ if (savedPrefs.playbackSpeed !== undefined) this.options.playbackSpeed = savedPrefs.playbackSpeed;
177
+ if (savedPrefs.muted !== undefined) this.options.muted = savedPrefs.muted;
178
+ }
179
+
180
+ // State
181
+ this.state = {
182
+ ready: false,
183
+ playing: false,
184
+ paused: true,
185
+ ended: false,
186
+ buffering: false,
187
+ seeking: false,
188
+ muted: this.options.muted,
189
+ volume: this.options.volume,
190
+ currentTime: 0,
191
+ duration: 0,
192
+ playbackSpeed: this.options.playbackSpeed,
193
+ fullscreen: false,
194
+ pip: false,
195
+ captionsEnabled: this.options.captionsDefault,
196
+ currentCaption: null,
197
+ controlsVisible: true,
198
+ audioDescriptionEnabled: false,
199
+ signLanguageEnabled: false
200
+ };
201
+
202
+ // Store original source for toggling
203
+ this.originalSrc = null;
204
+ this.audioDescriptionSrc = this.options.audioDescriptionSrc;
205
+ this.signLanguageSrc = this.options.signLanguageSrc;
206
+ this.signLanguageVideo = null;
207
+
208
+ // Components
209
+ this.container = null;
210
+ this.renderer = null;
211
+ this.controlBar = null;
212
+ this.captionManager = null;
213
+ this.keyboardManager = null;
214
+ this.settingsDialog = null;
215
+
216
+ // Initialize
217
+ this.init();
218
+ }
219
+
220
+ async init() {
221
+ try {
222
+ this.log('Initializing VidPly player');
223
+
224
+ // Auto-detect language from HTML lang attribute if not explicitly set
225
+ if (!this.options.language || this.options.language === 'en') {
226
+ const htmlLang = this.detectHtmlLanguage();
227
+ if (htmlLang) {
228
+ this.options.language = htmlLang;
229
+ this.log(`Auto-detected language from HTML: ${htmlLang}`);
230
+ }
231
+ }
232
+
233
+ // Set language
234
+ i18n.setLanguage(this.options.language);
235
+
236
+ // Create container
237
+ this.createContainer();
238
+
239
+ // Detect and initialize renderer (only if source exists)
240
+ const src = this.element.src || this.element.querySelector('source')?.src;
241
+ if (src) {
242
+ await this.initializeRenderer();
243
+ } else {
244
+ this.log('No initial source - waiting for playlist or manual load');
245
+ }
246
+
247
+ // Create controls
248
+ if (this.options.controls) {
249
+ this.controlBar = new ControlBar(this);
250
+ this.videoWrapper.appendChild(this.controlBar.element);
251
+ }
252
+
253
+ // Initialize captions
254
+ if (this.options.captions) {
255
+ this.captionManager = new CaptionManager(this);
256
+ }
257
+
258
+ // Initialize transcript
259
+ if (this.options.transcript || this.options.transcriptButton) {
260
+ this.transcriptManager = new TranscriptManager(this);
261
+ }
262
+
263
+ // Initialize keyboard controls
264
+ if (this.options.keyboard) {
265
+ this.keyboardManager = new KeyboardManager(this);
266
+ }
267
+
268
+ // Setup responsive handlers
269
+ this.setupResponsiveHandlers();
270
+
271
+ // Set initial state
272
+ if (this.options.startTime > 0) {
273
+ this.seek(this.options.startTime);
274
+ }
275
+
276
+ if (this.options.muted) {
277
+ this.mute();
278
+ }
279
+
280
+ if (this.options.volume !== 0.8) {
281
+ this.setVolume(this.options.volume);
282
+ }
283
+
284
+ // Mark as ready
285
+ this.state.ready = true;
286
+ this.emit('ready');
287
+
288
+ if (this.options.onReady) {
289
+ this.options.onReady.call(this);
290
+ }
291
+
292
+ // Autoplay if enabled
293
+ if (this.options.autoplay) {
294
+ this.play();
295
+ }
296
+
297
+ this.log('Player initialized successfully');
298
+ } catch (error) {
299
+ this.handleError(error);
300
+ }
301
+ }
302
+
303
+ /**
304
+ * Detect language from HTML lang attribute
305
+ * @returns {string|null} Language code if available in translations, null otherwise
306
+ */
307
+ detectHtmlLanguage() {
308
+ // Try to get lang from html element
309
+ const htmlLang = document.documentElement.lang || document.documentElement.getAttribute('lang');
310
+
311
+ if (!htmlLang) {
312
+ return null;
313
+ }
314
+
315
+ // Normalize the language code (e.g., "en-US" -> "en", "de-DE" -> "de")
316
+ const normalizedLang = htmlLang.toLowerCase().split('-')[0];
317
+
318
+ // Check if this language is available in our translations
319
+ const availableLanguages = ['en', 'de', 'es', 'fr', 'ja'];
320
+
321
+ if (availableLanguages.includes(normalizedLang)) {
322
+ return normalizedLang;
323
+ }
324
+
325
+ // Language not available, will fallback to English
326
+ this.log(`Language "${htmlLang}" not available, using English as fallback`);
327
+ return 'en';
328
+ }
329
+
330
+ createContainer() {
331
+ // Create main container
332
+ this.container = DOMUtils.createElement('div', {
333
+ className: `${this.options.classPrefix}-player`,
334
+ attributes: {
335
+ 'role': 'region',
336
+ 'aria-label': i18n.t('player.label'),
337
+ 'tabindex': '0'
338
+ }
339
+ });
340
+
341
+ // Add media type class
342
+ const mediaType = this.element.tagName.toLowerCase();
343
+ this.container.classList.add(`${this.options.classPrefix}-${mediaType}`);
344
+
345
+ // Add responsive class
346
+ if (this.options.responsive) {
347
+ this.container.classList.add(`${this.options.classPrefix}-responsive`);
348
+ }
349
+
350
+ // Create video wrapper (for proper positioning of controls)
351
+ this.videoWrapper = DOMUtils.createElement('div', {
352
+ className: `${this.options.classPrefix}-video-wrapper`
353
+ });
354
+
355
+ // Wrap original element
356
+ this.element.parentNode.insertBefore(this.container, this.element);
357
+ this.container.appendChild(this.videoWrapper);
358
+ this.videoWrapper.appendChild(this.element);
359
+
360
+ // Hide native controls and set dimensions
361
+ this.element.controls = false;
362
+ this.element.removeAttribute('controls');
363
+ this.element.setAttribute('tabindex', '-1'); // Remove from tab order
364
+ this.element.style.width = '100%';
365
+ this.element.style.height = '100%';
366
+
367
+ // Enable inline playback on iOS (prevents native fullscreen)
368
+ // This allows custom controls to work on iOS devices
369
+ if (this.element.tagName === 'VIDEO' && this.options.playsInline) {
370
+ this.element.setAttribute('playsinline', '');
371
+ this.element.setAttribute('webkit-playsinline', ''); // For older iOS versions
372
+ this.element.playsInline = true; // Property version
373
+ }
374
+
375
+ // Set dimensions
376
+ if (this.options.width) {
377
+ this.container.style.width = typeof this.options.width === 'number'
378
+ ? `${this.options.width}px`
379
+ : this.options.width;
380
+ }
381
+
382
+ if (this.options.height) {
383
+ this.container.style.height = typeof this.options.height === 'number'
384
+ ? `${this.options.height}px`
385
+ : this.options.height;
386
+ }
387
+
388
+ // Set poster
389
+ if (this.options.poster && this.element.tagName === 'VIDEO') {
390
+ this.element.poster = this.options.poster;
391
+ }
392
+
393
+ // Create centered play button overlay (only for video)
394
+ if (this.element.tagName === 'VIDEO') {
395
+ this.createPlayButtonOverlay();
396
+ }
397
+
398
+ // Make video/audio element clickable to toggle play/pause
399
+ this.element.style.cursor = 'pointer';
400
+ this.element.addEventListener('click', (e) => {
401
+ // Prevent if clicking on native controls (shouldn't happen but just in case)
402
+ if (e.target === this.element) {
403
+ this.toggle();
404
+ }
405
+ });
406
+ }
407
+
408
+ createPlayButtonOverlay() {
409
+ // Create complete SVG play button from Icons.js
410
+ this.playButtonOverlay = createPlayOverlay();
411
+
412
+ // Add click handler
413
+ this.playButtonOverlay.addEventListener('click', () => {
414
+ this.toggle();
415
+ });
416
+
417
+ // Add to video wrapper
418
+ this.videoWrapper.appendChild(this.playButtonOverlay);
419
+
420
+ // Show/hide based on play state
421
+ this.on('play', () => {
422
+ this.playButtonOverlay.style.opacity = '0';
423
+ this.playButtonOverlay.style.pointerEvents = 'none';
424
+ });
425
+
426
+ this.on('pause', () => {
427
+ this.playButtonOverlay.style.opacity = '1';
428
+ this.playButtonOverlay.style.pointerEvents = 'auto';
429
+ });
430
+
431
+ this.on('ended', () => {
432
+ this.playButtonOverlay.style.opacity = '1';
433
+ this.playButtonOverlay.style.pointerEvents = 'auto';
434
+ });
435
+ }
436
+
437
+ async initializeRenderer() {
438
+ const src = this.element.src || this.element.querySelector('source')?.src;
439
+
440
+ if (!src) {
441
+ throw new Error('No media source found');
442
+ }
443
+
444
+ // Store original source for audio description toggling
445
+ if (!this.originalSrc) {
446
+ this.originalSrc = src;
447
+ }
448
+
449
+ // Detect media type
450
+ let renderer;
451
+
452
+ if (src.includes('youtube.com') || src.includes('youtu.be')) {
453
+ renderer = YouTubeRenderer;
454
+ } else if (src.includes('vimeo.com')) {
455
+ renderer = VimeoRenderer;
456
+ } else if (src.includes('.m3u8')) {
457
+ renderer = HLSRenderer;
458
+ } else {
459
+ renderer = HTML5Renderer;
460
+ }
461
+
462
+ this.log(`Using ${renderer.name} renderer`);
463
+ this.renderer = new renderer(this);
464
+ await this.renderer.init();
465
+ }
466
+
467
+ /**
468
+ * Load new media source (for playlists)
469
+ * @param {Object} config - Media configuration
470
+ * @param {string} config.src - Media source URL
471
+ * @param {string} config.type - Media MIME type
472
+ * @param {string} [config.poster] - Poster image URL
473
+ * @param {Array} [config.tracks] - Text tracks (captions, chapters, etc.)
474
+ */
475
+ async load(config) {
476
+ try {
477
+ this.log('Loading new media:', config.src);
478
+
479
+ // Pause current playback
480
+ if (this.renderer) {
481
+ this.pause();
482
+ }
483
+
484
+ // Clear existing text tracks
485
+ const existingTracks = this.element.querySelectorAll('track');
486
+ existingTracks.forEach(track => track.remove());
487
+
488
+ // Update media element
489
+ this.element.src = config.src;
490
+
491
+ if (config.type) {
492
+ this.element.type = config.type;
493
+ }
494
+
495
+ if (config.poster && this.element.tagName === 'VIDEO') {
496
+ this.element.poster = config.poster;
497
+ }
498
+
499
+ // Add new text tracks
500
+ if (config.tracks && config.tracks.length > 0) {
501
+ config.tracks.forEach(trackConfig => {
502
+ const track = document.createElement('track');
503
+ track.src = trackConfig.src;
504
+ track.kind = trackConfig.kind || 'captions';
505
+ track.srclang = trackConfig.srclang || 'en';
506
+ track.label = trackConfig.label || trackConfig.srclang;
507
+
508
+ if (trackConfig.default) {
509
+ track.default = true;
510
+ }
511
+
512
+ this.element.appendChild(track);
513
+ });
514
+ }
515
+
516
+ // Check if we need to change renderer type
517
+ const shouldChangeRenderer = this.shouldChangeRenderer(config.src);
518
+
519
+ // Destroy old renderer if changing types
520
+ if (shouldChangeRenderer && this.renderer) {
521
+ this.renderer.destroy();
522
+ this.renderer = null;
523
+ }
524
+
525
+ // Initialize or reinitialize renderer
526
+ if (!this.renderer || shouldChangeRenderer) {
527
+ await this.initializeRenderer();
528
+ } else {
529
+ // Just reload the current renderer with the updated element
530
+ this.renderer.media = this.element; // Update media reference
531
+ this.element.load();
532
+ }
533
+
534
+ // Reinitialize caption manager to pick up new tracks
535
+ if (this.captionManager) {
536
+ this.captionManager.destroy();
537
+ this.captionManager = new CaptionManager(this);
538
+ }
539
+
540
+ // Reinitialize transcript manager to pick up new tracks
541
+ if (this.transcriptManager) {
542
+ const wasVisible = this.transcriptManager.isVisible;
543
+ this.transcriptManager.destroy();
544
+ this.transcriptManager = new TranscriptManager(this);
545
+
546
+ // Restore visibility state if transcript was open
547
+ if (wasVisible) {
548
+ this.transcriptManager.showTranscript();
549
+ }
550
+ }
551
+
552
+ // Update control bar to show/hide feature buttons based on new tracks
553
+ if (this.controlBar) {
554
+ this.updateControlBar();
555
+ }
556
+
557
+ this.emit('sourcechange', config);
558
+ this.log('Media loaded successfully');
559
+
560
+ } catch (error) {
561
+ this.handleError(error);
562
+ }
563
+ }
564
+
565
+ /**
566
+ * Check if we need to change renderer type
567
+ * @param {string} src - New source URL
568
+ * @returns {boolean}
569
+ */
570
+ /**
571
+ * Update control bar to refresh button visibility based on available features
572
+ */
573
+ updateControlBar() {
574
+ if (!this.controlBar) return;
575
+
576
+ const controlBar = this.controlBar;
577
+
578
+ // Clear existing controls content
579
+ controlBar.element.innerHTML = '';
580
+
581
+ // Recreate controls with updated feature detection
582
+ controlBar.createControls();
583
+
584
+ // Reattach events for the new controls
585
+ controlBar.attachEvents();
586
+ controlBar.setupAutoHide();
587
+ }
588
+
589
+ shouldChangeRenderer(src) {
590
+ if (!this.renderer) return true;
591
+
592
+ const isYouTube = src.includes('youtube.com') || src.includes('youtu.be');
593
+ const isVimeo = src.includes('vimeo.com');
594
+ const isHLS = src.includes('.m3u8');
595
+
596
+ const currentRendererName = this.renderer.constructor.name;
597
+
598
+ if (isYouTube && currentRendererName !== 'YouTubeRenderer') return true;
599
+ if (isVimeo && currentRendererName !== 'VimeoRenderer') return true;
600
+ if (isHLS && currentRendererName !== 'HLSRenderer') return true;
601
+ if (!isYouTube && !isVimeo && !isHLS && currentRendererName !== 'HTML5Renderer') return true;
602
+
603
+ return false;
604
+ }
605
+
606
+ // Playback controls
607
+ play() {
608
+ if (this.renderer) {
609
+ this.renderer.play();
610
+ }
611
+ }
612
+
613
+ pause() {
614
+ if (this.renderer) {
615
+ this.renderer.pause();
616
+ }
617
+ }
618
+
619
+ stop() {
620
+ this.pause();
621
+ this.seek(0);
622
+ }
623
+
624
+ toggle() {
625
+ if (this.state.playing) {
626
+ this.pause();
627
+ } else {
628
+ this.play();
629
+ }
630
+ }
631
+
632
+ seek(time) {
633
+ if (this.renderer) {
634
+ this.renderer.seek(time);
635
+ }
636
+ }
637
+
638
+ seekForward(interval = this.options.seekInterval) {
639
+ this.seek(Math.min(this.state.currentTime + interval, this.state.duration));
640
+ }
641
+
642
+ seekBackward(interval = this.options.seekInterval) {
643
+ this.seek(Math.max(this.state.currentTime - interval, 0));
644
+ }
645
+
646
+ // Volume controls
647
+ setVolume(volume) {
648
+ const newVolume = Math.max(0, Math.min(1, volume));
649
+ if (this.renderer) {
650
+ this.renderer.setVolume(newVolume);
651
+ }
652
+ this.state.volume = newVolume;
653
+
654
+ if (newVolume > 0 && this.state.muted) {
655
+ this.state.muted = false;
656
+ }
657
+
658
+ this.savePlayerPreferences();
659
+ }
660
+
661
+ getVolume() {
662
+ return this.state.volume;
663
+ }
664
+
665
+ mute() {
666
+ if (this.renderer) {
667
+ this.renderer.setMuted(true);
668
+ }
669
+ this.state.muted = true;
670
+ this.savePlayerPreferences();
671
+ this.emit('volumechange');
672
+ }
673
+
674
+ unmute() {
675
+ if (this.renderer) {
676
+ this.renderer.setMuted(false);
677
+ }
678
+ this.state.muted = false;
679
+ this.savePlayerPreferences();
680
+ this.emit('volumechange');
681
+ }
682
+
683
+ toggleMute() {
684
+ if (this.state.muted) {
685
+ this.unmute();
686
+ } else {
687
+ this.mute();
688
+ }
689
+ }
690
+
691
+ // Playback speed
692
+ setPlaybackSpeed(speed) {
693
+ const newSpeed = Math.max(0.25, Math.min(2, speed));
694
+ if (this.renderer) {
695
+ this.renderer.setPlaybackSpeed(newSpeed);
696
+ }
697
+ this.state.playbackSpeed = newSpeed;
698
+ this.savePlayerPreferences();
699
+ this.emit('playbackspeedchange', newSpeed);
700
+ }
701
+
702
+ getPlaybackSpeed() {
703
+ return this.state.playbackSpeed;
704
+ }
705
+
706
+ // Save player preferences to localStorage
707
+ savePlayerPreferences() {
708
+ this.storage.savePlayerPreferences({
709
+ volume: this.state.volume,
710
+ muted: this.state.muted,
711
+ playbackSpeed: this.state.playbackSpeed
712
+ });
713
+ }
714
+
715
+ // Fullscreen
716
+ enterFullscreen() {
717
+ const elem = this.container;
718
+
719
+ if (elem.requestFullscreen) {
720
+ elem.requestFullscreen();
721
+ } else if (elem.webkitRequestFullscreen) {
722
+ elem.webkitRequestFullscreen();
723
+ } else if (elem.mozRequestFullScreen) {
724
+ elem.mozRequestFullScreen();
725
+ } else if (elem.msRequestFullscreen) {
726
+ elem.msRequestFullscreen();
727
+ }
728
+
729
+ this.state.fullscreen = true;
730
+ this.container.classList.add(`${this.options.classPrefix}-fullscreen`);
731
+ this.emit('fullscreenchange', true);
732
+ }
733
+
734
+ exitFullscreen() {
735
+ if (document.exitFullscreen) {
736
+ document.exitFullscreen();
737
+ } else if (document.webkitExitFullscreen) {
738
+ document.webkitExitFullscreen();
739
+ } else if (document.mozCancelFullScreen) {
740
+ document.mozCancelFullScreen();
741
+ } else if (document.msExitFullscreen) {
742
+ document.msExitFullscreen();
743
+ }
744
+
745
+ this.state.fullscreen = false;
746
+ this.container.classList.remove(`${this.options.classPrefix}-fullscreen`);
747
+ this.emit('fullscreenchange', false);
748
+ }
749
+
750
+ toggleFullscreen() {
751
+ if (this.state.fullscreen) {
752
+ this.exitFullscreen();
753
+ } else {
754
+ this.enterFullscreen();
755
+ }
756
+ }
757
+
758
+ // Picture-in-Picture
759
+ enterPiP() {
760
+ if (this.element.requestPictureInPicture) {
761
+ this.element.requestPictureInPicture();
762
+ this.state.pip = true;
763
+ this.emit('pipchange', true);
764
+ }
765
+ }
766
+
767
+ exitPiP() {
768
+ if (document.pictureInPictureElement) {
769
+ document.exitPictureInPicture();
770
+ this.state.pip = false;
771
+ this.emit('pipchange', false);
772
+ }
773
+ }
774
+
775
+ togglePiP() {
776
+ if (this.state.pip) {
777
+ this.exitPiP();
778
+ } else {
779
+ this.enterPiP();
780
+ }
781
+ }
782
+
783
+ // Captions
784
+ enableCaptions() {
785
+ if (this.captionManager) {
786
+ this.captionManager.enable();
787
+ this.state.captionsEnabled = true;
788
+ }
789
+ }
790
+
791
+ disableCaptions() {
792
+ if (this.captionManager) {
793
+ this.captionManager.disable();
794
+ this.state.captionsEnabled = false;
795
+ }
796
+ }
797
+
798
+ toggleCaptions() {
799
+ if (this.state.captionsEnabled) {
800
+ this.disableCaptions();
801
+ } else {
802
+ this.enableCaptions();
803
+ }
804
+ }
805
+
806
+ // Audio Description
807
+ async enableAudioDescription() {
808
+ if (!this.audioDescriptionSrc) {
809
+ console.warn('VidPly: No audio description source provided');
810
+ return;
811
+ }
812
+
813
+ // Store current playback state
814
+ const currentTime = this.state.currentTime;
815
+ const wasPlaying = this.state.playing;
816
+
817
+ // Switch to audio-described version
818
+ this.element.src = this.audioDescriptionSrc;
819
+
820
+ // Wait for new source to load
821
+ await new Promise((resolve) => {
822
+ const onLoadedMetadata = () => {
823
+ this.element.removeEventListener('loadedmetadata', onLoadedMetadata);
824
+ resolve();
825
+ };
826
+ this.element.addEventListener('loadedmetadata', onLoadedMetadata);
827
+ });
828
+
829
+ // Restore playback position
830
+ this.seek(currentTime);
831
+
832
+ if (wasPlaying) {
833
+ this.play();
834
+ }
835
+
836
+ this.state.audioDescriptionEnabled = true;
837
+ this.emit('audiodescriptionenabled');
838
+ }
839
+
840
+ async disableAudioDescription() {
841
+ if (!this.originalSrc) {
842
+ return;
843
+ }
844
+
845
+ // Store current playback state
846
+ const currentTime = this.state.currentTime;
847
+ const wasPlaying = this.state.playing;
848
+
849
+ // Switch back to original version
850
+ this.element.src = this.originalSrc;
851
+
852
+ // Wait for new source to load
853
+ await new Promise((resolve) => {
854
+ const onLoadedMetadata = () => {
855
+ this.element.removeEventListener('loadedmetadata', onLoadedMetadata);
856
+ resolve();
857
+ };
858
+ this.element.addEventListener('loadedmetadata', onLoadedMetadata);
859
+ });
860
+
861
+ // Restore playback position
862
+ this.seek(currentTime);
863
+
864
+ if (wasPlaying) {
865
+ this.play();
866
+ }
867
+
868
+ this.state.audioDescriptionEnabled = false;
869
+ this.emit('audiodescriptiondisabled');
870
+ }
871
+
872
+ async toggleAudioDescription() {
873
+ // Check if we have description tracks or audio-described video
874
+ const textTracks = Array.from(this.element.textTracks || []);
875
+ const descriptionTrack = textTracks.find(track => track.kind === 'descriptions');
876
+
877
+ if (descriptionTrack) {
878
+ // Toggle description track
879
+ if (descriptionTrack.mode === 'showing') {
880
+ descriptionTrack.mode = 'hidden';
881
+ this.state.audioDescriptionEnabled = false;
882
+ this.emit('audiodescriptiondisabled');
883
+ } else {
884
+ descriptionTrack.mode = 'showing';
885
+ this.state.audioDescriptionEnabled = true;
886
+ this.emit('audiodescriptionenabled');
887
+ }
888
+ } else if (this.audioDescriptionSrc) {
889
+ // Use audio-described video source
890
+ if (this.state.audioDescriptionEnabled) {
891
+ await this.disableAudioDescription();
892
+ } else {
893
+ await this.enableAudioDescription();
894
+ }
895
+ }
896
+ }
897
+
898
+ // Sign Language
899
+ enableSignLanguage() {
900
+ if (!this.signLanguageSrc) {
901
+ console.warn('No sign language video source provided');
902
+ return;
903
+ }
904
+
905
+ if (this.signLanguageWrapper) {
906
+ // Already exists, just show it
907
+ this.signLanguageWrapper.style.display = 'block';
908
+ this.state.signLanguageEnabled = true;
909
+ this.emit('signlanguageenabled');
910
+ return;
911
+ }
912
+
913
+ // Create wrapper container
914
+ this.signLanguageWrapper = document.createElement('div');
915
+ this.signLanguageWrapper.className = 'vidply-sign-language-wrapper';
916
+ this.signLanguageWrapper.setAttribute('tabindex', '0');
917
+ this.signLanguageWrapper.setAttribute('aria-label', 'Sign Language Video - Press D to drag with keyboard, R to resize');
918
+
919
+ // Create sign language video element
920
+ this.signLanguageVideo = document.createElement('video');
921
+ this.signLanguageVideo.className = 'vidply-sign-language-video';
922
+ this.signLanguageVideo.src = this.signLanguageSrc;
923
+ this.signLanguageVideo.setAttribute('aria-label', i18n.t('player.signLanguage'));
924
+ this.signLanguageVideo.muted = true; // Sign language video should be muted
925
+
926
+ // Create resize handles
927
+ const resizeHandles = ['nw', 'ne', 'sw', 'se'].map(dir => {
928
+ const handle = document.createElement('div');
929
+ handle.className = `vidply-sign-resize-handle vidply-sign-resize-${dir}`;
930
+ handle.setAttribute('data-direction', dir);
931
+ handle.setAttribute('aria-label', `Resize ${dir.toUpperCase()}`);
932
+ return handle;
933
+ });
934
+
935
+ // Append video and handles to wrapper
936
+ this.signLanguageWrapper.appendChild(this.signLanguageVideo);
937
+ resizeHandles.forEach(handle => this.signLanguageWrapper.appendChild(handle));
938
+
939
+ // Set width FIRST to ensure proper dimensions
940
+ const saved = this.storage.getSignLanguagePreferences();
941
+ if (saved && saved.size && saved.size.width) {
942
+ this.signLanguageWrapper.style.width = saved.size.width;
943
+ } else {
944
+ this.signLanguageWrapper.style.width = '280px'; // Default width
945
+ }
946
+ // Height is always auto to maintain aspect ratio
947
+ this.signLanguageWrapper.style.height = 'auto';
948
+
949
+ // Position is always calculated fresh - use option or default to bottom-right
950
+ this.signLanguageDesiredPosition = this.options.signLanguagePosition || 'bottom-right';
951
+
952
+ // Add to main player container (NOT videoWrapper) to avoid overflow:hidden clipping
953
+ this.container.appendChild(this.signLanguageWrapper);
954
+
955
+ // Set position immediately after appending
956
+ requestAnimationFrame(() => {
957
+ this.constrainSignLanguagePosition();
958
+ });
959
+
960
+ // Sync with main video
961
+ this.signLanguageVideo.currentTime = this.state.currentTime;
962
+ if (!this.state.paused) {
963
+ this.signLanguageVideo.play();
964
+ }
965
+
966
+ // Setup drag and resize
967
+ this.setupSignLanguageInteraction();
968
+
969
+ // Create bound handlers to store references for cleanup
970
+ this.signLanguageHandlers = {
971
+ play: () => {
972
+ if (this.signLanguageVideo) {
973
+ this.signLanguageVideo.play();
974
+ }
975
+ },
976
+ pause: () => {
977
+ if (this.signLanguageVideo) {
978
+ this.signLanguageVideo.pause();
979
+ }
980
+ },
981
+ timeupdate: () => {
982
+ if (this.signLanguageVideo && Math.abs(this.signLanguageVideo.currentTime - this.state.currentTime) > 0.5) {
983
+ this.signLanguageVideo.currentTime = this.state.currentTime;
984
+ }
985
+ },
986
+ ratechange: () => {
987
+ if (this.signLanguageVideo) {
988
+ this.signLanguageVideo.playbackRate = this.state.playbackSpeed;
989
+ }
990
+ }
991
+ };
992
+
993
+ // Sync playback
994
+ this.on('play', this.signLanguageHandlers.play);
995
+ this.on('pause', this.signLanguageHandlers.pause);
996
+ this.on('timeupdate', this.signLanguageHandlers.timeupdate);
997
+ this.on('ratechange', this.signLanguageHandlers.ratechange);
998
+
999
+ this.state.signLanguageEnabled = true;
1000
+ this.emit('signlanguageenabled');
1001
+ }
1002
+
1003
+ disableSignLanguage() {
1004
+ if (this.signLanguageWrapper) {
1005
+ this.signLanguageWrapper.style.display = 'none';
1006
+ }
1007
+ this.state.signLanguageEnabled = false;
1008
+ this.emit('signlanguagedisabled');
1009
+ }
1010
+
1011
+ toggleSignLanguage() {
1012
+ if (this.state.signLanguageEnabled) {
1013
+ this.disableSignLanguage();
1014
+ } else {
1015
+ this.enableSignLanguage();
1016
+ }
1017
+ }
1018
+
1019
+ setupSignLanguageInteraction() {
1020
+ if (!this.signLanguageWrapper) return;
1021
+
1022
+ let isDragging = false;
1023
+ let isResizing = false;
1024
+ let resizeDirection = null;
1025
+ let startX = 0;
1026
+ let startY = 0;
1027
+ let startLeft = 0;
1028
+ let startTop = 0;
1029
+ let startWidth = 0;
1030
+ let startHeight = 0;
1031
+ let dragMode = false;
1032
+ let resizeMode = false;
1033
+
1034
+ // Mouse drag on video element
1035
+ const onMouseDownVideo = (e) => {
1036
+ if (e.target !== this.signLanguageVideo) return;
1037
+ e.preventDefault();
1038
+ isDragging = true;
1039
+ startX = e.clientX;
1040
+ startY = e.clientY;
1041
+ const rect = this.signLanguageWrapper.getBoundingClientRect();
1042
+ startLeft = rect.left;
1043
+ startTop = rect.top;
1044
+ this.signLanguageWrapper.classList.add('vidply-sign-dragging');
1045
+ };
1046
+
1047
+ // Mouse resize on handles
1048
+ const onMouseDownHandle = (e) => {
1049
+ if (!e.target.classList.contains('vidply-sign-resize-handle')) return;
1050
+ e.preventDefault();
1051
+ e.stopPropagation();
1052
+ isResizing = true;
1053
+ resizeDirection = e.target.getAttribute('data-direction');
1054
+ startX = e.clientX;
1055
+ startY = e.clientY;
1056
+ const rect = this.signLanguageWrapper.getBoundingClientRect();
1057
+ startLeft = rect.left;
1058
+ startTop = rect.top;
1059
+ startWidth = rect.width;
1060
+ startHeight = rect.height;
1061
+ this.signLanguageWrapper.classList.add('vidply-sign-resizing');
1062
+ };
1063
+
1064
+ const onMouseMove = (e) => {
1065
+ if (isDragging) {
1066
+ const deltaX = e.clientX - startX;
1067
+ const deltaY = e.clientY - startY;
1068
+
1069
+ // Get videoWrapper and container dimensions
1070
+ const videoWrapperRect = this.videoWrapper.getBoundingClientRect();
1071
+ const containerRect = this.container.getBoundingClientRect();
1072
+ const wrapperRect = this.signLanguageWrapper.getBoundingClientRect();
1073
+
1074
+ // Calculate videoWrapper position relative to container
1075
+ const videoWrapperLeft = videoWrapperRect.left - containerRect.left;
1076
+ const videoWrapperTop = videoWrapperRect.top - containerRect.top;
1077
+
1078
+ // Calculate new position (in client coordinates)
1079
+ let newLeft = startLeft + deltaX - containerRect.left;
1080
+ let newTop = startTop + deltaY - containerRect.top;
1081
+
1082
+ const controlsHeight = 95; // Height of controls when visible
1083
+
1084
+ // Constrain to videoWrapper bounds (ensuring it stays above controls)
1085
+ newLeft = Math.max(videoWrapperLeft, Math.min(newLeft, videoWrapperLeft + videoWrapperRect.width - wrapperRect.width));
1086
+ newTop = Math.max(videoWrapperTop, Math.min(newTop, videoWrapperTop + videoWrapperRect.height - wrapperRect.height - controlsHeight));
1087
+
1088
+ this.signLanguageWrapper.style.left = `${newLeft}px`;
1089
+ this.signLanguageWrapper.style.top = `${newTop}px`;
1090
+ this.signLanguageWrapper.style.right = 'auto';
1091
+ this.signLanguageWrapper.style.bottom = 'auto';
1092
+ // Remove position classes
1093
+ this.signLanguageWrapper.classList.remove(...Array.from(this.signLanguageWrapper.classList).filter(c => c.startsWith('vidply-sign-position-')));
1094
+ } else if (isResizing) {
1095
+ const deltaX = e.clientX - startX;
1096
+
1097
+ // Get videoWrapper and container dimensions
1098
+ const videoWrapperRect = this.videoWrapper.getBoundingClientRect();
1099
+ const containerRect = this.container.getBoundingClientRect();
1100
+
1101
+ let newWidth = startWidth;
1102
+ let newLeft = startLeft - containerRect.left;
1103
+
1104
+ // Only resize width, let height auto-adjust to maintain aspect ratio
1105
+ if (resizeDirection.includes('e')) {
1106
+ newWidth = Math.max(150, startWidth + deltaX);
1107
+ // Constrain width to not exceed videoWrapper right edge
1108
+ const maxWidth = (videoWrapperRect.right - startLeft);
1109
+ newWidth = Math.min(newWidth, maxWidth);
1110
+ }
1111
+ if (resizeDirection.includes('w')) {
1112
+ const proposedWidth = Math.max(150, startWidth - deltaX);
1113
+ const proposedLeft = startLeft + (startWidth - proposedWidth) - containerRect.left;
1114
+ // Constrain to not go beyond videoWrapper left edge
1115
+ const videoWrapperLeft = videoWrapperRect.left - containerRect.left;
1116
+ if (proposedLeft >= videoWrapperLeft) {
1117
+ newWidth = proposedWidth;
1118
+ newLeft = proposedLeft;
1119
+ }
1120
+ }
1121
+
1122
+ this.signLanguageWrapper.style.width = `${newWidth}px`;
1123
+ this.signLanguageWrapper.style.height = 'auto'; // Let video maintain aspect ratio
1124
+ if (resizeDirection.includes('w')) {
1125
+ this.signLanguageWrapper.style.left = `${newLeft}px`;
1126
+ }
1127
+ this.signLanguageWrapper.style.right = 'auto';
1128
+ this.signLanguageWrapper.style.bottom = 'auto';
1129
+ // Remove position classes
1130
+ this.signLanguageWrapper.classList.remove(...Array.from(this.signLanguageWrapper.classList).filter(c => c.startsWith('vidply-sign-position-')));
1131
+ }
1132
+ };
1133
+
1134
+ const onMouseUp = () => {
1135
+ if (isDragging || isResizing) {
1136
+ this.saveSignLanguagePreferences();
1137
+ }
1138
+ isDragging = false;
1139
+ isResizing = false;
1140
+ resizeDirection = null;
1141
+ this.signLanguageWrapper.classList.remove('vidply-sign-dragging', 'vidply-sign-resizing');
1142
+ };
1143
+
1144
+ // Keyboard controls
1145
+ const onKeyDown = (e) => {
1146
+ // Toggle drag mode with D key
1147
+ if (e.key === 'd' || e.key === 'D') {
1148
+ dragMode = !dragMode;
1149
+ resizeMode = false;
1150
+ this.signLanguageWrapper.classList.toggle('vidply-sign-keyboard-drag', dragMode);
1151
+ this.signLanguageWrapper.classList.remove('vidply-sign-keyboard-resize');
1152
+ e.preventDefault();
1153
+ return;
1154
+ }
1155
+
1156
+ // Toggle resize mode with R key
1157
+ if (e.key === 'r' || e.key === 'R') {
1158
+ resizeMode = !resizeMode;
1159
+ dragMode = false;
1160
+ this.signLanguageWrapper.classList.toggle('vidply-sign-keyboard-resize', resizeMode);
1161
+ this.signLanguageWrapper.classList.remove('vidply-sign-keyboard-drag');
1162
+ e.preventDefault();
1163
+ return;
1164
+ }
1165
+
1166
+ // Escape to exit modes
1167
+ if (e.key === 'Escape') {
1168
+ dragMode = false;
1169
+ resizeMode = false;
1170
+ this.signLanguageWrapper.classList.remove('vidply-sign-keyboard-drag', 'vidply-sign-keyboard-resize');
1171
+ e.preventDefault();
1172
+ return;
1173
+ }
1174
+
1175
+ // Arrow keys for drag/resize
1176
+ if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
1177
+ const step = e.shiftKey ? 10 : 5;
1178
+ const rect = this.signLanguageWrapper.getBoundingClientRect();
1179
+
1180
+ // Get videoWrapper and container bounds
1181
+ const videoWrapperRect = this.videoWrapper.getBoundingClientRect();
1182
+ const containerRect = this.container.getBoundingClientRect();
1183
+
1184
+ // Calculate videoWrapper position relative to container
1185
+ const videoWrapperLeft = videoWrapperRect.left - containerRect.left;
1186
+ const videoWrapperTop = videoWrapperRect.top - containerRect.top;
1187
+
1188
+ if (dragMode) {
1189
+ // Get current position relative to container
1190
+ let left = rect.left - containerRect.left;
1191
+ let top = rect.top - containerRect.top;
1192
+
1193
+ if (e.key === 'ArrowLeft') left -= step;
1194
+ if (e.key === 'ArrowRight') left += step;
1195
+ if (e.key === 'ArrowUp') top -= step;
1196
+ if (e.key === 'ArrowDown') top += step;
1197
+
1198
+ const controlsHeight = 95; // Height of controls when visible
1199
+
1200
+ // Constrain to videoWrapper bounds (ensuring it stays above controls)
1201
+ left = Math.max(videoWrapperLeft, Math.min(left, videoWrapperLeft + videoWrapperRect.width - rect.width));
1202
+ top = Math.max(videoWrapperTop, Math.min(top, videoWrapperTop + videoWrapperRect.height - rect.height - controlsHeight));
1203
+
1204
+ this.signLanguageWrapper.style.left = `${left}px`;
1205
+ this.signLanguageWrapper.style.top = `${top}px`;
1206
+ this.signLanguageWrapper.style.right = 'auto';
1207
+ this.signLanguageWrapper.style.bottom = 'auto';
1208
+ // Remove position classes
1209
+ this.signLanguageWrapper.classList.remove(...Array.from(this.signLanguageWrapper.classList).filter(c => c.startsWith('vidply-sign-position-')));
1210
+ this.saveSignLanguagePreferences();
1211
+ e.preventDefault();
1212
+ } else if (resizeMode) {
1213
+ let width = rect.width;
1214
+
1215
+ // Only adjust width, height will auto-adjust to maintain aspect ratio
1216
+ if (e.key === 'ArrowLeft') width -= step;
1217
+ if (e.key === 'ArrowRight') width += step;
1218
+ // Up/Down also adjusts width for simplicity
1219
+ if (e.key === 'ArrowUp') width += step;
1220
+ if (e.key === 'ArrowDown') width -= step;
1221
+
1222
+ // Constrain width
1223
+ width = Math.max(150, width);
1224
+ // Don't let it exceed videoWrapper width
1225
+ width = Math.min(width, videoWrapperRect.width);
1226
+
1227
+ this.signLanguageWrapper.style.width = `${width}px`;
1228
+ this.signLanguageWrapper.style.height = 'auto';
1229
+ this.saveSignLanguagePreferences();
1230
+ e.preventDefault();
1231
+ }
1232
+ }
1233
+ };
1234
+
1235
+ // Attach event listeners
1236
+ this.signLanguageVideo.addEventListener('mousedown', onMouseDownVideo);
1237
+ const handles = this.signLanguageWrapper.querySelectorAll('.vidply-sign-resize-handle');
1238
+ handles.forEach(handle => handle.addEventListener('mousedown', onMouseDownHandle));
1239
+ document.addEventListener('mousemove', onMouseMove);
1240
+ document.addEventListener('mouseup', onMouseUp);
1241
+ this.signLanguageWrapper.addEventListener('keydown', onKeyDown);
1242
+
1243
+ // Store for cleanup
1244
+ this.signLanguageInteractionHandlers = {
1245
+ mouseDownVideo: onMouseDownVideo,
1246
+ mouseDownHandle: onMouseDownHandle,
1247
+ mouseMove: onMouseMove,
1248
+ mouseUp: onMouseUp,
1249
+ keyDown: onKeyDown,
1250
+ handles
1251
+ };
1252
+ }
1253
+
1254
+ constrainSignLanguagePosition() {
1255
+ if (!this.signLanguageWrapper || !this.videoWrapper) return;
1256
+
1257
+ // Ensure width is set
1258
+ if (!this.signLanguageWrapper.style.width || this.signLanguageWrapper.style.width === '') {
1259
+ this.signLanguageWrapper.style.width = '280px'; // Default width
1260
+ }
1261
+
1262
+ // Get videoWrapper position relative to the player CONTAINER (where sign language video is attached)
1263
+ const videoWrapperRect = this.videoWrapper.getBoundingClientRect();
1264
+ const containerRect = this.container.getBoundingClientRect();
1265
+ const wrapperRect = this.signLanguageWrapper.getBoundingClientRect();
1266
+
1267
+ // Calculate videoWrapper's position and dimensions relative to container
1268
+ const videoWrapperLeft = videoWrapperRect.left - containerRect.left;
1269
+ const videoWrapperTop = videoWrapperRect.top - containerRect.top;
1270
+ const videoWrapperWidth = videoWrapperRect.width;
1271
+ const videoWrapperHeight = videoWrapperRect.height;
1272
+
1273
+ // Use estimated height if video hasn't loaded yet (16:9 aspect ratio)
1274
+ let wrapperWidth = wrapperRect.width || 280;
1275
+ let wrapperHeight = wrapperRect.height || ((280 * 9) / 16); // Estimate based on 16:9 aspect ratio
1276
+
1277
+ let left, top;
1278
+ const margin = 16; // Margin from edges
1279
+ const controlsHeight = 95; // Height of controls when visible
1280
+
1281
+ // Always calculate fresh position based on desired location (relative to videoWrapper)
1282
+ const position = this.signLanguageDesiredPosition || 'bottom-right';
1283
+
1284
+ switch (position) {
1285
+ case 'bottom-right':
1286
+ left = videoWrapperLeft + videoWrapperWidth - wrapperWidth - margin;
1287
+ top = videoWrapperTop + videoWrapperHeight - wrapperHeight - controlsHeight;
1288
+ break;
1289
+ case 'bottom-left':
1290
+ left = videoWrapperLeft + margin;
1291
+ top = videoWrapperTop + videoWrapperHeight - wrapperHeight - controlsHeight;
1292
+ break;
1293
+ case 'top-right':
1294
+ left = videoWrapperLeft + videoWrapperWidth - wrapperWidth - margin;
1295
+ top = videoWrapperTop + margin;
1296
+ break;
1297
+ case 'top-left':
1298
+ left = videoWrapperLeft + margin;
1299
+ top = videoWrapperTop + margin;
1300
+ break;
1301
+ default:
1302
+ left = videoWrapperLeft + videoWrapperWidth - wrapperWidth - margin;
1303
+ top = videoWrapperTop + videoWrapperHeight - wrapperHeight - controlsHeight;
1304
+ }
1305
+
1306
+ // Constrain to videoWrapper bounds (ensuring it stays above controls)
1307
+ left = Math.max(videoWrapperLeft, Math.min(left, videoWrapperLeft + videoWrapperWidth - wrapperWidth));
1308
+ top = Math.max(videoWrapperTop, Math.min(top, videoWrapperTop + videoWrapperHeight - wrapperHeight - controlsHeight));
1309
+
1310
+ // Apply constrained position
1311
+ this.signLanguageWrapper.style.left = `${left}px`;
1312
+ this.signLanguageWrapper.style.top = `${top}px`;
1313
+ this.signLanguageWrapper.style.right = 'auto';
1314
+ this.signLanguageWrapper.style.bottom = 'auto';
1315
+ // Remove position classes if any were applied
1316
+ this.signLanguageWrapper.classList.remove(...Array.from(this.signLanguageWrapper.classList).filter(c => c.startsWith('vidply-sign-position-')));
1317
+ }
1318
+
1319
+ saveSignLanguagePreferences() {
1320
+ if (!this.signLanguageWrapper) return;
1321
+
1322
+ // Only save width - position is always calculated fresh to bottom-right
1323
+ this.storage.saveSignLanguagePreferences({
1324
+ size: {
1325
+ width: this.signLanguageWrapper.style.width
1326
+ // Height is auto - maintained by aspect ratio
1327
+ }
1328
+ });
1329
+ }
1330
+
1331
+ cleanupSignLanguage() {
1332
+ // Remove event listeners
1333
+ if (this.signLanguageHandlers) {
1334
+ this.off('play', this.signLanguageHandlers.play);
1335
+ this.off('pause', this.signLanguageHandlers.pause);
1336
+ this.off('timeupdate', this.signLanguageHandlers.timeupdate);
1337
+ this.off('ratechange', this.signLanguageHandlers.ratechange);
1338
+ this.signLanguageHandlers = null;
1339
+ }
1340
+
1341
+ // Remove interaction handlers
1342
+ if (this.signLanguageInteractionHandlers) {
1343
+ if (this.signLanguageVideo) {
1344
+ this.signLanguageVideo.removeEventListener('mousedown', this.signLanguageInteractionHandlers.mouseDownVideo);
1345
+ }
1346
+ if (this.signLanguageInteractionHandlers.handles) {
1347
+ this.signLanguageInteractionHandlers.handles.forEach(handle => {
1348
+ handle.removeEventListener('mousedown', this.signLanguageInteractionHandlers.mouseDownHandle);
1349
+ });
1350
+ }
1351
+ document.removeEventListener('mousemove', this.signLanguageInteractionHandlers.mouseMove);
1352
+ document.removeEventListener('mouseup', this.signLanguageInteractionHandlers.mouseUp);
1353
+ if (this.signLanguageWrapper) {
1354
+ this.signLanguageWrapper.removeEventListener('keydown', this.signLanguageInteractionHandlers.keyDown);
1355
+ }
1356
+ this.signLanguageInteractionHandlers = null;
1357
+ }
1358
+
1359
+ // Remove video and wrapper elements
1360
+ if (this.signLanguageWrapper && this.signLanguageWrapper.parentNode) {
1361
+ if (this.signLanguageVideo) {
1362
+ this.signLanguageVideo.pause();
1363
+ this.signLanguageVideo.src = '';
1364
+ }
1365
+ this.signLanguageWrapper.parentNode.removeChild(this.signLanguageWrapper);
1366
+ this.signLanguageWrapper = null;
1367
+ this.signLanguageVideo = null;
1368
+ }
1369
+ }
1370
+
1371
+ // Settings
1372
+ // Settings dialog removed - using individual control buttons instead
1373
+ showSettings() {
1374
+ console.warn('[VidPly] Settings dialog has been removed. Use individual control buttons (speed, captions, etc.)');
1375
+ }
1376
+
1377
+ hideSettings() {
1378
+ // No-op - settings dialog removed
1379
+ }
1380
+
1381
+ // Utility methods
1382
+ getCurrentTime() {
1383
+ return this.state.currentTime;
1384
+ }
1385
+
1386
+ getDuration() {
1387
+ return this.state.duration;
1388
+ }
1389
+
1390
+ isPlaying() {
1391
+ return this.state.playing;
1392
+ }
1393
+
1394
+ isPaused() {
1395
+ return this.state.paused;
1396
+ }
1397
+
1398
+ isEnded() {
1399
+ return this.state.ended;
1400
+ }
1401
+
1402
+ isMuted() {
1403
+ return this.state.muted;
1404
+ }
1405
+
1406
+ isFullscreen() {
1407
+ return this.state.fullscreen;
1408
+ }
1409
+
1410
+ // Error handling
1411
+ handleError(error) {
1412
+ this.log('Error:', error, 'error');
1413
+ this.emit('error', error);
1414
+
1415
+ if (this.options.onError) {
1416
+ this.options.onError.call(this, error);
1417
+ }
1418
+ }
1419
+
1420
+ // Logging
1421
+ log(message, type = 'log') {
1422
+ if (this.options.debug) {
1423
+ console[type](`[VidPly]`, message);
1424
+ }
1425
+ }
1426
+
1427
+ // Setup responsive handlers
1428
+ setupResponsiveHandlers() {
1429
+ // Use ResizeObserver for efficient resize tracking
1430
+ if (typeof ResizeObserver !== 'undefined') {
1431
+ this.resizeObserver = new ResizeObserver((entries) => {
1432
+ for (const entry of entries) {
1433
+ const width = entry.contentRect.width;
1434
+
1435
+ // Update control bar for viewport
1436
+ if (this.controlBar && typeof this.controlBar.updateControlsForViewport === 'function') {
1437
+ this.controlBar.updateControlsForViewport(width);
1438
+ }
1439
+
1440
+ // Update transcript positioning
1441
+ if (this.transcriptManager && this.transcriptManager.isVisible) {
1442
+ this.transcriptManager.positionTranscript();
1443
+ }
1444
+ }
1445
+ });
1446
+
1447
+ this.resizeObserver.observe(this.container);
1448
+ } else {
1449
+ // Fallback to window resize event
1450
+ this.resizeHandler = () => {
1451
+ const width = this.container.clientWidth;
1452
+
1453
+ if (this.controlBar && typeof this.controlBar.updateControlsForViewport === 'function') {
1454
+ this.controlBar.updateControlsForViewport(width);
1455
+ }
1456
+
1457
+ if (this.transcriptManager && this.transcriptManager.isVisible) {
1458
+ this.transcriptManager.positionTranscript();
1459
+ }
1460
+ };
1461
+
1462
+ window.addEventListener('resize', this.resizeHandler);
1463
+ }
1464
+
1465
+ // Also listen for orientation changes on mobile
1466
+ if (window.matchMedia) {
1467
+ this.orientationHandler = (e) => {
1468
+ // Wait for layout to settle
1469
+ setTimeout(() => {
1470
+ if (this.transcriptManager && this.transcriptManager.isVisible) {
1471
+ this.transcriptManager.positionTranscript();
1472
+ }
1473
+ }, 100);
1474
+ };
1475
+
1476
+ const orientationQuery = window.matchMedia('(orientation: portrait)');
1477
+ if (orientationQuery.addEventListener) {
1478
+ orientationQuery.addEventListener('change', this.orientationHandler);
1479
+ } else if (orientationQuery.addListener) {
1480
+ // Fallback for older browsers
1481
+ orientationQuery.addListener(this.orientationHandler);
1482
+ }
1483
+
1484
+ this.orientationQuery = orientationQuery;
1485
+ }
1486
+
1487
+ // Listen for native fullscreen change events (e.g., when user presses ESC)
1488
+ this.fullscreenChangeHandler = () => {
1489
+ const isFullscreen = !!(
1490
+ document.fullscreenElement ||
1491
+ document.webkitFullscreenElement ||
1492
+ document.mozFullScreenElement ||
1493
+ document.msFullscreenElement
1494
+ );
1495
+
1496
+ // Only update if state has changed
1497
+ if (this.state.fullscreen !== isFullscreen) {
1498
+ this.state.fullscreen = isFullscreen;
1499
+
1500
+ if (isFullscreen) {
1501
+ this.container.classList.add(`${this.options.classPrefix}-fullscreen`);
1502
+ } else {
1503
+ this.container.classList.remove(`${this.options.classPrefix}-fullscreen`);
1504
+ }
1505
+
1506
+ this.emit('fullscreenchange', isFullscreen);
1507
+
1508
+ // Update fullscreen button icon
1509
+ if (this.controlBar) {
1510
+ this.controlBar.updateFullscreenButton();
1511
+ }
1512
+
1513
+ // Reposition sign language video after fullscreen transition
1514
+ if (this.signLanguageWrapper && this.signLanguageWrapper.style.display !== 'none') {
1515
+ // Use setTimeout to ensure layout has updated after fullscreen transition
1516
+ // Longer delay to account for CSS transition animations and layout recalculation
1517
+ setTimeout(() => {
1518
+ // Use requestAnimationFrame to ensure the browser has fully rendered the layout
1519
+ requestAnimationFrame(() => {
1520
+ // Clear saved size and reset to default for the new container size
1521
+ this.storage.saveSignLanguagePreferences({ size: null });
1522
+ this.signLanguageDesiredPosition = 'bottom-right';
1523
+ // Reset to default width for the new container
1524
+ this.signLanguageWrapper.style.width = isFullscreen ? '400px' : '280px';
1525
+ this.constrainSignLanguagePosition();
1526
+ });
1527
+ }, 500);
1528
+ }
1529
+ }
1530
+ };
1531
+
1532
+ // Add listeners for all vendor-prefixed fullscreenchange events
1533
+ document.addEventListener('fullscreenchange', this.fullscreenChangeHandler);
1534
+ document.addEventListener('webkitfullscreenchange', this.fullscreenChangeHandler);
1535
+ document.addEventListener('mozfullscreenchange', this.fullscreenChangeHandler);
1536
+ document.addEventListener('MSFullscreenChange', this.fullscreenChangeHandler);
1537
+ }
1538
+
1539
+ // Cleanup
1540
+ destroy() {
1541
+ this.log('Destroying player');
1542
+
1543
+ if (this.renderer) {
1544
+ this.renderer.destroy();
1545
+ }
1546
+
1547
+ if (this.controlBar) {
1548
+ this.controlBar.destroy();
1549
+ }
1550
+
1551
+ if (this.captionManager) {
1552
+ this.captionManager.destroy();
1553
+ }
1554
+
1555
+ if (this.keyboardManager) {
1556
+ this.keyboardManager.destroy();
1557
+ }
1558
+
1559
+ if (this.transcriptManager) {
1560
+ this.transcriptManager.destroy();
1561
+ }
1562
+
1563
+ // Cleanup sign language video and listeners
1564
+ this.cleanupSignLanguage();
1565
+
1566
+ // Cleanup play overlay button
1567
+ if (this.playButtonOverlay && this.playButtonOverlay.parentNode) {
1568
+ this.playButtonOverlay.remove();
1569
+ this.playButtonOverlay = null;
1570
+ }
1571
+
1572
+ // Cleanup resize observer
1573
+ if (this.resizeObserver) {
1574
+ this.resizeObserver.disconnect();
1575
+ this.resizeObserver = null;
1576
+ }
1577
+
1578
+ // Cleanup window resize handler
1579
+ if (this.resizeHandler) {
1580
+ window.removeEventListener('resize', this.resizeHandler);
1581
+ this.resizeHandler = null;
1582
+ }
1583
+
1584
+ // Cleanup orientation change handler
1585
+ if (this.orientationQuery && this.orientationHandler) {
1586
+ if (this.orientationQuery.removeEventListener) {
1587
+ this.orientationQuery.removeEventListener('change', this.orientationHandler);
1588
+ } else if (this.orientationQuery.removeListener) {
1589
+ this.orientationQuery.removeListener(this.orientationHandler);
1590
+ }
1591
+ this.orientationQuery = null;
1592
+ this.orientationHandler = null;
1593
+ }
1594
+
1595
+ // Cleanup fullscreen change handler
1596
+ if (this.fullscreenChangeHandler) {
1597
+ document.removeEventListener('fullscreenchange', this.fullscreenChangeHandler);
1598
+ document.removeEventListener('webkitfullscreenchange', this.fullscreenChangeHandler);
1599
+ document.removeEventListener('mozfullscreenchange', this.fullscreenChangeHandler);
1600
+ document.removeEventListener('MSFullscreenChange', this.fullscreenChangeHandler);
1601
+ this.fullscreenChangeHandler = null;
1602
+ }
1603
+
1604
+ // Remove container
1605
+ if (this.container && this.container.parentNode) {
1606
+ this.container.parentNode.insertBefore(this.element, this.container);
1607
+ this.container.parentNode.removeChild(this.container);
1608
+ }
1609
+
1610
+ this.removeAllListeners();
1611
+ }
1612
+ }
1613
+
1614
+ // Static instances tracker for pause others functionality
1615
+ Player.instances = [];
1616
+