Запись и воспроизведение голоса, а также отмена записи свайпом вправо с анимацией и изменением иконки на React Native

от автора

Всем привет! Меня зовут Хатам. Я работаю в компании Neti. Когда-то я был верстальщиком сайтов, но мне хотелось развиваться дальше. Поэтому я изучил React и научился делать веб-приложения, а затем решил попробовать свои силы в мобильной разработке. В этой статье я делюсь примерами решений, к которым пришел, работая над задачей одного из клиентов. Надеюсь, что мой опыт будет кому-то полезен.

Проект

Мобильное приложение, предназначенное для быстрой отправки сообщений о проблемах и опасных факторах для жизни и здоровья людей на территории производственного предприятия.

Пользователи: сотрудники компании, подрядчики и гости предприятия.

Платформы: Android, IOS.

Стек технологий: React Native, MobX.

Задача

Возможности, которые необходимо было реализовать: 

  • Запись голоса;

  • Отмена записи свайпом вправо с анимацией и изменением иконки;

  • Воспроизведение записанного звука. 

Также записанный голос должен был воспроизводиться в админке в браузере.

Вот как это должно работать:

Решение

Я приступил к поиску библиотеки. Из найденных вариантов самым популярным оказался react-native-audio-recorder-player. Основным его плюсом является принцип «2 в 1», то есть запись и воспроизведение записанного аудио.

Сделал два компонента: AudioRecorder и AudioPlayer.

Начнем с AudioRecorder.tsx. Библиотеку нашел, но она не умеет по свайпу отменять запись. Это надо было реализовать самостоятельно.

После долгих поисков и раздумий в голову пришла идея: а что, если этот свайп сделать слайдером? Почему бы и нет: у слайдера есть состояние, которое позволяет плавно анимировать нужный элемент и прописать отмену на определенной позиции. Но чтобы сделать такую кнопку, какая нам нужна, слайдер должен быть нестандартный. Поэтому подключил библиотеку react-native-slider-custom. Скрыл полосу слайдера, сделал кнопку «запись/отмена» с динамичными иконками, сделал абсолютное позиционирование поверх двух блоков: текста и продолжительности записи. Все готово, идея сработала ?

AudioRecorder.tsx

