[FEAT] Add keyboard shortcuts to built-in video player

This commit is contained in:
arabcoders 2025-11-02 19:07:14 +03:00
parent 44fd225ab1
commit 4b996a5675
5 changed files with 576 additions and 7 deletions

View file

@ -18,7 +18,11 @@
"aiocron",
"anyio",
"Archiver",
"arrowdown",
"arrowleft",
"arrowless",
"arrowright",
"arrowup",
"asyncio",
"attl",
"autonumber",
@ -36,6 +40,7 @@
"cifs",
"connectionpool",
"consoletitle",
"contenteditable",
"continuedl",
"cookiesfrombrowser",
"copyts",

View file

@ -6,18 +6,230 @@
height: auto;
max-height: 80vh;
max-width: 80vw;
position: relative;
}
.keyboard-help {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.95);
color: var(--bulma-white);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
z-index: 1000;
border-radius: 4px;
overflow-y: auto;
padding: 2rem;
}
.shortcuts-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 2rem;
max-width: 1000px;
width: 100%;
}
.shortcut-section {
text-align: left;
}
.shortcut-section h3 {
color: var(--bulma-primary);
margin-bottom: 1rem;
font-size: 1.1rem;
}
.shortcut-item {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
font-size: 0.95rem;
}
.shortcut-item span:first-child {
flex: 1;
}
.shortcut-item .kbd-group {
display: flex;
gap: 0.5rem;
margin-left: auto;
}
.shortcut-key {
background: rgba(255, 255, 255, 0.1);
padding: 0.25rem 0.5rem;
border-radius: 3px;
margin-right: 1rem;
font-family: monospace;
font-weight: bold;
min-width: 60px;
text-align: center;
}
.help-close-hint {
margin-top: 2rem;
font-size: 0.9rem;
color: var(--bulma-light);
}
</style>
<template>
<div v-if="infoLoaded">
<video class="player" ref="video" :poster="uri(thumbnail)" :title="title" playsinline controls
crossorigin="anonymous" preload="auto" autoplay>
<source v-for="source in sources" :key="source.src" :src="source.src" @error="source.onerror"
:type="source.type" />
<track v-for="(track, i) in tracks" :key="track.file" :kind="track.kind" :label="track.label"
:srclang="track.lang" :src="track.file" :default="notFirefox && 0 === i" />
</video>
<div style="position: relative;">
<video class="player" ref="video" :poster="uri(thumbnail)" :title="title" playsinline controls
crossorigin="anonymous" preload="auto" autoplay>
<source v-for="source in sources" :key="source.src" :src="source.src" @error="source.onerror"
:type="source.type" />
<track v-for="(track, i) in tracks" :key="track.file" :kind="track.kind" :label="track.label"
:srclang="track.lang" :src="track.file" :default="notFirefox && 0 === i" />
</video>
<span class="has-text-white is-pointer" @click="showHelp = !showHelp" title="Keyboard shortcuts (or press ?)">
<span class="icon"><i class="fa-solid fa-question" /></span>
<span>Click here to Keyboard shortcuts or press <kbd>?</kbd> or <kbd>/</kbd></span>
</span>
<div v-if="showHelp" class="keyboard-help" @click.self="showHelp = false">
<h2 style="margin-bottom: 1.5rem;">Keyboard Shortcuts</h2>
<div class="shortcuts-grid">
<div class="shortcut-section">
<h3>Playback Control</h3>
<div class="shortcut-item">
<span>Play/Pause</span>
<div class="kbd-group">
<kbd class="shortcut-key">SPACE</kbd>
<kbd class="shortcut-key">K</kbd>
</div>
</div>
<div class="shortcut-item">
<span>Rewind 10s</span>
<div class="kbd-group">
<kbd class="shortcut-key">J</kbd>
</div>
</div>
<div class="shortcut-item">
<span>Forward 10s</span>
<div class="kbd-group">
<kbd class="shortcut-key">L</kbd>
</div>
</div>
<div class="shortcut-item">
<span>Mute/Unmute</span>
<div class="kbd-group">
<kbd class="shortcut-key">M</kbd>
</div>
</div>
</div>
<div class="shortcut-section">
<h3>Seeking</h3>
<div class="shortcut-item">
<span>Seek Back 5s</span>
<div class="kbd-group">
<kbd class="shortcut-key"></kbd>
</div>
</div>
<div class="shortcut-item">
<span>Seek Forward 5s</span>
<div class="kbd-group">
<kbd class="shortcut-key"></kbd>
</div>
</div>
<div class="shortcut-item">
<span>Jump to Start</span>
<div class="kbd-group">
<kbd class="shortcut-key">HOME</kbd>
</div>
</div>
<div class="shortcut-item">
<span>Jump to End</span>
<div class="kbd-group">
<kbd class="shortcut-key">END</kbd>
</div>
</div>
<div class="shortcut-item">
<span>Jump to %</span>
<div class="kbd-group">
<kbd class="shortcut-key">0-9</kbd>
</div>
</div>
</div>
<div class="shortcut-section">
<h3>Volume & Speed</h3>
<div class="shortcut-item">
<span>Increase Volume</span>
<div class="kbd-group">
<kbd class="shortcut-key"></kbd>
</div>
</div>
<div class="shortcut-item">
<span>Decrease Volume</span>
<div class="kbd-group">
<kbd class="shortcut-key"></kbd>
</div>
</div>
<div class="shortcut-item">
<span>Increase Speed</span>
<div class="kbd-group">
<kbd class="shortcut-key">'</kbd>
</div>
</div>
<div class="shortcut-item">
<span>Decrease Speed</span>
<div class="kbd-group">
<kbd class="shortcut-key">;</kbd>
</div>
</div>
</div>
<div class="shortcut-section">
<h3>Display & Other</h3>
<div class="shortcut-item">
<span>Fullscreen</span>
<div class="kbd-group">
<kbd class="shortcut-key">F</kbd>
</div>
</div>
<div class="shortcut-item">
<span>Picture in Picture (PiP)</span>
<div class="kbd-group">
<kbd class="shortcut-key">P</kbd>
</div>
</div>
<div class="shortcut-item">
<span>Toggle Captions</span>
<div class="kbd-group">
<kbd class="shortcut-key">C</kbd>
</div>
</div>
<div class="shortcut-item">
<span>Frame Advance</span>
<div class="kbd-group">
<kbd class="shortcut-key">.</kbd>
</div>
</div>
<div class="shortcut-item">
<span>Frame Rewind</span>
<div class="kbd-group">
<kbd class="shortcut-key">,</kbd>
</div>
</div>
</div>
</div>
<div class="help-close-hint">Click outside or press <kbd>?</kbd> or <kbd>/</kbd> to close</div>
</div>
</div>
</div>
<div style="text-align: center;" v-else>
<div class="icon">
@ -28,8 +240,10 @@
<script setup lang="ts">
import { useStorage } from '@vueuse/core'
import { watch } from 'vue'
import Hls from 'hls.js'
import { disableOpacity, enableOpacity } from '~/utils'
import { useKeyboardShortcuts } from '~/composables/useKeyboardShortcuts'
import type { StoreItem } from '~/types/store'
import type { file_info, video_source_element, video_track_element } from '~/types/video'
@ -61,10 +275,12 @@ const infoLoaded = ref(false)
const destroyed = ref(false)
const isApple = /(iPhone|iPod|iPad).*AppleWebKit/i.test(navigator.userAgent)
const havePoster = ref(false)
const showHelp = ref(false)
let unbindMediaSessionListeners: null | (() => void) = null
let hls: Hls | null = null
let detachDecodeGuard: null | (() => void) = null
let cleanupKeyboardShortcuts: null | (() => void) = null
const handle_event = (e: KeyboardEvent) => {
if ('Escape' !== e.key) {
@ -296,6 +512,24 @@ onMounted(async () => {
unbindMediaSessionListeners = bindMediaSessionListeners(video.value)
}
// Initialize keyboard shortcuts
const keyboardShortcutsResult = useKeyboardShortcuts({
videoElement: video,
enabled: ref(true),
closePlayer: () => emitter('closeModel'),
onHelpToggle: () => {
// Help toggle callback (optional)
}
})
// Attach the keyboard shortcuts listener and store cleanup function
cleanupKeyboardShortcuts = keyboardShortcutsResult.attach()
// Bind the showHelp from the composable
watch(keyboardShortcutsResult.showHelp, (newVal) => {
showHelp.value = newVal
})
document.addEventListener('keydown', handle_event)
})
@ -323,6 +557,11 @@ onBeforeUnmount(() => {
document.removeEventListener('keydown', handle_event)
if (cleanupKeyboardShortcuts) {
cleanupKeyboardShortcuts()
cleanupKeyboardShortcuts = null
}
if (unbindMediaSessionListeners) {
unbindMediaSessionListeners()
unbindMediaSessionListeners = null

View file

@ -0,0 +1,217 @@
/**
* Vue composable for handling YouTube-style keyboard shortcuts in video player
*/
import { ref } from 'vue'
import type { Ref } from 'vue'
import {
handlePlayPause,
handleRewind,
handleForward,
handleMute,
handleVolumeChange,
handlePlaybackSpeedChange,
handleFrameStep,
handleSeekToPercent,
handleSeekBackward,
handleSeekForward,
handleFullscreen,
handlePictureInPicture,
handleToggleCaptions,
shouldHandleKeyboardShortcut,
isModifierKey,
} from '~/utils/keyboard'
import type { KeyboardShortcutContext } from '~/types/video'
export interface UseKeyboardShortcutsOptions {
videoElement: Ref<HTMLVideoElement | null | undefined>
enabled?: Ref<boolean>
closePlayer?: () => void
onHelpToggle?: () => void
}
export const useKeyboardShortcuts = (options: UseKeyboardShortcutsOptions) => {
const { videoElement, enabled, closePlayer, onHelpToggle } = options
const showHelp = ref(false)
const handleKeyDown = async (event: KeyboardEvent) => {
// Don't handle if composable is disabled
if (enabled && !enabled.value) {
return
}
// Don't handle if user is typing in an input
if (!shouldHandleKeyboardShortcut(event)) {
return
}
const video = videoElement.value
if (!video) {
return
}
// Skip if modifier keys are pressed (except for shortcuts that need them)
if (isModifierKey(event) && !['f', 'p', '?'].includes(event.key.toLowerCase())) {
return
}
const key = event.key.toLowerCase()
const ctx: KeyboardShortcutContext = { video }
try {
switch (key) {
// Play/Pause
case ' ':
case 'k':
event.preventDefault()
handlePlayPause(ctx)
break
// Rewind 10 seconds (J key)
case 'j':
event.preventDefault()
handleRewind(ctx, 10)
break
// Forward 10 seconds (L key)
case 'l':
event.preventDefault()
handleForward(ctx, 10)
break
// Seek backward 5 seconds (left arrow)
case 'arrowleft':
event.preventDefault()
handleSeekBackward(ctx, 5)
break
// Seek forward 5 seconds (right arrow)
case 'arrowright':
event.preventDefault()
handleSeekForward(ctx, 5)
break
// Increase volume (up arrow)
case 'arrowup':
event.preventDefault()
handleVolumeChange(ctx, 0.1)
break
// Decrease volume (down arrow)
case 'arrowdown':
event.preventDefault()
handleVolumeChange(ctx, -0.1)
break
// Mute/Unmute
case 'm':
event.preventDefault()
handleMute(ctx)
break
// Toggle fullscreen
case 'f':
event.preventDefault()
handleFullscreen(video)
break
// Picture-in-Picture
case 'p':
event.preventDefault()
await handlePictureInPicture(video)
break
// Toggle captions
case 'c':
event.preventDefault()
handleToggleCaptions(video)
break
// Frame advance (period key) / Increase playback speed (> or ')
case '.':
case "'": {
event.preventDefault()
if ('.' === key) {
handleFrameStep(ctx, 'forward')
} else {
handlePlaybackSpeedChange(ctx, 0.25)
}
break
}
// Frame rewind (comma key) / Decrease playback speed (< or ;)
case ',':
case ';': {
event.preventDefault()
if (',' === key) {
handleFrameStep(ctx, 'backward')
} else {
handlePlaybackSpeedChange(ctx, -0.25)
}
break
}
// Jump to percentage (0-9 keys)
case '0':
case '1':
case '2':
case '3':
case '4':
case '5':
case '6':
case '7':
case '8':
case '9': {
event.preventDefault()
const percent = parseInt(key) * 10
handleSeekToPercent(ctx, percent)
break
}
// Jump to start (Home)
case 'home':
event.preventDefault()
video.currentTime = 0
break
// Jump to end (End)
case 'end':
event.preventDefault()
video.currentTime = video.duration
break
// Show/hide help (Shift+/)
case '?':
case '/': {
event.preventDefault()
showHelp.value = !showHelp.value
onHelpToggle?.()
break
}
// Close player (Escape - already handled elsewhere but kept for completeness)
case 'escape':
event.preventDefault()
closePlayer?.()
break
default:
// No action for unrecognized keys
break
}
} catch (error) {
console.error('Error handling keyboard shortcut:', error)
}
}
const attach = () => {
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}
return {
showHelp,
attach,
}
}

View file

@ -1,3 +1,6 @@
type KeyboardShortcutContext = {
video: HTMLVideoElement
}
type video_track_element = {
file: string,
@ -82,4 +85,5 @@ export type {
video_source_element,
FFProbeResult,
file_info,
KeyboardShortcutContext,
};

104
ui/app/utils/keyboard.ts Normal file
View file

@ -0,0 +1,104 @@
import type { KeyboardShortcutContext } from '~/types/video'
export const handlePlayPause = (ctx: KeyboardShortcutContext) => {
if (ctx.video.paused) {
ctx.video.play()
} else {
ctx.video.pause()
}
}
export const handleRewind = (ctx: KeyboardShortcutContext, seconds: number = 10) => {
ctx.video.currentTime = Math.max(0, ctx.video.currentTime - seconds)
}
export const handleForward = (ctx: KeyboardShortcutContext, seconds: number = 10) => {
ctx.video.currentTime = Math.min(ctx.video.duration, ctx.video.currentTime + seconds)
}
export const handleMute = (ctx: KeyboardShortcutContext) => {
ctx.video.muted = !ctx.video.muted
}
export const handleVolumeChange = (ctx: KeyboardShortcutContext, delta: number) => {
const newVolume = Math.max(0, Math.min(1, ctx.video.volume + delta))
ctx.video.volume = newVolume
}
export const handlePlaybackSpeedChange = (ctx: KeyboardShortcutContext, delta: number) => {
const currentSpeed = ctx.video.playbackRate
const newSpeed = Math.max(0.25, Math.min(2, currentSpeed + delta))
ctx.video.playbackRate = newSpeed
}
export const handleFrameStep = (ctx: KeyboardShortcutContext, direction: 'forward' | 'backward' = 'forward') => {
if (!ctx.video.paused) {
ctx.video.pause()
}
// Frame step by ~33ms (approximately 1 frame at 30fps, ~16.7ms at 60fps)
const frameStep = 'forward' === direction ? 0.033 : -0.033
ctx.video.currentTime = Math.max(0, Math.min(ctx.video.duration, ctx.video.currentTime + frameStep))
}
export const handleSeekToPercent = (ctx: KeyboardShortcutContext, percent: number) => {
ctx.video.currentTime = (percent / 100) * ctx.video.duration
}
export const handleSeekBackward = (ctx: KeyboardShortcutContext, seconds: number = 5) => {
ctx.video.currentTime = Math.max(0, ctx.video.currentTime - seconds)
}
export const handleSeekForward = (ctx: KeyboardShortcutContext, seconds: number = 5) => {
ctx.video.currentTime = Math.min(ctx.video.duration, ctx.video.currentTime + seconds)
}
export const handleFullscreen = (videoElement: HTMLVideoElement) => {
if (!document.fullscreenElement) {
videoElement.requestFullscreen().catch(err => {
console.error(`Error attempting to enable fullscreen: ${err.message}`)
})
} else {
document.exitFullscreen()
}
}
export const handlePictureInPicture = async (videoElement: HTMLVideoElement) => {
try {
if (document.pictureInPictureElement) {
await document.exitPictureInPicture()
} else if (document.pictureInPictureEnabled) {
await videoElement.requestPictureInPicture()
}
} catch (error) {
console.error(`Picture-in-Picture error: ${error}`)
}
}
export const handleToggleCaptions = (videoElement: HTMLVideoElement) => {
const textTracks = videoElement.textTracks
if (0 === textTracks.length) {
return
}
for (let i = 0; i < textTracks.length; i++) {
const track = textTracks[i] as TextTrack | null
if (track && ('captions' === track.kind || 'subtitles' === track.kind)) {
track.mode = 'showing' === track.mode ? 'hidden' : 'showing'
break
}
}
}
export const shouldHandleKeyboardShortcut = (event: KeyboardEvent): boolean => {
const target = event.target as HTMLElement
const tagName = target?.tagName?.toLowerCase()
if ('input' === tagName || 'textarea' === tagName || 'true' === target?.contentEditable || 'true' === target?.getAttribute('contenteditable')) {
return false
}
return true
}
export const isModifierKey = (event: KeyboardEvent): boolean => event.ctrlKey || event.metaKey || event.altKey