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.

This commit is contained in:
Jamie Pine
2026-01-25 23:25:21 -08:00
parent 090b1f6dde
commit b2659e6a6d
19 changed files with 919 additions and 154 deletions
@@ -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<HTMLDivElement>(null);
const wavesurferRef = useRef<WaveSurfer | null>(null);
const loadingRef = useRef(false);
const previousAudioIdRef = useRef<string | null>(null);
const previousCurrentTimeRef = useRef<number>(0);
const hasInitializedRef = useRef(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(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 = () => {
@@ -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<string | null>(null);
const [downloadingDisplayName, setDownloadingDisplayName] = useState<string | null>(null);
// Use the download toast hook to show progress when model is downloading
useModelDownloadToast({
modelName: downloadingModelName || '',
displayName: downloadingDisplayName || '',
enabled: !!downloadingModelName,
});
const form = useForm<GenerationFormValues>({
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);
}
}
+20 -2
View File
@@ -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<HTMLDivElement>(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 (
<div className="flex flex-col h-full min-h-0">
<div className="flex flex-col h-full min-h-0 relative">
{history.length === 0 ? (
<div className="text-center py-12 text-muted-foreground flex-1 flex items-center justify-center">
No generation history yet. Generate your first audio to see it here.
</div>
) : (
<>
{isScrolled && (
<div className="absolute top-0 left-0 right-0 h-16 bg-gradient-to-b from-background to-transparent z-10 pointer-events-none" />
)}
<div
ref={scrollRef}
className={cn(
'flex-1 min-h-0 overflow-y-auto space-y-2',
isPlayerVisible && 'max-h-[calc(100vh-117px)]',
@@ -4,14 +4,26 @@ import { apiClient } from '@/lib/api/client';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Loader2, Download, CheckCircle2 } from 'lucide-react';
import { Loader2, Download, CheckCircle2, Trash2 } from 'lucide-react';
import { ModelProgress } from './ModelProgress';
import { useToast } from '@/components/ui/use-toast';
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
export function ModelManagement() {
const { toast } = useToast();
const queryClient = useQueryClient();
const [downloadingModel, setDownloadingModel] = useState<string | null>(null);
const [downloadingDisplayName, setDownloadingDisplayName] = useState<string | null>(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() {
</div>
) : null}
</CardContent>
{/* Delete Confirmation Dialog */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Model</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete <strong>{modelToDelete?.displayName}</strong>?
{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.
</>
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
if (modelToDelete) {
deleteMutation.mutate(modelToDelete.name);
}
}}
disabled={deleteMutation.isPending}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleteMutation.isPending ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Deleting...
</>
) : (
'Delete'
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Card>
);
}
@@ -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 (
<div className="flex items-center justify-between p-3 border rounded-lg">
<div className="flex-1">
@@ -171,9 +282,21 @@ function ModelItem({ model, onDownload, isDownloading, formatSize }: ModelItemPr
</div>
<div className="flex items-center gap-2">
{model.downloaded ? (
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<CheckCircle2 className="h-4 w-4 text-green-500" />
<span>Ready</span>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<CheckCircle2 className="h-4 w-4 text-green-500" />
<span>Ready</span>
</div>
<Button
size="sm"
onClick={onDelete}
variant="outline"
className="text-destructive hover:text-destructive"
disabled={model.loaded}
title={model.loaded ? 'Unload model before deleting' : 'Delete model'}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
) : (
<Button size="sm" onClick={onDownload} disabled={isDownloading} variant="outline">
+6 -2
View File
@@ -7,6 +7,7 @@ import { usePlayerStore } from '@/stores/playerStore';
interface SidebarProps {
activeTab: string;
onTabChange: (tab: string) => void;
isMacOS?: boolean;
}
const tabs = [
@@ -14,13 +15,16 @@ const tabs = [
{ id: 'settings', icon: Settings, label: 'Settings' },
];
export function Sidebar({ activeTab, onTabChange }: SidebarProps) {
export function Sidebar({ activeTab, onTabChange, isMacOS }: SidebarProps) {
const isGenerating = useGenerationStore((state) => state.isGenerating);
const audioUrl = usePlayerStore((state) => state.audioUrl);
const isPlayerVisible = !!audioUrl;
return (
<div className="fixed left-0 top-0 h-full w-20 bg-sidebar border-r border-border flex flex-col items-center py-6 gap-6">
<div className={cn(
"fixed left-0 top-0 h-full w-20 bg-sidebar border-r border-border flex flex-col items-center py-6 gap-6",
isMacOS && "pt-14"
)}>
{/* Logo */}
<div className="mb-2">
<img src={voiceboxLogo} alt="Voicebox" className="w-12 h-12 object-contain" />
@@ -0,0 +1,8 @@
export function TitleBarDragRegion() {
return (
<div
data-tauri-drag-region
className="fixed top-0 left-0 right-0 h-12 z-[9999]"
/>
);
}
+114
View File
@@ -0,0 +1,114 @@
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
import * as React from 'react';
import { cn } from '@/lib/utils/cn';
import { buttonVariants } from './button';
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className,
)}
{...props}
ref={ref}
/>
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className,
)}
{...props}
/>
</AlertDialogPortal>
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col space-y-2 text-center sm:text-left', className)} {...props} />
);
AlertDialogHeader.displayName = 'AlertDialogHeader';
const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
{...props}
/>
);
AlertDialogFooter.displayName = 'AlertDialogFooter';
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold', className)}
{...props}
/>
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', className)}
{...props}
/>
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};