vidply 1.0.7 → 1.0.8

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.
@@ -204,6 +204,11 @@ export class Player extends EventEmitter {
204
204
  this.audioDescriptionSrc = this.options.audioDescriptionSrc;
205
205
  this.signLanguageSrc = this.options.signLanguageSrc;
206
206
  this.signLanguageVideo = null;
207
+ // Store references to source elements with audio description attributes
208
+ this.audioDescriptionSourceElement = null;
209
+ this.originalAudioDescriptionSource = null;
210
+ // Store caption tracks that should be swapped for audio description
211
+ this.audioDescriptionCaptionTracks = [];
207
212
 
208
213
  // Components
209
214
  this.container = null;
@@ -441,7 +446,77 @@ export class Player extends EventEmitter {
441
446
  throw new Error('No media source found');
442
447
  }
443
448
 
444
- // Store original source for audio description toggling
449
+ // Check for source elements with audio description attributes
450
+ const sourceElements = this.element.querySelectorAll('source');
451
+ for (const sourceEl of sourceElements) {
452
+ const descSrc = sourceEl.getAttribute('data-desc-src');
453
+ const origSrc = sourceEl.getAttribute('data-orig-src');
454
+
455
+ if (descSrc || origSrc) {
456
+ // Found a source element with audio description attributes
457
+ // Store the first one as reference, but we'll search all of them when toggling
458
+ if (!this.audioDescriptionSourceElement) {
459
+ this.audioDescriptionSourceElement = sourceEl;
460
+ }
461
+
462
+ if (origSrc) {
463
+ // Store the original src from the attribute for this source
464
+ if (!this.originalAudioDescriptionSource) {
465
+ this.originalAudioDescriptionSource = origSrc;
466
+ }
467
+ // Store the original src from the first source element that has data-orig-src
468
+ if (!this.originalSrc) {
469
+ this.originalSrc = origSrc;
470
+ }
471
+ } else {
472
+ // If data-orig-src is not set, use the current src attribute
473
+ const currentSrcAttr = sourceEl.getAttribute('src');
474
+ if (!this.originalAudioDescriptionSource && currentSrcAttr) {
475
+ this.originalAudioDescriptionSource = currentSrcAttr;
476
+ }
477
+ if (!this.originalSrc && currentSrcAttr) {
478
+ this.originalSrc = currentSrcAttr;
479
+ }
480
+ }
481
+
482
+ // Store audio description source from data-desc-src (use first one found)
483
+ if (descSrc && !this.audioDescriptionSrc) {
484
+ this.audioDescriptionSrc = descSrc;
485
+ }
486
+ // Continue checking all source elements to ensure we capture all audio description sources
487
+ }
488
+ }
489
+
490
+ // Check for caption/subtitle tracks with audio description versions
491
+ // Only tracks with explicit data-desc-src attribute are swapped (no auto-detection to avoid 404 errors)
492
+ // Description tracks (kind="descriptions") are NOT swapped - they're for transcripts
493
+ const trackElements = this.element.querySelectorAll('track');
494
+ trackElements.forEach(trackEl => {
495
+ const trackKind = trackEl.getAttribute('kind');
496
+ const trackDescSrc = trackEl.getAttribute('data-desc-src');
497
+
498
+ // Only handle caption/subtitle tracks (not description tracks)
499
+ // Description tracks stay as-is since they're for transcripts
500
+ // Include captions, subtitles, and chapters tracks that can be swapped for audio description
501
+ if (trackKind === 'captions' || trackKind === 'subtitles' || trackKind === 'chapters') {
502
+ if (trackDescSrc) {
503
+ // Found a track with explicit data-desc-src - this is the described version
504
+ this.audioDescriptionCaptionTracks.push({
505
+ trackElement: trackEl,
506
+ originalSrc: trackEl.getAttribute('src'),
507
+ describedSrc: trackDescSrc,
508
+ originalTrackSrc: trackEl.getAttribute('data-orig-src') || trackEl.getAttribute('src'),
509
+ explicit: true // Explicitly defined, so we should validate it
510
+ });
511
+ this.log(`Found explicit described ${trackKind} track: ${trackEl.getAttribute('src')} -> ${trackDescSrc}`);
512
+ }
513
+ // Note: Auto-detection disabled to avoid 404 console errors
514
+ // If you want described tracks, add data-desc-src attribute to the track element
515
+ }
516
+ // Description tracks (kind="descriptions") are ignored - they remain unchanged for transcripts
517
+ });
518
+
519
+ // Store original source for audio description toggling (fallback if not set above)
445
520
  if (!this.originalSrc) {
446
521
  this.originalSrc = src;
447
522
  }
@@ -803,10 +878,28 @@ export class Player extends EventEmitter {
803
878
  }
804
879
  }
805
880
 
881
+ /**
882
+ * Check if a track file exists
883
+ * @param {string} url - Track file URL
884
+ * @returns {Promise<boolean>} - True if file exists
885
+ */
886
+ async validateTrackExists(url) {
887
+ try {
888
+ const response = await fetch(url, { method: 'HEAD', cache: 'no-cache' });
889
+ return response.ok;
890
+ } catch (error) {
891
+ return false;
892
+ }
893
+ }
894
+
806
895
  // Audio Description
