]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - client/src/assets/player/shared/hotkeys/peertube-hotkeys-plugin.ts
Merge branch 'release/5.0.0' into develop
[github/Chocobozzz/PeerTube.git] / client / src / assets / player / shared / hotkeys / peertube-hotkeys-plugin.ts
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
7 export type HotkeysOptions = {
8 isLive: boolean
9 }
10
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
19 private readonly isLive: boolean
20
21 constructor (player: videojs.Player, options: videojs.PlayerOptions & HotkeysOptions) {
22 super(player, options)
23
24 this.isLive = options.isLive
25
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
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: () => {
105 if (this.isLive) return
106
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: () => {
117 if (this.isLive) return
118
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: () => {
129 if (this.isLive) return
130
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: () => {
143 if (this.isLive) return
144
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
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
188 // 0-9 key handlers
189 for (let i = 0; i < 10; i++) {
190 handlers.push({
191 accept: e => this.isNakedOrShift(e, i + ''),
192 cb: e => {
193 if (this.isLive) return
194
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) {
221 return (!event.ctrlKey && !event.altKey && !event.metaKey && !event.shiftKey && event.key === key)
222 }
223
224 private isNakedOrShift (event: KeyboardEvent, key: string) {
225 return (!event.ctrlKey && !event.altKey && !event.metaKey && event.key === key)
226 }
227 }
228
229 videojs.registerPlugin('peerTubeHotkeysPlugin', PeerTubeHotkeysPlugin)
230 export { PeerTubeHotkeysPlugin }