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",
"version": "0.0.0",
"dependencies": {
"@arraypress/waveform-player": "^1.0.0",
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.3",
@@ -34,7 +35,6 @@
"next-themes": "^0.4.6",
"react": "^19.2",
"react-dom": "^19.2",
"react-h5-audio-player": "^3.10.1",
"react-hook-form": "^7.71.1",
"react-i18next": "^16.5.4",
"react-router-dom": "^7.13.0",
@@ -73,6 +73,19 @@
"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": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz",
@@ -1065,27 +1078,6 @@
"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": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -5506,20 +5498,6 @@
"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": {
"version": "7.71.1",
"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",
"react": "^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-i18next": "^16.5.4",
"react-router-dom": "^7.13.0",

View File

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

View File

@@ -1,7 +1,7 @@
import { useRef, useState, useEffect, useCallback, memo } from 'react'
import { useTranslation } from 'react-i18next'
import AudioPlayerLib from 'react-h5-audio-player'
import 'react-h5-audio-player/lib/styles.css'
import WaveformPlayer from '@arraypress/waveform-player'
import '@arraypress/waveform-player/dist/waveform-player.css'
import { Button } from '@/components/ui/button'
import { Download } from 'lucide-react'
import apiClient from '@/lib/api'
@@ -10,14 +10,17 @@ import styles from './AudioPlayer.module.css'
interface AudioPlayerProps {
audioUrl: string
jobId: number
text?: string
}
const AudioPlayer = memo(({ audioUrl, jobId }: AudioPlayerProps) => {
const AudioPlayer = memo(({ audioUrl, jobId, text }: AudioPlayerProps) => {
const { t } = useTranslation('common')
const [blobUrl, setBlobUrl] = useState<string>('')
const [isLoading, setIsLoading] = useState(false)
const [loadError, setLoadError] = useState<string | null>(null)
const previousAudioUrlRef = useRef<string>('')
const containerRef = useRef<HTMLDivElement>(null)
const playerInstanceRef = useRef<WaveformPlayer | null>(null)
useEffect(() => {
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 link = document.createElement('a')
link.href = blobUrl || audioUrl
@@ -94,12 +140,8 @@ const AudioPlayer = memo(({ audioUrl, jobId }: AudioPlayerProps) => {
return (
<div className={styles.audioPlayerWrapper}>
<AudioPlayerLib
src={blobUrl}
layout="horizontal"
customAdditionalControls={[
<div ref={containerRef} className={styles.waveformContainer} />
<Button
key="download"
type="button"
variant="ghost"
size="icon"
@@ -108,13 +150,6 @@ const AudioPlayer = memo(({ audioUrl, jobId }: AudioPlayerProps) => {
>
<Download className="h-4 w-4" />
</Button>
]}
customVolumeControls={[]}
showJumpControls={false}
volume={1}
preload="metadata"
autoPlayAfterSrcChange={false}
/>
</div>
)
})

View File

@@ -219,7 +219,7 @@ const JobDetailDialog = memo(({ job, open, onOpenChange }: JobDetailDialogProps)
<Separator />
<div className="space-y-2">
<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>
</>
)}

View File

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

View File

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

View File

@@ -457,6 +457,7 @@ const VoiceDesignForm = forwardRef<VoiceDesignFormHandle>((_props, ref) => {
<AudioPlayer
audioUrl={memoizedAudioUrl}
jobId={currentJob.id}
text={currentJob.parameters?.text}
/>
<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"),
},
},
optimizeDeps: {
include: ['@arraypress/waveform-player']
},
build: {
rollupOptions: {
output: {
@@ -29,7 +32,7 @@ export default defineConfig({
if (id.includes('i18next')) {
return 'i18n';
}
if (id.includes('react-h5-audio-player') || id.includes('sonner')) {
if (id.includes('@arraypress/waveform-player') || id.includes('sonner')) {
return 'audio';
}
if (id.includes('axios') || id.includes('clsx') || id.includes('tailwind-merge') ||