<!DOCTYPE html>
<html lang="id" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DIGIVOICE - Enterprise AI Voice Engine</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
slate: {
850: '#151F32',
}
},
animation: {
'pulse-fast': 'pulse 1s cubic-bezier(0.4, 0, 0.6, 1) infinite',
}
}
}
}
</script>
<script src="https://cdn.jsdelivr.net/npm/lamejs@1.2.1/lame.min.js"></script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.2.0/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.2.0/umd/react-dom.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/babel-standalone/7.23.5/babel.min.js"></script>
<style>
body {
background-color: #f1f5f9;
color: #1e293b;
font-family: 'Inter', system-ui, sans-serif;
transition: background-color 0.3s ease, color 0.3s ease;
}
html.dark body {
background-color: #0B1120;
color: #e2e8f0;
}
.glass-panel {
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(12px);
border: 1px solid rgba(0, 0, 0, 0.08);
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.05);
transition: all 0.3s ease;
}
html.dark .glass-panel {
background: rgba(30, 41, 59, 0.7);
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.3);
}
.glass-panel-accent {
background: rgba(45, 212, 191, 0.05);
backdrop-filter: blur(12px);
border: 1px solid rgba(45, 212, 191, 0.2);
}
html.dark .glass-panel-accent {
background: rgba(45, 212, 191, 0.1);
}
.glass-panel-photo {
background: rgba(236, 72, 153, 0.05);
backdrop-filter: blur(12px);
border: 1px solid rgba(236, 72, 153, 0.2);
}
html.dark .glass-panel-photo {
background: rgba(236, 72, 153, 0.1);
}
.glass-panel-speed {
background: rgba(245, 158, 11, 0.05);
backdrop-filter: blur(12px);
border: 1px solid rgba(245, 158, 11, 0.2);
box-shadow: inset 0 0 20px rgba(245, 158, 11, 0.05);
}
html.dark .glass-panel-speed {
background: rgba(245, 158, 11, 0.1);
border: 1px solid rgba(245, 158, 11, 0.3);
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
input[type=range] {
-webkit-appearance: none;
background: transparent;
}
input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none;
height: 16px;
width: 16px;
border-radius: 50%;
background: #10b981;
cursor: pointer;
margin-top: -6px;
box-shadow: 0 0 10px rgba(16, 185, 129, 0.3);
transition: transform 0.1s;
}
input[type=range]::-webkit-slider-thumb:hover {
transform: scale(1.2);
}
.speed-range::-webkit-slider-thumb {
background: #f59e0b;
box-shadow: 0 0 10px rgba(245, 158, 11, 0.5);
width: 22px;
height: 22px;
margin-top: -9px;
border: 2px solid #fff;
}
html.dark input[type=range]::-webkit-slider-thumb {
background: #fff;
box-shadow: 0 0 10px rgba(255, 255, 255, 0.5);
}
html.dark .speed-range::-webkit-slider-thumb {
background: #fbbf24;
border: 2px solid #1e293b;
}
.intensity-range::-webkit-slider-thumb {
background: #06b6d4;
box-shadow: 0 0 10px rgba(6, 182, 212, 0.5);
width: 18px;
height: 18px;
margin-top: -7px;
border: 2px solid #fff;
}
html.dark .intensity-range::-webkit-slider-thumb {
background: #22d3ee;
border: 2px solid #0f172a;
}
input[type=range]::-webkit-slider-runnable-track {
width: 100%;
height: 4px;
cursor: pointer;
background: #cbd5e1;
border-radius: 2px;
}
html.dark input[type=range]::-webkit-slider-runnable-track {
background: #334155;
}
@keyframes slideDown {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-slide-down {
animation: slideDown 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-slide-up {
animation: slideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.loading-gradient {
background: linear-gradient(90deg, #059669 0%, #34d399 50%, #059669 100%);
background-size: 200% 100%;
animation: gradientMove 2s linear infinite;
}
.loading-gradient-amber {
background: linear-gradient(90deg, #d97706 0%, #fbbf24 50%, #d97706 100%);
background-size: 200% 100%;
animation: gradientMove 2s linear infinite;
}
.loading-gradient-teal {
background: linear-gradient(90deg, #14b8a6 0%, #2dd4bf 50%, #14b8a6 100%);
background-size: 200% 100%;
animation: gradientMove 2s linear infinite;
}
.loading-gradient-pink {
background: linear-gradient(90deg, #ec4899 0%, #f472b6 50%, #ec4899 100%);
background-size: 200% 100%;
animation: gradientMove 2s linear infinite;
}
@keyframes gradientMove {
0% { background-position: 100% 0; }
100% { background-position: -100% 0; }
}
.bar {
width: 3px;
background: currentColor;
animation: equalize 0.5s infinite;
}
@keyframes equalize {
0% { height: 20%; }
50% { height: 100%; }
100% { height: 20%; }
}
</style>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
const { useState, useEffect, useRef } = React;
const VOICE_OPTIONS = [
{ name: "Kore", gender: "Female", style: "Balanced, Natural", id: "Kore", desc: "Suara wanita muda yang jernih dan santai." },
{ name: "Fenrir", gender: "Male", style: "Deep, Cinematic", id: "Fenrir", desc: "Suara pria berat, dalam, dan berwibawa." },
{ name: "Aoede", gender: "Female", style: "Emotional, Storyteller", id: "Aoede", desc: "Sangat ekspresif dan emosional." },
{ name: "Charon", gender: "Male", style: "Authoritative, News", id: "Charon", desc: "Tegas, serius, dan terpercaya." },
{ name: "Leda", gender: "Female", style: "Soft, ASMR", id: "Leda", desc: "Lembut, menenangkan, dan halus." },
{ name: "Orion", gender: "Male", style: "Casual, Friendly", id: "Orus", desc: "Seperti teman ngobrol sehari-hari." },
{ name: "Nyx", gender: "Female", style: "Confident, Ads", id: "Callirrhoe", desc: "Modern, percaya diri, dan 'mahal'." },
{ name: "Atlas", gender: "Male", style: "Professional, Edu", id: "Puck", desc: "Jelas, artikulatif, dan cerdas." },
{ name: "Selene", gender: "Female", style: "Elegant, Premium", id: "Erinome", desc: "Elegan, dewasa, dan sophisticated." },
{ name: "Ares", gender: "Male", style: "Hype, Energetic", id: "Zephyr", desc: "Cepat, penuh energi, dan semangat." },
{ name: "Luna", gender: "Female", style: "Empathetic, Soft", id: "Autonoe", desc: "Suara penuh empati dan kehangatan." },
{ name: "Titan", gender: "Male", style: "Deep, Gravelly", id: "Enceladus", desc: "Suara pria sangat berat dan berkarakter." },
{ name: "Nova", gender: "Male", style: "Broadcast, Radio", id: "Iapetus", desc: "Suara penyiar radio klasik." },
{ name: "Vega", gender: "Male", style: "Calm, Narrator", id: "Umbriel", desc: "Tenang, stabil, dan datar." },
{ name: "Lyra", gender: "Female", style: "Formal, Corporate", id: "Algieba", desc: "Sangat formal dan profesional." },
{ name: "Rhea", gender: "Female", style: "Friendly, CS", id: "Despina", desc: "Ramah, membantu, dan ceria." },
{ name: "Rigel", gender: "Male", style: "Fast, Promo", id: "Algenib", desc: "Cepat dan to-the-point." },
{ name: "Sirius", gender: "Male", style: "Audiobook, Story", id: "Rasalgethi", desc: "Gaya mendongeng klasik." },
{ name: "Gaia", gender: "Female", style: "Mature, Warm", id: "Laomedeia", desc: "Dewasa dan menenangkan." },
{ name: "Zenith", gender: "Male", style: "Tech, Futuristic", id: "Achernar", desc: "Bersih, modern, dan digital." }
];
const DELIVERY_STYLES = [
{ id: "semangat", name: "Semangat (Energetic)", icon: "fa-fire", prompt: "Suara penuh energi, antusias tinggi, tempo cepat, dan sangat bertenaga." },
{ id: "profesional", name: "Profesional", icon: "fa-user-tie", prompt: "Suara berwibawa, artikulasi sangat jelas, formal, meyakinkan." },
{ id: "santai", name: "Santai (Casual)", icon: "fa-couch", prompt: "Gaya bicara rileks, seperti ngobrol dengan teman akrab, tidak kaku." },
{ id: "ceria", name: "Ceria (Happy)", icon: "fa-smile-beam", prompt: "Nada suara tersenyum (smiling voice), bahagia, uplifting, dan positif." },
{ id: "sedih", name: "Sedih (Sad)", icon: "fa-sad-tear", prompt: "Nada rendah, lambat, terdengar sedih, kecewa, atau berduka." },
{ id: "marah", name: "Marah (Angry)", icon: "fa-face-angry", prompt: "Nada tinggi, tegas, tajam, intens, dan terdengar kesal." },
{ id: "berbisik", name: "Berbisik (Whisper)", icon: "fa-user-secret", prompt: "Suara sangat pelan, berbisik dekat microphone, mendesah." },
{ id: "dramatis", name: "Dramatis (Dramatic)", icon: "fa-masks-theater", prompt: "Penuh penekanan, jeda yang intens, emosional, teatrikal." },
{ id: "tenang", name: "Tenang (Calm)", icon: "fa-water", prompt: "Sangat stabil, lembut, datar, menenangkan, cocok untuk meditasi." },
{ id: "informatif", name: "Informatif (News)", icon: "fa-newspaper", prompt: "Objektif, jelas, netral, faktual, seperti pembaca berita." },
{ id: "teriak", name: "Teriak (Shouting)", icon: "fa-bullhorn", prompt: "Volume suara keras, intensitas tinggi, memanggil." },
{ id: "ngosngosan", name: "Ngos-ngosan (Panting)", icon: "fa-person-running", prompt: "Suara terengah-engah, nafas berat dan cepat." }
];
const App = () => {
// --- API KEY STATE MANAGEMENT ---
const [apiKey, setApiKey] = useState(() => localStorage.getItem('digivoice_api_key') || "");
const [showSettings, setShowSettings] = useState(!localStorage.getItem('digivoice_api_key'));
const [tempApiKey, setTempApiKey] = useState("");
const mainAudioRef = useRef(null);
const textareaRef = useRef(null);
const [isDarkMode, setIsDarkMode] = useState(true);
const [currentView, setCurrentView] = useState("tts");
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [text, setText] = useState("");
const [lastGeneratedConfig, setLastGeneratedConfig] = useState({ text: "", voice: "", style: "", intensity: 5 });
const [selectedVoice, setSelectedVoice] = useState(VOICE_OPTIONS[0].id);
const [selectedStyle, setSelectedStyle] = useState(DELIVERY_STYLES[0].id);
const [styleIntensity, setStyleIntensity] = useState(5);
const [optimizationMode, setOptimizationMode] = useState("optimized");
const [autoDetectLanguage, setAutoDetectLanguage] = useState(false);
const [previewState, setPreviewState] = useState({ playingVoiceId: null, isLoading: false, audio: null });
const [speed, setSpeed] = useState(1.0);
const [pitch, setPitch] = useState(0);
const [weight, setWeight] = useState(0);
const [volume, setVolume] = useState(0);
const [articulation, setArticulation] = useState(0);
const [uiState, setUiState] = useState({ voice: true, style: true, control: false });
const [processing, setProcessing] = useState({ isLoading: false, step: "", error: null });
const [result, setResult] = useState({ previewUrl: null, downloadUrl: null, downloadSpeed: 1.0, refinedScript: null, modeUsed: "original", rawPCM: null });
const [articulationProcessing, setArticulationProcessing] = useState(false);
const [ideaForm, setIdeaForm] = useState({ description: "", usp: "", contentType: "Iklan Media Sosial (TikTok/Reels)", language: "Bahasa Indonesia", count: 3 });
const [ideaProcessing, setIdeaProcessing] = useState(false);
const [generatedIdeas, setGeneratedIdeas] = useState([]);
const [uspProcessing, setUspProcessing] = useState(false);
const [photoForm, setPhotoForm] = useState({ imageData: null, imageName: "", contentType: "TikTok Affiliate", targetAge: "Gen Z (18 - 24 Tahun)", targetGender: "Semua Gender", language: "Bahasa Indonesia", count: 3 });
const [photoProcessing, setPhotoProcessing] = useState(false);
const [generatedPhotoScripts, setGeneratedPhotoScripts] = useState([]);
const fileInputRef = useRef(null);
useEffect(() => {
if (isDarkMode) document.documentElement.classList.add('dark');
else document.documentElement.classList.remove('dark');
}, [isDarkMode]);
useEffect(() => {
if (mainAudioRef.current) {
mainAudioRef.current.playbackRate = speed;
}
}, [speed, result.previewUrl]);
const saveApiKey = () => {
if (tempApiKey.trim().length > 10) {
setApiKey(tempApiKey.trim());
localStorage.setItem('digivoice_api_key', tempApiKey.trim());
setShowSettings(false);
} else {
alert("Mohon masukkan API Key yang valid.");
}
};
const toggleSection = (section) => {
setUiState(prev => ({ ...prev, [section]: !prev[section] }));
};
const pcmToAudioBuffer = async (pcmBase64, sampleRate = 24000) => {
const binaryString = atob(pcmBase64);
const len = binaryString.length;
const buffer = new ArrayBuffer(len);
const view = new Uint8Array(buffer);
for (let i = 0; i < len; i++) view[i] = binaryString.charCodeAt(i);
const int16Data = new Int16Array(buffer);
const float32Data = new Float32Array(int16Data.length);
for (let i = 0; i < int16Data.length; i++) float32Data[i] = int16Data[i] / 32768.0;
const audioCtx = new (window.AudioContext || window.webkitAudioContext)({ sampleRate });
try {
const audioBuffer = audioCtx.createBuffer(1, float32Data.length, sampleRate);
audioBuffer.getChannelData(0).set(float32Data);
return audioBuffer;
} finally {
if (audioCtx.state !== 'closed') await audioCtx.close();
}
};
const audioBufferToMp3Url = (buffer) => {
if (typeof lamejs === 'undefined') return null;
const channels = 1;
const sampleRate = buffer.sampleRate;
const kbps = 128;
const mp3encoder = new lamejs.Mp3Encoder(channels, sampleRate, kbps);
const mp3Data = [];
const rawData = buffer.getChannelData(0);
const samples = new Int16Array(rawData.length);
for (let i = 0; i < rawData.length; i++) {
const s = Math.max(-1, Math.min(1, rawData[i]));
samples[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
}
const sampleBlockSize = 1152;
for (let i = 0; i < samples.length; i += sampleBlockSize) {
const sampleChunk = samples.subarray(i, i + sampleBlockSize);
const mp3buf = mp3encoder.encodeBuffer(sampleChunk);
if (mp3buf.length > 0) mp3Data.push(mp3buf);
}
const mp3buf = mp3encoder.flush();
if (mp3buf.length > 0) mp3Data.push(mp3buf);
return URL.createObjectURL(new Blob(mp3Data, { type: 'audio/mp3' }));
};
const performTimeStretch = async (audioBuffer, speed) => {
if (speed === 1.0) return audioBuffer;
const sampleRate = audioBuffer.sampleRate;
const channels = audioBuffer.numberOfChannels;
const inputData = audioBuffer.getChannelData(0);
const winSize = 2048;
const overlap = 0.5;
const hs = Math.floor(winSize * overlap);
const ha = Math.floor(hs * speed);
const newLength = Math.floor(inputData.length / speed);
const offlineCtx = new OfflineAudioContext(channels, newLength, sampleRate);
const outBuffer = offlineCtx.createBuffer(channels, newLength, sampleRate);
const outData = outBuffer.getChannelData(0);
const window = new Float32Array(winSize);
for (let i = 0; i < winSize; i++) window[i] = 0.5 * (1 - Math.cos(2 * Math.PI * i / (winSize - 1)));
let inputIdx = 0;
let outputIdx = 0;
while (outputIdx + winSize < newLength && inputIdx + winSize < inputData.length) {
for (let i = 0; i < winSize; i++) outData[outputIdx + i] += inputData[Math.floor(inputIdx) + i] * window[i];
inputIdx += ha;
outputIdx += hs;
}
const normFactor = 1 / (1 / overlap * 0.55);
for (let i = 0; i < newLength; i++) outData[i] *= normFactor;
return outBuffer;
};
const pcmToWav = (pcmBase64, sampleRate = 24000) => {
const binaryString = atob(pcmBase64);
const len = binaryString.length;
const buffer = new ArrayBuffer(len);
const view = new Uint8Array(buffer);
for (let i = 0; i < len; i++) view[i] = binaryString.charCodeAt(i);
const pcmData = new Int16Array(buffer);
const wavHeaderBuffer = new ArrayBuffer(44);
const viewHeader = new DataView(wavHeaderBuffer);
const numChannels = 1;
const byteRate = sampleRate * numChannels * 2;
const dataSize = pcmData.length * 2;
const writeString = (v, o, s) => { for (let i=0;i<s.length;i++) v.setUint8(o+i, s.charCodeAt(i)); };
writeString(viewHeader, 0, 'RIFF');
viewHeader.setUint32(4, 36 + dataSize, true);
writeString(viewHeader, 8, 'WAVE');
writeString(viewHeader, 12, 'fmt ');
viewHeader.setUint32(16, 16, true);
viewHeader.setUint16(20, 1, true);
viewHeader.setUint16(22, numChannels, true);
viewHeader.setUint32(24, sampleRate, true);
viewHeader.setUint32(28, byteRate, true);
viewHeader.setUint16(32, numChannels * 2, true);
viewHeader.setUint16(34, 16, true);
writeString(viewHeader, 36, 'data');
viewHeader.setUint32(40, dataSize, true);
return URL.createObjectURL(new Blob([wavHeaderBuffer, pcmData], { type: 'audio/wav' }));
};
const handlePreview = async (e, voiceId) => {
e.stopPropagation();
if (!apiKey) return setShowSettings(true);
if (previewState.playingVoiceId === voiceId) {
if (previewState.audio) {
previewState.audio.pause();
previewState.audio.currentTime = 0;
}
setPreviewState({ playingVoiceId: null, isLoading: false, audio: null });
return;
}
if (previewState.audio) {
previewState.audio.pause();
previewState.audio.currentTime = 0;
}
setPreviewState({ playingVoiceId: voiceId, isLoading: true, audio: null });
try {
const targetVoice = VOICE_OPTIONS.find(v => v.id === voiceId);
const textToSay = targetVoice ? targetVoice.desc : "Halo, ini adalah contoh suara saya.";
const ttsResp = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-tts:generateContent?key=${apiKey}`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({
contents: [{ parts: [{ text: textToSay }] }],
generationConfig: { responseModalities: ["AUDIO"], speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName: voiceId } } } }
})
});
if (!ttsResp.ok) throw new Error(`API Error: ${ttsResp.status}`);
const ttsData = await ttsResp.json();
if (ttsData.error) throw new Error(ttsData.error.message);
const audioContent = ttsData.candidates?.[0]?.content?.parts?.[0]?.inlineData;
if (!audioContent) throw new Error("No audio returned");
const wavUrl = pcmToWav(audioContent.data);
const newAudio = new Audio(wavUrl);
newAudio.playbackRate = speed;
newAudio.onended = () => setPreviewState(prev => ({ ...prev, playingVoiceId: null, audio: null, isLoading: false }));
newAudio.onerror = () => {
setPreviewState(prev => ({ ...prev, playingVoiceId: null, audio: null, isLoading: false }));
alert("Gagal memutar preview.");
};
await newAudio.play();
setPreviewState({ playingVoiceId: voiceId, isLoading: false, audio: newAudio });
} catch (err) {
setPreviewState({ playingVoiceId: null, isLoading: false, audio: null });
if(err.message.includes("400") || err.message.includes("401") || err.message.includes("403")) {
alert("API Key tidak valid. Silakan periksa pengaturan Anda.");
setShowSettings(true);
} else {
console.error("Preview error:", err);
}
}
};
const stopPreview = () => {
if (previewState.audio) {
previewState.audio.pause();
previewState.audio.currentTime = 0;
}
setPreviewState({ playingVoiceId: null, isLoading: false, audio: null });
};
const handleImageUpload = (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onloadend = () => setPhotoForm(prev => ({ ...prev, imageData: reader.result.split(',')[1], imageName: file.name, mimeType: file.type }));
reader.readAsDataURL(file);
};
const generateScriptsFromPhoto = async () => {
if (!apiKey) return setShowSettings(true);
if (!photoForm.imageData) return alert("Mohon upload foto.");
setPhotoProcessing(true);
try {
const prompt = `Role: Expert Copywriter. Task: Create ${photoForm.count} voice-over scripts for ${photoForm.contentType} based on the image.
Language: ${photoForm.language}.
Target Audience: ${photoForm.targetAge}, ${photoForm.targetGender}.
Constraint: STRICTLY keep the duration around 30 seconds (approx 500 characters).
Style: Engaging, persuasive, and suitable for the chosen platform.
Return ONLY JSON array of strings (the script texts).`;
const resp = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${apiKey}`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ contents: [{ role: "user", parts: [{ text: prompt }, { inlineData: { mimeType: photoForm.mimeType || "image/jpeg", data: photoForm.imageData } }] }], generationConfig: { responseMimeType: "application/json" } })
});
if (!resp.ok) throw new Error(`API Error: ${resp.status}`);
const data = await resp.json();
if(data.error) throw new Error(data.error.message);
setGeneratedPhotoScripts(JSON.parse(data.candidates[0].content.parts[0].text));
} catch (err) {
if(err.message.includes("400") || err.message.includes("401")) setShowSettings(true);
else alert(`Gagal membuat script: ${err.message}`);
} finally {
setPhotoProcessing(false);
}
};
const generateIdeas = async () => {
if (!apiKey) return setShowSettings(true);
if (!ideaForm.description) return;
setIdeaProcessing(true);
try {
let instructions = "CRITICAL: Return ONLY the spoken words (dialogue). DO NOT include visual descriptions like 'Visual:', 'Scene:', 'Action:'. Just the script text.";
if (ideaForm.contentType === "Iklan Media Sosial (TikTok/Reels)") {
instructions += " MANDATORY: Because this is for TikTok Shop, EVERY script MUST end with a Call to Action to click the 'Keranjang Kuning' (Yellow Basket).";
instructions += " DURATION CONSTRAINT: The script must be concise, targeting exactly 30 seconds reading time (approx 60-75 words). Not too short, not too long.";
}
const prompt = `Role: Direct Response Copywriter. Task: Create ${ideaForm.count} short, punchy voice-over scripts in ${ideaForm.language} for ${ideaForm.contentType}. Product: ${ideaForm.description}. USP: ${ideaForm.usp}. ${instructions} Return ONLY JSON array of strings.`;
const resp = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${apiKey}`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ contents: [{ parts: [{ text: prompt }] }], generationConfig: { responseMimeType: "application/json" } })
});
if (!resp.ok) throw new Error(`API Error: ${resp.status}`);
const data = await resp.json();
if(data.error) throw new Error(data.error.message);
setGeneratedIdeas(JSON.parse(data.candidates[0].content.parts[0].text));
} catch (err) {
if(err.message.includes("400") || err.message.includes("401")) setShowSettings(true);
else alert(`Gagal membuat ide: ${err.message}`);
} finally {
setIdeaProcessing(false);
}
};
const generateUSP = async () => {
if (!apiKey) return setShowSettings(true);
if (!ideaForm.description) return alert("Isi deskripsi dulu.");
setUspProcessing(true);
try {
const prompt = `Extract one short Indonesian USP from: "${ideaForm.description}". Return ONLY the text.`;
const resp = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${apiKey}`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ contents: [{ parts: [{ text: prompt }] }] })
});
if (!resp.ok) throw new Error(`API Error: ${resp.status}`);
const data = await resp.json();
if(data.error) throw new Error(data.error.message);
setIdeaForm(prev => ({ ...prev, usp: data.candidates[0].content.parts[0].text.trim() }));
} catch(err) {
if(err.message.includes("400") || err.message.includes("401")) setShowSettings(true);
else alert(err.message);
} finally {
setUspProcessing(false);
}
};
const fixArticulation = async () => {
if (!apiKey) return setShowSettings(true);
if (!text) return;
setArticulationProcessing(true);
try {
const prompt = `Rewrite to improve Indonesian articulation/prosody for TTS by adding punctuation. Do not change words. Input: "${text}"`;
const resp = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${apiKey}`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ contents: [{ parts: [{ text: prompt }] }] })
});
if (!resp.ok) throw new Error(`API Error: ${resp.status}`);
const data = await resp.json();
if(data.error) throw new Error(data.error.message);
setText(data.candidates[0].content.parts[0].text.trim());
} catch (err) {
if(err.message.includes("400") || err.message.includes("401")) setShowSettings(true);
else alert(err.message);
} finally {
setArticulationProcessing(false);
}
};
const generateAudio = async () => {
if (!apiKey) return setShowSettings(true);
if (!text.trim()) return;
let effectiveMode = optimizationMode;
if (text.match(/\(.*\)/) && optimizationMode === "original") effectiveMode = "optimized";
const isRebakeOnly = result.rawPCM &&
text === lastGeneratedConfig.text &&
selectedVoice === lastGeneratedConfig.voice &&
selectedStyle === lastGeneratedConfig.style &&
styleIntensity === lastGeneratedConfig.intensity;
const stepStart = isRebakeOnly ? "baking" : (effectiveMode === 'optimized' ? "analyzing" : "synthesizing");
setProcessing({ isLoading: true, step: stepStart, error: null });
if (!isRebakeOnly) {
setResult(prev => ({ ...prev, previewUrl: null, downloadUrl: null, refinedScript: null, modeUsed: effectiveMode }));
}
try {
let rawPcmData = result.rawPCM;
if (!isRebakeOnly) {
let scriptToRead = text;
if (effectiveMode === 'optimized') {
const voiceProfile = VOICE_OPTIONS.find(v => v.id === selectedVoice);
const styleProfile = DELIVERY_STYLES.find(s => s.id === selectedStyle);
const langInstruction = autoDetectLanguage
? "Language: Detect the language of the Input Text. Output the instructions and script in that SAME language. DO NOT TRANSLATE to Indonesian if it is not Indonesian."
: "Language: Indonesian.";
const logicPrompt = `
Role: Expert TTS Director.
Task: Convert the input script to text-to-speech instructions.
${langInstruction}
Input Text: "${text}"
Target Voice: ${voiceProfile.name}
Target Style: ${styleProfile.name}
Style Description: ${styleProfile.prompt}
INTENSITY CONTROL (CRITICAL):
The user has set the Style Intensity to: ${styleIntensity} / 10.
- If 0-3: Apply the style subtly. Keep it very natural, almost neutral but with a hint of ${styleProfile.name}.
- If 4-6: Balanced application. Distinctly ${styleProfile.name} but not overacting.
- If 7-10: Extreme application. Heavily emphasize the characteristics of ${styleProfile.name}. Use strong punctuation, stage directions, and pacing changes.
Constraint:
- Maintain realistic, clear audio. Do not distort the phonemes.
- Use stage directions like (laugh), (sigh), (fast), (slow), (loud), (whisper) only if they fit the ${styleProfile.name} style and intensity ${styleIntensity}.
- Output ONLY the processed text with instructions.
`;
const refineResp = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${apiKey}`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ contents: [{ parts: [{ text: logicPrompt }] }] })
});
if (!refineResp.ok) throw new Error(`AI Director Error: ${refineResp.status}`);
const refineData = await refineResp.json();
if(refineData.error) throw new Error(refineData.error.message);
scriptToRead = refineData.candidates[0].content.parts[0].text;
setResult(prev => ({ ...prev, refinedScript: scriptToRead }));
}
setProcessing(prev => ({ ...prev, step: "synthesizing" }));
const ttsResp = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-tts:generateContent?key=${apiKey}`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({
contents: [{ parts: [{ text: scriptToRead }] }],
generationConfig: {
responseModalities: ["AUDIO"],
speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName: selectedVoice } } }
}
})
});
if (!ttsResp.ok) throw new Error(`TTS Generation Error: ${ttsResp.status}`);
const ttsData = await ttsResp.json();
if(ttsData.error) throw new Error(ttsData.error.message);
const audioContent = ttsData.candidates[0].content.parts[0].inlineData;
rawPcmData = audioContent.data;
}
const rawWavUrl = pcmToWav(rawPcmData);
setProcessing(prev => ({ ...prev, step: "baking" }));
const audioBuffer = await pcmToAudioBuffer(rawPcmData);
const bakedBuffer = await performTimeStretch(audioBuffer, speed);
const bakedMp3Url = audioBufferToMp3Url(bakedBuffer);
setResult(prev => ({
...prev,
previewUrl: rawWavUrl,
downloadUrl: bakedMp3Url,
downloadSpeed: speed,
rawPCM: rawPcmData
}));
setLastGeneratedConfig({ text: text, voice: selectedVoice, style: selectedStyle, intensity: styleIntensity });
setProcessing({ isLoading: false, step: "completed", error: null });
} catch (err) {
setProcessing({ isLoading: false, step: "failed", error: err.message });
if(err.message.includes("400") || err.message.includes("401") || err.message.includes("403")) {
setShowSettings(true);
}
}
};
const renderKnob = (label, value, setter, min, max, leftLabel, rightLabel) => (
<div className="space-y-2">
<div className="flex justify-between text-[11px] font-bold text-slate-500 dark:text-slate-400 uppercase tracking-wider">
<span>{label}</span>
<span className="text-indigo-600 dark:text-indigo-400">{value > 0 ? `+${value}` : value}</span>
</div>
<input type="range" min={min} max={max} step={1} value={value} onChange={(e) => setter(Number(e.target.value))} className="w-full" />
<div className="flex justify-between text-[9px] text-slate-500 font-medium">
<span>{leftLabel}</span>
<span>{rightLabel}</span>
</div>
</div>
);
const renderSpeedControl = () => (
<div className="space-y-4">
<div className="flex justify-between items-end">
<label className="text-xs font-bold text-amber-600 dark:text-amber-500 uppercase flex items-center gap-2">
<i className="fas fa-gauge-high"></i>
Speed Calibration
</label>
<div className="text-lg font-black text-amber-600 dark:text-amber-400 bg-amber-100 dark:bg-amber-900/30 px-3 py-1 rounded-lg tabular-nums shadow-sm border border-amber-200 dark:border-amber-700/50">
{speed.toFixed(1)}x
</div>
</div>
<div className="relative py-2">
<div className="absolute top-1/2 left-0 right-0 -translate-y-1/2 h-1 flex justify-between px-1 pointer-events-none z-0">
{[0.5, 1.0, 1.5, 2.0, 2.5, 3.0].map(mark => (
<div key={mark} className={`w-0.5 h-3 -mt-1 ${mark === 1.0 ? 'bg-slate-400 dark:bg-slate-500 h-4 -mt-1.5' : 'bg-slate-300 dark:bg-slate-600'}`}></div>
))}
</div>
<input type="range" min="0.5" max="3.0" step="0.1" value={speed} onChange={(e) => setSpeed(Number(e.target.value))} className="w-full speed-range relative z-10" />
</div>
<div className="flex justify-between text-[10px] font-bold text-slate-400 dark:text-slate-500 uppercase tracking-wider">
<span className={speed <= 0.8 ? "text-amber-600 dark:text-amber-400" : ""}>Slow</span>
<span className={speed >= 0.9 && speed <= 1.1 ? "text-slate-800 dark:text-white" : ""}>Normal</span>
<span className={speed >= 1.2 && speed < 2.0 ? "text-amber-600 dark:text-amber-400" : ""}>Fast</span>
<span className={speed >= 2.0 ? "text-red-500" : ""}>Hyper</span>
</div>
<div className="p-3 bg-amber-50 dark:bg-amber-900/10 rounded-lg border border-amber-100 dark:border-amber-800/30 text-[10px] text-amber-800 dark:text-amber-200/70 leading-relaxed flex gap-2 items-start">
<i className="fas fa-info-circle mt-0.5 shrink-0"></i>
<span>
<strong>Note:</strong> Geser slider untuk preview speed.
<br/>
<span className="font-bold underline">Klik "Generate Voice Over"</span> untuk menyimpan file dengan speed tersebut tanpa mengubah nada (no-chipmunk).
</span>
</div>
</div>
);
return (
<div className="min-h-screen flex flex-col p-4 md:p-8 items-center bg-slate-50 dark:bg-[#0B1120] transition-colors duration-300 relative pb-24">
{/* --- API KEY MODAL --- */}
{showSettings && (
<div className="fixed inset-0 bg-slate-900/80 backdrop-blur-sm z-[100] flex items-center justify-center p-4">
<div className="bg-white dark:bg-slate-800 rounded-2xl max-w-md w-full p-6 shadow-2xl animate-slide-up border border-emerald-500/30">
<div className="w-12 h-12 bg-emerald-500/20 text-emerald-500 rounded-full flex items-center justify-center mb-4 mx-auto text-xl">
<i className="fas fa-key"></i>
</div>
<h2 className="text-xl font-bold text-slate-800 dark:text-white text-center mb-2">Akses DIGIVOICE</h2>
<p className="text-xs text-slate-600 dark:text-slate-400 text-center mb-6 leading-relaxed">
Untuk menggunakan seluruh fitur AI Neural Audio Engine, silakan masukkan Gemini API Key Anda.
</p>
<div className="space-y-4">
<div>
<label className="text-[10px] font-bold text-slate-500 dark:text-slate-400 uppercase tracking-widest">Gemini API Key</label>
<input
type="password"
value={tempApiKey}
onChange={(e) => setTempApiKey(e.target.value)}
placeholder="AIzaSy..."
className="w-full mt-1.5 bg-slate-50 dark:bg-slate-900 border border-slate-300 dark:border-slate-700 rounded-xl p-3.5 text-sm text-slate-800 dark:text-slate-200 focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 outline-none transition-all"
/>
</div>
<div className="bg-emerald-50 dark:bg-emerald-900/20 p-3.5 rounded-xl border border-emerald-100 dark:border-emerald-800/30 flex items-start gap-3">
<i className="fas fa-info-circle text-emerald-600 dark:text-emerald-400 mt-0.5"></i>
<div className="text-xs text-emerald-800 dark:text-emerald-200 leading-relaxed">
Belum punya API Key? Ambil secara gratis melalui Google AI Studio.<br/>
<a href="https://aistudio.google.com/app/apikey" target="_blank" className="font-bold underline mt-1 inline-block hover:text-emerald-600 transition-colors">Ambil API Key Gratis Disini →</a>
</div>
</div>
<div className="flex gap-3 mt-6 pt-2 border-t border-slate-100 dark:border-slate-700">
{apiKey && (
<button onClick={() => setShowSettings(false)} className="flex-1 py-3 rounded-xl font-bold text-xs bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-600 transition-all">
Batal
</button>
)}
<button onClick={saveApiKey} className="flex-[2] py-3 rounded-xl font-bold text-xs bg-emerald-600 text-white hover:bg-emerald-500 transition-all shadow-lg shadow-emerald-500/30 flex items-center justify-center gap-2">
<i className="fas fa-save"></i>
SIMPAN & MULAI
</button>
</div>
</div>
</div>
</div>
)}
{/* --- HEADER --- */}
<div className="max-w-6xl w-full mb-8 flex flex-row items-center justify-between gap-6 relative z-50">
<div className="pl-1">
<h1 className="text-2xl font-black italic tracking-tighter bg-clip-text text-transparent bg-gradient-to-r from-emerald-600 to-teal-600 dark:from-emerald-400 dark:to-teal-400">
DIGI<span className="text-slate-800 dark:text-slate-100 not-italic font-light">VOICE</span>
</h1>
<p className="text-[10px] text-slate-500 dark:text-slate-400 font-mono tracking-widest mt-1">AI NEURAL AUDIO ENGINE</p>
</div>
<div className="flex items-center gap-3 relative">
{/* BUTTON PENGATURAN API KEY */}
<button onClick={() => { setTempApiKey(apiKey); setShowSettings(true); }} className="w-10 h-10 rounded-xl bg-white dark:bg-slate-800/80 border border-slate-200 dark:border-slate-700/50 flex items-center justify-center text-slate-600 dark:text-slate-400 hover:text-emerald-500 dark:hover:text-emerald-400 transition-all shadow-sm" title="Pengaturan API Key">
<i className="fas fa-key text-sm"></i>
</button>
<button onClick={() => setIsDarkMode(!isDarkMode)} className="w-10 h-10 rounded-xl bg-white dark:bg-slate-800/80 border border-slate-200 dark:border-slate-700/50 flex items-center justify-center text-slate-600 dark:text-slate-400 hover:text-emerald-500 dark:hover:text-emerald-400 transition-all shadow-sm" title="Ubah Tema">
<i className={`fas ${isDarkMode ? 'fa-sun' : 'fa-moon'} text-sm`}></i>
</button>
<button onClick={() => setIsMenuOpen(!isMenuOpen)} className="w-10 h-10 rounded-xl bg-emerald-600 text-white shadow-lg shadow-emerald-500/30 flex items-center justify-center transition-all active:scale-95 z-50 relative">
<i className={`fas ${isMenuOpen ? 'fa-times' : 'fa-bars'} text-lg`}></i>
</button>
{isMenuOpen && (
<div className="absolute top-full right-0 mt-3 w-64 bg-white dark:bg-slate-800 rounded-xl shadow-2xl border border-slate-100 dark:border-slate-700 overflow-hidden z-40 animate-slide-down origin-top-right">
<div className="p-1.5 flex flex-col gap-1">
<button onClick={() => { setCurrentView('tts'); setIsMenuOpen(false); }} className={`w-full text-left px-4 py-3 rounded-lg text-xs font-bold transition-all flex items-center gap-3 ${currentView === 'tts' ? 'bg-emerald-50 dark:bg-emerald-500/20 text-emerald-600 dark:text-emerald-400' : 'text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-700/50'}`}>
<div className={`w-8 h-8 rounded-lg flex items-center justify-center ${currentView === 'tts' ? 'bg-emerald-600 text-white' : 'bg-slate-200 dark:bg-slate-700 text-slate-500'}`}><i className="fas fa-microphone-lines text-xs"></i></div>
<div><div className="font-bold">TEXT TO SPEECH</div><div className="text-[9px] opacity-70 font-normal">Buat suara dari teks</div></div>
</button>
<button onClick={() => { setCurrentView('idea'); setIsMenuOpen(false); }} className={`w-full text-left px-4 py-3 rounded-lg text-xs font-bold transition-all flex items-center gap-3 ${currentView === 'idea' ? 'bg-teal-50 dark:bg-teal-500/20 text-teal-600 dark:text-teal-400' : 'text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-700/50'}`}>
<div className={`w-8 h-8 rounded-lg flex items-center justify-center ${currentView === 'idea' ? 'bg-teal-600 text-white' : 'bg-slate-200 dark:bg-slate-700 text-slate-500'}`}><i className="fas fa-lightbulb text-xs"></i></div>
<div><div className="font-bold">IDEA TO SCRIPT</div><div className="text-[9px] opacity-70 font-normal">Generasi ide konten</div></div>
</button>
<button onClick={() => { setCurrentView('photo'); setIsMenuOpen(false); }} className={`w-full text-left px-4 py-3 rounded-lg text-xs font-bold transition-all flex items-center gap-3 ${currentView === 'photo' ? 'bg-pink-50 dark:bg-pink-500/20 text-pink-600 dark:text-pink-400' : 'text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-700/50'}`}>
<div className={`w-8 h-8 rounded-lg flex items-center justify-center ${currentView === 'photo' ? 'bg-pink-600 text-white' : 'bg-slate-200 dark:bg-slate-700 text-slate-500'}`}><i className="fas fa-camera text-xs"></i></div>
<div><div className="font-bold">PHOTO TO SCRIPT</div><div className="text-[9px] opacity-70 font-normal">Analisa foto produk</div></div>
</button>
</div>
</div>
)}
</div>
</div>
{/* --- MAIN CONTENT --- */}
<div className="max-w-6xl w-full z-10">
{currentView === 'tts' && (
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 animate-slide-down">
{/* LEFT: CONTROLS */}
<div className="lg:col-span-4 space-y-4">
<div className="glass-panel rounded-xl overflow-hidden transition-all duration-300">
<button onClick={() => toggleSection('voice')} className="w-full p-4 flex items-center justify-between bg-slate-50/50 dark:bg-slate-800/50 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-emerald-500/10 dark:bg-emerald-500/20 text-emerald-600 dark:text-emerald-400 flex items-center justify-center"><i className="fas fa-microphone-lines"></i></div>
<div className="text-left"><div className="text-xs font-bold text-slate-700 dark:text-slate-300 uppercase">Voice Talent</div><div className="text-[10px] text-slate-500">{VOICE_OPTIONS.find(v => v.id === selectedVoice)?.name}</div></div>
</div>
<i className={`fas fa-chevron-down text-slate-400 dark:text-slate-500 transition-transform duration-300 ${uiState.voice ? 'rotate-180' : ''}`}></i>
</button>
{uiState.voice && (
<div className="p-2 space-y-1 max-h-[350px] overflow-y-auto scrollbar-hide border-t border-slate-200 dark:border-slate-700/50 animate-slide-down bg-slate-50/40 dark:bg-slate-900/40">
{VOICE_OPTIONS.map(voice => (
<div key={voice.id} className={`w-full p-2.5 rounded-lg flex items-center gap-3 transition-all relative group cursor-pointer ${selectedVoice === voice.id ? 'bg-emerald-600 text-white shadow-lg' : 'hover:bg-slate-100 dark:hover:bg-slate-800 text-slate-600 dark:text-slate-400'}`} onClick={() => setSelectedVoice(voice.id)}>
<div className="w-8 h-8 rounded-full bg-slate-200 dark:bg-slate-800 flex items-center justify-center shrink-0 border border-slate-300 dark:border-slate-600 text-slate-600 dark:text-slate-300 font-bold text-[10px]">{voice.gender === 'Male' ? 'M' : 'F'}</div>
<div className="text-left flex-1 min-w-0"><div className="flex items-center justify-between"><span className="font-bold text-sm truncate">{voice.name}</span><span className="text-[9px] opacity-70 px-1.5 py-0.5 rounded-full bg-slate-200 dark:bg-slate-800/50 text-slate-600 dark:text-slate-300">{voice.style}</span></div><p className="text-[10px] opacity-60 truncate">{voice.desc}</p></div>
{/* PREVIEW BUTTON */}
<div onClick={(e) => handlePreview(e, voice.id)} className={`w-9 h-9 rounded-full flex items-center justify-center transition-all z-20 shrink-0 border border-transparent hover:border-emerald-300 dark:hover:border-emerald-600 ${previewState.playingVoiceId === voice.id ? 'bg-white text-emerald-600 shadow-xl scale-110 border-emerald-500' : 'bg-slate-200/50 dark:bg-slate-800/50 hover:bg-emerald-100 dark:hover:bg-emerald-900 text-slate-500 dark:text-slate-400 hover:text-emerald-600 dark:hover:text-emerald-300'}`} title="Preview Suara Ini">
{previewState.playingVoiceId === voice.id && previewState.isLoading ? <i className="fas fa-circle-notch fa-spin text-xs"></i> : previewState.playingVoiceId === voice.id ? <i className="fas fa-stop text-xs"></i> : <i className="fas fa-play text-xs pl-0.5"></i>}
</div>
</div>
))}
</div>
)}
</div>
{/* DELIVERY STYLE SELECTOR WITH INTENSITY SLIDER */}
<div className="glass-panel rounded-xl overflow-hidden transition-all duration-300">
<button onClick={() => toggleSection('style')} className="w-full p-4 flex items-center justify-between bg-slate-50/50 dark:bg-slate-800/50 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors">
<div className="flex items-center gap-3"><div className="w-8 h-8 rounded-lg bg-cyan-500/10 dark:bg-cyan-500/20 text-cyan-600 dark:text-cyan-400 flex items-center justify-center"><i className="fas fa-sliders"></i></div><div className="text-left"><div className="text-xs font-bold text-slate-700 dark:text-slate-300 uppercase">Delivery Style</div><div className="text-[10px] text-slate-500">{DELIVERY_STYLES.find(s => s.id === selectedStyle)?.name}</div></div></div><i className={`fas fa-chevron-down text-slate-400 dark:text-slate-500 transition-transform duration-300 ${uiState.style ? 'rotate-180' : ''}`}></i>
</button>
{uiState.style && (
<div className="p-2 grid grid-cols-1 gap-1 max-h-[450px] overflow-y-auto scrollbar-hide border-t border-slate-200 dark:border-slate-700/50 animate-slide-down bg-slate-50/40 dark:bg-slate-900/40">
{DELIVERY_STYLES.map(style => (
<div key={style.id} className={`w-full p-1 rounded-lg transition-all ${selectedStyle === style.id ? 'bg-white dark:bg-slate-800 shadow-md border border-cyan-500/30' : ''}`}>
{/* Style Button */}
<button
onClick={() => setSelectedStyle(style.id)}
className={`w-full p-3 rounded-lg flex items-center gap-3 transition-all ${selectedStyle === style.id ? 'bg-cyan-600 text-white shadow-lg' : 'hover:bg-slate-100 dark:hover:bg-slate-700/50 text-slate-600 dark:text-slate-400'}`}
>
<i className={`fas ${style.icon} w-5 text-center`}></i>
<span className="font-medium text-sm flex-1 text-left">{style.name}</span>
{selectedStyle === style.id && <span className="text-[9px] bg-white/20 px-1.5 py-0.5 rounded font-mono font-bold">{styleIntensity}/10</span>}
</button>
{/* Intensity Slider - Only shown when selected */}
{selectedStyle === style.id && (
<div className="mt-2 mb-1 px-3 py-2 bg-slate-50 dark:bg-slate-900/50 rounded-lg animate-slide-down border border-slate-100 dark:border-slate-700/50">
<div className="flex justify-between items-center mb-1.5">
<span className="text-[10px] font-bold text-cyan-600 dark:text-cyan-400 uppercase tracking-wider">Level Intensitas</span>
<span className="text-[10px] font-mono text-slate-500 dark:text-slate-400">{styleIntensity === 0 ? "Normal" : styleIntensity === 10 ? "Ekstrem" : `${styleIntensity * 10}%`}</span>
</div>
<input
type="range"
min="0"
max="10"
step="1"
value={styleIntensity}
onChange={(e) => setStyleIntensity(Number(e.target.value))}
className="w-full intensity-range"
/>
<div className="flex justify-between mt-1">
<div className="text-[9px] text-slate-400">0 (Normal)</div>
<div className="text-[9px] text-slate-400">10 (Maksimal)</div>
</div>
</div>
)}
</div>
))}
</div>
)}
</div>
{/* SPEED CALIBRATION BOX */}
<div className="glass-panel-speed rounded-xl overflow-hidden transition-all duration-300 shadow-lg border-amber-500/30">
<div className="w-full p-5 space-y-2 border-b border-amber-500/10">
{renderSpeedControl()}
</div>
</div>
<div className="glass-panel rounded-xl overflow-hidden transition-all duration-300">
<button onClick={() => toggleSection('control')} className="w-full p-4 flex items-center justify-between bg-slate-50/50 dark:bg-slate-800/50 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors">
<div className="flex items-center gap-3"><div className="w-8 h-8 rounded-lg bg-emerald-500/10 dark:bg-emerald-500/20 text-emerald-600 dark:text-emerald-400 flex items-center justify-center"><i className="fas fa-wave-square"></i></div><div className="text-left"><div className="text-xs font-bold text-slate-700 dark:text-slate-300 uppercase">Other Controls</div><div className="text-[10px] text-slate-500">Pitch, Weight, Volume</div></div></div><i className={`fas fa-chevron-down text-slate-400 dark:text-slate-500 transition-transform duration-300 ${uiState.control ? 'rotate-180' : ''}`}></i>
</button>
{uiState.control && <div className="p-5 space-y-6 border-t border-slate-200 dark:border-slate-700/50 animate-slide-down bg-slate-50/40 dark:bg-slate-900/40">
{renderKnob("Pitch", pitch, setPitch, -5, 5, "Deep", "High")}
{renderKnob("Weight", weight, setWeight, -5, 5, "Thin", "Heavy")}
{renderKnob("Volume", volume, setVolume, -5, 5, "Soft", "Loud")}
{renderKnob("Articulation", articulation, setArticulation, -5, 5, "Relaxed", "Sharp")}
</div>}
</div>
</div>
{/* RIGHT: EDITOR */}
<div className="lg:col-span-8 flex flex-col h-full space-y-4">
<div className="flex gap-2 overflow-x-auto scrollbar-hide">
{[{ label: "Mendesah", code: "(mendesah)" }, { label: "Tertawa", code: "(tertawa)" }, { label: "Sedih", code: "(sedih)" }, { label: "Batuk", code: "(batuk)" }, { label: "Marah", code: "(marah)" }].map(tag => (
<button
key={tag.code}
onClick={() => {
const tagCode = tag.code;
const textarea = textareaRef.current;
if (textarea) {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const currentText = text;
const before = currentText.substring(0, start);
const after = currentText.substring(end);
const newText = before + (before.length > 0 && !before.endsWith(' ') ? ' ' : '') + tagCode + (after.length > 0 && !after.startsWith(' ') ? ' ' : '') + after;
setText(newText);
setTimeout(() => {
textarea.focus();
const finalPos = before.length + (before.length > 0 && !before.endsWith(' ') ? 1 : 0) + tagCode.length;
textarea.setSelectionRange(finalPos, finalPos);
}, 0);
} else {
setText(prev => prev + (prev.length > 0 && !prev.endsWith(' ') ? ' ' : '') + tagCode);
}
}}
className="px-3 py-1.5 rounded-full bg-emerald-600 text-[10px] font-bold text-white hover:bg-emerald-500 transition-colors shadow-sm border border-transparent"
>
+ {tag.label}
</button>
))}
</div>
<div className="flex-1 glass-panel rounded-2xl p-1 relative flex flex-col group focus-within:ring-2 focus-within:ring-emerald-500/50 transition-all">
<div className="absolute top-0 left-0 right-0 h-10 bg-slate-50/80 dark:bg-slate-800/50 rounded-t-2xl flex items-center justify-between px-4 border-b border-slate-200 dark:border-white/5"><span className="text-[10px] font-bold text-slate-500 uppercase tracking-widest">Director's Script Editor</span><span className="text-[9px] text-emerald-500 font-medium">Gunakan (...) di akhir baris untuk efek suara</span></div>
<textarea
ref={textareaRef}
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Ketik naskah Anda di sini... Contoh: Halo apa kabar? Ini rahasia lho (berbisik)"
className="w-full flex-1 bg-transparent border-none text-slate-800 dark:text-slate-200 p-6 pt-14 focus:ring-0 outline-none resize-none text-lg leading-relaxed placeholder-slate-400 dark:placeholder-slate-600 font-light"
/>
<div className="absolute bottom-2 left-2 right-2 flex items-center justify-between gap-2">
<button onClick={fixArticulation} disabled={articulationProcessing || !text} className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-[10px] font-bold uppercase tracking-wider transition-all backdrop-blur-md border shadow-sm ${articulationProcessing ? 'bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400 border-amber-200 dark:border-amber-700 cursor-wait' : 'bg-white/80 dark:bg-slate-800/80 text-emerald-600 dark:text-emerald-400 border-slate-200 dark:border-slate-700 hover:bg-emerald-50 dark:hover:bg-slate-700 hover:scale-[1.02]'}`}>{articulationProcessing ? <><i className="fas fa-circle-notch fa-spin"></i><span>Fixing...</span></> : <><i className="fas fa-wand-magic-sparkles"></i><span>AI Articulation Fixer</span></>}</button>
<div className="text-[10px] font-mono text-slate-500 dark:text-slate-600 bg-slate-100 dark:bg-slate-900/80 px-2 py-1.5 rounded">{text.length} Chars</div>
</div>
</div>
<div className="glass-panel rounded-xl p-4 flex items-center justify-between transition-all border border-blue-500/20 bg-blue-50/50 dark:bg-blue-900/10">
<div className="flex items-center gap-3">
<div className={`w-8 h-8 rounded-lg flex items-center justify-center transition-colors ${autoDetectLanguage ? 'bg-blue-500/20 text-blue-600 dark:text-blue-400' : 'bg-slate-200 dark:bg-slate-700 text-slate-500 dark:text-slate-400'}`}>
<i className="fas fa-language"></i>
</div>
<div>
<div className="text-xs font-bold text-slate-700 dark:text-slate-300 uppercase">Language Detective</div>
<div className="text-[10px] text-slate-500">Deteksi bahasa otomatis (Inggris, Jepang, Mandarin, dll)</div>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input type="checkbox" checked={autoDetectLanguage} onChange={(e) => setAutoDetectLanguage(e.target.checked)} className="sr-only peer" />
<div className="w-11 h-6 bg-slate-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-slate-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
</label>
</div>
<div className="glass-panel rounded-xl p-4 flex flex-col md:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-3"><div className={`w-8 h-8 rounded-lg flex items-center justify-center transition-colors ${optimizationMode === 'optimized' ? 'bg-purple-500/10 dark:bg-purple-500/20 text-purple-600 dark:text-purple-400' : 'bg-slate-200 dark:bg-slate-700 text-slate-500 dark:text-slate-400'}`}><i className={`fas ${optimizationMode === 'optimized' ? 'fa-wand-magic-sparkles' : 'fa-file-lines'}`}></i></div><div><div className="text-xs font-bold text-slate-700 dark:text-slate-300 uppercase">Director Mode</div><div className="text-[10px] text-slate-500">{optimizationMode === 'optimized' ? 'AI akan memproses tag (berbisik), dll.' : 'Baca mentah (Raw Text)'}</div></div></div>
<div className="flex bg-slate-100 dark:bg-slate-900/50 p-1 rounded-lg"><button onClick={() => setOptimizationMode("original")} className={`px-4 py-2 rounded-md text-xs font-bold transition-all ${optimizationMode === "original" ? "bg-slate-600 text-white shadow-lg" : "text-slate-500 dark:text-slate-400"}`}>Raw</button><button onClick={() => setOptimizationMode("optimized")} className={`px-4 py-2 rounded-md text-xs font-bold transition-all flex items-center gap-2 ${optimizationMode === "optimized" ? "bg-purple-600 text-white shadow-lg" : "text-slate-500 dark:text-slate-400"}`}><i className="fas fa-magic text-[10px]"></i>Smart Director</button></div>
</div>
<div className="flex items-center gap-4">
<button onClick={generateAudio} disabled={processing.isLoading || !text} className={`flex-1 py-4 rounded-xl font-bold text-sm tracking-wide shadow-xl transition-all relative overflow-hidden group ${processing.isLoading || !text ? "bg-slate-200 dark:bg-slate-800 text-slate-400 dark:text-slate-600 cursor-not-allowed" : "bg-emerald-600 text-white hover:bg-emerald-500 hover:shadow-emerald-500/25 active:scale-[0.99]"}`}>{processing.isLoading && <div className={`absolute inset-0 opacity-20 ${processing.step === 'baking' ? 'loading-gradient-amber' : 'loading-gradient'}`}></div>}<div className="relative flex items-center justify-center gap-2">{processing.isLoading ? <><i className="fas fa-circle-notch fa-spin"></i><span>{processing.step === 'analyzing' ? 'DIRECTING SCENE...' : processing.step === 'baking' ? 'CALIBRATING SPEED & ENCODING MP3...' : 'SYNTHESIZING VOICE...'}</span></> : <><i className="fas fa-play"></i><span>GENERATE VOICE OVER</span></>}</div></button>
</div>
{processing.error && <div className="p-4 rounded-xl bg-red-500/10 border border-red-500/20 text-red-500 dark:text-red-400 text-xs flex items-center gap-3 animate-slide-down"><i className="fas fa-exclamation-triangle"></i>{processing.error}</div>}
{result.previewUrl && !processing.isLoading && <div className="glass-panel rounded-2xl p-6 space-y-6 animate-slide-down border-t-2 border-emerald-500">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-full bg-emerald-500 flex items-center justify-center text-white shadow-lg shadow-emerald-500/40 animate-pulse"><i className="fas fa-music"></i></div>
<div className="flex-1">
<audio ref={mainAudioRef} controls src={result.previewUrl} className="w-full h-10 invert-[.9] dark:invert-0" />
<div className="flex justify-between mt-1 text-[10px] text-slate-400">
<span>Preview Speed: {speed.toFixed(1)}x</span>
<span>Download Speed: {result.downloadSpeed.toFixed(1)}x (MP3)</span>
</div>
</div>
{result.downloadUrl && (
<a href={result.downloadUrl} download={`DIGIVOICE_${selectedVoice}_${result.downloadSpeed}x_${Date.now()}.mp3`} className="h-10 px-4 rounded-lg bg-amber-500 hover:bg-amber-600 text-white shadow-lg hover:shadow-amber-500/30 text-xs font-bold flex items-center gap-2 transition-all">
<div className="flex flex-col items-end leading-none">
<span>DOWNLOAD</span>
<span className="text-[9px] opacity-80">{result.downloadSpeed.toFixed(1)}x MP3</span>
</div>
<i className="fas fa-download"></i>
</a>
)}
</div>
{result.refinedScript && result.modeUsed === 'optimized' && <div className="bg-slate-50 dark:bg-slate-900/50 rounded-xl p-4 border border-slate-200 dark:border-slate-700/50"><div className="flex items-center justify-between mb-3"><span className="text-[10px] font-bold text-slate-500 uppercase">Arahan Director (AI Instructions)</span><div className="flex gap-1"><span className="text-[9px] bg-purple-500/10 dark:bg-purple-500/20 text-purple-600 dark:text-purple-300 px-1.5 py-0.5 rounded">Processed</span></div></div><p className="text-sm text-slate-700 dark:text-slate-400 italic font-serif leading-relaxed opacity-80 border-l-2 border-purple-500 pl-3 whitespace-pre-line">"{result.refinedScript}"</p></div>}
</div>}
</div>
</div>
)}
{currentView === 'idea' && (
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 animate-slide-down">
<div className="lg:col-span-5 space-y-4">
<div className="glass-panel-accent rounded-xl p-6 border-t-2 border-teal-500/50">
<div className="mb-6 flex items-center gap-3"><div className="w-10 h-10 rounded-xl bg-teal-500/10 dark:bg-teal-500/20 text-teal-600 dark:text-teal-400 flex items-center justify-center"><i className="fas fa-brain"></i></div><div><h2 className="text-lg font-bold text-slate-800 dark:text-white">Idea Generator</h2><p className="text-xs text-slate-500 dark:text-slate-400">Deskripsikan produk, kami buatkan naskahnya.</p></div></div>
<div className="space-y-5">
<div className="space-y-2"><label className="text-xs font-bold text-slate-600 dark:text-slate-300 uppercase">Tentang Produk / Layanan</label><textarea value={ideaForm.description} onChange={(e) => setIdeaForm({...ideaForm, description: e.target.value})} placeholder="Contoh: Kopi bubuk premium arabika gayo, packaging praktis..." className="w-full h-24 bg-white/50 dark:bg-slate-900/50 border border-slate-300 dark:border-slate-700 rounded-lg p-3 text-sm text-slate-800 dark:text-slate-200 focus:border-teal-500 focus:ring-1 focus:ring-teal-500 outline-none resize-none placeholder-slate-400 dark:placeholder-slate-600" /></div>
<div className="space-y-2"><div className="flex justify-between items-end"><label className="text-xs font-bold text-slate-600 dark:text-slate-300 uppercase">USP (Keunggulan Utama)</label><button onClick={generateUSP} disabled={!ideaForm.description || uspProcessing} className="text-[10px] bg-teal-100 dark:bg-teal-900/30 text-teal-700 dark:text-teal-400 px-2 py-1 rounded hover:bg-teal-200 dark:hover:bg-teal-900/50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5 font-semibold">{uspProcessing ? <i className="fas fa-circle-notch fa-spin text-[9px]"></i> : <i className="fas fa-wand-magic-sparkles text-[9px]"></i>}{uspProcessing ? "Menganalisis..." : "Biar AI yang buatin"}</button></div><input type="text" value={ideaForm.usp} onChange={(e) => setIdeaForm({...ideaForm, usp: e.target.value})} placeholder="Contoh: Tanpa ampas, seduh 3 detik, promo beli 1 gratis 1" className="w-full bg-white/50 dark:bg-slate-900/50 border border-slate-300 dark:border-slate-700 rounded-lg p-3 text-sm text-slate-800 dark:text-slate-200 focus:border-teal-500 focus:ring-1 focus:ring-teal-500 outline-none placeholder-slate-400 dark:placeholder-slate-600" /></div>
<div className="space-y-2"><label className="text-xs font-bold text-slate-600 dark:text-slate-300 uppercase">Jenis Konten</label><select value={ideaForm.contentType} onChange={(e) => setIdeaForm({...ideaForm, contentType: e.target.value})} className="w-full bg-white/50 dark:bg-slate-900/50 border border-slate-300 dark:border-slate-700 rounded-lg p-3 text-sm text-slate-800 dark:text-slate-200 focus:border-teal-500 outline-none appearance-none"><option>Iklan Media Sosial (TikTok/Reels)</option><option>Iklan Radio / Spotify</option><option>Video Penjelasan Produk (Explainer)</option><option>Narasi Dokumenter Pendek</option><option>Soft Selling Storytelling</option><option>YouTube Educational (Edukasi)</option><option>YouTube Unboxing & Review</option></select></div>
<div className="space-y-2">
<label className="text-xs font-bold text-slate-600 dark:text-slate-300 uppercase">Bahasa Target</label>
<select value={ideaForm.language} onChange={(e) => setIdeaForm({...ideaForm, language: e.target.value})} className="w-full bg-white/50 dark:bg-slate-900/50 border border-slate-300 dark:border-slate-700 rounded-lg p-3 text-sm text-slate-800 dark:text-slate-200 focus:border-teal-500 outline-none appearance-none">
<option>Bahasa Indonesia</option>
<option>Bahasa Melayu (Malaysia)</option>
<option>English (Inggris)</option>
<option>English (Singapore)</option>
<option>Japanese (Jepang)</option>
<option>Mandarin (China)</option>
<option>Thai (Thailand)</option>
<option>Vietnamese (Vietnam)</option>
<option>Tagalog (Filipina)</option>
<option>Korean (Korea)</option>
<option>Hindi (India)</option>
</select>
</div>
<div className="space-y-2"><label className="text-xs font-bold text-slate-600 dark:text-slate-300 uppercase">Jumlah Variasi Script</label><div className="flex bg-white/50 dark:bg-slate-900/50 p-1 rounded-lg border border-slate-300 dark:border-slate-700">{[1, 3, 5].map(num => <button key={num} onClick={() => setIdeaForm({...ideaForm, count: num})} className={`flex-1 py-2 rounded-md text-xs font-bold transition-all ${ideaForm.count === num ? "bg-teal-600 text-white shadow" : "text-slate-500 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-200"}`}>{num} Script</button>)}</div></div>
<button onClick={generateIdeas} disabled={ideaProcessing || !ideaForm.description || !ideaForm.usp} className={`w-full py-3.5 rounded-xl font-bold text-sm tracking-wide shadow-lg transition-all relative overflow-hidden mt-2 ${ideaProcessing || !ideaForm.description || !ideaForm.usp ? "bg-slate-200 dark:bg-slate-800 text-slate-400 dark:text-slate-600 cursor-not-allowed" : "bg-teal-600 text-white hover:bg-teal-500 hover:shadow-teal-500/25 active:scale-[0.99]"}`}>{ideaProcessing && <div className="absolute inset-0 loading-gradient-teal opacity-20"></div>}<span className="relative flex items-center justify-center gap-2">{ideaProcessing ? <><i className="fas fa-circle-notch fa-spin"></i>MEMBUAT KONSEP...</> : <><i className="fas fa-pen-nib"></i>BUAT SCRIPT</>}</span></button>
</div>
</div>
</div>
<div className="lg:col-span-7 flex flex-col h-full">
{generatedIdeas.length > 0 ? (
<div className="space-y-4 animate-slide-down"><div className="flex items-center justify-between"><h3 className="text-sm font-bold text-slate-600 dark:text-slate-300 uppercase tracking-wide">Hasil Generasi ({generatedIdeas.length})</h3><button onClick={() => setGeneratedIdeas([])} className="text-xs text-slate-500 hover:text-red-400"><i className="fas fa-trash mr-1"></i> Clear</button></div><div className="grid gap-4 max-h-[600px] overflow-y-auto pr-2 scrollbar-hide">{generatedIdeas.map((script, idx) => <div key={idx} className="glass-panel p-5 rounded-xl border border-slate-200 dark:border-slate-700/50 hover:border-teal-500/30 transition-all group"><div className="flex justify-between items-start mb-3"><span className="bg-slate-200 dark:bg-slate-800 text-teal-600 dark:text-teal-400 text-[10px] font-bold px-2 py-1 rounded">VARIASI #{idx + 1}</span></div><p className="text-slate-700 dark:text-slate-300 text-sm leading-relaxed mb-4 font-light">{script}</p><button onClick={() => { setText(script); setCurrentView('tts'); }} className="w-full py-2 bg-slate-100 dark:bg-slate-800 hover:bg-emerald-600 text-slate-500 dark:text-slate-400 hover:text-white rounded-lg text-xs font-bold transition-all flex items-center justify-center gap-2 group-hover:bg-slate-200 dark:group-hover:bg-slate-700 group-hover:text-emerald-600 dark:group-hover:text-white"><span>Gunakan Script Ini</span><i className="fas fa-arrow-right"></i></button></div>)}</div></div>
) : <div className="h-full glass-panel rounded-xl flex flex-col items-center justify-center p-8 text-center opacity-60"><div className="w-16 h-16 rounded-full bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 dark:text-slate-600 text-2xl mb-4"><i className="fas fa-lightbulb"></i></div><h3 className="text-lg font-bold text-slate-500 dark:text-slate-400">Belum ada script</h3><p className="text-sm text-slate-500 max-w-xs mt-2">Isi detail produk di panel kiri dan klik "Buat Script" untuk melihat variasi ide konten Anda di sini.</p></div>}
</div>
</div>
)}
{currentView === 'photo' && (
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 animate-slide-down">
<div className="lg:col-span-5 space-y-4">
<div className="glass-panel-photo rounded-xl p-6 border-t-2 border-pink-500/50">
<div className="mb-6 flex items-center gap-3"><div className="w-10 h-10 rounded-xl bg-pink-500/10 dark:bg-pink-500/20 text-pink-600 dark:text-pink-400 flex items-center justify-center"><i className="fas fa-camera"></i></div><div><h2 className="text-lg font-bold text-slate-800 dark:text-white">Photo Analyzer</h2><p className="text-xs text-slate-500 dark:text-slate-400">Upload foto produk, AI akan membuatkan naskah.</p></div></div>
<div className="space-y-5">
<div onClick={() => fileInputRef.current?.click()} className={`border-2 border-dashed rounded-xl h-36 flex flex-col items-center justify-center cursor-pointer transition-all group relative overflow-hidden ${photoForm.imageData ? 'border-pink-500 bg-pink-50 dark:bg-pink-900/10' : 'border-slate-300 dark:border-slate-700 hover:border-pink-400 hover:bg-slate-50 dark:hover:bg-slate-800/50'}`}><input type="file" ref={fileInputRef} onChange={handleImageUpload} accept="image/*" className="hidden" />{photoForm.imageData ? <><img src={`data:${photoForm.mimeType || 'image/jpeg'};base64,${photoForm.imageData}`} className="absolute inset-0 w-full h-full object-cover opacity-60" /><div className="z-10 bg-black/50 p-2 rounded-lg text-white text-xs flex items-center gap-2"><i className="fas fa-check"></i>{photoForm.imageName}</div><div className="absolute bottom-2 text-[10px] text-white/80 bg-black/30 px-2 rounded">Klik untuk ganti foto</div></> : <div className="text-center p-4"><div className="w-10 h-10 bg-slate-100 dark:bg-slate-800 rounded-full flex items-center justify-center mx-auto mb-3 text-slate-400 group-hover:text-pink-500 transition-colors"><i className="fas fa-cloud-upload-alt text-lg"></i></div><p className="text-xs font-bold text-slate-600 dark:text-slate-300">Upload Foto Produk</p></div>}</div>
<div className="space-y-2">
<label className="text-xs font-bold text-slate-600 dark:text-slate-300 uppercase">Gaya Script / Konten</label>
<div className="grid grid-cols-2 gap-2">
{[
{ label: "TikTok Affiliate", val: "TikTok Affiliate" },
{ label: "Edukasi / Tips", val: "Edukasi" },
{ label: "Konten Iklan", val: "Konten Iklan" },
{ label: "YouTube Content", val: "YouTube Content" },
{ label: "Unboxing Review", val: "Unboxing Review" }
].map((opt) => (
<button key={opt.val} onClick={() => setPhotoForm({...photoForm, contentType: opt.val})} className={`p-2 rounded-lg text-[10px] font-bold border transition-all text-left truncate ${photoForm.contentType === opt.val ? "bg-pink-100 dark:bg-pink-900/30 border-pink-500 text-pink-700 dark:text-pink-300" : "bg-white/50 dark:bg-slate-900/50 border-transparent hover:bg-slate-100 dark:hover:bg-slate-800 text-slate-500 dark:text-slate-400"}`}>
{opt.label}
</button>
))}
</div>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-slate-600 dark:text-slate-300 uppercase">Bahasa Script</label>
<select value={photoForm.language} onChange={(e) => setPhotoForm({...photoForm, language: e.target.value})} className="w-full bg-white/50 dark:bg-slate-900/50 border border-slate-300 dark:border-slate-700 rounded-lg p-3 text-sm text-slate-800 dark:text-slate-200 focus:border-pink-500 outline-none appearance-none">
<option>Bahasa Indonesia</option>
<option>English (International)</option>
<option>English (Singapore)</option>
<option>English (Australia)</option>
<option>Japanese (Jepang)</option>
<option>Thai (Thailand)</option>
<option>Tagalog (Filipina)</option>
<option>Vietnamese (Vietnam)</option>
<option>Arabic (Arab)</option>
<option>Chinese (Mandarin)</option>
</select>
</div>
<div className="space-y-2"><label className="text-xs font-bold text-slate-600 dark:text-slate-300 uppercase flex items-center gap-2">Target Market <span className="text-[9px] bg-slate-200 dark:bg-slate-700 px-1.5 rounded-full text-slate-500">Auto-Tone</span></label><div className="grid grid-cols-2 gap-3"><div className="space-y-1"><label className="text-[10px] text-slate-500">Usia</label><select value={photoForm.targetAge} onChange={(e) => setPhotoForm({...photoForm, targetAge: e.target.value})} className="w-full bg-white/50 dark:bg-slate-900/50 border border-slate-300 dark:border-slate-700 rounded-lg p-2 text-xs text-slate-800 dark:text-slate-200 focus:border-pink-500 outline-none"><option>Gen Z (18 - 24 Tahun)</option><option>Millennials (25 - 40 Tahun)</option><option>Gen X (41 - 55 Tahun)</option><option>Boomers (55+ Tahun)</option></select></div><div className="space-y-1"><label className="text-[10px] text-slate-500">Gender</label><select value={photoForm.targetGender} onChange={(e) => setPhotoForm({...photoForm, targetGender: e.target.value})} className="w-full bg-white/50 dark:bg-slate-900/50 border border-slate-300 dark:border-slate-700 rounded-lg p-2 text-xs text-slate-800 dark:text-slate-200 focus:border-pink-500 outline-none"><option>Semua Gender</option><option>Wanita</option><option>Pria</option></select></div></div></div>
<div className="space-y-2"><label className="text-xs font-bold text-slate-600 dark:text-slate-300 uppercase">Jumlah Variasi</label><div className="flex bg-white/50 dark:bg-slate-900/50 p-1 rounded-lg border border-slate-300 dark:border-slate-700">{[1, 3, 5].map(num => <button key={num} onClick={() => setPhotoForm({...photoForm, count: num})} className={`flex-1 py-2 rounded-md text-xs font-bold transition-all ${photoForm.count === num ? "bg-pink-600 text-white shadow" : "text-slate-500 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-200"}`}>{num} Script</button>)}</div></div>
<button onClick={generateScriptsFromPhoto} disabled={photoProcessing || !photoForm.imageData} className={`w-full py-3.5 rounded-xl font-bold text-sm tracking-wide shadow-lg transition-all relative overflow-hidden mt-2 ${photoProcessing || !photoForm.imageData ? "bg-slate-200 dark:bg-slate-800 text-slate-400 dark:text-slate-600 cursor-not-allowed" : "bg-pink-600 text-white hover:bg-pink-500 hover:shadow-pink-500/25 active:scale-[0.99]"}`}>{photoProcessing && <div className="absolute inset-0 loading-gradient-pink opacity-20"></div>}<span className="relative flex items-center justify-center gap-2">{photoProcessing ? <><i className="fas fa-circle-notch fa-spin"></i>MENGANALISA FOTO...</> : <><i className="fas fa-magic"></i>BUAT SCRIPT DARI FOTO</>}</span></button>
</div>
</div>
</div>
<div className="lg:col-span-7 flex flex-col h-full">
{generatedPhotoScripts.length > 0 ? (
<div className="space-y-4 animate-slide-down"><div className="flex items-center justify-between"><h3 className="text-sm font-bold text-slate-600 dark:text-slate-300 uppercase tracking-wide">Hasil Analisa Foto ({generatedPhotoScripts.length})</h3><button onClick={() => setGeneratedPhotoScripts([])} className="text-xs text-slate-500 hover:text-red-400"><i className="fas fa-trash mr-1"></i> Clear</button></div><div className="grid gap-4 max-h-[600px] overflow-y-auto pr-2 scrollbar-hide">{generatedPhotoScripts.map((script, idx) => <div key={idx} className="glass-panel p-5 rounded-xl border border-slate-200 dark:border-slate-700/50 hover:border-pink-500/30 transition-all group"><div className="flex justify-between items-start mb-3"><span className="bg-pink-100 dark:bg-pink-900/30 text-pink-600 dark:text-pink-300 text-[10px] font-bold px-2 py-1 rounded border border-pink-200 dark:border-pink-800">OPSI #{idx + 1}</span></div><p className="text-slate-700 dark:text-slate-300 text-sm leading-relaxed mb-4 font-light border-l-2 border-pink-300 pl-3">{script}</p><button onClick={() => { setText(script); setCurrentView('tts'); }} className="w-full py-2 bg-slate-100 dark:bg-slate-800 hover:bg-emerald-600 text-slate-500 dark:text-slate-400 hover:text-white rounded-lg text-xs font-bold transition-all flex items-center justify-center gap-2 group-hover:bg-slate-200 dark:group-hover:bg-slate-700 group-hover:text-emerald-600 dark:group-hover:text-white"><span>Pakai Script Ini</span><i className="fas fa-arrow-right"></i></button></div>)}</div></div>
) : <div className="h-full glass-panel rounded-xl flex flex-col items-center justify-center p-8 text-center opacity-60"><div className="w-16 h-16 rounded-full bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 dark:text-slate-600 text-2xl mb-4"><i className="fas fa-images"></i></div><h3 className="text-lg font-bold text-slate-500 dark:text-slate-400">Preview Script</h3><p className="text-sm text-slate-500 max-w-xs mt-2">Upload foto di panel kiri untuk melihat script hasil analisa visual AI di sini.</p></div>}
</div>
</div>
)}
</div>
{/* --- FLOATING PREVIEW PLAYER --- */}
{previewState.playingVoiceId && (
<div className="fixed bottom-6 left-1/2 transform -translate-x-1/2 z-50 animate-slide-up">
<div className="bg-slate-900/90 dark:bg-emerald-900/90 backdrop-blur-md text-white rounded-full px-6 py-3 shadow-2xl flex items-center gap-4 border border-white/10">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-emerald-500 flex items-center justify-center animate-pulse-fast">
{previewState.isLoading ? <i className="fas fa-circle-notch fa-spin text-xs"></i> : <i className="fas fa-volume-high text-xs"></i>}
</div>
<div className="flex flex-col">
<span className="text-[10px] uppercase font-bold text-emerald-300 tracking-wider">PREVIEWING</span>
<span className="text-sm font-bold">{VOICE_OPTIONS.find(v => v.id === previewState.playingVoiceId)?.name || 'Voice'}</span>
</div>
</div>
<div className="h-8 w-[1px] bg-white/20"></div>
<div className="flex gap-1 h-4 items-center px-2">
<div className="bar" style={{animationDelay: '0s'}}></div>
<div className="bar" style={{animationDelay: '0.1s'}}></div>
<div className="bar" style={{animationDelay: '0.2s'}}></div>
<div className="bar" style={{animationDelay: '0.3s'}}></div>
<div className="bar" style={{animationDelay: '0.4s'}}></div>
</div>
<div className="h-8 w-[1px] bg-white/20"></div>
<button onClick={stopPreview} className="w-8 h-8 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center transition-all">
<i className="fas fa-stop text-xs"></i>
</button>
</div>
</div>
)}
</div>
);
};
ReactDOM.render(<App />, document.getElementById('root'));
</script>
</body>
</html>