vidply 1.0.8 → 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
 
@@ -210,6 +215,17 @@ export class Player extends EventEmitter {
210
215
  // Store caption tracks that should be swapped for audio description
211
216
  this.audioDescriptionCaptionTracks = [];
212
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();
228
+
213
229
  // Components
214
230
  this.container = null;
215
231
  this.renderer = null;
@@ -217,6 +233,10 @@ export class Player extends EventEmitter {
217
233
  this.captionManager = null;
218
234
  this.keyboardManager = null;
219
235
  this.settingsDialog = null;
236
+
237
+ // Metadata handling
238
+ this.metadataCueChangeHandler = null;
239
+ this.metadataAlertHandlers = new Map();
220
240
 
221
241
  // Initialize
222
242
  this.init();
@@ -264,6 +284,9 @@ export class Player extends EventEmitter {
264
284
  if (this.options.transcript || this.options.transcriptButton) {
265
285
  this.transcriptManager = new TranscriptManager(this);
266
286
  }
287
+
288
+ // Always set up metadata track handling (independent of transcript)
289
+ this.setupMetadataHandling();
267
290
 
268
291
  // Initialize keyboard controls
269
292
  if (this.options.keyboard) {
@@ -399,6 +422,12 @@ export class Player extends EventEmitter {
399
422
  if (this.element.tagName === 'VIDEO') {
400
423
  this.createPlayButtonOverlay();
401
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);
402
431
 
403
432
  // Make video/audio element clickable to toggle play/pause
404
433
  this.element.style.cursor = 'pointer';
@@ -447,7 +476,7 @@ export class Player extends EventEmitter {
447
476
  }
448
477
 
449
478
  // Check for source elements with audio description attributes
450
- const sourceElements = this.element.querySelectorAll('source');
479
+ const sourceElements = this.sourceElements;
451
480
  for (const sourceEl of sourceElements) {
452
481
  const descSrc = sourceEl.getAttribute('data-desc-src');
453
482
  const origSrc = sourceEl.getAttribute('data-orig-src');
@@ -490,7 +519,7 @@ export class Player extends EventEmitter {
490
519
  // Check for caption/subtitle tracks with audio description versions
491
520
  // Only tracks with explicit data-desc-src attribute are swapped (no auto-detection to avoid 404 errors)
492
521
  // Description tracks (kind="descriptions") are NOT swapped - they're for transcripts
493
- const trackElements = this.element.querySelectorAll('track');
522
+ const trackElements = this.trackElements;
494
523
  trackElements.forEach(trackEl => {
495
524
  const trackKind = trackEl.getAttribute('kind');
496
525
  const trackDescSrc = trackEl.getAttribute('data-desc-src');
@@ -537,6 +566,117 @@ export class Player extends EventEmitter {
537
566
  this.log(`Using ${renderer.name} renderer`);
538
567
  this.renderer = new renderer(this);
539
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
+ }
540
680
  }
541
681
 
542
682
  /**
@@ -557,8 +697,9 @@ export class Player extends EventEmitter {
557
697
  }
558
698
 
559
699
  // Clear existing text tracks
560
- const existingTracks = this.element.querySelectorAll('track');
700
+ const existingTracks = this.trackElements;
561
701
  existingTracks.forEach(track => track.remove());
702
+ this.invalidateTrackCache();
562
703
 
563
704
  // Update media element
564
705
  this.element.src = config.src;
@@ -586,6 +727,7 @@ export class Player extends EventEmitter {
586
727
 
587
728
  this.element.appendChild(track);
588
729
  });
730
+ this.invalidateTrackCache();
589
731
  }
590
732
 
591
733
  // Check if we need to change renderer type
@@ -895,7 +1037,7 @@ export class Player extends EventEmitter {
895
1037
  // Audio Description
896
1038
  async enableAudioDescription() {
897
1039
  // 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'));
1040
+ const hasSourceElementsWithDesc = this.sourceElements.some(el => el.getAttribute('data-desc-src'));
899
1041
  const hasTracksWithDesc = this.audioDescriptionCaptionTracks.length > 0;
900
1042
 
901
1043
  if (!this.audioDescriptionSrc && !hasSourceElementsWithDesc && !hasTracksWithDesc) {
@@ -916,7 +1058,7 @@ export class Player extends EventEmitter {
916
1058
  const currentSrc = this.element.currentSrc || this.element.src;
917
1059
 
918
1060
  // Find the source element that matches the currently active source
919
- const sourceElements = Array.from(this.element.querySelectorAll('source'));
1061
+ const sourceElements = this.sourceElements;
920
1062
  let sourceElementToUpdate = null;
921
1063
  let descSrc = this.audioDescriptionSrc;
922
1064
 
@@ -1060,11 +1202,12 @@ export class Player extends EventEmitter {
1060
1202
  // After all new tracks are added, force browser to reload media element again
1061
1203
  // This ensures new track elements are processed and new TextTrack objects are created
1062
1204
  this.element.load();
1205
+ this.invalidateTrackCache();
1063
1206
 
1064
1207
  // Wait for loadedmetadata event before accessing new TextTrack objects
1065
1208
  const setupNewTracks = () => {
1066
1209
  // Wait a bit more for browser to fully process the new track elements
1067
- setTimeout(() => {
1210
+ this.setManagedTimeout(() => {
1068
1211
  swappedTracksForTranscript.forEach((trackInfo) => {
1069
1212
  const trackElement = trackInfo.trackElement;
1070
1213
  const newTextTrack = trackElement.track;
@@ -1119,7 +1262,7 @@ export class Player extends EventEmitter {
1119
1262
  // Update all source elements that have data-desc-src to their described versions
1120
1263
  // Force browser to pick up changes by removing and re-adding source elements
1121
1264
  // 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'));
1265
+ const allSourceElements = this.sourceElements;
1123
1266
  const sourcesToUpdate = [];
1124
1267
 
1125
1268
  allSourceElements.forEach((sourceEl) => {
@@ -1374,7 +1517,7 @@ export class Player extends EventEmitter {
1374
1517
  }
1375
1518
 
1376
1519
  // Check if we have source elements with data-desc-src (fallback method)
1377
- const fallbackSourceElements = Array.from(this.element.querySelectorAll('source'));
1520
+ const fallbackSourceElements = this.sourceElements;
1378
1521
  const hasSourceElementsWithDesc = fallbackSourceElements.some(el => el.getAttribute('data-desc-src'));
1379
1522
 
1380
1523
  if (hasSourceElementsWithDesc) {
@@ -1433,6 +1576,7 @@ export class Player extends EventEmitter {
1433
1576
 
1434
1577
  // Force reload
1435
1578
  this.element.load();
1579
+ this.invalidateTrackCache();
1436
1580
  } else {
1437
1581
  // Fallback to updating element src directly (for videos without source elements)
1438
1582
  this.element.src = this.audioDescriptionSrc;
@@ -1456,7 +1600,7 @@ export class Player extends EventEmitter {
1456
1600
  // Seek to a tiny fraction to trigger poster hiding without actually moving
1457
1601
  this.element.currentTime = 0.001;
1458
1602
  // Then seek back to 0 after a brief moment to ensure poster stays hidden
1459
- setTimeout(() => {
1603
+ this.setManagedTimeout(() => {
1460
1604
  this.element.currentTime = 0;
1461
1605
  }, 10);
1462
1606
  }
@@ -1521,7 +1665,9 @@ export class Player extends EventEmitter {
1521
1665
  const onMetadataLoaded = () => {
1522
1666
  // Get fresh track references from the video element's textTracks collection
1523
1667
  // This ensures we get the actual textTrack objects that the browser created
1524
- const allTextTracks = Array.from(this.element.textTracks);
1668
+ // Invalidate cache first to get fresh tracks after swap
1669
+ this.invalidateTrackCache();
1670
+ const allTextTracks = this.textTracks;
1525
1671
 
1526
1672
  // Find the tracks that match our swapped tracks by language and kind
1527
1673
  // Match by checking the track element's src attribute
@@ -1541,9 +1687,7 @@ export class Player extends EventEmitter {
1541
1687
  if (track.language === srclang &&
1542
1688
  (track.kind === kind || (kind === 'captions' && track.kind === 'subtitles'))) {
1543
1689
  // Verify the src matches
1544
- const trackElementForTrack = Array.from(this.element.querySelectorAll('track')).find(
1545
- el => el.track === track
1546
- );
1690
+ const trackElementForTrack = this.findTrackElement(track);
1547
1691
  if (trackElementForTrack) {
1548
1692
  const actualSrc = trackElementForTrack.getAttribute('src');
1549
1693
  if (actualSrc === expectedSrc) {
@@ -1557,9 +1701,7 @@ export class Player extends EventEmitter {
1557
1701
 
1558
1702
  // Verify the track element's src matches what we expect
1559
1703
  if (foundTrack) {
1560
- const trackElement = Array.from(this.element.querySelectorAll('track')).find(
1561
- el => el.track === foundTrack
1562
- );
1704
+ const trackElement = this.findTrackElement(foundTrack);
1563
1705
  if (trackElement && trackElement.getAttribute('src') !== expectedSrc) {
1564
1706
  return null;
1565
1707
  }
@@ -1570,7 +1712,7 @@ export class Player extends EventEmitter {
1570
1712
 
1571
1713
  if (freshTracks.length === 0) {
1572
1714
  // Fallback: just reload after delay - transcript manager will find tracks itself
1573
- setTimeout(() => {
1715
+ this.setManagedTimeout(() => {
1574
1716
  if (this.transcriptManager && this.transcriptManager.loadTranscriptData) {
1575
1717
  this.transcriptManager.loadTranscriptData();
1576
1718
  }
@@ -1591,7 +1733,7 @@ export class Player extends EventEmitter {
1591
1733
  if (loadedCount >= freshTracks.length) {
1592
1734
  // Give a bit more time for cues to be fully parsed
1593
1735
  // Also ensure we're getting the latest TextTrack references
1594
- setTimeout(() => {
1736
+ this.setManagedTimeout(() => {
1595
1737
  if (this.transcriptManager && this.transcriptManager.loadTranscriptData) {
1596
1738
  // Force transcript manager to get fresh track references
1597
1739
  // Clear any cached track references by forcing a fresh read
@@ -1599,12 +1741,11 @@ export class Player extends EventEmitter {
1599
1741
  // which should now have the new TextTrack objects with the described captions
1600
1742
 
1601
1743
  // Verify the tracks have the correct src before reloading transcript
1602
- const allTextTracks = Array.from(this.element.textTracks);
1744
+ this.invalidateTrackCache();
1745
+ const allTextTracks = this.textTracks;
1603
1746
  const swappedTrackSrcs = swappedTracks.map(t => t.describedSrc);
1604
1747
  const hasCorrectTracks = freshTracks.some(track => {
1605
- const trackEl = Array.from(this.element.querySelectorAll('track')).find(
1606
- el => el.track === track
1607
- );
1748
+ const trackEl = this.findTrackElement(track);
1608
1749
  return trackEl && swappedTrackSrcs.includes(trackEl.getAttribute('src'));
1609
1750
  });
1610
1751
 
@@ -1624,9 +1765,7 @@ export class Player extends EventEmitter {
1624
1765
 
1625
1766
  // Check if track has cues loaded
1626
1767
  // 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
- );
1768
+ const trackElementForTrack = this.findTrackElement(track);
1630
1769
  const actualSrc = trackElementForTrack ? trackElementForTrack.getAttribute('src') : null;
1631
1770
 
1632
1771
  // Find the expected src from swappedTracks
@@ -1657,13 +1796,13 @@ export class Player extends EventEmitter {
1657
1796
  // Wait for track to load
1658
1797
  const onTrackLoad = () => {
1659
1798
  // Wait a bit for cues to be fully parsed
1660
- setTimeout(checkLoaded, 300);
1799
+ this.setManagedTimeout(checkLoaded, 300);
1661
1800
  };
1662
1801
 
1663
1802
  if (track.readyState >= 2) {
1664
1803
  // Already loaded, but might not have cues yet
1665
1804
  // Wait a bit and check again
1666
- setTimeout(() => {
1805
+ this.setManagedTimeout(() => {
1667
1806
  if (track.cues && track.cues.length > 0) {
1668
1807
  checkLoaded();
1669
1808
  } else {
@@ -1685,16 +1824,16 @@ export class Player extends EventEmitter {
1685
1824
  // Wait for loadedmetadata event which fires when browser processes track elements
1686
1825
  // Also wait for the tracks to be fully processed after the second load()
1687
1826
  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
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
1698
1837
  };
1699
1838
 
1700
1839
  waitForTracks();
@@ -1739,7 +1878,7 @@ export class Player extends EventEmitter {
1739
1878
 
1740
1879
  // Swap source elements back to original versions
1741
1880
  // Check if we have source elements with data-orig-src
1742
- const allSourceElements = Array.from(this.element.querySelectorAll('source'));
1881
+ const allSourceElements = this.sourceElements;
1743
1882
  const hasSourceElementsToSwap = allSourceElements.some(el => el.getAttribute('data-orig-src'));
1744
1883
 
1745
1884
  if (hasSourceElementsToSwap) {
@@ -1817,16 +1956,16 @@ export class Player extends EventEmitter {
1817
1956
  this.play();
1818
1957
  }
1819
1958
 
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);
1829
- }
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
+ }
1830
1969
 
1831
1970
  this.state.audioDescriptionEnabled = false;
1832
1971
  this.emit('audiodescriptiondisabled');
@@ -1834,12 +1973,11 @@ export class Player extends EventEmitter {
1834
1973
 
1835
1974
  async toggleAudioDescription() {
1836
1975
  // 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');
1976
+ const descriptionTrack = this.findTextTrack('descriptions');
1839
1977
 
1840
1978
  // Check if we have audio-described video source (either from options or source elements with data-desc-src)
1841
1979
  const hasAudioDescriptionSrc = this.audioDescriptionSrc ||
1842
- Array.from(this.element.querySelectorAll('source')).some(el => el.getAttribute('data-desc-src'));
1980
+ this.sourceElements.some(el => el.getAttribute('data-desc-src'));
1843
1981
 
1844
1982
  if (descriptionTrack && hasAudioDescriptionSrc) {
1845
1983
  // We have both: toggle description track AND swap caption tracks/sources
@@ -1850,7 +1988,35 @@ export class Player extends EventEmitter {
1850
1988
  } else {
1851
1989
  // Enable: swap caption tracks/sources and toggle description track on
1852
1990
  await this.enableAudioDescription();
1853
- descriptionTrack.mode = 'showing';
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
+ }
1854
2020
  }
1855
2021
  } else if (descriptionTrack) {
1856
2022
  // Only description track, no audio-described video source to swap
@@ -2397,9 +2563,28 @@ export class Player extends EventEmitter {
2397
2563
  }
2398
2564
 
2399
2565
  // Logging
2400
- log(message, type = 'log') {
2401
- if (this.options.debug) {
2402
- 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);
2403
2588
  }
2404
2589
  }
2405
2590
 
@@ -2493,7 +2678,7 @@ export class Player extends EventEmitter {
2493
2678
  if (this.signLanguageWrapper && this.signLanguageWrapper.style.display !== 'none') {
2494
2679
  // Use setTimeout to ensure layout has updated after fullscreen transition
2495
2680
  // Longer delay to account for CSS transition animations and layout recalculation
2496
- setTimeout(() => {
2681
+ this.setManagedTimeout(() => {
2497
2682
  // Use requestAnimationFrame to ensure the browser has fully rendered the layout
2498
2683
  requestAnimationFrame(() => {
2499
2684
  // Clear saved size and reset to default for the new container size
@@ -2580,6 +2765,29 @@ export class Player extends EventEmitter {
2580
2765
  this.fullscreenChangeHandler = null;
2581
2766
  }
2582
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
+
2583
2791
  // Remove container
2584
2792
  if (this.container && this.container.parentNode) {
2585
2793
  this.container.parentNode.insertBefore(this.element, this.container);
@@ -2588,6 +2796,424 @@ export class Player extends EventEmitter {
2588
2796
 
2589
2797
  this.removeAllListeners();
2590
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
+ }
2591
3217
  }
2592
3218
 
2593
3219
  // Static instances tracker for pause others functionality