import { useQuery, useQueryClient } from '@tanstack/react-query'; import { Mic, Plus, Search, Sparkles } from 'lucide-react'; import { useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { MultiSelect } from '@/components/ui/multi-select'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table'; import { ProfileForm } from '@/components/VoiceProfiles/ProfileForm'; import { apiClient } from '@/lib/api/client'; import type { VoiceProfileResponse } from '@/lib/api/types'; import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui'; import { useProfiles } from '@/lib/hooks/useProfiles'; import { cn } from '@/lib/utils/cn'; import { usePlayerStore } from '@/stores/playerStore'; import { useServerStore } from '@/stores/serverStore'; import { useUIStore } from '@/stores/uiStore'; import { VoiceInspector } from './VoiceInspector'; export function VoicesTab() { const { t } = useTranslation(); const { data: profiles, isLoading } = useProfiles(); const queryClient = useQueryClient(); const setDialogOpen = useUIStore((state) => state.setProfileDialogOpen); const selectedVoiceId = useUIStore((state) => state.selectedVoiceId); const setSelectedVoiceId = useUIStore((state) => state.setSelectedVoiceId); const scrollRef = useRef(null); const audioUrl = usePlayerStore((state) => state.audioUrl); const isPlayerVisible = !!audioUrl; const [search, setSearch] = useState(''); const filteredProfiles = useMemo(() => { if (!profiles) return []; if (!search.trim()) return profiles; const q = search.toLowerCase(); return profiles.filter( (p) => p.name.toLowerCase().includes(q) || p.description?.toLowerCase().includes(q) || p.language.toLowerCase().includes(q), ); }, [profiles, search]); // Auto-select first profile if none selected useEffect(() => { if (!selectedVoiceId && profiles && profiles.length > 0) { setSelectedVoiceId(profiles[0].id); } // Clear selection if selected profile was deleted if (selectedVoiceId && profiles && !profiles.find((p) => p.id === selectedVoiceId)) { setSelectedVoiceId(profiles.length > 0 ? profiles[0].id : null); } }, [profiles, selectedVoiceId, setSelectedVoiceId]); // Get channel assignments for each profile const { data: channelAssignments } = useQuery({ queryKey: ['profile-channels'], queryFn: async () => { if (!profiles) return {}; const assignments: Record = {}; for (const profile of profiles) { try { const result = await apiClient.getProfileChannels(profile.id); assignments[profile.id] = result.channel_ids; } catch { assignments[profile.id] = []; } } return assignments; }, enabled: !!profiles, }); // Get all channels const { data: channels } = useQuery({ queryKey: ['channels'], queryFn: () => apiClient.listChannels(), }); const handleChannelChange = async (profileId: string, channelIds: string[]) => { try { await apiClient.setProfileChannels(profileId, channelIds); queryClient.invalidateQueries({ queryKey: ['profile-channels'] }); } catch (error) { console.error('Failed to update channels:', error); } }; if (isLoading) { return (
{t('voicesTab.loading')}
); } return (
{/* Left: Table */}
{/* Scroll Mask */}
{/* Fixed Header */}

{t('voicesTab.title')}

setSearch(e.target.value)} className="h-10 pl-8 text-sm rounded-full focus-visible:ring-0 focus-visible:ring-offset-0" />
{/* Scrollable Content */}
{t('voicesTab.columns.name')} {t('voicesTab.columns.language')} {t('voicesTab.columns.generations')} {t('voicesTab.columns.samples')} {t('voicesTab.columns.effects')} {t('voicesTab.columns.channels')} {filteredProfiles.map((profile) => ( setSelectedVoiceId(profile.id)} channelIds={channelAssignments?.[profile.id] || []} channels={channels || []} onChannelChange={(channelIds) => handleChannelChange(profile.id, channelIds)} /> ))}
{/* Right: Inspector */} {selectedVoiceId && (
)}
); } interface VoiceRowProps { profile: VoiceProfileResponse; isSelected: boolean; onSelect: () => void; channelIds: string[]; channels: Array<{ id: string; name: string; is_default: boolean }>; onChannelChange: (channelIds: string[]) => void; } function VoiceRow({ profile, isSelected, onSelect, channelIds, channels, onChannelChange, }: VoiceRowProps) { const { t } = useTranslation(); const serverUrl = useServerStore((state) => state.serverUrl); const [avatarError, setAvatarError] = useState(false); const avatarUrl = profile.avatar_path ? `${serverUrl}/profiles/${profile.id}/avatar` : null; const enabledEffects = profile.effects_chain?.filter((e) => e.enabled) ?? []; const effectsSummary = enabledEffects.map((e) => e.type).join(' → '); return (
{avatarUrl && !avatarError ? ( {t('voicesTab.avatarAlt', setAvatarError(true)} /> ) : ( )}
{profile.name}
{profile.description && (
{profile.description}
)}
{profile.language} {profile.generation_count} {profile.sample_count} {enabledEffects.length > 0 ? ( {enabledEffects.length} ) : ( )} e.stopPropagation()}> ({ value: ch.id, label: ch.is_default ? t('voicesTab.channelDefaultLabel', { name: ch.name }) : ch.name, }))} value={channelIds} onChange={onChannelChange} placeholder={t('voicesTab.selectChannels')} className="w-full" />
); }