feat(voices): 配音素材卡片真实播放 + 预置/克隆音色 TTS 试听 #1544
@@ -77,14 +77,16 @@ const VoiceLibrary: React.FC = () => {
|
||||
showToast,
|
||||
})
|
||||
|
||||
// ── 播放控制 ──────────────────────────────────────────
|
||||
// ── 播放控制(三 tab 共用:真实 Audio 播放 + TTS 试听) ──
|
||||
const {
|
||||
playingId,
|
||||
loadingId,
|
||||
currentTime,
|
||||
handlePlay,
|
||||
duration: playDuration,
|
||||
handlePause,
|
||||
handleToggleVoice,
|
||||
handleToggleMaterial,
|
||||
handleSeek,
|
||||
handleTogglePlay,
|
||||
stopPlayback,
|
||||
} = useAudioPlayer()
|
||||
|
||||
@@ -210,8 +212,10 @@ const VoiceLibrary: React.FC = () => {
|
||||
loading={presetLoading}
|
||||
voices={filteredPreset}
|
||||
playingId={playingId}
|
||||
loadingId={loadingId}
|
||||
currentTime={currentTime}
|
||||
onPlay={handlePlay}
|
||||
playDuration={playDuration}
|
||||
onToggle={handleToggleVoice}
|
||||
onPause={handlePause}
|
||||
onSeek={handleSeek}
|
||||
onClearFilters={handleClearFilters}
|
||||
@@ -224,9 +228,12 @@ const VoiceLibrary: React.FC = () => {
|
||||
loading={cloneLoading}
|
||||
voices={clonedVoices}
|
||||
playingId={playingId}
|
||||
loadingId={loadingId}
|
||||
currentTime={currentTime}
|
||||
onPlay={handleTogglePlay}
|
||||
playDuration={playDuration}
|
||||
onToggle={handleToggleVoice}
|
||||
onPause={handlePause}
|
||||
onSeek={handleSeek}
|
||||
onUse={handleCloneUse}
|
||||
onDelete={handleCloneDelete}
|
||||
onRetry={handleCloneRetry}
|
||||
@@ -242,6 +249,11 @@ const VoiceLibrary: React.FC = () => {
|
||||
materials={materials as AssetItem[]}
|
||||
onOpenUpload={() => setUploadOpen(true)}
|
||||
onDelete={handleMaterialDelete}
|
||||
playingId={playingId}
|
||||
currentTime={currentTime}
|
||||
playDuration={playDuration}
|
||||
onToggle={handleToggleMaterial}
|
||||
onSeek={handleSeek}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -8,9 +8,12 @@ import CardFooter from "./clone-voice-card/CardFooter"
|
||||
export interface CloneVoiceCardProps {
|
||||
voice: ClonedVoiceDisplay
|
||||
isPlaying: boolean
|
||||
isLoading?: boolean
|
||||
currentTime: number
|
||||
playDuration?: number
|
||||
onPlay: () => void
|
||||
onPause: () => void
|
||||
onSeek?: (time: number) => void
|
||||
onUse: () => void
|
||||
onDelete: () => void
|
||||
onRetry: () => void
|
||||
|
||||
@@ -12,9 +12,12 @@ export interface ClonedVoiceTabProps {
|
||||
loading: boolean
|
||||
voices: ClonedVoiceDisplay[]
|
||||
playingId: string | null
|
||||
loadingId: string | null
|
||||
currentTime: number
|
||||
onPlay: (voiceId: string, duration: number) => void
|
||||
playDuration: number
|
||||
onToggle: (voiceId: string) => void
|
||||
onPause: () => void
|
||||
onSeek: (time: number) => void
|
||||
onUse: (voice: ClonedVoiceDisplay) => void
|
||||
onDelete: (voice: ClonedVoiceDisplay) => void
|
||||
onRetry: (voice: ClonedVoiceDisplay) => void
|
||||
@@ -26,9 +29,12 @@ export const ClonedVoiceTab: React.FC<ClonedVoiceTabProps> = ({
|
||||
loading,
|
||||
voices,
|
||||
playingId,
|
||||
loadingId,
|
||||
currentTime,
|
||||
onPlay,
|
||||
playDuration,
|
||||
onToggle,
|
||||
onPause,
|
||||
onSeek,
|
||||
onUse,
|
||||
onDelete,
|
||||
onRetry,
|
||||
@@ -54,9 +60,12 @@ export const ClonedVoiceTab: React.FC<ClonedVoiceTabProps> = ({
|
||||
key={voice.id}
|
||||
voice={voice}
|
||||
isPlaying={playingId === voice.id}
|
||||
isLoading={loadingId === voice.id}
|
||||
currentTime={playingId === voice.id ? currentTime : 0}
|
||||
onPlay={() => onPlay(voice.id, voice.duration)}
|
||||
playDuration={playingId === voice.id ? playDuration : voice.duration}
|
||||
onPlay={() => onToggle(voice.voiceId)}
|
||||
onPause={onPause}
|
||||
onSeek={onSeek}
|
||||
onUse={() => onUse(voice)}
|
||||
onDelete={() => onDelete(voice)}
|
||||
onRetry={() => onRetry(voice)}
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import React from "react"
|
||||
import { AudioOutlined } from "@ant-design/icons"
|
||||
import { type AssetItem } from "@/api/assets"
|
||||
import { formatFileSize } from "@/pages/voices/utils/format"
|
||||
|
||||
export interface MaterialVoiceCardProps {
|
||||
asset: AssetItem
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
/** 配音素材卡片 */
|
||||
const MaterialVoiceCard: React.FC<MaterialVoiceCardProps> = ({ asset, onClick }) => {
|
||||
const duration = (asset.metadata?.duration as number) || 0
|
||||
const minutes = Math.floor(duration / 60)
|
||||
const seconds = Math.floor(duration % 60)
|
||||
|
||||
return (
|
||||
<div className="vmat-card" onClick={onClick}>
|
||||
<div className="vmat-thumb">
|
||||
<AudioOutlined className="vmat-thumb-icon" />
|
||||
<span className="vmat-duration">
|
||||
{minutes}:{seconds.toString().padStart(2, "0")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="vmat-info">
|
||||
<div className="vmat-name" title={asset.name}>
|
||||
{asset.name}
|
||||
</div>
|
||||
<div className="vmat-meta">
|
||||
<span>{asset.file_size ? formatFileSize(asset.file_size) : "--"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MaterialVoiceCard
|
||||
@@ -1,16 +1,35 @@
|
||||
/**
|
||||
* VoiceLibrary 配音素材 Tab 内容
|
||||
*
|
||||
* 播放状态由页面级统一 hook(VoiceLibrary/useAudioPlayer)下发,
|
||||
* 与预置/克隆音色互斥:同一时刻全库只有一个音频在响。
|
||||
*/
|
||||
import React from "react"
|
||||
import { SoundOutlined, UploadOutlined, AudioOutlined, DeleteOutlined } from "@ant-design/icons"
|
||||
import React, { useRef } from "react"
|
||||
import {
|
||||
SoundOutlined,
|
||||
UploadOutlined,
|
||||
AudioOutlined,
|
||||
DeleteOutlined,
|
||||
PlayCircleOutlined,
|
||||
PauseCircleOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { Button } from "@/components/ui"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import { mapAssetToMaterial } from "@/pages/voice-materials/types"
|
||||
import type { VoiceMaterial } from "@/pages/voice-materials/types"
|
||||
import { formatTime } from "../utils/format"
|
||||
|
||||
export interface MaterialVoiceTabProps {
|
||||
loading: boolean
|
||||
materials: AssetItem[]
|
||||
onOpenUpload: () => void
|
||||
onDelete: (asset: AssetItem) => void
|
||||
// 播放控制(页面级统一 hook)
|
||||
playingId: string | null
|
||||
currentTime: number
|
||||
playDuration: number
|
||||
onToggle: (material: VoiceMaterial) => void
|
||||
onSeek: (time: number) => void
|
||||
}
|
||||
|
||||
export const MaterialVoiceTab: React.FC<MaterialVoiceTabProps> = ({
|
||||
@@ -18,7 +37,31 @@ export const MaterialVoiceTab: React.FC<MaterialVoiceTabProps> = ({
|
||||
materials,
|
||||
onOpenUpload,
|
||||
onDelete,
|
||||
playingId,
|
||||
currentTime,
|
||||
playDuration,
|
||||
onToggle,
|
||||
onSeek,
|
||||
}) => {
|
||||
const progressRefs = useRef<Record<string, HTMLDivElement | null>>({})
|
||||
|
||||
/** 播放中点击/拖动进度条 seek;非播放态点击则开始播放 */
|
||||
const handleProgressClick =
|
||||
(asset: AssetItem, material: VoiceMaterial, dur: number) =>
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation()
|
||||
if (!asset.file_url) return
|
||||
if (playingId !== asset.id) {
|
||||
onToggle(material)
|
||||
return
|
||||
}
|
||||
const el = progressRefs.current[asset.id]
|
||||
if (!el || dur <= 0) return
|
||||
const rect = el.getBoundingClientRect()
|
||||
const percent = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||
onSeek(percent * dur)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="xx-voices-tab-content">
|
||||
{/* 骨架屏加载 */}
|
||||
@@ -40,11 +83,15 @@ export const MaterialVoiceTab: React.FC<MaterialVoiceTabProps> = ({
|
||||
{!loading && materials.length > 0 && (
|
||||
<div className="xx-voice-grid">
|
||||
{materials.map((asset: AssetItem) => {
|
||||
const duration = (asset.metadata?.duration as number) || 0
|
||||
const minutes = Math.floor(duration / 60)
|
||||
const seconds = Math.floor(duration % 60)
|
||||
const material = mapAssetToMaterial(asset)
|
||||
// duration 优先取顶层(后端从 metadata 提取),兜底 metadata
|
||||
const cardDuration = asset.duration || material.duration || 0
|
||||
const isPlaying = playingId === asset.id
|
||||
// 播放中以 audio 真实时长为准,未播放显示卡片时长
|
||||
const effectiveDuration = isPlaying ? playDuration || cardDuration : cardDuration
|
||||
const progress = effectiveDuration > 0 ? (currentTime / effectiveDuration) * 100 : 0
|
||||
return (
|
||||
<div key={asset.id} className="vmat-card">
|
||||
<div key={asset.id} className={`vmat-card${isPlaying ? " playing" : ""}`}>
|
||||
{/* hover 操作区:删除 */}
|
||||
<div className="vmat-card-actions">
|
||||
<button
|
||||
@@ -62,9 +109,7 @@ export const MaterialVoiceTab: React.FC<MaterialVoiceTabProps> = ({
|
||||
</div>
|
||||
<div className="vmat-thumb">
|
||||
<AudioOutlined className="vmat-thumb-icon" />
|
||||
<span className="vmat-duration">
|
||||
{minutes}:{seconds.toString().padStart(2, "0")}
|
||||
</span>
|
||||
<span className="vmat-duration">{formatTime(cardDuration)}</span>
|
||||
</div>
|
||||
<div className="vmat-info">
|
||||
<div className="vmat-name" title={asset.name}>
|
||||
@@ -76,6 +121,38 @@ export const MaterialVoiceTab: React.FC<MaterialVoiceTabProps> = ({
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 波形装饰条(与预置音色卡片一致) */}
|
||||
<div className="xx-voice-wave" />
|
||||
|
||||
{/* 播放控制区:真实音频播放 */}
|
||||
<div className="xx-voice-controls">
|
||||
<button
|
||||
type="button"
|
||||
className="xx-voice-play-btn"
|
||||
disabled={!asset.file_url}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onToggle(material)
|
||||
}}
|
||||
title={asset.file_url ? (isPlaying ? "暂停" : "试听") : "暂无可播放音频"}
|
||||
aria-label={isPlaying ? "暂停播放" : "播放音频"}
|
||||
>
|
||||
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
||||
</button>
|
||||
<div
|
||||
ref={(el) => {
|
||||
progressRefs.current[asset.id] = el
|
||||
}}
|
||||
className="xx-voice-progress"
|
||||
onClick={handleProgressClick(asset, material, effectiveDuration)}
|
||||
>
|
||||
<div className="xx-voice-progress-bar" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
<span className="xx-voice-time">
|
||||
{isPlaying ? formatTime(currentTime) : formatTime(cardDuration)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -19,10 +19,12 @@ export interface PresetVoiceTabProps {
|
||||
loading: boolean
|
||||
voices: PresetVoiceDisplay[]
|
||||
playingId: string | null
|
||||
loadingId: string | null
|
||||
currentTime: number
|
||||
onPlay: (id: string, duration: number) => void
|
||||
playDuration: number
|
||||
onToggle: (voiceId: string) => void
|
||||
onPause: () => void
|
||||
onSeek: (id: string, time: number, duration: number) => void
|
||||
onSeek: (time: number) => void
|
||||
onClearFilters: () => void
|
||||
}
|
||||
|
||||
@@ -36,8 +38,10 @@ export const PresetVoiceTab: React.FC<PresetVoiceTabProps> = ({
|
||||
loading,
|
||||
voices,
|
||||
playingId,
|
||||
loadingId,
|
||||
currentTime,
|
||||
onPlay,
|
||||
playDuration,
|
||||
onToggle,
|
||||
onPause,
|
||||
onSeek,
|
||||
onClearFilters,
|
||||
@@ -71,15 +75,16 @@ export const PresetVoiceTab: React.FC<PresetVoiceTabProps> = ({
|
||||
name={voice.name}
|
||||
subtitle={`${genderLabel(voice.gender)} · ${languageLabel(voice.language)} · ${voice.description}`}
|
||||
tags={voice.tags}
|
||||
duration={voice.duration}
|
||||
duration={playingId === voice.id ? playDuration : voice.duration}
|
||||
gender={voice.gender}
|
||||
isPlaying={playingId === voice.id}
|
||||
isLoading={loadingId === voice.id}
|
||||
isSelected={false}
|
||||
currentTime={playingId === voice.id ? currentTime : 0}
|
||||
starred={voice.starred}
|
||||
onPlay={() => onPlay(voice.id, voice.duration)}
|
||||
onPlay={() => onToggle(voice.voiceId)}
|
||||
onPause={onPause}
|
||||
onSeek={(time) => onSeek(voice.id, time, voice.duration)}
|
||||
onSeek={(time) => onSeek(time)}
|
||||
onToggleStar={() => {}}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
PlayCircleOutlined,
|
||||
PauseCircleOutlined,
|
||||
HeartOutlined,
|
||||
LoadingOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { type VoiceGender } from "@/pages/voices/types"
|
||||
import { genderClass, formatTime } from "@/pages/voices/utils/format"
|
||||
@@ -16,6 +17,7 @@ export interface VoiceCardProps {
|
||||
duration: number
|
||||
gender: VoiceGender
|
||||
isPlaying: boolean
|
||||
isLoading?: boolean
|
||||
isSelected: boolean
|
||||
currentTime: number
|
||||
starred?: boolean
|
||||
@@ -36,6 +38,7 @@ const VoiceCard: React.FC<VoiceCardProps> = ({
|
||||
duration,
|
||||
gender,
|
||||
isPlaying,
|
||||
isLoading = false,
|
||||
isSelected,
|
||||
currentTime,
|
||||
starred,
|
||||
@@ -51,8 +54,12 @@ const VoiceCard: React.FC<VoiceCardProps> = ({
|
||||
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!progressRef.current || status !== "ready") return
|
||||
const rect = progressRef.current.getBoundingClientRect()
|
||||
const percent = (e.clientX - rect.left) / rect.width
|
||||
onSeek(percent * duration)
|
||||
const percent = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||
if (isPlaying && duration > 0) {
|
||||
onSeek(percent * duration)
|
||||
} else if (!isPlaying && !isLoading) {
|
||||
onPlay()
|
||||
}
|
||||
}
|
||||
|
||||
const progress = duration > 0 ? (currentTime / duration) * 100 : 0
|
||||
@@ -110,19 +117,27 @@ const VoiceCard: React.FC<VoiceCardProps> = ({
|
||||
<div className="xx-voice-controls">
|
||||
<button
|
||||
className="xx-voice-play-btn"
|
||||
disabled={isLoading}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
isPlaying ? onPause() : onPlay()
|
||||
}}
|
||||
title={isPlaying ? "暂停" : "试听"}
|
||||
aria-label={isPlaying ? "暂停" : "试听"}
|
||||
>
|
||||
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
||||
{isLoading ? (
|
||||
<LoadingOutlined spin />
|
||||
) : isPlaying ? (
|
||||
<PauseCircleOutlined />
|
||||
) : (
|
||||
<PlayCircleOutlined />
|
||||
)}
|
||||
</button>
|
||||
<div ref={progressRef} className="xx-voice-progress" onClick={handleProgressClick}>
|
||||
<div className="xx-voice-progress-bar" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
<span className="xx-voice-time">
|
||||
{isPlaying ? formatTime(currentTime) : formatTime(duration)}
|
||||
{isPlaying ? formatTime(currentTime) : duration > 0 ? formatTime(duration) : "试听"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import React from "react"
|
||||
import { PlayCircleOutlined, PauseCircleOutlined, ReloadOutlined } from "@ant-design/icons"
|
||||
import {
|
||||
PlayCircleOutlined,
|
||||
PauseCircleOutlined,
|
||||
ReloadOutlined,
|
||||
LoadingOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { type ClonedVoiceDisplay } from "@/pages/voices/types"
|
||||
|
||||
interface CardFooterProps {
|
||||
voice: ClonedVoiceDisplay
|
||||
isPlaying: boolean
|
||||
isLoading?: boolean
|
||||
currentTime: number
|
||||
playDuration?: number
|
||||
onPlay: () => void
|
||||
onPause: () => void
|
||||
onSeek?: (time: number) => void
|
||||
onUse: () => void
|
||||
onRetry: () => void
|
||||
}
|
||||
@@ -15,14 +23,30 @@ interface CardFooterProps {
|
||||
const CardFooter: React.FC<CardFooterProps> = ({
|
||||
voice,
|
||||
isPlaying,
|
||||
isLoading = false,
|
||||
currentTime,
|
||||
playDuration,
|
||||
onPlay,
|
||||
onPause,
|
||||
onSeek,
|
||||
onUse,
|
||||
onRetry,
|
||||
}) => {
|
||||
const isFailed = voice.status === "failed"
|
||||
const isProcessing = voice.status === "processing"
|
||||
// 播放中以 audio 真实时长为准
|
||||
const effectiveDuration = isPlaying ? playDuration || voice.duration : voice.duration
|
||||
|
||||
/** 播放中点击进度条 seek;非播放态点击触发播放 */
|
||||
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const percent = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||
if (isPlaying && effectiveDuration > 0) {
|
||||
onSeek?.(percent * effectiveDuration)
|
||||
} else if (!isLoading) {
|
||||
onPlay()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="xx-clone-footer">
|
||||
@@ -31,21 +55,30 @@ const CardFooter: React.FC<CardFooterProps> = ({
|
||||
<button
|
||||
type="button"
|
||||
className="xx-clone-play-btn"
|
||||
disabled={isLoading}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
isPlaying ? onPause() : onPlay()
|
||||
}}
|
||||
title={isPlaying ? "暂停" : "试听"}
|
||||
aria-label={isPlaying ? "暂停" : "试听"}
|
||||
>
|
||||
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
||||
{isLoading ? (
|
||||
<LoadingOutlined spin />
|
||||
) : isPlaying ? (
|
||||
<PauseCircleOutlined />
|
||||
) : (
|
||||
<PlayCircleOutlined />
|
||||
)}
|
||||
</button>
|
||||
<div className="xx-clone-progress">
|
||||
<div className="xx-clone-progress" onClick={handleProgressClick}>
|
||||
<div
|
||||
className="xx-clone-progress-bar"
|
||||
style={{
|
||||
width: isPlaying
|
||||
? `${Math.min((currentTime / Math.max(voice.duration, 1)) * 100, 100)}%`
|
||||
: "0%",
|
||||
width:
|
||||
isPlaying && effectiveDuration > 0
|
||||
? `${Math.min((currentTime / effectiveDuration) * 100, 100)}%`
|
||||
: "0%",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,102 +1,200 @@
|
||||
import { useState, useRef, useCallback, useEffect } from "react"
|
||||
|
||||
/**
|
||||
* 音频播放控制 Hook
|
||||
* 封装当前播放状态、播放/暂停/跳转控制,使用 setInterval 模拟进度更新
|
||||
* (适用于预置音色/克隆音色卡片的播放按钮交互)
|
||||
* 配音库真实音频播放控制 Hook(预置音色 / 克隆音色 / 配音素材三个 tab 共用)
|
||||
*
|
||||
* - 配音素材:直接播放 asset.file_url(用户上传的真实音频)
|
||||
* - 预置/克隆音色:调 POST /tts/preview 合成本示例文案,拿到 audio_url 后真实播放,
|
||||
* 合成结果按 voiceId 内存缓存,同一音色二次试听不重复合成
|
||||
* - 全库同一时刻只有一个 Audio 在响:切卡片 / 切 tab / 离开页面自动停止
|
||||
* - timeupdate 驱动进度条,loadedmetadata 取真实时长,ended 自动复位
|
||||
*
|
||||
* 注意:合成失败的错误提示由 apiClient 拦截器统一 toast(含后端
|
||||
* 「音色克隆尚未完成,请稍后再试」文案),hook 内不重复提示。
|
||||
*/
|
||||
import { useState, useRef, useCallback, useEffect } from "react"
|
||||
import { previewTts } from "@/api/tts"
|
||||
import type { VoiceMaterial } from "@/pages/voice-materials/types"
|
||||
|
||||
/** 卡片试听统一示例文案 */
|
||||
export const VOICE_PREVIEW_TEXT = "你好呀,欢迎使用小虾智剪,这是我的配音效果,希望你喜欢。"
|
||||
|
||||
interface PreviewCacheEntry {
|
||||
url: string
|
||||
duration?: number
|
||||
}
|
||||
|
||||
export function useAudioPlayer() {
|
||||
const [playingId, setPlayingId] = useState<string | null>(null)
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
const intervalRef = useRef<number | null>(null)
|
||||
const [duration, setDuration] = useState(0)
|
||||
const [loadingId, setLoadingId] = useState<string | null>(null)
|
||||
|
||||
/** 开始播放指定音色(从 startTime 开始,默认从 0 开始) */
|
||||
const handlePlay = useCallback(
|
||||
(voiceId: string, duration: number, startTime: number = 0) => {
|
||||
if (playingId === voiceId) return
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
}
|
||||
setPlayingId(voiceId)
|
||||
setCurrentTime(startTime)
|
||||
intervalRef.current = window.setInterval(() => {
|
||||
setCurrentTime((prev) => {
|
||||
if (prev >= duration) {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
intervalRef.current = null
|
||||
}
|
||||
setPlayingId(null)
|
||||
return 0
|
||||
}
|
||||
return prev + 0.1
|
||||
})
|
||||
}, 100)
|
||||
},
|
||||
[playingId],
|
||||
)
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const pausedRef = useRef<{ id: string; url: string; duration: number } | null>(null)
|
||||
const previewCacheRef = useRef<Map<string, PreviewCacheEntry>>(new Map())
|
||||
/** 试听合成请求序号:旧请求返回时丢弃,防止竞态 */
|
||||
const reqSeqRef = useRef(0)
|
||||
|
||||
/** 暂停播放 */
|
||||
const handlePause = useCallback(() => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
intervalRef.current = null
|
||||
/** 停止当前播放并复位状态 */
|
||||
const stopPlayback = useCallback(() => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
audioRef.current = null
|
||||
}
|
||||
pausedRef.current = null
|
||||
setPlayingId(null)
|
||||
setLoadingId(null)
|
||||
setCurrentTime(0)
|
||||
setDuration(0)
|
||||
}, [])
|
||||
|
||||
/** 跳转到指定时间 */
|
||||
const handleSeek = useCallback(
|
||||
(voiceId: string, time: number, duration: number) => {
|
||||
if (playingId !== voiceId) {
|
||||
// 不同音色:从指定时间开始播放
|
||||
handlePlay(voiceId, duration, time)
|
||||
} else {
|
||||
// 同一音色:直接跳转
|
||||
setCurrentTime(time)
|
||||
/** 用指定 URL 创建 Audio 并播放 */
|
||||
const startAudio = useCallback((id: string, url: string, knownDuration?: number) => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
audioRef.current = null
|
||||
}
|
||||
const audio = new Audio(url)
|
||||
audioRef.current = audio
|
||||
|
||||
audio.addEventListener("timeupdate", () => {
|
||||
setCurrentTime(audio.currentTime)
|
||||
})
|
||||
audio.addEventListener("loadedmetadata", () => {
|
||||
if (Number.isFinite(audio.duration) && audio.duration > 0) {
|
||||
setDuration(audio.duration)
|
||||
}
|
||||
})
|
||||
audio.addEventListener("ended", () => {
|
||||
if (audioRef.current === audio) audioRef.current = null
|
||||
pausedRef.current = null
|
||||
setPlayingId(null)
|
||||
setCurrentTime(0)
|
||||
})
|
||||
|
||||
if (knownDuration && knownDuration > 0) setDuration(knownDuration)
|
||||
setCurrentTime(0)
|
||||
setPlayingId(id)
|
||||
pausedRef.current = null
|
||||
|
||||
audio.play().catch(() => {
|
||||
// 自动播放被拦截或 URL 失效:复位按钮,错误提示由拦截器/环境处理
|
||||
if (audioRef.current === audio) audioRef.current = null
|
||||
setPlayingId(null)
|
||||
setLoadingId((cur) => (cur === id ? null : cur))
|
||||
})
|
||||
}, [])
|
||||
|
||||
/** 播放配音素材(file_url 直链) */
|
||||
const playMaterial = useCallback(
|
||||
(material: VoiceMaterial) => {
|
||||
if (!material.fileUrl) return
|
||||
startAudio(material.id, material.fileUrl, material.duration)
|
||||
},
|
||||
[playingId, handlePlay],
|
||||
[startAudio],
|
||||
)
|
||||
|
||||
/** 切换播放/暂停 */
|
||||
const handleTogglePlay = useCallback(
|
||||
(voiceId: string, duration: number) => {
|
||||
/** 预置/克隆音色试听:先 TTS 合成(带缓存),再真实播放 */
|
||||
const playVoice = useCallback(
|
||||
async (voiceId: string) => {
|
||||
// 暂停中恢复
|
||||
if (pausedRef.current?.id === voiceId && audioRef.current) {
|
||||
try {
|
||||
await audioRef.current.play()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
setPlayingId(voiceId)
|
||||
pausedRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
const cached = previewCacheRef.current.get(voiceId)
|
||||
if (cached) {
|
||||
startAudio(voiceId, cached.url, cached.duration)
|
||||
return
|
||||
}
|
||||
|
||||
const seq = ++reqSeqRef.current
|
||||
setLoadingId(voiceId)
|
||||
try {
|
||||
const res = await previewTts({ text: VOICE_PREVIEW_TEXT, voice_id: voiceId, speed: 1.0 })
|
||||
if (seq !== reqSeqRef.current) return // 已被更新的请求取代
|
||||
previewCacheRef.current.set(voiceId, { url: res.audio_url, duration: res.duration })
|
||||
setLoadingId(null)
|
||||
startAudio(voiceId, res.audio_url, res.duration)
|
||||
} catch {
|
||||
if (seq !== reqSeqRef.current) return
|
||||
// 错误文案(含「克隆尚未完成」)由 apiClient 拦截器统一 toast
|
||||
setLoadingId(null)
|
||||
}
|
||||
},
|
||||
[startAudio],
|
||||
)
|
||||
|
||||
/** 暂停(记录暂停对象,供再次点击恢复) */
|
||||
const handlePause = useCallback(() => {
|
||||
const audio = audioRef.current
|
||||
if (!audio) return
|
||||
audio.pause()
|
||||
pausedRef.current = { id: playingId ?? "", url: audio.src, duration }
|
||||
setPlayingId(null)
|
||||
}, [playingId, duration])
|
||||
|
||||
/** 音色卡片播放/暂停切换 */
|
||||
const handleToggleVoice = useCallback(
|
||||
(voiceId: string) => {
|
||||
if (playingId === voiceId) {
|
||||
handlePause()
|
||||
} else {
|
||||
handlePlay(voiceId, duration)
|
||||
stopPlayback()
|
||||
void playVoice(voiceId)
|
||||
}
|
||||
},
|
||||
[playingId, handlePlay, handlePause],
|
||||
[playingId, handlePause, stopPlayback, playVoice],
|
||||
)
|
||||
|
||||
/** 停止所有播放(切换 Tab 时调用) */
|
||||
const stopPlayback = useCallback(() => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
intervalRef.current = null
|
||||
}
|
||||
setPlayingId(null)
|
||||
setCurrentTime(0)
|
||||
/** 素材卡片播放/暂停切换 */
|
||||
const handleToggleMaterial = useCallback(
|
||||
(material: VoiceMaterial) => {
|
||||
if (playingId === material.id) {
|
||||
handlePause()
|
||||
} else {
|
||||
stopPlayback()
|
||||
playMaterial(material)
|
||||
}
|
||||
},
|
||||
[playingId, handlePause, stopPlayback, playMaterial],
|
||||
)
|
||||
|
||||
/** 进度条 seek(仅播放中有效;非播放态点击进度条由卡片改为触发播放) */
|
||||
const handleSeek = useCallback((time: number) => {
|
||||
const audio = audioRef.current
|
||||
if (!audio) return
|
||||
audio.currentTime = time
|
||||
setCurrentTime(time)
|
||||
}, [])
|
||||
|
||||
// 组件卸载时清理
|
||||
// 组件卸载时清理音频
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
audioRef.current = null
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
playingId,
|
||||
loadingId,
|
||||
currentTime,
|
||||
handlePlay,
|
||||
duration,
|
||||
playMaterial,
|
||||
playVoice,
|
||||
handlePause,
|
||||
handleToggleVoice,
|
||||
handleToggleMaterial,
|
||||
handleSeek,
|
||||
handleTogglePlay,
|
||||
stopPlayback,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1089,6 +1089,35 @@
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* 播放区:复用预置音色卡片 .xx-voice-wave / .xx-voice-controls 视觉 */
|
||||
.vmat-card .xx-voice-wave {
|
||||
margin: 2px 14px 0;
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.vmat-card .xx-voice-controls {
|
||||
grid-column: auto;
|
||||
padding: 8px 14px 12px;
|
||||
}
|
||||
|
||||
.vmat-card:hover .xx-voice-wave {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.vmat-card.playing .xx-voice-wave {
|
||||
opacity: 0.5;
|
||||
animation: xx-wave-pulse 0.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* 无可播放音频时播放按钮置灰 */
|
||||
.vmat-card .xx-voice-play-btn:disabled {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-tertiary);
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* 骨架屏 */
|
||||
.vmat-card--skeleton {
|
||||
pointer-events: none;
|
||||
|
||||
@@ -1,232 +1,242 @@
|
||||
/**
|
||||
* useAudioPlayer hook 测试 — VoiceLibrary 版本
|
||||
* useAudioPlayer hook 测试 — VoiceLibrary 版本(真实 Audio + TTS 试听)
|
||||
*
|
||||
* 该 Hook 使用 setInterval 模拟音频播放进度,纯逻辑可测。
|
||||
* 参考 voice-materials/hooks/useAudioPlayer.test.ts 的测试结构。
|
||||
* mock HTMLAudioElement 与 previewTts,验证三 tab 共用的播放状态逻辑。
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"
|
||||
import { renderHook, act } from "@testing-library/react"
|
||||
import { useAudioPlayer } from "@/pages/voices/hooks/useAudioPlayer"
|
||||
import type { VoiceMaterial } from "@/pages/voice-materials/types"
|
||||
|
||||
describe("useAudioPlayer (voices)", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
const mockAudioPlay = vi.fn()
|
||||
const mockAudioPause = vi.fn()
|
||||
const listeners: Record<string, (() => void) | ((ev: unknown) => void)> = {}
|
||||
let mockAudioInstance: {
|
||||
play: ReturnType<typeof vi.fn>
|
||||
pause: ReturnType<typeof vi.fn>
|
||||
addEventListener: (ev: string, cb: () => void) => void
|
||||
currentTime: number
|
||||
duration: number
|
||||
src: string
|
||||
volume: number
|
||||
paused: boolean
|
||||
}
|
||||
|
||||
const previewTtsMock = vi.fn()
|
||||
|
||||
vi.mock("@/api/tts", () => ({
|
||||
previewTts: (...args: unknown[]) => previewTtsMock(...args),
|
||||
}))
|
||||
|
||||
const mockMaterial: VoiceMaterial = {
|
||||
id: "asset-1",
|
||||
name: "测试素材",
|
||||
description: "",
|
||||
gender: "neutral",
|
||||
tagIds: [],
|
||||
fileName: "test.mp3",
|
||||
fileSize: 1024,
|
||||
duration: 30,
|
||||
mimeType: "audio/mpeg",
|
||||
createdAt: "2024-01-01T00:00:00Z",
|
||||
fileUrl: "https://example.com/test.mp3",
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
for (const k of Object.keys(listeners)) delete listeners[k]
|
||||
|
||||
mockAudioInstance = {
|
||||
play: mockAudioPlay.mockResolvedValue(undefined),
|
||||
pause: mockAudioPause,
|
||||
addEventListener: (ev: string, cb: () => void) => {
|
||||
listeners[ev] = cb
|
||||
},
|
||||
currentTime: 0,
|
||||
duration: 30,
|
||||
src: "",
|
||||
volume: 1,
|
||||
paused: true,
|
||||
}
|
||||
|
||||
global.Audio = vi.fn().mockImplementation((url: string) => {
|
||||
mockAudioInstance.src = url
|
||||
return mockAudioInstance
|
||||
}) as unknown as typeof Audio
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe("useAudioPlayer (voices, 真实播放)", () => {
|
||||
it("初始状态为空", () => {
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
expect(result.current.playingId).toBeNull()
|
||||
expect(result.current.loadingId).toBeNull()
|
||||
expect(result.current.currentTime).toBe(0)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it("应该使用初始状态初始化", () => {
|
||||
it("playMaterial 用 file_url 真实播放素材", () => {
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
act(() => {
|
||||
result.current.playMaterial(mockMaterial)
|
||||
})
|
||||
|
||||
expect(result.current.playingId).toBe("asset-1")
|
||||
expect(mockAudioPlay).toHaveBeenCalledTimes(1)
|
||||
expect(mockAudioInstance.src).toBe("https://example.com/test.mp3")
|
||||
})
|
||||
|
||||
it("file_url 缺失时不播放", () => {
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
act(() => {
|
||||
result.current.playMaterial({ ...mockMaterial, fileUrl: undefined })
|
||||
})
|
||||
|
||||
expect(result.current.playingId).toBeNull()
|
||||
expect(mockAudioPlay).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("handleToggleMaterial 播放中再点为暂停", () => {
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
act(() => {
|
||||
result.current.handleToggleMaterial(mockMaterial)
|
||||
})
|
||||
expect(result.current.playingId).toBe("asset-1")
|
||||
|
||||
act(() => {
|
||||
result.current.handleToggleMaterial(mockMaterial)
|
||||
})
|
||||
expect(result.current.playingId).toBeNull()
|
||||
expect(mockAudioPause).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("暂停后再次点击恢复播放", () => {
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
act(() => {
|
||||
result.current.handleToggleMaterial(mockMaterial)
|
||||
})
|
||||
act(() => {
|
||||
result.current.handleToggleMaterial(mockMaterial) // 暂停
|
||||
})
|
||||
mockAudioPlay.mockClear()
|
||||
act(() => {
|
||||
result.current.handleToggleMaterial(mockMaterial) // 恢复
|
||||
})
|
||||
expect(result.current.playingId).toBe("asset-1")
|
||||
expect(mockAudioPlay).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("playVoice 合成中显示 loading,成功后播放且二次点用缓存不重复合成", async () => {
|
||||
previewTtsMock.mockResolvedValue({ audio_url: "https://example.com/tts.mp3", duration: 5 })
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.playVoice("voice-1")
|
||||
})
|
||||
|
||||
expect(previewTtsMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ voice_id: "voice-1", speed: 1.0 }),
|
||||
)
|
||||
expect(result.current.playingId).toBe("voice-1")
|
||||
expect(mockAudioInstance.src).toBe("https://example.com/tts.mp3")
|
||||
|
||||
// 停止后再次试听同一音色 → 走缓存
|
||||
act(() => {
|
||||
result.current.stopPlayback()
|
||||
})
|
||||
mockAudioPlay.mockClear()
|
||||
previewTtsMock.mockClear()
|
||||
|
||||
await act(async () => {
|
||||
await result.current.playVoice("voice-1")
|
||||
})
|
||||
expect(previewTtsMock).not.toHaveBeenCalled()
|
||||
expect(result.current.playingId).toBe("voice-1")
|
||||
expect(mockAudioPlay).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("合成失败时复位 loading 且不播放", async () => {
|
||||
previewTtsMock.mockRejectedValue(new Error("克隆尚未完成"))
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.playVoice("voice-x")
|
||||
})
|
||||
|
||||
expect(result.current.loadingId).toBeNull()
|
||||
expect(result.current.playingId).toBeNull()
|
||||
expect(mockAudioPlay).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("互斥:播放新素材时停止上一个", () => {
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
act(() => {
|
||||
result.current.playMaterial(mockMaterial)
|
||||
})
|
||||
act(() => {
|
||||
result.current.playMaterial({
|
||||
...mockMaterial,
|
||||
id: "asset-2",
|
||||
fileUrl: "https://e.com/2.mp3",
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.current.playingId).toBe("asset-2")
|
||||
expect(mockAudioPause).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("handleSeek 播放中调整 currentTime", () => {
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
act(() => {
|
||||
result.current.playMaterial(mockMaterial)
|
||||
})
|
||||
act(() => {
|
||||
result.current.handleSeek(12)
|
||||
})
|
||||
|
||||
expect(mockAudioInstance.currentTime).toBe(12)
|
||||
})
|
||||
|
||||
it("timeupdate 更新进度、ended 复位", () => {
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
act(() => {
|
||||
result.current.playMaterial(mockMaterial)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
mockAudioInstance.currentTime = 7
|
||||
;(listeners["timeupdate"] as () => void)()
|
||||
})
|
||||
expect(result.current.currentTime).toBe(7)
|
||||
|
||||
act(() => {
|
||||
;(listeners["ended"] as () => void)()
|
||||
})
|
||||
expect(result.current.playingId).toBeNull()
|
||||
expect(result.current.currentTime).toBe(0)
|
||||
})
|
||||
|
||||
it("handlePlay 应该开始播放指定音色", () => {
|
||||
it("stopPlayback 复位全部状态", () => {
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
act(() => {
|
||||
result.current.handlePlay("voice-1", 10)
|
||||
result.current.playMaterial(mockMaterial)
|
||||
})
|
||||
|
||||
expect(result.current.playingId).toBe("voice-1")
|
||||
expect(result.current.currentTime).toBe(0)
|
||||
})
|
||||
|
||||
it("handlePlay 对同一个音色不应重复启动播放", () => {
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
act(() => {
|
||||
result.current.handlePlay("voice-1", 10)
|
||||
})
|
||||
|
||||
const initialTime = result.current.currentTime
|
||||
|
||||
// 推进一些时间让进度走动
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200)
|
||||
})
|
||||
|
||||
const timeAfterAdvance = result.current.currentTime
|
||||
expect(timeAfterAdvance).toBeGreaterThan(initialTime)
|
||||
|
||||
// 对同一个音色再次调用 handlePlay 不应重置
|
||||
act(() => {
|
||||
result.current.handlePlay("voice-1", 10)
|
||||
})
|
||||
|
||||
expect(result.current.playingId).toBe("voice-1")
|
||||
expect(result.current.currentTime).toBe(timeAfterAdvance)
|
||||
})
|
||||
|
||||
it("handlePlay 切换音色时应停止上一个并从头开始", () => {
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
act(() => {
|
||||
result.current.handlePlay("voice-1", 10)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500)
|
||||
})
|
||||
|
||||
expect(result.current.playingId).toBe("voice-1")
|
||||
expect(result.current.currentTime).toBeGreaterThan(0)
|
||||
|
||||
act(() => {
|
||||
result.current.handlePlay("voice-2", 15)
|
||||
})
|
||||
|
||||
expect(result.current.playingId).toBe("voice-2")
|
||||
expect(result.current.currentTime).toBe(0)
|
||||
})
|
||||
|
||||
it("播放进度应该随时间递增", () => {
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
act(() => {
|
||||
result.current.handlePlay("voice-1", 10)
|
||||
})
|
||||
|
||||
// 每 100ms 增加 0.1
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(300)
|
||||
})
|
||||
|
||||
expect(result.current.currentTime).toBeCloseTo(0.3, 1)
|
||||
expect(result.current.playingId).toBe("voice-1")
|
||||
})
|
||||
|
||||
it("播放到结尾应自动停止并重置", () => {
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
act(() => {
|
||||
result.current.handlePlay("voice-1", 0.5) // 0.5 秒的短音频
|
||||
})
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(600) // 超过 0.5 秒
|
||||
})
|
||||
|
||||
expect(result.current.playingId).toBeNull()
|
||||
expect(result.current.currentTime).toBe(0)
|
||||
})
|
||||
|
||||
it("handlePause 应该暂停播放", () => {
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
act(() => {
|
||||
result.current.handlePlay("voice-1", 10)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200)
|
||||
})
|
||||
|
||||
const timeBeforePause = result.current.currentTime
|
||||
|
||||
act(() => {
|
||||
result.current.handlePause()
|
||||
})
|
||||
|
||||
expect(result.current.playingId).toBeNull()
|
||||
|
||||
// 暂停后时间不应再变化
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500)
|
||||
})
|
||||
|
||||
expect(result.current.currentTime).toBe(timeBeforePause)
|
||||
})
|
||||
|
||||
it("handleSeek 应该跳转到指定时间", () => {
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
act(() => {
|
||||
result.current.handlePlay("voice-1", 10)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleSeek("voice-1", 5, 10)
|
||||
})
|
||||
|
||||
expect(result.current.currentTime).toBe(5)
|
||||
})
|
||||
|
||||
it("handleSeek 对不同音色应该开始播放该音色", () => {
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
act(() => {
|
||||
result.current.handlePlay("voice-1", 10)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleSeek("voice-2", 3, 15)
|
||||
})
|
||||
|
||||
expect(result.current.playingId).toBe("voice-2")
|
||||
expect(result.current.currentTime).toBe(3)
|
||||
})
|
||||
|
||||
it("handleTogglePlay 应该在播放和暂停之间切换", () => {
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
// 初始为暂停,调用应开始播放
|
||||
act(() => {
|
||||
result.current.handleTogglePlay("voice-1", 10)
|
||||
})
|
||||
|
||||
expect(result.current.playingId).toBe("voice-1")
|
||||
|
||||
// 再次调用应暂停
|
||||
act(() => {
|
||||
result.current.handleTogglePlay("voice-1", 10)
|
||||
})
|
||||
|
||||
expect(result.current.playingId).toBeNull()
|
||||
})
|
||||
|
||||
it("stopPlayback 应该重置所有播放状态", () => {
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
act(() => {
|
||||
result.current.handlePlay("voice-1", 10)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(300)
|
||||
})
|
||||
|
||||
expect(result.current.playingId).toBe("voice-1")
|
||||
expect(result.current.currentTime).toBeGreaterThan(0)
|
||||
|
||||
act(() => {
|
||||
result.current.stopPlayback()
|
||||
})
|
||||
|
||||
expect(result.current.playingId).toBeNull()
|
||||
expect(result.current.currentTime).toBe(0)
|
||||
|
||||
// 停止后定时器不应再触发
|
||||
const timeAfterStop = result.current.currentTime
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500)
|
||||
})
|
||||
expect(result.current.currentTime).toBe(timeAfterStop)
|
||||
})
|
||||
|
||||
it("返回值应该包含所有必要的方法和状态", () => {
|
||||
const { result } = renderHook(() => useAudioPlayer())
|
||||
|
||||
expect(typeof result.current.handlePlay).toBe("function")
|
||||
expect(typeof result.current.handlePause).toBe("function")
|
||||
expect(typeof result.current.handleSeek).toBe("function")
|
||||
expect(typeof result.current.handleTogglePlay).toBe("function")
|
||||
expect(typeof result.current.stopPlayback).toBe("function")
|
||||
expect(typeof result.current.playingId).toBe("object") // string | null
|
||||
expect(typeof result.current.currentTime).toBe("number")
|
||||
expect(mockAudioPause).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -29,7 +29,7 @@ import "@/pages/voices/components/tts-modal/ErrorAlert"
|
||||
import "@/pages/voices/components/tts-modal/ResultPanel"
|
||||
import "@/pages/voices/components/tts-modal/types"
|
||||
import "@/pages/voices/components/VoiceFilterBar"
|
||||
import "@/pages/voices/components/MaterialVoiceCard"
|
||||
import "@/pages/voices/components/MaterialVoiceTab"
|
||||
|
||||
// 类型与常量
|
||||
import "@/pages/voices/types"
|
||||
|
||||
Reference in New Issue
Block a user