import React, { useEffect, useState } from 'react'; import { PermissionsAndroid, Platform, StyleSheet, Text, ToastAndroid, View } from 'react-native'; import { observer } from 'mobx-react-lite'; import Slider from 'react-native-slider-custom'; import {   AudioEncoderAndroidType,   AudioSet,   AudioSourceAndroidType,   AVEncoderAudioQualityIOSType,   AVEncodingOption, } from 'react-native-audio-recorder-player';   import { Colors } from '../../../styles/Colors'; import MicroPhoneIcon from '../../svg/MicroPhoneIcon'; import { TextStyle } from '../../../styles/TextStyle'; import RecordingIcon from '../../svg/RecordingIcon'; import TrashIcon from '../../svg/TrashIcon'; import { playerStyles } from './playerStyles'; import { useStores } from '../../../hooks/use-stores';   const CANCEL_RECORDING_SLIDER_VALUE = 0.8; const MAX_AUDIO_DURATION = 180000; // ms = 180000=3m   interface IAudioRecorderProps {}   const AudioRecorder: React.FC<IAudioRecorderProps> = observer(({}) => {   const { audioRecPlayStore: store } = useStores();   const [androidGranted, setAndroidGranted] = useState(false);   const [recordSlidingValue, setRecordSlidingValue] = useState(0);   const [recordTime, setRecordTime] = useState('00:00:00');     // Effects   useEffect(() => {     (async () => {       if (Platform.OS === 'android') {         const hasPermissionWrite = await PermissionsAndroid.check(PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE);         const hasPermissionRecord = await PermissionsAndroid.check(PermissionsAndroid.PERMISSIONS.RECORD_AUDIO);           if (hasPermissionRecord && hasPermissionWrite) {           setAndroidGranted(true);         } else {           setAndroidGranted(false);         }       } else {         setAndroidGranted(true);       }     })();   }, []);     // Handlers   const handleSlidingStart = () => {     onStartRecord();   };     const handleSlidingChange = (value: number) => {     setRecordSlidingValue(value);   };     const handleSlidingComplete = (value: number) => {     onStopRecord(value);   };     // Actions   const onStartRecord = async () => {     if (!androidGranted) {       if (Platform.OS === 'android') {         const granted = await PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.RECORD_AUDIO);           if (granted !== PermissionsAndroid.RESULTS.GRANTED) {           ToastAndroid.show('Использование микрофона запрещена', ToastAndroid.LONG);           return false;         }       }         if (Platform.OS === 'android') {         const granted = await PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE);           if (granted !== PermissionsAndroid.RESULTS.GRANTED) {           ToastAndroid.show('Запись в хранилише запрещено', ToastAndroid.LONG);           return false;         }       }         setAndroidGranted(true);     } else {       const path = Platform.select({         ios: 'akkerman_voice_message.m4a',         android: 'sdcard/akkerman_voice_message.mp4',       });         const audioSet: AudioSet = {         AudioEncoderAndroid: AudioEncoderAndroidType.AAC,         AudioSourceAndroid: AudioSourceAndroidType.MIC,         AVEncoderAudioQualityKeyIOS: AVEncoderAudioQualityIOSType.high,         AVNumberOfChannelsKeyIOS: 2,         AVFormatIDKeyIOS: AVEncodingOption.aac,       };         await store.audioRecorderPlayer.startRecorder(path, audioSet);         store.setIsRecording(true);       store.audioRecorderPlayer.addRecordBackListener((e: any) => {         setRecordTime(store.audioRecorderPlayer.mmssss(Math.floor(e.current_position)));           // Stop and save recording         if (e.current_position > MAX_AUDIO_DURATION) {           onStopRecord(0);         }           return;       });     }       return;   };     const onStopRecord = async (value: number) => {     const result = await store.audioRecorderPlayer.stopRecorder();       setRecordSlidingValue(0);     store.setIsRecording(false);     store.audioRecorderPlayer.removeRecordBackListener();       if (value > CANCEL_RECORDING_SLIDER_VALUE) {       // Cancel recording       store.setAudio(null);     } else {       // Save recording       if (!result.includes('stop')) {         store.setAudio({ duration: recordTime, uri: result });       }     }   };     // Renders   return (     <View>       <Slider         value={recordSlidingValue}         minimumTrackTintColor="transparent"         maximumTrackTintColor="transparent"         onSlidingStart={handleSlidingStart}         onSlidingComplete={handleSlidingComplete}         onValueChange={handleSlidingChange}         customThumb={           store.isRecording ? (             <View style={styles.animatedIconsContainer}>               <View style={[styles.animatedIconsBG, { opacity: recordSlidingValue }]} />               <View style={[styles.animatedIconsWrap]}>                 <MicroPhoneIcon color={Colors.white} style={[styles.animatedIcon, { opacity: 1 - recordSlidingValue }]} />                 <TrashIcon style={[styles.animatedIcon, { opacity: recordSlidingValue > CANCEL_RECORDING_SLIDER_VALUE ? 1 : recordSlidingValue }]} />               </View>             </View>           ) : (             <MicroPhoneIcon />           )         }         thumbStyle={[           playerStyles.button,           store.isRecording && playerStyles.buttonShadow,           { backgroundColor: recordSlidingValue > CANCEL_RECORDING_SLIDER_VALUE ? Colors.error : Colors.primary },         ]}         style={styles.swipeableButton}         animateTransitions       />         <View style={[styles.recorder, { opacity: 1 - recordSlidingValue }]}>         <View style={{ flexDirection: 'row', alignItems: 'center' }}>           <Text style={{ ...TextStyle.caption, color: Colors.whiteMedium, marginLeft: 40 + 8 }}>             {store.isRecording ? 'Вправо для отмены' : 'Зажмите для записи'}           </Text>         </View>           {store.isRecording && (           <View style={{ flexDirection: 'row', alignItems: 'center' }}>             <Text style={{ ...TextStyle.body1, marginRight: 10 }}>{recordTime}</Text>             <RecordingIcon />           </View>         )}       </View>     </View>   ); });   export default AudioRecorder;   const styles = StyleSheet.create({   recorder: {     height: 40,     flexDirection: 'row',     alignItems: 'center',     justifyContent: 'space-between',   },   swipeableButton: {     position: 'absolute',     top: 0,     left: 0,     width: '100%',     height: 40,     zIndex: 1,   },   animatedIconsContainer: {     width: '100%',     height: '100%',     alignItems: 'center',     justifyContent: 'center',   },   animatedIconsBG: {     position: 'absolute',     top: 0,     left: 0,     bottom: 0,     right: 0,     backgroundColor: Colors.error,   },   animatedIconsWrap: {     position: 'relative',     width: 24,     height: 24,   },   animatedIcon: {     position: 'absolute',     top: 0,     left: 0,     bottom: 0,     right: 0,   }, });

