/** * VideoCircleRecorder — full-screen Modal для записи круглого видео- * сообщения (Telegram-style). * * UX: * 1. Открывается Modal с CameraView (по умолчанию front-camera). * 2. Превью — круглое (аналогично VideoCirclePlayer). * 3. Большая красная кнопка внизу: tap-to-start, tap-to-stop. * 4. Максимум 15 секунд — авто-стоп. * 5. По stop'у возвращаем attachment { kind:'video', circle:true, uri, duration }. * 6. Свайп вниз / close-icon → cancel (без отправки). */ import React, { useEffect, useRef, useState } from 'react'; import { View, Text, Pressable, Modal, Alert } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { CameraView, useCameraPermissions, useMicrophonePermissions } from 'expo-camera'; import type { Attachment } from '@/lib/types'; export interface VideoCircleRecorderProps { visible: boolean; onClose: () => void; onFinish: (att: Attachment) => void; } const MAX_DURATION_SEC = 15; function formatClock(sec: number): string { const m = Math.floor(sec / 60); const s = Math.floor(sec % 60); return `${m}:${String(s).padStart(2, '0')}`; } export function VideoCircleRecorder({ visible, onClose, onFinish }: VideoCircleRecorderProps) { const insets = useSafeAreaInsets(); const camRef = useRef(null); const [camPerm, requestCam] = useCameraPermissions(); const [micPerm, requestMic] = useMicrophonePermissions(); const [recording, setRecording] = useState(false); const [elapsed, setElapsed] = useState(0); const startedAt = useRef(0); const facing: 'front' | 'back' = 'front'; // Timer + auto-stop at MAX_DURATION_SEC useEffect(() => { if (!recording) return; const t = setInterval(() => { const s = Math.floor((Date.now() - startedAt.current) / 1000); setElapsed(s); if (s >= MAX_DURATION_SEC) stopAndSend(); }, 250); return () => clearInterval(t); // eslint-disable-next-line react-hooks/exhaustive-deps }, [recording]); // Permissions on mount of visible useEffect(() => { if (!visible) { setRecording(false); setElapsed(0); return; } (async () => { if (!camPerm?.granted) await requestCam(); if (!micPerm?.granted) await requestMic(); })(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [visible]); const start = async () => { if (!camRef.current || recording) return; try { startedAt.current = Date.now(); setElapsed(0); setRecording(true); // recordAsync блокируется до stopRecording или maxDuration const result = await camRef.current.recordAsync({ maxDuration: MAX_DURATION_SEC }); setRecording(false); if (!result?.uri) return; const seconds = Math.max(1, Math.floor((Date.now() - startedAt.current) / 1000)); onFinish({ kind: 'video', circle: true, uri: result.uri, duration: seconds, mime: 'video/mp4', }); onClose(); } catch (e: any) { setRecording(false); Alert.alert('Recording failed', e?.message ?? 'Unknown error'); } }; const stopAndSend = () => { if (!recording) return; camRef.current?.stopRecording(); // recordAsync promise выше resolve'нется с uri → onFinish }; const cancel = () => { if (recording) { camRef.current?.stopRecording(); } onClose(); }; const permOK = camPerm?.granted && micPerm?.granted; return ( {/* Header */} Video message {/* Camera */} {permOK ? ( ) : ( Permissions needed Camera + microphone access are required to record a video message. )} {/* Timer */} {recording && ( ● {formatClock(elapsed)} / {formatClock(MAX_DURATION_SEC)} )} {/* Record / Stop button */} ({ width: 72, height: 72, borderRadius: 36, backgroundColor: !permOK ? '#1a1a1a' : recording ? '#f4212e' : '#1d9bf0', alignItems: 'center', justifyContent: 'center', opacity: pressed ? 0.85 : 1, borderWidth: 4, borderColor: 'rgba(255,255,255,0.2)', })} > {recording ? 'Tap to stop & send' : permOK ? 'Tap to record' : 'Grant permissions'} ); }