vidply 1.0.29 → 1.0.31

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.
@@ -8,6 +8,7 @@ import {createIconElement} from '../icons/Icons.js';
8
8
  import {i18n} from '../i18n/i18n.js';
9
9
  import {focusElement, focusFirstElement} from '../utils/FocusUtils.js';
10
10
  import {isMobile} from '../utils/PerformanceUtils.js';
11
+ import {captureVideoFrame} from '../utils/VideoFrameCapture.js';
11
12
 
12
13
  export class ControlBar {
13
14
  constructor(player) {
@@ -909,6 +910,21 @@ export class ControlBar {
909
910
  className: `${this.player.options.classPrefix}-progress-tooltip`
910
911
  });
911
912
 
913
+ // Preview thumbnail (for video only)
914
+ this.controls.progressPreview = DOMUtils.createElement('div', {
915
+ className: `${this.player.options.classPrefix}-progress-preview`,
916
+ attributes: {
917
+ 'aria-hidden': 'true'
918
+ }
919
+ });
920
+ this.controls.progressTooltip.appendChild(this.controls.progressPreview);
921
+
922
+ // Time text
923
+ this.controls.progressTooltipTime = DOMUtils.createElement('div', {
924
+ className: `${this.player.options.classPrefix}-progress-tooltip-time`
925
+ });
926
+ this.controls.progressTooltip.appendChild(this.controls.progressTooltipTime);
927
+
912
928
  progressContainer.appendChild(this.controls.buffered);
913
929
  progressContainer.appendChild(this.controls.played);
914
930
  this.controls.played.appendChild(this.controls.progressHandle);
@@ -916,10 +932,234 @@ export class ControlBar {
916
932
 
917
933
  this.controls.progress = progressContainer;
918
934
 
935
+ // Initialize preview functionality
936
+ this.initPreviewThumbnail();
937
+
919
938
  // Progress bar events
920
939
  this.setupProgressBarEvents();
921
940
  }
922
941
 
942
+ /**
943
+ * Initialize preview thumbnail functionality for HTML5 video
944
+ */
945
+ initPreviewThumbnail() {
946
+ this.previewThumbnailCache = new Map();
947
+ this.previewVideo = null;
948
+ this.currentPreviewTime = null;
949
+ this.previewThumbnailTimeout = null;
950
+ this.previewSupported = false;
951
+
952
+ // Check if preview is supported (HTML5 video only)
953
+ // Check if element is a video
954
+ const isVideo = this.player.element && this.player.element.tagName === 'VIDEO';
955
+
956
+ if (!isVideo) {
957
+ return;
958
+ }
959
+
960
+ // Check if renderer supports preview (HTML5Renderer or HLSRenderer with native support)
961
+ // We check if renderer has a media property that is a video element
962
+ // Note: Don't rely on constructor.name as it's minified in production builds
963
+ const renderer = this.player.renderer;
964
+ const hasVideoMedia = renderer && renderer.media && renderer.media.tagName === 'VIDEO';
965
+
966
+ // Check if it's HTML5Renderer by checking:
967
+ // 1. Has media property that is a video element
968
+ // 2. Media is the same as player.element (HTML5Renderer sets this.media = player.element)
969
+ // 3. Doesn't have hls property (HLSRenderer has hls property)
970
+ // 4. Has seek method (all renderers have this, but combined with above checks it's reliable)
971
+ const isHTML5Renderer = hasVideoMedia &&
972
+ renderer.media === this.player.element &&
973
+ !renderer.hls &&
974
+ typeof renderer.seek === 'function';
975
+
976
+ this.previewSupported = isHTML5Renderer && hasVideoMedia;
977
+
978
+ if (this.previewSupported) {
979
+ // Create a hidden video element for capturing frames
980
+ this.previewVideo = document.createElement('video');
981
+ this.previewVideo.muted = true;
982
+ this.previewVideo.preload = 'auto'; // Need more than metadata to capture frames
983
+ this.previewVideo.playsInline = true;
984
+ this.previewVideo.style.position = 'absolute';
985
+ this.previewVideo.style.visibility = 'hidden';
986
+ this.previewVideo.style.width = '1px';
987
+ this.previewVideo.style.height = '1px';
988
+ this.previewVideo.style.top = '-9999px';
989
+
990
+ // Copy source and attributes from main video
991
+ const mainVideo = renderer.media || this.player.element;
992
+ let videoSrc = null;
993
+
994
+ if (mainVideo.src) {
995
+ videoSrc = mainVideo.src;
996
+ } else {
997
+ const source = mainVideo.querySelector('source');
998
+ if (source) {
999
+ videoSrc = source.src;
1000
+ }
1001
+ }
1002
+
1003
+ if (!videoSrc) {
1004
+ this.player.log('No video source found for preview', 'warn');
1005
+ this.previewSupported = false;
1006
+ return;
1007
+ }
1008
+
1009
+ // Copy crossOrigin if set (important for CORS)
1010
+ if (mainVideo.crossOrigin) {
1011
+ this.previewVideo.crossOrigin = mainVideo.crossOrigin;
1012
+ }
1013
+
1014
+ // Handle errors gracefully
1015
+ this.previewVideo.addEventListener('error', (e) => {
1016
+ this.player.log('Preview video failed to load:', e, 'warn');
1017
+ this.previewSupported = false;
1018
+ });
1019
+
1020
+ // Wait for metadata to be loaded before using
1021
+ this.previewVideo.addEventListener('loadedmetadata', () => {
1022
+ this.previewVideoReady = true;
1023
+ }, { once: true });
1024
+
1025
+ // Append to player container (hidden) BEFORE setting src
1026
+ if (this.player.container) {
1027
+ this.player.container.appendChild(this.previewVideo);
1028
+ }
1029
+
1030
+ // Set source after appending to DOM
1031
+ this.previewVideo.src = videoSrc;
1032
+ this.previewVideoReady = false;
1033
+ }
1034
+ }
1035
+
1036
+ /**
1037
+ * Generate preview thumbnail for a specific time
1038
+ * @param {number} time - Time in seconds
1039
+ * @returns {Promise<string>} Data URL of the thumbnail
1040
+ */
1041
+ async generatePreviewThumbnail(time) {
1042
+ if (!this.previewSupported || !this.previewVideo) {
1043
+ return null;
1044
+ }
1045
+
1046
+ // Wait for preview video to be ready if not yet loaded
1047
+ if (!this.previewVideoReady) {
1048
+ if (this.previewVideo.readyState < 2) {
1049
+ // Wait for at least HAVE_CURRENT_DATA (2) to ensure we can capture frames
1050
+ await new Promise((resolve, reject) => {
1051
+ const timeout = setTimeout(() => {
1052
+ reject(new Error('Preview video data load timeout'));
1053
+ }, 10000);
1054
+
1055
+ const cleanup = () => {
1056
+ clearTimeout(timeout);
1057
+ this.previewVideo.removeEventListener('loadeddata', checkReady);
1058
+ this.previewVideo.removeEventListener('canplay', checkReady);
1059
+ this.previewVideo.removeEventListener('error', onError);
1060
+ };
1061
+
1062
+ const checkReady = () => {
1063
+ if (this.previewVideo.readyState >= 2) {
1064
+ cleanup();
1065
+ this.previewVideoReady = true;
1066
+ resolve();
1067
+ }
1068
+ };
1069
+
1070
+ const onError = () => {
1071
+ cleanup();
1072
+ reject(new Error('Preview video failed to load'));
1073
+ };
1074
+
1075
+ // Try loadeddata first (faster), fallback to canplay
1076
+ if (this.previewVideo.readyState >= 1) {
1077
+ this.previewVideo.addEventListener('loadeddata', checkReady);
1078
+ }
1079
+ this.previewVideo.addEventListener('canplay', checkReady);
1080
+ this.previewVideo.addEventListener('error', onError);
1081
+
1082
+ // If already ready, resolve immediately
1083
+ if (this.previewVideo.readyState >= 2) {
1084
+ checkReady();
1085
+ }
1086
+ }).catch(() => {
1087
+ this.previewSupported = false;
1088
+ return null;
1089
+ });
1090
+ } else {
1091
+ this.previewVideoReady = true;
1092
+ }
1093
+ }
1094
+
1095
+ // Check cache first
1096
+ const cacheKey = Math.floor(time);
1097
+ if (this.previewThumbnailCache.has(cacheKey)) {
1098
+ return this.previewThumbnailCache.get(cacheKey);
1099
+ }
1100
+
1101
+ // Use shared frame capture utility
1102
+ // Don't restore state since preview video is always muted and hidden
1103
+ const dataURL = await captureVideoFrame(this.previewVideo, time, {
1104
+ restoreState: false,
1105
+ quality: 0.8,
1106
+ maxWidth: 160,
1107
+ maxHeight: 90
1108
+ });
1109
+
1110
+ if (dataURL) {
1111
+ // Cache the thumbnail (limit cache size to 20 entries using LRU-like behavior)
1112
+ if (this.previewThumbnailCache.size >= 20) {
1113
+ // Delete oldest entry (first key in insertion order)
1114
+ const firstKey = this.previewThumbnailCache.keys().next().value;
1115
+ this.previewThumbnailCache.delete(firstKey);
1116
+ }
1117
+ this.previewThumbnailCache.set(cacheKey, dataURL);
1118
+ }
1119
+
1120
+ return dataURL;
1121
+ }
1122
+
1123
+ /**
1124
+ * Update preview thumbnail display
1125
+ * @param {number} time - Time in seconds
1126
+ */
1127
+ async updatePreviewThumbnail(time) {
1128
+ if (!this.previewSupported || !this.controls.progressPreview) {
1129
+ return;
1130
+ }
1131
+
1132
+ // Clear any pending updates
1133
+ if (this.previewThumbnailTimeout) {
1134
+ clearTimeout(this.previewThumbnailTimeout);
1135
+ }
1136
+
1137
+ // Debounce thumbnail generation to avoid excessive seeking
1138
+ this.previewThumbnailTimeout = setTimeout(async () => {
1139
+ try {
1140
+ const thumbnail = await this.generatePreviewThumbnail(time);
1141
+ if (thumbnail && this.controls.progressPreview) {
1142
+ // Set background image and make visible
1143
+ this.controls.progressPreview.style.backgroundImage = `url("${thumbnail}")`;
1144
+ this.controls.progressPreview.style.display = 'block';
1145
+ this.controls.progressPreview.style.backgroundRepeat = 'no-repeat';
1146
+ this.controls.progressPreview.style.backgroundPosition = 'center';
1147
+ } else {
1148
+ // Hide if thumbnail generation failed
1149
+ if (this.controls.progressPreview) {
1150
+ this.controls.progressPreview.style.display = 'none';
1151
+ }
1152
+ }
1153
+ this.currentPreviewTime = time;
1154
+ } catch (error) {
1155
+ this.player.log('Preview thumbnail update failed:', error, 'warn');
1156
+ if (this.controls.progressPreview) {
1157
+ this.controls.progressPreview.style.display = 'none';
1158
+ }
1159
+ }
1160
+ }, 100);
1161
+ }
1162
+
923
1163
  setupProgressBarEvents() {
924
1164
  const progress = this.controls.progress;
925
1165
 
@@ -954,15 +1194,28 @@ export class ControlBar {
954
1194
  progress.addEventListener('mousemove', (e) => {
955
1195
  if (!this.isDraggingProgress) {
956
1196
  const {time} = updateProgress(e.clientX);
957
- // Update tooltip text content instead of aria-label (divs shouldn't have aria-label)
958
- this.controls.progressTooltip.textContent = TimeUtils.formatTime(time);
959
- this.controls.progressTooltip.style.left = `${e.clientX - progress.getBoundingClientRect().left}px`;
1197
+ const rect = progress.getBoundingClientRect();
1198
+ const left = e.clientX - rect.left;
1199
+
1200
+ // Update tooltip time text
1201
+ this.controls.progressTooltipTime.textContent = TimeUtils.formatTime(time);
1202
+
1203
+ // Update tooltip position
1204
+ this.controls.progressTooltip.style.left = `${left}px`;
960
1205
  this.controls.progressTooltip.style.display = 'block';
1206
+
1207
+ // Update preview thumbnail (only if supported)
1208
+ if (this.previewSupported) {
1209
+ this.updatePreviewThumbnail(time);
1210
+ }
961
1211
  }
962
1212
  });
963
1213
 
964
1214
  progress.addEventListener('mouseleave', () => {
965
1215
  this.controls.progressTooltip.style.display = 'none';
1216
+ if (this.previewThumbnailTimeout) {
1217
+ clearTimeout(this.previewThumbnailTimeout);
1218
+ }
966
1219
  });
967
1220
 
968
1221
  // Keyboard navigation
@@ -2659,6 +2912,12 @@ export class ControlBar {
2659
2912
  this.updateDuration();
2660
2913
  this.ensureQualityButton();
2661
2914
  this.updateQualityIndicator();
2915
+ // Update preview video source when metadata loads (for playlists)
2916
+ this.updatePreviewVideoSource();
2917
+ });
2918
+ this.player.on('sourcechange', () => {
2919
+ // Update preview video source when source changes (for playlists)
2920
+ this.updatePreviewVideoSource();
2662
2921
  });
2663
2922
  this.player.on('volumechange', () => this.updateVolumeDisplay());
2664
2923
  this.player.on('progress', () => this.updateBuffered());
@@ -3320,6 +3579,66 @@ export class ControlBar {
3320
3579
  this.element.style.display = 'none';
3321
3580
  }
3322
3581
 
3582
+ /**
3583
+ * Update preview video source when player source changes (for playlists)
3584
+ * Also re-initializes if preview wasn't set up initially
3585
+ */
3586
+ updatePreviewVideoSource() {
3587
+ const renderer = this.player.renderer;
3588
+ if (!renderer || !renderer.media || renderer.media.tagName !== 'VIDEO') {
3589
+ return;
3590
+ }
3591
+
3592
+ // If preview wasn't initialized yet, try to initialize it now
3593
+ if (!this.previewSupported && !this.previewVideo) {
3594
+ this.initPreviewThumbnail();
3595
+ }
3596
+
3597
+ if (!this.previewSupported || !this.previewVideo) {
3598
+ return;
3599
+ }
3600
+
3601
+ const mainVideo = renderer.media;
3602
+ const newSrc = mainVideo.src || mainVideo.querySelector('source')?.src;
3603
+
3604
+ if (newSrc && this.previewVideo.src !== newSrc) {
3605
+ // Clear cache when source changes
3606
+ this.previewThumbnailCache.clear();
3607
+ this.previewVideoReady = false;
3608
+ this.previewVideo.src = newSrc;
3609
+
3610
+ // Copy crossOrigin if set
3611
+ if (mainVideo.crossOrigin) {
3612
+ this.previewVideo.crossOrigin = mainVideo.crossOrigin;
3613
+ }
3614
+
3615
+ // Wait for new source to load
3616
+ this.previewVideo.addEventListener('loadedmetadata', () => {
3617
+ this.previewVideoReady = true;
3618
+ }, { once: true });
3619
+ } else if (newSrc && !this.previewVideoReady && this.previewVideo.readyState >= 1) {
3620
+ // If source is the same but video is ready, mark as ready
3621
+ this.previewVideoReady = true;
3622
+ }
3623
+ }
3624
+
3625
+ /**
3626
+ * Cleanup preview thumbnail resources
3627
+ */
3628
+ cleanupPreviewThumbnail() {
3629
+ if (this.previewThumbnailTimeout) {
3630
+ clearTimeout(this.previewThumbnailTimeout);
3631
+ this.previewThumbnailTimeout = null;
3632
+ }
3633
+
3634
+ if (this.previewVideo && this.previewVideo.parentNode) {
3635
+ this.previewVideo.parentNode.removeChild(this.previewVideo);
3636
+ this.previewVideo = null;
3637
+ }
3638
+
3639
+ this.previewThumbnailCache.clear();
3640
+ }
3641
+
3323
3642
  destroy() {
3324
3643
  if (this.hideTimeout) {
3325
3644
  clearTimeout(this.hideTimeout);
@@ -3329,6 +3648,9 @@ export class ControlBar {
3329
3648
  this.overflowResizeObserver.disconnect();
3330
3649
  }
3331
3650
 
3651
+ // Cleanup preview thumbnail resources
3652
+ this.cleanupPreviewThumbnail();
3653
+
3332
3654
  if (this.element && this.element.parentNode) {
3333
3655
  this.element.parentNode.removeChild(this.element);
3334
3656
  }