import { useQuery } from '@tanstack/react-query'; import { useMatchRoute } from '@tanstack/react-router'; import { AnimatePresence, motion } from 'framer-motion'; import { Loader2, SlidersHorizontal, Sparkles } from 'lucide-react'; import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; 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 { apiClient } from '@/lib/api/client'; import { getLanguageOptionsForEngine, type LanguageCode } from '@/lib/constants/languages'; import { useGenerationForm } from '@/lib/hooks/useGenerationForm'; import { useProfile, useProfiles } from '@/lib/hooks/useProfiles'; import { useStory } from '@/lib/hooks/useStories'; import { cn } from '@/lib/utils/cn'; import { useGenerationStore } from '@/stores/generationStore'; import { useStoryStore } from '@/stores/storyStore'; import { useUIStore } from '@/stores/uiStore'; import { EngineModelSelector } from './EngineModelSelector'; import { ParalinguisticInput } from './ParalinguisticInput'; interface FloatingGenerateBoxProps { isPlayerOpen?: boolean; showVoiceSelector?: boolean; } export function FloatingGenerateBox({ isPlayerOpen = false, showVoiceSelector = false, }: FloatingGenerateBoxProps) { const { t } = useTranslation(); const selectedProfileId = useUIStore((state) => state.selectedProfileId); const setSelectedProfileId = useUIStore((state) => state.setSelectedProfileId); const setSelectedEngine = useUIStore((state) => state.setSelectedEngine); const { data: selectedProfile } = useProfile(selectedProfileId || ''); const { data: profiles } = useProfiles(); const [isExpanded, setIsExpanded] = useState(false); const [isInstructExpanded, setIsInstructExpanded] = useState(false); const [selectedPresetId, setSelectedPresetId] = useState(null); const containerRef = useRef(null); const textareaRef = useRef(null); const matchRoute = useMatchRoute(); const isStoriesRoute = matchRoute({ to: '/stories' }); const selectedStoryId = useStoryStore((state) => state.selectedStoryId); const trackEditorHeight = useStoryStore((state) => state.trackEditorHeight); const { data: currentStory } = useStory(selectedStoryId); const addPendingStoryAdd = useGenerationStore((s) => s.addPendingStoryAdd); // Fetch effect presets for the dropdown const { data: effectPresets } = useQuery({ queryKey: ['effectPresets'], queryFn: () => apiClient.listEffectPresets(), }); // Calculate if track editor is visible (on stories route with items) const hasTrackEditor = isStoriesRoute && currentStory && currentStory.items.length > 0; const { form, handleSubmit, isPending } = useGenerationForm({ onSuccess: async (generationId) => { setIsExpanded(false); // Defer the story add until TTS completes -- useGenerationProgress handles it if (isStoriesRoute && selectedStoryId && generationId) { addPendingStoryAdd(generationId, selectedStoryId); } }, getEffectsChain: () => { if (!selectedPresetId) return undefined; // Profile's own effects chain (no matching preset) if (selectedPresetId === '_profile') { return selectedProfile?.effects_chain ?? undefined; } if (!effectPresets) return undefined; const preset = effectPresets.find((p) => p.id === selectedPresetId); return preset?.effects_chain; }, }); // 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]); // Set first voice as default if none selected useEffect(() => { if (!selectedProfileId && profiles && profiles.length > 0) { setSelectedProfileId(profiles[0].id); } }, [selectedProfileId, profiles, setSelectedProfileId]); // Sync engine selection to global store so ProfileList can filter const watchedEngine = form.watch('engine'); useEffect(() => { if (watchedEngine) { setSelectedEngine(watchedEngine); } }, [watchedEngine, setSelectedEngine]); // Sync generation form language, engine, and effects with selected profile type EngineValue = | 'qwen' | 'luxtts' | 'chatterbox' | 'chatterbox_turbo' | 'tada' | 'kokoro' | 'qwen_custom_voice'; useEffect(() => { if (selectedProfile?.language) { form.setValue('language', selectedProfile.language as LanguageCode); } // Auto-switch engine to match the profile const engine = selectedProfile?.default_engine ?? selectedProfile?.preset_engine; if (engine) { form.setValue('engine', engine as EngineValue); } else if (selectedProfile && selectedProfile.voice_type !== 'preset') { // Cloned/designed profile with no default — ensure a compatible (non-preset) engine const currentEngine = form.getValues('engine'); const presetEngines = new Set(['kokoro', 'qwen_custom_voice']); if (currentEngine && presetEngines.has(currentEngine)) { form.setValue('engine', 'qwen'); } } // Pre-fill effects from profile defaults if ( selectedProfile?.effects_chain && selectedProfile.effects_chain.length > 0 && effectPresets ) { // Try to match against a known preset const profileChainJson = JSON.stringify(selectedProfile.effects_chain); const matchingPreset = effectPresets.find( (p) => JSON.stringify(p.effects_chain) === profileChainJson, ); if (matchingPreset) { setSelectedPresetId(matchingPreset.id); } else { // No matching preset — use special value to pass profile chain directly setSelectedPresetId('_profile'); } } else if ( selectedProfile && (!selectedProfile.effects_chain || selectedProfile.effects_chain.length === 0) ) { setSelectedPresetId(null); } }, [selectedProfile, effectPresets, form]); // Auto-resize textarea based on content (only when expanded) useEffect(() => { if (!isExpanded) { // Reset textarea height after collapse animation completes const timeoutId = setTimeout(() => { const textarea = textareaRef.current; if (textarea) { textarea.style.height = '32px'; textarea.style.overflowY = 'hidden'; } }, 200); // Wait for animation to complete return () => clearTimeout(timeoutId); } const textarea = textareaRef.current; if (!textarea) return; const adjustHeight = () => { textarea.style.height = 'auto'; const scrollHeight = textarea.scrollHeight; const minHeight = 100; // Expanded minimum const maxHeight = 300; // Max height in pixels const targetHeight = Math.max(minHeight, Math.min(scrollHeight, maxHeight)); textarea.style.height = `${targetHeight}px`; // Show scrollbar if content exceeds max height if (scrollHeight > maxHeight) { textarea.style.overflowY = 'auto'; } else { textarea.style.overflowY = 'hidden'; } }; // Small delay to let framer animation complete const timeoutId = setTimeout(() => { adjustHeight(); }, 200); // Adjust on mount and when value changes adjustHeight(); // Watch for input changes textarea.addEventListener('input', adjustHeight); return () => { clearTimeout(timeoutId); textarea.removeEventListener('input', adjustHeight); }; }, [isExpanded]); async function onSubmit(data: Parameters[0]) { await handleSubmit(data, selectedProfileId); } return (
( {form.watch('engine') === 'chatterbox_turbo' ? ( setIsExpanded(true)} onFocus={() => setIsExpanded(true)} /> ) : (