Spaces:
Running
Running
| 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; |