Spaces:
Running
Running
File size: 15,237 Bytes
a8df197 4ed4d62 a8df197 4ed4d62 a8df197 4ed4d62 a8df197 4ed4d62 a8df197 4ed4d62 a8df197 4ed4d62 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 | import React, { useState, useRef, useEffect } from 'react';
import { Model } from '../types';
interface ModelModalProps {
model: Model | null;
isOpen: boolean;
onClose: () => void;
onConfirm: (data: { file?: File; text?: string; mode: 'vc' | 'tts'; pitch: number }) => void;
isLoading: boolean;
subscriptionStatus?: 'free' | 'paid';
onUpgrade?: () => void;
}
const ModelModal: React.FC<ModelModalProps> = ({
model,
isOpen,
onClose,
onConfirm,
isLoading,
subscriptionStatus = 'free',
onUpgrade
}) => {
const [activeMode, setActiveMode] = useState<'vc' | 'tts'>('vc');
const [file, setFile] = useState<File | null>(null);
const [fileUrl, setFileUrl] = useState<string | null>(null);
const [text, setText] = useState('');
const [pitch, setPitch] = useState<number | null>(null); // Null initially to force selection
const inputRef = useRef<HTMLInputElement>(null);
// Reset state when modal opens
useEffect(() => {
if (isOpen) {
setFile(null);
setFileUrl(null);
setText('');
setPitch(null); // Reset pitch
}
}, [isOpen]);
// Clean up object URL
useEffect(() => {
return () => {
if (fileUrl) URL.revokeObjectURL(fileUrl);
};
}, [fileUrl]);
const handleFileChange = (selectedFile: File) => {
if (fileUrl) URL.revokeObjectURL(fileUrl);
setFile(selectedFile);
setFileUrl(URL.createObjectURL(selectedFile));
};
if (!isOpen || !model) return null;
const handleConfirm = () => {
if (activeMode === 'vc' && file) {
onConfirm({ file, mode: 'vc', pitch: pitch ?? 0 });
} else if (activeMode === 'tts' && text.trim().length > 0) {
onConfirm({ text, mode: 'tts', pitch: pitch ?? 0 });
}
};
const isLegacy = model.type === 'legacy_rvc';
// Logic for pitch selection based on TARGET GENDER
const targetIsFemale = model.targetGender === 'female';
const setPitchForGender = (inputGender: 'male' | 'female') => {
if (targetIsFemale) {
if (inputGender === 'male') setPitch(12);
else setPitch(0);
} else {
if (inputGender === 'male') setPitch(0);
else setPitch(-12);
}
};
const isMaleSelected = targetIsFemale ? pitch === 12 : pitch === 0;
const isFemaleSelected = targetIsFemale ? pitch === 0 : pitch === -12;
const isReady = activeMode === 'vc'
? (!!file && (!isLegacy || pitch !== null))
: (text.trim().length > 0 && (subscriptionStatus !== 'free' || text.length <= 500));
const activeColor = activeMode === 'vc' ? 'bg-gradient-to-r from-blue-500 to-indigo-600' : 'bg-gradient-to-r from-teal-400 to-teal-600';
const containerBorder = activeMode === 'vc' ? 'border-blue-100 bg-blue-50/30' : 'border-teal-100 bg-teal-50/30';
// Handle Model Image for Custom Models (Handle File/Blob)
const getModelImage = () => {
if (model.isCustom && model.image && typeof model.image !== 'string') {
try {
return URL.createObjectURL(model.image as any);
} catch (e) {
return '';
}
}
return (model.image as string) || '';
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm transition-opacity"
onClick={onClose}
></div>
<div className="relative bg-white rounded-3xl w-full max-w-[350px] shadow-2xl animate-[fadeIn_0.3s_ease-out] overflow-hidden flex flex-col max-h-[90vh]">
<div className="overflow-y-auto p-5 custom-scrollbar">
<button
onClick={onClose}
className="absolute top-4 right-4 text-gray-400 hover:text-gray-600 w-8 h-8 flex items-center justify-center rounded-full hover:bg-gray-100 transition-colors z-10"
>
<i className="fas fa-times text-xl"></i>
</button>
<div className="text-center mb-6 mt-2">
<div className="w-16 h-16 mx-auto rounded-full p-1 bg-gradient-to-tr from-primary to-secondary mb-3 shadow-lg overflow-hidden">
<img
src={getModelImage()}
alt={model.name}
className="w-full h-full object-cover rounded-full border-2 border-white"
/>
</div>
<h3 className="text-xl font-black text-gray-800">{model.name}</h3>
</div>
<div className="relative flex bg-gray-100 p-1 rounded-xl mb-6 h-14 shadow-inner">
<div
className={`absolute top-1 bottom-1 w-[calc(50%-4px)] rounded-lg shadow-sm transition-all duration-300 ease-[cubic-bezier(0.25,0.1,0.25,1)]
${activeMode === 'vc'
? 'right-1 translate-x-0 bg-gradient-to-r from-blue-500 to-indigo-600'
: 'right-1 -translate-x-[100%] -ml-2 bg-gradient-to-r from-teal-400 to-emerald-500'
}`}
></div>
<button
onClick={() => setActiveMode('vc')}
className={`flex-1 relative z-10 text-sm font-bold text-center transition-colors duration-200 flex items-center justify-center gap-2
${activeMode === 'vc' ? 'text-white' : 'text-gray-600 hover:text-gray-800'}`}
>
<i className="fas fa-microphone-alt text-lg"></i> تغییر صدا
</button>
<button
onClick={() => setActiveMode('tts')}
className={`flex-1 relative z-10 text-sm font-bold text-center transition-colors duration-200 flex items-center justify-center gap-2
${activeMode === 'tts' ? 'text-white' : 'text-gray-600 hover:text-gray-800'}`}
>
<i className="fas fa-keyboard text-lg"></i> متن به صدا
</button>
</div>
<div className={`min-h-[160px] rounded-2xl p-2 border-2 transition-all duration-300 ${containerBorder}`}>
{activeMode === 'vc' ? (
<div className="animate-[fadeIn_0.3s_ease-out] h-full flex flex-col justify-center">
{!file && (
<div
onClick={() => inputRef.current?.click()}
className="flex-1 border-2 border-dashed border-blue-200 rounded-xl p-5 text-center cursor-pointer hover:border-blue-400 hover:bg-white/50 transition-all flex flex-col items-center justify-center min-h-[140px]"
>
<input
type="file"
ref={inputRef}
hidden
accept="audio/*"
onChange={(e) => e.target.files && e.target.files[0] && handleFileChange(e.target.files[0])}
/>
<div className="w-12 h-12 rounded-full mb-3 flex items-center justify-center bg-blue-100 text-blue-500">
<i className="fas fa-cloud-upload-alt text-xl"></i>
</div>
<p className="font-bold text-gray-700 text-sm mb-1 text-center leading-relaxed">برای انتخاب فایل صوتی کلیک کنید</p>
<p className="text-[10px] text-gray-400">یا صدای خود را ضبط کنید</p>
</div>
)}
{file && (
<div className="w-full animate-[fadeIn_0.3s_ease-out]">
<div className="text-center py-2">
<p className="text-xs text-gray-500 mb-2 flex items-center justify-center gap-2">
<i className="fas fa-headphones-alt text-primary"></i> پیشنمایش صدای شما
</p>
<audio
controls
src={fileUrl || ''}
className="w-full h-8 rounded-lg shadow-sm"
/>
<div className="flex items-center justify-between mt-2 px-1">
<span className="text-[10px] text-gray-400 truncate max-w-[150px] dir-ltr">{file.name}</span>
<button
onClick={() => { setFile(null); setPitch(null); }}
className="text-[10px] text-red-500 hover:text-red-700 font-bold"
>
<i className="fas fa-sync-alt mr-1"></i> تعویض فایل
</button>
</div>
</div>
{isLegacy && (
<div className="mt-2 animate-[fadeIn_0.3s_ease-out]">
<hr className="border-blue-200/50 mb-3" />
<div>
<p className="text-xs text-center text-gray-600 mb-3 font-bold">جنسیت صدای این فایل چیست؟</p>
<div className="flex gap-3">
<div
onClick={() => setPitchForGender('male')}
className={`flex-1 p-3 rounded-xl border-2 cursor-pointer transition-all flex flex-col items-center gap-2 shadow-sm
${isMaleSelected
? 'border-blue-500 bg-blue-600 text-white transform scale-105 shadow-blue-200'
: 'border-gray-200 bg-white text-gray-500 hover:border-blue-200 hover:bg-gray-50'}`}
>
<i className="fas fa-male text-2xl"></i>
<span className="text-xs font-bold">مرد</span>
</div>
<div
onClick={() => setPitchForGender('female')}
className={`flex-1 p-3 rounded-xl border-2 cursor-pointer transition-all flex flex-col items-center gap-2 shadow-sm
${isFemaleSelected
? 'border-pink-500 bg-pink-500 text-white transform scale-105 shadow-pink-200'
: 'border-gray-200 bg-white text-gray-500 hover:border-pink-200 hover:bg-gray-50'}`}
>
<i className="fas fa-female text-2xl"></i>
<span className="text-xs font-bold">زن</span>
</div>
</div>
</div>
</div>
)}
{!isLegacy && (
<p className="text-center text-[11px] text-gray-400 mt-4 animate-pulse">
برای شروع پردازش دکمه پایین را بزنید
</p>
)}
</div>
)}
</div>
) : (
<div className="animate-[fadeIn_0.3s_ease-out] h-full flex flex-col">
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="متن را اینجا بنویسید..."
className="flex-1 w-full p-4 rounded-xl border border-teal-200 focus:border-teal-500 focus:ring-2 focus:ring-teal-500/20 outline-none resize-none bg-white text-gray-700 text-sm leading-relaxed shadow-sm min-h-[140px]"
></textarea>
{/* شمارشگر و وضعیت کاراکترها */}
<div className="flex justify-between items-center mt-1.5 px-1 text-[10px]">
<span className={text.length > 500 && subscriptionStatus === 'free' ? 'text-red-500 font-bold' : 'text-gray-400'}>
تعداد حروف: {text.length}
</span>
{subscriptionStatus === 'free' && <span className="text-gray-400">حداکثر ۵۰۰ حرف برای نسخه رایگان</span>}
</div>
{/* باکس پیغام زیبای محدودیت کاراکتر و ارتقا */}
{text.length > 500 && subscriptionStatus === 'free' && (
<div className="mt-3 p-3 bg-amber-50 border border-amber-200 rounded-xl text-right animate-[slideUp_0.3s_ease-out]">
<div className="flex items-start gap-2">
<i className="fas fa-exclamation-triangle text-amber-500 mt-0.5 text-xs"></i>
<div className="flex-1">
<p className="text-[11px] font-black text-amber-900 leading-relaxed">تعداد حروف بیشتر از حد مجاز است!</p>
<p className="text-[10px] text-amber-700 mt-1 leading-relaxed">در نسخه رایگان حداکثر میتوانید ۵۰۰ حرف را به گفتار تبدیل کنید. برای خروجیهای طولانیتر لطفاً حساب خود را ارتقا دهید.</p>
</div>
</div>
<button
onClick={onUpgrade}
className="mt-2.5 w-full py-2.5 bg-gradient-to-r from-amber-500 via-yellow-500 to-orange-500 text-gray-950 rounded-lg font-bold text-[10px] flex items-center justify-center gap-1.5 shadow-sm hover:shadow-md transition-all active:scale-95"
>
<i className="fas fa-crown"></i> ارتقای حساب کاربری و دسترسی نامحدود
</button>
</div>
)}
</div>
)}
</div>
<button
onClick={handleConfirm}
disabled={!isReady || isLoading}
className={`w-full py-4 rounded-2xl font-bold text-white text-base shadow-md transition-all mt-6 transform active:scale-95
${(!isReady || isLoading)
? 'bg-gray-300 cursor-not-allowed shadow-none'
: `${activeColor} hover:shadow-lg shadow-blue-200`}`}
>
{isLoading ? (
<span className="flex items-center justify-center gap-2">
<i className="fas fa-circle-notch fa-spin"></i> در حال پردازش...
</span>
) : (
<span className="flex items-center justify-center gap-2">
<span>شروع پردازش</span>
<i className={`fas ${activeMode === 'vc' ? 'fa-magic' : 'fa-play'}`}></i>
</span>
)}
</button>
</div>
</div>
</div>
);
};
export default ModelModal; |