feat: Replace react-h5-audio-player with @arraypress/waveform-player and update AudioPlayer component to support waveform visualization
This commit is contained in:
50
qwen3-tts-frontend/package-lock.json
generated
50
qwen3-tts-frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,27 +140,16 @@ const AudioPlayer = memo(({ audioUrl, jobId }: AudioPlayerProps) => {
|
||||
|
||||
return (
|
||||
<div className={styles.audioPlayerWrapper}>
|
||||
<AudioPlayerLib
|
||||
src={blobUrl}
|
||||
layout="horizontal"
|
||||
customAdditionalControls={[
|
||||
<Button
|
||||
key="download"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleDownload}
|
||||
className={styles.downloadButton}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
]}
|
||||
customVolumeControls={[]}
|
||||
showJumpControls={false}
|
||||
volume={1}
|
||||
preload="metadata"
|
||||
autoPlayAfterSrcChange={false}
|
||||
/>
|
||||
<div ref={containerRef} className={styles.waveformContainer} />
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleDownload}
|
||||
className={styles.downloadButton}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -542,6 +542,7 @@ const CustomVoiceForm = forwardRef<CustomVoiceFormHandle>((_props, ref) => {
|
||||
<AudioPlayer
|
||||
audioUrl={memoizedAudioUrl}
|
||||
jobId={currentJob.id}
|
||||
text={currentJob.parameters?.text}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -422,6 +422,7 @@ function VoiceCloneForm() {
|
||||
<AudioPlayer
|
||||
audioUrl={memoizedAudioUrl}
|
||||
jobId={currentJob.id}
|
||||
text={currentJob.parameters?.text}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -457,6 +457,7 @@ const VoiceDesignForm = forwardRef<VoiceDesignFormHandle>((_props, ref) => {
|
||||
<AudioPlayer
|
||||
audioUrl={memoizedAudioUrl}
|
||||
jobId={currentJob.id}
|
||||
text={currentJob.parameters?.text}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
35
qwen3-tts-frontend/src/types/waveform-player.d.ts
vendored
Normal file
35
qwen3-tts-frontend/src/types/waveform-player.d.ts
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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') ||
|
||||
|
||||
Reference in New Issue
Block a user