import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { Check, CheckCircle2, Edit, Plus, Speaker, Trash2 } from 'lucide-react'; import { useState } from 'react'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { apiClient } from '@/lib/api/client'; import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui'; import { cn } from '@/lib/utils/cn'; import { usePlayerStore } from '@/stores/playerStore'; import { usePlatform } from '@/platform/PlatformContext'; interface AudioDevice { id: string; name: string; is_default: boolean; } export function AudioTab() { const platform = usePlatform(); const [createDialogOpen, setCreateDialogOpen] = useState(false); const [editingChannel, setEditingChannel] = useState(null); const [selectedChannelId, setSelectedChannelId] = useState(null); const queryClient = useQueryClient(); const audioUrl = usePlayerStore((state) => state.audioUrl); const isPlayerVisible = !!audioUrl; const { data: channels, isLoading: channelsLoading } = useQuery({ queryKey: ['channels'], queryFn: () => apiClient.listChannels(), }); const { data: devices, isLoading: devicesLoading } = useQuery({ queryKey: ['audio-devices'], queryFn: async () => { if (!platform.metadata.isTauri) { return []; } try { return await platform.audio.listOutputDevices(); } catch (error) { console.error('Failed to list audio devices:', error); return []; } }, enabled: platform.metadata.isTauri, }); const { data: profiles } = useQuery({ queryKey: ['profiles'], queryFn: () => apiClient.listProfiles(), }); const createChannel = useMutation({ mutationFn: (data: { name: string; device_ids: string[] }) => apiClient.createChannel(data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['channels'] }); setCreateDialogOpen(false); }, }); const updateChannel = useMutation({ mutationFn: ({ channelId, data, }: { channelId: string; data: { name?: string; device_ids?: string[] }; }) => apiClient.updateChannel(channelId, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['channels'] }); queryClient.invalidateQueries({ queryKey: ['profile-channels'] }); setEditingChannel(null); }, }); const deleteChannel = useMutation({ mutationFn: (channelId: string) => apiClient.deleteChannel(channelId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['channels'] }); queryClient.invalidateQueries({ queryKey: ['profile-channels'] }); }, }); const { data: channelVoices } = useQuery({ queryKey: ['channel-voices', editingChannel], queryFn: async () => { if (!editingChannel) return { profile_ids: [] }; return apiClient.getChannelVoices(editingChannel); }, enabled: !!editingChannel, }); const setChannelVoices = useMutation({ mutationFn: ({ channelId, profileIds }: { channelId: string; profileIds: string[] }) => apiClient.setChannelVoices(channelId, profileIds), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['channel-voices'] }); queryClient.invalidateQueries({ queryKey: ['profile-channels'] }); }, }); if (channelsLoading || devicesLoading) { return (
Loading...
); } const handleChannelDelete = async (e, channelId) => { e.stopPropagation(); if (await confirm('Delete this channel?')) { deleteChannel.mutate(channelId); } } const allChannels = channels || []; const allDevices = devices || []; const selectedChannel = selectedChannelId ? allChannels.find((c) => c.id === selectedChannelId) : null; return (

Audio Channels

{/* Left Column - Channels */}
{allChannels.length === 0 ? (

No audio channels yet. Create your first channel to route voices to specific devices.

) : (
{allChannels.map((channel) => { const isSelected = selectedChannelId === channel.id; return (
)}
); })}
)}
{/* Right Column - Available Devices */}

Available Devices

{selectedChannelId ? selectedChannel?.is_default ? 'Default channel uses system default device' : 'Click devices to add or remove them from the selected channel' : 'Select a channel to assign devices'}

