Tttt / wordpress_export (8).html
Ezmary's picture
Upload wordpress_export (8).html
32e5dc9 verified
<!DOCTYPE html>
<html lang="fa" dir="rtl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>تغییر صدا با هوش مصنوعی آلفا</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
primary: '#667eea',
secondary: '#764ba2',
accent: '#38b2ac',
},
fontFamily: {
vazir: ['Vazirmatn', 'sans-serif'],
}
}
}
}
</script>
<!-- Fonts -->
<link href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@100;300;400;500;700;900&display=swap" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<!-- React & Babel -->
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<style>
body {
font-family: 'Vazirmatn', sans-serif;
background: #fdfbfb;
background-image: radial-gradient(#e0e0e0 1px, transparent 1px);
background-size: 30px 30px;
}
/* Custom Scrollbar */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
}
::-webkit-scrollbar-thumb {
background: #c7c7c7;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
/* Animation Helpers */
@keyframes scaleIn {
from { opacity: 0; transform: scale(0.8) translateY(20px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes fadeOut {
from { opacity: 1; }
to { opacity: 0; }
}
@keyframes shimmer {
100% { transform: translateX(100%); }
}
</style>
</head>
<body>
<div id="root"></div>
<script type="text/babel" data-presets="env,react,typescript">
const { useState, useEffect, useRef } = React;
// --- CONSTANTS ---
const MODELS = [
// --- خوانندگان ---
{
id: 'shadmehr',
name: 'شادمهر عقیلی',
image: 'https://app.puzzley.net/uploads/user/Jydo/%D8%AA%D8%BA%DB%8C%D8%B1%20%D8%B5%D8%AF%D8%A7%20%D8%A8%D8%A7%20%D9%87%D9%88%D8%B4%20%D9%85%D8%B5%D9%86%D9%88%D8%B9%DB%8C/1000188203.jpg?_t=1725334498',
refFile: '/refs/shadmehr.wav',
category: 'singers',
type: 'legacy_rvc',
targetGender: 'male',
legacyConfig: {
modelUrl: "https://huggingface.co/amirmatrix/shadmehr/resolve/main/added_IVF722_Flat_nprobe_1_shadmehr_v2.zip?download=true"
},
sampleAudio: "https://uploadkon.ir/uploads/55c918_25شادمهر-قوی-2-.mp3"
},
{
id: 'moein',
name: 'معین',
image: 'https://app.puzzley.net/uploads/user/Jydo/%D8%AA%D8%BA%DB%8C%D8%B1%20%D8%B5%D8%AF%D8%A7%20%D8%A8%D8%A7%20%D9%87%D9%88%D8%B4%20%D9%85%D8%B5%D9%86%D9%88%D8%B9%DB%8C/5dbc55de-d6ab-442f-9a00-da874521cc0b.jpg?_t=1725334795',
refFile: '/refs/moein.wav',
category: 'singers',
type: 'legacy_rvc',
targetGender: 'male',
legacyConfig: {
modelUrl: "https://huggingface.co/datasets/Hamed744/Ezmary/resolve/main/Moein.zip?download=true"
},
sampleAudio: "https://uploadkon.ir/uploads/f8bb17_25معین-2-.mp3"
},
{
id: 'sorena',
name: 'علی سورنا',
image: 'https://app.puzzley.net/uploads/user/Jydo/%D8%AA%D8%BA%DB%8C%D8%B1%20%D8%B5%D8%AF%D8%A7%20%D8%A8%D8%A7%20%D9%87%D9%88%D8%B4%20%D9%85%D8%B5%D9%86%D9%88%D8%B9%DB%8C/32eda534-2dfc-4053-8248-6fafea195a54.jpg?_t=1726904951',
refFile: 'https://huggingface.co/datasets/Opera8/mp3/resolve/main/%D9%85%D8%AF%D9%84%20%D8%B5%D8%AF%D8%A7%DB%8C%20%D8%B3%D9%88%D8%B1%D9%86%D8%A7.mp3?download=true',
category: 'singers',
type: 'standard',
targetGender: 'male',
sampleAudio: "https://uploadkon.ir/uploads/659018_25%D8%B3%D9%88%D8%B1%D9%86%D8%A7-2-.mp3"
},
{
id: 'billie',
name: 'بیلی آیلیش',
image: 'https://app.puzzley.net/uploads/user/Jydo/%D8%AA%D8%BA%DB%8C%D8%B1%20%D8%B5%D8%AF%D8%A7%20%D8%A8%D8%A7%20%D9%87%D9%88%D8%B4%20%D9%85%D8%B5%D9%86%D9%88%D8%B9%DB%8C/1551c598-f02f-4ced-a037-33d2d7317edd.jpg?_t=1726723022',
refFile: '/refs/billie.wav',
category: 'singers',
type: 'legacy_rvc',
targetGender: 'female',
legacyConfig: {
modelUrl: "https://huggingface.co/datasets/Hamed744/Ezmary/resolve/main/Billie.zip?download=true"
},
sampleAudio: "https://uploadkon.ir/uploads/c21018_25بیلی-آیلیش-2-.mp3"
},
{
id: 'chavoshi',
name: 'محسن چاوشی',
image: 'https://app.puzzley.net/uploads/user/Jydo/%D8%AA%D8%BA%DB%8C%D8%B1%20%D8%B5%D8%AF%D8%A7%20%D8%A8%D8%A7%20%D9%87%D9%88%D8%B4%20%D9%85%D8%B5%D9%86%D9%88%D8%B9%DB%8C/c52eefb1-071e-40ea-9bc2-e20a7c29cb81.jpg?_t=1726907812',
refFile: '/refs/chavoshi.wav',
category: 'singers',
type: 'legacy_rvc',
targetGender: 'male',
legacyConfig: {
modelUrl: "https://huggingface.co/datasets/Hamed744/Ezmary/resolve/main/chavoshi250.zip?download=true"
},
sampleAudio: "https://uploadkon.ir/uploads/7ca518_25محسن-چاووشی-3-2-.mp3"
},
{
id: 'ghomayshi',
name: 'سیاوش قمیشی',
image: 'https://app.puzzley.net/uploads/user/Jydo/%D8%AA%D8%BA%DB%8C%D8%B1%20%D8%B5%D8%AF%D8%A7%20%D8%A8%D8%A7%20%D9%87%D9%88%D8%B4%20%D9%85%D8%B5%D9%86%D9%88%D8%B9%DB%8C/1000189540%20(1).jpg?_t=1726726690',
refFile: '/refs/ghomayshi.wav',
category: 'singers',
type: 'legacy_rvc',
targetGender: 'male',
legacyConfig: {
modelUrl: "https://huggingface.co/datasets/Hamed744/Ezmary/resolve/main/Ghomayshi250.zip?download=true"
},
sampleAudio: "https://uploadkon.ir/uploads/787e17_25قمیشی-2-.mp3"
},
{
id: 'yas',
name: 'یاس',
image: 'https://app.puzzley.net/uploads/user/Jydo/%D8%AA%D8%BA%DB%8C%D8%B1%20%D8%B5%D8%AF%D8%A7%20%D8%A8%D8%A7%20%D9%87%D9%88%D8%B4%20%D9%85%D8%B5%D9%86%D9%88%D8%B9%DB%8C/c942a66b-55e9-4987-8554-26ea572627e3.jpg?_t=1726905661',
refFile: '/refs/yas.wav',
category: 'singers',
type: 'legacy_rvc',
targetGender: 'male',
legacyConfig: {
modelUrl: "https://huggingface.co/datasets/Hamed744/Ezmary/resolve/main/Yas300.zip?download=true"
},
sampleAudio: "https://uploadkon.ir/uploads/2a2617_25یاس-2-.mp3"
},
// --- دوبلورها و گویندگان ---
{
id: 'ferdosipour',
name: 'عادل فردوسی‌پور',
image: 'https://app.puzzley.net/uploads/user/Jydo/%D8%AA%D8%BA%DB%8C%D8%B1%20%D8%B5%D8%AF%D8%A7%20%D8%A8%D8%A7%20%D9%87%D9%88%D8%B4%20%D9%85%D8%B5%D9%86%D9%88%D8%B9%DB%8C/1000188207.jpg?_t=1725334637',
refFile: '/refs/ferdosipour.wav',
category: 'dubbers',
type: 'legacy_rvc',
targetGender: 'male',
legacyConfig: {
modelUrl: "https://huggingface.co/datasets/Hamed744/Ezmary/resolve/main/Adel.zip?download=true"
},
sampleAudio: "https://uploadkon.ir/uploads/b5f918_25-عادل-فردوسی-پور-2-.mp3"
},
{
id: 'khiabani',
name: 'جواد خیابانی',
image: 'https://app.puzzley.net/uploads/user/Jydo/%D8%AA%D8%BA%DB%8C%D8%B1%20%D8%B5%D8%AF%D8%A7%20%D8%A8%D8%A7%20%D9%87%D9%88%D8%B4%20%D9%85%D8%B5%D9%86%D9%88%D8%B9%DB%8C/1000189552.jpg?_t=1726729222',
refFile: '/refs/khiabani.wav',
category: 'dubbers',
type: 'legacy_rvc',
targetGender: 'male',
legacyConfig: {
modelUrl: "https://huggingface.co/datasets/Hamed744/Ezmary/resolve/main/Khiabani250.zip?download=true"
},
sampleAudio: "https://uploadkon.ir/uploads/b7b118_25خیابانی-2-.mp3"
},
{
id: 'jalilvand',
name: 'چنگیز جلیلوند',
image: 'https://app.puzzley.net/uploads/user/Jydo/%D8%AA%D8%BA%DB%8C%D8%B1%20%D8%B5%D8%AF%D8%A7%20%D8%A8%D8%A7%20%D9%87%D9%88%D8%B4%20%D9%85%D8%B5%D9%86%D9%88%D8%B9%DB%8C/bf38e73f-19c8-44e7-bf2f-e53b9fb8f6a1.jpg?_t=1726723782',
refFile: '/refs/jalilvand.wav',
category: 'dubbers',
type: 'legacy_rvc',
targetGender: 'male',
legacyConfig: {
modelUrl: "https://huggingface.co/datasets/Hamed744/Ezmary/resolve/main/ChangizJalilvand.zip?download=true"
},
sampleAudio: "https://uploadkon.ir/uploads/520514_25%D8%AC%D9%84%DB%8C%D9%84%D9%88%D9%86%D8%AF-2-.mp3"
},
{
id: 'tahmasb',
name: 'ناصر طهماسب',
image: 'https://app.puzzley.net/uploads/user/Jydo/%D8%AA%D8%BA%DB%8C%D8%B1%20%D8%B5%D8%AF%D8%A7%20%D8%A8%D8%A7%20%D9%87%D9%88%D8%B4%20%D9%85%D8%B5%D9%86%D9%88%D8%B9%DB%8C/1000188502.jpg?_t=1725942876',
refFile: '/refs/tahmasb.wav',
category: 'dubbers',
type: 'legacy_rvc',
targetGender: 'male',
legacyConfig: {
modelUrl: "https://huggingface.co/datasets/Hamed744/Ezmary/resolve/main/NasserTahmasb350.zip?download=true"
},
sampleAudio: "https://uploadkon.ir/uploads/932a18_25-ناصر-طهماسب-2-.mp3"
},
{
id: 'nasr',
name: 'تورج نصر',
image: 'https://uploadkon.ir/uploads/f4f220_25۲۰۲۵۱۰۲۰-۱۱۰۷۰۵.jpg',
refFile: 'https://huggingface.co/datasets/Opera8/mp3/resolve/main/%D8%AA%D9%88%D8%B1%D8%AC%20%D9%86%D8%B5%D8%B1.mp3?download=true',
category: 'dubbers',
type: 'standard',
targetGender: 'male',
sampleAudio: "https://uploadkon.ir/uploads/4c8018_25-تورج-نصر-2-.mp3"
},
{
id: 'mozafari',
name: 'سعید مظفری',
image: 'https://uploadkon.ir/uploads/830a19_25IMG-20251219-102026-776.jpg',
refFile: 'https://huggingface.co/datasets/Opera8/mp3/resolve/main/saeid-mozafari.mp3?download=true',
category: 'dubbers',
type: 'standard',
targetGender: 'male',
sampleAudio: "https://uploadkon.ir/uploads/ac7019_25%D8%B3%D8%B9%DB%8C%D8%AF-%D9%85%D8%B8%D9%81%D8%B1%DB%8C-2-.mp3"
},
{
id: 'valizadeh',
name: 'منوچهر والی زاده',
image: 'https://uploadkon.ir/uploads/654119_25IMG-20251219-115557-637.jpg',
refFile: 'https://huggingface.co/datasets/Opera8/mp3/resolve/main/%D8%B5%D8%AF%D8%A7%DB%8C%20%D9%85%D8%AF%D9%84%20%D9%85%D9%86%D9%88%DA%86%D9%87%D8%B1%20%D9%88%D8%A7%D9%84%DB%8C%E2%80%8C%D8%B2%D8%A7%D8%AF%D9%87.wav?download=true',
category: 'dubbers',
type: 'standard',
targetGender: 'male',
sampleAudio: "https://uploadkon.ir/uploads/549019_25%D9%85%D9%86%D9%88%DA%86%D9%87%D8%B1-%D9%88%D8%A7%D9%84%DB%8C-%D8%B2%D8%A7%D8%AF%D9%87-2-.mp3"
},
// --- کارتون و کودک ---
{
id: 'pourang',
name: 'عمو پورنگ',
image: 'https://app.puzzley.net/uploads/user/Jydo/%D8%AA%D8%BA%DB%8C%D8%B1%20%D8%B5%D8%AF%D8%A7%20%D8%A8%D8%A7%20%D9%87%D9%88%D8%B4%20%D9%85%D8%B5%D9%86%D9%88%D8%B9%DB%8C/64a802d3-a50d-45a7-8ba2-0262b9d7bdcd.jpg?_t=1725948260',
refFile: '/refs/pourang.wav',
category: 'cartoons',
type: 'legacy_rvc',
targetGender: 'male',
legacyConfig: {
modelUrl: "https://huggingface.co/datasets/Hamed744/Ezmary/resolve/main/Poorang220.zip?download=true"
},
sampleAudio: "https://uploadkon.ir/uploads/36bf18_25عمو-پورنگ-2-.mp3"
},
{
id: 'sponge',
name: 'باب اسفنجی',
image: 'https://app.puzzley.net/uploads/user/Jydo/%D8%AA%D8%BA%DB%8C%D8%B1%20%D8%B5%D8%AF%D8%A7%20%D8%A8%D8%A7%20%D9%87%D9%88%D8%B4%20%D9%85%D8%B5%D9%86%D9%88%D8%B9%DB%8C/1000188654.jpg?_t=1725975676',
refFile: '/refs/sponge.wav',
category: 'cartoons',
type: 'legacy_rvc',
targetGender: 'male',
legacyConfig: {
modelUrl: "https://huggingface.co/datasets/Hamed744/Ezmary/resolve/main/Bab_Asfanj300.zip?download=true"
},
sampleAudio: "https://uploadkon.ir/uploads/6f9118_25باب-اسفنجی-2-.mp3"
},
{
id: 'jenab',
name: 'جناب خان',
image: 'https://app.puzzley.net/uploads/user/Jydo/%D8%AA%D8%BA%DB%8C%D8%B1%20%D8%B5%D8%AF%D8%A7%20%D8%A8%D8%A7%20%D9%87%D9%88%D8%B4%20%D9%85%D8%B5%D9%86%D9%88%D8%B9%DB%8C/86501f6b-a29a-48ef-98ef-b97289de1a65.jpg?_t=1726728539',
refFile: '/refs/jenab.wav',
category: 'cartoons',
type: 'legacy_rvc',
targetGender: 'male',
legacyConfig: {
modelUrl: "https://huggingface.co/datasets/Hamed744/Ezmary/resolve/main/Jenab.zip?download=true"
},
sampleAudio: "https://uploadkon.ir/uploads/d50a18_25جناب-خان-2-.mp3"
},
// --- بازیگران و مشاهیر ---
{
id: 'yousef',
name: 'یوسف پیامبر',
image: 'https://app.puzzley.net/uploads/user/Jydo/%D8%AA%D8%BA%DB%8C%D8%B1%20%D8%B5%D8%AF%D8%A7%20%D8%A8%D8%A7%20%D9%87%D9%88%D8%B4%20%D9%85%D8%B5%D9%86%D9%88%D8%B9%DB%8C/1000188210.jpg?_t=1725334745',
refFile: '/refs/yousef.wav',
category: 'famous',
type: 'legacy_rvc',
targetGender: 'male',
legacyConfig: {
modelUrl: "https://huggingface.co/datasets/Hamed744/Ezmary/resolve/main/Yusuf250.zip?download=true"
},
sampleAudio: "https://uploadkon.ir/uploads/18a618_25یوسف-2-.mp3"
},
{
id: 'jomeong',
name: 'جومونگ',
image: 'https://app.puzzley.net/uploads/user/Jydo/%D8%AA%D8%BA%DB%8C%D8%B1%20%D8%B5%D8%AF%D8%A7%20%D8%A8%D8%A7%20%D9%87%D9%88%D8%B4%20%D9%85%D8%B5%D9%86%D9%88%D8%B9%DB%8C/1000188512.jpg?_t=1725946678',
refFile: '/refs/jomeong.wav',
category: 'famous',
type: 'legacy_rvc',
targetGender: 'male',
legacyConfig: {
modelUrl: "https://huggingface.co/datasets/Hamed744/Ezmary/resolve/main/Jumong250.zip?download=true"
},
sampleAudio: "https://uploadkon.ir/uploads/2a4918_25جومونگ-2-.mp3"
},
{
id: 'ronaldo',
name: 'رونالدو',
image: 'https://app.puzzley.net/uploads/user/Jydo/%D8%AA%D8%BA%DB%8C%D8%B1%20%D8%B5%D8%AF%D8%A7%20%D8%A8%D8%A7%20%D9%87%D9%88%D8%B4%20%D9%85%D8%B5%D9%86%D9%88%D8%B9%DB%8C/1000188814.jpg?_t=1726224287',
refFile: 'https://huggingface.co/datasets/Opera8/mp3/resolve/main/%D8%B1%D9%88%D9%86%D8%A7%D9%84%D8%AF%D9%88%20%D8%AE%D9%88%D8%A8_02.wav?download=true',
category: 'famous',
type: 'standard',
targetGender: 'male',
sampleAudio: "https://uploadkon.ir/uploads/084318_25رونالدو-2-.mp3"
},
{
id: 'messi',
name: 'مسی',
image: 'https://app.puzzley.net/uploads/user/Jydo/%D8%AA%D8%BA%DB%8C%D8%B1%20%D8%B5%D8%AF%D8%A7%20%D8%A8%D8%A7%20%D9%87%D9%88%D8%B4%20%D9%85%D8%B5%D9%86%D9%88%D8%B9%DB%8C/1000189556.jpg?_t=1726730420',
refFile: 'https://huggingface.co/datasets/Opera8/mp3/resolve/main/%D8%Bص%D8%AF%D8%A7%DB%8C%20%D9%85%D8%B3%DB%8C.mp3?download=true',
category: 'famous',
type: 'standard',
targetGender: 'male',
sampleAudio: "https://uploadkon.ir/uploads/1f9b18_25مسی-2-.mp3"
},
{
id: 'musk',
name: 'ایلان ماسک',
image: 'https://app.puzzley.net/uploads/user/Jydo/%D9%87%D9%88%D8%B4%20%D9%85%D8%B5%D9%86%D9%88%D8%B9%DB%8C/7ba0101a-83ed-46c3-a2e7-57a85e034b2c.jpg',
refFile: '/refs/musk.wav',
category: 'famous',
type: 'legacy_rvc',
targetGender: 'male',
legacyConfig: {
modelUrl: "https://huggingface.co/rayzox57/ElonMusk_90s_RVC/resolve/main/ElonMusk_90s_v2_Ov2_500e.zip?download=true"
},
sampleAudio: "https://uploadkon.ir/uploads/e27318_25ایلان-ماسک-2-.mp3"
},
// --- گویندگان آلفا ---
{
id: 'maryam',
name: 'مریم',
image: 'https://app.puzzley.net/uploads/user/Jydo/%D9%87%D9%88%D8%B4%20%D9%85%D8%B5%D9%86%D9%88%D8%B9%DB%8C/ab1e28b106df4c48ac228da6ced9d076.jpeg',
refFile: 'https://huggingface.co/datasets/Opera8/mp3/resolve/main/%D8%B5%D8%AF%D8%A7%DB%8C%20%D8%AE%D8%A7%D9%86%D9%85.wav?download=true',
category: 'alpha',
type: 'standard',
targetGender: 'female',
sampleAudio: "https://uploadkon.ir/uploads/67b718_25%D9%85%D8%B1%DB%8C%D9%85.mp3"
}
];
// --- SERVICES: DB ---
const DB_NAME = 'SadaAI_DB';
const STORE_JOBS = 'jobs';
const STORE_CUSTOM_MODELS = 'custom_models';
const DB_VERSION = 2;
const initDB = () => {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains(STORE_JOBS)) {
db.createObjectStore(STORE_JOBS, { keyPath: 'job_id' });
}
if (!db.objectStoreNames.contains(STORE_CUSTOM_MODELS)) {
db.createObjectStore(STORE_CUSTOM_MODELS, { keyPath: 'id' });
}
};
});
};
const saveJob = async (job) => {
const db = await initDB();
const transaction = db.transaction(STORE_JOBS, 'readwrite');
const store = transaction.objectStore(STORE_JOBS);
await store.put(job);
};
const getAllJobs = async () => {
const db = await initDB();
const transaction = db.transaction(STORE_JOBS, 'readonly');
const store = transaction.objectStore(STORE_JOBS);
const request = store.getAll();
return new Promise((resolve) => {
request.onsuccess = () => resolve(request.result || []);
});
};
const deleteJob = async (jobId) => {
const db = await initDB();
const transaction = db.transaction(STORE_JOBS, 'readwrite');
const store = transaction.objectStore(STORE_JOBS);
await store.delete(jobId);
};
const clearAllJobs = async () => {
const db = await initDB();
const transaction = db.transaction(STORE_JOBS, 'readwrite');
const store = transaction.objectStore(STORE_JOBS);
await store.clear();
};
const saveCustomModel = async (model) => {
const db = await initDB();
const transaction = db.transaction(STORE_CUSTOM_MODELS, 'readwrite');
const store = transaction.objectStore(STORE_CUSTOM_MODELS);
await store.put(model);
};
const getAllCustomModels = async () => {
const db = await initDB();
const transaction = db.transaction(STORE_CUSTOM_MODELS, 'readonly');
const store = transaction.objectStore(STORE_CUSTOM_MODELS);
const request = store.getAll();
return new Promise((resolve) => {
request.onsuccess = () => resolve(request.result || []);
});
};
const deleteCustomModel = async (id) => {
const db = await initDB();
const transaction = db.transaction(STORE_CUSTOM_MODELS, 'readwrite');
const store = transaction.objectStore(STORE_CUSTOM_MODELS);
await store.delete(id);
};
// --- SERVICES: CREDIT ---
const getBrowserFingerprint = async () => {
const components = [
navigator.userAgent,
navigator.language,
screen.width + 'x' + screen.height,
new Date().getTimezoneOffset()
];
const str = components.join('---');
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) - hash) + str.charCodeAt(i);
hash |= 0;
}
return 'fp_' + Math.abs(hash).toString(16);
};
const checkCredit = async (fingerprint, modelId) => {
// Placeholder implementation for standalone
return { limit_reached: false };
};
const useCredit = async (fingerprint, modelId) => {
// Placeholder
return {};
};
// --- SERVICES: LEGACY API ---
const LEGACY_BASE_URL = 'https://ezmary-rvc-zero.hf.space';
const uploadLegacyJob = async (
sourceFile, modelUrl, pitch, modelMetadata, algo = "rmvpe+", indexInf = 0.75
) => {
const formData = new FormData();
formData.append('audio_file', sourceFile, sourceFile.name || 'input.wav');
formData.append('model_url', modelUrl);
formData.append('pitch', pitch.toString());
formData.append('algo', algo);
formData.append('index_inf', indexInf.toString());
formData.append('res_filter', "3");
formData.append('env_ratio', "0.25");
formData.append('protect', "0.33");
formData.append('denoise', "false");
formData.append('reverb', "false");
const response = await fetch(`${LEGACY_BASE_URL}/upload`, {
method: 'POST',
body: formData,
});
if (!response.ok) {
const errData = await response.json().catch(() => ({}));
throw new Error(errData.error || `خطا در اتصال به سرور لگاسی: ${response.status}`);
}
const data = await response.json();
return {
job_id: data.job_id,
status: 'started',
progress: 10,
statusMessage: 'در صف انتظار سرور (Persistent)...',
total_chunks: 1,
chunks: [],
date: new Date().toLocaleString('fa-IR'),
timestamp: Date.now(),
type: 'model',
modelName: modelMetadata.name,
modelImage: modelMetadata.image,
retryCount: 0,
backend: 'legacy'
};
};
const checkLegacyStatus = async (jobId) => {
try {
const response = await fetch(`${LEGACY_BASE_URL}/status/${jobId}`);
if (!response.ok) throw new Error(`Legacy status check failed: ${response.status}`);
const data = await response.json();
let status = 'processing';
if (data.status === 'completed') status = 'completed';
else if (data.status === 'failed') status = 'failed';
else if (data.status === 'not_found') status = 'error';
return {
status,
progress: data.status === 'completed' ? 100 : (data.status === 'processing' ? 60 : 10),
filename: data.filename,
detail: data.log,
downloadUrl: data.filename ? `${LEGACY_BASE_URL}/download/${data.filename}` : undefined
};
} catch (e) {
return { status: 'processing', progress: 10, detail: 'درحال تلاش برای برقراری ارتباط با سرور...' };
}
};
// --- SERVICES: API ---
const VC_BASE_URL = 'https://ezmary-sada.hf.space';
const TTS_BASE_URL = 'https://hamed744-ttsp3.hf.space';
const ENHANCE_BASE_URL = 'https://ezmary-taqviat-sada.hf.space';
const uploadJob = async (sourceFile, refFile, metadata) => {
const formData = new FormData();
formData.append('source_audio', sourceFile, 'source.wav');
formData.append('ref_audio', refFile, 'ref.wav');
const response = await fetch(`${VC_BASE_URL}/upload`, {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error(`Upload failed: ${response.status}`);
}
const data = await response.json();
return {
...data,
date: new Date().toLocaleString('fa-IR'),
timestamp: Date.now(),
progress: 0,
status: 'started',
type: metadata.type,
modelName: metadata.modelName,
modelImage: metadata.modelImage,
backend: 'standard'
};
};
const uploadEnhancementJob = async (audioBlob) => {
const formData = new FormData();
formData.append('source_audio', audioBlob, 'input_to_enhance.wav');
formData.append('solver', 'Midpoint');
formData.append('nfe', '64');
formData.append('tau', '0.5');
formData.append('denoising', 'true');
formData.append('subscriptionStatus', 'paid');
formData.append('fingerprint', 'admin_bypass');
const response = await fetch(`${ENHANCE_BASE_URL}/upload`, {
method: 'POST',
body: formData,
});
return await response.json();
};
const checkEnhancementStatus = async (metadata) => {
const response = await fetch(`${ENHANCE_BASE_URL}/check_status`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
job_id: metadata.job_id,
total_chunks: metadata.total_chunks,
chunks: metadata.chunks
}),
});
return await response.json();
};
const getEnhancementDownloadUrl = (filename) => {
return `${ENHANCE_BASE_URL}/download/${filename}`;
};
const generateBaseTTS = async (text, speakerId = "Algieba") => {
const response = await fetch(`${TTS_BASE_URL}/api/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, speaker: speakerId, temperature: 0.9, subscriptionStatus: 'paid', fingerprint: 'admin_bypass' })
});
if (!response.ok) throw new Error(`TTS Generation failed`);
const data = await response.json();
const jobId = data.job_id;
return new Promise((resolve, reject) => {
const pollInterval = setInterval(async () => {
try {
const statusRes = await fetch(`${TTS_BASE_URL}/api/check_status`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ job_id: jobId })
});
const statusData = await statusRes.json();
if (statusData.status === 'completed' && statusData.proxy_url) {
clearInterval(pollInterval);
const audioRes = await fetch(`${TTS_BASE_URL}${statusData.proxy_url}`);
resolve(await audioRes.blob());
} else if (statusData.status === 'failed') {
clearInterval(pollInterval);
reject(new Error("TTS failed"));
}
} catch (e) { clearInterval(pollInterval); reject(e); }
}, 3000);
});
};
const fetchRefAudioAsFile = async (url, filename) => {
const response = await fetch(url);
const blob = await response.blob();
return new File([blob], filename, { type: 'audio/wav' });
};
const checkJobStatus = async (job) => {
if (job.backend === 'legacy') return await checkLegacyStatus(job.job_id);
if (job.backend === 'standard') {
const response = await fetch(`${VC_BASE_URL}/check_status`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ job_id: job.job_id, total_chunks: job.total_chunks || 1, chunks: job.chunks || [] }),
});
return await response.json();
}
const legacyNames = [
'shadmehr', 'moein', 'billie', 'chavoshi', 'ghomayshi', 'yas', 'ferdosipour', 'khiabani', 'jalilvand', 'tahmasb', 'pourang', 'sponge', 'jenab', 'yousef', 'jomeong', 'musk',
'شادمهر عقیلی', 'معین', 'بیلی آیلیش', 'محسن چاوشی', 'سیاوش قمیشی', 'یاس',
'عادل فردوسی‌پور', 'جواد خیابانی', 'چنگیز جلیلوند', 'ناصر طهماسب',
'عمو پورنگ', 'باب اسفنجی', 'جناب خان',
'یوسف پیامبر', 'جومونگ', 'ایلان ماسک'
];
const isLegacy = (job.modelName && legacyNames.includes(job.modelName.toLowerCase())) || job.job_id.includes('-');
if (isLegacy) return await checkLegacyStatus(job.job_id);
const response = await fetch(`${VC_BASE_URL}/check_status`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ job_id: job.job_id, total_chunks: job.total_chunks || 1, chunks: job.chunks || [] }),
});
return await response.json();
};
const processUnifiedJob = async (
sourceFile, text, refFile, model, mode, pitch, onProgress, jobType
) => {
let actualSource;
if (mode === 'vc') {
if (!sourceFile) throw new Error("فایل صوتی یافت نشد");
actualSource = sourceFile;
} else {
if (!text) throw new Error("متن یافت نشد");
let baseSpeaker = (model && model.targetGender === 'female') ? "Despina" : "Algieba";
onProgress(`تولید صدای پایه...`, 5);
actualSource = await generateBaseTTS(text, baseSpeaker);
onProgress("صدای پایه تولید شد.", 10);
}
const metadata = { type: jobType, modelName: model?.name, modelImage: typeof model?.image === 'string' ? model.image : undefined };
if (model && model.type === 'legacy_rvc' && model.legacyConfig) {
onProgress("ارسال به سرور لگاسی...", 15);
return await uploadLegacyJob(actualSource, model.legacyConfig.modelUrl, pitch, { name: model.name, image: metadata.modelImage });
} else {
onProgress("ارسال به سرور تغییر صدا...", 20);
return await uploadJob(actualSource, refFile, metadata);
}
};
// --- COMPONENTS ---
// ProcessingPanel
const ProcessingPanel = ({ job, embedded = false, onEnhance }) => {
const [downloadingType, setDownloadingType] = useState(null);
const [copySuccess, setCopySuccess] = useState(false);
if (!job) return null;
const isCompleted = job.status === 'completed';
const isFailed = job.status === 'failed' || job.status === 'error';
const VC_BASE_URL = 'https://ezmary-sada.hf.space';
let downloadUrl = '#';
if (isCompleted) {
if (job.downloadUrl) {
downloadUrl = job.downloadUrl;
} else if (job.filename) {
downloadUrl = `${VC_BASE_URL}/download/${job.filename}`;
}
}
const enhanceInfo = job.enhancement;
const isEnhancing = enhanceInfo?.status === 'processing' || enhanceInfo?.status === 'started';
const isEnhanceCompleted = enhanceInfo?.status === 'completed';
const enhanceDownloadUrl = isEnhanceCompleted && enhanceInfo?.filename
? getEnhancementDownloadUrl(enhanceInfo.filename)
: '#';
const handleDownload = async (e, url, type) => {
e.preventDefault();
if (downloadingType) return;
setDownloadingType(type);
try {
let blobUrl = url;
if (!url.startsWith('blob:')) {
const resp = await fetch(url);
if (!resp.ok) throw new Error(`Network response was not ok`);
const blob = await resp.blob();
blobUrl = URL.createObjectURL(blob);
}
// Try iframe communication first (for mobile apps/wrappers)
window.parent.postMessage({
type: 'INITIATE_DOWNLOAD_FROM_URL',
payload: { audioUrl: blobUrl }
}, '*');
// Fallback to standard download
const a = document.createElement('a');
a.href = blobUrl;
a.download = type === 'main' ? (job.filename || 'audio.wav') : `enhanced_${job.filename || 'audio.wav'}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
} catch (err) {
console.error("Download error:", err);
alert("خطا در دانلود");
} finally {
setDownloadingType(null);
}
};
const handleCopyErrorLog = () => {
const logText = job.statusMessage || 'Unknown Error';
navigator.clipboard.writeText(logText).then(() => {
setCopySuccess(true);
setTimeout(() => setCopySuccess(false), 2000);
}).catch(err => console.error('Failed to copy', err));
};
const containerClasses = embedded
? "bg-gray-50/80 rounded-lg p-3 mt-2 border border-gray-100 animate-[fadeIn_0.3s_ease-out]"
: "bg-white rounded-3xl shadow-lg border border-gray-100 p-6 mt-6 animate-[slideUp_0.5s_ease-out]";
return (
<div className={containerClasses}>
<div className="flex items-center justify-between mb-2">
<div>
<h4 className={`font-bold text-gray-800 ${embedded ? 'text-xs' : 'text-lg'}`}>
{isCompleted ? 'فایل اصلی آماده' : isFailed ? 'خطا در پردازش' : 'در حال تغییر صدا...'}
</h4>
{!embedded && (
<p className="text-xs text-gray-500">
{job.type === 'model' ? `مدل: ${job.modelName}` : 'اختصاصی'}
</p>
)}
</div>
{!embedded && (
<div className={`w-10 h-10 rounded-full flex items-center justify-center text-white shadow-md
${isCompleted ? 'bg-green-500' : isFailed ? 'bg-red-500' : 'bg-amber-400'}`}>
<i className={`fas ${isCompleted ? 'fa-check' : isFailed ? 'fa-exclamation' : 'fa-cog fa-spin'}`}></i>
</div>
)}
</div>
{!isCompleted && !isFailed && (
<div className="mb-2">
{!embedded && (
<div className="flex justify-between text-xs text-gray-500 mb-1">
<span>پیشرفت کلی</span>
<span>{job.progress || 0}%</span>
</div>
)}
<div className={`w-full bg-gray-200 rounded-full overflow-hidden ${embedded ? 'h-1.5' : 'h-2'}`}>
<div className="h-full bg-gradient-to-r from-amber-400 to-orange-500 rounded-full transition-all duration-500 relative"
style={{ width: `${job.progress || 0}%` }}>
<div className="absolute inset-0 bg-white/20 w-full h-full animate-[shimmer_2s_infinite]"></div>
</div>
</div>
<p className="text-center text-[10px] text-gray-500 mt-2 font-medium truncate px-1">
{job.statusMessage || 'در حال انجام عملیات...'}
</p>
</div>
)}
{isCompleted && (
<div className={`${embedded ? 'mt-1' : 'mt-4 pt-4 border-t border-gray-100'}`}>
<audio controls className={`w-full ${embedded ? 'mb-2 h-7' : 'mb-3 h-8'}`} src={downloadUrl} />
<a href="#" onClick={(e) => handleDownload(e, downloadUrl, 'main')}
className={`block w-full text-center text-white rounded-xl font-bold transition-all shadow-sm flex items-center justify-center gap-2
${embedded ? 'py-2 text-xs bg-gray-600 hover:bg-gray-700' : 'py-3 bg-gray-600 shadow-gray-200'}
${downloadingType === 'main' ? 'opacity-70 cursor-wait' : ''}`}>
{downloadingType === 'main' ? <><i className="fas fa-circle-notch fa-spin"></i> آماده‌سازی...</> : <><i className="fas fa-download"></i> دانلود صدای اصلی (معمولی)</>}
</a>
</div>
)}
{isFailed && (
<div onClick={handleCopyErrorLog}
className="mt-1 p-2 bg-red-50 text-red-600 rounded-lg text-[10px] text-center border border-red-100 cursor-pointer hover:bg-red-100 transition-colors relative group">
{copySuccess ? <span className="font-bold text-green-600 animate-pulse"><i className="fas fa-check mr-1"></i> متن خطا کپی شد</span> : <span className="flex items-center justify-center gap-1">خطا در پردازش.</span>}
</div>
)}
{isCompleted && onEnhance && (
<div className={`mt-4 pt-4 border-t-2 border-dashed ${isEnhanceCompleted ? 'border-amber-200 bg-amber-50/50 -mx-3 px-3 pb-3 rounded-b-xl' : 'border-gray-100'}`}>
{!enhanceInfo && (
<button onClick={() => onEnhance(job)}
className="group relative w-full overflow-hidden rounded-xl bg-gradient-to-r from-violet-600 to-indigo-600 p-[2px] transition-all hover:shadow-lg hover:shadow-indigo-500/30 focus:outline-none focus:ring-2 focus:ring-indigo-400 focus:ring-offset-2 active:scale-95">
<div className="relative flex items-center justify-center gap-2 rounded-[10px] bg-white px-4 py-2.5 transition-all group-hover:bg-transparent group-hover:text-white">
<i className="fas fa-wand-magic-sparkles text-lg text-indigo-600 transition-colors group-hover:text-white animate-pulse"></i>
<span className="font-black text-sm text-gray-800 transition-colors group-hover:text-white">افزایش کیفیت صدا و حذف نویز (AI)</span>
</div>
</button>
)}
{enhanceInfo && isEnhancing && (
<div className="animate-[fadeIn_0.5s_ease-out]">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-bold text-indigo-700 flex items-center gap-2"><i className="fas fa-sparkles fa-spin text-amber-500"></i>در حال حذف نویز و تقویت صدا...</span>
<span className="text-[11px] font-black text-indigo-600">{enhanceInfo.progress || 0}%</span>
</div>
<div className="h-3 w-full rounded-full bg-indigo-100 overflow-hidden shadow-inner">
<div className="h-full rounded-full bg-gradient-to-r from-violet-500 via-fuchsia-500 to-indigo-500 transition-all duration-500 ease-out relative" style={{ width: `${Math.max(5, enhanceInfo.progress || 0)}%` }}>
<div className="absolute inset-0 bg-white/30 w-full h-full animate-[shimmer_1.5s_infinite]"></div>
</div>
</div>
<p className="text-[9px] text-center text-indigo-400 mt-1 font-bold">در حال انجام کار ممکن است زمان‌بر باشد</p>
</div>
)}
{enhanceInfo && isEnhanceCompleted && (
<div className="animate-[scaleIn_0.4s_cubic-bezier(0.175,0.885,0.32,1.275)]">
<div className="flex items-center gap-2 mb-2 justify-center">
<i className="fas fa-star text-amber-400 text-sm animate-[spin_3s_linear_infinite]"></i>
<h5 className="text-xs font-black text-amber-700">نسخه تقویت شده (High Quality)</h5>
<i className="fas fa-star text-amber-400 text-sm animate-[spin_3s_linear_infinite_reverse]"></i>
</div>
<audio controls className="w-full h-7 mb-2 border border-amber-200 rounded-full bg-amber-100" src={enhanceDownloadUrl} />
<a href="#" onClick={(e) => handleDownload(e, enhanceDownloadUrl, 'enhanced')}
className={`block w-full text-center text-white rounded-xl font-bold bg-gradient-to-r from-amber-500 to-orange-500 py-2.5 text-xs shadow-lg shadow-amber-500/30 hover:shadow-amber-500/50 transition-all transform hover:-translate-y-0.5 active:translate-y-0 ${downloadingType === 'enhanced' ? 'opacity-70 cursor-wait' : ''}`}>
{downloadingType === 'enhanced' ? <><i className="fas fa-circle-notch fa-spin mr-1"></i> در حال آماده‌سازی...</> : <><i className="fas fa-download mr-1"></i> دانلود صدای با کیفیت عالی</>}
</a>
</div>
)}
{enhanceInfo?.status === 'failed' && (
<div className="text-xs text-red-500 bg-red-50 p-2 rounded border border-red-100 text-center font-bold">خطا در تقویت صدا. لطفا دوباره تلاش کنید.</div>
)}
</div>
)}
</div>
);
};
// CustomVoice
const CustomVoice = ({ onStart, isLoading }) => {
const [srcFile, setSrcFile] = useState(null);
const [refFile, setRefFile] = useState(null);
const [playingType, setPlayingType] = useState(null);
const audioRef = useRef(null);
const srcInputRef = useRef(null);
const refInputRef = useRef(null);
useEffect(() => {
return () => {
if (audioRef.current) { audioRef.current.pause(); audioRef.current = null; }
};
}, []);
const handleFileChange = (e, type) => {
if (e.target.files && e.target.files[0]) {
if (playingType === type && audioRef.current) { audioRef.current.pause(); setPlayingType(null); }
if (type === 'src') setSrcFile(e.target.files[0]);
else setRefFile(e.target.files[0]);
}
};
const togglePreview = (e, type) => {
e.stopPropagation();
const file = type === 'src' ? srcFile : refFile;
if (!file) return;
if (playingType === type) {
if (audioRef.current) { audioRef.current.pause(); audioRef.current = null; }
setPlayingType(null);
return;
}
if (audioRef.current) audioRef.current.pause();
const url = URL.createObjectURL(file);
const audio = new Audio(url);
audioRef.current = audio;
audio.onended = () => { setPlayingType(null); URL.revokeObjectURL(url); };
audio.onerror = () => { alert("خطا در پخش فایل"); setPlayingType(null); };
audio.play();
setPlayingType(type);
};
const handleSubmit = (e) => {
e.preventDefault();
if (srcFile && refFile) {
if (audioRef.current) { audioRef.current.pause(); setPlayingType(null); }
onStart(srcFile, refFile);
}
};
return (
<div className="animate-[fadeIn_0.5s_ease-out]">
<div className="text-center mb-8">
<div className="w-16 h-16 bg-gradient-to-tr from-blue-500 to-purple-600 rounded-2xl mx-auto mb-4 flex items-center justify-center shadow-lg shadow-purple-500/20 rotate-3 transform hover:rotate-6 transition-transform">
<i className="fas fa-sliders-h text-2xl text-white"></i>
</div>
<h2 className="text-2xl font-black text-gray-800 mb-2">استودیو ساخت صدای اختصاصی</h2>
<p className="text-gray-700 text-sm font-bold mb-2 px-4 leading-relaxed">با داشتن یک نمونه صدا هر صدایی رو به همون صدا تبدیل کنید</p>
<p className="text-gray-500 text-xs px-6 leading-relaxed">برای شبیه‌سازی دقیق، صدای خود و صدای هدف را با کیفیت بالا بارگذاری کنید.</p>
</div>
<form onSubmit={handleSubmit} className="space-y-6 px-2">
<div onClick={() => srcInputRef.current?.click()} className={`relative overflow-hidden rounded-3xl border-2 border-dashed p-6 transition-all duration-300 active:scale-95 cursor-pointer ${srcFile ? 'border-primary bg-blue-50/50' : 'border-gray-200 bg-white hover:bg-gray-50'}`}>
<input type="file" ref={srcInputRef} hidden accept="audio/*" onChange={(e) => handleFileChange(e, 'src')} />
<div className="flex items-center gap-4">
<button type="button" onClick={(e) => { if (srcFile) togglePreview(e, 'src'); }} className={`w-12 h-12 rounded-full flex items-center justify-center flex-shrink-0 transition-all duration-300 relative outline-none ${srcFile ? 'bg-primary text-white shadow-lg shadow-indigo-500/30 hover:scale-110 z-10' : 'bg-gray-100 text-gray-400'}`}>
{srcFile ? (playingType === 'src' ? <><span className="absolute inset-0 rounded-full bg-white/30 animate-ping"></span><i className="fas fa-stop text-sm relative z-10"></i></> : <i className="fas fa-play ml-1 text-sm"></i>) : <i className="fas fa-microphone text-xl"></i>}
</button>
<div className="text-right flex-1">
<h6 className="font-bold text-gray-700 text-sm">صدای ورودی (ویس شما)</h6>
<p className="text-xs text-gray-400 mt-1 truncate">{srcFile ? srcFile.name : 'برای انتخاب ضربه بزنید'}</p>
</div>
{srcFile && <i className="fas fa-check-circle text-primary text-xl"></i>}
</div>
</div>
<div onClick={() => refInputRef.current?.click()} className={`relative overflow-hidden rounded-3xl border-2 border-dashed p-6 transition-all duration-300 active:scale-95 cursor-pointer ${refFile ? 'border-secondary bg-purple-50/50' : 'border-gray-200 bg-white hover:bg-gray-50'}`}>
<input type="file" ref={refInputRef} hidden accept="audio/*" onChange={(e) => handleFileChange(e, 'ref')} />
<div className="flex items-center gap-4">
<button type="button" onClick={(e) => { if (refFile) togglePreview(e, 'ref'); }} className={`w-12 h-12 rounded-full flex items-center justify-center flex-shrink-0 transition-all duration-300 relative outline-none ${refFile ? 'bg-secondary text-white shadow-lg shadow-purple-500/30 hover:scale-110 z-10' : 'bg-gray-100 text-gray-400'}`}>
{refFile ? (playingType === 'ref' ? <><span className="absolute inset-0 rounded-full bg-white/30 animate-ping"></span><i className="fas fa-stop text-sm relative z-10"></i></> : <i className="fas fa-play ml-1 text-sm"></i>) : <i className="fas fa-music text-xl"></i>}
</button>
<div className="text-right flex-1">
<h6 className="font-bold text-gray-700 text-sm">صدای الگو (رفرنس)</h6>
<p className="text-xs text-gray-400 mt-1 truncate">{refFile ? refFile.name : 'برای انتخاب ضربه بزنید'}</p>
</div>
{refFile && <i className="fas fa-check-circle text-secondary text-xl"></i>}
</div>
</div>
<button type="submit" disabled={!srcFile || !refFile || isLoading} className={`w-full py-4 rounded-2xl font-bold text-white shadow-xl shadow-purple-500/20 transition-all transform active:scale-95 ${(!srcFile || !refFile || isLoading) ? 'bg-gray-300 cursor-not-allowed shadow-none grayscale' : 'bg-gradient-to-r from-blue-600 to-purple-600'}`}>
{isLoading ? <span className="flex items-center justify-center gap-2"><i className="fas fa-circle-notch fa-spin"></i> در حال پردازش...</span> : 'شروع پردازش'}
</button>
<div className="relative group overflow-hidden rounded-[2.5rem] p-6 transition-all duration-500 hover:shadow-2xl hover:shadow-amber-200/50 border border-amber-100/40">
<div className="absolute inset-0 bg-gradient-to-br from-amber-50/95 via-orange-50/90 to-amber-100/95 backdrop-blur-md -z-10"></div>
<div className="absolute top-0 left-0 w-full h-full bg-gradient-to-r from-transparent via-white/50 to-transparent -translate-x-full animate-[shimmer_4s_infinite] -z-10"></div>
<div className="absolute -bottom-12 -right-12 w-40 h-40 bg-amber-200/20 rounded-full blur-3xl group-hover:bg-amber-300/30 transition-colors duration-1000"></div>
<div className="space-y-6 relative z-10">
<div className="flex gap-4 items-start">
<div className="relative flex-shrink-0">
<div className="absolute inset-0 bg-amber-400 blur-md opacity-30 rounded-full animate-pulse"></div>
<div className="w-10 h-10 bg-white rounded-2xl shadow-sm border border-amber-100 flex items-center justify-center relative transform group-hover:scale-110 transition-transform">
<i className="fas fa-lightbulb text-amber-500 text-lg animate-[bounce_3s_infinite]"></i>
</div>
</div>
<div className="text-right flex-1">
<h5 className="text-amber-950 font-black text-sm mb-2">راهنمای هوشمند</h5>
<p className="text-[11.5px] text-amber-900 leading-[1.8] font-medium opacity-95">در قسمت <span className="font-black text-amber-950 underline decoration-amber-300/60 decoration-4 underline-offset-4">صدای ورودی</span> صدای خودتان و در قسمت <span className="font-black text-amber-950 underline decoration-amber-300/60 decoration-4 underline-offset-4">صدای الگو</span> صدای هدف را بارگذاری کنید.</p>
</div>
</div>
<div className="flex gap-4 items-start border-t border-amber-200/40 pt-5">
<div className="relative flex-shrink-0">
<div className="absolute inset-0 bg-amber-400 blur-md opacity-20 rounded-full"></div>
<div className="w-10 h-10 bg-white rounded-2xl shadow-sm border border-amber-100 flex items-center justify-center relative transform group-hover:rotate-12 transition-transform">
<i className="fas fa-stopwatch text-amber-600 text-lg"></i>
</div>
</div>
<div className="text-right flex-1 pt-1">
<p className="text-[11px] text-amber-950 font-bold leading-relaxed">نکته: بهترین حالت این است که صدای مدل بین <span className="text-sm font-black text-amber-600 underline decoration-amber-300/60 decoration-4 underline-offset-4 mx-1">۳ تا ۹</span> ثانیه بدون نویز باشه.</p>
</div>
</div>
</div>
</div>
</form>
</div>
);
};
// ModelGrid
const ModelGrid = ({ customModels, onSelectModel, onOpenCustom, onAddNewModel, onEditModel }) => {
const [activeTab, setActiveTab] = useState('all');
const [playingModelId, setPlayingModelId] = useState(null);
const audioRef = useRef(null);
const categories = [
{ key: 'singers', title: 'خوانندگان', icon: 'fa-music' },
{ key: 'dubbers', title: 'دوبلورها', icon: 'fa-microphone-alt' },
{ key: 'cartoons', title: 'کارتون', icon: 'fa-child' },
{ key: 'famous', title: 'مشاهیر', icon: 'fa-star' },
{ key: 'alpha', title: 'گویندگان آلفا', icon: 'fa-user-tie' },
];
useEffect(() => {
return () => { if (audioRef.current) { audioRef.current.pause(); audioRef.current = null; } };
}, []);
const togglePreview = (e, model) => {
e.stopPropagation();
if (playingModelId === model.id) {
if (audioRef.current) { audioRef.current.pause(); audioRef.current = null; }
setPlayingModelId(null);
return;
}
if (audioRef.current) audioRef.current.pause();
let audioSrc = model.sampleAudio;
if (!audioSrc && model.isCustom && typeof model.refFile !== 'string') {
audioSrc = URL.createObjectURL(model.refFile);
}
if (audioSrc) {
const audio = new Audio(audioSrc);
audioRef.current = audio;
audio.onerror = () => setPlayingModelId(null);
audio.play().catch(() => setPlayingModelId(null));
setPlayingModelId(model.id);
audio.onended = () => { setPlayingModelId(null); audioRef.current = null; };
}
};
const renderModelCard = (model) => {
const imageUrl = (model.isCustom && typeof model.image !== 'string') ? URL.createObjectURL(model.image) : model.image;
return (
<div key={model.id} onClick={() => onSelectModel(model)} className="group relative cursor-pointer rounded-2xl overflow-hidden aspect-square shadow-sm hover:shadow-xl transition-all duration-300 active:scale-95 bg-gray-100">
<img src={imageUrl} alt={model.name} loading="lazy" className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110" />
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/20 to-transparent opacity-80"></div>
<button onClick={(e) => togglePreview(e, model)} className={`absolute top-2 left-2 w-8 h-8 rounded-full flex items-center justify-center backdrop-blur-md shadow-sm transition-all duration-300 z-20 ${playingModelId === model.id ? 'bg-red-500/90 text-white scale-110 shadow-red-500/50' : 'bg-white/30 hover:bg-white/50 text-white hover:scale-105'}`}>
{playingModelId === model.id ? <div className="flex items-center justify-center h-full w-full relative"><i className="fas fa-stop text-xs relative z-10"></i><span className="absolute inset-0 rounded-full bg-red-400 animate-ping opacity-75"></span></div> : <i className="fas fa-play text-xs ml-0.5"></i>}
</button>
{model.isCustom && (
<button onClick={(e) => { e.stopPropagation(); onEditModel(model); }} className="absolute top-2 right-2 w-8 h-8 rounded-full bg-amber-500/90 text-white flex items-center justify-center shadow-lg hover:scale-110 transition-all z-20"><i className="fas fa-edit text-xs"></i></button>
)}
<div className="absolute bottom-0 left-0 right-0 p-3">
<span className="block text-white text-sm font-bold truncate">{model.name}</span>
<span className="text-[10px] text-gray-300 flex items-center gap-1 mt-1 opacity-0 group-hover:opacity-100 transition-opacity duration-300 transform translate-y-2 group-hover:translate-y-0"><i className="fas fa-wave-square"></i> انتخاب مدل</span>
</div>
</div>
);
};
return (
<div className="pb-24 animate-[fadeIn_0.5s_ease-out]">
<div onClick={onOpenCustom} className="relative overflow-hidden rounded-3xl bg-gradient-to-r from-gray-900 to-gray-800 p-6 mb-6 shadow-xl shadow-gray-900/20 cursor-pointer group">
<div className="absolute top-0 left-0 w-full h-full opacity-10 bg-[url('https://www.transparenttextures.com/patterns/cubes.png')]"></div>
<div className="relative z-10 flex items-center justify-between">
<div><h3 className="text-white font-bold text-lg mb-1">هنوز مدل دلخواهت رو پیدا نکردی؟</h3><p className="text-gray-400 text-xs">ساخت صدای اختصاصی با آپلود فایل</p></div>
<div className="w-10 h-10 rounded-full bg-white/10 backdrop-blur-md flex items-center justify-center group-hover:bg-white/20 transition-colors"><i className="fas fa-arrow-left text-white"></i></div>
</div>
</div>
<div className="flex overflow-x-auto pb-4 gap-3 mb-2 px-1 no-scrollbar">
<button onClick={() => setActiveTab('all')} className={`flex-shrink-0 px-5 py-2.5 rounded-full text-sm font-bold transition-all duration-300 border ${activeTab === 'all' ? 'bg-gradient-to-r from-primary to-secondary text-white border-transparent shadow-lg shadow-indigo-200' : 'bg-white text-gray-600 border-gray-200 hover:bg-gray-50'}`}>همه</button>
{categories.map((cat) => (
<button key={cat.key} onClick={() => setActiveTab(cat.key)} className={`flex-shrink-0 px-4 py-2.5 rounded-full text-sm font-bold transition-all duration-300 border flex items-center gap-2 ${activeTab === cat.key ? 'bg-gradient-to-r from-primary to-secondary text-white border-transparent shadow-lg shadow-indigo-200' : 'bg-white text-gray-600 border-gray-200 hover:bg-gray-50'}`}>
<i className={`fas ${cat.icon} text-xs`}></i>{cat.title}
</button>
))}
</div>
<div className="min-h-[300px]">
{categories.map((category) => {
if (activeTab !== 'all' && activeTab !== category.key) return null;
const categoryModels = MODELS.filter(m => m.category === category.key);
const isAlpha = category.key === 'alpha';
return (
<div key={category.key} className="mb-8 animate-[fadeIn_0.5s_ease-out]">
<div className="flex items-center gap-2 mb-4 px-2">
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-white shadow-sm ${activeTab === category.key ? 'bg-secondary' : 'bg-gray-300'}`}><i className={`fas ${category.icon} text-xs`}></i></div>
<h2 className="text-lg font-black text-gray-800">{category.title}</h2>
{activeTab === 'all' && <div className="h-px bg-gray-100 flex-1 ml-4"></div>}
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
{categoryModels.map(renderModelCard)}
{isAlpha && customModels.map(renderModelCard)}
{isAlpha && (
<div onClick={onAddNewModel} className="relative group cursor-pointer rounded-2xl aspect-square border-4 border-dashed border-gray-200 flex flex-col items-center justify-center gap-3 transition-all duration-300 hover:border-primary/50 hover:bg-primary/5 active:scale-95">
<div className="w-14 h-14 rounded-full bg-gradient-to-br from-primary to-secondary flex items-center justify-center text-white shadow-lg shadow-primary/20 group-hover:scale-110 transition-transform"><i className="fas fa-plus text-2xl animate-[pulse_2s_infinite]"></i></div>
<span className="text-[10px] font-black text-gray-500 group-hover:text-primary transition-colors text-center px-1">ساخت مدل صدای دلخواه</span>
</div>
)}
</div>
</div>
);
})}
</div>
</div>
);
};
// ModelModal
const ModelModal = ({ model, isOpen, onClose, onConfirm, isLoading }) => {
const [activeMode, setActiveMode] = useState('vc');
const [file, setFile] = useState(null);
const [fileUrl, setFileUrl] = useState(null);
const [text, setText] = useState('');
const [pitch, setPitch] = useState(null);
const inputRef = useRef(null);
useEffect(() => {
if (isOpen) { setFile(null); setFileUrl(null); setText(''); setPitch(null); }
}, [isOpen]);
useEffect(() => {
return () => { if (fileUrl) URL.revokeObjectURL(fileUrl); };
}, [fileUrl]);
const handleFileChange = (selectedFile) => {
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';
const targetIsFemale = model.targetGender === 'female';
const setPitchForGender = (inputGender) => {
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;
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 getModelImage = () => {
if (model.isCustom && model.image && typeof model.image !== 'string') {
try { return URL.createObjectURL(model.image); } catch (e) { return ''; }
}
return model.image || '';
};
return (
<div className="fixed inset-0 z-[99999] 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 ${activeMode === 'vc' ? 'border-blue-100 bg-blue-50/30' : 'border-teal-100 bg-teal-50/30'}`}>
{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>
</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>
)}
</div>
)}
</div>
) : (
<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>
<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>
);
};
// CreateModelModal
const CreateModelModal = ({ isOpen, onClose, onSave, onDelete, editingModel }) => {
const [name, setName] = useState('');
const [image, setImage] = useState(null);
const [audio, setAudio] = useState(null);
const [gender, setGender] = useState('male');
const imgInputRef = useRef(null);
const audioInputRef = useRef(null);
useEffect(() => {
if (editingModel) {
setName(editingModel.name); setImage(editingModel.image); setAudio(null); setGender(editingModel.targetGender || 'male');
} else {
setName(''); setImage(null); setAudio(null); setGender('male');
}
}, [editingModel, isOpen]);
if (!isOpen) return null;
const handleSave = () => {
if (!name || !image || (!audio && !editingModel)) return;
const model = {
id: editingModel?.id || `user_${Date.now()}`,
name,
image: image,
refFile: audio || editingModel?.refFile,
category: 'user_custom',
targetGender: gender,
isCustom: true,
type: 'standard'
};
onSave(model);
};
const getImgPreview = () => {
if (!image) return null;
if (typeof image === 'string') return image;
return URL.createObjectURL(image);
};
return (
<div className="fixed inset-0 z-[99999] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/70 backdrop-blur-md" onClick={onClose}></div>
<div className="relative bg-white rounded-[2.5rem] w-full max-w-[380px] p-6 shadow-2xl animate-[scaleIn_0.3s_ease-out] overflow-hidden">
<div className="text-center mb-6"><h3 className="text-xl font-black text-gray-800">{editingModel ? 'ویرایش مدل' : 'ساخت مدل صوتی جدید'}</h3></div>
<div className="space-y-5">
<div className="flex flex-col items-center">
<div onClick={() => imgInputRef.current?.click()} className="w-24 h-24 rounded-3xl border-2 border-dashed border-gray-200 overflow-hidden cursor-pointer hover:border-primary transition-all relative group">
{image ? <img src={getImgPreview()} className="w-full h-full object-cover" /> : <div className="w-full h-full flex flex-col items-center justify-center bg-gray-50"><i className="fas fa-image text-gray-300 text-2xl"></i></div>}
</div>
<input type="file" ref={imgInputRef} hidden accept="image/*" onChange={(e) => setImage(e.target.files[0])} />
</div>
<div><label className="block text-[10px] font-bold text-gray-400 mb-1 mr-2">نام مدل</label><input type="text" value={name} onChange={(e) => setName(e.target.value)} className="w-full px-4 py-3 rounded-xl border border-gray-100 bg-gray-50 focus:bg-white focus:border-primary outline-none text-sm transition-all" /></div>
<div onClick={() => audioInputRef.current?.click()} className={`p-4 rounded-xl border-2 border-dashed transition-all cursor-pointer flex flex-col gap-2 text-right ${audio ? 'border-green-400 bg-green-50' : 'border-gray-100 bg-gray-50 hover:border-primary'}`}>
<input type="file" ref={audioInputRef} hidden accept="audio/*" onChange={(e) => setAudio(e.target.files[0])} />
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 ${audio ? 'bg-green-500 text-white' : 'bg-white text-gray-400 shadow-sm'}`}><i className={`fas ${audio ? 'fa-check' : 'fa-microphone'}`}></i></div>
<div className="flex-1"><p className="text-xs font-bold text-gray-700">{audio ? 'فایل صوتی انتخاب شد' : 'آپلود فایل صوتی مرجع'}</p></div>
</div>
</div>
<div className="flex gap-2">
<button onClick={() => setGender('male')} className={`flex-1 py-2 rounded-xl text-xs font-bold transition-all border ${gender === 'male' ? 'bg-blue-600 text-white border-blue-600' : 'bg-white text-gray-400 border-gray-100'}`}>مرد</button>
<button onClick={() => setGender('female')} className={`flex-1 py-2 rounded-xl text-xs font-bold transition-all border ${gender === 'female' ? 'bg-pink-600 text-white border-pink-600' : 'bg-white text-gray-400 border-gray-100'}`}>زن</button>
</div>
<div className="flex gap-2 pt-2">
<button onClick={handleSave} disabled={!name || !image || (!audio && !editingModel)} className="flex-[2] py-4 bg-gradient-to-r from-primary to-secondary text-white rounded-2xl font-black text-sm shadow-xl shadow-primary/20 hover:scale-[1.02] active:scale-95 transition-all disabled:grayscale disabled:opacity-50">{editingModel ? 'بروزرسانی مدل' : 'ساخت و ذخیره مدل'}</button>
{editingModel && onDelete && <button onClick={() => onDelete(editingModel.id)} className="flex-1 bg-red-50 text-red-500 rounded-2xl flex items-center justify-center hover:bg-red-100 transition-colors"><i className="fas fa-trash-alt"></i></button>}
</div>
<button onClick={onClose} className="w-full py-2 text-xs text-gray-400 font-bold">انصراف</button>
</div>
</div>
</div>
);
};
// History
const History = ({ jobs, onClearAll, onDeleteSingle, onSelect, onEnhance }) => {
const sortedJobs = [...jobs].sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0));
const [expandedId, setExpandedId] = useState(null);
const toggleExpand = (job) => { if (expandedId === job.job_id) setExpandedId(null); else { setExpandedId(job.job_id); onSelect(job); } };
const getModelImageUrl = (job) => {
if (job.modelImage) {
if (typeof job.modelImage === 'string') return job.modelImage;
try { return URL.createObjectURL(job.modelImage); } catch (e) { return null; }
}
if (job.type === 'model' && job.modelName) {
const found = MODELS.find(m => m.name === job.modelName);
if (found) return found.image;
}
return null;
};
return (
<div className="animate-[fadeIn_0.5s_ease-out]">
<div className="flex items-center justify-between mb-4 px-3 mt-2">
<div><h2 className="text-xl font-black text-gray-800">سوابق پروژه</h2><p className="text-xs text-gray-500 mt-1">لیست درخواست‌های پردازش شده</p></div>
{jobs.length > 0 && <button onClick={onClearAll} className="px-3 py-1.5 rounded-full bg-red-50 text-red-500 hover:bg-red-100 flex items-center gap-2 transition-colors shadow-sm text-xs font-bold"><i className="fas fa-trash-alt"></i> حذف همه</button>}
</div>
<div className="space-y-3 pb-24 px-1">
{jobs.length === 0 ? (
<div className="text-center py-24 opacity-50">
<div className="w-20 h-20 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4 grayscale"><i className="fas fa-folder-open text-3xl text-gray-300"></i></div>
<p className="text-gray-400 font-medium text-sm">هنوز هیچ پروژه‌ای انجام نشده است</p>
</div>
) : (
sortedJobs.map((job) => {
const isExpanded = expandedId === job.job_id;
const modelImage = getModelImageUrl(job);
return (
<div key={job.job_id} onClick={() => toggleExpand(job)} className={`group relative bg-white rounded-2xl px-4 py-4 border transition-all duration-300 cursor-pointer overflow-hidden ${isExpanded ? 'border-primary shadow-lg ring-1 ring-primary/10' : 'border-gray-100 shadow-sm hover:shadow-md'}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4 overflow-hidden">
<div className={`w-12 h-12 flex-shrink-0 rounded-full flex items-center justify-center shadow-md transition-all duration-300 border-2 border-white overflow-hidden ${modelImage ? 'bg-gray-200' : (job.type === 'model' ? 'bg-gradient-to-br from-indigo-500 to-purple-600' : 'bg-gradient-to-br from-gray-700 to-gray-900')}`}>
{modelImage ? <img src={modelImage} alt="Model" className="w-full h-full object-cover" /> : <div className="text-white">{job.type === 'model' ? <i className="fas fa-user text-base"></i> : <i className="fas fa-fingerprint text-base"></i>}</div>}
</div>
<div className="min-w-0">
<h3 className="font-bold text-gray-800 text-sm truncate leading-tight">{job.type === 'model' ? job.modelName : 'پروژه اختصاصی'}</h3>
<div className="flex items-center gap-2 mt-1.5">
<span className={`text-[10px] px-2 py-0.5 rounded-md font-bold border ${job.status === 'completed' ? 'bg-green-50 text-green-600 border-green-100' : job.status === 'failed' ? 'bg-red-50 text-red-600 border-red-100' : 'bg-amber-50 text-amber-600 border-amber-100'}`}>{job.status === 'completed' ? 'تکمیل شده' : job.status === 'failed' ? 'خطا' : 'در حال کار'}</span>
<span className="text-[10px] text-gray-400 dir-ltr font-medium">{job.date?.split(',')[0]}</span>
</div>
</div>
</div>
<div className="flex items-center gap-3">
<i className={`fas fa-chevron-left text-gray-300 text-sm transition-transform duration-300 ${isExpanded ? '-rotate-90 text-primary' : ''}`}></i>
<button onClick={(e) => { e.stopPropagation(); onDeleteSingle(job.job_id); }} className="w-8 h-8 flex items-center justify-center rounded-full text-gray-300 bg-gray-50 hover:text-red-500 hover:bg-red-50 transition-colors z-10 text-sm border border-transparent hover:border-red-100"><i className="fas fa-trash"></i></button>
</div>
</div>
<div className={`grid transition-[grid-template-rows] duration-300 ease-out ${isExpanded ? 'grid-rows-[1fr] opacity-100 mt-2' : 'grid-rows-[0fr] opacity-0 mt-0'}`}>
<div className="overflow-hidden">
<ProcessingPanel job={job} embedded={true} onEnhance={onEnhance} />
</div>
</div>
</div>
);
})
)}
</div>
</div>
);
};
// BottomNav
const BottomNav = ({ activeTab, onChange, badgeCount, historyRef }) => {
const tabs = [
{ id: 'home', icon: 'fa-house', label: 'خانه' },
{ id: 'custom', icon: 'fa-microphone-lines', label: 'ساخت صدا' },
{ id: 'history', icon: 'fa-clock-rotate-left', label: 'سوابق', ref: historyRef },
];
return (
<div className="fixed bottom-6 left-0 right-0 z-50 px-6 pointer-events-none">
<div className="max-w-[360px] mx-auto pointer-events-auto relative">
<div className="absolute -inset-[2px] rounded-full overflow-hidden">
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[200%] h-[1000%] bg-[conic-gradient(from_0deg_at_50%_50%,transparent_0%,rgba(30,41,59,0.15)_50%,transparent_100%)] animate-[spin_10s_linear_infinite]"></div>
</div>
<div className="relative bg-white/95 backdrop-blur-xl shadow-[0_10px_40px_-10px_rgba(0,0,0,0.1)] border border-white/50 rounded-full h-[70px] flex items-center justify-between px-2">
{tabs.map((tab) => {
const isActive = activeTab === tab.id;
return (
<button key={tab.id} ref={tab.ref} onClick={() => onChange(tab.id)} className="relative flex-1 h-full flex flex-col items-center justify-center group rounded-full overflow-hidden">
<div className={`absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full h-full rounded-full transition-all duration-500 ease-out -z-10 ${isActive ? 'bg-gradient-to-t from-gray-50 to-transparent opacity-100' : 'opacity-0'}`}></div>
{tab.id === 'history' && badgeCount > 0 && <span className="absolute top-3 right-[28%] flex h-2 w-2 z-20"><span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span><span className="relative inline-flex rounded-full h-2 w-2 bg-red-500 border border-white"></span></span>}
<div className={`relative w-12 h-8 flex items-center justify-center mb-0.5 transition-all duration-300 ${isActive ? '-translate-y-1' : 'translate-y-0'}`}>
{isActive && <div className="absolute inset-0 bg-blue-500/10 blur-xl rounded-full"></div>}
<i className={`fas ${tab.icon} text-xl transition-all duration-300 z-10 ${isActive ? 'text-transparent bg-clip-text bg-gradient-to-r from-blue-600 to-purple-600 scale-110 drop-shadow-sm' : 'text-gray-400 group-hover:text-gray-600'}`}></i>
</div>
<span className={`text-[10px] font-bold transition-all duration-300 absolute bottom-3 ${isActive ? 'text-gray-800' : 'text-gray-400'}`}>{tab.label}</span>
<div className={`absolute bottom-1 w-1 h-1 rounded-full bg-gradient-to-r from-blue-600 to-purple-600 transition-all duration-300 ${isActive ? 'opacity-100 scale-100' : 'opacity-0 scale-0'}`}></div>
</button>
);
})}
</div>
</div>
</div>
);
};
// UpgradeModal
const UpgradeModal = ({ isOpen, onClose, onUpgrade, resetDate, mode = 'limit' }) => {
if (!isOpen) return null;
const resetDateStr = resetDate ? new Date(resetDate * 1000).toLocaleString('fa-IR', { weekday: 'long', hour: '2-digit', minute: '2-digit' }) : 'هفته آینده';
const isFeatureMode = mode === 'feature';
if (isFeatureMode) {
return (
<div className="fixed inset-0 z-[99999] flex items-center justify-center p-2 sm:p-4">
<div className="absolute inset-0 bg-black/80 backdrop-blur-xl transition-opacity animate-[fadeIn_0.5s_ease-out]" onClick={onClose}></div>
<div className="relative bg-[#0f172a] rounded-[2rem] sm:rounded-[2.5rem] w-full max-w-[380px] max-h-[92vh] flex flex-col p-5 sm:p-8 shadow-[0_0_50px_rgba(139,92,246,0.3)] animate-[scaleIn_0.5s_cubic-bezier(0.34,1.56,0.64,1)] overflow-hidden border border-white/10">
<div className="absolute -top-24 -right-24 w-64 h-64 bg-purple-600/20 rounded-full blur-[80px] animate-pulse pointer-events-none"></div>
<div className="absolute -bottom-24 -left-24 w-64 h-64 bg-blue-600/20 rounded-full blur-[80px] animate-pulse pointer-events-none"></div>
<div className="relative z-10 overflow-y-auto custom-scrollbar pr-1 flex flex-col items-center">
<div className="relative mb-5 sm:mb-6 text-center">
<div className="relative inline-block">
<div className="absolute inset-0 bg-gradient-to-tr from-amber-400 to-purple-600 blur-2xl opacity-40 rounded-full animate-pulse"></div>
<div className="w-16 h-16 sm:w-20 sm:h-20 bg-gradient-to-tr from-[#1e293b] to-[#334155] rounded-[1.5rem] sm:rounded-[2rem] flex items-center justify-center shadow-2xl relative z-10 border border-white/20 transform hover:rotate-6 transition-transform">
<i className="fas fa-wand-sparkles text-2xl sm:text-3xl text-transparent bg-clip-text bg-gradient-to-br from-amber-300 via-yellow-400 to-amber-600"></i>
</div>
</div>
</div>
<div className="text-center w-full">
<h3 className="text-lg sm:text-xl font-black text-white mb-3 sm:mb-4 leading-tight text-center">
استودیوی اختصاصی خودت رو بساز
</h3>
<div className="bg-white/5 backdrop-blur-md rounded-2xl sm:rounded-3xl p-4 sm:p-5 mb-5 sm:mb-6 border border-white/10 text-right">
<div className="space-y-3 sm:space-y-4">
<p className="text-gray-300 text-[12px] sm:text-[13px] leading-[1.8] font-medium">
برای ساخت مدل تغییر صدا با هوش مصنوعی آلفا باید اشتراک برنامه رو داشته باشید.
<span className="text-amber-400 font-black mx-1">مدل‌های اختصاصی شما</span>
مثل بقیه مدل‌ها در این صفحه برای همیشه ذخیره میشن.
</p>
<div className="flex flex-col gap-1.5 pt-1">
<div className="flex items-center gap-2 text-[10px] sm:text-[11px] text-gray-400 bg-white/5 p-2 rounded-xl">
<i className="fas fa-check-circle text-green-500"></i>
<span>شبیه‌سازی دقیق هر صدایی (حتی خودت)</span>
</div>
<div className="flex items-center gap-2 text-[10px] sm:text-[11px] text-gray-400 bg-white/5 p-2 rounded-xl">
<i className="fas fa-check-circle text-green-500"></i>
<span>بالاترین کیفیت + جدیدترین تکنولوژی</span>
</div>
<div className="flex items-center gap-2 text-[10px] sm:text-[11px] text-gray-400 bg-white/5 p-2 rounded-xl">
<i className="fas fa-check-circle text-green-500"></i>
<span>ذخیره دائمی در لیست گویندگان اصلی</span>
</div>
<div className="flex items-center gap-2 text-[10px] sm:text-[11px] text-gray-400 bg-white/5 p-2 rounded-xl">
<i className="fas fa-check-circle text-green-500"></i>
<span>تولید نامحدود تا آخرین روز اشتراک</span>
</div>
<div className="flex items-center gap-2 text-[10px] sm:text-[11px] text-gray-400 bg-white/5 p-2 rounded-xl">
<i className="fas fa-check-circle text-green-500"></i>
<span>فعال شدن تمام بخش های برنامه</span>
</div>
</div>
</div>
</div>
</div>
<div className="space-y-3 w-full">
<button onClick={onUpgrade} className="w-full py-3.5 sm:py-4 rounded-xl sm:rounded-2xl font-black text-sm sm:text-base text-gray-900 bg-gradient-to-r from-amber-300 via-yellow-400 to-amber-600 hover:from-amber-400 hover:to-amber-700 shadow-[0_0_20px_rgba(245,158,11,0.2)] transition-all transform hover:-translate-y-1 active:scale-95 flex items-center justify-center gap-2 group">
<span>تهیه اشتراک برنامه</span>
<i className="fas fa-bolt-lightning text-lg group-hover:animate-bounce"></i>
</button>
<button onClick={onClose} className="w-full py-2 rounded-lg font-bold text-[11px] sm:text-xs text-gray-500 hover:text-white transition-colors">فعلاً نه، برمی‌گردم</button>
</div>
</div>
</div>
</div>
);
}
return (
<div className="fixed inset-0 z-[99999] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/60 backdrop-blur-md transition-opacity animate-[fadeIn_0.5s_ease-out]" onClick={onClose}></div>
<div className="relative bg-white rounded-[2rem] w-full max-w-[380px] p-8 shadow-2xl animate-[scaleIn_0.4s_cubic-bezier(0.34,1.56,0.64,1)] overflow-hidden text-center border-4 border-amber-100">
<div className="absolute top-0 left-0 w-full h-32 bg-gradient-to-b from-amber-50 to-transparent -z-10"></div>
<div className="relative mb-6 inline-block">
<div className="absolute inset-0 bg-amber-400 blur-xl opacity-20 rounded-full animate-pulse"></div>
<div className="w-24 h-24 bg-gradient-to-tr from-amber-300 to-yellow-500 rounded-full flex items-center justify-center shadow-lg shadow-amber-500/30 animate-[bounce_3s_infinite]">
<i className="fas fa-crown text-4xl text-white drop-shadow-md"></i>
</div>
<div className="absolute -top-2 -right-2 w-8 h-8 bg-white rounded-full flex items-center justify-center shadow-md animate-[spin_10s_linear_infinite]">
<i className="fas fa-star text-amber-500 text-xs"></i>
</div>
</div>
<h3 className="text-2xl font-black text-gray-800 mb-2">اعتبار شما تمام شد!</h3>
<div className="bg-amber-50 rounded-xl p-3 border border-amber-100 mb-6">
<p className="text-gray-600 text-[11px] leading-relaxed">
شما از ۳ اعتبار رایگان خود برای این مدل در این هفته استفاده کردید. از مدل دیگری استفاده کنید و یا با تهیه اشتراک نامحدود تولید کنید.
<br/>
<span className="text-[10px] text-amber-700 font-bold mt-2 block">
زمان شارژ مجدد: {resetDateStr}
</span>
</p>
</div>
<div className="space-y-3">
<button onClick={onUpgrade} className="w-full py-4 rounded-xl font-bold text-base text-gray-900 bg-gradient-to-r from-amber-300 via-yellow-400 to-amber-500 hover:from-amber-400 hover:to-amber-600 shadow-xl shadow-amber-500/20 transition-all transform hover:-translate-y-1 active:scale-95 flex items-center justify-center gap-2 group">
<span>ارتقا به نسخه نامحدود</span>
<i className="fas fa-rocket group-hover:animate-ping"></i>
</button>
<button onClick={onClose} className="w-full py-3 rounded-xl font-bold text-sm text-gray-500 hover:bg-gray-50 transition-colors">متوجه شدم، فعلا نه</button>
</div>
<div className="mt-4 flex items-center justify-center gap-2 opacity-60">
<i className="fas fa-infinity text-amber-500"></i>
<span className="text-[10px] text-gray-400">دسترسی نامحدود با ارتقای حساب</span>
</div>
</div>
</div>
);
};
// DeleteModal
const DeleteModal = ({ isOpen, type = 'single', title, message, onClose, onConfirm }) => {
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[110000] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm transition-opacity animate-[fadeIn_0.3s_ease-out]" onClick={onClose}></div>
<div className="relative bg-white rounded-[2.5rem] w-full max-w-[320px] p-8 shadow-2xl animate-[scaleIn_0.4s_cubic-bezier(0.175,0.885,0.32,1.275)] overflow-hidden text-center mx-auto border border-gray-100">
<h3 className="text-xl font-black text-gray-800 mb-3">{title || (type === 'all' ? 'پاکسازی کل تاریخچه' : 'حذف فایل')}</h3>
<p className="text-gray-500 text-sm mb-8 leading-relaxed px-2 font-medium">{message || (type === 'all' ? 'آیا مطمئن هستید؟ تمام سوابق حذف می‌شوند.' : 'آیا از حذف این فایل اطمینان دارید؟')}</p>
<div className="flex gap-3">
<button onClick={onClose} className="flex-1 py-4 rounded-2xl font-bold text-sm text-gray-500 bg-gray-100 hover:bg-gray-200 transition-all active:scale-95">انصراف</button>
<button onClick={onConfirm} className="flex-1 py-4 rounded-2xl font-bold text-sm text-white bg-gradient-to-r from-red-500 to-rose-600 hover:from-red-600 hover:to-rose-700 shadow-lg shadow-red-200 transition-all transform active:scale-95">حذف کن</button>
</div>
</div>
</div>
);
};
// FlyToHistory
const FlyToHistory = ({ onComplete, targetRef, mode }) => {
const [style, setStyle] = useState({ top: '50%', left: '50%', transform: 'translate(-50%, -50%) scale(1.5)', opacity: 0 });
const isVoice = mode === 'vc';
useEffect(() => {
requestAnimationFrame(() => setStyle(prev => ({ ...prev, opacity: 1, transform: 'translate(-50%, -50%) scale(1)' })));
if (!targetRef.current) { setTimeout(onComplete, 800); return; }
const rect = targetRef.current.getBoundingClientRect();
setTimeout(() => setStyle({ top: `${rect.top + rect.height / 2}px`, left: `${rect.left + rect.width / 2}px`, transform: 'translate(-50%, -50%) scale(0.1) rotate(-180deg)', opacity: 0.1 }), 250);
setTimeout(onComplete, 1000);
}, [targetRef, onComplete]);
return (
<>
<div style={{ ...style, transition: 'all 0.75s cubic-bezier(0.175, 0.885, 0.32, 1.275)', position: 'fixed', zIndex: 100 }} className="pointer-events-none flex items-center justify-center will-change-transform">
<div className={`relative w-20 h-20 bg-gradient-to-br ${isVoice ? 'from-blue-600 via-indigo-500 to-purple-600' : 'from-teal-400 via-emerald-500 to-cyan-600'} rounded-2xl flex items-center justify-center z-20 border-2 border-white/40 backdrop-blur-sm`}>
<i className={`fas ${isVoice ? 'fa-microphone-lines' : 'fa-keyboard'} text-3xl text-white drop-shadow-md`}></i>
</div>
</div>
<div className="fixed inset-0 bg-white/30 z-[90] animate-[fadeOut_0.5s_ease-out] pointer-events-none"></div>
</>
);
};
// --- MAIN APP COMPONENT ---
const App = () => {
const [jobs, setJobs] = useState({});
const [customModels, setCustomModels] = useState([]);
const [activeJobId, setActiveJobId] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const [activeTab, setActiveTab] = useState('home');
const [isModalOpen, setIsModalOpen] = useState(false);
const [isCreateModelOpen, setIsCreateModelOpen] = useState(false);
const [selectedModel, setSelectedModel] = useState(null);
const [editingModel, setEditingModel] = useState(null);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState(null);
const [deleteMode, setDeleteMode] = useState('job');
const [subscriptionStatus, setSubscriptionStatus] = useState('free');
const [upgradeModalOpen, setUpgradeModalOpen] = useState(false);
const [upgradeModalMode, setUpgradeModalMode] = useState('limit');
const [resetDate, setResetDate] = useState(null);
const [userFingerprint, setUserFingerprint] = useState(null);
const [showFlyAnimation, setShowFlyAnimation] = useState(false);
const [lastJobMode, setLastJobMode] = useState('vc');
const activeJobPayloads = useRef({});
const historyBtnRef = useRef(null);
const PREMIUM_PAGE_ID = '1149636';
const FULL_UPGRADE_URL = "#/nav/online/news/getSingle/1149636/eyJpdiI6IkVWcVZ2RFRERVhyaXBxaGtVSHpFK2c9PSIsInZhbHVlIjoiMmJWVllYNnhDNDJjbU96Q0Z5RHdzeE9HWjkrWTVNVzVFTzl3bFlUTzVsWHVTU1pCMWRteUJFeUZ6RVBYUks2VSIsIm1hYyI6IjU3Y2ZjM2IyZDEyNjY4ZTQ3NDgyM2JkMzE0Zjg4Y2ZmZDc5OWQyNTRmYzM4MzBjY2M5ZTNmMmViZDE3N2U5YmQiLCJ0YWciOiIifQ==/20934991";
useEffect(() => {
const initUser = async () => {
const fp = await getBrowserFingerprint();
setUserFingerprint(fp);
window.parent.postMessage({ type: 'REQUEST_USER_STATUS' }, '*');
};
initUser();
const handleMessage = (event) => {
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([getAllJobs(), getAllCustomModels()]);
const jobsMap = {};
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 executeJobProcess = async (tempId, data) => {
try {
let actualRefFile = data.refFile;
const isLegacy = data.model?.type === 'legacy_rvc';
if (!isLegacy && !actualRefFile && data.refFileUrl) {
actualRefFile = await fetchRefAudioAsFile(data.refFileUrl, 'ref.wav');
}
const finalRef = actualRefFile || new Blob([''], { type: 'audio/wav' });
const resultJob = await 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] || {};
const finalJobData = {
...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 saveJob(finalJobData);
await 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) {
const payload = activeJobPayloads.current[tempId];
if (payload && payload.retryCount < 3) {
payload.retryCount += 1;
setJobs(prev => {
const job = prev[tempId]; if (!job) return prev;
const updated = { ...job, status: 'started', statusMessage: `تلاش مجدد (${payload.retryCount}/3)...`, retryCount: payload.retryCount };
saveJob(updated); return { ...prev, [tempId]: updated };
});
setTimeout(() => executeJobProcess(tempId, payload), 2000);
} else {
delete activeJobPayloads.current[tempId];
setJobs(prev => {
const job = prev[tempId]; if (!job) return prev;
const failedJob = { ...job, status: 'failed', statusMessage: 'خطا: ' + error.message };
saveJob(failedJob); return { ...prev, [tempId]: failedJob };
});
}
}
};
useEffect(() => {
const pollActiveJobs = async () => {
const activeJobsList = Object.values(jobs);
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 = {};
let hasChanges = false;
await Promise.all(jobsToPoll.map(async (job) => {
try {
const statusRes = await checkJobStatus(job);
if (statusRes.status === 'completed' || statusRes.status === 'failed' || statusRes.status === 'error') {
const isSuccess = statusRes.status === 'completed';
const updatedJob = {
...job, status: isSuccess ? 'completed' : 'failed', filename: statusRes.filename, downloadUrl: statusRes.downloadUrl,
progress: isSuccess ? 100 : job.progress, statusMessage: statusRes.detail || (isSuccess ? 'تکمیل شد' : 'خطا در پردازش')
};
updates[job.job_id] = updatedJob; hasChanges = true; saveJob(updatedJob);
} else {
let shouldUpdate = false;
let newChunks = job.chunks;
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]);
// Added this missing useEffect to handle enhancement polling
useEffect(() => {
const pollEnhancementJobs = async () => {
const enhanceJobsList = Object.values(jobs).filter(job => job.enhancement && job.enhancement.status === 'processing');
if (enhanceJobsList.length === 0) return;
const updates = {};
let hasChanges = false;
await Promise.all(enhanceJobsList.map(async (job) => {
const enh = job.enhancement;
try {
const statusRes = await 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) {
const updatedJob = { ...job, enhancement: { ...enh, status: statusRes.status, progress: nextProgress, filename: statusRes.filename || enh.filename } };
updates[job.job_id] = updatedJob;
hasChanges = true;
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) => {
const isCustomModel = data.type === 'custom' || data.model?.isCustom;
if (subscriptionStatus === 'free') {
if (isCustomModel) { setUpgradeModalMode('feature'); setUpgradeModalOpen(true); return; }
if (userFingerprint) {
const check = await checkCredit(userFingerprint, data.model?.id || 'standard_vc');
if (check.limit_reached) { setResetDate(check.reset_timestamp); setUpgradeModalMode('limit'); setUpgradeModalOpen(true); return; }
useCredit(userFingerprint, data.model?.id || 'standard_vc');
}
}
const tempJobId = `temp_${Date.now()}`;
const initialJob = {
job_id: tempJobId, status: 'started', 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 saveJob(initialJob);
setIsModalOpen(false);
setLastJobMode(data.mode);
setShowFlyAnimation(true);
activeJobPayloads.current[tempJobId] = { ...data, retryCount: 0 };
executeJobProcess(tempJobId, data);
};
const handleEnhance = async (job) => {
setJobs(prev => ({ ...prev, [job.job_id]: { ...job, enhancement: { status: 'processing', progress: 5 } } }));
try {
const audioFile = await fetchRefAudioAsFile(job.downloadUrl || `https://ezmary-sada.hf.space/download/${job.filename}`, 'orig.wav');
const response = await uploadEnhancementJob(audioFile);
const processingJob = { ...job, enhancement: { status: 'processing', job_id: response.job_id, progress: 5, metadata: response } };
setJobs(prev => ({ ...prev, [job.job_id]: processingJob }));
saveJob(processingJob);
} catch (err) { setJobs(prev => ({ ...prev, [job.job_id]: { ...job, enhancement: { status: 'failed', progress: 0 } } })); }
};
const handleSaveModel = async (model) => {
try {
await saveCustomModel(model);
setCustomModels(await getAllCustomModels());
setIsCreateModelOpen(false);
setEditingModel(null);
} catch (error) {
console.error("Save model error:", error);
alert("خطا در ذخیره مدل. ممکن است حجم فایل زیاد باشد یا حافظه مرورگر پر شده باشد.");
}
};
const confirmDelete = async () => {
if (deleteMode === 'job') {
if (deleteTarget === 'all') { setJobs({}); await clearAllJobs(); }
else if (typeof deleteTarget === 'string') { setJobs(prev => { const next = { ...prev }; delete next[deleteTarget]; return next; }); await deleteJob(deleteTarget); }
} else if (deleteMode === 'model' && typeof deleteTarget === 'string') {
await deleteCustomModel(deleteTarget);
setCustomModels(await 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>
<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={(src, ref) => startJob({ sourceFile: src, refFile: ref, type: 'custom', mode: 'vc', pitch: 0 })} 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={(data) => {
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
});
}} isLoading={isLoading} />
<CreateModelModal isOpen={isCreateModelOpen} editingModel={editingModel} onClose={() => setIsCreateModelOpen(false)} onSave={handleSaveModel} onDelete={(id) => { setDeleteMode('model'); setDeleteTarget(id); setDeleteModalOpen(true); setIsCreateModelOpen(false); }} />
<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)).filter(j=>j.status==='processing'||j.status==='started').length} historyRef={historyBtnRef} />
</div>
);
};
const rootElement = document.getElementById('root');
const root = ReactDOM.createRoot(rootElement);
root.render(<App />);
</script>
</body>
</html>