forked from 77media/video-flow
295 lines
10 KiB
TypeScript
295 lines
10 KiB
TypeScript
import React, { useState, useRef } from 'react';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import { Upload, Library, Play, Pause, RefreshCw, Wand2, Users } from 'lucide-react';
|
|
import { cn } from '@/public/lib/utils';
|
|
import { GlassIconButton } from './glass-icon-button';
|
|
import { ReplaceCharacterModal } from './replace-character-modal';
|
|
|
|
interface Role {
|
|
name: string;
|
|
url: string;
|
|
sound: string;
|
|
soundDescription: string;
|
|
roleDescription: string;
|
|
}
|
|
|
|
interface CharacterTabContentProps {
|
|
taskSketch: any[];
|
|
currentSketchIndex: number;
|
|
onSketchSelect: (index: number) => void;
|
|
roles: Role[];
|
|
}
|
|
|
|
export function CharacterTabContent({
|
|
taskSketch,
|
|
currentSketchIndex,
|
|
onSketchSelect,
|
|
roles = []
|
|
}: 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);
|
|
|
|
// 如果没有角色数据,显示占位内容
|
|
if (!roles || roles.length === 0) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center min-h-[400px] text-white/50">
|
|
<Users className="w-16 h-16 mb-4" />
|
|
<p>No character data</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 处理音频播放进度
|
|
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);
|
|
}
|
|
};
|
|
|
|
// 获取当前选中的角色
|
|
const currentRole = roles[selectedCharacterIndex];
|
|
|
|
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">
|
|
{roles.map((role, index) => (
|
|
<motion.div
|
|
key={`role-${index}`}
|
|
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={role.url}
|
|
alt={role.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">{role.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">Replace character</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>Upload character</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>Character library</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">Character name</label>
|
|
<input
|
|
type="text"
|
|
value={currentRole.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">Voice description</label>
|
|
<textarea
|
|
value={currentRole.soundDescription}
|
|
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">Voice preview</span>
|
|
<GlassIconButton
|
|
icon={RefreshCw}
|
|
tooltip="Regenerate voice"
|
|
onClick={() => console.log('regenerate voice')}
|
|
size="sm"
|
|
/>
|
|
</div>
|
|
|
|
<div className="relative">
|
|
<audio
|
|
ref={audioRef}
|
|
src={currentRole.sound}
|
|
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">Character description</label>
|
|
<textarea
|
|
value={currentRole.roleDescription}
|
|
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 mx-auto rounded-lg overflow-hidden relative group">
|
|
<img
|
|
src={currentRole.url}
|
|
alt={currentRole.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="Regenerate character"
|
|
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>
|
|
);
|
|
}
|