vidply 1.2.1 → 1.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/README.md +30 -2
  2. package/dist/dev/{vidply.AudioDescriptionManager-6RFVF27Z.js → vidply.AudioDescriptionManager-JYYLK5KB.js} +6 -6
  3. package/dist/dev/{vidply.CaptionStyleMenu-R2SCQ6VP.js → vidply.CaptionStyleMenu-47RCKQF5.js} +4 -4
  4. package/dist/dev/{vidply.DASHRenderer-EOVEW73H.js → vidply.DASHRenderer-VEJ4HDHO.js} +3 -3
  5. package/dist/dev/{vidply.FloatingPlayerManager-V5XQWS75.js → vidply.FloatingPlayerManager-C2UVRPZY.js} +5 -5
  6. package/dist/dev/{vidply.HLSRenderer-ZBEFVH5W.js → vidply.HLSRenderer-ZVBK2TNQ.js} +4 -4
  7. package/dist/dev/vidply.HTML5Renderer-HLRQUOCU.js +12 -0
  8. package/dist/dev/{vidply.SignLanguageManager-7IAJOVF6.js → vidply.SignLanguageManager-FLUCBW5H.js} +8 -8
  9. package/dist/dev/{vidply.SoundCloudRenderer-OA7QQYGN.js → vidply.SoundCloudRenderer-MGMAJ7NK.js} +3 -3
  10. package/dist/dev/{vidply.TranscriptManager-BPM3T23L.js → vidply.TranscriptManager-Y2RYXHJW.js} +10 -10
  11. package/dist/dev/{vidply.VimeoRenderer-BRHFYPNJ.js → vidply.VimeoRenderer-RQFQD7OE.js} +3 -3
  12. package/dist/dev/{vidply.YouTubeRenderer-HRTN4EJ5.js → vidply.YouTubeRenderer-EU2BARZF.js} +3 -3
  13. package/dist/dev/{vidply.chunk-UHMVDBDB.js → vidply.chunk-6LYZYI5G.js} +3 -3
  14. package/dist/dev/{vidply.chunk-W5BR65JY.js → vidply.chunk-A4ESEOQ2.js} +6 -6
  15. package/dist/dev/{vidply.chunk-XAK3DPTO.js → vidply.chunk-CGYIED2I.js} +2 -2
  16. package/dist/dev/{vidply.chunk-YVCYQZJI.js → vidply.chunk-HZ7UOKOU.js} +3 -3
  17. package/dist/dev/{vidply.chunk-NSMAZCMB.js → vidply.chunk-I6EDRYGJ.js} +33 -6
  18. package/dist/dev/{vidply.chunk-NSMAZCMB.js.map → vidply.chunk-I6EDRYGJ.js.map} +2 -2
  19. package/dist/dev/{vidply.chunk-LDVFAFGC.js → vidply.chunk-JKMQGLCJ.js} +5 -5
  20. package/dist/dev/{vidply.chunk-HOYEMJSD.js → vidply.chunk-JZNZX32Z.js} +2 -2
  21. package/dist/dev/{vidply.chunk-JAKZNGOR.js → vidply.chunk-P25MRJ6O.js} +2 -2
  22. package/dist/dev/{vidply.chunk-5IK3IC5V.js → vidply.chunk-PRFNESYA.js} +2 -2
  23. package/dist/dev/{vidply.chunk-RQG3MA7W.js → vidply.chunk-UI3RYCVF.js} +4 -3
  24. package/dist/dev/{vidply.chunk-RQG3MA7W.js.map → vidply.chunk-UI3RYCVF.js.map} +2 -2
  25. package/dist/dev/{vidply.chunk-C55OBT5H.js → vidply.chunk-UNFGMABP.js} +2 -2
  26. package/dist/dev/{vidply.de-5W5LBFSJ.js → vidply.de-VAUVSQXC.js} +29 -2
  27. package/dist/dev/{vidply.de-5W5LBFSJ.js.map → vidply.de-VAUVSQXC.js.map} +2 -2
  28. package/dist/dev/{vidply.es-SHDAT6L6.js → vidply.es-G65HFWO6.js} +29 -2
  29. package/dist/dev/{vidply.es-SHDAT6L6.js.map → vidply.es-G65HFWO6.js.map} +2 -2
  30. package/dist/dev/vidply.esm.js +594 -20
  31. package/dist/dev/vidply.esm.js.map +3 -3
  32. package/dist/dev/{vidply.fr-2JANOMNB.js → vidply.fr-MJNOZELM.js} +29 -2
  33. package/dist/dev/{vidply.fr-2JANOMNB.js.map → vidply.fr-MJNOZELM.js.map} +2 -2
  34. package/dist/dev/{vidply.ja-WWMSTEJH.js → vidply.ja-INMDHIYJ.js} +29 -2
  35. package/dist/dev/{vidply.ja-WWMSTEJH.js.map → vidply.ja-INMDHIYJ.js.map} +2 -2
  36. package/dist/legacy/vidply.js +718 -3
  37. package/dist/legacy/vidply.js.map +3 -3
  38. package/dist/legacy/vidply.min.js +2 -2
  39. package/dist/legacy/vidply.min.meta.json +72 -19
  40. package/dist/prod/{vidply.AudioDescriptionManager-YTDVDQBO.min.js → vidply.AudioDescriptionManager-EKWUNOPE.min.js} +2 -2
  41. package/dist/prod/{vidply.CaptionStyleMenu-2AFF3HF2.min.js → vidply.CaptionStyleMenu-JTWSNHRQ.min.js} +2 -2
  42. package/dist/prod/{vidply.DASHRenderer-URDOTOKS.min.js → vidply.DASHRenderer-CIDAHFWM.min.js} +2 -2
  43. package/dist/prod/{vidply.FloatingPlayerManager-QUBFK7CQ.min.js → vidply.FloatingPlayerManager-VZXHPCPM.min.js} +2 -2
  44. package/dist/prod/{vidply.HLSRenderer-46KNUVAB.min.js → vidply.HLSRenderer-Q6EFYKID.min.js} +2 -2
  45. package/dist/prod/vidply.HTML5Renderer-XRDJUTOO.min.js +6 -0
  46. package/dist/prod/{vidply.SignLanguageManager-H4C36EVL.min.js → vidply.SignLanguageManager-JUOSA5AM.min.js} +2 -2
  47. package/dist/prod/{vidply.SoundCloudRenderer-OYSWMW7D.min.js → vidply.SoundCloudRenderer-7ODD46PO.min.js} +2 -2
  48. package/dist/prod/{vidply.TranscriptManager-BA3DAS63.min.js → vidply.TranscriptManager-4B2QKYGQ.min.js} +2 -2
  49. package/dist/prod/{vidply.VimeoRenderer-NF3COTHU.min.js → vidply.VimeoRenderer-MR3UYLY7.min.js} +2 -2
  50. package/dist/prod/{vidply.YouTubeRenderer-UCQKYIJ4.min.js → vidply.YouTubeRenderer-ITRTC7NR.min.js} +2 -2
  51. package/dist/prod/{vidply.chunk-7S5AFTST.min.js → vidply.chunk-3EEDXU4T.min.js} +2 -2
  52. package/dist/prod/{vidply.chunk-EUOA2DJK.min.js → vidply.chunk-5HTX5QXW.min.js} +1 -1
  53. package/dist/prod/{vidply.chunk-JPMAK3NS.min.js → vidply.chunk-6OGHHIYP.min.js} +2 -2
  54. package/dist/prod/{vidply.chunk-XXUH7ADU.min.js → vidply.chunk-GUPGMFUZ.min.js} +2 -2
  55. package/dist/prod/{vidply.chunk-M76ABMKB.min.js → vidply.chunk-MWEBITCP.min.js} +1 -1
  56. package/dist/prod/{vidply.chunk-G6KGXNEW.min.js → vidply.chunk-PL3P3JHR.min.js} +1 -1
  57. package/dist/prod/{vidply.chunk-6ECBIYVI.min.js → vidply.chunk-QQZGV7TD.min.js} +2 -2
  58. package/dist/prod/{vidply.chunk-2HNCP64N.min.js → vidply.chunk-U5IRRYG5.min.js} +1 -1
  59. package/dist/prod/{vidply.chunk-WRVDVD33.min.js → vidply.chunk-VKHV4QHA.min.js} +2 -2
  60. package/dist/prod/{vidply.chunk-L4O44K63.min.js → vidply.chunk-XA4M574A.min.js} +1 -1
  61. package/dist/prod/{vidply.chunk-X2FQEQYR.min.js → vidply.chunk-ZPTV4JEP.min.js} +2 -2
  62. package/dist/prod/{vidply.de-JMCATTKZ.min.js → vidply.de-UZHFCEZU.min.js} +2 -2
  63. package/dist/prod/{vidply.es-YVG37V7Z.min.js → vidply.es-BOISUV2S.min.js} +2 -2
  64. package/dist/prod/vidply.esm.min.js +2 -2
  65. package/dist/prod/{vidply.fr-OGKGN2R4.min.js → vidply.fr-ICSMKPFM.min.js} +2 -2
  66. package/dist/prod/{vidply.ja-Z6DS7QLS.min.js → vidply.ja-VQ2HKGAY.min.js} +2 -2
  67. package/dist/vidply.css +56 -0
  68. package/dist/vidply.esm.min.meta.json +154 -112
  69. package/dist/vidply.min.css +1 -1
  70. package/package.json +7 -7
  71. package/src/controls/ControlBar.ts +32 -0
  72. package/src/controls/KeyboardHelp.ts +326 -0
  73. package/src/controls/KeyboardManager.ts +4 -0
  74. package/src/core/MediaSessionManager.ts +341 -0
  75. package/src/core/Player.ts +59 -1
  76. package/src/i18n/languages/de.ts +27 -0
  77. package/src/i18n/languages/en.ts +27 -0
  78. package/src/i18n/languages/es.ts +27 -0
  79. package/src/i18n/languages/fr.ts +27 -0
  80. package/src/i18n/languages/ja.ts +27 -0
  81. package/src/icons/Icons.ts +2 -1
  82. package/src/styles/vidply.css +56 -0
  83. package/src/types/events.ts +4 -0
  84. package/src/types/options.ts +14 -0
  85. package/dist/dev/vidply.HTML5Renderer-P7ICKYU4.js +0 -12
  86. package/dist/prod/vidply.HTML5Renderer-7TQDMIGY.min.js +0 -6
  87. /package/dist/dev/{vidply.AudioDescriptionManager-6RFVF27Z.js.map → vidply.AudioDescriptionManager-JYYLK5KB.js.map} +0 -0
  88. /package/dist/dev/{vidply.CaptionStyleMenu-R2SCQ6VP.js.map → vidply.CaptionStyleMenu-47RCKQF5.js.map} +0 -0
  89. /package/dist/dev/{vidply.DASHRenderer-EOVEW73H.js.map → vidply.DASHRenderer-VEJ4HDHO.js.map} +0 -0
  90. /package/dist/dev/{vidply.FloatingPlayerManager-V5XQWS75.js.map → vidply.FloatingPlayerManager-C2UVRPZY.js.map} +0 -0
  91. /package/dist/dev/{vidply.HLSRenderer-ZBEFVH5W.js.map → vidply.HLSRenderer-ZVBK2TNQ.js.map} +0 -0
  92. /package/dist/dev/{vidply.HTML5Renderer-P7ICKYU4.js.map → vidply.HTML5Renderer-HLRQUOCU.js.map} +0 -0
  93. /package/dist/dev/{vidply.SignLanguageManager-7IAJOVF6.js.map → vidply.SignLanguageManager-FLUCBW5H.js.map} +0 -0
  94. /package/dist/dev/{vidply.SoundCloudRenderer-OA7QQYGN.js.map → vidply.SoundCloudRenderer-MGMAJ7NK.js.map} +0 -0
  95. /package/dist/dev/{vidply.TranscriptManager-BPM3T23L.js.map → vidply.TranscriptManager-Y2RYXHJW.js.map} +0 -0
  96. /package/dist/dev/{vidply.VimeoRenderer-BRHFYPNJ.js.map → vidply.VimeoRenderer-RQFQD7OE.js.map} +0 -0
  97. /package/dist/dev/{vidply.YouTubeRenderer-HRTN4EJ5.js.map → vidply.YouTubeRenderer-EU2BARZF.js.map} +0 -0
  98. /package/dist/dev/{vidply.chunk-UHMVDBDB.js.map → vidply.chunk-6LYZYI5G.js.map} +0 -0
  99. /package/dist/dev/{vidply.chunk-W5BR65JY.js.map → vidply.chunk-A4ESEOQ2.js.map} +0 -0
  100. /package/dist/dev/{vidply.chunk-XAK3DPTO.js.map → vidply.chunk-CGYIED2I.js.map} +0 -0
  101. /package/dist/dev/{vidply.chunk-YVCYQZJI.js.map → vidply.chunk-HZ7UOKOU.js.map} +0 -0
  102. /package/dist/dev/{vidply.chunk-LDVFAFGC.js.map → vidply.chunk-JKMQGLCJ.js.map} +0 -0
  103. /package/dist/dev/{vidply.chunk-HOYEMJSD.js.map → vidply.chunk-JZNZX32Z.js.map} +0 -0
  104. /package/dist/dev/{vidply.chunk-JAKZNGOR.js.map → vidply.chunk-P25MRJ6O.js.map} +0 -0
  105. /package/dist/dev/{vidply.chunk-5IK3IC5V.js.map → vidply.chunk-PRFNESYA.js.map} +0 -0
  106. /package/dist/dev/{vidply.chunk-C55OBT5H.js.map → vidply.chunk-UNFGMABP.js.map} +0 -0
