mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-19 22:09:37 +00:00
## Summary Add a waveform-based audio player component (`WaveAudioPlayer`) replacing the native `<audio>` element, with authenticated API fetch for cloud audio playback. ## Changes - **What**: - Add `useWaveAudioPlayer` composable with waveform visualization from audio data (Web Audio API `decodeAudioData`), playback controls, and seek support - Add `WaveAudioPlayer.vue` component with compact (inline waveform + time) and expanded (full transport controls) variants - Replace native `<audio>` in `MediaAudioTop.vue` and `ResultAudio.vue` with `WaveAudioPlayer` - Use `api.fetchApi()` instead of bare `fetch()` to include Firebase JWT auth headers, fixing 401 errors in cloud environments - Add Storybook stories and unit tests ## Review Focus - The audio URL is fetched via `api.fetchApi()` with auth headers, converted to a Blob URL, then passed to the native `<audio>` element. This avoids 401 Unauthorized in cloud environments where `/api/view` requires authentication. - URL-to-route extraction logic (`url.includes(apiBase)`) handles both full API URLs and relative paths. [screen-capture.webm](https://github.com/user-attachments/assets/44e61812-0391-4b47-a199-92927e75f8b4) ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-10158-feat-add-WaveAudioPlayer-with-waveform-visualization-and-authenticated-audio-fetch-3266d73d365081beab3fc6274c39fcd4) by [Unito](https://www.unito.io) --------- Co-authored-by: Amp <amp@ampcode.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Alexander Brown <drjkl@comfy.org>
222 lines
5.8 KiB
Vue
222 lines
5.8 KiB
Vue
<template>
|
|
<!-- Compact: [▶] [waveform] [time] -->
|
|
<div
|
|
v-if="variant === 'compact'"
|
|
:class="
|
|
cn('flex w-full gap-2', align === 'center' ? 'items-center' : 'items-end')
|
|
"
|
|
@pointerdown.stop
|
|
@click.stop
|
|
>
|
|
<Button
|
|
variant="textonly"
|
|
size="icon-sm"
|
|
class="size-7 shrink-0 rounded-full bg-muted-foreground/15 hover:bg-muted-foreground/25"
|
|
:aria-label="isPlaying ? $t('g.pause') : $t('g.play')"
|
|
:loading="loading"
|
|
@click.stop="togglePlayPause"
|
|
>
|
|
<i
|
|
v-if="!isPlaying"
|
|
class="ml-0.5 icon-[lucide--play] size-3 text-base-foreground"
|
|
/>
|
|
<i v-else class="icon-[lucide--pause] size-3 text-base-foreground" />
|
|
</Button>
|
|
|
|
<div
|
|
:ref="(el) => (waveformRef = el as HTMLElement)"
|
|
:class="
|
|
cn(
|
|
'flex min-w-0 flex-1 cursor-pointer gap-px',
|
|
align === 'center' ? 'items-center' : 'items-end'
|
|
)
|
|
"
|
|
:style="{ height: height + 'px' }"
|
|
@click="handleWaveformClick"
|
|
>
|
|
<div
|
|
v-for="(bar, index) in bars"
|
|
:key="index"
|
|
:class="
|
|
cn(
|
|
'min-h-0.5 flex-1 rounded-full',
|
|
loading
|
|
? 'bg-muted-foreground/20'
|
|
: index <= playedBarIndex
|
|
? 'bg-base-foreground'
|
|
: 'bg-muted-foreground/40'
|
|
)
|
|
"
|
|
:style="{ height: (bar.height / 100) * height + 'px' }"
|
|
/>
|
|
</div>
|
|
|
|
<span class="shrink-0 text-xs text-muted-foreground tabular-nums">
|
|
{{ formattedCurrentTime }} / {{ formattedDuration }}
|
|
</span>
|
|
</div>
|
|
|
|
<!-- Expanded: waveform / progress bar + times / transport -->
|
|
<div v-else class="flex w-full flex-col gap-4" @pointerdown.stop @click.stop>
|
|
<div
|
|
class="flex w-full items-center gap-0.5"
|
|
:style="{ height: height + 'px' }"
|
|
>
|
|
<div
|
|
v-for="(bar, index) in bars"
|
|
:key="index"
|
|
:class="
|
|
cn(
|
|
'min-h-0.5 flex-1 rounded-full',
|
|
loading ? 'bg-muted-foreground/20' : 'bg-base-foreground'
|
|
)
|
|
"
|
|
:style="{ height: (bar.height / 100) * height + 'px' }"
|
|
/>
|
|
</div>
|
|
|
|
<div class="flex flex-col gap-1">
|
|
<div
|
|
ref="progressRef"
|
|
class="relative h-1 w-full cursor-pointer rounded-full bg-muted-foreground/20"
|
|
@click="handleProgressClick"
|
|
>
|
|
<div
|
|
class="absolute top-0 left-0 h-full rounded-full bg-base-foreground"
|
|
:style="{ width: progressRatio + '%' }"
|
|
/>
|
|
</div>
|
|
<div
|
|
class="flex justify-between text-xs text-muted-foreground tabular-nums"
|
|
>
|
|
<span>{{ formattedCurrentTime }}</span>
|
|
<span>{{ formattedDuration }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-2">
|
|
<div class="w-20" />
|
|
|
|
<div class="flex flex-1 items-center justify-center gap-2">
|
|
<Button
|
|
variant="textonly"
|
|
size="icon-sm"
|
|
class="size-8 rounded-full"
|
|
:aria-label="$t('g.skipToStart')"
|
|
:disabled="loading"
|
|
@click="seekToStart"
|
|
>
|
|
<i class="icon-[lucide--skip-back] size-4 text-base-foreground" />
|
|
</Button>
|
|
<Button
|
|
variant="textonly"
|
|
size="icon-sm"
|
|
class="size-10 rounded-full bg-muted-foreground/15 hover:bg-muted-foreground/25"
|
|
:aria-label="isPlaying ? $t('g.pause') : $t('g.play')"
|
|
:loading="loading"
|
|
@click="togglePlayPause"
|
|
>
|
|
<i
|
|
v-if="!isPlaying"
|
|
class="ml-0.5 icon-[lucide--play] size-5 text-base-foreground"
|
|
/>
|
|
<i v-else class="icon-[lucide--pause] size-5 text-base-foreground" />
|
|
</Button>
|
|
<Button
|
|
variant="textonly"
|
|
size="icon-sm"
|
|
class="size-8 rounded-full"
|
|
:aria-label="$t('g.skipToEnd')"
|
|
:disabled="loading"
|
|
@click="seekToEnd"
|
|
>
|
|
<i class="icon-[lucide--skip-forward] size-4 text-base-foreground" />
|
|
</Button>
|
|
</div>
|
|
|
|
<div class="flex w-20 items-center gap-1">
|
|
<Button
|
|
variant="textonly"
|
|
size="icon-sm"
|
|
class="size-8 shrink-0 rounded-full"
|
|
:aria-label="$t('g.volume')"
|
|
:disabled="loading"
|
|
@click="toggleMute"
|
|
>
|
|
<i :class="cn(volumeIcon, 'size-4 text-base-foreground')" />
|
|
</Button>
|
|
<Slider
|
|
:model-value="[volume * 100]"
|
|
:min="0"
|
|
:max="100"
|
|
:step="1"
|
|
class="flex-1"
|
|
@update:model-value="(v) => (volume = (v?.[0] ?? 100) / 100)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<audio
|
|
:ref="(el) => (audioRef = el as HTMLAudioElement)"
|
|
:src="audioSrc"
|
|
preload="metadata"
|
|
class="hidden"
|
|
/>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, toRef } from 'vue'
|
|
|
|
import Button from '@/components/ui/button/Button.vue'
|
|
import Slider from '@/components/ui/slider/Slider.vue'
|
|
import { useWaveAudioPlayer } from '@/composables/useWaveAudioPlayer'
|
|
import { cn } from '@/utils/tailwindUtil'
|
|
|
|
const {
|
|
src,
|
|
barCount = 40,
|
|
height = 32,
|
|
align = 'center',
|
|
variant = 'compact'
|
|
} = defineProps<{
|
|
src: string
|
|
barCount?: number
|
|
height?: number
|
|
align?: 'center' | 'bottom'
|
|
variant?: 'compact' | 'expanded'
|
|
}>()
|
|
|
|
const progressRef = ref<HTMLElement>()
|
|
|
|
const {
|
|
audioRef,
|
|
waveformRef,
|
|
audioSrc,
|
|
bars,
|
|
loading,
|
|
isPlaying,
|
|
playedBarIndex,
|
|
progressRatio,
|
|
formattedCurrentTime,
|
|
formattedDuration,
|
|
togglePlayPause,
|
|
seekToStart,
|
|
seekToEnd,
|
|
volume,
|
|
volumeIcon,
|
|
toggleMute,
|
|
seekToRatio,
|
|
handleWaveformClick
|
|
} = useWaveAudioPlayer({
|
|
src: toRef(() => src),
|
|
barCount
|
|
})
|
|
|
|
function handleProgressClick(event: MouseEvent) {
|
|
if (!progressRef.value) return
|
|
const rect = progressRef.value.getBoundingClientRect()
|
|
seekToRatio((event.clientX - rect.left) / rect.width)
|
|
}
|
|
</script>
|