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.
- package/README.md +974 -946
- package/dist/dev/{vidply.AudioDescriptionManager-R47Q4YR4.js → vidply.AudioDescriptionManager-JYYLK5KB.js} +275 -19
- package/dist/dev/vidply.AudioDescriptionManager-JYYLK5KB.js.map +7 -0
- package/dist/dev/{vidply.CaptionStyleMenu-7XHRBWUM.js → vidply.CaptionStyleMenu-47RCKQF5.js} +4 -4
- package/dist/dev/{vidply.DASHRenderer-PWEMC3BT.js → vidply.DASHRenderer-VEJ4HDHO.js} +3 -3
- package/dist/dev/vidply.DASHRenderer-VEJ4HDHO.js.map +7 -0
- package/dist/dev/{vidply.FloatingPlayerManager-PKNNNAS2.js → vidply.FloatingPlayerManager-C2UVRPZY.js} +5 -5
- package/dist/dev/vidply.FloatingPlayerManager-C2UVRPZY.js.map +7 -0
- package/dist/dev/{vidply.HLSRenderer-E7RQ74HI.js → vidply.HLSRenderer-ZVBK2TNQ.js} +4 -4
- package/dist/dev/vidply.HLSRenderer-ZVBK2TNQ.js.map +7 -0
- package/dist/dev/vidply.HTML5Renderer-HLRQUOCU.js +12 -0
- package/dist/dev/{vidply.SignLanguageManager-FET5FQZ7.js → vidply.SignLanguageManager-FLUCBW5H.js} +8 -8
- package/dist/dev/vidply.SignLanguageManager-FLUCBW5H.js.map +7 -0
- package/dist/dev/{vidply.SoundCloudRenderer-RVSJZODO.js → vidply.SoundCloudRenderer-MGMAJ7NK.js} +3 -3
- package/dist/dev/vidply.SoundCloudRenderer-MGMAJ7NK.js.map +7 -0
- package/dist/dev/{vidply.TranscriptManager-5GXOQCLX.js → vidply.TranscriptManager-Y2RYXHJW.js} +10 -10
- package/dist/dev/vidply.TranscriptManager-Y2RYXHJW.js.map +7 -0
- package/dist/dev/{vidply.VimeoRenderer-ER2LSBBW.js → vidply.VimeoRenderer-RQFQD7OE.js} +3 -3
- package/dist/dev/vidply.VimeoRenderer-RQFQD7OE.js.map +7 -0
- package/dist/dev/{vidply.YouTubeRenderer-NFTOWJAB.js → vidply.YouTubeRenderer-EU2BARZF.js} +3 -3
- package/dist/dev/vidply.YouTubeRenderer-EU2BARZF.js.map +7 -0
- package/dist/dev/{vidply.chunk-BNFIUL2Z.js → vidply.chunk-6LYZYI5G.js} +3 -3
- package/dist/dev/vidply.chunk-6LYZYI5G.js.map +7 -0
- package/dist/dev/{vidply.chunk-NQPURMQY.js → vidply.chunk-A4ESEOQ2.js} +6 -6
- package/dist/dev/vidply.chunk-A4ESEOQ2.js.map +7 -0
- package/dist/dev/{vidply.chunk-W25HUCHS.js → vidply.chunk-CGYIED2I.js} +2 -2
- package/dist/dev/vidply.chunk-CGYIED2I.js.map +7 -0
- package/dist/dev/{vidply.chunk-SK4V2DAU.js → vidply.chunk-HZ7UOKOU.js} +3 -3
- package/dist/dev/{vidply.chunk-RZWZGDV7.js → vidply.chunk-I6EDRYGJ.js} +33 -6
- package/dist/dev/vidply.chunk-I6EDRYGJ.js.map +7 -0
- package/dist/dev/{vidply.chunk-64WXMTGL.js → vidply.chunk-JKMQGLCJ.js} +5 -5
- package/dist/dev/vidply.chunk-JKMQGLCJ.js.map +7 -0
- package/dist/dev/{vidply.chunk-DZSDO3B7.js → vidply.chunk-JZNZX32Z.js} +2 -2
- package/dist/dev/{vidply.chunk-VMSQAZM7.js → vidply.chunk-P25MRJ6O.js} +2 -2
- package/dist/dev/{vidply.chunk-6RQA4ROD.js → vidply.chunk-PRFNESYA.js} +2 -2
- package/dist/dev/{vidply.chunk-KI7C3QVL.js → vidply.chunk-UI3RYCVF.js} +4 -3
- package/dist/dev/vidply.chunk-UI3RYCVF.js.map +7 -0
- package/dist/dev/{vidply.chunk-JJ47NO24.js → vidply.chunk-UNFGMABP.js} +2 -2
- package/dist/dev/vidply.chunk-UNFGMABP.js.map +7 -0
- package/dist/dev/{vidply.de-4YAT7FFM.js → vidply.de-VAUVSQXC.js} +29 -2
- package/dist/dev/vidply.de-VAUVSQXC.js.map +7 -0
- package/dist/dev/{vidply.es-LEWIRUBH.js → vidply.es-G65HFWO6.js} +29 -2
- package/dist/dev/vidply.es-G65HFWO6.js.map +7 -0
- package/dist/dev/vidply.esm.js +648 -35
- package/dist/dev/vidply.esm.js.map +3 -3
- package/dist/dev/{vidply.fr-Q4GV2NOE.js → vidply.fr-MJNOZELM.js} +29 -2
- package/dist/dev/vidply.fr-MJNOZELM.js.map +7 -0
- package/dist/dev/{vidply.ja-S6ITA6WU.js → vidply.ja-INMDHIYJ.js} +29 -2
- package/dist/dev/vidply.ja-INMDHIYJ.js.map +7 -0
- package/dist/legacy/vidply.js +1062 -34
- package/dist/legacy/vidply.js.map +3 -3
- package/dist/legacy/vidply.min.js +2 -2
- package/dist/legacy/vidply.min.meta.json +127 -55
- package/dist/prod/vidply.AudioDescriptionManager-EKWUNOPE.min.js +6 -0
- package/dist/prod/{vidply.CaptionStyleMenu-M3KVQWB5.min.js → vidply.CaptionStyleMenu-JTWSNHRQ.min.js} +2 -2
- package/dist/prod/{vidply.DASHRenderer-DN3S27AV.min.js → vidply.DASHRenderer-CIDAHFWM.min.js} +2 -2
- package/dist/prod/{vidply.FloatingPlayerManager-SKAZ4J43.min.js → vidply.FloatingPlayerManager-VZXHPCPM.min.js} +2 -2
- package/dist/prod/{vidply.HLSRenderer-62HH4YTK.min.js → vidply.HLSRenderer-Q6EFYKID.min.js} +2 -2
- package/dist/prod/vidply.HTML5Renderer-XRDJUTOO.min.js +6 -0
- package/dist/prod/{vidply.SignLanguageManager-V3OBTSSF.min.js → vidply.SignLanguageManager-JUOSA5AM.min.js} +2 -2
- package/dist/prod/{vidply.SoundCloudRenderer-CXRVZIAJ.min.js → vidply.SoundCloudRenderer-7ODD46PO.min.js} +2 -2
- package/dist/prod/{vidply.TranscriptManager-SYM3DFUY.min.js → vidply.TranscriptManager-4B2QKYGQ.min.js} +2 -2
- package/dist/prod/{vidply.VimeoRenderer-72TWRYFQ.min.js → vidply.VimeoRenderer-MR3UYLY7.min.js} +2 -2
- package/dist/prod/{vidply.YouTubeRenderer-UMHEV3KD.min.js → vidply.YouTubeRenderer-ITRTC7NR.min.js} +2 -2
- package/dist/prod/{vidply.chunk-YEENJS3Q.min.js → vidply.chunk-3EEDXU4T.min.js} +2 -2
- package/dist/prod/{vidply.chunk-P4UXOHWA.min.js → vidply.chunk-5HTX5QXW.min.js} +1 -1
- package/dist/prod/{vidply.chunk-ABPXVDLZ.min.js → vidply.chunk-6OGHHIYP.min.js} +2 -2
- package/dist/prod/{vidply.chunk-UK7YGOB5.min.js → vidply.chunk-GUPGMFUZ.min.js} +2 -2
- package/dist/prod/{vidply.chunk-WX3MVEAL.min.js → vidply.chunk-MWEBITCP.min.js} +1 -1
- package/dist/prod/{vidply.chunk-2TB6MUHO.min.js → vidply.chunk-PL3P3JHR.min.js} +1 -1
- package/dist/prod/{vidply.chunk-EKEYJWMG.min.js → vidply.chunk-QQZGV7TD.min.js} +2 -2
- package/dist/prod/{vidply.chunk-Z3YDSA7O.min.js → vidply.chunk-U5IRRYG5.min.js} +1 -1
- package/dist/prod/{vidply.chunk-TM6AXJVD.min.js → vidply.chunk-VKHV4QHA.min.js} +2 -2
- package/dist/prod/{vidply.chunk-P3PQBY7N.min.js → vidply.chunk-XA4M574A.min.js} +1 -1
- package/dist/prod/{vidply.chunk-RI3X7WHK.min.js → vidply.chunk-ZPTV4JEP.min.js} +2 -2
- package/dist/prod/{vidply.de-BCHXRQ53.min.js → vidply.de-UZHFCEZU.min.js} +2 -2
- package/dist/prod/{vidply.es-PICAKMZD.min.js → vidply.es-BOISUV2S.min.js} +2 -2
- package/dist/prod/vidply.esm.min.js +2 -2
- package/dist/prod/{vidply.fr-ZILI7CDR.min.js → vidply.fr-ICSMKPFM.min.js} +2 -2
- package/dist/prod/{vidply.ja-5WQ4OK4J.min.js → vidply.ja-VQ2HKGAY.min.js} +2 -2
- package/dist/vidply.css +5852 -5796
- package/dist/vidply.esm.min.meta.json +204 -149
- package/dist/vidply.min.css +1 -1
- package/package.json +11 -11
- package/src/controls/CaptionManager.ts +598 -598
- package/src/controls/ControlBar.ts +4191 -4159
- package/src/controls/KeyboardHelp.ts +326 -0
- package/src/controls/KeyboardManager.ts +387 -383
- package/src/controls/SettingsDialog.ts +488 -488
- package/src/controls/TranscriptManager.ts +2426 -2426
- package/src/core/AudioDescriptionManager.ts +863 -774
- package/src/core/DescriptionSpeechManager.ts +232 -0
- package/src/core/FloatingPlayerManager.ts +674 -674
- package/src/core/LazyInit.ts +80 -80
- package/src/core/MediaSessionManager.ts +341 -0
- package/src/core/MetadataAlertsManager.ts +514 -514
- package/src/core/Player.ts +2973 -2872
- package/src/core/PosterManager.ts +169 -169
- package/src/core/PseudoFullscreen.ts +179 -179
- package/src/core/ResponsiveManager.ts +231 -231
- package/src/core/ResumeManager.ts +236 -236
- package/src/core/SignLanguageManager.ts +1308 -1308
- package/src/core/ThemeManager.ts +157 -157
- package/src/features/PlaylistManager.ts +1838 -1827
- package/src/i18n/i18n.ts +223 -223
- package/src/i18n/languages/de.ts +244 -217
- package/src/i18n/languages/en.ts +245 -218
- package/src/i18n/languages/es.ts +241 -214
- package/src/i18n/languages/fr.ts +241 -214
- package/src/i18n/languages/ja.ts +241 -214
- package/src/icons/Icons.ts +168 -167
- package/src/index.ts +196 -193
- package/src/renderers/DASHRenderer.ts +1077 -1077
- package/src/renderers/HLSRenderer.ts +944 -944
- package/src/renderers/HTML5Renderer.ts +389 -389
- package/src/renderers/SoundCloudRenderer.ts +437 -437
- package/src/renderers/VimeoRenderer.ts +298 -298
- package/src/renderers/YouTubeRenderer.ts +290 -290
- package/src/styles/vidply.css +5852 -5796
- package/src/types/events.ts +128 -122
- package/src/types/globals.d.ts +313 -313
- package/src/types/options.ts +199 -182
- package/src/types/renderer.ts +58 -58
- package/src/utils/DOMUtils.ts +281 -281
- package/src/utils/DownloadInfo.ts +171 -171
- package/src/utils/DraggablePanel.ts +539 -539
- package/src/utils/DraggablePanelMenu.ts +195 -195
- package/src/utils/DraggableResizable.ts +997 -997
- package/src/utils/EventEmitter.ts +59 -59
- package/src/utils/FormUtils.ts +95 -95
- package/src/utils/MenuFactory.ts +483 -483
- package/src/utils/Sanitize.ts +70 -70
- package/src/utils/SettingsMenuFactory.ts +289 -289
- package/src/utils/StorageManager.ts +287 -287
- package/src/utils/TrackLabelUtils.ts +52 -52
- package/src/utils/UrlSafe.ts +69 -69
- package/src/utils/VideoFrameCapture.ts +114 -114
- package/src/utils/WindowComponents.ts +250 -250
- package/dist/dev/vidply.AudioDescriptionManager-R47Q4YR4.js.map +0 -7
- package/dist/dev/vidply.DASHRenderer-PWEMC3BT.js.map +0 -7
- package/dist/dev/vidply.FloatingPlayerManager-PKNNNAS2.js.map +0 -7
- package/dist/dev/vidply.HLSRenderer-E7RQ74HI.js.map +0 -7
- package/dist/dev/vidply.HTML5Renderer-BU4QJJJY.js +0 -12
- package/dist/dev/vidply.SignLanguageManager-FET5FQZ7.js.map +0 -7
- package/dist/dev/vidply.SoundCloudRenderer-RVSJZODO.js.map +0 -7
- package/dist/dev/vidply.TranscriptManager-5GXOQCLX.js.map +0 -7
- package/dist/dev/vidply.VimeoRenderer-ER2LSBBW.js.map +0 -7
- package/dist/dev/vidply.YouTubeRenderer-NFTOWJAB.js.map +0 -7
- package/dist/dev/vidply.chunk-64WXMTGL.js.map +0 -7
- package/dist/dev/vidply.chunk-BNFIUL2Z.js.map +0 -7
- package/dist/dev/vidply.chunk-JJ47NO24.js.map +0 -7
- package/dist/dev/vidply.chunk-KI7C3QVL.js.map +0 -7
- package/dist/dev/vidply.chunk-NQPURMQY.js.map +0 -7
- package/dist/dev/vidply.chunk-RZWZGDV7.js.map +0 -7
- package/dist/dev/vidply.chunk-W25HUCHS.js.map +0 -7
- package/dist/dev/vidply.de-4YAT7FFM.js.map +0 -7
- package/dist/dev/vidply.es-LEWIRUBH.js.map +0 -7
- package/dist/dev/vidply.fr-Q4GV2NOE.js.map +0 -7
- package/dist/dev/vidply.ja-S6ITA6WU.js.map +0 -7
- package/dist/prod/vidply.AudioDescriptionManager-RHLIJW5C.min.js +0 -6
- package/dist/prod/vidply.HTML5Renderer-IU5T3WEW.min.js +0 -6
- /package/dist/dev/{vidply.CaptionStyleMenu-7XHRBWUM.js.map → vidply.CaptionStyleMenu-47RCKQF5.js.map} +0 -0
- /package/dist/dev/{vidply.HTML5Renderer-BU4QJJJY.js.map → vidply.HTML5Renderer-HLRQUOCU.js.map} +0 -0
- /package/dist/dev/{vidply.chunk-SK4V2DAU.js.map → vidply.chunk-HZ7UOKOU.js.map} +0 -0
- /package/dist/dev/{vidply.chunk-DZSDO3B7.js.map → vidply.chunk-JZNZX32Z.js.map} +0 -0
- /package/dist/dev/{vidply.chunk-VMSQAZM7.js.map → vidply.chunk-P25MRJ6O.js.map} +0 -0
- /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
|
+
}
|