ui-nyla/src/contexts/VoiceCatalogContext.tsx
2026-04-19 00:36:42 +02:00

151 lines
4.8 KiB
TypeScript

/**
* 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<VoiceCatalogContextType | undefined>(undefined);
interface VoiceCatalogProviderProps {
children: ReactNode;
}
export const VoiceCatalogProvider: React.FC<VoiceCatalogProviderProps> = ({ children }) => {
const [languages, setLanguages] = useState<VoiceLanguage[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(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<string, VoiceLanguage>();
for (const v of languages) map.set(v.bcp47.toLowerCase(), v);
return map;
}, [languages]);
const byIso = useMemo(() => {
const map = new Map<string, VoiceLanguage>();
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<VoiceCatalogContextType>(
() => ({
languages,
isLoading,
error,
getByBcp47,
getByIso,
isoToBcp47,
getDefaultVoice,
}),
[languages, isLoading, error, getByBcp47, getByIso, isoToBcp47, getDefaultVoice],
);
return (
<VoiceCatalogContext.Provider value={value}>{children}</VoiceCatalogContext.Provider>
);
};
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';
};