Files
ComfyUI_frontend/src/renderer/extensions/vueNodes/widgets/components/audio/AudioPreviewPlayer.vue
Christian Byrne 725a0a2b89 fix: remove timeouts from error toasts so they persist until dismissed (#9543)
## Summary

Remove `life` (timeout) property from all error-severity toast calls so
they persist until manually dismissed, preventing users from missing
important error messages.

## Changes

- **What**: Removed `life` property from 86 error toast calls across 46
files. Error toasts now use PrimeVue's default behavior (no
auto-dismiss). Non-error toasts (success, warn, info) are unchanged.
- Also fixed a pre-existing lint issue in `TaskListPanel.vue` (`import {
t } from '@/i18n'` → `useI18n()`)

## Review Focus

- One conditional toast in `useMediaAssetActions.ts` intentionally keeps
`life` because its severity alternates between `warn` and `error`

Fixes
https://www.notion.so/comfy-org/Implement-Remove-timeouts-for-all-error-toasts-31b6d73d365081cead54fddc77ae7c3d

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9543-fix-remove-timeouts-from-error-toasts-so-they-persist-until-dismissed-31c6d73d365081fa8d30f6366e9bfe38)
by [Unito](https://www.unito.io)
2026-03-07 15:08:13 -08:00

319 lines
8.3 KiB
Vue

<template>
<div class="relative" @pointerdown.stop>
<div
v-if="!hideWhenEmpty || modelValue"
class="relative box-border flex h-16 w-full items-center justify-start gap-4 rounded-lg bg-component-node-widget-background px-4 py-0"
>
<!-- Hidden audio element -->
<audio
ref="audioRef"
:src="modelValue"
@loadedmetadata="handleLoadedMetadata"
@timeupdate="handleTimeUpdate"
@ended="handleEnded"
/>
<!-- Left Actions -->
<div class="relative flex shrink-0 items-center justify-start gap-2">
<!-- Play/Pause Button -->
<Button
variant="textonly"
size="unset"
:aria-label="$t('g.playPause')"
class="size-6 rounded-sm"
@click="togglePlayPause"
>
<i
v-if="!isPlaying"
class="text-secondary icon-[lucide--play] size-4"
/>
<i v-else class="text-secondary icon-[lucide--pause] size-4" />
</Button>
<!-- Time Display -->
<div class="text-sm font-normal text-nowrap text-base-foreground">
{{ formatTime(currentTime) }} / {{ formatTime(duration) }}
</div>
</div>
<!-- Progress Bar -->
<div class="relative h-0.5 flex-1 rounded-full bg-interface-stroke">
<div
class="absolute top-0 left-0 h-full rounded-full bg-button-icon transition-all"
:style="{ width: `${progressPercentage}%` }"
/>
<input
type="range"
:value="progressPercentage"
min="0"
max="100"
step="0.1"
:aria-label="$t('g.audioProgress')"
class="absolute inset-0 w-full cursor-pointer opacity-0"
@input="handleSeek"
/>
</div>
<!-- Right Actions -->
<div class="relative flex shrink-0 items-center justify-start gap-2">
<!-- Volume Button -->
<Button
variant="textonly"
size="unset"
:aria-label="$t('g.volume')"
class="size-6 rounded-sm"
@click="toggleMute"
>
<i
v-if="showVolumeTwo"
class="text-secondary icon-[lucide--volume-2] size-4"
/>
<i
v-else-if="showVolumeOne"
class="text-secondary icon-[lucide--volume-1] size-4"
/>
<i v-else class="text-secondary icon-[lucide--volume-x] size-4" />
</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 -->
<Button
v-if="showOptionsButton"
variant="textonly"
size="unset"
:aria-label="$t('g.moreOptions')"
class="size-6 rounded-sm"
@click="toggleOptionsMenu"
>
<i class="text-secondary icon-[lucide--more-vertical] size-4" />
</Button>
</div>
<!-- Options Menu -->
<TieredMenu
ref="optionsMenu"
:model="menuItems"
popup
class="audio-player-menu"
:pt:root:class="
cn('border-component-node-border bg-component-node-widget-background')
"
:pt:submenu:class="cn('bg-component-node-widget-background')"
>
<template #item="{ item }">
<div v-if="item.key === 'volume'" class="w-48 px-4 py-2">
<label class="mb-2 block text-xs text-base-foreground">{{
item.label
}}</label>
<Slider
:model-value="volume * 10"
:min="0"
:max="10"
:step="1"
class="w-full"
@update:model-value="handleVolumeChange"
/>
</div>
<div
v-else
class="flex cursor-pointer items-center px-4 py-2 text-xs hover:bg-white/10"
@click="item.onClick?.()"
>
<span class="text-base-foreground">{{ item.label }}</span>
<i
v-if="item.selected"
class="ml-auto icon-[lucide--check] size-4 text-base-foreground"
/>
</div>
</template>
</TieredMenu>
</div>
</div>
</template>
<script setup lang="ts">
import Slider from 'primevue/slider'
import TieredMenu from 'primevue/tieredmenu'
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 { hideWhenEmpty = true, showOptionsButton } = defineProps<{
hideWhenEmpty?: boolean
showOptionsButton?: boolean
}>()
// Refs
const audioRef = useTemplateRef('audioRef')
const optionsMenu = ref()
const isPlaying = ref(false)
const isMuted = ref(false)
const volume = ref(1)
const currentTime = ref(0)
const duration = ref(0)
const playbackRate = ref(1)
// Computed
const progressPercentage = computed(() => {
if (!duration.value || duration.value === 0) return 0
return (currentTime.value / duration.value) * 100
})
const modelValue = defineModel<string>()
const showVolumeTwo = computed(() => !isMuted.value && volume.value > 0.5)
const showVolumeOne = computed(() => isMuted.value && volume.value > 0)
// Playback controls
const togglePlayPause = () => {
if (!audioRef.value || !audioRef.value.src) {
return
}
if (isPlaying.value) {
audioRef.value.pause()
} else {
void audioRef.value.play()
}
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')
})
}
}
const toggleMute = () => {
if (audioRef.value) {
isMuted.value = !isMuted.value
audioRef.value.muted = isMuted.value
}
}
const handleSeek = (event: Event) => {
const target = event.target as HTMLInputElement
const value = parseFloat(target.value)
if (audioRef.value && duration.value > 0) {
const newTime = (value / 100) * duration.value
audioRef.value.currentTime = newTime
currentTime.value = newTime
}
}
// Audio events
const handleLoadedMetadata = () => {
if (audioRef.value) {
duration.value = audioRef.value.duration
}
}
const handleTimeUpdate = () => {
if (audioRef.value) {
currentTime.value = audioRef.value.currentTime
}
}
const handleEnded = () => {
isPlaying.value = false
currentTime.value = 0
}
// Options menu
const toggleOptionsMenu = (event: Event) => {
optionsMenu.value?.toggle(event)
}
const setPlaybackSpeed = (speed: number) => {
playbackRate.value = speed
if (audioRef.value) {
audioRef.value.playbackRate = speed
}
}
const handleVolumeChange = (value: number | number[]) => {
const numValue = Array.isArray(value) ? value[0] : value
volume.value = numValue / 10
if (audioRef.value) {
audioRef.value.volume = volume.value
if (volume.value > 0 && isMuted.value) {
isMuted.value = false
audioRef.value.muted = false
}
}
}
const menuItems = computed(() => [
{
label: t('g.playbackSpeed'),
items: [
{
label: t('g.halfSpeed'),
onClick: () => setPlaybackSpeed(0.5),
selected: playbackRate.value === 0.5
},
{
label: t('g.1x'),
onClick: () => setPlaybackSpeed(1),
selected: playbackRate.value === 1
},
{
label: t('g.2x'),
onClick: () => setPlaybackSpeed(2),
selected: playbackRate.value === 2
}
]
},
{
label: t('g.volume'),
key: 'volume'
}
])
whenever(
modelValue,
() => {
isPlaying.value = false
audioRef.value?.pause()
void audioRef.value?.load()
},
{ immediate: true }
)
</script>
<style scoped>
.audio-player-menu {
--p-tieredmenu-item-focus-background: rgb(255 255 255 / 0.1);
--p-tieredmenu-item-active-background: rgb(255 255 255 / 0.1);
}
</style>