Merge pull request #599 from arabcoders/dev
refactor: improve video player rendering
This commit is contained in:
commit
6cf2eb053c
12 changed files with 394 additions and 369 deletions
|
|
@ -98,27 +98,13 @@
|
||||||
Your browser does not support the video tag.
|
Your browser does not support the video tag.
|
||||||
</video>
|
</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
|
<button
|
||||||
v-if="active && isTouchDevice"
|
v-if="active && isTouchDevice"
|
||||||
type="button"
|
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'"
|
:aria-label="controlsVisible ? 'Hide controls' : 'Show controls'"
|
||||||
@click="toggleControls"
|
@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
|
<div
|
||||||
v-if="usesAssSubtitleTrack"
|
v-if="usesAssSubtitleTrack"
|
||||||
|
|
@ -127,72 +113,67 @@
|
||||||
aria-hidden="true"
|
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
|
<div
|
||||||
v-if="active"
|
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'"
|
:class="controlsVisible ? 'opacity-100' : 'pointer-events-none opacity-0'"
|
||||||
@click.self="toggleControls"
|
|
||||||
@pointermove="showControls"
|
@pointermove="showControls"
|
||||||
>
|
>
|
||||||
<div
|
<div class="rounded-sm border border-white/8 bg-black/8 p-2.5 shadow-lg backdrop-blur-sm">
|
||||||
class="rounded-sm border border-white/8 bg-black/18 p-2.5 shadow-lg backdrop-blur-sm sm:p-3"
|
<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="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="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
|
<input
|
||||||
:value="progress"
|
:value="progress"
|
||||||
type="range"
|
type="range"
|
||||||
min="0"
|
min="0"
|
||||||
max="1000"
|
max="1000"
|
||||||
step="1"
|
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"
|
aria-label="Seek video"
|
||||||
@input="handleSeekInput"
|
@input="handleSeekInput"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between gap-2 sm:shrink-0 sm:justify-end">
|
<div class="order-3 flex items-center justify-end sm:order-3 sm:shrink-0">
|
||||||
<div class="flex min-w-0 items-center gap-2">
|
<div class="flex shrink-0 items-center gap-1.5 sm:gap-2">
|
||||||
<UButton
|
<UTooltip
|
||||||
color="neutral"
|
v-if="!usingHls && hasVideo"
|
||||||
variant="soft"
|
side="top"
|
||||||
size="sm"
|
text="Trouble playing? Switch to HLS stream."
|
||||||
class="opacity-75 transition-opacity hover:opacity-100 focus-visible:opacity-100"
|
>
|
||||||
:icon="paused ? 'i-lucide-play' : 'i-lucide-pause'"
|
<UButton
|
||||||
:aria-label="paused ? 'Play video' : 'Pause video'"
|
color="neutral"
|
||||||
@click="togglePlayback"
|
variant="soft"
|
||||||
/>
|
size="sm"
|
||||||
<div class="min-w-0 whitespace-nowrap text-xs font-medium text-white/70">
|
class="opacity-65 transition-opacity hover:opacity-100 focus-visible:opacity-100"
|
||||||
{{ timeLabel }}
|
icon="i-lucide-refresh-cw"
|
||||||
</div>
|
aria-label="Switch to HLS stream"
|
||||||
</div>
|
@click="forceSwitchToHls"
|
||||||
<div class="flex items-center gap-2">
|
/>
|
||||||
|
</UTooltip>
|
||||||
<UButton
|
<UButton
|
||||||
v-if="hasSubtitles"
|
v-if="hasSubtitles"
|
||||||
color="neutral"
|
color="neutral"
|
||||||
variant="soft"
|
variant="soft"
|
||||||
size="sm"
|
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'"
|
:icon="subtitleEnabled ? 'i-lucide-captions' : 'i-lucide-captions-off'"
|
||||||
:aria-label="subtitleEnabled ? 'Disable subtitles' : 'Enable subtitles'"
|
:aria-label="subtitleEnabled ? 'Disable subtitles' : 'Enable subtitles'"
|
||||||
@click="subtitleEnabled = !subtitleEnabled"
|
@click="subtitleEnabled = !subtitleEnabled"
|
||||||
|
|
@ -201,18 +182,19 @@
|
||||||
color="neutral"
|
color="neutral"
|
||||||
variant="soft"
|
variant="soft"
|
||||||
size="sm"
|
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="effectiveVolume <= 0 ? 'i-lucide-volume-x' : 'i-lucide-volume-2'"
|
:icon="muted || mediaVol <= 0 ? 'i-lucide-volume-x' : 'i-lucide-volume-2'"
|
||||||
:aria-label="effectiveVolume <= 0 ? 'Unmute video' : 'Mute video'"
|
:aria-label="muted || mediaVol <= 0 ? 'Unmute video' : 'Mute video'"
|
||||||
@click="toggleMute"
|
@click="toggleMute"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
:value="Math.round(effectiveVolume * 100)"
|
v-if="!isTouchDevice"
|
||||||
|
:value="Math.round(mediaVol * 100)"
|
||||||
type="range"
|
type="range"
|
||||||
min="0"
|
min="0"
|
||||||
max="100"
|
max="100"
|
||||||
step="1"
|
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"
|
aria-label="Video volume"
|
||||||
@input="handleVolumeInput"
|
@input="handleVolumeInput"
|
||||||
/>
|
/>
|
||||||
|
|
@ -220,11 +202,22 @@
|
||||||
color="neutral"
|
color="neutral"
|
||||||
variant="soft"
|
variant="soft"
|
||||||
size="sm"
|
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'"
|
:icon="isFullscreen ? 'i-lucide-minimize' : 'i-lucide-maximize'"
|
||||||
:aria-label="isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'"
|
:aria-label="isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'"
|
||||||
@click="toggleFullscreen"
|
@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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -232,29 +225,12 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
<div
|
||||||
<div class="flex flex-wrap items-center gap-3 text-sm">
|
v-if="subtitleLoading || subtitleLoadError"
|
||||||
<button
|
class="flex flex-wrap items-center gap-3 text-sm"
|
||||||
v-if="!usingHls && hasVideo"
|
>
|
||||||
type="button"
|
<span v-if="subtitleLoading" class="text-toned">Looking for matching subtitles...</span>
|
||||||
class="inline-flex items-center gap-2 text-info transition-colors hover:text-info/80"
|
<span v-else-if="subtitleLoadError" class="text-warning">{{ subtitleLoadError }}</span>
|
||||||
@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>
|
</div>
|
||||||
|
|
||||||
<UModal
|
<UModal
|
||||||
|
|
@ -349,6 +325,7 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
|
import { useStorage } from '@vueuse/core';
|
||||||
import Hls from 'hls.js';
|
import Hls from 'hls.js';
|
||||||
import {
|
import {
|
||||||
disableOpacity,
|
disableOpacity,
|
||||||
|
|
@ -359,7 +336,6 @@ import {
|
||||||
request,
|
request,
|
||||||
uri,
|
uri,
|
||||||
} from '~/utils';
|
} from '~/utils';
|
||||||
import { usePlayerMediaVolume } from '~/composables/usePlayerMediaVolume';
|
|
||||||
import { usePlayerShortcutHelp } from '~/composables/usePlayerShortcutHelp';
|
import { usePlayerShortcutHelp } from '~/composables/usePlayerShortcutHelp';
|
||||||
import { usePlayerShortcuts } from '~/composables/usePlayerShortcuts';
|
import { usePlayerShortcuts } from '~/composables/usePlayerShortcuts';
|
||||||
import { usePlayerSubtitles } from '~/composables/usePlayerSubtitles';
|
import { usePlayerSubtitles } from '~/composables/usePlayerSubtitles';
|
||||||
|
|
@ -369,6 +345,9 @@ import {
|
||||||
getFullscreenElement,
|
getFullscreenElement,
|
||||||
requestElementFullscreen,
|
requestElementFullscreen,
|
||||||
} from '~/utils/fullscreen';
|
} 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 { StoreItem } from '~/types/store';
|
||||||
import type { FileInfo, PlayerSourceElement } from '~/types/video';
|
import type { FileInfo, PlayerSourceElement } from '~/types/video';
|
||||||
|
|
@ -382,14 +361,6 @@ const emitter = defineEmits<{
|
||||||
(e: 'playback-state-change', isPlaying: boolean): void;
|
(e: 'playback-state-change', isPlaying: boolean): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const {
|
|
||||||
volume,
|
|
||||||
muted,
|
|
||||||
effectiveVolume,
|
|
||||||
setVolume,
|
|
||||||
changeVolume,
|
|
||||||
toggleMute: toggleStoredMute,
|
|
||||||
} = usePlayerMediaVolume();
|
|
||||||
const showShortcutHelp = usePlayerShortcutHelp();
|
const showShortcutHelp = usePlayerShortcutHelp();
|
||||||
|
|
||||||
const playerContainer = ref<HTMLElement | null>(null);
|
const playerContainer = ref<HTMLElement | null>(null);
|
||||||
|
|
@ -415,6 +386,8 @@ const isAudio = ref(false);
|
||||||
const hasVideo = ref(false);
|
const hasVideo = ref(false);
|
||||||
const usingHls = ref(false);
|
const usingHls = ref(false);
|
||||||
const destroyed = ref(false);
|
const destroyed = ref(false);
|
||||||
|
const mediaVol = useStorage<number>('player_volume', 1);
|
||||||
|
const muted = useStorage<boolean>('player_muted', false);
|
||||||
const showHelp = computed({
|
const showHelp = computed({
|
||||||
get: () => showShortcutHelp.value,
|
get: () => showShortcutHelp.value,
|
||||||
set: (value: boolean) => {
|
set: (value: boolean) => {
|
||||||
|
|
@ -432,19 +405,15 @@ const helpPortal = computed<boolean | HTMLElement>(() => {
|
||||||
let assLayoutRefreshFrame = 0;
|
let assLayoutRefreshFrame = 0;
|
||||||
let controlsHideTimeout = 0;
|
let controlsHideTimeout = 0;
|
||||||
let pendingVideoClickTimeout = 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 unbindMediaSession: null | (() => void) = null;
|
||||||
let hls: Hls | null = 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 isApple = /(iPhone|iPod|iPad).*AppleWebKit/i.test(navigator.userAgent);
|
||||||
const mediaFile = computed(() => props.item.filename || '');
|
const mediaFile = computed(() => props.item.filename || '');
|
||||||
const subtitleManifestUrl = computed(() => currentPlaybackUrl('api/player/subtitles/manifest'));
|
const subtitleManifestUrl = computed(() => currentPlaybackUrl('api/player/subtitles/manifest'));
|
||||||
const canPlay = computed(() => Boolean(mediaFile.value && !loadingError.value));
|
const canPlay = computed(() => Boolean(mediaFile.value && !loadingError.value));
|
||||||
const shouldRender = computed(() => active.value && !loading.value);
|
const shouldRender = computed(() => active.value && !loading.value);
|
||||||
const isPlaying = computed(() => !paused.value);
|
|
||||||
const progress = computed(() => {
|
const progress = computed(() => {
|
||||||
if (!duration.value) return 0;
|
if (!duration.value) return 0;
|
||||||
return Math.round((currentTime.value / duration.value) * 1000);
|
return Math.round((currentTime.value / duration.value) * 1000);
|
||||||
|
|
@ -454,13 +423,6 @@ const timeLabel = computed(() => {
|
||||||
const durationLabel = duration.value ? formatDuration(Math.round(duration.value)) : '--:--';
|
const durationLabel = duration.value ? formatDuration(Math.round(duration.value)) : '--:--';
|
||||||
return `${currentLabel} / ${durationLabel}`;
|
return `${currentLabel} / ${durationLabel}`;
|
||||||
});
|
});
|
||||||
const showSeekZones = computed(() => {
|
|
||||||
return Boolean(isTouchDevice.value && isPlaying.value);
|
|
||||||
});
|
|
||||||
const useGainFallback = computed(() => {
|
|
||||||
return Boolean(isTouchDevice.value && getAudioContextCtor());
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
subtitleLoading,
|
subtitleLoading,
|
||||||
subtitleLoadError,
|
subtitleLoadError,
|
||||||
|
|
@ -481,15 +443,15 @@ const {
|
||||||
useHead(() => (title.value ? { title: formatPageTitle(`Playing: ${title.value}`) } : {}));
|
useHead(() => (title.value ? { title: formatPageTitle(`Playing: ${title.value}`) } : {}));
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
[volume, muted],
|
[mediaVol, muted],
|
||||||
([nextVolume]) => {
|
([nextVol]) => {
|
||||||
const normalizedVolume = normalizeMediaVolume(nextVolume);
|
const normalizedVolume = clampMediaVolume(nextVol);
|
||||||
if (normalizedVolume !== nextVolume) {
|
if (normalizedVolume !== nextVol) {
|
||||||
volume.value = normalizedVolume;
|
mediaVol.value = normalizedVolume;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
applyStoredMediaState(videoElement.value);
|
applyMediaState(videoElement.value);
|
||||||
syncVideoState();
|
syncVideoState();
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
|
|
@ -499,10 +461,10 @@ watch(
|
||||||
videoElement,
|
videoElement,
|
||||||
(element, previousElement) => {
|
(element, previousElement) => {
|
||||||
if (previousElement && previousElement !== element) {
|
if (previousElement && previousElement !== element) {
|
||||||
disconnectGainController();
|
previousElement.muted = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
applyStoredMediaState(element);
|
applyMediaState(element);
|
||||||
syncVideoState();
|
syncVideoState();
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
|
|
@ -526,21 +488,6 @@ function formatDuration(totalSeconds: number): string {
|
||||||
return [minutes, seconds].map((value) => String(value).padStart(2, '0')).join(':');
|
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 {
|
function currentPlaybackUrl(base: string, playlist: boolean = false): string {
|
||||||
if (!props.item.filename) {
|
if (!props.item.filename) {
|
||||||
return '';
|
return '';
|
||||||
|
|
@ -556,8 +503,7 @@ function currentPlaybackUrl(base: string, playlist: boolean = false): string {
|
||||||
function activatePlayer() {
|
function activatePlayer() {
|
||||||
active.value = true;
|
active.value = true;
|
||||||
void nextTick(async () => {
|
void nextTick(async () => {
|
||||||
applyStoredMediaState(videoElement.value);
|
applyMediaState(videoElement.value);
|
||||||
await resumeGainController();
|
|
||||||
try {
|
try {
|
||||||
await videoElement.value?.play();
|
await videoElement.value?.play();
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
@ -578,6 +524,14 @@ function handleVideoLoadedMetadata() {
|
||||||
if (videoElement.value) {
|
if (videoElement.value) {
|
||||||
updateMediaSessionPosition(videoElement.value);
|
updateMediaSessionPosition(videoElement.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pendingResume) {
|
||||||
|
void resumeMedia(videoElement.value, pendingResume).finally(() => {
|
||||||
|
pendingResume = null;
|
||||||
|
syncVideoState();
|
||||||
|
showControls();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleVideoTimeUpdate() {
|
function handleVideoTimeUpdate() {
|
||||||
|
|
@ -589,7 +543,6 @@ function handleVideoTimeUpdate() {
|
||||||
|
|
||||||
function handleVideoPlay() {
|
function handleVideoPlay() {
|
||||||
loadingError.value = '';
|
loadingError.value = '';
|
||||||
void resumeGainController();
|
|
||||||
syncVideoState();
|
syncVideoState();
|
||||||
showControls();
|
showControls();
|
||||||
emitter('playback-state-change', true);
|
emitter('playback-state-change', true);
|
||||||
|
|
@ -604,13 +557,17 @@ function handleVideoPause() {
|
||||||
|
|
||||||
function handleVideoClick() {
|
function handleVideoClick() {
|
||||||
if (isTouchDevice.value) {
|
if (isTouchDevice.value) {
|
||||||
|
toggleControls();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
clearPendingVideoClickTimeout();
|
clearPendingVideoClickTimeout();
|
||||||
pendingVideoClickTimeout = window.setTimeout(() => {
|
pendingVideoClickTimeout = window.setTimeout(() => {
|
||||||
pendingVideoClickTimeout = 0;
|
pendingVideoClickTimeout = 0;
|
||||||
toggleControls();
|
if (controlsVisible.value && !videoElement.value?.paused) {
|
||||||
|
clearControlsHideTimeout();
|
||||||
|
controlsVisible.value = false;
|
||||||
|
}
|
||||||
}, 180);
|
}, 180);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -650,11 +607,9 @@ function handleMediaVolumeChange(event: Event) {
|
||||||
muted.value = target.muted;
|
muted.value = target.muted;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!useGainFallback.value) {
|
const normalizedVolume = clampMediaVolume(target.volume);
|
||||||
const normalizedVolume = normalizeMediaVolume(target.volume);
|
if (Math.abs(mediaVol.value - normalizedVolume) > 0.001) {
|
||||||
if (Math.abs(volume.value - normalizedVolume) > 0.001) {
|
mediaVol.value = normalizedVolume;
|
||||||
volume.value = normalizedVolume;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
syncVideoState();
|
syncVideoState();
|
||||||
|
|
@ -691,11 +646,12 @@ function handleVolumeInput(event: Event) {
|
||||||
const target = event.target as HTMLInputElement | null;
|
const target = event.target as HTMLInputElement | null;
|
||||||
if (!target || !videoElement.value) return;
|
if (!target || !videoElement.value) return;
|
||||||
|
|
||||||
setVolume(Number(target.value) / 100);
|
const nextVol = clampMediaVolume(Number(target.value) / 100);
|
||||||
applyStoredMediaState(videoElement.value);
|
mediaVol.value = nextVol;
|
||||||
|
muted.value = nextVol <= 0;
|
||||||
|
applyMediaState(videoElement.value);
|
||||||
syncVideoState();
|
syncVideoState();
|
||||||
showControls();
|
showControls();
|
||||||
void resumeGainController();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function togglePlayback() {
|
async function togglePlayback() {
|
||||||
|
|
@ -703,7 +659,6 @@ async function togglePlayback() {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (videoElement.value.paused) {
|
if (videoElement.value.paused) {
|
||||||
await resumeGainController();
|
|
||||||
await videoElement.value.play();
|
await videoElement.value.play();
|
||||||
syncVideoState();
|
syncVideoState();
|
||||||
showControls();
|
showControls();
|
||||||
|
|
@ -716,46 +671,22 @@ async function togglePlayback() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleMute() {
|
function toggleMute() {
|
||||||
toggleStoredMute();
|
if (muted.value || mediaVol.value <= 0) {
|
||||||
applyStoredMediaState(videoElement.value);
|
mediaVol.value = mediaVol.value > 0 ? clampMediaVolume(mediaVol.value) : 1;
|
||||||
syncVideoState();
|
muted.value = false;
|
||||||
showControls();
|
} else {
|
||||||
void resumeGainController();
|
muted.value = true;
|
||||||
}
|
|
||||||
|
|
||||||
function seekBy(deltaSeconds: number) {
|
|
||||||
if (!videoElement.value) return;
|
|
||||||
|
|
||||||
if (!isPlaying.value) {
|
|
||||||
showControls();
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const duration = Number.isFinite(videoElement.value.duration) ? videoElement.value.duration : 0;
|
applyMediaState(videoElement.value);
|
||||||
const nextTime = Math.min(
|
|
||||||
Math.max(videoElement.value.currentTime + deltaSeconds, 0),
|
|
||||||
duration || Infinity,
|
|
||||||
);
|
|
||||||
videoElement.value.currentTime = nextTime;
|
|
||||||
syncVideoState();
|
syncVideoState();
|
||||||
showControls();
|
showControls();
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyStoredMediaState(element: HTMLMediaElement | null) {
|
function applyMediaState(element: HTMLMediaElement | null) {
|
||||||
if (!element) return;
|
if (!element) return;
|
||||||
|
|
||||||
if (useGainFallback.value) {
|
const normalizedVolume = clampMediaVolume(mediaVol.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);
|
|
||||||
if (Math.abs(element.volume - normalizedVolume) > 0.001) {
|
if (Math.abs(element.volume - normalizedVolume) > 0.001) {
|
||||||
element.volume = normalizedVolume;
|
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() {
|
function syncVideoState() {
|
||||||
const video = videoElement.value;
|
const video = videoElement.value;
|
||||||
if (!video) {
|
if (!video) {
|
||||||
|
|
@ -873,12 +745,18 @@ function showControls() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleControls() {
|
function toggleControls() {
|
||||||
if (!controlsVisible.value) {
|
const nextVisible = nextTapVisible({
|
||||||
showControls();
|
touch: isTouchDevice.value,
|
||||||
|
paused: Boolean(videoElement.value?.paused),
|
||||||
|
visible: controlsVisible.value,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (nextVisible === controlsVisible.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (videoElement.value?.paused) {
|
if (nextVisible) {
|
||||||
|
showControls();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1216,7 +1094,7 @@ function prepareVideoPlayer() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
applyStoredMediaState(videoElement.value);
|
applyMediaState(videoElement.value);
|
||||||
restoreDefaultTextTrack();
|
restoreDefaultTextTrack();
|
||||||
|
|
||||||
if (hasVideo.value) {
|
if (hasVideo.value) {
|
||||||
|
|
@ -1249,11 +1127,13 @@ async function src_error(event: Event) {
|
||||||
attach_hls(currentPlaybackUrl('m3u8', true));
|
attach_hls(currentPlaybackUrl('m3u8', true));
|
||||||
}
|
}
|
||||||
|
|
||||||
function attach_hls(link: string) {
|
function attach_hls(link: string, resume = readResumeState(videoElement.value)) {
|
||||||
if (!videoElement.value) {
|
if (!videoElement.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pendingResume = resume;
|
||||||
|
|
||||||
if (hls) {
|
if (hls) {
|
||||||
hls.destroy();
|
hls.destroy();
|
||||||
}
|
}
|
||||||
|
|
@ -1313,11 +1193,11 @@ usePlayerShortcuts({
|
||||||
media: videoElement,
|
media: videoElement,
|
||||||
video: videoElement,
|
video: videoElement,
|
||||||
adjustVolume: (delta) => {
|
adjustVolume: (delta) => {
|
||||||
changeVolume(delta);
|
mediaVol.value = clampMediaVolume(mediaVol.value + delta);
|
||||||
applyStoredMediaState(videoElement.value);
|
muted.value = mediaVol.value <= 0;
|
||||||
|
applyMediaState(videoElement.value);
|
||||||
syncVideoState();
|
syncVideoState();
|
||||||
showControls();
|
showControls();
|
||||||
void resumeGainController();
|
|
||||||
},
|
},
|
||||||
canToggleSubs: hasSubtitles,
|
canToggleSubs: hasSubtitles,
|
||||||
helpOpen: showShortcutHelp,
|
helpOpen: showShortcutHelp,
|
||||||
|
|
@ -1369,12 +1249,6 @@ onBeforeUnmount(() => {
|
||||||
unbindMediaSession = null;
|
unbindMediaSession = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnectGainController();
|
|
||||||
if (gainContext && gainContext.state !== 'closed') {
|
|
||||||
void gainContext.close().catch(() => {});
|
|
||||||
}
|
|
||||||
gainContext = null;
|
|
||||||
|
|
||||||
if (videoElement.value) {
|
if (videoElement.value) {
|
||||||
destroyed.value = true;
|
destroyed.value = true;
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ import {
|
||||||
seekToPercent,
|
seekToPercent,
|
||||||
seekBackward,
|
seekBackward,
|
||||||
seekForward,
|
seekForward,
|
||||||
|
seekStart,
|
||||||
|
seekEnd,
|
||||||
fullscreen,
|
fullscreen,
|
||||||
pictureInPicture,
|
pictureInPicture,
|
||||||
toggleCaptions,
|
toggleCaptions,
|
||||||
|
|
@ -171,13 +173,13 @@ export const useKeyboardShortcuts = (options: UseKeyboardShortcutsOptions) => {
|
||||||
// Jump to start (Home)
|
// Jump to start (Home)
|
||||||
case 'home':
|
case 'home':
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
video.currentTime = 0;
|
seekStart(ctx);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// Jump to end (End)
|
// Jump to end (End)
|
||||||
case 'end':
|
case 'end':
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
video.currentTime = video.duration;
|
seekEnd(ctx);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// Show/hide help (Shift+/)
|
// 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,
|
toValue,
|
||||||
} from 'vue';
|
} from 'vue';
|
||||||
import {
|
import {
|
||||||
clampMediaTime,
|
changeSpeed,
|
||||||
clampMediaVolume,
|
changeVolume,
|
||||||
|
forward,
|
||||||
|
frameStep,
|
||||||
hasModifierKey,
|
hasModifierKey,
|
||||||
|
playPause,
|
||||||
|
rewind,
|
||||||
|
seekBackward,
|
||||||
|
seekEnd,
|
||||||
|
seekForward,
|
||||||
|
seekStart,
|
||||||
|
seekToPercent,
|
||||||
shouldHandleKeyboardShortcut,
|
shouldHandleKeyboardShortcut,
|
||||||
|
toggleCaptions,
|
||||||
} from '~/utils/keyboard';
|
} from '~/utils/keyboard';
|
||||||
|
|
||||||
|
import type { KeyboardShortcutContext } from '~/types/video';
|
||||||
|
|
||||||
type UsePlayerShortcutsOptions = {
|
type UsePlayerShortcutsOptions = {
|
||||||
enabled: MaybeRefOrGetter<boolean>;
|
enabled: MaybeRefOrGetter<boolean>;
|
||||||
media: MaybeRefOrGetter<HTMLMediaElement | null>;
|
media: MaybeRefOrGetter<HTMLMediaElement | null>;
|
||||||
|
|
@ -30,36 +42,6 @@ type UsePlayerShortcutsOptions = {
|
||||||
export function usePlayerShortcuts(options: UsePlayerShortcutsOptions) {
|
export function usePlayerShortcuts(options: UsePlayerShortcutsOptions) {
|
||||||
const showHelp = options.helpOpen || ref(false);
|
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) {
|
async function handleKeyDown(event: KeyboardEvent) {
|
||||||
if (!toValue(options.enabled) || !shouldHandleKeyboardShortcut(event)) {
|
if (!toValue(options.enabled) || !shouldHandleKeyboardShortcut(event)) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -70,6 +52,8 @@ export function usePlayerShortcuts(options: UsePlayerShortcutsOptions) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ctx: KeyboardShortcutContext = { video: media };
|
||||||
|
|
||||||
const key = event.key.toLowerCase();
|
const key = event.key.toLowerCase();
|
||||||
if (hasModifierKey(event) && !['f', '?', '/'].includes(key)) {
|
if (hasModifierKey(event) && !['f', '?', '/'].includes(key)) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -80,39 +64,37 @@ export function usePlayerShortcuts(options: UsePlayerShortcutsOptions) {
|
||||||
case 'k':
|
case 'k':
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
togglePlayPause(media);
|
playPause(ctx);
|
||||||
break;
|
break;
|
||||||
case 'j':
|
case 'j':
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
clampMediaTime(media, media.currentTime - 10);
|
rewind(ctx, 10);
|
||||||
break;
|
break;
|
||||||
case 'l':
|
case 'l':
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
clampMediaTime(media, media.currentTime + 10);
|
forward(ctx, 10);
|
||||||
break;
|
break;
|
||||||
case 'arrowleft':
|
case 'arrowleft':
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
clampMediaTime(media, media.currentTime - 5);
|
seekBackward(ctx, 5);
|
||||||
break;
|
break;
|
||||||
case 'arrowright':
|
case 'arrowright':
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
clampMediaTime(media, media.currentTime + 5);
|
seekForward(ctx, 5);
|
||||||
break;
|
break;
|
||||||
case 'home':
|
case 'home':
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
media.currentTime = 0;
|
seekStart(ctx);
|
||||||
break;
|
break;
|
||||||
case 'end':
|
case 'end':
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
if (Number.isFinite(media.duration)) {
|
seekEnd(ctx);
|
||||||
media.currentTime = media.duration;
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
case '0':
|
case '0':
|
||||||
case '1':
|
case '1':
|
||||||
|
|
@ -126,9 +108,7 @@ export function usePlayerShortcuts(options: UsePlayerShortcutsOptions) {
|
||||||
case '9': {
|
case '9': {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
if (Number.isFinite(media.duration) && media.duration > 0) {
|
seekToPercent(ctx, parseInt(key, 10) * 10);
|
||||||
media.currentTime = (parseInt(key, 10) / 10) * media.duration;
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'arrowup':
|
case 'arrowup':
|
||||||
|
|
@ -139,8 +119,7 @@ export function usePlayerShortcuts(options: UsePlayerShortcutsOptions) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
media.volume = clampMediaVolume(media.volume + 0.1);
|
changeVolume(ctx, 0.1);
|
||||||
media.muted = false;
|
|
||||||
break;
|
break;
|
||||||
case 'arrowdown':
|
case 'arrowdown':
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
@ -150,10 +129,7 @@ export function usePlayerShortcuts(options: UsePlayerShortcutsOptions) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
media.volume = clampMediaVolume(media.volume - 0.1);
|
changeVolume(ctx, -0.1);
|
||||||
if (media.volume <= 0) {
|
|
||||||
media.muted = true;
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
case 'm':
|
case 'm':
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
@ -168,22 +144,22 @@ export function usePlayerShortcuts(options: UsePlayerShortcutsOptions) {
|
||||||
case ';':
|
case ';':
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
media.playbackRate = Math.max(0.25, media.playbackRate - 0.25);
|
changeSpeed(ctx, -0.25);
|
||||||
break;
|
break;
|
||||||
case "'":
|
case "'":
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
media.playbackRate = Math.min(2, media.playbackRate + 0.25);
|
changeSpeed(ctx, 0.25);
|
||||||
break;
|
break;
|
||||||
case ',':
|
case ',':
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
stepFrame(media, 'backward');
|
frameStep(ctx, 'backward');
|
||||||
break;
|
break;
|
||||||
case '.':
|
case '.':
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
stepFrame(media, 'forward');
|
frameStep(ctx, 'forward');
|
||||||
break;
|
break;
|
||||||
case 'f':
|
case 'f':
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
@ -199,7 +175,7 @@ export function usePlayerShortcuts(options: UsePlayerShortcutsOptions) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
const video = toValue(options.video);
|
const video = toValue(options.video);
|
||||||
if (video?.textTracks.length) {
|
if (video?.textTracks.length) {
|
||||||
toggleNativeSubtitles(video);
|
toggleCaptions(video);
|
||||||
}
|
}
|
||||||
options.toggleSubtitles();
|
options.toggleSubtitles();
|
||||||
break;
|
break;
|
||||||
|
|
|
||||||
2
ui/app/types/video.d.ts
vendored
2
ui/app/types/video.d.ts
vendored
|
|
@ -1,5 +1,5 @@
|
||||||
type KeyboardShortcutContext = {
|
type KeyboardShortcutContext = {
|
||||||
video: HTMLVideoElement;
|
video: HTMLMediaElement;
|
||||||
};
|
};
|
||||||
|
|
||||||
type VideoTrackElement = {
|
type VideoTrackElement = {
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,10 @@ export const clampMediaTime = (media: HTMLMediaElement, nextTime: number) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const clampMediaVolume = (volume: number) => {
|
export const clampMediaVolume = (volume: number) => {
|
||||||
|
if (!Number.isFinite(volume)) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
return Math.min(1, Math.max(0, volume));
|
return Math.min(1, Math.max(0, volume));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -15,18 +19,18 @@ export const hasModifierKey = (event: KeyboardEvent): boolean =>
|
||||||
|
|
||||||
export const playPause = (ctx: KeyboardShortcutContext) => {
|
export const playPause = (ctx: KeyboardShortcutContext) => {
|
||||||
if (ctx.video.paused) {
|
if (ctx.video.paused) {
|
||||||
ctx.video.play();
|
void ctx.video.play().catch(() => {});
|
||||||
} else {
|
} else {
|
||||||
ctx.video.pause();
|
ctx.video.pause();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const rewind = (ctx: KeyboardShortcutContext, seconds: number = 10) => {
|
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) => {
|
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) => {
|
export const mute = (ctx: KeyboardShortcutContext) => {
|
||||||
|
|
@ -36,6 +40,7 @@ export const mute = (ctx: KeyboardShortcutContext) => {
|
||||||
export const changeVolume = (ctx: KeyboardShortcutContext, delta: number) => {
|
export const changeVolume = (ctx: KeyboardShortcutContext, delta: number) => {
|
||||||
const volume = clampMediaVolume(ctx.video.volume + delta);
|
const volume = clampMediaVolume(ctx.video.volume + delta);
|
||||||
ctx.video.volume = volume;
|
ctx.video.volume = volume;
|
||||||
|
ctx.video.muted = volume <= 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const changeSpeed = (ctx: KeyboardShortcutContext, delta: number) => {
|
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)
|
// Frame step by ~33ms (approximately 1 frame at 30fps, ~16.7ms at 60fps)
|
||||||
const frameStep = 'forward' === direction ? 0.033 : -0.033;
|
const frameStep = 'forward' === direction ? 0.033 : -0.033;
|
||||||
ctx.video.currentTime = Math.max(
|
clampMediaTime(ctx.video, ctx.video.currentTime + frameStep);
|
||||||
0,
|
|
||||||
Math.min(ctx.video.duration, ctx.video.currentTime + frameStep),
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const seekToPercent = (ctx: KeyboardShortcutContext, percent: number) => {
|
export const seekToPercent = (ctx: KeyboardShortcutContext, percent: number) => {
|
||||||
ctx.video.currentTime = (percent / 100) * ctx.video.duration;
|
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) => {
|
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) => {
|
export const seekForward = (ctx: KeyboardShortcutContext, seconds: number = 5) => {
|
||||||
clampMediaTime(ctx.video, ctx.video.currentTime + seconds);
|
forward(ctx, seconds);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fullscreen = (video: HTMLVideoElement) => {
|
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',
|
'marked-gfm-heading-id',
|
||||||
'hls.js',
|
'hls.js',
|
||||||
'assjs',
|
'assjs',
|
||||||
'@vue/devtools-core',
|
|
||||||
'@vue/devtools-kit',
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,15 @@
|
||||||
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
|
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
|
||||||
import { ref } from 'vue';
|
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', () => {
|
describe('usePlayerShortcuts', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
document.body.innerHTML = '';
|
document.body.innerHTML = '';
|
||||||
|
|
@ -40,10 +49,7 @@ describe('usePlayerShortcuts', () => {
|
||||||
toggleFullscreen: () => {},
|
toggleFullscreen: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handler = addEventListenerSpy.mock.calls.find((call) => call[0] === 'keydown')?.[1];
|
const handler = getKeydownHandler(addEventListenerSpy);
|
||||||
if (!handler || typeof handler !== 'function') {
|
|
||||||
throw new Error('Expected keydown handler to be registered');
|
|
||||||
}
|
|
||||||
|
|
||||||
const preventDefault = mock(() => {});
|
const preventDefault = mock(() => {});
|
||||||
const stopPropagation = mock(() => {});
|
const stopPropagation = mock(() => {});
|
||||||
|
|
@ -63,6 +69,51 @@ describe('usePlayerShortcuts', () => {
|
||||||
addEventListenerSpy.mockRestore();
|
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 () => {
|
it('close_help_first', async () => {
|
||||||
const { usePlayerShortcuts } = await import('~/composables/usePlayerShortcuts');
|
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