Compare commits

...

3 Commits

Author SHA1 Message Date
bymyself
dea1c51875 fix: use Promise.allSettled to keep downloadingAll true through batch
`Promise.all` rejects on first failure, flipping `downloadingAll` back to
false while remaining downloads are still in flight. That reopens the
overlap guard and allows a second batch to start before the first has
actually finished.

Use `Promise.allSettled` so the flag stays true until every download
settles, then check results to surface a single error toast on any
failure.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11410#discussion_r3127279583
2026-05-03 21:49:14 -07:00
bymyself
4eeb900e56 fix: use generic failure i18n key in single-asset download toast
`downloadAssets` is shared across all asset types (image, video, audio,
model, etc.), so an image-specific failure toast is misleading.
Switch to the existing generic `g.failedToDownloadFile` key.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11410#discussion_r3127279579
2026-05-03 21:49:14 -07:00
bymyself
db617fa3cc feat: add loading spinner to download buttons during cloud blob fetch
Expose a loading state from a new useDownloadFile composable that
wraps the async blob fetch lifecycle. Download buttons on output
nodes (image, video, audio, carousel), linear mode, and the assets
panel now show a spinner while the download is in progress.

- Add downloadFileAsync to downloadUtil (awaitable variant)
- Add assertValidDownloadUrl and inferDownloadFilename pure helpers
- Create useDownloadFile composable (isLoading, error, downloadIfIdle)
- downloadFile now delegates to downloadFileAsync (single code path)
- Update ImagePreview, VideoPreview, AudioPreviewPlayer, DisplayCarousel,
  LinearPreview, and useMediaAssetActions to use the composable
- Use Loader.vue for accessible loading indicator
2026-05-03 21:49:14 -07:00
13 changed files with 508 additions and 88 deletions

View File

