vidply 1.2.0 → 1.2.1

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