import { useEffect, useState } from 'react'; import { View, Text, Platform } from 'react-native'; import { Asset } from 'expo-asset'; import Rive, { Fit, Alignment, RNRiveErrorType, type RNRiveError } 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 (+ ggf. TWO_PHASE/SUSTAINED) // erweitern, kein weiterer Code-Change nötig. export type SupportedEmotion = | 'idle' | 'happy' | 'empathy' | 'thinking' | 'listening' | 'calm' | 'sad' | 'joy' | 'confusion' | 'surprise'; export type Emotion = SupportedEmotion | (string & {}); // Direkte Timeline-Namen aus der .riv-Datei (Code-Contract — siehe // docs/RIVE_ANIMATOR_BRIEF.md). Der Animator MUSS die States exakt so benennen, // sonst spielt nichts (silent, kein Error). Bis die erweiterte .riv geliefert // wird, existieren die neuen Timelines noch nicht → der Avatar zeigt für diese // States den statischen Idle-Frame (kein Crash). "thinking" ersetzt den alten // "WALK"-Platzhalter und animiert daher erst mit der neuen .riv wieder. export const EMOTION_ANIMATIONS: Record = { idle: 'Idle Loop', // Reuse vorhandener Hasen-Posen (0 Rive-Arbeit). Namen, die es noch NICHT in // der .riv gibt (calm/sad/joy/confusion/surprise), fallen via safeAnim auf Idle. happy: '01 Wave 2', // fröhliches Winken (war ungenutzt) empathy: '01 Wave 1', // sanftes Winken thinking: 'Pose 1 loop', // Klemmbrett: arbeitet an deiner Eingabe (direkt Loop, kein Zucken) listening: 'Pose 1 loop', // Therapeut notiert beim Zuhören (direkt Loop, kein Zucken) calm: 'calm', sad: 'sad', joy: 'joy', confusion: 'confusion', surprise: 'surprise', }; // Timelines, die AKTUELL wirklich in lyra-avatar.riv existieren. Wird ein Name an // die native Rive-View gegeben, der NICHT existiert, crasht die App hart — onError // fängt das NICHT zuverlässig ab (empirisch verifiziert, der native Runtime stürzt // tiefer ab). Deshalb mappen wir jeden unbekannten Namen VOR dem Rendern auf den // garantiert vorhandenen Idle-Loop. → Beim Anlegen einer neuen Timeline in der // .riv hier den exakten Namen ergänzen, dann animiert der zugehörige State. export const EXISTING_TIMELINES = new Set([ 'Idle Loop', 'idle to Pose 1', 'Pose 1 loop', '01 Wave 1', '01 Wave 2', 'WALK', 'Kedip', ]); function safeAnim(name: string): string { return EXISTING_TIMELINES.has(name) ? name : EMOTION_ANIMATIONS.idle; } // Mehrphasige States: Intro-Timeline läuft einmal, danach Loop-Timeline. Aktuell // leer — der Remount beim Intro→Loop-Wechsel verursacht ein sichtbares Zucken, // daher spielen thinking/listening direkt die Loop-Pose. Mechanismus bleibt für // künftige ' intro' + ' loop'-States verfügbar. const TWO_PHASE: Record = {}; // Sustained = bleibt aktiv bis die Emotion explizit wechselt (z.B. solange Lyra // "denkt", "zuhört" oder mitatmet). Alle anderen sind One-Shot-Reaktionen und // fallen nach SETTLE_MS in den Idle-Loop zurück, damit der Avatar lebendig bleibt // und nie auf dem letzten Frame einfriert. const SUSTAINED = new Set(['idle', 'thinking', 'listening', 'calm']); const SETTLE_MS = 2600; const EMOTION_LABELS: Record = { idle: 'bereit', happy: 'froh für dich', empathy: 'versteht dich', thinking: 'überlegt ...', listening: 'hört zu', calm: 'atmet mit dir', sad: 'fühlt mit dir', joy: 'freut sich für dich', confusion: 'fragt nach', surprise: 'überrascht', }; 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(safeAnim(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(() => { const wanted = EMOTION_ANIMATIONS[resolvedEmotion] ?? EMOTION_ANIMATIONS.idle; const base = safeAnim(wanted); setCurrentAnim(base); // Mehrphasig (z.B. happy: 'idle to Pose 1' → 'Pose 1 loop'): Intro einmal // spielen, dann in den Loop blenden. Nur wenn BEIDE Timelines wirklich // existieren — sonst bleibt's bei base (kein Sprung auf einen toten Namen). const phase = TWO_PHASE[resolvedEmotion]; if (phase && EXISTING_TIMELINES.has(wanted) && EXISTING_TIMELINES.has(phase.loop)) { const t = setTimeout(() => setCurrentAnim(phase.loop), phase.introMs); return () => clearTimeout(t); } // One-Shots (empathy/sad/joy/confusion/surprise) frieren sonst auf dem // letzten Frame ein → nach SETTLE_MS zurück in den Idle-Loop. Sustained- // States (idle/happy/thinking/listening/calm) bleiben aktiv bis Emotion-Wechsel. if (!SUSTAINED.has(resolvedEmotion) && base !== EMOTION_ANIMATIONS.idle) { const t = setTimeout(() => setCurrentAnim(EMOTION_ANIMATIONS.idle), SETTLE_MS); return () => clearTimeout(t); } }, [resolvedEmotion]); // Crash-Guard: rive-react-native crasht NATIV (App-Absturz, kein JS-Error), // wenn animationName nicht in der .riv existiert — z.B. ein neuer Emotion-State, // der noch nicht gebaut wurde. Sobald onError gesetzt ist, schaltet die Lib auf // isUserHandlingErrors und ruft statt zu crashen diesen Handler. Wir fallen dann // auf den garantiert vorhandenen Idle-Loop zurück (Guard verhindert Endlos-Reset). const handleRiveError = (err: RNRiveError) => { if (__DEV__) console.warn('[RiveAvatar]', err?.type, '—', err?.message); if ( err?.type === RNRiveErrorType.IncorrectAnimationName && currentAnim !== EMOTION_ANIMATIONS.idle ) { setCurrentAnim(EMOTION_ANIMATIONS.idle); } }; 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] ?? ''} )} ); }