vidply 1.2.0 → 1.2.1

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.
Files changed (164) hide show
  1. package/README.md +946 -946
  2. package/dist/dev/{vidply.AudioDescriptionManager-R47Q4YR4.js → vidply.AudioDescriptionManager-6RFVF27Z.js} +275 -19
  3. package/dist/dev/vidply.AudioDescriptionManager-6RFVF27Z.js.map +7 -0
  4. package/dist/dev/{vidply.CaptionStyleMenu-7XHRBWUM.js → vidply.CaptionStyleMenu-R2SCQ6VP.js} +4 -4
  5. package/dist/dev/{vidply.DASHRenderer-PWEMC3BT.js → vidply.DASHRenderer-EOVEW73H.js} +3 -3
  6. package/dist/dev/vidply.DASHRenderer-EOVEW73H.js.map +7 -0
  7. package/dist/dev/{vidply.FloatingPlayerManager-PKNNNAS2.js → vidply.FloatingPlayerManager-V5XQWS75.js} +5 -5
  8. package/dist/dev/vidply.FloatingPlayerManager-V5XQWS75.js.map +7 -0
  9. package/dist/dev/{vidply.HLSRenderer-E7RQ74HI.js → vidply.HLSRenderer-ZBEFVH5W.js} +4 -4
  10. package/dist/dev/vidply.HLSRenderer-ZBEFVH5W.js.map +7 -0
  11. package/dist/dev/vidply.HTML5Renderer-P7ICKYU4.js +12 -0
  12. package/dist/dev/{vidply.SignLanguageManager-FET5FQZ7.js → vidply.SignLanguageManager-7IAJOVF6.js} +8 -8
  13. package/dist/dev/vidply.SignLanguageManager-7IAJOVF6.js.map +7 -0
  14. package/dist/dev/{vidply.SoundCloudRenderer-RVSJZODO.js → vidply.SoundCloudRenderer-OA7QQYGN.js} +3 -3
  15. package/dist/dev/vidply.SoundCloudRenderer-OA7QQYGN.js.map +7 -0
  16. package/dist/dev/{vidply.TranscriptManager-5GXOQCLX.js → vidply.TranscriptManager-BPM3T23L.js} +10 -10
  17. package/dist/dev/vidply.TranscriptManager-BPM3T23L.js.map +7 -0
  18. package/dist/dev/{vidply.VimeoRenderer-ER2LSBBW.js → vidply.VimeoRenderer-BRHFYPNJ.js} +3 -3
  19. package/dist/dev/vidply.VimeoRenderer-BRHFYPNJ.js.map +7 -0
  20. package/dist/dev/{vidply.YouTubeRenderer-NFTOWJAB.js → vidply.YouTubeRenderer-HRTN4EJ5.js} +3 -3
  21. package/dist/dev/vidply.YouTubeRenderer-HRTN4EJ5.js.map +7 -0
  22. package/dist/dev/{vidply.chunk-6RQA4ROD.js → vidply.chunk-5IK3IC5V.js} +2 -2
  23. package/dist/dev/{vidply.chunk-JJ47NO24.js → vidply.chunk-C55OBT5H.js} +2 -2
  24. package/dist/dev/vidply.chunk-C55OBT5H.js.map +7 -0
  25. package/dist/dev/{vidply.chunk-DZSDO3B7.js → vidply.chunk-HOYEMJSD.js} +2 -2
  26. package/dist/dev/{vidply.chunk-VMSQAZM7.js → vidply.chunk-JAKZNGOR.js} +2 -2
  27. package/dist/dev/{vidply.chunk-64WXMTGL.js → vidply.chunk-LDVFAFGC.js} +5 -5
  28. package/dist/dev/vidply.chunk-LDVFAFGC.js.map +7 -0
  29. package/dist/dev/{vidply.chunk-RZWZGDV7.js → vidply.chunk-NSMAZCMB.js} +6 -6
  30. package/dist/dev/vidply.chunk-NSMAZCMB.js.map +7 -0
  31. package/dist/dev/{vidply.chunk-KI7C3QVL.js → vidply.chunk-RQG3MA7W.js} +2 -2
  32. package/dist/dev/vidply.chunk-RQG3MA7W.js.map +7 -0
  33. package/dist/dev/{vidply.chunk-BNFIUL2Z.js → vidply.chunk-UHMVDBDB.js} +3 -3
  34. package/dist/dev/vidply.chunk-UHMVDBDB.js.map +7 -0
  35. package/dist/dev/{vidply.chunk-NQPURMQY.js → vidply.chunk-W5BR65JY.js} +6 -6
  36. package/dist/dev/vidply.chunk-W5BR65JY.js.map +7 -0
  37. package/dist/dev/{vidply.chunk-W25HUCHS.js → vidply.chunk-XAK3DPTO.js} +2 -2
  38. package/dist/dev/vidply.chunk-XAK3DPTO.js.map +7 -0
  39. package/dist/dev/{vidply.chunk-SK4V2DAU.js → vidply.chunk-YVCYQZJI.js} +3 -3
  40. package/dist/dev/{vidply.de-4YAT7FFM.js → vidply.de-5W5LBFSJ.js} +2 -2
  41. package/dist/dev/vidply.de-5W5LBFSJ.js.map +7 -0
  42. package/dist/dev/{vidply.es-LEWIRUBH.js → vidply.es-SHDAT6L6.js} +2 -2
  43. package/dist/dev/vidply.es-SHDAT6L6.js.map +7 -0
  44. package/dist/dev/vidply.esm.js +73 -34
  45. package/dist/dev/vidply.esm.js.map +2 -2
  46. package/dist/dev/{vidply.fr-Q4GV2NOE.js → vidply.fr-2JANOMNB.js} +2 -2
  47. package/dist/dev/vidply.fr-2JANOMNB.js.map +7 -0
  48. package/dist/dev/{vidply.ja-S6ITA6WU.js → vidply.ja-WWMSTEJH.js} +2 -2
  49. package/dist/dev/vidply.ja-WWMSTEJH.js.map +7 -0
  50. package/dist/legacy/vidply.js +345 -32
  51. package/dist/legacy/vidply.js.map +3 -3
  52. package/dist/legacy/vidply.min.js +2 -2
  53. package/dist/legacy/vidply.min.meta.json +66 -47
  54. package/dist/prod/vidply.AudioDescriptionManager-YTDVDQBO.min.js +6 -0
  55. package/dist/prod/{vidply.CaptionStyleMenu-M3KVQWB5.min.js → vidply.CaptionStyleMenu-2AFF3HF2.min.js} +2 -2
  56. package/dist/prod/{vidply.DASHRenderer-DN3S27AV.min.js → vidply.DASHRenderer-URDOTOKS.min.js} +2 -2
  57. package/dist/prod/{vidply.FloatingPlayerManager-SKAZ4J43.min.js → vidply.FloatingPlayerManager-QUBFK7CQ.min.js} +2 -2
  58. package/dist/prod/{vidply.HLSRenderer-62HH4YTK.min.js → vidply.HLSRenderer-46KNUVAB.min.js} +2 -2
  59. package/dist/prod/vidply.HTML5Renderer-7TQDMIGY.min.js +6 -0
  60. package/dist/prod/{vidply.SignLanguageManager-V3OBTSSF.min.js → vidply.SignLanguageManager-H4C36EVL.min.js} +2 -2
  61. package/dist/prod/{vidply.SoundCloudRenderer-CXRVZIAJ.min.js → vidply.SoundCloudRenderer-OYSWMW7D.min.js} +2 -2
  62. package/dist/prod/{vidply.TranscriptManager-SYM3DFUY.min.js → vidply.TranscriptManager-BA3DAS63.min.js} +2 -2
  63. package/dist/prod/{vidply.VimeoRenderer-72TWRYFQ.min.js → vidply.VimeoRenderer-NF3COTHU.min.js} +2 -2
  64. package/dist/prod/{vidply.YouTubeRenderer-UMHEV3KD.min.js → vidply.YouTubeRenderer-UCQKYIJ4.min.js} +2 -2
  65. package/dist/prod/{vidply.chunk-Z3YDSA7O.min.js → vidply.chunk-2HNCP64N.min.js} +1 -1
  66. package/dist/prod/{vidply.chunk-EKEYJWMG.min.js → vidply.chunk-6ECBIYVI.min.js} +2 -2
  67. package/dist/prod/{vidply.chunk-YEENJS3Q.min.js → vidply.chunk-7S5AFTST.min.js} +2 -2
  68. package/dist/prod/{vidply.chunk-P4UXOHWA.min.js → vidply.chunk-EUOA2DJK.min.js} +1 -1
  69. package/dist/prod/{vidply.chunk-2TB6MUHO.min.js → vidply.chunk-G6KGXNEW.min.js} +1 -1
  70. package/dist/prod/{vidply.chunk-ABPXVDLZ.min.js → vidply.chunk-JPMAK3NS.min.js} +1 -1
  71. package/dist/prod/{vidply.chunk-P3PQBY7N.min.js → vidply.chunk-L4O44K63.min.js} +1 -1
  72. package/dist/prod/{vidply.chunk-WX3MVEAL.min.js → vidply.chunk-M76ABMKB.min.js} +1 -1
  73. package/dist/prod/{vidply.chunk-TM6AXJVD.min.js → vidply.chunk-WRVDVD33.min.js} +2 -2
  74. package/dist/prod/{vidply.chunk-RI3X7WHK.min.js → vidply.chunk-X2FQEQYR.min.js} +2 -2
  75. package/dist/prod/{vidply.chunk-UK7YGOB5.min.js → vidply.chunk-XXUH7ADU.min.js} +2 -2
  76. package/dist/prod/{vidply.de-BCHXRQ53.min.js → vidply.de-JMCATTKZ.min.js} +1 -1
  77. package/dist/prod/{vidply.es-PICAKMZD.min.js → vidply.es-YVG37V7Z.min.js} +1 -1
  78. package/dist/prod/vidply.esm.min.js +2 -2
  79. package/dist/prod/{vidply.fr-ZILI7CDR.min.js → vidply.fr-OGKGN2R4.min.js} +1 -1
  80. package/dist/prod/{vidply.ja-5WQ4OK4J.min.js → vidply.ja-Z6DS7QLS.min.js} +1 -1
  81. package/dist/vidply.css +5796 -5796
  82. package/dist/vidply.esm.min.meta.json +148 -135
  83. package/package.json +7 -7
  84. package/src/controls/CaptionManager.ts +598 -598
  85. package/src/controls/ControlBar.ts +4159 -4159
  86. package/src/controls/KeyboardManager.ts +383 -383
  87. package/src/controls/SettingsDialog.ts +488 -488
  88. package/src/controls/TranscriptManager.ts +2426 -2426
  89. package/src/core/AudioDescriptionManager.ts +863 -774
  90. package/src/core/DescriptionSpeechManager.ts +232 -0
  91. package/src/core/FloatingPlayerManager.ts +674 -674
  92. package/src/core/LazyInit.ts +80 -80
  93. package/src/core/MetadataAlertsManager.ts +514 -514
  94. package/src/core/Player.ts +2915 -2872
  95. package/src/core/PosterManager.ts +169 -169
  96. package/src/core/PseudoFullscreen.ts +179 -179
  97. package/src/core/ResponsiveManager.ts +231 -231
  98. package/src/core/ResumeManager.ts +236 -236
  99. package/src/core/SignLanguageManager.ts +1308 -1308
  100. package/src/core/ThemeManager.ts +157 -157
  101. package/src/features/PlaylistManager.ts +1838 -1827
  102. package/src/i18n/i18n.ts +223 -223
  103. package/src/i18n/languages/de.ts +217 -217
  104. package/src/i18n/languages/en.ts +218 -218
  105. package/src/i18n/languages/es.ts +214 -214
  106. package/src/i18n/languages/fr.ts +214 -214
  107. package/src/i18n/languages/ja.ts +214 -214
  108. package/src/icons/Icons.ts +167 -167
  109. package/src/index.ts +196 -193
  110. package/src/renderers/DASHRenderer.ts +1077 -1077
  111. package/src/renderers/HLSRenderer.ts +944 -944
  112. package/src/renderers/HTML5Renderer.ts +389 -389
  113. package/src/renderers/SoundCloudRenderer.ts +437 -437
  114. package/src/renderers/VimeoRenderer.ts +298 -298
  115. package/src/renderers/YouTubeRenderer.ts +290 -290
  116. package/src/styles/vidply.css +5796 -5796
  117. package/src/types/events.ts +124 -122
  118. package/src/types/globals.d.ts +313 -313
  119. package/src/types/options.ts +185 -182
  120. package/src/types/renderer.ts +58 -58
  121. package/src/utils/DOMUtils.ts +281 -281
  122. package/src/utils/DownloadInfo.ts +171 -171
  123. package/src/utils/DraggablePanel.ts +539 -539
  124. package/src/utils/DraggablePanelMenu.ts +195 -195
  125. package/src/utils/DraggableResizable.ts +997 -997
  126. package/src/utils/EventEmitter.ts +59 -59
  127. package/src/utils/FormUtils.ts +95 -95
  128. package/src/utils/MenuFactory.ts +483 -483
  129. package/src/utils/Sanitize.ts +70 -70
  130. package/src/utils/SettingsMenuFactory.ts +289 -289
  131. package/src/utils/StorageManager.ts +287 -287
  132. package/src/utils/TrackLabelUtils.ts +52 -52
  133. package/src/utils/UrlSafe.ts +69 -69
  134. package/src/utils/VideoFrameCapture.ts +114 -114
  135. package/src/utils/WindowComponents.ts +250 -250
  136. package/dist/dev/vidply.AudioDescriptionManager-R47Q4YR4.js.map +0 -7
  137. package/dist/dev/vidply.DASHRenderer-PWEMC3BT.js.map +0 -7
  138. package/dist/dev/vidply.FloatingPlayerManager-PKNNNAS2.js.map +0 -7
  139. package/dist/dev/vidply.HLSRenderer-E7RQ74HI.js.map +0 -7
  140. package/dist/dev/vidply.HTML5Renderer-BU4QJJJY.js +0 -12
  141. package/dist/dev/vidply.SignLanguageManager-FET5FQZ7.js.map +0 -7
  142. package/dist/dev/vidply.SoundCloudRenderer-RVSJZODO.js.map +0 -7
  143. package/dist/dev/vidply.TranscriptManager-5GXOQCLX.js.map +0 -7
  144. package/dist/dev/vidply.VimeoRenderer-ER2LSBBW.js.map +0 -7
  145. package/dist/dev/vidply.YouTubeRenderer-NFTOWJAB.js.map +0 -7
  146. package/dist/dev/vidply.chunk-64WXMTGL.js.map +0 -7
  147. package/dist/dev/vidply.chunk-BNFIUL2Z.js.map +0 -7
  148. package/dist/dev/vidply.chunk-JJ47NO24.js.map +0 -7
  149. package/dist/dev/vidply.chunk-KI7C3QVL.js.map +0 -7
  150. package/dist/dev/vidply.chunk-NQPURMQY.js.map +0 -7
  151. package/dist/dev/vidply.chunk-RZWZGDV7.js.map +0 -7
  152. package/dist/dev/vidply.chunk-W25HUCHS.js.map +0 -7
  153. package/dist/dev/vidply.de-4YAT7FFM.js.map +0 -7
  154. package/dist/dev/vidply.es-LEWIRUBH.js.map +0 -7
  155. package/dist/dev/vidply.fr-Q4GV2NOE.js.map +0 -7
  156. package/dist/dev/vidply.ja-S6ITA6WU.js.map +0 -7
  157. package/dist/prod/vidply.AudioDescriptionManager-RHLIJW5C.min.js +0 -6
  158. package/dist/prod/vidply.HTML5Renderer-IU5T3WEW.min.js +0 -6
  159. /package/dist/dev/{vidply.CaptionStyleMenu-7XHRBWUM.js.map → vidply.CaptionStyleMenu-R2SCQ6VP.js.map} +0 -0
  160. /package/dist/dev/{vidply.HTML5Renderer-BU4QJJJY.js.map → vidply.HTML5Renderer-P7ICKYU4.js.map} +0 -0
  161. /package/dist/dev/{vidply.chunk-6RQA4ROD.js.map → vidply.chunk-5IK3IC5V.js.map} +0 -0
  162. /package/dist/dev/{vidply.chunk-DZSDO3B7.js.map → vidply.chunk-HOYEMJSD.js.map} +0 -0
  163. /package/dist/dev/{vidply.chunk-VMSQAZM7.js.map → vidply.chunk-JAKZNGOR.js.map} +0 -0
  164. /package/dist/dev/{vidply.chunk-SK4V2DAU.js.map → vidply.chunk-YVCYQZJI.js.map} +0 -0
