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 { Check, CheckCircle2, Edit, Plus, Speaker, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
@@ -33,6 +34,7 @@ interface AudioDevice {
}
export function AudioTab() {
const { t } = useTranslation();
const platform = usePlatform();
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [editingChannel, setEditingChannel] = useState<string | null>(null);
@@ -119,14 +121,14 @@ export function AudioTab() {
if (channelsLoading || devicesLoading) {
return (
<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>
);
}
const handleChannelDelete = async (e: React.MouseEvent, channelId: string) => {
e.stopPropagation();
if (await confirm('Delete this channel?')) {
if (await confirm(t('audioChannels.confirmDelete'))) {
deleteChannel.mutate(channelId);
}
};
@@ -140,10 +142,10 @@ export function AudioTab() {
return (
<div className="h-full flex flex-col">
<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)}>
<Plus className="h-4 w-4 mr-2" />
New Channel
{t('audioChannels.newChannel')}
</Button>
</div>
@@ -158,13 +160,10 @@ export function AudioTab() {
{allChannels.length === 0 ? (
<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" />
<p className="text-muted-foreground mb-4">
No audio channels yet. Create your first channel to route voices to specific
devices.
</p>
<p className="text-muted-foreground mb-4">{t('audioChannels.empty.message')}</p>
<Button onClick={() => setCreateDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
Create Channel
{t('audioChannels.empty.action')}
</Button>
</div>
) : (
@@ -195,7 +194,7 @@ export function AudioTab() {
<div className="space-y-2.5 ml-10">
<div>
<div className="text-xs font-medium text-muted-foreground mb-1">
Output Devices
{t('audioChannels.labels.outputDevices')}
</div>
<div className="flex flex-wrap gap-1.5">
{channel.device_ids.length > 0
@@ -224,7 +223,7 @@ export function AudioTab() {
<div>
<div className="text-xs font-medium text-muted-foreground mb-1">
Assigned Voices
{t('audioChannels.labels.assignedVoices')}
</div>
<ChannelVoicesList channelId={channel.id} />
</div>
@@ -270,13 +269,13 @@ export function AudioTab() {
)}
>
<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">
{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'}
? t('audioChannels.devices.defaultNote')
: t('audioChannels.devices.toggleHint')
: t('audioChannels.devices.selectHint')}
</p>
</div>
{allDevices.length > 0 ? (
@@ -344,8 +343,8 @@ export function AudioTab() {
<CheckCircle2 className="h-12 w-12 text-muted-foreground mb-4" />
<p className="text-muted-foreground text-center">
{platform.metadata.isTauri
? 'No audio devices found'
: 'Audio device selection requires Tauri'}
? t('audioChannels.devices.empty')
: t('audioChannels.devices.requiresTauri')}
</p>
</div>
)}
@@ -394,6 +393,7 @@ export function AudioTab() {
}
function ChannelVoicesList({ channelId }: { channelId: string }) {
const { t } = useTranslation();
const { data: voices } = useQuery({
queryKey: ['channel-voices', channelId],
queryFn: () => apiClient.getChannelVoices(channelId),
@@ -416,7 +416,7 @@ function ChannelVoicesList({ channelId }: { channelId: string }) {
</Badge>
))
) : (
<span className="text-sm text-muted-foreground">No voices assigned</span>
<span className="text-sm text-muted-foreground">{t('audioChannels.noVoicesAssigned')}</span>
)}
</div>
);
@@ -430,6 +430,7 @@ interface CreateChannelDialogProps {
}
function CreateChannelDialog({ open, onOpenChange, devices, onCreate }: CreateChannelDialogProps) {
const { t } = useTranslation();
const [name, setName] = useState('');
const [selectedDevices, setSelectedDevices] = useState<string[]>([]);
@@ -445,23 +446,21 @@ function CreateChannelDialog({ open, onOpenChange, devices, onCreate }: CreateCh
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Audio Channel</DialogTitle>
<DialogDescription>
Create a new audio channel (bus) to route voices to specific output devices.
</DialogDescription>
<DialogTitle>{t('audioChannels.createDialog.title')}</DialogTitle>
<DialogDescription>{t('audioChannels.createDialog.description')}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="channel-name">Channel Name</Label>
<Label htmlFor="channel-name">{t('audioChannels.fields.name')}</Label>
<Input
id="channel-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., Virtual Cable, Broadcast"
placeholder={t('audioChannels.fields.namePlaceholder')}
/>
</div>
<div>
<Label>Output Devices</Label>
<Label>{t('audioChannels.labels.outputDevices')}</Label>
<Select
value={selectedDevices[0] || ''}
onValueChange={(value) => {
@@ -471,12 +470,12 @@ function CreateChannelDialog({ open, onOpenChange, devices, onCreate }: CreateCh
}}
>
<SelectTrigger>
<SelectValue placeholder="Select device" />
<SelectValue placeholder={t('audioChannels.selectDevice')} />
</SelectTrigger>
<SelectContent>
{devices.map((device) => (
<SelectItem key={device.id} value={device.id}>
{device.name} {device.is_default && '(default)'}
{device.name} {device.is_default && `(${t('audioChannels.defaultSuffix')})`}
</SelectItem>
))}
</SelectContent>
@@ -509,10 +508,10 @@ function CreateChannelDialog({ open, onOpenChange, devices, onCreate }: CreateCh
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
{t('common.cancel')}
</Button>
<Button onClick={handleSubmit} disabled={!name.trim()}>
Create
{t('audioChannels.createDialog.action')}
</Button>
</DialogFooter>
</DialogContent>
@@ -545,6 +544,7 @@ function EditChannelDialog({
onUpdate,
onSetVoices,
}: EditChannelDialogProps) {
const { t } = useTranslation();
const [name, setName] = useState(channel.name);
const [selectedDevices, setSelectedDevices] = useState<string[]>(channel.device_ids);
const [selectedVoices, setSelectedVoices] = useState<string[]>(channelVoices);
@@ -560,16 +560,16 @@ function EditChannelDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Edit Channel</DialogTitle>
<DialogDescription>Update channel settings and voice assignments.</DialogDescription>
<DialogTitle>{t('audioChannels.editDialog.title')}</DialogTitle>
<DialogDescription>{t('audioChannels.editDialog.description')}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<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)} />
</div>
<div>
<Label>Output Devices</Label>
<Label>{t('audioChannels.labels.outputDevices')}</Label>
<Select
value=""
onValueChange={(value) => {
@@ -579,12 +579,12 @@ function EditChannelDialog({
}}
>
<SelectTrigger>
<SelectValue placeholder="Add device" />
<SelectValue placeholder={t('audioChannels.addDevice')} />
</SelectTrigger>
<SelectContent>
{devices.map((device) => (
<SelectItem key={device.id} value={device.id}>
{device.name} {device.is_default && '(default)'}
{device.name} {device.is_default && `(${t('audioChannels.defaultSuffix')})`}
</SelectItem>
))}
</SelectContent>
@@ -615,7 +615,7 @@ function EditChannelDialog({
)}
</div>
<div>
<Label>Assigned Voices</Label>
<Label>{t('audioChannels.labels.assignedVoices')}</Label>
<Select
value=""
onValueChange={(value) => {
@@ -625,7 +625,7 @@ function EditChannelDialog({
}}
>
<SelectTrigger>
<SelectValue placeholder="Add voice" />
<SelectValue placeholder={t('audioChannels.addVoice')} />
</SelectTrigger>
<SelectContent>
{profiles.map((profile) => (
@@ -663,10 +663,10 @@ function EditChannelDialog({
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
{t('common.cancel')}
</Button>
<Button onClick={handleSubmit} disabled={!name.trim()}>
Save
{t('common.save')}
</Button>
</DialogFooter>
</DialogContent>
+40
View File
@@ -21,6 +21,46 @@
"settings": "Settings",
"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": {
"createTitle": "Create Voice",
"editTitle": "Edit Voice",
@@ -21,6 +21,46 @@
"settings": "设置",
"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": {
"createTitle": "创建声音",
"editTitle": "编辑声音",