@@ -0,0 +1,326 @@
1
+ /**
2
+ * Keyboard Shortcuts Help Dialog
3
+ *
4
+ * A focus-trapped modal listing the player's active keyboard shortcuts.
5
+ * Built lazily from `player.options.keyboardShortcuts` so it always reflects
6
+ * the live bindings (including consumer overrides). Mirrors the focus, escape
7
+ * and return-focus behaviour of SettingsDialog for a consistent, accessible UX.
8
+ */
9
+
10
+ import { DOMUtils } from '../utils/DOMUtils.js';
11
+ import { createIconElement } from '../icons/Icons.js';
12
+ import { i18n } from '../i18n/i18n.js';
13
+ import type { Player } from '../core/Player.js';
14
+ import type { KeyboardShortcuts } from '../types/options.js';
15
+
16
+ /**
17
+ * Display order for the shortcut rows. Only actions that are both listed here
18
+ * and have at least one bound key are rendered, so removing a binding hides
19
+ * its row automatically.
20
+ */
21
+ const ACTION_ORDER: Array<keyof KeyboardShortcuts> = [
22
+ 'play-pause',
23
+ 'seek-backward',
24
+ 'seek-forward',
25
+ 'volume-up',
26
+ 'volume-down',
27
+ 'mute',
28
+ 'captions',
29
+ 'caption-style-menu',
30
+ 'speed-down',
31
+ 'speed-up',
32
+ 'speed-menu',
33
+ 'quality-menu',
34
+ 'chapters-menu',
35
+ 'transcript-toggle',
36
+ 'fullscreen',
37
+ 'help'
38
+ ];
39
+
40
+ /**
41
+ * Feature-specific actions only make sense when the matching feature actually
42
+ * exists on this player (e.g. there's no point listing the captions shortcut
43
+ * for a video without captions). Each value is the key under
44
+ * `player.controlBar.controls` whose presence proves the feature is available
45
+ * and enabled for this player. Actions not listed here (play/pause, seek,
46
+ * volume, mute, help) are always relevant for any audio/video player.
47
+ */
48
+ const ACTION_REQUIRES_CONTROL: Partial<Record<keyof KeyboardShortcuts, string>> = {
49
+ captions: 'captions',
50
+ 'caption-style-menu': 'captionStyle',
51
+ 'speed-down': 'speed',
52
+ 'speed-up': 'speed',
53
+ 'speed-menu': 'speed',
54
+ 'quality-menu': 'quality',
55
+ 'chapters-menu': 'chapters',
56
+ 'transcript-toggle': 'transcript',
57
+ fullscreen: 'fullscreen'
58
+ };
59
+
60
+ export class KeyboardHelp {
61
+ player: Player;
62
+ isOpen = false;
63
+ overlay: HTMLElement | null = null;
64
+ private _triggerElement: HTMLElement | null = null;
65
+ private _keydownHandler: ((e: KeyboardEvent) => void) | null = null;
66
+ private _content: HTMLElement | null = null;
67
+
68
+ constructor(player: Player) {
69
+ this.player = player;
70
+ }
71
+
72
+ private get prefix(): string {
73
+ return this.player.options.classPrefix;
74
+ }
75
+
76
+ /**
77
+ * Turn a raw KeyboardEvent.key value into a human-readable label. Arrow
78
+ * keys become universally understood glyphs; the space bar and single
79
+ * letters are normalised for legibility.
80
+ */
81
+ private formatKey(key: string): string {
82
+ switch (key) {
83
+ case ' ':
84
+ return i18n.t('help.keys.space');
85
+ case 'ArrowUp':
86
+ return '\u2191';
87
+ case 'ArrowDown':
88
+ return '\u2193';
89
+ case 'ArrowLeft':
90
+ return '\u2190';
91
+ case 'ArrowRight':
92
+ return '\u2192';
93
+ case 'Escape':
94
+ return 'Esc';
95
+ default:
96
+ return key.length === 1 ? key.toUpperCase() : key;
97
+ }
98
+ }
99
+
100
+ private createElement(): HTMLElement {
101
+ const titleId = `${this.prefix}-help-title-${this.player.instanceId}`;
102
+
103
+ const overlay = DOMUtils.createElement('div', {
104
+ className: `${this.prefix}-settings-overlay ${this.prefix}-help-overlay`,
105
+ attributes: {
106
+ role: 'dialog',
107
+ 'aria-modal': 'true',
108
+ 'aria-labelledby': titleId
109
+ }
110
+ });
111
+ overlay.style.display = 'none';
112
+
113
+ const dialog = DOMUtils.createElement('div', {
114
+ className: `${this.prefix}-settings-dialog ${this.prefix}-help-dialog`
115
+ });
116
+
117
+ const header = DOMUtils.createElement('div', {
118
+ className: `${this.prefix}-settings-header`
119
+ });
120
+
121
+ const title = DOMUtils.createElement('h2', {
122
+ textContent: i18n.t('help.title'),
123
+ attributes: { id: titleId }
124
+ });
125
+
126
+ const closeButton = DOMUtils.createElement('button', {
127
+ className: `${this.prefix}-button ${this.prefix}-settings-close`,
128
+ attributes: {
129
+ type: 'button',
130
+ 'aria-label': i18n.t('help.close')
131
+ }
132
+ });
133
+ closeButton.appendChild(createIconElement('close'));
134
+ closeButton.addEventListener('click', () => this.hide());
135
+
136
+ header.appendChild(title);
137
+ header.appendChild(closeButton);
138
+
139
+ const content = DOMUtils.createElement('div', {
140
+ className: `${this.prefix}-settings-content`
141
+ });
142
+ this._content = content;
143
+ content.appendChild(this.buildShortcutList());
144
+
145
+ dialog.appendChild(header);
146
+ dialog.appendChild(content);
147
+ overlay.appendChild(dialog);
148
+
149
+ overlay.addEventListener('click', (e: MouseEvent) => {
150
+ if (e.target === overlay) {
151
+ this.hide();
152
+ }
153
+ });
154
+
155
+ this._keydownHandler = (e: KeyboardEvent) => {
156
+ if (!this.isOpen || !this.overlay) return;
157
+
158
+ if (e.key === 'Escape') {
159
+ e.preventDefault();
160
+ this.hide();
161
+ return;
162
+ }
163
+
164
+ // Trap Tab within the dialog (WCAG 2.1.2, 2.4.3).
165
+ if (e.key === 'Tab') {
166
+ const focusable = Array.from(
167
+ this.overlay.querySelectorAll<HTMLElement>(
168
+ 'button, [href], select, input, textarea, [tabindex]:not([tabindex="-1"])'
169
+ )
170
+ ).filter((el) => !el.hasAttribute('disabled') && el.offsetParent !== null);
171
+ if (focusable.length === 0) return;
172
+
173
+ const first = focusable[0];
174
+ const last = focusable[focusable.length - 1];
175
+ if (!first || !last) return;
176
+ const active = document.activeElement as HTMLElement | null;
177
+
178
+ if (e.shiftKey && (active === first || !this.overlay.contains(active))) {
179
+ e.preventDefault();
180
+ last.focus({ preventScroll: true });
181
+ } else if (!e.shiftKey && active === last) {
182
+ e.preventDefault();
183
+ first.focus({ preventScroll: true });
184
+ }
185
+ }
186
+ };
187
+ const lifecycleSignal = (this.player as { lifecycleSignal?: AbortSignal }).lifecycleSignal;
188
+ document.addEventListener(
189
+ 'keydown',
190
+ this._keydownHandler,
191
+ lifecycleSignal ? { signal: lifecycleSignal } : undefined
192
+ );
193
+
194
+ return overlay;
195
+ }
196
+
197
+ /**
198
+ * Whether a shortcut row is worth showing for *this* player. Feature actions
199
+ * are hidden when their control isn't present (e.g. no captions track, an
200
+ * audio-only player with no fullscreen). Core actions are always relevant.
201
+ *
202
+ * When the player has no control bar we can't infer availability, so nothing
203
+ * is hidden — the shortcuts still work and we'd rather over-show than mislead.
204
+ */
205
+ private isActionRelevant(action: keyof KeyboardShortcuts): boolean {
206
+ const requiredControl = ACTION_REQUIRES_CONTROL[action];
207
+ if (!requiredControl) return true;
208
+
209
+ const controlBar = (this.player as { controlBar?: { controls?: Record<string, unknown> } }).controlBar;
210
+ if (!controlBar || !controlBar.controls) return true;
211
+
212
+ return Boolean(controlBar.controls[requiredControl]);
213
+ }
214
+
215
+ private buildShortcutList(): HTMLElement {
216
+ const list = DOMUtils.createElement('dl', {
217
+ className: `${this.prefix}-help-list`
218
+ });
219
+
220
+ const shortcuts = this.player.options.keyboardShortcuts as unknown as Record<string, string[] | undefined>;
221
+
222
+ for (const action of ACTION_ORDER) {
223
+ const keys = shortcuts[action];
224
+ if (!Array.isArray(keys) || keys.length === 0) continue;
225
+ if (!this.isActionRelevant(action)) continue;
226
+
227
+ const term = DOMUtils.createElement('dt', {
228
+ className: `${this.prefix}-help-action`,
229
+ textContent: i18n.t(`help.actions.${action}`)
230
+ });
231
+
232
+ const desc = DOMUtils.createElement('dd', {
233
+ className: `${this.prefix}-help-keys`
234
+ });
235
+
236
+ keys.forEach((key, index) => {
237
+ if (index > 0) {
238
+ desc.appendChild(
239
+ DOMUtils.createElement('span', {
240
+ className: `${this.prefix}-help-key-sep`,
241
+ textContent: i18n.t('help.or')
242
+ })
243
+ );
244
+ }
245
+ desc.appendChild(
246
+ DOMUtils.createElement('kbd', {
247
+ className: `${this.prefix}-help-key`,
248
+ textContent: this.formatKey(key)
249
+ })
250
+ );
251
+ });
252
+
253
+ list.appendChild(term);
254
+ list.appendChild(desc);
255
+ }
256
+
257
+ return list;
258
+ }
259
+
260
+ show(): void {
261
+ if (this.isOpen) return;
262
+
263
+ if (!this.overlay) {
264
+ this.overlay = this.createElement();
265
+ this.player.container.appendChild(this.overlay);
266
+ } else if (this._content) {
267
+ // Rebuild so feature availability that changed since last open
268
+ // (e.g. HLS captions/qualities loaded later) is reflected.
269
+ this._content.replaceChildren(this.buildShortcutList());
270
+ }
271
+
272
+ const active = (typeof document !== 'undefined' ? document.activeElement : null) as HTMLElement | null;
273
+ this._triggerElement = active && typeof active.focus === 'function' ? active : null;
274
+
275
+ this.overlay.style.display = 'flex';
276
+ this.isOpen = true;
277
+
278
+ const closeButton = this.overlay.querySelector<HTMLElement>(`.${this.prefix}-settings-close`);
279
+ closeButton?.focus({ preventScroll: true });
280
+
281
+ this.player.emit('keyboardhelpopen');
282
+ }
283
+
284
+ hide(): void {
285
+ if (!this.overlay) return;
286
+
287
+ this.overlay.style.display = 'none';
288
+ this.isOpen = false;
289
+
290
+ const trigger = this._triggerElement;
291
+ this._triggerElement = null;
292
+ if (trigger && document.contains(trigger)) {
293
+ try {
294
+ trigger.focus({ preventScroll: true });
295
+ } catch {
296
+ this.player.container?.focus();
297
+ }
298
+ } else {
299
+ this.player.container?.focus();
300
+ }
301
+
302
+ this.player.emit('keyboardhelpclose');
303
+ }
304
+
305
+ toggle(): void {
306
+ if (this.isOpen) {
307
+ this.hide();
308
+ } else {
309
+ this.show();
310
+ }
311
+ }
312
+
313
+ destroy(): void {
314
+ if (this._keydownHandler) {
315
+ document.removeEventListener('keydown', this._keydownHandler);
316
+ this._keydownHandler = null;
317
+ }
318
+ if (this.overlay && this.overlay.parentNode) {
319
+ this.overlay.parentNode.removeChild(this.overlay);
320
+ }
321
+ this.overlay = null;
322
+ this._content = null;
323
+ this._triggerElement = null;
324
+ this.isOpen = false;
325
+ }
326
+ }
@@ -269,6 +269,10 @@ export class KeyboardManager {
269
269
  }
