refactor: improve video player rendering

This commit is contained in:
arabcoders 2026-05-05 23:01:04 +03:00
parent f2d7e59420
commit 8f436a7afb
12 changed files with 394 additions and 369 deletions

View file

@ -98,27 +98,13 @@
Your browser does not support the video tag.
</video>
<button
v-if="active && showSeekZones"
type="button"
class="absolute inset-y-0 left-0 z-10 w-1/3"
aria-label="Back 10 seconds"
@click="seekBy(-10)"
/>
<button
v-if="active && isTouchDevice"
type="button"
class="absolute inset-y-0 left-1/3 z-10 w-1/3"
class="absolute inset-0 z-10"
:aria-label="controlsVisible ? 'Hide controls' : 'Show controls'"
@click="toggleControls"
/>
<button
v-if="active && showSeekZones"
type="button"
class="absolute inset-y-0 right-0 z-10 w-1/3"
aria-label="Forward 10 seconds"
@click="seekBy(10)"
/>
<div
v-if="usesAssSubtitleTrack"
@ -127,72 +113,67 @@
aria-hidden="true"
/>
<div
v-if="active && showSeekZones && controlsVisible"
class="pointer-events-none absolute inset-y-0 left-0 z-20 flex w-1/3 items-center justify-start px-5"
>
<div
class="rounded-full bg-black/60 px-4 py-2 text-sm font-semibold text-white backdrop-blur-sm"
>
-10s
</div>
</div>
<div
v-if="active && showSeekZones && controlsVisible"
class="pointer-events-none absolute inset-y-0 right-0 z-20 flex w-1/3 items-center justify-end px-5"
>
<div
class="rounded-full bg-black/60 px-4 py-2 text-sm font-semibold text-white backdrop-blur-sm"
>
+10s
</div>
</div>
<div
v-if="active"
class="absolute inset-x-0 bottom-0 z-30 bg-linear-to-t from-black/60 via-black/22 to-transparent px-3 pb-3 pt-10 text-white transition-opacity duration-150"
class="absolute inset-x-0 bottom-0 z-30 bg-linear-to-t from-black/36 via-black/8 to-transparent px-3 pb-3 pt-10 text-white transition-opacity duration-150"
:class="controlsVisible ? 'opacity-100' : 'pointer-events-none opacity-0'"
@click.self="toggleControls"
@pointermove="showControls"
>
<div
class="rounded-sm border border-white/8 bg-black/18 p-2.5 shadow-lg backdrop-blur-sm sm:p-3"
>
<div class="flex flex-col gap-2.5 sm:flex-row sm:items-center sm:gap-3">
<div class="sm:min-w-0 sm:flex-1">
<div class="rounded-sm border border-white/8 bg-black/8 p-2.5 shadow-lg backdrop-blur-sm">
<div
class="grid grid-cols-[minmax(0,1fr)_auto] gap-x-3 gap-y-2.5 sm:grid-cols-[auto_minmax(0,1fr)_auto] sm:items-center sm:gap-3"
>
<div class="order-2 min-w-0 sm:order-1 sm:flex sm:min-w-0 sm:items-center sm:gap-3">
<div class="flex min-w-0 items-center gap-2">
<UButton
color="neutral"
variant="soft"
size="sm"
class="opacity-65 transition-opacity hover:opacity-100 focus-visible:opacity-100"
:icon="paused ? 'i-lucide-play' : 'i-lucide-pause'"
:aria-label="paused ? 'Play video' : 'Pause video'"
@click="togglePlayback"
/>
<div class="min-w-0 truncate whitespace-nowrap text-xs font-medium text-white/60">
{{ timeLabel }}
</div>
</div>
</div>
<div class="order-1 col-span-2 sm:order-2 sm:col-span-1 sm:min-w-0 sm:flex-1">
<input
:value="progress"
type="range"
min="0"
max="1000"
step="1"
class="h-1.5 w-full accent-white opacity-70 transition-opacity hover:opacity-100"
class="h-1.5 w-full accent-white opacity-55 transition-opacity hover:opacity-100"
aria-label="Seek video"
@input="handleSeekInput"
/>
</div>
<div class="flex items-center justify-between gap-2 sm:shrink-0 sm:justify-end">
<div class="flex min-w-0 items-center gap-2">
<UButton
color="neutral"
variant="soft"
size="sm"
class="opacity-75 transition-opacity hover:opacity-100 focus-visible:opacity-100"
:icon="paused ? 'i-lucide-play' : 'i-lucide-pause'"
:aria-label="paused ? 'Play video' : 'Pause video'"
@click="togglePlayback"
/>
<div class="min-w-0 whitespace-nowrap text-xs font-medium text-white/70">
{{ timeLabel }}
</div>
</div>
<div class="flex items-center gap-2">
<div class="order-3 flex items-center justify-end sm:order-3 sm:shrink-0">
<div class="flex shrink-0 items-center gap-1.5 sm:gap-2">
<UTooltip
v-if="!usingHls && hasVideo"
side="top"
text="Trouble playing? Switch to HLS stream."
>
<UButton
color="neutral"
variant="soft"
size="sm"
class="opacity-65 transition-opacity hover:opacity-100 focus-visible:opacity-100"
icon="i-lucide-refresh-cw"
aria-label="Switch to HLS stream"
@click="forceSwitchToHls"
/>
</UTooltip>
<UButton
v-if="hasSubtitles"
color="neutral"
variant="soft"
size="sm"
class="opacity-75 transition-opacity hover:opacity-100 focus-visible:opacity-100"
class="opacity-65 transition-opacity hover:opacity-100 focus-visible:opacity-100"
:icon="subtitleEnabled ? 'i-lucide-captions' : 'i-lucide-captions-off'"
:aria-label="subtitleEnabled ? 'Disable subtitles' : 'Enable subtitles'"
@click="subtitleEnabled = !subtitleEnabled"
@ -201,18 +182,19 @@
color="neutral"
variant="soft"
size="sm"
class="opacity-75 transition-opacity hover:opacity-100 focus-visible:opacity-100"
:icon="effectiveVolume <= 0 ? 'i-lucide-volume-x' : 'i-lucide-volume-2'"
:aria-label="effectiveVolume <= 0 ? 'Unmute video' : 'Mute video'"
class="opacity-65 transition-opacity hover:opacity-100 focus-visible:opacity-100"
:icon="muted || mediaVol <= 0 ? 'i-lucide-volume-x' : 'i-lucide-volume-2'"
:aria-label="muted || mediaVol <= 0 ? 'Unmute video' : 'Mute video'"
@click="toggleMute"
/>
<input
:value="Math.round(effectiveVolume * 100)"
v-if="!isTouchDevice"
:value="Math.round(mediaVol * 100)"
type="range"
min="0"
max="100"
step="1"
class="w-16 accent-white opacity-70 transition-opacity hover:opacity-100 sm:w-20"
class="w-16 accent-white opacity-55 transition-opacity hover:opacity-100 sm:w-18"
aria-label="Video volume"
@input="handleVolumeInput"
/>
@ -220,11 +202,22 @@
color="neutral"
variant="soft"
size="sm"
class="opacity-75 transition-opacity hover:opacity-100 focus-visible:opacity-100"
class="opacity-65 transition-opacity hover:opacity-100 focus-visible:opacity-100"
:icon="isFullscreen ? 'i-lucide-minimize' : 'i-lucide-maximize'"
:aria-label="isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'"
@click="toggleFullscreen"
/>
<UTooltip side="top" text="Show keyboard shortcuts with ? or /">
<UButton
color="neutral"
variant="soft"
size="sm"
class="opacity-65 transition-opacity hover:opacity-100 focus-visible:opacity-100"
icon="i-lucide-circle-help"
aria-label="Show keyboard shortcuts"
@click="showHelp = !showHelp"
/>
</UTooltip>
</div>
</div>
</div>
@ -232,29 +225,12 @@
</div>
</div>
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex flex-wrap items-center gap-3 text-sm">
<button
v-if="!usingHls && hasVideo"
type="button"
class="inline-flex items-center gap-2 text-info transition-colors hover:text-info/80"
@click.prevent="forceSwitchToHls"
>
<UIcon name="i-lucide-refresh-cw" class="size-4" />
<span>Trouble playing? switch to HLS stream.</span>
</button>
<span v-if="subtitleLoading" class="text-toned">Looking for matching subtitles...</span>
<span v-else-if="subtitleLoadError" class="text-warning">{{ subtitleLoadError }}</span>
</div>
<button
type="button"
class="inline-flex items-center gap-2 text-toned transition-colors hover:text-default"
@click="showHelp = !showHelp"
>
<UIcon name="i-lucide-circle-help" class="size-4" />
<span>Show keyboard shortcuts with <kbd>?</kbd> or <kbd>/</kbd></span>
</button>
<div
v-if="subtitleLoading || subtitleLoadError"
class="flex flex-wrap items-center gap-3 text-sm"
>
<span v-if="subtitleLoading" class="text-toned">Looking for matching subtitles...</span>
<span v-else-if="subtitleLoadError" class="text-warning">{{ subtitleLoadError }}</span>
</div>
<UModal
@ -349,6 +325,7 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useStorage } from '@vueuse/core';
import Hls from 'hls.js';
import {
disableOpacity,
@ -359,7 +336,6 @@ import {
request,
uri,
} from '~/utils';
import { usePlayerMediaVolume } from '~/composables/usePlayerMediaVolume';
import { usePlayerShortcutHelp } from '~/composables/usePlayerShortcutHelp';
import { usePlayerShortcuts } from '~/composables/usePlayerShortcuts';
import { usePlayerSubtitles } from '~/composables/usePlayerSubtitles';
@ -369,6 +345,9 @@ import {
getFullscreenElement,
requestElementFullscreen,
} from '~/utils/fullscreen';
import { clampMediaVolume } from '~/utils/keyboard';
import { readResumeState, resumeMedia } from '~/utils/media';
import { nextTapVisible } from '~/utils/playerControls';
import type { StoreItem } from '~/types/store';
import type { FileInfo, PlayerSourceElement } from '~/types/video';
@ -382,14 +361,6 @@ const emitter = defineEmits<{
(e: 'playback-state-change', isPlaying: boolean): void;
}>();
const {
volume,
muted,
effectiveVolume,
setVolume,
changeVolume,
toggleMute: toggleStoredMute,
} = usePlayerMediaVolume();
const showShortcutHelp = usePlayerShortcutHelp();
const playerContainer = ref<HTMLElement | null>(null);
@ -415,6 +386,8 @@ const isAudio = ref(false);
const hasVideo = ref(false);
const usingHls = ref(false);
const destroyed = ref(false);
const mediaVol = useStorage<number>('player_volume', 1);
const muted = useStorage<boolean>('player_muted', false);
const showHelp = computed({
get: () => showShortcutHelp.value,
set: (value: boolean) => {
@ -432,19 +405,15 @@ const helpPortal = computed<boolean | HTMLElement>(() => {
let assLayoutRefreshFrame = 0;
let controlsHideTimeout = 0;
let pendingVideoClickTimeout = 0;
let gainContext: AudioContext | null = null;
let gainSource: MediaElementAudioSourceNode | null = null;
let gainNode: GainNode | null = null;
let gainElement: HTMLMediaElement | null = null;
let unbindMediaSession: null | (() => void) = null;
let hls: Hls | null = null;
let pendingResume: null | { time: number; shouldPlay: boolean } = null;
const isApple = /(iPhone|iPod|iPad).*AppleWebKit/i.test(navigator.userAgent);
const mediaFile = computed(() => props.item.filename || '');
const subtitleManifestUrl = computed(() => currentPlaybackUrl('api/player/subtitles/manifest'));
const canPlay = computed(() => Boolean(mediaFile.value && !loadingError.value));
const shouldRender = computed(() => active.value && !loading.value);
const isPlaying = computed(() => !paused.value);
const progress = computed(() => {
if (!duration.value) return 0;
return Math.round((currentTime.value / duration.value) * 1000);
@ -454,13 +423,6 @@ const timeLabel = computed(() => {
const durationLabel = duration.value ? formatDuration(Math.round(duration.value)) : '--:--';
return `${currentLabel} / ${durationLabel}`;
});
const showSeekZones = computed(() => {
return Boolean(isTouchDevice.value && isPlaying.value);
});
const useGainFallback = computed(() => {
return Boolean(isTouchDevice.value && getAudioContextCtor());
});
const {
subtitleLoading,
subtitleLoadError,
@ -481,15 +443,15 @@ const {
useHead(() => (title.value ? { title: formatPageTitle(`Playing: ${title.value}`) } : {}));
watch(
[volume, muted],
([nextVolume]) => {
const normalizedVolume = normalizeMediaVolume(nextVolume);
if (normalizedVolume !== nextVolume) {
volume.value = normalizedVolume;
[mediaVol, muted],
([nextVol]) => {
const normalizedVolume = clampMediaVolume(nextVol);
if (normalizedVolume !== nextVol) {
mediaVol.value = normalizedVolume;
return;
}
applyStoredMediaState(videoElement.value);
applyMediaState(videoElement.value);
syncVideoState();
},
{ immediate: true },
@ -499,10 +461,10 @@ watch(
videoElement,
(element, previousElement) => {
if (previousElement && previousElement !== element) {
disconnectGainController();
previousElement.muted = true;
}
applyStoredMediaState(element);
applyMediaState(element);
syncVideoState();
},
{ immediate: true },
@ -526,21 +488,6 @@ function formatDuration(totalSeconds: number): string {
return [minutes, seconds].map((value) => String(value).padStart(2, '0')).join(':');
}
function normalizeMediaVolume(volume: number): number {
if (!Number.isFinite(volume)) return 1;
return Math.min(1, Math.max(0, volume));
}
function getAudioContextCtor(): typeof AudioContext | null {
if (!import.meta.client) return null;
const maybeWindow = window as Window &
typeof globalThis & {
webkitAudioContext?: typeof AudioContext;
};
return maybeWindow.AudioContext || maybeWindow.webkitAudioContext || null;
}
function currentPlaybackUrl(base: string, playlist: boolean = false): string {
if (!props.item.filename) {
return '';
@ -556,8 +503,7 @@ function currentPlaybackUrl(base: string, playlist: boolean = false): string {
function activatePlayer() {
active.value = true;
void nextTick(async () => {
applyStoredMediaState(videoElement.value);
await resumeGainController();
applyMediaState(videoElement.value);
try {
await videoElement.value?.play();
} catch {}
@ -578,6 +524,14 @@ function handleVideoLoadedMetadata() {
if (videoElement.value) {
updateMediaSessionPosition(videoElement.value);
}
if (pendingResume) {
void resumeMedia(videoElement.value, pendingResume).finally(() => {
pendingResume = null;
syncVideoState();
showControls();
});
}
}
function handleVideoTimeUpdate() {
@ -589,7 +543,6 @@ function handleVideoTimeUpdate() {
function handleVideoPlay() {
loadingError.value = '';
void resumeGainController();
syncVideoState();
showControls();
emitter('playback-state-change', true);
@ -604,13 +557,17 @@ function handleVideoPause() {
function handleVideoClick() {
if (isTouchDevice.value) {
toggleControls();
return;
}
clearPendingVideoClickTimeout();
pendingVideoClickTimeout = window.setTimeout(() => {
pendingVideoClickTimeout = 0;
toggleControls();
if (controlsVisible.value && !videoElement.value?.paused) {
clearControlsHideTimeout();
controlsVisible.value = false;
}
}, 180);
}
@ -650,11 +607,9 @@ function handleMediaVolumeChange(event: Event) {
muted.value = target.muted;
}
if (!useGainFallback.value) {
const normalizedVolume = normalizeMediaVolume(target.volume);
if (Math.abs(volume.value - normalizedVolume) > 0.001) {
volume.value = normalizedVolume;
}
const normalizedVolume = clampMediaVolume(target.volume);
if (Math.abs(mediaVol.value - normalizedVolume) > 0.001) {
mediaVol.value = normalizedVolume;
}
syncVideoState();
@ -691,11 +646,12 @@ function handleVolumeInput(event: Event) {
const target = event.target as HTMLInputElement | null;
if (!target || !videoElement.value) return;
setVolume(Number(target.value) / 100);
applyStoredMediaState(videoElement.value);
const nextVol = clampMediaVolume(Number(target.value) / 100);
mediaVol.value = nextVol;
muted.value = nextVol <= 0;
applyMediaState(videoElement.value);
syncVideoState();
showControls();
void resumeGainController();
}
async function togglePlayback() {
@ -703,7 +659,6 @@ async function togglePlayback() {
try {
if (videoElement.value.paused) {
await resumeGainController();
await videoElement.value.play();
syncVideoState();
showControls();
@ -716,46 +671,22 @@ async function togglePlayback() {
}
function toggleMute() {
toggleStoredMute();
applyStoredMediaState(videoElement.value);
syncVideoState();
showControls();
void resumeGainController();
}
function seekBy(deltaSeconds: number) {
if (!videoElement.value) return;
if (!isPlaying.value) {
showControls();
return;
if (muted.value || mediaVol.value <= 0) {
mediaVol.value = mediaVol.value > 0 ? clampMediaVolume(mediaVol.value) : 1;
muted.value = false;
} else {
muted.value = true;
}
const duration = Number.isFinite(videoElement.value.duration) ? videoElement.value.duration : 0;
const nextTime = Math.min(
Math.max(videoElement.value.currentTime + deltaSeconds, 0),
duration || Infinity,
);
videoElement.value.currentTime = nextTime;
applyMediaState(videoElement.value);
syncVideoState();
showControls();
}
function applyStoredMediaState(element: HTMLMediaElement | null) {
function applyMediaState(element: HTMLMediaElement | null) {
if (!element) return;
if (useGainFallback.value) {
ensureGainController(element);
if (element.muted !== muted.value) {
element.muted = muted.value;
}
if (gainNode) {
gainNode.gain.value = effectiveVolume.value;
}
return;
}
const normalizedVolume = normalizeMediaVolume(volume.value);
const normalizedVolume = clampMediaVolume(mediaVol.value);
if (Math.abs(element.volume - normalizedVolume) > 0.001) {
element.volume = normalizedVolume;
}
@ -765,65 +696,6 @@ function applyStoredMediaState(element: HTMLMediaElement | null) {
}
}
function ensureGainController(element: HTMLMediaElement | null) {
if (!useGainFallback.value || !element) return;
if (gainElement === element && gainNode) {
gainNode.gain.value = effectiveVolume.value;
return;
}
disconnectGainController();
const AudioContextCtor = getAudioContextCtor();
if (!AudioContextCtor) return;
if (!gainContext || gainContext.state === 'closed') {
try {
gainContext = new AudioContextCtor();
} catch {
gainContext = null;
return;
}
}
try {
gainSource = gainContext.createMediaElementSource(element);
gainNode = gainContext.createGain();
gainSource.connect(gainNode);
gainNode.connect(gainContext.destination);
gainNode.gain.value = effectiveVolume.value;
gainElement = element;
} catch {
disconnectGainController();
}
}
async function resumeGainController() {
if (!useGainFallback.value) return;
ensureGainController(videoElement.value);
if (!gainContext || gainContext.state !== 'suspended') return;
try {
await gainContext.resume();
} catch {}
}
function disconnectGainController() {
try {
gainSource?.disconnect();
} catch {}
try {
gainNode?.disconnect();
} catch {}
gainSource = null;
gainNode = null;
gainElement = null;
}
function syncVideoState() {
const video = videoElement.value;
if (!video) {
@ -873,12 +745,18 @@ function showControls() {
}
function toggleControls() {
if (!controlsVisible.value) {
showControls();
const nextVisible = nextTapVisible({
touch: isTouchDevice.value,
paused: Boolean(videoElement.value?.paused),
visible: controlsVisible.value,
});
if (nextVisible === controlsVisible.value) {
return;
}
if (videoElement.value?.paused) {
if (nextVisible) {
showControls();
return;
}
@ -1216,7 +1094,7 @@ function prepareVideoPlayer() {
return;
}
applyStoredMediaState(videoElement.value);
applyMediaState(videoElement.value);
restoreDefaultTextTrack();
if (hasVideo.value) {
@ -1249,11 +1127,13 @@ async function src_error(event: Event) {
attach_hls(currentPlaybackUrl('m3u8', true));
}
function attach_hls(link: string) {
function attach_hls(link: string, resume = readResumeState(videoElement.value)) {
if (!videoElement.value) {
return;
}
pendingResume = resume;
if (hls) {
hls.destroy();
}
@ -1313,11 +1193,11 @@ usePlayerShortcuts({
media: videoElement,
video: videoElement,
adjustVolume: (delta) => {
changeVolume(delta);
applyStoredMediaState(videoElement.value);
mediaVol.value = clampMediaVolume(mediaVol.value + delta);
muted.value = mediaVol.value <= 0;
applyMediaState(videoElement.value);
syncVideoState();
showControls();
void resumeGainController();
},
canToggleSubs: hasSubtitles,
helpOpen: showShortcutHelp,
@ -1369,12 +1249,6 @@ onBeforeUnmount(() => {
unbindMediaSession = null;
}
disconnectGainController();
if (gainContext && gainContext.state !== 'closed') {
void gainContext.close().catch(() => {});
}
gainContext = null;
if (videoElement.value) {
destroyed.value = true;
try {

View file

@ -15,6 +15,8 @@ import {
seekToPercent,
seekBackward,
seekForward,
seekStart,
seekEnd,
fullscreen,
pictureInPicture,
toggleCaptions,
@ -171,13 +173,13 @@ export const useKeyboardShortcuts = (options: UseKeyboardShortcutsOptions) => {
// Jump to start (Home)
case 'home':
event.preventDefault();
video.currentTime = 0;
seekStart(ctx);
break;
// Jump to end (End)
case 'end':
event.preventDefault();
video.currentTime = video.duration;
seekEnd(ctx);
break;
// Show/hide help (Shift+/)

View file

@ -1,44 +0,0 @@
import { computed } from 'vue';
import { useStorage } from '@vueuse/core';
function clampVolume(volume: number): number {
if (!Number.isFinite(volume)) return 1;
return Math.min(1, Math.max(0, volume));
}
export function usePlayerMediaVolume() {
const volume = useStorage<number>('player_volume', 1);
const muted = useStorage<boolean>('player_muted', false);
const effectiveVolume = computed(() => {
return muted.value ? 0 : clampVolume(volume.value);
});
function setVolume(nextVolume: number) {
const value = clampVolume(nextVolume);
volume.value = value;
muted.value = value <= 0;
}
function changeVolume(delta: number) {
setVolume(volume.value + delta);
}
function toggleMute() {
if (muted.value || effectiveVolume.value <= 0) {
volume.value = volume.value > 0 ? clampVolume(volume.value) : 1;
muted.value = false;
return;
}
muted.value = true;
}
return {
volume,
muted,
effectiveVolume,
setVolume,
changeVolume,
toggleMute,
};
}

View file

@ -8,12 +8,24 @@ import {
toValue,
} from 'vue';
import {
clampMediaTime,
clampMediaVolume,
changeSpeed,
changeVolume,
forward,
frameStep,
hasModifierKey,
playPause,
rewind,
seekBackward,
seekEnd,
seekForward,
seekStart,
seekToPercent,
shouldHandleKeyboardShortcut,
toggleCaptions,
} from '~/utils/keyboard';
import type { KeyboardShortcutContext } from '~/types/video';
type UsePlayerShortcutsOptions = {
enabled: MaybeRefOrGetter<boolean>;
media: MaybeRefOrGetter<HTMLMediaElement | null>;
@ -30,36 +42,6 @@ type UsePlayerShortcutsOptions = {
export function usePlayerShortcuts(options: UsePlayerShortcutsOptions) {
const showHelp = options.helpOpen || ref(false);
function togglePlayPause(media: HTMLMediaElement) {
if (media.paused) {
void media.play().catch(() => {});
return;
}
media.pause();
}
function stepFrame(media: HTMLMediaElement, direction: 'forward' | 'backward') {
if (!media.paused) {
media.pause();
}
const frameStep = direction === 'forward' ? 0.033 : -0.033;
clampMediaTime(media, media.currentTime + frameStep);
}
function toggleNativeSubtitles(video: HTMLVideoElement) {
const tracks = Array.from(video.textTracks);
const subtitleTrack = tracks.find(
(track) => track.kind === 'subtitles' || track.kind === 'captions',
);
if (!subtitleTrack) {
return;
}
subtitleTrack.mode = subtitleTrack.mode === 'showing' ? 'hidden' : 'showing';
}
async function handleKeyDown(event: KeyboardEvent) {
if (!toValue(options.enabled) || !shouldHandleKeyboardShortcut(event)) {
return;
@ -70,6 +52,8 @@ export function usePlayerShortcuts(options: UsePlayerShortcutsOptions) {
return;
}
const ctx: KeyboardShortcutContext = { video: media };
const key = event.key.toLowerCase();
if (hasModifierKey(event) && !['f', '?', '/'].includes(key)) {
return;
@ -80,39 +64,37 @@ export function usePlayerShortcuts(options: UsePlayerShortcutsOptions) {
case 'k':
event.preventDefault();
event.stopPropagation();
togglePlayPause(media);
playPause(ctx);
break;
case 'j':
event.preventDefault();
event.stopPropagation();
clampMediaTime(media, media.currentTime - 10);
rewind(ctx, 10);
break;
case 'l':
event.preventDefault();
event.stopPropagation();
clampMediaTime(media, media.currentTime + 10);
forward(ctx, 10);
break;
case 'arrowleft':
event.preventDefault();
event.stopPropagation();
clampMediaTime(media, media.currentTime - 5);
seekBackward(ctx, 5);
break;
case 'arrowright':
event.preventDefault();
event.stopPropagation();
clampMediaTime(media, media.currentTime + 5);
seekForward(ctx, 5);
break;
case 'home':
event.preventDefault();
event.stopPropagation();
media.currentTime = 0;
seekStart(ctx);
break;
case 'end':
event.preventDefault();
event.stopPropagation();
if (Number.isFinite(media.duration)) {
media.currentTime = media.duration;
}
seekEnd(ctx);
break;
case '0':
case '1':
@ -126,9 +108,7 @@ export function usePlayerShortcuts(options: UsePlayerShortcutsOptions) {
case '9': {
event.preventDefault();
event.stopPropagation();
if (Number.isFinite(media.duration) && media.duration > 0) {
media.currentTime = (parseInt(key, 10) / 10) * media.duration;
}
seekToPercent(ctx, parseInt(key, 10) * 10);
break;
}
case 'arrowup':
@ -139,8 +119,7 @@ export function usePlayerShortcuts(options: UsePlayerShortcutsOptions) {
break;
}
media.volume = clampMediaVolume(media.volume + 0.1);
media.muted = false;
changeVolume(ctx, 0.1);
break;
case 'arrowdown':
event.preventDefault();
@ -150,10 +129,7 @@ export function usePlayerShortcuts(options: UsePlayerShortcutsOptions) {
break;
}
media.volume = clampMediaVolume(media.volume - 0.1);
if (media.volume <= 0) {
media.muted = true;
}
changeVolume(ctx, -0.1);
break;
case 'm':
event.preventDefault();
@ -168,22 +144,22 @@ export function usePlayerShortcuts(options: UsePlayerShortcutsOptions) {
case ';':
event.preventDefault();
event.stopPropagation();
media.playbackRate = Math.max(0.25, media.playbackRate - 0.25);
changeSpeed(ctx, -0.25);
break;
case "'":
event.preventDefault();
event.stopPropagation();
media.playbackRate = Math.min(2, media.playbackRate + 0.25);
changeSpeed(ctx, 0.25);
break;
case ',':
event.preventDefault();
event.stopPropagation();
stepFrame(media, 'backward');
frameStep(ctx, 'backward');
break;
case '.':
event.preventDefault();
event.stopPropagation();
stepFrame(media, 'forward');
frameStep(ctx, 'forward');
break;
case 'f':
event.preventDefault();
@ -199,7 +175,7 @@ export function usePlayerShortcuts(options: UsePlayerShortcutsOptions) {
event.stopPropagation();
const video = toValue(options.video);
if (video?.textTracks.length) {
toggleNativeSubtitles(video);
toggleCaptions(video);
}
options.toggleSubtitles();
break;

View file

@ -1,5 +1,5 @@
type KeyboardShortcutContext = {
video: HTMLVideoElement;
video: HTMLMediaElement;
};
type VideoTrackElement = {

View file

@ -7,6 +7,10 @@ export const clampMediaTime = (media: HTMLMediaElement, nextTime: number) => {
};
export const clampMediaVolume = (volume: number) => {
if (!Number.isFinite(volume)) {
return 1;
}
return Math.min(1, Math.max(0, volume));
};
@ -15,18 +19,18 @@ export const hasModifierKey = (event: KeyboardEvent): boolean =>
export const playPause = (ctx: KeyboardShortcutContext) => {
if (ctx.video.paused) {
ctx.video.play();
void ctx.video.play().catch(() => {});
} else {
ctx.video.pause();
}
};
export const rewind = (ctx: KeyboardShortcutContext, seconds: number = 10) => {
ctx.video.currentTime = Math.max(0, ctx.video.currentTime - seconds);
clampMediaTime(ctx.video, ctx.video.currentTime - seconds);
};
export const forward = (ctx: KeyboardShortcutContext, seconds: number = 10) => {
ctx.video.currentTime = Math.min(ctx.video.duration, ctx.video.currentTime + seconds);
clampMediaTime(ctx.video, ctx.video.currentTime + seconds);
};
export const mute = (ctx: KeyboardShortcutContext) => {
@ -36,6 +40,7 @@ export const mute = (ctx: KeyboardShortcutContext) => {
export const changeVolume = (ctx: KeyboardShortcutContext, delta: number) => {
const volume = clampMediaVolume(ctx.video.volume + delta);
ctx.video.volume = volume;
ctx.video.muted = volume <= 0;
};
export const changeSpeed = (ctx: KeyboardShortcutContext, delta: number) => {
@ -53,22 +58,29 @@ export const frameStep = (
// 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),
);
clampMediaTime(ctx.video, ctx.video.currentTime + frameStep);
};
export const seekToPercent = (ctx: KeyboardShortcutContext, percent: number) => {
ctx.video.currentTime = (percent / 100) * ctx.video.duration;
};
export const seekStart = (ctx: KeyboardShortcutContext) => {
ctx.video.currentTime = 0;
};
export const seekEnd = (ctx: KeyboardShortcutContext) => {
if (Number.isFinite(ctx.video.duration)) {
ctx.video.currentTime = ctx.video.duration;
}
};
export const seekBackward = (ctx: KeyboardShortcutContext, seconds: number = 5) => {
clampMediaTime(ctx.video, ctx.video.currentTime - seconds);
rewind(ctx, seconds);
};
export const seekForward = (ctx: KeyboardShortcutContext, seconds: number = 5) => {
clampMediaTime(ctx.video, ctx.video.currentTime + seconds);
forward(ctx, seconds);
};
export const fullscreen = (video: HTMLVideoElement) => {

68
ui/app/utils/media.ts Normal file
View file

@ -0,0 +1,68 @@
type ResumeState = {
time: number;
shouldPlay: boolean;
};
const clampResumeTime = (
target: Pick<HTMLMediaElement, 'duration' | 'seekable'>,
time: number,
): number => {
if (!Number.isFinite(time) || time <= 0) {
return 0;
}
const seekable = target.seekable;
if (seekable && seekable.length > 0) {
const last = seekable.length - 1;
const start = seekable.start(last);
const end = seekable.end(last);
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) {
return 0;
}
return Math.min(Math.max(time, start), end);
}
const duration = target.duration;
if (Number.isFinite(duration) && duration > 0) {
return Math.min(time, Math.max(duration - 0.25, 0));
}
return time;
};
const readResumeState = (target: HTMLMediaElement | null): ResumeState => {
if (!target) {
return { time: 0, shouldPlay: false };
}
const time =
Number.isFinite(target.currentTime) && target.currentTime > 0 ? target.currentTime : 0;
return {
time,
shouldPlay: !target.paused && !target.ended,
};
};
const resumeMedia = async (target: HTMLMediaElement | null, state: ResumeState): Promise<void> => {
if (!target) {
return;
}
const nextTime = clampResumeTime(target, state.time);
if (nextTime > 0) {
try {
target.currentTime = nextTime;
} catch {}
}
if (state.shouldPlay) {
try {
await target.play();
} catch {}
}
};
export { clampResumeTime, readResumeState, resumeMedia };
export type { ResumeState };

View file

@ -0,0 +1,20 @@
type TapState = {
touch: boolean;
paused: boolean;
visible: boolean;
};
const nextTapVisible = ({ touch, paused, visible }: TapState): boolean => {
if (paused) {
return visible;
}
if (visible) {
return false;
}
return touch;
};
export { nextTapVisible };
export type { TapState };

View file

@ -92,8 +92,6 @@ export default defineNuxtConfig({
'marked-gfm-heading-id',
'hls.js',
'assjs',
'@vue/devtools-core',
'@vue/devtools-kit',
],
},
server: {

View file

@ -1,6 +1,15 @@
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
import { ref } from 'vue';
function getKeydownHandler(addEventListenerSpy: ReturnType<typeof spyOn>) {
const handler = addEventListenerSpy.mock.calls.find((call) => call[0] === 'keydown')?.[1];
if (!handler || typeof handler !== 'function') {
throw new Error('Expected keydown handler to be registered');
}
return handler;
}
describe('usePlayerShortcuts', () => {
beforeEach(() => {
document.body.innerHTML = '';
@ -40,10 +49,7 @@ describe('usePlayerShortcuts', () => {
toggleFullscreen: () => {},
});
const handler = addEventListenerSpy.mock.calls.find((call) => call[0] === 'keydown')?.[1];
if (!handler || typeof handler !== 'function') {
throw new Error('Expected keydown handler to be registered');
}
const handler = getKeydownHandler(addEventListenerSpy);
const preventDefault = mock(() => {});
const stopPropagation = mock(() => {});
@ -63,6 +69,51 @@ describe('usePlayerShortcuts', () => {
addEventListenerSpy.mockRestore();
});
it('volume_up_unmute', async () => {
const { usePlayerShortcuts } = await import('~/composables/usePlayerShortcuts');
const addEventListenerSpy = spyOn(document, 'addEventListener');
const media = {
paused: true,
currentTime: 0,
duration: 100,
playbackRate: 1,
volume: 0,
muted: true,
play: async () => {},
pause: () => {},
textTracks: [],
} as unknown as HTMLMediaElement;
usePlayerShortcuts({
enabled: ref(true),
media: ref(media),
video: ref(null),
canToggleSubs: ref(false),
toggleSubtitles: () => {},
toggleFullscreen: () => {},
});
const handler = getKeydownHandler(addEventListenerSpy);
const preventDefault = mock(() => {});
const stopPropagation = mock(() => {});
handler({
key: 'ArrowUp',
target: document.body,
preventDefault,
stopPropagation,
ctrlKey: false,
metaKey: false,
altKey: false,
} as unknown as KeyboardEvent);
expect(media.volume).toBe(0.1);
expect(media.muted).toBe(false);
addEventListenerSpy.mockRestore();
});
it('close_help_first', async () => {
const { usePlayerShortcuts } = await import('~/composables/usePlayerShortcuts');

View file

@ -0,0 +1,47 @@
import { describe, expect, it, mock } from 'bun:test';
describe('media utils', () => {
it('clamp_seekable_range', async () => {
const { clampResumeTime } = await import('~/utils/media');
const seekable = {
length: 1,
start: () => 12,
end: () => 48,
} as TimeRanges;
expect(clampResumeTime({ duration: Number.NaN, seekable }, 4)).toBe(12);
expect(clampResumeTime({ duration: Number.NaN, seekable }, 22)).toBe(22);
expect(clampResumeTime({ duration: Number.NaN, seekable }, 60)).toBe(48);
});
it('resume_keep_time_and_play', async () => {
const { resumeMedia } = await import('~/utils/media');
const play = mock(async () => {});
const target = {
currentTime: 0,
duration: 100,
seekable: { length: 0 } as TimeRanges,
play,
} as unknown as HTMLMediaElement;
await resumeMedia(target, { time: 33, shouldPlay: true });
expect(target.currentTime).toBe(33);
expect(play).toHaveBeenCalledTimes(1);
});
it('read_resume_state', async () => {
const { readResumeState } = await import('~/utils/media');
const target = {
currentTime: 19,
paused: false,
ended: false,
} as HTMLMediaElement;
expect(readResumeState(target)).toEqual({ time: 19, shouldPlay: true });
expect(readResumeState(null)).toEqual({ time: 0, shouldPlay: false });
});
});

View file

@ -0,0 +1,21 @@
import { describe, expect, it } from 'bun:test';
describe('playerControls utils', () => {
it('show_hidden_touch', async () => {
const { nextTapVisible } = await import('~/utils/playerControls');
expect(nextTapVisible({ touch: true, paused: false, visible: false })).toBe(true);
});
it('keep_hidden_desktop', async () => {
const { nextTapVisible } = await import('~/utils/playerControls');
expect(nextTapVisible({ touch: false, paused: false, visible: false })).toBe(false);
});
it('hide_visible_when_playing', async () => {
const { nextTapVisible } = await import('~/utils/playerControls');
expect(nextTapVisible({ touch: true, paused: false, visible: true })).toBe(false);
});
});