import { useState, useCallback, useMemo, Fragment } from 'react'; import type { LucideIcon } from 'lucide-react'; import { Image as ImageIcon, Video, Volume2, Mic, SlidersHorizontal, ChevronRight, } from 'lucide-react'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { Switch } from '@/components/ui/switch'; import { cn } from '@/lib/utils'; import { useI18n } from '@/lib/hooks/use-i18n'; import { useSettingsStore } from '@/lib/store/settings'; import { IMAGE_PROVIDERS } from '@/lib/media/image-providers'; import { VIDEO_PROVIDERS } from '@/lib/media/video-providers'; import { CUSTOM_ASR_DEFAULT_LANGUAGES } from '@/lib/audio/constants'; import { ASR_PROVIDERS, getASRSupportedLanguages } from '@/lib/audio/constants'; import type { ImageProviderId, VideoProviderId } from '@/lib/media/types'; import type { ASRProviderId } from '@/lib/audio/types'; import { isCustomASRProvider } from '@/lib/audio/types'; import type { SettingsSection } from '@/lib/types/settings'; interface MediaPopoverProps { onSettingsOpen: (section: SettingsSection) => void; } // ─── Provider icon maps ─── const IMAGE_PROVIDER_ICONS: Record = { seedream: '/logos/doubao.svg', 'openai-image': '/logos/openai.svg', 'qwen-image': '/logos/bailian.svg', 'nano-banana': '/logos/gemini.svg', 'grok-image': '/logos/grok.svg', }; const VIDEO_PROVIDER_ICONS: Record = { seedance: '/logos/doubao.svg', kling: '/logos/kling.svg', veo: '/logos/gemini.svg', sora: '/logos/openai.svg', 'grok-video': '/logos/grok.svg', }; type TabId = 'image' | 'video' | 'tts' | 'asr'; const TABS: Array<{ id: TabId; icon: LucideIcon; label: string }> = [ { id: 'image', icon: ImageIcon, label: 'Image' }, { id: 'video', icon: Video, label: 'Video' }, { id: 'tts', icon: Volume2, label: 'TTS' }, { id: 'asr', icon: Mic, label: 'ASR' }, ]; export function MediaPopover({ onSettingsOpen }: MediaPopoverProps) { const { t } = useI18n(); const [open, setOpen] = useState(false); const [activeTab, setActiveTab] = useState('image'); // ─── Store ─── const imageGenerationEnabled = useSettingsStore((s) => s.imageGenerationEnabled); const videoGenerationEnabled = useSettingsStore((s) => s.videoGenerationEnabled); const ttsEnabled = useSettingsStore((s) => s.ttsEnabled); const asrEnabled = useSettingsStore((s) => s.asrEnabled); const setImageGenerationEnabled = useSettingsStore((s) => s.setImageGenerationEnabled); const setVideoGenerationEnabled = useSettingsStore((s) => s.setVideoGenerationEnabled); const setTTSEnabled = useSettingsStore((s) => s.setTTSEnabled); const setASREnabled = useSettingsStore((s) => s.setASREnabled); const imageProviderId = useSettingsStore((s) => s.imageProviderId); const imageModelId = useSettingsStore((s) => s.imageModelId); const imageProvidersConfig = useSettingsStore((s) => s.imageProvidersConfig); const setImageProvider = useSettingsStore((s) => s.setImageProvider); const setImageModelId = useSettingsStore((s) => s.setImageModelId); const videoProviderId = useSettingsStore((s) => s.videoProviderId); const videoModelId = useSettingsStore((s) => s.videoModelId); const videoProvidersConfig = useSettingsStore((s) => s.videoProvidersConfig); const setVideoProvider = useSettingsStore((s) => s.setVideoProvider); const setVideoModelId = useSettingsStore((s) => s.setVideoModelId); const asrProviderId = useSettingsStore((s) => s.asrProviderId); const asrLanguage = useSettingsStore((s) => s.asrLanguage); const asrProvidersConfig = useSettingsStore((s) => s.asrProvidersConfig); const setASRProvider = useSettingsStore((s) => s.setASRProvider); const setASRLanguage = useSettingsStore((s) => s.setASRLanguage); const enabledMap: Record = { image: imageGenerationEnabled, video: videoGenerationEnabled, tts: ttsEnabled, asr: asrEnabled, }; const enabledCount = [ imageGenerationEnabled, videoGenerationEnabled, ttsEnabled, asrEnabled, ].filter(Boolean).length; const cfgOk = useCallback( ( configs: Record, id: string, needsKey: boolean, ) => !needsKey || !!configs[id]?.apiKey || !!configs[id]?.isServerConfigured, [], ); // ─── Grouped select data (only available providers) ─── const imageGroups = useMemo( () => Object.values(IMAGE_PROVIDERS) .filter((p) => cfgOk(imageProvidersConfig, p.id, p.requiresApiKey)) .map((p) => ({ groupId: p.id, groupName: p.name, groupIcon: IMAGE_PROVIDER_ICONS[p.id], available: true, items: [...p.models, ...(imageProvidersConfig[p.id]?.customModels || [])].map((m) => ({ id: m.id, name: m.name, })), })), [cfgOk, imageProvidersConfig], ); const videoGroups = useMemo( () => Object.values(VIDEO_PROVIDERS) .filter((p) => cfgOk(videoProvidersConfig, p.id, p.requiresApiKey)) .map((p) => ({ groupId: p.id, groupName: p.name, groupIcon: VIDEO_PROVIDER_ICONS[p.id], available: true, items: [...p.models, ...(videoProvidersConfig[p.id]?.customModels || [])].map((m) => ({ id: m.id, name: m.name, })), })), [cfgOk, videoProvidersConfig], ); // ASR: built-in + custom providers const asrGroups = useMemo(() => { const groups: SelectGroupData[] = []; // Built-in providers for (const p of Object.values(ASR_PROVIDERS)) { if (!cfgOk(asrProvidersConfig, p.id, p.requiresApiKey)) continue; groups.push({ groupId: p.id, groupName: p.name, groupIcon: p.icon, available: true, items: getASRSupportedLanguages(p.id).map((l) => ({ id: l, name: l, })), }); } // Custom providers — only show if at least one model is configured for (const [id, cfg] of Object.entries(asrProvidersConfig)) { if (!isCustomASRProvider(id)) continue; const customModels = cfg.customModels || []; if (customModels.length === 0) continue; const providerName = cfg.customName || id; groups.push({ groupId: id, groupName: providerName, available: true, items: CUSTOM_ASR_DEFAULT_LANGUAGES.map((l) => ({ id: l, name: l })), }); } return groups; }, [asrProvidersConfig, cfgOk]); // Auto-select first enabled tab on open const handleOpenChange = (isOpen: boolean) => { setOpen(isOpen); if (isOpen) { const first = (['image', 'video', 'tts', 'asr'] as TabId[]).find((id) => enabledMap[id]); setActiveTab(first || 'image'); } }; return ( {/* ── Tab bar (segmented control) ── */}
{TABS.map((tab) => { const isActive = activeTab === tab.id; const isEnabled = enabledMap[tab.id]; const Icon = tab.icon; return ( ); })}
{/* ── Tab content ── */}
{activeTab === 'image' && ( { setImageProvider(gid as ImageProviderId); setImageModelId(iid); }} /> )} {activeTab === 'video' && ( { setVideoProvider(gid as VideoProviderId); setVideoModelId(iid); }} /> )} {activeTab === 'tts' && ( )} {activeTab === 'asr' && ( { setASRProvider(gid as ASRProviderId); setASRLanguage(iid); }} /> )}
{/* ── Footer ── */}
); } // ─── Tab panel: header (label + switch) + optional body ─── function TabPanel({ icon: Icon, label, enabled, onToggle, children, }: { icon: LucideIcon; label: string; enabled: boolean; onToggle: (v: boolean) => void; children?: React.ReactNode; }) { return (
{label}
{enabled && children}
); } // ─── Grouped provider+model select ─── interface SelectGroupData { groupId: string; groupName: string; groupIcon?: string; available: boolean; items: Array<{ id: string; name: string }>; } function GroupedSelect({ groups, selectedGroupId, selectedItemId, onSelect, }: { groups: SelectGroupData[]; selectedGroupId: string; selectedItemId: string; onSelect: (groupId: string, itemId: string) => void; }) { const composite = `${selectedGroupId}::${selectedItemId}`; // When multiple groups share the same groupId (e.g. browser-native-tts split by language), // find the sub-group that actually contains the selected item. const selectedGroup = groups.find( (g) => g.groupId === selectedGroupId && g.items.some((item) => item.id === selectedItemId), ) || groups.find((g) => g.groupId === selectedGroupId); return ( ); }