vidply 1.0.6 → 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.
@@ -15,6 +15,7 @@ import {VimeoRenderer} from '../renderers/VimeoRenderer.js';
15
15
  import {HLSRenderer} from '../renderers/HLSRenderer.js';
16
16
  import {createPlayOverlay} from '../icons/Icons.js';
17
17
  import {i18n} from '../i18n/i18n.js';
18
+ import {StorageManager} from '../utils/StorageManager.js';
18
19
 
19
20
  export class Player extends EventEmitter {
20
21
  constructor(element, options = {}) {
@@ -86,7 +87,7 @@ export class Player extends EventEmitter {
86
87
  captionsButton: true,
87
88
  transcriptButton: true,
88
89
  fullscreenButton: true,
89
- pipButton: true,
90
+ pipButton: false,
90
91
 
91
92
  // Seeking
92
93
  seekInterval: 10,
@@ -165,6 +166,17 @@ export class Player extends EventEmitter {
165
166
  ...options
166
167
  };
167
168
 
169
+ // Storage manager
170
+ this.storage = new StorageManager('vidply');
171
+
172
+ // Load saved player preferences
173
+ const savedPrefs = this.storage.getPlayerPreferences();
174
+ if (savedPrefs) {
175
+ if (savedPrefs.volume !== undefined) this.options.volume = savedPrefs.volume;
176
+ if (savedPrefs.playbackSpeed !== undefined) this.options.playbackSpeed = savedPrefs.playbackSpeed;
177
+ if (savedPrefs.muted !== undefined) this.options.muted = savedPrefs.muted;
178
+ }
179
+
168
180
  // State
169
181
  this.state = {
170
182
  ready: false,
@@ -192,6 +204,11 @@ export class Player extends EventEmitter {
192
204
  this.audioDescriptionSrc = this.options.audioDescriptionSrc;
193
205
  this.signLanguageSrc = this.options.signLanguageSrc;
194
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 = [];
195
212
 
196
213
  // Components
197
214
  this.container = null;
@@ -429,7 +446,77 @@ export class Player extends EventEmitter {
429
446
  throw new Error('No media source found');
430
447
  }
431
448
 
432
- // 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)
433
520
  if (!this.originalSrc) {
434
521
  this.originalSrc = src;
435
522
  }
@@ -642,6 +729,8 @@ export class Player extends EventEmitter {
642
729
  if (newVolume > 0 && this.state.muted) {
643
730
  this.state.muted = false;
644
731
  }
732
+
733
+ this.savePlayerPreferences();
645
734
  }
646
735
 
647
736
  getVolume() {
@@ -653,6 +742,7 @@ export class Player extends EventEmitter {
653
742
  this.renderer.setMuted(true);
654
743
  }
655
744
  this.state.muted = true;
745
+ this.savePlayerPreferences();
656
746
  this.emit('volumechange');
657
747
  }
658
748
 
@@ -661,6 +751,7 @@ export class Player extends EventEmitter {
661
751
  this.renderer.setMuted(false);
662
752
  }
663
753
  this.state.muted = false;
754
+ this.savePlayerPreferences();
664
755
  this.emit('volumechange');
665
756
  }
666
757
 
@@ -679,12 +770,22 @@ export class Player extends EventEmitter {
679
770
  this.renderer.setPlaybackSpeed(newSpeed);
680
771
  }
681
772
  this.state.playbackSpeed = newSpeed;
773
+ this.savePlayerPreferences();
682
774
  this.emit('playbackspeedchange', newSpeed);
683
775
  }
684
776
 
685
777
  getPlaybackSpeed() {
686
778
  return this.state.playbackSpeed;
687
779
  }
780
+
781
+ // Save player preferences to localStorage
782
+ savePlayerPreferences() {
783
+ this.storage.savePlayerPreferences({
784
+ volume: this.state.volume,
785
+ muted: this.state.muted,
786
+ playbackSpeed: this.state.playbackSpeed
787
+ });
788
+ }
688
789
 
689
790
  // Fullscreen
690
791
  enterFullscreen() {
@@ -777,10 +878,28 @@ export class Player extends EventEmitter {
777
878
  }
778
879
  }
779
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
+
780
895
  // Audio Description
781
896
  async enableAudioDescription() {
782
- if (!this.audioDescriptionSrc) {
783
- 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');
784
903
  return;
785
904
  }
786
905
 
@@ -788,8 +907,537 @@ export class Player extends EventEmitter {
788
907
  const currentTime = this.state.currentTime;
789
908
  const wasPlaying = this.state.playing;
790
909
 
910
+ // Store swapped tracks for transcript reload (declare at function scope)
911
+ let swappedTracksForTranscript = [];
912
+
791
913
  // Switch to audio-described version
792
- 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
+ }
793
1441
 
794
1442
  // Wait for new source to load
795
1443
  await new Promise((resolve) => {
@@ -800,6 +1448,20 @@ export class Player extends EventEmitter {
800
1448
  this.element.addEventListener('loadedmetadata', onLoadedMetadata);
801
1449
  });
802
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
+
803
1465
  // Restore playback position
804
1466
  this.seek(currentTime);
805
1467
 
@@ -807,6 +1469,252 @@ export class Player extends EventEmitter {
807
1469
  this.play();
808
1470
  }
809
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
+
810
1718
  this.state.audioDescriptionEnabled = true;
811
1719
  this.emit('audiodescriptionenabled');
812
1720
  }
@@ -820,8 +1728,78 @@ export class Player extends EventEmitter {
820
1728
  const currentTime = this.state.currentTime;
821
1729
  const wasPlaying = this.state.playing;
822
1730
 
823
- // Switch back to original version
824
- 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
+ }
825
1803
 
826
1804
  // Wait for new source to load
827
1805
  await new Promise((resolve) => {
@@ -839,15 +1817,60 @@ export class Player extends EventEmitter {
839
1817
  this.play();
840
1818
  }
841
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
+
842
1831
  this.state.audioDescriptionEnabled = false;
843
1832
  this.emit('audiodescriptiondisabled');
844
1833
  }
845
1834
 
846
1835
  async toggleAudioDescription() {
847
- if (this.state.audioDescriptionEnabled) {
848
- await this.disableAudioDescription();
849
- } else {
850
- await this.enableAudioDescription();
1836
+ // 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');
1839
+
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
1857
+ // Toggle description track
1858
+ if (descriptionTrack.mode === 'showing') {
1859
+ descriptionTrack.mode = 'hidden';
1860
+ this.state.audioDescriptionEnabled = false;
1861
+ this.emit('audiodescriptiondisabled');
1862
+ } else {
1863
+ descriptionTrack.mode = 'showing';
1864
+ this.state.audioDescriptionEnabled = true;
1865
+ this.emit('audiodescriptionenabled');
1866
+ }
1867
+ } else if (hasAudioDescriptionSrc) {
1868
+ // Use audio-described video source (no description track)
1869
+ if (this.state.audioDescriptionEnabled) {
1870
+ await this.disableAudioDescription();
1871
+ } else {
1872
+ await this.enableAudioDescription();
1873
+ }
851
1874
  }
852
1875
  }
853
1876
 
@@ -858,33 +1881,69 @@ export class Player extends EventEmitter {
858
1881
  return;
859
1882
  }
860
1883
 
861
- if (this.signLanguageVideo) {
1884
+ if (this.signLanguageWrapper) {
862
1885
  // Already exists, just show it
863
- this.signLanguageVideo.style.display = 'block';
1886
+ this.signLanguageWrapper.style.display = 'block';
864
1887
  this.state.signLanguageEnabled = true;
865
1888
  this.emit('signlanguageenabled');
866
1889
  return;
867
1890
  }
868
1891
 
1892
+ // Create wrapper container
1893
+ this.signLanguageWrapper = document.createElement('div');
1894
+ this.signLanguageWrapper.className = 'vidply-sign-language-wrapper';
1895
+ this.signLanguageWrapper.setAttribute('tabindex', '0');
1896
+ this.signLanguageWrapper.setAttribute('aria-label', 'Sign Language Video - Press D to drag with keyboard, R to resize');
1897
+
869
1898
  // Create sign language video element
870
1899
  this.signLanguageVideo = document.createElement('video');
871
1900
  this.signLanguageVideo.className = 'vidply-sign-language-video';
872
1901
  this.signLanguageVideo.src = this.signLanguageSrc;
873
1902
  this.signLanguageVideo.setAttribute('aria-label', i18n.t('player.signLanguage'));
1903
+ this.signLanguageVideo.muted = true; // Sign language video should be muted
874
1904
 
875
- // Set position based on options
876
- const position = this.options.signLanguagePosition || 'bottom-right';
877
- this.signLanguageVideo.classList.add(`vidply-sign-position-${position}`);
1905
+ // Create resize handles
1906
+ const resizeHandles = ['nw', 'ne', 'sw', 'se'].map(dir => {
1907
+ const handle = document.createElement('div');
1908
+ handle.className = `vidply-sign-resize-handle vidply-sign-resize-${dir}`;
1909
+ handle.setAttribute('data-direction', dir);
1910
+ handle.setAttribute('aria-label', `Resize ${dir.toUpperCase()}`);
1911
+ return handle;
1912
+ });
1913
+
1914
+ // Append video and handles to wrapper
1915
+ this.signLanguageWrapper.appendChild(this.signLanguageVideo);
1916
+ resizeHandles.forEach(handle => this.signLanguageWrapper.appendChild(handle));
1917
+
1918
+ // Set width FIRST to ensure proper dimensions
1919
+ const saved = this.storage.getSignLanguagePreferences();
1920
+ if (saved && saved.size && saved.size.width) {
1921
+ this.signLanguageWrapper.style.width = saved.size.width;
1922
+ } else {
1923
+ this.signLanguageWrapper.style.width = '280px'; // Default width
1924
+ }
1925
+ // Height is always auto to maintain aspect ratio
1926
+ this.signLanguageWrapper.style.height = 'auto';
1927
+
1928
+ // Position is always calculated fresh - use option or default to bottom-right
1929
+ this.signLanguageDesiredPosition = this.options.signLanguagePosition || 'bottom-right';
1930
+
1931
+ // Add to main player container (NOT videoWrapper) to avoid overflow:hidden clipping
1932
+ this.container.appendChild(this.signLanguageWrapper);
1933
+
1934
+ // Set position immediately after appending
1935
+ requestAnimationFrame(() => {
1936
+ this.constrainSignLanguagePosition();
1937
+ });
878
1938
 
879
1939
  // Sync with main video
880
- this.signLanguageVideo.muted = true; // Sign language video should be muted
881
1940
  this.signLanguageVideo.currentTime = this.state.currentTime;
882
1941
  if (!this.state.paused) {
883
1942
  this.signLanguageVideo.play();
884
1943
  }
885
1944
 
886
- // Add to video wrapper (so it overlays the video, not the entire container)
887
- this.videoWrapper.appendChild(this.signLanguageVideo);
1945
+ // Setup drag and resize
1946
+ this.setupSignLanguageInteraction();
888
1947
 
889
1948
  // Create bound handlers to store references for cleanup
890
1949
  this.signLanguageHandlers = {
@@ -921,8 +1980,8 @@ export class Player extends EventEmitter {
921
1980
  }
922
1981
 
923
1982
  disableSignLanguage() {
924
- if (this.signLanguageVideo) {
925
- this.signLanguageVideo.style.display = 'none';
1983
+ if (this.signLanguageWrapper) {
1984
+ this.signLanguageWrapper.style.display = 'none';
926
1985
  }
927
1986
  this.state.signLanguageEnabled = false;
928
1987
  this.emit('signlanguagedisabled');
@@ -936,6 +1995,318 @@ export class Player extends EventEmitter {
936
1995
  }
937
1996
  }
938
1997
 
1998
+ setupSignLanguageInteraction() {
1999
+ if (!this.signLanguageWrapper) return;
2000
+
2001
+ let isDragging = false;
2002
+ let isResizing = false;
2003
+ let resizeDirection = null;
2004
+ let startX = 0;
2005
+ let startY = 0;
2006
+ let startLeft = 0;
2007
+ let startTop = 0;
2008
+ let startWidth = 0;
2009
+ let startHeight = 0;
2010
+ let dragMode = false;
2011
+ let resizeMode = false;
2012
+
2013
+ // Mouse drag on video element
2014
+ const onMouseDownVideo = (e) => {
2015
+ if (e.target !== this.signLanguageVideo) return;
2016
+ e.preventDefault();
2017
+ isDragging = true;
2018
+ startX = e.clientX;
2019
+ startY = e.clientY;
2020
+ const rect = this.signLanguageWrapper.getBoundingClientRect();
2021
+ startLeft = rect.left;
2022
+ startTop = rect.top;
2023
+ this.signLanguageWrapper.classList.add('vidply-sign-dragging');
2024
+ };
2025
+
2026
+ // Mouse resize on handles
2027
+ const onMouseDownHandle = (e) => {
2028
+ if (!e.target.classList.contains('vidply-sign-resize-handle')) return;
2029
+ e.preventDefault();
2030
+ e.stopPropagation();
2031
+ isResizing = true;
2032
+ resizeDirection = e.target.getAttribute('data-direction');
2033
+ startX = e.clientX;
2034
+ startY = e.clientY;
2035
+ const rect = this.signLanguageWrapper.getBoundingClientRect();
2036
+ startLeft = rect.left;
2037
+ startTop = rect.top;
2038
+ startWidth = rect.width;
2039
+ startHeight = rect.height;
2040
+ this.signLanguageWrapper.classList.add('vidply-sign-resizing');
2041
+ };
2042
+
2043
+ const onMouseMove = (e) => {
2044
+ if (isDragging) {
2045
+ const deltaX = e.clientX - startX;
2046
+ const deltaY = e.clientY - startY;
2047
+
2048
+ // Get videoWrapper and container dimensions
2049
+ const videoWrapperRect = this.videoWrapper.getBoundingClientRect();
2050
+ const containerRect = this.container.getBoundingClientRect();
2051
+ const wrapperRect = this.signLanguageWrapper.getBoundingClientRect();
2052
+
2053
+ // Calculate videoWrapper position relative to container
2054
+ const videoWrapperLeft = videoWrapperRect.left - containerRect.left;
2055
+ const videoWrapperTop = videoWrapperRect.top - containerRect.top;
2056
+
2057
+ // Calculate new position (in client coordinates)
2058
+ let newLeft = startLeft + deltaX - containerRect.left;
2059
+ let newTop = startTop + deltaY - containerRect.top;
2060
+
2061
+ const controlsHeight = 95; // Height of controls when visible
2062
+
2063
+ // Constrain to videoWrapper bounds (ensuring it stays above controls)
2064
+ newLeft = Math.max(videoWrapperLeft, Math.min(newLeft, videoWrapperLeft + videoWrapperRect.width - wrapperRect.width));
2065
+ newTop = Math.max(videoWrapperTop, Math.min(newTop, videoWrapperTop + videoWrapperRect.height - wrapperRect.height - controlsHeight));
2066
+
2067
+ this.signLanguageWrapper.style.left = `${newLeft}px`;
2068
+ this.signLanguageWrapper.style.top = `${newTop}px`;
2069
+ this.signLanguageWrapper.style.right = 'auto';
2070
+ this.signLanguageWrapper.style.bottom = 'auto';
2071
+ // Remove position classes
2072
+ this.signLanguageWrapper.classList.remove(...Array.from(this.signLanguageWrapper.classList).filter(c => c.startsWith('vidply-sign-position-')));
2073
+ } else if (isResizing) {
2074
+ const deltaX = e.clientX - startX;
2075
+
2076
+ // Get videoWrapper and container dimensions
2077
+ const videoWrapperRect = this.videoWrapper.getBoundingClientRect();
2078
+ const containerRect = this.container.getBoundingClientRect();
2079
+
2080
+ let newWidth = startWidth;
2081
+ let newLeft = startLeft - containerRect.left;
2082
+
2083
+ // Only resize width, let height auto-adjust to maintain aspect ratio
2084
+ if (resizeDirection.includes('e')) {
2085
+ newWidth = Math.max(150, startWidth + deltaX);
2086
+ // Constrain width to not exceed videoWrapper right edge
2087
+ const maxWidth = (videoWrapperRect.right - startLeft);
2088
+ newWidth = Math.min(newWidth, maxWidth);
2089
+ }
2090
+ if (resizeDirection.includes('w')) {
2091
+ const proposedWidth = Math.max(150, startWidth - deltaX);
2092
+ const proposedLeft = startLeft + (startWidth - proposedWidth) - containerRect.left;
2093
+ // Constrain to not go beyond videoWrapper left edge
2094
+ const videoWrapperLeft = videoWrapperRect.left - containerRect.left;
2095
+ if (proposedLeft >= videoWrapperLeft) {
2096
+ newWidth = proposedWidth;
2097
+ newLeft = proposedLeft;
2098
+ }
2099
+ }
2100
+
2101
+ this.signLanguageWrapper.style.width = `${newWidth}px`;
2102
+ this.signLanguageWrapper.style.height = 'auto'; // Let video maintain aspect ratio
2103
+ if (resizeDirection.includes('w')) {
2104
+ this.signLanguageWrapper.style.left = `${newLeft}px`;
2105
+ }
2106
+ this.signLanguageWrapper.style.right = 'auto';
2107
+ this.signLanguageWrapper.style.bottom = 'auto';
2108
+ // Remove position classes
2109
+ this.signLanguageWrapper.classList.remove(...Array.from(this.signLanguageWrapper.classList).filter(c => c.startsWith('vidply-sign-position-')));
2110
+ }
2111
+ };
2112
+
2113
+ const onMouseUp = () => {
2114
+ if (isDragging || isResizing) {
2115
+ this.saveSignLanguagePreferences();
2116
+ }
2117
+ isDragging = false;
2118
+ isResizing = false;
2119
+ resizeDirection = null;
2120
+ this.signLanguageWrapper.classList.remove('vidply-sign-dragging', 'vidply-sign-resizing');
2121
+ };
2122
+
2123
+ // Keyboard controls
2124
+ const onKeyDown = (e) => {
2125
+ // Toggle drag mode with D key
2126
+ if (e.key === 'd' || e.key === 'D') {
2127
+ dragMode = !dragMode;
2128
+ resizeMode = false;
2129
+ this.signLanguageWrapper.classList.toggle('vidply-sign-keyboard-drag', dragMode);
2130
+ this.signLanguageWrapper.classList.remove('vidply-sign-keyboard-resize');
2131
+ e.preventDefault();
2132
+ return;
2133
+ }
2134
+
2135
+ // Toggle resize mode with R key
2136
+ if (e.key === 'r' || e.key === 'R') {
2137
+ resizeMode = !resizeMode;
2138
+ dragMode = false;
2139
+ this.signLanguageWrapper.classList.toggle('vidply-sign-keyboard-resize', resizeMode);
2140
+ this.signLanguageWrapper.classList.remove('vidply-sign-keyboard-drag');
2141
+ e.preventDefault();
2142
+ return;
2143
+ }
2144
+
2145
+ // Escape to exit modes
2146
+ if (e.key === 'Escape') {
2147
+ dragMode = false;
2148
+ resizeMode = false;
2149
+ this.signLanguageWrapper.classList.remove('vidply-sign-keyboard-drag', 'vidply-sign-keyboard-resize');
2150
+ e.preventDefault();
2151
+ return;
2152
+ }
2153
+
2154
+ // Arrow keys for drag/resize
2155
+ if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
2156
+ const step = e.shiftKey ? 10 : 5;
2157
+ const rect = this.signLanguageWrapper.getBoundingClientRect();
2158
+
2159
+ // Get videoWrapper and container bounds
2160
+ const videoWrapperRect = this.videoWrapper.getBoundingClientRect();
2161
+ const containerRect = this.container.getBoundingClientRect();
2162
+
2163
+ // Calculate videoWrapper position relative to container
2164
+ const videoWrapperLeft = videoWrapperRect.left - containerRect.left;
2165
+ const videoWrapperTop = videoWrapperRect.top - containerRect.top;
2166
+
2167
+ if (dragMode) {
2168
+ // Get current position relative to container
2169
+ let left = rect.left - containerRect.left;
2170
+ let top = rect.top - containerRect.top;
2171
+
2172
+ if (e.key === 'ArrowLeft') left -= step;
2173
+ if (e.key === 'ArrowRight') left += step;
2174
+ if (e.key === 'ArrowUp') top -= step;
2175
+ if (e.key === 'ArrowDown') top += step;
2176
+
2177
+ const controlsHeight = 95; // Height of controls when visible
2178
+
2179
+ // Constrain to videoWrapper bounds (ensuring it stays above controls)
2180
+ left = Math.max(videoWrapperLeft, Math.min(left, videoWrapperLeft + videoWrapperRect.width - rect.width));
2181
+ top = Math.max(videoWrapperTop, Math.min(top, videoWrapperTop + videoWrapperRect.height - rect.height - controlsHeight));
2182
+
2183
+ this.signLanguageWrapper.style.left = `${left}px`;
2184
+ this.signLanguageWrapper.style.top = `${top}px`;
2185
+ this.signLanguageWrapper.style.right = 'auto';
2186
+ this.signLanguageWrapper.style.bottom = 'auto';
2187
+ // Remove position classes
2188
+ this.signLanguageWrapper.classList.remove(...Array.from(this.signLanguageWrapper.classList).filter(c => c.startsWith('vidply-sign-position-')));
2189
+ this.saveSignLanguagePreferences();
2190
+ e.preventDefault();
2191
+ } else if (resizeMode) {
2192
+ let width = rect.width;
2193
+
2194
+ // Only adjust width, height will auto-adjust to maintain aspect ratio
2195
+ if (e.key === 'ArrowLeft') width -= step;
2196
+ if (e.key === 'ArrowRight') width += step;
2197
+ // Up/Down also adjusts width for simplicity
2198
+ if (e.key === 'ArrowUp') width += step;
2199
+ if (e.key === 'ArrowDown') width -= step;
2200
+
2201
+ // Constrain width
2202
+ width = Math.max(150, width);
2203
+ // Don't let it exceed videoWrapper width
2204
+ width = Math.min(width, videoWrapperRect.width);
2205
+
2206
+ this.signLanguageWrapper.style.width = `${width}px`;
2207
+ this.signLanguageWrapper.style.height = 'auto';
2208
+ this.saveSignLanguagePreferences();
2209
+ e.preventDefault();
2210
+ }
2211
+ }
2212
+ };
2213
+
2214
+ // Attach event listeners
2215
+ this.signLanguageVideo.addEventListener('mousedown', onMouseDownVideo);
2216
+ const handles = this.signLanguageWrapper.querySelectorAll('.vidply-sign-resize-handle');
2217
+ handles.forEach(handle => handle.addEventListener('mousedown', onMouseDownHandle));
2218
+ document.addEventListener('mousemove', onMouseMove);
2219
+ document.addEventListener('mouseup', onMouseUp);
2220
+ this.signLanguageWrapper.addEventListener('keydown', onKeyDown);
2221
+
2222
+ // Store for cleanup
2223
+ this.signLanguageInteractionHandlers = {
2224
+ mouseDownVideo: onMouseDownVideo,
2225
+ mouseDownHandle: onMouseDownHandle,
2226
+ mouseMove: onMouseMove,
2227
+ mouseUp: onMouseUp,
2228
+ keyDown: onKeyDown,
2229
+ handles
2230
+ };
2231
+ }
2232
+
2233
+ constrainSignLanguagePosition() {
2234
+ if (!this.signLanguageWrapper || !this.videoWrapper) return;
2235
+
2236
+ // Ensure width is set
2237
+ if (!this.signLanguageWrapper.style.width || this.signLanguageWrapper.style.width === '') {
2238
+ this.signLanguageWrapper.style.width = '280px'; // Default width
2239
+ }
2240
+
2241
+ // Get videoWrapper position relative to the player CONTAINER (where sign language video is attached)
2242
+ const videoWrapperRect = this.videoWrapper.getBoundingClientRect();
2243
+ const containerRect = this.container.getBoundingClientRect();
2244
+ const wrapperRect = this.signLanguageWrapper.getBoundingClientRect();
2245
+
2246
+ // Calculate videoWrapper's position and dimensions relative to container
2247
+ const videoWrapperLeft = videoWrapperRect.left - containerRect.left;
2248
+ const videoWrapperTop = videoWrapperRect.top - containerRect.top;
2249
+ const videoWrapperWidth = videoWrapperRect.width;
2250
+ const videoWrapperHeight = videoWrapperRect.height;
2251
+
2252
+ // Use estimated height if video hasn't loaded yet (16:9 aspect ratio)
2253
+ let wrapperWidth = wrapperRect.width || 280;
2254
+ let wrapperHeight = wrapperRect.height || ((280 * 9) / 16); // Estimate based on 16:9 aspect ratio
2255
+
2256
+ let left, top;
2257
+ const margin = 16; // Margin from edges
2258
+ const controlsHeight = 95; // Height of controls when visible
2259
+
2260
+ // Always calculate fresh position based on desired location (relative to videoWrapper)
2261
+ const position = this.signLanguageDesiredPosition || 'bottom-right';
2262
+
2263
+ switch (position) {
2264
+ case 'bottom-right':
2265
+ left = videoWrapperLeft + videoWrapperWidth - wrapperWidth - margin;
2266
+ top = videoWrapperTop + videoWrapperHeight - wrapperHeight - controlsHeight;
2267
+ break;
2268
+ case 'bottom-left':
2269
+ left = videoWrapperLeft + margin;
2270
+ top = videoWrapperTop + videoWrapperHeight - wrapperHeight - controlsHeight;
2271
+ break;
2272
+ case 'top-right':
2273
+ left = videoWrapperLeft + videoWrapperWidth - wrapperWidth - margin;
2274
+ top = videoWrapperTop + margin;
2275
+ break;
2276
+ case 'top-left':
2277
+ left = videoWrapperLeft + margin;
2278
+ top = videoWrapperTop + margin;
2279
+ break;
2280
+ default:
2281
+ left = videoWrapperLeft + videoWrapperWidth - wrapperWidth - margin;
2282
+ top = videoWrapperTop + videoWrapperHeight - wrapperHeight - controlsHeight;
2283
+ }
2284
+
2285
+ // Constrain to videoWrapper bounds (ensuring it stays above controls)
2286
+ left = Math.max(videoWrapperLeft, Math.min(left, videoWrapperLeft + videoWrapperWidth - wrapperWidth));
2287
+ top = Math.max(videoWrapperTop, Math.min(top, videoWrapperTop + videoWrapperHeight - wrapperHeight - controlsHeight));
2288
+
2289
+ // Apply constrained position
2290
+ this.signLanguageWrapper.style.left = `${left}px`;
2291
+ this.signLanguageWrapper.style.top = `${top}px`;
2292
+ this.signLanguageWrapper.style.right = 'auto';
2293
+ this.signLanguageWrapper.style.bottom = 'auto';
2294
+ // Remove position classes if any were applied
2295
+ this.signLanguageWrapper.classList.remove(...Array.from(this.signLanguageWrapper.classList).filter(c => c.startsWith('vidply-sign-position-')));
2296
+ }
2297
+
2298
+ saveSignLanguagePreferences() {
2299
+ if (!this.signLanguageWrapper) return;
2300
+
2301
+ // Only save width - position is always calculated fresh to bottom-right
2302
+ this.storage.saveSignLanguagePreferences({
2303
+ size: {
2304
+ width: this.signLanguageWrapper.style.width
2305
+ // Height is auto - maintained by aspect ratio
2306
+ }
2307
+ });
2308
+ }
2309
+
939
2310
  cleanupSignLanguage() {
940
2311
  // Remove event listeners
941
2312
  if (this.signLanguageHandlers) {
@@ -946,11 +2317,32 @@ export class Player extends EventEmitter {
946
2317
  this.signLanguageHandlers = null;
947
2318
  }
948
2319
 
949
- // Remove video element
950
- if (this.signLanguageVideo && this.signLanguageVideo.parentNode) {
951
- this.signLanguageVideo.pause();
952
- this.signLanguageVideo.src = '';
953
- this.signLanguageVideo.parentNode.removeChild(this.signLanguageVideo);
2320
+ // Remove interaction handlers
2321
+ if (this.signLanguageInteractionHandlers) {
2322
+ if (this.signLanguageVideo) {
2323
+ this.signLanguageVideo.removeEventListener('mousedown', this.signLanguageInteractionHandlers.mouseDownVideo);
2324
+ }
2325
+ if (this.signLanguageInteractionHandlers.handles) {
2326
+ this.signLanguageInteractionHandlers.handles.forEach(handle => {
2327
+ handle.removeEventListener('mousedown', this.signLanguageInteractionHandlers.mouseDownHandle);
2328
+ });
2329
+ }
2330
+ document.removeEventListener('mousemove', this.signLanguageInteractionHandlers.mouseMove);
2331
+ document.removeEventListener('mouseup', this.signLanguageInteractionHandlers.mouseUp);
2332
+ if (this.signLanguageWrapper) {
2333
+ this.signLanguageWrapper.removeEventListener('keydown', this.signLanguageInteractionHandlers.keyDown);
2334
+ }
2335
+ this.signLanguageInteractionHandlers = null;
2336
+ }
2337
+
2338
+ // Remove video and wrapper elements
2339
+ if (this.signLanguageWrapper && this.signLanguageWrapper.parentNode) {
2340
+ if (this.signLanguageVideo) {
2341
+ this.signLanguageVideo.pause();
2342
+ this.signLanguageVideo.src = '';
2343
+ }
2344
+ this.signLanguageWrapper.parentNode.removeChild(this.signLanguageWrapper);
2345
+ this.signLanguageWrapper = null;
954
2346
  this.signLanguageVideo = null;
955
2347
  }
956
2348
  }
@@ -1096,6 +2488,23 @@ export class Player extends EventEmitter {
1096
2488
  if (this.controlBar) {
1097
2489
  this.controlBar.updateFullscreenButton();
1098
2490
  }
2491
+
2492
+ // Reposition sign language video after fullscreen transition
2493
+ if (this.signLanguageWrapper && this.signLanguageWrapper.style.display !== 'none') {
2494
+ // Use setTimeout to ensure layout has updated after fullscreen transition
2495
+ // Longer delay to account for CSS transition animations and layout recalculation
2496
+ setTimeout(() => {
2497
+ // Use requestAnimationFrame to ensure the browser has fully rendered the layout
2498
+ requestAnimationFrame(() => {
2499
+ // Clear saved size and reset to default for the new container size
2500
+ this.storage.saveSignLanguagePreferences({ size: null });
2501
+ this.signLanguageDesiredPosition = 'bottom-right';
2502
+ // Reset to default width for the new container
2503
+ this.signLanguageWrapper.style.width = isFullscreen ? '400px' : '280px';
2504
+ this.constrainSignLanguagePosition();
2505
+ });
2506
+ }, 500);
2507
+ }
1099
2508
  }
1100
2509
  };
1101
2510