270
270
  return false;
271
271
 
272
+ case 'help':
273
+ this.player.toggleKeyboardHelp();
274
+ return true;
275
+
272
276
  default:
273
277
  return false;
274
278
  }
@@ -0,0 +1,341 @@
1
+ /**
2
+ * Media Session API integration.
3
+ *
4
+ * Wires the player into `navigator.mediaSession` so the OS shows
5
+ * now-playing metadata (title / artist / album / artwork) and routes
6
+ * hardware/lock-screen/notification controls (play, pause, stop, seek,
7
+ * and previous/next track for playlists) back into the player.
8
+ *
9
+ * Everything is feature-detected and wrapped in try/catch: unsupported
10
+ * browsers and unsupported individual actions degrade silently.
11
+ */
12
+
13
+ import type { Player } from './Player.js';
14
+
15
+ interface PlaylistLikeTrack {
16
+ title?: string;
17
+ artist?: string;
18
+ poster?: string;
19
+ }
20
+
21
+ const POSITION_THROTTLE_MS = 1000;
22
+
23
+ /**
24
+ * `navigator.mediaSession` is a single, global object shared by the whole
25
+ * document, but a page can host several VidPly players at once (common in a
26
+ * CMS). Only one of them may own the session at a time, otherwise the OS
27
+ * controls would be wired to a player the user isn't actually listening to
28
+ * (clicking play/pause/next then appears to "do nothing").
29
+ *
30
+ * We track the owning manager here and follow the browser convention: the
31
+ * player that most recently started playing claims the session. Background
32
+ * players never touch the global session, and a player only releases it on
33
+ * destroy if it still owns it.
34
+ */
35
+ let activeManager: MediaSessionManager | null = null;
36
+
37
+ export class MediaSessionManager {
38
+ player: Player;
39
+ private supported: boolean;
40
+ private handlers: Partial<Record<string, () => void>> = {};
41
+ private boundActions: string[] = [];
42
+ private lastPositionUpdate = 0;
43
+
44
+ constructor(player: Player) {
45
+ this.player = player;
46
+ this.supported =
47
+ typeof navigator !== 'undefined' && 'mediaSession' in navigator;
48
+
49
+ if (!this.supported) return;
50
+
51
+ this.attachEvents();
52
+
53
+ // Claim the session up front only when no other player owns it yet (i.e.
54
+ // we're the first/only player on the page). When other players already
55
+ // exist we wait until this one actually starts playing before taking
56
+ // over, so we never hijack a player the user is currently using.
57
+ if (activeManager === null) {
58
+ this.claimSession();
59
+ }
60
+ }
61
+
62
+ /** Does this manager currently own the global media session? */
63
+ private isActive(): boolean {
64
+ return activeManager === this;
65
+ }
66
+
67
+ /**
68
+ * Take ownership of the global session: (re)register the action handlers so
69
+ * the OS controls drive this player, and refresh metadata/state/position.
70
+ */
71
+ private claimSession(): void {
72
+ if (!this.supported) return;
73
+ activeManager = this;
74
+ this.setupActionHandlers();
75
+ this.updateMetadata();
76
+ this.updatePlaybackState();
77
+ this.updatePositionState(true);
78
+ }
79
+
80
+ private get session(): MediaSession {
81
+ return navigator.mediaSession;
82
+ }
83
+
84
+ private setActionHandler(
85
+ action: MediaSessionAction,
86
+ handler: MediaSessionActionHandler | null
87
+ ): void {
88
+ try {
89
+ this.session.setActionHandler(action, handler);
90
+ if (handler && !this.boundActions.includes(action)) {
91
+ this.boundActions.push(action);
92
+ }
93
+ } catch {
94
+ // Action not supported by this browser - ignore.
95
+ }
96
+ }
97
+
98
+ private setupActionHandlers(): void {
99
+ this.setActionHandler('play', () => this.player.play());
100
+ this.setActionHandler('pause', () => this.player.pause());
101
+ this.setActionHandler('stop', () => this.player.stop());
102
+
103
+ this.setActionHandler('seekbackward', (details) => {
104
+ this.player.seekBackward(this.offsetFrom(details));
105
+ });
106
+ this.setActionHandler('seekforward', (details) => {
107
+ this.player.seekForward(this.offsetFrom(details));
108
+ });
109
+ this.setActionHandler('seekto', (details) => {
110
+ if (details && typeof details.seekTime === 'number') {
111
+ this.player.seek(details.seekTime);
112
+ }
113
+ });
114
+
115
+ this.updateTrackHandlers();
116
+ }
117
+
118
+ private offsetFrom(details?: MediaSessionActionDetails): number | undefined {
119
+ const offset = details && typeof details.seekOffset === 'number' ? details.seekOffset : undefined;
120
+ return typeof offset === 'number' && offset > 0 ? offset : undefined;
121
+ }
122
+
123
+ /**
124
+ * previous/next track only make sense with a multi-item playlist; bind
125
+ * or clear them whenever the playlist state changes so the OS shows the
126
+ * correct affordances.
127
+ */
128
+ private updateTrackHandlers(): void {
129
+ const pm = this.player.playlistManager;
130
+ const hasPlaylist = Boolean(pm && Array.isArray(pm.tracks) && pm.tracks.length > 1);
131
+
132
+ if (hasPlaylist && pm) {
133
+ this.setActionHandler('previoustrack', () => pm.previous());
134
+ this.setActionHandler('nexttrack', () => pm.next());
135
+ } else {
136
+ this.setActionHandler('previoustrack', null);
137
+ this.setActionHandler('nexttrack', null);
138
+ }
139
+ }
140
+
141
+ private attachEvents(): void {
142
+ this.handlers = {
143
+ // Starting playback makes this player the session owner, taking over
144
+ // the OS controls from any other player on the page.
145
+ play: () => this.claimSession(),
146
+ pause: () => {
147
+ if (!this.isActive()) return;
148
+ this.updatePlaybackState();
149
+ this.updatePositionState(true);
150
+ },
151
+ ended: () => {
152
+ if (!this.isActive()) return;
153
+ this.updatePlaybackState();
154
+ },
155
+ timeupdate: () => {
156
+ if (!this.isActive()) return;
157
+ this.updatePositionState();
158
+ },
159
+ durationchange: () => {
160
+ if (!this.isActive()) return;
161
+ this.updatePositionState(true);
162
+ },
163
+ ratechange: () => {
164
+ if (!this.isActive()) return;
165
+ this.updatePositionState(true);
166
+ },
167
+ loadedmetadata: () => {
168
+ if (!this.isActive()) return;
169
+ this.updateMetadata();
170
+ this.updatePositionState(true);
171
+ },
172
+ playlisttrackchange: () => {
173
+ if (!this.isActive()) return;
174
+ this.updateMetadata();
175
+ this.updateTrackHandlers();
176
+ this.updatePositionState(true);
177
+ }
178
+ };
179
+
180
+ for (const [event, handler] of Object.entries(this.handlers)) {
181
+ if (handler) {
182
+ this.player.on(event as never, handler as never);
183
+ }
184
+ }
185
+ }
186
+
187
+ private resolveMetadata(): {
188
+ title: string;
189
+ artist: string;
190
+ album: string;
191
+ poster: string | null;
192
+ } {
193
+ const opts = this.player.options;
194
+ let title = opts.title || '';
195
+ let artist = opts.artist || '';
196
+ const album = opts.album || '';
197
+ let poster = opts.poster || null;
198
+
199
+ const pm = this.player.playlistManager;
200
+ if (pm && Array.isArray(pm.tracks) && pm.currentIndex >= 0) {
201
+ const track = pm.tracks[pm.currentIndex] as PlaylistLikeTrack | undefined;
202
+ if (track) {
203
+ if (track.title) title = track.title;
204
+ if (track.artist) artist = track.artist;
205
+ if (track.poster) poster = track.poster;
206
+ }
207
+ }
208
+
209
+ if (!title && typeof document !== 'undefined') {
210
+ title = document.title || 'VidPly';
211
+ }
212
+
213
+ return { title, artist, album, poster };
214
+ }
215
+
216
+ updateMetadata(): void {
217
+ if (!this.supported || typeof window === 'undefined' || typeof window.MediaMetadata === 'undefined') {
218
+ return;
219
+ }
220
+
221
+ const { title, artist, album, poster } = this.resolveMetadata();
222
+ const artwork: MediaImage[] = [];
223
+
224
+ if (poster) {
225
+ try {
226
+ const src = this.player.resolvePosterPath(poster);
227
+ if (src) {
228
+ artwork.push({ src });
229
+ }
230
+ } catch {
231
+ // Ignore poster resolution failures.
232
+ }
233
+ }
234
+
235
+ try {
236
+ this.session.metadata = new window.MediaMetadata({
237
+ title,
238
+ artist,
239
+ album,
240
+ artwork
241
+ });
242
+ } catch {
243
+ // Ignore metadata construction failures.
244
+ }
245
+ }
246
+
247
+ updatePlaybackState(): void {
248
+ if (!this.supported) return;
249
+ try {
250
+ this.session.playbackState = this.player.state.playing ? 'playing' : 'paused';
251
+ } catch {
252
+ // Ignore.
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Push the current position to the OS scrubber. `timeupdate` fires
258
+ * several times a second, so non-forced updates are throttled.
259
+ */
260
+ updatePositionState(force = false): void {
261
+ if (!this.supported || typeof this.session.setPositionState !== 'function') {
262
+ return;
263
+ }
264
+
265
+ const now = Date.now();
266
+ if (!force && now - this.lastPositionUpdate < POSITION_THROTTLE_MS) {
267
+ return;
268
+ }
269
+ this.lastPositionUpdate = now;
270
+
271
+ const duration = this.player.state.duration;
272
+ if (!Number.isFinite(duration) || duration <= 0) {
273
+ // A finite, positive duration is required; clear any stale state
274
+ // (e.g. live streams or before metadata is known).
275
+ try {
276
+ this.session.setPositionState();
277
+ } catch {
278
+ // Ignore.
279
+ }
280
+ return;
281
+ }
282
+
283
+ const playbackRate = this.player.state.playbackSpeed || 1;
284
+ const position = Math.min(Math.max(0, this.player.state.currentTime || 0), duration);
285
+
286
+ try {
287
+ this.session.setPositionState({
288
+ duration,
289
+ playbackRate: playbackRate > 0 ? playbackRate : 1,
290
+ position
291
+ });
292
+ } catch {
293
+ // Ignore (e.g. position > duration race).
294
+ }
295
+ }
296
+
297
+ destroy(): void {
298
+ if (!this.supported) return;
299
+
300
+ for (const [event, handler] of Object.entries(this.handlers)) {
301
+ if (handler) {
302
+ this.player.off(event as never, handler as never);
303
+ }
304
+ }
305
+ this.handlers = {};
306
+
307
+ // Only tear down the global session if we currently own it; otherwise we
308
+ // would wipe the controls of whichever player is actually playing.
309
+ if (this.isActive()) {
310
+ for (const action of this.boundActions) {
311
+ try {
312
+ this.session.setActionHandler(action as MediaSessionAction, null);
313
+ } catch {
314
+ // Ignore.
315
+ }
316
+ }
317
+
318
+ try {
319
+ this.session.metadata = null;
320
+ } catch {
321
+ // Ignore.
322
+ }
323
+ try {
324
+ if (typeof this.session.setPositionState === 'function') {
325
+ this.session.setPositionState();
326
+ }
327
+ } catch {
328
+ // Ignore.
329
+ }
330
+ try {
331
+ this.session.playbackState = 'none';
332
+ } catch {
333
+ // Ignore.
334
+ }
335
+
336
+ activeManager = null;
337
+ }
338
+
339
+ this.boundActions = [];
340
+ }
341
+ }