@@ -1,774 +1,863 @@
1
- /**
2
- * Audio Description Manager
3
- * Handles audio-described video source switching and caption track swapping
4
- */
5
-
6
- import { CaptionManager } from '../controls/CaptionManager.js';
7
- import type { Player } from './Player.js';
8
-
9
- /**
10
- * Caption-track entry tracked by the audio-description manager. Each
11
- * entry pairs a `<track>` element with its described/original source
12
- * URLs so the manager can swap the rendered captions when the user
13
- * toggles audio description.
14
- */
15
- interface CaptionTrackInfo {
16
- trackElement: HTMLTrackElement;
17
- originalSrc: string | null;
18
- describedSrc: string;
19
- originalTrackSrc: string | null;
20
- explicit: boolean;
21
- }
22
-
23
- export class AudioDescriptionManager {
24
- player: Player;
25
- captionTracks: CaptionTrackInfo[];
26
- desiredState: boolean;
27
- enabled: boolean;
28
- originalSource: string | null;
29
- sourceElement: Element | null;
30
- src: string | null;
31
-
32
- constructor(player: Player) {
33
- this.player = player;
34
-
35
- // State
36
- this.enabled = false;
37
- this.desiredState = false;
38
-
39
- // Sources
40
- this.src = player.options.audioDescriptionSrc;
41
- this.sourceElement = null;
42
- this.originalSource = null;
43
- this.captionTracks = [];
44
- }
45
-
46
- /**
47
- * Initialize audio description from source elements
48
- * Called during player initialization
49
- */
50
- initFromSourceElements(sourceElements: Element[], trackElements: Element[]) {
51
- // Check for source elements with audio description attributes
52
- for (const sourceEl of sourceElements) {
53
- const descSrc = sourceEl.getAttribute('data-desc-src');
54
- const origSrc = sourceEl.getAttribute('data-orig-src');
55
-
56
- if (descSrc || origSrc) {
57
- if (!this.sourceElement) {
58
- this.sourceElement = sourceEl;
59
- }
60
-
61
- if (origSrc) {
62
- if (!this.originalSource) {
63
- this.originalSource = origSrc;
64
- }
65
- if (!this.player.originalSrc) {
66
- this.player.originalSrc = origSrc;
67
- }
68
- } else {
69
- const currentSrcAttr = sourceEl.getAttribute('src');
70
- if (!this.originalSource && currentSrcAttr) {
71
- this.originalSource = currentSrcAttr;
72
- }
73
- if (!this.player.originalSrc && currentSrcAttr) {
74
- this.player.originalSrc = currentSrcAttr;
75
- }
76
- }
77
-
78
- if (descSrc && !this.src) {
79
- this.src = descSrc;
80
- }
81
- }
82
- }
83
-
84
- // Check for text tracks with audio description versions
85
- trackElements.forEach((trackEl: Element) => {
86
- const trackKind = trackEl.getAttribute('kind');
87
- const trackDescSrc = trackEl.getAttribute('data-desc-src');
88
-
89
- if ((trackKind === 'captions' || trackKind === 'subtitles' ||
90
- trackKind === 'chapters' || trackKind === 'descriptions') &&
91
- trackDescSrc && trackEl instanceof HTMLTrackElement) {
92
- this.captionTracks.push({
93
- trackElement: trackEl,
94
- originalSrc: trackEl.getAttribute('src'),
95
- describedSrc: trackDescSrc,
96
- originalTrackSrc: trackEl.getAttribute('data-orig-src') || trackEl.getAttribute('src'),
97
- explicit: true
98
- });
99
- this.player.log(`Found explicit described ${trackKind} track: ${trackEl.getAttribute('src')} -> ${trackDescSrc}`);
100
- }
101
- });
102
- }
103
-
104
- /**
105
- * Check if audio description is available
106
- */
107
- isAvailable() {
108
- const hasSourceElementsWithDesc = this.player.sourceElements.some(
109
- (el: Element) => el.getAttribute('data-desc-src')
110
- );
111
- return Boolean(this.src || hasSourceElementsWithDesc || this.captionTracks.length > 0);
112
- }
113
-
114
- /**
115
- * Enable audio description
116
- */
117
- async enable() {
118
- const hasSourceElementsWithDesc = this.player.sourceElements.some(
119
- (el: Element) => el.getAttribute('data-desc-src')
120
- );
121
- const hasTracksWithDesc = this.captionTracks.length > 0;
122
-
123
- if (!this.src && !hasSourceElementsWithDesc && !hasTracksWithDesc) {
124
- console.warn('VidPly: No audio description source, source elements, or tracks provided');
125
- return;
126
- }
127
-
128
- this.desiredState = true;
129
-
130
- // Store current state for restoration
131
- const currentTime = this.player.state.currentTime;
132
- const wasPlaying = this.player.state.playing;
133
- const posterValue = (this.player.element as HTMLVideoElement).poster ||
134
- this.player.element.getAttribute('poster') ||
135
- this.player.options.poster;
136
- const shouldKeepPoster = currentTime < 0.1 && !wasPlaying;
137
-
138
- // Get current caption text for synchronization
139
- const currentCaptionText = this._getCurrentCaptionText();
140
-
141
- // Switch to audio-described version based on what's available
142
- if (this.sourceElement) {
143
- // Use source element approach (data-desc-src on <source> elements)
144
- await this._enableWithSourceElement(currentTime, wasPlaying, posterValue, shouldKeepPoster, currentCaptionText);
145
- } else if (this.src) {
146
- // Use direct src approach (audioDescriptionSrc option)
147
- await this._enableWithDirectSrc(currentTime, wasPlaying, posterValue, shouldKeepPoster);
148
- } else if (hasTracksWithDesc) {
149
- // Only caption tracks with descriptions - swap tracks without changing video source
150
- await this._swapCaptionTracks(true);
151
- this.enabled = true;
152
- this.player.emit('audiodescriptionenabled');
153
- }
154
- // If none of the above, we already returned at the top check
155
- }
156
-
157
- /**
158
- * Disable audio description
159
- */
160
- async disable() {
161
- this.desiredState = false;
162
-
163
- // If we only had caption tracks (no video source swap), just swap tracks back
164
- const hasTracksWithDesc = this.captionTracks.length > 0;
165
- if (!this.sourceElement && !this.src && hasTracksWithDesc) {
166
- await this._swapCaptionTracks(false);
167
- this.enabled = false;
168
- this.player.emit('audiodescriptiondisabled');
169
- return;
170
- }
171
-
172
- if (!this.player.originalSrc) {
173
- return;
174
- }
175
-
176
- // Store current state
177
- const currentTime = this.player.state.currentTime;
178
- const wasPlaying = this.player.state.playing;
179
- const posterValue = (this.player.element as HTMLVideoElement).poster ||
180
- this.player.element.getAttribute('poster') ||
181
- this.player.options.poster;
182
- const shouldKeepPoster = currentTime < 0.1 && !wasPlaying;
183
-
184
- // Get current caption for sync
185
- const currentCaptionText = this._getCurrentCaptionText();
186
-
187
- if (this.sourceElement) {
188
- await this._disableWithSourceElement(currentTime, wasPlaying, posterValue, shouldKeepPoster, currentCaptionText);
189
- } else if (this.src) {
190
- await this._disableWithDirectSrc(currentTime, wasPlaying, posterValue);
191
- }
192
- }
193
-
194
- /**
195
- * Toggle audio description
196
- */
197
- async toggle() {
198
- const descriptionTrack = this.player.findTextTrack('descriptions');
199
- const hasAudioDescriptionSrc = this.isAvailable();
200
-
201
- if (descriptionTrack && !hasAudioDescriptionSrc) {
202
- // Toggle description track playback
203
- if (descriptionTrack.mode === 'showing') {
204
- descriptionTrack.mode = 'hidden';
205
- this.enabled = false;
206
- this.player.emit('audiodescriptiondisabled');
207
- } else {
208
- descriptionTrack.mode = 'showing';
209
- this.enabled = true;
210
- this.player.emit('audiodescriptionenabled');
211
- }
212
- } else if (descriptionTrack && hasAudioDescriptionSrc) {
213
- // Toggle both
214
- if (this.enabled) {
215
- this.desiredState = false;
216
- await this.disable();
217
- } else {
218
- descriptionTrack.mode = 'showing';
219
- this.desiredState = true;
220
- await this.enable();
221
- }
222
- } else if (hasAudioDescriptionSrc) {
223
- // Toggle source
224
- if (this.enabled) {
225
- this.desiredState = false;
226
- await this.disable();
227
- } else {
228
- this.desiredState = true;
229
- await this.enable();
230
- }
231
- }
232
- }
233
-
234
- /**
235
- * Get current caption text for synchronization
236
- */
237
- _getCurrentCaptionText() {
238
- if (this.player.captionManager &&
239
- this.player.captionManager.currentTrack &&
240
- this.player.captionManager.currentCue) {
241
- return this.player.captionManager.currentCue.text;
242
- }
243
- return null;
244
- }
245
-
246
- /**
247
- * Validate that a track URL exists. Bounded by the player's lifecycle
248
- * AbortController + an 8s timeout so a torn-down player cannot leak
249
- * the request.
250
- */
251
- async _validateTrackExists(url: string): Promise<boolean> {
252
- if (typeof url !== 'string' || !url) return false;
253
- const signals: AbortSignal[] = [];
254
- const lifecycle = (this.player as { lifecycleSignal?: AbortSignal }).lifecycleSignal;
255
- if (lifecycle) signals.push(lifecycle);
256
- if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
257
- signals.push(AbortSignal.timeout(8000));
258
- }
259
- let signal: AbortSignal | undefined;
260
- if (signals.length === 1) signal = signals[0];
261
- else if (signals.length > 1) {
262
- const anyFn = (AbortSignal as { any?: (s: AbortSignal[]) => AbortSignal }).any;
263
- signal = anyFn ? anyFn(signals) : signals[0];
264
- }
265
- try {
266
- const response = await fetch(url, { method: 'HEAD', signal });
267
- return response.ok;
268
- } catch {
269
- return false;
270
- }
271
- }
272
-
273
- /**
274
- * Swap caption tracks to described versions
275
- */
276
- async _swapCaptionTracks(toDescribed = true) {
277
- if (this.captionTracks.length === 0) return [];
278
-
279
- const swappedTracks: CaptionTrackInfo[] = [];
280
-
281
- const validationPromises = this.captionTracks.map(async (trackInfo) => {
282
- if (trackInfo.trackElement && trackInfo.describedSrc) {
283
- if (trackInfo.explicit === true) {
284
- const url = toDescribed ? trackInfo.describedSrc : trackInfo.originalSrc;
285
- if (!url) return { trackInfo, exists: false };
286
- try {
287
- const exists = await this._validateTrackExists(url);
288
- return { trackInfo, exists };
289
- } catch {
290
- return { trackInfo, exists: false };
291
- }
292
- }
293
- }
294
- return { trackInfo, exists: false };
295
- });
296
-
297
- const validationResults = await Promise.all(validationPromises);
298
- const tracksToSwap = validationResults.filter(result => result.exists);
299
-
300
- if (tracksToSwap.length > 0) {
301
- // Store track modes before removing
302
- const trackModes = new Map();
303
- tracksToSwap.forEach(({ trackInfo }) => {
304
- const textTrack = trackInfo.trackElement.track;
305
- if (textTrack) {
306
- trackModes.set(trackInfo, {
307
- wasShowing: textTrack.mode === 'showing',
308
- wasHidden: textTrack.mode === 'hidden'
309
- });
310
- } else {
311
- trackModes.set(trackInfo, { wasShowing: false, wasHidden: false });
312
- }
313
- });
314
-
315
- // Store track info and remove
316
- const tracksToReadd = tracksToSwap.map(({ trackInfo }) => {
317
- const attributes: Record<string, string> = {};
318
- Array.from(trackInfo.trackElement.attributes).forEach((attr: Attr) => {
319
- attributes[attr.name] = attr.value;
320
- });
321
-
322
- const result = {
323
- trackInfo,
324
- oldSrc: trackInfo.trackElement.getAttribute('src'),
325
- parent: trackInfo.trackElement.parentNode,
326
- nextSibling: trackInfo.trackElement.nextSibling,
327
- attributes
328
- };
329
-
330
- trackInfo.trackElement.remove();
331
- return result;
332
- });
333
-
334
- // Force browser to process removal
335
- this.player.element.load();
336
-
337
- // Re-add tracks with new src. Use a managed timeout so the swap
338
- // is cancelled (and won't mutate a torn-down player) on destroy.
339
- await new Promise<void>(resolve => {
340
- this.player.setManagedTimeout(() => {
341
- tracksToReadd.forEach(({ trackInfo, parent, nextSibling, attributes }) => {
342
- const newSrc = toDescribed ? trackInfo.describedSrc : trackInfo.originalSrc;
343
- if (!newSrc) {
344
- // Skip tracks without a usable src; no swap can occur.
345
- return;
346
- }
347
- swappedTracks.push(trackInfo);
348
-
349
- const newTrackElement = document.createElement('track');
350
- newTrackElement.setAttribute('src', newSrc);
351
-
352
- Object.keys(attributes).forEach(attrName => {
353
- const attrValue = attributes[attrName];
354
- if (attrName !== 'src' && attrName !== 'data-desc-src' && attrValue !== undefined) {
355
- newTrackElement.setAttribute(attrName, attrValue);
356
- }
357
- });
358
-
359
- // Use the player's video element as parent if the original parent is null
360
- const targetParent = parent || this.player.element;
361
-
362
- if (nextSibling && nextSibling.parentNode) {
363
- targetParent.insertBefore(newTrackElement, nextSibling);
364
- } else {
365
- targetParent.appendChild(newTrackElement);
366
- }
367
-
368
- trackInfo.trackElement = newTrackElement;
369
- });
370
-
371
- this.player.invalidateTrackCache();
372
-
373
- // Setup new tracks
374
- const setupNewTracks = () => {
375
- this.player.setManagedTimeout(() => {
376
- swappedTracks.forEach((trackInfo) => {
377
- const trackElement = trackInfo.trackElement;
378
- const newTextTrack = trackElement.track;
379
- if (newTextTrack) {
380
- const modeInfo = trackModes.get(trackInfo) || { wasShowing: false, wasHidden: false };
381
- newTextTrack.mode = 'hidden';
382
-
383
- const restoreMode = () => {
384
- if (modeInfo.wasShowing || modeInfo.wasHidden) {
385
- newTextTrack.mode = 'hidden';
386
- } else {
387
- newTextTrack.mode = 'disabled';
388
- }
389
- };
390
-
391
- // `readyState` and the `load`/`error`
392
- // events live on `<track>`, not on
393
- // the underlying TextTrack object.
394
- if (trackElement.readyState >= 2) {
395
- restoreMode();
396
- } else {
397
- trackElement.addEventListener('load', restoreMode, { once: true });
398
- trackElement.addEventListener('error', restoreMode, { once: true });
399
- }
400
- }
401
- });
402
- }, 300);
403
- };
404
-
405
- if (this.player.element.readyState >= 1) {
406
- this.player.setManagedTimeout(setupNewTracks, 200);
407
- } else {
408
- this.player.element.addEventListener('loadedmetadata', setupNewTracks, { once: true });
409
- this.player.setManagedTimeout(setupNewTracks, 2000);
410
- }
411
-
412
- resolve();
413
- }, 100);
414
- });
415
- }
416
-
417
- return swappedTracks;
418
- }
419
-
420
- /**
421
- * Update source elements to described versions
422
- */
423
- _updateSourceElements(toDescribed = true) {
424
- const sourceElements = this.player.sourceElements;
425
- const sourcesToUpdate: Array<{ src: string | null; type: string | null; origSrc: string | null; descSrc: string | null }> = [];
426
-
427
- sourceElements.forEach((sourceEl: Element) => {
428
- const descSrcAttr = sourceEl.getAttribute('data-desc-src');
429
- const currentSrc = sourceEl.getAttribute('src');
430
-
431
- if (descSrcAttr) {
432
- const type = sourceEl.getAttribute('type');
433
- const origSrc = sourceEl.getAttribute('data-orig-src') || currentSrc;
434
-
435
- sourcesToUpdate.push({
436
- src: toDescribed ? descSrcAttr : origSrc,
437
- type,
438
- origSrc,
439
- descSrc: descSrcAttr
440
- });
441
- } else {
442
- sourcesToUpdate.push({
443
- src: sourceEl.getAttribute('src'),
444
- type: sourceEl.getAttribute('type'),
445
- origSrc: null,
446
- descSrc: null
447
- });
448
- }
449
- });
450
-
451
- // Remove src attribute if present
452
- if (this.player.element.hasAttribute('src')) {
453
- this.player.element.removeAttribute('src');
454
- }
455
-
456
- // Remove all source elements
457
- sourceElements.forEach((sourceEl: Element) => sourceEl.remove());
458
-
459
- // Re-add with updated src
460
- sourcesToUpdate.forEach((sourceInfo) => {
461
- if (!sourceInfo.src) {
462
- return;
463
- }
464
- const newSource = document.createElement('source');
465
- newSource.setAttribute('src', sourceInfo.src);
466
- if (sourceInfo.type) {
467
- newSource.setAttribute('type', sourceInfo.type);
468
- }
469
- if (sourceInfo.origSrc) {
470
- newSource.setAttribute('data-orig-src', sourceInfo.origSrc);
471
- }
472
- if (sourceInfo.descSrc) {
473
- newSource.setAttribute('data-desc-src', sourceInfo.descSrc);
474
- }
475
-
476
- const firstTrack = this.player.element.querySelector('track');
477
- if (firstTrack) {
478
- this.player.element.insertBefore(newSource, firstTrack);
479
- } else {
480
- this.player.element.appendChild(newSource);
481
- }
482
- });
483
-
484
- this.player._sourceElementsDirty = true;
485
- this.player._sourceElementsCache = null;
486
- }
487
-
488
- /**
489
- * Wait for media to be ready
490
- */
491
- async _waitForMediaReady(needSeek = false) {
492
- // Wait for metadata
493
- await new Promise<void>((resolve) => {
494
- if (this.player.element.readyState >= 1) {
495
- resolve();
496
- } else {
497
- const onLoad = () => {
498
- this.player.element.removeEventListener('loadedmetadata', onLoad);
499
- resolve();
500
- };
501
- this.player.element.addEventListener('loadedmetadata', onLoad);
502
- }
503
- });
504
-
505
- // Wait for tracks
506
- await new Promise(resolve => setTimeout(resolve, 300));
507
-
508
- // Wait for playback if needed
509
- if (needSeek) {
510
- await new Promise<void>((resolve) => {
511
- if (this.player.element.readyState >= 3) {
512
- resolve();
513
- } else {
514
- const onCanPlay = () => {
515
- this.player.element.removeEventListener('canplay', onCanPlay);
516
- this.player.element.removeEventListener('canplaythrough', onCanPlay);
517
- resolve();
518
- };
519
- this.player.element.addEventListener('canplay', onCanPlay, { once: true });
520
- this.player.element.addEventListener('canplaythrough', onCanPlay, { once: true });
521
- setTimeout(() => {
522
- this.player.element.removeEventListener('canplay', onCanPlay);
523
- this.player.element.removeEventListener('canplaythrough', onCanPlay);
524
- resolve();
525
- }, 3000);
526
- }
527
- });
528
- }
529
- }
530
-
531
- /**
532
- * Restore playback state after source change
533
- */
534
- async _restorePlaybackState(currentTime: number, wasPlaying: boolean, shouldKeepPoster: boolean, currentCaptionText: string | null) {
535
- // Try to find matching caption for sync
536
- let syncTime = currentTime;
537
- if (currentCaptionText && this.player.captionManager && this.player.captionManager.tracks.length > 0) {
538
- await new Promise(resolve => setTimeout(resolve, 500));
539
- const matchingTime = this.player.findMatchingCaptionTime(
540
- currentCaptionText,
541
- this.player.captionManager.tracks
542
- );
543
- if (matchingTime !== null) {
544
- syncTime = matchingTime;
545
- if (this.player.options.debug) {
546
- this.player.log(`[VidPly] Syncing via caption: ${currentTime}s -> ${syncTime}s`);
547
- }
548
- }
549
- }
550
-
551
- // Seek
552
- if (syncTime > 0) {
553
- this.player.seek(syncTime);
554
- await new Promise(resolve => setTimeout(resolve, 100));
555
- }
556
-
557
- // Play/pause
558
- if (wasPlaying) {
559
- await this.player.play();
560
- this.player.setManagedTimeout(() => {
561
- this.player.hidePosterOverlay();
562
- }, 100);
563
- } else {
564
- this.player.pause();
565
- if (!shouldKeepPoster) {
566
- this.player.hidePosterOverlay();
567
- }
568
- }
569
- }
570
-
571
- /**
572
- * Enable with source element method
573
- */
574
- async _enableWithSourceElement(currentTime: number, wasPlaying: boolean, posterValue: string | null, shouldKeepPoster: boolean, currentCaptionText: string | null) {
575
- // Swap caption tracks
576
- await this._swapCaptionTracks(true);
577
-
578
- // Update source elements
579
- this._updateSourceElements(true);
580
-
581
- // Preserve poster
582
- if (posterValue && this.player.element.tagName === 'VIDEO') {
583
- (this.player.element as HTMLVideoElement).poster = posterValue;
584
- }
585
-
586
- // Reload
587
- this.player.element.load();
588
-
589
- // Wait for ready
590
- await this._waitForMediaReady(currentTime > 0 || wasPlaying);
591
-
592
- // Restore playback
593
- await this._restorePlaybackState(currentTime, wasPlaying, shouldKeepPoster, currentCaptionText);
594
-
595
- // Update state
596
- if (!this.desiredState) return;
597
- this.enabled = true;
598
- this.player.state.audioDescriptionEnabled = true;
599
- this.player.emit('audiodescriptionenabled');
600
-
601
- // Reload transcript if visible
602
- this._reloadTranscript();
603
- }
604
-
605
- /**
606
- * Enable with direct src method
607
- */
608
- async _enableWithDirectSrc(currentTime: number, wasPlaying: boolean, posterValue: string | null, shouldKeepPoster: boolean) {
609
- // Swap caption tracks
610
- await this._swapCaptionTracks(true);
611
-
612
- // Set poster
613
- if (posterValue && this.player.element.tagName === 'VIDEO') {
614
- (this.player.element as HTMLVideoElement).poster = posterValue;
615
- }
616
-
617
- // Set src (this method should only be called when this.src exists)
618
- if (!this.src) return;
619
- this.player.element.src = this.src;
620
-
621
- // Wait and restore
622
- await this._waitForMediaReady(currentTime > 0 || wasPlaying);
623
-
624
- if (currentTime > 0) {
625
- this.player.seek(currentTime);
626
- await new Promise(resolve => setTimeout(resolve, 100));
627
- }
628
-
629
- if (wasPlaying) {
630
- await this.player.play();
631
- } else {
632
- this.player.pause();
633
- if (!shouldKeepPoster) {
634
- this.player.hidePosterOverlay();
635
- }
636
- }
637
-
638
- if (!this.desiredState) return;
639
- this.enabled = true;
640
- this.player.state.audioDescriptionEnabled = true;
641
- this.player.emit('audiodescriptionenabled');
642
-
643
- // Reload transcript if visible
644
- this._reloadTranscript();
645
- }
646
-
647
- /**
648
- * Disable with source element method
649
- */
650
- async _disableWithSourceElement(currentTime: number, wasPlaying: boolean, posterValue: string | null, shouldKeepPoster: boolean, currentCaptionText: string | null) {
651
- // Swap caption tracks back
652
- await this._swapCaptionTracks(false);
653
-
654
- // Update source elements
655
- this._updateSourceElements(false);
656
-
657
- // Preserve poster
658
- if (posterValue && this.player.element.tagName === 'VIDEO') {
659
- (this.player.element as HTMLVideoElement).poster = posterValue;
660
- }
661
-
662
- // Reload
663
- this.player.element.load();
664
- this.player.invalidateTrackCache();
665
-
666
- // Wait for ready
667
- await this._waitForMediaReady(currentTime > 0 || wasPlaying);
668
-
669
- // Restore playback
670
- await this._restorePlaybackState(currentTime, wasPlaying, shouldKeepPoster, currentCaptionText);
671
-
672
- // Reinitialize caption manager
673
- if (this.player.captionManager) {
674
- this.player.captionManager.destroy();
675
- this.player.captionManager = new CaptionManager(this.player);
676
- }
677
-
678
- // Update state
679
- if (this.desiredState) return;
680
- this.enabled = false;
681
- this.player.state.audioDescriptionEnabled = false;
682
- this.player.emit('audiodescriptiondisabled');
683
-
684
- // Reload transcript if visible
685
- this._reloadTranscript();
686
- }
687
-
688
- /**
689
- * Disable with direct src method
690
- */
691
- async _disableWithDirectSrc(currentTime: number, wasPlaying: boolean, posterValue: string | null) {
692
- // Swap caption tracks back
693
- await this._swapCaptionTracks(false);
694
-
695
- // Set poster
696
- if (posterValue && this.player.element.tagName === 'VIDEO') {
697
- (this.player.element as HTMLVideoElement).poster = posterValue;
698
- }
699
-
700
- // Restore original src
701
- const originalSrcToUse = this.originalSource || this.player.originalSrc;
702
- if (!originalSrcToUse) {
703
- return;
704
- }
705
- this.player.element.src = originalSrcToUse;
706
- this.player.element.load();
707
-
708
- // Wait and restore
709
- await this._waitForMediaReady(currentTime > 0 || wasPlaying);
710
-
711
- if (currentTime > 0) {
712
- this.player.seek(currentTime);
713
- }
714
-
715
- if (wasPlaying) {
716
- await this.player.play();
717
- }
718
-
719
- if (this.desiredState) return;
720
- this.enabled = false;
721
- this.player.state.audioDescriptionEnabled = false;
722
- this.player.emit('audiodescriptiondisabled');
723
-
724
- // Reload transcript if visible
725
- this._reloadTranscript();
726
- }
727
-
728
- /**
729
- * Reload transcript after audio description state change
730
- */
731
- _reloadTranscript() {
732
- if (this.player.transcriptManager && this.player.transcriptManager.isVisible) {
733
- // Wait for tracks to load after source swap
734
- this.player.setManagedTimeout(() => {
735
- if (this.player.transcriptManager && this.player.transcriptManager.loadTranscriptData) {
736
- this.player.transcriptManager.loadTranscriptData();
737
- }
738
- }, 800);
739
- }
740
- }
741
-
742
- /**
743
- * Update sources (called when playlist changes)
744
- */
745
- updateSources(audioDescriptionSrc: string | null | undefined) {
746
- this.src = audioDescriptionSrc || null;
747
- // Reset state for new playlist item
748
- this.enabled = false;
749
- this.desiredState = false;
750
- this.sourceElement = null;
751
- this.originalSource = null;
752
- this.captionTracks = [];
753
- }
754
-
755
- /**
756
- * Reinitialize from current player elements (called after playlist loads new tracks)
757
- */
758
- reinitialize() {
759
- this.player.invalidateTrackCache();
760
- this.initFromSourceElements(this.player.sourceElements, this.player.trackElements);
761
- }
762
-
763
- /**
764
- * Cleanup
765
- */
766
- destroy() {
767
- this.enabled = false;
768
- this.desiredState = false;
769
- this.captionTracks = [];
770
- this.sourceElement = null;
771
- this.originalSource = null;
772
- }
773
- }
774
-
1
+ /**
2
+ * Audio Description Manager
3
+ * Handles audio-described video source switching and caption track swapping
4
+ */
5
+
6
+ import { CaptionManager } from '../controls/CaptionManager.js';
7
+ import type { Player } from './Player.js';
8
+ import { DescriptionSpeechManager } from './DescriptionSpeechManager.js';
9
+
10
+ export type AudioDescriptionDeliveryMode = 'swap' | 'vtt_speech' | 'none';
11
+
12
+ /**
13
+ * Caption-track entry tracked by the audio-description manager. Each
14
+ * entry pairs a `<track>` element with its described/original source
15
+ * URLs so the manager can swap the rendered captions when the user
16
+ * toggles audio description.
17
+ */
18
+ interface CaptionTrackInfo {
19
+ trackElement: HTMLTrackElement;
20
+ originalSrc: string | null;
21
+ describedSrc: string;
22
+ originalTrackSrc: string | null;
23
+ explicit: boolean;
24
+ }
25
+
26
+ export class AudioDescriptionManager {
27
+ player: Player;
28
+ captionTracks: CaptionTrackInfo[];
29
+ desiredState: boolean;
30
+ enabled: boolean;
31
+ originalSource: string | null;
32
+ sourceElement: Element | null;
33
+ src: string | null;
34
+ speechManager: DescriptionSpeechManager | null;
35
+
36
+ constructor(player: Player) {
37
+ this.player = player;
38
+
39
+ // State
40
+ this.enabled = false;
41
+ this.desiredState = false;
42
+
43
+ // Sources
44
+ this.src = player.options.audioDescriptionSrc;
45
+ this.sourceElement = null;
46
+ this.originalSource = null;
47
+ this.captionTracks = [];
48
+ this.speechManager = null;
49
+ }
50
+
51
+ /**
52
+ * Whether a described video source swap is configured.
53
+ */
54
+ _hasSwapSource(): boolean {
55
+ const hasSourceElementsWithDesc = this.player.sourceElements.some(
56
+ (el: Element) => el.getAttribute('data-desc-src')
57
+ );
58
+ return Boolean(this.src || hasSourceElementsWithDesc);
59
+ }
60
+
61
+ /**
62
+ * Whether a descriptions VTT track is present on the media element.
63
+ */
64
+ _hasDescriptionsTrack(): boolean {
65
+ return Boolean(this.player.findTextTrack('descriptions'));
66
+ }
67
+
68
+ /**
69
+ * Resolve which audio-description delivery mode applies for the current media.
70
+ */
71
+ resolveDeliveryMode(): AudioDescriptionDeliveryMode {
72
+ const configured = this.player.options.audioDescriptionMode ?? 'auto';
73
+ const hasSwap = this._hasSwapSource();
74
+ const hasDescriptions = this._hasDescriptionsTrack();
75
+
76
+ if (configured === 'swap') {
77
+ return hasSwap ? 'swap' : 'none';
78
+ }
79
+ if (configured === 'vtt_speech') {
80
+ return hasDescriptions ? 'vtt_speech' : 'none';
81
+ }
82
+ // auto: described video takes precedence over VTT speech
83
+ if (hasSwap) {
84
+ return 'swap';
85
+ }
86
+ if (hasDescriptions) {
87
+ return 'vtt_speech';
88
+ }
89
+ return 'none';
90
+ }
91
+
92
+ _ensureSpeechManager(): DescriptionSpeechManager {
93
+ if (!this.speechManager) {
94
+ this.speechManager = new DescriptionSpeechManager(this.player);
95
+ }
96
+ return this.speechManager;
97
+ }
98
+
99
+ /**
100
+ * Initialize audio description from source elements
101
+ * Called during player initialization
102
+ */
103
+ initFromSourceElements(sourceElements: Element[], trackElements: Element[]) {
104
+ // Check for source elements with audio description attributes
105
+ for (const sourceEl of sourceElements) {
106
+ const descSrc = sourceEl.getAttribute('data-desc-src');
107
+ const origSrc = sourceEl.getAttribute('data-orig-src');
108
+
109
+ if (descSrc || origSrc) {
110
+ if (!this.sourceElement) {
111
+ this.sourceElement = sourceEl;
112
+ }
113
+
114
+ if (origSrc) {
115
+ if (!this.originalSource) {
116
+ this.originalSource = origSrc;
117
+ }
118
+ if (!this.player.originalSrc) {
119
+ this.player.originalSrc = origSrc;
120
+ }
121
+ } else {
122
+ const currentSrcAttr = sourceEl.getAttribute('src');
123
+ if (!this.originalSource && currentSrcAttr) {
124
+ this.originalSource = currentSrcAttr;
125
+ }
126
+ if (!this.player.originalSrc && currentSrcAttr) {
127
+ this.player.originalSrc = currentSrcAttr;
128
+ }
129
+ }
130
+
131
+ if (descSrc && !this.src) {
132
+ this.src = descSrc;
133
+ }
134
+ }
135
+ }
136
+
137
+ // Check for text tracks with audio description versions
138
+ trackElements.forEach((trackEl: Element) => {
139
+ const trackKind = trackEl.getAttribute('kind');
140
+ const trackDescSrc = trackEl.getAttribute('data-desc-src');
141
+
142
+ if ((trackKind === 'captions' || trackKind === 'subtitles' ||
143
+ trackKind === 'chapters' || trackKind === 'descriptions') &&
144
+ trackDescSrc && trackEl instanceof HTMLTrackElement) {
145
+ this.captionTracks.push({
146
+ trackElement: trackEl,
147
+ originalSrc: trackEl.getAttribute('src'),
148
+ describedSrc: trackDescSrc,
149
+ originalTrackSrc: trackEl.getAttribute('data-orig-src') || trackEl.getAttribute('src'),
150
+ explicit: true
151
+ });
152
+ this.player.log(`Found explicit described ${trackKind} track: ${trackEl.getAttribute('src')} -> ${trackDescSrc}`);
153
+ }
154
+ });
155
+ }
156
+
157
+ /**
158
+ * Check if audio description is available
159
+ */
160
+ isAvailable() {
161
+ const mode = this.resolveDeliveryMode();
162
+ if (mode === 'swap' || mode === 'vtt_speech') {
163
+ return true;
164
+ }
165
+ return this.captionTracks.length > 0;
166
+ }
167
+
168
+ /**
169
+ * Enable audio description
170
+ */
171
+ async enable() {
172
+ const deliveryMode = this.resolveDeliveryMode();
173
+ const hasTracksWithDesc = this.captionTracks.length > 0;
174
+
175
+ if (deliveryMode === 'none' && !hasTracksWithDesc) {
176
+ console.warn('VidPly: No audio description source, descriptions track, or tracks provided');
177
+ return;
178
+ }
179
+
180
+ this.desiredState = true;
181
+
182
+ if (deliveryMode === 'vtt_speech') {
183
+ const speechManager = this._ensureSpeechManager();
184
+ const started = speechManager.enable();
185
+ if (!started) {
186
+ console.warn('VidPly: No descriptions track available for VTT speech mode');
187
+ return;
188
+ }
189
+ this.enabled = true;
190
+ this.player.state.audioDescriptionEnabled = true;
191
+ this.player.emit('audiodescriptionenabled');
192
+ return;
193
+ }
194
+
195
+ // Store current state for restoration
196
+ const currentTime = this.player.state.currentTime;
197
+ const wasPlaying = this.player.state.playing;
198
+ const posterValue = (this.player.element as HTMLVideoElement).poster ||
199
+ this.player.element.getAttribute('poster') ||
200
+ this.player.options.poster;
201
+ const shouldKeepPoster = currentTime < 0.1 && !wasPlaying;
202
+
203
+ // Get current caption text for synchronization
204
+ const currentCaptionText = this._getCurrentCaptionText();
205
+
206
+ // Switch to audio-described version based on what's available
207
+ if (this.sourceElement) {
208
+ // Use source element approach (data-desc-src on <source> elements)
209
+ await this._enableWithSourceElement(currentTime, wasPlaying, posterValue, shouldKeepPoster, currentCaptionText);
210
+ } else if (this.src) {
211
+ // Use direct src approach (audioDescriptionSrc option)
212
+ await this._enableWithDirectSrc(currentTime, wasPlaying, posterValue, shouldKeepPoster);
213
+ } else if (hasTracksWithDesc) {
214
+ // Only caption tracks with descriptions - swap tracks without changing video source
215
+ await this._swapCaptionTracks(true);
216
+ this.enabled = true;
217
+ this.player.emit('audiodescriptionenabled');
218
+ }
219
+ // If none of the above, we already returned at the top check
220
+ }
221
+
222
+ /**
223
+ * Disable audio description
224
+ */
225
+ async disable() {
226
+ this.desiredState = false;
227
+
228
+ if (this.speechManager?.enabled) {
229
+ this.speechManager.disable();
230
+ this.enabled = false;
231
+ this.player.state.audioDescriptionEnabled = false;
232
+ this.player.emit('audiodescriptiondisabled');
233
+ return;
234
+ }
235
+
236
+ // If we only had caption tracks (no video source swap), just swap tracks back
237
+ const hasTracksWithDesc = this.captionTracks.length > 0;
238
+ if (!this.sourceElement && !this.src && hasTracksWithDesc) {
239
+ await this._swapCaptionTracks(false);
240
+ this.enabled = false;
241
+ this.player.emit('audiodescriptiondisabled');
242
+ return;
243
+ }
244
+
245
+ if (!this.player.originalSrc) {
246
+ return;
247
+ }
248
+
249
+ // Store current state
250
+ const currentTime = this.player.state.currentTime;
251
+ const wasPlaying = this.player.state.playing;
252
+ const posterValue = (this.player.element as HTMLVideoElement).poster ||
253
+ this.player.element.getAttribute('poster') ||
254
+ this.player.options.poster;
255
+ const shouldKeepPoster = currentTime < 0.1 && !wasPlaying;
256
+
257
+ // Get current caption for sync
258
+ const currentCaptionText = this._getCurrentCaptionText();
259
+
260
+ if (this.sourceElement) {
261
+ await this._disableWithSourceElement(currentTime, wasPlaying, posterValue, shouldKeepPoster, currentCaptionText);
262
+ } else if (this.src) {
263
+ await this._disableWithDirectSrc(currentTime, wasPlaying, posterValue);
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Toggle audio description
269
+ */
270
+ async toggle() {
271
+ const deliveryMode = this.resolveDeliveryMode();
272
+ const descriptionTrack = this.player.findTextTrack('descriptions');
273
+ const hasSwapOrTracks = this._hasSwapSource() || this.captionTracks.length > 0;
274
+
275
+ if (deliveryMode === 'vtt_speech') {
276
+ if (this.enabled) {
277
+ await this.disable();
278
+ } else {
279
+ await this.enable();
280
+ }
281
+ return;
282
+ }
283
+
284
+ if (descriptionTrack && !hasSwapOrTracks) {
285
+ // Text-only fallback when speech synthesis is unavailable
286
+ if (descriptionTrack.mode === 'showing') {
287
+ descriptionTrack.mode = 'hidden';
288
+ this.enabled = false;
289
+ this.player.state.audioDescriptionEnabled = false;
290
+ this.player.emit('audiodescriptiondisabled');
291
+ } else {
292
+ descriptionTrack.mode = 'showing';
293
+ this.enabled = true;
294
+ this.player.state.audioDescriptionEnabled = true;
295
+ this.player.emit('audiodescriptionenabled');
296
+ }
297
+ } else if (descriptionTrack && hasSwapOrTracks) {
298
+ // Toggle both
299
+ if (this.enabled) {
300
+ this.desiredState = false;
301
+ await this.disable();
302
+ } else {
303
+ descriptionTrack.mode = 'showing';
304
+ this.desiredState = true;
305
+ await this.enable();
306
+ }
307
+ } else if (hasSwapOrTracks) {
308
+ // Toggle source swap
309
+ if (this.enabled) {
310
+ this.desiredState = false;
311
+ await this.disable();
312
+ } else {
313
+ this.desiredState = true;
314
+ await this.enable();
315
+ }
316
+ }
317
+ }
318
+
319
+ /**
320
+ * Get current caption text for synchronization
321
+ */
322
+ _getCurrentCaptionText() {
323
+ if (this.player.captionManager &&
324
+ this.player.captionManager.currentTrack &&
325
+ this.player.captionManager.currentCue) {
326
+ return this.player.captionManager.currentCue.text;
327
+ }
328
+ return null;
329
+ }
330
+
331
+ /**
332
+ * Validate that a track URL exists. Bounded by the player's lifecycle
333
+ * AbortController + an 8s timeout so a torn-down player cannot leak
334
+ * the request.
335
+ */
336
+ async _validateTrackExists(url: string): Promise<boolean> {
337
+ if (typeof url !== 'string' || !url) return false;
338
+ const signals: AbortSignal[] = [];
339
+ const lifecycle = (this.player as { lifecycleSignal?: AbortSignal }).lifecycleSignal;
340
+ if (lifecycle) signals.push(lifecycle);
341
+ if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
342
+ signals.push(AbortSignal.timeout(8000));
343
+ }
344
+ let signal: AbortSignal | undefined;
345
+ if (signals.length === 1) signal = signals[0];
346
+ else if (signals.length > 1) {
347
+ const anyFn = (AbortSignal as { any?: (s: AbortSignal[]) => AbortSignal }).any;
348
+ signal = anyFn ? anyFn(signals) : signals[0];
349
+ }
350
+ try {
351
+ const response = await fetch(url, { method: 'HEAD', signal });
352
+ return response.ok;
353
+ } catch {
354
+ return false;
355
+ }
356
+ }
357
+
358
+ /**
359
+ * Swap caption tracks to described versions
360
+ */
361
+ async _swapCaptionTracks(toDescribed = true) {
362
+ if (this.captionTracks.length === 0) return [];
363
+
364
+ const swappedTracks: CaptionTrackInfo[] = [];
365
+
366
+ const validationPromises = this.captionTracks.map(async (trackInfo) => {
367
+ if (trackInfo.trackElement && trackInfo.describedSrc) {
368
+ if (trackInfo.explicit === true) {
369
+ const url = toDescribed ? trackInfo.describedSrc : trackInfo.originalSrc;
370
+ if (!url) return { trackInfo, exists: false };
371
+ try {
372
+ const exists = await this._validateTrackExists(url);
373
+ return { trackInfo, exists };
374
+ } catch {
375
+ return { trackInfo, exists: false };
376
+ }
377
+ }
378
+ }
379
+ return { trackInfo, exists: false };
380
+ });
381
+
382
+ const validationResults = await Promise.all(validationPromises);
383
+ const tracksToSwap = validationResults.filter(result => result.exists);
384
+
385
+ if (tracksToSwap.length > 0) {
386
+ // Store track modes before removing
387
+ const trackModes = new Map();
388
+ tracksToSwap.forEach(({ trackInfo }) => {
389
+ const textTrack = trackInfo.trackElement.track;
390
+ if (textTrack) {
391
+ trackModes.set(trackInfo, {
392
+ wasShowing: textTrack.mode === 'showing',
393
+ wasHidden: textTrack.mode === 'hidden'
394
+ });
395
+ } else {
396
+ trackModes.set(trackInfo, { wasShowing: false, wasHidden: false });
397
+ }
398
+ });
399
+
400
+ // Store track info and remove
401
+ const tracksToReadd = tracksToSwap.map(({ trackInfo }) => {
402
+ const attributes: Record<string, string> = {};
403
+ Array.from(trackInfo.trackElement.attributes).forEach((attr: Attr) => {
404
+ attributes[attr.name] = attr.value;
405
+ });
406
+
407
+ const result = {
408
+ trackInfo,
409
+ oldSrc: trackInfo.trackElement.getAttribute('src'),
410
+ parent: trackInfo.trackElement.parentNode,
411
+ nextSibling: trackInfo.trackElement.nextSibling,
412
+ attributes
413
+ };
414
+
415
+ trackInfo.trackElement.remove();
416
+ return result;
417
+ });
418
+
419
+ // Force browser to process removal
420
+ this.player.element.load();
421
+
422
+ // Re-add tracks with new src. Use a managed timeout so the swap
423
+ // is cancelled (and won't mutate a torn-down player) on destroy.
424
+ await new Promise<void>(resolve => {
425
+ this.player.setManagedTimeout(() => {
426
+ tracksToReadd.forEach(({ trackInfo, parent, nextSibling, attributes }) => {
427
+ const newSrc = toDescribed ? trackInfo.describedSrc : trackInfo.originalSrc;
428
+ if (!newSrc) {
429
+ // Skip tracks without a usable src; no swap can occur.
430
+ return;
431
+ }
432
+ swappedTracks.push(trackInfo);
433
+
434
+ const newTrackElement = document.createElement('track');
435
+ newTrackElement.setAttribute('src', newSrc);
436
+
437
+ Object.keys(attributes).forEach(attrName => {
438
+ const attrValue = attributes[attrName];
439
+ if (attrName !== 'src' && attrName !== 'data-desc-src' && attrValue !== undefined) {
440
+ newTrackElement.setAttribute(attrName, attrValue);
441
+ }
442
+ });
443
+
444
+ // Use the player's video element as parent if the original parent is null
445
+ const targetParent = parent || this.player.element;
446
+
447
+ if (nextSibling && nextSibling.parentNode) {
448
+ targetParent.insertBefore(newTrackElement, nextSibling);
449
+ } else {
450
+ targetParent.appendChild(newTrackElement);
451
+ }
452
+
453
+ trackInfo.trackElement = newTrackElement;
454
+ });
455
+
456
+ this.player.invalidateTrackCache();
457
+
458
+ // Setup new tracks
459
+ const setupNewTracks = () => {
460
+ this.player.setManagedTimeout(() => {
461
+ swappedTracks.forEach((trackInfo) => {
462
+ const trackElement = trackInfo.trackElement;
463
+ const newTextTrack = trackElement.track;
464
+ if (newTextTrack) {
465
+ const modeInfo = trackModes.get(trackInfo) || { wasShowing: false, wasHidden: false };
466
+ newTextTrack.mode = 'hidden';
467
+
468
+ const restoreMode = () => {
469
+ if (modeInfo.wasShowing || modeInfo.wasHidden) {
470
+ newTextTrack.mode = 'hidden';
471
+ } else {
472
+ newTextTrack.mode = 'disabled';
473
+ }
474
+ };
475
+
476
+ // `readyState` and the `load`/`error`
477
+ // events live on `<track>`, not on
478
+ // the underlying TextTrack object.
479
+ if (trackElement.readyState >= 2) {
480
+ restoreMode();
481
+ } else {
482
+ trackElement.addEventListener('load', restoreMode, { once: true });
483
+ trackElement.addEventListener('error', restoreMode, { once: true });
484
+ }
485
+ }
486
+ });
487
+ }, 300);
488
+ };
489
+
490
+ if (this.player.element.readyState >= 1) {
491
+ this.player.setManagedTimeout(setupNewTracks, 200);
492
+ } else {
493
+ this.player.element.addEventListener('loadedmetadata', setupNewTracks, { once: true });
494
+ this.player.setManagedTimeout(setupNewTracks, 2000);
495
+ }
496
+
497
+ resolve();
498
+ }, 100);
499
+ });
500
+ }
501
+
502
+ return swappedTracks;
503
+ }
504
+
505
+ /**
506
+ * Update source elements to described versions
507
+ */
508
+ _updateSourceElements(toDescribed = true) {
509
+ const sourceElements = this.player.sourceElements;
510
+ const sourcesToUpdate: Array<{ src: string | null; type: string | null; origSrc: string | null; descSrc: string | null }> = [];
511
+
512
+ sourceElements.forEach((sourceEl: Element) => {
513
+ const descSrcAttr = sourceEl.getAttribute('data-desc-src');
514
+ const currentSrc = sourceEl.getAttribute('src');
515
+
516
+ if (descSrcAttr) {
517
+ const type = sourceEl.getAttribute('type');
518
+ const origSrc = sourceEl.getAttribute('data-orig-src') || currentSrc;
519
+
520
+ sourcesToUpdate.push({
521
+ src: toDescribed ? descSrcAttr : origSrc,
522
+ type,
523
+ origSrc,
524
+ descSrc: descSrcAttr
525
+ });
526
+ } else {
527
+ sourcesToUpdate.push({
528
+ src: sourceEl.getAttribute('src'),
529
+ type: sourceEl.getAttribute('type'),
530
+ origSrc: null,
531
+ descSrc: null
532
+ });
533
+ }
534
+ });
535
+
536
+ // Remove src attribute if present
537
+ if (this.player.element.hasAttribute('src')) {
538
+ this.player.element.removeAttribute('src');
539
+ }
540
+
541
+ // Remove all source elements
542
+ sourceElements.forEach((sourceEl: Element) => sourceEl.remove());
543
+
544
+ // Re-add with updated src
545
+ sourcesToUpdate.forEach((sourceInfo) => {
546
+ if (!sourceInfo.src) {
547
+ return;
548
+ }
549
+ const newSource = document.createElement('source');
550
+ newSource.setAttribute('src', sourceInfo.src);
551
+ if (sourceInfo.type) {
552
+ newSource.setAttribute('type', sourceInfo.type);
553
+ }
554
+ if (sourceInfo.origSrc) {
555
+ newSource.setAttribute('data-orig-src', sourceInfo.origSrc);
556
+ }
557
+ if (sourceInfo.descSrc) {
558
+ newSource.setAttribute('data-desc-src', sourceInfo.descSrc);
559
+ }
560
+
561
+ const firstTrack = this.player.element.querySelector('track');
562
+ if (firstTrack) {
563
+ this.player.element.insertBefore(newSource, firstTrack);
564
+ } else {
565
+ this.player.element.appendChild(newSource);
566
+ }
567
+ });
568
+
569
+ this.player._sourceElementsDirty = true;
570
+ this.player._sourceElementsCache = null;
571
+ }
572
+
573
+ /**
574
+ * Wait for media to be ready
575
+ */
576
+ async _waitForMediaReady(needSeek = false) {
577
+ // Wait for metadata
578
+ await new Promise<void>((resolve) => {
579
+ if (this.player.element.readyState >= 1) {
580
+ resolve();
581
+ } else {
582
+ const onLoad = () => {
583
+ this.player.element.removeEventListener('loadedmetadata', onLoad);
584
+ resolve();
585
+ };
586
+ this.player.element.addEventListener('loadedmetadata', onLoad);
587
+ }
588
+ });
589
+
590
+ // Wait for tracks
591
+ await new Promise(resolve => setTimeout(resolve, 300));
592
+
593
+ // Wait for playback if needed
594
+ if (needSeek) {
595
+ await new Promise<void>((resolve) => {
596
+ if (this.player.element.readyState >= 3) {
597
+ resolve();
598
+ } else {
599
+ const onCanPlay = () => {
600
+ this.player.element.removeEventListener('canplay', onCanPlay);
601
+ this.player.element.removeEventListener('canplaythrough', onCanPlay);
602
+ resolve();
603
+ };
604
+ this.player.element.addEventListener('canplay', onCanPlay, { once: true });
605
+ this.player.element.addEventListener('canplaythrough', onCanPlay, { once: true });
606
+ setTimeout(() => {
607
+ this.player.element.removeEventListener('canplay', onCanPlay);
608
+ this.player.element.removeEventListener('canplaythrough', onCanPlay);
609
+ resolve();
610
+ }, 3000);
611
+ }
612
+ });
613
+ }
614
+ }
615
+
616
+ /**
617
+ * Restore playback state after source change
618
+ */
619
+ async _restorePlaybackState(currentTime: number, wasPlaying: boolean, shouldKeepPoster: boolean, currentCaptionText: string | null) {
620
+ // Try to find matching caption for sync
621
+ let syncTime = currentTime;
622
+ if (currentCaptionText && this.player.captionManager && this.player.captionManager.tracks.length > 0) {
623
+ await new Promise(resolve => setTimeout(resolve, 500));
624
+ const matchingTime = this.player.findMatchingCaptionTime(
625
+ currentCaptionText,
626
+ this.player.captionManager.tracks
627
+ );
628
+ if (matchingTime !== null) {
629
+ syncTime = matchingTime;
630
+ if (this.player.options.debug) {
631
+ this.player.log(`[VidPly] Syncing via caption: ${currentTime}s -> ${syncTime}s`);
632
+ }
633
+ }
634
+ }
635
+
636
+ // Seek
637
+ if (syncTime > 0) {
638
+ this.player.seek(syncTime);
639
+ await new Promise(resolve => setTimeout(resolve, 100));
640
+ }
641
+
642
+ // Play/pause
643
+ if (wasPlaying) {
644
+ await this.player.play();
645
+ this.player.setManagedTimeout(() => {
646
+ this.player.hidePosterOverlay();
647
+ }, 100);
648
+ } else {
649
+ this.player.pause();
650
+ if (!shouldKeepPoster) {
651
+ this.player.hidePosterOverlay();
652
+ }
653
+ }
654
+ }
655
+
656
+ /**
657
+ * Enable with source element method
658
+ */
659
+ async _enableWithSourceElement(currentTime: number, wasPlaying: boolean, posterValue: string | null, shouldKeepPoster: boolean, currentCaptionText: string | null) {
660
+ // Swap caption tracks
661
+ await this._swapCaptionTracks(true);
662
+
663
+ // Update source elements
664
+ this._updateSourceElements(true);
665
+
666
+ // Preserve poster
667
+ if (posterValue && this.player.element.tagName === 'VIDEO') {
668
+ (this.player.element as HTMLVideoElement).poster = posterValue;
669
+ }
670
+
671
+ // Reload
672
+ this.player.element.load();
673
+
674
+ // Wait for ready
675
+ await this._waitForMediaReady(currentTime > 0 || wasPlaying);
676
+
677
+ // Restore playback
678
+ await this._restorePlaybackState(currentTime, wasPlaying, shouldKeepPoster, currentCaptionText);
679
+
680
+ // Update state
681
+ if (!this.desiredState) return;
682
+ this.enabled = true;
683
+ this.player.state.audioDescriptionEnabled = true;
684
+ this.player.emit('audiodescriptionenabled');
685
+
686
+ // Reload transcript if visible
687
+ this._reloadTranscript();
688
+ }
689
+
690
+ /**
691
+ * Enable with direct src method
692
+ */
693
+ async _enableWithDirectSrc(currentTime: number, wasPlaying: boolean, posterValue: string | null, shouldKeepPoster: boolean) {
694
+ // Swap caption tracks
695
+ await this._swapCaptionTracks(true);
696
+
697
+ // Set poster
698
+ if (posterValue && this.player.element.tagName === 'VIDEO') {
699
+ (this.player.element as HTMLVideoElement).poster = posterValue;
700
+ }
701
+
702
+ // Set src (this method should only be called when this.src exists)
703
+ if (!this.src) return;
704
+ this.player.element.src = this.src;
705
+
706
+ // Wait and restore
707
+ await this._waitForMediaReady(currentTime > 0 || wasPlaying);
708
+
709
+ if (currentTime > 0) {
710
+ this.player.seek(currentTime);
711
+ await new Promise(resolve => setTimeout(resolve, 100));
712
+ }
713
+
714
+ if (wasPlaying) {
715
+ await this.player.play();
716
+ } else {
717
+ this.player.pause();
718
+ if (!shouldKeepPoster) {
719
+ this.player.hidePosterOverlay();
720
+ }
721
+ }
722
+
723
+ if (!this.desiredState) return;
724
+ this.enabled = true;
725
+ this.player.state.audioDescriptionEnabled = true;
726
+ this.player.emit('audiodescriptionenabled');
727
+
728
+ // Reload transcript if visible
729
+ this._reloadTranscript();
730
+ }
731
+
732
+ /**
733
+ * Disable with source element method
734
+ */
735
+ async _disableWithSourceElement(currentTime: number, wasPlaying: boolean, posterValue: string | null, shouldKeepPoster: boolean, currentCaptionText: string | null) {
736
+ // Swap caption tracks back
737
+ await this._swapCaptionTracks(false);
738
+
739
+ // Update source elements
740
+ this._updateSourceElements(false);
741
+
742
+ // Preserve poster
743
+ if (posterValue && this.player.element.tagName === 'VIDEO') {
744
+ (this.player.element as HTMLVideoElement).poster = posterValue;
745
+ }
746
+
747
+ // Reload
748
+ this.player.element.load();
749
+ this.player.invalidateTrackCache();
750
+
751
+ // Wait for ready
752
+ await this._waitForMediaReady(currentTime > 0 || wasPlaying);
753
+
754
+ // Restore playback
755
+ await this._restorePlaybackState(currentTime, wasPlaying, shouldKeepPoster, currentCaptionText);
756
+
757
+ // Reinitialize caption manager
758
+ if (this.player.captionManager) {
759
+ this.player.captionManager.destroy();
760
+ this.player.captionManager = new CaptionManager(this.player);
761
+ }
762
+
763
+ // Update state
764
+ if (this.desiredState) return;
765
+ this.enabled = false;
766
+ this.player.state.audioDescriptionEnabled = false;
767
+ this.player.emit('audiodescriptiondisabled');
768
+
769
+ // Reload transcript if visible
770
+ this._reloadTranscript();
771
+ }
772
+
773
+ /**
774
+ * Disable with direct src method
775
+ */
776
+ async _disableWithDirectSrc(currentTime: number, wasPlaying: boolean, posterValue: string | null) {
777
+ // Swap caption tracks back
778
+ await this._swapCaptionTracks(false);
779
+
780
+ // Set poster
781
+ if (posterValue && this.player.element.tagName === 'VIDEO') {
782
+ (this.player.element as HTMLVideoElement).poster = posterValue;
783
+ }
784
+
785
+ // Restore original src
786
+ const originalSrcToUse = this.originalSource || this.player.originalSrc;
787
+ if (!originalSrcToUse) {
788
+ return;
789
+ }
790
+ this.player.element.src = originalSrcToUse;
791
+ this.player.element.load();
792
+
793
+ // Wait and restore
794
+ await this._waitForMediaReady(currentTime > 0 || wasPlaying);
795
+
796
+ if (currentTime > 0) {
797
+ this.player.seek(currentTime);
798
+ }
799
+
800
+ if (wasPlaying) {
801
+ await this.player.play();
802
+ }
803
+
804
+ if (this.desiredState) return;
805
+ this.enabled = false;
806
+ this.player.state.audioDescriptionEnabled = false;
807
+ this.player.emit('audiodescriptiondisabled');
808
+
809
+ // Reload transcript if visible
810
+ this._reloadTranscript();
811
+ }
812
+
813
+ /**
814
+ * Reload transcript after audio description state change
815
+ */
816
+ _reloadTranscript() {
817
+ if (this.player.transcriptManager && this.player.transcriptManager.isVisible) {
818
+ // Wait for tracks to load after source swap
819
+ this.player.setManagedTimeout(() => {
820
+ if (this.player.transcriptManager && this.player.transcriptManager.loadTranscriptData) {
821
+ this.player.transcriptManager.loadTranscriptData();
822
+ }
823
+ }, 800);
824
+ }
825
+ }
826
+
827
+ /**
828
+ * Update sources (called when playlist changes)
829
+ */
830
+ updateSources(audioDescriptionSrc: string | null | undefined) {
831
+ this.speechManager?.destroy();
832
+ this.speechManager = null;
833
+ this.src = audioDescriptionSrc || null;
834
+ // Reset state for new playlist item
835
+ this.enabled = false;
836
+ this.desiredState = false;
837
+ this.sourceElement = null;
838
+ this.originalSource = null;
839
+ this.captionTracks = [];
840
+ }
841
+
842
+ /**
843
+ * Reinitialize from current player elements (called after playlist loads new tracks)
844
+ */
845
+ reinitialize() {
846
+ this.player.invalidateTrackCache();
847
+ this.initFromSourceElements(this.player.sourceElements, this.player.trackElements);
848
+ }
849
+
850
+ /**
851
+ * Cleanup
852
+ */
853
+ destroy() {
854
+ this.speechManager?.destroy();
855
+ this.speechManager = null;
856
+ this.enabled = false;
857
+ this.desiredState = false;
858
+ this.captionTracks = [];
859
+ this.sourceElement = null;
860
+ this.originalSource = null;
861
+ }
862
+ }
863
+