import { useEffect, useState } from 'react'; import { View, Text, Platform } from 'react-native'; import { Asset } from 'expo-asset'; import Rive, { Fit, Alignment } from 'rive-react-native'; // Android: Rive akzeptiert NUR raw-resource oder url, kein file:// uri. // Asset liegt als android/app/src/main/res/raw/lyra_avatar.riv (gebundelt // via plugins/with-rive-asset-android.js). resourceName = filename ohne // extension, lowercase + underscores (Android raw-resource convention). const ANDROID_RIVE_RESOURCE = 'lyra_avatar'; // Modul-Level: nur EINMAL die Asset registrieren + URI cachen. // In Production-Builds wird die .riv ins App-Bundle gebakt (Asset.localUri // zeigt sofort auf das Bundle-File). In Dev-Builds wird sie beim ersten Mal // von Metro gezogen + ins App-Sandbox-Cache geschrieben — danach offline. const RIVE_MODULE = require('../assets/lyra-avatar.riv'); let cachedRiveUri: string | null = null; let preloadPromise: Promise | null = null; function preloadRiveAsset(): Promise { if (cachedRiveUri) return Promise.resolve(cachedRiveUri); if (preloadPromise) return preloadPromise; preloadPromise = Asset.fromModule(RIVE_MODULE) .downloadAsync() .then((asset) => { const uri = asset.localUri ?? asset.uri; cachedRiveUri = uri; return uri; }) .catch((err) => { console.warn('[RiveAvatar] preload failed:', err?.message ?? err); preloadPromise = null; return null; }); return preloadPromise; } // Kicke den Preload sofort beim Modul-Import an — damit der erste // Render bereits die cached URI nutzt (außer im allerersten App-Start). preloadRiveAsset(); // Supported emotions sind durch state-machine im .riv-file definiert. // Neue states: nur EMOTION_ANIMATIONS + EMOTION_LABELS erweitern, kein weiterer Code-Change nötig. export type SupportedEmotion = 'idle' | 'happy' | 'thinking' | 'empathy'; export type Emotion = SupportedEmotion | (string & {}); // Direkte Animation-Namen aus der .riv-Datei (1:1 mit Nuxt BenAvatar.vue). // "happy" hat zwei Phasen: erst Übergangs-Animation, dann Loop. const EMOTION_ANIMATIONS: Record = { idle: 'Idle Loop', happy: 'idle to Pose 1', thinking: 'WALK', empathy: '01 Wave 1', }; const EMOTION_LABELS: Record = { idle: 'bereit', happy: 'froh für dich', thinking: 'überlegt ...', empathy: 'versteht dich', }; const SIZE_PX: Record<'sm' | 'md' | 'lg', number> = { sm: 40, md: 112, lg: 160, }; type Props = { emotion: Emotion; size?: 'sm' | 'md' | 'lg'; showLabel?: boolean; fallback?: Emotion; }; export function RiveAvatar({ emotion, size = 'md', showLabel = false, fallback = 'idle' }: Props) { const px = SIZE_PX[size]; const resolvedEmotion = EMOTION_ANIMATIONS[emotion] !== undefined ? emotion : fallback; // Aktuelle Animation als deklarativer State (kein imperatives ref.play()). const [currentAnim, setCurrentAnim] = useState(EMOTION_ANIMATIONS[resolvedEmotion] ?? EMOTION_ANIMATIONS.idle); // Lokale URI für die .riv-Datei — geht über expo-asset damit der File // im App-Sandbox gecached wird statt jedes Mal von Metro zu streamen. // Nach erstem Load funktioniert's auch komplett offline. const [riveUri, setRiveUri] = useState(cachedRiveUri); useEffect(() => { if (riveUri) return; // schon gecached let active = true; preloadRiveAsset().then((uri) => { if (active && uri) setRiveUri(uri); }); return () => { active = false; }; }, [riveUri]); useEffect(() => { if (resolvedEmotion === 'happy') { // 2-Phasen-Flow: Übergang (~900ms) → Loop (1:1 wie Nuxt-BenAvatar) setCurrentAnim('idle to Pose 1'); const t = setTimeout(() => setCurrentAnim('Pose 1 loop'), 900); return () => clearTimeout(t); } setCurrentAnim(EMOTION_ANIMATIONS[resolvedEmotion] ?? EMOTION_ANIMATIONS.idle); }, [resolvedEmotion]); return ( {Platform.OS === 'android' ? ( // Android: Bundle-Resource direkt (kein expo-asset Preload nötig) ) : riveUri ? ( // iOS: file:// URI aus expo-asset Cache funktioniert ) : ( )} {showLabel && ( {EMOTION_LABELS[resolvedEmotion] ?? EMOTION_LABELS[fallback] ?? ''} )} ); }