Для реализации отмены записи воспользовался методом onSlidingComplete у библиотеки слайдера, который срабатывает при отпускании ползунка. Позицию отмены решил сделать 80%. Вынес в константу для удобства CANCEL_RECORDING_SLIDER_VALUE, равный 0.8, потому что слайдер по умолчанию работает от 0 до 1.

При отпускании кнопки записи (ползунка слайдера) вызываем обработчик handleSlidingComplete. Он, в свою очередь, вызывает функцию onStopRecord, передавая туда позицию слайдера на момент отпускания. В функции onStopRecord в зависимости от позиции слайдера делаем проверку: сохранить запись или отменить запись. Чтобы определить, какое действие выполнить, прописываем условие value > CANCEL_RECORDING_SLIDER_VALUE. Если значения слайдера при отпускании кнопки записи больше 0.8 — отменяем запись, если меньше — сохраняем.

Вот и все!

AudioPlayer.tsx

import React, { useCallback, useEffect, useState } from 'react'; import { ActivityIndicator, Platform, Pressable, StyleSheet, Text, View } from 'react-native'; import { observer } from 'mobx-react-lite'; import Slider from 'react-native-slider-custom'; import { useFocusEffect } from '@react-navigation/native';   import { useStores } from '../../../hooks/use-stores'; import { Colors } from '../../../styles/Colors'; import { TextStyle } from '../../../styles/TextStyle'; import PlayIcon from '../../svg/PlayIcon'; import PauseIcon from '../../svg/PauseIcon'; import { playerStyles } from './playerStyles';   interface IAudioPlayerProps {   audio?: string; }   const AudioPlayer: React.FC<IAudioPlayerProps> = observer(({ audio }) => {   const { audioRecPlayStore: store } = useStores();   const [currentPositionSec, setCurrentPositionSec] = useState(0);   const [currentDurationSec, setCurrentDurationSec] = useState(0);   const [playTime, setPlayTime] = useState('00:00:00');   const [isPlaying, setIsPlaying] = useState(false);   const [loading, setLoading] = useState(false);     useFocusEffect(     React.useCallback(() => {       return () => {         onStopPlay();       };     }, []),   );     useEffect(() => {     return () => {       onStopPlay();     };   }, []);     // Actions   const onPlayPause = () => {     if (!loading) {       if (isPlaying) {         onPausePlay();       } else {         onStartPlay();       }     }   };     const onStartPlay = useCallback(async () => {     const path = Platform.select({       ios: audio ? audio : 'akkerman_voice_message.m4a',       android: audio ? audio : 'sdcard/akkerman_voice_message.mp4',     });       setLoading(true);     await store.audioRecorderPlayer.startPlayer(path);       setLoading(false);     setIsPlaying(true);       store.audioRecorderPlayer.addPlayBackListener((e: any) => {       if (e.current_position === e.duration) {         setIsPlaying(false);         onStopPlay();       }         setCurrentPositionSec(e.current_position);       setCurrentDurationSec(e.duration);       setPlayTime(store.audioRecorderPlayer.mmssss(Math.floor(e.current_position)));         return;     });   }, [store.audioRecorderPlayer]);     const onPausePlay = async () => {     await store.audioRecorderPlayer.pausePlayer();     setIsPlaying(false);   };     const onStopPlay = async () => {     store.audioRecorderPlayer.stopPlayer();     store.audioRecorderPlayer.removePlayBackListener();   };     // Renders   return (     <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>       <Pressable style={[playerStyles.button, { marginRight: 12 }]} onPress={onPlayPause}>         {loading ? <ActivityIndicator size={24} color={Colors.black} /> : isPlaying ? <PauseIcon /> : <PlayIcon />}       </Pressable>       <View style={{ flex: 1 }}>         <Slider           maximumValue={Math.floor(currentDurationSec)}           value={Math.floor(currentPositionSec)}           minimumTrackTintColor={Colors.primary}           maximumTrackTintColor={Colors.whiteMedium}           thumbStyle={{ width: 12, height: 12, backgroundColor: Colors.primary }}           style={{ height: 12 + 8 }}           disabled         />         <Text style={{ ...TextStyle.caption, color: Colors.white }}>{playTime}</Text>       </View>     </View>   ); });   export default AudioPlayer;

Это обычный кастомный аудиоплеер с некоторыми отличиями библиотеки. 

Чтобы рекордер и плеер работали совместно, нужно использовать один и тот же экземпляр плеера. Также для хорошего UX нужно было отключить скролл экрана «Создания сообщения о риске» во время записи голоса, чтобы работал только слайдер. Поэтому сделал MobX стор AudioRecPlayStore.ts.

AudioRecPlayStore.ts

import { makeAutoObservable } from 'mobx'; import AudioRecorderPlayer from 'react-native-audio-recorder-player';   import { Nullable } from '../../../types/CommonTypes'; import { IAudioObject } from './AudioRecPlay';   export class AudioRecPlayStore {   isRecording = false;     audioRecorderPlayer: AudioRecorderPlayer;   audio: Nullable<IAudioObject> = null;     constructor() {     makeAutoObservable(this);     this.audioRecorderPlayer = new AudioRecorderPlayer();   }     setIsRecording = (state: boolean) => {     this.isRecording = state;   };     setAudio = (audio: Nullable<IAudioObject>) => {     this.audio = audio;   };     clear = () => {     this.isRecording = false;     this.audio = null;   }; }

В сторе есть следующие свойства:

  • Экземпляр плеера audioRecorderPlayer для переключение из рекордера в плеер и обратно;

  • Состояние записи isRecording для отключения скролла во время записи.

<ScrollView scrollEnabled={!audioRecPlayStore.isRecording}>

За рендер рекордера или плеера отвечает компонент MessageAudioMessage.tsx.

MessageAudioMessage.tsx

import React, { useEffect } from 'react'; import { Text, TouchableOpacity, View } from 'react-native'; import { observer } from 'mobx-react-lite';   import { useStores } from '../../../hooks/use-stores'; import InputLabel from '../../InputLabel'; import { messageStyles } from '../messageStyles'; import { Colors } from '../../../styles/Colors'; import { TextStyle } from '../../../styles/TextStyle'; import AudioRecorder from './AudioRecorder'; import AudioPlayer from './AudioPlayer'; import { FIleData } from '../../../modules/message/Message'; import { Nullable } from '../../../types/CommonTypes';   interface IMessageAudioMessageProps {   onlyPlayer?: boolean;   audio?: Nullable<FIleData>; }   const MessageAudioMessage: React.FC<IMessageAudioMessageProps> = observer(({ onlyPlayer, audio }) => {   const { audioRecPlayStore: store } = useStores();     useEffect(() => {     return () => {       store.clear();     };   }, []);     const handleRemoveAudio = () => {     store.setAudio(null);     store.audioRecorderPlayer.stopPlayer();     store.audioRecorderPlayer.removePlayBackListener();   };     return (     <View style={{ ...messageStyles.card, flexDirection: 'row', alignItems: 'center' }}>       <View style={{ flex: 1 }}>         <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>           <InputLabel label="Аудиосообщение" />           {!onlyPlayer && !!store.audio && (             <TouchableOpacity onPress={handleRemoveAudio} activeOpacity={0.8}>               <Text style={{ ...TextStyle.subtitle, color: Colors.error, letterSpacing: 0 }}>Удалить</Text>             </TouchableOpacity>           )}         </View>         {onlyPlayer ? <AudioPlayer audio={audio?.fullUrl || ''} /> : !!store.audio ? <AudioPlayer /> : <AudioRecorder />}       </View>     </View>   ); });   export default MessageAudioMessage;

Были проблемы с воспроизведением записанного голоса на разных платформах из разных платформ: они были несовместимы. Решение есть в самой документации библиотеки рекордера.

сonst audioSet: AudioSet = {   AudioEncoderAndroid: AudioEncoderAndroidType.AAC,   AudioSourceAndroid: AudioSourceAndroidType.MIC,   AVEncoderAudioQualityKeyIOS: AVEncoderAudioQualityIOSType.high,   AVNumberOfChannelsKeyIOS: 2,   AVFormatIDKeyIOS: AVEncodingOption.aac, };   await store.audioRecorderPlayer.startRecorder(path, audioSet); 

Создаем объект audioSet с настройками записи аудиофайла и передаем как второй аргумент методу startRecorder. Эти настройки кроссплатформенные, то есть записанный голос на Android/iOS воспроизводится на вебе в нативном html5 плеере и на Android/iOS.

Полезные ресурсы:

https://www.npmjs.com/package/react-native-audio-recorder-player 

https://www.npmjs.com/package/react-native-slider-custom


ссылка на оригинал статьи https://habr.com/ru/articles/585726/