Merge pull request #473 from arabcoders/dev

[FEAT] Add keyboard shortcuts to built-in video player
This commit is contained in:
Abdulmohsen 2025-11-03 01:46:07 +03:00 committed by GitHub
commit 1db5e26843
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 700 additions and 55 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

@ -46,11 +46,12 @@ ENV PYTHONFAULTHANDLER=1
ARG DEBIAN_FRONTEND=noninteractive
RUN mkdir /config /downloads && ln -snf /usr/share/zoneinfo/${TZ} /etc/localtime && echo ${TZ} > /etc/timezone && \
RUN sed -i -E '/^Suites:[[:space:]]*trixie[[:space:]]+trixie-updates$/ {n; s/^(Components:[[:space:]]*)main([[:space:]]*|$)/\1main contrib non-free\2/}' /etc/apt/sources.list.d/debian.sources && \
mkdir /config /downloads && ln -snf /usr/share/zoneinfo/${TZ} /etc/localtime && echo ${TZ} > /etc/timezone && \
apt-get update && \
ARCH="$(dpkg --print-architecture)" && \
EXTRA_PACKAGES="" && \
if [ "$ARCH" = "amd64" ]; then EXTRA_PACKAGES="intel-media-va-driver i965-va-driver libmfx-gen1.2"; fi && \
if [ "$ARCH" = "amd64" ]; then EXTRA_PACKAGES="intel-media-va-driver-non-free i965-va-driver libmfx-gen1.2"; fi && \
apt-get install -y --no-install-recommends locales \
bash mkvtoolnix patch aria2 curl ca-certificates xz-utils git sqlite3 tzdata file libmagic1 vainfo ${EXTRA_PACKAGES} \
&& useradd -u ${USER_ID:-1000} -U -d /app -s /bin/bash app && \
@ -87,7 +88,7 @@ USER app
WORKDIR /tmp
HEALTHCHECK --interval=10s --timeout=20s --start-period=10s --retries=3 CMD [ "/usr/local/bin/healthcheck" ]
HEALTHCHECK --interval=10s --timeout=20s --start-period=60s --retries=3 CMD [ "/usr/local/bin/healthcheck" ]
ENTRYPOINT ["/entrypoint.sh"]

View file

