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,514 +1,514 @@
1
- /**
2
- * Metadata-track alert handling extracted from Player.
3
- *
4
- * The alert system lets a `kind=metadata` text track drive
5
- * accessibility affordances on the page — pausing the video,
6
- * focusing an element, showing or hiding a marked-up alert region,
7
- * wiring a "Continue" button. All of that lives here rather than on
8
- * Player so the code can be skipped entirely (via tree-shaking) when
9
- * the `metadataAlerts`/`metadataDirectives` options are disabled.
10
- *
11
- * Public-ish entry points (Player exposes them through thin
12
- * delegates that keep the same names):
13
- *
14
- * - `setupHandling()` — wire the `cuechange` listener
15
- * - `handleCue(cue)` — parse a single cue and dispatch directives
16
- * - `handleAlert(selector, options)` — manually drive the alert UI
17
- * - `handleHashtags(tags)` — resolve a list of cue hashtags
18
- * - `cleanup()` — called from Player.destroy()
19
- */
20
-
21
- import { reducedMotionScrollOptions } from '../utils/PerformanceUtils.js';
22
- import type { Player } from './Player.js';
23
-
24
- /** Per-selector metadata alert configuration. */
25
- export interface MetadataAlertConfig {
26
- titleSelector?: string;
27
- messageSelector?: string;
28
- title?: string;
29
- message?: string;
30
- focus?: boolean;
31
- focusOnShow?: boolean;
32
- focusTarget?: string;
33
- focusDelay?: number;
34
- label?: string;
35
- role?: string;
36
- show?: boolean;
37
- display?: string;
38
- hideDisplay?: string;
39
- autoScroll?: boolean;
40
- selector?: string;
41
- alert?: string;
42
- target?: string;
43
- continueButton?: string;
44
- hideOnContinue?: boolean;
45
- resume?: boolean;
46
- resetContent?: boolean;
47
- notification?: string;
48
- persist?: boolean;
49
-
50
- [key: string]: unknown;
51
- }
52
-
53
- /** Options accepted by `handleAlert`. */
54
- export interface MetadataAlertOptions {
55
- element?: HTMLElement | null;
56
- reason?: string;
57
- cue?: VTTCue | null;
58
- show?: boolean;
59
- focus?: boolean;
60
- autoScroll?: boolean;
61
- }
62
-
63
- interface AlertHandlerEntry {
64
- button: HTMLElement | null;
65
- handler: EventListener | null;
66
- }
67
-
68
- export class MetadataAlertsManager {
69
- private readonly player: Player;
70
- private cueChangeHandler: (() => void) | null = null;
71
- private readonly alertHandlers: Map<string, AlertHandlerEntry> = new Map();
72
-
73
- constructor(player: Player) {
74
- this.player = player;
75
- }
76
-
77
- /** The `cuechange` handler this manager installed on the metadata
78
- * track. Exposed so Player can mirror it onto itself for legacy
79
- * access (some tests poke at `player.metadataCueChangeHandler`). */
80
- get cuechangeListener(): (() => void) | null {
81
- return this.cueChangeHandler;
82
- }
83
-
84
- setupHandling(): void {
85
- const player = this.player;
86
-
87
- const setupMetadata = () => {
88
- const textTracks = player.textTracks;
89
- const metadataTrack = textTracks.find((track) => track.kind === 'metadata');
90
-
91
- if (!metadataTrack) {
92
- if (player.options.debug) player.log('[Metadata] No metadata track found');
93
- return;
94
- }
95
-
96
- // `disabled` suppresses cuechange entirely; `hidden` still fires
97
- // events without rendering the track.
98
- if (metadataTrack.mode === 'disabled') {
99
- metadataTrack.mode = 'hidden';
100
- }
101
-
102
- if (this.cueChangeHandler) {
103
- metadataTrack.removeEventListener('cuechange', this.cueChangeHandler);
104
- }
105
-
106
- this.cueChangeHandler = () => {
107
- const activeCues = Array.from(metadataTrack.activeCues || []) as VTTCue[];
108
- if (activeCues.length > 0 && player.options.debug) {
109
- player.log('[Metadata] Active cues:', activeCues.map((c) => ({
110
- start: c.startTime,
111
- end: c.endTime,
112
- text: c.text
113
- })));
114
- }
115
- activeCues.forEach((cue) => this.handleCue(cue));
116
- };
117
-
118
- metadataTrack.addEventListener('cuechange', this.cueChangeHandler);
119
- // Mirror onto player for existing destroy() cleanup path.
120
- player.metadataCueChangeHandler = this.cueChangeHandler;
121
-
122
- if (player.options.debug) {
123
- const cueCount = metadataTrack.cues ? metadataTrack.cues.length : 0;
124
- player.log('[Metadata] Track enabled,', cueCount, 'cues available');
125
- }
126
- };
127
-
128
- setupMetadata();
129
- player.on('loadedmetadata', setupMetadata);
130
- }
131
-
132
- /**
133
- * Sanitise a user-supplied selector string. Returns `null` for
134
- * anything that isn't obviously safe: non-string input, empty
135
- * after trimming, or too long to bound selector-engine cost.
136
- */
137
- normalizeSelector(selector: unknown): string | null {
138
- if (typeof selector !== 'string') return null;
139
- const trimmed = selector.trim();
140
- if (!trimmed) return null;
141
- if (trimmed.length > 200) return null;
142
- if (trimmed.startsWith('#') || trimmed.startsWith('.') || trimmed.startsWith('[')) {
143
- return trimmed;
144
- }
145
- return `#${trimmed}`;
146
- }
147
-
148
- resolveConfig(
149
- map: Record<string, unknown> | null | undefined,
150
- key: string | null | undefined
151
- ): MetadataAlertConfig | null {
152
- if (!map || !key) return null;
153
- if (Object.prototype.hasOwnProperty.call(map, key)) {
154
- return map[key] as MetadataAlertConfig;
155
- }
156
- const withoutHash = key.replace(/^#/, '');
157
- if (Object.prototype.hasOwnProperty.call(map, withoutHash)) {
158
- return map[withoutHash] as MetadataAlertConfig;
159
- }
160
- return null;
161
- }
162
-
163
- /**
164
- * Remember the original title/message text before a hashtag cue
165
- * overwrites them, so `restoreContent` can roll back on the next
166
- * cue boundary. Idempotent — a second call for the same element
167
- * does not overwrite the already-cached value.
168
- */
169
- cacheContent(element: HTMLElement | null | undefined, config: MetadataAlertConfig = {}): void {
170
- if (!element) return;
171
- const titleSelector = config.titleSelector || '[data-vidply-alert-title], h3, header';
172
- const messageSelector = config.messageSelector || '[data-vidply-alert-message], p';
173
-
174
- const titleEl = element.querySelector<HTMLElement>(titleSelector);
175
- if (titleEl && !titleEl.dataset.vidplyAlertTitleOriginal) {
176
- titleEl.dataset.vidplyAlertTitleOriginal = titleEl.textContent?.trim() ?? '';
177
- }
178
-
179
- const messageEl = element.querySelector<HTMLElement>(messageSelector);
180
- if (messageEl && !messageEl.dataset.vidplyAlertMessageOriginal) {
181
- messageEl.dataset.vidplyAlertMessageOriginal = messageEl.textContent?.trim() ?? '';
182
- }
183
- }
184
-
185
- restoreContent(element: HTMLElement | null | undefined, config: MetadataAlertConfig = {}): void {
186
- if (!element) return;
187
- const titleSelector = config.titleSelector || '[data-vidply-alert-title], h3, header';
188
- const messageSelector = config.messageSelector || '[data-vidply-alert-message], p';
189
-
190
- const titleEl = element.querySelector<HTMLElement>(titleSelector);
191
- if (titleEl && titleEl.dataset.vidplyAlertTitleOriginal) {
192
- titleEl.textContent = titleEl.dataset.vidplyAlertTitleOriginal;
193
- }
194
-
195
- const messageEl = element.querySelector<HTMLElement>(messageSelector);
196
- if (messageEl && messageEl.dataset.vidplyAlertMessageOriginal) {
197
- messageEl.textContent = messageEl.dataset.vidplyAlertMessageOriginal;
198
- }
199
- }
200
-
201
- /**
202
- * Move focus to one of the well-known targets understood by the
203
- * alert system, or to a named selector. Never silently errors — an
204
- * unresolved target is simply a no-op.
205
- */
206
- focusTarget(target: string | null | undefined, fallbackElement: HTMLElement | null = null): void {
207
- if (!target || target === 'none') return;
208
-
209
- if (target === 'alert' && fallbackElement) {
210
- fallbackElement.focus({ preventScroll: true });
211
- return;
212
- }
213
-
214
- const player = this.player;
215
-
216
- if (target === 'player') {
217
- player.container?.focus({ preventScroll: true });
218
- return;
219
- }
220
-
221
- if (target === 'media') {
222
- player.element.focus({ preventScroll: true });
223
- return;
224
- }
225
-
226
- if (target === 'playButton') {
227
- const playButton = player.controlBar?.controls?.playPause;
228
- playButton?.focus({ preventScroll: true });
229
- return;
230
- }
231
-
232
- if (typeof target === 'string') {
233
- const targetElement = document.querySelector(target) as HTMLElement | null;
234
- if (targetElement) {
235
- if (targetElement.tabIndex === -1 && !targetElement.hasAttribute('tabindex')) {
236
- targetElement.setAttribute('tabindex', '-1');
237
- }
238
- targetElement.focus({ preventScroll: true });
239
- }
240
- }
241
- }
242
-
243
- /**
244
- * The public alert entry point. Pulls config out of
245
- * `options.metadataAlerts`, locates the DOM element, and applies
246
- * show/focus/continue logic per configuration.
247
- */
248
- handleAlert(selector: string, options: MetadataAlertOptions = {}): HTMLElement | undefined {
249
- const player = this.player;
250
- if (!selector) return undefined;
251
-
252
- const config: MetadataAlertConfig =
253
- this.resolveConfig(player.options.metadataAlerts as Record<string, unknown> | null | undefined, selector) || {};
254
-
255
- // Container-scoped resolution by default; only fall back to a
256
- // global lookup when `metadataDirectives === 'global' | true`.
257
- const element = options.element || this.resolveElement(selector);
258
-
259
- if (!element) {
260
- if (player.options.debug) player.log('[Metadata] Alert element not found:', selector);
261
- return undefined;
262
- }
263
-
264
- if (player.options.debug) {
265
- player.log('[Metadata] Handling alert', selector, { reason: options.reason, config });
266
- }
267
-
268
- this.cacheContent(element, config);
269
-
270
- if (!element.dataset.vidplyAlertOriginalDisplay) {
271
- element.dataset.vidplyAlertOriginalDisplay = element.style.display || '';
272
- }
273
-
274
- if (!element.dataset.vidplyAlertDisplay) {
275
- element.dataset.vidplyAlertDisplay = config.display || 'block';
276
- }
277
-
278
- const shouldShow = options.show !== undefined ? options.show : (config.show !== false);
279
- if (shouldShow) {
280
- const displayValue = config.display || element.dataset.vidplyAlertDisplay || 'block';
281
- element.style.display = displayValue;
282
- element.hidden = false;
283
- element.removeAttribute('hidden');
284
- element.setAttribute('aria-hidden', 'false');
285
- element.setAttribute('data-vidply-alert-active', 'true');
286
- }
287
-
288
- const shouldReset = config.resetContent !== false && options.reason === 'focus';
289
- if (shouldReset) this.restoreContent(element, config);
290
-
291
- const shouldFocus = options.focus !== undefined
292
- ? options.focus
293
- : (config.focusOnShow ?? (options.reason !== 'focus'));
294
-
295
- if (shouldShow && shouldFocus) {
296
- if (element.tabIndex === -1 && !element.hasAttribute('tabindex')) {
297
- element.setAttribute('tabindex', '-1');
298
- }
299
- element.focus({ preventScroll: true });
300
- }
301
-
302
- if (shouldShow && config.autoScroll !== false && options.autoScroll !== false) {
303
- element.scrollIntoView(reducedMotionScrollOptions('nearest'));
304
- }
305
-
306
- const continueSelector = config.continueButton;
307
- if (continueSelector) {
308
- let continueButton: HTMLElement | null = null;
309
- if (continueSelector === 'self') {
310
- continueButton = element;
311
- } else if (element.matches(continueSelector)) {
312
- continueButton = element;
313
- } else {
314
- continueButton =
315
- element.querySelector<HTMLElement>(continueSelector) ||
316
- document.querySelector<HTMLElement>(continueSelector);
317
- }
318
-
319
- if (continueButton && !this.alertHandlers.has(selector)) {
320
- const handler = () => {
321
- const hideOnContinue = config.hideOnContinue !== false;
322
- if (hideOnContinue) {
323
- const originalDisplay = element.dataset.vidplyAlertOriginalDisplay || '';
324
- element.style.display = config.hideDisplay || originalDisplay || 'none';
325
- element.setAttribute('aria-hidden', 'true');
326
- element.removeAttribute('data-vidply-alert-active');
327
- }
328
-
329
- if (config.resume !== false && player.state.paused) {
330
- player.play();
331
- }
332
-
333
- const focusTarget = config.focusTarget || 'playButton';
334
- player.setManagedTimeout(() => {
335
- this.focusTarget(focusTarget, element);
336
- }, config.focusDelay ?? 100);
337
- };
338
-
339
- continueButton.addEventListener('click', handler);
340
- this.alertHandlers.set(selector, { button: continueButton, handler });
341
- }
342
- }
343
-
344
- return element;
345
- }
346
-
347
- handleHashtags(hashtags: string[] | null | undefined): void {
348
- if (!Array.isArray(hashtags) || hashtags.length === 0) return;
349
-
350
- const player = this.player;
351
- const configMap = player.options.metadataHashtags as Record<string, unknown> | null | undefined;
352
- if (!configMap) return;
353
-
354
- hashtags.forEach((tag) => {
355
- const config = this.resolveConfig(configMap, tag);
356
- if (!config) return;
357
-
358
- const selector = this.normalizeSelector(config.alert || config.selector || config.target);
359
- if (!selector) return;
360
-
361
- const element = this.resolveElement(selector);
362
- if (!element) {
363
- if (player.options.debug) player.log('[Metadata] Hashtag target not found:', selector);
364
- return;
365
- }
366
-
367
- if (player.options.debug) {
368
- player.log('[Metadata] Handling hashtag', tag, { selector, config });
369
- }
370
-
371
- this.cacheContent(element, config);
372
-
373
- if (config.title) {
374
- const titleSelector = config.titleSelector || '[data-vidply-alert-title], h3, header';
375
- const titleEl = element.querySelector<HTMLElement>(titleSelector);
376
- if (titleEl) titleEl.textContent = config.title;
377
- }
378
-
379
- if (config.message) {
380
- const messageSelector = config.messageSelector || '[data-vidply-alert-message], p';
381
- const messageEl = element.querySelector<HTMLElement>(messageSelector);
382
- if (messageEl) messageEl.textContent = config.message;
383
- }
384
-
385
- const show = config.show !== false;
386
- const focus = config.focus !== undefined ? config.focus : false;
387
-
388
- this.handleAlert(selector, {
389
- element,
390
- show,
391
- focus,
392
- autoScroll: config.autoScroll,
393
- reason: 'hashtag'
394
- });
395
- });
396
- }
397
-
398
- /**
399
- * Parse a single metadata cue for directives (`PAUSE`, `FOCUS:x`,
400
- * `#hashtag`), emit the corresponding public events, and execute
401
- * DOM side-effects only when `options.metadataDirectives` is set.
402
- */
403
- handleCue(cue: VTTCue | TextTrackCue): void {
404
- const player = this.player;
405
- const text = (cue as VTTCue).text.trim();
406
-
407
- if (player.options.debug) {
408
- player.log('[Metadata] Processing cue:', { time: cue.startTime, text });
409
- }
410
-
411
- // Public event fires regardless of directive opt-in so consumers
412
- // can always observe metadata without granting DOM side-effects.
413
- player.emit('metadata', {
414
- time: cue.startTime,
415
- endTime: cue.endTime,
416
- text,
417
- cue
418
- });
419
-
420
- if (text.includes('PAUSE')) {
421
- if (!player.state.paused) {
422
- if (player.options.debug) player.log('[Metadata] Pausing video at', cue.startTime);
423
- player.pause();
424
- }
425
- player.emit('metadata:pause', { time: cue.startTime, text });
426
- }
427
-
428
- const focusMatch = text.match(/FOCUS:([\w#-]{1,128})/);
429
- if (focusMatch) {
430
- const targetSelector = focusMatch[1];
431
- const normalizedSelector = this.normalizeSelector(targetSelector);
432
- const targetElement = this.resolveElement(normalizedSelector);
433
- if (targetElement) {
434
- if (player.options.debug) player.log('[Metadata] Focusing element:', normalizedSelector);
435
- if (targetElement.tabIndex === -1 && !targetElement.hasAttribute('tabindex')) {
436
- targetElement.setAttribute('tabindex', '-1');
437
- }
438
- player.setManagedTimeout(() => {
439
- targetElement.focus({ preventScroll: true });
440
- }, 10);
441
- } else if (player.options.debug && player.options.metadataDirectives) {
442
- player.log('[Metadata] Element not found:', normalizedSelector || targetSelector);
443
- }
444
- player.emit('metadata:focus', {
445
- time: cue.startTime,
446
- target: targetSelector,
447
- selector: normalizedSelector,
448
- element: targetElement,
449
- text
450
- });
451
-
452
- if (player.options.metadataDirectives && normalizedSelector) {
453
- this.handleAlert(normalizedSelector, {
454
- element: targetElement,
455
- reason: 'focus'
456
- });
457
- }
458
- }
459
-
460
- const hashtags = text.match(/#[\w-]{1,64}/g);
461
- if (hashtags && hashtags.length > 0) {
462
- // Cap at 32 hashtags per cue to bound subsequent work.
463
- const safeTags = hashtags.slice(0, 32);
464
- if (player.options.debug) player.log('[Metadata] Hashtags found:', safeTags);
465
- player.emit('metadata:hashtags', {
466
- time: cue.startTime,
467
- hashtags: safeTags,
468
- text
469
- });
470
-
471
- if (player.options.metadataDirectives) this.handleHashtags(safeTags);
472
- }
473
- }
474
-
475
- /**
476
- * Resolve a metadata-cue selector inside the configured directive
477
- * scope. Returns `null` when directives are disabled or the
478
- * selector doesn't resolve. Container-scoped resolution is the
479
- * default so a malicious caption cannot focus a login-form input
480
- * or trigger a dialog elsewhere on the page.
481
- */
482
- private resolveElement(selector: string | null): HTMLElement | null {
483
- const player = this.player;
484
- const mode = player.options.metadataDirectives;
485
- if (!mode) return null;
486
- if (!selector) return null;
487
- try {
488
- if (mode === true || mode === 'global') {
489
- return document.querySelector(selector) as HTMLElement | null;
490
- }
491
- const root = player.container || player.element.parentElement || document;
492
- return (root as ParentNode).querySelector(selector) as HTMLElement | null;
493
- } catch {
494
- // Bad selector — never surface to the page.
495
- return null;
496
- }
497
- }
498
-
499
- /** Tear down the per-alert click handlers and the cuechange
500
- * listener. Called from Player.destroy(). */
501
- cleanup(): void {
502
- // The cuechange listener is removed by the Player destroy path
503
- // because the underlying TextTrack still exists and Player
504
- // already knows how to walk textTracks. We only need to release
505
- // the per-alert click handlers we installed.
506
- if (this.alertHandlers.size > 0) {
507
- this.alertHandlers.forEach(({ button, handler }) => {
508
- if (button && handler) button.removeEventListener('click', handler);
509
- });
510
- this.alertHandlers.clear();
511
- }
512
- this.cueChangeHandler = null;
513
- }
514
- }
1
+ /**
2
+ * Metadata-track alert handling extracted from Player.
3
+ *
4
+ * The alert system lets a `kind=metadata` text track drive
5
+ * accessibility affordances on the page — pausing the video,
6
+ * focusing an element, showing or hiding a marked-up alert region,
7
+ * wiring a "Continue" button. All of that lives here rather than on
8
+ * Player so the code can be skipped entirely (via tree-shaking) when
9
+ * the `metadataAlerts`/`metadataDirectives` options are disabled.
10
+ *
11
+ * Public-ish entry points (Player exposes them through thin
12
+ * delegates that keep the same names):
13
+ *
14
+ * - `setupHandling()` — wire the `cuechange` listener
15
+ * - `handleCue(cue)` — parse a single cue and dispatch directives
16
+ * - `handleAlert(selector, options)` — manually drive the alert UI
17
+ * - `handleHashtags(tags)` — resolve a list of cue hashtags
18
+ * - `cleanup()` — called from Player.destroy()
19
+ */
20
+
21
+ import { reducedMotionScrollOptions } from '../utils/PerformanceUtils.js';
22
+ import type { Player } from './Player.js';
23
+
24
+ /** Per-selector metadata alert configuration. */
25
+ export interface MetadataAlertConfig {
26
+ titleSelector?: string;
27
+ messageSelector?: string;
28
+ title?: string;
29
+ message?: string;
30
+ focus?: boolean;
31
+ focusOnShow?: boolean;
32
+ focusTarget?: string;
33
+ focusDelay?: number;
34
+ label?: string;
35
+ role?: string;
36
+ show?: boolean;
37
+ display?: string;
38
+ hideDisplay?: string;
39
+ autoScroll?: boolean;
40
+ selector?: string;
41
+ alert?: string;
42
+ target?: string;
43
+ continueButton?: string;
44
+ hideOnContinue?: boolean;
45
+ resume?: boolean;
46
+ resetContent?: boolean;
47
+ notification?: string;
48
+ persist?: boolean;
49
+
50
+ [key: string]: unknown;
51
+ }
52
+
53
+ /** Options accepted by `handleAlert`. */
54
+ export interface MetadataAlertOptions {
55
+ element?: HTMLElement | null;
56
+ reason?: string;
57
+ cue?: VTTCue | null;
58
+ show?: boolean;
59
+ focus?: boolean;
60
+ autoScroll?: boolean;
61
+ }
62
+
63
+ interface AlertHandlerEntry {
64
+ button: HTMLElement | null;
65
+ handler: EventListener | null;
66
+ }
67
+
68
+ export class MetadataAlertsManager {
69
+ private readonly player: Player;
70
+ private cueChangeHandler: (() => void) | null = null;
71
+ private readonly alertHandlers: Map<string, AlertHandlerEntry> = new Map();
72
+
73
+ constructor(player: Player) {
74
+ this.player = player;
75
+ }
76
+
77
+ /** The `cuechange` handler this manager installed on the metadata
78
+ * track. Exposed so Player can mirror it onto itself for legacy
79
+ * access (some tests poke at `player.metadataCueChangeHandler`). */
80
+ get cuechangeListener(): (() => void) | null {
81
+ return this.cueChangeHandler;
82
+ }
83
+
84
+ setupHandling(): void {
85
+ const player = this.player;
86
+
87
+ const setupMetadata = () => {
88
+ const textTracks = player.textTracks;
89
+ const metadataTrack = textTracks.find((track) => track.kind === 'metadata');
90
+
91
+ if (!metadataTrack) {
92
+ if (player.options.debug) player.log('[Metadata] No metadata track found');
93
+ return;
94
+ }
95
+
96
+ // `disabled` suppresses cuechange entirely; `hidden` still fires
97
+ // events without rendering the track.
98
+ if (metadataTrack.mode === 'disabled') {
99
+ metadataTrack.mode = 'hidden';
100
+ }
101
+
102
+ if (this.cueChangeHandler) {
103
+ metadataTrack.removeEventListener('cuechange', this.cueChangeHandler);
104
+ }
105
+
106
+ this.cueChangeHandler = () => {
107
+ const activeCues = Array.from(metadataTrack.activeCues || []) as VTTCue[];
108
+ if (activeCues.length > 0 && player.options.debug) {
109
+ player.log('[Metadata] Active cues:', activeCues.map((c) => ({
110
+ start: c.startTime,
111
+ end: c.endTime,
112
+ text: c.text
113
+ })));
114
+ }
115
+ activeCues.forEach((cue) => this.handleCue(cue));
116
+ };
117
+
118
+ metadataTrack.addEventListener('cuechange', this.cueChangeHandler);
119
+ // Mirror onto player for existing destroy() cleanup path.
120
+ player.metadataCueChangeHandler = this.cueChangeHandler;
121
+
122
+ if (player.options.debug) {
123
+ const cueCount = metadataTrack.cues ? metadataTrack.cues.length : 0;
124
+ player.log('[Metadata] Track enabled,', cueCount, 'cues available');
125
+ }
126
+ };
127
+
128
+ setupMetadata();
129
+ player.on('loadedmetadata', setupMetadata);
130
+ }
131
+
132
+ /**
133
+ * Sanitise a user-supplied selector string. Returns `null` for
134
+ * anything that isn't obviously safe: non-string input, empty
135
+ * after trimming, or too long to bound selector-engine cost.
136
+ */
137
+ normalizeSelector(selector: unknown): string | null {
138
+ if (typeof selector !== 'string') return null;
139
+ const trimmed = selector.trim();
140
+ if (!trimmed) return null;
141
+ if (trimmed.length > 200) return null;
142
+ if (trimmed.startsWith('#') || trimmed.startsWith('.') || trimmed.startsWith('[')) {
143
+ return trimmed;
144
+ }
145
+ return `#${trimmed}`;
146
+ }
147
+
148
+ resolveConfig(
149
+ map: Record<string, unknown> | null | undefined,
150
+ key: string | null | undefined
151
+ ): MetadataAlertConfig | null {
152
+ if (!map || !key) return null;
153
+ if (Object.prototype.hasOwnProperty.call(map, key)) {
154
+ return map[key] as MetadataAlertConfig;
155
+ }
156
+ const withoutHash = key.replace(/^#/, '');
157
+ if (Object.prototype.hasOwnProperty.call(map, withoutHash)) {
158
+ return map[withoutHash] as MetadataAlertConfig;
159
+ }
160
+ return null;
161
+ }
162
+
163
+ /**
164
+ * Remember the original title/message text before a hashtag cue
165
+ * overwrites them, so `restoreContent` can roll back on the next
166
+ * cue boundary. Idempotent — a second call for the same element
167
+ * does not overwrite the already-cached value.
168
+ */
169
+ cacheContent(element: HTMLElement | null | undefined, config: MetadataAlertConfig = {}): void {
170
+ if (!element) return;
171
+ const titleSelector = config.titleSelector || '[data-vidply-alert-title], h3, header';
172
+ const messageSelector = config.messageSelector || '[data-vidply-alert-message], p';
173
+
174
+ const titleEl = element.querySelector<HTMLElement>(titleSelector);
175
+ if (titleEl && !titleEl.dataset.vidplyAlertTitleOriginal) {
176
+ titleEl.dataset.vidplyAlertTitleOriginal = titleEl.textContent?.trim() ?? '';
177
+ }
178
+
179
+ const messageEl = element.querySelector<HTMLElement>(messageSelector);
180
+ if (messageEl && !messageEl.dataset.vidplyAlertMessageOriginal) {
181
+ messageEl.dataset.vidplyAlertMessageOriginal = messageEl.textContent?.trim() ?? '';
182
+ }
183
+ }
184
+
185
+ restoreContent(element: HTMLElement | null | undefined, config: MetadataAlertConfig = {}): void {
186
+ if (!element) return;
187
+ const titleSelector = config.titleSelector || '[data-vidply-alert-title], h3, header';
188
+ const messageSelector = config.messageSelector || '[data-vidply-alert-message], p';
189
+
190
+ const titleEl = element.querySelector<HTMLElement>(titleSelector);
191
+ if (titleEl && titleEl.dataset.vidplyAlertTitleOriginal) {
192
+ titleEl.textContent = titleEl.dataset.vidplyAlertTitleOriginal;
193
+ }
194
+
195
+ const messageEl = element.querySelector<HTMLElement>(messageSelector);
196
+ if (messageEl && messageEl.dataset.vidplyAlertMessageOriginal) {
197
+ messageEl.textContent = messageEl.dataset.vidplyAlertMessageOriginal;
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Move focus to one of the well-known targets understood by the
203
+ * alert system, or to a named selector. Never silently errors — an
204
+ * unresolved target is simply a no-op.
205
+ */
206
+ focusTarget(target: string | null | undefined, fallbackElement: HTMLElement | null = null): void {
207
+ if (!target || target === 'none') return;
208
+
209
+ if (target === 'alert' && fallbackElement) {
210
+ fallbackElement.focus({ preventScroll: true });
211
+ return;
212
+ }
213
+
214
+ const player = this.player;
215
+
216
+ if (target === 'player') {
217
+ player.container?.focus({ preventScroll: true });
218
+ return;
219
+ }
220
+
221
+ if (target === 'media') {
222
+ player.element.focus({ preventScroll: true });
223
+ return;
224
+ }
225
+
226
+ if (target === 'playButton') {
227
+ const playButton = player.controlBar?.controls?.playPause;
228
+ playButton?.focus({ preventScroll: true });
229
+ return;
230
+ }
231
+
232
+ if (typeof target === 'string') {
233
+ const targetElement = document.querySelector(target) as HTMLElement | null;
234
+ if (targetElement) {
235
+ if (targetElement.tabIndex === -1 && !targetElement.hasAttribute('tabindex')) {
236
+ targetElement.setAttribute('tabindex', '-1');
237
+ }
238
+ targetElement.focus({ preventScroll: true });
239
+ }
240
+ }
241
+ }
242
+
243
+ /**
244
+ * The public alert entry point. Pulls config out of
245
+ * `options.metadataAlerts`, locates the DOM element, and applies
246
+ * show/focus/continue logic per configuration.
247
+ */
248
+ handleAlert(selector: string, options: MetadataAlertOptions = {}): HTMLElement | undefined {
249
+ const player = this.player;
250
+ if (!selector) return undefined;
251
+
252
+ const config: MetadataAlertConfig =
253
+ this.resolveConfig(player.options.metadataAlerts as Record<string, unknown> | null | undefined, selector) || {};
254
+
255
+ // Container-scoped resolution by default; only fall back to a
256
+ // global lookup when `metadataDirectives === 'global' | true`.
257
+ const element = options.element || this.resolveElement(selector);
258
+
259
+ if (!element) {
260
+ if (player.options.debug) player.log('[Metadata] Alert element not found:', selector);
261
+ return undefined;
262
+ }
263
+
264
+ if (player.options.debug) {
265
+ player.log('[Metadata] Handling alert', selector, { reason: options.reason, config });
266
+ }
267
+
268
+ this.cacheContent(element, config);
269
+
270
+ if (!element.dataset.vidplyAlertOriginalDisplay) {
271
+ element.dataset.vidplyAlertOriginalDisplay = element.style.display || '';
272
+ }
273
+
274
+ if (!element.dataset.vidplyAlertDisplay) {
275
+ element.dataset.vidplyAlertDisplay = config.display || 'block';
276
+ }
277
+
278
+ const shouldShow = options.show !== undefined ? options.show : (config.show !== false);
279
+ if (shouldShow) {
280
+ const displayValue = config.display || element.dataset.vidplyAlertDisplay || 'block';
281
+ element.style.display = displayValue;
282
+ element.hidden = false;
283
+ element.removeAttribute('hidden');
284
+ element.setAttribute('aria-hidden', 'false');
285
+ element.setAttribute('data-vidply-alert-active', 'true');
286
+ }
287
+
288
+ const shouldReset = config.resetContent !== false && options.reason === 'focus';
289
+ if (shouldReset) this.restoreContent(element, config);
290
+
291
+ const shouldFocus = options.focus !== undefined
292
+ ? options.focus
293
+ : (config.focusOnShow ?? (options.reason !== 'focus'));
294
+
295
+ if (shouldShow && shouldFocus) {
296
+ if (element.tabIndex === -1 && !element.hasAttribute('tabindex')) {
297
+ element.setAttribute('tabindex', '-1');
298
+ }
299
+ element.focus({ preventScroll: true });
300
+ }
301
+
302
+ if (shouldShow && config.autoScroll !== false && options.autoScroll !== false) {
303
+ element.scrollIntoView(reducedMotionScrollOptions('nearest'));
304
+ }
305
+
306
+ const continueSelector = config.continueButton;
307
+ if (continueSelector) {
308
+ let continueButton: HTMLElement | null = null;
309
+ if (continueSelector === 'self') {
310
+ continueButton = element;
311
+ } else if (element.matches(continueSelector)) {
312
+ continueButton = element;
313
+ } else {
314
+ continueButton =
315
+ element.querySelector<HTMLElement>(continueSelector) ||
316
+ document.querySelector<HTMLElement>(continueSelector);
317
+ }
318
+
319
+ if (continueButton && !this.alertHandlers.has(selector)) {
320
+ const handler = () => {
321
+ const hideOnContinue = config.hideOnContinue !== false;
322
+ if (hideOnContinue) {
323
+ const originalDisplay = element.dataset.vidplyAlertOriginalDisplay || '';
324
+ element.style.display = config.hideDisplay || originalDisplay || 'none';
325
+ element.setAttribute('aria-hidden', 'true');
326
+ element.removeAttribute('data-vidply-alert-active');
327
+ }
328
+
329
+ if (config.resume !== false && player.state.paused) {
330
+ player.play();
331
+ }
332
+
333
+ const focusTarget = config.focusTarget || 'playButton';
334
+ player.setManagedTimeout(() => {
335
+ this.focusTarget(focusTarget, element);
336
+ }, config.focusDelay ?? 100);
337
+ };
338
+
339
+ continueButton.addEventListener('click', handler);
340
+ this.alertHandlers.set(selector, { button: continueButton, handler });
341
+ }
342
+ }
343
+
344
+ return element;
345
+ }
346
+
347
+ handleHashtags(hashtags: string[] | null | undefined): void {
348
+ if (!Array.isArray(hashtags) || hashtags.length === 0) return;
349
+
350
+ const player = this.player;
351
+ const configMap = player.options.metadataHashtags as Record<string, unknown> | null | undefined;
352
+ if (!configMap) return;
353
+
354
+ hashtags.forEach((tag) => {
355
+ const config = this.resolveConfig(configMap, tag);
356
+ if (!config) return;
357
+
358
+ const selector = this.normalizeSelector(config.alert || config.selector || config.target);
359
+ if (!selector) return;
360
+
361
+ const element = this.resolveElement(selector);
362
+ if (!element) {
363
+ if (player.options.debug) player.log('[Metadata] Hashtag target not found:', selector);
364
+ return;
365
+ }
366
+
367
+ if (player.options.debug) {
368
+ player.log('[Metadata] Handling hashtag', tag, { selector, config });
369
+ }
370
+
371
+ this.cacheContent(element, config);
372
+
373
+ if (config.title) {
374
+ const titleSelector = config.titleSelector || '[data-vidply-alert-title], h3, header';
375
+ const titleEl = element.querySelector<HTMLElement>(titleSelector);
376
+ if (titleEl) titleEl.textContent = config.title;
377
+ }
378
+
379
+ if (config.message) {
380
+ const messageSelector = config.messageSelector || '[data-vidply-alert-message], p';
381
+ const messageEl = element.querySelector<HTMLElement>(messageSelector);
382
+ if (messageEl) messageEl.textContent = config.message;
383
+ }
384
+
385
+ const show = config.show !== false;
386
+ const focus = config.focus !== undefined ? config.focus : false;
387
+
388
+ this.handleAlert(selector, {
389
+ element,
390
+ show,
391
+ focus,
392
+ autoScroll: config.autoScroll,
393
+ reason: 'hashtag'
394
+ });
395
+ });
396
+ }
397
+
398
+ /**
399
+ * Parse a single metadata cue for directives (`PAUSE`, `FOCUS:x`,
400
+ * `#hashtag`), emit the corresponding public events, and execute
401
+ * DOM side-effects only when `options.metadataDirectives` is set.
402
+ */
403
+ handleCue(cue: VTTCue | TextTrackCue): void {
404
+ const player = this.player;
405
+ const text = (cue as VTTCue).text.trim();
406
+
407
+ if (player.options.debug) {
408
+ player.log('[Metadata] Processing cue:', { time: cue.startTime, text });
409
+ }
410
+
411
+ // Public event fires regardless of directive opt-in so consumers
412
+ // can always observe metadata without granting DOM side-effects.
413
+ player.emit('metadata', {
414
+ time: cue.startTime,
415
+ endTime: cue.endTime,
416
+ text,
417
+ cue
418
+ });
419
+
420
+ if (text.includes('PAUSE')) {
421
+ if (!player.state.paused) {
422
+ if (player.options.debug) player.log('[Metadata] Pausing video at', cue.startTime);
423
+ player.pause();
424
+ }
425
+ player.emit('metadata:pause', { time: cue.startTime, text });
426
+ }
427
+
428
+ const focusMatch = text.match(/FOCUS:([\w#-]{1,128})/);
429
+ if (focusMatch) {
430
+ const targetSelector = focusMatch[1];
431
+ const normalizedSelector = this.normalizeSelector(targetSelector);
432
+ const targetElement = this.resolveElement(normalizedSelector);
433
+ if (targetElement) {
434
+ if (player.options.debug) player.log('[Metadata] Focusing element:', normalizedSelector);
435
+ if (targetElement.tabIndex === -1 && !targetElement.hasAttribute('tabindex')) {
436
+ targetElement.setAttribute('tabindex', '-1');
437
+ }
438
+ player.setManagedTimeout(() => {
439
+ targetElement.focus({ preventScroll: true });
440
+ }, 10);
441
+ } else if (player.options.debug && player.options.metadataDirectives) {
442
+ player.log('[Metadata] Element not found:', normalizedSelector || targetSelector);
443
+ }
444
+ player.emit('metadata:focus', {
445
+ time: cue.startTime,
446
+ target: targetSelector,
447
+ selector: normalizedSelector,
448
+ element: targetElement,
449
+ text
450
+ });
451
+
452
+ if (player.options.metadataDirectives && normalizedSelector) {
453
+ this.handleAlert(normalizedSelector, {
454
+ element: targetElement,
455
+ reason: 'focus'
456
+ });
457
+ }
458
+ }
459
+
460
+ const hashtags = text.match(/#[\w-]{1,64}/g);
461
+ if (hashtags && hashtags.length > 0) {
462
+ // Cap at 32 hashtags per cue to bound subsequent work.
463
+ const safeTags = hashtags.slice(0, 32);
464
+ if (player.options.debug) player.log('[Metadata] Hashtags found:', safeTags);
465
+ player.emit('metadata:hashtags', {
466
+ time: cue.startTime,
467
+ hashtags: safeTags,
468
+ text
469
+ });
470
+
471
+ if (player.options.metadataDirectives) this.handleHashtags(safeTags);
472
+ }
473
+ }
474
+
475
+ /**
476
+ * Resolve a metadata-cue selector inside the configured directive
477
+ * scope. Returns `null` when directives are disabled or the
478
+ * selector doesn't resolve. Container-scoped resolution is the
479
+ * default so a malicious caption cannot focus a login-form input
480
+ * or trigger a dialog elsewhere on the page.
481
+ */
482
+ private resolveElement(selector: string | null): HTMLElement | null {
483
+ const player = this.player;
484
+ const mode = player.options.metadataDirectives;
485
+ if (!mode) return null;
486
+ if (!selector) return null;
487
+ try {
488
+ if (mode === true || mode === 'global') {
489
+ return document.querySelector(selector) as HTMLElement | null;
490
+ }
491
+ const root = player.container || player.element.parentElement || document;
492
+ return (root as ParentNode).querySelector(selector) as HTMLElement | null;
493
+ } catch {
494
+ // Bad selector — never surface to the page.
495
+ return null;
496
+ }
497
+ }
498
+
499
+ /** Tear down the per-alert click handlers and the cuechange
500
+ * listener. Called from Player.destroy(). */
501
+ cleanup(): void {
502
+ // The cuechange listener is removed by the Player destroy path
503
+ // because the underlying TextTrack still exists and Player
504
+ // already knows how to walk textTracks. We only need to release
505
+ // the per-alert click handlers we installed.
506
+ if (this.alertHandlers.size > 0) {
507
+ this.alertHandlers.forEach(({ button, handler }) => {
508
+ if (button && handler) button.removeEventListener('click', handler);
509
+ });
510
+ this.alertHandlers.clear();
511
+ }
512
+ this.cueChangeHandler = null;
513
+ }
514
+ }