vidply 1.2.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. package/README.md +946 -946
  2. package/dist/dev/{vidply.AudioDescriptionManager-R47Q4YR4.js → vidply.AudioDescriptionManager-6RFVF27Z.js} +275 -19
  3. package/dist/dev/vidply.AudioDescriptionManager-6RFVF27Z.js.map +7 -0
  4. package/dist/dev/{vidply.CaptionStyleMenu-7XHRBWUM.js → vidply.CaptionStyleMenu-R2SCQ6VP.js} +4 -4
  5. package/dist/dev/{vidply.DASHRenderer-PWEMC3BT.js → vidply.DASHRenderer-EOVEW73H.js} +3 -3
  6. package/dist/dev/vidply.DASHRenderer-EOVEW73H.js.map +7 -0
  7. package/dist/dev/{vidply.FloatingPlayerManager-PKNNNAS2.js → vidply.FloatingPlayerManager-V5XQWS75.js} +5 -5
  8. package/dist/dev/vidply.FloatingPlayerManager-V5XQWS75.js.map +7 -0
  9. package/dist/dev/{vidply.HLSRenderer-E7RQ74HI.js → vidply.HLSRenderer-ZBEFVH5W.js} +4 -4
  10. package/dist/dev/vidply.HLSRenderer-ZBEFVH5W.js.map +7 -0
  11. package/dist/dev/vidply.HTML5Renderer-P7ICKYU4.js +12 -0
  12. package/dist/dev/{vidply.SignLanguageManager-FET5FQZ7.js → vidply.SignLanguageManager-7IAJOVF6.js} +8 -8
  13. package/dist/dev/vidply.SignLanguageManager-7IAJOVF6.js.map +7 -0
  14. package/dist/dev/{vidply.SoundCloudRenderer-RVSJZODO.js → vidply.SoundCloudRenderer-OA7QQYGN.js} +3 -3
  15. package/dist/dev/vidply.SoundCloudRenderer-OA7QQYGN.js.map +7 -0
  16. package/dist/dev/{vidply.TranscriptManager-5GXOQCLX.js → vidply.TranscriptManager-BPM3T23L.js} +10 -10
  17. package/dist/dev/vidply.TranscriptManager-BPM3T23L.js.map +7 -0
  18. package/dist/dev/{vidply.VimeoRenderer-ER2LSBBW.js → vidply.VimeoRenderer-BRHFYPNJ.js} +3 -3
  19. package/dist/dev/vidply.VimeoRenderer-BRHFYPNJ.js.map +7 -0
  20. package/dist/dev/{vidply.YouTubeRenderer-NFTOWJAB.js → vidply.YouTubeRenderer-HRTN4EJ5.js} +3 -3
  21. package/dist/dev/vidply.YouTubeRenderer-HRTN4EJ5.js.map +7 -0
  22. package/dist/dev/{vidply.chunk-6RQA4ROD.js → vidply.chunk-5IK3IC5V.js} +2 -2
  23. package/dist/dev/{vidply.chunk-JJ47NO24.js → vidply.chunk-C55OBT5H.js} +2 -2
  24. package/dist/dev/vidply.chunk-C55OBT5H.js.map +7 -0
  25. package/dist/dev/{vidply.chunk-DZSDO3B7.js → vidply.chunk-HOYEMJSD.js} +2 -2
  26. package/dist/dev/{vidply.chunk-VMSQAZM7.js → vidply.chunk-JAKZNGOR.js} +2 -2
  27. package/dist/dev/{vidply.chunk-64WXMTGL.js → vidply.chunk-LDVFAFGC.js} +5 -5
  28. package/dist/dev/vidply.chunk-LDVFAFGC.js.map +7 -0
  29. package/dist/dev/{vidply.chunk-RZWZGDV7.js → vidply.chunk-NSMAZCMB.js} +6 -6
  30. package/dist/dev/vidply.chunk-NSMAZCMB.js.map +7 -0
  31. package/dist/dev/{vidply.chunk-KI7C3QVL.js → vidply.chunk-RQG3MA7W.js} +2 -2
  32. package/dist/dev/vidply.chunk-RQG3MA7W.js.map +7 -0
  33. package/dist/dev/{vidply.chunk-BNFIUL2Z.js → vidply.chunk-UHMVDBDB.js} +3 -3
  34. package/dist/dev/vidply.chunk-UHMVDBDB.js.map +7 -0
  35. package/dist/dev/{vidply.chunk-NQPURMQY.js → vidply.chunk-W5BR65JY.js} +6 -6
  36. package/dist/dev/vidply.chunk-W5BR65JY.js.map +7 -0
  37. package/dist/dev/{vidply.chunk-W25HUCHS.js → vidply.chunk-XAK3DPTO.js} +2 -2
  38. package/dist/dev/vidply.chunk-XAK3DPTO.js.map +7 -0
  39. package/dist/dev/{vidply.chunk-SK4V2DAU.js → vidply.chunk-YVCYQZJI.js} +3 -3
  40. package/dist/dev/{vidply.de-4YAT7FFM.js → vidply.de-5W5LBFSJ.js} +2 -2
  41. package/dist/dev/vidply.de-5W5LBFSJ.js.map +7 -0
  42. package/dist/dev/{vidply.es-LEWIRUBH.js → vidply.es-SHDAT6L6.js} +2 -2
  43. package/dist/dev/vidply.es-SHDAT6L6.js.map +7 -0
  44. package/dist/dev/vidply.esm.js +73 -34
  45. package/dist/dev/vidply.esm.js.map +2 -2
  46. package/dist/dev/{vidply.fr-Q4GV2NOE.js → vidply.fr-2JANOMNB.js} +2 -2
  47. package/dist/dev/vidply.fr-2JANOMNB.js.map +7 -0
  48. package/dist/dev/{vidply.ja-S6ITA6WU.js → vidply.ja-WWMSTEJH.js} +2 -2
  49. package/dist/dev/vidply.ja-WWMSTEJH.js.map +7 -0
  50. package/dist/legacy/vidply.js +345 -32
  51. package/dist/legacy/vidply.js.map +3 -3
  52. package/dist/legacy/vidply.min.js +2 -2
  53. package/dist/legacy/vidply.min.meta.json +66 -47
  54. package/dist/prod/vidply.AudioDescriptionManager-YTDVDQBO.min.js +6 -0
  55. package/dist/prod/{vidply.CaptionStyleMenu-M3KVQWB5.min.js → vidply.CaptionStyleMenu-2AFF3HF2.min.js} +2 -2
  56. package/dist/prod/{vidply.DASHRenderer-DN3S27AV.min.js → vidply.DASHRenderer-URDOTOKS.min.js} +2 -2
  57. package/dist/prod/{vidply.FloatingPlayerManager-SKAZ4J43.min.js → vidply.FloatingPlayerManager-QUBFK7CQ.min.js} +2 -2
  58. package/dist/prod/{vidply.HLSRenderer-62HH4YTK.min.js → vidply.HLSRenderer-46KNUVAB.min.js} +2 -2
  59. package/dist/prod/vidply.HTML5Renderer-7TQDMIGY.min.js +6 -0
  60. package/dist/prod/{vidply.SignLanguageManager-V3OBTSSF.min.js → vidply.SignLanguageManager-H4C36EVL.min.js} +2 -2
  61. package/dist/prod/{vidply.SoundCloudRenderer-CXRVZIAJ.min.js → vidply.SoundCloudRenderer-OYSWMW7D.min.js} +2 -2
  62. package/dist/prod/{vidply.TranscriptManager-SYM3DFUY.min.js → vidply.TranscriptManager-BA3DAS63.min.js} +2 -2
  63. package/dist/prod/{vidply.VimeoRenderer-72TWRYFQ.min.js → vidply.VimeoRenderer-NF3COTHU.min.js} +2 -2
  64. package/dist/prod/{vidply.YouTubeRenderer-UMHEV3KD.min.js → vidply.YouTubeRenderer-UCQKYIJ4.min.js} +2 -2
  65. package/dist/prod/{vidply.chunk-Z3YDSA7O.min.js → vidply.chunk-2HNCP64N.min.js} +1 -1
  66. package/dist/prod/{vidply.chunk-EKEYJWMG.min.js → vidply.chunk-6ECBIYVI.min.js} +2 -2
  67. package/dist/prod/{vidply.chunk-YEENJS3Q.min.js → vidply.chunk-7S5AFTST.min.js} +2 -2
  68. package/dist/prod/{vidply.chunk-P4UXOHWA.min.js → vidply.chunk-EUOA2DJK.min.js} +1 -1
  69. package/dist/prod/{vidply.chunk-2TB6MUHO.min.js → vidply.chunk-G6KGXNEW.min.js} +1 -1
  70. package/dist/prod/{vidply.chunk-ABPXVDLZ.min.js → vidply.chunk-JPMAK3NS.min.js} +1 -1
  71. package/dist/prod/{vidply.chunk-P3PQBY7N.min.js → vidply.chunk-L4O44K63.min.js} +1 -1
  72. package/dist/prod/{vidply.chunk-WX3MVEAL.min.js → vidply.chunk-M76ABMKB.min.js} +1 -1
  73. package/dist/prod/{vidply.chunk-TM6AXJVD.min.js → vidply.chunk-WRVDVD33.min.js} +2 -2
  74. package/dist/prod/{vidply.chunk-RI3X7WHK.min.js → vidply.chunk-X2FQEQYR.min.js} +2 -2
  75. package/dist/prod/{vidply.chunk-UK7YGOB5.min.js → vidply.chunk-XXUH7ADU.min.js} +2 -2
  76. package/dist/prod/{vidply.de-BCHXRQ53.min.js → vidply.de-JMCATTKZ.min.js} +1 -1
  77. package/dist/prod/{vidply.es-PICAKMZD.min.js → vidply.es-YVG37V7Z.min.js} +1 -1
  78. package/dist/prod/vidply.esm.min.js +2 -2
  79. package/dist/prod/{vidply.fr-ZILI7CDR.min.js → vidply.fr-OGKGN2R4.min.js} +1 -1
  80. package/dist/prod/{vidply.ja-5WQ4OK4J.min.js → vidply.ja-Z6DS7QLS.min.js} +1 -1
  81. package/dist/vidply.css +5796 -5796
  82. package/dist/vidply.esm.min.meta.json +148 -135
  83. package/package.json +7 -7
  84. package/src/controls/CaptionManager.ts +598 -598
  85. package/src/controls/ControlBar.ts +4159 -4159
  86. package/src/controls/KeyboardManager.ts +383 -383
  87. package/src/controls/SettingsDialog.ts +488 -488
  88. package/src/controls/TranscriptManager.ts +2426 -2426
  89. package/src/core/AudioDescriptionManager.ts +863 -774
  90. package/src/core/DescriptionSpeechManager.ts +232 -0
  91. package/src/core/FloatingPlayerManager.ts +674 -674
  92. package/src/core/LazyInit.ts +80 -80
  93. package/src/core/MetadataAlertsManager.ts +514 -514
  94. package/src/core/Player.ts +2915 -2872
  95. package/src/core/PosterManager.ts +169 -169
  96. package/src/core/PseudoFullscreen.ts +179 -179
  97. package/src/core/ResponsiveManager.ts +231 -231
  98. package/src/core/ResumeManager.ts +236 -236
  99. package/src/core/SignLanguageManager.ts +1308 -1308
  100. package/src/core/ThemeManager.ts +157 -157
  101. package/src/features/PlaylistManager.ts +1838 -1827
  102. package/src/i18n/i18n.ts +223 -223
  103. package/src/i18n/languages/de.ts +217 -217
  104. package/src/i18n/languages/en.ts +218 -218
  105. package/src/i18n/languages/es.ts +214 -214
  106. package/src/i18n/languages/fr.ts +214 -214
  107. package/src/i18n/languages/ja.ts +214 -214
  108. package/src/icons/Icons.ts +167 -167
  109. package/src/index.ts +196 -193
  110. package/src/renderers/DASHRenderer.ts +1077 -1077
  111. package/src/renderers/HLSRenderer.ts +944 -944
  112. package/src/renderers/HTML5Renderer.ts +389 -389
  113. package/src/renderers/SoundCloudRenderer.ts +437 -437
  114. package/src/renderers/VimeoRenderer.ts +298 -298
  115. package/src/renderers/YouTubeRenderer.ts +290 -290
  116. package/src/styles/vidply.css +5796 -5796
  117. package/src/types/events.ts +124 -122
  118. package/src/types/globals.d.ts +313 -313
  119. package/src/types/options.ts +185 -182
  120. package/src/types/renderer.ts +58 -58
  121. package/src/utils/DOMUtils.ts +281 -281
  122. package/src/utils/DownloadInfo.ts +171 -171
  123. package/src/utils/DraggablePanel.ts +539 -539
  124. package/src/utils/DraggablePanelMenu.ts +195 -195
  125. package/src/utils/DraggableResizable.ts +997 -997
  126. package/src/utils/EventEmitter.ts +59 -59
  127. package/src/utils/FormUtils.ts +95 -95
  128. package/src/utils/MenuFactory.ts +483 -483
  129. package/src/utils/Sanitize.ts +70 -70
  130. package/src/utils/SettingsMenuFactory.ts +289 -289
  131. package/src/utils/StorageManager.ts +287 -287
  132. package/src/utils/TrackLabelUtils.ts +52 -52
  133. package/src/utils/UrlSafe.ts +69 -69
  134. package/src/utils/VideoFrameCapture.ts +114 -114
  135. package/src/utils/WindowComponents.ts +250 -250
  136. package/dist/dev/vidply.AudioDescriptionManager-R47Q4YR4.js.map +0 -7
  137. package/dist/dev/vidply.DASHRenderer-PWEMC3BT.js.map +0 -7
  138. package/dist/dev/vidply.FloatingPlayerManager-PKNNNAS2.js.map +0 -7
  139. package/dist/dev/vidply.HLSRenderer-E7RQ74HI.js.map +0 -7
  140. package/dist/dev/vidply.HTML5Renderer-BU4QJJJY.js +0 -12
  141. package/dist/dev/vidply.SignLanguageManager-FET5FQZ7.js.map +0 -7
  142. package/dist/dev/vidply.SoundCloudRenderer-RVSJZODO.js.map +0 -7
  143. package/dist/dev/vidply.TranscriptManager-5GXOQCLX.js.map +0 -7
  144. package/dist/dev/vidply.VimeoRenderer-ER2LSBBW.js.map +0 -7
  145. package/dist/dev/vidply.YouTubeRenderer-NFTOWJAB.js.map +0 -7
  146. package/dist/dev/vidply.chunk-64WXMTGL.js.map +0 -7
  147. package/dist/dev/vidply.chunk-BNFIUL2Z.js.map +0 -7
  148. package/dist/dev/vidply.chunk-JJ47NO24.js.map +0 -7
  149. package/dist/dev/vidply.chunk-KI7C3QVL.js.map +0 -7
  150. package/dist/dev/vidply.chunk-NQPURMQY.js.map +0 -7
  151. package/dist/dev/vidply.chunk-RZWZGDV7.js.map +0 -7
  152. package/dist/dev/vidply.chunk-W25HUCHS.js.map +0 -7
  153. package/dist/dev/vidply.de-4YAT7FFM.js.map +0 -7
  154. package/dist/dev/vidply.es-LEWIRUBH.js.map +0 -7
  155. package/dist/dev/vidply.fr-Q4GV2NOE.js.map +0 -7
  156. package/dist/dev/vidply.ja-S6ITA6WU.js.map +0 -7
  157. package/dist/prod/vidply.AudioDescriptionManager-RHLIJW5C.min.js +0 -6
  158. package/dist/prod/vidply.HTML5Renderer-IU5T3WEW.min.js +0 -6
  159. /package/dist/dev/{vidply.CaptionStyleMenu-7XHRBWUM.js.map → vidply.CaptionStyleMenu-R2SCQ6VP.js.map} +0 -0
  160. /package/dist/dev/{vidply.HTML5Renderer-BU4QJJJY.js.map → vidply.HTML5Renderer-P7ICKYU4.js.map} +0 -0
  161. /package/dist/dev/{vidply.chunk-6RQA4ROD.js.map → vidply.chunk-5IK3IC5V.js.map} +0 -0
  162. /package/dist/dev/{vidply.chunk-DZSDO3B7.js.map → vidply.chunk-HOYEMJSD.js.map} +0 -0
  163. /package/dist/dev/{vidply.chunk-VMSQAZM7.js.map → vidply.chunk-JAKZNGOR.js.map} +0 -0
  164. /package/dist/dev/{vidply.chunk-SK4V2DAU.js.map → vidply.chunk-YVCYQZJI.js.map} +0 -0
@@ -1,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
+ }