@ -4,10 +4,57 @@ from __future__ import annotations
import os
import subprocess
import sys
from functools import lru_cache
from pathlib import Path
from typing import Any, Protocol
@lru_cache(maxsize=1)
def detect_qsv_capabilities() -> dict[str, dict[str, bool]]:
"""
Detects QSV encode capability for each relevant codec.
Returns a dict where keys are codec names (e.g. "h264", "hevc", "vp9") and
values are dicts with keys "full" and "lp" booleans.
"""
try:
result: subprocess.CompletedProcess[str] = subprocess.run(
["vainfo"], # noqa: S607
capture_output=True,
text=True,
check=False,
)
out: str = result.stdout + result.stderr
except Exception:
return {}
caps = {}
for line in out.splitlines():
line: str = line.strip()
parts: list[str] = line.split(":")
if len(parts) < 2:
continue
prof, ep_str = parts[0].strip(), parts[1].strip()
if "H264" in prof:
key = "h264"
elif "HEVC" in prof:
key = "hevc"
elif "VP9" in prof:
key = "vp9"
else:
continue
if key not in caps:
caps[key] = {"full": False, "lp": False}
if "EncSlice " in ep_str:
caps[key]["full"] = True
if "EncSliceLP" in ep_str:
caps[key]["lp"] = True
return caps
@lru_cache(maxsize=1)
def has_dri_devices() -> bool:
"""
Check if there are any /dev/dri devices.
@ -29,6 +76,7 @@ def has_dri_devices() -> bool:
return False
@lru_cache(maxsize=1)
def ffmpeg_encoders() -> set[str]:
"""
Return a set of available ffmpeg encoders.
@ -38,6 +86,7 @@ def ffmpeg_encoders() -> set[str]:
"""
from .config import SUPPORTED_CODECS
try:
result: subprocess.CompletedProcess[str] = subprocess.run(
["ffmpeg", "-hide_banner", "-loglevel", "error", "-encoders"], # noqa: S607
@ -74,6 +123,7 @@ def select_encoder(configured: str) -> str:
"""
from .config import SUPPORTED_CODECS
configured = (configured or "").strip()
avail: set[str] = ffmpeg_encoders()
@ -109,7 +159,7 @@ class SoftwareBuilder(_BaseBuilder):
codec_name = "libx264"
def add_video_args(self, args: list[str], ctx: dict[str, Any] | None = None) -> list[str]:
return super().add_video_args(["-pix_fmt", "yuv420p", *args])
return super().add_video_args(["-pix_fmt", "yuv420p", *args], ctx)
class NvencBuilder(_BaseBuilder):
@ -164,31 +214,60 @@ class QsvBuilder(_BaseBuilder):
def input_args(self, ctx: dict[str, Any] | None = None) -> list[str]:
ctx = ctx or {}
args = []
is_linux: bool = bool(ctx.get("is_linux", sys.platform.startswith("linux")))
has_dri: bool = bool(ctx.get("has_dri", False))
device: str = ctx.get("vaapi_device", "/dev/dri/renderD128")
if is_linux and has_dri:
return ["-init_hw_device", f"qsv=hw:{device}", "-filter_hw_device", "hw"]
return []
args: list[str] = [
"-init_hw_device",
f"qsv=hw:{device}",
"-hwaccel",
"qsv",
"-hwaccel_output_format",
"qsv",
"-filter_hw_device",
"hw",
]
return args
def add_video_args(self, args: list[str], ctx: dict[str, Any] | None = None) -> list[str]:
ctx = ctx or {}
new_args: list[str] = list(args)
is_linux: bool = bool(ctx.get("is_linux", sys.platform.startswith("linux")))
has_dri: bool = bool(ctx.get("has_dri", False))
if is_linux and has_dri:
new_args += [
if ctx.get("qsv", {}).get("full", False):
return [
*args,
"-vf",
"vpp_qsv=w=trunc(iw/2)*2:h=trunc(ih/2)*2:format=nv12",
"-codec:v",
self.codec_name,
"-global_quality",
"24",
"-rc_mode",
"cqp",
]
return [
*args,
"-vf",
"scale=trunc(iw/2)*2:trunc(ih/2)*2,format=nv12,hwupload=extra_hw_frames=64",
# Favor widely-supported constant quality path and disable LA
"vpp_qsv=w=trunc(iw/2)*2:h=trunc(ih/2)*2:format=nv12",
"-codec:v",
self.codec_name,
"-low_power",
"1",
"-rc_mode",
"cbr",
"-b:v",
"0",
"-global_quality",
"23",
"-look_ahead",
"0",
"3M",
"-maxrate",
"3M",
"-bufsize",
"6M",
]
return super().add_video_args(new_args)
return super().add_video_args(args, ctx)
def get_builder_for_codec(codec: str) -> EncoderBuilder:

View file

@ -6,13 +6,19 @@ import subprocess # type: ignore
import sys
import tempfile
from pathlib import Path
from typing import TYPE_CHECKING, Any, ClassVar
from typing import TYPE_CHECKING, ClassVar
from aiohttp import web
from .config import SUPPORTED_CODECS, Config
from .ffprobe import ffprobe
from .SegmentEncoders import encoder_fallback_chain, get_builder_for_codec, has_dri_devices, select_encoder
from .SegmentEncoders import (
detect_qsv_capabilities,
encoder_fallback_chain,
get_builder_for_codec,
has_dri_devices,
select_encoder,
)
if TYPE_CHECKING:
from asyncio.subprocess import Process
@ -80,27 +86,28 @@ class Segments:
startTime: str = f"{0:.6f}" if self.index == 0 else f"{self.duration * self.index:.6f}"
ctx = {
"is_linux": sys.platform.startswith("linux"),
"has_dri": has_dri_devices(),
"vaapi_device": Config.get_instance().vaapi_device,
"qsv": {"full": False, "lp": False},
}
caps: dict[str, dict[str, bool]] = detect_qsv_capabilities()
base_codec: str = s_codec.split("_")[0]
codec_caps: dict[str, bool] = caps.get(base_codec, {"full": False, "lp": False})
ctx["qsv"] = codec_caps
if self.vconvert and ff and hasattr(ff, "has_video") and ff.has_video():
builder: EncoderBuilder = get_builder_for_codec(s_codec)
builder_ctx: dict[str, Any] = {
"is_linux": sys.platform.startswith("linux"),
"has_dri": has_dri_devices(),
"vaapi_device": Config.get_instance().vaapi_device,
}
else:
builder = None
builder_ctx = {}
ctx: dict = {}
# Collect encoder-specific input/global flags that must precede the input
input_args: list[str] = []
if builder:
input_args = builder.input_args(
{
"is_linux": sys.platform.startswith("linux"),
"has_dri": has_dri_devices(),
"vaapi_device": Config.get_instance().vaapi_device,
}
)
input_args = builder.input_args(ctx)
fargs: list[str] = [
"-xerror",
@ -123,10 +130,7 @@ class Segments:
v_args: list[str] = []
if builder:
v_args = builder.add_video_args(
["-g", "52", "-map", "0:v:0", "-strict", "-2"],
builder_ctx,
)
v_args = builder.add_video_args(["-g", "52", "-map", "0:v:0", "-strict", "-2"], ctx)
else:
v_args += ["-codec:v", "copy"]

View file

@ -314,9 +314,7 @@ async def test_stream_gpu_fallback_switches_codec(
assert "-filter_hw_device" in first
assert first[first.index("-filter_hw_device") + 1] == "hw"
assert "-vf" in first
assert first[first.index("-vf") + 1] == (
"scale=trunc(iw/2)*2:trunc(ih/2)*2,format=nv12,hwupload=extra_hw_frames=64"
)
assert "vpp_qsv" in first[first.index("-vf") + 1]
# Second call (fallback) must switch codec to a safe fallback
second = captured_args[1]
@ -329,8 +327,8 @@ async def test_stream_gpu_fallback_switches_codec(
assert "-filter_hw_device" not in second
if "-vf" in second:
vf_val = second[second.index("-vf") + 1]
assert not vf_val.startswith("scale=trunc(")
assert not vf_val.startswith("format=nv12,hwupload")
assert "scale_qsv" not in vf_val
assert "hwupload" not in vf_val
assert "-pix_fmt" in second
assert second[second.index("-pix_fmt") + 1] == "yuv420p"
else:

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
@ -482,22 +721,16 @@ const src_error = async () => {
return
}
// If we already successfully captured a frame, that means the streamed video codec is working.
if (havePoster.value) {
return
}
await nextTick()
if (destroyed.value) {
return
}
// Check if video is actually paused.
if (video.value && video.value.paused) {
if (video.value && (notFirefox && video.value.paused)) {
return
}
console.warn('Source failed to load, attempting HLS fallback...')
console.warn('Source failed to load, attempting HLS fallback via hls.js...')
attach_hls(makeDownload(config, props.item, 'm3u8'))
}

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