ProfileSettings reaches zero: api keys, profile, voice, dock, languages and the model defaults. Language names now come from Intl.DisplayNames rather than the catalog. The runtime already knows "German" in English and "alemao" in Portuguese, so 28 names would have been 56 hand-written entries that Intl gets right for free -- including for locales not shipped yet. Same reasoning D7 gives for dates and numbers. The tag LIST stays hardcoded: which languages you may claim to speak is a product choice. ApiKeys had toLocaleDateString(undefined, ...), which followed the BROWSER locale -- the exact contradiction D7 describes. It uses useFormat().date now. Its revoked-key toggle hand-rolled a plural with a ternary on n === 1; that is a real plural entry now. Scanner: an Authorization header value like "Bearer <token>" is not a sentence. Excluded by auth-scheme prefix; verified the delta is exactly the four such credentials across both remaining areas.
196 lines
6.9 KiB
TypeScript
196 lines
6.9 KiB
TypeScript
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';
|
|
import { useTranslation } from '@/lib/i18n';
|
|
|
|
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 { t } = useTranslation();
|
|
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">{t('settings.profile.voice.label')}</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={t('settings.profile.voice.refresh')}
|
|
>
|
|
<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={t('settings.profile.voice.serverDefault')} />
|
|
</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' ? t('settings.profile.voice.stop') : t('settings.profile.voice.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">{t('settings.profile.voice.hint')}</span>
|
|
</Label>
|
|
</div>
|
|
);
|
|
};
|