Spaces:
Sleeping
Sleeping
| import React, { useState, useEffect, useRef } from 'react'; | |
| import { APP_NAME } from './constants'; | |
| import { JobData, Model, EnhancementData } from './types'; | |
| import * as api from './services/api'; | |
| import * as db from './services/db'; | |
| import * as creditApi from './services/credit'; | |
| import CustomVoice from './components/CustomVoice'; | |
| import ModelGrid from './components/ModelGrid'; | |
| import ModelModal from './components/ModelModal'; | |
| import CreateModelModal from './components/CreateModelModal'; | |
| import History from './components/History'; | |
| import BottomNav from './components/BottomNav'; | |
| import DeleteModal from './components/DeleteModal'; | |
| import UpgradeModal from './components/UpgradeModal'; | |
| import FlyToHistory from './components/FlyToHistory'; | |
| type TabType = 'home' | 'custom' | 'history'; | |
| type SubscriptionStatus = 'free' | 'paid'; | |
| type DeleteMode = 'job' | 'model'; | |
| interface JobPayload { | |
| sourceFile?: File; | |
| text?: string; | |
| refFile?: File | Blob; | |
| refFileUrl?: string; | |
| type: 'custom' | 'model'; | |
| mode: 'vc' | 'tts'; | |
| model?: Model | null; | |
| pitch: number; | |
| retryCount: number; | |
| } | |
| export default function App() { | |
| const [jobs, setJobs] = useState<Record<string, JobData>>({}); | |
| const [customModels, setCustomModels] = useState<Model[]>([]); | |
| const [activeJobId, setActiveJobId] = useState<string | null>(null); | |
| const [isLoading, setIsLoading] = useState(false); | |
| const [activeTab, setActiveTab] = useState<TabType>('home'); | |
| const [isModalOpen, setIsModalOpen] = useState(false); | |
| const [isCreateModelOpen, setIsCreateModelOpen] = useState(false); | |
| const [selectedModel, setSelectedModel] = useState<Model | null>(null); | |
| const [editingModel, setEditingModel] = useState<Model | null>(null); | |
| const [deleteModalOpen, setDeleteModalOpen] = useState(false); | |
| const [deleteTarget, setDeleteTarget] = useState<'all' | string | null>(null); | |
| const [deleteMode, setDeleteMode] = useState<DeleteMode>('job'); | |
| const [subscriptionStatus, setSubscriptionStatus] = useState<SubscriptionStatus>('free'); | |
| const [upgradeModalOpen, setUpgradeModalOpen] = useState(false); | |
| const [upgradeModalMode, setUpgradeModalMode] = useState<'limit' | 'feature'>('limit'); | |
| const [resetDate, setResetDate] = useState<number | null>(null); | |
| const [userFingerprint, setUserFingerprint] = useState<string | null>(null); | |
| const [showFlyAnimation, setShowFlyAnimation] = useState(false); | |
| const [lastJobMode, setLastJobMode] = useState<'vc' | 'tts'>('vc'); | |
| const activeJobPayloads = useRef<Record<string, JobPayload>>({}); | |
| const historyBtnRef = useRef<HTMLButtonElement>(null); | |
| const PREMIUM_PAGE_ID = '1149636'; | |
| const FULL_UPGRADE_URL = "#/nav/online/news/getSingle/1149636/eyJpdiI6IkVWcVZ2RFRERVhyaXBxaGtVSHpFK2c9PSIsInZhbHVlIjoiMmJWVllYNnhDNDJjbU96Q0Z5RHdzeE9HWjkrWTVNVzVFTzl3bFlUTzVsWHVTU1pCMWRteUJFeUZ6RVBYUks2VSIsIm1hYyI6IjU3Y2ZjM2IyZDEyNjY4ZTQ3NDgyM2JkMzE0Zjg4Y2ZmZDc5OWQyNTRmYzM4MzBjY2M5ZTNmMmViZDE3N2U5YmQiLCJ0YWciOiIifQ==/20934991"; | |
| useEffect(() => { | |
| const initUser = async () => { | |
| const fp = await creditApi.getBrowserFingerprint(); | |
| setUserFingerprint(fp); | |
| window.parent.postMessage({ type: 'REQUEST_USER_STATUS' }, '*'); | |
| }; | |
| initUser(); | |
| const handleMessage = (event: MessageEvent) => { | |
| if (event.data?.type === 'USER_STATUS_RESPONSE') { | |
| try { | |
| if (event.data.error || !event.data.payload) { | |
| setSubscriptionStatus('free'); | |
| return; | |
| } | |
| const userObject = JSON.parse(event.data.payload); | |
| const isPaid = userObject && userObject.isLogin && userObject.accessible_pages && | |
| (userObject.accessible_pages.includes(PREMIUM_PAGE_ID) || userObject.accessible_pages.includes(parseInt(PREMIUM_PAGE_ID))); | |
| setSubscriptionStatus(isPaid ? 'paid' : 'free'); | |
| } catch (e) { | |
| setSubscriptionStatus('free'); | |
| } | |
| } | |
| }; | |
| window.addEventListener('message', handleMessage); | |
| return () => window.removeEventListener('message', handleMessage); | |
| }, []); | |
| useEffect(() => { | |
| const loadData = async () => { | |
| try { | |
| const [storedJobs, storedModels] = await Promise.all([ | |
| db.getAllJobs(), | |
| db.getAllCustomModels() | |
| ]); | |
| const jobsMap: Record<string, JobData> = {}; | |
| storedJobs.forEach(job => { jobsMap[job.job_id] = job; }); | |
| setJobs(jobsMap); | |
| setCustomModels(storedModels); | |
| } catch (err) { | |
| console.error("Failed to load data from DB", err); | |
| } | |
| }; | |
| loadData(); | |
| }, []); | |
| const handleJobFailure = async (jobId: string, errorMessage: string) => { | |
| const payload = activeJobPayloads.current[jobId]; | |
| if (payload && payload.retryCount < 3) { | |
| payload.retryCount += 1; | |
| setJobs(prev => { | |
| const job = prev[jobId]; if (!job) return prev; | |
| const updated = { ...job, status: 'started' as const, statusMessage: `تلاش مجدد (${payload.retryCount}/3)...`, retryCount: payload.retryCount }; | |
| db.saveJob(updated); | |
| return { ...prev, [jobId]: updated }; | |
| }); | |
| setTimeout(() => executeJobProcess(jobId, payload), 2000); | |
| } else { | |
| delete activeJobPayloads.current[jobId]; | |
| setJobs(prev => { | |
| const job = prev[jobId]; if (!job) return prev; | |
| const failedJob = { ...job, status: 'failed' as const, statusMessage: 'خطا: ' + errorMessage }; | |
| db.saveJob(failedJob); | |
| return { ...prev, [jobId]: failedJob }; | |
| }); | |
| } | |
| }; | |
| const executeJobProcess = async (tempId: string, data: JobPayload) => { | |
| try { | |
| let actualRefFile = data.refFile; | |
| const isLegacy = data.model?.type === 'legacy_rvc'; | |
| if (!isLegacy && !actualRefFile && data.refFileUrl) { | |
| actualRefFile = await api.fetchRefAudioAsFile(data.refFileUrl, 'ref.wav'); | |
| } | |
| const finalRef = actualRefFile || new Blob([''], { type: 'audio/wav' }); | |
| const resultJob = await api.processUnifiedJob( | |
| data.sourceFile, data.text, finalRef, data.model || null, data.mode, data.pitch, | |
| (msg, progress) => { | |
| setJobs(prev => { | |
| const job = prev[tempId]; if (!job) return prev; | |
| return { ...prev, [tempId]: { ...job, statusMessage: msg, progress: progress !== undefined ? progress : job.progress } }; | |
| }); | |
| }, | |
| data.type | |
| ); | |
| const existingJob = jobs[tempId] || {}; | |
| // CRITICAL FIX: Ensure metadata (type, modelName, image) is preserved from existing/payload | |
| // if missing or undefined in resultJob to prevent UI reverting to "Custom Project". | |
| const finalJobData: JobData = { | |
| ...existingJob, | |
| ...resultJob, | |
| type: existingJob.type || resultJob.type || data.type, | |
| modelName: existingJob.modelName || resultJob.modelName || data.model?.name, | |
| modelImage: existingJob.modelImage || resultJob.modelImage || (typeof data.model?.image === 'string' ? data.model.image : undefined), | |
| mode: existingJob.mode || resultJob.mode || data.mode | |
| }; | |
| await db.saveJob(finalJobData); | |
| await db.deleteJob(tempId); | |
| setJobs(prev => { | |
| const nextJobs = { ...prev }; | |
| delete nextJobs[tempId]; | |
| nextJobs[finalJobData.job_id] = finalJobData; | |
| if (finalJobData.status === 'completed' || finalJobData.status === 'failed') { | |
| delete activeJobPayloads.current[tempId]; | |
| } | |
| return nextJobs; | |
| }); | |
| } catch (error: any) { | |
| handleJobFailure(tempId, error.message); | |
| } | |
| }; | |
| useEffect(() => { | |
| const pollActiveJobs = async () => { | |
| const activeJobsList = Object.values(jobs) as JobData[]; | |
| const jobsToPoll = activeJobsList.filter(job => | |
| (job.status === 'started' || job.status === 'processing') && | |
| !job.downloadUrl && !job.job_id.startsWith('temp_') | |
| ); | |
| if (jobsToPoll.length === 0) return; | |
| const updates: Record<string, JobData> = {}; | |
| let hasChanges = false; | |
| await Promise.all(jobsToPoll.map(async (job) => { | |
| try { | |
| const statusRes = await api.checkJobStatus(job); | |
| if (statusRes.status === 'completed' || statusRes.status === 'failed' || statusRes.status === 'error') { | |
| const isSuccess = statusRes.status === 'completed'; | |
| const finalStatus: JobData['status'] = isSuccess ? 'completed' : 'failed'; | |
| const updatedJob: JobData = { | |
| ...job, | |
| status: finalStatus, | |
| filename: statusRes.filename, | |
| downloadUrl: statusRes.downloadUrl, | |
| progress: isSuccess ? 100 : job.progress, | |
| statusMessage: statusRes.detail || (isSuccess ? 'تکمیل شد' : 'خطا در پردازش') | |
| }; | |
| updates[job.job_id] = updatedJob; | |
| hasChanges = true; | |
| db.saveJob(updatedJob); | |
| } else { | |
| // Check for progress, detail, OR chunks updates | |
| let shouldUpdate = false; | |
| let newChunks = job.chunks; | |
| // Critical for distributed processing: update chunks if server returns new task IDs | |
| if (statusRes.chunks && JSON.stringify(statusRes.chunks) !== JSON.stringify(job.chunks)) { | |
| newChunks = statusRes.chunks; | |
| shouldUpdate = true; | |
| } | |
| if ((statusRes.detail && statusRes.detail !== job.statusMessage) || (statusRes.progress && statusRes.progress > (job.progress || 0))) { | |
| shouldUpdate = true; | |
| } | |
| if (shouldUpdate) { | |
| updates[job.job_id] = { | |
| ...job, | |
| progress: statusRes.progress || job.progress, | |
| statusMessage: statusRes.detail || job.statusMessage, | |
| chunks: newChunks | |
| }; | |
| hasChanges = true; | |
| } | |
| } | |
| } catch (err) { console.error(err); } | |
| })); | |
| if (hasChanges) setJobs(prev => ({ ...prev, ...updates })); | |
| }; | |
| const interval = setInterval(pollActiveJobs, 3000); | |
| return () => clearInterval(interval); | |
| }, [jobs]); | |
| useEffect(() => { | |
| const pollEnhancementJobs = async () => { | |
| const enhanceJobsList = (Object.values(jobs) as JobData[]).filter(job => job.enhancement && job.enhancement.status === 'processing'); | |
| if (enhanceJobsList.length === 0) return; | |
| const updates: Record<string, JobData> = {}; | |
| let hasChanges = false; | |
| await Promise.all(enhanceJobsList.map(async (job) => { | |
| const enh = job.enhancement!; | |
| try { | |
| const statusRes = await api.checkEnhancementStatus(enh.metadata); | |
| let nextProgress = enh.progress || 5; | |
| if (statusRes.status === 'completed') nextProgress = 100; | |
| else nextProgress = Math.min(95, nextProgress + 1); | |
| if (statusRes.status !== enh.status || nextProgress !== enh.progress || statusRes.downloadUrl !== enh.downloadUrl) { | |
| const updatedJob = { | |
| ...job, | |
| enhancement: { | |
| ...enh, | |
| status: statusRes.status, | |
| progress: nextProgress, | |
| filename: statusRes.filename || enh.filename, | |
| downloadUrl: statusRes.downloadUrl || enh.downloadUrl // ذخیره آدرس مستقیم بله در مرورگر کاربر برای مقاومت در برابر ریاستارت | |
| } | |
| }; | |
| updates[job.job_id] = updatedJob; | |
| hasChanges = true; | |
| db.saveJob(updatedJob); | |
| } | |
| } catch (err) { console.error(err); } | |
| })); | |
| if (hasChanges) setJobs(prev => ({ ...prev, ...updates })); | |
| }; | |
| const interval = setInterval(pollEnhancementJobs, 3000); | |
| return () => clearInterval(interval); | |
| }, [jobs]); | |
| const startJob = async (data: any) => { | |
| const isCustomModel = data.type === 'custom' || data.model?.isCustom; | |
| if (subscriptionStatus === 'free') { | |
| if (isCustomModel) { setUpgradeModalMode('feature'); setUpgradeModalOpen(true); return; } | |
| if (userFingerprint) { | |
| const check = await creditApi.checkCredit(userFingerprint, data.model?.id || 'standard_vc'); | |
| if (check.limit_reached) { setResetDate(check.reset_timestamp); setUpgradeModalMode('limit'); setUpgradeModalOpen(true); return; } | |
| creditApi.useCredit(userFingerprint, data.model?.id || 'standard_vc'); | |
| } | |
| } | |
| const tempJobId = `temp_${Date.now()}`; | |
| const initialJob: JobData = { | |
| job_id: tempJobId, status: 'started' as const, progress: 0, statusMessage: 'ارسال درخواست...', total_chunks: 1, chunks: [], | |
| date: new Date().toLocaleString('fa-IR'), timestamp: Date.now(), | |
| type: data.type, mode: data.mode, modelName: data.model?.name, modelImage: typeof data.model?.image === 'string' ? data.model.image : undefined | |
| }; | |
| setJobs(prev => ({ ...prev, [tempJobId]: initialJob })); | |
| await db.saveJob(initialJob); | |
| setIsModalOpen(false); | |
| setLastJobMode(data.mode); | |
| setShowFlyAnimation(true); | |
| activeJobPayloads.current[tempJobId] = { ...data, retryCount: 0 }; | |
| executeJobProcess(tempJobId, data); | |
| }; | |
| const handleModelConfirm = (data: { file?: File; text?: string; mode: 'vc' | 'tts'; pitch: number }) => { | |
| if (!selectedModel) return; | |
| startJob({ | |
| sourceFile: data.file, | |
| text: data.text, | |
| refFile: typeof selectedModel.refFile !== 'string' ? selectedModel.refFile : undefined, | |
| refFileUrl: typeof selectedModel.refFile === 'string' ? selectedModel.refFile : undefined, | |
| type: 'model', | |
| mode: data.mode, | |
| model: selectedModel, | |
| pitch: data.pitch | |
| }); | |
| }; | |
| const handleCustomStart = (src: File, ref: File) => startJob({ sourceFile: src, refFile: ref, type: 'custom', mode: 'vc', pitch: 0 }); | |
| const handleEnhance = async (job: JobData) => { | |
| setJobs(prev => ({ ...prev, [job.job_id]: { ...job, enhancement: { status: 'processing' as const, progress: 5 } } })); | |
| try { | |
| const audioFile = await api.fetchRefAudioAsFile(job.downloadUrl || `https://ezmarynoori-sada.hf.space/download/${job.filename}`, 'orig.wav'); | |
| const response = await api.uploadEnhancementJob(audioFile); | |
| const processingJob: JobData = { ...job, enhancement: { status: 'processing' as const, job_id: response.job_id, progress: 5, metadata: response } }; | |
| setJobs(prev => ({ ...prev, [job.job_id]: processingJob })); | |
| db.saveJob(processingJob); | |
| } catch (err) { setJobs(prev => ({ ...prev, [job.job_id]: { ...job, enhancement: { status: 'failed' as const, progress: 0 } } })); } | |
| }; | |
| const handleSaveModel = async (model: Model) => { | |
| await db.saveCustomModel(model); | |
| setCustomModels(await db.getAllCustomModels()); | |
| setIsCreateModelOpen(false); | |
| setEditingModel(null); | |
| }; | |
| const handleDeleteCustomModel = (id: string) => { | |
| setDeleteMode('model'); | |
| setDeleteTarget(id); | |
| setDeleteModalOpen(true); | |
| setIsCreateModelOpen(false); | |
| }; | |
| const confirmDelete = async () => { | |
| if (deleteMode === 'job') { | |
| if (deleteTarget === 'all') { setJobs({}); await db.clearAllJobs(); } | |
| else if (typeof deleteTarget === 'string') { setJobs(prev => { const next = { ...prev }; delete next[deleteTarget]; return next; }); await db.deleteJob(deleteTarget); } | |
| } else if (deleteMode === 'model' && typeof deleteTarget === 'string') { | |
| await db.deleteCustomModel(deleteTarget); | |
| setCustomModels(await db.getAllCustomModels()); | |
| setEditingModel(null); | |
| } | |
| setDeleteModalOpen(false); | |
| }; | |
| return ( | |
| <div className="min-h-screen bg-[#f8fafc] text-gray-800 font-vazir pb-24 overflow-x-hidden"> | |
| {showFlyAnimation && <FlyToHistory targetRef={historyBtnRef} onComplete={() => setShowFlyAnimation(false)} mode={lastJobMode} />} | |
| <div className="max-w-lg mx-auto p-4 pt-8"> | |
| <div className="text-center mb-8 relative animate-[fadeIn_0.8s_ease-out]"> | |
| <div className="w-20 h-20 mx-auto mb-5 bg-white rounded-3xl flex items-center justify-center shadow-xl shadow-indigo-500/10 rotate-3 border border-indigo-50 relative group"> | |
| <div className="relative bg-gradient-to-tr from-primary to-secondary w-12 h-12 rounded-xl flex items-center justify-center text-white shadow-lg shadow-primary/40"><i className="fas fa-wave-square text-2xl animate-[pulse_3s_infinite]"></i></div> | |
| </div> | |
| <h1 className="text-2xl font-black text-gray-800 mb-3">تغییر صدا با <span className="text-transparent bg-clip-text bg-gradient-to-r from-blue-600 to-purple-600">هوش مصنوعی آلفا</span></h1> | |
| {/* Added subtext from the provided image */} | |
| <p className="text-gray-500 text-[13px] font-medium mb-6 max-w-[280px] mx-auto leading-relaxed"> | |
| استودیو پیشرفته تبدیل متن به گفتار و تغییر صدای خوانندگان و مشاهیر | |
| </p> | |
| <div className={`inline-flex items-center gap-2 px-4 py-1.5 rounded-full text-xs font-bold transition-all ${subscriptionStatus === 'paid' ? 'bg-amber-300 shadow-yellow-500/20' : 'bg-white border border-gray-200'}`}> | |
| {subscriptionStatus === 'paid' ? <><i className="fas fa-crown"></i><span>نسخه نامحدود</span></> : <><i className="fas fa-leaf text-green-500"></i><span>نسخه رایگان</span></>} | |
| </div> | |
| </div> | |
| {activeTab === 'home' && <ModelGrid customModels={customModels} onSelectModel={(m) => { setSelectedModel(m); setIsModalOpen(true); }} onOpenCustom={() => setActiveTab('custom')} onAddNewModel={() => { if(subscriptionStatus==='paid') setIsCreateModelOpen(true); else { setUpgradeModalMode('feature'); setUpgradeModalOpen(true); } }} onEditModel={(m) => { setEditingModel(m); setIsCreateModelOpen(true); }} />} | |
| {activeTab === 'custom' && <CustomVoice onStart={handleCustomStart} isLoading={isLoading} />} | |
| {activeTab === 'history' && <History jobs={Object.values(jobs)} onClearAll={() => { setDeleteMode('job'); setDeleteTarget('all'); setDeleteModalOpen(true); }} onDeleteSingle={(id) => { setDeleteMode('job'); setDeleteTarget(id); setDeleteModalOpen(true); }} onSelect={(j) => setActiveJobId(j.job_id)} onEnhance={handleEnhance} />} | |
| </div> | |
| <ModelModal | |
| isOpen={isModalOpen} | |
| model={selectedModel} | |
| onClose={() => setIsModalOpen(false)} | |
| onConfirm={handleModelConfirm} | |
| isLoading={isLoading} | |
| subscriptionStatus={subscriptionStatus} | |
| onUpgrade={() => { | |
| setIsModalOpen(false); | |
| // ارسال مستقیم پیام هدایت به بخش خرید اشتراک به برنامه اصلی | |
| window.parent.postMessage({ type: 'NAVIGATE_TO_PREMIUM', payload: { url: FULL_UPGRADE_URL } }, '*'); | |
| }} | |
| /> | |
| <CreateModelModal isOpen={isCreateModelOpen} editingModel={editingModel} onClose={() => setIsCreateModelOpen(false)} onSave={handleSaveModel} onDelete={handleDeleteCustomModel} /> | |
| <DeleteModal isOpen={deleteModalOpen} type={deleteTarget === 'all' ? 'all' : 'single'} title={deleteMode === 'model' ? 'حذف فایل' : undefined} message={deleteMode === 'model' ? 'آیا از حذف این مدل اختصاصی اطمینان دارید؟' : undefined} onClose={() => setDeleteModalOpen(false)} onConfirm={confirmDelete} /> | |
| <UpgradeModal isOpen={upgradeModalOpen} mode={upgradeModalMode} onClose={() => setUpgradeModalOpen(false)} onUpgrade={() => window.parent.postMessage({ type: 'NAVIGATE_TO_PREMIUM', payload: { url: FULL_UPGRADE_URL } }, '*')} resetDate={resetDate || undefined} /> | |
| <BottomNav activeTab={activeTab} onChange={setActiveTab} badgeCount={(Object.values(jobs) as JobData[]).filter(j=>j.status==='processing'||j.status==='started').length} historyRef={historyBtnRef} /> | |
| </div> | |
| ); | |
| } | |