vidply 1.0.7 → 1.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,7 +11,7 @@
11
11
  "format": "esm"
12
12
  },
13
13
  "src/i18n/translations.js": {
14
- "bytes": 22406,
14
+ "bytes": 22623,
15
15
  "imports": [],
16
16
  "format": "esm"
17
17
  },
@@ -100,7 +100,7 @@
100
100
  "format": "esm"
101
101
  },
102
102
  "src/controls/TranscriptManager.js": {
103
- "bytes": 59186,
103
+ "bytes": 69891,
104
104
  "imports": [
105
105
  {
106
106
  "path": "src/utils/DOMUtils.js",
@@ -157,7 +157,7 @@
157
157
  "format": "esm"
158
158
  },
159
159
  "src/core/Player.js": {
160
- "bytes": 61179,
160
+ "bytes": 139352,
161
161
  "imports": [
162
162
  {
163
163
  "path": "src/utils/EventEmitter.js",
@@ -279,7 +279,7 @@
279
279
  "bytesInOutput": 1581
280
280
  },
281
281
  "src/i18n/translations.js": {
282
- "bytesInOutput": 19604
282
+ "bytesInOutput": 19801
283
283
  },
284
284
  "src/i18n/i18n.js": {
285
285
  "bytesInOutput": 720
@@ -303,10 +303,10 @@
303
303
  "bytesInOutput": 3738
304
304
  },
305
305
  "src/controls/TranscriptManager.js": {
306
- "bytesInOutput": 31200
306
+ "bytesInOutput": 36165
307
307
  },
308
308
  "src/core/Player.js": {
309
- "bytesInOutput": 26599
309
+ "bytesInOutput": 47003
310
310
  },
311
311
  "src/renderers/YouTubeRenderer.js": {
312
312
  "bytesInOutput": 4140
@@ -321,7 +321,7 @@
321
321
  "bytesInOutput": 8100
322
322
  }
323
323
  },
324
- "bytes": 165967
324
+ "bytes": 191533
325
325
  }
326
326
  }
327
327
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vidply",
3
- "version": "1.0.7",
3
+ "version": "1.0.9",
4
4
  "description": "Universal, accessible video & audio player with ES6 modules",
5
5
  "type": "module",
6
6
  "main": "dist/vidply.js",
@@ -50,7 +50,7 @@
50
50
  "access": "public"
51
51
  },
52
52
  "devDependencies": {
53
- "esbuild": "^0.25.11",
53
+ "esbuild": "^0.25.12",
54
54
  "clean-css": "^5.3.3"
55
55
  },
56
56
  "dependencies": {}
