tts-config-popover.tsx 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183
  1. 'use client';
  2. import { useState, useCallback, useMemo } from 'react';
  3. import { Volume2, Play, Loader2 } from 'lucide-react';
  4. import { toast } from 'sonner';
  5. import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
  6. import {
  7. Select,
  8. SelectContent,
  9. SelectItem,
  10. SelectTrigger,
  11. SelectValue,
  12. } from '@/components/ui/select';
  13. import { Switch } from '@/components/ui/switch';
  14. import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
  15. import { cn } from '@/lib/utils';
  16. import { useI18n } from '@/lib/hooks/use-i18n';
  17. import { useSettingsStore } from '@/lib/store/settings';
  18. import { getTTSVoices } from '@/lib/audio/constants';
  19. import { useTTSPreview } from '@/lib/audio/use-tts-preview';
  20. /** Extract the English name from voice name format "ChineseName (English)" */
  21. function getVoiceDisplayName(name: string, lang: string): string {
  22. if (lang === 'en-US') {
  23. const match = name.match(/\(([^)]+)\)/);
  24. return match ? match[1] : name;
  25. }
  26. return name;
  27. }
  28. export function TtsConfigPopover() {
  29. const { t, locale } = useI18n();
  30. const [open, setOpen] = useState(false);
  31. const { previewing, startPreview, stopPreview } = useTTSPreview();
  32. const ttsEnabled = useSettingsStore((s) => s.ttsEnabled);
  33. const setTTSEnabled = useSettingsStore((s) => s.setTTSEnabled);
  34. const ttsProviderId = useSettingsStore((s) => s.ttsProviderId);
  35. const ttsVoice = useSettingsStore((s) => s.ttsVoice);
  36. const ttsSpeed = useSettingsStore((s) => s.ttsSpeed);
  37. const ttsProvidersConfig = useSettingsStore((s) => s.ttsProvidersConfig);
  38. const setTTSVoice = useSettingsStore((s) => s.setTTSVoice);
  39. const voices = getTTSVoices(ttsProviderId);
  40. const localizedVoices = useMemo(
  41. () =>
  42. voices.map((v) => ({
  43. ...v,
  44. displayName: getVoiceDisplayName(v.name, locale),
  45. })),
  46. [voices, locale],
  47. );
  48. const pillCls =
  49. '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';
  50. const handlePreview = useCallback(async () => {
  51. if (previewing) {
  52. stopPreview();
  53. return;
  54. }
  55. try {
  56. const providerConfig = ttsProvidersConfig[ttsProviderId];
  57. await startPreview({
  58. text: t('settings.ttsTestTextDefault'),
  59. providerId: ttsProviderId,
  60. modelId: providerConfig?.modelId,
  61. voice: ttsVoice,
  62. speed: ttsSpeed,
  63. apiKey: providerConfig?.apiKey,
  64. baseUrl: providerConfig?.baseUrl,
  65. });
  66. } catch (error) {
  67. const message =
  68. error instanceof Error && error.message ? error.message : t('settings.ttsTestFailed');
  69. toast.error(message);
  70. }
  71. }, [
  72. previewing,
  73. startPreview,
  74. stopPreview,
  75. t,
  76. ttsProviderId,
  77. ttsProvidersConfig,
  78. ttsSpeed,
  79. ttsVoice,
  80. ]);
  81. const handleOpenChange = useCallback(
  82. (nextOpen: boolean) => {
  83. if (!nextOpen) {
  84. stopPreview();
  85. }
  86. setOpen(nextOpen);
  87. },
  88. [stopPreview],
  89. );
  90. return (
  91. <Popover open={open} onOpenChange={handleOpenChange}>
  92. <Tooltip>
  93. <TooltipTrigger asChild>
  94. <PopoverTrigger asChild>
  95. <button
  96. className={cn(
  97. pillCls,
  98. ttsEnabled
  99. ? 'bg-emerald-50 dark:bg-emerald-950/30 text-emerald-700 dark:text-emerald-300 border-emerald-200/60 dark:border-emerald-700/50'
  100. : 'border-border/50 text-muted-foreground/70 hover:text-foreground hover:bg-muted/60',
  101. )}
  102. >
  103. <Volume2 className="size-3.5" />
  104. {ttsEnabled && (
  105. <span className="max-w-[60px] truncate">
  106. {localizedVoices.find((v) => v.id === ttsVoice)?.displayName || ttsVoice}
  107. </span>
  108. )}
  109. </button>
  110. </PopoverTrigger>
  111. </TooltipTrigger>
  112. <TooltipContent>{t('toolbar.ttsHint')}</TooltipContent>
  113. </Tooltip>
  114. <PopoverContent align="start" className="w-[280px] p-0">
  115. {/* Header with toggle */}
  116. <div className="flex items-center gap-2.5 px-3.5 py-3 border-b border-border/40">
  117. <Volume2
  118. className={cn(
  119. 'size-4 shrink-0',
  120. ttsEnabled ? 'text-emerald-600 dark:text-emerald-400' : 'text-muted-foreground',
  121. )}
  122. />
  123. <span
  124. className={cn('flex-1 text-sm font-medium', !ttsEnabled && 'text-muted-foreground')}
  125. >
  126. {t('toolbar.ttsTitle')}
  127. </span>
  128. <Switch
  129. checked={ttsEnabled}
  130. onCheckedChange={setTTSEnabled}
  131. className="scale-[0.85] origin-right"
  132. />
  133. </div>
  134. {/* Config body */}
  135. {ttsEnabled && (
  136. <div className="px-3.5 py-3 space-y-3">
  137. {/* Voice + Preview row */}
  138. <div className="flex items-center gap-2">
  139. <Select value={ttsVoice} onValueChange={setTTSVoice}>
  140. <SelectTrigger className="h-7 text-xs flex-1 min-w-0">
  141. <SelectValue />
  142. </SelectTrigger>
  143. <SelectContent>
  144. {localizedVoices.map((v) => (
  145. <SelectItem key={v.id} value={v.id} className="text-xs">
  146. {v.displayName}
  147. </SelectItem>
  148. ))}
  149. </SelectContent>
  150. </Select>
  151. <button
  152. onClick={handlePreview}
  153. className={cn(
  154. 'inline-flex items-center gap-1 rounded-md px-2 py-1 text-[11px] font-medium transition-all shrink-0',
  155. previewing
  156. ? 'bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-300'
  157. : 'bg-muted/60 text-muted-foreground hover:bg-muted hover:text-foreground',
  158. )}
  159. >
  160. {previewing ? (
  161. <Loader2 className="size-3 animate-spin" />
  162. ) : (
  163. <Play className="size-3" />
  164. )}
  165. {previewing ? t('toolbar.ttsPreviewing') : t('toolbar.ttsPreview')}
  166. </button>
  167. </div>
  168. </div>
  169. )}
  170. </PopoverContent>
  171. </Popover>
  172. );
  173. }