refactor: improve video player rendering
This commit is contained in:
parent
f2d7e59420
commit
8f436a7afb
12 changed files with 394 additions and 369 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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+/)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
2
ui/app/types/video.d.ts
vendored
2
ui/app/types/video.d.ts
vendored
|
|
@ -1,5 +1,5 @@
|
|||
type KeyboardShortcutContext = {
|
||||
video: HTMLVideoElement;
|
||||
video: HTMLMediaElement;
|
||||
};
|
||||
|
||||
type VideoTrackElement = {
|
||||
|
|
|
|||
|
|
@ -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
68
ui/app/utils/media.ts
Normal 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 };
|
||||
20
ui/app/utils/playerControls.ts
Normal file
20
ui/app/utils/playerControls.ts
Normal 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 };
|
||||
|
|
@ -92,8 +92,6 @@ export default defineNuxtConfig({
|
|||
'marked-gfm-heading-id',
|
||||
'hls.js',
|
||||
'assjs',
|
||||
'@vue/devtools-core',
|
||||
'@vue/devtools-kit',
|
||||
],
|
||||
},
|
||||
server: {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
47
ui/tests/utils/media.test.ts
Normal file
47
ui/tests/utils/media.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
21
ui/tests/utils/playerControls.test.ts
Normal file
21
ui/tests/utils/playerControls.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue