This commit is contained in:
2026-02-25 06:59:29 +00:00
parent 88fff9efae
commit acd7713c86
37 changed files with 1581 additions and 69 deletions
@@ -0,0 +1,168 @@
import { useState, useEffect, useRef } from 'react';
import { RefreshCw, Play, Square, Loader2 } from 'lucide-react';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useClient } from 'hooks/useClient';
import { useSettings } from 'state/useSettings';
type VoiceGroup = { label: string; voices: string[] };
type TtsConfig = {
provider: string;
url: string;
apiKey?: string;
model: string;
voice: string;
};
const SERVER_DEFAULT = '__server_default__';
export const VoicePreference = () => {
const client = useClient();
const { settings, saveSettings } = useSettings();
const [voices, setVoices] = useState<string[]>([]);
const [groups, setGroups] = useState<VoiceGroup[]>([]);
const [loading, setLoading] = useState(false);
const [serverConfig, setServerConfig] = useState<TtsConfig | null>(null);
const [listening, setListening] = useState<'idle' | 'loading' | 'playing'>('idle');
const audioRef = useRef<HTMLAudioElement | null>(null);
const fetchConfig = async () => {
try {
const config = await client.get<TtsConfig | null>('/server-settings/tts');
setServerConfig(config);
return config;
} catch {
return null;
}
};
const fetchVoices = async (config: TtsConfig) => {
setLoading(true);
try {
const res = await client.post<{ voices?: string[]; groups?: VoiceGroup[] }>('/server-settings/tts/voices', {
provider: config.provider,
url: config.url,
apiKey: config.apiKey || undefined,
model: config.model || undefined,
});
setVoices(res.voices ?? []);
setGroups(res.groups ?? []);
} catch {
setVoices([]);
setGroups([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchConfig().then((config) => {
if (config) fetchVoices(config);
});
}, []);
const handleChange = (value: string) => {
const voice = value === SERVER_DEFAULT ? null : value;
saveSettings({ ...settings, tts: { voice } });
};
const handleRefresh = () => {
if (serverConfig) fetchVoices(serverConfig);
};
const handleListen = async () => {
if (listening === 'loading') return;
if (listening === 'playing') {
audioRef.current?.pause();
audioRef.current = null;
setListening('idle');
return;
}
if (!serverConfig) return;
setListening('loading');
try {
const effectiveVoice = settings.tts.voice ?? serverConfig.voice;
const res = await fetch(`${client.baseUrl}/server-settings/tts/test`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${client.token}` },
body: JSON.stringify({ ...serverConfig, voice: effectiveVoice }),
});
if (!res.ok || res.headers.get('content-type')?.includes('json')) {
setListening('idle');
return;
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const audio = new Audio(url);
audioRef.current = audio;
audio.onended = () => { audioRef.current = null; setListening('idle'); URL.revokeObjectURL(url); };
audio.onerror = () => { audioRef.current = null; setListening('idle'); URL.revokeObjectURL(url); };
await audio.play();
setListening('playing');
} catch {
setListening('idle');
}
};
const selectedValue = settings.tts.voice ?? SERVER_DEFAULT;
const prettify = (v: string) => v.replace(/^[a-z]{2}_/, '').replace(/^\w/, (c) => c.toUpperCase());
return (
<div className="grid gap-4">
<Label className="grid gap-2">
<div className="flex items-center gap-1.5">
<span className="text-duck-dark/70 dark:text-foreground/70">Voice</span>
<button
type="button"
onClick={handleRefresh}
disabled={loading}
className="p-0.5 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors disabled:opacity-40"
title="Refresh voices"
>
<RefreshCw className={`h-3 w-3 text-duck-dark/50 dark:text-foreground/50 ${loading ? 'animate-spin' : ''}`} />
</button>
</div>
<div className="flex items-center gap-2">
<Select value={selectedValue} onValueChange={handleChange}>
<SelectTrigger className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground">
<SelectValue placeholder="Server default" />
</SelectTrigger>
<SelectContent className="z-[600] max-h-[300px]">
<SelectItem value={SERVER_DEFAULT}>Server default{serverConfig?.voice ? ` (${prettify(serverConfig.voice)})` : ''}</SelectItem>
{groups.length > 0
? groups.map((g) => (
<SelectGroup key={g.label}>
<SelectLabel className="text-xs font-semibold text-duck-dark/50 dark:text-foreground/50">{g.label}</SelectLabel>
{g.voices.map((v) => (
<SelectItem key={v} value={v}>{prettify(v)}</SelectItem>
))}
</SelectGroup>
))
: voices.map((v) => (
<SelectItem key={v} value={v}>{v}</SelectItem>
))
}
</SelectContent>
</Select>
<button
type="button"
onClick={handleListen}
disabled={listening === 'loading' || !serverConfig}
className="shrink-0 h-11 w-11 flex items-center justify-center rounded-md border border-duck-dark/20 bg-background/60 hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors disabled:opacity-40 disabled:cursor-default"
title={listening === 'playing' ? 'Stop' : 'Listen'}
>
{listening === 'loading' ? (
<Loader2 className="h-4 w-4 text-duck-dark/70 dark:text-foreground/70 animate-spin" />
) : listening === 'playing' ? (
<Square className="h-4 w-4 text-duck-dark/70 dark:text-foreground/70" />
) : (
<Play className="h-4 w-4 text-duck-dark/70 dark:text-foreground/70" />
)}
</button>
</div>
<span className="text-xs text-duck-dark/40 dark:text-foreground/40">Choose a voice for text-to-speech. Leave as server default to use the admin-configured voice.</span>
</Label>
</div>
);
};