807
896
  async enableAudioDescription() {
808
- if (!this.audioDescriptionSrc) {
809
- console.warn('VidPly: No audio description source provided');
897
+ // 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'));
899
+ const hasTracksWithDesc = this.audioDescriptionCaptionTracks.length > 0;
900
+
901
+ if (!this.audioDescriptionSrc && !hasSourceElementsWithDesc && !hasTracksWithDesc) {
902
+ console.warn('VidPly: No audio description source, source elements, or tracks provided');
810
903
  return;
811
904
  }
812
905
 
@@ -814,8 +907,537 @@ export class Player extends EventEmitter {
814
907
  const currentTime = this.state.currentTime;
815
908
  const wasPlaying = this.state.playing;
816
909
 
910
+ // Store swapped tracks for transcript reload (declare at function scope)
911
+ let swappedTracksForTranscript = [];
912
+
817
913
  // Switch to audio-described version
818
- this.element.src = this.audioDescriptionSrc;
914
+ // If we have a source element with audio description attributes, update that instead
915
+ if (this.audioDescriptionSourceElement) {
916
+ const currentSrc = this.element.currentSrc || this.element.src;
917
+
918
+ // Find the source element that matches the currently active source
919
+ const sourceElements = Array.from(this.element.querySelectorAll('source'));
920
+ let sourceElementToUpdate = null;
921
+ let descSrc = this.audioDescriptionSrc;
922
+
923
+ for (const sourceEl of sourceElements) {
924
+ const sourceSrc = sourceEl.getAttribute('src');
925
+ const descSrcAttr = sourceEl.getAttribute('data-desc-src');
926
+
927
+ // Check if this source matches the current source (by filename)
928
+ // Match by full path or just filename
929
+ const sourceFilename = sourceSrc ? sourceSrc.split('/').pop() : '';
930
+ const currentFilename = currentSrc ? currentSrc.split('/').pop() : '';
931
+
932
+ if (currentSrc && (currentSrc === sourceSrc ||
933
+ currentSrc.includes(sourceSrc) ||
934
+ currentSrc.includes(sourceFilename) ||
935
+ (sourceFilename && currentFilename === sourceFilename))) {
936
+ sourceElementToUpdate = sourceEl;
937
+ if (descSrcAttr) {
938
+ descSrc = descSrcAttr;
939
+ } else if (sourceSrc) {
940
+ // If no data-desc-src, try to construct it from the source
941
+ // But prefer the stored audioDescriptionSrc if available
942
+ descSrc = this.audioDescriptionSrc || descSrc;
943
+ }
944
+ break;
945
+ }
946
+ }
947
+
948
+ // If we didn't find a match, use the stored source element
949
+ if (!sourceElementToUpdate) {
950
+ sourceElementToUpdate = this.audioDescriptionSourceElement;
951
+ // Ensure we have the correct descSrc from the stored element
952
+ const storedDescSrc = sourceElementToUpdate.getAttribute('data-desc-src');
953
+ if (storedDescSrc) {
954
+ descSrc = storedDescSrc;
955
+ }
956
+ }
957
+
958
+ // Swap caption tracks to described versions BEFORE loading
959
+ if (this.audioDescriptionCaptionTracks.length > 0) {
960
+ // Swap tracks: validate explicit tracks, but try auto-detected tracks without validation
961
+ // This avoids 404 errors while still allowing auto-detection to work
962
+ const validationPromises = this.audioDescriptionCaptionTracks.map(async (trackInfo) => {
963
+ if (trackInfo.trackElement && trackInfo.describedSrc) {
964
+ // Only validate explicitly defined tracks (to confirm they exist)
965
+ // Auto-detected tracks are used without validation (browser will handle missing files gracefully)
966
+ if (trackInfo.explicit === true) {
967
+ try {
968
+ const exists = await this.validateTrackExists(trackInfo.describedSrc);
969
+ return { trackInfo, exists };
970
+ } catch (error) {
971
+ // Silently handle validation errors
972
+ return { trackInfo, exists: false };
973
+ }
974
+ } else {
975
+ // This shouldn't happen since auto-detection is disabled
976
+ // But if it does, don't validate to avoid 404s
977
+ return { trackInfo, exists: false };
978
+ }
979
+ }
980
+ return { trackInfo, exists: false };
981
+ });
982
+
983
+ const validationResults = await Promise.all(validationPromises);
984
+ const tracksToSwap = validationResults.filter(result => result.exists);
985
+
986
+ if (tracksToSwap.length > 0) {
987
+ // Store original track modes before removing tracks
988
+ const trackModes = new Map();
989
+ tracksToSwap.forEach(({ trackInfo }) => {
990
+ const textTrack = trackInfo.trackElement.track;
991
+ if (textTrack) {
992
+ trackModes.set(trackInfo, {
993
+ wasShowing: textTrack.mode === 'showing',
994
+ wasHidden: textTrack.mode === 'hidden'
995
+ });
996
+ } else {
997
+ trackModes.set(trackInfo, {
998
+ wasShowing: false,
999
+ wasHidden: false
1000
+ });
1001
+ }
1002
+ });
1003
+
1004
+ // Store all track information before removing
1005
+ const tracksToReadd = tracksToSwap.map(({ trackInfo }) => {
1006
+ const oldSrc = trackInfo.trackElement.getAttribute('src');
1007
+ const parent = trackInfo.trackElement.parentNode;
1008
+ const nextSibling = trackInfo.trackElement.nextSibling;
1009
+
1010
+ // Store all attributes from the old track
1011
+ const attributes = {};
1012
+ Array.from(trackInfo.trackElement.attributes).forEach(attr => {
1013
+ attributes[attr.name] = attr.value;
1014
+ });
1015
+
1016
+ return {
1017
+ trackInfo,
1018
+ oldSrc,
1019
+ parent,
1020
+ nextSibling,
1021
+ attributes
1022
+ };
1023
+ });
1024
+
1025
+ // Remove ALL old tracks first to force browser to clear TextTrack objects
1026
+ tracksToReadd.forEach(({ trackInfo }) => {
1027
+ trackInfo.trackElement.remove();
1028
+ });
1029
+
1030
+ // Force browser to process the removal by calling load()
1031
+ this.element.load();
1032
+
1033
+ // Wait for browser to process the removal, then add new tracks
1034
+ setTimeout(() => {
1035
+ tracksToReadd.forEach(({ trackInfo, oldSrc, parent, nextSibling, attributes }) => {
1036
+ swappedTracksForTranscript.push(trackInfo);
1037
+
1038
+ // Create a completely new track element (not a clone) to force browser to create new TextTrack
1039
+ const newTrackElement = document.createElement('track');
1040
+ newTrackElement.setAttribute('src', trackInfo.describedSrc);
1041
+
1042
+ // Copy all attributes except src and data-desc-src
1043
+ Object.keys(attributes).forEach(attrName => {
1044
+ if (attrName !== 'src' && attrName !== 'data-desc-src') {
1045
+ newTrackElement.setAttribute(attrName, attributes[attrName]);
1046
+ }
1047
+ });
1048
+
1049
+ // Insert new track element
1050
+ if (nextSibling && nextSibling.parentNode) {
1051
+ parent.insertBefore(newTrackElement, nextSibling);
1052
+ } else {
1053
+ parent.appendChild(newTrackElement);
1054
+ }
1055
+
1056
+ // Update reference to the new track element
1057
+ trackInfo.trackElement = newTrackElement;
1058
+ });
1059
+
1060
+ // After all new tracks are added, force browser to reload media element again
1061
+ // This ensures new track elements are processed and new TextTrack objects are created
1062
+ this.element.load();
1063
+
1064
+ // Wait for loadedmetadata event before accessing new TextTrack objects
1065
+ const setupNewTracks = () => {
1066
+ // Wait a bit more for browser to fully process the new track elements
1067
+ setTimeout(() => {
1068
+ swappedTracksForTranscript.forEach((trackInfo) => {
1069
+ const trackElement = trackInfo.trackElement;
1070
+ const newTextTrack = trackElement.track;
1071
+
1072
+ if (newTextTrack) {
1073
+ // Get original mode from stored map
1074
+ const modeInfo = trackModes.get(trackInfo) || { wasShowing: false, wasHidden: false };
1075
+
1076
+ // Set mode to load the new track
1077
+ newTextTrack.mode = 'hidden'; // Use hidden to load cues without showing
1078
+
1079
+ // Restore original mode after track loads
1080
+ // Note: CaptionManager will handle enabling captions separately
1081
+ const restoreMode = () => {
1082
+ if (modeInfo.wasShowing) {
1083
+ // Set to hidden - CaptionManager will set it to showing when it enables
1084
+ newTextTrack.mode = 'hidden';
1085
+ } else if (modeInfo.wasHidden) {
1086
+ newTextTrack.mode = 'hidden';
1087
+ } else {
1088
+ newTextTrack.mode = 'disabled';
1089
+ }
1090
+ };
1091
+
1092
+ // Wait for track to load
1093
+ if (newTextTrack.readyState >= 2) { // LOADED
1094
+ restoreMode();
1095
+ } else {
1096
+ newTextTrack.addEventListener('load', restoreMode, { once: true });
1097
+ newTextTrack.addEventListener('error', restoreMode, { once: true });
1098
+ }
1099
+ }
1100
+ });
1101
+ }, 300); // Additional wait for browser to process track elements
1102
+ };
1103
+
1104
+ // Wait for loadedmetadata event which fires when browser processes track elements
1105
+ if (this.element.readyState >= 1) { // HAVE_METADATA
1106
+ // Already loaded, wait a bit and setup
1107
+ setTimeout(setupNewTracks, 200);
1108
+ } else {
1109
+ this.element.addEventListener('loadedmetadata', setupNewTracks, { once: true });
1110
+ // Fallback timeout
1111
+ setTimeout(setupNewTracks, 2000);
1112
+ }
1113
+ }, 100); // Wait 100ms after first load() before adding new tracks
1114
+
1115
+ const skippedCount = validationResults.length - tracksToSwap.length;
1116
+ }
1117
+ }
1118
+
1119
+ // Update all source elements that have data-desc-src to their described versions
1120
+ // Force browser to pick up changes by removing and re-adding source elements
1121
+ // 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'));
1123
+ const sourcesToUpdate = [];
1124
+
1125
+ allSourceElements.forEach((sourceEl) => {
1126
+ const descSrcAttr = sourceEl.getAttribute('data-desc-src');
1127
+ const currentSrc = sourceEl.getAttribute('src');
1128
+
1129
+ if (descSrcAttr) {
1130
+ const type = sourceEl.getAttribute('type');
1131
+ let origSrc = sourceEl.getAttribute('data-orig-src');
1132
+
1133
+ // Store current src as data-orig-src if not already set
1134
+ if (!origSrc) {
1135
+ origSrc = currentSrc;
1136
+ }
1137
+
1138
+ // Store info for re-adding with described src
1139
+ sourcesToUpdate.push({
1140
+ src: descSrcAttr, // Use described version
1141
+ type: type,
1142
+ origSrc: origSrc,
1143
+ descSrc: descSrcAttr
1144
+ });
1145
+ } else {
1146
+ // Source element without data-desc-src - keep as-is
1147
+ const type = sourceEl.getAttribute('type');
1148
+ const src = sourceEl.getAttribute('src');
1149
+ sourcesToUpdate.push({
1150
+ src: src,
1151
+ type: type,
1152
+ origSrc: null,
1153
+ descSrc: null
1154
+ });
1155
+ }
1156
+ });
1157
+
1158
+ // Remove all source elements
1159
+ allSourceElements.forEach(sourceEl => {
1160
+ sourceEl.remove();
1161
+ });
1162
+
1163
+ // Re-add them with updated src attributes (described versions)
1164
+ sourcesToUpdate.forEach(sourceInfo => {
1165
+ const newSource = document.createElement('source');
1166
+ newSource.setAttribute('src', sourceInfo.src);
1167
+ if (sourceInfo.type) {
1168
+ newSource.setAttribute('type', sourceInfo.type);
1169
+ }
1170
+ if (sourceInfo.origSrc) {
1171
+ newSource.setAttribute('data-orig-src', sourceInfo.origSrc);
1172
+ }
1173
+ if (sourceInfo.descSrc) {
1174
+ newSource.setAttribute('data-desc-src', sourceInfo.descSrc);
1175
+ }
1176
+ this.element.appendChild(newSource);
1177
+ });
1178
+
1179
+ // Force reload by calling load() on the element
1180
+ // This should pick up the new src attributes from the re-added source elements
1181
+ // and also reload the track elements
1182
+ this.element.load();
1183
+
1184
+ // Wait for new source to load
1185
+ await new Promise((resolve) => {
1186
+ const onLoadedMetadata = () => {
1187
+ this.element.removeEventListener('loadedmetadata', onLoadedMetadata);
1188
+ resolve();
1189
+ };
1190
+ this.element.addEventListener('loadedmetadata', onLoadedMetadata);
1191
+ });
1192
+
1193
+ // Wait a bit more for tracks to be recognized and loaded after video metadata loads
1194
+ await new Promise(resolve => setTimeout(resolve, 300));
1195
+
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
+ }
1208
+ }
1209
+
1210
+ // Restore playback position
1211
+ this.seek(currentTime);
1212
+
1213
+ if (wasPlaying) {
1214
+ this.play();
1215
+ }
1216
+
1217
+ // Update state and emit event
1218
+ this.state.audioDescriptionEnabled = true;
1219
+ this.emit('audiodescriptionenabled');
1220
+ } else {
1221
+ // Fallback to updating element src directly
1222
+ // Swap caption tracks to described versions BEFORE loading
1223
+ if (this.audioDescriptionCaptionTracks.length > 0) {
1224
+ // Swap tracks: validate explicit tracks, but try auto-detected tracks without validation
1225
+ const validationPromises = this.audioDescriptionCaptionTracks.map(async (trackInfo) => {
1226
+ if (trackInfo.trackElement && trackInfo.describedSrc) {
1227
+ // Only validate explicitly defined tracks
1228
+ // Auto-detected tracks are used without validation (no 404s)
1229
+ if (trackInfo.explicit === true) {
1230
+ try {
1231
+ const exists = await this.validateTrackExists(trackInfo.describedSrc);
1232
+ return { trackInfo, exists };
1233
+ } catch (error) {
1234
+ return { trackInfo, exists: false };
1235
+ }
1236
+ } else {
1237
+ // This shouldn't happen since auto-detection is disabled
1238
+ return { trackInfo, exists: false };
1239
+ }
1240
+ }
1241
+ return { trackInfo, exists: false };
1242
+ });
1243
+
1244
+ const validationResults = await Promise.all(validationPromises);
1245
+ const tracksToSwap = validationResults.filter(result => result.exists);
1246
+
1247
+ if (tracksToSwap.length > 0) {
1248
+ // Store original track modes before removing tracks
1249
+ const trackModes = new Map();
1250
+ tracksToSwap.forEach(({ trackInfo }) => {
1251
+ const textTrack = trackInfo.trackElement.track;
1252
+ if (textTrack) {
1253
+ trackModes.set(trackInfo, {
1254
+ wasShowing: textTrack.mode === 'showing',
1255
+ wasHidden: textTrack.mode === 'hidden'
1256
+ });
1257
+ } else {
1258
+ trackModes.set(trackInfo, {
1259
+ wasShowing: false,
1260
+ wasHidden: false
1261
+ });
1262
+ }
1263
+ });
1264
+
1265
+ // Store all track information before removing
1266
+ const tracksToReadd = tracksToSwap.map(({ trackInfo }) => {
1267
+ const oldSrc = trackInfo.trackElement.getAttribute('src');
1268
+ const parent = trackInfo.trackElement.parentNode;
1269
+ const nextSibling = trackInfo.trackElement.nextSibling;
1270
+
1271
+ // Store all attributes from the old track
1272
+ const attributes = {};
1273
+ Array.from(trackInfo.trackElement.attributes).forEach(attr => {
1274
+ attributes[attr.name] = attr.value;
1275
+ });
1276
+
1277
+ return {
1278
+ trackInfo,
1279
+ oldSrc,
1280
+ parent,
1281
+ nextSibling,
1282
+ attributes
1283
+ };
1284
+ });
1285
+
1286
+ // Remove ALL old tracks first to force browser to clear TextTrack objects
1287
+ tracksToReadd.forEach(({ trackInfo }) => {
1288
+ trackInfo.trackElement.remove();
1289
+ });
1290
+
1291
+ // Force browser to process the removal by calling load()
1292
+ this.element.load();
1293
+
1294
+ // Wait for browser to process the removal, then add new tracks
1295
+ setTimeout(() => {
1296
+ tracksToReadd.forEach(({ trackInfo, oldSrc, parent, nextSibling, attributes }) => {
1297
+ swappedTracksForTranscript.push(trackInfo);
1298
+
1299
+ // Create a completely new track element (not a clone) to force browser to create new TextTrack
1300
+ const newTrackElement = document.createElement('track');
1301
+ newTrackElement.setAttribute('src', trackInfo.describedSrc);
1302
+
1303
+ // Copy all attributes except src and data-desc-src
1304
+ Object.keys(attributes).forEach(attrName => {
1305
+ if (attrName !== 'src' && attrName !== 'data-desc-src') {
1306
+ newTrackElement.setAttribute(attrName, attributes[attrName]);
1307
+ }
1308
+ });
1309
+
1310
+ // Insert new track element
1311
+ if (nextSibling && nextSibling.parentNode) {
1312
+ parent.insertBefore(newTrackElement, nextSibling);
1313
+ } else {
1314
+ parent.appendChild(newTrackElement);
1315
+ }
1316
+
1317
+ // Update reference to the new track element
1318
+ trackInfo.trackElement = newTrackElement;
1319
+ });
1320
+
1321
+ // After all new tracks are added, force browser to reload media element again
1322
+ this.element.load();
1323
+
1324
+ // Wait for loadedmetadata event before accessing new TextTrack objects
1325
+ const setupNewTracks = () => {
1326
+ // Wait a bit more for browser to fully process the new track elements
1327
+ setTimeout(() => {
1328
+ swappedTracksForTranscript.forEach((trackInfo) => {
1329
+ const trackElement = trackInfo.trackElement;
1330
+ const newTextTrack = trackElement.track;
1331
+
1332
+ if (newTextTrack) {
1333
+ // Get original mode from stored map
1334
+ const modeInfo = trackModes.get(trackInfo) || { wasShowing: false, wasHidden: false };
1335
+
1336
+ // Set mode to load the new track
1337
+ newTextTrack.mode = 'hidden'; // Use hidden to load cues without showing
1338
+
1339
+ // Restore original mode after track loads
1340
+ const restoreMode = () => {
1341
+ if (modeInfo.wasShowing) {
1342
+ // Set to hidden - CaptionManager will set it to showing when it enables
1343
+ newTextTrack.mode = 'hidden';
1344
+ } else if (modeInfo.wasHidden) {
1345
+ newTextTrack.mode = 'hidden';
1346
+ } else {
1347
+ newTextTrack.mode = 'disabled';
1348
+ }
1349
+ };
1350
+
1351
+ // Wait for track to load
1352
+ if (newTextTrack.readyState >= 2) { // LOADED
1353
+ restoreMode();
1354
+ } else {
1355
+ newTextTrack.addEventListener('load', restoreMode, { once: true });
1356
+ newTextTrack.addEventListener('error', restoreMode, { once: true });
1357
+ }
1358
+ }
1359
+ });
1360
+ }, 300); // Additional wait for browser to process track elements
1361
+ };
1362
+
1363
+ // Wait for loadedmetadata event which fires when browser processes track elements
1364
+ if (this.element.readyState >= 1) { // HAVE_METADATA
1365
+ // Already loaded, wait a bit and setup
1366
+ setTimeout(setupNewTracks, 200);
1367
+ } else {
1368
+ this.element.addEventListener('loadedmetadata', setupNewTracks, { once: true });
1369
+ // Fallback timeout
1370
+ setTimeout(setupNewTracks, 2000);
1371
+ }
1372
+ }, 100); // Wait 100ms after first load() before adding new tracks
1373
+ }
1374
+ }
1375
+
1376
+ // Check if we have source elements with data-desc-src (fallback method)
1377
+ const fallbackSourceElements = Array.from(this.element.querySelectorAll('source'));
1378
+ const hasSourceElementsWithDesc = fallbackSourceElements.some(el => el.getAttribute('data-desc-src'));
1379
+
1380
+ if (hasSourceElementsWithDesc) {
1381
+ const fallbackSourcesToUpdate = [];
1382
+
1383
+ fallbackSourceElements.forEach((sourceEl) => {
1384
+ const descSrcAttr = sourceEl.getAttribute('data-desc-src');
1385
+ const currentSrc = sourceEl.getAttribute('src');
1386
+
1387
+ if (descSrcAttr) {
1388
+ const type = sourceEl.getAttribute('type');
1389
+ let origSrc = sourceEl.getAttribute('data-orig-src');
1390
+
1391
+ if (!origSrc) {
1392
+ origSrc = currentSrc;
1393
+ }
1394
+
1395
+ fallbackSourcesToUpdate.push({
1396
+ src: descSrcAttr,
1397
+ type: type,
1398
+ origSrc: origSrc,
1399
+ descSrc: descSrcAttr
1400
+ });
1401
+ } else {
1402
+ const type = sourceEl.getAttribute('type');
1403
+ const src = sourceEl.getAttribute('src');
1404
+ fallbackSourcesToUpdate.push({
1405
+ src: src,
1406
+ type: type,
1407
+ origSrc: null,
1408
+ descSrc: null
1409
+ });
1410
+ }
1411
+ });
1412
+
1413
+ // Remove all source elements
1414
+ fallbackSourceElements.forEach(sourceEl => {
1415
+ sourceEl.remove();
1416
+ });
1417
+
1418
+ // Re-add them with updated src attributes
1419
+ fallbackSourcesToUpdate.forEach(sourceInfo => {
1420
+ const newSource = document.createElement('source');
1421
+ newSource.setAttribute('src', sourceInfo.src);
1422
+ if (sourceInfo.type) {
1423
+ newSource.setAttribute('type', sourceInfo.type);
1424
+ }
1425
+ if (sourceInfo.origSrc) {
1426
+ newSource.setAttribute('data-orig-src', sourceInfo.origSrc);
1427
+ }
1428
+ if (sourceInfo.descSrc) {
1429
+ newSource.setAttribute('data-desc-src', sourceInfo.descSrc);
1430
+ }
1431
+ this.element.appendChild(newSource);
1432
+ });
1433
+
1434
+ // Force reload
1435
+ this.element.load();
1436
+ } else {
1437
+ // Fallback to updating element src directly (for videos without source elements)
1438
+ this.element.src = this.audioDescriptionSrc;
1439
+ }
1440
+ }
819
1441
 
820
1442
  // Wait for new source to load
821
1443
  await new Promise((resolve) => {
@@ -826,6 +1448,20 @@ export class Player extends EventEmitter {
826
1448
  this.element.addEventListener('loadedmetadata', onLoadedMetadata);
827
1449
  });
828
1450
 
1451
+ // Hide poster if video hasn't started yet (poster should hide when we seek or play)
1452
+ if (this.element.tagName === 'VIDEO' && currentTime === 0 && !wasPlaying) {
1453
+ // Force poster to hide by doing a minimal seek or loading first frame
1454
+ // Setting readyState check or seeking to 0.001 seconds will hide the poster
1455
+ if (this.element.readyState >= 1) { // HAVE_METADATA
1456
+ // Seek to a tiny fraction to trigger poster hiding without actually moving
1457
+ this.element.currentTime = 0.001;
1458
+ // Then seek back to 0 after a brief moment to ensure poster stays hidden
1459
+ setTimeout(() => {
1460
+ this.element.currentTime = 0;
1461
+ }, 10);
1462
+ }
1463
+ }
1464
+
829
1465
  // Restore playback position
830
1466
  this.seek(currentTime);
831
1467
 
@@ -833,6 +1469,252 @@ export class Player extends EventEmitter {
833
1469
  this.play();
834
1470
  }
835
1471
 
1472
+ // Reload CaptionManager tracks if tracks were swapped (so it has fresh references)
1473
+ if (swappedTracksForTranscript.length > 0 && this.captionManager) {
1474
+ // Store if captions were enabled and which track
1475
+ const wasCaptionsEnabled = this.state.captionsEnabled;
1476
+ let currentTrackInfo = null;
1477
+ if (this.captionManager.currentTrack) {
1478
+ const currentTrackIndex = this.captionManager.tracks.findIndex(t => t.track === this.captionManager.currentTrack.track);
1479
+ if (currentTrackIndex >= 0) {
1480
+ currentTrackInfo = {
1481
+ language: this.captionManager.tracks[currentTrackIndex].language,
1482
+ kind: this.captionManager.tracks[currentTrackIndex].kind
1483
+ };
1484
+ }
1485
+ }
1486
+
1487
+ // Wait a bit for new tracks to be available, then reload
1488
+ setTimeout(() => {
1489
+ // Reload tracks to get fresh references to new TextTrack objects
1490
+ this.captionManager.tracks = [];
1491
+ this.captionManager.loadTracks();
1492
+
1493
+ // Re-enable captions if they were enabled before
1494
+ if (wasCaptionsEnabled && currentTrackInfo && this.captionManager.tracks.length > 0) {
1495
+ // Find the track by language and kind to match the swapped track
1496
+ const matchingTrackIndex = this.captionManager.tracks.findIndex(t =>
1497
+ t.language === currentTrackInfo.language && t.kind === currentTrackInfo.kind
1498
+ );
1499
+
1500
+ if (matchingTrackIndex >= 0) {
1501
+ this.captionManager.enable(matchingTrackIndex);
1502
+ } else if (this.captionManager.tracks.length > 0) {
1503
+ // Fallback: enable first track
1504
+ this.captionManager.enable(0);
1505
+ }
1506
+ }
1507
+ }, 600); // Wait for tracks to be processed
1508
+ }
1509
+
1510
+ // Reload transcript if visible (after video metadata loaded, tracks should be available)
1511
+ // Reload regardless of whether caption tracks were swapped, in case tracks changed
1512
+ if (this.transcriptManager && this.transcriptManager.isVisible) {
1513
+ // Wait for tracks to load after source swap
1514
+ // If tracks were swapped, wait for them to load; otherwise wait a bit for any track changes
1515
+ const swappedTracks = typeof swappedTracksForTranscript !== 'undefined' ? swappedTracksForTranscript : [];
1516
+
1517
+ if (swappedTracks.length > 0) {
1518
+ // Wait for swapped tracks to load their new cues
1519
+ // Since we re-added track elements and called load(), wait for loadedmetadata event
1520
+ // which is when the browser processes track elements
1521
+ const onMetadataLoaded = () => {
1522
+ // Get fresh track references from the video element's textTracks collection
1523
+ // This ensures we get the actual textTrack objects that the browser created
1524
+ const allTextTracks = Array.from(this.element.textTracks);
1525
+
1526
+ // Find the tracks that match our swapped tracks by language and kind
1527
+ // Match by checking the track element's src attribute
1528
+ const freshTracks = swappedTracks.map((trackInfo) => {
1529
+ const trackEl = trackInfo.trackElement;
1530
+ const expectedSrc = trackEl.getAttribute('src');
1531
+ const srclang = trackEl.getAttribute('srclang');
1532
+ const kind = trackEl.getAttribute('kind');
1533
+
1534
+ // Find matching track in textTracks collection
1535
+ // First try to match by the track element reference
1536
+ let foundTrack = allTextTracks.find(track => trackEl.track === track);
1537
+
1538
+ // If not found, try matching by language and kind, but verify src
1539
+ if (!foundTrack) {
1540
+ foundTrack = allTextTracks.find(track => {
1541
+ if (track.language === srclang &&
1542
+ (track.kind === kind || (kind === 'captions' && track.kind === 'subtitles'))) {
1543
+ // Verify the src matches
1544
+ const trackElementForTrack = Array.from(this.element.querySelectorAll('track')).find(
1545
+ el => el.track === track
1546
+ );
1547
+ if (trackElementForTrack) {
1548
+ const actualSrc = trackElementForTrack.getAttribute('src');
1549
+ if (actualSrc === expectedSrc) {
1550
+ return true;
1551
+ }
1552
+ }
1553
+ }
1554
+ return false;
1555
+ });
1556
+ }
1557
+
1558
+ // Verify the track element's src matches what we expect
1559
+ if (foundTrack) {
1560
+ const trackElement = Array.from(this.element.querySelectorAll('track')).find(
1561
+ el => el.track === foundTrack
1562
+ );
1563
+ if (trackElement && trackElement.getAttribute('src') !== expectedSrc) {
1564
+ return null;
1565
+ }
1566
+ }
1567
+
1568
+ return foundTrack;
1569
+ }).filter(Boolean);
1570
+
1571
+ if (freshTracks.length === 0) {
1572
+ // Fallback: just reload after delay - transcript manager will find tracks itself
1573
+ setTimeout(() => {
1574
+ if (this.transcriptManager && this.transcriptManager.loadTranscriptData) {
1575
+ this.transcriptManager.loadTranscriptData();
1576
+ }
1577
+ }, 1000);
1578
+ return;
1579
+ }
1580
+
1581
+ // Ensure tracks are in hidden mode to load cues for transcript
1582
+ freshTracks.forEach(track => {
1583
+ if (track.mode === 'disabled') {
1584
+ track.mode = 'hidden';
1585
+ }
1586
+ });
1587
+
1588
+ let loadedCount = 0;
1589
+ const checkLoaded = () => {
1590
+ loadedCount++;
1591
+ if (loadedCount >= freshTracks.length) {
1592
+ // Give a bit more time for cues to be fully parsed
1593
+ // Also ensure we're getting the latest TextTrack references
1594
+ setTimeout(() => {
1595
+ if (this.transcriptManager && this.transcriptManager.loadTranscriptData) {
1596
+ // Force transcript manager to get fresh track references
1597
+ // Clear any cached track references by forcing a fresh read
1598
+ // The transcript manager will find tracks from this.element.textTracks
1599
+ // which should now have the new TextTrack objects with the described captions
1600
+
1601
+ // Verify the tracks have the correct src before reloading transcript
1602
+ const allTextTracks = Array.from(this.element.textTracks);
1603
+ const swappedTrackSrcs = swappedTracks.map(t => t.describedSrc);
1604
+ const hasCorrectTracks = freshTracks.some(track => {
1605
+ const trackEl = Array.from(this.element.querySelectorAll('track')).find(
1606
+ el => el.track === track
1607
+ );
1608
+ return trackEl && swappedTrackSrcs.includes(trackEl.getAttribute('src'));
1609
+ });
1610
+
1611
+ if (hasCorrectTracks || freshTracks.length > 0) {
1612
+ this.transcriptManager.loadTranscriptData();
1613
+ }
1614
+ }
1615
+ }, 800); // Increased wait time to ensure cues are fully loaded
1616
+ }
1617
+ };
1618
+
1619
+ freshTracks.forEach(track => {
1620
+ // Ensure track is in hidden mode to load cues (required for transcript)
1621
+ if (track.mode === 'disabled') {
1622
+ track.mode = 'hidden';
1623
+ }
1624
+
1625
+ // Check if track has cues loaded
1626
+ // 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
+ );
1630
+ const actualSrc = trackElementForTrack ? trackElementForTrack.getAttribute('src') : null;
1631
+
1632
+ // Find the expected src from swappedTracks
1633
+ const expectedTrackInfo = swappedTracks.find(t => {
1634
+ const tEl = t.trackElement;
1635
+ return tEl && (tEl.track === track ||
1636
+ (tEl.getAttribute('srclang') === track.language &&
1637
+ tEl.getAttribute('kind') === track.kind));
1638
+ });
1639
+ const expectedSrc = expectedTrackInfo ? expectedTrackInfo.describedSrc : null;
1640
+
1641
+ // Only proceed if the src matches (or we can't verify)
1642
+ if (expectedSrc && actualSrc && actualSrc !== expectedSrc) {
1643
+ // Wrong track, skip it
1644
+ checkLoaded(); // Count it as loaded to not block
1645
+ return;
1646
+ }
1647
+
1648
+ if (track.readyState >= 2 && track.cues && track.cues.length > 0) { // LOADED with cues
1649
+ // Track already loaded with cues
1650
+ checkLoaded();
1651
+ } else {
1652
+ // Force track to load by setting mode
1653
+ if (track.mode === 'disabled') {
1654
+ track.mode = 'hidden';
1655
+ }
1656
+
1657
+ // Wait for track to load
1658
+ const onTrackLoad = () => {
1659
+ // Wait a bit for cues to be fully parsed
1660
+ setTimeout(checkLoaded, 300);
1661
+ };
1662
+
1663
+ if (track.readyState >= 2) {
1664
+ // Already loaded, but might not have cues yet
1665
+ // Wait a bit and check again
1666
+ setTimeout(() => {
1667
+ if (track.cues && track.cues.length > 0) {
1668
+ checkLoaded();
1669
+ } else {
1670
+ // Still no cues, wait for load event
1671
+ track.addEventListener('load', onTrackLoad, { once: true });
1672
+ }
1673
+ }, 100);
1674
+ } else {
1675
+ track.addEventListener('load', onTrackLoad, { once: true });
1676
+ track.addEventListener('error', () => {
1677
+ // Even on error, try to reload transcript
1678
+ checkLoaded();
1679
+ }, { once: true });
1680
+ }
1681
+ }
1682
+ });
1683
+ };
1684
+
1685
+ // Wait for loadedmetadata event which fires when browser processes track elements
1686
+ // Also wait for the tracks to be fully processed after the second load()
1687
+ 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
1698
+ };
1699
+
1700
+ waitForTracks();
1701
+
1702
+ // Fallback timeout - longer to ensure tracks are loaded
1703
+ setTimeout(() => {
1704
+ if (this.transcriptManager && this.transcriptManager.loadTranscriptData) {
1705
+ this.transcriptManager.loadTranscriptData();
1706
+ }
1707
+ }, 5000);
1708
+ } else {
1709
+ // No tracks swapped, just wait a bit and reload
1710
+ setTimeout(() => {
1711
+ if (this.transcriptManager && this.transcriptManager.loadTranscriptData) {
1712
+ this.transcriptManager.loadTranscriptData();
1713
+ }
1714
+ }, 800);
1715
+ }
1716
+ }
1717
+
836
1718
  this.state.audioDescriptionEnabled = true;
