feat(i18n): localize Audio Channels tab (list, dialogs, device picker)

Covers the "Audio Channels" title and "New Channel" button, the empty
state, per-channel section labels (Output Devices / Assigned Voices),
the Available Devices right pane with its three contextual hints, the
"No voices assigned" fallback, and both Create/Edit dialogs (titles,
descriptions, field labels, Select placeholders, and the "(default)"
badge).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
James Pine
2026-04-20 03:29:58 -07:00
co-authored by Claude Opus 4.7
parent 0c51132a93
commit 6e13a30a59
3 changed files with 119 additions and 39 deletions
+39 -39
View File
@@ -1,6 +1,7 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Check, CheckCircle2, Edit, Plus, Speaker, Trash2 } from 'lucide-react'; import { Check, CheckCircle2, Edit, Plus, Speaker, Trash2 } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
@@ -33,6 +34,7 @@ interface AudioDevice {
} }
export function AudioTab() { export function AudioTab() {
const { t } = useTranslation();
const platform = usePlatform(); const platform = usePlatform();
const [createDialogOpen, setCreateDialogOpen] = useState(false); const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [editingChannel, setEditingChannel] = useState<string | null>(null); const [editingChannel, setEditingChannel] = useState<string | null>(null);
@@ -119,14 +121,14 @@ export function AudioTab() {
if (channelsLoading || devicesLoading) { if (channelsLoading || devicesLoading) {
return ( return (
<div className="flex items-center justify-center h-full"> <div className="flex items-center justify-center h-full">
<div className="text-muted-foreground">Loading...</div> <div className="text-muted-foreground">{t('audioChannels.loading')}</div>
</div> </div>
); );
} }
const handleChannelDelete = async (e: React.MouseEvent, channelId: string) => { const handleChannelDelete = async (e: React.MouseEvent, channelId: string) => {
e.stopPropagation(); e.stopPropagation();
if (await confirm('Delete this channel?')) { if (await confirm(t('audioChannels.confirmDelete'))) {
deleteChannel.mutate(channelId); deleteChannel.mutate(channelId);
} }
}; };
@@ -140,10 +142,10 @@ export function AudioTab() {
return ( return (
<div className="h-full flex flex-col"> <div className="h-full flex flex-col">
<div className="flex items-center justify-between mb-6 shrink-0"> <div className="flex items-center justify-between mb-6 shrink-0">
<h2 className="text-2xl font-bold">Audio Channels</h2> <h2 className="text-2xl font-bold">{t('audioChannels.title')}</h2>
<Button onClick={() => setCreateDialogOpen(true)}> <Button onClick={() => setCreateDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
New Channel {t('audioChannels.newChannel')}
</Button> </Button>
</div> </div>
@@ -158,13 +160,10 @@ export function AudioTab() {
{allChannels.length === 0 ? ( {allChannels.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 border-2 border-dashed border-muted rounded-md"> <div className="flex flex-col items-center justify-center py-12 border-2 border-dashed border-muted rounded-md">
<Speaker className="h-12 w-12 text-muted-foreground mb-4" /> <Speaker className="h-12 w-12 text-muted-foreground mb-4" />
<p className="text-muted-foreground mb-4"> <p className="text-muted-foreground mb-4">{t('audioChannels.empty.message')}</p>
No audio channels yet. Create your first channel to route voices to specific
devices.
</p>
<Button onClick={() => setCreateDialogOpen(true)}> <Button onClick={() => setCreateDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
Create Channel {t('audioChannels.empty.action')}
</Button> </Button>
</div> </div>
) : ( ) : (
@@ -195,7 +194,7 @@ export function AudioTab() {
<div className="space-y-2.5 ml-10"> <div className="space-y-2.5 ml-10">
<div> <div>
<div className="text-xs font-medium text-muted-foreground mb-1"> <div className="text-xs font-medium text-muted-foreground mb-1">
Output Devices {t('audioChannels.labels.outputDevices')}
</div> </div>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{channel.device_ids.length > 0 {channel.device_ids.length > 0
@@ -224,7 +223,7 @@ export function AudioTab() {
<div> <div>
<div className="text-xs font-medium text-muted-foreground mb-1"> <div className="text-xs font-medium text-muted-foreground mb-1">
Assigned Voices {t('audioChannels.labels.assignedVoices')}
</div> </div>
<ChannelVoicesList channelId={channel.id} /> <ChannelVoicesList channelId={channel.id} />
</div> </div>
@@ -270,13 +269,13 @@ export function AudioTab() {
)} )}
> >
<div className="shrink-0 mb-4"> <div className="shrink-0 mb-4">
<h3 className="text-lg font-semibold">Available Devices</h3> <h3 className="text-lg font-semibold">{t('audioChannels.devices.title')}</h3>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
{selectedChannelId {selectedChannelId
? selectedChannel?.is_default ? selectedChannel?.is_default
? 'Default channel uses system default device' ? t('audioChannels.devices.defaultNote')
: 'Click devices to add or remove them from the selected channel' : t('audioChannels.devices.toggleHint')
: 'Select a channel to assign devices'} : t('audioChannels.devices.selectHint')}
</p> </p>
</div> </div>
{allDevices.length > 0 ? ( {allDevices.length > 0 ? (
@@ -344,8 +343,8 @@ export function AudioTab() {
<CheckCircle2 className="h-12 w-12 text-muted-foreground mb-4" /> <CheckCircle2 className="h-12 w-12 text-muted-foreground mb-4" />
<p className="text-muted-foreground text-center"> <p className="text-muted-foreground text-center">
{platform.metadata.isTauri {platform.metadata.isTauri
? 'No audio devices found' ? t('audioChannels.devices.empty')
: 'Audio device selection requires Tauri'} : t('audioChannels.devices.requiresTauri')}
</p> </p>
</div> </div>
)} )}
@@ -394,6 +393,7 @@ export function AudioTab() {
} }
function ChannelVoicesList({ channelId }: { channelId: string }) { function ChannelVoicesList({ channelId }: { channelId: string }) {
const { t } = useTranslation();
const { data: voices } = useQuery({ const { data: voices } = useQuery({
queryKey: ['channel-voices', channelId], queryKey: ['channel-voices', channelId],
queryFn: () => apiClient.getChannelVoices(channelId), queryFn: () => apiClient.getChannelVoices(channelId),
@@ -416,7 +416,7 @@ function ChannelVoicesList({ channelId }: { channelId: string }) {
</Badge> </Badge>
)) ))
) : ( ) : (
<span className="text-sm text-muted-foreground">No voices assigned</span> <span className="text-sm text-muted-foreground">{t('audioChannels.noVoicesAssigned')}</span>
)} )}
</div> </div>
); );
@@ -430,6 +430,7 @@ interface CreateChannelDialogProps {
} }
function CreateChannelDialog({ open, onOpenChange, devices, onCreate }: CreateChannelDialogProps) { function CreateChannelDialog({ open, onOpenChange, devices, onCreate }: CreateChannelDialogProps) {
const { t } = useTranslation();
const [name, setName] = useState(''); const [name, setName] = useState('');
const [selectedDevices, setSelectedDevices] = useState<string[]>([]); const [selectedDevices, setSelectedDevices] = useState<string[]>([]);
@@ -445,23 +446,21 @@ function CreateChannelDialog({ open, onOpenChange, devices, onCreate }: CreateCh
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Create Audio Channel</DialogTitle> <DialogTitle>{t('audioChannels.createDialog.title')}</DialogTitle>
<DialogDescription> <DialogDescription>{t('audioChannels.createDialog.description')}</DialogDescription>
Create a new audio channel (bus) to route voices to specific output devices.
</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<Label htmlFor="channel-name">Channel Name</Label> <Label htmlFor="channel-name">{t('audioChannels.fields.name')}</Label>
<Input <Input
id="channel-name" id="channel-name"
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
placeholder="e.g., Virtual Cable, Broadcast" placeholder={t('audioChannels.fields.namePlaceholder')}
/> />
</div> </div>
<div> <div>
<Label>Output Devices</Label> <Label>{t('audioChannels.labels.outputDevices')}</Label>
<Select <Select
value={selectedDevices[0] || ''} value={selectedDevices[0] || ''}
onValueChange={(value) => { onValueChange={(value) => {
@@ -471,12 +470,12 @@ function CreateChannelDialog({ open, onOpenChange, devices, onCreate }: CreateCh
}} }}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select device" /> <SelectValue placeholder={t('audioChannels.selectDevice')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{devices.map((device) => ( {devices.map((device) => (
<SelectItem key={device.id} value={device.id}> <SelectItem key={device.id} value={device.id}>
{device.name} {device.is_default && '(default)'} {device.name} {device.is_default && `(${t('audioChannels.defaultSuffix')})`}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -509,10 +508,10 @@ function CreateChannelDialog({ open, onOpenChange, devices, onCreate }: CreateCh
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}> <Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel {t('common.cancel')}
</Button> </Button>
<Button onClick={handleSubmit} disabled={!name.trim()}> <Button onClick={handleSubmit} disabled={!name.trim()}>
Create {t('audioChannels.createDialog.action')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -545,6 +544,7 @@ function EditChannelDialog({
onUpdate, onUpdate,
onSetVoices, onSetVoices,
}: EditChannelDialogProps) { }: EditChannelDialogProps) {
const { t } = useTranslation();
const [name, setName] = useState(channel.name); const [name, setName] = useState(channel.name);
const [selectedDevices, setSelectedDevices] = useState<string[]>(channel.device_ids); const [selectedDevices, setSelectedDevices] = useState<string[]>(channel.device_ids);
const [selectedVoices, setSelectedVoices] = useState<string[]>(channelVoices); const [selectedVoices, setSelectedVoices] = useState<string[]>(channelVoices);
@@ -560,16 +560,16 @@ function EditChannelDialog({
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl"> <DialogContent className="max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle>Edit Channel</DialogTitle> <DialogTitle>{t('audioChannels.editDialog.title')}</DialogTitle>
<DialogDescription>Update channel settings and voice assignments.</DialogDescription> <DialogDescription>{t('audioChannels.editDialog.description')}</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<Label htmlFor="edit-channel-name">Channel Name</Label> <Label htmlFor="edit-channel-name">{t('audioChannels.fields.name')}</Label>
<Input id="edit-channel-name" value={name} onChange={(e) => setName(e.target.value)} /> <Input id="edit-channel-name" value={name} onChange={(e) => setName(e.target.value)} />
</div> </div>
<div> <div>
<Label>Output Devices</Label> <Label>{t('audioChannels.labels.outputDevices')}</Label>
<Select <Select
value="" value=""
onValueChange={(value) => { onValueChange={(value) => {
@@ -579,12 +579,12 @@ function EditChannelDialog({
}} }}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Add device" /> <SelectValue placeholder={t('audioChannels.addDevice')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{devices.map((device) => ( {devices.map((device) => (
<SelectItem key={device.id} value={device.id}> <SelectItem key={device.id} value={device.id}>
{device.name} {device.is_default && '(default)'} {device.name} {device.is_default && `(${t('audioChannels.defaultSuffix')})`}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -615,7 +615,7 @@ function EditChannelDialog({
)} )}
</div> </div>
<div> <div>
<Label>Assigned Voices</Label> <Label>{t('audioChannels.labels.assignedVoices')}</Label>
<Select <Select
value="" value=""
onValueChange={(value) => { onValueChange={(value) => {
@@ -625,7 +625,7 @@ function EditChannelDialog({
}} }}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Add voice" /> <SelectValue placeholder={t('audioChannels.addVoice')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{profiles.map((profile) => ( {profiles.map((profile) => (
@@ -663,10 +663,10 @@ function EditChannelDialog({
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}> <Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel {t('common.cancel')}
</Button> </Button>
<Button onClick={handleSubmit} disabled={!name.trim()}> <Button onClick={handleSubmit} disabled={!name.trim()}>
Save {t('common.save')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
+40
View File
@@ -21,6 +21,46 @@
"settings": "Settings", "settings": "Settings",
"updateBadge": "Update" "updateBadge": "Update"
}, },
"audioChannels": {
"title": "Audio Channels",
"newChannel": "New Channel",
"loading": "Loading…",
"confirmDelete": "Delete this channel?",
"noVoicesAssigned": "No voices assigned",
"selectDevice": "Select device",
"addDevice": "Add device",
"addVoice": "Add voice",
"defaultSuffix": "default",
"empty": {
"message": "No audio channels yet. Create your first channel to route voices to specific devices.",
"action": "Create Channel"
},
"labels": {
"outputDevices": "Output Devices",
"assignedVoices": "Assigned Voices"
},
"devices": {
"title": "Available Devices",
"defaultNote": "Default channel uses system default device",
"toggleHint": "Click devices to add or remove them from the selected channel",
"selectHint": "Select a channel to assign devices",
"empty": "No audio devices found",
"requiresTauri": "Audio device selection requires Tauri"
},
"fields": {
"name": "Channel Name",
"namePlaceholder": "e.g., Virtual Cable, Broadcast"
},
"createDialog": {
"title": "Create Audio Channel",
"description": "Create a new audio channel (bus) to route voices to specific output devices.",
"action": "Create"
},
"editDialog": {
"title": "Edit Channel",
"description": "Update channel settings and voice assignments."
}
},
"profileForm": { "profileForm": {
"createTitle": "Create Voice", "createTitle": "Create Voice",
"editTitle": "Edit Voice", "editTitle": "Edit Voice",
@@ -21,6 +21,46 @@
"settings": "设置", "settings": "设置",
"updateBadge": "更新" "updateBadge": "更新"
}, },
"audioChannels": {
"title": "音频通道",
"newChannel": "新建通道",
"loading": "加载中…",
"confirmDelete": "删除此通道?",
"noVoicesAssigned": "未分配声音",
"selectDevice": "选择设备",
"addDevice": "添加设备",
"addVoice": "添加声音",
"defaultSuffix": "默认",
"empty": {
"message": "暂无音频通道。创建您的第一个通道,将声音路由到特定设备。",
"action": "创建通道"
},
"labels": {
"outputDevices": "输出设备",
"assignedVoices": "已分配声音"
},
"devices": {
"title": "可用设备",
"defaultNote": "默认通道使用系统默认设备",
"toggleHint": "点击设备以将其添加到或从选定通道中移除",
"selectHint": "选择通道以分配设备",
"empty": "未找到音频设备",
"requiresTauri": "音频设备选择需要 Tauri"
},
"fields": {
"name": "通道名称",
"namePlaceholder": "例如:虚拟线缆、广播"
},
"createDialog": {
"title": "创建音频通道",
"description": "创建新的音频通道(总线),将声音路由到特定的输出设备。",
"action": "创建"
},
"editDialog": {
"title": "编辑通道",
"description": "更新通道设置和声音分配。"
}
},
"profileForm": { "profileForm": {
"createTitle": "创建声音", "createTitle": "创建声音",
"editTitle": "编辑声音", "editTitle": "编辑声音",