{allDevices.length > 0 ? (
{allDevices.map((device) => { const isConnected = selectedChannelId && selectedChannel && (selectedChannel.device_ids.length === 0 ? device.is_default : selectedChannel.device_ids.includes(device.id)); const canToggle = selectedChannelId && selectedChannel && !selectedChannel.is_default; const handleDeviceClick = () => { if (!canToggle || !selectedChannel) return; const currentDeviceIds = selectedChannel.device_ids; const newDeviceIds = isConnected ? currentDeviceIds.filter((id) => id !== device.id) : [...currentDeviceIds, device.id]; updateChannel.mutate({ channelId: selectedChannelId, data: { device_ids: newDeviceIds }, }); }; return ( ); })}
) : (

{platform.metadata.isTauri ? 'No audio devices found' : 'Audio device selection requires Tauri'}

)}
{/* Create Channel Dialog */} { createChannel.mutate({ name, device_ids: deviceIds }); }} /> {/* Edit Channel Dialog */} {editingChannel && (() => { const channel = channels?.find((c) => c.id === editingChannel); return channel ? ( !open && setEditingChannel(null)} channel={channel} devices={devices || []} profiles={profiles || []} channelVoices={channelVoices?.profile_ids || []} onUpdate={(name, deviceIds) => { updateChannel.mutate({ channelId: editingChannel, data: { name, device_ids: deviceIds }, }); }} onSetVoices={(profileIds) => { setChannelVoices.mutate({ channelId: editingChannel, profileIds, }); }} /> ) : null; })()} ); } function ChannelVoicesList({ channelId }: { channelId: string }) { const { data: voices } = useQuery({ queryKey: ['channel-voices', channelId], queryFn: () => apiClient.getChannelVoices(channelId), }); const { data: profiles } = useQuery({ queryKey: ['profiles'], queryFn: () => apiClient.listProfiles(), }); const voiceNames = voices?.profile_ids.map((id) => profiles?.find((p) => p.id === id)?.name).filter(Boolean) || []; return (
{voiceNames.length > 0 ? ( voiceNames.map((name) => ( {name} )) ) : ( No voices assigned )}
); } interface CreateChannelDialogProps { open: boolean; onOpenChange: (open: boolean) => void; devices: AudioDevice[]; onCreate: (name: string, deviceIds: string[]) => void; } function CreateChannelDialog({ open, onOpenChange, devices, onCreate }: CreateChannelDialogProps) { const [name, setName] = useState(''); const [selectedDevices, setSelectedDevices] = useState([]); const handleSubmit = () => { if (name.trim()) { onCreate(name.trim(), selectedDevices); setName(''); setSelectedDevices([]); } }; return ( Create Audio Channel Create a new audio channel (bus) to route voices to specific output devices.
setName(e.target.value)} placeholder="e.g., Virtual Cable, Broadcast" />
{selectedDevices.length > 0 && (
{selectedDevices.map((deviceId) => { const device = devices.find((d) => d.id === deviceId); return (
{device?.name || deviceId}
); })}
)}
); } interface EditChannelDialogProps { open: boolean; onOpenChange: (open: boolean) => void; channel: { id: string; name: string; device_ids: string[]; }; devices: AudioDevice[]; profiles: Array<{ id: string; name: string }>; channelVoices: string[]; onUpdate: (name: string, deviceIds: string[]) => void; onSetVoices: (profileIds: string[]) => void; } function EditChannelDialog({ open, onOpenChange, channel, devices, profiles, channelVoices, onUpdate, onSetVoices, }: EditChannelDialogProps) { const [name, setName] = useState(channel.name); const [selectedDevices, setSelectedDevices] = useState(channel.device_ids); const [selectedVoices, setSelectedVoices] = useState(channelVoices); const handleSubmit = () => { if (name.trim()) { onUpdate(name.trim(), selectedDevices); onSetVoices(selectedVoices); } }; return ( Edit Channel Update channel settings and voice assignments.
setName(e.target.value)} />
{selectedDevices.length > 0 && (
{selectedDevices.map((deviceId) => { const device = devices.find((d) => d.id === deviceId); return (
{device?.name || deviceId}
); })}
)}
{selectedVoices.length > 0 && (
{selectedVoices.map((profileId) => { const profile = profiles.find((p) => p.id === profileId); return (
{profile?.name || profileId}
); })}
)}
); }