feat: Improve MediaAssetCard video controls and add gallery view (#6065)

## Summary
- Enhanced video control visibility logic for better UX
- Added fullscreen gallery view with zoom-in button  
- Fixed hover interaction issues with overlays

## Changes

### Video Controls
- **Before**: Controls hidden when not hovering
- **After**: Controls always visible when not playing, hover-based
during playback

### Overlay Behavior  
- **Before**: All overlays hidden during video playback
- **After**: All overlays (actions, tags, layers) show on hover even
during playback

### Gallery View
- Added zoom-in button to top-right corner (all media types except 3D)
- Integrated with existing ResultGallery component
- Gallery closes when clicking dimmed background area

### Bug Fixes
- Fixed hover flicker issue by proper event handling on overlay elements

## Test Plan
- [x] Test video controls visibility (paused vs playing)
- [x] Test overlay hover behavior during video playback
- [x] Test zoom-in button opens gallery view
- [x] Test gallery closes on background click
- [x] Test 3D assets don't show zoom button
- [x] Test in Storybook with various media types

🤖 Generated with [Claude Code](https://claude.ai/code)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6065-feat-Improve-MediaAssetCard-video-controls-and-add-gallery-view-28d6d73d3650818c90cfc5d0d00e4826)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Jin Yi
2025-10-16 03:45:55 +09:00
committed by GitHub
parent 97417736be
commit 4dab27a84e
7 changed files with 348 additions and 49 deletions

View File

@@ -1,11 +1,32 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
import { useMediaAssetGalleryStore } from '../composables/useMediaAssetGalleryStore'
import type { AssetMeta } from '../schemas/mediaAssetSchema'
import MediaAssetCard from './MediaAssetCard.vue'
const meta: Meta<typeof MediaAssetCard> = {
title: 'AssetLibrary/MediaAssetCard',
title: 'Platform/Assets/MediaAssetCard',
component: MediaAssetCard,
decorators: [
() => ({
components: { ResultGallery },
setup() {
const galleryStore = useMediaAssetGalleryStore()
return { galleryStore }
},
template: `
<div>
<story />
<ResultGallery
v-model:active-index="galleryStore.activeIndex"
:all-gallery-items="galleryStore.items"
/>
</div>
`
})
],
argTypes: {
context: {
control: 'select',

View File

@@ -33,7 +33,7 @@
:is="getTopComponent(asset.kind)"
:asset="asset"
:context="context"
@view="actions.viewAsset(asset!.id)"
@view="handleZoomClick"
@download="actions.downloadAsset(asset!.id)"
@play="actions.playAsset(asset!.id)"
@video-playing-state-changed="isVideoPlaying = $event"
@@ -41,31 +41,48 @@
/>
</template>
<!-- Actions overlay (top-left) - show on hover or when menu is open, but not when video is playing -->
<!-- Actions overlay (top-left) - show on hover or when menu is open -->
<template v-if="showActionsOverlay" #top-left>
<MediaAssetActions @menu-state-changed="isMenuOpen = $event" />
<MediaAssetActions
@menu-state-changed="isMenuOpen = $event"
@mouseenter="handleOverlayMouseEnter"
@mouseleave="handleOverlayMouseLeave"
/>
</template>
<!-- Zoom button (top-right) - show on hover, but not when video is playing -->
<!-- Zoom button (top-right) - show on hover for all media types -->
<template v-if="showZoomOverlay" #top-right>
<IconButton size="sm" @click="actions.viewAsset(asset!.id)">
<IconButton
size="sm"
@click.stop="handleZoomClick"
@mouseenter="handleOverlayMouseEnter"
@mouseleave="handleOverlayMouseLeave"
>
<i class="icon-[lucide--zoom-in] size-4" />
</IconButton>
</template>
<!-- Duration/Format chips (bottom-left) - hide when video is playing -->
<!-- Duration/Format chips (bottom-left) - show on hover even when playing -->
<template v-if="showDurationChips" #bottom-left>
<SquareChip variant="light" :label="formattedDuration" />
<SquareChip v-if="fileFormat" variant="light" :label="fileFormat" />
<div
class="flex flex-wrap items-center gap-1"
@mouseenter="handleOverlayMouseEnter"
@mouseleave="handleOverlayMouseLeave"
>
<SquareChip variant="light" :label="formattedDuration" />
<SquareChip v-if="fileFormat" variant="light" :label="fileFormat" />
</div>
</template>
<!-- Output count (bottom-right) - hide when video is playing -->
<!-- Output count (bottom-right) - show on hover even when playing -->
<template v-if="showOutputCount" #bottom-right>
<IconTextButton
type="secondary"
size="sm"
:label="context?.outputCount?.toString() ?? '0'"
@click="actions.openMoreOutputs(asset?.id || '')"
@click.stop="actions.openMoreOutputs(asset?.id || '')"
@mouseenter="handleOverlayMouseEnter"
@mouseleave="handleOverlayMouseLeave"
>
<template #icon>
<i class="icon-[lucide--layers] size-4" />
@@ -116,6 +133,7 @@ import { formatDuration } from '@/utils/formatUtil'
import { cn } from '@/utils/tailwindUtil'
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
import { useMediaAssetGalleryStore } from '../composables/useMediaAssetGalleryStore'
import type {
AssetContext,
AssetMeta,
@@ -159,10 +177,12 @@ const cardContainerRef = ref<HTMLElement>()
const isVideoPlaying = ref(false)
const isMenuOpen = ref(false)
const showVideoControls = ref(false)
const isOverlayHovered = ref(false)
const isHovered = useElementHover(cardContainerRef)
const actions = useMediaAssetActions()
const galleryStore = useMediaAssetGalleryStore()
provide(MediaAssetKey, {
asset: toRef(() => asset),
@@ -171,14 +191,14 @@ provide(MediaAssetKey, {
showVideoControls
})
const containerClasses = computed(() => {
return cn(
const containerClasses = computed(() =>
cn(
'gap-1',
selected
? 'border-3 border-zinc-900 dark-theme:border-white bg-zinc-200 dark-theme:bg-zinc-700'
: 'hover:bg-zinc-100 dark-theme:hover:bg-zinc-800'
)
})
)
const formattedDuration = computed(() => {
if (!asset?.duration) return ''
@@ -201,33 +221,58 @@ const durationChipClasses = computed(() => {
return ''
})
const showHoverActions = computed(() => {
return !loading && !!asset && (isHovered.value || isMenuOpen.value)
})
const isCardOrOverlayHovered = computed(
() => isHovered.value || isOverlayHovered.value || isMenuOpen.value
)
const showZoomButton = computed(() => {
return asset?.kind === 'image' || asset?.kind === '3D'
})
const showHoverActions = computed(
() => !loading && !!asset && isCardOrOverlayHovered.value
)
const showActionsOverlay = computed(() => {
return showHoverActions.value && !isVideoPlaying.value
})
const showActionsOverlay = computed(
() =>
showHoverActions.value &&
(!isVideoPlaying.value || isCardOrOverlayHovered.value)
)
const showZoomOverlay = computed(() => {
return showHoverActions.value && showZoomButton.value && !isVideoPlaying.value
})
const showZoomOverlay = computed(
() =>
showHoverActions.value &&
asset?.kind !== '3D' &&
(!isVideoPlaying.value || isCardOrOverlayHovered.value)
)
const showDurationChips = computed(() => {
return !loading && asset?.duration && !isVideoPlaying.value
})
const showDurationChips = computed(
() =>
!loading &&
asset?.duration &&
(!isVideoPlaying.value || isCardOrOverlayHovered.value)
)
const showOutputCount = computed(() => {
return !loading && context?.outputCount && !isVideoPlaying.value
})
const showOutputCount = computed(
() =>
!loading &&
context?.outputCount &&
(!isVideoPlaying.value || isCardOrOverlayHovered.value)
)
const handleCardClick = () => {
if (asset) {
actions.selectAsset(asset)
}
}
const handleOverlayMouseEnter = () => {
isOverlayHovered.value = true
}
const handleOverlayMouseLeave = () => {
isOverlayHovered.value = false
}
const handleZoomClick = () => {
if (asset) {
galleryStore.openSingle(asset)
}
}
</script>

View File

@@ -1,6 +1,7 @@
<template>
<div class="flex flex-col">
<IconTextButton
v-if="asset?.kind !== '3D'"
type="transparent"
class="dark-theme:text-white"
label="Inspect asset"
@@ -93,6 +94,7 @@ import { computed, inject } from 'vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
import { useMediaAssetGalleryStore } from '../composables/useMediaAssetGalleryStore'
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
import MediaAssetButtonDivider from './MediaAssetButtonDivider.vue'
@@ -102,14 +104,13 @@ const { close } = defineProps<{
const { asset, context } = inject(MediaAssetKey)!
const actions = useMediaAssetActions()
const galleryStore = useMediaAssetGalleryStore()
const showWorkflowOptions = computed(() => {
return context.value.type
})
const showWorkflowOptions = computed(() => context.value.type)
const handleInspect = () => {
if (asset.value) {
actions.viewAsset(asset.value.id)
galleryStore.openSingle(asset.value)
}
close()
}

View File

@@ -1,18 +1,19 @@
<template>
<div
class="relative h-full w-full overflow-hidden rounded bg-black"
@mouseenter="showControls = true"
@mouseleave="showControls = false"
@mouseenter="isHovered = true"
@mouseleave="isHovered = false"
>
<video
ref="videoRef"
:controls="showControls"
:controls="shouldShowControls"
preload="none"
:poster="asset.preview_url"
class="relative h-full w-full object-contain"
@click.stop
@play="onVideoPlay"
@pause="onVideoPause"
@ended="onVideoEnded"
>
<source :src="asset.src || ''" />
</video>
@@ -20,7 +21,7 @@
</template>
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import type { AssetContext, AssetMeta } from '../schemas/mediaAssetSchema'
@@ -36,22 +37,32 @@ const emit = defineEmits<{
}>()
const videoRef = ref<HTMLVideoElement>()
const showControls = ref(true)
const isHovered = ref(false)
const isPlaying = ref(false)
watch(showControls, (controlsVisible) => {
// Always show controls when not playing, hide/show based on hover when playing
const shouldShowControls = computed(() => !isPlaying.value || isHovered.value)
watch(shouldShowControls, (controlsVisible) => {
emit('videoControlsChanged', controlsVisible)
})
onMounted(() => {
emit('videoControlsChanged', showControls.value)
emit('videoControlsChanged', shouldShowControls.value)
})
const onVideoPlay = () => {
showControls.value = true
isPlaying.value = true
emit('videoPlayingStateChanged', true)
}
const onVideoPause = () => {
isPlaying.value = false
emit('videoPlayingStateChanged', false)
}
const onVideoEnded = () => {
isPlaying.value = false
emit('videoPlayingStateChanged', false)
}
</script>