Implement active task management for downloads and generations, enhancing user experience with toast notifications for ongoing tasks. Refactor language handling in forms to support multiple languages. Update audio player to manage restart functionality and improve sidebar icon representation. Adjust progress tracking for model downloads in the backend.

This commit is contained in:
Jamie Pine
2026-01-26 00:00:00 -08:00
parent b2659e6a6d
commit 04bc1aded4
24 changed files with 660 additions and 198 deletions
+18 -38
View File
@@ -16,18 +16,19 @@ export function AudioPlayer() {
duration,
volume,
isLooping,
shouldRestart,
setIsPlaying,
setCurrentTime,
setDuration,
setVolume,
toggleLoop,
clearRestartFlag,
} = usePlayerStore();
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);
@@ -367,51 +368,30 @@ export function AudioPlayer() {
if (audioId !== previousAudioIdRef.current && previousAudioIdRef.current !== null) {
hasInitializedRef.current = false;
}
if (audioId !== null) {
previousAudioIdRef.current = audioId;
}
}, [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.
// Handle restart flag - when history item is clicked again, restart from beginning
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;
if (!wavesurfer || !shouldRestart || duration === 0) {
return;
}
const previousAudioId = previousAudioIdRef.current;
const previousCurrentTime = previousCurrentTimeRef.current;
// Reset to beginning and play
console.log('Restarting current audio from beginning');
wavesurfer.seekTo(0);
wavesurfer.play().catch((error) => {
console.error('Failed to play after restart:', error);
setIsPlaying(false);
setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`);
});
// 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]);
// Clear the restart flag
clearRestartFlag();
}, [shouldRestart, duration, setIsPlaying, clearRestartFlag]);
// Handle loop - WaveSurfer handles this via the 'finish' event
@@ -25,6 +25,7 @@ import {
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';
@@ -34,7 +35,7 @@ import { useUIStore } from '@/stores/uiStore';
const generationSchema = z.object({
text: z.string().min(1, 'Text is required').max(5000),
language: z.enum(['en', 'zh']),
language: z.enum(LANGUAGE_CODES as [LanguageCode, ...LanguageCode[]]),
seed: z.number().int().optional(),
modelSize: z.enum(['1.7B', '0.6B']).optional(),
instruct: z.string().max(500).optional(),
@@ -214,8 +215,11 @@ export function GenerationForm() {
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="en">English</SelectItem>
<SelectItem value="zh">Chinese</SelectItem>
{LANGUAGE_OPTIONS.map((lang) => (
<SelectItem key={lang.value} value={lang.value}>
{lang.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
+9 -3
View File
@@ -31,6 +31,7 @@ export function HistoryTable() {
const deleteGeneration = useDeleteGeneration();
const setAudio = usePlayerStore((state) => state.setAudio);
const restartCurrentAudio = usePlayerStore((state) => state.restartCurrentAudio);
const currentAudioId = usePlayerStore((state) => state.audioId);
const isPlaying = usePlayerStore((state) => state.isPlaying);
const audioUrl = usePlayerStore((state) => state.audioUrl);
@@ -49,9 +50,14 @@ export function HistoryTable() {
}, []);
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
setAudio(audioUrl, audioId, text.substring(0, 50));
// If clicking the same audio, restart it from the beginning
if (currentAudioId === audioId) {
restartCurrentAudio();
} else {
// Otherwise, load the new audio
const audioUrl = apiClient.getAudioUrl(audioId);
setAudio(audioUrl, audioId, text.substring(0, 50));
}
};
const handleDownload = (audioId: string, text: string) => {
@@ -54,10 +54,10 @@ export function ModelManagement() {
return apiClient.triggerModelDownload(modelName);
},
onSuccess: () => {
// Refetch status after a delay to see progress
setTimeout(() => {
queryClient.invalidateQueries({ queryKey: ['modelStatus'] });
}, 1000);
// Download completed - clear state and refetch status
setDownloadingModel(null);
setDownloadingDisplayName(null);
queryClient.invalidateQueries({ queryKey: ['modelStatus'] });
},
onError: (error: Error) => {
setDownloadingModel(null);
@@ -68,13 +68,6 @@ export function ModelManagement() {
variant: 'destructive',
});
},
onSettled: () => {
// Clear downloading state after a delay to allow progress to show
setTimeout(() => {
setDownloadingModel(null);
setDownloadingDisplayName(null);
}, 2000);
},
});
const deleteMutation = useMutation({
+2 -2
View File
@@ -1,4 +1,4 @@
import { Home, Loader2, Settings } from 'lucide-react';
import { Volume2, Loader2, Settings } from 'lucide-react';
import voiceboxLogo from '@/assets/voicebox-logo.png';
import { cn } from '@/lib/utils/cn';
import { useGenerationStore } from '@/stores/generationStore';
@@ -11,7 +11,7 @@ interface SidebarProps {
}
const tabs = [
{ id: 'main', icon: Home, label: 'Main' },
{ id: 'main', icon: Volume2, label: 'Main' },
{ id: 'settings', icon: Settings, label: 'Settings' },
];
@@ -30,6 +30,7 @@ import {
import { Textarea } from '@/components/ui/textarea';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useToast } from '@/components/ui/use-toast';
import { LANGUAGE_CODES, LANGUAGE_OPTIONS, type LanguageCode } from '@/lib/constants/languages';
import {
useCreateProfile,
useProfile,
@@ -68,7 +69,7 @@ const profileSchema = z
.object({
name: z.string().min(1, 'Name is required').max(100),
description: z.string().max(500).optional(),
language: z.enum(['en', 'zh']),
language: z.enum(LANGUAGE_CODES as [LanguageCode, ...LanguageCode[]]),
// Sample fields - only required when creating (not editing)
sampleFile: z.instanceof(File).optional(),
referenceText: z.string().max(1000).optional(),
@@ -186,7 +187,7 @@ export function ProfileForm() {
form.reset({
name: editingProfile.name,
description: editingProfile.description || '',
language: editingProfile.language as 'en' | 'zh',
language: editingProfile.language as LanguageCode,
sampleFile: undefined,
referenceText: undefined,
});
@@ -214,7 +215,7 @@ export function ProfileForm() {
}
try {
const language = form.getValues('language') as 'en' | 'zh' | undefined;
const language = form.getValues('language');
const result = await transcribe.mutateAsync({ file, language });
form.setValue('referenceText', result.text, { shouldValidate: true });
@@ -405,8 +406,11 @@ export function ProfileForm() {
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="en">English</SelectItem>
<SelectItem value="zh">Chinese</SelectItem>
{LANGUAGE_OPTIONS.map((lang) => (
<SelectItem key={lang.value} value={lang.value}>
{lang.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
+1 -1
View File
@@ -12,7 +12,7 @@ const Progress = React.forwardRef<
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
className="h-full w-full flex-1 bg-accent transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
+1 -1
View File
@@ -15,7 +15,7 @@ export function Toaster() {
<ToastProvider>
{toasts.map(({ id, title, description, action, ...props }) => (
<Toast key={id} {...props}>
<div className="grid gap-1">
<div className="grid gap-1 flex-1 min-w-0">
{title && <ToastTitle>{title}</ToastTitle>}
{description && <ToastDescription>{description}</ToastDescription>}
</div>