]>
Commit | Line | Data |
---|---|---|
d7b052ff C |
1 | import videojs from 'video.js' |
2 | ||
3 | type KeyHandler = { accept: (event: KeyboardEvent) => boolean, cb: (e: KeyboardEvent) => void } | |
4 | ||
5 | const Plugin = videojs.getPlugin('plugin') | |
6 | ||
66b73484 C |
7 | export type HotkeysOptions = { |
8 | isLive: boolean | |
9 | } | |
10 | ||
d7b052ff C |
11 | class PeerTubeHotkeysPlugin extends Plugin { |
12 | private static readonly VOLUME_STEP = 0.1 | |
13 | private static readonly SEEK_STEP = 5 | |
14 | ||
15 | private readonly handleKeyFunction: (event: KeyboardEvent) => void | |
16 | ||
17 | private readonly handlers: KeyHandler[] | |
18 | ||
66b73484 C |
19 | private readonly isLive: boolean |
20 | ||
21 | constructor (player: videojs.Player, options: videojs.PlayerOptions & HotkeysOptions) { | |
d7b052ff C |
22 | super(player, options) |
23 | ||
66b73484 C |
24 | this.isLive = options.isLive |
25 | ||
d7b052ff C |
26 | this.handlers = this.buildHandlers() |
27 | ||
28 | this.handleKeyFunction = (event: KeyboardEvent) => this.onKeyDown(event) | |
29 | document.addEventListener('keydown', this.handleKeyFunction) | |
30 | } | |
31 | ||
32 | dispose () { | |
33 | document.removeEventListener('keydown', this.handleKeyFunction) | |
34 | } | |
35 | ||
36 | private onKeyDown (event: KeyboardEvent) { | |
37 | if (!this.isValidKeyTarget(event.target as HTMLElement)) return | |
38 | ||
39 | for (const handler of this.handlers) { | |
40 | if (handler.accept(event)) { | |
41 | handler.cb(event) | |
42 | return | |
43 | } | |
44 | } | |
45 | } | |
46 | ||
47 | private buildHandlers () { | |
48 | const handlers: KeyHandler[] = [ | |
49 | // Play | |
50 | { | |
51 | accept: e => (e.key === ' ' || e.key === 'MediaPlayPause'), | |
52 | cb: e => { | |
53 | e.preventDefault() | |
54 | e.stopPropagation() | |
55 | ||
56 | if (this.player.paused()) this.player.play() | |
57 | else this.player.pause() | |
58 | } | |
59 | }, | |
60 | ||
61 | // Increase volume | |
62 | { | |
63 | accept: e => this.isNaked(e, 'ArrowUp'), | |
64 | cb: e => { | |
65 | e.preventDefault() | |
66 | this.player.volume(this.player.volume() + PeerTubeHotkeysPlugin.VOLUME_STEP) | |
67 | } | |
68 | }, | |
69 | ||
70 | // Decrease volume | |
71 | { | |
72 | accept: e => this.isNaked(e, 'ArrowDown'), | |
73 | cb: e => { | |
74 | e.preventDefault() | |
75 | this.player.volume(this.player.volume() - PeerTubeHotkeysPlugin.VOLUME_STEP) | |
76 | } | |
77 | }, | |
78 | ||
d7b052ff C |
79 | // Fullscreen |
80 | { | |
81 | // f key or Ctrl + Enter | |
82 | accept: e => this.isNaked(e, 'f') || (!e.altKey && e.ctrlKey && e.key === 'Enter'), | |
83 | cb: e => { | |
84 | e.preventDefault() | |
85 | ||
86 | if (this.player.isFullscreen()) this.player.exitFullscreen() | |
87 | else this.player.requestFullscreen() | |
88 | } | |
89 | }, | |
90 | ||
91 | // Mute | |
92 | { | |
93 | accept: e => this.isNaked(e, 'm'), | |
94 | cb: e => { | |
95 | e.preventDefault() | |
96 | ||
97 | this.player.muted(!this.player.muted()) | |
98 | } | |
99 | }, | |
100 | ||
101 | // Increase playback rate | |
102 | { | |
103 | accept: e => e.key === '>', | |
104 | cb: () => { | |
66b73484 C |
105 | if (this.isLive) return |
106 | ||
d7b052ff C |
107 | const target = Math.min(this.player.playbackRate() + 0.1, 5) |
108 | ||
109 | this.player.playbackRate(parseFloat(target.toFixed(2))) | |
110 | } | |
111 | }, | |
112 | ||
113 | // Decrease playback rate | |
114 | { | |
115 | accept: e => e.key === '<', | |
116 | cb: () => { | |
66b73484 C |
117 | if (this.isLive) return |
118 | ||
d7b052ff C |
119 | const target = Math.max(this.player.playbackRate() - 0.1, 0.10) |
120 | ||
121 | this.player.playbackRate(parseFloat(target.toFixed(2))) | |
122 | } | |
123 | }, | |
124 | ||
125 | // Previous frame | |
126 | { | |
127 | accept: e => e.key === ',', | |
128 | cb: () => { | |
66b73484 C |
129 | if (this.isLive) return |
130 | ||
d7b052ff C |
131 | this.player.pause() |
132 | ||
133 | // Calculate movement distance (assuming 30 fps) | |
134 | const dist = 1 / 30 | |
135 | this.player.currentTime(this.player.currentTime() - dist) | |
136 | } | |
137 | }, | |
138 | ||
139 | // Next frame | |
140 | { | |
141 | accept: e => e.key === '.', | |
142 | cb: () => { | |
66b73484 C |
143 | if (this.isLive) return |
144 | ||
d7b052ff C |
145 | this.player.pause() |
146 | ||
147 | // Calculate movement distance (assuming 30 fps) | |
148 | const dist = 1 / 30 | |
149 | this.player.currentTime(this.player.currentTime() + dist) | |
150 | } | |
151 | } | |
152 | ] | |
153 | ||
66b73484 C |
154 | if (this.isLive) return handlers |
155 | ||
156 | return handlers.concat(this.buildVODHandlers()) | |
157 | } | |
158 | ||
159 | private buildVODHandlers () { | |
160 | const handlers: KeyHandler[] = [ | |
161 | // Rewind | |
162 | { | |
163 | accept: e => this.isNaked(e, 'ArrowLeft') || this.isNaked(e, 'MediaRewind'), | |
164 | cb: e => { | |
165 | if (this.isLive) return | |
166 | ||
167 | e.preventDefault() | |
168 | ||
169 | const target = Math.max(0, this.player.currentTime() - PeerTubeHotkeysPlugin.SEEK_STEP) | |
170 | this.player.currentTime(target) | |
171 | } | |
172 | }, | |
173 | ||
174 | // Forward | |
175 | { | |
176 | accept: e => this.isNaked(e, 'ArrowRight') || this.isNaked(e, 'MediaForward'), | |
177 | cb: e => { | |
178 | if (this.isLive) return | |
179 | ||
180 | e.preventDefault() | |
181 | ||
182 | const target = Math.min(this.player.duration(), this.player.currentTime() + PeerTubeHotkeysPlugin.SEEK_STEP) | |
183 | this.player.currentTime(target) | |
184 | } | |
185 | } | |
186 | ] | |
187 | ||
d7b052ff C |
188 | // 0-9 key handlers |
189 | for (let i = 0; i < 10; i++) { | |
190 | handlers.push({ | |
ce3121ef | 191 | accept: e => this.isNakedOrShift(e, i + ''), |
d7b052ff | 192 | cb: e => { |
66b73484 C |
193 | if (this.isLive) return |
194 | ||
d7b052ff C |
195 | e.preventDefault() |
196 | ||
197 | this.player.currentTime(this.player.duration() * i * 0.1) | |
198 | } | |
199 | }) | |
200 | } | |
201 | ||
202 | return handlers | |
203 | } | |
204 | ||
205 | private isValidKeyTarget (eventEl: HTMLElement) { | |
206 | const playerEl = this.player.el() | |
207 | const activeEl = document.activeElement | |
208 | const currentElTagName = eventEl.tagName.toLowerCase() | |
209 | ||
210 | return ( | |
211 | activeEl === playerEl || | |
212 | activeEl === playerEl.querySelector('.vjs-tech') || | |
213 | activeEl === playerEl.querySelector('.vjs-control-bar') || | |
214 | eventEl.id === 'content' || | |
215 | currentElTagName === 'body' || | |
216 | currentElTagName === 'video' | |
217 | ) | |
218 | } | |
219 | ||
220 | private isNaked (event: KeyboardEvent, key: string) { | |
2c525a54 W |
221 | if (key.length === 1) key = key.toUpperCase() |
222 | ||
223 | return (!event.ctrlKey && !event.altKey && !event.metaKey && !event.shiftKey && this.getLatinKey(event.key, event.code) === key) | |
d7b052ff | 224 | } |
ce3121ef C |
225 | |
226 | private isNakedOrShift (event: KeyboardEvent, key: string) { | |
227 | return (!event.ctrlKey && !event.altKey && !event.metaKey && event.key === key) | |
228 | } | |
2c525a54 W |
229 | |
230 | // Thanks Maciej Krawczyk | |
231 | // https://stackoverflow.com/questions/70211837/keyboard-shortcuts-commands-on-non-latin-alphabet-keyboards-javascript?rq=1 | |
232 | private getLatinKey (key: string, code: string) { | |
233 | if (key.length !== 1) { | |
234 | return key | |
235 | } | |
236 | ||
237 | const capitalHetaCode = 880 | |
238 | const isNonLatin = key.charCodeAt(0) >= capitalHetaCode | |
239 | ||
240 | if (isNonLatin) { | |
241 | if (code.indexOf('Key') === 0 && code.length === 4) { // i.e. 'KeyW' | |
242 | return code.charAt(3) | |
243 | } | |
244 | ||
245 | if (code.indexOf('Digit') === 0 && code.length === 6) { // i.e. 'Digit7' | |
246 | return code.charAt(5) | |
247 | } | |
248 | } | |
249 | ||
250 | return key.toUpperCase() | |
251 | } | |
d7b052ff C |
252 | } |
253 | ||
254 | videojs.registerPlugin('peerTubeHotkeysPlugin', PeerTubeHotkeysPlugin) | |
255 | export { PeerTubeHotkeysPlugin } |