refactor: more eye candy for video player.
This commit is contained in:
parent
b438947b0d
commit
f2d7e59420
4 changed files with 130 additions and 25 deletions
|
|
@ -2,6 +2,12 @@
|
|||
<div
|
||||
class="mx-auto flex min-h-0 w-full max-w-6xl flex-1 flex-col gap-4 px-3 py-4 sm:px-4 sm:py-5"
|
||||
>
|
||||
<div
|
||||
class="pointer-events-none fixed inset-0 z-20 bg-black/45 backdrop-blur-[1px] transition-all duration-500 ease-out"
|
||||
:class="lightsOut ? 'opacity-100' : 'opacity-0'"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="transition-transform duration-300"
|
||||
:class="{
|
||||
|
|
@ -615,7 +621,10 @@
|
|||
:open="Boolean(videoItem)"
|
||||
title="Video"
|
||||
:dismissible="true"
|
||||
:ui="{ content: 'w-full sm:max-w-5xl', body: 'p-0' }"
|
||||
:ui="{
|
||||
content: lightsOut ? 'w-full sm:max-w-5xl shadow-2xl' : 'w-full sm:max-w-5xl',
|
||||
body: 'p-0',
|
||||
}"
|
||||
@update:open="(open) => !open && closePlayer()"
|
||||
>
|
||||
<template #body>
|
||||
|
|
@ -628,6 +637,7 @@
|
|||
class="w-full"
|
||||
@closeModel="closePlayer"
|
||||
@error="async (error: string) => await box.alert(error)"
|
||||
@playback-state-change="(playing: boolean) => (playingNow = playing)"
|
||||
/>
|
||||
</template>
|
||||
</UModal>
|
||||
|
|
@ -719,6 +729,7 @@ const {
|
|||
|
||||
const embedUrl = ref('');
|
||||
const videoItem = ref<StoreItem | null>(null);
|
||||
const playingNow = ref(false);
|
||||
const autoRefreshInterval = ref<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const formUrl = ref('');
|
||||
|
|
@ -768,6 +779,7 @@ const historyEntries = computed<StoreItem[]>(() => historyItems.value);
|
|||
const hasAnyItems = computed(() => queueItems.value.length > 0 || historyEntries.value.length > 0);
|
||||
const showSections = computed(() => hasAnyItems.value || historyIsLoading.value);
|
||||
const isFormDisabled = computed(() => addInProgress.value);
|
||||
const lightsOut = computed(() => Boolean(videoItem.value && playingNow.value));
|
||||
const formContainerClass = computed(() => {
|
||||
if (showSections.value) {
|
||||
return 'max-w-full';
|
||||
|
|
@ -1027,6 +1039,7 @@ const openPlayer = (item: StoreItem): void => {
|
|||
};
|
||||
|
||||
const closePlayer = (): void => {
|
||||
playingNow.value = false;
|
||||
embedUrl.value = '';
|
||||
videoItem.value = null;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
:class="
|
||||
isFullscreen
|
||||
? 'h-screen w-screen max-h-screen max-w-none items-center justify-center rounded-none'
|
||||
: 'max-h-[70vh] max-w-full items-center justify-center sm:max-h-[72vh]'
|
||||
: 'min-h-72 max-h-[70vh] max-w-full items-center justify-center sm:min-h-88 sm:max-h-[72vh]'
|
||||
"
|
||||
>
|
||||
<button
|
||||
|
|
@ -24,6 +24,7 @@
|
|||
:src="uri(poster)"
|
||||
:alt="`${title || 'Untitled media'} preview`"
|
||||
class="block h-full w-full bg-black object-contain opacity-90 transition duration-200 group-hover:opacity-100"
|
||||
@error="handlePosterError"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
|
|
@ -57,7 +58,7 @@
|
|||
:class="
|
||||
isFullscreen
|
||||
? 'h-full w-full max-h-screen max-w-screen'
|
||||
: 'h-auto w-full max-w-full max-h-[70vh] sm:max-h-[72vh]'
|
||||
: 'h-full min-h-72 w-full max-w-full max-h-[70vh] sm:min-h-88 sm:max-h-[72vh]'
|
||||
"
|
||||
playsinline
|
||||
webkit-playsinline
|
||||
|
|
@ -70,8 +71,9 @@
|
|||
@timeupdate="handleVideoTimeUpdate"
|
||||
@play="handleVideoPlay"
|
||||
@pause="handleVideoPause"
|
||||
@click="handleVideoClick"
|
||||
@dblclick="handleVideoDoubleClick"
|
||||
@pointermove="showControls"
|
||||
@pointermove="handlePointerMove"
|
||||
@resize="scheduleAssLayoutRefresh"
|
||||
@volumechange="handleMediaVolumeChange"
|
||||
@webkitbeginfullscreen="handleVideoWebkitBeginFullscreen"
|
||||
|
|
@ -148,34 +150,39 @@
|
|||
|
||||
<div
|
||||
v-if="active"
|
||||
class="absolute inset-x-0 bottom-0 z-30 bg-linear-to-t from-black/95 via-black/70 to-transparent px-3 pb-3 pt-10 text-white transition-opacity duration-200"
|
||||
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="controlsVisible ? 'opacity-100' : 'pointer-events-none opacity-0'"
|
||||
@click.self="toggleControls"
|
||||
@pointermove="showControls"
|
||||
>
|
||||
<div class="rounded-sm border border-white/10 bg-black/45 p-3 shadow-2xl backdrop-blur-md">
|
||||
<div class="space-y-3">
|
||||
<input
|
||||
:value="progress"
|
||||
type="range"
|
||||
min="0"
|
||||
max="1000"
|
||||
step="1"
|
||||
class="h-1.5 w-full accent-white"
|
||||
aria-label="Seek video"
|
||||
@input="handleSeekInput"
|
||||
/>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<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">
|
||||
<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"
|
||||
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 text-xs font-medium text-white/90">
|
||||
<div class="min-w-0 whitespace-nowrap text-xs font-medium text-white/70">
|
||||
{{ timeLabel }}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -185,6 +192,7 @@
|
|||
color="neutral"
|
||||
variant="soft"
|
||||
size="sm"
|
||||
class="opacity-75 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"
|
||||
|
|
@ -193,6 +201,7 @@
|
|||
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'"
|
||||
@click="toggleMute"
|
||||
|
|
@ -203,7 +212,7 @@
|
|||
min="0"
|
||||
max="100"
|
||||
step="1"
|
||||
class="w-20 accent-white"
|
||||
class="w-16 accent-white opacity-70 transition-opacity hover:opacity-100 sm:w-20"
|
||||
aria-label="Video volume"
|
||||
@input="handleVolumeInput"
|
||||
/>
|
||||
|
|
@ -211,6 +220,7 @@
|
|||
color="neutral"
|
||||
variant="soft"
|
||||
size="sm"
|
||||
class="opacity-75 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"
|
||||
|
|
@ -369,6 +379,7 @@ const props = defineProps<{ item: StoreItem }>();
|
|||
const emitter = defineEmits<{
|
||||
(e: 'closeModel'): void;
|
||||
(e: 'error', message: string): void;
|
||||
(e: 'playback-state-change', isPlaying: boolean): void;
|
||||
}>();
|
||||
|
||||
const {
|
||||
|
|
@ -420,6 +431,7 @@ 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;
|
||||
|
|
@ -580,15 +592,30 @@ function handleVideoPlay() {
|
|||
void resumeGainController();
|
||||
syncVideoState();
|
||||
showControls();
|
||||
emitter('playback-state-change', true);
|
||||
}
|
||||
|
||||
function handleVideoPause() {
|
||||
syncVideoState();
|
||||
clearControlsHideTimeout();
|
||||
controlsVisible.value = true;
|
||||
emitter('playback-state-change', false);
|
||||
}
|
||||
|
||||
function handleVideoClick() {
|
||||
if (isTouchDevice.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearPendingVideoClickTimeout();
|
||||
pendingVideoClickTimeout = window.setTimeout(() => {
|
||||
pendingVideoClickTimeout = 0;
|
||||
toggleControls();
|
||||
}, 180);
|
||||
}
|
||||
|
||||
function handleVideoDoubleClick() {
|
||||
clearPendingVideoClickTimeout();
|
||||
void toggleFullscreen();
|
||||
}
|
||||
|
||||
|
|
@ -604,6 +631,17 @@ function handleMediaError(event: Event) {
|
|||
void src_error(event);
|
||||
}
|
||||
|
||||
function handlePosterError(event: Event) {
|
||||
const target = event.target as HTMLImageElement | null;
|
||||
if (!target || poster.value === '/images/placeholder.png') {
|
||||
return;
|
||||
}
|
||||
|
||||
poster.value = '/images/placeholder.png';
|
||||
hasPoster.value = false;
|
||||
target.src = uri('/images/placeholder.png');
|
||||
}
|
||||
|
||||
function handleMediaVolumeChange(event: Event) {
|
||||
const target = event.target as HTMLMediaElement | null;
|
||||
if (!target || typeof target.volume !== 'number') return;
|
||||
|
|
@ -623,6 +661,20 @@ function handleMediaVolumeChange(event: Event) {
|
|||
updateMediaSessionPosition(target);
|
||||
}
|
||||
|
||||
function handlePointerMove(event: PointerEvent) {
|
||||
if (!playerContainer.value || isTouchDevice.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = playerContainer.value.getBoundingClientRect();
|
||||
const y = event.clientY - rect.top;
|
||||
const bottomZone = Math.min(Math.max(rect.height * 0.28, 96), 180);
|
||||
|
||||
if (y >= rect.height - bottomZone) {
|
||||
showControls();
|
||||
}
|
||||
}
|
||||
|
||||
function handleSeekInput(event: Event) {
|
||||
const target = event.target as HTMLInputElement | null;
|
||||
if (!target || !videoElement.value || !duration.value) return;
|
||||
|
|
@ -778,6 +830,7 @@ function syncVideoState() {
|
|||
currentTime.value = 0;
|
||||
duration.value = 0;
|
||||
paused.value = true;
|
||||
emitter('playback-state-change', false);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -788,6 +841,7 @@ function syncVideoState() {
|
|||
duration.value = nextDuration;
|
||||
currentTime.value = nextTime;
|
||||
paused.value = video.paused;
|
||||
emitter('playback-state-change', !video.paused);
|
||||
}
|
||||
|
||||
function scheduleAssLayoutRefresh() {
|
||||
|
|
@ -839,6 +893,13 @@ function clearControlsHideTimeout() {
|
|||
}
|
||||
}
|
||||
|
||||
function clearPendingVideoClickTimeout() {
|
||||
if (pendingVideoClickTimeout) {
|
||||
window.clearTimeout(pendingVideoClickTimeout);
|
||||
pendingVideoClickTimeout = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function syncFullscreenState() {
|
||||
const fullscreenElement = getFullscreenElement();
|
||||
isFullscreen.value = Boolean(
|
||||
|
|
@ -1076,6 +1137,9 @@ async function loadPlayerInfo() {
|
|||
|
||||
playerInfo.value = response;
|
||||
|
||||
poster.value = '/images/placeholder.png';
|
||||
hasPoster.value = false;
|
||||
|
||||
if (props.item.extras?.thumbnail) {
|
||||
poster.value = `/api/thumbnail?url=${encodePath(props.item.extras.thumbnail)}`;
|
||||
hasPoster.value = true;
|
||||
|
|
@ -1293,6 +1357,7 @@ onBeforeUnmount(() => {
|
|||
}
|
||||
|
||||
clearControlsHideTimeout();
|
||||
clearPendingVideoClickTimeout();
|
||||
|
||||
if (hls) {
|
||||
hls.destroy();
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
<template>
|
||||
<div class="w-full min-w-0 max-w-full space-y-6">
|
||||
<div
|
||||
class="pointer-events-none fixed inset-0 z-20 bg-black/45 backdrop-blur-[1px] transition-all duration-500 ease-out"
|
||||
:class="lightsOut ? 'opacity-100' : 'opacity-0'"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div class="flex min-w-0 items-start gap-3">
|
||||
<span
|
||||
|
|
@ -502,6 +508,7 @@
|
|||
:item="model_item"
|
||||
class="w-full"
|
||||
@closeModel="closeModel"
|
||||
@playback-state-change="(playing: boolean) => (playingNow = playing)"
|
||||
/>
|
||||
|
||||
<GetInfo
|
||||
|
|
@ -620,6 +627,7 @@ const initialPath = (() => {
|
|||
const isUpdating = ref(false);
|
||||
|
||||
const model_item = ref<any | null>(null);
|
||||
const playingNow = ref(false);
|
||||
|
||||
const previewTitle = computed(() => {
|
||||
if (!model_item.value) {
|
||||
|
|
@ -640,7 +648,10 @@ const previewTitle = computed(() => {
|
|||
|
||||
const previewModalUi = computed(() => {
|
||||
if (model_item.value?.type === 'video') {
|
||||
return { content: 'sm:max-w-5xl', body: 'p-0' };
|
||||
return {
|
||||
content: lightsOut.value ? 'sm:max-w-5xl shadow-2xl' : 'sm:max-w-5xl',
|
||||
body: 'p-0',
|
||||
};
|
||||
}
|
||||
|
||||
if (model_item.value?.type === 'image') {
|
||||
|
|
@ -649,6 +660,7 @@ const previewModalUi = computed(() => {
|
|||
|
||||
return { content: 'sm:max-w-4xl', body: 'p-0' };
|
||||
});
|
||||
const lightsOut = computed(() => Boolean(model_item.value?.type === 'video' && playingNow.value));
|
||||
|
||||
const buildStateUrl = (dir: string, page?: number): string => {
|
||||
const params = new URLSearchParams();
|
||||
|
|
@ -730,6 +742,7 @@ watch(localSearch, async (value) => {
|
|||
});
|
||||
|
||||
const closeModel = (): void => {
|
||||
playingNow.value = false;
|
||||
model_item.value = null;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
<template>
|
||||
<main class="w-full min-w-0 max-w-full space-y-6">
|
||||
<div
|
||||
class="pointer-events-none fixed inset-0 z-20 bg-black/45 backdrop-blur-[1px] transition-all duration-500 ease-out"
|
||||
:class="lightsOut ? 'opacity-100' : 'opacity-0'"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<span
|
||||
|
|
@ -714,8 +720,8 @@
|
|||
:open="Boolean(video_item)"
|
||||
:dismissible="true"
|
||||
:title="video_item?.title || 'Player'"
|
||||
:ui="{ content: 'sm:max-w-5xl', body: 'p-0' }"
|
||||
@update:open="(open) => !open && (video_item = null)"
|
||||
:ui="{ content: lightsOut ? 'sm:max-w-5xl shadow-2xl' : 'sm:max-w-5xl', body: 'p-0' }"
|
||||
@update:open="(open) => !open && closeVideo()"
|
||||
>
|
||||
<template #body>
|
||||
<VideoPlayer
|
||||
|
|
@ -725,8 +731,9 @@
|
|||
:isControls="true"
|
||||
:item="video_item"
|
||||
class="w-full"
|
||||
@closeModel="video_item = null"
|
||||
@closeModel="closeVideo()"
|
||||
@error="async (error: string) => await box.alert(error)"
|
||||
@playback-state-change="(playing: boolean) => (playingNow = playing)"
|
||||
/>
|
||||
</template>
|
||||
</UModal>
|
||||
|
|
@ -826,12 +833,14 @@ const selectedElms = ref<string[]>([]);
|
|||
const masterSelectAll = ref(false);
|
||||
const embed_url = ref('');
|
||||
const video_item = ref<StoreItem | null>(null);
|
||||
const playingNow = ref(false);
|
||||
const expandedMessages = reactive<Record<string, Set<string>>>({});
|
||||
|
||||
const contentStyle = computed<'grid' | 'list'>(() =>
|
||||
isMobile.value ? 'grid' : display_style.value,
|
||||
);
|
||||
const showThumbnails = computed(() => show_thumbnail.value && !hideThumbnail.value);
|
||||
const lightsOut = computed(() => Boolean(video_item.value && playingNow.value));
|
||||
const paginationInfo = computed(() => ({
|
||||
...pagination.value,
|
||||
isLoading: isLoading.value,
|
||||
|
|
@ -878,6 +887,11 @@ const close_info = (): void => {
|
|||
info_view.value.useUrl = false;
|
||||
};
|
||||
|
||||
const closeVideo = (): void => {
|
||||
playingNow.value = false;
|
||||
video_item.value = null;
|
||||
};
|
||||
|
||||
const view_info = (
|
||||
url: string,
|
||||
useUrl: boolean = false,
|
||||
|
|
|
|||
Loading…
Reference in a new issue