vidply 1.2.0 → 1.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/README.md +974 -946
  2. package/dist/dev/{vidply.AudioDescriptionManager-R47Q4YR4.js → vidply.AudioDescriptionManager-JYYLK5KB.js} +275 -19
  3. package/dist/dev/vidply.AudioDescriptionManager-JYYLK5KB.js.map +7 -0
  4. package/dist/dev/{vidply.CaptionStyleMenu-7XHRBWUM.js → vidply.CaptionStyleMenu-47RCKQF5.js} +4 -4
  5. package/dist/dev/{vidply.DASHRenderer-PWEMC3BT.js → vidply.DASHRenderer-VEJ4HDHO.js} +3 -3
  6. package/dist/dev/vidply.DASHRenderer-VEJ4HDHO.js.map +7 -0
  7. package/dist/dev/{vidply.FloatingPlayerManager-PKNNNAS2.js → vidply.FloatingPlayerManager-C2UVRPZY.js} +5 -5
  8. package/dist/dev/vidply.FloatingPlayerManager-C2UVRPZY.js.map +7 -0
  9. package/dist/dev/{vidply.HLSRenderer-E7RQ74HI.js → vidply.HLSRenderer-ZVBK2TNQ.js} +4 -4
  10. package/dist/dev/vidply.HLSRenderer-ZVBK2TNQ.js.map +7 -0
  11. package/dist/dev/vidply.HTML5Renderer-HLRQUOCU.js +12 -0
  12. package/dist/dev/{vidply.SignLanguageManager-FET5FQZ7.js → vidply.SignLanguageManager-FLUCBW5H.js} +8 -8
  13. package/dist/dev/vidply.SignLanguageManager-FLUCBW5H.js.map +7 -0
  14. package/dist/dev/{vidply.SoundCloudRenderer-RVSJZODO.js → vidply.SoundCloudRenderer-MGMAJ7NK.js} +3 -3
  15. package/dist/dev/vidply.SoundCloudRenderer-MGMAJ7NK.js.map +7 -0
  16. package/dist/dev/{vidply.TranscriptManager-5GXOQCLX.js → vidply.TranscriptManager-Y2RYXHJW.js} +10 -10
  17. package/dist/dev/vidply.TranscriptManager-Y2RYXHJW.js.map +7 -0
  18. package/dist/dev/{vidply.VimeoRenderer-ER2LSBBW.js → vidply.VimeoRenderer-RQFQD7OE.js} +3 -3
  19. package/dist/dev/vidply.VimeoRenderer-RQFQD7OE.js.map +7 -0
  20. package/dist/dev/{vidply.YouTubeRenderer-NFTOWJAB.js → vidply.YouTubeRenderer-EU2BARZF.js} +3 -3
  21. package/dist/dev/vidply.YouTubeRenderer-EU2BARZF.js.map +7 -0
  22. package/dist/dev/{vidply.chunk-BNFIUL2Z.js → vidply.chunk-6LYZYI5G.js} +3 -3
  23. package/dist/dev/vidply.chunk-6LYZYI5G.js.map +7 -0
  24. package/dist/dev/{vidply.chunk-NQPURMQY.js → vidply.chunk-A4ESEOQ2.js} +6 -6
  25. package/dist/dev/vidply.chunk-A4ESEOQ2.js.map +7 -0
  26. package/dist/dev/{vidply.chunk-W25HUCHS.js → vidply.chunk-CGYIED2I.js} +2 -2
  27. package/dist/dev/vidply.chunk-CGYIED2I.js.map +7 -0
  28. package/dist/dev/{vidply.chunk-SK4V2DAU.js → vidply.chunk-HZ7UOKOU.js} +3 -3
  29. package/dist/dev/{vidply.chunk-RZWZGDV7.js → vidply.chunk-I6EDRYGJ.js} +33 -6
  30. package/dist/dev/vidply.chunk-I6EDRYGJ.js.map +7 -0
  31. package/dist/dev/{vidply.chunk-64WXMTGL.js → vidply.chunk-JKMQGLCJ.js} +5 -5
  32. package/dist/dev/vidply.chunk-JKMQGLCJ.js.map +7 -0
  33. package/dist/dev/{vidply.chunk-DZSDO3B7.js → vidply.chunk-JZNZX32Z.js} +2 -2
  34. package/dist/dev/{vidply.chunk-VMSQAZM7.js → vidply.chunk-P25MRJ6O.js} +2 -2
  35. package/dist/dev/{vidply.chunk-6RQA4ROD.js → vidply.chunk-PRFNESYA.js} +2 -2
  36. package/dist/dev/{vidply.chunk-KI7C3QVL.js → vidply.chunk-UI3RYCVF.js} +4 -3
  37. package/dist/dev/vidply.chunk-UI3RYCVF.js.map +7 -0
  38. package/dist/dev/{vidply.chunk-JJ47NO24.js → vidply.chunk-UNFGMABP.js} +2 -2
  39. package/dist/dev/vidply.chunk-UNFGMABP.js.map +7 -0
  40. package/dist/dev/{vidply.de-4YAT7FFM.js → vidply.de-VAUVSQXC.js} +29 -2
  41. package/dist/dev/vidply.de-VAUVSQXC.js.map +7 -0
  42. package/dist/dev/{vidply.es-LEWIRUBH.js → vidply.es-G65HFWO6.js} +29 -2
  43. package/dist/dev/vidply.es-G65HFWO6.js.map +7 -0
  44. package/dist/dev/vidply.esm.js +648 -35
  45. package/dist/dev/vidply.esm.js.map +3 -3
  46. package/dist/dev/{vidply.fr-Q4GV2NOE.js → vidply.fr-MJNOZELM.js} +29 -2
  47. package/dist/dev/vidply.fr-MJNOZELM.js.map +7 -0
  48. package/dist/dev/{vidply.ja-S6ITA6WU.js → vidply.ja-INMDHIYJ.js} +29 -2
  49. package/dist/dev/vidply.ja-INMDHIYJ.js.map +7 -0
  50. package/dist/legacy/vidply.js +1062 -34
  51. package/dist/legacy/vidply.js.map +3 -3
  52. package/dist/legacy/vidply.min.js +2 -2
  53. package/dist/legacy/vidply.min.meta.json +127 -55
  54. package/dist/prod/vidply.AudioDescriptionManager-EKWUNOPE.min.js +6 -0
  55. package/dist/prod/{vidply.CaptionStyleMenu-M3KVQWB5.min.js → vidply.CaptionStyleMenu-JTWSNHRQ.min.js} +2 -2
  56. package/dist/prod/{vidply.DASHRenderer-DN3S27AV.min.js → vidply.DASHRenderer-CIDAHFWM.min.js} +2 -2
  57. package/dist/prod/{vidply.FloatingPlayerManager-SKAZ4J43.min.js → vidply.FloatingPlayerManager-VZXHPCPM.min.js} +2 -2
  58. package/dist/prod/{vidply.HLSRenderer-62HH4YTK.min.js → vidply.HLSRenderer-Q6EFYKID.min.js} +2 -2
  59. package/dist/prod/vidply.HTML5Renderer-XRDJUTOO.min.js +6 -0
  60. package/dist/prod/{vidply.SignLanguageManager-V3OBTSSF.min.js → vidply.SignLanguageManager-JUOSA5AM.min.js} +2 -2
  61. package/dist/prod/{vidply.SoundCloudRenderer-CXRVZIAJ.min.js → vidply.SoundCloudRenderer-7ODD46PO.min.js} +2 -2
  62. package/dist/prod/{vidply.TranscriptManager-SYM3DFUY.min.js → vidply.TranscriptManager-4B2QKYGQ.min.js} +2 -2
  63. package/dist/prod/{vidply.VimeoRenderer-72TWRYFQ.min.js → vidply.VimeoRenderer-MR3UYLY7.min.js} +2 -2
  64. package/dist/prod/{vidply.YouTubeRenderer-UMHEV3KD.min.js → vidply.YouTubeRenderer-ITRTC7NR.min.js} +2 -2
  65. package/dist/prod/{vidply.chunk-YEENJS3Q.min.js → vidply.chunk-3EEDXU4T.min.js} +2 -2
  66. package/dist/prod/{vidply.chunk-P4UXOHWA.min.js → vidply.chunk-5HTX5QXW.min.js} +1 -1
  67. package/dist/prod/{vidply.chunk-ABPXVDLZ.min.js → vidply.chunk-6OGHHIYP.min.js} +2 -2
  68. package/dist/prod/{vidply.chunk-UK7YGOB5.min.js → vidply.chunk-GUPGMFUZ.min.js} +2 -2
  69. package/dist/prod/{vidply.chunk-WX3MVEAL.min.js → vidply.chunk-MWEBITCP.min.js} +1 -1
  70. package/dist/prod/{vidply.chunk-2TB6MUHO.min.js → vidply.chunk-PL3P3JHR.min.js} +1 -1
  71. package/dist/prod/{vidply.chunk-EKEYJWMG.min.js → vidply.chunk-QQZGV7TD.min.js} +2 -2
  72. package/dist/prod/{vidply.chunk-Z3YDSA7O.min.js → vidply.chunk-U5IRRYG5.min.js} +1 -1
  73. package/dist/prod/{vidply.chunk-TM6AXJVD.min.js → vidply.chunk-VKHV4QHA.min.js} +2 -2
  74. package/dist/prod/{vidply.chunk-P3PQBY7N.min.js → vidply.chunk-XA4M574A.min.js} +1 -1
  75. package/dist/prod/{vidply.chunk-RI3X7WHK.min.js → vidply.chunk-ZPTV4JEP.min.js} +2 -2
  76. package/dist/prod/{vidply.de-BCHXRQ53.min.js → vidply.de-UZHFCEZU.min.js} +2 -2
  77. package/dist/prod/{vidply.es-PICAKMZD.min.js → vidply.es-BOISUV2S.min.js} +2 -2
  78. package/dist/prod/vidply.esm.min.js +2 -2
  79. package/dist/prod/{vidply.fr-ZILI7CDR.min.js → vidply.fr-ICSMKPFM.min.js} +2 -2
  80. package/dist/prod/{vidply.ja-5WQ4OK4J.min.js → vidply.ja-VQ2HKGAY.min.js} +2 -2
  81. package/dist/vidply.css +5852 -5796
  82. package/dist/vidply.esm.min.meta.json +204 -149
  83. package/dist/vidply.min.css +1 -1
  84. package/package.json +11 -11
  85. package/src/controls/CaptionManager.ts +598 -598
  86. package/src/controls/ControlBar.ts +4191 -4159
  87. package/src/controls/KeyboardHelp.ts +326 -0
  88. package/src/controls/KeyboardManager.ts +387 -383
  89. package/src/controls/SettingsDialog.ts +488 -488
  90. package/src/controls/TranscriptManager.ts +2426 -2426
  91. package/src/core/AudioDescriptionManager.ts +863 -774
  92. package/src/core/DescriptionSpeechManager.ts +232 -0
  93. package/src/core/FloatingPlayerManager.ts +674 -674
  94. package/src/core/LazyInit.ts +80 -80
  95. package/src/core/MediaSessionManager.ts +341 -0
  96. package/src/core/MetadataAlertsManager.ts +514 -514
  97. package/src/core/Player.ts +2973 -2872
  98. package/src/core/PosterManager.ts +169 -169
  99. package/src/core/PseudoFullscreen.ts +179 -179
  100. package/src/core/ResponsiveManager.ts +231 -231
  101. package/src/core/ResumeManager.ts +236 -236
  102. package/src/core/SignLanguageManager.ts +1308 -1308
  103. package/src/core/ThemeManager.ts +157 -157
  104. package/src/features/PlaylistManager.ts +1838 -1827
  105. package/src/i18n/i18n.ts +223 -223
  106. package/src/i18n/languages/de.ts +244 -217
  107. package/src/i18n/languages/en.ts +245 -218
  108. package/src/i18n/languages/es.ts +241 -214
  109. package/src/i18n/languages/fr.ts +241 -214
  110. package/src/i18n/languages/ja.ts +241 -214
  111. package/src/icons/Icons.ts +168 -167
  112. package/src/index.ts +196 -193
  113. package/src/renderers/DASHRenderer.ts +1077 -1077
  114. package/src/renderers/HLSRenderer.ts +944 -944
  115. package/src/renderers/HTML5Renderer.ts +389 -389
  116. package/src/renderers/SoundCloudRenderer.ts +437 -437
  117. package/src/renderers/VimeoRenderer.ts +298 -298
  118. package/src/renderers/YouTubeRenderer.ts +290 -290
  119. package/src/styles/vidply.css +5852 -5796
  120. package/src/types/events.ts +128 -122
  121. package/src/types/globals.d.ts +313 -313
  122. package/src/types/options.ts +199 -182
  123. package/src/types/renderer.ts +58 -58
  124. package/src/utils/DOMUtils.ts +281 -281
  125. package/src/utils/DownloadInfo.ts +171 -171
  126. package/src/utils/DraggablePanel.ts +539 -539
  127. package/src/utils/DraggablePanelMenu.ts +195 -195
  128. package/src/utils/DraggableResizable.ts +997 -997
  129. package/src/utils/EventEmitter.ts +59 -59
  130. package/src/utils/FormUtils.ts +95 -95
  131. package/src/utils/MenuFactory.ts +483 -483
  132. package/src/utils/Sanitize.ts +70 -70
  133. package/src/utils/SettingsMenuFactory.ts +289 -289
  134. package/src/utils/StorageManager.ts +287 -287
  135. package/src/utils/TrackLabelUtils.ts +52 -52
  136. package/src/utils/UrlSafe.ts +69 -69
  137. package/src/utils/VideoFrameCapture.ts +114 -114
  138. package/src/utils/WindowComponents.ts +250 -250
  139. package/dist/dev/vidply.AudioDescriptionManager-R47Q4YR4.js.map +0 -7
  140. package/dist/dev/vidply.DASHRenderer-PWEMC3BT.js.map +0 -7
  141. package/dist/dev/vidply.FloatingPlayerManager-PKNNNAS2.js.map +0 -7
  142. package/dist/dev/vidply.HLSRenderer-E7RQ74HI.js.map +0 -7
  143. package/dist/dev/vidply.HTML5Renderer-BU4QJJJY.js +0 -12
  144. package/dist/dev/vidply.SignLanguageManager-FET5FQZ7.js.map +0 -7
  145. package/dist/dev/vidply.SoundCloudRenderer-RVSJZODO.js.map +0 -7
  146. package/dist/dev/vidply.TranscriptManager-5GXOQCLX.js.map +0 -7
  147. package/dist/dev/vidply.VimeoRenderer-ER2LSBBW.js.map +0 -7
  148. package/dist/dev/vidply.YouTubeRenderer-NFTOWJAB.js.map +0 -7
  149. package/dist/dev/vidply.chunk-64WXMTGL.js.map +0 -7
  150. package/dist/dev/vidply.chunk-BNFIUL2Z.js.map +0 -7
  151. package/dist/dev/vidply.chunk-JJ47NO24.js.map +0 -7
  152. package/dist/dev/vidply.chunk-KI7C3QVL.js.map +0 -7
  153. package/dist/dev/vidply.chunk-NQPURMQY.js.map +0 -7
  154. package/dist/dev/vidply.chunk-RZWZGDV7.js.map +0 -7
  155. package/dist/dev/vidply.chunk-W25HUCHS.js.map +0 -7
  156. package/dist/dev/vidply.de-4YAT7FFM.js.map +0 -7
  157. package/dist/dev/vidply.es-LEWIRUBH.js.map +0 -7
  158. package/dist/dev/vidply.fr-Q4GV2NOE.js.map +0 -7
  159. package/dist/dev/vidply.ja-S6ITA6WU.js.map +0 -7
  160. package/dist/prod/vidply.AudioDescriptionManager-RHLIJW5C.min.js +0 -6
  161. package/dist/prod/vidply.HTML5Renderer-IU5T3WEW.min.js +0 -6
  162. /package/dist/dev/{vidply.CaptionStyleMenu-7XHRBWUM.js.map → vidply.CaptionStyleMenu-47RCKQF5.js.map} +0 -0
  163. /package/dist/dev/{vidply.HTML5Renderer-BU4QJJJY.js.map → vidply.HTML5Renderer-HLRQUOCU.js.map} +0 -0
  164. /package/dist/dev/{vidply.chunk-SK4V2DAU.js.map → vidply.chunk-HZ7UOKOU.js.map} +0 -0
  165. /package/dist/dev/{vidply.chunk-DZSDO3B7.js.map → vidply.chunk-JZNZX32Z.js.map} +0 -0
  166. /package/dist/dev/{vidply.chunk-VMSQAZM7.js.map → vidply.chunk-P25MRJ6O.js.map} +0 -0
  167. /package/dist/dev/{vidply.chunk-6RQA4ROD.js.map → vidply.chunk-PRFNESYA.js.map} +0 -0
