vidply 1.2.0 → 1.2.3

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 (167) hide show
  1. package/README.md +974 -946
  2. package/dist/dev/{vidply.AudioDescriptionManager-R47Q4YR4.js → vidply.AudioDescriptionManager-JYYLK5KB.js} +275 -19
  3. package/dist/dev/vidply.AudioDescriptionManager-JYYLK5KB.js.map +7 -0
  4. package/dist/dev/{vidply.CaptionStyleMenu-7XHRBWUM.js → vidply.CaptionStyleMenu-47RCKQF5.js} +4 -4
  5. package/dist/dev/{vidply.DASHRenderer-PWEMC3BT.js → vidply.DASHRenderer-VEJ4HDHO.js} +3 -3
  6. package/dist/dev/vidply.DASHRenderer-VEJ4HDHO.js.map +7 -0
  7. package/dist/dev/{vidply.FloatingPlayerManager-PKNNNAS2.js → vidply.FloatingPlayerManager-C2UVRPZY.js} +5 -5
  8. package/dist/dev/vidply.FloatingPlayerManager-C2UVRPZY.js.map +7 -0
  9. package/dist/dev/{vidply.HLSRenderer-E7RQ74HI.js → vidply.HLSRenderer-ZVBK2TNQ.js} +4 -4
  10. package/dist/dev/vidply.HLSRenderer-ZVBK2TNQ.js.map +7 -0
  11. package/dist/dev/vidply.HTML5Renderer-HLRQUOCU.js +12 -0
  12. package/dist/dev/{vidply.SignLanguageManager-FET5FQZ7.js → vidply.SignLanguageManager-FLUCBW5H.js} +8 -8
  13. package/dist/dev/vidply.SignLanguageManager-FLUCBW5H.js.map +7 -0
  14. package/dist/dev/{vidply.SoundCloudRenderer-RVSJZODO.js → vidply.SoundCloudRenderer-MGMAJ7NK.js} +3 -3
  15. package/dist/dev/vidply.SoundCloudRenderer-MGMAJ7NK.js.map +7 -0
  16. package/dist/dev/{vidply.TranscriptManager-5GXOQCLX.js → vidply.TranscriptManager-Y2RYXHJW.js} +10 -10
  17. package/dist/dev/vidply.TranscriptManager-Y2RYXHJW.js.map +7 -0
  18. package/dist/dev/{vidply.VimeoRenderer-ER2LSBBW.js → vidply.VimeoRenderer-RQFQD7OE.js} +3 -3
  19. package/dist/dev/vidply.VimeoRenderer-RQFQD7OE.js.map +7 -0
  20. package/dist/dev/{vidply.YouTubeRenderer-NFTOWJAB.js → vidply.YouTubeRenderer-EU2BARZF.js} +3 -3
  21. package/dist/dev/vidply.YouTubeRenderer-EU2BARZF.js.map +7 -0
  22. package/dist/dev/{vidply.chunk-BNFIUL2Z.js → vidply.chunk-6LYZYI5G.js} +3 -3
  23. package/dist/dev/vidply.chunk-6LYZYI5G.js.map +7 -0
  24. package/dist/dev/{vidply.chunk-NQPURMQY.js → vidply.chunk-A4ESEOQ2.js} +6 -6
  25. package/dist/dev/vidply.chunk-A4ESEOQ2.js.map +7 -0
  26. package/dist/dev/{vidply.chunk-W25HUCHS.js → vidply.chunk-CGYIED2I.js} +2 -2
  27. package/dist/dev/vidply.chunk-CGYIED2I.js.map +7 -0
  28. package/dist/dev/{vidply.chunk-SK4V2DAU.js → vidply.chunk-HZ7UOKOU.js} +3 -3
  29. package/dist/dev/{vidply.chunk-RZWZGDV7.js → vidply.chunk-I6EDRYGJ.js} +33 -6
  30. package/dist/dev/vidply.chunk-I6EDRYGJ.js.map +7 -0
  31. package/dist/dev/{vidply.chunk-64WXMTGL.js → vidply.chunk-JKMQGLCJ.js} +5 -5
  32. package/dist/dev/vidply.chunk-JKMQGLCJ.js.map +7 -0
  33. package/dist/dev/{vidply.chunk-DZSDO3B7.js → vidply.chunk-JZNZX32Z.js} +2 -2
  34. package/dist/dev/{vidply.chunk-VMSQAZM7.js → vidply.chunk-P25MRJ6O.js} +2 -2
  35. package/dist/dev/{vidply.chunk-6RQA4ROD.js → vidply.chunk-PRFNESYA.js} +2 -2
  36. package/dist/dev/{vidply.chunk-KI7C3QVL.js → vidply.chunk-UI3RYCVF.js} +4 -3
  37. package/dist/dev/vidply.chunk-UI3RYCVF.js.map +7 -0
  38. package/dist/dev/{vidply.chunk-JJ47NO24.js → vidply.chunk-UNFGMABP.js} +2 -2
  39. package/dist/dev/vidply.chunk-UNFGMABP.js.map +7 -0
  40. package/dist/dev/{vidply.de-4YAT7FFM.js → vidply.de-VAUVSQXC.js} +29 -2
  41. package/dist/dev/vidply.de-VAUVSQXC.js.map +7 -0
  42. package/dist/dev/{vidply.es-LEWIRUBH.js → vidply.es-G65HFWO6.js} +29 -2
  43. package/dist/dev/vidply.es-G65HFWO6.js.map +7 -0
  44. package/dist/dev/vidply.esm.js +648 -35
  45. package/dist/dev/vidply.esm.js.map +3 -3
  46. package/dist/dev/{vidply.fr-Q4GV2NOE.js → vidply.fr-MJNOZELM.js} +29 -2
  47. package/dist/dev/vidply.fr-MJNOZELM.js.map +7 -0
  48. package/dist/dev/{vidply.ja-S6ITA6WU.js → vidply.ja-INMDHIYJ.js} +29 -2
  49. package/dist/dev/vidply.ja-INMDHIYJ.js.map +7 -0
  50. package/dist/legacy/vidply.js +1062 -34
  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 +127 -55
  54. package/dist/prod/vidply.AudioDescriptionManager-EKWUNOPE.min.js +6 -0
  55. package/dist/prod/{vidply.CaptionStyleMenu-M3KVQWB5.min.js → vidply.CaptionStyleMenu-JTWSNHRQ.min.js} +2 -2
  56. package/dist/prod/{vidply.DASHRenderer-DN3S27AV.min.js → vidply.DASHRenderer-CIDAHFWM.min.js} +2 -2
  57. package/dist/prod/{vidply.FloatingPlayerManager-SKAZ4J43.min.js → vidply.FloatingPlayerManager-VZXHPCPM.min.js} +2 -2
  58. package/dist/prod/{vidply.HLSRenderer-62HH4YTK.min.js → vidply.HLSRenderer-Q6EFYKID.min.js} +2 -2
  59. package/dist/prod/vidply.HTML5Renderer-XRDJUTOO.min.js +6 -0
  60. package/dist/prod/{vidply.SignLanguageManager-V3OBTSSF.min.js → vidply.SignLanguageManager-JUOSA5AM.min.js} +2 -2
  61. package/dist/prod/{vidply.SoundCloudRenderer-CXRVZIAJ.min.js → vidply.SoundCloudRenderer-7ODD46PO.min.js} +2 -2
  62. package/dist/prod/{vidply.TranscriptManager-SYM3DFUY.min.js → vidply.TranscriptManager-4B2QKYGQ.min.js} +2 -2
  63. package/dist/prod/{vidply.VimeoRenderer-72TWRYFQ.min.js → vidply.VimeoRenderer-MR3UYLY7.min.js} +2 -2
  64. package/dist/prod/{vidply.YouTubeRenderer-UMHEV3KD.min.js → vidply.YouTubeRenderer-ITRTC7NR.min.js} +2 -2
  65. package/dist/prod/{vidply.chunk-YEENJS3Q.min.js → vidply.chunk-3EEDXU4T.min.js} +2 -2
  66. package/dist/prod/{vidply.chunk-P4UXOHWA.min.js → vidply.chunk-5HTX5QXW.min.js} +1 -1
  67. package/dist/prod/{vidply.chunk-ABPXVDLZ.min.js → vidply.chunk-6OGHHIYP.min.js} +2 -2
  68. package/dist/prod/{vidply.chunk-UK7YGOB5.min.js → vidply.chunk-GUPGMFUZ.min.js} +2 -2
  69. package/dist/prod/{vidply.chunk-WX3MVEAL.min.js → vidply.chunk-MWEBITCP.min.js} +1 -1
  70. package/dist/prod/{vidply.chunk-2TB6MUHO.min.js → vidply.chunk-PL3P3JHR.min.js} +1 -1
  71. package/dist/prod/{vidply.chunk-EKEYJWMG.min.js → vidply.chunk-QQZGV7TD.min.js} +2 -2
  72. package/dist/prod/{vidply.chunk-Z3YDSA7O.min.js → vidply.chunk-U5IRRYG5.min.js} +1 -1
  73. package/dist/prod/{vidply.chunk-TM6AXJVD.min.js → vidply.chunk-VKHV4QHA.min.js} +2 -2
  74. package/dist/prod/{vidply.chunk-P3PQBY7N.min.js → vidply.chunk-XA4M574A.min.js} +1 -1
  75. package/dist/prod/{vidply.chunk-RI3X7WHK.min.js → vidply.chunk-ZPTV4JEP.min.js} +2 -2
  76. package/dist/prod/{vidply.de-BCHXRQ53.min.js → vidply.de-UZHFCEZU.min.js} +2 -2
  77. package/dist/prod/{vidply.es-PICAKMZD.min.js → vidply.es-BOISUV2S.min.js} +2 -2
  78. package/dist/prod/vidply.esm.min.js +2 -2
  79. package/dist/prod/{vidply.fr-ZILI7CDR.min.js → vidply.fr-ICSMKPFM.min.js} +2 -2
  80. package/dist/prod/{vidply.ja-5WQ4OK4J.min.js → vidply.ja-VQ2HKGAY.min.js} +2 -2
  81. package/dist/vidply.css +5852 -5796
  82. package/dist/vidply.esm.min.meta.json +204 -149
  83. package/dist/vidply.min.css +1 -1
  84. package/package.json +11 -11
  85. package/src/controls/CaptionManager.ts +598 -598
  86. package/src/controls/ControlBar.ts +4191 -4159
  87. package/src/controls/KeyboardHelp.ts +326 -0
  88. package/src/controls/KeyboardManager.ts +387 -383
  89. package/src/controls/SettingsDialog.ts +488 -488
  90. package/src/controls/TranscriptManager.ts +2426 -2426
  91. package/src/core/AudioDescriptionManager.ts +863 -774
  92. package/src/core/DescriptionSpeechManager.ts +232 -0
  93. package/src/core/FloatingPlayerManager.ts +674 -674
  94. package/src/core/LazyInit.ts +80 -80
  95. package/src/core/MediaSessionManager.ts +341 -0
  96. package/src/core/MetadataAlertsManager.ts +514 -514
  97. package/src/core/Player.ts +2973 -2872
  98. package/src/core/PosterManager.ts +169 -169
  99. package/src/core/PseudoFullscreen.ts +179 -179
  100. package/src/core/ResponsiveManager.ts +231 -231
  101. package/src/core/ResumeManager.ts +236 -236
  102. package/src/core/SignLanguageManager.ts +1308 -1308
  103. package/src/core/ThemeManager.ts +157 -157
  104. package/src/features/PlaylistManager.ts +1838 -1827
  105. package/src/i18n/i18n.ts +223 -223
  106. package/src/i18n/languages/de.ts +244 -217
  107. package/src/i18n/languages/en.ts +245 -218
  108. package/src/i18n/languages/es.ts +241 -214
  109. package/src/i18n/languages/fr.ts +241 -214
  110. package/src/i18n/languages/ja.ts +241 -214
  111. package/src/icons/Icons.ts +168 -167
  112. package/src/index.ts +196 -193
  113. package/src/renderers/DASHRenderer.ts +1077 -1077
  114. package/src/renderers/HLSRenderer.ts +944 -944
  115. package/src/renderers/HTML5Renderer.ts +389 -389
  116. package/src/renderers/SoundCloudRenderer.ts +437 -437
  117. package/src/renderers/VimeoRenderer.ts +298 -298
  118. package/src/renderers/YouTubeRenderer.ts +290 -290
  119. package/src/styles/vidply.css +5852 -5796
  120. package/src/types/events.ts +128 -122
  121. package/src/types/globals.d.ts +313 -313
  122. package/src/types/options.ts +199 -182
  123. package/src/types/renderer.ts +58 -58
  124. package/src/utils/DOMUtils.ts +281 -281
  125. package/src/utils/DownloadInfo.ts +171 -171
  126. package/src/utils/DraggablePanel.ts +539 -539
  127. package/src/utils/DraggablePanelMenu.ts +195 -195
  128. package/src/utils/DraggableResizable.ts +997 -997
  129. package/src/utils/EventEmitter.ts +59 -59
  130. package/src/utils/FormUtils.ts +95 -95
  131. package/src/utils/MenuFactory.ts +483 -483
  132. package/src/utils/Sanitize.ts +70 -70
  133. package/src/utils/SettingsMenuFactory.ts +289 -289
  134. package/src/utils/StorageManager.ts +287 -287
  135. package/src/utils/TrackLabelUtils.ts +52 -52
  136. package/src/utils/UrlSafe.ts +69 -69
  137. package/src/utils/VideoFrameCapture.ts +114 -114
  138. package/src/utils/WindowComponents.ts +250 -250
  139. package/dist/dev/vidply.AudioDescriptionManager-R47Q4YR4.js.map +0 -7
  140. package/dist/dev/vidply.DASHRenderer-PWEMC3BT.js.map +0 -7
  141. package/dist/dev/vidply.FloatingPlayerManager-PKNNNAS2.js.map +0 -7
  142. package/dist/dev/vidply.HLSRenderer-E7RQ74HI.js.map +0 -7
  143. package/dist/dev/vidply.HTML5Renderer-BU4QJJJY.js +0 -12
  144. package/dist/dev/vidply.SignLanguageManager-FET5FQZ7.js.map +0 -7
  145. package/dist/dev/vidply.SoundCloudRenderer-RVSJZODO.js.map +0 -7
  146. package/dist/dev/vidply.TranscriptManager-5GXOQCLX.js.map +0 -7
  147. package/dist/dev/vidply.VimeoRenderer-ER2LSBBW.js.map +0 -7
  148. package/dist/dev/vidply.YouTubeRenderer-NFTOWJAB.js.map +0 -7
  149. package/dist/dev/vidply.chunk-64WXMTGL.js.map +0 -7
  150. package/dist/dev/vidply.chunk-BNFIUL2Z.js.map +0 -7
  151. package/dist/dev/vidply.chunk-JJ47NO24.js.map +0 -7
  152. package/dist/dev/vidply.chunk-KI7C3QVL.js.map +0 -7
  153. package/dist/dev/vidply.chunk-NQPURMQY.js.map +0 -7
  154. package/dist/dev/vidply.chunk-RZWZGDV7.js.map +0 -7
  155. package/dist/dev/vidply.chunk-W25HUCHS.js.map +0 -7
  156. package/dist/dev/vidply.de-4YAT7FFM.js.map +0 -7
  157. package/dist/dev/vidply.es-LEWIRUBH.js.map +0 -7
  158. package/dist/dev/vidply.fr-Q4GV2NOE.js.map +0 -7
  159. package/dist/dev/vidply.ja-S6ITA6WU.js.map +0 -7
  160. package/dist/prod/vidply.AudioDescriptionManager-RHLIJW5C.min.js +0 -6
  161. package/dist/prod/vidply.HTML5Renderer-IU5T3WEW.min.js +0 -6
  162. /package/dist/dev/{vidply.CaptionStyleMenu-7XHRBWUM.js.map → vidply.CaptionStyleMenu-47RCKQF5.js.map} +0 -0
  163. /package/dist/dev/{vidply.HTML5Renderer-BU4QJJJY.js.map → vidply.HTML5Renderer-HLRQUOCU.js.map} +0 -0
  164. /package/dist/dev/{vidply.chunk-SK4V2DAU.js.map → vidply.chunk-HZ7UOKOU.js.map} +0 -0
  165. /package/dist/dev/{vidply.chunk-DZSDO3B7.js.map → vidply.chunk-JZNZX32Z.js.map} +0 -0
  166. /package/dist/dev/{vidply.chunk-VMSQAZM7.js.map → vidply.chunk-P25MRJ6O.js.map} +0 -0
  167. /package/dist/dev/{vidply.chunk-6RQA4ROD.js.map → vidply.chunk-PRFNESYA.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
+