837
1719
  this.emit('audiodescriptionenabled');
838
1720
  }
@@ -846,8 +1728,78 @@ export class Player extends EventEmitter {
846
1728
  const currentTime = this.state.currentTime;
847
1729
  const wasPlaying = this.state.playing;
848
1730
 
849
- // Switch back to original version
850
- this.element.src = this.originalSrc;
1731
+ // Swap caption/chapter tracks back to original versions BEFORE loading
1732
+ if (this.audioDescriptionCaptionTracks.length > 0) {
1733
+ this.audioDescriptionCaptionTracks.forEach(trackInfo => {
1734
+ if (trackInfo.trackElement && trackInfo.originalTrackSrc) {
1735
+ trackInfo.trackElement.setAttribute('src', trackInfo.originalTrackSrc);
1736
+ }
1737
+ });
1738
+ }
1739
+
1740
+ // Swap source elements back to original versions
1741
+ // Check if we have source elements with data-orig-src
1742
+ const allSourceElements = Array.from(this.element.querySelectorAll('source'));
1743
+ const hasSourceElementsToSwap = allSourceElements.some(el => el.getAttribute('data-orig-src'));
1744
+
1745
+ if (hasSourceElementsToSwap) {
1746
+ const sourcesToRestore = [];
1747
+
1748
+ allSourceElements.forEach((sourceEl) => {
1749
+ const origSrcAttr = sourceEl.getAttribute('data-orig-src');
1750
+ const descSrcAttr = sourceEl.getAttribute('data-desc-src');
1751
+
1752
+ if (origSrcAttr) {
1753
+ // Swap back to original src
1754
+ const type = sourceEl.getAttribute('type');
1755
+ sourcesToRestore.push({
1756
+ src: origSrcAttr, // Use original version
1757
+ type: type,
1758
+ origSrc: origSrcAttr,
1759
+ descSrc: descSrcAttr // Keep data-desc-src for future swaps
1760
+ });
1761
+ } else {
1762
+ // Keep as-is (no data-orig-src means it wasn't swapped)
1763
+ const type = sourceEl.getAttribute('type');
1764
+ const src = sourceEl.getAttribute('src');
1765
+ sourcesToRestore.push({
1766
+ src: src,
1767
+ type: type,
1768
+ origSrc: null,
1769
+ descSrc: descSrcAttr
1770
+ });
1771
+ }
1772
+ });
1773
+
1774
+ // Remove all source elements
1775
+ allSourceElements.forEach(sourceEl => {
1776
+ sourceEl.remove();
1777
+ });
1778
+
1779
+ // Re-add them with original src attributes
1780
+ sourcesToRestore.forEach(sourceInfo => {
1781
+ const newSource = document.createElement('source');
1782
+ newSource.setAttribute('src', sourceInfo.src);
1783
+ if (sourceInfo.type) {
1784
+ newSource.setAttribute('type', sourceInfo.type);
1785
+ }
1786
+ if (sourceInfo.origSrc) {
1787
+ newSource.setAttribute('data-orig-src', sourceInfo.origSrc);
1788
+ }
1789
+ if (sourceInfo.descSrc) {
1790
+ newSource.setAttribute('data-desc-src', sourceInfo.descSrc);
1791
+ }
1792
+ this.element.appendChild(newSource);
1793
+ });
1794
+
1795
+ // Force reload
1796
+ this.element.load();
1797
+ } else {
1798
+ // Fallback to updating element src directly (for videos without source elements)
1799
+ const originalSrcToUse = this.originalAudioDescriptionSource || this.originalSrc;
1800
+ this.element.src = originalSrcToUse;
1801
+ this.element.load();
1802
+ }
851
1803
 
852
1804
  // Wait for new source to load
853
1805
  await new Promise((resolve) => {
@@ -865,6 +1817,17 @@ export class Player extends EventEmitter {
865
1817
  this.play();
866
1818
  }
867
1819
 
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
+ }
1830
+
868
1831
  this.state.audioDescriptionEnabled = false;
869
1832
  this.emit('audiodescriptiondisabled');
870
1833
  }
@@ -874,7 +1837,23 @@ export class Player extends EventEmitter {
874
1837
  const textTracks = Array.from(this.element.textTracks || []);
875
1838
  const descriptionTrack = textTracks.find(track => track.kind === 'descriptions');
876
1839
 
877
- if (descriptionTrack) {
1840
+ // Check if we have audio-described video source (either from options or source elements with data-desc-src)
1841
+ const hasAudioDescriptionSrc = this.audioDescriptionSrc ||
1842
+ Array.from(this.element.querySelectorAll('source')).some(el => el.getAttribute('data-desc-src'));
1843
+
1844
+ if (descriptionTrack && hasAudioDescriptionSrc) {
1845
+ // We have both: toggle description track AND swap caption tracks/sources
1846
+ if (this.state.audioDescriptionEnabled) {
1847
+ // Disable: toggle description track off and swap captions/sources back
1848
+ descriptionTrack.mode = 'hidden';
1849
+ await this.disableAudioDescription();
1850
+ } else {
1851
+ // Enable: swap caption tracks/sources and toggle description track on
1852
+ await this.enableAudioDescription();
1853
+ descriptionTrack.mode = 'showing';
1854
+ }
1855
+ } else if (descriptionTrack) {
1856
+ // Only description track, no audio-described video source to swap
878
1857
  // Toggle description track
879
1858
  if (descriptionTrack.mode === 'showing') {
880
1859
  descriptionTrack.mode = 'hidden';
@@ -885,8 +1864,8 @@ export class Player extends EventEmitter {
885
1864
  this.state.audioDescriptionEnabled = true;
886
1865
  this.emit('audiodescriptionenabled');
887
1866
  }
888
- } else if (this.audioDescriptionSrc) {
889
- // Use audio-described video source
1867
+ } else if (hasAudioDescriptionSrc) {
1868
+ // Use audio-described video source (no description track)
890
1869
  if (this.state.audioDescriptionEnabled) {
891
1870
  await this.disableAudioDescription();
892
1871
  } else {