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.
- package/README.md +946 -946
- package/dist/dev/{vidply.AudioDescriptionManager-R47Q4YR4.js → vidply.AudioDescriptionManager-6RFVF27Z.js} +275 -19
- package/dist/dev/vidply.AudioDescriptionManager-6RFVF27Z.js.map +7 -0
- package/dist/dev/{vidply.CaptionStyleMenu-7XHRBWUM.js → vidply.CaptionStyleMenu-R2SCQ6VP.js} +4 -4
- package/dist/dev/{vidply.DASHRenderer-PWEMC3BT.js → vidply.DASHRenderer-EOVEW73H.js} +3 -3
- package/dist/dev/vidply.DASHRenderer-EOVEW73H.js.map +7 -0
- package/dist/dev/{vidply.FloatingPlayerManager-PKNNNAS2.js → vidply.FloatingPlayerManager-V5XQWS75.js} +5 -5
- package/dist/dev/vidply.FloatingPlayerManager-V5XQWS75.js.map +7 -0
- package/dist/dev/{vidply.HLSRenderer-E7RQ74HI.js → vidply.HLSRenderer-ZBEFVH5W.js} +4 -4
- package/dist/dev/vidply.HLSRenderer-ZBEFVH5W.js.map +7 -0
- package/dist/dev/vidply.HTML5Renderer-P7ICKYU4.js +12 -0
- package/dist/dev/{vidply.SignLanguageManager-FET5FQZ7.js → vidply.SignLanguageManager-7IAJOVF6.js} +8 -8
- package/dist/dev/vidply.SignLanguageManager-7IAJOVF6.js.map +7 -0
- package/dist/dev/{vidply.SoundCloudRenderer-RVSJZODO.js → vidply.SoundCloudRenderer-OA7QQYGN.js} +3 -3
- package/dist/dev/vidply.SoundCloudRenderer-OA7QQYGN.js.map +7 -0
- package/dist/dev/{vidply.TranscriptManager-5GXOQCLX.js → vidply.TranscriptManager-BPM3T23L.js} +10 -10
- package/dist/dev/vidply.TranscriptManager-BPM3T23L.js.map +7 -0
- package/dist/dev/{vidply.VimeoRenderer-ER2LSBBW.js → vidply.VimeoRenderer-BRHFYPNJ.js} +3 -3
- package/dist/dev/vidply.VimeoRenderer-BRHFYPNJ.js.map +7 -0
- package/dist/dev/{vidply.YouTubeRenderer-NFTOWJAB.js → vidply.YouTubeRenderer-HRTN4EJ5.js} +3 -3
- package/dist/dev/vidply.YouTubeRenderer-HRTN4EJ5.js.map +7 -0
- package/dist/dev/{vidply.chunk-6RQA4ROD.js → vidply.chunk-5IK3IC5V.js} +2 -2
- package/dist/dev/{vidply.chunk-JJ47NO24.js → vidply.chunk-C55OBT5H.js} +2 -2
- package/dist/dev/vidply.chunk-C55OBT5H.js.map +7 -0
- package/dist/dev/{vidply.chunk-DZSDO3B7.js → vidply.chunk-HOYEMJSD.js} +2 -2
- package/dist/dev/{vidply.chunk-VMSQAZM7.js → vidply.chunk-JAKZNGOR.js} +2 -2
- package/dist/dev/{vidply.chunk-64WXMTGL.js → vidply.chunk-LDVFAFGC.js} +5 -5
- package/dist/dev/vidply.chunk-LDVFAFGC.js.map +7 -0
- package/dist/dev/{vidply.chunk-RZWZGDV7.js → vidply.chunk-NSMAZCMB.js} +6 -6
- package/dist/dev/vidply.chunk-NSMAZCMB.js.map +7 -0
- package/dist/dev/{vidply.chunk-KI7C3QVL.js → vidply.chunk-RQG3MA7W.js} +2 -2
- package/dist/dev/vidply.chunk-RQG3MA7W.js.map +7 -0
- package/dist/dev/{vidply.chunk-BNFIUL2Z.js → vidply.chunk-UHMVDBDB.js} +3 -3
- package/dist/dev/vidply.chunk-UHMVDBDB.js.map +7 -0
- package/dist/dev/{vidply.chunk-NQPURMQY.js → vidply.chunk-W5BR65JY.js} +6 -6
- package/dist/dev/vidply.chunk-W5BR65JY.js.map +7 -0
- package/dist/dev/{vidply.chunk-W25HUCHS.js → vidply.chunk-XAK3DPTO.js} +2 -2
- package/dist/dev/vidply.chunk-XAK3DPTO.js.map +7 -0
- package/dist/dev/{vidply.chunk-SK4V2DAU.js → vidply.chunk-YVCYQZJI.js} +3 -3
- package/dist/dev/{vidply.de-4YAT7FFM.js → vidply.de-5W5LBFSJ.js} +2 -2
- package/dist/dev/vidply.de-5W5LBFSJ.js.map +7 -0
- package/dist/dev/{vidply.es-LEWIRUBH.js → vidply.es-SHDAT6L6.js} +2 -2
- package/dist/dev/vidply.es-SHDAT6L6.js.map +7 -0
- package/dist/dev/vidply.esm.js +73 -34
- package/dist/dev/vidply.esm.js.map +2 -2
- package/dist/dev/{vidply.fr-Q4GV2NOE.js → vidply.fr-2JANOMNB.js} +2 -2
- package/dist/dev/vidply.fr-2JANOMNB.js.map +7 -0
- package/dist/dev/{vidply.ja-S6ITA6WU.js → vidply.ja-WWMSTEJH.js} +2 -2
- package/dist/dev/vidply.ja-WWMSTEJH.js.map +7 -0
- package/dist/legacy/vidply.js +345 -32
- package/dist/legacy/vidply.js.map +3 -3
- package/dist/legacy/vidply.min.js +2 -2
- package/dist/legacy/vidply.min.meta.json +66 -47
- package/dist/prod/vidply.AudioDescriptionManager-YTDVDQBO.min.js +6 -0
- package/dist/prod/{vidply.CaptionStyleMenu-M3KVQWB5.min.js → vidply.CaptionStyleMenu-2AFF3HF2.min.js} +2 -2
- package/dist/prod/{vidply.DASHRenderer-DN3S27AV.min.js → vidply.DASHRenderer-URDOTOKS.min.js} +2 -2
- package/dist/prod/{vidply.FloatingPlayerManager-SKAZ4J43.min.js → vidply.FloatingPlayerManager-QUBFK7CQ.min.js} +2 -2
- package/dist/prod/{vidply.HLSRenderer-62HH4YTK.min.js → vidply.HLSRenderer-46KNUVAB.min.js} +2 -2
- package/dist/prod/vidply.HTML5Renderer-7TQDMIGY.min.js +6 -0
- package/dist/prod/{vidply.SignLanguageManager-V3OBTSSF.min.js → vidply.SignLanguageManager-H4C36EVL.min.js} +2 -2
- package/dist/prod/{vidply.SoundCloudRenderer-CXRVZIAJ.min.js → vidply.SoundCloudRenderer-OYSWMW7D.min.js} +2 -2
- package/dist/prod/{vidply.TranscriptManager-SYM3DFUY.min.js → vidply.TranscriptManager-BA3DAS63.min.js} +2 -2
- package/dist/prod/{vidply.VimeoRenderer-72TWRYFQ.min.js → vidply.VimeoRenderer-NF3COTHU.min.js} +2 -2
- package/dist/prod/{vidply.YouTubeRenderer-UMHEV3KD.min.js → vidply.YouTubeRenderer-UCQKYIJ4.min.js} +2 -2
- package/dist/prod/{vidply.chunk-Z3YDSA7O.min.js → vidply.chunk-2HNCP64N.min.js} +1 -1
- package/dist/prod/{vidply.chunk-EKEYJWMG.min.js → vidply.chunk-6ECBIYVI.min.js} +2 -2
- package/dist/prod/{vidply.chunk-YEENJS3Q.min.js → vidply.chunk-7S5AFTST.min.js} +2 -2
- package/dist/prod/{vidply.chunk-P4UXOHWA.min.js → vidply.chunk-EUOA2DJK.min.js} +1 -1
- package/dist/prod/{vidply.chunk-2TB6MUHO.min.js → vidply.chunk-G6KGXNEW.min.js} +1 -1
- package/dist/prod/{vidply.chunk-ABPXVDLZ.min.js → vidply.chunk-JPMAK3NS.min.js} +1 -1
- package/dist/prod/{vidply.chunk-P3PQBY7N.min.js → vidply.chunk-L4O44K63.min.js} +1 -1
- package/dist/prod/{vidply.chunk-WX3MVEAL.min.js → vidply.chunk-M76ABMKB.min.js} +1 -1
- package/dist/prod/{vidply.chunk-TM6AXJVD.min.js → vidply.chunk-WRVDVD33.min.js} +2 -2
- package/dist/prod/{vidply.chunk-RI3X7WHK.min.js → vidply.chunk-X2FQEQYR.min.js} +2 -2
- package/dist/prod/{vidply.chunk-UK7YGOB5.min.js → vidply.chunk-XXUH7ADU.min.js} +2 -2
- package/dist/prod/{vidply.de-BCHXRQ53.min.js → vidply.de-JMCATTKZ.min.js} +1 -1
- package/dist/prod/{vidply.es-PICAKMZD.min.js → vidply.es-YVG37V7Z.min.js} +1 -1
- package/dist/prod/vidply.esm.min.js +2 -2
- package/dist/prod/{vidply.fr-ZILI7CDR.min.js → vidply.fr-OGKGN2R4.min.js} +1 -1
- package/dist/prod/{vidply.ja-5WQ4OK4J.min.js → vidply.ja-Z6DS7QLS.min.js} +1 -1
- package/dist/vidply.css +5796 -5796
- package/dist/vidply.esm.min.meta.json +148 -135
- package/package.json +7 -7
- package/src/controls/CaptionManager.ts +598 -598
- package/src/controls/ControlBar.ts +4159 -4159
- package/src/controls/KeyboardManager.ts +383 -383
- package/src/controls/SettingsDialog.ts +488 -488
- package/src/controls/TranscriptManager.ts +2426 -2426
- package/src/core/AudioDescriptionManager.ts +863 -774
- package/src/core/DescriptionSpeechManager.ts +232 -0
- package/src/core/FloatingPlayerManager.ts +674 -674
- package/src/core/LazyInit.ts +80 -80
- package/src/core/MetadataAlertsManager.ts +514 -514
- package/src/core/Player.ts +2915 -2872
- package/src/core/PosterManager.ts +169 -169
- package/src/core/PseudoFullscreen.ts +179 -179
- package/src/core/ResponsiveManager.ts +231 -231
- package/src/core/ResumeManager.ts +236 -236
- package/src/core/SignLanguageManager.ts +1308 -1308
- package/src/core/ThemeManager.ts +157 -157
- package/src/features/PlaylistManager.ts +1838 -1827
- package/src/i18n/i18n.ts +223 -223
- package/src/i18n/languages/de.ts +217 -217
- package/src/i18n/languages/en.ts +218 -218
- package/src/i18n/languages/es.ts +214 -214
- package/src/i18n/languages/fr.ts +214 -214
- package/src/i18n/languages/ja.ts +214 -214
- package/src/icons/Icons.ts +167 -167
- package/src/index.ts +196 -193
- package/src/renderers/DASHRenderer.ts +1077 -1077
- package/src/renderers/HLSRenderer.ts +944 -944
- package/src/renderers/HTML5Renderer.ts +389 -389
- package/src/renderers/SoundCloudRenderer.ts +437 -437
- package/src/renderers/VimeoRenderer.ts +298 -298
- package/src/renderers/YouTubeRenderer.ts +290 -290
- package/src/styles/vidply.css +5796 -5796
- package/src/types/events.ts +124 -122
- package/src/types/globals.d.ts +313 -313
- package/src/types/options.ts +185 -182
- package/src/types/renderer.ts +58 -58
- package/src/utils/DOMUtils.ts +281 -281
- package/src/utils/DownloadInfo.ts +171 -171
- package/src/utils/DraggablePanel.ts +539 -539
- package/src/utils/DraggablePanelMenu.ts +195 -195
- package/src/utils/DraggableResizable.ts +997 -997
- package/src/utils/EventEmitter.ts +59 -59
- package/src/utils/FormUtils.ts +95 -95
- package/src/utils/MenuFactory.ts +483 -483
- package/src/utils/Sanitize.ts +70 -70
- package/src/utils/SettingsMenuFactory.ts +289 -289
- package/src/utils/StorageManager.ts +287 -287
- package/src/utils/TrackLabelUtils.ts +52 -52
- package/src/utils/UrlSafe.ts +69 -69
- package/src/utils/VideoFrameCapture.ts +114 -114
- package/src/utils/WindowComponents.ts +250 -250
- package/dist/dev/vidply.AudioDescriptionManager-R47Q4YR4.js.map +0 -7
- package/dist/dev/vidply.DASHRenderer-PWEMC3BT.js.map +0 -7
- package/dist/dev/vidply.FloatingPlayerManager-PKNNNAS2.js.map +0 -7
- package/dist/dev/vidply.HLSRenderer-E7RQ74HI.js.map +0 -7
- package/dist/dev/vidply.HTML5Renderer-BU4QJJJY.js +0 -12
- package/dist/dev/vidply.SignLanguageManager-FET5FQZ7.js.map +0 -7
- package/dist/dev/vidply.SoundCloudRenderer-RVSJZODO.js.map +0 -7
- package/dist/dev/vidply.TranscriptManager-5GXOQCLX.js.map +0 -7
- package/dist/dev/vidply.VimeoRenderer-ER2LSBBW.js.map +0 -7
- package/dist/dev/vidply.YouTubeRenderer-NFTOWJAB.js.map +0 -7
- package/dist/dev/vidply.chunk-64WXMTGL.js.map +0 -7
- package/dist/dev/vidply.chunk-BNFIUL2Z.js.map +0 -7
- package/dist/dev/vidply.chunk-JJ47NO24.js.map +0 -7
- package/dist/dev/vidply.chunk-KI7C3QVL.js.map +0 -7
- package/dist/dev/vidply.chunk-NQPURMQY.js.map +0 -7
- package/dist/dev/vidply.chunk-RZWZGDV7.js.map +0 -7
- package/dist/dev/vidply.chunk-W25HUCHS.js.map +0 -7
- package/dist/dev/vidply.de-4YAT7FFM.js.map +0 -7
- package/dist/dev/vidply.es-LEWIRUBH.js.map +0 -7
- package/dist/dev/vidply.fr-Q4GV2NOE.js.map +0 -7
- package/dist/dev/vidply.ja-S6ITA6WU.js.map +0 -7
- package/dist/prod/vidply.AudioDescriptionManager-RHLIJW5C.min.js +0 -6
- package/dist/prod/vidply.HTML5Renderer-IU5T3WEW.min.js +0 -6
- /package/dist/dev/{vidply.CaptionStyleMenu-7XHRBWUM.js.map → vidply.CaptionStyleMenu-R2SCQ6VP.js.map} +0 -0
- /package/dist/dev/{vidply.HTML5Renderer-BU4QJJJY.js.map → vidply.HTML5Renderer-P7ICKYU4.js.map} +0 -0
- /package/dist/dev/{vidply.chunk-6RQA4ROD.js.map → vidply.chunk-5IK3IC5V.js.map} +0 -0
- /package/dist/dev/{vidply.chunk-DZSDO3B7.js.map → vidply.chunk-HOYEMJSD.js.map} +0 -0
- /package/dist/dev/{vidply.chunk-VMSQAZM7.js.map → vidply.chunk-JAKZNGOR.js.map} +0 -0
- /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
|
+
|