diff --git a/apps/fabric-example/.eslintrc.js b/apps/common-app/.eslintrc.js similarity index 100% rename from apps/fabric-example/.eslintrc.js rename to apps/common-app/.eslintrc.js diff --git a/apps/common-app/package.json b/apps/common-app/package.json index 14088516d..005d8fe6a 100644 --- a/apps/common-app/package.json +++ b/apps/common-app/package.json @@ -41,6 +41,6 @@ "react": "19.1.1", "react-native": "0.82.0", "react-test-renderer": "19.1.1", - "typescript": "5.8.3" + "typescript": "~5.8.3" } } diff --git a/apps/common-app/prettier.config.js b/apps/common-app/prettier.config.js new file mode 100644 index 000000000..4e6d1badd --- /dev/null +++ b/apps/common-app/prettier.config.js @@ -0,0 +1,10 @@ +/** @type {import('prettier').Config} */ +module.exports = { + plugins: ["prettier-plugin-jsdoc"], + bracketSameLine: false, + printWidth: 80, + singleQuote: true, + trailingComma: "es5", + tabWidth: 2, + arrowParens: "always", +}; diff --git a/apps/common-app/src/components/BGGradient.tsx b/apps/common-app/src/components/BGGradient.tsx index 15962e358..f3079587f 100644 --- a/apps/common-app/src/components/BGGradient.tsx +++ b/apps/common-app/src/components/BGGradient.tsx @@ -1,6 +1,6 @@ +import { Canvas, RadialGradient, Rect, vec } from '@shopify/react-native-skia'; import React, { useCallback, useState } from 'react'; import { LayoutChangeEvent, StyleSheet, View } from 'react-native'; -import { vec, Rect, Canvas, RadialGradient } from '@shopify/react-native-skia'; import { colors } from '../styles'; @@ -19,7 +19,7 @@ const BGGradient = () => { diff --git a/apps/common-app/src/components/Container.tsx b/apps/common-app/src/components/Container.tsx index 19797d5f4..ae9bb17f6 100644 --- a/apps/common-app/src/components/Container.tsx +++ b/apps/common-app/src/components/Container.tsx @@ -1,8 +1,7 @@ import React, { PropsWithChildren } from 'react'; -import { SafeAreaView } from 'react-native-safe-area-context'; import { StyleProp, StyleSheet, ViewStyle } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; -import BGGradient from './BGGradient'; import { colors } from '../styles'; type ContainerProps = PropsWithChildren<{ @@ -19,8 +18,13 @@ const Container: React.FC = (props) => { return ( - + style={[ + styles.basic, + centered && styles.centered, + !disablePadding && styles.padding, + style, + ]} + > {children} ); diff --git a/apps/common-app/src/components/icons/PlayPauseIcon.tsx b/apps/common-app/src/components/icons/PlayPauseIcon.tsx index 9bc65eefa..7259ad01e 100644 --- a/apps/common-app/src/components/icons/PlayPauseIcon.tsx +++ b/apps/common-app/src/components/icons/PlayPauseIcon.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import Svg, { Path, Rect } from 'react-native-svg'; import { View } from 'react-native'; +import Svg, { Path, Rect } from 'react-native-svg'; type Props = { isPlaying: boolean; @@ -8,7 +8,11 @@ type Props = { color?: string; }; -const PlayPauseIcon: React.FC = ({ isPlaying = true, size = 48, color = '#FFFFFF' }) => { +const PlayPauseIcon: React.FC = ({ + isPlaying = true, + size = 48, + color = '#FFFFFF', +}) => { return ( @@ -18,10 +22,7 @@ const PlayPauseIcon: React.FC = ({ isPlaying = true, size = 48, color = ' ) : ( - + )} diff --git a/apps/common-app/src/examples/AudioFile/AudioPlayer.ts b/apps/common-app/src/examples/AudioFile/AudioPlayer.ts index bdeb84042..87169549e 100644 --- a/apps/common-app/src/examples/AudioFile/AudioPlayer.ts +++ b/apps/common-app/src/examples/AudioFile/AudioPlayer.ts @@ -1,8 +1,8 @@ -import { AudioContext, AudioManager } from 'react-native-audio-api'; import type { - AudioBufferSourceNode, AudioBuffer, + AudioBufferSourceNode, } from 'react-native-audio-api'; +import { AudioContext, AudioManager } from 'react-native-audio-api'; class AudioPlayer { private readonly audioContext: AudioContext; @@ -93,7 +93,8 @@ class AudioPlayer { loadBuffer = async (url: string) => { const buffer = await fetch(url, { headers: { - 'User-Agent': 'Mozilla/5.0 (Android; Mobile; rv:122.0) Gecko/122.0 Firefox/122.0', + 'User-Agent': + 'Mozilla/5.0 (Android; Mobile; rv:122.0) Gecko/122.0 Firefox/122.0', }, }) .then((response) => response.arrayBuffer()) diff --git a/apps/common-app/src/examples/AudioVisualizer/AudioVisualizer.tsx b/apps/common-app/src/examples/AudioVisualizer/AudioVisualizer.tsx index 50fefe1c6..655077030 100644 --- a/apps/common-app/src/examples/AudioVisualizer/AudioVisualizer.tsx +++ b/apps/common-app/src/examples/AudioVisualizer/AudioVisualizer.tsx @@ -1,15 +1,15 @@ -import React, { useState, useEffect, useRef } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; +import { ActivityIndicator, View } from 'react-native'; import { - AudioContext, AnalyserNode, AudioBuffer, AudioBufferSourceNode, + AudioContext, } from 'react-native-audio-api'; -import { ActivityIndicator, View } from 'react-native'; -import FreqTimeChart from './FreqTimeChart'; -import { Container, Button } from '../../components'; +import { Button, Container } from '../../components'; import { layout } from '../../styles'; +import FreqTimeChart from './FreqTimeChart'; const FFT_SIZE = 512; diff --git a/apps/common-app/src/examples/Record/ControlPanel.tsx b/apps/common-app/src/examples/Record/ControlPanel.tsx new file mode 100644 index 000000000..7f033b82a --- /dev/null +++ b/apps/common-app/src/examples/Record/ControlPanel.tsx @@ -0,0 +1,37 @@ +import React, { FC } from 'react'; +import { StyleSheet } from 'react-native'; + +import Animated from 'react-native-reanimated'; +import PauseButton from './PauseButton'; +import RecordButton from './RecordButton'; +import { RecordingState } from './types'; + +interface ControlPanelProps { + state: RecordingState; + onToggleState: (action: RecordingState) => void; +} + +const ControlPanel: FC = ({ state, onToggleState }) => { + return ( + + { + onToggleState(RecordingState.Paused); + }} + /> + + + ); +}; + +export default ControlPanel; + +const styles = StyleSheet.create({ + controlPanelView: { + alignItems: 'center', + justifyContent: 'center', + flexDirection: 'row', + gap: 12, + }, +}); diff --git a/apps/common-app/src/examples/Record/PauseButton.tsx b/apps/common-app/src/examples/Record/PauseButton.tsx new file mode 100644 index 000000000..af5d79e5a --- /dev/null +++ b/apps/common-app/src/examples/Record/PauseButton.tsx @@ -0,0 +1,89 @@ +import React, { FC } from 'react'; +import { Pressable, StyleSheet, View } from 'react-native'; +import Animated, { + useAnimatedProps, + withSpring, +} from 'react-native-reanimated'; +import { RecordingState } from './types'; + +interface PauseButtonProps { + state: RecordingState; + onPress: () => void; +} + +const PauseButton: FC = ({ state, onPress }) => ( + + + {({ pressed }) => } + + +); + +export default PauseButton; + +const size = 24; +const innerSize = size * 0.3; + +const PauseButtonInner: FC<{ + pressed: boolean; + state: RecordingState; +}> = ({ pressed, state }) => { + const leftViewStyle = useAnimatedProps(() => { + return { + transform: [ + { + translateX: withSpring(pressed ? 6 : 0), + }, + ], + }; + }); + + const rightViewStyle = useAnimatedProps(() => { + return { + transform: [ + { + translateX: withSpring(pressed ? -6 : 0), + }, + ], + }; + }); + + const containerStyle = useAnimatedProps(() => { + return { + transform: [ + { + scale: withSpring(state === RecordingState.Recording ? 1 : 0), + }, + ], + }; + }); + + return ( + + + + + ); +}; + +const styles = StyleSheet.create({ + pressable: { + position: 'absolute', + left: -size, + top: -size / 2, + }, + container: { + width: size, + height: size, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: innerSize - 2, + }, + bar: { + width: innerSize, + height: size, + backgroundColor: '#d4d4d4', + borderRadius: 2, + }, +}); diff --git a/apps/common-app/src/examples/Record/Record.tsx b/apps/common-app/src/examples/Record/Record.tsx index cdb09addb..871768693 100644 --- a/apps/common-app/src/examples/Record/Record.tsx +++ b/apps/common-app/src/examples/Record/Record.tsx @@ -1,174 +1,202 @@ -import React, { useRef, FC, useEffect } from 'react'; -import { - AudioContext, - AudioManager, - AudioRecorder, - RecorderAdapterNode, - AudioBufferSourceNode, - AudioBuffer, -} from 'react-native-audio-api'; - -import { Container, Button } from '../../components'; -import { View, Text } from 'react-native'; -import { colors } from '../../styles'; - -const SAMPLE_RATE = 16000; +import React, { FC, useCallback, useEffect, useState } from 'react'; +import { AudioContext, AudioManager } from 'react-native-audio-api'; + +import { Alert, StyleSheet, View } from 'react-native'; +import { Container } from '../../components'; + +import ControlPanel from './ControlPanel'; +import Recorder from './Recorder'; +import RecordingTime from './RecordingTime'; +import RecordingVisualization from './RecordingVisualization'; +import Status from './Status'; +import { RecordingState } from './types'; + +AudioManager.setAudioSessionOptions({ + iosCategory: 'playAndRecord', + iosMode: 'default', + iosOptions: ['defaultToSpeaker', 'allowBluetoothA2DP'], +}); + +const audioContext = new AudioContext({ initSuspended: true }); + +Recorder.enableFileOutput({}); const Record: FC = () => { - const recorderRef = useRef(null); - const aCtxRef = useRef(null); - const recorderAdapterRef = useRef(null); - const audioBuffersRef = useRef([]); - const sourcesRef = useRef([]); + const [state, setState] = useState(RecordingState.Idle); + const [hasPermissions, setHasPermissions] = useState(false); - useEffect(() => { - const setup = async () => { - try { - await AudioManager.requestRecordingPermissions(); - } catch (err) { - console.log(err); - console.error('Recording permission denied', err); + const onStartRecording = useCallback(async () => { + if (state !== RecordingState.Idle) { + return; + } + + setState(RecordingState.Loading); + + if (!hasPermissions) { + const permissionStatus = await AudioManager.requestRecordingPermissions(); + + if (permissionStatus !== 'Granted') { + Alert.alert('Baka!', "Recording permissions are no't granted"); return; } - recorderRef.current = new AudioRecorder({ - sampleRate: SAMPLE_RATE, - bufferLengthInSamples: SAMPLE_RATE, - }); - }; - - setup(); - return () => { - aCtxRef.current?.close(); - stopRecorder(); - }; - }, []); - const setupRecording = () => { - AudioManager.setAudioSessionOptions({ - iosCategory: 'playAndRecord', - iosMode: 'spokenAudio', - iosOptions: ['defaultToSpeaker', 'allowBluetoothA2DP'], - }); - }; - - const stopRecorder = () => { - if (recorderRef.current) { - recorderRef.current.stop(); - console.log('Recording stopped'); - // advised, but not required - AudioManager.setAudioSessionOptions({ - iosCategory: 'playback', - iosMode: 'default', - }); - } else { - console.error('AudioRecorder is not initialized'); + setHasPermissions(true); } - }; - const startEcho = () => { - if (!recorderRef.current) { - console.error('AudioContext or AudioRecorder is not initialized'); + const success = await AudioManager.setAudioSessionActivity(true); + + if (!success) { + Alert.alert('Error', 'Failed to activate audio session for recording.'); return; } - setupRecording(); - - aCtxRef.current = new AudioContext({ sampleRate: SAMPLE_RATE }); - recorderAdapterRef.current = aCtxRef.current.createRecorderAdapter(); - recorderAdapterRef.current.connect(aCtxRef.current.destination); - recorderRef.current.connect(recorderAdapterRef.current); - - recorderRef.current.start(); - console.log('Recording started'); - console.log('Audio context state:', aCtxRef.current.state); - if (aCtxRef.current.state === 'suspended') { - console.log('Resuming audio context'); - aCtxRef.current.resume(); - } - }; - - /// This stops only the recording, not the audio context - const stopEcho = () => { - stopRecorder(); - aCtxRef.current = null; - recorderAdapterRef.current = null; - }; - - const startRecordReplay = () => { - if (!recorderRef.current) { - console.error('AudioRecorder is not initialized'); + + const result = Recorder.start(); + + if (result.status === 'success') { + setState(RecordingState.Recording); return; } - setupRecording(); - audioBuffersRef.current = []; - - recorderRef.current.onAudioReady((event) => { - const { buffer, numFrames } = event; - console.log('Audio recorder buffer ready:', buffer.duration, numFrames); - audioBuffersRef.current.push(buffer); - }); + Alert.alert('Error', `Failed to start recording: ${result.message}`); + setState(RecordingState.Idle); + }, [state, hasPermissions]); - recorderRef.current.start(); + const onPauseRecording = useCallback(() => { + Recorder.pause(); + setState(RecordingState.Paused); + }, []); - setTimeout(() => { - stopRecorder(); - }, 5000); - }; + const onResumeRecording = useCallback(() => { + Recorder.resume(); + setState(RecordingState.Recording); + }, []); - const stopRecordReplay = () => { - const aCtx = new AudioContext({ sampleRate: SAMPLE_RATE }); - aCtxRef.current = aCtx; + const onStopRecording = useCallback(() => { + Recorder.stop(); + setState(RecordingState.ReadyToPlay); + }, []); - if (aCtx.state === 'suspended') { - aCtx.resume(); + const onPlayRecording = useCallback(() => { + if (state !== RecordingState.ReadyToPlay) { + return; } - const tNow = aCtx.currentTime; - let nextStartAt = tNow + 1; - const buffers = audioBuffersRef.current; + setState(RecordingState.Playing); + }, [state]); + + const onToggleState = useCallback( + (action: RecordingState) => { + if (state === RecordingState.Paused) { + if (action === RecordingState.Recording) { + onResumeRecording(); + return; + } + } - console.log(tNow, nextStartAt, buffers.length); + if (action === RecordingState.Recording) { + onStartRecording(); + return; + } - for (let i = 0; i < buffers.length; i++) { - const source = aCtx.createBufferSource(); - source.buffer = buffers[i]; + if (action === RecordingState.Paused) { + onPauseRecording(); + return; + } - source.connect(aCtx.destination); - sourcesRef.current.push(source); + if (action === RecordingState.Idle) { + if (state === RecordingState.Recording) { + onStopRecording(); + } else if (state === RecordingState.Playing) { + setState(RecordingState.Idle); + } + return; + } - source.start(nextStartAt); - nextStartAt += buffers[i].duration; - } + if (action === RecordingState.ReadyToPlay) { + onStopRecording(); + return; + } + + if (action === RecordingState.Playing) { + onPlayRecording(); + } + }, + [ + state, + onStartRecording, + onPauseRecording, + onStopRecording, + onResumeRecording, + onPlayRecording, + ] + ); - setTimeout( - () => { - console.log('clearing data'); - audioBuffersRef.current = []; - sourcesRef.current = []; - }, - (nextStartAt - tNow) * 1000 - ); - }; + useEffect(() => { + (async () => { + const permissionStatus = await AudioManager.checkRecordingPermissions(); + + if (permissionStatus === 'Granted') { + setHasPermissions(true); + } + })(); + }, []); return ( - - - Sample rate: {SAMPLE_RATE} - - - Echo -