-
@@ -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
(() => {
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();
diff --git a/ui/app/pages/browser/[...slug].vue b/ui/app/pages/browser/[...slug].vue
index ba126a06..45afc5b8 100644
--- a/ui/app/pages/browser/[...slug].vue
+++ b/ui/app/pages/browser/[...slug].vue
@@ -1,5 +1,11 @@
+
+
(playingNow = playing)"
/>
{
const isUpdating = ref(false);
const model_item = ref(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;
};
diff --git a/ui/app/pages/history.vue b/ui/app/pages/history.vue
index 9937fb02..52381444 100644
--- a/ui/app/pages/history.vue
+++ b/ui/app/pages/history.vue
@@ -1,5 +1,11 @@
+
+
!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()"
>
await box.alert(error)"
+ @playback-state-change="(playing: boolean) => (playingNow = playing)"
/>
@@ -826,12 +833,14 @@ const selectedElms = ref([]);
const masterSelectAll = ref(false);
const embed_url = ref('');
const video_item = ref(null);
+const playingNow = ref(false);
const expandedMessages = reactive>>({});
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,