UNPKG

enx-uikit-react-native

Version:
796 lines (734 loc) 27.1 kB
/** * EnxAudioOnlyView.tsx — WhatsApp-style audio-only call screen for React Native * * Dependencies (install once per project): * npm install react-native-linear-gradient * # Expo: * npx expo install expo-linear-gradient * * Usage: * const ref = useRef<EnxAudioOnlyViewRef>(null); * * <EnxAudioOnlyView * ref={ref} * onMuteToggle={isMuted => ...} * onSpeakerToggle={isSpeaker => ...} * onDisconnect={() => ...} * /> * * ref.current?.configure('John', 'Doe', 'Calling…') * ref.current?.setSpeaking(true) // start / stop avatar pulse * ref.current?.setUserVisible(false) // switch to animated "Calling…" label * ref.current?.setUserVisible(true) // switch back to name + avatar */ import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState, } from 'react'; import { Animated, Dimensions, Easing, Image, Platform, StatusBar, StyleSheet, Text, TouchableOpacity, View, } from 'react-native'; // ─── If react-native-linear-gradient is unavailable, fall back to a plain View. let LinearGradient; try { LinearGradient = require('react-native-linear-gradient').default; } catch (error) { LinearGradient = ({ children, style }) => <View style={style}>{children}</View>; } const AUDIO_ON_ICON = require('../image_asset/audio_on.png'); const AUDIO_OFF_ICON = require('../image_asset/audio_off.png'); const SPEAKER_ICON = require('../image_asset/speaker_off.png'); const EARPIECE_ICON = require('../image_asset/earpic.png'); // ─── Design constants ───────────────────────────────────────────────────────── const BASE_AVATAR_SIZE = 120; const BASE_BUTTON_SIZE = 64; const ACTIVE_COLOR = '#128C7E'; const RING_ALPHAS = [0.35, 0.22, 0.12]; const RING_DELAYS = [0, 220, 440]; // stagger in ms const HEADER_HEIGHT = Platform.OS === 'ios' ? 44 : 56; const DEFAULT_AUDIO_VIEW_CONFIG = { gradientLayer1: '#0A3733', gradientLayer2: '#031412', nameTextColor: '#FFFFFF', sortNameTextColor: '#FFFFFF', sortNameBGColor: ACTIVE_COLOR, statusTextColor: '#D1D5DB', controlCardColor: 'rgba(0,0,0,0.8)', controlButtonColor: '#1F1F1F', activeControlButtonColor: '#063B34', ringBackgroundColor: 'rgba(255,255,255,0.08)', ringBorderColor: 'rgba(255,255,255,0.18)', }; const clamp = (value, min, max) => Math.min(Math.max(value, min), max); const normalizeColor = (value, fallback) => { if (typeof value === 'number') { const color = value >>> 0; const alpha = ((color >>> 24) & 255) / 255; const red = (color >>> 16) & 255; const green = (color >>> 8) & 255; const blue = color & 255; return `rgba(${red},${green},${blue},${Number(alpha.toFixed(3))})`; } if (typeof value === 'string' && value.trim()) { return value.trim(); } return fallback; }; const resolveAudioViewConfig = config => { const mergedConfig = { ...DEFAULT_AUDIO_VIEW_CONFIG, ...(config || {}) }; return Object.keys(DEFAULT_AUDIO_VIEW_CONFIG).reduce((resolvedConfig, key) => { resolvedConfig[key] = normalizeColor(mergedConfig[key], DEFAULT_AUDIO_VIEW_CONFIG[key]); return resolvedConfig; }, {}); }; const getResponsiveLayout = (width, height) => { const shortSide = Math.min(width || 360, height || 640); const usableHeight = height || 640; const compact = usableHeight < 620 || shortSide < 360; const scale = clamp(Math.min(shortSide / 390, usableHeight / 760), 0.74, 1.22); const avatarSize = Math.round(BASE_AVATAR_SIZE * scale); const ringStep = Math.round((compact ? 18 : 24) * scale); const ringSizes = RING_ALPHAS.map((_, i) => avatarSize + (i + 1) * ringStep * 2); const containerSize = ringSizes[ringSizes.length - 1] + Math.round(16 * scale); const buttonSize = Math.round(BASE_BUTTON_SIZE * clamp(scale, 0.78, 1.05)); return { avatarSize, buttonSize, ringSizes, containerSize, topPadding: compact ? 8 : Math.round(18 * scale), bottomPadding: compact ? 10 : Math.round(18 * scale), cardPaddingVertical: compact ? 10 : Math.round(12 * scale), cardPaddingHorizontal: compact ? 12 : Math.round(16 * scale), cardRadius: Math.round(22 * scale), cardWidth: width > 520 ? 420 : Math.max(300, Math.round((width || 360) * 0.92)), nameFontSize: Math.round(28 * clamp(scale, 0.82, 1.12)), statusFontSize: Math.round(15 * clamp(scale, 0.86, 1.08)), labelFontSize: Math.round(12 * clamp(scale, 0.9, 1.05)), iconFontSize: Math.round(20 * clamp(scale, 0.9, 1.08)), initialsFontSize: Math.round(42 * clamp(scale, 0.82, 1.12)), avatarMarginTop: compact ? 4 : Math.round(10 * scale), }; }; const getNameParts = user => { const displayName = String( user?.name || user?.username || user?.userName || user?.clientName || user?.user_ref || '' ).trim(); const [first = '', ...rest] = displayName.split(/\s+/); return { firstName: first, lastName: rest.join(' '), }; }; // ─── Icon components ─────────────────────────────────────────────────────── const MicIcon = ({ color = 'white', muted = false, size = 20, }) => ( <View style={styles.iconContainer}> <Image source={muted ? AUDIO_OFF_ICON : AUDIO_ON_ICON} style={[styles.imageIcon, { width: size + 4, height: size + 4, tintColor: color }]} resizeMode="contain" /> </View> ); const SpeakerIcon = ({ color = 'white', active = false, size = 20, }) => ( <View style={styles.iconContainer}> <Image source={active ? SPEAKER_ICON : EARPIECE_ICON} style={[styles.imageIcon, { width: size + 4, height: size + 4, tintColor: color }]} resizeMode="contain" /> </View> ); const PhoneDownIcon = ({ size = 20 }) => ( <View style={styles.iconContainer}> <Text style={[styles.iconText, { color: 'white', fontSize: size, lineHeight: size + 4 }]}>📞</Text> </View> ); // ─── Main component ──────────────────────────────────────────────────────────── const EnxAudioOnlyView = forwardRef( ({ onMuteToggle, onSpeakerToggle, onDisconnect, audioViewConfig }, ref) => { const { width, height } = Dimensions.get('window'); const statusBarHeight = Platform.OS === 'android' ? StatusBar.currentHeight || 0 : 0; const usableHeight = Math.max(height - statusBarHeight - HEADER_HEIGHT, 0); const [layoutSize, setLayoutSize] = useState({ width, height: usableHeight }); const responsiveLayout = getResponsiveLayout(layoutSize.width, layoutSize.height); const resolvedAudioConfig = resolveAudioViewConfig(audioViewConfig); // ── UI state ───────────────────────────────────────────────────────────── const [firstName, setFirstName] = useState(''); const [lastName, setLastName] = useState(''); const [callStatus, setCallStatus] = useState('Audio Call'); const [isMuted, setIsMuted] = useState(false); const [isSpeaker, setIsSpeaker] = useState(false); const [userVisible, setUserVisible] = useState(true); const [callingText, setCallingText] = useState('Calling'); // ── Animated values ─────────────────────────────────────────────────────── // One { scale, opacity } pair per pulse ring const pulseAnims = useRef( RING_ALPHAS.map(alpha => ({ scale: new Animated.Value(1), opacity: new Animated.Value(alpha), })) ).current; const userContentOpacity = useRef(new Animated.Value(1)).current; const callingLabelOpacity = useRef(new Animated.Value(0)).current; // ── Internal refs ───────────────────────────────────────────────────────── const pulseLoopsRef = useRef([]); const pulseTimeoutsRef = useRef([]); const dotTimerRef = useRef(null); const dotStepRef = useRef(0); const selfUserRef = useRef({ firstName: '', lastName: '', status: 'Audio Call' }); // ── Pulse animation ─────────────────────────────────────────────────────── const cancelPulse = () => { pulseLoopsRef.current.forEach(loop => loop.stop()); pulseLoopsRef.current = []; pulseTimeoutsRef.current.forEach(t => clearTimeout(t)); pulseTimeoutsRef.current = []; pulseAnims.forEach((anim, i) => { anim.scale.setValue(1); anim.opacity.setValue(RING_ALPHAS[i]); }); }; const startPulse = useCallback(() => { cancelPulse(); pulseAnims.forEach((anim, i) => { const loop = Animated.loop( Animated.sequence([ Animated.parallel([ Animated.timing(anim.scale, { toValue: 1.18, duration: 1350, easing: Easing.inOut(Easing.ease), useNativeDriver: true, }), Animated.timing(anim.opacity, { toValue: 0.04, duration: 1350, easing: Easing.inOut(Easing.ease), useNativeDriver: true, }), ]), Animated.parallel([ Animated.timing(anim.scale, { toValue: 1.0, duration: 1350, easing: Easing.inOut(Easing.ease), useNativeDriver: true, }), Animated.timing(anim.opacity, { toValue: RING_ALPHAS[i], duration: 1350, easing: Easing.inOut(Easing.ease), useNativeDriver: true, }), ]), ]) ); // Stagger each ring's start so they ripple outward const t = setTimeout(() => loop.start(), RING_DELAYS[i]); pulseTimeoutsRef.current.push(t); pulseLoopsRef.current.push(loop); }); }, []); // eslint-disable-line react-hooks/exhaustive-deps const stopPulse = useCallback(cancelPulse, []); // eslint-disable-line react-hooks/exhaustive-deps // ── Calling dots animation ──────────────────────────────────────────────── const startCallingDots = () => { dotStepRef.current = 0; setCallingText('Calling'); if (dotTimerRef.current) clearInterval(dotTimerRef.current); dotTimerRef.current = setInterval(() => { dotStepRef.current = (dotStepRef.current + 1) % 4; setCallingText('Calling' + '.'.repeat(dotStepRef.current)); }, 500); }; const stopCallingDots = () => { if (dotTimerRef.current) { clearInterval(dotTimerRef.current); dotTimerRef.current = null; } dotStepRef.current = 0; }; // ── Cleanup on unmount ──────────────────────────────────────────────────── useEffect(() => { return () => { cancelPulse(); stopCallingDots(); }; }, []); // eslint-disable-line react-hooks/exhaustive-deps // ── Imperative API (matches iOS / Android method signatures) ───────────── useImperativeHandle(ref, () => ({ configure(fn, ln, status = 'Audio Call') { selfUserRef.current = { firstName: fn || '', lastName: ln || '', status }; setFirstName(fn); setLastName(ln); setCallStatus(status); }, updateActiveTalker(activeTalker, fallbackUser) { const activeName = getNameParts(activeTalker); const fallbackName = getNameParts(fallbackUser); const hasActiveTalker = !!(activeName.firstName || activeName.lastName); if (hasActiveTalker) { setFirstName(activeName.firstName); setLastName(activeName.lastName); setCallStatus(activeTalker?.clientId === fallbackUser?.clientId ? 'Audio Call' : 'Speaking'); startPulse(); return; } setFirstName(fallbackName.firstName || selfUserRef.current.firstName); setLastName(fallbackName.lastName || selfUserRef.current.lastName); setCallStatus(selfUserRef.current.status || 'Audio Call'); stopPulse(); }, setSpeaking(speaking) { speaking ? startPulse() : stopPulse(); }, setUserVisible(visible) { setUserVisible(visible); if (visible) { stopCallingDots(); Animated.parallel([ Animated.timing(userContentOpacity, { toValue: 1, duration: 250, useNativeDriver: true }), Animated.timing(callingLabelOpacity, { toValue: 0, duration: 250, useNativeDriver: true }), ]).start(); } else { startCallingDots(); Animated.parallel([ Animated.timing(userContentOpacity, { toValue: 0, duration: 250, useNativeDriver: true }), Animated.timing(callingLabelOpacity, { toValue: 1, duration: 250, useNativeDriver: true }), ]).start(); } }, }), [startPulse, stopPulse, userContentOpacity, callingLabelOpacity]); // ── Button handlers ─────────────────────────────────────────────────────── const handleMute = () => setIsMuted(prev => { const next = !prev; onMuteToggle?.(next); return next; }); const handleSpeaker = () => setIsSpeaker(prev => { const next = !prev; onSpeakerToggle?.(next); return next; }); const initials = `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase(); const handleRootLayout = event => { const { width: nextWidth, height: nextHeight } = event.nativeEvent.layout; if ( Math.abs(nextWidth - layoutSize.width) > 1 || Math.abs(nextHeight - layoutSize.height) > 1 ) { setLayoutSize({ width: nextWidth || width, height: nextHeight || usableHeight }); } }; // ── Render ──────────────────────────────────────────────────────────────── return ( <LinearGradient colors={[resolvedAudioConfig.gradientLayer1, resolvedAudioConfig.gradientLayer2]} onLayout={handleRootLayout} style={[ styles.root, { minHeight: layoutSize.height || usableHeight, paddingTop: responsiveLayout.topPadding, paddingBottom: responsiveLayout.bottomPadding, }, ]} > {/* ── Calling label — shown when setUserVisible(false) ── */} <Animated.View style={[styles.callingContainer, { opacity: callingLabelOpacity }]} pointerEvents="none" > <Text style={[ styles.callingText, { color: resolvedAudioConfig.nameTextColor, fontSize: responsiveLayout.nameFontSize, }, ]} > {callingText} </Text> </Animated.View> <View style={styles.topInfo}> {firstName ? ( <Text style={[ styles.nameText, { color: resolvedAudioConfig.nameTextColor, fontSize: responsiveLayout.nameFontSize, }, ]} numberOfLines={1} adjustsFontSizeToFit > {firstName} </Text> ) : null} {callStatus ? ( <Text style={[ styles.statusText, { color: resolvedAudioConfig.statusTextColor, fontSize: responsiveLayout.statusFontSize, }, ]} numberOfLines={1} > {callStatus} </Text> ) : null} </View> <Animated.View style={[styles.userContent, { opacity: userContentOpacity }]} pointerEvents={userVisible ? 'box-none' : 'none'} > <View style={[ styles.avatarContainer, { width: responsiveLayout.containerSize, height: responsiveLayout.containerSize, marginTop: responsiveLayout.avatarMarginTop, }, ]} > {[...responsiveLayout.ringSizes].reverse().map((ringSize, reverseIdx) => { const i = responsiveLayout.ringSizes.length - 1 - reverseIdx; const offset = (responsiveLayout.containerSize - ringSize) / 2; return ( <Animated.View key={i} style={[ styles.ring, { width: ringSize, height: ringSize, borderRadius: ringSize / 2, top: offset, left: offset, backgroundColor: resolvedAudioConfig.ringBackgroundColor, borderColor: resolvedAudioConfig.ringBorderColor, opacity: pulseAnims[i].opacity, transform: [{ scale: pulseAnims[i].scale }], }, ]} /> ); })} <View style={[ styles.avatarCircle, { width: responsiveLayout.avatarSize, height: responsiveLayout.avatarSize, borderRadius: responsiveLayout.avatarSize / 2, backgroundColor: resolvedAudioConfig.sortNameBGColor, top: (responsiveLayout.containerSize - responsiveLayout.avatarSize) / 2, left: (responsiveLayout.containerSize - responsiveLayout.avatarSize) / 2, }, ]} > <Text style={[ styles.initialsText, { color: resolvedAudioConfig.sortNameTextColor, fontSize: responsiveLayout.initialsFontSize, }, ]} > {initials} </Text> </View> </View> </Animated.View> <View style={[ styles.topControlCard, { width: responsiveLayout.cardWidth, maxWidth: '94%', paddingVertical: responsiveLayout.cardPaddingVertical, paddingHorizontal: responsiveLayout.cardPaddingHorizontal, borderRadius: responsiveLayout.cardRadius, backgroundColor: resolvedAudioConfig.controlCardColor, }, ]} > <View style={styles.controlItem}> <TouchableOpacity style={[ styles.circleBtn, { width: responsiveLayout.buttonSize, height: responsiveLayout.buttonSize, borderRadius: responsiveLayout.buttonSize / 2, backgroundColor: isMuted ? resolvedAudioConfig.activeControlButtonColor : resolvedAudioConfig.controlButtonColor, }, isMuted && { borderWidth: 1, borderColor: ACTIVE_COLOR }, ]} onPress={handleMute} activeOpacity={0.8} > <MicIcon color="#FFFFFF" muted={isMuted} size={responsiveLayout.iconFontSize} /> </TouchableOpacity> <Text style={[ styles.cardLabel, { color: resolvedAudioConfig.nameTextColor, fontSize: responsiveLayout.labelFontSize, }, ]} > {isMuted ? 'Unmute' : 'Mute'} </Text> </View> <View style={styles.controlItem}> <TouchableOpacity style={[ styles.circleBtn, styles.endCallBtn, { width: responsiveLayout.buttonSize, height: responsiveLayout.buttonSize, borderRadius: responsiveLayout.buttonSize / 2, }, ]} onPress={onDisconnect} activeOpacity={0.8} > <PhoneDownIcon size={responsiveLayout.iconFontSize} /> </TouchableOpacity> <Text style={[ styles.cardLabel, { color: resolvedAudioConfig.nameTextColor, fontSize: responsiveLayout.labelFontSize, }, ]} > End Call </Text> </View> <View style={styles.controlItem}> <TouchableOpacity style={[ styles.circleBtn, { width: responsiveLayout.buttonSize, height: responsiveLayout.buttonSize, borderRadius: responsiveLayout.buttonSize / 2, backgroundColor: isSpeaker ? resolvedAudioConfig.activeControlButtonColor : resolvedAudioConfig.controlButtonColor, }, isSpeaker && { borderWidth: 1, borderColor: ACTIVE_COLOR }, ]} onPress={handleSpeaker} activeOpacity={0.8} > <SpeakerIcon color="#FFFFFF" active={isSpeaker} size={responsiveLayout.iconFontSize} /> </TouchableOpacity> <Text style={[ styles.cardLabel, { color: resolvedAudioConfig.nameTextColor, fontSize: responsiveLayout.labelFontSize, }, ]} > {isSpeaker ? 'Earpiece' : 'Speaker'} </Text> </View> </View> </LinearGradient> ); } ); EnxAudioOnlyView.displayName = 'EnxAudioOnlyView'; export default EnxAudioOnlyView; // ─── Styles ─────────────────────────────────────────────────────────────────── const styles = StyleSheet.create({ root: { flex: 1, flexGrow: 1, alignSelf: 'stretch', width: '100%', height: '100%', alignItems: 'center', justifyContent: 'space-between', paddingTop: Platform.OS === 'ios' ? 24 : 12, paddingBottom: Platform.OS === 'ios' ? 24 : 14, backgroundColor: '#121213', }, // Calling label — absolute, centered in the space above content callingContainer: { ...StyleSheet.absoluteFillObject, justifyContent: 'center', alignItems: 'center', }, callingText: { fontSize: 32, fontWeight: '600', color: '#FFFFFF', textAlign: 'center', }, // User content — centered avatar + labels userContent: { alignItems: 'center', justifyContent: 'center', flex: 1, width: '100%', }, topInfo: { alignItems: 'center', paddingHorizontal: 24, marginTop: 4, width: '100%', }, topControlCard: { width: '92%', marginBottom: 0, paddingVertical: 12, paddingHorizontal: 14, borderRadius: 24, backgroundColor: 'rgba(0,0,0,0.8)', flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', shadowColor: '#000', shadowOffset: { width: 0, height: 8 }, shadowOpacity: 0.08, shadowRadius: 20, elevation: 6, }, cardLabel: { marginTop: 8, color: '#FFFFFF', fontSize: 12, fontWeight: '600', textAlign: 'center', }, nameText: { fontSize: 30, fontWeight: '600', color: '#FFFFFF', textAlign: 'center', }, statusText: { fontSize: 16, color: '#D1D5DB', marginTop: 10, textAlign: 'center', }, avatarContainer: { width: BASE_AVATAR_SIZE + 160, height: BASE_AVATAR_SIZE + 160, marginTop: 10, }, ring: { position: 'absolute', backgroundColor: 'rgba(255,255,255,0.08)', borderWidth: 1, borderColor: 'rgba(255,255,255,0.18)', }, avatarCircle: { position: 'absolute', width: BASE_AVATAR_SIZE, height: BASE_AVATAR_SIZE, borderRadius: BASE_AVATAR_SIZE / 2, backgroundColor: '#009688', top: 80, left: 80, alignItems: 'center', justifyContent: 'center', shadowColor: '#000', shadowOffset: { width: 0, height: 8 }, shadowOpacity: 0.12, shadowRadius: 20, elevation: 4, }, initialsText: { fontSize: 44, fontWeight: '500', color: 'white', }, // Controls bar — bottom button row controlsBar: { position: 'absolute', bottom: Platform.OS === 'ios' ? 40 : 28, left: 0, right: 0, flexDirection: 'row', justifyContent: 'space-evenly', alignItems: 'center', paddingHorizontal: 14, paddingVertical: 8, }, iconContainer: { width: 24, height: 24, alignItems: 'center', justifyContent: 'center', }, iconText: { fontSize: 20, lineHeight: 24, }, imageIcon: { tintColor: '#FFFFFF', }, controlItem: { alignItems: 'center', }, circleBtn: { width: BASE_BUTTON_SIZE, height: BASE_BUTTON_SIZE, borderRadius: BASE_BUTTON_SIZE / 2, backgroundColor: '#1F1F1F', alignItems: 'center', justifyContent: 'center', }, circleBtnActive: { backgroundColor: '#063B34', borderWidth: 1, borderColor: ACTIVE_COLOR, }, endCallBtn: { backgroundColor: '#EE3A3A', }, btnLabel: { color: 'rgba(238, 235, 235, 0.97)', fontSize: 12, marginTop: 8, textAlign: 'center', }, });