@@ -1,383 +1,387 @@
1
- /**
2
- * Keyboard Accessibility Manager
3
- */
4
-
5
- import { i18n } from '../i18n/i18n.js';
6
- import { debounce } from '../utils/PerformanceUtils.js';
7
- import type { Player } from '../core/Player.js';
8
- import type { KeyboardShortcuts } from '../types/options.js';
9
-
10
- export class KeyboardManager {
11
- player: Player;
12
- shortcuts: KeyboardShortcuts;
13
- private announcer: HTMLElement | null = null;
14
- // Announcements are driven by player state-change events so they fire for
15
- // mouse/touch control use too, not only keyboard shortcuts (WCAG 4.1.3).
16
- // Gated until 'ready' so initial volume/mute/source setup stays silent.
17
- private _announceReady = false;
18
- private _prevMuted: boolean;
19
- private _stateAnnouncers: Array<{ event: string; handler: (...args: unknown[]) => void }> = [];
20
- private _announceVolume: () => void;
21
-
22
- constructor(player: Player) {
23
- this.player = player;
24
- this.shortcuts = player.options.keyboardShortcuts;
25
- this._prevMuted = player.state.muted;
26
- // Volume slider drags emit a stream of volumechange events; debounce the
27
- // level announcement so AT hears the final value instead of every step.
28
- this._announceVolume = debounce(() => {
29
- const percent = Math.round(this.player.state.volume * 100);
30
- this.announce(i18n.t('player.volumePercent', { percent }));
31
- }, 500);
32
-
33
- this.init();
34
- }
35
-
36
- init() {
37
- this.attachEvents();
38
- this.attachStateAnnouncements();
39
- }
40
-
41
- /**
42
- * Subscribe to player state-change events so play/pause, mute, volume,
43
- * captions, fullscreen and speed changes are announced to assistive tech
44
- * regardless of whether the user used the keyboard, mouse or touch
45
- * (WCAG 4.1.3 Status Messages).
46
- */
47
- attachStateAnnouncements(): void {
48
- // Defensive: the player exposes an EventEmitter `on`/`off` API at runtime.
49
- if (typeof this.player.on !== 'function') return;
50
-
51
- const onReady = () => { this._announceReady = true; };
52
- this.player.on('ready', onReady);
53
-
54
- const register = (event: string, handler: () => void) => {
55
- const wrapped = () => { if (this._announceReady) handler(); };
56
- this.player.on(event as never, wrapped as never);
57
- this._stateAnnouncers.push({ event, handler: wrapped as (...args: unknown[]) => void });
58
- };
59
-
60
- // Track the 'ready' handler too so destroy() detaches it.
61
- this._stateAnnouncers.push({ event: 'ready', handler: onReady as (...args: unknown[]) => void });
62
-
63
- register('play', () => this.announce(i18n.t('player.playing')));
64
- register('pause', () => this.announce(i18n.t('player.paused')));
65
- register('captionsenabled', () => this.announce(i18n.t('player.captionsOn')));
66
- register('captionsdisabled', () => this.announce(i18n.t('player.captionsOff')));
67
- register('fullscreenchange', () => {
68
- this.announce(this.player.state.fullscreen
69
- ? i18n.t('player.fullscreen')
70
- : i18n.t('player.exitFullscreen'));
71
- });
72
- register('ratechange', () => {
73
- const rate = this.player.state.playbackSpeed;
74
- this.announce(i18n.t('player.speedRate', { rate: String(rate) }));
75
- });
76
- register('volumechange', () => {
77
- // Mute toggles are discrete and announced immediately; continuous
78
- // level changes are debounced via _announceVolume.
79
- if (this.player.state.muted !== this._prevMuted) {
80
- this._prevMuted = this.player.state.muted;
81
- this.announce(this.player.state.muted
82
- ? i18n.t('player.muted')
83
- : i18n.t('player.unmuted'));
84
- return;
85
- }
86
- if (!this.player.state.muted) {
87
- this._announceVolume();
88
- }
89
- });
90
- }
91
-
92
- attachEvents() {
93
- // Listen for keyboard events on the player container using CAPTURE phase
94
- // This ensures we intercept events before they reach child elements
95
- this.player.container.addEventListener('keydown', (e: KeyboardEvent) => {
96
- this.handleKeydown(e);
97
- }, true); // Use capture phase
98
-
99
- // Make player container focusable
100
- if (!this.player.container.hasAttribute('tabindex')) {
101
- this.player.container.setAttribute('tabindex', '0');
102
- }
103
- }
104
-
105
- handleKeydown(e: KeyboardEvent) {
106
- // Don't handle if target is an input element
107
- const target = e.target as HTMLElement;
108
- if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT') {
109
- return;
110
- }
111
-
112
- // Don't handle if focus is inside a menu (let menu handle its own keyboard navigation)
113
- const activeElement = document.activeElement;
114
- if (activeElement) {
115
- const menu = activeElement.closest('.vidply-menu, [role="menu"]');
116
- if (menu) {
117
- return; // Let the menu handle keyboard events
118
- }
119
-
120
- // Don't handle if focus is on a playlist button (let playlist handle navigation)
121
- const playlistButton = activeElement.closest('.vidply-playlist-item-button');
122
- if (playlistButton) {
123
- return; // Let the playlist handle keyboard events
124
- }
125
-
126
- // Don't steal arrow keys when the sign-language overlay is in keyboard drag/resize mode.
127
- // DraggableResizable listens on the overlay itself, but we run in CAPTURE phase, so we must opt out here.
128
- const signWrapper = activeElement.closest('.vidply-sign-language-wrapper');
129
- if (signWrapper) {
130
- const draggable = this.player.signLanguageManager?.draggable;
131
- if (draggable?.keyboardDragMode || draggable?.keyboardResizeMode) {
132
- return;
133
- }
134
- }
135
-
136
- // Same idea for the transcript floating window (it also uses DraggableResizable).
137
- const transcriptWindow = activeElement.closest('.vidply-transcript-window');
138
- if (transcriptWindow) {
139
- const draggable = this.player.transcriptManager?.draggableResizable;
140
- if (draggable?.keyboardDragMode || draggable?.keyboardResizeMode) {
141
- return;
142
- }
143
- }
144
- }
145
-
146
- const key = e.key;
147
- let handled = false;
148
-
149
- // Special handling for ESC key - exit fullscreen (especially for iOS pseudo-fullscreen)
150
- if (key === 'Escape' && this.player.state.fullscreen) {
151
- this.player.exitFullscreen();
152
- e.preventDefault();
153
- e.stopPropagation();
154
- return;
155
- }
156
-
157
- // Check each shortcut category
158
- for (const [action, keys] of Object.entries(this.shortcuts)) {
159
- if ((keys as string[]).includes(key)) {
160
- handled = this.executeAction(action, e);
161
- if (handled) {
162
- e.preventDefault();
163
- e.stopPropagation();
164
- // Announcements are handled centrally by attachStateAnnouncements()
165
- // via player events, so they fire for pointer interactions too.
166
- break;
167
- }
168
- }
169
- }
170
-
171
- // Log unhandled keys for debugging (in development)
172
- if (!handled && this.player.options.debug) {
173
- console.log('[VidPly] Unhandled key:', e.key, 'code:', e.code, 'shiftKey:', e.shiftKey);
174
- }
175
- }
176
-
177
- executeAction(action: string, _event: KeyboardEvent): boolean {
178
- switch (action) {
179
- case 'play-pause':
180
- this.player.toggle();
181
- return true;
182
-
183
- case 'volume-up':
184
- this.player.setVolume(Math.min(1, this.player.state.volume + 0.1));
185
- return true;
186
-
187
- case 'volume-down':
188
- this.player.setVolume(Math.max(0, this.player.state.volume - 0.1));
189
- return true;
190
-
191
- case 'seek-forward':
192
- this.player.seekForward();
193
- return true;
194
-
195
- case 'seek-backward':
196
- this.player.seekBackward();
197
- return true;
198
-
199
- case 'mute':
200
- this.player.toggleMute();
201
- return true;
202
-
203
- case 'fullscreen':
204
- this.player.toggleFullscreen();
205
- return true;
206
-
207
- case 'captions':
208
- if (this.player.captionManager && this.player.captionManager.tracks.length > 1) {
209
- const captionsButton = this.player.controlBar?.controls.captions;
210
- if (captionsButton && this.player.controlBar) {
211
- this.player.controlBar.showCaptionsMenu(captionsButton);
212
- } else {
213
- this.player.toggleCaptions();
214
- }
215
- } else {
216
- this.player.toggleCaptions();
217
- }
218
- return true;
219
-
220
- case 'caption-style-menu':
221
- // Open caption style menu
222
- if (this.player.controlBar && this.player.controlBar.controls.captionStyle) {
223
- this.player.controlBar.showCaptionStyleMenu(this.player.controlBar.controls.captionStyle);
224
- return true;
225
- }
226
- return false;
227
-
228
- case 'speed-up':
229
- this.player.setPlaybackSpeed(
230
- Math.min(2, this.player.state.playbackSpeed + 0.25)
231
- );
232
- return true;
233
-
234
- case 'speed-down':
235
- this.player.setPlaybackSpeed(
236
- Math.max(0.25, this.player.state.playbackSpeed - 0.25)
237
- );
238
- return true;
239
-
240
- case 'speed-menu':
241
- // Open speed menu
242
- if (this.player.controlBar && this.player.controlBar.controls.speed) {
243
- this.player.controlBar.showSpeedMenu(this.player.controlBar.controls.speed);
244
- return true;
245
- }
246
- return false;
247
-
248
- case 'quality-menu':
249
- // Open quality menu
250
- if (this.player.controlBar && this.player.controlBar.controls.quality) {
251
- this.player.controlBar.showQualityMenu(this.player.controlBar.controls.quality);
252
- return true;
253
- }
254
- return false;
255
-
256
- case 'chapters-menu':
257
- // Open chapters menu
258
- if (this.player.controlBar && this.player.controlBar.controls.chapters) {
259
- this.player.controlBar.showChaptersMenu(this.player.controlBar.controls.chapters);
260
- return true;
261
- }
262
- return false;
263
-
264
- case 'transcript-toggle':
265
- // Toggle transcript
266
- if (this.player.transcriptManager) {
267
- this.player.transcriptManager.toggleTranscript();
268
- return true;
269
- }
270
- return false;
271
-
272
- default:
273
- return false;
274
- }
275
- }
276
-
277
- announceAction(action: string): void {
278
- if (!this.player.options.screenReaderAnnouncements) return;
279
-
280
- // Every announcement now goes through i18n.t so the screen-reader
281
- // hears the player's configured language instead of always English.
282
- // Missing keys fall back to the same English literal used previously,
283
- // so behavior is unchanged for unconfigured locales.
284
- let message = '';
285
-
286
- switch (action) {
287
- case 'play-pause':
288
- message = this.player.state.playing
289
- ? i18n.t('player.playing')
290
- : i18n.t('player.paused');
291
- break;
292
- case 'volume-up':
293
- case 'volume-down': {
294
- const percent = Math.round(this.player.state.volume * 100);
295
- message = i18n.t('player.volumePercent', { percent });
296
- break;
297
- }
298
- case 'mute':
299
- message = this.player.state.muted
300
- ? i18n.t('player.muted')
301
- : i18n.t('player.unmuted');
302
- break;
303
- case 'fullscreen':
304
- message = this.player.state.fullscreen
305
- ? i18n.t('player.fullscreen')
306
- : i18n.t('player.exitFullscreen');
307
- break;
308
- case 'captions':
309
- message = this.player.state.captionsEnabled
310
- ? i18n.t('player.captionsOn')
311
- : i18n.t('player.captionsOff');
312
- break;
313
- case 'speed-up':
314
- case 'speed-down': {
315
- const rate = this.player.state.playbackSpeed;
316
- message = i18n.t('player.speedRate', { rate: String(rate) });
317
- break;
318
- }
319
- }
320
-
321
- if (message) {
322
- this.announce(message);
323
- }
324
- }
325
-
326
- /**
327
- * Live-region announcer scoped to *this* player instance so multi-player
328
- * pages do not cross-talk through a shared `#vidply-announcer` id. The
329
- * region is appended to `document.body` so it is reachable regardless of
330
- * the embedding container's stacking / overflow context.
331
- */
332
- announce(message: string, priority: 'polite' | 'assertive' = 'polite'): void {
333
- if (!this.announcer) {
334
- const id = `vidply-announcer-${this.player.instanceId}`;
335
- this.announcer = document.createElement('div');
336
- this.announcer.id = id;
337
- this.announcer.className = 'vidply-sr-only';
338
- this.announcer.setAttribute('aria-live', priority);
339
- this.announcer.setAttribute('aria-atomic', 'true');
340
- this.announcer.style.cssText = `
341
- position: absolute;
342
- left: -10000px;
343
- width: 1px;
344
- height: 1px;
345
- overflow: hidden;
346
- `;
347
- document.body.appendChild(this.announcer);
348
- } else {
349
- this.announcer.setAttribute('aria-live', priority);
350
- }
351
-
352
- this.announcer.textContent = '';
353
- const announcer = this.announcer;
354
- setTimeout(() => {
355
- if (announcer) announcer.textContent = message;
356
- }, 100);
357
- }
358
-
359
- updateShortcut(action: string, keys: string[]): void {
360
- if (Array.isArray(keys)) {
361
- (this.shortcuts as Record<string, string[]>)[action] = keys;
362
- }
363
- }
364
-
365
- destroy(): void {
366
- // Detach the player state-change listeners that drive announcements.
367
- if (typeof this.player.off === 'function') {
368
- for (const { event, handler } of this._stateAnnouncers) {
369
- this.player.off(event as never, handler as never);
370
- }
371
- }
372
- this._stateAnnouncers = [];
373
-
374
- // Container-attached keydown listener is removed when the container is
375
- // destroyed by Player.destroy(). The live-region announcer, however,
376
- // lives on `document.body` so we must remove it explicitly.
377
- if (this.announcer && this.announcer.parentNode) {
378
- this.announcer.parentNode.removeChild(this.announcer);
379
- }
380
- this.announcer = null;
381
- }
382
- }
383
-
1
+ /**
2
+ * Keyboard Accessibility Manager
3
+ */
4
+
5
+ import { i18n } from '../i18n/i18n.js';
6
+ import { debounce } from '../utils/PerformanceUtils.js';
7
+ import type { Player } from '../core/Player.js';
8
+ import type { KeyboardShortcuts } from '../types/options.js';
9
+
10
+ export class KeyboardManager {
11
+ player: Player;
12
+ shortcuts: KeyboardShortcuts;
13
+ private announcer: HTMLElement | null = null;
14
+ // Announcements are driven by player state-change events so they fire for
15
+ // mouse/touch control use too, not only keyboard shortcuts (WCAG 4.1.3).
16
+ // Gated until 'ready' so initial volume/mute/source setup stays silent.
17
+ private _announceReady = false;
18
+ private _prevMuted: boolean;
19
+ private _stateAnnouncers: Array<{ event: string; handler: (...args: unknown[]) => void }> = [];
20
+ private _announceVolume: () => void;
21
+
22
+ constructor(player: Player) {
23
+ this.player = player;
24
+ this.shortcuts = player.options.keyboardShortcuts;
25
+ this._prevMuted = player.state.muted;
26
+ // Volume slider drags emit a stream of volumechange events; debounce the
27
+ // level announcement so AT hears the final value instead of every step.
28
+ this._announceVolume = debounce(() => {
29
+ const percent = Math.round(this.player.state.volume * 100);
30
+ this.announce(i18n.t('player.volumePercent', { percent }));
31
+ }, 500);
32
+
33
+ this.init();
34
+ }
35
+
36
+ init() {
37
+ this.attachEvents();
38
+ this.attachStateAnnouncements();
39
+ }
40
+
41
+ /**
42
+ * Subscribe to player state-change events so play/pause, mute, volume,
43
+ * captions, fullscreen and speed changes are announced to assistive tech
44
+ * regardless of whether the user used the keyboard, mouse or touch
45
+ * (WCAG 4.1.3 Status Messages).
46
+ */
47
+ attachStateAnnouncements(): void {
48
+ // Defensive: the player exposes an EventEmitter `on`/`off` API at runtime.
49
+ if (typeof this.player.on !== 'function') return;
50
+
51
+ const onReady = () => { this._announceReady = true; };
52
+ this.player.on('ready', onReady);
53
+
54
+ const register = (event: string, handler: () => void) => {
55
+ const wrapped = () => { if (this._announceReady) handler(); };
56
+ this.player.on(event as never, wrapped as never);
57
+ this._stateAnnouncers.push({ event, handler: wrapped as (...args: unknown[]) => void });
58
+ };
59
+
60
+ // Track the 'ready' handler too so destroy() detaches it.
61
+ this._stateAnnouncers.push({ event: 'ready', handler: onReady as (...args: unknown[]) => void });
62
+
63
+ register('play', () => this.announce(i18n.t('player.playing')));
64
+ register('pause', () => this.announce(i18n.t('player.paused')));
65
+ register('captionsenabled', () => this.announce(i18n.t('player.captionsOn')));
66
+ register('captionsdisabled', () => this.announce(i18n.t('player.captionsOff')));
67
+ register('fullscreenchange', () => {
68
+ this.announce(this.player.state.fullscreen
69
+ ? i18n.t('player.fullscreen')
70
+ : i18n.t('player.exitFullscreen'));
71
+ });
72
+ register('ratechange', () => {
73
+ const rate = this.player.state.playbackSpeed;
74
+ this.announce(i18n.t('player.speedRate', { rate: String(rate) }));
75
+ });
76
+ register('volumechange', () => {
77
+ // Mute toggles are discrete and announced immediately; continuous
78
+ // level changes are debounced via _announceVolume.
79
+ if (this.player.state.muted !== this._prevMuted) {
80
+ this._prevMuted = this.player.state.muted;
81
+ this.announce(this.player.state.muted
82
+ ? i18n.t('player.muted')
83
+ : i18n.t('player.unmuted'));
84
+ return;
85
+ }
86
+ if (!this.player.state.muted) {
87
+ this._announceVolume();
88
+ }
89
+ });
90
+ }
91
+
92
+ attachEvents() {
93
+ // Listen for keyboard events on the player container using CAPTURE phase
94
+ // This ensures we intercept events before they reach child elements
95
+ this.player.container.addEventListener('keydown', (e: KeyboardEvent) => {
96
+ this.handleKeydown(e);
97
+ }, true); // Use capture phase
98
+
99
+ // Make player container focusable
100
+ if (!this.player.container.hasAttribute('tabindex')) {
101
+ this.player.container.setAttribute('tabindex', '0');
102
+ }
103
+ }
104
+
105
+ handleKeydown(e: KeyboardEvent) {
106
+ // Don't handle if target is an input element
107
+ const target = e.target as HTMLElement;
108
+ if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT') {
109
+ return;
110
+ }
111
+
112
+ // Don't handle if focus is inside a menu (let menu handle its own keyboard navigation)
113
+ const activeElement = document.activeElement;
114
+ if (activeElement) {
115
+ const menu = activeElement.closest('.vidply-menu, [role="menu"]');
116
+ if (menu) {
117
+ return; // Let the menu handle keyboard events
118
+ }
119
+
120
+ // Don't handle if focus is on a playlist button (let playlist handle navigation)
121
+ const playlistButton = activeElement.closest('.vidply-playlist-item-button');
122
+ if (playlistButton) {
123
+ return; // Let the playlist handle keyboard events
124
+ }
125
+
126
+ // Don't steal arrow keys when the sign-language overlay is in keyboard drag/resize mode.
127
+ // DraggableResizable listens on the overlay itself, but we run in CAPTURE phase, so we must opt out here.
128
+ const signWrapper = activeElement.closest('.vidply-sign-language-wrapper');
129
+ if (signWrapper) {
130
+ const draggable = this.player.signLanguageManager?.draggable;
131
+ if (draggable?.keyboardDragMode || draggable?.keyboardResizeMode) {
132
+ return;
133
+ }
134
+ }
135
+
136
+ // Same idea for the transcript floating window (it also uses DraggableResizable).
137
+ const transcriptWindow = activeElement.closest('.vidply-transcript-window');
138
+ if (transcriptWindow) {
139
+ const draggable = this.player.transcriptManager?.draggableResizable;
140
+ if (draggable?.keyboardDragMode || draggable?.keyboardResizeMode) {
141
+ return;
142
+ }
143
+ }
144
+ }
145
+
146
+ const key = e.key;
147
+ let handled = false;
148
+
149
+ // Special handling for ESC key - exit fullscreen (especially for iOS pseudo-fullscreen)
150
+ if (key === 'Escape' && this.player.state.fullscreen) {
151
+ this.player.exitFullscreen();
152
+ e.preventDefault();
153
+ e.stopPropagation();
154
+ return;
155
+ }
156
+
157
+ // Check each shortcut category
158
+ for (const [action, keys] of Object.entries(this.shortcuts)) {
159
+ if ((keys as string[]).includes(key)) {
160
+ handled = this.executeAction(action, e);
161
+ if (handled) {
162
+ e.preventDefault();
163
+ e.stopPropagation();
164
+ // Announcements are handled centrally by attachStateAnnouncements()
165
+ // via player events, so they fire for pointer interactions too.
166
+ break;
167
+ }
168
+ }
169
+ }
170
+
171
+ // Log unhandled keys for debugging (in development)
172
+ if (!handled && this.player.options.debug) {
173
+ console.log('[VidPly] Unhandled key:', e.key, 'code:', e.code, 'shiftKey:', e.shiftKey);
174
+ }
175
+ }
176
+
177
+ executeAction(action: string, _event: KeyboardEvent): boolean {
178
+ switch (action) {
179
+ case 'play-pause':
180
+ this.player.toggle();
181
+ return true;
182
+
183
+ case 'volume-up':
184
+ this.player.setVolume(Math.min(1, this.player.state.volume + 0.1));
185
+ return true;
186
+
187
+ case 'volume-down':
188
+ this.player.setVolume(Math.max(0, this.player.state.volume - 0.1));
189
+ return true;
190
+
191
+ case 'seek-forward':
192
+ this.player.seekForward();
193
+ return true;
194
+
195
+ case 'seek-backward':
196
+ this.player.seekBackward();
197
+ return true;
198
+
199
+ case 'mute':
200
+ this.player.toggleMute();
201
+ return true;
202
+
203
+ case 'fullscreen':
204
+ this.player.toggleFullscreen();
205
+ return true;
206
+
207
+ case 'captions':
208
+ if (this.player.captionManager && this.player.captionManager.tracks.length > 1) {
209
+ const captionsButton = this.player.controlBar?.controls.captions;
210
+ if (captionsButton && this.player.controlBar) {
211
+ this.player.controlBar.showCaptionsMenu(captionsButton);
212
+ } else {
213
+ this.player.toggleCaptions();
214
+ }
215
+ } else {
216
+ this.player.toggleCaptions();
217
+ }
218
+ return true;
219
+
220
+ case 'caption-style-menu':
221
+ // Open caption style menu
222
+ if (this.player.controlBar && this.player.controlBar.controls.captionStyle) {
223
+ this.player.controlBar.showCaptionStyleMenu(this.player.controlBar.controls.captionStyle);
224
+ return true;
225
+ }
226
+ return false;
227
+
228
+ case 'speed-up':
229
+ this.player.setPlaybackSpeed(
230
+ Math.min(2, this.player.state.playbackSpeed + 0.25)
231
+ );
232
+ return true;
233
+
234
+ case 'speed-down':
235
+ this.player.setPlaybackSpeed(
236
+ Math.max(0.25, this.player.state.playbackSpeed - 0.25)
237
+ );
238
+ return true;
239
+
240
+ case 'speed-menu':
241
+ // Open speed menu
242
+ if (this.player.controlBar && this.player.controlBar.controls.speed) {
243
+ this.player.controlBar.showSpeedMenu(this.player.controlBar.controls.speed);
244
+ return true;
245
+ }
246
+ return false;
247
+
248
+ case 'quality-menu':
249
+ // Open quality menu
250
+ if (this.player.controlBar && this.player.controlBar.controls.quality) {
251
+ this.player.controlBar.showQualityMenu(this.player.controlBar.controls.quality);
252
+ return true;
253
+ }
254
+ return false;
255
+
256
+ case 'chapters-menu':
257
+ // Open chapters menu
258
+ if (this.player.controlBar && this.player.controlBar.controls.chapters) {
259
+ this.player.controlBar.showChaptersMenu(this.player.controlBar.controls.chapters);
260
+ return true;
261
+ }
262
+ return false;
263
+
264
+ case 'transcript-toggle':
265
+ // Toggle transcript
266
+ if (this.player.transcriptManager) {
267
+ this.player.transcriptManager.toggleTranscript();
268
+ return true;
269
+ }
270
+ return false;
271
+
272
+ case 'help':
273
+ this.player.toggleKeyboardHelp();
274
+ return true;
275
+
276
+ default:
277
+ return false;
278
+ }
279
+ }
280
+
281
+ announceAction(action: string): void {
282
+ if (!this.player.options.screenReaderAnnouncements) return;
283
+
284
+ // Every announcement now goes through i18n.t so the screen-reader
285
+ // hears the player's configured language instead of always English.
286
+ // Missing keys fall back to the same English literal used previously,
287
+ // so behavior is unchanged for unconfigured locales.
288
+ let message = '';
289
+
290
+ switch (action) {
291
+ case 'play-pause':
292
+ message = this.player.state.playing
293
+ ? i18n.t('player.playing')
294
+ : i18n.t('player.paused');
295
+ break;
296
+ case 'volume-up':
297
+ case 'volume-down': {
298
+ const percent = Math.round(this.player.state.volume * 100);
299
+ message = i18n.t('player.volumePercent', { percent });
300
+ break;
301
+ }
302
+ case 'mute':
303
+ message = this.player.state.muted
304
+ ? i18n.t('player.muted')
305
+ : i18n.t('player.unmuted');
306
+ break;
307
+ case 'fullscreen':
308
+ message = this.player.state.fullscreen
309
+ ? i18n.t('player.fullscreen')
310
+ : i18n.t('player.exitFullscreen');
311
+ break;
312
+ case 'captions':
313
+ message = this.player.state.captionsEnabled
314
+ ? i18n.t('player.captionsOn')
315
+ : i18n.t('player.captionsOff');
316
+ break;
317
+ case 'speed-up':
318
+ case 'speed-down': {
319
+ const rate = this.player.state.playbackSpeed;
320
+ message = i18n.t('player.speedRate', { rate: String(rate) });
321
+ break;
322
+ }
323
+ }
324
+
325
+ if (message) {
326
+ this.announce(message);
327
+ }
328
+ }
329
+
330
+ /**
331
+ * Live-region announcer scoped to *this* player instance so multi-player
332
+ * pages do not cross-talk through a shared `#vidply-announcer` id. The
333
+ * region is appended to `document.body` so it is reachable regardless of
334
+ * the embedding container's stacking / overflow context.
335
+ */
336
+ announce(message: string, priority: 'polite' | 'assertive' = 'polite'): void {
337
+ if (!this.announcer) {
338
+ const id = `vidply-announcer-${this.player.instanceId}`;
339
+ this.announcer = document.createElement('div');
340
+ this.announcer.id = id;
341
+ this.announcer.className = 'vidply-sr-only';
342
+ this.announcer.setAttribute('aria-live', priority);
343
+ this.announcer.setAttribute('aria-atomic', 'true');
344
+ this.announcer.style.cssText = `
345
+ position: absolute;
346
+ left: -10000px;
347
+ width: 1px;
348
+ height: 1px;
349
+ overflow: hidden;
350
+ `;
351
+ document.body.appendChild(this.announcer);
352
+ } else {
353
+ this.announcer.setAttribute('aria-live', priority);
354
+ }
355
+
356
+ this.announcer.textContent = '';
357
+ const announcer = this.announcer;
358
+ setTimeout(() => {
359
+ if (announcer) announcer.textContent = message;
360
+ }, 100);
361
+ }
362
+
363
+ updateShortcut(action: string, keys: string[]): void {
364
+ if (Array.isArray(keys)) {
365
+ (this.shortcuts as Record<string, string[]>)[action] = keys;
366
+ }
367
+ }
368
+
369
+ destroy(): void {
370
+ // Detach the player state-change listeners that drive announcements.
371
+ if (typeof this.player.off === 'function') {
372
+ for (const { event, handler } of this._stateAnnouncers) {
373
+ this.player.off(event as never, handler as never);
374
+ }
375
+ }
376
+ this._stateAnnouncers = [];
377
+
378
+ // Container-attached keydown listener is removed when the container is
379
+ // destroyed by Player.destroy(). The live-region announcer, however,
380
+ // lives on `document.body` so we must remove it explicitly.
381
+ if (this.announcer && this.announcer.parentNode) {
382
+ this.announcer.parentNode.removeChild(this.announcer);
383
+ }
384
+ this.announcer = null;
385
+ }
386
+ }
387
+