mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-03 14:54:37 +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 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 type { AssetMeta } from '../schemas/mediaAssetSchema'
|
||||||
import MediaAssetCard from './MediaAssetCard.vue'
|
import MediaAssetCard from './MediaAssetCard.vue'
|
||||||
|
|
||||||
const meta: Meta<typeof MediaAssetCard> = {
|
const meta: Meta<typeof MediaAssetCard> = {
|
||||||
title: 'AssetLibrary/MediaAssetCard',
|
title: 'Platform/Assets/MediaAssetCard',
|
||||||
component: 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: {
|
argTypes: {
|
||||||
context: {
|
context: {
|
||||||
control: 'select',
|
control: 'select',
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
:is="getTopComponent(asset.kind)"
|
:is="getTopComponent(asset.kind)"
|
||||||
:asset="asset"
|
:asset="asset"
|
||||||
:context="context"
|
:context="context"
|
||||||
@view="actions.viewAsset(asset!.id)"
|
@view="handleZoomClick"
|
||||||
@download="actions.downloadAsset(asset!.id)"
|
@download="actions.downloadAsset(asset!.id)"
|
||||||
@play="actions.playAsset(asset!.id)"
|
@play="actions.playAsset(asset!.id)"
|
||||||
@video-playing-state-changed="isVideoPlaying = $event"
|
@video-playing-state-changed="isVideoPlaying = $event"
|
||||||
@@ -41,31 +41,48 @@
|
|||||||
/>
|
/>
|
||||||
</template>
|
</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>
|
<template v-if="showActionsOverlay" #top-left>
|
||||||
<MediaAssetActions @menu-state-changed="isMenuOpen = $event" />
|
<MediaAssetActions
|
||||||
|
@menu-state-changed="isMenuOpen = $event"
|
||||||
|
@mouseenter="handleOverlayMouseEnter"
|
||||||
|
@mouseleave="handleOverlayMouseLeave"
|
||||||
|
/>
|
||||||
</template>
|
</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>
|
<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" />
|
<i class="icon-[lucide--zoom-in] size-4" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</template>
|
</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>
|
<template v-if="showDurationChips" #bottom-left>
|
||||||
<SquareChip variant="light" :label="formattedDuration" />
|
<div
|
||||||
<SquareChip v-if="fileFormat" variant="light" :label="fileFormat" />
|
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>
|
</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>
|
<template v-if="showOutputCount" #bottom-right>
|
||||||
<IconTextButton
|
<IconTextButton
|
||||||
type="secondary"
|
type="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
:label="context?.outputCount?.toString() ?? '0'"
|
:label="context?.outputCount?.toString() ?? '0'"
|
||||||
@click="actions.openMoreOutputs(asset?.id || '')"
|
@click.stop="actions.openMoreOutputs(asset?.id || '')"
|
||||||
|
@mouseenter="handleOverlayMouseEnter"
|
||||||
|
@mouseleave="handleOverlayMouseLeave"
|
||||||
>
|
>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<i class="icon-[lucide--layers] size-4" />
|
<i class="icon-[lucide--layers] size-4" />
|
||||||
@@ -116,6 +133,7 @@ import { formatDuration } from '@/utils/formatUtil'
|
|||||||
import { cn } from '@/utils/tailwindUtil'
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
|
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
|
||||||
|
import { useMediaAssetGalleryStore } from '../composables/useMediaAssetGalleryStore'
|
||||||
import type {
|
import type {
|
||||||
AssetContext,
|
AssetContext,
|
||||||
AssetMeta,
|
AssetMeta,
|
||||||
@@ -159,10 +177,12 @@ const cardContainerRef = ref<HTMLElement>()
|
|||||||
const isVideoPlaying = ref(false)
|
const isVideoPlaying = ref(false)
|
||||||
const isMenuOpen = ref(false)
|
const isMenuOpen = ref(false)
|
||||||
const showVideoControls = ref(false)
|
const showVideoControls = ref(false)
|
||||||
|
const isOverlayHovered = ref(false)
|
||||||
|
|
||||||
const isHovered = useElementHover(cardContainerRef)
|
const isHovered = useElementHover(cardContainerRef)
|
||||||
|
|
||||||
const actions = useMediaAssetActions()
|
const actions = useMediaAssetActions()
|
||||||
|
const galleryStore = useMediaAssetGalleryStore()
|
||||||
|
|
||||||
provide(MediaAssetKey, {
|
provide(MediaAssetKey, {
|
||||||
asset: toRef(() => asset),
|
asset: toRef(() => asset),
|
||||||
@@ -171,14 +191,14 @@ provide(MediaAssetKey, {
|
|||||||
showVideoControls
|
showVideoControls
|
||||||
})
|
})
|
||||||
|
|
||||||
const containerClasses = computed(() => {
|
const containerClasses = computed(() =>
|
||||||
return cn(
|
cn(
|
||||||
'gap-1',
|
'gap-1',
|
||||||
selected
|
selected
|
||||||
? 'border-3 border-zinc-900 dark-theme:border-white bg-zinc-200 dark-theme:bg-zinc-700'
|
? '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'
|
: 'hover:bg-zinc-100 dark-theme:hover:bg-zinc-800'
|
||||||
)
|
)
|
||||||
})
|
)
|
||||||
|
|
||||||
const formattedDuration = computed(() => {
|
const formattedDuration = computed(() => {
|
||||||
if (!asset?.duration) return ''
|
if (!asset?.duration) return ''
|
||||||
@@ -201,33 +221,58 @@ const durationChipClasses = computed(() => {
|
|||||||
return ''
|
return ''
|
||||||
})
|
})
|
||||||
|
|
||||||
const showHoverActions = computed(() => {
|
const isCardOrOverlayHovered = computed(
|
||||||
return !loading && !!asset && (isHovered.value || isMenuOpen.value)
|
() => isHovered.value || isOverlayHovered.value || isMenuOpen.value
|
||||||
})
|
)
|
||||||
|
|
||||||
const showZoomButton = computed(() => {
|
const showHoverActions = computed(
|
||||||
return asset?.kind === 'image' || asset?.kind === '3D'
|
() => !loading && !!asset && isCardOrOverlayHovered.value
|
||||||
})
|
)
|
||||||
|
|
||||||
const showActionsOverlay = computed(() => {
|
const showActionsOverlay = computed(
|
||||||
return showHoverActions.value && !isVideoPlaying.value
|
() =>
|
||||||
})
|
showHoverActions.value &&
|
||||||
|
(!isVideoPlaying.value || isCardOrOverlayHovered.value)
|
||||||
|
)
|
||||||
|
|
||||||
const showZoomOverlay = computed(() => {
|
const showZoomOverlay = computed(
|
||||||
return showHoverActions.value && showZoomButton.value && !isVideoPlaying.value
|
() =>
|
||||||
})
|
showHoverActions.value &&
|
||||||
|
asset?.kind !== '3D' &&
|
||||||
|
(!isVideoPlaying.value || isCardOrOverlayHovered.value)
|
||||||
|
)
|
||||||
|
|
||||||
const showDurationChips = computed(() => {
|
const showDurationChips = computed(
|
||||||
return !loading && asset?.duration && !isVideoPlaying.value
|
() =>
|
||||||
})
|
!loading &&
|
||||||
|
asset?.duration &&
|
||||||
|
(!isVideoPlaying.value || isCardOrOverlayHovered.value)
|
||||||
|
)
|
||||||
|
|
||||||
const showOutputCount = computed(() => {
|
const showOutputCount = computed(
|
||||||
return !loading && context?.outputCount && !isVideoPlaying.value
|
() =>
|
||||||
})
|
!loading &&
|
||||||
|
context?.outputCount &&
|
||||||
|
(!isVideoPlaying.value || isCardOrOverlayHovered.value)
|
||||||
|
)
|
||||||
|
|
||||||
const handleCardClick = () => {
|
const handleCardClick = () => {
|
||||||
if (asset) {
|
if (asset) {
|
||||||
actions.selectAsset(asset)
|
actions.selectAsset(asset)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleOverlayMouseEnter = () => {
|
||||||
|
isOverlayHovered.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOverlayMouseLeave = () => {
|
||||||
|
isOverlayHovered.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleZoomClick = () => {
|
||||||
|
if (asset) {
|
||||||
|
galleryStore.openSingle(asset)
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<IconTextButton
|
<IconTextButton
|
||||||
|
v-if="asset?.kind !== '3D'"
|
||||||
type="transparent"
|
type="transparent"
|
||||||
class="dark-theme:text-white"
|
class="dark-theme:text-white"
|
||||||
label="Inspect asset"
|
label="Inspect asset"
|
||||||
@@ -93,6 +94,7 @@ import { computed, inject } from 'vue'
|
|||||||
import IconTextButton from '@/components/button/IconTextButton.vue'
|
import IconTextButton from '@/components/button/IconTextButton.vue'
|
||||||
|
|
||||||
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
|
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
|
||||||
|
import { useMediaAssetGalleryStore } from '../composables/useMediaAssetGalleryStore'
|
||||||
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
|
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
|
||||||
import MediaAssetButtonDivider from './MediaAssetButtonDivider.vue'
|
import MediaAssetButtonDivider from './MediaAssetButtonDivider.vue'
|
||||||
|
|
||||||
@@ -102,14 +104,13 @@ const { close } = defineProps<{
|
|||||||
|
|
||||||
const { asset, context } = inject(MediaAssetKey)!
|
const { asset, context } = inject(MediaAssetKey)!
|
||||||
const actions = useMediaAssetActions()
|
const actions = useMediaAssetActions()
|
||||||
|
const galleryStore = useMediaAssetGalleryStore()
|
||||||
|
|
||||||
const showWorkflowOptions = computed(() => {
|
const showWorkflowOptions = computed(() => context.value.type)
|
||||||
return context.value.type
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleInspect = () => {
|
const handleInspect = () => {
|
||||||
if (asset.value) {
|
if (asset.value) {
|
||||||
actions.viewAsset(asset.value.id)
|
galleryStore.openSingle(asset.value)
|
||||||
}
|
}
|
||||||
close()
|
close()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="relative h-full w-full overflow-hidden rounded bg-black"
|
class="relative h-full w-full overflow-hidden rounded bg-black"
|
||||||
@mouseenter="showControls = true"
|
@mouseenter="isHovered = true"
|
||||||
@mouseleave="showControls = false"
|
@mouseleave="isHovered = false"
|
||||||
>
|
>
|
||||||
<video
|
<video
|
||||||
ref="videoRef"
|
ref="videoRef"
|
||||||
:controls="showControls"
|
:controls="shouldShowControls"
|
||||||
preload="none"
|
preload="none"
|
||||||
:poster="asset.preview_url"
|
:poster="asset.preview_url"
|
||||||
class="relative h-full w-full object-contain"
|
class="relative h-full w-full object-contain"
|
||||||
@click.stop
|
@click.stop
|
||||||
@play="onVideoPlay"
|
@play="onVideoPlay"
|
||||||
@pause="onVideoPause"
|
@pause="onVideoPause"
|
||||||
|
@ended="onVideoEnded"
|
||||||
>
|
>
|
||||||
<source :src="asset.src || ''" />
|
<source :src="asset.src || ''" />
|
||||||
</video>
|
</video>
|
||||||
@@ -20,7 +21,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
import type { AssetContext, AssetMeta } from '../schemas/mediaAssetSchema'
|
import type { AssetContext, AssetMeta } from '../schemas/mediaAssetSchema'
|
||||||
|
|
||||||
@@ -36,22 +37,32 @@ const emit = defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const videoRef = ref<HTMLVideoElement>()
|
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)
|
emit('videoControlsChanged', controlsVisible)
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
emit('videoControlsChanged', showControls.value)
|
emit('videoControlsChanged', shouldShowControls.value)
|
||||||
})
|
})
|
||||||
|
|
||||||
const onVideoPlay = () => {
|
const onVideoPlay = () => {
|
||||||
showControls.value = true
|
isPlaying.value = true
|
||||||
emit('videoPlayingStateChanged', true)
|
emit('videoPlayingStateChanged', true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onVideoPause = () => {
|
const onVideoPause = () => {
|
||||||
|
isPlaying.value = false
|
||||||
|
emit('videoPlayingStateChanged', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onVideoEnded = () => {
|
||||||
|
isPlaying.value = false
|
||||||
emit('videoPlayingStateChanged', false)
|
emit('videoPlayingStateChanged', false)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -6,10 +6,6 @@ export function useMediaAssetActions() {
|
|||||||
console.log('Asset selected:', asset)
|
console.log('Asset selected:', asset)
|
||||||
}
|
}
|
||||||
|
|
||||||
const viewAsset = (assetId: string) => {
|
|
||||||
console.log('Viewing asset:', assetId)
|
|
||||||
}
|
|
||||||
|
|
||||||
const downloadAsset = (assetId: string) => {
|
const downloadAsset = (assetId: string) => {
|
||||||
console.log('Downloading asset:', assetId)
|
console.log('Downloading asset:', assetId)
|
||||||
}
|
}
|
||||||
@@ -48,7 +44,6 @@ export function useMediaAssetActions() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
selectAsset,
|
selectAsset,
|
||||||
viewAsset,
|
|
||||||
downloadAsset,
|
downloadAsset,
|
||||||
deleteAsset,
|
deleteAsset,
|
||||||
playAsset,
|
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