import videojs from 'video.js'
type KeyHandler = { accept: (event: KeyboardEvent) => boolean, cb: (e: KeyboardEvent) => void }
const Plugin = videojs.getPlugin('plugin')
export type HotkeysOptions = {
isLive: boolean
}
class PeerTubeHotkeysPlugin extends Plugin {
private static readonly VOLUME_STEP = 0.1
private static readonly SEEK_STEP = 5
private readonly handleKeyFunction: (event: KeyboardEvent) => void
private readonly handlers: KeyHandler[]
private readonly isLive: boolean
constructor (player: videojs.Player, options: videojs.PlayerOptions & HotkeysOptions) {
super(player, options)
this.isLive = options.isLive
this.handlers = this.buildHandlers()
this.handleKeyFunction = (event: KeyboardEvent) => this.onKeyDown(event)
document.addEventListener('keydown', this.handleKeyFunction)
}
dispose () {
document.removeEventListener('keydown', this.handleKeyFunction)
}
private onKeyDown (event: KeyboardEvent) {
if (!this.isValidKeyTarget(event.target as HTMLElement)) return
for (const handler of this.handlers) {
if (handler.accept(event)) {
handler.cb(event)
return
}
}
}
private buildHandlers () {
const handlers: KeyHandler[] = [
// Play
{
accept: e => (e.key === ' ' || e.key === 'MediaPlayPause'),
cb: e => {
e.preventDefault()
e.stopPropagation()
if (this.player.paused()) this.player.play()
else this.player.pause()
}
},
// Increase volume
{
accept: e => this.isNaked(e, 'ArrowUp'),
cb: e => {
e.preventDefault()
this.player.volume(this.player.volume() + PeerTubeHotkeysPlugin.VOLUME_STEP)
}
},
// Decrease volume
{
accept: e => this.isNaked(e, 'ArrowDown'),
cb: e => {
e.preventDefault()
this.player.volume(this.player.volume() - PeerTubeHotkeysPlugin.VOLUME_STEP)
}
},
// Fullscreen
{
// f key or Ctrl + Enter
accept: e => this.isNaked(e, 'f') || (!e.altKey && e.ctrlKey && e.key === 'Enter'),
cb: e => {
e.preventDefault()
if (this.player.isFullscreen()) this.player.exitFullscreen()
else this.player.requestFullscreen()
}
},
// Mute
{
accept: e => this.isNaked(e, 'm'),
cb: e => {
e.preventDefault()
this.player.muted(!this.player.muted())
}
},
// Increase playback rate
{
accept: e => e.key === '>',
cb: () => {
if (this.isLive) return
const target = Math.min(this.player.playbackRate() + 0.1, 5)
this.player.playbackRate(parseFloat(target.toFixed(2)))
}
},
// Decrease playback rate
{
accept: e => e.key === '<',
cb: () => {
if (this.isLive) return
const target = Math.max(this.player.playbackRate() - 0.1, 0.10)
this.player.playbackRate(parseFloat(target.toFixed(2)))
}
},
// Previous frame
{
accept: e => e.key === ',',
cb: () => {
if (this.isLive) return
this.player.pause()
// Calculate movement distance (assuming 30 fps)
const dist = 1 / 30
this.player.currentTime(this.player.currentTime() - dist)
}
},
// Next frame
{
accept: e => e.key === '.',
cb: () => {
if (this.isLive) return
this.player.pause()
// Calculate movement distance (assuming 30 fps)
const dist = 1 / 30
this.player.currentTime(this.player.currentTime() + dist)
}
}
]
if (this.isLive) return handlers
return handlers.concat(this.buildVODHandlers())
}
private buildVODHandlers () {
const handlers: KeyHandler[] = [
// Rewind
{
accept: e => this.isNaked(e, 'ArrowLeft') || this.isNaked(e, 'MediaRewind'),
cb: e => {
if (this.isLive) return
e.preventDefault()
const target = Math.max(0, this.player.currentTime() - PeerTubeHotkeysPlugin.SEEK_STEP)
this.player.currentTime(target)
}
},
// Forward
{
accept: e => this.isNaked(e, 'ArrowRight') || this.isNaked(e, 'MediaForward'),
cb: e => {
if (this.isLive) return
e.preventDefault()
const target = Math.min(this.player.duration(), this.player.currentTime() + PeerTubeHotkeysPlugin.SEEK_STEP)
this.player.currentTime(target)
}
}
]
// 0-9 key handlers
for (let i = 0; i < 10; i++) {
handlers.push({
accept: e => this.isNakedOrShift(e, i + ''),
cb: e => {
if (this.isLive) return
e.preventDefault()
this.player.currentTime(this.player.duration() * i * 0.1)
}
})
}
return handlers
}
private isValidKeyTarget (eventEl: HTMLElement) {
const playerEl = this.player.el()
const activeEl = document.activeElement
const currentElTagName = eventEl.tagName.toLowerCase()
return (
activeEl === playerEl ||
activeEl === playerEl.querySelector('.vjs-tech') ||
activeEl === playerEl.querySelector('.vjs-control-bar') ||
eventEl.id === 'content' ||
currentElTagName === 'body' ||
currentElTagName === 'video'
)
}
private isNaked (event: KeyboardEvent, key: string) {
if (key.length === 1) key = key.toUpperCase()
return (!event.ctrlKey && !event.altKey && !event.metaKey && !event.shiftKey && this.getLatinKey(event.key, event.code) === key)
}
private isNakedOrShift (event: KeyboardEvent, key: string) {
return (!event.ctrlKey && !event.altKey && !event.metaKey && event.key === key)
}
// Thanks Maciej Krawczyk
// https://stackoverflow.com/questions/70211837/keyboard-shortcuts-commands-on-non-latin-alphabet-keyboards-javascript?rq=1
private getLatinKey (key: string, code: string) {
if (key.length !== 1) {
return key
}
const capitalHetaCode = 880
const isNonLatin = key.charCodeAt(0) >= capitalHetaCode
if (isNonLatin) {
if (code.indexOf('Key') === 0 && code.length === 4) { // i.e. 'KeyW'
return code.charAt(3)
}
if (code.indexOf('Digit') === 0 && code.length === 6) { // i.e. 'Digit7'
return code.charAt(5)
}
}
return key.toUpperCase()
}
}
videojs.registerPlugin('peerTubeHotkeysPlugin', PeerTubeHotkeysPlugin)
export { PeerTubeHotkeysPlugin }