@@ -2,8 +2,11 @@ import { fromAny, fromPartial } from '@total-typescript/shoehorn'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
assertValidDownloadUrl,
downloadFile,
downloadFileAsync,
extractFilenameFromContentDisposition,
inferDownloadFilename,
openFileInNewTab
} from '@/base/common/downloadUtil'
@@ -214,9 +217,9 @@ describe('downloadUtil', () => {
expect(fetchMock).toHaveBeenCalledWith(testUrl)
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
await fetchPromise
await Promise.resolve() // let fetchAsBlob throw
await Promise.resolve() // let .catch handler run
expect(consoleSpy).toHaveBeenCalled()
await vi.waitFor(() => {
expect(consoleSpy).toHaveBeenCalled()
})
expect(createObjectURLSpy).not.toHaveBeenCalled()
consoleSpy.mockRestore()
})
@@ -312,6 +315,102 @@ describe('downloadUtil', () => {
})
})
describe('downloadFileAsync', () => {
it('resolves after blob download completes in cloud mode', async () => {
mockIsCloud.value = true
const testUrl = 'https://storage.googleapis.com/bucket/file.bin'
const blob = new Blob(['test'])
const blobFn = vi.fn().mockResolvedValue(blob)
const headersMock = { get: vi.fn().mockReturnValue(null) }
fetchMock.mockResolvedValue(
fromPartial<Response>({
ok: true,
status: 200,
blob: blobFn,
headers: headersMock
})
)
await downloadFileAsync(testUrl)
expect(fetchMock).toHaveBeenCalledWith(testUrl)
expect(blobFn).toHaveBeenCalled()
expect(createObjectURLSpy).toHaveBeenCalledWith(blob)
expect(mockLink.click).toHaveBeenCalled()
})
it('rejects when cloud fetch fails', async () => {
mockIsCloud.value = true
const testUrl = 'https://storage.googleapis.com/bucket/missing.bin'
fetchMock.mockResolvedValue(
fromPartial<Response>({ ok: false, status: 404, blob: vi.fn() })
)
await expect(downloadFileAsync(testUrl)).rejects.toThrow(
'Failed to download file'
)
expect(createObjectURLSpy).not.toHaveBeenCalled()
})
it('resolves immediately for non-cloud downloads', async () => {
mockIsCloud.value = false
const testUrl = 'https://example.com/image.png'
await downloadFileAsync(testUrl)
expect(mockLink.href).toBe(testUrl)
expect(mockLink.click).toHaveBeenCalled()
expect(fetchMock).not.toHaveBeenCalled()
})
it('throws synchronously for invalid URLs', async () => {
await expect(downloadFileAsync('')).rejects.toThrow(
'Invalid URL provided for download'
)
})
})
describe('assertValidDownloadUrl', () => {
it('throws for empty string', () => {
expect(() => assertValidDownloadUrl('')).toThrow(
'Invalid URL provided for download'
)
})
it('throws for whitespace-only string', () => {
expect(() => assertValidDownloadUrl(' ')).toThrow(
'Invalid URL provided for download'
)
})
it('does not throw for valid URL', () => {
expect(() => assertValidDownloadUrl('https://example.com')).not.toThrow()
})
})
describe('inferDownloadFilename', () => {
it('prefers explicit filename over URL extraction', () => {
expect(
inferDownloadFilename(
'https://example.com/file?filename=url-name.png',
'explicit.png'
)
).toBe('explicit.png')
})
it('falls back to URL filename parameter', () => {
expect(
inferDownloadFilename('https://example.com/file?filename=url-name.png')
).toBe('url-name.png')
})
it('falls back to default when no filename available', () => {
expect(inferDownloadFilename('https://example.com/file')).toBe(
'download.png'
)
})
})
describe('openFileInNewTab', () => {
let windowOpenSpy: ReturnType<typeof vi.spyOn>

View File

@@ -10,6 +10,16 @@ import { useToastStore } from '@/platform/updates/common/toastStore'
// Constants
const DEFAULT_DOWNLOAD_FILENAME = 'download.png'
export function assertValidDownloadUrl(url: string): void {
if (!url || typeof url !== 'string' || url.trim().length === 0) {
throw new Error('Invalid URL provided for download')
}
}
export function inferDownloadFilename(url: string, filename?: string): string {
return filename || extractFilenameFromUrl(url) || DEFAULT_DOWNLOAD_FILENAME
}
/**
* Trigger a download by creating a temporary anchor element
* @param href - The URL or blob URL to download
@@ -27,24 +37,41 @@ function triggerLinkDownload(href: string, filename: string): void {
}
/**
* Download a file from a URL by creating a temporary anchor element
* Download a file from a URL by creating a temporary anchor element.
* Fire-and-forget: errors on cloud blob fetches are logged but not thrown.
* Use {@link downloadFileAsync} when you need to await completion or track loading state.
* @param url - The URL of the file to download (must be a valid URL string)
* @param filename - Optional filename override (will use URL filename or default if not provided)
* @throws {Error} If the URL is invalid or empty
*/
export function downloadFile(url: string, filename?: string): void {
if (!url || typeof url !== 'string' || url.trim().length === 0) {
throw new Error('Invalid URL provided for download')
}
void downloadFileAsync(url, filename).catch((error) => {
console.error('Failed to download file', error)
})
}
const inferredFilename =
filename || extractFilenameFromUrl(url) || DEFAULT_DOWNLOAD_FILENAME
/**
* Async version of {@link downloadFile} that returns a Promise.
* On cloud, the promise resolves after the blob fetch and browser download trigger complete.
* On non-cloud, resolves immediately after triggering the anchor download.
* Use this when you need to track download completion (e.g. for loading spinners).
* @param url - The URL of the file to download (must be a valid URL string)
* @param filename - Optional filename override
* @throws {Error} If the URL is invalid or empty, or if the cloud blob fetch fails
*/
export async function downloadFileAsync(
url: string,
filename?: string
): Promise<void> {
assertValidDownloadUrl(url)
const inferredFilename = inferDownloadFilename(url, filename)
if (isCloud) {
// Assets from cross-origin (e.g., GCS) cannot be downloaded this way
void downloadViaBlobFetch(url, inferredFilename).catch((error) => {
console.error('Failed to download file', error)
})
try {
await downloadViaBlobFetch(url, inferredFilename)
} catch {
throw new Error('Failed to download file')
}
return
}

View File

@@ -0,0 +1,132 @@
import { nextTick } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useDownloadFile } from '@/base/common/useDownloadFile'
const { mockDownloadFileAsync } = vi.hoisted(() => ({
mockDownloadFileAsync: vi.fn()
}))
vi.mock('@/base/common/downloadUtil', () => ({
downloadFileAsync: mockDownloadFileAsync
}))
describe('useDownloadFile', () => {
beforeEach(() => {
mockDownloadFileAsync.mockReset()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('starts with isLoading false and no error', () => {
const { isLoading, error } = useDownloadFile()
expect(isLoading.value).toBe(false)
expect(error.value).toBeUndefined()
})
it('sets isLoading true while download is in progress', async () => {
let resolveDownload!: () => void
mockDownloadFileAsync.mockReturnValue(
new Promise<void>((resolve) => {
resolveDownload = resolve
})
)
const { isLoading, downloadIfIdle } = useDownloadFile()
const promise = downloadIfIdle('https://example.com/file.png')
await nextTick()
expect(isLoading.value).toBe(true)
resolveDownload()
await promise
expect(isLoading.value).toBe(false)
})
it('sets error ref when download fails', async () => {
mockDownloadFileAsync.mockRejectedValue(new Error('Network error'))
const { error, downloadIfIdle } = useDownloadFile()
await downloadIfIdle('https://example.com/file.png')
expect(error.value).toBeInstanceOf(Error)
expect(error.value?.message).toBe('Network error')
})
it('clears error on next successful download', async () => {
mockDownloadFileAsync.mockRejectedValueOnce(new Error('fail'))
mockDownloadFileAsync.mockResolvedValueOnce(undefined)
const { error, isLoading, downloadIfIdle } = useDownloadFile()
await downloadIfIdle('https://example.com/a.png')
expect(error.value).toBeDefined()
expect(isLoading.value).toBe(false)
await downloadIfIdle('https://example.com/b.png')
expect(error.value).toBeUndefined()
})
it('passes url and filename to downloadFileAsync', async () => {
mockDownloadFileAsync.mockResolvedValue(undefined)
const { downloadIfIdle } = useDownloadFile()
await downloadIfIdle('https://example.com/file.png', 'custom.png')
expect(mockDownloadFileAsync).toHaveBeenCalledWith(
'https://example.com/file.png',
'custom.png'
)
})
it('ignores concurrent calls while download is in progress', async () => {
let resolveDownload!: () => void
mockDownloadFileAsync.mockReturnValue(
new Promise<void>((resolve) => {
resolveDownload = resolve
})
)
const { isLoading, downloadIfIdle } = useDownloadFile()
const promise = downloadIfIdle('https://example.com/a.png')
await nextTick()
expect(isLoading.value).toBe(true)
await downloadIfIdle('https://example.com/b.png')
expect(mockDownloadFileAsync).toHaveBeenCalledTimes(1)
resolveDownload()
await promise
expect(isLoading.value).toBe(false)
})
it('tracks loading independently per instance', async () => {
let resolveFirst!: () => void
mockDownloadFileAsync
.mockReturnValueOnce(
new Promise<void>((resolve) => {
resolveFirst = resolve
})
)
.mockResolvedValueOnce(undefined)
const first = useDownloadFile()
const second = useDownloadFile()
const promise1 = first.downloadIfIdle('https://example.com/a.png')
await nextTick()
expect(first.isLoading.value).toBe(true)
expect(second.isLoading.value).toBe(false)
await second.downloadIfIdle('https://example.com/b.png')
expect(second.isLoading.value).toBe(false)
expect(first.isLoading.value).toBe(true)
resolveFirst()
await promise1
expect(first.isLoading.value).toBe(false)
})
})

View File

@@ -0,0 +1,29 @@
import type { Ref } from 'vue'
import { ref } from 'vue'
import { downloadFileAsync } from '@/base/common/downloadUtil'
export function useDownloadFile(): {
isLoading: Ref<boolean>
error: Ref<Error | undefined>
downloadIfIdle: (url: string, filename?: string) => Promise<void>
} {
const isLoading = ref(false)
const error = ref<Error | undefined>()
async function downloadIfIdle(url: string, filename?: string): Promise<void> {
if (isLoading.value) return
error.value = undefined
isLoading.value = true
try {
await downloadFileAsync(url, filename)
} catch (e) {
error.value =
e instanceof Error ? e : new Error('Failed to download file')
} finally {
isLoading.value = false
}
}
return { isLoading, error, downloadIfIdle }
}

View File

@@ -3,7 +3,7 @@ import { inject } from 'vue'
import { useI18n } from 'vue-i18n'
import ConfirmationDialogContent from '@/components/dialog/content/ConfirmationDialogContent.vue'
import { downloadFile } from '@/base/common/downloadUtil'
import { downloadFile, downloadFileAsync } from '@/base/common/downloadUtil'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { isCloud } from '@/platform/distribution/types'
import { useWorkflowActionsService } from '@/platform/workflow/core/services/workflowActionsService'
@@ -71,6 +71,10 @@ export function useMediaAssetActions() {
* 2+ assets or with any asset whose job has `outputCount > 1`.
* Falls back to direct downloads in OSS mode and for single single-output
* assets. With no argument, uses the asset from `MediaAssetKey` context.
*
* Single-asset downloads use the awaitable `downloadFileAsync` so toasts
* reflect the actual blob fetch result (cloud) instead of just the
* navigator dispatch.
*/
const downloadAssets = (assets?: AssetItem[]) => {
const targetAssets =
@@ -87,6 +91,31 @@ export function useMediaAssetActions() {
return
}
if (targetAssets.length === 1) {
const asset = targetAssets[0]
const filename = getAssetDisplayName(asset)
const downloadUrl = asset.preview_url || getAssetUrl(asset)
downloadFileAsync(downloadUrl, filename).then(
() => {
toast.add({
severity: 'success',
summary: t('g.success'),
detail: t('mediaAsset.selection.downloadsStarted', 1),
life: 2000
})
},
() => {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('g.failedToDownloadFile')
})
}
)
return
}
try {
targetAssets.forEach((asset) => {
const filename = getAssetDisplayName(asset)

View File

@@ -1,13 +1,16 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { downloadFile } from '@/base/common/downloadUtil'
import { downloadFileAsync } from '@/base/common/downloadUtil'
import { useDownloadFile } from '@/base/common/useDownloadFile'
import Loader from '@/components/loader/Loader.vue'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
import { useAppMode } from '@/composables/useAppMode'
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { extractWorkflowFromAsset } from '@/platform/workflow/utils/workflowExtractionUtil'
import ImagePreview from '@/renderer/extensions/linearMode/ImagePreview.vue'
@@ -23,7 +26,13 @@ import { app } from '@/scripts/app'
import type { ResultItemImpl } from '@/stores/queueStore'
const { t } = useI18n()
const toastStore = useToastStore()
const mediaActions = useMediaAssetActions()
const {
isLoading: downloading,
error: downloadError,
downloadIfIdle: download
} = useDownloadFile()
const { isBuilderMode, isArrangeMode } = useAppMode()
const { allOutputs, isWorkflowActive, cancelActiveWorkflowJobs } =
useOutputHistory()
@@ -39,6 +48,16 @@ const canShowPreview = ref(true)
const latentPreview = ref<string>()
const showSkeleton = ref(false)
watch(downloadError, (err) => {
if (err) {
toastStore.add({
severity: 'error',
summary: t('g.error'),
detail: t('g.failedToDownloadImage')
})
}
})
function handleSelection(sel: OutputSelection) {
selectedItem.value = sel.asset
selectedOutput.value = sel.output
@@ -47,9 +66,35 @@ function handleSelection(sel: OutputSelection) {
showSkeleton.value = sel.showSkeleton ?? false
}
function downloadAsset(item?: AssetItem) {
for (const output of allOutputs(item))
downloadFile(output.url, output.filename)
const downloadingAll = ref(false)
async function downloadAsset(item?: AssetItem) {
if (downloadingAll.value) return
const outputs = allOutputs(item)
if (outputs.length === 0) return
downloadingAll.value = true
try {
const results = await Promise.allSettled(
outputs.map((output) => downloadFileAsync(output.url, output.filename))
)
if (results.some((result) => result.status === 'rejected')) {
toastStore.add({
severity: 'error',
summary: t('g.error'),
detail: t('g.failedToDownloadFile')
})
}
} finally {
downloadingAll.value = false
}
}
function handleSingleDownload() {
if (!selectedOutput.value?.url) return
void download(selectedOutput.value.url)
}
async function loadWorkflow(item: AssetItem | undefined) {
@@ -93,13 +138,11 @@ async function rerun(e: Event) {
v-tooltip.top="t('g.download')"
size="icon"
:aria-label="t('g.download')"
@click="
() => {
if (selectedOutput?.url) downloadFile(selectedOutput.url)
}
"
:disabled="downloading"
@click="handleSingleDownload"
>
<i class="icon-[lucide--download]" />
<Loader v-if="downloading" size="sm" />
<i v-else class="icon-[lucide--download]" />
</Button>
<Button
v-if="isWorkflowActive && !selectedItem"

View File

@@ -9,7 +9,7 @@ import type { ComponentProps } from 'vue-component-type-helpers'
import VideoPreview from '@/renderer/extensions/vueNodes/VideoPreview.vue'
vi.mock('@/base/common/downloadUtil', () => ({
downloadFile: vi.fn()
downloadFileAsync: vi.fn().mockResolvedValue(undefined)
}))
const i18n = createI18n({

View File

@@ -64,9 +64,16 @@
:class="actionButtonClass"
:title="$t('g.downloadVideo')"
:aria-label="$t('g.downloadVideo')"
:disabled="downloading"
@click="handleDownload"
>
<i class="icon-[lucide--download] size-4" />
<i
:class="
downloading
? 'icon-[lucide--loader-circle] size-4 animate-spin'
: 'icon-[lucide--download] size-4'
"
/>
</button>
<!-- Close Button -->
@@ -125,7 +132,7 @@ import Skeleton from 'primevue/skeleton'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { downloadFile } from '@/base/common/downloadUtil'
import { useDownloadFile } from '@/base/common/useDownloadFile'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { cn } from '@comfyorg/tailwind-utils'
@@ -140,6 +147,23 @@ const props = defineProps<VideoPreviewProps>()
const { t } = useI18n()
const nodeOutputStore = useNodeOutputStore()
const {
isLoading: downloading,
error: downloadError,
downloadIfIdle: download
} = useDownloadFile()
watch(downloadError, (err) => {
if (err) {
useToast().add({
severity: 'error',
summary: t('g.error'),
detail: t('g.failedToDownloadVideo'),
life: 3000,
group: 'video-preview'
})
}
})
const actionButtonClass =
'flex h-8 min-h-8 items-center justify-center gap-2.5 rounded-lg border-0 bg-button-surface px-2 py-2 text-button-surface-contrast shadow-sm transition-colors duration-200 hover:bg-button-hover-surface focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-button-surface-contrast focus-visible:ring-offset-2 focus-visible:ring-offset-transparent cursor-pointer'
@@ -201,17 +225,7 @@ const handleVideoError = () => {
}
const handleDownload = () => {
try {
downloadFile(currentVideoUrl.value)
} catch (error) {
useToast().add({
severity: 'error',
summary: 'Error',
detail: t('g.failedToDownloadVideo'),
life: 3000,
group: 'video-preview'
})
}
void download(currentVideoUrl.value)
}
const handleRemove = () => {

View File

@@ -7,12 +7,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import { downloadFile } from '@/base/common/downloadUtil'
import { downloadFileAsync } from '@/base/common/downloadUtil'
import ImagePreview from '@/renderer/extensions/vueNodes/components/ImagePreview.vue'
// Mock downloadFile to avoid DOM errors
vi.mock('@/base/common/downloadUtil', () => ({
downloadFile: vi.fn()
downloadFileAsync: vi.fn().mockResolvedValue(undefined)
}))
const i18n = createI18n({
@@ -145,7 +144,10 @@ describe('ImagePreview', () => {
})
await user.click(downloadButton)
expect(downloadFile).toHaveBeenCalledWith(defaultProps.imageUrls[0])
expect(downloadFileAsync).toHaveBeenCalledWith(
defaultProps.imageUrls[0],
undefined
)
})
it('switches images when navigation dots are clicked', async () => {

View File

@@ -94,9 +94,16 @@
:class="actionButtonClass"
:title="$t('g.downloadImage')"
:aria-label="$t('g.downloadImage')"
:disabled="downloading"
@click="handleDownload"
>
<i class="icon-[lucide--download] size-4" />
<i
:class="
downloading
? 'icon-[lucide--loader-circle] size-4 animate-spin'
: 'icon-[lucide--download] size-4'
"
/>
</button>
<!-- Back to Grid Button -->
@@ -170,8 +177,8 @@ import { useTimeoutFn } from '@vueuse/core'
import { computed, nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { downloadFile } from '@/base/common/downloadUtil'
import Skeleton from '@/components/ui/skeleton/Skeleton.vue'
import { useDownloadFile } from '@/base/common/useDownloadFile'
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
@@ -191,6 +198,21 @@ const { t } = useI18n()
const maskEditor = useMaskEditor()
const nodeOutputStore = useNodeOutputStore()
const toastStore = useToastStore()
const {
isLoading: downloading,
error: downloadError,
downloadIfIdle: download
} = useDownloadFile()
watch(downloadError, (err) => {
if (err) {
toastStore.add({
severity: 'error',
summary: t('g.error'),
detail: t('g.failedToDownloadImage')
})
}
})
const actionButtonClass =
'flex h-8 min-h-8 cursor-pointer items-center justify-center rounded-lg border-0 bg-base-foreground p-2 text-base-background shadow-interface transition-colors duration-200 hover:bg-base-foreground/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-base-foreground focus-visible:ring-offset-2'
@@ -288,15 +310,7 @@ function handleEditMask() {
}
function handleDownload() {
try {
downloadFile(currentImageUrl.value)
} catch {
toastStore.add({
severity: 'error',
summary: t('g.error'),
detail: t('g.failedToDownloadImage')
})
}
void download(currentImageUrl.value)
}
function setCurrentIndex(index: number) {

View File

@@ -49,9 +49,16 @@
<button
:class="actionButtonClass"
:aria-label="t('g.downloadImage')"
:disabled="downloading"
@click="handleDownload"
>
<i class="icon-[lucide--arrow-down-to-line] size-4" />
<i
:class="
downloading
? 'icon-[lucide--loader-circle] size-4 animate-spin'
: 'icon-[lucide--arrow-down-to-line] size-4'
"
/>
</button>
<button
:class="actionButtonClass"
@@ -163,7 +170,7 @@
import { computed, nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { downloadFile } from '@/base/common/downloadUtil'
import { useDownloadFile } from '@/base/common/useDownloadFile'
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
@@ -197,6 +204,21 @@ const { t } = useI18n()
const maskEditor = useMaskEditor()
const nodeOutputStore = useNodeOutputStore()
const toastStore = useToastStore()
const {
isLoading: downloading,
error: downloadError,
downloadIfIdle: download
} = useDownloadFile()
watch(downloadError, (err) => {
if (err) {
toastStore.add({
severity: 'error',
summary: t('g.error'),
detail: t('g.failedToDownloadImage')
})
}
})
const activeIndex = ref(0)
const displayMode = ref<DisplayMode>('single')
@@ -349,15 +371,7 @@ function handleEditMask() {
function handleDownload() {
const src = activeItem.value ? getItemSrc(activeItem.value) : ''
if (!src) return
try {
downloadFile(src)
} catch {
toastStore.add({
severity: 'error',
summary: t('g.error'),
detail: t('g.failedToDownloadImage')
})
}
void download(src)
}
function handleRemove() {

View File

@@ -13,7 +13,7 @@ vi.mock('primevue/usetoast', () => ({
}))
vi.mock('@/base/common/downloadUtil', () => ({
downloadFile: vi.fn()
downloadFileAsync: vi.fn().mockResolvedValue(undefined)
}))
const i18n = createI18n({
@@ -55,21 +55,24 @@ describe('AudioPreviewPlayer', () => {
).not.toBeInTheDocument()
})
it('calls downloadFile when download button is clicked', async () => {
const { downloadFile } = await import('@/base/common/downloadUtil')
it('calls downloadFileAsync when download button is clicked', async () => {
const { downloadFileAsync } = await import('@/base/common/downloadUtil')
const user = userEvent.setup()
renderPlayer('http://example.com/audio.mp3')
await user.click(screen.getByRole('button', { name: 'g.downloadAudio' }))
expect(downloadFile).toHaveBeenCalledWith('http://example.com/audio.mp3')
expect(downloadFileAsync).toHaveBeenCalledWith(
'http://example.com/audio.mp3',
undefined
)
})
it('shows toast on download failure', async () => {
const { downloadFile } = await import('@/base/common/downloadUtil')
vi.mocked(downloadFile).mockImplementation(() => {
throw new Error('download failed')
})
const { downloadFileAsync } = await import('@/base/common/downloadUtil')
vi.mocked(downloadFileAsync).mockRejectedValueOnce(
new Error('download failed')
)
const user = userEvent.setup()
renderPlayer('http://example.com/audio.mp3')
@@ -80,8 +83,6 @@ describe('AudioPreviewPlayer', () => {
severity: 'error'
})
)
vi.mocked(downloadFile).mockReset()
})
})
})

View File

@@ -82,10 +82,17 @@
variant="textonly"
:aria-label="$t('g.downloadAudio')"
:title="$t('g.downloadAudio')"
:disabled="downloading"
class="size-6 hover:bg-interface-menu-component-surface-hovered"
@click="handleDownload"
>
<i class="text-secondary icon-[lucide--download] size-4" />
<i
:class="
downloading
? 'text-secondary icon-[lucide--loader-circle] size-4 animate-spin'
: 'text-secondary icon-[lucide--download] size-4'
"
/>
</Button>
<!-- Options Button -->
@@ -146,20 +153,37 @@
<script setup lang="ts">
import Slider from 'primevue/slider'
import TieredMenu from 'primevue/tieredmenu'
import { computed, ref, useTemplateRef } from 'vue'
import { computed, ref, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { whenever } from '@vueuse/core'
import { useToast } from 'primevue/usetoast'
import { downloadFile } from '@/base/common/downloadUtil'
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@comfyorg/tailwind-utils'
import Button from '@/components/ui/button/Button.vue'
import { useDownloadFile } from '@/base/common/useDownloadFile'
import { formatTime } from '@/utils/formatUtil'
const { t } = useI18n()
const toast = useToast()
const {
isLoading: downloading,
error: downloadError,
downloadIfIdle: download
} = useDownloadFile()
watch(downloadError, (err) => {
if (err) {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('g.failedToDownloadFile')
})
}
})
const { hideWhenEmpty = true, showOptionsButton } = defineProps<{
hideWhenEmpty?: boolean
@@ -202,15 +226,7 @@ const togglePlayPause = () => {
const handleDownload = () => {
if (!modelValue.value) return
try {
downloadFile(modelValue.value)
} catch {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('g.failedToDownloadFile')
})
}
void download(modelValue.value)
}
const toggleMute = () => {