import { useEffect, useRef, useState } from 'react'; import { View, Text, TouchableOpacity, Alert, StyleSheet } from 'react-native'; import { LinearGradient } from 'expo-linear-gradient'; import { SafeAreaView } from 'react-native-safe-area-context'; import { Ionicons } from '@expo/vector-icons'; import { useRouter } from 'expo-router'; import { useTranslation } from 'react-i18next'; import { useCallStore } from '../stores/call'; import { UserAvatar } from '../components/UserAvatar'; function fmtDuration(ms: number) { const s = Math.floor(ms / 1000); const m = Math.floor(s / 60); return `${m}:${String(s % 60).padStart(2, '0')}`; } export default function CallScreen() { const { t } = useTranslation(); const router = useRouter(); const status = useCallStore((s) => s.status); const peer = useCallStore((s) => s.peer); const muted = useCallStore((s) => s.muted); const speaker = useCallStore((s) => s.speaker); const startedAt = useCallStore((s) => s.startedAt); const endReason = useCallStore((s) => s.endReason); const acceptCall = useCallStore((s) => s.acceptCall); const declineCall = useCallStore((s) => s.declineCall); const hangup = useCallStore((s) => s.hangup); const toggleMute = useCallStore((s) => s.toggleMute); const toggleSpeaker = useCallStore((s) => s.toggleSpeaker); const clear = useCallStore((s) => s._clear); const [elapsed, setElapsed] = useState(0); // Race-Guard: zustand-set in dm.tsx → router.push('/call') passiert manchmal // bevor unser useCallStore-Selector den neuen status sieht. Beim allerersten // Render kann status also kurz 'idle' sein, obwohl gerade ein Call gestartet // wird. Wir geben dem Store 250ms Zeit bevor wir bei 'idle' den Screen // schlie\u00dfen \u2014 sonst flickert die Call-UI sofort weg ("kurz und verschwindet"). const mountedAt = useRef(Date.now()); // Helper: Call-Screen schließen. Wir nutzen IMMER replace('/') statt back(). // // Warum nicht router.back()? // React-Navigation dispatched GO_BACK asynchron über seinen Reducer. Wenn // canGoBack() zwar true zurückgibt aber der Stack-Zustand zwischenzeitlich // (z.B. durch AppState-Change oder Tab-Switch während des Calls) inkonsistent // geworden ist, wirft der Reducer einen GO_BACK-Action-Error — und der landet // NICHT im try/catch um back(), sondern crasht beim nächsten Render in // wrap-jsx.js. replace('/') ist deterministisch. const closeScreen = (why: string) => { const sinceMount = Date.now() - mountedAt.current; console.log('[call-screen] closeScreen why=', why, 'sinceMount=', sinceMount, 'ms'); try { router.replace('/'); } catch (err) { console.warn('[call-screen] router.replace(/) threw', err); } }; // Kein aktiver Call \u2192 Screen schlie\u00dfen. useEffect(() => { if (status !== 'idle') return; const sinceMount = Date.now() - mountedAt.current; if (sinceMount < 250) { // Initial-Mount-Race: noch warten ob startCall/receiveIncoming den status // gleich auf outgoing/incoming setzt. const tm = setTimeout(() => { if (useCallStore.getState().status === 'idle') closeScreen('idle-after-grace'); }, 250 - sinceMount); return () => clearTimeout(tm); } closeScreen('idle'); // eslint-disable-next-line react-hooks/exhaustive-deps }, [status]); // Call beendet → kurz "beendet" zeigen, dann schließen + aufräumen. useEffect(() => { if (status !== 'ended') return; console.log('[call-screen] status=ended -> will close in 1300ms; endReason=', endReason); const tm = setTimeout(() => { clear(); closeScreen('ended-timeout'); }, 1300); return () => clearTimeout(tm); // eslint-disable-next-line react-hooks/exhaustive-deps }, [status, clear]); // Gesprächsdauer-Timer. useEffect(() => { if (status !== 'connected' || !startedAt) return; const id = setInterval(() => setElapsed(Date.now() - startedAt), 1000); return () => clearInterval(id); }, [status, startedAt]); async function onAccept() { try { await acceptCall(); } catch (e: any) { if (e?.message === 'webrtc_unavailable') { Alert.alert(t('call.title'), t('call.needs_rebuild')); } hangup('failed'); } } const subtitle = status === 'outgoing' ? t('call.calling') : status === 'incoming' ? t('call.incoming') : status === 'connecting' ? t('call.connecting') : status === 'connected' ? fmtDuration(elapsed) : status === 'ended' ? endReason === 'declined' ? t('call.declined') : endReason === 'unanswered' ? t('call.no_answer') : endReason === 'failed' ? t('call.failed') : t('call.ended') : ''; return ( {/* Oben: Avatar + Name + Status */} {peer?.nickname ?? '…'} {subtitle} {/* Unten: Aktions-Buttons */} {status === 'incoming' ? ( ) : status === 'ended' ? null : ( {(status === 'connected' || status === 'connecting') && ( )} {(status === 'connected' || status === 'connecting' || status === 'outgoing') && ( )} hangup('ended')} label={t('call.hang_up')} /> )} ); } function CircleBtn({ color, iconColor = '#fff', icon, rotate, onPress, label, }: { color: string; iconColor?: string; icon: keyof typeof Ionicons.glyphMap; rotate?: boolean; onPress: () => void; label: string; }) { return ( {label} ); }