refactor: more eye candy for video player.

This commit is contained in:
arabcoders 2026-05-05 10:10:33 +03:00
parent b438947b0d
commit f2d7e59420
4 changed files with 130 additions and 25 deletions

View file

@ -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;
};

View file

@ -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();

View file

@ -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;
};

View file

@ -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,