From b2659e6a6d9a6177dd653565f74e270a7d92c123 Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Sun, 25 Jan 2026 23:25:21 -0800 Subject: [PATCH] Refactor App and Sidebar components to support macOS, add TitleBarDragRegion for improved window dragging, and enhance model management with delete functionality and download progress tracking. Update audio player to handle audio resets more effectively and improve generation form with model download notifications. --- app/src/App.tsx | 49 ++--- .../components/AudioPlayer/AudioPlayer.tsx | 59 ++++++ .../components/Generation/GenerationForm.tsx | 35 ++++ app/src/components/History/HistoryTable.tsx | 22 ++- .../ServerSettings/ModelManagement.tsx | 143 +++++++++++++- app/src/components/Sidebar.tsx | 8 +- app/src/components/TitleBarDragRegion.tsx | 8 + app/src/components/ui/alert-dialog.tsx | 114 +++++++++++ app/src/lib/api/client.ts | 6 + app/src/lib/hooks/useModelDownloadToast.tsx | 176 +++++++++++++++++ app/src/lib/tauri.ts | 7 + backend/main.py | 88 ++++++++- backend/transcribe.py | 183 ++++++++++-------- backend/tts.py | 75 +++++-- backend/utils/hf_progress.py | 94 +++++++-- tauri/src-tauri/capabilities/default.json | 1 + tauri/src-tauri/gen/Assets.car | Bin 3847048 -> 3847048 bytes tauri/src-tauri/gen/schemas/capabilities.json | 2 +- tauri/src-tauri/tauri.conf.json | 3 +- 19 files changed, 919 insertions(+), 154 deletions(-) create mode 100644 app/src/components/TitleBarDragRegion.tsx create mode 100644 app/src/components/ui/alert-dialog.tsx create mode 100644 app/src/lib/hooks/useModelDownloadToast.tsx 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 +
+
) : (