feat: Replace react-h5-audio-player with @arraypress/waveform-player and update AudioPlayer component to support waveform visualization

This commit is contained in:
2026-02-06 18:50:06 +08:00
parent 26e40039a9
commit 8f5cfd8093
10 changed files with 149 additions and 129 deletions

View File

@@ -8,6 +8,7 @@
"name": "qwen3-tts-frontend", "name": "qwen3-tts-frontend",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@arraypress/waveform-player": "^1.0.0",
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
@@ -34,7 +35,6 @@
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^19.2", "react": "^19.2",
"react-dom": "^19.2", "react-dom": "^19.2",
"react-h5-audio-player": "^3.10.1",
"react-hook-form": "^7.71.1", "react-hook-form": "^7.71.1",
"react-i18next": "^16.5.4", "react-i18next": "^16.5.4",
"react-router-dom": "^7.13.0", "react-router-dom": "^7.13.0",
@@ -73,6 +73,19 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/@arraypress/waveform-player": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@arraypress/waveform-player/-/waveform-player-1.2.0.tgz",
"integrity": "sha512-ZZq8s3DAe0rD1P6pEdTkc6TFmT/dyTyJbajqfGYNuIcNe+OtFiY3FlHfuo/GK1+bIL5TLnJTK99887RBzyaVfA==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/arraypress"
}
},
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
"version": "7.28.6", "version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz",
@@ -1065,27 +1078,6 @@
"url": "https://github.com/sponsors/nzakas" "url": "https://github.com/sponsors/nzakas"
} }
}, },
"node_modules/@iconify/react": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/@iconify/react/-/react-5.2.1.tgz",
"integrity": "sha512-37GDR3fYDZmnmUn9RagyaX+zca24jfVOMY8E1IXTqJuE8pxNtN51KWPQe3VODOWvuUurq7q9uUu3CFrpqj5Iqg==",
"license": "MIT",
"dependencies": {
"@iconify/types": "^2.0.0"
},
"funding": {
"url": "https://github.com/sponsors/cyberalien"
},
"peerDependencies": {
"react": ">=16"
}
},
"node_modules/@iconify/types": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz",
"integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==",
"license": "MIT"
},
"node_modules/@jridgewell/gen-mapping": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13", "version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -5506,20 +5498,6 @@
"react": "^19.2.4" "react": "^19.2.4"
} }
}, },
"node_modules/react-h5-audio-player": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/react-h5-audio-player/-/react-h5-audio-player-3.10.1.tgz",
"integrity": "sha512-r6fSj9WXR6af1kxH5qQ/tawwDK4KrMfayiVCUettLYGX/KZ3BH8OGuaZP4O5KD0AxwsKAXtBv4kVQCWFzaIrUA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.10.2",
"@iconify/react": "^5"
},
"peerDependencies": {
"react": "^16.3.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.3.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-hook-form": { "node_modules/react-hook-form": {
"version": "7.71.1", "version": "7.71.1",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.1.tgz", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.1.tgz",

View File

@@ -36,7 +36,7 @@
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^19.2", "react": "^19.2",
"react-dom": "^19.2", "react-dom": "^19.2",
"react-h5-audio-player": "^3.10.1", "@arraypress/waveform-player": "^1.0.0",
"react-hook-form": "^7.71.1", "react-hook-form": "^7.71.1",
"react-i18next": "^16.5.4", "react-i18next": "^16.5.4",
"react-router-dom": "^7.13.0", "react-router-dom": "^7.13.0",

View File

@@ -8,92 +8,58 @@
background: transparent; background: transparent;
} }
.audioPlayerWrapper :global(.rhap_container) { .waveformContainer {
flex: 1; flex: 1;
background-color: transparent; min-width: 0;
box-shadow: none; }
.audioPlayerWrapper :global(.waveform-player) {
background: transparent;
border: none;
padding: 0; padding: 0;
} }
.audioPlayerWrapper :global(.rhap_main) { .audioPlayerWrapper :global(.waveform-btn) {
--rhap_theme-color: hsl(var(--primary)); color: hsl(var(--foreground));
--rhap_background-color: transparent; border-color: hsl(var(--border));
--rhap_bar-color: hsl(var(--secondary)); background: transparent;
--rhap_time-color: hsl(var(--muted-foreground)); transition: all 150ms ease;
} }
.audioPlayerWrapper :global(.rhap_progress-indicator), .audioPlayerWrapper :global(.waveform-btn:hover) {
.audioPlayerWrapper :global(.rhap_volume-indicator) { color: hsl(var(--primary));
background: hsl(var(--primary)); border-color: hsl(var(--primary));
} }
.audioPlayerWrapper :global(.rhap_progress-filled), .audioPlayerWrapper :global(.waveform-canvas) {
.audioPlayerWrapper :global(.rhap_volume-bar) {
background-color: hsl(var(--primary));
}
.audioPlayerWrapper :global(.rhap_progress-bar),
.audioPlayerWrapper :global(.rhap_volume-container) {
background-color: hsl(var(--secondary));
}
.audioPlayerWrapper :global(.rhap_progress-bar) {
height: 6px;
border-radius: 3px;
transition: height 0.15s ease;
}
.audioPlayerWrapper :global(.rhap_progress-bar):hover {
height: 7px;
}
.audioPlayerWrapper :global(.rhap_progress-filled) {
border-radius: 3px; border-radius: 3px;
} }
.audioPlayerWrapper :global(.rhap_progress-indicator) { .audioPlayerWrapper :global(.waveform-info) {
width: 14px; position: relative;
height: 14px; justify-content: center;
top: -4px;
margin-left: -7px;
transition: transform 0.15s ease, box-shadow 0.15s ease;
} }
.audioPlayerWrapper :global(.rhap_progress-indicator):hover { .audioPlayerWrapper :global(.waveform-text) {
transform: scale(1.1); text-align: center;
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
} }
.audioPlayerWrapper :global(.rhap_progress-container) { .audioPlayerWrapper :global(.waveform-title) {
margin: 0 0.5rem; text-align: center;
font-size: 0.8125rem;
} }
.audioPlayerWrapper :global(.rhap_horizontal .rhap_controls-section) { .audioPlayerWrapper :global(.waveform-time) {
margin-left: 0;
}
.audioPlayerWrapper :global(.rhap_time) {
color: hsl(var(--muted-foreground)); color: hsl(var(--muted-foreground));
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 500; font-weight: 500;
} position: absolute;
right: 0;
.audioPlayerWrapper :global(.rhap_button-clear) {
color: hsl(var(--foreground));
font-size: 1.25rem;
}
.audioPlayerWrapper :global(.rhap_button-clear):hover {
color: hsl(var(--primary));
}
.audioPlayerWrapper :global(.rhap_main-controls-button) {
width: 40px;
height: 40px;
}
.audioPlayerWrapper :global(.rhap_main-controls-button svg) {
width: 22px;
height: 22px;
} }
.downloadButton { .downloadButton {

View File

@@ -1,7 +1,7 @@
import { useRef, useState, useEffect, useCallback, memo } from 'react' import { useRef, useState, useEffect, useCallback, memo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import AudioPlayerLib from 'react-h5-audio-player' import WaveformPlayer from '@arraypress/waveform-player'
import 'react-h5-audio-player/lib/styles.css' import '@arraypress/waveform-player/dist/waveform-player.css'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Download } from 'lucide-react' import { Download } from 'lucide-react'
import apiClient from '@/lib/api' import apiClient from '@/lib/api'
@@ -10,14 +10,17 @@ import styles from './AudioPlayer.module.css'
interface AudioPlayerProps { interface AudioPlayerProps {
audioUrl: string audioUrl: string
jobId: number jobId: number
text?: string
} }
const AudioPlayer = memo(({ audioUrl, jobId }: AudioPlayerProps) => { const AudioPlayer = memo(({ audioUrl, jobId, text }: AudioPlayerProps) => {
const { t } = useTranslation('common') const { t } = useTranslation('common')
const [blobUrl, setBlobUrl] = useState<string>('') const [blobUrl, setBlobUrl] = useState<string>('')
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [loadError, setLoadError] = useState<string | null>(null) const [loadError, setLoadError] = useState<string | null>(null)
const previousAudioUrlRef = useRef<string>('') const previousAudioUrlRef = useRef<string>('')
const containerRef = useRef<HTMLDivElement>(null)
const playerInstanceRef = useRef<WaveformPlayer | null>(null)
useEffect(() => { useEffect(() => {
if (!audioUrl || audioUrl === previousAudioUrlRef.current) return if (!audioUrl || audioUrl === previousAudioUrlRef.current) return
@@ -65,6 +68,49 @@ const AudioPlayer = memo(({ audioUrl, jobId }: AudioPlayerProps) => {
} }
}, []) }, [])
useEffect(() => {
if (!containerRef.current || !blobUrl) return
const truncateText = (str: string, maxLength: number = 30) => {
if (!str) return ''
return str.length > maxLength ? str.substring(0, maxLength) + '...' : str
}
const player = new WaveformPlayer(containerRef.current, {
url: blobUrl,
waveformStyle: 'mirror',
height: 60,
barWidth: 3,
barSpacing: 1,
samples: 200,
showTime: true,
showPlaybackSpeed: false,
autoplay: false,
enableMediaSession: true,
title: text ? truncateText(text) : undefined,
})
playerInstanceRef.current = player
setTimeout(() => {
if (containerRef.current) {
const buttons = containerRef.current.querySelectorAll('button')
buttons.forEach(btn => {
if (!btn.hasAttribute('type')) {
btn.setAttribute('type', 'button')
}
})
}
}, 0)
return () => {
if (playerInstanceRef.current) {
playerInstanceRef.current.destroy()
playerInstanceRef.current = null
}
}
}, [blobUrl, text])
const handleDownload = useCallback(() => { const handleDownload = useCallback(() => {
const link = document.createElement('a') const link = document.createElement('a')
link.href = blobUrl || audioUrl link.href = blobUrl || audioUrl
@@ -94,27 +140,16 @@ const AudioPlayer = memo(({ audioUrl, jobId }: AudioPlayerProps) => {
return ( return (
<div className={styles.audioPlayerWrapper}> <div className={styles.audioPlayerWrapper}>
<AudioPlayerLib <div ref={containerRef} className={styles.waveformContainer} />
src={blobUrl} <Button
layout="horizontal" type="button"
customAdditionalControls={[ variant="ghost"
<Button size="icon"
key="download" onClick={handleDownload}
type="button" className={styles.downloadButton}
variant="ghost" >
size="icon" <Download className="h-4 w-4" />
onClick={handleDownload} </Button>
className={styles.downloadButton}
>
<Download className="h-4 w-4" />
</Button>
]}
customVolumeControls={[]}
showJumpControls={false}
volume={1}
preload="metadata"
autoPlayAfterSrcChange={false}
/>
</div> </div>
) )
}) })

View File

@@ -219,7 +219,7 @@ const JobDetailDialog = memo(({ job, open, onOpenChange }: JobDetailDialogProps)
<Separator /> <Separator />
<div className="space-y-2"> <div className="space-y-2">
<h3 className="font-semibold text-sm">{t('job:audioPlayback')}</h3> <h3 className="font-semibold text-sm">{t('job:audioPlayback')}</h3>
<AudioPlayer audioUrl={audioUrl} jobId={job.id} /> <AudioPlayer audioUrl={audioUrl} jobId={job.id} text={job.parameters?.text} />
</div> </div>
</> </>
)} )}

View File

@@ -542,6 +542,7 @@ const CustomVoiceForm = forwardRef<CustomVoiceFormHandle>((_props, ref) => {
<AudioPlayer <AudioPlayer
audioUrl={memoizedAudioUrl} audioUrl={memoizedAudioUrl}
jobId={currentJob.id} jobId={currentJob.id}
text={currentJob.parameters?.text}
/> />
</div> </div>
)} )}

View File

@@ -422,6 +422,7 @@ function VoiceCloneForm() {
<AudioPlayer <AudioPlayer
audioUrl={memoizedAudioUrl} audioUrl={memoizedAudioUrl}
jobId={currentJob.id} jobId={currentJob.id}
text={currentJob.parameters?.text}
/> />
</div> </div>
)} )}

View File

@@ -457,6 +457,7 @@ const VoiceDesignForm = forwardRef<VoiceDesignFormHandle>((_props, ref) => {
<AudioPlayer <AudioPlayer
audioUrl={memoizedAudioUrl} audioUrl={memoizedAudioUrl}
jobId={currentJob.id} jobId={currentJob.id}
text={currentJob.parameters?.text}
/> />
<Button <Button
type="button" type="button"

View File

@@ -0,0 +1,35 @@
declare module '@arraypress/waveform-player' {
export interface WaveformPlayerOptions {
url?: string
waveformStyle?: 'bars' | 'mirror' | 'line' | 'blocks' | 'dots' | 'seekbar'
height?: number
barWidth?: number
barSpacing?: number
samples?: number
waveformColor?: string
progressColor?: string
buttonColor?: string
showTime?: boolean
showPlaybackSpeed?: boolean
playbackRate?: number
autoplay?: boolean
enableMediaSession?: boolean
title?: string
subtitle?: string
onLoad?: (player: WaveformPlayer) => void
onPlay?: (player: WaveformPlayer) => void
onPause?: (player: WaveformPlayer) => void
onEnd?: (player: WaveformPlayer) => void
onTimeUpdate?: (current: number, total: number, player: WaveformPlayer) => void
}
export default class WaveformPlayer {
constructor(container: HTMLElement, options?: WaveformPlayerOptions)
play(): void
pause(): void
togglePlay(): void
seekTo(seconds: number): void
setVolume(level: number): void
destroy(): void
}
}

View File

@@ -9,6 +9,9 @@ export default defineConfig({
"@": path.resolve(__dirname, "./src"), "@": path.resolve(__dirname, "./src"),
}, },
}, },
optimizeDeps: {
include: ['@arraypress/waveform-player']
},
build: { build: {
rollupOptions: { rollupOptions: {
output: { output: {
@@ -29,7 +32,7 @@ export default defineConfig({
if (id.includes('i18next')) { if (id.includes('i18next')) {
return 'i18n'; return 'i18n';
} }
if (id.includes('react-h5-audio-player') || id.includes('sonner')) { if (id.includes('@arraypress/waveform-player') || id.includes('sonner')) {
return 'audio'; return 'audio';
} }
if (id.includes('axios') || id.includes('clsx') || id.includes('tailwind-merge') || if (id.includes('axios') || id.includes('clsx') || id.includes('tailwind-merge') ||