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,598 +1,598 @@
1
- /**
2
- * Caption/Subtitle Manager
3
- */
4
-
5
- import {DOMUtils} from '../utils/DOMUtils.js';
6
- import {i18n} from '../i18n/i18n.js';
7
- import {StorageManager} from '../utils/StorageManager.js';
8
- import {debounce, isMobile, rafWithTimeout, reducedMotionScrollOptions} from '../utils/PerformanceUtils.js';
9
- import {deriveTrackLabel} from '../utils/TrackLabelUtils.js';
10
- import type { Player } from '../core/Player.js';
11
-
12
- /**
13
- * Deduplicated subtitle/captions entry shown in the captions menu. Native HLS
14
- * on Safari can expose the same language as both `SUBTITLES` (WebVTT sidecar)
15
- * and `CLOSED-CAPTIONS` (inband CEA-608) groups; the duplicates are kept in
16
- * `alternatives` so we can listen on all of them and use whichever delivers
17
- * cues first.
18
- */
19
- export type CaptionTrackEntry = {
20
- track: TextTrack;
21
- language: string;
22
- label: string;
23
- kind: string;
24
- index: number;
25
- isDefault: boolean;
26
- alternatives: TextTrack[];
27
- };
28
-
29
- type CueChangeHandler = () => void;
30
-
31
- export class CaptionManager {
32
- player: Player;
33
- _altCueChangeHandler: CueChangeHandler | null = null;
34
- cueChangeHandler: CueChangeHandler | null = null;
35
- currentCue: VTTCue | null;
36
- currentTrack: CaptionTrackEntry | null;
37
- debouncedPositionCaptions!: () => void;
38
- element!: HTMLElement;
39
- storage: StorageManager;
40
- tracks: CaptionTrackEntry[];
41
-
42
- constructor(player: Player) {
43
- this.player = player;
44
- this.tracks = [];
45
- this.currentTrack = null;
46
- this.currentCue = null;
47
-
48
- // Storage manager
49
- this.storage = new StorageManager('vidply');
50
-
51
- // Load saved preferences
52
- this.loadSavedPreferences();
53
-
54
- this.init();
55
- }
56
-
57
- loadSavedPreferences() {
58
- const saved = this.storage.getCaptionPreferences();
59
- if (saved) {
60
- if (typeof saved.fontSize === 'string') this.player.options.captionsFontSize = saved.fontSize;
61
- if (typeof saved.fontFamily === 'string') this.player.options.captionsFontFamily = saved.fontFamily;
62
- if (typeof saved.color === 'string') this.player.options.captionsColor = saved.color;
63
- if (typeof saved.backgroundColor === 'string') this.player.options.captionsBackgroundColor = saved.backgroundColor;
64
- if (typeof saved.opacity === 'number') this.player.options.captionsOpacity = saved.opacity;
65
- }
66
- }
67
-
68
- saveCaptionPreferences() {
69
- this.storage.saveCaptionPreferences({
70
- fontSize: this.player.options.captionsFontSize,
71
- fontFamily: this.player.options.captionsFontFamily,
72
- color: this.player.options.captionsColor,
73
- backgroundColor: this.player.options.captionsBackgroundColor,
74
- opacity: this.player.options.captionsOpacity
75
- });
76
- }
77
-
78
- init() {
79
- this.createElement();
80
- this.loadTracks();
81
- this.attachEvents();
82
-
83
- // Only enable captions via captionsDefault option if no default track was found
84
- // (loadTracks() already enables tracks with the default attribute)
85
- if (this.player.options.captionsDefault && this.tracks.length > 0 && !this.currentTrack) {
86
- this.enable();
87
- }
88
- }
89
-
90
- createElement() {
91
- // The native <track> is rendered hidden and captions are drawn into
92
- // this overlay, so assistive tech cannot read cues from the track
93
- // itself. aria-live="polite" makes each cue change announceable for
94
- // screen-reader users (notably deaf-blind/braille users) without
95
- // interrupting other speech (WCAG 1.2.2, 4.1.3).
96
- this.element = DOMUtils.createElement('div', {
97
- className: `${this.player.options.classPrefix}-captions`,
98
- attributes: {
99
- 'role': 'region',
100
- 'aria-label': i18n.t('player.captions'),
101
- 'aria-live': 'polite'
102
- }
103
- });
104
-
105
- // Apply caption styles
106
- this.updateStyles();
107
-
108
- // Append to videoWrapper if it exists, otherwise to container
109
- const target = this.player.videoWrapper || this.player.container;
110
- target.appendChild(this.element);
111
- }
112
-
113
- loadTracks() {
114
- const textTracks = this.player.element.textTracks;
115
- let defaultTrackIndex = -1;
116
-
117
- // Safari's native HLS can expose both SUBTITLES (WebVTT sidecar) and
118
- // CLOSED-CAPTIONS (inband CEA-608) groups as separate TextTrack objects
119
- // with the same language and label. Deduplicate to one menu entry per
120
- // language+label, storing the extras as alternatives. enable() will
121
- // listen on all of them and use whichever delivers cues first.
122
- const seen = new Map();
123
-
124
- for (let i = 0; i < textTracks.length; i++) {
125
- const track = textTracks[i];
126
- if (!track) continue;
127
-
128
- if ((track.kind === 'subtitles' || track.kind === 'captions') && !track._vidplyStale) {
129
- track.mode = 'hidden';
130
-
131
- const dedupeKey = `${track.language}|${track.label}`;
132
- const existing = seen.get(dedupeKey);
133
-
134
- if (existing) {
135
- existing.alternatives.push(track);
136
- continue;
137
- }
138
-
139
- const trackElement = this.player.findTrackElement(track) as Element | undefined;
140
- const isDefault = trackElement ? trackElement.hasAttribute('default') : false;
141
- const entry: CaptionTrackEntry = {
142
- track,
143
- language: track.language,
144
- label: deriveTrackLabel(track.label, track.language),
145
- kind: track.kind,
146
- index: i,
147
- isDefault,
148
- alternatives: []
149
- };
150
-
151
- this.tracks.push(entry);
152
- seen.set(dedupeKey, entry);
153
-
154
- if (isDefault) {
155
- defaultTrackIndex = this.tracks.length - 1;
156
- }
157
- }
158
- }
159
-
160
- if (defaultTrackIndex >= 0) {
161
- requestAnimationFrame(() => {
162
- this.enable(defaultTrackIndex);
163
- });
164
- }
165
- }
166
-
167
- /**
168
- * Sync hls.js subtitle rendition to match the given language.
169
- * Matches by lang, language, or falls back to name/label.
170
- */
171
- private _syncHlsSubtitleTrack(targetLang: string, targetLabel?: string) {
172
- const renderer = this.player.renderer;
173
- if (!renderer?.hls || !renderer.hls.subtitleTracks?.length) return;
174
-
175
- const tracks = renderer.hls.subtitleTracks as Array<{ lang?: string; name?: string }>;
176
- let hlsIndex = tracks.findIndex(
177
- (t) => t.lang === targetLang
178
- );
179
- // Fallback: match by name/label
180
- if (hlsIndex < 0 && targetLabel) {
181
- hlsIndex = tracks.findIndex(
182
- (t) => t.name === targetLabel
183
- );
184
- }
185
- if (hlsIndex >= 0 && renderer.hls.subtitleTrack !== hlsIndex) {
186
- renderer.hls.subtitleTrack = hlsIndex;
187
- this.player.log(`HLS subtitle track set to index ${hlsIndex} (${targetLang})`, 'info');
188
- }
189
- }
190
-
191
- attachEvents() {
192
- this.player.on('timeupdate', () => {
193
- this.updateCaptions();
194
- });
195
-
196
- // When hls.js finishes loading new subtitle cues, refresh captions
197
- this.player.on('textcuesupdate', () => {
198
- this.updateCaptions();
199
- });
200
-
201
- this.player.on('captionschange', () => {
202
- this.updateStyles();
203
- });
204
-
205
- // Debounced resize handler to avoid excessive recalculations
206
- this.debouncedPositionCaptions = debounce(() => {
207
- this.positionCaptionsOnMobile();
208
- }, 150);
209
-
210
- window.addEventListener('resize', this.debouncedPositionCaptions, {
211
- signal: this.player.lifecycleSignal
212
- });
213
-
214
- // Recalculate on fullscreen change with RAF
215
- this.player.on('enterfullscreen', () => {
216
- rafWithTimeout(() => this.positionCaptionsOnMobile(), 100);
217
- });
218
-
219
- this.player.on('exitfullscreen', () => {
220
- rafWithTimeout(() => this.positionCaptionsOnMobile(), 100);
221
- });
222
- }
223
-
224
- enable(trackIndex = 0) {
225
- if (this.tracks.length === 0) {
226
- return;
227
- }
228
-
229
- // Disable current track and clean up alternative listeners
230
- this._cleanupTrackListeners();
231
-
232
- // Enable selected track
233
- const selectedTrack = this.tracks[trackIndex];
234
-
235
- if (selectedTrack && selectedTrack.track) {
236
- selectedTrack.track.mode = 'hidden';
237
- this.currentTrack = selectedTrack;
238
- this.player.state.captionsEnabled = true;
239
-
240
- if (selectedTrack.language) {
241
- this.element.setAttribute('lang', selectedTrack.language);
242
- }
243
-
244
- // Bind handlers to locals so the non-null narrowing survives both
245
- // closure captures and `this.X = …` reassignments. Without this,
246
- // `addEventListener(this.cueChangeHandler)` widens to `… | null`
247
- // and stops type-checking even though we just assigned a function.
248
- const cueChangeHandler: CueChangeHandler = () => {
249
- this.updateCaptions();
250
- };
251
- this.cueChangeHandler = cueChangeHandler;
252
- selectedTrack.track.addEventListener('cuechange', cueChangeHandler);
253
-
254
- // Native HLS dedup: Safari can expose duplicate TextTrack objects
255
- // for the same language (SUBTITLES vs CLOSED-CAPTIONS groups) but
256
- // only one actually delivers cues. Listen on all alternatives and
257
- // swap to whichever fires first with real cues.
258
- if (selectedTrack.alternatives && selectedTrack.alternatives.length > 0) {
259
- const altCueChangeHandler: CueChangeHandler = () => {
260
- if (this.currentTrack !== selectedTrack) return;
261
- for (const alt of selectedTrack.alternatives) {
262
- if (alt.activeCues && alt.activeCues.length > 0) {
263
- this.player.log(`Switching to alternative caption track for "${selectedTrack.label}"`, 'info');
264
- selectedTrack.track.removeEventListener('cuechange', cueChangeHandler);
265
- selectedTrack.alternatives.forEach((a) => a.removeEventListener('cuechange', altCueChangeHandler));
266
- selectedTrack.track = alt;
267
- selectedTrack.track.addEventListener('cuechange', cueChangeHandler);
268
- this._altCueChangeHandler = null;
269
- this.updateCaptions();
270
- return;
271
- }
272
- }
273
- };
274
- this._altCueChangeHandler = altCueChangeHandler;
275
- selectedTrack.alternatives.forEach((alt) => {
276
- alt.mode = 'hidden';
277
- alt.addEventListener('cuechange', altCueChangeHandler);
278
- });
279
- }
280
-
281
- // The "ready" sense actually lives on the underlying <track> HTML
282
- // element — not on the TextTrack itself, which has no `readyState`
283
- // or `load` event. Look the element up so this is type-safe and
284
- // also fires the load callback in browsers where TextTrack alone
285
- // would never have done so.
286
- const trackElement = this.player.findTrackElement(selectedTrack.track) as HTMLTrackElement | undefined;
287
- const ensureTrackReady = () => {
288
- if (trackElement && trackElement.readyState < 2) {
289
- const onTrackLoad = () => {
290
- trackElement.removeEventListener('load', onTrackLoad);
291
- trackElement.removeEventListener('error', onTrackLoad);
292
- requestAnimationFrame(() => {
293
- if (this.currentTrack && this.currentTrack.track === selectedTrack.track) {
294
- this.updateCaptions();
295
- }
296
- });
297
- };
298
- trackElement.addEventListener('load', onTrackLoad, { once: true });
299
- trackElement.addEventListener('error', onTrackLoad, { once: true });
300
- } else {
301
- requestAnimationFrame(() => {
302
- if (this.currentTrack && this.currentTrack.track === selectedTrack.track) {
303
- this.updateCaptions();
304
- }
305
- });
306
- }
307
- };
308
-
309
- requestAnimationFrame(() => {
310
- if (this.currentTrack && this.currentTrack.track === selectedTrack.track) {
311
- ensureTrackReady();
312
- }
313
- });
314
-
315
- // Sync hls.js subtitle rendition to match the selected track language
316
- this._syncHlsSubtitleTrack(selectedTrack.language, selectedTrack.label);
317
-
318
- this.player.emit('captionsenabled', selectedTrack);
319
- }
320
- }
321
-
322
- _cleanupTrackListeners() {
323
- if (this.currentTrack && this.currentTrack.track) {
324
- const cueChangeHandler = this.cueChangeHandler;
325
- if (cueChangeHandler) {
326
- this.currentTrack.track.removeEventListener('cuechange', cueChangeHandler);
327
- }
328
- const altCueChangeHandler = this._altCueChangeHandler;
329
- if (altCueChangeHandler && this.currentTrack.alternatives) {
330
- this.currentTrack.alternatives.forEach((alt) => {
331
- alt.removeEventListener('cuechange', altCueChangeHandler);
332
- });
333
- }
334
- this.currentTrack.track.mode = 'hidden';
335
- }
336
- this._altCueChangeHandler = null;
337
- }
338
-
339
- disable() {
340
- this._cleanupTrackListeners();
341
- this.currentTrack = null;
342
-
343
- this.element.style.display = 'none';
344
- this.element.replaceChildren();
345
- this.element.removeAttribute('lang');
346
- this.currentCue = null;
347
- this.player.state.captionsEnabled = false;
348
- this.player.emit('captionsdisabled');
349
- }
350
-
351
- updateCaptions() {
352
- if (!this.currentTrack || !this.currentTrack.track) {
353
- return;
354
- }
355
-
356
- // When the renderer handles its own caption display (e.g. dash.js TTML
357
- // rendering div), skip VidPly's caption overlay entirely.
358
- if (this.player.renderer?.handlesOwnCaptions?.()) {
359
- return;
360
- }
361
-
362
- // Ensure track mode is set to 'hidden' (not 'disabled') to receive cue updates
363
- // 'hidden' mode loads cues and fires cuechange events, but doesn't show native captions
364
- if (this.currentTrack.track.mode === 'disabled') {
365
- this.currentTrack.track.mode = 'hidden';
366
- }
367
-
368
- if (this.currentTrack.track.mode === 'showing') {
369
- this.currentTrack.track.mode = 'hidden';
370
- }
371
-
372
- if (!this.currentTrack.track.activeCues) {
373
- if (this.currentTrack.track.cues && this.currentTrack.track.cues.length > 0) {
374
- if (this.currentCue) {
375
- this.element.replaceChildren();
376
- this.element.style.display = 'none';
377
- this.currentCue = null;
378
- }
379
- }
380
- return;
381
- }
382
-
383
- const activeCues = this.currentTrack.track.activeCues;
384
- const isAudioPlayer = this.player.element.tagName.toLowerCase() === 'audio';
385
-
386
- if (activeCues.length > 0) {
387
- // VTT/SRT subtitle tracks deliver `VTTCue` instances (which extend
388
- // TextTrackCue with `.text`). The lib.dom typing widens this to
389
- // `TextTrackCue`, so cast at the boundary.
390
- const cue = activeCues[0] as VTTCue;
391
-
392
- if (this.currentCue !== cue) {
393
- this.currentCue = cue;
394
-
395
- const rawText = cue.text || '';
396
- if (!rawText.trim()) {
397
- return;
398
- }
399
-
400
- const fragment = DOMUtils.renderVTTToDOM(rawText);
401
-
402
- if (isAudioPlayer) {
403
- const existingCues = this.element.querySelectorAll(`.${this.player.options.classPrefix}-caption-cue`);
404
- existingCues.forEach((el: Element) => el.classList.remove(`${this.player.options.classPrefix}-caption-active`));
405
-
406
- const cueId = `cue-${cue.startTime}-${cue.endTime}`;
407
- let cueElement = this.element.querySelector(`[data-cue-id="${cueId}"]`);
408
-
409
- if (!cueElement) {
410
- cueElement = document.createElement('div');
411
- cueElement.className = `${this.player.options.classPrefix}-caption-cue`;
412
- cueElement.setAttribute('data-cue-id', cueId);
413
- cueElement.replaceChildren(fragment);
414
- this.element.appendChild(cueElement);
415
- } else {
416
- cueElement.replaceChildren(fragment);
417
- }
418
-
419
- cueElement.classList.add(`${this.player.options.classPrefix}-caption-active`);
420
-
421
- requestAnimationFrame(() => {
422
- if (cueElement) {
423
- cueElement.scrollIntoView(reducedMotionScrollOptions('center'));
424
- }
425
- });
426
- } else {
427
- this.element.replaceChildren(fragment);
428
- }
429
-
430
- this.element.style.display = 'block';
431
- this.positionCaptionsOnMobile();
432
- this.player.emit('captionchange', cue);
433
- }
434
- } else if (this.currentCue) {
435
- if (!isAudioPlayer) {
436
- this.element.replaceChildren();
437
- this.element.style.display = 'none';
438
- }
439
- this.currentCue = null;
440
- }
441
- }
442
-
443
- positionCaptionsOnMobile() {
444
- if (!this.element || this.element.style.display === 'none') {
445
- return;
446
- }
447
-
448
- const isFullscreen = this.player.state?.fullscreen || false;
449
- const mobile = isMobile();
450
-
451
- if (!mobile && !isFullscreen) {
452
- // Reset to CSS defaults on desktop
453
- this.element.style.bottom = '';
454
- return;
455
- }
456
-
457
- const controls = this.player.controlBar?.element;
458
- if (!controls) {
459
- return;
460
- }
461
-
462
- // Use requestAnimationFrame to ensure layout is complete
463
- requestAnimationFrame(() => {
464
- if (!this.element || this.element.style.display === 'none') {
465
- return;
466
- }
467
-
468
- const controlsRect = controls.getBoundingClientRect();
469
- if (!this.player.videoWrapper) return;
470
- const wrapperRect = this.player.videoWrapper.getBoundingClientRect();
471
- const bottomOffset = wrapperRect.bottom - controlsRect.top + 16;
472
-
473
- this.element.style.bottom = `${bottomOffset}px`;
474
-
475
- if (this.player.options.debug) {
476
- console.log('[VidPly] Caption position:', {
477
- mobile,
478
- isFullscreen,
479
- controlsHeight: controlsRect.height,
480
- bottomOffset: `${bottomOffset}px`
481
- });
482
- }
483
- });
484
- }
485
-
486
- // VTT formatting is parsed via DOMUtils.renderVTTToDOM() which returns
487
- // a DocumentFragment built with createElement / createTextNode. The
488
- // previous regex-based parseVTTFormatting helper was removed because
489
- // it produced strings that were assigned to `innerHTML`, which is
490
- // unsafe for cue text from third-party WebVTT sources.
491
-
492
- updateStyles() {
493
- if (!this.element) return;
494
-
495
- const options = this.player.options;
496
-
497
- this.element.style.fontSize = options.captionsFontSize;
498
- this.element.style.fontFamily = options.captionsFontFamily;
499
- this.element.style.color = options.captionsColor;
500
- this.element.style.backgroundColor = this.hexToRgba(
501
- options.captionsBackgroundColor,
502
- options.captionsOpacity
503
- );
504
- }
505
-
506
- hexToRgba(hex: string, alpha: number) {
507
- const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
508
- if (result && result[1] && result[2] && result[3]) {
509
- return `rgba(${parseInt(result[1], 16)}, ${parseInt(result[2], 16)}, ${parseInt(result[3], 16)}, ${alpha})`;
510
- }
511
- return hex;
512
- }
513
-
514
- setCaptionStyle(property: string, value: string | number) {
515
- // The persisted preference shape is mixed (string for font/colors,
516
- // number for opacity). Cast through the player's loose options
517
- // index so we don't have to split this method into 5 strongly-typed
518
- // overloads when the underlying string-keyed bag is itself loose.
519
- switch (property) {
520
- case 'fontSize':
521
- this.player.options.captionsFontSize = String(value);
522
- break;
523
- case 'fontFamily':
524
- this.player.options.captionsFontFamily = String(value);
525
- break;
526
- case 'color':
527
- this.player.options.captionsColor = String(value);
528
- break;
529
- case 'backgroundColor':
530
- this.player.options.captionsBackgroundColor = String(value);
531
- break;
532
- case 'opacity':
533
- this.player.options.captionsOpacity = Number(value);
534
- break;
535
- }
536
-
537
- this.updateStyles();
538
- this.saveCaptionPreferences();
539
- this.player.emit('captionschange');
540
- }
541
-
542
- getAvailableTracks() {
543
- return this.tracks.map((t, index) => ({
544
- index,
545
- language: t.language,
546
- label: t.label || t.language,
547
- kind: t.kind
548
- }));
549
- }
550
-
551
- /**
552
- * Refresh tracks list - useful when HLS adds subtitle tracks dynamically
553
- */
554
- refreshTracks() {
555
- // Store current track info to restore after refresh
556
- const currentLanguage = this.currentTrack?.language;
557
- const wasEnabled = this.player.state.captionsEnabled;
558
-
559
- // Disable current caption
560
- if (this.currentTrack) {
561
- this.disable();
562
- }
563
-
564
- // Clear existing tracks
565
- this.tracks = [];
566
-
567
- // Reload tracks from video element
568
- this.loadTracks();
569
-
570
- this.player.log(`Caption tracks refreshed, found ${this.tracks.length} tracks`, 'info');
571
-
572
- // Try to restore previous track if it exists
573
- if (wasEnabled && currentLanguage && this.tracks.length > 0) {
574
- const matchingIndex = this.tracks.findIndex((t) => t.language === currentLanguage);
575
- if (matchingIndex >= 0) {
576
- this.enable(matchingIndex);
577
- }
578
- }
579
-
580
- return this.tracks.length;
581
- }
582
-
583
- switchTrack(trackIndex: number) {
584
- if (trackIndex >= 0 && trackIndex < this.tracks.length) {
585
- this.disable();
586
- this.enable(trackIndex);
587
- }
588
- }
589
-
590
- destroy() {
591
- this.disable();
592
-
593
- if (this.element && this.element.parentNode) {
594
- this.element.parentNode.removeChild(this.element);
595
- }
596
- }
597
- }
598
-
1
+ /**
2
+ * Caption/Subtitle Manager
3
+ */
4
+
5
+ import {DOMUtils} from '../utils/DOMUtils.js';
6
+ import {i18n} from '../i18n/i18n.js';
7
+ import {StorageManager} from '../utils/StorageManager.js';
8
+ import {debounce, isMobile, rafWithTimeout, reducedMotionScrollOptions} from '../utils/PerformanceUtils.js';
9
+ import {deriveTrackLabel} from '../utils/TrackLabelUtils.js';
10
+ import type { Player } from '../core/Player.js';
11
+
12
+ /**
13
+ * Deduplicated subtitle/captions entry shown in the captions menu. Native HLS
14
+ * on Safari can expose the same language as both `SUBTITLES` (WebVTT sidecar)
15
+ * and `CLOSED-CAPTIONS` (inband CEA-608) groups; the duplicates are kept in
16
+ * `alternatives` so we can listen on all of them and use whichever delivers
17
+ * cues first.
18
+ */
19
+ export type CaptionTrackEntry = {
20
+ track: TextTrack;
21
+ language: string;
22
+ label: string;
23
+ kind: string;
24
+ index: number;
25
+ isDefault: boolean;
26
+ alternatives: TextTrack[];
27
+ };
28
+
29
+ type CueChangeHandler = () => void;
30
+
31
+ export class CaptionManager {
32
+ player: Player;
33
+ _altCueChangeHandler: CueChangeHandler | null = null;
34
+ cueChangeHandler: CueChangeHandler | null = null;
35
+ currentCue: VTTCue | null;
36
+ currentTrack: CaptionTrackEntry | null;
37
+ debouncedPositionCaptions!: () => void;
38
+ element!: HTMLElement;
39
+ storage: StorageManager;
40
+ tracks: CaptionTrackEntry[];
41
+
42
+ constructor(player: Player) {
43
+ this.player = player;
44
+ this.tracks = [];
45
+ this.currentTrack = null;
46
+ this.currentCue = null;
47
+
48
+ // Storage manager
49
+ this.storage = new StorageManager('vidply');
50
+
51
+ // Load saved preferences
52
+ this.loadSavedPreferences();
53
+
54
+ this.init();
55
+ }
56
+
57
+ loadSavedPreferences() {
58
+ const saved = this.storage.getCaptionPreferences();
59
+ if (saved) {
60
+ if (typeof saved.fontSize === 'string') this.player.options.captionsFontSize = saved.fontSize;
61
+ if (typeof saved.fontFamily === 'string') this.player.options.captionsFontFamily = saved.fontFamily;
62
+ if (typeof saved.color === 'string') this.player.options.captionsColor = saved.color;
63
+ if (typeof saved.backgroundColor === 'string') this.player.options.captionsBackgroundColor = saved.backgroundColor;
64
+ if (typeof saved.opacity === 'number') this.player.options.captionsOpacity = saved.opacity;
65
+ }
66
+ }
67
+
68
+ saveCaptionPreferences() {
69
+ this.storage.saveCaptionPreferences({
70
+ fontSize: this.player.options.captionsFontSize,
71
+ fontFamily: this.player.options.captionsFontFamily,
72
+ color: this.player.options.captionsColor,
73
+ backgroundColor: this.player.options.captionsBackgroundColor,
74
+ opacity: this.player.options.captionsOpacity
75
+ });
76
+ }
77
+
78
+ init() {
79
+ this.createElement();
80
+ this.loadTracks();
81
+ this.attachEvents();
82
+
83
+ // Only enable captions via captionsDefault option if no default track was found
84
+ // (loadTracks() already enables tracks with the default attribute)
85
+ if (this.player.options.captionsDefault && this.tracks.length > 0 && !this.currentTrack) {
86
+ this.enable();
87
+ }
88
+ }
89
+
90
+ createElement() {
91
+ // The native <track> is rendered hidden and captions are drawn into
92
+ // this overlay, so assistive tech cannot read cues from the track
93
+ // itself. aria-live="polite" makes each cue change announceable for
94
+ // screen-reader users (notably deaf-blind/braille users) without
95
+ // interrupting other speech (WCAG 1.2.2, 4.1.3).
96
+ this.element = DOMUtils.createElement('div', {
97
+ className: `${this.player.options.classPrefix}-captions`,
98
+ attributes: {
99
+ 'role': 'region',
100
+ 'aria-label': i18n.t('player.captions'),
101
+ 'aria-live': 'polite'
102
+ }
103
+ });
104
+
105
+ // Apply caption styles
106
+ this.updateStyles();
107
+
108
+ // Append to videoWrapper if it exists, otherwise to container
109
+ const target = this.player.videoWrapper || this.player.container;
110
+ target.appendChild(this.element);
111
+ }
112
+
113
+ loadTracks() {
114
+ const textTracks = this.player.element.textTracks;
115
+ let defaultTrackIndex = -1;
116
+
117
+ // Safari's native HLS can expose both SUBTITLES (WebVTT sidecar) and
118
+ // CLOSED-CAPTIONS (inband CEA-608) groups as separate TextTrack objects
119
+ // with the same language and label. Deduplicate to one menu entry per
120
+ // language+label, storing the extras as alternatives. enable() will
121
+ // listen on all of them and use whichever delivers cues first.
122
+ const seen = new Map();
123
+
124
+ for (let i = 0; i < textTracks.length; i++) {
125
+ const track = textTracks[i];
126
+ if (!track) continue;
127
+
128
+ if ((track.kind === 'subtitles' || track.kind === 'captions') && !track._vidplyStale) {
129
+ track.mode = 'hidden';
130
+
131
+ const dedupeKey = `${track.language}|${track.label}`;
132
+ const existing = seen.get(dedupeKey);
133
+
134
+ if (existing) {
135
+ existing.alternatives.push(track);
136
+ continue;
137
+ }
138
+
139
+ const trackElement = this.player.findTrackElement(track) as Element | undefined;
140
+ const isDefault = trackElement ? trackElement.hasAttribute('default') : false;
141
+ const entry: CaptionTrackEntry = {
142
+ track,
143
+ language: track.language,
144
+ label: deriveTrackLabel(track.label, track.language),
145
+ kind: track.kind,
146
+ index: i,
147
+ isDefault,
148
+ alternatives: []
149
+ };
150
+
151
+ this.tracks.push(entry);
152
+ seen.set(dedupeKey, entry);
153
+
154
+ if (isDefault) {
155
+ defaultTrackIndex = this.tracks.length - 1;
156
+ }
157
+ }
158
+ }
159
+
160
+ if (defaultTrackIndex >= 0) {
161
+ requestAnimationFrame(() => {
162
+ this.enable(defaultTrackIndex);
163
+ });
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Sync hls.js subtitle rendition to match the given language.
169
+ * Matches by lang, language, or falls back to name/label.
170
+ */
171
+ private _syncHlsSubtitleTrack(targetLang: string, targetLabel?: string) {
172
+ const renderer = this.player.renderer;
173
+ if (!renderer?.hls || !renderer.hls.subtitleTracks?.length) return;
174
+
175
+ const tracks = renderer.hls.subtitleTracks as Array<{ lang?: string; name?: string }>;
176
+ let hlsIndex = tracks.findIndex(
177
+ (t) => t.lang === targetLang
178
+ );
179
+ // Fallback: match by name/label
180
+ if (hlsIndex < 0 && targetLabel) {
181
+ hlsIndex = tracks.findIndex(
182
+ (t) => t.name === targetLabel
183
+ );
184
+ }
185
+ if (hlsIndex >= 0 && renderer.hls.subtitleTrack !== hlsIndex) {
186
+ renderer.hls.subtitleTrack = hlsIndex;
187
+ this.player.log(`HLS subtitle track set to index ${hlsIndex} (${targetLang})`, 'info');
188
+ }
189
+ }
190
+
191
+ attachEvents() {
192
+ this.player.on('timeupdate', () => {
193
+ this.updateCaptions();
194
+ });
195
+
196
+ // When hls.js finishes loading new subtitle cues, refresh captions
197
+ this.player.on('textcuesupdate', () => {
198
+ this.updateCaptions();
199
+ });
200
+
201
+ this.player.on('captionschange', () => {
202
+ this.updateStyles();
203
+ });
204
+
205
+ // Debounced resize handler to avoid excessive recalculations
206
+ this.debouncedPositionCaptions = debounce(() => {
207
+ this.positionCaptionsOnMobile();
208
+ }, 150);
209
+
210
+ window.addEventListener('resize', this.debouncedPositionCaptions, {
211
+ signal: this.player.lifecycleSignal
212
+ });
213
+
214
+ // Recalculate on fullscreen change with RAF
215
+ this.player.on('enterfullscreen', () => {
216
+ rafWithTimeout(() => this.positionCaptionsOnMobile(), 100);
217
+ });
218
+
219
+ this.player.on('exitfullscreen', () => {
220
+ rafWithTimeout(() => this.positionCaptionsOnMobile(), 100);
221
+ });
222
+ }
223
+
224
+ enable(trackIndex = 0) {
225
+ if (this.tracks.length === 0) {
226
+ return;
227
+ }
228
+
229
+ // Disable current track and clean up alternative listeners
230
+ this._cleanupTrackListeners();
231
+
232
+ // Enable selected track
233
+ const selectedTrack = this.tracks[trackIndex];
234
+
235
+ if (selectedTrack && selectedTrack.track) {
236
+ selectedTrack.track.mode = 'hidden';
237
+ this.currentTrack = selectedTrack;
238
+ this.player.state.captionsEnabled = true;
239
+
240
+ if (selectedTrack.language) {
241
+ this.element.setAttribute('lang', selectedTrack.language);
242
+ }
243
+
244
+ // Bind handlers to locals so the non-null narrowing survives both
245
+ // closure captures and `this.X = …` reassignments. Without this,
246
+ // `addEventListener(this.cueChangeHandler)` widens to `… | null`
247
+ // and stops type-checking even though we just assigned a function.
248
+ const cueChangeHandler: CueChangeHandler = () => {
249
+ this.updateCaptions();
250
+ };
251
+ this.cueChangeHandler = cueChangeHandler;
252
+ selectedTrack.track.addEventListener('cuechange', cueChangeHandler);
253
+
254
+ // Native HLS dedup: Safari can expose duplicate TextTrack objects
255
+ // for the same language (SUBTITLES vs CLOSED-CAPTIONS groups) but
256
+ // only one actually delivers cues. Listen on all alternatives and
257
+ // swap to whichever fires first with real cues.
258
+ if (selectedTrack.alternatives && selectedTrack.alternatives.length > 0) {
259
+ const altCueChangeHandler: CueChangeHandler = () => {
260
+ if (this.currentTrack !== selectedTrack) return;
261
+ for (const alt of selectedTrack.alternatives) {
262
+ if (alt.activeCues && alt.activeCues.length > 0) {
263
+ this.player.log(`Switching to alternative caption track for "${selectedTrack.label}"`, 'info');
264
+ selectedTrack.track.removeEventListener('cuechange', cueChangeHandler);
265
+ selectedTrack.alternatives.forEach((a) => a.removeEventListener('cuechange', altCueChangeHandler));
266
+ selectedTrack.track = alt;
267
+ selectedTrack.track.addEventListener('cuechange', cueChangeHandler);
268
+ this._altCueChangeHandler = null;
269
+ this.updateCaptions();
270
+ return;
271
+ }
272
+ }
273
+ };
274
+ this._altCueChangeHandler = altCueChangeHandler;
275
+ selectedTrack.alternatives.forEach((alt) => {
276
+ alt.mode = 'hidden';
277
+ alt.addEventListener('cuechange', altCueChangeHandler);
278
+ });
279
+ }
280
+
281
+ // The "ready" sense actually lives on the underlying <track> HTML
282
+ // element — not on the TextTrack itself, which has no `readyState`
283
+ // or `load` event. Look the element up so this is type-safe and
284
+ // also fires the load callback in browsers where TextTrack alone
285
+ // would never have done so.
286
+ const trackElement = this.player.findTrackElement(selectedTrack.track) as HTMLTrackElement | undefined;
287
+ const ensureTrackReady = () => {
288
+ if (trackElement && trackElement.readyState < 2) {
289
+ const onTrackLoad = () => {
290
+ trackElement.removeEventListener('load', onTrackLoad);
291
+ trackElement.removeEventListener('error', onTrackLoad);
292
+ requestAnimationFrame(() => {
293
+ if (this.currentTrack && this.currentTrack.track === selectedTrack.track) {
294
+ this.updateCaptions();
295
+ }
296
+ });
297
+ };
298
+ trackElement.addEventListener('load', onTrackLoad, { once: true });
299
+ trackElement.addEventListener('error', onTrackLoad, { once: true });
300
+ } else {
301
+ requestAnimationFrame(() => {
302
+ if (this.currentTrack && this.currentTrack.track === selectedTrack.track) {
303
+ this.updateCaptions();
304
+ }
305
+ });
306
+ }
307
+ };
308
+
309
+ requestAnimationFrame(() => {
310
+ if (this.currentTrack && this.currentTrack.track === selectedTrack.track) {
311
+ ensureTrackReady();
312
+ }
313
+ });
314
+
315
+ // Sync hls.js subtitle rendition to match the selected track language
316
+ this._syncHlsSubtitleTrack(selectedTrack.language, selectedTrack.label);
317
+
318
+ this.player.emit('captionsenabled', selectedTrack);
319
+ }
320
+ }
321
+
322
+ _cleanupTrackListeners() {
323
+ if (this.currentTrack && this.currentTrack.track) {
324
+ const cueChangeHandler = this.cueChangeHandler;
325
+ if (cueChangeHandler) {
326
+ this.currentTrack.track.removeEventListener('cuechange', cueChangeHandler);
327
+ }
328
+ const altCueChangeHandler = this._altCueChangeHandler;
329
+ if (altCueChangeHandler && this.currentTrack.alternatives) {
330
+ this.currentTrack.alternatives.forEach((alt) => {
331
+ alt.removeEventListener('cuechange', altCueChangeHandler);
332
+ });
333
+ }
334
+ this.currentTrack.track.mode = 'hidden';
335
+ }
336
+ this._altCueChangeHandler = null;
337
+ }
338
+
339
+ disable() {
340
+ this._cleanupTrackListeners();
341
+ this.currentTrack = null;
342
+
343
+ this.element.style.display = 'none';
344
+ this.element.replaceChildren();
345
+ this.element.removeAttribute('lang');
346
+ this.currentCue = null;
347
+ this.player.state.captionsEnabled = false;
348
+ this.player.emit('captionsdisabled');
349
+ }
350
+
351
+ updateCaptions() {
352
+ if (!this.currentTrack || !this.currentTrack.track) {
353
+ return;
354
+ }
355
+
356
+ // When the renderer handles its own caption display (e.g. dash.js TTML
357
+ // rendering div), skip VidPly's caption overlay entirely.
358
+ if (this.player.renderer?.handlesOwnCaptions?.()) {
359
+ return;
360
+ }
361
+
362
+ // Ensure track mode is set to 'hidden' (not 'disabled') to receive cue updates
363
+ // 'hidden' mode loads cues and fires cuechange events, but doesn't show native captions
364
+ if (this.currentTrack.track.mode === 'disabled') {
365
+ this.currentTrack.track.mode = 'hidden';
366
+ }
367
+
368
+ if (this.currentTrack.track.mode === 'showing') {
369
+ this.currentTrack.track.mode = 'hidden';
370
+ }
371
+
372
+ if (!this.currentTrack.track.activeCues) {
373
+ if (this.currentTrack.track.cues && this.currentTrack.track.cues.length > 0) {
374
+ if (this.currentCue) {
375
+ this.element.replaceChildren();
376
+ this.element.style.display = 'none';
377
+ this.currentCue = null;
378
+ }
379
+ }
380
+ return;
381
+ }
382
+
383
+ const activeCues = this.currentTrack.track.activeCues;
384
+ const isAudioPlayer = this.player.element.tagName.toLowerCase() === 'audio';
385
+
386
+ if (activeCues.length > 0) {
387
+ // VTT/SRT subtitle tracks deliver `VTTCue` instances (which extend
388
+ // TextTrackCue with `.text`). The lib.dom typing widens this to
389
+ // `TextTrackCue`, so cast at the boundary.
390
+ const cue = activeCues[0] as VTTCue;
391
+
392
+ if (this.currentCue !== cue) {
393
+ this.currentCue = cue;
394
+
395
+ const rawText = cue.text || '';
396
+ if (!rawText.trim()) {
397
+ return;
398
+ }
399
+
400
+ const fragment = DOMUtils.renderVTTToDOM(rawText);
401
+
402
+ if (isAudioPlayer) {
403
+ const existingCues = this.element.querySelectorAll(`.${this.player.options.classPrefix}-caption-cue`);
404
+ existingCues.forEach((el: Element) => el.classList.remove(`${this.player.options.classPrefix}-caption-active`));
405
+
406
+ const cueId = `cue-${cue.startTime}-${cue.endTime}`;
407
+ let cueElement = this.element.querySelector(`[data-cue-id="${cueId}"]`);
408
+
409
+ if (!cueElement) {
410
+ cueElement = document.createElement('div');
411
+ cueElement.className = `${this.player.options.classPrefix}-caption-cue`;
412
+ cueElement.setAttribute('data-cue-id', cueId);
413
+ cueElement.replaceChildren(fragment);
414
+ this.element.appendChild(cueElement);
415
+ } else {
416
+ cueElement.replaceChildren(fragment);
417
+ }
418
+
419
+ cueElement.classList.add(`${this.player.options.classPrefix}-caption-active`);
420
+
421
+ requestAnimationFrame(() => {
422
+ if (cueElement) {
423
+ cueElement.scrollIntoView(reducedMotionScrollOptions('center'));
424
+ }
425
+ });
426
+ } else {
427
+ this.element.replaceChildren(fragment);
428
+ }
429
+
430
+ this.element.style.display = 'block';
431
+ this.positionCaptionsOnMobile();
432
+ this.player.emit('captionchange', cue);
433
+ }
434
+ } else if (this.currentCue) {
435
+ if (!isAudioPlayer) {
436
+ this.element.replaceChildren();
437
+ this.element.style.display = 'none';
438
+ }
439
+ this.currentCue = null;
440
+ }
441
+ }
442
+
443
+ positionCaptionsOnMobile() {
444
+ if (!this.element || this.element.style.display === 'none') {
445
+ return;
446
+ }
447
+
448
+ const isFullscreen = this.player.state?.fullscreen || false;
449
+ const mobile = isMobile();
450
+
451
+ if (!mobile && !isFullscreen) {
452
+ // Reset to CSS defaults on desktop
453
+ this.element.style.bottom = '';
454
+ return;
455
+ }
456
+
457
+ const controls = this.player.controlBar?.element;
458
+ if (!controls) {
459
+ return;
460
+ }
461
+
462
+ // Use requestAnimationFrame to ensure layout is complete
463
+ requestAnimationFrame(() => {
464
+ if (!this.element || this.element.style.display === 'none') {
465
+ return;
466
+ }
467
+
468
+ const controlsRect = controls.getBoundingClientRect();
469
+ if (!this.player.videoWrapper) return;
470
+ const wrapperRect = this.player.videoWrapper.getBoundingClientRect();
471
+ const bottomOffset = wrapperRect.bottom - controlsRect.top + 16;
472
+
473
+ this.element.style.bottom = `${bottomOffset}px`;
474
+
475
+ if (this.player.options.debug) {
476
+ console.log('[VidPly] Caption position:', {
477
+ mobile,
478
+ isFullscreen,
479
+ controlsHeight: controlsRect.height,
480
+ bottomOffset: `${bottomOffset}px`
481
+ });
482
+ }
483
+ });
484
+ }
485
+
486
+ // VTT formatting is parsed via DOMUtils.renderVTTToDOM() which returns
487
+ // a DocumentFragment built with createElement / createTextNode. The
488
+ // previous regex-based parseVTTFormatting helper was removed because
489
+ // it produced strings that were assigned to `innerHTML`, which is
490
+ // unsafe for cue text from third-party WebVTT sources.
491
+
492
+ updateStyles() {
493
+ if (!this.element) return;
494
+
495
+ const options = this.player.options;
496
+
497
+ this.element.style.fontSize = options.captionsFontSize;
498
+ this.element.style.fontFamily = options.captionsFontFamily;
499
+ this.element.style.color = options.captionsColor;
500
+ this.element.style.backgroundColor = this.hexToRgba(
501
+ options.captionsBackgroundColor,
502
+ options.captionsOpacity
503
+ );
504
+ }
505
+
506
+ hexToRgba(hex: string, alpha: number) {
507
+ const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
508
+ if (result && result[1] && result[2] && result[3]) {
509
+ return `rgba(${parseInt(result[1], 16)}, ${parseInt(result[2], 16)}, ${parseInt(result[3], 16)}, ${alpha})`;
510
+ }
511
+ return hex;
512
+ }
513
+
514
+ setCaptionStyle(property: string, value: string | number) {
515
+ // The persisted preference shape is mixed (string for font/colors,
516
+ // number for opacity). Cast through the player's loose options
517
+ // index so we don't have to split this method into 5 strongly-typed
518
+ // overloads when the underlying string-keyed bag is itself loose.
519
+ switch (property) {
520
+ case 'fontSize':
521
+ this.player.options.captionsFontSize = String(value);
522
+ break;
523
+ case 'fontFamily':
524
+ this.player.options.captionsFontFamily = String(value);
525
+ break;
526
+ case 'color':
527
+ this.player.options.captionsColor = String(value);
528
+ break;
529
+ case 'backgroundColor':
530
+ this.player.options.captionsBackgroundColor = String(value);
531
+ break;
532
+ case 'opacity':
533
+ this.player.options.captionsOpacity = Number(value);
534
+ break;
535
+ }
536
+
537
+ this.updateStyles();
538
+ this.saveCaptionPreferences();
539
+ this.player.emit('captionschange');
540
+ }
541
+
542
+ getAvailableTracks() {
543
+ return this.tracks.map((t, index) => ({
544
+ index,
545
+ language: t.language,
546
+ label: t.label || t.language,
547
+ kind: t.kind
548
+ }));
549
+ }
550
+
551
+ /**
552
+ * Refresh tracks list - useful when HLS adds subtitle tracks dynamically
553
+ */
554
+ refreshTracks() {
555
+ // Store current track info to restore after refresh
556
+ const currentLanguage = this.currentTrack?.language;
557
+ const wasEnabled = this.player.state.captionsEnabled;
558
+
559
+ // Disable current caption
560
+ if (this.currentTrack) {
561
+ this.disable();
562
+ }
563
+
564
+ // Clear existing tracks
565
+ this.tracks = [];
566
+
567
+ // Reload tracks from video element
568
+ this.loadTracks();
569
+
570
+ this.player.log(`Caption tracks refreshed, found ${this.tracks.length} tracks`, 'info');
571
+
572
+ // Try to restore previous track if it exists
573
+ if (wasEnabled && currentLanguage && this.tracks.length > 0) {
574
+ const matchingIndex = this.tracks.findIndex((t) => t.language === currentLanguage);
575
+ if (matchingIndex >= 0) {
576
+ this.enable(matchingIndex);
577
+ }
578
+ }
579
+
580
+ return this.tracks.length;
581
+ }
582
+
583
+ switchTrack(trackIndex: number) {
584
+ if (trackIndex >= 0 && trackIndex < this.tracks.length) {
585
+ this.disable();
586
+ this.enable(trackIndex);
587
+ }
588
+ }
589
+
590
+ destroy() {
591
+ this.disable();
592
+
593
+ if (this.element && this.element.parentNode) {
594
+ this.element.parentNode.removeChild(this.element);
595
+ }
596
+ }
597
+ }
598
+