import { zodResolver } from '@hookform/resolvers/zod'; import { AnimatePresence, motion } from 'framer-motion'; import { Loader2, Sparkles } from 'lucide-react'; import { useEffect, useRef, useState } from 'react'; import { useForm } from 'react-hook-form'; import * as z from 'zod'; import { Button } from '@/components/ui/button'; import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { Textarea } from '@/components/ui/textarea'; import { useToast } from '@/components/ui/use-toast'; import { apiClient } from '@/lib/api/client'; import { LANGUAGE_CODES, LANGUAGE_OPTIONS, type LanguageCode } from '@/lib/constants/languages'; import { useGeneration } from '@/lib/hooks/useGeneration'; import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast'; import { useProfile } from '@/lib/hooks/useProfiles'; import { useGenerationStore } from '@/stores/generationStore'; import { usePlayerStore } from '@/stores/playerStore'; import { useUIStore } from '@/stores/uiStore'; const generationSchema = z.object({ text: z.string().min(1, 'Text is required').max(5000), language: z.enum(LANGUAGE_CODES as [LanguageCode, ...LanguageCode[]]), modelSize: z.enum(['1.7B', '0.6B']).optional(), }); type GenerationFormValues = z.infer; interface FloatingGenerateBoxProps { isPlayerOpen: boolean; } export function FloatingGenerateBox({ isPlayerOpen }: FloatingGenerateBoxProps) { const selectedProfileId = useUIStore((state) => state.selectedProfileId); const { data: selectedProfile } = useProfile(selectedProfileId || ''); const generation = useGeneration(); const { toast } = useToast(); const setAudio = usePlayerStore((state) => state.setAudio); const setIsGenerating = useGenerationStore((state) => state.setIsGenerating); const [downloadingModelName, setDownloadingModelName] = useState(null); const [downloadingDisplayName, setDownloadingDisplayName] = useState(null); const [isExpanded, setIsExpanded] = useState(false); const containerRef = useRef(null); useModelDownloadToast({ modelName: downloadingModelName || '', displayName: downloadingDisplayName || '', enabled: !!downloadingModelName, }); const form = useForm({ resolver: zodResolver(generationSchema), defaultValues: { text: '', language: 'en', modelSize: '1.7B', }, }); // Click away handler to collapse the box useEffect(() => { function handleClickOutside(event: MouseEvent) { const target = event.target as HTMLElement; // Don't collapse if clicking inside the container if (containerRef.current?.contains(target)) { return; } // Don't collapse if clicking on a Select dropdown (which renders in a portal) if ( target.closest('[role="listbox"]') || target.closest('[data-radix-popper-content-wrapper]') ) { return; } setIsExpanded(false); } if (isExpanded) { document.addEventListener('mousedown', handleClickOutside); } return () => { document.removeEventListener('mousedown', handleClickOutside); }; }, [isExpanded]); async function onSubmit(data: GenerationFormValues) { if (!selectedProfileId) { toast({ title: 'No profile selected', description: 'Please select a voice profile from the cards above.', variant: 'destructive', }); return; } try { setIsGenerating(true); const modelName = `qwen-tts-${data.modelSize}`; const displayName = data.modelSize === '1.7B' ? 'Qwen TTS 1.7B' : 'Qwen TTS 0.6B'; try { const modelStatus = await apiClient.getModelStatus(); const model = modelStatus.models.find((m) => m.model_name === modelName); if (model && !model.downloaded) { setDownloadingModelName(modelName); setDownloadingDisplayName(displayName); } } catch (error) { console.error('Failed to check model status:', error); } const result = await generation.mutateAsync({ profile_id: selectedProfileId, text: data.text, language: data.language, model_size: data.modelSize, }); toast({ title: 'Generation complete!', description: `Audio generated (${result.duration.toFixed(2)}s)`, }); const audioUrl = apiClient.getAudioUrl(result.id); setAudio(audioUrl, result.id, data.text.substring(0, 50)); form.reset(); setIsExpanded(false); } catch (error) { toast({ title: 'Generation failed', description: error instanceof Error ? error.message : 'Failed to generate audio', variant: 'destructive', }); } finally { setIsGenerating(false); setDownloadingModelName(null); setDownloadingDisplayName(null); } } return (
(