Merge pull request #473 from arabcoders/dev
[FEAT] Add keyboard shortcuts to built-in video player
This commit is contained in:
commit
1db5e26843
9 changed files with 700 additions and 55 deletions
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
}
|
||||
|
||||
|
|
|
|||
217
ui/app/composables/useKeyboardShortcuts.ts
Normal file
217
ui/app/composables/useKeyboardShortcuts.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
4
ui/app/types/video.d.ts
vendored
4
ui/app/types/video.d.ts
vendored
|
|
@ -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
104
ui/app/utils/keyboard.ts
Normal 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
|
||||
Loading…
Reference in a new issue