vidply 1.0.8 → 1.0.10

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.
@@ -16,6 +16,7 @@ import {HLSRenderer} from '../renderers/HLSRenderer.js';
16
16
  import {createPlayOverlay} from '../icons/Icons.js';
17
17
  import {i18n} from '../i18n/i18n.js';
18
18
  import {StorageManager} from '../utils/StorageManager.js';
19
+ import {DraggableResizable} from '../utils/DraggableResizable.js';
19
20
 
20
21
  export class Player extends EventEmitter {
21
22
  constructor(element, options = {}) {
@@ -143,6 +144,8 @@ export class Player extends EventEmitter {
143
144
  screenReaderAnnouncements: true,
144
145
  highContrast: false,
145
146
  focusHighlight: true,
147
+ metadataAlerts: {},
148
+ metadataHashtags: {},
146
149
 
147
150
  // Languages
148
151
  language: 'en',
@@ -166,6 +169,9 @@ export class Player extends EventEmitter {
166
169
  ...options
167
170
  };
168
171
 
172
+ this.options.metadataAlerts = this.options.metadataAlerts || {};
173
+ this.options.metadataHashtags = this.options.metadataHashtags || {};
174
+
169
175
  // Storage manager
170
176
  this.storage = new StorageManager('vidply');
171
177
 
@@ -209,6 +215,18 @@ export class Player extends EventEmitter {
209
215
  this.originalAudioDescriptionSource = null;
210
216
  // Store caption tracks that should be swapped for audio description
211
217
  this.audioDescriptionCaptionTracks = [];
218
+ this._audioDescriptionDesiredState = false;
219
+
220
+ // DOM query cache (for performance optimization)
221
+ this._textTracksCache = null;
222
+ this._textTracksDirty = true;
223
+ this._sourceElementsCache = null;
224
+ this._sourceElementsDirty = true;
225
+ this._trackElementsCache = null;
226
+ this._trackElementsDirty = true;
227
+
228
+ // Timeout management (for cleanup)
229
+ this.timeouts = new Set();
212
230
 
213
231
  // Components
214
232
  this.container = null;
@@ -217,6 +235,10 @@ export class Player extends EventEmitter {
217
235
  this.captionManager = null;
218
236
  this.keyboardManager = null;
219
237
  this.settingsDialog = null;
238
+
239
+ // Metadata handling
240
+ this.metadataCueChangeHandler = null;
241
+ this.metadataAlertHandlers = new Map();
220
242
 
221
243
  // Initialize
222
244
  this.init();
@@ -264,6 +286,9 @@ export class Player extends EventEmitter {
264
286
  if (this.options.transcript || this.options.transcriptButton) {
265
287
  this.transcriptManager = new TranscriptManager(this);
266
288
  }
289
+
290
+ // Always set up metadata track handling (independent of transcript)
291
+ this.setupMetadataHandling();
267
292
 
268
293
  // Initialize keyboard controls
269
294
  if (this.options.keyboard) {
@@ -373,7 +398,6 @@ export class Player extends EventEmitter {
373
398
  // This allows custom controls to work on iOS devices
374
399
  if (this.element.tagName === 'VIDEO' && this.options.playsInline) {
375
400
  this.element.setAttribute('playsinline', '');
376
- this.element.setAttribute('webkit-playsinline', ''); // For older iOS versions
377
401
  this.element.playsInline = true; // Property version
378
402
  }
379
403
 
@@ -399,6 +423,12 @@ export class Player extends EventEmitter {
399
423
  if (this.element.tagName === 'VIDEO') {
400
424
  this.createPlayButtonOverlay();
401
425
  }
426
+
427
+ // Store reference to player on element for easy access
428
+ this.element.vidply = this;
429
+
430
+ // Add to static instances array
431
+ Player.instances.push(this);
402
432
 
403
433
  // Make video/audio element clickable to toggle play/pause
404
434
  this.element.style.cursor = 'pointer';
@@ -408,6 +438,16 @@ export class Player extends EventEmitter {
408
438
  this.toggle();
409
439
  }
410
440
  });
441
+
442
+ this.on('play', () => {
443
+ this.hidePosterOverlay();
444
+ });
445
+
446
+ this.on('timeupdate', () => {
447
+ if (this.state.currentTime > 0) {
448
+ this.hidePosterOverlay();
449
+ }
450
+ });
411
451
  }
412
452
 
413
453
  createPlayButtonOverlay() {
@@ -447,7 +487,7 @@ export class Player extends EventEmitter {
447
487
  }
448
488
 
449
489
  // Check for source elements with audio description attributes
450
- const sourceElements = this.element.querySelectorAll('source');
490
+ const sourceElements = this.sourceElements;
451
491
  for (const sourceEl of sourceElements) {
452
492
  const descSrc = sourceEl.getAttribute('data-desc-src');
453
493
  const origSrc = sourceEl.getAttribute('data-orig-src');
@@ -490,7 +530,7 @@ export class Player extends EventEmitter {
490
530
  // Check for caption/subtitle tracks with audio description versions
491
531
  // Only tracks with explicit data-desc-src attribute are swapped (no auto-detection to avoid 404 errors)
492
532
  // Description tracks (kind="descriptions") are NOT swapped - they're for transcripts
493
- const trackElements = this.element.querySelectorAll('track');
533
+ const trackElements = this.trackElements;
494
534
  trackElements.forEach(trackEl => {
495
535
  const trackKind = trackEl.getAttribute('kind');
496
536
  const trackDescSrc = trackEl.getAttribute('data-desc-src');
@@ -537,6 +577,144 @@ export class Player extends EventEmitter {
537
577
  this.log(`Using ${renderer.name} renderer`);
538
578
  this.renderer = new renderer(this);
539
579
  await this.renderer.init();
580
+
581
+ // Invalidate cache after renderer initialization (tracks may have changed)
582
+ this.invalidateTrackCache();
583
+ }
584
+
585
+ /**
586
+ * Get cached text tracks array
587
+ * @returns {Array} Array of text tracks
588
+ */
589
+ get textTracks() {
590
+ if (!this._textTracksCache || this._textTracksDirty) {
591
+ this._textTracksCache = Array.from(this.element.textTracks || []);
592
+ this._textTracksDirty = false;
593
+ }
594
+ return this._textTracksCache;
595
+ }
596
+
597
+ /**
598
+ * Get cached source elements array
599
+ * @returns {Array} Array of source elements
600
+ */
601
+ get sourceElements() {
602
+ if (!this._sourceElementsCache || this._sourceElementsDirty) {
603
+ this._sourceElementsCache = Array.from(this.element.querySelectorAll('source'));
604
+ this._sourceElementsDirty = false;
605
+ }
606
+ return this._sourceElementsCache;
607
+ }
608
+
609
+ /**
610
+ * Get cached track elements array
611
+ * @returns {Array} Array of track elements
612
+ */
613
+ get trackElements() {
614
+ if (!this._trackElementsCache || this._trackElementsDirty) {
615
+ this._trackElementsCache = Array.from(this.element.querySelectorAll('track'));
616
+ this._trackElementsDirty = false;
617
+ }
618
+ return this._trackElementsCache;
619
+ }
620
+
621
+ /**
622
+ * Invalidate DOM query cache (call when tracks/sources change)
623
+ */
624
+ invalidateTrackCache() {
625
+ this._textTracksDirty = true;
626
+ this._trackElementsDirty = true;
627
+ this._sourceElementsDirty = true;
628
+ }
629
+
630
+ /**
631
+ * Find a text track by kind and optionally language
632
+ * @param {string} kind - Track kind (captions, subtitles, descriptions, chapters, metadata)
633
+ * @param {string} [language] - Optional language code
634
+ * @returns {TextTrack|null} Found track or null
635
+ */
636
+ findTextTrack(kind, language = null) {
637
+ const tracks = this.textTracks;
638
+ if (language) {
639
+ return tracks.find(t => t.kind === kind && t.language === language);
640
+ }
641
+ return tracks.find(t => t.kind === kind);
642
+ }
643
+
644
+ /**
645
+ * Find a source element by attribute
646
+ * @param {string} attribute - Attribute name (e.g., 'data-desc-src')
647
+ * @param {string} [value] - Optional attribute value
648
+ * @returns {Element|null} Found source element or null
649
+ */
650
+ findSourceElement(attribute, value = null) {
651
+ const sources = this.sourceElements;
652
+ if (value) {
653
+ return sources.find(el => el.getAttribute(attribute) === value);
654
+ }
655
+ return sources.find(el => el.hasAttribute(attribute));
656
+ }
657
+
658
+ /**
659
+ * Find a track element by its associated TextTrack
660
+ * @param {TextTrack} track - The TextTrack object
661
+ * @returns {Element|null} Found track element or null
662
+ */
663
+ findTrackElement(track) {
664
+ return this.trackElements.find(el => el.track === track);
665
+ }
666
+
667
+ showPosterOverlay() {
668
+ if (!this.videoWrapper || this.element.tagName !== 'VIDEO') {
669
+ return;
670
+ }
671
+
672
+ const poster =
673
+ this.element.getAttribute('poster') ||
674
+ this.element.poster ||
675
+ this.options.poster;
676
+
677
+ if (!poster) {
678
+ return;
679
+ }
680
+
681
+ this.videoWrapper.style.setProperty('--vidply-poster-image', `url("${poster}")`);
682
+ this.videoWrapper.classList.add('vidply-forced-poster');
683
+ }
684
+
685
+ hidePosterOverlay() {
686
+ if (!this.videoWrapper) {
687
+ return;
688
+ }
689
+
690
+ this.videoWrapper.classList.remove('vidply-forced-poster');
691
+ this.videoWrapper.style.removeProperty('--vidply-poster-image');
692
+ }
693
+
694
+ /**
695
+ * Set a managed timeout that will be cleaned up on destroy
696
+ * @param {Function} callback - Callback function
697
+ * @param {number} delay - Delay in milliseconds
698
+ * @returns {number} Timeout ID
699
+ */
700
+ setManagedTimeout(callback, delay) {
701
+ const timeoutId = setTimeout(() => {
702
+ this.timeouts.delete(timeoutId);
703
+ callback();
704
+ }, delay);
705
+ this.timeouts.add(timeoutId);
706
+ return timeoutId;
707
+ }
708
+
709
+ /**
710
+ * Clear a managed timeout
711
+ * @param {number} timeoutId - Timeout ID to clear
712
+ */
713
+ clearManagedTimeout(timeoutId) {
714
+ if (timeoutId) {
715
+ clearTimeout(timeoutId);
716
+ this.timeouts.delete(timeoutId);
717
+ }
540
718
  }
541
719
 
542
720
  /**
@@ -557,8 +735,9 @@ export class Player extends EventEmitter {
557
735
  }
558
736
 
559
737
  // Clear existing text tracks
560
- const existingTracks = this.element.querySelectorAll('track');
738
+ const existingTracks = this.trackElements;
561
739
  existingTracks.forEach(track => track.remove());
740
+ this.invalidateTrackCache();
562
741
 
563
742
  // Update media element
564
743
  this.element.src = config.src;
@@ -586,6 +765,7 @@ export class Player extends EventEmitter {
586
765
 
587
766
  this.element.appendChild(track);
588
767
  });
768
+ this.invalidateTrackCache();
589
769
  }
590
770
 
591
771
  // Check if we need to change renderer type
@@ -895,7 +1075,7 @@ export class Player extends EventEmitter {
895
1075
  // Audio Description
896
1076
  async enableAudioDescription() {
897
1077
  // Check if we have source elements with data-desc-src (even if audioDescriptionSrc is not set)
898
- const hasSourceElementsWithDesc = Array.from(this.element.querySelectorAll('source')).some(el => el.getAttribute('data-desc-src'));
1078
+ const hasSourceElementsWithDesc = this.sourceElements.some(el => el.getAttribute('data-desc-src'));
899
1079
  const hasTracksWithDesc = this.audioDescriptionCaptionTracks.length > 0;
900
1080
 
901
1081
  if (!this.audioDescriptionSrc && !hasSourceElementsWithDesc && !hasTracksWithDesc) {
@@ -906,6 +1086,11 @@ export class Player extends EventEmitter {
906
1086
  // Store current playback state
907
1087
  const currentTime = this.state.currentTime;
908
1088
  const wasPlaying = this.state.playing;
1089
+ const shouldKeepPoster = !wasPlaying && currentTime === 0;
1090
+
1091
+ if (shouldKeepPoster) {
1092
+ this.showPosterOverlay();
1093
+ }
909
1094
 
910
1095
  // Store swapped tracks for transcript reload (declare at function scope)
911
1096
  let swappedTracksForTranscript = [];
@@ -916,7 +1101,7 @@ export class Player extends EventEmitter {
916
1101
  const currentSrc = this.element.currentSrc || this.element.src;
917
1102
 
918
1103
  // Find the source element that matches the currently active source
919
- const sourceElements = Array.from(this.element.querySelectorAll('source'));
1104
+ const sourceElements = this.sourceElements;
920
1105
  let sourceElementToUpdate = null;
921
1106
  let descSrc = this.audioDescriptionSrc;
922
1107
 
@@ -1060,11 +1245,12 @@ export class Player extends EventEmitter {
1060
1245
  // After all new tracks are added, force browser to reload media element again
1061
1246
  // This ensures new track elements are processed and new TextTrack objects are created
1062
1247
  this.element.load();
1248
+ this.invalidateTrackCache();
1063
1249
 
1064
1250
  // Wait for loadedmetadata event before accessing new TextTrack objects
1065
1251
  const setupNewTracks = () => {
1066
1252
  // Wait a bit more for browser to fully process the new track elements
1067
- setTimeout(() => {
1253
+ this.setManagedTimeout(() => {
1068
1254
  swappedTracksForTranscript.forEach((trackInfo) => {
1069
1255
  const trackElement = trackInfo.trackElement;
1070
1256
  const newTextTrack = trackElement.track;
@@ -1119,7 +1305,7 @@ export class Player extends EventEmitter {
1119
1305
  // Update all source elements that have data-desc-src to their described versions
1120
1306
  // Force browser to pick up changes by removing and re-adding source elements
1121
1307
  // Get source elements (may have been defined in if block above, but get fresh list here)
1122
- const allSourceElements = Array.from(this.element.querySelectorAll('source'));
1308
+ const allSourceElements = this.sourceElements;
1123
1309
  const sourcesToUpdate = [];
1124
1310
 
1125
1311
  allSourceElements.forEach((sourceEl) => {
@@ -1173,8 +1359,17 @@ export class Player extends EventEmitter {
1173
1359
  if (sourceInfo.descSrc) {
1174
1360
  newSource.setAttribute('data-desc-src', sourceInfo.descSrc);
1175
1361
  }
1176
- this.element.appendChild(newSource);
1362
+ const firstTrack = this.element.querySelector('track');
1363
+ if (firstTrack) {
1364
+ this.element.insertBefore(newSource, firstTrack);
1365
+ } else {
1366
+ this.element.appendChild(newSource);
1367
+ }
1177
1368
  });
1369
+
1370
+ // Ensure cached source references are refreshed after rebuilding the list
1371
+ this._sourceElementsDirty = true;
1372
+ this._sourceElementsCache = null;
1178
1373
 
1179
1374
  // Force reload by calling load() on the element
1180
1375
  // This should pick up the new src attributes from the re-added source elements
@@ -1193,28 +1388,23 @@ export class Player extends EventEmitter {
1193
1388
  // Wait a bit more for tracks to be recognized and loaded after video metadata loads
1194
1389
  await new Promise(resolve => setTimeout(resolve, 300));
1195
1390
 
1196
- // Hide poster if video hasn't started yet (poster should hide when we seek or play)
1197
- if (this.element.tagName === 'VIDEO' && currentTime === 0 && !wasPlaying) {
1198
- // Force poster to hide by doing a minimal seek or loading first frame
1199
- // Setting readyState check or seeking to 0.001 seconds will hide the poster
1200
- if (this.element.readyState >= 1) { // HAVE_METADATA
1201
- // Seek to a tiny fraction to trigger poster hiding without actually moving
1202
- this.element.currentTime = 0.001;
1203
- // Then seek back to 0 after a brief moment to ensure poster stays hidden
1204
- setTimeout(() => {
1205
- this.element.currentTime = 0;
1206
- }, 10);
1207
- }
1391
+ // Restore playback position (avoid forcing first frame if still at start)
1392
+ if (currentTime > 0) {
1393
+ this.seek(currentTime);
1208
1394
  }
1209
1395
 
1210
- // Restore playback position
1211
- this.seek(currentTime);
1212
-
1213
1396
  if (wasPlaying) {
1214
1397
  this.play();
1215
1398
  }
1216
1399
 
1400
+ if (!shouldKeepPoster) {
1401
+ this.hidePosterOverlay();
1402
+ }
1403
+
1217
1404
  // Update state and emit event
1405
+ if (!this._audioDescriptionDesiredState) {
1406
+ return;
1407
+ }
1218
1408
  this.state.audioDescriptionEnabled = true;
1219
1409
  this.emit('audiodescriptionenabled');
1220
1410
  } else {
@@ -1374,7 +1564,7 @@ export class Player extends EventEmitter {
1374
1564
  }
1375
1565
 
1376
1566
  // Check if we have source elements with data-desc-src (fallback method)
1377
- const fallbackSourceElements = Array.from(this.element.querySelectorAll('source'));
1567
+ const fallbackSourceElements = this.sourceElements;
1378
1568
  const hasSourceElementsWithDesc = fallbackSourceElements.some(el => el.getAttribute('data-desc-src'));
1379
1569
 
1380
1570
  if (hasSourceElementsWithDesc) {
@@ -1433,6 +1623,7 @@ export class Player extends EventEmitter {
1433
1623
 
1434
1624
  // Force reload
1435
1625
  this.element.load();
1626
+ this.invalidateTrackCache();
1436
1627
  } else {
1437
1628
  // Fallback to updating element src directly (for videos without source elements)
1438
1629
  this.element.src = this.audioDescriptionSrc;
@@ -1456,14 +1647,16 @@ export class Player extends EventEmitter {
1456
1647
  // Seek to a tiny fraction to trigger poster hiding without actually moving
1457
1648
  this.element.currentTime = 0.001;
1458
1649
  // Then seek back to 0 after a brief moment to ensure poster stays hidden
1459
- setTimeout(() => {
1650
+ this.setManagedTimeout(() => {
1460
1651
  this.element.currentTime = 0;
1461
1652
  }, 10);
1462
1653
  }
1463
1654
  }
1464
1655
 
1465
- // Restore playback position
1466
- this.seek(currentTime);
1656
+ // Restore playback position (avoid forcing first frame if still at start)
1657
+ if (currentTime > 0) {
1658
+ this.seek(currentTime);
1659
+ }
1467
1660
 
1468
1661
  if (wasPlaying) {
1469
1662
  this.play();
@@ -1521,7 +1714,9 @@ export class Player extends EventEmitter {
1521
1714
  const onMetadataLoaded = () => {
1522
1715
  // Get fresh track references from the video element's textTracks collection
1523
1716
  // This ensures we get the actual textTrack objects that the browser created
1524
- const allTextTracks = Array.from(this.element.textTracks);
1717
+ // Invalidate cache first to get fresh tracks after swap
1718
+ this.invalidateTrackCache();
1719
+ const allTextTracks = this.textTracks;
1525
1720
 
1526
1721
  // Find the tracks that match our swapped tracks by language and kind
1527
1722
  // Match by checking the track element's src attribute
@@ -1541,9 +1736,7 @@ export class Player extends EventEmitter {
1541
1736
  if (track.language === srclang &&
1542
1737
  (track.kind === kind || (kind === 'captions' && track.kind === 'subtitles'))) {
1543
1738
  // Verify the src matches
1544
- const trackElementForTrack = Array.from(this.element.querySelectorAll('track')).find(
1545
- el => el.track === track
1546
- );
1739
+ const trackElementForTrack = this.findTrackElement(track);
1547
1740
  if (trackElementForTrack) {
1548
1741
  const actualSrc = trackElementForTrack.getAttribute('src');
1549
1742
  if (actualSrc === expectedSrc) {
@@ -1557,9 +1750,7 @@ export class Player extends EventEmitter {
1557
1750
 
1558
1751
  // Verify the track element's src matches what we expect
1559
1752
  if (foundTrack) {
1560
- const trackElement = Array.from(this.element.querySelectorAll('track')).find(
1561
- el => el.track === foundTrack
1562
- );
1753
+ const trackElement = this.findTrackElement(foundTrack);
1563
1754
  if (trackElement && trackElement.getAttribute('src') !== expectedSrc) {
1564
1755
  return null;
1565
1756
  }
@@ -1570,7 +1761,7 @@ export class Player extends EventEmitter {
1570
1761
 
1571
1762
  if (freshTracks.length === 0) {
1572
1763
  // Fallback: just reload after delay - transcript manager will find tracks itself
1573
- setTimeout(() => {
1764
+ this.setManagedTimeout(() => {
1574
1765
  if (this.transcriptManager && this.transcriptManager.loadTranscriptData) {
1575
1766
  this.transcriptManager.loadTranscriptData();
1576
1767
  }
@@ -1591,7 +1782,7 @@ export class Player extends EventEmitter {
1591
1782
  if (loadedCount >= freshTracks.length) {
1592
1783
  // Give a bit more time for cues to be fully parsed
1593
1784
  // Also ensure we're getting the latest TextTrack references
1594
- setTimeout(() => {
1785
+ this.setManagedTimeout(() => {
1595
1786
  if (this.transcriptManager && this.transcriptManager.loadTranscriptData) {
1596
1787
  // Force transcript manager to get fresh track references
1597
1788
  // Clear any cached track references by forcing a fresh read
@@ -1599,12 +1790,11 @@ export class Player extends EventEmitter {
1599
1790
  // which should now have the new TextTrack objects with the described captions
1600
1791
 
1601
1792
  // Verify the tracks have the correct src before reloading transcript
1602
- const allTextTracks = Array.from(this.element.textTracks);
1793
+ this.invalidateTrackCache();
1794
+ const allTextTracks = this.textTracks;
1603
1795
  const swappedTrackSrcs = swappedTracks.map(t => t.describedSrc);
1604
1796
  const hasCorrectTracks = freshTracks.some(track => {
1605
- const trackEl = Array.from(this.element.querySelectorAll('track')).find(
1606
- el => el.track === track
1607
- );
1797
+ const trackEl = this.findTrackElement(track);
1608
1798
  return trackEl && swappedTrackSrcs.includes(trackEl.getAttribute('src'));
1609
1799
  });
1610
1800
 
@@ -1624,9 +1814,7 @@ export class Player extends EventEmitter {
1624
1814
 
1625
1815
  // Check if track has cues loaded
1626
1816
  // Verify the track element's src matches the expected described src
1627
- const trackElementForTrack = Array.from(this.element.querySelectorAll('track')).find(
1628
- el => el.track === track
1629
- );
1817
+ const trackElementForTrack = this.findTrackElement(track);
1630
1818
  const actualSrc = trackElementForTrack ? trackElementForTrack.getAttribute('src') : null;
1631
1819
 
1632
1820
  // Find the expected src from swappedTracks
@@ -1657,13 +1845,13 @@ export class Player extends EventEmitter {
1657
1845
  // Wait for track to load
1658
1846
  const onTrackLoad = () => {
1659
1847
  // Wait a bit for cues to be fully parsed
1660
- setTimeout(checkLoaded, 300);
1848
+ this.setManagedTimeout(checkLoaded, 300);
1661
1849
  };
1662
1850
 
1663
1851
  if (track.readyState >= 2) {
1664
1852
  // Already loaded, but might not have cues yet
1665
1853
  // Wait a bit and check again
1666
- setTimeout(() => {
1854
+ this.setManagedTimeout(() => {
1667
1855
  if (track.cues && track.cues.length > 0) {
1668
1856
  checkLoaded();
1669
1857
  } else {
@@ -1685,16 +1873,16 @@ export class Player extends EventEmitter {
1685
1873
  // Wait for loadedmetadata event which fires when browser processes track elements
1686
1874
  // Also wait for the tracks to be fully processed after the second load()
1687
1875
  const waitForTracks = () => {
1688
- // Wait a bit more to ensure new TextTrack objects are created
1689
- setTimeout(() => {
1690
- if (this.element.readyState >= 1) { // HAVE_METADATA
1691
- onMetadataLoaded();
1692
- } else {
1693
- this.element.addEventListener('loadedmetadata', onMetadataLoaded, { once: true });
1694
- // Fallback timeout
1695
- setTimeout(onMetadataLoaded, 2000);
1696
- }
1697
- }, 500); // Wait 500ms after second load() for tracks to be processed
1876
+ // Wait a bit more to ensure new TextTrack objects are created
1877
+ this.setManagedTimeout(() => {
1878
+ if (this.element.readyState >= 1) { // HAVE_METADATA
1879
+ onMetadataLoaded();
1880
+ } else {
1881
+ this.element.addEventListener('loadedmetadata', onMetadataLoaded, { once: true });
1882
+ // Fallback timeout
1883
+ this.setManagedTimeout(onMetadataLoaded, 2000);
1884
+ }
1885
+ }, 500); // Wait 500ms after second load() for tracks to be processed
1698
1886
  };
1699
1887
 
1700
1888
  waitForTracks();
@@ -1715,6 +1903,14 @@ export class Player extends EventEmitter {
1715
1903
  }
1716
1904
  }
1717
1905
 
1906
+ if (!shouldKeepPoster) {
1907
+ this.hidePosterOverlay();
1908
+ }
1909
+
1910
+ if (!this._audioDescriptionDesiredState) {
1911
+ return;
1912
+ }
1913
+
1718
1914
  this.state.audioDescriptionEnabled = true;
1719
1915
  this.emit('audiodescriptionenabled');
1720
1916
  }
@@ -1739,7 +1935,7 @@ export class Player extends EventEmitter {
1739
1935
 
1740
1936
  // Swap source elements back to original versions
1741
1937
  // Check if we have source elements with data-orig-src
1742
- const allSourceElements = Array.from(this.element.querySelectorAll('source'));
1938
+ const allSourceElements = this.sourceElements;
1743
1939
  const hasSourceElementsToSwap = allSourceElements.some(el => el.getAttribute('data-orig-src'));
1744
1940
 
1745
1941
  if (hasSourceElementsToSwap) {
@@ -1789,8 +1985,17 @@ export class Player extends EventEmitter {
1789
1985
  if (sourceInfo.descSrc) {
1790
1986
  newSource.setAttribute('data-desc-src', sourceInfo.descSrc);
1791
1987
  }
1792
- this.element.appendChild(newSource);
1988
+ const firstTrack = this.element.querySelector('track');
1989
+ if (firstTrack) {
1990
+ this.element.insertBefore(newSource, firstTrack);
1991
+ } else {
1992
+ this.element.appendChild(newSource);
1993
+ }
1793
1994
  });
1995
+
1996
+ // Ensure cached source references are refreshed after rebuilding the list
1997
+ this._sourceElementsDirty = true;
1998
+ this._sourceElementsCache = null;
1794
1999
 
1795
2000
  // Force reload
1796
2001
  this.element.load();
@@ -1801,31 +2006,44 @@ export class Player extends EventEmitter {
1801
2006
  this.element.load();
1802
2007
  }
1803
2008
 
1804
- // Wait for new source to load
1805
- await new Promise((resolve) => {
1806
- const onLoadedMetadata = () => {
1807
- this.element.removeEventListener('loadedmetadata', onLoadedMetadata);
1808
- resolve();
1809
- };
1810
- this.element.addEventListener('loadedmetadata', onLoadedMetadata);
1811
- });
2009
+ if (currentTime > 0 || wasPlaying) {
2010
+ // Wait for new source to load
2011
+ await new Promise((resolve) => {
2012
+ const onLoadedMetadata = () => {
2013
+ this.element.removeEventListener('loadedmetadata', onLoadedMetadata);
2014
+ resolve();
2015
+ };
2016
+ this.element.addEventListener('loadedmetadata', onLoadedMetadata);
2017
+ });
1812
2018
 
1813
- // Restore playback position
1814
- this.seek(currentTime);
2019
+ if (currentTime > 0) {
2020
+ this.seek(currentTime);
2021
+ }
1815
2022
 
1816
- if (wasPlaying) {
1817
- this.play();
2023
+ if (wasPlaying) {
2024
+ this.play();
2025
+ }
1818
2026
  }
1819
2027
 
1820
- // Reload transcript if visible (after video metadata loaded, tracks should be available)
1821
- // Reload regardless of whether caption tracks were swapped, in case tracks changed
1822
- if (this.transcriptManager && this.transcriptManager.isVisible) {
1823
- // Wait for tracks to load after source swap
1824
- setTimeout(() => {
1825
- if (this.transcriptManager && this.transcriptManager.loadTranscriptData) {
1826
- this.transcriptManager.loadTranscriptData();
1827
- }
1828
- }, 500);
2028
+ if (!wasPlaying && currentTime === 0) {
2029
+ this.showPosterOverlay();
2030
+ } else {
2031
+ this.hidePosterOverlay();
2032
+ }
2033
+
2034
+ // Reload transcript if visible (after video metadata loaded, tracks should be available)
2035
+ // Reload regardless of whether caption tracks were swapped, in case tracks changed
2036
+ if (this.transcriptManager && this.transcriptManager.isVisible) {
2037
+ // Wait for tracks to load after source swap
2038
+ this.setManagedTimeout(() => {
2039
+ if (this.transcriptManager && this.transcriptManager.loadTranscriptData) {
2040
+ this.transcriptManager.loadTranscriptData();
2041
+ }
2042
+ }, 500);
2043
+ }
2044
+
2045
+ if (this._audioDescriptionDesiredState) {
2046
+ return;
1829
2047
  }
1830
2048
 
1831
2049
  this.state.audioDescriptionEnabled = false;
@@ -1834,32 +2052,63 @@ export class Player extends EventEmitter {
1834
2052
 
1835
2053
  async toggleAudioDescription() {
1836
2054
  // Check if we have description tracks or audio-described video
1837
- const textTracks = Array.from(this.element.textTracks || []);
1838
- const descriptionTrack = textTracks.find(track => track.kind === 'descriptions');
2055
+ const descriptionTrack = this.findTextTrack('descriptions');
1839
2056
 
1840
2057
  // Check if we have audio-described video source (either from options or source elements with data-desc-src)
1841
2058
  const hasAudioDescriptionSrc = this.audioDescriptionSrc ||
1842
- Array.from(this.element.querySelectorAll('source')).some(el => el.getAttribute('data-desc-src'));
2059
+ this.sourceElements.some(el => el.getAttribute('data-desc-src'));
1843
2060
 
1844
2061
  if (descriptionTrack && hasAudioDescriptionSrc) {
1845
2062
  // We have both: toggle description track AND swap caption tracks/sources
1846
2063
  if (this.state.audioDescriptionEnabled) {
2064
+ this._audioDescriptionDesiredState = false;
1847
2065
  // Disable: toggle description track off and swap captions/sources back
1848
2066
  descriptionTrack.mode = 'hidden';
1849
2067
  await this.disableAudioDescription();
1850
2068
  } else {
2069
+ this._audioDescriptionDesiredState = true;
1851
2070
  // Enable: swap caption tracks/sources and toggle description track on
1852
2071
  await this.enableAudioDescription();
1853
- descriptionTrack.mode = 'showing';
2072
+ // Wait for tracks to be ready after source swap, then enable description track
2073
+ // Use a longer timeout to ensure tracks are loaded after source swap
2074
+ const enableDescriptionTrack = () => {
2075
+ this.invalidateTrackCache();
2076
+ const descTrack = this.findTextTrack('descriptions');
2077
+ if (descTrack) {
2078
+ // Set to 'hidden' first if it's in 'disabled' mode, then to 'showing'
2079
+ if (descTrack.mode === 'disabled') {
2080
+ descTrack.mode = 'hidden';
2081
+ // Use setTimeout to ensure the browser processes the mode change
2082
+ this.setManagedTimeout(() => {
2083
+ descTrack.mode = 'showing';
2084
+ }, 50);
2085
+ } else {
2086
+ descTrack.mode = 'showing';
2087
+ }
2088
+ } else if (this.element.readyState < 2) {
2089
+ // Tracks not ready yet, wait a bit more
2090
+ this.setManagedTimeout(enableDescriptionTrack, 100);
2091
+ }
2092
+ };
2093
+ // Wait for metadata to load first
2094
+ if (this.element.readyState >= 1) {
2095
+ this.setManagedTimeout(enableDescriptionTrack, 200);
2096
+ } else {
2097
+ this.element.addEventListener('loadedmetadata', () => {
2098
+ this.setManagedTimeout(enableDescriptionTrack, 200);
2099
+ }, { once: true });
2100
+ }
1854
2101
  }
1855
2102
  } else if (descriptionTrack) {
1856
2103
  // Only description track, no audio-described video source to swap
1857
2104
  // Toggle description track
1858
2105
  if (descriptionTrack.mode === 'showing') {
2106
+ this._audioDescriptionDesiredState = false;
1859
2107
  descriptionTrack.mode = 'hidden';
1860
2108
  this.state.audioDescriptionEnabled = false;
1861
2109
  this.emit('audiodescriptiondisabled');
1862
2110
  } else {
2111
+ this._audioDescriptionDesiredState = true;
1863
2112
  descriptionTrack.mode = 'showing';
1864
2113
  this.state.audioDescriptionEnabled = true;
1865
2114
  this.emit('audiodescriptionenabled');
@@ -1867,8 +2116,10 @@ export class Player extends EventEmitter {
1867
2116
  } else if (hasAudioDescriptionSrc) {
1868
2117
  // Use audio-described video source (no description track)
1869
2118
  if (this.state.audioDescriptionEnabled) {
2119
+ this._audioDescriptionDesiredState = false;
1870
2120
  await this.disableAudioDescription();
1871
2121
  } else {
2122
+ this._audioDescriptionDesiredState = true;
1872
2123
  await this.enableAudioDescription();
1873
2124
  }
1874
2125
  }
@@ -1998,235 +2249,27 @@ export class Player extends EventEmitter {
1998
2249
  setupSignLanguageInteraction() {
1999
2250
  if (!this.signLanguageWrapper) return;
2000
2251
 
2001
- let isDragging = false;
2002
- let isResizing = false;
2003
- let resizeDirection = null;
2004
- let startX = 0;
2005
- let startY = 0;
2006
- let startLeft = 0;
2007
- let startTop = 0;
2008
- let startWidth = 0;
2009
- let startHeight = 0;
2010
- let dragMode = false;
2011
- let resizeMode = false;
2012
-
2013
- // Mouse drag on video element
2014
- const onMouseDownVideo = (e) => {
2015
- if (e.target !== this.signLanguageVideo) return;
2016
- e.preventDefault();
2017
- isDragging = true;
2018
- startX = e.clientX;
2019
- startY = e.clientY;
2020
- const rect = this.signLanguageWrapper.getBoundingClientRect();
2021
- startLeft = rect.left;
2022
- startTop = rect.top;
2023
- this.signLanguageWrapper.classList.add('vidply-sign-dragging');
2024
- };
2025
-
2026
- // Mouse resize on handles
2027
- const onMouseDownHandle = (e) => {
2028
- if (!e.target.classList.contains('vidply-sign-resize-handle')) return;
2029
- e.preventDefault();
2030
- e.stopPropagation();
2031
- isResizing = true;
2032
- resizeDirection = e.target.getAttribute('data-direction');
2033
- startX = e.clientX;
2034
- startY = e.clientY;
2035
- const rect = this.signLanguageWrapper.getBoundingClientRect();
2036
- startLeft = rect.left;
2037
- startTop = rect.top;
2038
- startWidth = rect.width;
2039
- startHeight = rect.height;
2040
- this.signLanguageWrapper.classList.add('vidply-sign-resizing');
2041
- };
2042
-
2043
- const onMouseMove = (e) => {
2044
- if (isDragging) {
2045
- const deltaX = e.clientX - startX;
2046
- const deltaY = e.clientY - startY;
2047
-
2048
- // Get videoWrapper and container dimensions
2049
- const videoWrapperRect = this.videoWrapper.getBoundingClientRect();
2050
- const containerRect = this.container.getBoundingClientRect();
2051
- const wrapperRect = this.signLanguageWrapper.getBoundingClientRect();
2052
-
2053
- // Calculate videoWrapper position relative to container
2054
- const videoWrapperLeft = videoWrapperRect.left - containerRect.left;
2055
- const videoWrapperTop = videoWrapperRect.top - containerRect.top;
2056
-
2057
- // Calculate new position (in client coordinates)
2058
- let newLeft = startLeft + deltaX - containerRect.left;
2059
- let newTop = startTop + deltaY - containerRect.top;
2060
-
2061
- const controlsHeight = 95; // Height of controls when visible
2062
-
2063
- // Constrain to videoWrapper bounds (ensuring it stays above controls)
2064
- newLeft = Math.max(videoWrapperLeft, Math.min(newLeft, videoWrapperLeft + videoWrapperRect.width - wrapperRect.width));
2065
- newTop = Math.max(videoWrapperTop, Math.min(newTop, videoWrapperTop + videoWrapperRect.height - wrapperRect.height - controlsHeight));
2066
-
2067
- this.signLanguageWrapper.style.left = `${newLeft}px`;
2068
- this.signLanguageWrapper.style.top = `${newTop}px`;
2069
- this.signLanguageWrapper.style.right = 'auto';
2070
- this.signLanguageWrapper.style.bottom = 'auto';
2071
- // Remove position classes
2072
- this.signLanguageWrapper.classList.remove(...Array.from(this.signLanguageWrapper.classList).filter(c => c.startsWith('vidply-sign-position-')));
2073
- } else if (isResizing) {
2074
- const deltaX = e.clientX - startX;
2075
-
2076
- // Get videoWrapper and container dimensions
2077
- const videoWrapperRect = this.videoWrapper.getBoundingClientRect();
2078
- const containerRect = this.container.getBoundingClientRect();
2079
-
2080
- let newWidth = startWidth;
2081
- let newLeft = startLeft - containerRect.left;
2082
-
2083
- // Only resize width, let height auto-adjust to maintain aspect ratio
2084
- if (resizeDirection.includes('e')) {
2085
- newWidth = Math.max(150, startWidth + deltaX);
2086
- // Constrain width to not exceed videoWrapper right edge
2087
- const maxWidth = (videoWrapperRect.right - startLeft);
2088
- newWidth = Math.min(newWidth, maxWidth);
2089
- }
2090
- if (resizeDirection.includes('w')) {
2091
- const proposedWidth = Math.max(150, startWidth - deltaX);
2092
- const proposedLeft = startLeft + (startWidth - proposedWidth) - containerRect.left;
2093
- // Constrain to not go beyond videoWrapper left edge
2094
- const videoWrapperLeft = videoWrapperRect.left - containerRect.left;
2095
- if (proposedLeft >= videoWrapperLeft) {
2096
- newWidth = proposedWidth;
2097
- newLeft = proposedLeft;
2098
- }
2099
- }
2100
-
2101
- this.signLanguageWrapper.style.width = `${newWidth}px`;
2102
- this.signLanguageWrapper.style.height = 'auto'; // Let video maintain aspect ratio
2103
- if (resizeDirection.includes('w')) {
2104
- this.signLanguageWrapper.style.left = `${newLeft}px`;
2105
- }
2106
- this.signLanguageWrapper.style.right = 'auto';
2107
- this.signLanguageWrapper.style.bottom = 'auto';
2108
- // Remove position classes
2109
- this.signLanguageWrapper.classList.remove(...Array.from(this.signLanguageWrapper.classList).filter(c => c.startsWith('vidply-sign-position-')));
2110
- }
2111
- };
2112
-
2113
- const onMouseUp = () => {
2114
- if (isDragging || isResizing) {
2115
- this.saveSignLanguagePreferences();
2116
- }
2117
- isDragging = false;
2118
- isResizing = false;
2119
- resizeDirection = null;
2120
- this.signLanguageWrapper.classList.remove('vidply-sign-dragging', 'vidply-sign-resizing');
2121
- };
2122
-
2123
- // Keyboard controls
2124
- const onKeyDown = (e) => {
2125
- // Toggle drag mode with D key
2126
- if (e.key === 'd' || e.key === 'D') {
2127
- dragMode = !dragMode;
2128
- resizeMode = false;
2129
- this.signLanguageWrapper.classList.toggle('vidply-sign-keyboard-drag', dragMode);
2130
- this.signLanguageWrapper.classList.remove('vidply-sign-keyboard-resize');
2131
- e.preventDefault();
2132
- return;
2133
- }
2134
-
2135
- // Toggle resize mode with R key
2136
- if (e.key === 'r' || e.key === 'R') {
2137
- resizeMode = !resizeMode;
2138
- dragMode = false;
2139
- this.signLanguageWrapper.classList.toggle('vidply-sign-keyboard-resize', resizeMode);
2140
- this.signLanguageWrapper.classList.remove('vidply-sign-keyboard-drag');
2141
- e.preventDefault();
2142
- return;
2143
- }
2144
-
2145
- // Escape to exit modes
2146
- if (e.key === 'Escape') {
2147
- dragMode = false;
2148
- resizeMode = false;
2149
- this.signLanguageWrapper.classList.remove('vidply-sign-keyboard-drag', 'vidply-sign-keyboard-resize');
2150
- e.preventDefault();
2151
- return;
2152
- }
2153
-
2154
- // Arrow keys for drag/resize
2155
- if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
2156
- const step = e.shiftKey ? 10 : 5;
2157
- const rect = this.signLanguageWrapper.getBoundingClientRect();
2158
-
2159
- // Get videoWrapper and container bounds
2160
- const videoWrapperRect = this.videoWrapper.getBoundingClientRect();
2161
- const containerRect = this.container.getBoundingClientRect();
2162
-
2163
- // Calculate videoWrapper position relative to container
2164
- const videoWrapperLeft = videoWrapperRect.left - containerRect.left;
2165
- const videoWrapperTop = videoWrapperRect.top - containerRect.top;
2166
-
2167
- if (dragMode) {
2168
- // Get current position relative to container
2169
- let left = rect.left - containerRect.left;
2170
- let top = rect.top - containerRect.top;
2171
-
2172
- if (e.key === 'ArrowLeft') left -= step;
2173
- if (e.key === 'ArrowRight') left += step;
2174
- if (e.key === 'ArrowUp') top -= step;
2175
- if (e.key === 'ArrowDown') top += step;
2176
-
2177
- const controlsHeight = 95; // Height of controls when visible
2178
-
2179
- // Constrain to videoWrapper bounds (ensuring it stays above controls)
2180
- left = Math.max(videoWrapperLeft, Math.min(left, videoWrapperLeft + videoWrapperRect.width - rect.width));
2181
- top = Math.max(videoWrapperTop, Math.min(top, videoWrapperTop + videoWrapperRect.height - rect.height - controlsHeight));
2182
-
2183
- this.signLanguageWrapper.style.left = `${left}px`;
2184
- this.signLanguageWrapper.style.top = `${top}px`;
2185
- this.signLanguageWrapper.style.right = 'auto';
2186
- this.signLanguageWrapper.style.bottom = 'auto';
2187
- // Remove position classes
2188
- this.signLanguageWrapper.classList.remove(...Array.from(this.signLanguageWrapper.classList).filter(c => c.startsWith('vidply-sign-position-')));
2189
- this.saveSignLanguagePreferences();
2190
- e.preventDefault();
2191
- } else if (resizeMode) {
2192
- let width = rect.width;
2193
-
2194
- // Only adjust width, height will auto-adjust to maintain aspect ratio
2195
- if (e.key === 'ArrowLeft') width -= step;
2196
- if (e.key === 'ArrowRight') width += step;
2197
- // Up/Down also adjusts width for simplicity
2198
- if (e.key === 'ArrowUp') width += step;
2199
- if (e.key === 'ArrowDown') width -= step;
2200
-
2201
- // Constrain width
2202
- width = Math.max(150, width);
2203
- // Don't let it exceed videoWrapper width
2204
- width = Math.min(width, videoWrapperRect.width);
2205
-
2206
- this.signLanguageWrapper.style.width = `${width}px`;
2207
- this.signLanguageWrapper.style.height = 'auto';
2208
- this.saveSignLanguagePreferences();
2209
- e.preventDefault();
2210
- }
2211
- }
2212
- };
2213
-
2214
- // Attach event listeners
2215
- this.signLanguageVideo.addEventListener('mousedown', onMouseDownVideo);
2216
- const handles = this.signLanguageWrapper.querySelectorAll('.vidply-sign-resize-handle');
2217
- handles.forEach(handle => handle.addEventListener('mousedown', onMouseDownHandle));
2218
- document.addEventListener('mousemove', onMouseMove);
2219
- document.addEventListener('mouseup', onMouseUp);
2220
- this.signLanguageWrapper.addEventListener('keydown', onKeyDown);
2252
+ // Get resize handles
2253
+ const resizeHandles = Array.from(this.signLanguageWrapper.querySelectorAll('.vidply-sign-resize-handle'));
2254
+
2255
+ // Create DraggableResizable utility
2256
+ this.signLanguageDraggable = new DraggableResizable(this.signLanguageWrapper, {
2257
+ dragHandle: this.signLanguageVideo,
2258
+ resizeHandles: resizeHandles,
2259
+ constrainToViewport: true,
2260
+ maintainAspectRatio: true,
2261
+ minWidth: 150,
2262
+ minHeight: 100,
2263
+ classPrefix: 'vidply-sign',
2264
+ keyboardDragKey: 'd',
2265
+ keyboardResizeKey: 'r',
2266
+ keyboardStep: 5,
2267
+ keyboardStepLarge: 10
2268
+ });
2221
2269
 
2222
2270
  // Store for cleanup
2223
2271
  this.signLanguageInteractionHandlers = {
2224
- mouseDownVideo: onMouseDownVideo,
2225
- mouseDownHandle: onMouseDownHandle,
2226
- mouseMove: onMouseMove,
2227
- mouseUp: onMouseUp,
2228
- keyDown: onKeyDown,
2229
- handles
2272
+ draggable: this.signLanguageDraggable
2230
2273
  };
2231
2274
  }
2232
2275
 
@@ -2317,23 +2360,14 @@ export class Player extends EventEmitter {
2317
2360
  this.signLanguageHandlers = null;
2318
2361
  }
2319
2362
 
2320
- // Remove interaction handlers
2321
- if (this.signLanguageInteractionHandlers) {
2322
- if (this.signLanguageVideo) {
2323
- this.signLanguageVideo.removeEventListener('mousedown', this.signLanguageInteractionHandlers.mouseDownVideo);
2324
- }
2325
- if (this.signLanguageInteractionHandlers.handles) {
2326
- this.signLanguageInteractionHandlers.handles.forEach(handle => {
2327
- handle.removeEventListener('mousedown', this.signLanguageInteractionHandlers.mouseDownHandle);
2328
- });
2329
- }
2330
- document.removeEventListener('mousemove', this.signLanguageInteractionHandlers.mouseMove);
2331
- document.removeEventListener('mouseup', this.signLanguageInteractionHandlers.mouseUp);
2332
- if (this.signLanguageWrapper) {
2333
- this.signLanguageWrapper.removeEventListener('keydown', this.signLanguageInteractionHandlers.keyDown);
2334
- }
2335
- this.signLanguageInteractionHandlers = null;
2363
+ // Destroy draggable utility
2364
+ if (this.signLanguageDraggable) {
2365
+ this.signLanguageDraggable.destroy();
2366
+ this.signLanguageDraggable = null;
2336
2367
  }
2368
+
2369
+ // Clear interaction handlers reference
2370
+ this.signLanguageInteractionHandlers = null;
2337
2371
 
2338
2372
  // Remove video and wrapper elements
2339
2373
  if (this.signLanguageWrapper && this.signLanguageWrapper.parentNode) {
@@ -2397,9 +2431,28 @@ export class Player extends EventEmitter {
2397
2431
  }
2398
2432
 
2399
2433
  // Logging
2400
- log(message, type = 'log') {
2401
- if (this.options.debug) {
2402
- console[type](`[VidPly]`, message);
2434
+ log(...messages) {
2435
+ if (!this.options.debug) {
2436
+ return;
2437
+ }
2438
+
2439
+ let type = 'log';
2440
+ if (messages.length > 0) {
2441
+ const potentialType = messages[messages.length - 1];
2442
+ if (typeof potentialType === 'string' && console[potentialType]) {
2443
+ type = potentialType;
2444
+ messages = messages.slice(0, -1);
2445
+ }
2446
+ }
2447
+
2448
+ if (messages.length === 0) {
2449
+ messages = [''];
2450
+ }
2451
+
2452
+ if (typeof console[type] === 'function') {
2453
+ console[type]('[VidPly]', ...messages);
2454
+ } else {
2455
+ console.log('[VidPly]', ...messages);
2403
2456
  }
2404
2457
  }
2405
2458
 
@@ -2434,7 +2487,10 @@ export class Player extends EventEmitter {
2434
2487
  }
2435
2488
 
2436
2489
  if (this.transcriptManager && this.transcriptManager.isVisible) {
2437
- this.transcriptManager.positionTranscript();
2490
+ // Only auto-position if user hasn't manually moved it
2491
+ if (!this.transcriptManager.draggableResizable || !this.transcriptManager.draggableResizable.manuallyPositioned) {
2492
+ this.transcriptManager.positionTranscript();
2493
+ }
2438
2494
  }
2439
2495
  };
2440
2496
 
@@ -2447,7 +2503,10 @@ export class Player extends EventEmitter {
2447
2503
  // Wait for layout to settle
2448
2504
  setTimeout(() => {
2449
2505
  if (this.transcriptManager && this.transcriptManager.isVisible) {
2450
- this.transcriptManager.positionTranscript();
2506
+ // Only auto-position if user hasn't manually moved it
2507
+ if (!this.transcriptManager.draggableResizable || !this.transcriptManager.draggableResizable.manuallyPositioned) {
2508
+ this.transcriptManager.positionTranscript();
2509
+ }
2451
2510
  }
2452
2511
  }, 100);
2453
2512
  };
@@ -2493,7 +2552,7 @@ export class Player extends EventEmitter {
2493
2552
  if (this.signLanguageWrapper && this.signLanguageWrapper.style.display !== 'none') {
2494
2553
  // Use setTimeout to ensure layout has updated after fullscreen transition
2495
2554
  // Longer delay to account for CSS transition animations and layout recalculation
2496
- setTimeout(() => {
2555
+ this.setManagedTimeout(() => {
2497
2556
  // Use requestAnimationFrame to ensure the browser has fully rendered the layout
2498
2557
  requestAnimationFrame(() => {
2499
2558
  // Clear saved size and reset to default for the new container size
@@ -2580,6 +2639,29 @@ export class Player extends EventEmitter {
2580
2639
  this.fullscreenChangeHandler = null;
2581
2640
  }
2582
2641
 
2642
+ // Cleanup all managed timeouts
2643
+ this.timeouts.forEach(timeoutId => clearTimeout(timeoutId));
2644
+ this.timeouts.clear();
2645
+
2646
+ // Cleanup metadata handling
2647
+ if (this.metadataCueChangeHandler) {
2648
+ const textTracks = this.textTracks;
2649
+ const metadataTrack = textTracks.find(track => track.kind === 'metadata');
2650
+ if (metadataTrack) {
2651
+ metadataTrack.removeEventListener('cuechange', this.metadataCueChangeHandler);
2652
+ }
2653
+ this.metadataCueChangeHandler = null;
2654
+ }
2655
+
2656
+ if (this.metadataAlertHandlers && this.metadataAlertHandlers.size > 0) {
2657
+ this.metadataAlertHandlers.forEach(({ button, handler }) => {
2658
+ if (button && handler) {
2659
+ button.removeEventListener('click', handler);
2660
+ }
2661
+ });
2662
+ this.metadataAlertHandlers.clear();
2663
+ }
2664
+
2583
2665
  // Remove container
2584
2666
  if (this.container && this.container.parentNode) {
2585
2667
  this.container.parentNode.insertBefore(this.element, this.container);
@@ -2588,6 +2670,424 @@ export class Player extends EventEmitter {
2588
2670
 
2589
2671
  this.removeAllListeners();
2590
2672
  }
2673
+
2674
+ /**
2675
+ * Setup metadata track handling
2676
+ * This enables metadata tracks and listens for cue changes to trigger actions
2677
+ */
2678
+ setupMetadataHandling() {
2679
+ const setupMetadata = () => {
2680
+ const textTracks = this.textTracks;
2681
+ const metadataTrack = textTracks.find(track => track.kind === 'metadata');
2682
+
2683
+ if (metadataTrack) {
2684
+ // Enable the metadata track so cuechange events fire
2685
+ // Use 'hidden' mode so it doesn't display anything, but events still work
2686
+ if (metadataTrack.mode === 'disabled') {
2687
+ metadataTrack.mode = 'hidden';
2688
+ }
2689
+
2690
+ // Remove existing listener if any
2691
+ if (this.metadataCueChangeHandler) {
2692
+ metadataTrack.removeEventListener('cuechange', this.metadataCueChangeHandler);
2693
+ }
2694
+
2695
+ // Add event listener for cue changes
2696
+ this.metadataCueChangeHandler = () => {
2697
+ const activeCues = Array.from(metadataTrack.activeCues || []);
2698
+ if (activeCues.length > 0) {
2699
+ // Debug logging
2700
+ if (this.options.debug) {
2701
+ this.log('[Metadata] Active cues:', activeCues.map(c => ({
2702
+ start: c.startTime,
2703
+ end: c.endTime,
2704
+ text: c.text
2705
+ })));
2706
+ }
2707
+ }
2708
+ activeCues.forEach(cue => {
2709
+ this.handleMetadataCue(cue);
2710
+ });
2711
+ };
2712
+
2713
+ metadataTrack.addEventListener('cuechange', this.metadataCueChangeHandler);
2714
+
2715
+ // Debug: Log metadata track setup
2716
+ if (this.options.debug) {
2717
+ const cueCount = metadataTrack.cues ? metadataTrack.cues.length : 0;
2718
+ this.log('[Metadata] Track enabled,', cueCount, 'cues available');
2719
+ }
2720
+ } else if (this.options.debug) {
2721
+ this.log('[Metadata] No metadata track found');
2722
+ }
2723
+ };
2724
+
2725
+ // Try immediately
2726
+ setupMetadata();
2727
+
2728
+ // Also try after loadedmetadata event (tracks might not be ready yet)
2729
+ this.on('loadedmetadata', setupMetadata);
2730
+ }
2731
+
2732
+ normalizeMetadataSelector(selector) {
2733
+ if (!selector) {
2734
+ return null;
2735
+ }
2736
+ const trimmed = selector.trim();
2737
+ if (!trimmed) {
2738
+ return null;
2739
+ }
2740
+ if (trimmed.startsWith('#') || trimmed.startsWith('.') || trimmed.startsWith('[')) {
2741
+ return trimmed;
2742
+ }
2743
+ return `#${trimmed}`;
2744
+ }
2745
+
2746
+ resolveMetadataConfig(map, key) {
2747
+ if (!map || !key) {
2748
+ return null;
2749
+ }
2750
+ if (Object.prototype.hasOwnProperty.call(map, key)) {
2751
+ return map[key];
2752
+ }
2753
+ const withoutHash = key.replace(/^#/, '');
2754
+ if (Object.prototype.hasOwnProperty.call(map, withoutHash)) {
2755
+ return map[withoutHash];
2756
+ }
2757
+ return null;
2758
+ }
2759
+
2760
+ cacheMetadataAlertContent(element, config = {}) {
2761
+ if (!element) {
2762
+ return;
2763
+ }
2764
+ const titleSelector = config.titleSelector || '[data-vidply-alert-title], h3, header';
2765
+ const messageSelector = config.messageSelector || '[data-vidply-alert-message], p';
2766
+
2767
+ const titleEl = element.querySelector(titleSelector);
2768
+ if (titleEl && !titleEl.dataset.vidplyAlertTitleOriginal) {
2769
+ titleEl.dataset.vidplyAlertTitleOriginal = titleEl.textContent.trim();
2770
+ }
2771
+
2772
+ const messageEl = element.querySelector(messageSelector);
2773
+ if (messageEl && !messageEl.dataset.vidplyAlertMessageOriginal) {
2774
+ messageEl.dataset.vidplyAlertMessageOriginal = messageEl.textContent.trim();
2775
+ }
2776
+ }
2777
+
2778
+ restoreMetadataAlertContent(element, config = {}) {
2779
+ if (!element) {
2780
+ return;
2781
+ }
2782
+ const titleSelector = config.titleSelector || '[data-vidply-alert-title], h3, header';
2783
+ const messageSelector = config.messageSelector || '[data-vidply-alert-message], p';
2784
+
2785
+ const titleEl = element.querySelector(titleSelector);
2786
+ if (titleEl && titleEl.dataset.vidplyAlertTitleOriginal) {
2787
+ titleEl.textContent = titleEl.dataset.vidplyAlertTitleOriginal;
2788
+ }
2789
+
2790
+ const messageEl = element.querySelector(messageSelector);
2791
+ if (messageEl && messageEl.dataset.vidplyAlertMessageOriginal) {
2792
+ messageEl.textContent = messageEl.dataset.vidplyAlertMessageOriginal;
2793
+ }
2794
+ }
2795
+
2796
+ focusMetadataTarget(target, fallbackElement = null) {
2797
+ if (!target || target === 'none') {
2798
+ return;
2799
+ }
2800
+
2801
+ if (target === 'alert' && fallbackElement) {
2802
+ fallbackElement.focus();
2803
+ return;
2804
+ }
2805
+
2806
+ if (target === 'player') {
2807
+ if (this.container) {
2808
+ this.container.focus();
2809
+ }
2810
+ return;
2811
+ }
2812
+
2813
+ if (target === 'media') {
2814
+ this.element.focus();
2815
+ return;
2816
+ }
2817
+
2818
+ if (target === 'playButton') {
2819
+ const playButton = this.controlBar?.controls?.playPause;
2820
+ if (playButton) {
2821
+ playButton.focus();
2822
+ }
2823
+ return;
2824
+ }
2825
+
2826
+ if (typeof target === 'string') {
2827
+ const targetElement = document.querySelector(target);
2828
+ if (targetElement) {
2829
+ if (targetElement.tabIndex === -1 && !targetElement.hasAttribute('tabindex')) {
2830
+ targetElement.setAttribute('tabindex', '-1');
2831
+ }
2832
+ targetElement.focus();
2833
+ }
2834
+ }
2835
+ }
2836
+
2837
+ handleMetadataAlert(selector, options = {}) {
2838
+ if (!selector) {
2839
+ return;
2840
+ }
2841
+
2842
+ const config = this.resolveMetadataConfig(this.options.metadataAlerts, selector) || {};
2843
+ const element = options.element || document.querySelector(selector);
2844
+
2845
+ if (!element) {
2846
+ if (this.options.debug) {
2847
+ this.log('[Metadata] Alert element not found:', selector);
2848
+ }
2849
+ return;
2850
+ }
2851
+
2852
+ if (this.options.debug) {
2853
+ this.log('[Metadata] Handling alert', selector, { reason: options.reason, config });
2854
+ }
2855
+
2856
+ this.cacheMetadataAlertContent(element, config);
2857
+
2858
+ if (!element.dataset.vidplyAlertOriginalDisplay) {
2859
+ element.dataset.vidplyAlertOriginalDisplay = element.style.display || '';
2860
+ }
2861
+
2862
+ if (!element.dataset.vidplyAlertDisplay) {
2863
+ element.dataset.vidplyAlertDisplay = config.display || 'block';
2864
+ }
2865
+
2866
+ const shouldShow = options.show !== undefined ? options.show : (config.show !== false);
2867
+ if (shouldShow) {
2868
+ const displayValue = config.display || element.dataset.vidplyAlertDisplay || 'block';
2869
+ element.style.display = displayValue;
2870
+ element.hidden = false;
2871
+ element.removeAttribute('hidden');
2872
+ element.setAttribute('aria-hidden', 'false');
2873
+ element.setAttribute('data-vidply-alert-active', 'true');
2874
+ }
2875
+
2876
+ const shouldReset = config.resetContent !== false && options.reason === 'focus';
2877
+ if (shouldReset) {
2878
+ this.restoreMetadataAlertContent(element, config);
2879
+ }
2880
+
2881
+ const shouldFocus = options.focus !== undefined
2882
+ ? options.focus
2883
+ : (config.focusOnShow ?? (options.reason !== 'focus'));
2884
+
2885
+ if (shouldShow && shouldFocus) {
2886
+ if (element.tabIndex === -1 && !element.hasAttribute('tabindex')) {
2887
+ element.setAttribute('tabindex', '-1');
2888
+ }
2889
+ element.focus();
2890
+ }
2891
+
2892
+ if (shouldShow && config.autoScroll !== false && options.autoScroll !== false) {
2893
+ element.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
2894
+ }
2895
+
2896
+ const continueSelector = config.continueButton;
2897
+ if (continueSelector) {
2898
+ let continueButton = null;
2899
+ if (continueSelector === 'self') {
2900
+ continueButton = element;
2901
+ } else if (element.matches(continueSelector)) {
2902
+ continueButton = element;
2903
+ } else {
2904
+ continueButton = element.querySelector(continueSelector) || document.querySelector(continueSelector);
2905
+ }
2906
+
2907
+ if (continueButton && !this.metadataAlertHandlers.has(selector)) {
2908
+ const handler = () => {
2909
+ const hideOnContinue = config.hideOnContinue !== false;
2910
+ if (hideOnContinue) {
2911
+ const originalDisplay = element.dataset.vidplyAlertOriginalDisplay || '';
2912
+ element.style.display = config.hideDisplay || originalDisplay || 'none';
2913
+ element.setAttribute('aria-hidden', 'true');
2914
+ element.removeAttribute('data-vidply-alert-active');
2915
+ }
2916
+
2917
+ if (config.resume !== false && this.state.paused) {
2918
+ this.play();
2919
+ }
2920
+
2921
+ const focusTarget = config.focusTarget || 'playButton';
2922
+ this.setManagedTimeout(() => {
2923
+ this.focusMetadataTarget(focusTarget, element);
2924
+ }, config.focusDelay ?? 100);
2925
+ };
2926
+
2927
+ continueButton.addEventListener('click', handler);
2928
+ this.metadataAlertHandlers.set(selector, { button: continueButton, handler });
2929
+ }
2930
+ }
2931
+
2932
+ return element;
2933
+ }
2934
+
2935
+ handleMetadataHashtags(hashtags) {
2936
+ if (!Array.isArray(hashtags) || hashtags.length === 0) {
2937
+ return;
2938
+ }
2939
+
2940
+ const configMap = this.options.metadataHashtags;
2941
+ if (!configMap) {
2942
+ return;
2943
+ }
2944
+
2945
+ hashtags.forEach(tag => {
2946
+ const config = this.resolveMetadataConfig(configMap, tag);
2947
+ if (!config) {
2948
+ return;
2949
+ }
2950
+
2951
+ const selector = this.normalizeMetadataSelector(config.alert || config.selector || config.target);
2952
+ if (!selector) {
2953
+ return;
2954
+ }
2955
+
2956
+ const element = document.querySelector(selector);
2957
+ if (!element) {
2958
+ if (this.options.debug) {
2959
+ this.log('[Metadata] Hashtag target not found:', selector);
2960
+ }
2961
+ return;
2962
+ }
2963
+
2964
+ if (this.options.debug) {
2965
+ this.log('[Metadata] Handling hashtag', tag, { selector, config });
2966
+ }
2967
+
2968
+ this.cacheMetadataAlertContent(element, config);
2969
+
2970
+ if (config.title) {
2971
+ const titleSelector = config.titleSelector || '[data-vidply-alert-title], h3, header';
2972
+ const titleEl = element.querySelector(titleSelector);
2973
+ if (titleEl) {
2974
+ titleEl.textContent = config.title;
2975
+ }
2976
+ }
2977
+
2978
+ if (config.message) {
2979
+ const messageSelector = config.messageSelector || '[data-vidply-alert-message], p';
2980
+ const messageEl = element.querySelector(messageSelector);
2981
+ if (messageEl) {
2982
+ messageEl.textContent = config.message;
2983
+ }
2984
+ }
2985
+
2986
+ const show = config.show !== false;
2987
+ const focus = config.focus !== undefined ? config.focus : false;
2988
+
2989
+ this.handleMetadataAlert(selector, {
2990
+ element,
2991
+ show,
2992
+ focus,
2993
+ autoScroll: config.autoScroll,
2994
+ reason: 'hashtag'
2995
+ });
2996
+ });
2997
+ }
2998
+
2999
+ /**
3000
+ * Handle individual metadata cues
3001
+ * Parses metadata text and emits events or triggers actions
3002
+ */
3003
+ handleMetadataCue(cue) {
3004
+ const text = cue.text.trim();
3005
+
3006
+ // Debug logging
3007
+ if (this.options.debug) {
3008
+ this.log('[Metadata] Processing cue:', {
3009
+ time: cue.startTime,
3010
+ text: text
3011
+ });
3012
+ }
3013
+
3014
+ // Emit a generic metadata event that developers can listen to
3015
+ this.emit('metadata', {
3016
+ time: cue.startTime,
3017
+ endTime: cue.endTime,
3018
+ text: text,
3019
+ cue: cue
3020
+ });
3021
+
3022
+ // Parse for specific commands (examples based on wwa_meta.vtt format)
3023
+ if (text.includes('PAUSE')) {
3024
+ // Automatically pause the video
3025
+ if (!this.state.paused) {
3026
+ if (this.options.debug) {
3027
+ this.log('[Metadata] Pausing video at', cue.startTime);
3028
+ }
3029
+ this.pause();
3030
+ }
3031
+ // Also emit event for developers who want to listen
3032
+ this.emit('metadata:pause', { time: cue.startTime, text: text });
3033
+ }
3034
+
3035
+ // Parse for focus directives
3036
+ const focusMatch = text.match(/FOCUS:([\w#-]+)/);
3037
+ if (focusMatch) {
3038
+ const targetSelector = focusMatch[1];
3039
+ const normalizedSelector = this.normalizeMetadataSelector(targetSelector);
3040
+ // Automatically focus the target element
3041
+ const targetElement = normalizedSelector ? document.querySelector(normalizedSelector) : null;
3042
+ if (targetElement) {
3043
+ if (this.options.debug) {
3044
+ this.log('[Metadata] Focusing element:', normalizedSelector);
3045
+ }
3046
+ // Make element focusable if it isn't already
3047
+ if (targetElement.tabIndex === -1 && !targetElement.hasAttribute('tabindex')) {
3048
+ targetElement.setAttribute('tabindex', '-1');
3049
+ }
3050
+ // Use setTimeout to ensure DOM is ready
3051
+ this.setManagedTimeout(() => {
3052
+ targetElement.focus();
3053
+ // Scroll element into view if needed
3054
+ targetElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
3055
+ }, 10);
3056
+ } else if (this.options.debug) {
3057
+ this.log('[Metadata] Element not found:', normalizedSelector || targetSelector);
3058
+ }
3059
+ // Also emit event for developers who want to listen
3060
+ this.emit('metadata:focus', {
3061
+ time: cue.startTime,
3062
+ target: targetSelector,
3063
+ selector: normalizedSelector,
3064
+ element: targetElement,
3065
+ text: text
3066
+ });
3067
+
3068
+ if (normalizedSelector) {
3069
+ this.handleMetadataAlert(normalizedSelector, {
3070
+ element: targetElement,
3071
+ reason: 'focus'
3072
+ });
3073
+ }
3074
+ }
3075
+
3076
+ // Parse for hashtag references
3077
+ const hashtags = text.match(/#[\w-]+/g);
3078
+ if (hashtags) {
3079
+ if (this.options.debug) {
3080
+ this.log('[Metadata] Hashtags found:', hashtags);
3081
+ }
3082
+ this.emit('metadata:hashtags', {
3083
+ time: cue.startTime,
3084
+ hashtags: hashtags,
3085
+ text: text
3086
+ });
3087
+
3088
+ this.handleMetadataHashtags(hashtags);
3089
+ }
3090
+ }
2591
3091
  }
2592
3092
 
2593
3093
  // Static instances tracker for pause others functionality