@@ -49,9 +49,18 @@ export class TranscriptManager {
49
49
  this.styleDialogVisible = false;
50
50
  this.styleDialogJustOpened = false;
51
51
 
52
+ // Language selector state
53
+ this.languageSelector = null;
54
+ this.currentTranscriptLanguage = null;
55
+ this.availableTranscriptLanguages = [];
56
+ this.languageSelectorHandler = null;
57
+
52
58
  // Load saved preferences from localStorage
53
59
  const savedPreferences = this.storage.getTranscriptPreferences();
54
60
 
61
+ // Autoscroll state (default: true)
62
+ this.autoscrollEnabled = savedPreferences?.autoscroll !== undefined ? savedPreferences.autoscroll : true;
63
+
55
64
  // Transcript styling options (with defaults, then player options, then saved preferences)
56
65
  this.transcriptStyle = {
57
66
  fontSize: savedPreferences?.fontSize || this.player.options.transcriptFontSize || '100%',
@@ -78,10 +87,16 @@ export class TranscriptManager {
78
87
  styleDialogKeydown: null
79
88
  };
80
89
 
90
+ // Timeout management (for cleanup)
91
+ this.timeouts = new Set();
92
+
81
93
  this.init();
82
94
  }
83
95
 
84
96
  init() {
97
+ // Set up metadata handling immediately (independent of transcript display)
98
+ this.setupMetadataHandlingOnLoad();
99
+
85
100
  // Listen for time updates to highlight active transcript entry
86
101
  this.player.on('timeupdate', this.handlers.timeupdate);
87
102
 
@@ -89,7 +104,7 @@ export class TranscriptManager {
89
104
  this.player.on('fullscreenchange', () => {
90
105
  if (this.isVisible) {
91
106
  // Add a small delay to ensure DOM has updated after fullscreen transition
92
- setTimeout(() => this.positionTranscript(), 100);
107
+ this.setManagedTimeout(() => this.positionTranscript(), 100);
93
108
  }
94
109
  });
95
110
  }
@@ -114,7 +129,7 @@ export class TranscriptManager {
114
129
  this.isVisible = true;
115
130
 
116
131
  // Focus the settings button for keyboard accessibility
117
- setTimeout(() => {
132
+ this.setManagedTimeout(() => {
118
133
  if (this.settingsButton) {
119
134
  this.settingsButton.focus();
120
135
  }
@@ -130,10 +145,10 @@ export class TranscriptManager {
130
145
  if (this.transcriptWindow) {
131
146
  this.transcriptWindow.style.display = 'flex';
132
147
  // Re-position after showing (in case window was resized while hidden)
133
- setTimeout(() => this.positionTranscript(), 0);
148
+ this.setManagedTimeout(() => this.positionTranscript(), 0);
134
149
 
135
150
  // Focus the settings button for keyboard accessibility
136
- setTimeout(() => {
151
+ this.setManagedTimeout(() => {
137
152
  if (this.settingsButton) {
138
153
  this.settingsButton.focus();
139
154
  }
@@ -227,8 +242,49 @@ export class TranscriptManager {
227
242
  textContent: i18n.t('transcript.title')
228
243
  });
229
244
 
245
+ // Autoscroll checkbox
246
+ const autoscrollLabel = DOMUtils.createElement('label', {
247
+ className: `${this.player.options.classPrefix}-transcript-autoscroll-label`,
248
+ attributes: {
249
+ 'title': i18n.t('transcript.autoscroll')
250
+ }
251
+ });
252
+
253
+ this.autoscrollCheckbox = DOMUtils.createElement('input', {
254
+ attributes: {
255
+ 'type': 'checkbox',
256
+ 'checked': this.autoscrollEnabled,
257
+ 'aria-label': i18n.t('transcript.autoscroll')
258
+ }
259
+ });
260
+
261
+ const autoscrollText = DOMUtils.createElement('span', {
262
+ textContent: i18n.t('transcript.autoscroll'),
263
+ className: `${this.player.options.classPrefix}-transcript-autoscroll-text`
264
+ });
265
+
266
+ autoscrollLabel.appendChild(this.autoscrollCheckbox);
267
+ autoscrollLabel.appendChild(autoscrollText);
268
+
269
+ // Handle autoscroll checkbox change
270
+ this.autoscrollCheckbox.addEventListener('change', (e) => {
271
+ this.autoscrollEnabled = e.target.checked;
272
+ this.saveAutoscrollPreference();
273
+ });
274
+
230
275
  this.headerLeft.appendChild(this.settingsButton);
231
276
  this.headerLeft.appendChild(title);
277
+ this.headerLeft.appendChild(autoscrollLabel);
278
+
279
+ // Language selector (will be populated after tracks are loaded)
280
+ this.languageSelector = DOMUtils.createElement('select', {
281
+ className: `${this.player.options.classPrefix}-transcript-language-select`,
282
+ attributes: {
283
+ 'aria-label': i18n.t('settings.language') || 'Language',
284
+ 'style': 'display: none;' // Hidden until we detect multiple languages
285
+ }
286
+ });
287
+ this.headerLeft.appendChild(this.languageSelector);
232
288
 
233
289
  const closeButton = DOMUtils.createElement('button', {
234
290
  className: `${this.player.options.classPrefix}-transcript-close`,
@@ -300,8 +356,10 @@ export class TranscriptManager {
300
356
  // Re-position on window resize (debounced)
301
357
  let resizeTimeout;
302
358
  this.handlers.resize = () => {
303
- clearTimeout(resizeTimeout);
304
- resizeTimeout = setTimeout(() => this.positionTranscript(), 100);
359
+ if (resizeTimeout) {
360
+ this.clearManagedTimeout(resizeTimeout);
361
+ }
362
+ resizeTimeout = this.setManagedTimeout(() => this.positionTranscript(), 100);
305
363
  };
306
364
  window.addEventListener('resize', this.handlers.resize);
307
365
  }
@@ -388,6 +446,84 @@ export class TranscriptManager {
388
446
  }
389
447
  }
390
448
 
449
+ /**
450
+ * Get available transcript languages from tracks
451
+ */
452
+ getAvailableTranscriptLanguages() {
453
+ const textTracks = this.player.textTracks;
454
+ const languages = new Map();
455
+
456
+ // Collect all caption/subtitle tracks with their languages
457
+ textTracks.forEach(track => {
458
+ if ((track.kind === 'captions' || track.kind === 'subtitles') && track.language) {
459
+ if (!languages.has(track.language)) {
460
+ languages.set(track.language, {
461
+ language: track.language,
462
+ label: track.label || track.language,
463
+ track: track
464
+ });
465
+ }
466
+ }
467
+ });
468
+
469
+ return Array.from(languages.values());
470
+ }
471
+
472
+ /**
473
+ * Update language selector dropdown
474
+ */
475
+ updateLanguageSelector() {
476
+ if (!this.languageSelector) return;
477
+
478
+ this.availableTranscriptLanguages = this.getAvailableTranscriptLanguages();
479
+
480
+ // Clear existing options
481
+ this.languageSelector.innerHTML = '';
482
+
483
+ // Only show selector if there are 2+ languages
484
+ if (this.availableTranscriptLanguages.length < 2) {
485
+ this.languageSelector.style.display = 'none';
486
+ return;
487
+ }
488
+
489
+ // Show selector and populate options
490
+ this.languageSelector.style.display = 'block';
491
+
492
+ this.availableTranscriptLanguages.forEach((langInfo, index) => {
493
+ const option = DOMUtils.createElement('option', {
494
+ textContent: langInfo.label,
495
+ attributes: {
496
+ 'value': langInfo.language
497
+ }
498
+ });
499
+ this.languageSelector.appendChild(option);
500
+ });
501
+
502
+ // Set current selection
503
+ if (this.currentTranscriptLanguage) {
504
+ this.languageSelector.value = this.currentTranscriptLanguage;
505
+ } else if (this.availableTranscriptLanguages.length > 0) {
506
+ // Default to first language or active track
507
+ const activeTrack = this.player.textTracks.find(
508
+ track => (track.kind === 'captions' || track.kind === 'subtitles') && track.mode === 'showing'
509
+ );
510
+ this.currentTranscriptLanguage = activeTrack ? activeTrack.language : this.availableTranscriptLanguages[0].language;
511
+ this.languageSelector.value = this.currentTranscriptLanguage;
512
+ }
513
+
514
+ // Remove existing change listener if any
515
+ if (this.languageSelectorHandler) {
516
+ this.languageSelector.removeEventListener('change', this.languageSelectorHandler);
517
+ }
518
+
519
+ // Handle language change
520
+ this.languageSelectorHandler = (e) => {
521
+ this.currentTranscriptLanguage = e.target.value;
522
+ this.loadTranscriptData();
523
+ };
524
+ this.languageSelector.addEventListener('change', this.languageSelectorHandler);
525
+ }
526
+
391
527
  /**
392
528
  * Load transcript data from caption/subtitle tracks
393
529
  */
@@ -396,13 +532,39 @@ export class TranscriptManager {
396
532
  this.transcriptContent.innerHTML = '';
397
533
 
398
534
  // Get all text tracks
399
- const textTracks = Array.from(this.player.element.textTracks);
535
+ const textTracks = this.player.textTracks;
536
+
537
+ // Find track for selected language, or default to first available
538
+ let captionTrack = null;
539
+ if (this.currentTranscriptLanguage) {
540
+ captionTrack = textTracks.find(
541
+ track => (track.kind === 'captions' || track.kind === 'subtitles') &&
542
+ track.language === this.currentTranscriptLanguage
543
+ );
544
+ }
545
+
546
+ // Fallback to first available caption/subtitle track
547
+ if (!captionTrack) {
548
+ captionTrack = textTracks.find(
549
+ track => track.kind === 'captions' || track.kind === 'subtitles'
550
+ );
551
+ if (captionTrack) {
552
+ this.currentTranscriptLanguage = captionTrack.language;
553
+ }
554
+ }
555
+
556
+ // Find description track matching the selected language
557
+ let descriptionTrack = null;
558
+ if (this.currentTranscriptLanguage) {
559
+ descriptionTrack = textTracks.find(
560
+ track => track.kind === 'descriptions' && track.language === this.currentTranscriptLanguage
561
+ );
562
+ }
563
+ // Fallback to first available description track if no match found
564
+ if (!descriptionTrack) {
565
+ descriptionTrack = textTracks.find(track => track.kind === 'descriptions');
566
+ }
400
567
 
401
- // Find different track types
402
- const captionTrack = textTracks.find(
403
- track => track.kind === 'captions' || track.kind === 'subtitles'
404
- );
405
- const descriptionTrack = textTracks.find(track => track.kind === 'descriptions');
406
568
  const metadataTrack = textTracks.find(track => track.kind === 'metadata');
407
569
 
408
570
  // We need at least one track type
@@ -443,7 +605,7 @@ export class TranscriptManager {
443
605
  });
444
606
 
445
607
  // Fallback timeout
446
- setTimeout(() => {
608
+ this.setManagedTimeout(() => {
447
609
  this.loadTranscriptData();
448
610
  }, 500);
449
611
 
@@ -489,28 +651,84 @@ export class TranscriptManager {
489
651
 
490
652
  // Apply current styles to newly loaded entries
491
653
  this.applyTranscriptStyles();
654
+
655
+ // Update language selector after loading
656
+ this.updateLanguageSelector();
657
+ }
658
+
659
+ /**
660
+ * Setup metadata handling on player load
661
+ * This runs independently of transcript loading
662
+ */
663
+ setupMetadataHandlingOnLoad() {
664
+ // Wait for metadata to be loaded
665
+ const setupMetadata = () => {
666
+ const textTracks = this.player.textTracks;
667
+ const metadataTrack = textTracks.find(track => track.kind === 'metadata');
668
+
669
+ if (metadataTrack) {
670
+ // Enable the metadata track so cuechange events fire
671
+ // Use 'hidden' mode so it doesn't display anything, but events still work
672
+ if (metadataTrack.mode === 'disabled') {
673
+ metadataTrack.mode = 'hidden';
674
+ }
675
+
676
+ // Check if we already added the listener
677
+ if (this.metadataCueChangeHandler) {
678
+ metadataTrack.removeEventListener('cuechange', this.metadataCueChangeHandler);
679
+ }
680
+
681
+ // Add event listener for cue changes
682
+ this.metadataCueChangeHandler = () => {
683
+ const activeCues = Array.from(metadataTrack.activeCues || []);
684
+ if (activeCues.length > 0) {
685
+ // Debug logging (can be removed in production)
686
+ if (this.player.options.debug) {
687
+ console.log('[VidPly Metadata] Active cues:', activeCues.map(c => ({
688
+ start: c.startTime,
689
+ end: c.endTime,
690
+ text: c.text
691
+ })));
692
+ }
693
+ }
694
+ activeCues.forEach(cue => {
695
+ this.handleMetadataCue(cue);
696
+ });
697
+ };
698
+
699
+ metadataTrack.addEventListener('cuechange', this.metadataCueChangeHandler);
700
+
701
+ // Debug: Log metadata track setup
702
+ if (this.player.options.debug) {
703
+ const cueCount = metadataTrack.cues ? metadataTrack.cues.length : 0;
704
+ console.log('[VidPly Metadata] Track enabled,', cueCount, 'cues available');
705
+ }
706
+ } else if (this.player.options.debug) {
707
+ console.warn('[VidPly Metadata] No metadata track found');
708
+ }
709
+ };
710
+
711
+ // Try immediately
712
+ setupMetadata();
713
+
714
+ // Also try after loadedmetadata event
715
+ this.player.on('loadedmetadata', setupMetadata);
492
716
  }
493
717
 
494
718
  /**
495
719
  * Setup metadata handling
496
720
  * Metadata cues are not displayed but can be used programmatically
721
+ * This is called when transcript data is loaded (for storing cues)
497
722
  */
498
723
  setupMetadataHandling() {
499
724
  if (!this.metadataCues || this.metadataCues.length === 0) {
500
725
  return;
501
726
  }
502
727
 
503
- // Listen for cuechange events on the metadata track to trigger custom actions
504
- const textTracks = Array.from(this.player.element.textTracks);
505
- const metadataTrack = textTracks.find(track => track.kind === 'metadata');
506
-
507
- if (metadataTrack) {
508
- metadataTrack.addEventListener('cuechange', () => {
509
- const activeCues = Array.from(metadataTrack.activeCues || []);
510
- activeCues.forEach(cue => {
511
- this.handleMetadataCue(cue);
512
- });
513
- });
728
+ // The actual event handling is set up in setupMetadataHandlingOnLoad()
729
+ // This method just stores the cues for reference
730
+ if (this.player.options.debug) {
731
+ console.log('[VidPly Metadata]', this.metadataCues.length, 'cues stored from transcript load');
514
732
  }
515
733
  }
516
734
 
@@ -521,6 +739,14 @@ export class TranscriptManager {
521
739
  handleMetadataCue(cue) {
522
740
  const text = cue.text.trim();
523
741
 
742
+ // Debug logging
743
+ if (this.player.options.debug) {
744
+ console.log('[VidPly Metadata] Processing cue:', {
745
+ time: cue.startTime,
746
+ text: text
747
+ });
748
+ }
749
+
524
750
  // Emit a generic metadata event that developers can listen to
525
751
  this.player.emit('metadata', {
526
752
  time: cue.startTime,
@@ -531,16 +757,39 @@ export class TranscriptManager {
531
757
 
532
758
  // Parse for specific commands (examples based on wwa_meta.vtt format)
533
759
  if (text.includes('PAUSE')) {
534
- // Emit pause suggestion event (don't auto-pause, let developer decide)
760
+ // Automatically pause the video
761
+ if (!this.player.state.paused) {
762
+ if (this.player.options.debug) {
763
+ console.log('[VidPly Metadata] Pausing video at', cue.startTime);
764
+ }
765
+ this.player.pause();
766
+ }
767
+ // Also emit event for developers who want to listen
535
768
  this.player.emit('metadata:pause', { time: cue.startTime, text: text });
536
769
  }
537
770
 
538
771
  // Parse for focus directives
539
772
  const focusMatch = text.match(/FOCUS:([\w#-]+)/);
540
773
  if (focusMatch) {
774
+ const targetSelector = focusMatch[1];
775
+ // Automatically focus the target element
776
+ const targetElement = document.querySelector(targetSelector);
777
+ if (targetElement) {
778
+ if (this.player.options.debug) {
779
+ console.log('[VidPly Metadata] Focusing element:', targetSelector);
780
+ }
781
+ // Use setTimeout to ensure DOM is ready
782
+ this.setManagedTimeout(() => {
783
+ targetElement.focus();
784
+ }, 10);
785
+ } else if (this.player.options.debug) {
786
+ console.warn('[VidPly Metadata] Element not found:', targetSelector);
787
+ }
788
+ // Also emit event for developers who want to listen
541
789
  this.player.emit('metadata:focus', {
542
790
  time: cue.startTime,
543
- target: focusMatch[1],
791
+ target: targetSelector,
792
+ element: targetElement,
544
793
  text: text
545
794
  });
546
795
  }
@@ -548,6 +797,9 @@ export class TranscriptManager {
548
797
  // Parse for hashtag references
549
798
  const hashtags = text.match(/#[\w-]+/g);
550
799
  if (hashtags) {
800
+ if (this.player.options.debug) {
801
+ console.log('[VidPly Metadata] Hashtags found:', hashtags);
802
+ }
551
803
  this.player.emit('metadata:hashtags', {
552
804
  time: cue.startTime,
553
805
  hashtags: hashtags,
@@ -668,7 +920,7 @@ export class TranscriptManager {
668
920
  * Scroll transcript window to show active entry
669
921
  */
670
922
  scrollToEntry(entryElement) {
671
- if (!this.transcriptContent) return;
923
+ if (!this.transcriptContent || !this.autoscrollEnabled) return;
672
924
 
673
925
  const contentRect = this.transcriptContent.getBoundingClientRect();
674
926
  const entryRect = entryElement.getBoundingClientRect();
@@ -683,6 +935,15 @@ export class TranscriptManager {
683
935
  });
684
936
  }
685
937
  }
938
+
939
+ /**
940
+ * Save autoscroll preference to localStorage
941
+ */
942
+ saveAutoscrollPreference() {
943
+ const savedPreferences = this.storage.getTranscriptPreferences() || {};
944
+ savedPreferences.autoscroll = this.autoscrollEnabled;
945
+ this.storage.saveTranscriptPreferences(savedPreferences);
946
+ }
686
947
 
687
948
  /**
688
949
  * Setup drag and drop functionality
@@ -702,6 +963,11 @@ export class TranscriptManager {
702
963
  return;
703
964
  }
704
965
 
966
+ // Don't drag if clicking on language selector
967
+ if (e.target.closest(`.${this.player.options.classPrefix}-transcript-language-select`)) {
968
+ return;
969
+ }
970
+
705
971
  // Don't drag if clicking on settings menu
706
972
  if (e.target.closest(`.${this.player.options.classPrefix}-transcript-settings-menu`)) {
707
973
  return;
@@ -738,6 +1004,11 @@ export class TranscriptManager {
738
1004
  return;
739
1005
  }
740
1006
 
1007
+ // Don't drag if touching language selector
1008
+ if (e.target.closest(`.${this.player.options.classPrefix}-transcript-language-select`)) {
1009
+ return;
1010
+ }
1011
+
741
1012
  // Don't drag if touching settings menu
742
1013
  if (e.target.closest(`.${this.player.options.classPrefix}-transcript-settings-menu`)) {
743
1014
  return;
@@ -1718,6 +1989,32 @@ export class TranscriptManager {
1718
1989
  });
1719
1990
  }
1720
1991
 
1992
+ /**
1993
+ * Set a managed timeout that will be cleaned up on destroy
1994
+ * @param {Function} callback - Callback function
1995
+ * @param {number} delay - Delay in milliseconds
1996
+ * @returns {number} Timeout ID
1997
+ */
1998
+ setManagedTimeout(callback, delay) {
1999
+ const timeoutId = setTimeout(() => {
2000
+ this.timeouts.delete(timeoutId);
2001
+ callback();
2002
+ }, delay);
2003
+ this.timeouts.add(timeoutId);
2004
+ return timeoutId;
2005
+ }
2006
+
2007
+ /**
2008
+ * Clear a managed timeout
2009
+ * @param {number} timeoutId - Timeout ID to clear
2010
+ */
2011
+ clearManagedTimeout(timeoutId) {
2012
+ if (timeoutId) {
2013
+ clearTimeout(timeoutId);
2014
+ this.timeouts.delete(timeoutId);
2015
+ }
2016
+ }
2017
+
1721
2018
  /**
1722
2019
  * Cleanup
1723
2020
  */
@@ -1785,6 +2082,10 @@ export class TranscriptManager {
1785
2082
  window.removeEventListener('resize', this.handlers.resize);
1786
2083
  }
1787
2084
 
2085
+ // Cleanup all managed timeouts
2086
+ this.timeouts.forEach(timeoutId => clearTimeout(timeoutId));
2087
+ this.timeouts.clear();
2088
+
1788
2089
  // Clear handlers
1789
2090
  this.handlers = null;
1790
2091