167 lines
5.3 KiB
TypeScript

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<string | null> | null = null;
function preloadRiveAsset(): Promise<string | null> {
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();
export type Emotion = 'idle' | 'happy' | 'thinking' | 'empathy';
// 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<Emotion, string> = {
idle: 'Idle Loop',
happy: 'idle to Pose 1',
thinking: 'WALK',
empathy: '01 Wave 1',
};
const EMOTION_LABELS: Record<Emotion, string> = {
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;
};
export function RiveAvatar({ emotion, size = 'md', showLabel = false }: Props) {
const px = SIZE_PX[size];
// Aktuelle Animation als deklarativer State (kein imperatives ref.play()).
const [currentAnim, setCurrentAnim] = useState<string>(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<string | null>(cachedRiveUri);
useEffect(() => {
if (riveUri) return; // schon gecached
let active = true;
preloadRiveAsset().then((uri) => {
if (active && uri) setRiveUri(uri);
});
return () => {
active = false;
};
}, [riveUri]);
useEffect(() => {
if (emotion === '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[emotion]);
}, [emotion]);
return (
<View style={{ alignItems: 'center', gap: 4 }}>
<View
style={{
width: px,
height: px,
// Floating-Shadow nur für md/lg — bei sm zu klein, würde unsauber wirken
...(size !== 'sm' && {
shadowColor: '#000',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.18,
shadowRadius: 16,
elevation: 10,
}),
}}
>
<View
style={{
width: px,
height: px,
borderRadius: px / 2,
overflow: 'hidden',
backgroundColor: '#ffffff',
borderWidth: size !== 'sm' ? 4 : 0,
borderColor: '#ffffff',
}}
>
{Platform.OS === 'android' ? (
// Android: Bundle-Resource direkt (kein expo-asset Preload nötig)
<Rive
key={currentAnim}
resourceName={ANDROID_RIVE_RESOURCE}
autoplay
animationName={currentAnim}
fit={Fit.Cover}
alignment={Alignment.Center}
style={{ width: px, height: px }}
/>
) : riveUri ? (
// iOS: file:// URI aus expo-asset Cache funktioniert
<Rive
key={currentAnim}
source={{ uri: riveUri }}
autoplay
animationName={currentAnim}
fit={Fit.Cover}
alignment={Alignment.Center}
style={{ width: px, height: px }}
/>
) : (
<View style={{ width: px, height: px, backgroundColor: '#ffffff' }} />
)}
</View>
</View>
{showLabel && (
<Text style={{ fontSize: 11, color: '#737373', letterSpacing: 0.3 }}>
{EMOTION_LABELS[emotion]}
</Text>
)}
</View>
);
}