/** * VoiceRecorder — inline UI для записи голосового сообщения через * expo-audio (заменил deprecated expo-av). * * UX: * - При монтировании проверяет permission + запускает запись * - [🗑] ● timer Recording… [↑] * - 🗑 = cancel (discard), ↑ = stop + send * * Состояние recorder'а живёт в useAudioRecorder hook'е. Prepare + start * вызывается из useEffect. Stop — при release, finalized URI через * `recorder.uri`. */ import React, { useEffect, useRef, useState } from 'react'; import { View, Text, Pressable, Alert, Animated } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { useAudioRecorder, AudioModule, RecordingPresets, setAudioModeAsync, } from 'expo-audio'; import type { Attachment } from '@/lib/types'; export interface VoiceRecorderProps { onFinish: (att: Attachment) => void; onCancel: () => void; } export function VoiceRecorder({ onFinish, onCancel }: VoiceRecorderProps) { const recorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY); const startedAt = useRef(0); const [elapsed, setElapsed] = useState(0); const [error, setError] = useState(null); const [ready, setReady] = useState(false); // Pulsing red dot const pulse = useRef(new Animated.Value(1)).current; useEffect(() => { const loop = Animated.loop( Animated.sequence([ Animated.timing(pulse, { toValue: 0.4, duration: 500, useNativeDriver: true }), Animated.timing(pulse, { toValue: 1, duration: 500, useNativeDriver: true }), ]), ); loop.start(); return () => loop.stop(); }, [pulse]); // Start recording at mount useEffect(() => { let cancelled = false; (async () => { try { const perm = await AudioModule.requestRecordingPermissionsAsync(); if (!perm.granted) { setError('Microphone permission denied'); return; } await setAudioModeAsync({ allowsRecording: true, playsInSilentMode: true, }); await recorder.prepareToRecordAsync(); if (cancelled) return; recorder.record(); startedAt.current = Date.now(); setReady(true); } catch (e: any) { setError(e?.message ?? 'Failed to start recording'); } })(); return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Timer tick useEffect(() => { if (!ready) return; const t = setInterval(() => { setElapsed(Math.floor((Date.now() - startedAt.current) / 1000)); }, 250); return () => clearInterval(t); }, [ready]); const stop = async (send: boolean) => { try { if (recorder.isRecording) { await recorder.stop(); } const uri = recorder.uri; const seconds = Math.max(1, Math.floor((Date.now() - startedAt.current) / 1000)); if (!send || !uri || seconds < 1) { onCancel(); return; } onFinish({ kind: 'voice', uri, duration: seconds, mime: 'audio/m4a', }); } catch (e: any) { Alert.alert('Recording failed', e?.message ?? 'Unknown error'); onCancel(); } }; const mm = Math.floor(elapsed / 60); const ss = elapsed % 60; if (error) { return ( {error} ); } return ( stop(false)} hitSlop={8} style={{ width: 32, height: 32, borderRadius: 16, alignItems: 'center', justifyContent: 'center', }} > {mm}:{String(ss).padStart(2, '0')} Recording… stop(true)} style={({ pressed }) => ({ width: 32, height: 32, borderRadius: 16, backgroundColor: pressed ? '#1a8cd8' : '#1d9bf0', alignItems: 'center', justifyContent: 'center', })} > ); }