feat: add download button to audio preview player (#8628)

## Summary
- Adds a download icon button to the `AudioPreviewPlayer` widget for
PreviewAudio and SaveAudio nodes
- Reuses the existing `downloadFile` utility (same as video download)
- Button appears inline next to volume/options controls, matching the
player's existing UI style

## Test plan
- [x] Add a PreviewAudio or SaveAudio node, run a workflow that produces
audio output
- [x] Verify the download icon appears in the audio player controls
- [x] Click the download button and confirm the audio file downloads
correctly
- [x] Verify the button does not appear when no audio is loaded


https://github.com/user-attachments/assets/7fb2df16-82a6-40aa-a938-aed57032e30b

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8628-feat-add-download-button-to-audio-preview-player-2fe6d73d365081e3997fc45d3bb8cffc)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
Johnpaul Chiwetelu
2026-02-06 01:35:32 +01:00
committed by GitHub
parent 478cfc0b5e
commit 7d3d00858a
4 changed files with 134 additions and 15 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 80 KiB

View File

@@ -10,6 +10,7 @@
"download": "Download",
"downloadImage": "Download image",
"downloadVideo": "Download video",
"downloadAudio": "Download audio",
"editOrMaskImage": "Edit or mask image",
"editImage": "Edit image",
"decrement": "Decrement",

View File

@@ -0,0 +1,86 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import AudioPreviewPlayer from '@/renderer/extensions/vueNodes/widgets/components/audio/AudioPreviewPlayer.vue'
const mockToastAdd = vi.fn()
vi.mock('primevue/usetoast', () => ({
useToast: () => ({ add: mockToastAdd })
}))
vi.mock('@/base/common/downloadUtil', () => ({
downloadFile: vi.fn()
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} }
})
function mountPlayer(modelValue?: string) {
return mount(AudioPreviewPlayer, {
props: {
modelValue,
hideWhenEmpty: false
},
global: {
plugins: [i18n],
components: { Button },
stubs: {
TieredMenu: true,
Slider: true
}
}
})
}
function findDownloadButton(wrapper: ReturnType<typeof mountPlayer>) {
return wrapper.find('[aria-label="g.downloadAudio"]')
}
describe('AudioPreviewPlayer', () => {
describe('download button', () => {
it('shows download button when audio is loaded', () => {
const wrapper = mountPlayer('http://example.com/audio.mp3')
expect(findDownloadButton(wrapper).exists()).toBe(true)
})
it('hides download button when no audio is loaded', () => {
const wrapper = mountPlayer()
expect(findDownloadButton(wrapper).exists()).toBe(false)
})
it('calls downloadFile when download button is clicked', async () => {
const { downloadFile } = await import('@/base/common/downloadUtil')
const wrapper = mountPlayer('http://example.com/audio.mp3')
await findDownloadButton(wrapper).trigger('click')
expect(downloadFile).toHaveBeenCalledWith('http://example.com/audio.mp3')
})
it('shows toast on download failure', async () => {
const { downloadFile } = await import('@/base/common/downloadUtil')
vi.mocked(downloadFile).mockImplementation(() => {
throw new Error('download failed')
})
const wrapper = mountPlayer('http://example.com/audio.mp3')
await findDownloadButton(wrapper).trigger('click')
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({
severity: 'error'
})
)
vi.mocked(downloadFile).mockReset()
})
})
})

View File

@@ -16,11 +16,11 @@
<!-- Left Actions -->
<div class="relative flex shrink-0 items-center justify-start gap-2">
<!-- Play/Pause Button -->
<div
role="button"
:tabindex="0"
<Button
variant="textonly"
size="unset"
:aria-label="$t('g.playPause')"
class="flex size-6 cursor-pointer items-center justify-center rounded hover:bg-interface-menu-component-surface-hovered"
class="size-6 rounded"
@click="togglePlayPause"
>
<i
@@ -28,7 +28,7 @@
class="text-secondary icon-[lucide--play] size-4"
/>
<i v-else class="text-secondary icon-[lucide--pause] size-4" />
</div>
</Button>
<!-- Time Display -->
<div class="text-sm font-normal text-nowrap text-base-foreground">
@@ -57,11 +57,11 @@
<!-- Right Actions -->
<div class="relative flex shrink-0 items-center justify-start gap-2">
<!-- Volume Button -->
<div
role="button"
:tabindex="0"
<Button
variant="textonly"
size="unset"
:aria-label="$t('g.volume')"
class="flex size-6 cursor-pointer items-center justify-center rounded hover:bg-interface-menu-component-surface-hovered"
class="size-6 rounded"
@click="toggleMute"
>
<i
@@ -73,19 +73,32 @@
class="text-secondary icon-[lucide--volume-1] size-4"
/>
<i v-else class="text-secondary icon-[lucide--volume-x] size-4" />
</div>
</Button>
<!-- Download Button -->
<Button
v-if="modelValue"
size="icon-sm"
variant="textonly"
:aria-label="$t('g.downloadAudio')"
:title="$t('g.downloadAudio')"
class="size-6 hover:bg-interface-menu-component-surface-hovered"
@click="handleDownload"
>
<i class="text-secondary icon-[lucide--download] size-4" />
</Button>
<!-- Options Button -->
<div
<Button
v-if="showOptionsButton"
role="button"
:tabindex="0"
variant="textonly"
size="unset"
:aria-label="$t('g.moreOptions')"
class="flex size-6 cursor-pointer items-center justify-center rounded hover:bg-interface-menu-component-surface-hovered"
class="size-6 rounded"
@click="toggleOptionsMenu"
>
<i class="text-secondary icon-[lucide--more-vertical] size-4" />
</div>
</Button>
</div>
<!-- Options Menu -->
@@ -137,11 +150,16 @@ import { computed, ref, useTemplateRef } 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 '@/utils/tailwindUtil'
import { formatTime } from '../../utils/audioUtils'
const { t } = useI18n()
const toast = useToast()
const props = withDefaults(
defineProps<{
@@ -187,6 +205,20 @@ const togglePlayPause = () => {
isPlaying.value = !isPlaying.value
}
const handleDownload = () => {
if (!modelValue.value) return
try {
downloadFile(modelValue.value)
} catch {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('g.failedToDownloadFile'),
life: 3000
})
}
}
const toggleMute = () => {
if (audioRef.value) {
isMuted.value = !isMuted.value