From 8f5cfd80937752b7e831a309f5d7c14099a0c9f6 Mon Sep 17 00:00:00 2001 From: bdim404 Date: Fri, 6 Feb 2026 18:50:06 +0800 Subject: [PATCH] feat: Replace react-h5-audio-player with @arraypress/waveform-player and update AudioPlayer component to support waveform visualization --- qwen3-tts-frontend/package-lock.json | 50 +++------- qwen3-tts-frontend/package.json | 2 +- .../src/components/AudioPlayer.module.css | 98 ++++++------------- .../src/components/AudioPlayer.tsx | 83 +++++++++++----- .../src/components/JobDetailDialog.tsx | 2 +- .../src/components/tts/CustomVoiceForm.tsx | 1 + .../src/components/tts/VoiceCloneForm.tsx | 1 + .../src/components/tts/VoiceDesignForm.tsx | 1 + .../src/types/waveform-player.d.ts | 35 +++++++ qwen3-tts-frontend/vite.config.ts | 5 +- 10 files changed, 149 insertions(+), 129 deletions(-) create mode 100644 qwen3-tts-frontend/src/types/waveform-player.d.ts diff --git a/qwen3-tts-frontend/package-lock.json b/qwen3-tts-frontend/package-lock.json index 1d7ae49..e779cbe 100644 --- a/qwen3-tts-frontend/package-lock.json +++ b/qwen3-tts-frontend/package-lock.json @@ -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", diff --git a/qwen3-tts-frontend/package.json b/qwen3-tts-frontend/package.json index f9179e8..11bd9e8 100644 --- a/qwen3-tts-frontend/package.json +++ b/qwen3-tts-frontend/package.json @@ -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", diff --git a/qwen3-tts-frontend/src/components/AudioPlayer.module.css b/qwen3-tts-frontend/src/components/AudioPlayer.module.css index 34e5778..5d67d75 100644 --- a/qwen3-tts-frontend/src/components/AudioPlayer.module.css +++ b/qwen3-tts-frontend/src/components/AudioPlayer.module.css @@ -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 { diff --git a/qwen3-tts-frontend/src/components/AudioPlayer.tsx b/qwen3-tts-frontend/src/components/AudioPlayer.tsx index 1dced73..f1fba12 100644 --- a/qwen3-tts-frontend/src/components/AudioPlayer.tsx +++ b/qwen3-tts-frontend/src/components/AudioPlayer.tsx @@ -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('') const [isLoading, setIsLoading] = useState(false) const [loadError, setLoadError] = useState(null) const previousAudioUrlRef = useRef('') + const containerRef = useRef(null) + const playerInstanceRef = useRef(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 (
- - - - ]} - customVolumeControls={[]} - showJumpControls={false} - volume={1} - preload="metadata" - autoPlayAfterSrcChange={false} - /> +
+
) }) diff --git a/qwen3-tts-frontend/src/components/JobDetailDialog.tsx b/qwen3-tts-frontend/src/components/JobDetailDialog.tsx index cba010b..4dc65d5 100644 --- a/qwen3-tts-frontend/src/components/JobDetailDialog.tsx +++ b/qwen3-tts-frontend/src/components/JobDetailDialog.tsx @@ -219,7 +219,7 @@ const JobDetailDialog = memo(({ job, open, onOpenChange }: JobDetailDialogProps)

{t('job:audioPlayback')}

- +
)} diff --git a/qwen3-tts-frontend/src/components/tts/CustomVoiceForm.tsx b/qwen3-tts-frontend/src/components/tts/CustomVoiceForm.tsx index d06d288..4370937 100644 --- a/qwen3-tts-frontend/src/components/tts/CustomVoiceForm.tsx +++ b/qwen3-tts-frontend/src/components/tts/CustomVoiceForm.tsx @@ -542,6 +542,7 @@ const CustomVoiceForm = forwardRef((_props, ref) => {
)} diff --git a/qwen3-tts-frontend/src/components/tts/VoiceCloneForm.tsx b/qwen3-tts-frontend/src/components/tts/VoiceCloneForm.tsx index 0f6b792..18d8e58 100644 --- a/qwen3-tts-frontend/src/components/tts/VoiceCloneForm.tsx +++ b/qwen3-tts-frontend/src/components/tts/VoiceCloneForm.tsx @@ -422,6 +422,7 @@ function VoiceCloneForm() { )} diff --git a/qwen3-tts-frontend/src/components/tts/VoiceDesignForm.tsx b/qwen3-tts-frontend/src/components/tts/VoiceDesignForm.tsx index 5b339f1..ad306a3 100644 --- a/qwen3-tts-frontend/src/components/tts/VoiceDesignForm.tsx +++ b/qwen3-tts-frontend/src/components/tts/VoiceDesignForm.tsx @@ -457,6 +457,7 @@ const VoiceDesignForm = forwardRef((_props, ref) => {