/** * VoiceCatalogContext * * Loads the central voice/language catalog from the backend exactly once and * makes it available to every component via `useVoiceCatalog()`. * * Provides convenience helpers for ISO ⇄ BCP-47 lookups and curated default * voices, mirroring the backend `voiceCatalog` API. Components MUST NOT keep * their own static language lists. */ import React, { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useState, } from 'react'; import { fetchVoiceCatalog, VoiceLanguage } from '../api/voiceCatalogApi'; interface VoiceCatalogContextType { languages: VoiceLanguage[]; isLoading: boolean; error: string | null; getByBcp47: (code: string | null | undefined) => VoiceLanguage | undefined; getByIso: (iso: string | null | undefined) => VoiceLanguage | undefined; isoToBcp47: (iso: string | null | undefined) => string | undefined; getDefaultVoice: (bcp47: string | null | undefined) => string | null; } const VoiceCatalogContext = createContext(undefined); interface VoiceCatalogProviderProps { children: ReactNode; } export const VoiceCatalogProvider: React.FC = ({ children }) => { const [languages, setLanguages] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { let cancelled = false; (async () => { try { const data = await fetchVoiceCatalog(); if (!cancelled) { setLanguages(data); setError(null); } } catch (err: any) { if (!cancelled) { setError(err?.message || 'Failed to load voice catalog'); setLanguages([]); } } finally { if (!cancelled) setIsLoading(false); } })(); return () => { cancelled = true; }; }, []); const byBcp47 = useMemo(() => { const map = new Map(); for (const v of languages) map.set(v.bcp47.toLowerCase(), v); return map; }, [languages]); const byIso = useMemo(() => { const map = new Map(); for (const v of languages) { if (!map.has(v.iso.toLowerCase())) map.set(v.iso.toLowerCase(), v); } return map; }, [languages]); const getByBcp47 = useCallback( (code: string | null | undefined) => code ? byBcp47.get(code.trim().toLowerCase()) : undefined, [byBcp47], ); const getByIso = useCallback( (iso: string | null | undefined) => iso ? byIso.get(iso.trim().toLowerCase()) : undefined, [byIso], ); const isoToBcp47 = useCallback( (iso: string | null | undefined): string | undefined => { if (!iso) return undefined; const trimmed = iso.trim(); if (!trimmed) return undefined; if (trimmed.includes('-')) { const canonical = byBcp47.get(trimmed.toLowerCase()); return canonical ? canonical.bcp47 : trimmed; } const entry = byIso.get(trimmed.toLowerCase()); if (entry) return entry.bcp47; return `${trimmed.toLowerCase()}-${trimmed.toUpperCase()}`; }, [byBcp47, byIso], ); const getDefaultVoice = useCallback( (bcp47: string | null | undefined): string | null => { const entry = getByBcp47(bcp47); return entry?.defaultVoice ?? null; }, [getByBcp47], ); const value = useMemo( () => ({ languages, isLoading, error, getByBcp47, getByIso, isoToBcp47, getDefaultVoice, }), [languages, isLoading, error, getByBcp47, getByIso, isoToBcp47, getDefaultVoice], ); return ( {children} ); }; export const useVoiceCatalog = (): VoiceCatalogContextType => { const ctx = useContext(VoiceCatalogContext); if (!ctx) { throw new Error('useVoiceCatalog must be used within VoiceCatalogProvider'); } return ctx; }; /** * Map a profile language (ISO short code) to a default voice locale. * Returns the catalog's BCP-47 for the ISO if available, else falls back to * `de-DE` so the UI always has a deterministic starting value. */ export const useDefaultVoiceLocale = (profileLanguage?: string | null): string => { const { isoToBcp47 } = useVoiceCatalog(); return isoToBcp47(profileLanguage) || 'de-DE'; };