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.
- package/README.md +19 -4
- package/dist/vidply.css +640 -25
- package/dist/vidply.esm.js +2208 -177
- package/dist/vidply.esm.js.map +4 -4
- package/dist/vidply.esm.min.js +6 -6
- package/dist/vidply.esm.min.meta.json +38 -15
- package/dist/vidply.js +2208 -177
- package/dist/vidply.js.map +4 -4
- package/dist/vidply.min.css +1 -1
- package/dist/vidply.min.js +6 -6
- package/dist/vidply.min.meta.json +38 -15
- package/package.json +2 -2
- package/src/controls/CaptionManager.js +30 -0
- package/src/controls/ControlBar.js +11 -4
- package/src/controls/SettingsDialog.js +3 -3
- package/src/controls/TranscriptManager.js +1147 -72
- package/src/core/Player.js +1435 -26
- package/src/i18n/translations.js +70 -15
- package/src/icons/Icons.js +8 -4
- package/src/styles/vidply.css +640 -25
- package/src/utils/StorageManager.js +156 -0
package/src/core/Player.js
CHANGED
|
@@ -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:
|
|
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
|
-
//
|
|
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 (
|
|
783
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
824
|
-
this.
|
|
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
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
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.
|
|
1884
|
+
if (this.signLanguageWrapper) {
|
|
862
1885
|
// Already exists, just show it
|
|
863
|
-
this.
|
|
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
|
-
//
|
|
876
|
-
const
|
|
877
|
-
|
|
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
|
-
//
|
|
887
|
-
this.
|
|
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.
|
|
925
|
-
this.
|
|
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
|
|
950
|
-
if (this.
|
|
951
|
-
this.signLanguageVideo
|
|
952
|
-
|
|
953
|
-
|
|
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
|
|