mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-03 06:47:33 +00:00
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:
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -6,10 +6,6 @@ export function useMediaAssetActions() {
|
||||
console.log('Asset selected:', asset)
|
||||
}
|
||||
|
||||
const viewAsset = (assetId: string) => {
|
||||
console.log('Viewing asset:', assetId)
|
||||
}
|
||||
|
||||
const downloadAsset = (assetId: string) => {
|
||||
console.log('Downloading asset:', assetId)
|
||||
}
|
||||
@@ -48,7 +44,6 @@ export function useMediaAssetActions() {
|
||||
|
||||
return {
|
||||
selectAsset,
|
||||
viewAsset,
|
||||
downloadAsset,
|
||||
deleteAsset,
|
||||
playAsset,
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
import type { AssetMeta } from '../schemas/mediaAssetSchema'
|
||||
import { useMediaAssetGalleryStore } from './useMediaAssetGalleryStore'
|
||||
|
||||
vi.mock('@/stores/queueStore', () => ({
|
||||
ResultItemImpl: vi.fn().mockImplementation((data) => ({
|
||||
...data,
|
||||
url: ''
|
||||
}))
|
||||
}))
|
||||
|
||||
describe('useMediaAssetGalleryStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
describe('openSingle', () => {
|
||||
it('should convert AssetMeta to ResultItemImpl format', () => {
|
||||
const store = useMediaAssetGalleryStore()
|
||||
const mockAsset: AssetMeta = {
|
||||
id: 'test-1',
|
||||
name: 'test-image.png',
|
||||
kind: 'image',
|
||||
src: 'https://example.com/image.png',
|
||||
size: 1024,
|
||||
tags: [],
|
||||
created_at: '2025-01-01'
|
||||
}
|
||||
|
||||
store.openSingle(mockAsset)
|
||||
|
||||
expect(ResultItemImpl).toHaveBeenCalledWith({
|
||||
filename: 'test-image.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '0',
|
||||
mediaType: 'images'
|
||||
})
|
||||
expect(store.items).toHaveLength(1)
|
||||
expect(store.activeIndex).toBe(0)
|
||||
})
|
||||
|
||||
it('should set correct mediaType for video assets', () => {
|
||||
const store = useMediaAssetGalleryStore()
|
||||
const mockVideoAsset: AssetMeta = {
|
||||
id: 'test-2',
|
||||
name: 'test-video.mp4',
|
||||
kind: 'video',
|
||||
src: 'https://example.com/video.mp4',
|
||||
size: 2048,
|
||||
tags: [],
|
||||
created_at: '2025-01-01'
|
||||
}
|
||||
|
||||
store.openSingle(mockVideoAsset)
|
||||
|
||||
expect(ResultItemImpl).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filename: 'test-video.mp4',
|
||||
mediaType: 'video'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should set correct mediaType for audio assets', () => {
|
||||
const store = useMediaAssetGalleryStore()
|
||||
const mockAudioAsset: AssetMeta = {
|
||||
id: 'test-3',
|
||||
name: 'test-audio.mp3',
|
||||
kind: 'audio',
|
||||
src: 'https://example.com/audio.mp3',
|
||||
size: 512,
|
||||
tags: [],
|
||||
created_at: '2025-01-01'
|
||||
}
|
||||
|
||||
store.openSingle(mockAudioAsset)
|
||||
|
||||
expect(ResultItemImpl).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filename: 'test-audio.mp3',
|
||||
mediaType: 'audio'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should override url getter with asset.src', () => {
|
||||
const store = useMediaAssetGalleryStore()
|
||||
const mockAsset: AssetMeta = {
|
||||
id: 'test-4',
|
||||
name: 'test.png',
|
||||
kind: 'image',
|
||||
src: 'https://example.com/custom-url.png',
|
||||
size: 1024,
|
||||
tags: [],
|
||||
created_at: '2025-01-01'
|
||||
}
|
||||
|
||||
store.openSingle(mockAsset)
|
||||
|
||||
const resultItem = store.items[0]
|
||||
expect(resultItem.url).toBe('https://example.com/custom-url.png')
|
||||
})
|
||||
|
||||
it('should handle assets without src gracefully', () => {
|
||||
const store = useMediaAssetGalleryStore()
|
||||
const mockAsset: AssetMeta = {
|
||||
id: 'test-5',
|
||||
name: 'no-src.png',
|
||||
kind: 'image',
|
||||
src: '',
|
||||
size: 1024,
|
||||
tags: [],
|
||||
created_at: '2025-01-01'
|
||||
}
|
||||
|
||||
store.openSingle(mockAsset)
|
||||
|
||||
const resultItem = store.items[0]
|
||||
expect(resultItem.url).toBe('')
|
||||
})
|
||||
|
||||
it('should update activeIndex and items when called multiple times', () => {
|
||||
const store = useMediaAssetGalleryStore()
|
||||
const asset1: AssetMeta = {
|
||||
id: '1',
|
||||
name: 'first.png',
|
||||
kind: 'image',
|
||||
src: 'url1',
|
||||
size: 100,
|
||||
tags: [],
|
||||
created_at: '2025-01-01'
|
||||
}
|
||||
const asset2: AssetMeta = {
|
||||
id: '2',
|
||||
name: 'second.png',
|
||||
kind: 'image',
|
||||
src: 'url2',
|
||||
size: 200,
|
||||
tags: [],
|
||||
created_at: '2025-01-01'
|
||||
}
|
||||
|
||||
store.openSingle(asset1)
|
||||
expect(store.items).toHaveLength(1)
|
||||
expect(store.items[0].filename).toBe('first.png')
|
||||
|
||||
store.openSingle(asset2)
|
||||
expect(store.items).toHaveLength(1)
|
||||
expect(store.items[0].filename).toBe('second.png')
|
||||
expect(store.activeIndex).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('close', () => {
|
||||
it('should reset activeIndex to -1', () => {
|
||||
const store = useMediaAssetGalleryStore()
|
||||
const mockAsset: AssetMeta = {
|
||||
id: 'test',
|
||||
name: 'test.png',
|
||||
kind: 'image',
|
||||
src: 'test-url',
|
||||
size: 1024,
|
||||
tags: [],
|
||||
created_at: '2025-01-01'
|
||||
}
|
||||
|
||||
store.openSingle(mockAsset)
|
||||
expect(store.activeIndex).toBe(0)
|
||||
|
||||
store.close()
|
||||
expect(store.activeIndex).toBe(-1)
|
||||
})
|
||||
})
|
||||
})
|
||||
47
src/platform/assets/composables/useMediaAssetGalleryStore.ts
Normal file
47
src/platform/assets/composables/useMediaAssetGalleryStore.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, shallowRef } from 'vue'
|
||||
|
||||
import { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
import type { AssetMeta } from '../schemas/mediaAssetSchema'
|
||||
|
||||
export const useMediaAssetGalleryStore = defineStore(
|
||||
'mediaAssetGallery',
|
||||
() => {
|
||||
const activeIndex = ref(-1)
|
||||
const items = shallowRef<ResultItemImpl[]>([])
|
||||
|
||||
const close = () => {
|
||||
activeIndex.value = -1
|
||||
}
|
||||
|
||||
const openSingle = (asset: AssetMeta) => {
|
||||
// Convert AssetMeta to ResultItemImpl format
|
||||
const resultItem = new ResultItemImpl({
|
||||
filename: asset.name,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '0',
|
||||
mediaType: asset.kind === 'image' ? 'images' : asset.kind
|
||||
})
|
||||
|
||||
// Override the url getter to use asset.src
|
||||
Object.defineProperty(resultItem, 'url', {
|
||||
get() {
|
||||
return asset.src || ''
|
||||
},
|
||||
configurable: true
|
||||
})
|
||||
|
||||
items.value = [resultItem]
|
||||
activeIndex.value = 0
|
||||
}
|
||||
|
||||
return {
|
||||
activeIndex,
|
||||
items,
|
||||
close,
|
||||
openSingle
|
||||
}
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user