diff --git a/app/src/App.tsx b/app/src/App.tsx index d76f7655..f28a4a3b 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -1,7 +1,8 @@ import { useEffect, useState } from 'react'; import voiceboxLogo from '@/assets/voicebox-logo.png'; import { AudioPlayer } from '@/components/AudioPlayer/AudioPlayer'; -import { GenerationForm } from '@/components/Generation/GenerationForm'; +// import { GenerationForm } from '@/components/Generation/GenerationForm'; +import { FloatingGenerateBox } from '@/components/Generation/FloatingGenerateBox'; import { HistoryTable } from '@/components/History/HistoryTable'; import { ConnectionForm } from '@/components/ServerSettings/ConnectionForm'; import { ModelManagement } from '@/components/ServerSettings/ModelManagement'; @@ -21,6 +22,7 @@ import { setupWindowCloseHandler, startServer, } from '@/lib/tauri'; +import { usePlayerStore } from '@/stores/playerStore'; import { useServerStore } from '@/stores/serverStore'; // Track if server is starting to prevent duplicate starts @@ -53,6 +55,7 @@ function App() { const [activeTab, setActiveTab] = useState('main'); const [serverReady, setServerReady] = useState(false); const [loadingMessageIndex, setLoadingMessageIndex] = useState(0); + const audioUrl = usePlayerStore((state) => state.audioUrl); // Monitor active downloads/generations and show toasts for them const activeDownloads = useRestoreActiveTasks(); @@ -196,7 +199,7 @@ function App() { ) : ( // Main view: Profiles top left, Generator bottom left, History right -
+
{/* Left Column */}
{/* Profiles - Top Left */} @@ -205,15 +208,18 @@ function App() {
{/* Generator - Bottom Left */} -
+ {/*
-
+
*/}
{/* Right Column - History */}
+ + {/* Floating Generate Box */} +
)} diff --git a/app/src/components/Generation/FloatingGenerateBox.tsx b/app/src/components/Generation/FloatingGenerateBox.tsx new file mode 100644 index 00000000..e0f1daf4 --- /dev/null +++ b/app/src/components/Generation/FloatingGenerateBox.tsx @@ -0,0 +1,282 @@ +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 ( + + +
+ +
+ + ( + + +