vidply 1.0.7 → 1.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/vidply.css +113 -44
- package/dist/vidply.esm.js +1457 -44
- package/dist/vidply.esm.js.map +3 -3
- package/dist/vidply.esm.min.js +6 -6
- package/dist/vidply.esm.min.meta.json +7 -7
- package/dist/vidply.js +1457 -44
- package/dist/vidply.js.map +3 -3
- package/dist/vidply.min.css +1 -1
- package/dist/vidply.min.js +6 -6
- package/dist/vidply.min.meta.json +7 -7
- package/package.json +2 -2
- package/src/controls/TranscriptManager.js +328 -27
- package/src/core/Player.js +1621 -16
- package/src/i18n/translations.js +10 -5
- package/src/icons/Icons.js +2 -2
- package/src/styles/vidply.css +113 -44
package/src/core/Player.js
CHANGED
|
@@ -143,6 +143,8 @@ export class Player extends EventEmitter {
|
|
|
143
143
|
screenReaderAnnouncements: true,
|
|
144
144
|
highContrast: false,
|
|
145
145
|
focusHighlight: true,
|
|
146
|
+
metadataAlerts: {},
|
|
147
|
+
metadataHashtags: {},
|
|
146
148
|
|
|
147
149
|
// Languages
|
|
148
150
|
language: 'en',
|
|
@@ -166,6 +168,9 @@ export class Player extends EventEmitter {
|
|
|
166
168
|
...options
|
|
167
169
|
};
|
|
168
170
|
|
|
171
|
+
this.options.metadataAlerts = this.options.metadataAlerts || {};
|
|
172
|
+
this.options.metadataHashtags = this.options.metadataHashtags || {};
|
|
173
|
+
|
|
169
174
|
// Storage manager
|
|
170
175
|
this.storage = new StorageManager('vidply');
|
|
171
176
|
|
|
@@ -204,6 +209,22 @@ export class Player extends EventEmitter {
|
|
|
204
209
|
this.audioDescriptionSrc = this.options.audioDescriptionSrc;
|
|
205
210
|
this.signLanguageSrc = this.options.signLanguageSrc;
|
|
206
211
|
this.signLanguageVideo = null;
|
|
212
|
+
// Store references to source elements with audio description attributes
|
|
213
|
+
this.audioDescriptionSourceElement = null;
|
|
214
|
+
this.originalAudioDescriptionSource = null;
|
|
215
|
+
// Store caption tracks that should be swapped for audio description
|
|
216
|
+
this.audioDescriptionCaptionTracks = [];
|
|
217
|
+
|
|
218
|
+
// DOM query cache (for performance optimization)
|
|
219
|
+
this._textTracksCache = null;
|
|
220
|
+
this._textTracksDirty = true;
|
|
221
|
+
this._sourceElementsCache = null;
|
|
222
|
+
this._sourceElementsDirty = true;
|
|
223
|
+
this._trackElementsCache = null;
|
|
224
|
+
this._trackElementsDirty = true;
|
|
225
|
+
|
|
226
|
+
// Timeout management (for cleanup)
|
|
227
|
+
this.timeouts = new Set();
|
|
207
228
|
|
|
208
229
|
// Components
|
|
209
230
|
this.container = null;
|
|
@@ -212,6 +233,10 @@ export class Player extends EventEmitter {
|
|
|
212
233
|
this.captionManager = null;
|
|
213
234
|
this.keyboardManager = null;
|
|
214
235
|
this.settingsDialog = null;
|
|
236
|
+
|
|
237
|
+
// Metadata handling
|
|
238
|
+
this.metadataCueChangeHandler = null;
|
|
239
|
+
this.metadataAlertHandlers = new Map();
|
|
215
240
|
|
|
216
241
|
// Initialize
|
|
217
242
|
this.init();
|
|
@@ -259,6 +284,9 @@ export class Player extends EventEmitter {
|
|
|
259
284
|
if (this.options.transcript || this.options.transcriptButton) {
|
|
260
285
|
this.transcriptManager = new TranscriptManager(this);
|
|
261
286
|
}
|
|
287
|
+
|
|
288
|
+
// Always set up metadata track handling (independent of transcript)
|
|
289
|
+
this.setupMetadataHandling();
|
|
262
290
|
|
|
263
291
|
// Initialize keyboard controls
|
|
264
292
|
if (this.options.keyboard) {
|
|
@@ -394,6 +422,12 @@ export class Player extends EventEmitter {
|
|
|
394
422
|
if (this.element.tagName === 'VIDEO') {
|
|
395
423
|
this.createPlayButtonOverlay();
|
|
396
424
|
}
|
|
425
|
+
|
|
426
|
+
// Store reference to player on element for easy access
|
|
427
|
+
this.element.vidply = this;
|
|
428
|
+
|
|
429
|
+
// Add to static instances array
|
|
430
|
+
Player.instances.push(this);
|
|
397
431
|
|
|
398
432
|
// Make video/audio element clickable to toggle play/pause
|
|
399
433
|
this.element.style.cursor = 'pointer';
|
|
@@ -441,7 +475,77 @@ export class Player extends EventEmitter {
|
|
|
441
475
|
throw new Error('No media source found');
|
|
442
476
|
}
|
|
443
477
|
|
|
444
|
-
//
|
|
478
|
+
// Check for source elements with audio description attributes
|
|
479
|
+
const sourceElements = this.sourceElements;
|
|
480
|
+
for (const sourceEl of sourceElements) {
|
|
481
|
+
const descSrc = sourceEl.getAttribute('data-desc-src');
|
|
482
|
+
const origSrc = sourceEl.getAttribute('data-orig-src');
|
|
483
|
+
|
|
484
|
+
if (descSrc || origSrc) {
|
|
485
|
+
// Found a source element with audio description attributes
|
|
486
|
+
// Store the first one as reference, but we'll search all of them when toggling
|
|
487
|
+
if (!this.audioDescriptionSourceElement) {
|
|
488
|
+
this.audioDescriptionSourceElement = sourceEl;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
if (origSrc) {
|
|
492
|
+
// Store the original src from the attribute for this source
|
|
493
|
+
if (!this.originalAudioDescriptionSource) {
|
|
494
|
+
this.originalAudioDescriptionSource = origSrc;
|
|
495
|
+
}
|
|
496
|
+
// Store the original src from the first source element that has data-orig-src
|
|
497
|
+
if (!this.originalSrc) {
|
|
498
|
+
this.originalSrc = origSrc;
|
|
499
|
+
}
|
|
500
|
+
} else {
|
|
501
|
+
// If data-orig-src is not set, use the current src attribute
|
|
502
|
+
const currentSrcAttr = sourceEl.getAttribute('src');
|
|
503
|
+
if (!this.originalAudioDescriptionSource && currentSrcAttr) {
|
|
504
|
+
this.originalAudioDescriptionSource = currentSrcAttr;
|
|
505
|
+
}
|
|
506
|
+
if (!this.originalSrc && currentSrcAttr) {
|
|
507
|
+
this.originalSrc = currentSrcAttr;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Store audio description source from data-desc-src (use first one found)
|
|
512
|
+
if (descSrc && !this.audioDescriptionSrc) {
|
|
513
|
+
this.audioDescriptionSrc = descSrc;
|
|
514
|
+
}
|
|
515
|
+
// Continue checking all source elements to ensure we capture all audio description sources
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Check for caption/subtitle tracks with audio description versions
|
|
520
|
+
// Only tracks with explicit data-desc-src attribute are swapped (no auto-detection to avoid 404 errors)
|
|
521
|
+
// Description tracks (kind="descriptions") are NOT swapped - they're for transcripts
|
|
522
|
+
const trackElements = this.trackElements;
|
|
523
|
+
trackElements.forEach(trackEl => {
|
|
524
|
+
const trackKind = trackEl.getAttribute('kind');
|
|
525
|
+
const trackDescSrc = trackEl.getAttribute('data-desc-src');
|
|
526
|
+
|
|
527
|
+
// Only handle caption/subtitle tracks (not description tracks)
|
|
528
|
+
// Description tracks stay as-is since they're for transcripts
|
|
529
|
+
// Include captions, subtitles, and chapters tracks that can be swapped for audio description
|
|
530
|
+
if (trackKind === 'captions' || trackKind === 'subtitles' || trackKind === 'chapters') {
|
|
531
|
+
if (trackDescSrc) {
|
|
532
|
+
// Found a track with explicit data-desc-src - this is the described version
|
|
533
|
+
this.audioDescriptionCaptionTracks.push({
|
|
534
|
+
trackElement: trackEl,
|
|
535
|
+
originalSrc: trackEl.getAttribute('src'),
|
|
536
|
+
describedSrc: trackDescSrc,
|
|
537
|
+
originalTrackSrc: trackEl.getAttribute('data-orig-src') || trackEl.getAttribute('src'),
|
|
538
|
+
explicit: true // Explicitly defined, so we should validate it
|
|
539
|
+
});
|
|
540
|
+
this.log(`Found explicit described ${trackKind} track: ${trackEl.getAttribute('src')} -> ${trackDescSrc}`);
|
|
541
|
+
}
|
|
542
|
+
// Note: Auto-detection disabled to avoid 404 console errors
|
|
543
|
+
// If you want described tracks, add data-desc-src attribute to the track element
|
|
544
|
+
}
|
|
545
|
+
// Description tracks (kind="descriptions") are ignored - they remain unchanged for transcripts
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
// Store original source for audio description toggling (fallback if not set above)
|
|
445
549
|
if (!this.originalSrc) {
|
|
446
550
|
this.originalSrc = src;
|
|
447
551
|
}
|
|
@@ -462,6 +566,117 @@ export class Player extends EventEmitter {
|
|
|
462
566
|
this.log(`Using ${renderer.name} renderer`);
|
|
463
567
|
this.renderer = new renderer(this);
|
|
464
568
|
await this.renderer.init();
|
|
569
|
+
|
|
570
|
+
// Invalidate cache after renderer initialization (tracks may have changed)
|
|
571
|
+
this.invalidateTrackCache();
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Get cached text tracks array
|
|
576
|
+
* @returns {Array} Array of text tracks
|
|
577
|
+
*/
|
|
578
|
+
get textTracks() {
|
|
579
|
+
if (!this._textTracksCache || this._textTracksDirty) {
|
|
580
|
+
this._textTracksCache = Array.from(this.element.textTracks || []);
|
|
581
|
+
this._textTracksDirty = false;
|
|
582
|
+
}
|
|
583
|
+
return this._textTracksCache;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Get cached source elements array
|
|
588
|
+
* @returns {Array} Array of source elements
|
|
589
|
+
*/
|
|
590
|
+
get sourceElements() {
|
|
591
|
+
if (!this._sourceElementsCache || this._sourceElementsDirty) {
|
|
592
|
+
this._sourceElementsCache = Array.from(this.element.querySelectorAll('source'));
|
|
593
|
+
this._sourceElementsDirty = false;
|
|
594
|
+
}
|
|
595
|
+
return this._sourceElementsCache;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Get cached track elements array
|
|
600
|
+
* @returns {Array} Array of track elements
|
|
601
|
+
*/
|
|
602
|
+
get trackElements() {
|
|
603
|
+
if (!this._trackElementsCache || this._trackElementsDirty) {
|
|
604
|
+
this._trackElementsCache = Array.from(this.element.querySelectorAll('track'));
|
|
605
|
+
this._trackElementsDirty = false;
|
|
606
|
+
}
|
|
607
|
+
return this._trackElementsCache;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Invalidate DOM query cache (call when tracks/sources change)
|
|
612
|
+
*/
|
|
613
|
+
invalidateTrackCache() {
|
|
614
|
+
this._textTracksDirty = true;
|
|
615
|
+
this._trackElementsDirty = true;
|
|
616
|
+
this._sourceElementsDirty = true;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Find a text track by kind and optionally language
|
|
621
|
+
* @param {string} kind - Track kind (captions, subtitles, descriptions, chapters, metadata)
|
|
622
|
+
* @param {string} [language] - Optional language code
|
|
623
|
+
* @returns {TextTrack|null} Found track or null
|
|
624
|
+
*/
|
|
625
|
+
findTextTrack(kind, language = null) {
|
|
626
|
+
const tracks = this.textTracks;
|
|
627
|
+
if (language) {
|
|
628
|
+
return tracks.find(t => t.kind === kind && t.language === language);
|
|
629
|
+
}
|
|
630
|
+
return tracks.find(t => t.kind === kind);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Find a source element by attribute
|
|
635
|
+
* @param {string} attribute - Attribute name (e.g., 'data-desc-src')
|
|
636
|
+
* @param {string} [value] - Optional attribute value
|
|
637
|
+
* @returns {Element|null} Found source element or null
|
|
638
|
+
*/
|
|
639
|
+
findSourceElement(attribute, value = null) {
|
|
640
|
+
const sources = this.sourceElements;
|
|
641
|
+
if (value) {
|
|
642
|
+
return sources.find(el => el.getAttribute(attribute) === value);
|
|
643
|
+
}
|
|
644
|
+
return sources.find(el => el.hasAttribute(attribute));
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* Find a track element by its associated TextTrack
|
|
649
|
+
* @param {TextTrack} track - The TextTrack object
|
|
650
|
+
* @returns {Element|null} Found track element or null
|
|
651
|
+
*/
|
|
652
|
+
findTrackElement(track) {
|
|
653
|
+
return this.trackElements.find(el => el.track === track);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Set a managed timeout that will be cleaned up on destroy
|
|
658
|
+
* @param {Function} callback - Callback function
|
|
659
|
+
* @param {number} delay - Delay in milliseconds
|
|
660
|
+
* @returns {number} Timeout ID
|
|
661
|
+
*/
|
|
662
|
+
setManagedTimeout(callback, delay) {
|
|
663
|
+
const timeoutId = setTimeout(() => {
|
|
664
|
+
this.timeouts.delete(timeoutId);
|
|
665
|
+
callback();
|
|
666
|
+
}, delay);
|
|
667
|
+
this.timeouts.add(timeoutId);
|
|
668
|
+
return timeoutId;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Clear a managed timeout
|
|
673
|
+
* @param {number} timeoutId - Timeout ID to clear
|
|
674
|
+
*/
|
|
675
|
+
clearManagedTimeout(timeoutId) {
|
|
676
|
+
if (timeoutId) {
|
|
677
|
+
clearTimeout(timeoutId);
|
|
678
|
+
this.timeouts.delete(timeoutId);
|
|
679
|
+
}
|
|
465
680
|
}
|
|
466
681
|
|
|
467
682
|
/**
|
|
@@ -482,8 +697,9 @@ export class Player extends EventEmitter {
|
|
|
482
697
|
}
|
|
483
698
|
|
|
484
699
|
// Clear existing text tracks
|
|
485
|
-
const existingTracks = this.
|
|
700
|
+
const existingTracks = this.trackElements;
|
|
486
701
|
existingTracks.forEach(track => track.remove());
|
|
702
|
+
this.invalidateTrackCache();
|
|
487
703
|
|
|
488
704
|
// Update media element
|
|
489
705
|
this.element.src = config.src;
|
|
@@ -511,6 +727,7 @@ export class Player extends EventEmitter {
|
|
|
511
727
|
|
|
512
728
|
this.element.appendChild(track);
|
|
513
729
|
});
|
|
730
|
+
this.invalidateTrackCache();
|
|
514
731
|
}
|
|
515
732
|
|
|
516
733
|
// Check if we need to change renderer type
|
|
@@ -803,10 +1020,28 @@ export class Player extends EventEmitter {
|
|
|
803
1020
|
}
|
|
804
1021
|
}
|
|
805
1022
|
|
|
1023
|
+
/**
|
|
1024
|
+
* Check if a track file exists
|
|
1025
|
+
* @param {string} url - Track file URL
|
|
1026
|
+
* @returns {Promise<boolean>} - True if file exists
|
|
1027
|
+
*/
|
|
1028
|
+
async validateTrackExists(url) {
|
|
1029
|
+
try {
|
|
1030
|
+
const response = await fetch(url, { method: 'HEAD', cache: 'no-cache' });
|
|
1031
|
+
return response.ok;
|
|
1032
|
+
} catch (error) {
|
|
1033
|
+
return false;
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
|
|
806
1037
|
// Audio Description
|
|
807
1038
|
async enableAudioDescription() {
|
|
808
|
-
if (
|
|
809
|
-
|
|
1039
|
+
// Check if we have source elements with data-desc-src (even if audioDescriptionSrc is not set)
|
|
1040
|
+
const hasSourceElementsWithDesc = this.sourceElements.some(el => el.getAttribute('data-desc-src'));
|
|
1041
|
+
const hasTracksWithDesc = this.audioDescriptionCaptionTracks.length > 0;
|
|
1042
|
+
|
|
1043
|
+
if (!this.audioDescriptionSrc && !hasSourceElementsWithDesc && !hasTracksWithDesc) {
|
|
1044
|
+
console.warn('VidPly: No audio description source, source elements, or tracks provided');
|
|
810
1045
|
return;
|
|
811
1046
|
}
|
|
812
1047
|
|
|
@@ -814,8 +1049,539 @@ export class Player extends EventEmitter {
|
|
|
814
1049
|
const currentTime = this.state.currentTime;
|
|
815
1050
|
const wasPlaying = this.state.playing;
|
|
816
1051
|
|
|
1052
|
+
// Store swapped tracks for transcript reload (declare at function scope)
|
|
1053
|
+
let swappedTracksForTranscript = [];
|
|
1054
|
+
|
|
817
1055
|
// Switch to audio-described version
|
|
818
|
-
|
|
1056
|
+
// If we have a source element with audio description attributes, update that instead
|
|
1057
|
+
if (this.audioDescriptionSourceElement) {
|
|
1058
|
+
const currentSrc = this.element.currentSrc || this.element.src;
|
|
1059
|
+
|
|
1060
|
+
// Find the source element that matches the currently active source
|
|
1061
|
+
const sourceElements = this.sourceElements;
|
|
1062
|
+
let sourceElementToUpdate = null;
|
|
1063
|
+
let descSrc = this.audioDescriptionSrc;
|
|
1064
|
+
|
|
1065
|
+
for (const sourceEl of sourceElements) {
|
|
1066
|
+
const sourceSrc = sourceEl.getAttribute('src');
|
|
1067
|
+
const descSrcAttr = sourceEl.getAttribute('data-desc-src');
|
|
1068
|
+
|
|
1069
|
+
// Check if this source matches the current source (by filename)
|
|
1070
|
+
// Match by full path or just filename
|
|
1071
|
+
const sourceFilename = sourceSrc ? sourceSrc.split('/').pop() : '';
|
|
1072
|
+
const currentFilename = currentSrc ? currentSrc.split('/').pop() : '';
|
|
1073
|
+
|
|
1074
|
+
if (currentSrc && (currentSrc === sourceSrc ||
|
|
1075
|
+
currentSrc.includes(sourceSrc) ||
|
|
1076
|
+
currentSrc.includes(sourceFilename) ||
|
|
1077
|
+
(sourceFilename && currentFilename === sourceFilename))) {
|
|
1078
|
+
sourceElementToUpdate = sourceEl;
|
|
1079
|
+
if (descSrcAttr) {
|
|
1080
|
+
descSrc = descSrcAttr;
|
|
1081
|
+
} else if (sourceSrc) {
|
|
1082
|
+
// If no data-desc-src, try to construct it from the source
|
|
1083
|
+
// But prefer the stored audioDescriptionSrc if available
|
|
1084
|
+
descSrc = this.audioDescriptionSrc || descSrc;
|
|
1085
|
+
}
|
|
1086
|
+
break;
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
// If we didn't find a match, use the stored source element
|
|
1091
|
+
if (!sourceElementToUpdate) {
|
|
1092
|
+
sourceElementToUpdate = this.audioDescriptionSourceElement;
|
|
1093
|
+
// Ensure we have the correct descSrc from the stored element
|
|
1094
|
+
const storedDescSrc = sourceElementToUpdate.getAttribute('data-desc-src');
|
|
1095
|
+
if (storedDescSrc) {
|
|
1096
|
+
descSrc = storedDescSrc;
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
// Swap caption tracks to described versions BEFORE loading
|
|
1101
|
+
if (this.audioDescriptionCaptionTracks.length > 0) {
|
|
1102
|
+
// Swap tracks: validate explicit tracks, but try auto-detected tracks without validation
|
|
1103
|
+
// This avoids 404 errors while still allowing auto-detection to work
|
|
1104
|
+
const validationPromises = this.audioDescriptionCaptionTracks.map(async (trackInfo) => {
|
|
1105
|
+
if (trackInfo.trackElement && trackInfo.describedSrc) {
|
|
1106
|
+
// Only validate explicitly defined tracks (to confirm they exist)
|
|
1107
|
+
// Auto-detected tracks are used without validation (browser will handle missing files gracefully)
|
|
1108
|
+
if (trackInfo.explicit === true) {
|
|
1109
|
+
try {
|
|
1110
|
+
const exists = await this.validateTrackExists(trackInfo.describedSrc);
|
|
1111
|
+
return { trackInfo, exists };
|
|
1112
|
+
} catch (error) {
|
|
1113
|
+
// Silently handle validation errors
|
|
1114
|
+
return { trackInfo, exists: false };
|
|
1115
|
+
}
|
|
1116
|
+
} else {
|
|
1117
|
+
// This shouldn't happen since auto-detection is disabled
|
|
1118
|
+
// But if it does, don't validate to avoid 404s
|
|
1119
|
+
return { trackInfo, exists: false };
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
return { trackInfo, exists: false };
|
|
1123
|
+
});
|
|
1124
|
+
|
|
1125
|
+
const validationResults = await Promise.all(validationPromises);
|
|
1126
|
+
const tracksToSwap = validationResults.filter(result => result.exists);
|
|
1127
|
+
|
|
1128
|
+
if (tracksToSwap.length > 0) {
|
|
1129
|
+
// Store original track modes before removing tracks
|
|
1130
|
+
const trackModes = new Map();
|
|
1131
|
+
tracksToSwap.forEach(({ trackInfo }) => {
|
|
1132
|
+
const textTrack = trackInfo.trackElement.track;
|
|
1133
|
+
if (textTrack) {
|
|
1134
|
+
trackModes.set(trackInfo, {
|
|
1135
|
+
wasShowing: textTrack.mode === 'showing',
|
|
1136
|
+
wasHidden: textTrack.mode === 'hidden'
|
|
1137
|
+
});
|
|
1138
|
+
} else {
|
|
1139
|
+
trackModes.set(trackInfo, {
|
|
1140
|
+
wasShowing: false,
|
|
1141
|
+
wasHidden: false
|
|
1142
|
+
});
|
|
1143
|
+
}
|
|
1144
|
+
});
|
|
1145
|
+
|
|
1146
|
+
// Store all track information before removing
|
|
1147
|
+
const tracksToReadd = tracksToSwap.map(({ trackInfo }) => {
|
|
1148
|
+
const oldSrc = trackInfo.trackElement.getAttribute('src');
|
|
1149
|
+
const parent = trackInfo.trackElement.parentNode;
|
|
1150
|
+
const nextSibling = trackInfo.trackElement.nextSibling;
|
|
1151
|
+
|
|
1152
|
+
// Store all attributes from the old track
|
|
1153
|
+
const attributes = {};
|
|
1154
|
+
Array.from(trackInfo.trackElement.attributes).forEach(attr => {
|
|
1155
|
+
attributes[attr.name] = attr.value;
|
|
1156
|
+
});
|
|
1157
|
+
|
|
1158
|
+
return {
|
|
1159
|
+
trackInfo,
|
|
1160
|
+
oldSrc,
|
|
1161
|
+
parent,
|
|
1162
|
+
nextSibling,
|
|
1163
|
+
attributes
|
|
1164
|
+
};
|
|
1165
|
+
});
|
|
1166
|
+
|
|
1167
|
+
// Remove ALL old tracks first to force browser to clear TextTrack objects
|
|
1168
|
+
tracksToReadd.forEach(({ trackInfo }) => {
|
|
1169
|
+
trackInfo.trackElement.remove();
|
|
1170
|
+
});
|
|
1171
|
+
|
|
1172
|
+
// Force browser to process the removal by calling load()
|
|
1173
|
+
this.element.load();
|
|
1174
|
+
|
|
1175
|
+
// Wait for browser to process the removal, then add new tracks
|
|
1176
|
+
setTimeout(() => {
|
|
1177
|
+
tracksToReadd.forEach(({ trackInfo, oldSrc, parent, nextSibling, attributes }) => {
|
|
1178
|
+
swappedTracksForTranscript.push(trackInfo);
|
|
1179
|
+
|
|
1180
|
+
// Create a completely new track element (not a clone) to force browser to create new TextTrack
|
|
1181
|
+
const newTrackElement = document.createElement('track');
|
|
1182
|
+
newTrackElement.setAttribute('src', trackInfo.describedSrc);
|
|
1183
|
+
|
|
1184
|
+
// Copy all attributes except src and data-desc-src
|
|
1185
|
+
Object.keys(attributes).forEach(attrName => {
|
|
1186
|
+
if (attrName !== 'src' && attrName !== 'data-desc-src') {
|
|
1187
|
+
newTrackElement.setAttribute(attrName, attributes[attrName]);
|
|
1188
|
+
}
|
|
1189
|
+
});
|
|
1190
|
+
|
|
1191
|
+
// Insert new track element
|
|
1192
|
+
if (nextSibling && nextSibling.parentNode) {
|
|
1193
|
+
parent.insertBefore(newTrackElement, nextSibling);
|
|
1194
|
+
} else {
|
|
1195
|
+
parent.appendChild(newTrackElement);
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
// Update reference to the new track element
|
|
1199
|
+
trackInfo.trackElement = newTrackElement;
|
|
1200
|
+
});
|
|
1201
|
+
|
|
1202
|
+
// After all new tracks are added, force browser to reload media element again
|
|
1203
|
+
// This ensures new track elements are processed and new TextTrack objects are created
|
|
1204
|
+
this.element.load();
|
|
1205
|
+
this.invalidateTrackCache();
|
|
1206
|
+
|
|
1207
|
+
// Wait for loadedmetadata event before accessing new TextTrack objects
|
|
1208
|
+
const setupNewTracks = () => {
|
|
1209
|
+
// Wait a bit more for browser to fully process the new track elements
|
|
1210
|
+
this.setManagedTimeout(() => {
|
|
1211
|
+
swappedTracksForTranscript.forEach((trackInfo) => {
|
|
1212
|
+
const trackElement = trackInfo.trackElement;
|
|
1213
|
+
const newTextTrack = trackElement.track;
|
|
1214
|
+
|
|
1215
|
+
if (newTextTrack) {
|
|
1216
|
+
// Get original mode from stored map
|
|
1217
|
+
const modeInfo = trackModes.get(trackInfo) || { wasShowing: false, wasHidden: false };
|
|
1218
|
+
|
|
1219
|
+
// Set mode to load the new track
|
|
1220
|
+
newTextTrack.mode = 'hidden'; // Use hidden to load cues without showing
|
|
1221
|
+
|
|
1222
|
+
// Restore original mode after track loads
|
|
1223
|
+
// Note: CaptionManager will handle enabling captions separately
|
|
1224
|
+
const restoreMode = () => {
|
|
1225
|
+
if (modeInfo.wasShowing) {
|
|
1226
|
+
// Set to hidden - CaptionManager will set it to showing when it enables
|
|
1227
|
+
newTextTrack.mode = 'hidden';
|
|
1228
|
+
} else if (modeInfo.wasHidden) {
|
|
1229
|
+
newTextTrack.mode = 'hidden';
|
|
1230
|
+
} else {
|
|
1231
|
+
newTextTrack.mode = 'disabled';
|
|
1232
|
+
}
|
|
1233
|
+
};
|
|
1234
|
+
|
|
1235
|
+
// Wait for track to load
|
|
1236
|
+
if (newTextTrack.readyState >= 2) { // LOADED
|
|
1237
|
+
restoreMode();
|
|
1238
|
+
} else {
|
|
1239
|
+
newTextTrack.addEventListener('load', restoreMode, { once: true });
|
|
1240
|
+
newTextTrack.addEventListener('error', restoreMode, { once: true });
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
});
|
|
1244
|
+
}, 300); // Additional wait for browser to process track elements
|
|
1245
|
+
};
|
|
1246
|
+
|
|
1247
|
+
// Wait for loadedmetadata event which fires when browser processes track elements
|
|
1248
|
+
if (this.element.readyState >= 1) { // HAVE_METADATA
|
|
1249
|
+
// Already loaded, wait a bit and setup
|
|
1250
|
+
setTimeout(setupNewTracks, 200);
|
|
1251
|
+
} else {
|
|
1252
|
+
this.element.addEventListener('loadedmetadata', setupNewTracks, { once: true });
|
|
1253
|
+
// Fallback timeout
|
|
1254
|
+
setTimeout(setupNewTracks, 2000);
|
|
1255
|
+
}
|
|
1256
|
+
}, 100); // Wait 100ms after first load() before adding new tracks
|
|
1257
|
+
|
|
1258
|
+
const skippedCount = validationResults.length - tracksToSwap.length;
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
// Update all source elements that have data-desc-src to their described versions
|
|
1263
|
+
// Force browser to pick up changes by removing and re-adding source elements
|
|
1264
|
+
// Get source elements (may have been defined in if block above, but get fresh list here)
|
|
1265
|
+
const allSourceElements = this.sourceElements;
|
|
1266
|
+
const sourcesToUpdate = [];
|
|
1267
|
+
|
|
1268
|
+
allSourceElements.forEach((sourceEl) => {
|
|
1269
|
+
const descSrcAttr = sourceEl.getAttribute('data-desc-src');
|
|
1270
|
+
const currentSrc = sourceEl.getAttribute('src');
|
|
1271
|
+
|
|
1272
|
+
if (descSrcAttr) {
|
|
1273
|
+
const type = sourceEl.getAttribute('type');
|
|
1274
|
+
let origSrc = sourceEl.getAttribute('data-orig-src');
|
|
1275
|
+
|
|
1276
|
+
// Store current src as data-orig-src if not already set
|
|
1277
|
+
if (!origSrc) {
|
|
1278
|
+
origSrc = currentSrc;
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
// Store info for re-adding with described src
|
|
1282
|
+
sourcesToUpdate.push({
|
|
1283
|
+
src: descSrcAttr, // Use described version
|
|
1284
|
+
type: type,
|
|
1285
|
+
origSrc: origSrc,
|
|
1286
|
+
descSrc: descSrcAttr
|
|
1287
|
+
});
|
|
1288
|
+
} else {
|
|
1289
|
+
// Source element without data-desc-src - keep as-is
|
|
1290
|
+
const type = sourceEl.getAttribute('type');
|
|
1291
|
+
const src = sourceEl.getAttribute('src');
|
|
1292
|
+
sourcesToUpdate.push({
|
|
1293
|
+
src: src,
|
|
1294
|
+
type: type,
|
|
1295
|
+
origSrc: null,
|
|
1296
|
+
descSrc: null
|
|
1297
|
+
});
|
|
1298
|
+
}
|
|
1299
|
+
});
|
|
1300
|
+
|
|
1301
|
+
// Remove all source elements
|
|
1302
|
+
allSourceElements.forEach(sourceEl => {
|
|
1303
|
+
sourceEl.remove();
|
|
1304
|
+
});
|
|
1305
|
+
|
|
1306
|
+
// Re-add them with updated src attributes (described versions)
|
|
1307
|
+
sourcesToUpdate.forEach(sourceInfo => {
|
|
1308
|
+
const newSource = document.createElement('source');
|
|
1309
|
+
newSource.setAttribute('src', sourceInfo.src);
|
|
1310
|
+
if (sourceInfo.type) {
|
|
1311
|
+
newSource.setAttribute('type', sourceInfo.type);
|
|
1312
|
+
}
|
|
1313
|
+
if (sourceInfo.origSrc) {
|
|
1314
|
+
newSource.setAttribute('data-orig-src', sourceInfo.origSrc);
|
|
1315
|
+
}
|
|
1316
|
+
if (sourceInfo.descSrc) {
|
|
1317
|
+
newSource.setAttribute('data-desc-src', sourceInfo.descSrc);
|
|
1318
|
+
}
|
|
1319
|
+
this.element.appendChild(newSource);
|
|
1320
|
+
});
|
|
1321
|
+
|
|
1322
|
+
// Force reload by calling load() on the element
|
|
1323
|
+
// This should pick up the new src attributes from the re-added source elements
|
|
1324
|
+
// and also reload the track elements
|
|
1325
|
+
this.element.load();
|
|
1326
|
+
|
|
1327
|
+
// Wait for new source to load
|
|
1328
|
+
await new Promise((resolve) => {
|
|
1329
|
+
const onLoadedMetadata = () => {
|
|
1330
|
+
this.element.removeEventListener('loadedmetadata', onLoadedMetadata);
|
|
1331
|
+
resolve();
|
|
1332
|
+
};
|
|
1333
|
+
this.element.addEventListener('loadedmetadata', onLoadedMetadata);
|
|
1334
|
+
});
|
|
1335
|
+
|
|
1336
|
+
// Wait a bit more for tracks to be recognized and loaded after video metadata loads
|
|
1337
|
+
await new Promise(resolve => setTimeout(resolve, 300));
|
|
1338
|
+
|
|
1339
|
+
// Hide poster if video hasn't started yet (poster should hide when we seek or play)
|
|
1340
|
+
if (this.element.tagName === 'VIDEO' && currentTime === 0 && !wasPlaying) {
|
|
1341
|
+
// Force poster to hide by doing a minimal seek or loading first frame
|
|
1342
|
+
// Setting readyState check or seeking to 0.001 seconds will hide the poster
|
|
1343
|
+
if (this.element.readyState >= 1) { // HAVE_METADATA
|
|
1344
|
+
// Seek to a tiny fraction to trigger poster hiding without actually moving
|
|
1345
|
+
this.element.currentTime = 0.001;
|
|
1346
|
+
// Then seek back to 0 after a brief moment to ensure poster stays hidden
|
|
1347
|
+
setTimeout(() => {
|
|
1348
|
+
this.element.currentTime = 0;
|
|
1349
|
+
}, 10);
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
// Restore playback position
|
|
1354
|
+
this.seek(currentTime);
|
|
1355
|
+
|
|
1356
|
+
if (wasPlaying) {
|
|
1357
|
+
this.play();
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
// Update state and emit event
|
|
1361
|
+
this.state.audioDescriptionEnabled = true;
|
|
1362
|
+
this.emit('audiodescriptionenabled');
|
|
1363
|
+
} else {
|
|
1364
|
+
// Fallback to updating element src directly
|
|
1365
|
+
// Swap caption tracks to described versions BEFORE loading
|
|
1366
|
+
if (this.audioDescriptionCaptionTracks.length > 0) {
|
|
1367
|
+
// Swap tracks: validate explicit tracks, but try auto-detected tracks without validation
|
|
1368
|
+
const validationPromises = this.audioDescriptionCaptionTracks.map(async (trackInfo) => {
|
|
1369
|
+
if (trackInfo.trackElement && trackInfo.describedSrc) {
|
|
1370
|
+
// Only validate explicitly defined tracks
|
|
1371
|
+
// Auto-detected tracks are used without validation (no 404s)
|
|
1372
|
+
if (trackInfo.explicit === true) {
|
|
1373
|
+
try {
|
|
1374
|
+
const exists = await this.validateTrackExists(trackInfo.describedSrc);
|
|
1375
|
+
return { trackInfo, exists };
|
|
1376
|
+
} catch (error) {
|
|
1377
|
+
return { trackInfo, exists: false };
|
|
1378
|
+
}
|
|
1379
|
+
} else {
|
|
1380
|
+
// This shouldn't happen since auto-detection is disabled
|
|
1381
|
+
return { trackInfo, exists: false };
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
return { trackInfo, exists: false };
|
|
1385
|
+
});
|
|
1386
|
+
|
|
1387
|
+
const validationResults = await Promise.all(validationPromises);
|
|
1388
|
+
const tracksToSwap = validationResults.filter(result => result.exists);
|
|
1389
|
+
|
|
1390
|
+
if (tracksToSwap.length > 0) {
|
|
1391
|
+
// Store original track modes before removing tracks
|
|
1392
|
+
const trackModes = new Map();
|
|
1393
|
+
tracksToSwap.forEach(({ trackInfo }) => {
|
|
1394
|
+
const textTrack = trackInfo.trackElement.track;
|
|
1395
|
+
if (textTrack) {
|
|
1396
|
+
trackModes.set(trackInfo, {
|
|
1397
|
+
wasShowing: textTrack.mode === 'showing',
|
|
1398
|
+
wasHidden: textTrack.mode === 'hidden'
|
|
1399
|
+
});
|
|
1400
|
+
} else {
|
|
1401
|
+
trackModes.set(trackInfo, {
|
|
1402
|
+
wasShowing: false,
|
|
1403
|
+
wasHidden: false
|
|
1404
|
+
});
|
|
1405
|
+
}
|
|
1406
|
+
});
|
|
1407
|
+
|
|
1408
|
+
// Store all track information before removing
|
|
1409
|
+
const tracksToReadd = tracksToSwap.map(({ trackInfo }) => {
|
|
1410
|
+
const oldSrc = trackInfo.trackElement.getAttribute('src');
|
|
1411
|
+
const parent = trackInfo.trackElement.parentNode;
|
|
1412
|
+
const nextSibling = trackInfo.trackElement.nextSibling;
|
|
1413
|
+
|
|
1414
|
+
// Store all attributes from the old track
|
|
1415
|
+
const attributes = {};
|
|
1416
|
+
Array.from(trackInfo.trackElement.attributes).forEach(attr => {
|
|
1417
|
+
attributes[attr.name] = attr.value;
|
|
1418
|
+
});
|
|
1419
|
+
|
|
1420
|
+
return {
|
|
1421
|
+
trackInfo,
|
|
1422
|
+
oldSrc,
|
|
1423
|
+
parent,
|
|
1424
|
+
nextSibling,
|
|
1425
|
+
attributes
|
|
1426
|
+
};
|
|
1427
|
+
});
|
|
1428
|
+
|
|
1429
|
+
// Remove ALL old tracks first to force browser to clear TextTrack objects
|
|
1430
|
+
tracksToReadd.forEach(({ trackInfo }) => {
|
|
1431
|
+
trackInfo.trackElement.remove();
|
|
1432
|
+
});
|
|
1433
|
+
|
|
1434
|
+
// Force browser to process the removal by calling load()
|
|
1435
|
+
this.element.load();
|
|
1436
|
+
|
|
1437
|
+
// Wait for browser to process the removal, then add new tracks
|
|
1438
|
+
setTimeout(() => {
|
|
1439
|
+
tracksToReadd.forEach(({ trackInfo, oldSrc, parent, nextSibling, attributes }) => {
|
|
1440
|
+
swappedTracksForTranscript.push(trackInfo);
|
|
1441
|
+
|
|
1442
|
+
// Create a completely new track element (not a clone) to force browser to create new TextTrack
|
|
1443
|
+
const newTrackElement = document.createElement('track');
|
|
1444
|
+
newTrackElement.setAttribute('src', trackInfo.describedSrc);
|
|
1445
|
+
|
|
1446
|
+
// Copy all attributes except src and data-desc-src
|
|
1447
|
+
Object.keys(attributes).forEach(attrName => {
|
|
1448
|
+
if (attrName !== 'src' && attrName !== 'data-desc-src') {
|
|
1449
|
+
newTrackElement.setAttribute(attrName, attributes[attrName]);
|
|
1450
|
+
}
|
|
1451
|
+
});
|
|
1452
|
+
|
|
1453
|
+
// Insert new track element
|
|
1454
|
+
if (nextSibling && nextSibling.parentNode) {
|
|
1455
|
+
parent.insertBefore(newTrackElement, nextSibling);
|
|
1456
|
+
} else {
|
|
1457
|
+
parent.appendChild(newTrackElement);
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
// Update reference to the new track element
|
|
1461
|
+
trackInfo.trackElement = newTrackElement;
|
|
1462
|
+
});
|
|
1463
|
+
|
|
1464
|
+
// After all new tracks are added, force browser to reload media element again
|
|
1465
|
+
this.element.load();
|
|
1466
|
+
|
|
1467
|
+
// Wait for loadedmetadata event before accessing new TextTrack objects
|
|
1468
|
+
const setupNewTracks = () => {
|
|
1469
|
+
// Wait a bit more for browser to fully process the new track elements
|
|
1470
|
+
setTimeout(() => {
|
|
1471
|
+
swappedTracksForTranscript.forEach((trackInfo) => {
|
|
1472
|
+
const trackElement = trackInfo.trackElement;
|
|
1473
|
+
const newTextTrack = trackElement.track;
|
|
1474
|
+
|
|
1475
|
+
if (newTextTrack) {
|
|
1476
|
+
// Get original mode from stored map
|
|
1477
|
+
const modeInfo = trackModes.get(trackInfo) || { wasShowing: false, wasHidden: false };
|
|
1478
|
+
|
|
1479
|
+
// Set mode to load the new track
|
|
1480
|
+
newTextTrack.mode = 'hidden'; // Use hidden to load cues without showing
|
|
1481
|
+
|
|
1482
|
+
// Restore original mode after track loads
|
|
1483
|
+
const restoreMode = () => {
|
|
1484
|
+
if (modeInfo.wasShowing) {
|
|
1485
|
+
// Set to hidden - CaptionManager will set it to showing when it enables
|
|
1486
|
+
newTextTrack.mode = 'hidden';
|
|
1487
|
+
} else if (modeInfo.wasHidden) {
|
|
1488
|
+
newTextTrack.mode = 'hidden';
|
|
1489
|
+
} else {
|
|
1490
|
+
newTextTrack.mode = 'disabled';
|
|
1491
|
+
}
|
|
1492
|
+
};
|
|
1493
|
+
|
|
1494
|
+
// Wait for track to load
|
|
1495
|
+
if (newTextTrack.readyState >= 2) { // LOADED
|
|
1496
|
+
restoreMode();
|
|
1497
|
+
} else {
|
|
1498
|
+
newTextTrack.addEventListener('load', restoreMode, { once: true });
|
|
1499
|
+
newTextTrack.addEventListener('error', restoreMode, { once: true });
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
});
|
|
1503
|
+
}, 300); // Additional wait for browser to process track elements
|
|
1504
|
+
};
|
|
1505
|
+
|
|
1506
|
+
// Wait for loadedmetadata event which fires when browser processes track elements
|
|
1507
|
+
if (this.element.readyState >= 1) { // HAVE_METADATA
|
|
1508
|
+
// Already loaded, wait a bit and setup
|
|
1509
|
+
setTimeout(setupNewTracks, 200);
|
|
1510
|
+
} else {
|
|
1511
|
+
this.element.addEventListener('loadedmetadata', setupNewTracks, { once: true });
|
|
1512
|
+
// Fallback timeout
|
|
1513
|
+
setTimeout(setupNewTracks, 2000);
|
|
1514
|
+
}
|
|
1515
|
+
}, 100); // Wait 100ms after first load() before adding new tracks
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
// Check if we have source elements with data-desc-src (fallback method)
|
|
1520
|
+
const fallbackSourceElements = this.sourceElements;
|
|
1521
|
+
const hasSourceElementsWithDesc = fallbackSourceElements.some(el => el.getAttribute('data-desc-src'));
|
|
1522
|
+
|
|
1523
|
+
if (hasSourceElementsWithDesc) {
|
|
1524
|
+
const fallbackSourcesToUpdate = [];
|
|
1525
|
+
|
|
1526
|
+
fallbackSourceElements.forEach((sourceEl) => {
|
|
1527
|
+
const descSrcAttr = sourceEl.getAttribute('data-desc-src');
|
|
1528
|
+
const currentSrc = sourceEl.getAttribute('src');
|
|
1529
|
+
|
|
1530
|
+
if (descSrcAttr) {
|
|
1531
|
+
const type = sourceEl.getAttribute('type');
|
|
1532
|
+
let origSrc = sourceEl.getAttribute('data-orig-src');
|
|
1533
|
+
|
|
1534
|
+
if (!origSrc) {
|
|
1535
|
+
origSrc = currentSrc;
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
fallbackSourcesToUpdate.push({
|
|
1539
|
+
src: descSrcAttr,
|
|
1540
|
+
type: type,
|
|
1541
|
+
origSrc: origSrc,
|
|
1542
|
+
descSrc: descSrcAttr
|
|
1543
|
+
});
|
|
1544
|
+
} else {
|
|
1545
|
+
const type = sourceEl.getAttribute('type');
|
|
1546
|
+
const src = sourceEl.getAttribute('src');
|
|
1547
|
+
fallbackSourcesToUpdate.push({
|
|
1548
|
+
src: src,
|
|
1549
|
+
type: type,
|
|
1550
|
+
origSrc: null,
|
|
1551
|
+
descSrc: null
|
|
1552
|
+
});
|
|
1553
|
+
}
|
|
1554
|
+
});
|
|
1555
|
+
|
|
1556
|
+
// Remove all source elements
|
|
1557
|
+
fallbackSourceElements.forEach(sourceEl => {
|
|
1558
|
+
sourceEl.remove();
|
|
1559
|
+
});
|
|
1560
|
+
|
|
1561
|
+
// Re-add them with updated src attributes
|
|
1562
|
+
fallbackSourcesToUpdate.forEach(sourceInfo => {
|
|
1563
|
+
const newSource = document.createElement('source');
|
|
1564
|
+
newSource.setAttribute('src', sourceInfo.src);
|
|
1565
|
+
if (sourceInfo.type) {
|
|
1566
|
+
newSource.setAttribute('type', sourceInfo.type);
|
|
1567
|
+
}
|
|
1568
|
+
if (sourceInfo.origSrc) {
|
|
1569
|
+
newSource.setAttribute('data-orig-src', sourceInfo.origSrc);
|
|
1570
|
+
}
|
|
1571
|
+
if (sourceInfo.descSrc) {
|
|
1572
|
+
newSource.setAttribute('data-desc-src', sourceInfo.descSrc);
|
|
1573
|
+
}
|
|
1574
|
+
this.element.appendChild(newSource);
|
|
1575
|
+
});
|
|
1576
|
+
|
|
1577
|
+
// Force reload
|
|
1578
|
+
this.element.load();
|
|
1579
|
+
this.invalidateTrackCache();
|
|
1580
|
+
} else {
|
|
1581
|
+
// Fallback to updating element src directly (for videos without source elements)
|
|
1582
|
+
this.element.src = this.audioDescriptionSrc;
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
819
1585
|
|
|
820
1586
|
// Wait for new source to load
|
|
821
1587
|
await new Promise((resolve) => {
|
|
@@ -826,6 +1592,20 @@ export class Player extends EventEmitter {
|
|
|
826
1592
|
this.element.addEventListener('loadedmetadata', onLoadedMetadata);
|
|
827
1593
|
});
|
|
828
1594
|
|
|
1595
|
+
// Hide poster if video hasn't started yet (poster should hide when we seek or play)
|
|
1596
|
+
if (this.element.tagName === 'VIDEO' && currentTime === 0 && !wasPlaying) {
|
|
1597
|
+
// Force poster to hide by doing a minimal seek or loading first frame
|
|
1598
|
+
// Setting readyState check or seeking to 0.001 seconds will hide the poster
|
|
1599
|
+
if (this.element.readyState >= 1) { // HAVE_METADATA
|
|
1600
|
+
// Seek to a tiny fraction to trigger poster hiding without actually moving
|
|
1601
|
+
this.element.currentTime = 0.001;
|
|
1602
|
+
// Then seek back to 0 after a brief moment to ensure poster stays hidden
|
|
1603
|
+
this.setManagedTimeout(() => {
|
|
1604
|
+
this.element.currentTime = 0;
|
|
1605
|
+
}, 10);
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
|
|
829
1609
|
// Restore playback position
|
|
830
1610
|
this.seek(currentTime);
|
|
831
1611
|
|
|
@@ -833,6 +1613,247 @@ export class Player extends EventEmitter {
|
|
|
833
1613
|
this.play();
|
|
834
1614
|
}
|
|
835
1615
|
|
|
1616
|
+
// Reload CaptionManager tracks if tracks were swapped (so it has fresh references)
|
|
1617
|
+
if (swappedTracksForTranscript.length > 0 && this.captionManager) {
|
|
1618
|
+
// Store if captions were enabled and which track
|
|
1619
|
+
const wasCaptionsEnabled = this.state.captionsEnabled;
|
|
1620
|
+
let currentTrackInfo = null;
|
|
1621
|
+
if (this.captionManager.currentTrack) {
|
|
1622
|
+
const currentTrackIndex = this.captionManager.tracks.findIndex(t => t.track === this.captionManager.currentTrack.track);
|
|
1623
|
+
if (currentTrackIndex >= 0) {
|
|
1624
|
+
currentTrackInfo = {
|
|
1625
|
+
language: this.captionManager.tracks[currentTrackIndex].language,
|
|
1626
|
+
kind: this.captionManager.tracks[currentTrackIndex].kind
|
|
1627
|
+
};
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
// Wait a bit for new tracks to be available, then reload
|
|
1632
|
+
setTimeout(() => {
|
|
1633
|
+
// Reload tracks to get fresh references to new TextTrack objects
|
|
1634
|
+
this.captionManager.tracks = [];
|
|
1635
|
+
this.captionManager.loadTracks();
|
|
1636
|
+
|
|
1637
|
+
// Re-enable captions if they were enabled before
|
|
1638
|
+
if (wasCaptionsEnabled && currentTrackInfo && this.captionManager.tracks.length > 0) {
|
|
1639
|
+
// Find the track by language and kind to match the swapped track
|
|
1640
|
+
const matchingTrackIndex = this.captionManager.tracks.findIndex(t =>
|
|
1641
|
+
t.language === currentTrackInfo.language && t.kind === currentTrackInfo.kind
|
|
1642
|
+
);
|
|
1643
|
+
|
|
1644
|
+
if (matchingTrackIndex >= 0) {
|
|
1645
|
+
this.captionManager.enable(matchingTrackIndex);
|
|
1646
|
+
} else if (this.captionManager.tracks.length > 0) {
|
|
1647
|
+
// Fallback: enable first track
|
|
1648
|
+
this.captionManager.enable(0);
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
}, 600); // Wait for tracks to be processed
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
// Reload transcript if visible (after video metadata loaded, tracks should be available)
|
|
1655
|
+
// Reload regardless of whether caption tracks were swapped, in case tracks changed
|
|
1656
|
+
if (this.transcriptManager && this.transcriptManager.isVisible) {
|
|
1657
|
+
// Wait for tracks to load after source swap
|
|
1658
|
+
// If tracks were swapped, wait for them to load; otherwise wait a bit for any track changes
|
|
1659
|
+
const swappedTracks = typeof swappedTracksForTranscript !== 'undefined' ? swappedTracksForTranscript : [];
|
|
1660
|
+
|
|
1661
|
+
if (swappedTracks.length > 0) {
|
|
1662
|
+
// Wait for swapped tracks to load their new cues
|
|
1663
|
+
// Since we re-added track elements and called load(), wait for loadedmetadata event
|
|
1664
|
+
// which is when the browser processes track elements
|
|
1665
|
+
const onMetadataLoaded = () => {
|
|
1666
|
+
// Get fresh track references from the video element's textTracks collection
|
|
1667
|
+
// This ensures we get the actual textTrack objects that the browser created
|
|
1668
|
+
// Invalidate cache first to get fresh tracks after swap
|
|
1669
|
+
this.invalidateTrackCache();
|
|
1670
|
+
const allTextTracks = this.textTracks;
|
|
1671
|
+
|
|
1672
|
+
// Find the tracks that match our swapped tracks by language and kind
|
|
1673
|
+
// Match by checking the track element's src attribute
|
|
1674
|
+
const freshTracks = swappedTracks.map((trackInfo) => {
|
|
1675
|
+
const trackEl = trackInfo.trackElement;
|
|
1676
|
+
const expectedSrc = trackEl.getAttribute('src');
|
|
1677
|
+
const srclang = trackEl.getAttribute('srclang');
|
|
1678
|
+
const kind = trackEl.getAttribute('kind');
|
|
1679
|
+
|
|
1680
|
+
// Find matching track in textTracks collection
|
|
1681
|
+
// First try to match by the track element reference
|
|
1682
|
+
let foundTrack = allTextTracks.find(track => trackEl.track === track);
|
|
1683
|
+
|
|
1684
|
+
// If not found, try matching by language and kind, but verify src
|
|
1685
|
+
if (!foundTrack) {
|
|
1686
|
+
foundTrack = allTextTracks.find(track => {
|
|
1687
|
+
if (track.language === srclang &&
|
|
1688
|
+
(track.kind === kind || (kind === 'captions' && track.kind === 'subtitles'))) {
|
|
1689
|
+
// Verify the src matches
|
|
1690
|
+
const trackElementForTrack = this.findTrackElement(track);
|
|
1691
|
+
if (trackElementForTrack) {
|
|
1692
|
+
const actualSrc = trackElementForTrack.getAttribute('src');
|
|
1693
|
+
if (actualSrc === expectedSrc) {
|
|
1694
|
+
return true;
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
return false;
|
|
1699
|
+
});
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
// Verify the track element's src matches what we expect
|
|
1703
|
+
if (foundTrack) {
|
|
1704
|
+
const trackElement = this.findTrackElement(foundTrack);
|
|
1705
|
+
if (trackElement && trackElement.getAttribute('src') !== expectedSrc) {
|
|
1706
|
+
return null;
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
return foundTrack;
|
|
1711
|
+
}).filter(Boolean);
|
|
1712
|
+
|
|
1713
|
+
if (freshTracks.length === 0) {
|
|
1714
|
+
// Fallback: just reload after delay - transcript manager will find tracks itself
|
|
1715
|
+
this.setManagedTimeout(() => {
|
|
1716
|
+
if (this.transcriptManager && this.transcriptManager.loadTranscriptData) {
|
|
1717
|
+
this.transcriptManager.loadTranscriptData();
|
|
1718
|
+
}
|
|
1719
|
+
}, 1000);
|
|
1720
|
+
return;
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
// Ensure tracks are in hidden mode to load cues for transcript
|
|
1724
|
+
freshTracks.forEach(track => {
|
|
1725
|
+
if (track.mode === 'disabled') {
|
|
1726
|
+
track.mode = 'hidden';
|
|
1727
|
+
}
|
|
1728
|
+
});
|
|
1729
|
+
|
|
1730
|
+
let loadedCount = 0;
|
|
1731
|
+
const checkLoaded = () => {
|
|
1732
|
+
loadedCount++;
|
|
1733
|
+
if (loadedCount >= freshTracks.length) {
|
|
1734
|
+
// Give a bit more time for cues to be fully parsed
|
|
1735
|
+
// Also ensure we're getting the latest TextTrack references
|
|
1736
|
+
this.setManagedTimeout(() => {
|
|
1737
|
+
if (this.transcriptManager && this.transcriptManager.loadTranscriptData) {
|
|
1738
|
+
// Force transcript manager to get fresh track references
|
|
1739
|
+
// Clear any cached track references by forcing a fresh read
|
|
1740
|
+
// The transcript manager will find tracks from this.element.textTracks
|
|
1741
|
+
// which should now have the new TextTrack objects with the described captions
|
|
1742
|
+
|
|
1743
|
+
// Verify the tracks have the correct src before reloading transcript
|
|
1744
|
+
this.invalidateTrackCache();
|
|
1745
|
+
const allTextTracks = this.textTracks;
|
|
1746
|
+
const swappedTrackSrcs = swappedTracks.map(t => t.describedSrc);
|
|
1747
|
+
const hasCorrectTracks = freshTracks.some(track => {
|
|
1748
|
+
const trackEl = this.findTrackElement(track);
|
|
1749
|
+
return trackEl && swappedTrackSrcs.includes(trackEl.getAttribute('src'));
|
|
1750
|
+
});
|
|
1751
|
+
|
|
1752
|
+
if (hasCorrectTracks || freshTracks.length > 0) {
|
|
1753
|
+
this.transcriptManager.loadTranscriptData();
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
}, 800); // Increased wait time to ensure cues are fully loaded
|
|
1757
|
+
}
|
|
1758
|
+
};
|
|
1759
|
+
|
|
1760
|
+
freshTracks.forEach(track => {
|
|
1761
|
+
// Ensure track is in hidden mode to load cues (required for transcript)
|
|
1762
|
+
if (track.mode === 'disabled') {
|
|
1763
|
+
track.mode = 'hidden';
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
// Check if track has cues loaded
|
|
1767
|
+
// Verify the track element's src matches the expected described src
|
|
1768
|
+
const trackElementForTrack = this.findTrackElement(track);
|
|
1769
|
+
const actualSrc = trackElementForTrack ? trackElementForTrack.getAttribute('src') : null;
|
|
1770
|
+
|
|
1771
|
+
// Find the expected src from swappedTracks
|
|
1772
|
+
const expectedTrackInfo = swappedTracks.find(t => {
|
|
1773
|
+
const tEl = t.trackElement;
|
|
1774
|
+
return tEl && (tEl.track === track ||
|
|
1775
|
+
(tEl.getAttribute('srclang') === track.language &&
|
|
1776
|
+
tEl.getAttribute('kind') === track.kind));
|
|
1777
|
+
});
|
|
1778
|
+
const expectedSrc = expectedTrackInfo ? expectedTrackInfo.describedSrc : null;
|
|
1779
|
+
|
|
1780
|
+
// Only proceed if the src matches (or we can't verify)
|
|
1781
|
+
if (expectedSrc && actualSrc && actualSrc !== expectedSrc) {
|
|
1782
|
+
// Wrong track, skip it
|
|
1783
|
+
checkLoaded(); // Count it as loaded to not block
|
|
1784
|
+
return;
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
if (track.readyState >= 2 && track.cues && track.cues.length > 0) { // LOADED with cues
|
|
1788
|
+
// Track already loaded with cues
|
|
1789
|
+
checkLoaded();
|
|
1790
|
+
} else {
|
|
1791
|
+
// Force track to load by setting mode
|
|
1792
|
+
if (track.mode === 'disabled') {
|
|
1793
|
+
track.mode = 'hidden';
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
// Wait for track to load
|
|
1797
|
+
const onTrackLoad = () => {
|
|
1798
|
+
// Wait a bit for cues to be fully parsed
|
|
1799
|
+
this.setManagedTimeout(checkLoaded, 300);
|
|
1800
|
+
};
|
|
1801
|
+
|
|
1802
|
+
if (track.readyState >= 2) {
|
|
1803
|
+
// Already loaded, but might not have cues yet
|
|
1804
|
+
// Wait a bit and check again
|
|
1805
|
+
this.setManagedTimeout(() => {
|
|
1806
|
+
if (track.cues && track.cues.length > 0) {
|
|
1807
|
+
checkLoaded();
|
|
1808
|
+
} else {
|
|
1809
|
+
// Still no cues, wait for load event
|
|
1810
|
+
track.addEventListener('load', onTrackLoad, { once: true });
|
|
1811
|
+
}
|
|
1812
|
+
}, 100);
|
|
1813
|
+
} else {
|
|
1814
|
+
track.addEventListener('load', onTrackLoad, { once: true });
|
|
1815
|
+
track.addEventListener('error', () => {
|
|
1816
|
+
// Even on error, try to reload transcript
|
|
1817
|
+
checkLoaded();
|
|
1818
|
+
}, { once: true });
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
});
|
|
1822
|
+
};
|
|
1823
|
+
|
|
1824
|
+
// Wait for loadedmetadata event which fires when browser processes track elements
|
|
1825
|
+
// Also wait for the tracks to be fully processed after the second load()
|
|
1826
|
+
const waitForTracks = () => {
|
|
1827
|
+
// Wait a bit more to ensure new TextTrack objects are created
|
|
1828
|
+
this.setManagedTimeout(() => {
|
|
1829
|
+
if (this.element.readyState >= 1) { // HAVE_METADATA
|
|
1830
|
+
onMetadataLoaded();
|
|
1831
|
+
} else {
|
|
1832
|
+
this.element.addEventListener('loadedmetadata', onMetadataLoaded, { once: true });
|
|
1833
|
+
// Fallback timeout
|
|
1834
|
+
this.setManagedTimeout(onMetadataLoaded, 2000);
|
|
1835
|
+
}
|
|
1836
|
+
}, 500); // Wait 500ms after second load() for tracks to be processed
|
|
1837
|
+
};
|
|
1838
|
+
|
|
1839
|
+
waitForTracks();
|
|
1840
|
+
|
|
1841
|
+
// Fallback timeout - longer to ensure tracks are loaded
|
|
1842
|
+
setTimeout(() => {
|
|
1843
|
+
if (this.transcriptManager && this.transcriptManager.loadTranscriptData) {
|
|
1844
|
+
this.transcriptManager.loadTranscriptData();
|
|
1845
|
+
}
|
|
1846
|
+
}, 5000);
|
|
1847
|
+
} else {
|
|
1848
|
+
// No tracks swapped, just wait a bit and reload
|
|
1849
|
+
setTimeout(() => {
|
|
1850
|
+
if (this.transcriptManager && this.transcriptManager.loadTranscriptData) {
|
|
1851
|
+
this.transcriptManager.loadTranscriptData();
|
|
1852
|
+
}
|
|
1853
|
+
}, 800);
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
|
|
836
1857
|
this.state.audioDescriptionEnabled = true;
|
|
837
1858
|
this.emit('audiodescriptionenabled');
|
|
838
1859
|
}
|
|
@@ -846,8 +1867,78 @@ export class Player extends EventEmitter {
|
|
|
846
1867
|
const currentTime = this.state.currentTime;
|
|
847
1868
|
const wasPlaying = this.state.playing;
|
|
848
1869
|
|
|
849
|
-
//
|
|
850
|
-
this.
|
|
1870
|
+
// Swap caption/chapter tracks back to original versions BEFORE loading
|
|
1871
|
+
if (this.audioDescriptionCaptionTracks.length > 0) {
|
|
1872
|
+
this.audioDescriptionCaptionTracks.forEach(trackInfo => {
|
|
1873
|
+
if (trackInfo.trackElement && trackInfo.originalTrackSrc) {
|
|
1874
|
+
trackInfo.trackElement.setAttribute('src', trackInfo.originalTrackSrc);
|
|
1875
|
+
}
|
|
1876
|
+
});
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
// Swap source elements back to original versions
|
|
1880
|
+
// Check if we have source elements with data-orig-src
|
|
1881
|
+
const allSourceElements = this.sourceElements;
|
|
1882
|
+
const hasSourceElementsToSwap = allSourceElements.some(el => el.getAttribute('data-orig-src'));
|
|
1883
|
+
|
|
1884
|
+
if (hasSourceElementsToSwap) {
|
|
1885
|
+
const sourcesToRestore = [];
|
|
1886
|
+
|
|
1887
|
+
allSourceElements.forEach((sourceEl) => {
|
|
1888
|
+
const origSrcAttr = sourceEl.getAttribute('data-orig-src');
|
|
1889
|
+
const descSrcAttr = sourceEl.getAttribute('data-desc-src');
|
|
1890
|
+
|
|
1891
|
+
if (origSrcAttr) {
|
|
1892
|
+
// Swap back to original src
|
|
1893
|
+
const type = sourceEl.getAttribute('type');
|
|
1894
|
+
sourcesToRestore.push({
|
|
1895
|
+
src: origSrcAttr, // Use original version
|
|
1896
|
+
type: type,
|
|
1897
|
+
origSrc: origSrcAttr,
|
|
1898
|
+
descSrc: descSrcAttr // Keep data-desc-src for future swaps
|
|
1899
|
+
});
|
|
1900
|
+
} else {
|
|
1901
|
+
// Keep as-is (no data-orig-src means it wasn't swapped)
|
|
1902
|
+
const type = sourceEl.getAttribute('type');
|
|
1903
|
+
const src = sourceEl.getAttribute('src');
|
|
1904
|
+
sourcesToRestore.push({
|
|
1905
|
+
src: src,
|
|
1906
|
+
type: type,
|
|
1907
|
+
origSrc: null,
|
|
1908
|
+
descSrc: descSrcAttr
|
|
1909
|
+
});
|
|
1910
|
+
}
|
|
1911
|
+
});
|
|
1912
|
+
|
|
1913
|
+
// Remove all source elements
|
|
1914
|
+
allSourceElements.forEach(sourceEl => {
|
|
1915
|
+
sourceEl.remove();
|
|
1916
|
+
});
|
|
1917
|
+
|
|
1918
|
+
// Re-add them with original src attributes
|
|
1919
|
+
sourcesToRestore.forEach(sourceInfo => {
|
|
1920
|
+
const newSource = document.createElement('source');
|
|
1921
|
+
newSource.setAttribute('src', sourceInfo.src);
|
|
1922
|
+
if (sourceInfo.type) {
|
|
1923
|
+
newSource.setAttribute('type', sourceInfo.type);
|
|
1924
|
+
}
|
|
1925
|
+
if (sourceInfo.origSrc) {
|
|
1926
|
+
newSource.setAttribute('data-orig-src', sourceInfo.origSrc);
|
|
1927
|
+
}
|
|
1928
|
+
if (sourceInfo.descSrc) {
|
|
1929
|
+
newSource.setAttribute('data-desc-src', sourceInfo.descSrc);
|
|
1930
|
+
}
|
|
1931
|
+
this.element.appendChild(newSource);
|
|
1932
|
+
});
|
|
1933
|
+
|
|
1934
|
+
// Force reload
|
|
1935
|
+
this.element.load();
|
|
1936
|
+
} else {
|
|
1937
|
+
// Fallback to updating element src directly (for videos without source elements)
|
|
1938
|
+
const originalSrcToUse = this.originalAudioDescriptionSource || this.originalSrc;
|
|
1939
|
+
this.element.src = originalSrcToUse;
|
|
1940
|
+
this.element.load();
|
|
1941
|
+
}
|
|
851
1942
|
|
|
852
1943
|
// Wait for new source to load
|
|
853
1944
|
await new Promise((resolve) => {
|
|
@@ -865,16 +1956,70 @@ export class Player extends EventEmitter {
|
|
|
865
1956
|
this.play();
|
|
866
1957
|
}
|
|
867
1958
|
|
|
1959
|
+
// Reload transcript if visible (after video metadata loaded, tracks should be available)
|
|
1960
|
+
// Reload regardless of whether caption tracks were swapped, in case tracks changed
|
|
1961
|
+
if (this.transcriptManager && this.transcriptManager.isVisible) {
|
|
1962
|
+
// Wait for tracks to load after source swap
|
|
1963
|
+
this.setManagedTimeout(() => {
|
|
1964
|
+
if (this.transcriptManager && this.transcriptManager.loadTranscriptData) {
|
|
1965
|
+
this.transcriptManager.loadTranscriptData();
|
|
1966
|
+
}
|
|
1967
|
+
}, 500);
|
|
1968
|
+
}
|
|
1969
|
+
|
|
868
1970
|
this.state.audioDescriptionEnabled = false;
|
|
869
1971
|
this.emit('audiodescriptiondisabled');
|
|
870
1972
|
}
|
|
871
1973
|
|
|
872
1974
|
async toggleAudioDescription() {
|
|
873
1975
|
// Check if we have description tracks or audio-described video
|
|
874
|
-
const
|
|
875
|
-
|
|
1976
|
+
const descriptionTrack = this.findTextTrack('descriptions');
|
|
1977
|
+
|
|
1978
|
+
// Check if we have audio-described video source (either from options or source elements with data-desc-src)
|
|
1979
|
+
const hasAudioDescriptionSrc = this.audioDescriptionSrc ||
|
|
1980
|
+
this.sourceElements.some(el => el.getAttribute('data-desc-src'));
|
|
876
1981
|
|
|
877
|
-
if (descriptionTrack) {
|
|
1982
|
+
if (descriptionTrack && hasAudioDescriptionSrc) {
|
|
1983
|
+
// We have both: toggle description track AND swap caption tracks/sources
|
|
1984
|
+
if (this.state.audioDescriptionEnabled) {
|
|
1985
|
+
// Disable: toggle description track off and swap captions/sources back
|
|
1986
|
+
descriptionTrack.mode = 'hidden';
|
|
1987
|
+
await this.disableAudioDescription();
|
|
1988
|
+
} else {
|
|
1989
|
+
// Enable: swap caption tracks/sources and toggle description track on
|
|
1990
|
+
await this.enableAudioDescription();
|
|
1991
|
+
// Wait for tracks to be ready after source swap, then enable description track
|
|
1992
|
+
// Use a longer timeout to ensure tracks are loaded after source swap
|
|
1993
|
+
const enableDescriptionTrack = () => {
|
|
1994
|
+
this.invalidateTrackCache();
|
|
1995
|
+
const descTrack = this.findTextTrack('descriptions');
|
|
1996
|
+
if (descTrack) {
|
|
1997
|
+
// Set to 'hidden' first if it's in 'disabled' mode, then to 'showing'
|
|
1998
|
+
if (descTrack.mode === 'disabled') {
|
|
1999
|
+
descTrack.mode = 'hidden';
|
|
2000
|
+
// Use setTimeout to ensure the browser processes the mode change
|
|
2001
|
+
this.setManagedTimeout(() => {
|
|
2002
|
+
descTrack.mode = 'showing';
|
|
2003
|
+
}, 50);
|
|
2004
|
+
} else {
|
|
2005
|
+
descTrack.mode = 'showing';
|
|
2006
|
+
}
|
|
2007
|
+
} else if (this.element.readyState < 2) {
|
|
2008
|
+
// Tracks not ready yet, wait a bit more
|
|
2009
|
+
this.setManagedTimeout(enableDescriptionTrack, 100);
|
|
2010
|
+
}
|
|
2011
|
+
};
|
|
2012
|
+
// Wait for metadata to load first
|
|
2013
|
+
if (this.element.readyState >= 1) {
|
|
2014
|
+
this.setManagedTimeout(enableDescriptionTrack, 200);
|
|
2015
|
+
} else {
|
|
2016
|
+
this.element.addEventListener('loadedmetadata', () => {
|
|
2017
|
+
this.setManagedTimeout(enableDescriptionTrack, 200);
|
|
2018
|
+
}, { once: true });
|
|
2019
|
+
}
|
|
2020
|
+
}
|
|
2021
|
+
} else if (descriptionTrack) {
|
|
2022
|
+
// Only description track, no audio-described video source to swap
|
|
878
2023
|
// Toggle description track
|
|
879
2024
|
if (descriptionTrack.mode === 'showing') {
|
|
880
2025
|
descriptionTrack.mode = 'hidden';
|
|
@@ -885,8 +2030,8 @@ export class Player extends EventEmitter {
|
|
|
885
2030
|
this.state.audioDescriptionEnabled = true;
|
|
886
2031
|
this.emit('audiodescriptionenabled');
|
|
887
2032
|
}
|
|
888
|
-
} else if (
|
|
889
|
-
// Use audio-described video source
|
|
2033
|
+
} else if (hasAudioDescriptionSrc) {
|
|
2034
|
+
// Use audio-described video source (no description track)
|
|
890
2035
|
if (this.state.audioDescriptionEnabled) {
|
|
891
2036
|
await this.disableAudioDescription();
|
|
892
2037
|
} else {
|
|
@@ -1418,9 +2563,28 @@ export class Player extends EventEmitter {
|
|
|
1418
2563
|
}
|
|
1419
2564
|
|
|
1420
2565
|
// Logging
|
|
1421
|
-
log(
|
|
1422
|
-
if (this.options.debug) {
|
|
1423
|
-
|
|
2566
|
+
log(...messages) {
|
|
2567
|
+
if (!this.options.debug) {
|
|
2568
|
+
return;
|
|
2569
|
+
}
|
|
2570
|
+
|
|
2571
|
+
let type = 'log';
|
|
2572
|
+
if (messages.length > 0) {
|
|
2573
|
+
const potentialType = messages[messages.length - 1];
|
|
2574
|
+
if (typeof potentialType === 'string' && console[potentialType]) {
|
|
2575
|
+
type = potentialType;
|
|
2576
|
+
messages = messages.slice(0, -1);
|
|
2577
|
+
}
|
|
2578
|
+
}
|
|
2579
|
+
|
|
2580
|
+
if (messages.length === 0) {
|
|
2581
|
+
messages = [''];
|
|
2582
|
+
}
|
|
2583
|
+
|
|
2584
|
+
if (typeof console[type] === 'function') {
|
|
2585
|
+
console[type]('[VidPly]', ...messages);
|
|
2586
|
+
} else {
|
|
2587
|
+
console.log('[VidPly]', ...messages);
|
|
1424
2588
|
}
|
|
1425
2589
|
}
|
|
1426
2590
|
|
|
@@ -1514,7 +2678,7 @@ export class Player extends EventEmitter {
|
|
|
1514
2678
|
if (this.signLanguageWrapper && this.signLanguageWrapper.style.display !== 'none') {
|
|
1515
2679
|
// Use setTimeout to ensure layout has updated after fullscreen transition
|
|
1516
2680
|
// Longer delay to account for CSS transition animations and layout recalculation
|
|
1517
|
-
|
|
2681
|
+
this.setManagedTimeout(() => {
|
|
1518
2682
|
// Use requestAnimationFrame to ensure the browser has fully rendered the layout
|
|
1519
2683
|
requestAnimationFrame(() => {
|
|
1520
2684
|
// Clear saved size and reset to default for the new container size
|
|
@@ -1601,6 +2765,29 @@ export class Player extends EventEmitter {
|
|
|
1601
2765
|
this.fullscreenChangeHandler = null;
|
|
1602
2766
|
}
|
|
1603
2767
|
|
|
2768
|
+
// Cleanup all managed timeouts
|
|
2769
|
+
this.timeouts.forEach(timeoutId => clearTimeout(timeoutId));
|
|
2770
|
+
this.timeouts.clear();
|
|
2771
|
+
|
|
2772
|
+
// Cleanup metadata handling
|
|
2773
|
+
if (this.metadataCueChangeHandler) {
|
|
2774
|
+
const textTracks = this.textTracks;
|
|
2775
|
+
const metadataTrack = textTracks.find(track => track.kind === 'metadata');
|
|
2776
|
+
if (metadataTrack) {
|
|
2777
|
+
metadataTrack.removeEventListener('cuechange', this.metadataCueChangeHandler);
|
|
2778
|
+
}
|
|
2779
|
+
this.metadataCueChangeHandler = null;
|
|
2780
|
+
}
|
|
2781
|
+
|
|
2782
|
+
if (this.metadataAlertHandlers && this.metadataAlertHandlers.size > 0) {
|
|
2783
|
+
this.metadataAlertHandlers.forEach(({ button, handler }) => {
|
|
2784
|
+
if (button && handler) {
|
|
2785
|
+
button.removeEventListener('click', handler);
|
|
2786
|
+
}
|
|
2787
|
+
});
|
|
2788
|
+
this.metadataAlertHandlers.clear();
|
|
2789
|
+
}
|
|
2790
|
+
|
|
1604
2791
|
// Remove container
|
|
1605
2792
|
if (this.container && this.container.parentNode) {
|
|
1606
2793
|
this.container.parentNode.insertBefore(this.element, this.container);
|
|
@@ -1609,6 +2796,424 @@ export class Player extends EventEmitter {
|
|
|
1609
2796
|
|
|
1610
2797
|
this.removeAllListeners();
|
|
1611
2798
|
}
|
|
2799
|
+
|
|
2800
|
+
/**
|
|
2801
|
+
* Setup metadata track handling
|
|
2802
|
+
* This enables metadata tracks and listens for cue changes to trigger actions
|
|
2803
|
+
*/
|
|
2804
|
+
setupMetadataHandling() {
|
|
2805
|
+
const setupMetadata = () => {
|
|
2806
|
+
const textTracks = this.textTracks;
|
|
2807
|
+
const metadataTrack = textTracks.find(track => track.kind === 'metadata');
|
|
2808
|
+
|
|
2809
|
+
if (metadataTrack) {
|
|
2810
|
+
// Enable the metadata track so cuechange events fire
|
|
2811
|
+
// Use 'hidden' mode so it doesn't display anything, but events still work
|
|
2812
|
+
if (metadataTrack.mode === 'disabled') {
|
|
2813
|
+
metadataTrack.mode = 'hidden';
|
|
2814
|
+
}
|
|
2815
|
+
|
|
2816
|
+
// Remove existing listener if any
|
|
2817
|
+
if (this.metadataCueChangeHandler) {
|
|
2818
|
+
metadataTrack.removeEventListener('cuechange', this.metadataCueChangeHandler);
|
|
2819
|
+
}
|
|
2820
|
+
|
|
2821
|
+
// Add event listener for cue changes
|
|
2822
|
+
this.metadataCueChangeHandler = () => {
|
|
2823
|
+
const activeCues = Array.from(metadataTrack.activeCues || []);
|
|
2824
|
+
if (activeCues.length > 0) {
|
|
2825
|
+
// Debug logging
|
|
2826
|
+
if (this.options.debug) {
|
|
2827
|
+
this.log('[Metadata] Active cues:', activeCues.map(c => ({
|
|
2828
|
+
start: c.startTime,
|
|
2829
|
+
end: c.endTime,
|
|
2830
|
+
text: c.text
|
|
2831
|
+
})));
|
|
2832
|
+
}
|
|
2833
|
+
}
|
|
2834
|
+
activeCues.forEach(cue => {
|
|
2835
|
+
this.handleMetadataCue(cue);
|
|
2836
|
+
});
|
|
2837
|
+
};
|
|
2838
|
+
|
|
2839
|
+
metadataTrack.addEventListener('cuechange', this.metadataCueChangeHandler);
|
|
2840
|
+
|
|
2841
|
+
// Debug: Log metadata track setup
|
|
2842
|
+
if (this.options.debug) {
|
|
2843
|
+
const cueCount = metadataTrack.cues ? metadataTrack.cues.length : 0;
|
|
2844
|
+
this.log('[Metadata] Track enabled,', cueCount, 'cues available');
|
|
2845
|
+
}
|
|
2846
|
+
} else if (this.options.debug) {
|
|
2847
|
+
this.log('[Metadata] No metadata track found');
|
|
2848
|
+
}
|
|
2849
|
+
};
|
|
2850
|
+
|
|
2851
|
+
// Try immediately
|
|
2852
|
+
setupMetadata();
|
|
2853
|
+
|
|
2854
|
+
// Also try after loadedmetadata event (tracks might not be ready yet)
|
|
2855
|
+
this.on('loadedmetadata', setupMetadata);
|
|
2856
|
+
}
|
|
2857
|
+
|
|
2858
|
+
normalizeMetadataSelector(selector) {
|
|
2859
|
+
if (!selector) {
|
|
2860
|
+
return null;
|
|
2861
|
+
}
|
|
2862
|
+
const trimmed = selector.trim();
|
|
2863
|
+
if (!trimmed) {
|
|
2864
|
+
return null;
|
|
2865
|
+
}
|
|
2866
|
+
if (trimmed.startsWith('#') || trimmed.startsWith('.') || trimmed.startsWith('[')) {
|
|
2867
|
+
return trimmed;
|
|
2868
|
+
}
|
|
2869
|
+
return `#${trimmed}`;
|
|
2870
|
+
}
|
|
2871
|
+
|
|
2872
|
+
resolveMetadataConfig(map, key) {
|
|
2873
|
+
if (!map || !key) {
|
|
2874
|
+
return null;
|
|
2875
|
+
}
|
|
2876
|
+
if (Object.prototype.hasOwnProperty.call(map, key)) {
|
|
2877
|
+
return map[key];
|
|
2878
|
+
}
|
|
2879
|
+
const withoutHash = key.replace(/^#/, '');
|
|
2880
|
+
if (Object.prototype.hasOwnProperty.call(map, withoutHash)) {
|
|
2881
|
+
return map[withoutHash];
|
|
2882
|
+
}
|
|
2883
|
+
return null;
|
|
2884
|
+
}
|
|
2885
|
+
|
|
2886
|
+
cacheMetadataAlertContent(element, config = {}) {
|
|
2887
|
+
if (!element) {
|
|
2888
|
+
return;
|
|
2889
|
+
}
|
|
2890
|
+
const titleSelector = config.titleSelector || '[data-vidply-alert-title], h3, header';
|
|
2891
|
+
const messageSelector = config.messageSelector || '[data-vidply-alert-message], p';
|
|
2892
|
+
|
|
2893
|
+
const titleEl = element.querySelector(titleSelector);
|
|
2894
|
+
if (titleEl && !titleEl.dataset.vidplyAlertTitleOriginal) {
|
|
2895
|
+
titleEl.dataset.vidplyAlertTitleOriginal = titleEl.textContent.trim();
|
|
2896
|
+
}
|
|
2897
|
+
|
|
2898
|
+
const messageEl = element.querySelector(messageSelector);
|
|
2899
|
+
if (messageEl && !messageEl.dataset.vidplyAlertMessageOriginal) {
|
|
2900
|
+
messageEl.dataset.vidplyAlertMessageOriginal = messageEl.textContent.trim();
|
|
2901
|
+
}
|
|
2902
|
+
}
|
|
2903
|
+
|
|
2904
|
+
restoreMetadataAlertContent(element, config = {}) {
|
|
2905
|
+
if (!element) {
|
|
2906
|
+
return;
|
|
2907
|
+
}
|
|
2908
|
+
const titleSelector = config.titleSelector || '[data-vidply-alert-title], h3, header';
|
|
2909
|
+
const messageSelector = config.messageSelector || '[data-vidply-alert-message], p';
|
|
2910
|
+
|
|
2911
|
+
const titleEl = element.querySelector(titleSelector);
|
|
2912
|
+
if (titleEl && titleEl.dataset.vidplyAlertTitleOriginal) {
|
|
2913
|
+
titleEl.textContent = titleEl.dataset.vidplyAlertTitleOriginal;
|
|
2914
|
+
}
|
|
2915
|
+
|
|
2916
|
+
const messageEl = element.querySelector(messageSelector);
|
|
2917
|
+
if (messageEl && messageEl.dataset.vidplyAlertMessageOriginal) {
|
|
2918
|
+
messageEl.textContent = messageEl.dataset.vidplyAlertMessageOriginal;
|
|
2919
|
+
}
|
|
2920
|
+
}
|
|
2921
|
+
|
|
2922
|
+
focusMetadataTarget(target, fallbackElement = null) {
|
|
2923
|
+
if (!target || target === 'none') {
|
|
2924
|
+
return;
|
|
2925
|
+
}
|
|
2926
|
+
|
|
2927
|
+
if (target === 'alert' && fallbackElement) {
|
|
2928
|
+
fallbackElement.focus();
|
|
2929
|
+
return;
|
|
2930
|
+
}
|
|
2931
|
+
|
|
2932
|
+
if (target === 'player') {
|
|
2933
|
+
if (this.container) {
|
|
2934
|
+
this.container.focus();
|
|
2935
|
+
}
|
|
2936
|
+
return;
|
|
2937
|
+
}
|
|
2938
|
+
|
|
2939
|
+
if (target === 'media') {
|
|
2940
|
+
this.element.focus();
|
|
2941
|
+
return;
|
|
2942
|
+
}
|
|
2943
|
+
|
|
2944
|
+
if (target === 'playButton') {
|
|
2945
|
+
const playButton = this.controlBar?.controls?.playPause;
|
|
2946
|
+
if (playButton) {
|
|
2947
|
+
playButton.focus();
|
|
2948
|
+
}
|
|
2949
|
+
return;
|
|
2950
|
+
}
|
|
2951
|
+
|
|
2952
|
+
if (typeof target === 'string') {
|
|
2953
|
+
const targetElement = document.querySelector(target);
|
|
2954
|
+
if (targetElement) {
|
|
2955
|
+
if (targetElement.tabIndex === -1 && !targetElement.hasAttribute('tabindex')) {
|
|
2956
|
+
targetElement.setAttribute('tabindex', '-1');
|
|
2957
|
+
}
|
|
2958
|
+
targetElement.focus();
|
|
2959
|
+
}
|
|
2960
|
+
}
|
|
2961
|
+
}
|
|
2962
|
+
|
|
2963
|
+
handleMetadataAlert(selector, options = {}) {
|
|
2964
|
+
if (!selector) {
|
|
2965
|
+
return;
|
|
2966
|
+
}
|
|
2967
|
+
|
|
2968
|
+
const config = this.resolveMetadataConfig(this.options.metadataAlerts, selector) || {};
|
|
2969
|
+
const element = options.element || document.querySelector(selector);
|
|
2970
|
+
|
|
2971
|
+
if (!element) {
|
|
2972
|
+
if (this.options.debug) {
|
|
2973
|
+
this.log('[Metadata] Alert element not found:', selector);
|
|
2974
|
+
}
|
|
2975
|
+
return;
|
|
2976
|
+
}
|
|
2977
|
+
|
|
2978
|
+
if (this.options.debug) {
|
|
2979
|
+
this.log('[Metadata] Handling alert', selector, { reason: options.reason, config });
|
|
2980
|
+
}
|
|
2981
|
+
|
|
2982
|
+
this.cacheMetadataAlertContent(element, config);
|
|
2983
|
+
|
|
2984
|
+
if (!element.dataset.vidplyAlertOriginalDisplay) {
|
|
2985
|
+
element.dataset.vidplyAlertOriginalDisplay = element.style.display || '';
|
|
2986
|
+
}
|
|
2987
|
+
|
|
2988
|
+
if (!element.dataset.vidplyAlertDisplay) {
|
|
2989
|
+
element.dataset.vidplyAlertDisplay = config.display || 'block';
|
|
2990
|
+
}
|
|
2991
|
+
|
|
2992
|
+
const shouldShow = options.show !== undefined ? options.show : (config.show !== false);
|
|
2993
|
+
if (shouldShow) {
|
|
2994
|
+
const displayValue = config.display || element.dataset.vidplyAlertDisplay || 'block';
|
|
2995
|
+
element.style.display = displayValue;
|
|
2996
|
+
element.hidden = false;
|
|
2997
|
+
element.removeAttribute('hidden');
|
|
2998
|
+
element.setAttribute('aria-hidden', 'false');
|
|
2999
|
+
element.setAttribute('data-vidply-alert-active', 'true');
|
|
3000
|
+
}
|
|
3001
|
+
|
|
3002
|
+
const shouldReset = config.resetContent !== false && options.reason === 'focus';
|
|
3003
|
+
if (shouldReset) {
|
|
3004
|
+
this.restoreMetadataAlertContent(element, config);
|
|
3005
|
+
}
|
|
3006
|
+
|
|
3007
|
+
const shouldFocus = options.focus !== undefined
|
|
3008
|
+
? options.focus
|
|
3009
|
+
: (config.focusOnShow ?? (options.reason !== 'focus'));
|
|
3010
|
+
|
|
3011
|
+
if (shouldShow && shouldFocus) {
|
|
3012
|
+
if (element.tabIndex === -1 && !element.hasAttribute('tabindex')) {
|
|
3013
|
+
element.setAttribute('tabindex', '-1');
|
|
3014
|
+
}
|
|
3015
|
+
element.focus();
|
|
3016
|
+
}
|
|
3017
|
+
|
|
3018
|
+
if (shouldShow && config.autoScroll !== false && options.autoScroll !== false) {
|
|
3019
|
+
element.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
3020
|
+
}
|
|
3021
|
+
|
|
3022
|
+
const continueSelector = config.continueButton;
|
|
3023
|
+
if (continueSelector) {
|
|
3024
|
+
let continueButton = null;
|
|
3025
|
+
if (continueSelector === 'self') {
|
|
3026
|
+
continueButton = element;
|
|
3027
|
+
} else if (element.matches(continueSelector)) {
|
|
3028
|
+
continueButton = element;
|
|
3029
|
+
} else {
|
|
3030
|
+
continueButton = element.querySelector(continueSelector) || document.querySelector(continueSelector);
|
|
3031
|
+
}
|
|
3032
|
+
|
|
3033
|
+
if (continueButton && !this.metadataAlertHandlers.has(selector)) {
|
|
3034
|
+
const handler = () => {
|
|
3035
|
+
const hideOnContinue = config.hideOnContinue !== false;
|
|
3036
|
+
if (hideOnContinue) {
|
|
3037
|
+
const originalDisplay = element.dataset.vidplyAlertOriginalDisplay || '';
|
|
3038
|
+
element.style.display = config.hideDisplay || originalDisplay || 'none';
|
|
3039
|
+
element.setAttribute('aria-hidden', 'true');
|
|
3040
|
+
element.removeAttribute('data-vidply-alert-active');
|
|
3041
|
+
}
|
|
3042
|
+
|
|
3043
|
+
if (config.resume !== false && this.state.paused) {
|
|
3044
|
+
this.play();
|
|
3045
|
+
}
|
|
3046
|
+
|
|
3047
|
+
const focusTarget = config.focusTarget || 'playButton';
|
|
3048
|
+
this.setManagedTimeout(() => {
|
|
3049
|
+
this.focusMetadataTarget(focusTarget, element);
|
|
3050
|
+
}, config.focusDelay ?? 100);
|
|
3051
|
+
};
|
|
3052
|
+
|
|
3053
|
+
continueButton.addEventListener('click', handler);
|
|
3054
|
+
this.metadataAlertHandlers.set(selector, { button: continueButton, handler });
|
|
3055
|
+
}
|
|
3056
|
+
}
|
|
3057
|
+
|
|
3058
|
+
return element;
|
|
3059
|
+
}
|
|
3060
|
+
|
|
3061
|
+
handleMetadataHashtags(hashtags) {
|
|
3062
|
+
if (!Array.isArray(hashtags) || hashtags.length === 0) {
|
|
3063
|
+
return;
|
|
3064
|
+
}
|
|
3065
|
+
|
|
3066
|
+
const configMap = this.options.metadataHashtags;
|
|
3067
|
+
if (!configMap) {
|
|
3068
|
+
return;
|
|
3069
|
+
}
|
|
3070
|
+
|
|
3071
|
+
hashtags.forEach(tag => {
|
|
3072
|
+
const config = this.resolveMetadataConfig(configMap, tag);
|
|
3073
|
+
if (!config) {
|
|
3074
|
+
return;
|
|
3075
|
+
}
|
|
3076
|
+
|
|
3077
|
+
const selector = this.normalizeMetadataSelector(config.alert || config.selector || config.target);
|
|
3078
|
+
if (!selector) {
|
|
3079
|
+
return;
|
|
3080
|
+
}
|
|
3081
|
+
|
|
3082
|
+
const element = document.querySelector(selector);
|
|
3083
|
+
if (!element) {
|
|
3084
|
+
if (this.options.debug) {
|
|
3085
|
+
this.log('[Metadata] Hashtag target not found:', selector);
|
|
3086
|
+
}
|
|
3087
|
+
return;
|
|
3088
|
+
}
|
|
3089
|
+
|
|
3090
|
+
if (this.options.debug) {
|
|
3091
|
+
this.log('[Metadata] Handling hashtag', tag, { selector, config });
|
|
3092
|
+
}
|
|
3093
|
+
|
|
3094
|
+
this.cacheMetadataAlertContent(element, config);
|
|
3095
|
+
|
|
3096
|
+
if (config.title) {
|
|
3097
|
+
const titleSelector = config.titleSelector || '[data-vidply-alert-title], h3, header';
|
|
3098
|
+
const titleEl = element.querySelector(titleSelector);
|
|
3099
|
+
if (titleEl) {
|
|
3100
|
+
titleEl.textContent = config.title;
|
|
3101
|
+
}
|
|
3102
|
+
}
|
|
3103
|
+
|
|
3104
|
+
if (config.message) {
|
|
3105
|
+
const messageSelector = config.messageSelector || '[data-vidply-alert-message], p';
|
|
3106
|
+
const messageEl = element.querySelector(messageSelector);
|
|
3107
|
+
if (messageEl) {
|
|
3108
|
+
messageEl.textContent = config.message;
|
|
3109
|
+
}
|
|
3110
|
+
}
|
|
3111
|
+
|
|
3112
|
+
const show = config.show !== false;
|
|
3113
|
+
const focus = config.focus !== undefined ? config.focus : false;
|
|
3114
|
+
|
|
3115
|
+
this.handleMetadataAlert(selector, {
|
|
3116
|
+
element,
|
|
3117
|
+
show,
|
|
3118
|
+
focus,
|
|
3119
|
+
autoScroll: config.autoScroll,
|
|
3120
|
+
reason: 'hashtag'
|
|
3121
|
+
});
|
|
3122
|
+
});
|
|
3123
|
+
}
|
|
3124
|
+
|
|
3125
|
+
/**
|
|
3126
|
+
* Handle individual metadata cues
|
|
3127
|
+
* Parses metadata text and emits events or triggers actions
|
|
3128
|
+
*/
|
|
3129
|
+
handleMetadataCue(cue) {
|
|
3130
|
+
const text = cue.text.trim();
|
|
3131
|
+
|
|
3132
|
+
// Debug logging
|
|
3133
|
+
if (this.options.debug) {
|
|
3134
|
+
this.log('[Metadata] Processing cue:', {
|
|
3135
|
+
time: cue.startTime,
|
|
3136
|
+
text: text
|
|
3137
|
+
});
|
|
3138
|
+
}
|
|
3139
|
+
|
|
3140
|
+
// Emit a generic metadata event that developers can listen to
|
|
3141
|
+
this.emit('metadata', {
|
|
3142
|
+
time: cue.startTime,
|
|
3143
|
+
endTime: cue.endTime,
|
|
3144
|
+
text: text,
|
|
3145
|
+
cue: cue
|
|
3146
|
+
});
|
|
3147
|
+
|
|
3148
|
+
// Parse for specific commands (examples based on wwa_meta.vtt format)
|
|
3149
|
+
if (text.includes('PAUSE')) {
|
|
3150
|
+
// Automatically pause the video
|
|
3151
|
+
if (!this.state.paused) {
|
|
3152
|
+
if (this.options.debug) {
|
|
3153
|
+
this.log('[Metadata] Pausing video at', cue.startTime);
|
|
3154
|
+
}
|
|
3155
|
+
this.pause();
|
|
3156
|
+
}
|
|
3157
|
+
// Also emit event for developers who want to listen
|
|
3158
|
+
this.emit('metadata:pause', { time: cue.startTime, text: text });
|
|
3159
|
+
}
|
|
3160
|
+
|
|
3161
|
+
// Parse for focus directives
|
|
3162
|
+
const focusMatch = text.match(/FOCUS:([\w#-]+)/);
|
|
3163
|
+
if (focusMatch) {
|
|
3164
|
+
const targetSelector = focusMatch[1];
|
|
3165
|
+
const normalizedSelector = this.normalizeMetadataSelector(targetSelector);
|
|
3166
|
+
// Automatically focus the target element
|
|
3167
|
+
const targetElement = normalizedSelector ? document.querySelector(normalizedSelector) : null;
|
|
3168
|
+
if (targetElement) {
|
|
3169
|
+
if (this.options.debug) {
|
|
3170
|
+
this.log('[Metadata] Focusing element:', normalizedSelector);
|
|
3171
|
+
}
|
|
3172
|
+
// Make element focusable if it isn't already
|
|
3173
|
+
if (targetElement.tabIndex === -1 && !targetElement.hasAttribute('tabindex')) {
|
|
3174
|
+
targetElement.setAttribute('tabindex', '-1');
|
|
3175
|
+
}
|
|
3176
|
+
// Use setTimeout to ensure DOM is ready
|
|
3177
|
+
this.setManagedTimeout(() => {
|
|
3178
|
+
targetElement.focus();
|
|
3179
|
+
// Scroll element into view if needed
|
|
3180
|
+
targetElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
3181
|
+
}, 10);
|
|
3182
|
+
} else if (this.options.debug) {
|
|
3183
|
+
this.log('[Metadata] Element not found:', normalizedSelector || targetSelector);
|
|
3184
|
+
}
|
|
3185
|
+
// Also emit event for developers who want to listen
|
|
3186
|
+
this.emit('metadata:focus', {
|
|
3187
|
+
time: cue.startTime,
|
|
3188
|
+
target: targetSelector,
|
|
3189
|
+
selector: normalizedSelector,
|
|
3190
|
+
element: targetElement,
|
|
3191
|
+
text: text
|
|
3192
|
+
});
|
|
3193
|
+
|
|
3194
|
+
if (normalizedSelector) {
|
|
3195
|
+
this.handleMetadataAlert(normalizedSelector, {
|
|
3196
|
+
element: targetElement,
|
|
3197
|
+
reason: 'focus'
|
|
3198
|
+
});
|
|
3199
|
+
}
|
|
3200
|
+
}
|
|
3201
|
+
|
|
3202
|
+
// Parse for hashtag references
|
|
3203
|
+
const hashtags = text.match(/#[\w-]+/g);
|
|
3204
|
+
if (hashtags) {
|
|
3205
|
+
if (this.options.debug) {
|
|
3206
|
+
this.log('[Metadata] Hashtags found:', hashtags);
|
|
3207
|
+
}
|
|
3208
|
+
this.emit('metadata:hashtags', {
|
|
3209
|
+
time: cue.startTime,
|
|
3210
|
+
hashtags: hashtags,
|
|
3211
|
+
text: text
|
|
3212
|
+
});
|
|
3213
|
+
|
|
3214
|
+
this.handleMetadataHashtags(hashtags);
|
|
3215
|
+
}
|
|
3216
|
+
}
|
|
1612
3217
|
}
|
|
1613
3218
|
|
|
1614
3219
|
// Static instances tracker for pause others functionality
|