| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183 |
- 'use client';
- import { useState, useCallback, useMemo } from 'react';
- import { Volume2, Play, Loader2 } from 'lucide-react';
- import { toast } from 'sonner';
- import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
- import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
- } from '@/components/ui/select';
- import { Switch } from '@/components/ui/switch';
- import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
- import { cn } from '@/lib/utils';
- import { useI18n } from '@/lib/hooks/use-i18n';
- import { useSettingsStore } from '@/lib/store/settings';
- import { getTTSVoices } from '@/lib/audio/constants';
- import { useTTSPreview } from '@/lib/audio/use-tts-preview';
- /** Extract the English name from voice name format "ChineseName (English)" */
- function getVoiceDisplayName(name: string, lang: string): string {
- if (lang === 'en-US') {
- const match = name.match(/\(([^)]+)\)/);
- return match ? match[1] : name;
- }
- return name;
- }
- export function TtsConfigPopover() {
- const { t, locale } = useI18n();
- const [open, setOpen] = useState(false);
- const { previewing, startPreview, stopPreview } = useTTSPreview();
- const ttsEnabled = useSettingsStore((s) => s.ttsEnabled);
- const setTTSEnabled = useSettingsStore((s) => s.setTTSEnabled);
- const ttsProviderId = useSettingsStore((s) => s.ttsProviderId);
- const ttsVoice = useSettingsStore((s) => s.ttsVoice);
- const ttsSpeed = useSettingsStore((s) => s.ttsSpeed);
- const ttsProvidersConfig = useSettingsStore((s) => s.ttsProvidersConfig);
- const setTTSVoice = useSettingsStore((s) => s.setTTSVoice);
- const voices = getTTSVoices(ttsProviderId);
- const localizedVoices = useMemo(
- () =>
- voices.map((v) => ({
- ...v,
- displayName: getVoiceDisplayName(v.name, locale),
- })),
- [voices, locale],
- );
- const pillCls =
- 'inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition-all cursor-pointer select-none whitespace-nowrap border';
- const handlePreview = useCallback(async () => {
- if (previewing) {
- stopPreview();
- return;
- }
- try {
- const providerConfig = ttsProvidersConfig[ttsProviderId];
- await startPreview({
- text: t('settings.ttsTestTextDefault'),
- providerId: ttsProviderId,
- modelId: providerConfig?.modelId,
- voice: ttsVoice,
- speed: ttsSpeed,
- apiKey: providerConfig?.apiKey,
- baseUrl: providerConfig?.baseUrl,
- });
- } catch (error) {
- const message =
- error instanceof Error && error.message ? error.message : t('settings.ttsTestFailed');
- toast.error(message);
- }
- }, [
- previewing,
- startPreview,
- stopPreview,
- t,
- ttsProviderId,
- ttsProvidersConfig,
- ttsSpeed,
- ttsVoice,
- ]);
- const handleOpenChange = useCallback(
- (nextOpen: boolean) => {
- if (!nextOpen) {
- stopPreview();
- }
- setOpen(nextOpen);
- },
- [stopPreview],
- );
- return (
- <Popover open={open} onOpenChange={handleOpenChange}>
- <Tooltip>
- <TooltipTrigger asChild>
- <PopoverTrigger asChild>
- <button
- className={cn(
- pillCls,
- ttsEnabled
- ? 'bg-emerald-50 dark:bg-emerald-950/30 text-emerald-700 dark:text-emerald-300 border-emerald-200/60 dark:border-emerald-700/50'
- : 'border-border/50 text-muted-foreground/70 hover:text-foreground hover:bg-muted/60',
- )}
- >
- <Volume2 className="size-3.5" />
- {ttsEnabled && (
- <span className="max-w-[60px] truncate">
- {localizedVoices.find((v) => v.id === ttsVoice)?.displayName || ttsVoice}
- </span>
- )}
- </button>
- </PopoverTrigger>
- </TooltipTrigger>
- <TooltipContent>{t('toolbar.ttsHint')}</TooltipContent>
- </Tooltip>
- <PopoverContent align="start" className="w-[280px] p-0">
- {/* Header with toggle */}
- <div className="flex items-center gap-2.5 px-3.5 py-3 border-b border-border/40">
- <Volume2
- className={cn(
- 'size-4 shrink-0',
- ttsEnabled ? 'text-emerald-600 dark:text-emerald-400' : 'text-muted-foreground',
- )}
- />
- <span
- className={cn('flex-1 text-sm font-medium', !ttsEnabled && 'text-muted-foreground')}
- >
- {t('toolbar.ttsTitle')}
- </span>
- <Switch
- checked={ttsEnabled}
- onCheckedChange={setTTSEnabled}
- className="scale-[0.85] origin-right"
- />
- </div>
- {/* Config body */}
- {ttsEnabled && (
- <div className="px-3.5 py-3 space-y-3">
- {/* Voice + Preview row */}
- <div className="flex items-center gap-2">
- <Select value={ttsVoice} onValueChange={setTTSVoice}>
- <SelectTrigger className="h-7 text-xs flex-1 min-w-0">
- <SelectValue />
- </SelectTrigger>
- <SelectContent>
- {localizedVoices.map((v) => (
- <SelectItem key={v.id} value={v.id} className="text-xs">
- {v.displayName}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- <button
- onClick={handlePreview}
- className={cn(
- 'inline-flex items-center gap-1 rounded-md px-2 py-1 text-[11px] font-medium transition-all shrink-0',
- previewing
- ? 'bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-300'
- : 'bg-muted/60 text-muted-foreground hover:bg-muted hover:text-foreground',
- )}
- >
- {previewing ? (
- <Loader2 className="size-3 animate-spin" />
- ) : (
- <Play className="size-3" />
- )}
- {previewing ? t('toolbar.ttsPreviewing') : t('toolbar.ttsPreview')}
- </button>
- </div>
- </div>
- )}
- </PopoverContent>
- </Popover>
- );
- }
|