video-flow-b/components/ui/character-tab-content.tsx
2025-06-29 00:04:15 +08:00

292 lines
11 KiB
TypeScript

import React, { useState, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Upload, Library, Play, Pause, RefreshCw, Wand2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { GlassIconButton } from './glass-icon-button';
import { ReplaceCharacterModal } from './replace-character-modal';
interface CharacterTabContentProps {
taskSketch: any[];
currentSketchIndex: number;
onSketchSelect: (index: number) => void;
}
// 模拟角色数据
const MOCK_CHARACTERS = [
{
id: 1,
name: '雪 (YUKI)',
avatar: '/assets/3dr_chihiro.png',
voiceDescription: '年轻女性,温柔而坚定的声线,语速适中,带有轻微的感性色彩。',
characterDescription: '一位接近二十岁或二十出头的年轻女性,东亚裔,拥有深色长发和刘海,五官柔和且富有表现力。',
voiceUrl: 'https://example.com/voice-sample.mp3'
},
{
id: 2,
name: '春 (HARU)',
avatar: '/assets/3dr_mono.png',
voiceDescription: '年轻男性,清澈而温和的声线,语速从容,带有知性的特质。',
characterDescription: '一位接近二十岁或二十出头的年轻男性,东亚裔,拥有深色、发型整洁的中长发和深思的气质。',
voiceUrl: 'https://example.com/voice-sample.mp3'
},
];
export function CharacterTabContent({
taskSketch,
currentSketchIndex,
onSketchSelect
}: CharacterTabContentProps) {
const [selectedCharacterIndex, setSelectedCharacterIndex] = useState(0);
const [isReplaceModalOpen, setIsReplaceModalOpen] = useState(false);
const [activeReplaceMethod, setActiveReplaceMethod] = useState('upload');
const [isPlaying, setIsPlaying] = useState(false);
const [progress, setProgress] = useState(0);
const [editingField, setEditingField] = useState<{
type: 'name' | 'voiceDescription' | 'characterDescription' | null;
value: string;
}>({ type: null, value: '' });
const audioRef = useRef<HTMLAudioElement>(null);
// 处理音频播放进度
const handleTimeUpdate = () => {
if (audioRef.current) {
const progress = (audioRef.current.currentTime / audioRef.current.duration) * 100;
setProgress(progress);
}
};
// 处理播放/暂停
const togglePlay = () => {
if (audioRef.current) {
if (isPlaying) {
audioRef.current.pause();
} else {
audioRef.current.play();
}
setIsPlaying(!isPlaying);
}
};
// 处理进度条点击
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (audioRef.current) {
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const percentage = (x / rect.width) * 100;
const time = (percentage / 100) * audioRef.current.duration;
audioRef.current.currentTime = time;
setProgress(percentage);
}
};
return (
<div className="flex flex-col gap-6">
{/* 上部分:角色缩略图 */}
<motion.div
className="space-y-6"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
>
<div className="relative">
<div className="flex gap-4 overflow-x-auto p-2 hide-scrollbar">
{MOCK_CHARACTERS.map((character, index) => (
<motion.div
key={character.id}
className={cn(
'relative flex-shrink-0 w-24 rounded-lg overflow-hidden cursor-pointer',
'aspect-[9/16]',
selectedCharacterIndex === index ? 'ring-2 ring-blue-500' : 'hover:ring-2 hover:ring-blue-500/50'
)}
onClick={() => setSelectedCharacterIndex(index)}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<img
src={character.avatar}
alt={character.name}
className="w-full h-full object-cover"
/>
<div className="absolute bottom-0 left-0 right-0 p-1 bg-gradient-to-t from-black/60 to-transparent">
<span className="text-xs text-white/90 line-clamp-1">{character.name}</span>
</div>
</motion.div>
))}
</div>
</div>
</motion.div>
{/* 中间部分:替换角色 */}
<motion.div
className="p-4 rounded-lg bg-white/5"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
<h3 className="text-sm font-medium mb-2"></h3>
<div className="flex gap-4">
<motion.button
className="flex-1 flex flex-col items-center gap-2 p-4 rounded-lg border border-white/10 hover:border-white/20 transition-colors"
onClick={() => {
setActiveReplaceMethod('upload');
setIsReplaceModalOpen(true);
}}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<Upload className="w-6 h-6" />
<span></span>
</motion.button>
<motion.button
className="flex-1 flex flex-col items-center gap-2 p-4 rounded-lg border border-white/10 hover:border-white/20 transition-colors"
onClick={() => {
setActiveReplaceMethod('library');
setIsReplaceModalOpen(true);
}}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<Library className="w-6 h-6" />
<span></span>
</motion.button>
</div>
</motion.div>
{/* 下部分:角色详情 */}
<motion.div
className="grid grid-cols-2 gap-6"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
{/* 左列:角色信息 */}
<div className="space-y-4">
{/* 角色姓名 */}
<div className="space-y-2">
<label className="text-sm text-white/70"></label>
<input
type="text"
value={MOCK_CHARACTERS[selectedCharacterIndex].name}
onChange={(e) => console.log('name changed:', e.target.value)}
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg
focus:outline-none focus:border-blue-500"
/>
</div>
{/* 声音描述 */}
<div className="space-y-2">
<label className="text-sm text-white/70"></label>
<textarea
value={MOCK_CHARACTERS[selectedCharacterIndex].voiceDescription}
onChange={(e) => console.log('voice description changed:', e.target.value)}
className="w-full h-24 px-3 py-2 bg-white/5 border border-white/10 rounded-lg
focus:outline-none focus:border-blue-500 resize-none"
/>
</div>
{/* 声音预览 */}
<div className="p-4 rounded-lg bg-white/5 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-white/70"></span>
<GlassIconButton
icon={RefreshCw}
tooltip="重新生成声音"
onClick={() => console.log('regenerate voice')}
size="sm"
/>
</div>
<div className="relative">
<audio
ref={audioRef}
src={MOCK_CHARACTERS[selectedCharacterIndex].voiceUrl}
onTimeUpdate={handleTimeUpdate}
onEnded={() => setIsPlaying(false)}
/>
{/* 进度条 */}
<div
className="w-full h-1 bg-white/10 rounded-full cursor-pointer overflow-hidden"
onClick={handleProgressClick}
>
<motion.div
className="h-full bg-blue-500 rounded-full"
style={{ width: `${progress}%` }}
transition={{ type: "spring", stiffness: 300, damping: 25 }}
/>
</div>
{/* 播放控制 */}
<div className="mt-2 flex items-center gap-2">
<motion.button
className="p-2 rounded-full hover:bg-white/10"
onClick={togglePlay}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
{isPlaying ? (
<Pause className="w-4 h-4" />
) : (
<Play className="w-4 h-4" />
)}
</motion.button>
<div className="text-xs text-white/50">
{audioRef.current ? (
`${Math.floor(audioRef.current.currentTime)}s / ${Math.floor(audioRef.current.duration)}s`
) : '0:00 / 0:00'}
</div>
</div>
</div>
</div>
</div>
{/* 右列:角色信息 */}
<div className="space-y-4">
{/* 角色描述 */}
<div className="space-y-2">
<label className="text-sm text-white/70"></label>
<textarea
value={MOCK_CHARACTERS[selectedCharacterIndex].characterDescription}
onChange={(e) => console.log('character description changed:', e.target.value)}
className="w-full h-24 px-3 py-2 bg-white/5 border border-white/10 rounded-lg
focus:outline-none focus:border-blue-500 resize-none"
/>
</div>
{/* 角色预览 */}
<div className="w-full max-w-[280px] mx-auto aspect-[9/16] rounded-lg overflow-hidden relative group">
<img
src={MOCK_CHARACTERS[selectedCharacterIndex].avatar}
alt={MOCK_CHARACTERS[selectedCharacterIndex].name}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent
opacity-100 transition-opacity">
<div className="absolute bottom-4 left-4">
<GlassIconButton
icon={Wand2}
tooltip="重新生成角色形象"
onClick={() => console.log('regenerate character')}
/>
</div>
</div>
</div>
</div>
</motion.div>
{/* 替换角色弹窗 */}
<ReplaceCharacterModal
isOpen={isReplaceModalOpen}
activeReplaceMethod={activeReplaceMethod}
onClose={() => setIsReplaceModalOpen(false)}
onCharacterSelect={(character) => {
console.log('Selected character:', character);
setIsReplaceModalOpen(false);
// TODO: 处理角色选择逻辑
}}
/>
</div>
);
}