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.
@@ -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
- // Store original source for audio description toggling
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.element.querySelectorAll('track');
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 (!this.audioDescriptionSrc) {
809
- console.warn('VidPly: No audio description source provided');
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
- this.element.src = this.audioDescriptionSrc;
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
- // Switch back to original version
850
- this.element.src = this.originalSrc;
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 textTracks = Array.from(this.element.textTracks || []);
875
- const descriptionTrack = textTracks.find(track => track.kind === 'descriptions');
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 (this.audioDescriptionSrc) {
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(message, type = 'log') {
1422
- if (this.options.debug) {
1423
- console[type](`[VidPly]`, message);
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
- setTimeout(() => {
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