taghirsado / App.tsx
Opera10's picture
Update App.tsx
1bc0471 verified
Raw
History Blame Contribute Delete
20.4 kB
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>
);
}