media-popover.tsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588
  1. 'use client';
  2. import { useState, useCallback, useMemo, useEffect, Fragment } from 'react';
  3. import type { LucideIcon } from 'lucide-react';
  4. import {
  5. Image as ImageIcon,
  6. Video,
  7. Volume2,
  8. Mic,
  9. SlidersHorizontal,
  10. ChevronRight,
  11. Play,
  12. Loader2,
  13. } from 'lucide-react';
  14. import { toast } from 'sonner';
  15. import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
  16. import {
  17. Select,
  18. SelectContent,
  19. SelectGroup,
  20. SelectItem,
  21. SelectLabel,
  22. SelectSeparator,
  23. SelectTrigger,
  24. SelectValue,
  25. } from '@/components/ui/select';
  26. import { Slider } from '@/components/ui/slider';
  27. import { Switch } from '@/components/ui/switch';
  28. import { cn } from '@/lib/utils';
  29. import { useI18n } from '@/lib/hooks/use-i18n';
  30. import { useSettingsStore } from '@/lib/store/settings';
  31. import { useTTSPreview } from '@/lib/audio/use-tts-preview';
  32. import { IMAGE_PROVIDERS } from '@/lib/media/image-providers';
  33. import { VIDEO_PROVIDERS } from '@/lib/media/video-providers';
  34. import { TTS_PROVIDERS, getTTSVoices } from '@/lib/audio/constants';
  35. import { ASR_PROVIDERS, getASRSupportedLanguages } from '@/lib/audio/constants';
  36. import type { ImageProviderId, VideoProviderId } from '@/lib/media/types';
  37. import type { TTSProviderId, ASRProviderId } from '@/lib/audio/types';
  38. import type { SettingsSection } from '@/lib/types/settings';
  39. interface MediaPopoverProps {
  40. onSettingsOpen: (section: SettingsSection) => void;
  41. }
  42. // ─── Provider icon maps ───
  43. const IMAGE_PROVIDER_ICONS: Record<string, string> = {
  44. seedream: '/logos/doubao.svg',
  45. 'qwen-image': '/logos/bailian.svg',
  46. 'nano-banana': '/logos/gemini.svg',
  47. 'grok-image': '/logos/grok.svg',
  48. };
  49. const VIDEO_PROVIDER_ICONS: Record<string, string> = {
  50. seedance: '/logos/doubao.svg',
  51. kling: '/logos/kling.svg',
  52. veo: '/logos/gemini.svg',
  53. sora: '/logos/openai.svg',
  54. 'grok-video': '/logos/grok.svg',
  55. };
  56. type TabId = 'image' | 'video' | 'tts' | 'asr';
  57. const LANG_LABELS: Record<string, string> = {
  58. zh: '中文',
  59. en: 'English',
  60. ja: '日本語',
  61. ko: '한국어',
  62. fr: 'Français',
  63. de: 'Deutsch',
  64. es: 'Español',
  65. pt: 'Português',
  66. ru: 'Русский',
  67. it: 'Italiano',
  68. ar: 'العربية',
  69. hi: 'हिन्दी',
  70. };
  71. const TABS: Array<{ id: TabId; icon: LucideIcon; label: string }> = [
  72. { id: 'image', icon: ImageIcon, label: 'Image' },
  73. { id: 'video', icon: Video, label: 'Video' },
  74. { id: 'tts', icon: Volume2, label: 'TTS' },
  75. { id: 'asr', icon: Mic, label: 'ASR' },
  76. ];
  77. /** Localized TTS provider name (mirrors audio-settings.tsx) */
  78. function getTTSProviderName(providerId: TTSProviderId, t: (key: string) => string): string {
  79. const names: Record<TTSProviderId, string> = {
  80. 'openai-tts': t('settings.providerOpenAITTS'),
  81. 'azure-tts': t('settings.providerAzureTTS'),
  82. 'glm-tts': t('settings.providerGLMTTS'),
  83. 'qwen-tts': t('settings.providerQwenTTS'),
  84. 'doubao-tts': t('settings.providerDoubaoTTS'),
  85. 'elevenlabs-tts': t('settings.providerElevenLabsTTS'),
  86. 'minimax-tts': t('settings.providerMiniMaxTTS'),
  87. 'browser-native-tts': t('settings.providerBrowserNativeTTS'),
  88. };
  89. return names[providerId] || providerId;
  90. }
  91. /** Extract the English name from voice name format "ChineseName (English)" */
  92. function getVoiceDisplayName(name: string, lang: string): string {
  93. if (lang === 'en-US') {
  94. const match = name.match(/\(([^)]+)\)/);
  95. return match ? match[1] : name;
  96. }
  97. return name;
  98. }
  99. export function MediaPopover({ onSettingsOpen }: MediaPopoverProps) {
  100. const { t, locale } = useI18n();
  101. const [open, setOpen] = useState(false);
  102. const [activeTab, setActiveTab] = useState<TabId>('image');
  103. const { previewing, startPreview, stopPreview } = useTTSPreview();
  104. // ─── Store ───
  105. const imageGenerationEnabled = useSettingsStore((s) => s.imageGenerationEnabled);
  106. const videoGenerationEnabled = useSettingsStore((s) => s.videoGenerationEnabled);
  107. const ttsEnabled = useSettingsStore((s) => s.ttsEnabled);
  108. const asrEnabled = useSettingsStore((s) => s.asrEnabled);
  109. const setImageGenerationEnabled = useSettingsStore((s) => s.setImageGenerationEnabled);
  110. const setVideoGenerationEnabled = useSettingsStore((s) => s.setVideoGenerationEnabled);
  111. const setTTSEnabled = useSettingsStore((s) => s.setTTSEnabled);
  112. const setASREnabled = useSettingsStore((s) => s.setASREnabled);
  113. const imageProviderId = useSettingsStore((s) => s.imageProviderId);
  114. const imageModelId = useSettingsStore((s) => s.imageModelId);
  115. const imageProvidersConfig = useSettingsStore((s) => s.imageProvidersConfig);
  116. const setImageProvider = useSettingsStore((s) => s.setImageProvider);
  117. const setImageModelId = useSettingsStore((s) => s.setImageModelId);
  118. const videoProviderId = useSettingsStore((s) => s.videoProviderId);
  119. const videoModelId = useSettingsStore((s) => s.videoModelId);
  120. const videoProvidersConfig = useSettingsStore((s) => s.videoProvidersConfig);
  121. const setVideoProvider = useSettingsStore((s) => s.setVideoProvider);
  122. const setVideoModelId = useSettingsStore((s) => s.setVideoModelId);
  123. const ttsProviderId = useSettingsStore((s) => s.ttsProviderId);
  124. const ttsVoice = useSettingsStore((s) => s.ttsVoice);
  125. const ttsSpeed = useSettingsStore((s) => s.ttsSpeed);
  126. const ttsProvidersConfig = useSettingsStore((s) => s.ttsProvidersConfig);
  127. const setTTSProvider = useSettingsStore((s) => s.setTTSProvider);
  128. const setTTSVoice = useSettingsStore((s) => s.setTTSVoice);
  129. const setTTSSpeed = useSettingsStore((s) => s.setTTSSpeed);
  130. const asrProviderId = useSettingsStore((s) => s.asrProviderId);
  131. const asrLanguage = useSettingsStore((s) => s.asrLanguage);
  132. const asrProvidersConfig = useSettingsStore((s) => s.asrProvidersConfig);
  133. const setASRProvider = useSettingsStore((s) => s.setASRProvider);
  134. const setASRLanguage = useSettingsStore((s) => s.setASRLanguage);
  135. const enabledMap: Record<TabId, boolean> = {
  136. image: imageGenerationEnabled,
  137. video: videoGenerationEnabled,
  138. tts: ttsEnabled,
  139. asr: asrEnabled,
  140. };
  141. const enabledCount = [
  142. imageGenerationEnabled,
  143. videoGenerationEnabled,
  144. ttsEnabled,
  145. asrEnabled,
  146. ].filter(Boolean).length;
  147. const cfgOk = (
  148. configs: Record<string, { apiKey?: string; isServerConfigured?: boolean }>,
  149. id: string,
  150. needsKey: boolean,
  151. ) => !needsKey || !!configs[id]?.apiKey || !!configs[id]?.isServerConfigured;
  152. const ttsSpeedRange = TTS_PROVIDERS[ttsProviderId]?.speedRange;
  153. // ─── Dynamic browser voices ───
  154. const [browserVoices, setBrowserVoices] = useState<SpeechSynthesisVoice[]>([]);
  155. useEffect(() => {
  156. if (typeof window === 'undefined' || !window.speechSynthesis) return;
  157. const load = () => setBrowserVoices(window.speechSynthesis.getVoices());
  158. load();
  159. window.speechSynthesis.addEventListener('voiceschanged', load);
  160. return () => window.speechSynthesis.removeEventListener('voiceschanged', load);
  161. }, []);
  162. // ─── Grouped select data (only available providers) ───
  163. const imageGroups = useMemo(
  164. () =>
  165. Object.values(IMAGE_PROVIDERS)
  166. .filter((p) => cfgOk(imageProvidersConfig, p.id, p.requiresApiKey))
  167. .map((p) => ({
  168. groupId: p.id,
  169. groupName: p.name,
  170. groupIcon: IMAGE_PROVIDER_ICONS[p.id],
  171. available: true,
  172. items: [...p.models, ...(imageProvidersConfig[p.id]?.customModels || [])].map((m) => ({
  173. id: m.id,
  174. name: m.name,
  175. })),
  176. })),
  177. [imageProvidersConfig],
  178. );
  179. const videoGroups = useMemo(
  180. () =>
  181. Object.values(VIDEO_PROVIDERS)
  182. .filter((p) => cfgOk(videoProvidersConfig, p.id, p.requiresApiKey))
  183. .map((p) => ({
  184. groupId: p.id,
  185. groupName: p.name,
  186. groupIcon: VIDEO_PROVIDER_ICONS[p.id],
  187. available: true,
  188. items: [...p.models, ...(videoProvidersConfig[p.id]?.customModels || [])].map((m) => ({
  189. id: m.id,
  190. name: m.name,
  191. })),
  192. })),
  193. [videoProvidersConfig],
  194. );
  195. // TTS: grouped by provider, voices as items (matching Image/Video pattern)
  196. // Browser-native voices are split into sub-groups by language.
  197. const ttsGroups = useMemo(() => {
  198. const groups: SelectGroupData[] = [];
  199. for (const p of Object.values(TTS_PROVIDERS)) {
  200. if (p.requiresApiKey && !cfgOk(ttsProvidersConfig, p.id, p.requiresApiKey)) continue;
  201. const providerName = getTTSProviderName(p.id, t);
  202. // For browser-native-tts, split voices by language
  203. if (p.id === 'browser-native-tts' && browserVoices.length > 0) {
  204. const byLang = new Map<string, SpeechSynthesisVoice[]>();
  205. for (const v of browserVoices) {
  206. const langKey = v.lang.split('-')[0]; // "zh-CN" → "zh"
  207. if (!byLang.has(langKey)) byLang.set(langKey, []);
  208. byLang.get(langKey)!.push(v);
  209. }
  210. for (const [langKey, voices] of byLang) {
  211. const langLabel = LANG_LABELS[langKey] || langKey;
  212. groups.push({
  213. groupId: p.id,
  214. groupName: `${providerName} · ${langLabel}`,
  215. groupIcon: p.icon,
  216. available: true,
  217. items: voices.map((v) => ({ id: v.voiceURI, name: v.name })),
  218. });
  219. }
  220. continue;
  221. }
  222. groups.push({
  223. groupId: p.id,
  224. groupName: providerName,
  225. groupIcon: p.icon,
  226. available: true,
  227. items: getTTSVoices(p.id).map((v) => ({
  228. id: v.id,
  229. name: getVoiceDisplayName(v.name, locale),
  230. })),
  231. });
  232. }
  233. return groups;
  234. }, [ttsProvidersConfig, locale, browserVoices, t]);
  235. // TTS preview
  236. const handlePreview = useCallback(async () => {
  237. if (previewing) {
  238. stopPreview();
  239. return;
  240. }
  241. try {
  242. const providerConfig = ttsProvidersConfig[ttsProviderId];
  243. await startPreview({
  244. text: t('settings.ttsTestTextDefault'),
  245. providerId: ttsProviderId,
  246. modelId: providerConfig?.modelId,
  247. voice: ttsVoice,
  248. speed: ttsSpeed,
  249. apiKey: providerConfig?.apiKey,
  250. baseUrl: providerConfig?.baseUrl,
  251. });
  252. } catch (error) {
  253. const message =
  254. error instanceof Error && error.message ? error.message : t('settings.ttsTestFailed');
  255. toast.error(message);
  256. }
  257. }, [
  258. previewing,
  259. startPreview,
  260. stopPreview,
  261. t,
  262. ttsProviderId,
  263. ttsProvidersConfig,
  264. ttsSpeed,
  265. ttsVoice,
  266. ]);
  267. // ASR: only available providers
  268. const asrGroups = useMemo(
  269. () =>
  270. Object.values(ASR_PROVIDERS)
  271. .filter((p) => cfgOk(asrProvidersConfig, p.id, p.requiresApiKey))
  272. .map((p) => ({
  273. groupId: p.id,
  274. groupName: p.name,
  275. groupIcon: p.icon,
  276. available: true,
  277. items: getASRSupportedLanguages(p.id).map((l) => ({
  278. id: l,
  279. name: l,
  280. })),
  281. })),
  282. [asrProvidersConfig],
  283. );
  284. // Auto-select first enabled tab on open
  285. const handleOpenChange = (isOpen: boolean) => {
  286. if (!isOpen) {
  287. stopPreview();
  288. }
  289. setOpen(isOpen);
  290. if (isOpen) {
  291. const first = (['image', 'video', 'tts', 'asr'] as TabId[]).find((id) => enabledMap[id]);
  292. setActiveTab(first || 'image');
  293. }
  294. };
  295. return (
  296. <Popover open={open} onOpenChange={handleOpenChange}>
  297. <PopoverTrigger asChild>
  298. <button
  299. className={cn(
  300. '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',
  301. enabledCount > 0
  302. ? 'bg-violet-100 dark:bg-violet-900/30 text-violet-700 dark:text-violet-300 border-violet-200/60 dark:border-violet-700/50'
  303. : 'text-muted-foreground/70 hover:text-foreground hover:bg-muted/60 border-border/50',
  304. )}
  305. >
  306. <SlidersHorizontal className="size-3.5" />
  307. {imageGenerationEnabled && <ImageIcon className="size-3.5" />}
  308. {videoGenerationEnabled && <Video className="size-3.5" />}
  309. {ttsEnabled && <Volume2 className="size-3.5" />}
  310. {asrEnabled && <Mic className="size-3.5" />}
  311. </button>
  312. </PopoverTrigger>
  313. <PopoverContent align="start" side="bottom" avoidCollisions={false} className="w-80 p-0">
  314. {/* ── Tab bar (segmented control) ── */}
  315. <div className="p-2 pb-0">
  316. <div className="flex gap-0.5 p-0.5 bg-muted/60 rounded-lg">
  317. {TABS.map((tab) => {
  318. const isActive = activeTab === tab.id;
  319. const isEnabled = enabledMap[tab.id];
  320. const Icon = tab.icon;
  321. return (
  322. <button
  323. key={tab.id}
  324. onClick={() => setActiveTab(tab.id)}
  325. className={cn(
  326. 'flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-md text-[11px] font-medium transition-all relative',
  327. isActive
  328. ? 'bg-background text-foreground shadow-sm'
  329. : 'text-muted-foreground hover:text-foreground/80',
  330. )}
  331. >
  332. <Icon className="size-3.5" />
  333. <span className="hidden sm:inline">{tab.label}</span>
  334. {isEnabled && !isActive && (
  335. <span className="absolute top-1 right-1 size-1.5 rounded-full bg-violet-500" />
  336. )}
  337. </button>
  338. );
  339. })}
  340. </div>
  341. </div>
  342. {/* ── Tab content ── */}
  343. <div className="p-3 pt-2.5">
  344. {activeTab === 'image' && (
  345. <TabPanel
  346. icon={ImageIcon}
  347. label={t('media.imageCapability')}
  348. enabled={imageGenerationEnabled}
  349. onToggle={setImageGenerationEnabled}
  350. >
  351. <GroupedSelect
  352. groups={imageGroups}
  353. selectedGroupId={imageProviderId}
  354. selectedItemId={imageModelId}
  355. onSelect={(gid, iid) => {
  356. setImageProvider(gid as ImageProviderId);
  357. setImageModelId(iid);
  358. }}
  359. />
  360. </TabPanel>
  361. )}
  362. {activeTab === 'video' && (
  363. <TabPanel
  364. icon={Video}
  365. label={t('media.videoCapability')}
  366. enabled={videoGenerationEnabled}
  367. onToggle={setVideoGenerationEnabled}
  368. >
  369. <GroupedSelect
  370. groups={videoGroups}
  371. selectedGroupId={videoProviderId}
  372. selectedItemId={videoModelId}
  373. onSelect={(gid, iid) => {
  374. setVideoProvider(gid as VideoProviderId);
  375. setVideoModelId(iid);
  376. }}
  377. />
  378. </TabPanel>
  379. )}
  380. {activeTab === 'tts' && (
  381. <TabPanel
  382. icon={Volume2}
  383. label={t('media.ttsCapability')}
  384. enabled={ttsEnabled}
  385. onToggle={setTTSEnabled}
  386. >
  387. <p className="text-[11px] text-muted-foreground/60">
  388. {t('settings.ttsVoiceConfigHint')}
  389. </p>
  390. </TabPanel>
  391. )}
  392. {activeTab === 'asr' && (
  393. <TabPanel
  394. icon={Mic}
  395. label={t('media.asrCapability')}
  396. enabled={asrEnabled}
  397. onToggle={setASREnabled}
  398. >
  399. <GroupedSelect
  400. groups={asrGroups}
  401. selectedGroupId={asrProviderId}
  402. selectedItemId={asrLanguage}
  403. onSelect={(gid, iid) => {
  404. setASRProvider(gid as ASRProviderId);
  405. setASRLanguage(iid);
  406. }}
  407. />
  408. </TabPanel>
  409. )}
  410. </div>
  411. {/* ── Footer ── */}
  412. <div className="border-t border-border/40">
  413. <button
  414. onClick={() => {
  415. setOpen(false);
  416. onSettingsOpen(activeTab);
  417. }}
  418. className="w-full flex items-center justify-between px-3.5 py-2.5 text-[11px] text-muted-foreground/60 hover:text-muted-foreground transition-colors"
  419. >
  420. <span>{t('toolbar.advancedSettings')}</span>
  421. <ChevronRight className="size-3" />
  422. </button>
  423. </div>
  424. </PopoverContent>
  425. </Popover>
  426. );
  427. }
  428. // ─── Tab panel: header (label + switch) + optional body ───
  429. function TabPanel({
  430. icon: Icon,
  431. label,
  432. enabled,
  433. onToggle,
  434. children,
  435. }: {
  436. icon: LucideIcon;
  437. label: string;
  438. enabled: boolean;
  439. onToggle: (v: boolean) => void;
  440. children?: React.ReactNode;
  441. }) {
  442. return (
  443. <div className="space-y-2.5">
  444. <div className="flex items-center gap-2.5">
  445. <Icon
  446. className={cn(
  447. 'size-4 shrink-0 transition-colors',
  448. enabled ? 'text-violet-600 dark:text-violet-400' : 'text-muted-foreground/50',
  449. )}
  450. />
  451. <span
  452. className={cn(
  453. 'flex-1 text-sm font-medium transition-colors',
  454. !enabled && 'text-muted-foreground',
  455. )}
  456. >
  457. {label}
  458. </span>
  459. <Switch
  460. checked={enabled}
  461. onCheckedChange={onToggle}
  462. className="scale-[0.85] origin-right"
  463. />
  464. </div>
  465. {enabled && children}
  466. </div>
  467. );
  468. }
  469. // ─── Grouped provider+model select ───
  470. interface SelectGroupData {
  471. groupId: string;
  472. groupName: string;
  473. groupIcon?: string;
  474. available: boolean;
  475. items: Array<{ id: string; name: string }>;
  476. }
  477. function GroupedSelect({
  478. groups,
  479. selectedGroupId,
  480. selectedItemId,
  481. onSelect,
  482. }: {
  483. groups: SelectGroupData[];
  484. selectedGroupId: string;
  485. selectedItemId: string;
  486. onSelect: (groupId: string, itemId: string) => void;
  487. }) {
  488. const composite = `${selectedGroupId}::${selectedItemId}`;
  489. // When multiple groups share the same groupId (e.g. browser-native-tts split by language),
  490. // find the sub-group that actually contains the selected item.
  491. const selectedGroup =
  492. groups.find(
  493. (g) => g.groupId === selectedGroupId && g.items.some((item) => item.id === selectedItemId),
  494. ) || groups.find((g) => g.groupId === selectedGroupId);
  495. return (
  496. <Select
  497. value={composite}
  498. onValueChange={(v) => {
  499. const sep = v.indexOf('::');
  500. if (sep === -1) return;
  501. onSelect(v.slice(0, sep), v.slice(sep + 2));
  502. }}
  503. >
  504. <SelectTrigger className="h-8 w-full rounded-lg border-border/40 bg-background/80 hover:bg-muted/40 shadow-none text-xs focus:ring-1 focus:ring-ring/30 px-2.5">
  505. <span className="flex items-center gap-2 min-w-0 flex-1 overflow-hidden">
  506. {selectedGroup?.groupIcon && (
  507. <img src={selectedGroup.groupIcon} alt="" className="size-4 rounded-sm shrink-0" />
  508. )}
  509. <span className="font-medium truncate">{selectedGroup?.groupName}</span>
  510. <span className="text-muted-foreground/40">/</span>
  511. <span className="text-muted-foreground truncate">
  512. <SelectValue />
  513. </span>
  514. </span>
  515. </SelectTrigger>
  516. <SelectContent>
  517. {groups.map((group, i) => (
  518. <Fragment key={`${group.groupId}-${i}`}>
  519. {i > 0 && <SelectSeparator />}
  520. <SelectGroup>
  521. <SelectLabel className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider">
  522. {group.groupIcon && (
  523. <img
  524. src={group.groupIcon}
  525. alt=""
  526. className={cn('size-3.5 rounded-sm', !group.available && 'opacity-40')}
  527. />
  528. )}
  529. {group.groupName}
  530. </SelectLabel>
  531. {group.items.map((item) => (
  532. <SelectItem
  533. key={`${group.groupId}::${item.id}`}
  534. value={`${group.groupId}::${item.id}`}
  535. disabled={!group.available}
  536. className="text-xs"
  537. >
  538. {item.name}
  539. </SelectItem>
  540. ))}
  541. </SelectGroup>
  542. </Fragment>
  543. ))}
  544. </SelectContent>
  545. </Select>
  546. );
  547. }