diff --git a/app/src/App.tsx b/app/src/App.tsx index 1124b64d..f8d8f02c 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -9,10 +9,11 @@ import { ServerStatus } from '@/components/ServerSettings/ServerStatus'; import { UpdateStatus } from '@/components/ServerSettings/UpdateStatus'; import ShinyText from '@/components/ShinyText'; import { Sidebar } from '@/components/Sidebar'; +import { TitleBarDragRegion } from '@/components/TitleBarDragRegion'; import { UpdateNotification } from '@/components/UpdateNotification'; import { Toaster } from '@/components/ui/toaster'; import { ProfileList } from '@/components/VoiceProfiles/ProfileList'; -import { isTauri, setupWindowCloseHandler, startServer } from '@/lib/tauri'; +import { isTauri, isMacOS, setupWindowCloseHandler, startServer } from '@/lib/tauri'; // Track if server is starting to prevent duplicate starts let serverStarting = false; @@ -115,36 +116,38 @@ function App() { // Show loading screen while server is starting in Tauri if (isTauri() && !serverReady) { return ( -
-
-
-
-
+
+ +
+
+
+
+
+ Voicebox +
+
+
- Voicebox
-
- -
-
); } return ( -
+
+
- +
diff --git a/app/src/components/AudioPlayer/AudioPlayer.tsx b/app/src/components/AudioPlayer/AudioPlayer.tsx index d6ffb5bd..81fb51f0 100644 --- a/app/src/components/AudioPlayer/AudioPlayer.tsx +++ b/app/src/components/AudioPlayer/AudioPlayer.tsx @@ -9,6 +9,7 @@ import { usePlayerStore } from '@/stores/playerStore'; export function AudioPlayer() { const { audioUrl, + audioId, title, isPlaying, currentTime, @@ -25,6 +26,9 @@ export function AudioPlayer() { const waveformRef = useRef(null); const wavesurferRef = useRef(null); const loadingRef = useRef(false); + const previousAudioIdRef = useRef(null); + const previousCurrentTimeRef = useRef(0); + const hasInitializedRef = useRef(false); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); @@ -354,6 +358,61 @@ export function AudioPlayer() { } }, [volume]); + // Mark as initialized when audio is ready, reset when audioId changes + useEffect(() => { + if (duration > 0 && audioId) { + hasInitializedRef.current = true; + } + // Reset initialization flag when audioId changes to a new audio + if (audioId !== previousAudioIdRef.current && previousAudioIdRef.current !== null) { + hasInitializedRef.current = false; + } + }, [duration, audioId]); + + // Handle clicking the same audio again - always restart from beginning + // When setAudio is called with the same audioId, it sets currentTime to 0 in the store + // but WaveSurfer's actual position is still wherever it was. We detect this mismatch and reset. + useEffect(() => { + const wavesurfer = wavesurferRef.current; + if (!wavesurfer || !audioId || duration === 0 || !hasInitializedRef.current) { + // Update the refs even if we don't process + if (audioId !== null) { + previousAudioIdRef.current = audioId; + } + previousCurrentTimeRef.current = currentTime; + return; + } + + const previousAudioId = previousAudioIdRef.current; + const previousCurrentTime = previousCurrentTimeRef.current; + + // Check if the same audio was clicked again + // This happens when: + // 1. audioId matches the previous one (same audio) + // 2. currentTime was reset from a non-zero value to 0 (setAudio was called) + // 3. WaveSurfer is not at the beginning (needs reset) + const wasResetToZero = previousCurrentTime > 0.1 && currentTime < 0.1; + const isSameAudio = audioId === previousAudioId; + const wavesurferPosition = wavesurfer.getCurrentTime(); + const wavesurferNotAtStart = wavesurferPosition > 0.1; + + // Update refs for next time + previousAudioIdRef.current = audioId; + previousCurrentTimeRef.current = currentTime; + + // If same audio was clicked (reset to 0) and WaveSurfer is not at start, reset it + if (isSameAudio && wasResetToZero && wavesurferNotAtStart) { + // Reset to beginning and play + console.log('Same audio clicked again, resetting to beginning'); + wavesurfer.seekTo(0); + wavesurfer.play().catch((error) => { + console.error('Failed to play after reset:', error); + setIsPlaying(false); + setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`); + }); + } + }, [audioId, duration, currentTime, setIsPlaying]); + // Handle loop - WaveSurfer handles this via the 'finish' event const handlePlayPause = () => { diff --git a/app/src/components/Generation/GenerationForm.tsx b/app/src/components/Generation/GenerationForm.tsx index c029f05b..37c3c246 100644 --- a/app/src/components/Generation/GenerationForm.tsx +++ b/app/src/components/Generation/GenerationForm.tsx @@ -1,5 +1,6 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { Loader2, Mic } from 'lucide-react'; +import { useState } from 'react'; import { useForm } from 'react-hook-form'; import * as z from 'zod'; import { Button } from '@/components/ui/button'; @@ -25,6 +26,7 @@ import { Textarea } from '@/components/ui/textarea'; import { useToast } from '@/components/ui/use-toast'; import { apiClient } from '@/lib/api/client'; 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'; @@ -47,6 +49,15 @@ export function GenerationForm() { 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); + + // Use the download toast hook to show progress when model is downloading + useModelDownloadToast({ + modelName: downloadingModelName || '', + displayName: downloadingDisplayName || '', + enabled: !!downloadingModelName, + }); const form = useForm({ resolver: zodResolver(generationSchema), @@ -71,6 +82,27 @@ export function GenerationForm() { try { setIsGenerating(true); + + // Determine model name and display name + const modelName = `qwen-tts-${data.modelSize}`; + const displayName = data.modelSize === '1.7B' ? 'Qwen TTS 1.7B' : 'Qwen TTS 0.6B'; + + // Check if model is downloaded before starting generation + try { + const modelStatus = await apiClient.getModelStatus(); + const model = modelStatus.models.find((m) => m.model_name === modelName); + + if (model && !model.downloaded) { + // Model is not downloaded, enable download toast + setDownloadingModelName(modelName); + setDownloadingDisplayName(displayName); + } + } catch (error) { + // If status check fails, continue anyway - generation will handle it + console.error('Failed to check model status:', error); + } + + // Proceed with generation (which will trigger download if needed) const result = await generation.mutateAsync({ profile_id: selectedProfileId, text: data.text, @@ -98,6 +130,9 @@ export function GenerationForm() { }); } finally { setIsGenerating(false); + // Clear download state after generation completes + setDownloadingModelName(null); + setDownloadingDisplayName(null); } } diff --git a/app/src/components/History/HistoryTable.tsx b/app/src/components/History/HistoryTable.tsx index 3f3f4044..4c0bec95 100644 --- a/app/src/components/History/HistoryTable.tsx +++ b/app/src/components/History/HistoryTable.tsx @@ -1,5 +1,5 @@ import { AudioWaveform, Download, MoreHorizontal, Play, Trash2 } from 'lucide-react'; -import { useState } from 'react'; +import { useState, useRef, useEffect } from 'react'; import { Button } from '@/components/ui/button'; import { DropdownMenu, @@ -20,6 +20,8 @@ import { usePlayerStore } from '@/stores/playerStore'; // NEW ALTERNATE HISTORY VIEW - FIXED HEIGHT ROWS export function HistoryTable() { const [page, setPage] = useState(0); + const [isScrolled, setIsScrolled] = useState(false); + const scrollRef = useRef(null); const limit = 20; const { data: historyData, isLoading } = useHistory({ @@ -34,6 +36,18 @@ export function HistoryTable() { const audioUrl = usePlayerStore((state) => state.audioUrl); const isPlayerVisible = !!audioUrl; + useEffect(() => { + const scrollEl = scrollRef.current; + if (!scrollEl) return; + + const handleScroll = () => { + setIsScrolled(scrollEl.scrollTop > 0); + }; + + scrollEl.addEventListener('scroll', handleScroll); + return () => scrollEl.removeEventListener('scroll', handleScroll); + }, []); + const handlePlay = (audioId: string, text: string) => { const audioUrl = apiClient.getAudioUrl(audioId); // If clicking the same audio that's playing, it will be handled by the player @@ -64,14 +78,18 @@ export function HistoryTable() { const hasMore = history.length === limit && (page + 1) * limit < total; return ( -
+
{history.length === 0 ? (
No generation history yet. Generate your first audio to see it here.
) : ( <> + {isScrolled && ( +
+ )}
(null); + const [downloadingDisplayName, setDownloadingDisplayName] = useState(null); const { data: modelStatus, isLoading } = useQuery({ queryKey: ['modelStatus'], @@ -19,16 +31,29 @@ export function ModelManagement() { refetchInterval: 5000, // Refresh every 5 seconds }); + // Use progress toast hook for the downloading model + useModelDownloadToast({ + modelName: downloadingModel || '', + displayName: downloadingDisplayName || '', + enabled: !!downloadingModel && !!downloadingDisplayName, + }); + + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [modelToDelete, setModelToDelete] = useState<{ + name: string; + displayName: string; + sizeMb?: number; + } | null>(null); + const downloadMutation = useMutation({ mutationFn: (modelName: string) => { setDownloadingModel(modelName); + // Find display name from model status + const model = modelStatus?.models.find((m) => m.model_name === modelName); + setDownloadingDisplayName(model?.display_name || modelName); return apiClient.triggerModelDownload(modelName); }, - onSuccess: (_, modelName) => { - toast({ - title: 'Download started', - description: `Downloading ${modelName}...`, - }); + onSuccess: () => { // Refetch status after a delay to see progress setTimeout(() => { queryClient.invalidateQueries({ queryKey: ['modelStatus'] }); @@ -36,6 +61,7 @@ export function ModelManagement() { }, onError: (error: Error) => { setDownloadingModel(null); + setDownloadingDisplayName(null); toast({ title: 'Download failed', description: error.message, @@ -46,10 +72,32 @@ export function ModelManagement() { // Clear downloading state after a delay to allow progress to show setTimeout(() => { setDownloadingModel(null); + setDownloadingDisplayName(null); }, 2000); }, }); + const deleteMutation = useMutation({ + mutationFn: (modelName: string) => apiClient.deleteModel(modelName), + onSuccess: () => { + toast({ + title: 'Model deleted', + description: `${modelToDelete?.displayName || 'Model'} has been deleted successfully.`, + }); + setDeleteDialogOpen(false); + setModelToDelete(null); + // Refetch status to update UI + queryClient.invalidateQueries({ queryKey: ['modelStatus'] }); + }, + onError: (error: Error) => { + toast({ + title: 'Delete failed', + description: error.message, + variant: 'destructive', + }); + }, + }); + const formatSize = (sizeMb?: number): string => { if (!sizeMb) return 'Unknown'; if (sizeMb < 1024) return `${sizeMb.toFixed(1)} MB`; @@ -84,6 +132,14 @@ export function ModelManagement() { key={model.model_name} model={model} onDownload={() => downloadMutation.mutate(model.model_name)} + onDelete={() => { + setModelToDelete({ + name: model.model_name, + displayName: model.display_name, + sizeMb: model.size_mb, + }); + setDeleteDialogOpen(true); + }} isDownloading={downloadingModel === model.model_name} formatSize={formatSize} /> @@ -104,6 +160,14 @@ export function ModelManagement() { key={model.model_name} model={model} onDownload={() => downloadMutation.mutate(model.model_name)} + onDelete={() => { + setModelToDelete({ + name: model.model_name, + displayName: model.display_name, + sizeMb: model.size_mb, + }); + setDeleteDialogOpen(true); + }} isDownloading={downloadingModel === model.model_name} formatSize={formatSize} /> @@ -129,6 +193,46 @@ export function ModelManagement() {
) : null} + + {/* Delete Confirmation Dialog */} + + + + Delete Model + + Are you sure you want to delete {modelToDelete?.displayName}? + {modelToDelete?.sizeMb && ( + <> + {' '} + This will free up {formatSize(modelToDelete.sizeMb)} of disk space. The model + will need to be re-downloaded if you want to use it again. + + )} + + + + Cancel + { + if (modelToDelete) { + deleteMutation.mutate(modelToDelete.name); + } + }} + disabled={deleteMutation.isPending} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {deleteMutation.isPending ? ( + <> + + Deleting... + + ) : ( + 'Delete' + )} + + + + ); } @@ -142,11 +246,18 @@ interface ModelItemProps { loaded: boolean; }; onDownload: () => void; + onDelete: () => void; isDownloading: boolean; formatSize: (sizeMb?: number) => string; } -function ModelItem({ model, onDownload, isDownloading, formatSize }: ModelItemProps) { +function ModelItem({ + model, + onDownload, + onDelete, + isDownloading, + formatSize, +}: ModelItemProps) { return (
@@ -171,9 +282,21 @@ function ModelItem({ model, onDownload, isDownloading, formatSize }: ModelItemPr
{model.downloaded ? ( -
- - Ready +
+
+ + Ready +
+
) : (