fix(mobile): migrate expo-av to expo-video and expo-audio

expo-av has no SDK 56 compatible release, and its EXEventEmitter.h
interface was removed from expo-modules-core, so the iOS release
archive fails compiling Pods/EXAV. Move video playback to
expo-video's useVideoPlayer/VideoView, and audio playback and
recording to expo-audio's createAudioPlayer/AudioModule.AudioRecorder,
preserving existing controls, mute/loop/resize behavior, and the
recording audio session configuration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jjbruntonandClaude Fable 5 committed 2026-08-31 14:35:51 +01:00
1 parent 5e28117662
commit b8cb2addff
11 files changed
+524 -387

No files matched your search

+2 -1
View File
@@ -52,7 +52,7 @@
"expo": "^56.0.0",
"expo-apple-authentication": "~56.0.4",
"expo-asset": "~56.0.24",
"expo-av": "~16.0.8",
"expo-audio": "~56.0.13",
"expo-blur": "~56.0.4",
"expo-build-properties": "~56.0.27",
"expo-clipboard": "~56.0.4",
@@ -76,6 +76,7 @@
"expo-status-bar": "~56.0.4",
"expo-system-ui": "~56.0.5",
"expo-updates": "~56.0.26",
"expo-video": "~56.1.4",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-native": "0.85.3",
@@ -1,31 +1,43 @@
import { act, renderHook } from '@testing-library/react-native';
import { Alert } from 'react-native';
import { Audio } from 'expo-av';
import { AudioModule, requestRecordingPermissionsAsync, setAudioModeAsync } from 'expo-audio';
import { useAudioRecorder } from '@/hooks/useAudioRecorder';
// eslint-plugin-import's namespace rule cannot statically resolve the
// `AudioRecorder` class exposed on the `AudioModule` native module instance.
// Alias it once here so the mock constructor is only dereferenced this way
// in a single, deliberately suppressed spot.
// eslint-disable-next-line import/namespace
const AudioRecorderMock = AudioModule.AudioRecorder as jest.Mock;
const makeRecording = (overrides: Record<string, unknown> = {}) => ({
getStatusAsync: jest.fn(async () => ({ isRecording: true, durationMillis: 2400 })),
stopAndUnloadAsync: jest.fn(async () => undefined),
getURI: jest.fn(() => 'file://recording.m4a'),
setOnRecordingStatusUpdate: jest.fn(),
setProgressUpdateInterval: jest.fn(),
getStatus: jest.fn(() => ({ isRecording: true, durationMillis: 2400, canRecord: true, url: null })),
prepareToRecordAsync: jest.fn(async () => undefined),
record: jest.fn(),
stop: jest.fn(async () => undefined),
uri: 'file://recording.m4a',
...overrides,
});
describe('useAudioRecorder', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.clearAllMocks();
jest.spyOn(Alert, 'alert').mockImplementation(() => undefined);
jest.spyOn(console, 'error').mockImplementation(() => undefined);
(Audio.requestPermissionsAsync as jest.Mock).mockResolvedValue({ status: 'granted' });
(Audio.setAudioModeAsync as jest.Mock).mockResolvedValue(undefined);
(requestRecordingPermissionsAsync as jest.Mock).mockResolvedValue({ status: 'granted' });
(setAudioModeAsync as jest.Mock).mockResolvedValue(undefined);
});
afterEach(() => jest.restoreAllMocks());
afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
jest.restoreAllMocks();
});
it('alerts and refuses to start without microphone permission', async () => {
(Audio.requestPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'denied' });
(requestRecordingPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'denied' });
const { result } = renderHook(() => useAudioRecorder());
let started = true;
@@ -36,21 +48,22 @@ describe('useAudioRecorder', () => {
'Microphone Permission',
'Please allow microphone access to record audio.'
);
expect(Audio.Recording.createAsync).not.toHaveBeenCalled();
expect(AudioRecorderMock).not.toHaveBeenCalled();
});
it('starts, reports duration, and returns the completed recording', async () => {
const recording = makeRecording();
(Audio.Recording.createAsync as jest.Mock).mockResolvedValue({ recording });
AudioRecorderMock.mockImplementation(() => recording);
const onComplete = jest.fn();
const { result } = renderHook(() => useAudioRecorder({ onRecordingComplete: onComplete }));
await act(async () => { expect(await result.current.startRecording()).toBe(true); });
expect(result.current.state).toBe('recording');
expect(recording.setProgressUpdateInterval).toHaveBeenCalledWith(100);
expect(recording.prepareToRecordAsync).toHaveBeenCalled();
expect(recording.record).toHaveBeenCalled();
const statusCallback = recording.setOnRecordingStatusUpdate.mock.calls[0][0];
act(() => statusCallback({ isRecording: true, durationMillis: 2400 }));
// Duration is reported by polling the recorder status, not by a push event.
await act(async () => { jest.advanceTimersByTime(100); });
expect(result.current.durationSeconds).toBe(2);
let completed: unknown;
@@ -66,50 +79,55 @@ describe('useAudioRecorder', () => {
it('auto-stops when the configured duration is reached', async () => {
const recording = makeRecording({
getStatusAsync: jest.fn(async () => ({ isRecording: true, durationMillis: 1000 })),
getStatus: jest.fn(() => ({ isRecording: true, durationMillis: 1000, canRecord: true, url: null })),
});
(Audio.Recording.createAsync as jest.Mock).mockResolvedValue({ recording });
AudioRecorderMock.mockImplementation(() => recording);
const onComplete = jest.fn();
const { result } = renderHook(() => useAudioRecorder({ maxDurationSeconds: 1, onRecordingComplete: onComplete }));
await act(async () => { await result.current.startRecording(); });
const statusCallback = recording.setOnRecordingStatusUpdate.mock.calls[0][0];
await act(async () => { statusCallback({ isRecording: true, durationMillis: 1000 }); });
await act(async () => { jest.advanceTimersByTime(100); });
// Flush the microtasks queued by the auto-stop call.
await act(async () => { await Promise.resolve(); });
expect(recording.stopAndUnloadAsync).toHaveBeenCalled();
expect(recording.stop).toHaveBeenCalled();
expect(onComplete).toHaveBeenCalledWith('file://recording.m4a', 1);
});
it('resets an active recording and cleans it up on unmount', async () => {
const first = makeRecording();
const second = makeRecording();
(Audio.Recording.createAsync as jest.Mock)
.mockResolvedValueOnce({ recording: first })
.mockResolvedValueOnce({ recording: second });
AudioRecorderMock
.mockImplementationOnce(() => first)
.mockImplementationOnce(() => second);
const view = renderHook(() => useAudioRecorder());
await act(async () => { await view.result.current.startRecording(); });
act(() => view.result.current.resetRecording());
expect(first.stopAndUnloadAsync).toHaveBeenCalled();
expect(first.stop).toHaveBeenCalled();
expect(view.result.current.state).toBe('idle');
await act(async () => { await view.result.current.startRecording(); });
view.unmount();
expect(second.stopAndUnloadAsync).toHaveBeenCalled();
expect(Audio.setAudioModeAsync).toHaveBeenLastCalledWith({
allowsRecordingIOS: false,
playsInSilentModeIOS: true,
expect(second.stop).toHaveBeenCalled();
expect(setAudioModeAsync).toHaveBeenLastCalledWith({
allowsRecording: false,
playsInSilentMode: true,
});
});
it('recovers when starting or stopping throws', async () => {
(Audio.Recording.createAsync as jest.Mock).mockRejectedValueOnce(new Error('native unavailable'));
AudioRecorderMock.mockImplementationOnce(() => {
throw new Error('native unavailable');
});
const first = renderHook(() => useAudioRecorder());
await act(async () => { expect(await first.result.current.startRecording()).toBe(false); });
expect(first.result.current.state).toBe('idle');
const recording = makeRecording({ getStatusAsync: jest.fn(async () => { throw new Error('lost'); }) });
(Audio.Recording.createAsync as jest.Mock).mockResolvedValueOnce({ recording });
const recording = makeRecording({
getStatus: jest.fn(() => { throw new Error('lost'); }),
});
AudioRecorderMock.mockImplementationOnce(() => recording);
const second = renderHook(() => useAudioRecorder());
await act(async () => { await second.result.current.startRecording(); });
await act(async () => { expect(await second.result.current.stopRecording()).toBeNull(); });
@@ -1,7 +1,7 @@
import { useState, useEffect } from "react";
import { View, Text, StyleSheet, Platform, TouchableOpacity } from "react-native";
import { LinearGradient } from "expo-linear-gradient";
import { Audio } from "expo-av";
import { createAudioPlayer, type AudioPlayer } from "expo-audio";
import Animated, {
useSharedValue,
useAnimatedStyle,
@@ -59,7 +59,7 @@ export default function QuestionCardAudio({ question, packInfo, onAnswer, onRepo
maxDurationSeconds: maxDuration,
});
const [sound, setSound] = useState<Audio.Sound | null>(null);
const [sound, setSound] = useState<AudioPlayer | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [playbackPosition, setPlaybackPosition] = useState(0);
@@ -86,7 +86,7 @@ export default function QuestionCardAudio({ question, packInfo, onAnswer, onRepo
useEffect(() => {
return () => {
if (sound) {
sound.unloadAsync();
sound.remove();
}
};
}, [sound]);
@@ -113,30 +113,26 @@ export default function QuestionCardAudio({ question, packInfo, onAnswer, onRepo
await triggerHaptic('light');
if (isPlaying && sound) {
await sound.pauseAsync();
sound.pause();
setIsPlaying(false);
return;
}
if (sound) {
await sound.playAsync();
sound.play();
setIsPlaying(true);
return;
}
const { sound: newSound } = await Audio.Sound.createAsync(
{ uri: recordingUri },
{ shouldPlay: true },
(status) => {
if (status.isLoaded) {
setPlaybackPosition(status.positionMillis / 1000);
if (status.didJustFinish) {
setIsPlaying(false);
setPlaybackPosition(0);
}
}
const newSound = createAudioPlayer({ uri: recordingUri });
newSound.addListener('playbackStatusUpdate', (status) => {
setPlaybackPosition(status.currentTime);
if (status.didJustFinish) {
setIsPlaying(false);
setPlaybackPosition(0);
}
);
});
newSound.play();
setSound(newSound);
setIsPlaying(true);
};
@@ -145,7 +141,7 @@ export default function QuestionCardAudio({ question, packInfo, onAnswer, onRepo
if (!recordingUri) return;
await triggerHaptic('medium');
if (sound) {
await sound.unloadAsync();
sound.remove();
setSound(null);
}
onAnswer('yes', { type: 'audio', media_path: recordingUri, duration_seconds: durationSeconds });
@@ -154,7 +150,7 @@ export default function QuestionCardAudio({ question, packInfo, onAnswer, onRepo
const handleReRecord = async () => {
await triggerHaptic('light');
if (sound) {
await sound.unloadAsync();
sound.remove();
setSound(null);
}
setIsPlaying(false);
@@ -165,7 +161,7 @@ export default function QuestionCardAudio({ question, packInfo, onAnswer, onRepo
const handleCancel = async () => {
await triggerHaptic('light');
if (sound) {
await sound.unloadAsync();
sound.remove();
setSound(null);
}
setIsPlaying(false);
@@ -176,7 +172,7 @@ export default function QuestionCardAudio({ question, packInfo, onAnswer, onRepo
const handleSkip = async () => {
await triggerHaptic('light');
if (sound) {
await sound.unloadAsync();
sound.remove();
setSound(null);
}
onAnswer('skip');
@@ -1,6 +1,6 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { View, Text, TouchableOpacity, ActivityIndicator, StyleSheet } from 'react-native';
import { Video, ResizeMode, AVPlaybackStatus } from 'expo-av';
import { useVideoPlayer, VideoView } from 'expo-video';
import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons } from '@expo/vector-icons';
import { colors, gradients, spacing, radius } from '../../../theme';
@@ -30,7 +30,6 @@ const ChatVideoPlayerComponent: React.FC<ChatVideoPlayerProps> = ({
urlError,
onFullScreen,
}) => {
const videoRef = useRef<Video>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [hasEnded, setHasEnded] = useState(false);
const [isLoading, setIsLoading] = useState(true);
@@ -42,22 +41,39 @@ const ChatVideoPlayerComponent: React.FC<ChatVideoPlayerProps> = ({
// Skip the cache lookup for local file URIs
const isLocalFile = signedUrl?.startsWith('file://');
const videoSource = isLocalFile ? signedUrl : (cachedUri || signedUrl);
// Handle playback status updates
const handlePlaybackStatusUpdate = useCallback(async (status: AVPlaybackStatus) => {
if (!status.isLoaded) {
// Log error if load failed
if ('error' in status && status.error) {
console.error(`[ChatVideoPlayer] Video load error:`, status.error);
setIsLoading(false);
}
return;
const player = useVideoPlayer(videoSource ? { uri: videoSource } : null, (setupPlayer) => {
setupPlayer.loop = false;
});
// Swap the player source when the cached local file becomes available,
// without reloading on the initial mount (useVideoPlayer already loaded it).
const mountedSourceRef = useRef(videoSource);
useEffect(() => {
if (videoSource && videoSource !== mountedSourceRef.current) {
mountedSourceRef.current = videoSource;
player.replace({ uri: videoSource });
}
}, [videoSource, player]);
setIsLoading(false);
setIsPlaying(status.isPlaying);
// Handle playback status updates
useEffect(() => {
const statusSubscription = player.addListener('statusChange', ({ status, error }) => {
if (status === 'error') {
if (error) {
console.error(`[ChatVideoPlayer] Video load error:`, error);
}
setIsLoading(false);
return;
}
setIsLoading(status === 'loading');
});
if (status.didJustFinish) {
const playingSubscription = player.addListener('playingChange', ({ isPlaying: playing }) => {
setIsPlaying(playing);
});
const endSubscription = player.addListener('playToEnd', () => {
setHasEnded(true);
setIsPlaying(false);
@@ -65,22 +81,26 @@ const ChatVideoPlayerComponent: React.FC<ChatVideoPlayerProps> = ({
if (signedUrl && !cachedUri && !isLocalFile) {
cacheVideoFile();
}
}
}, [signedUrl, cachedUri, isLocalFile, cacheVideoFile]);
});
return () => {
statusSubscription.remove();
playingSubscription.remove();
endSubscription.remove();
};
}, [player, signedUrl, cachedUri, isLocalFile, cacheVideoFile]);
// Toggle play/pause with native-like behavior
const handleTapToPlay = useCallback(async () => {
if (!videoRef.current) return;
const handleTapToPlay = useCallback(() => {
if (isPlaying) {
await videoRef.current.pauseAsync();
player.pause();
setIsPlaying(false);
} else {
if (hasEnded) {
await videoRef.current.setPositionAsync(0);
player.replay();
setHasEnded(false);
}
await videoRef.current.playAsync();
player.play();
setIsPlaying(true);
// Start caching when playback starts
@@ -88,19 +108,19 @@ const ChatVideoPlayerComponent: React.FC<ChatVideoPlayerProps> = ({
cacheVideoFile();
}
}
}, [isPlaying, hasEnded, signedUrl, cachedUri, cacheVideoFile]);
}, [isPlaying, hasEnded, player, signedUrl, cachedUri, cacheVideoFile]);
// Handle full screen
const handleFullScreen = useCallback(() => {
const uri = cachedUri || signedUrl;
if (uri) {
if (videoRef.current && isPlaying) {
videoRef.current.pauseAsync();
if (isPlaying) {
player.pause();
setIsPlaying(false);
}
onFullScreen(uri);
}
}, [cachedUri, signedUrl, isPlaying, onFullScreen]);
}, [cachedUri, signedUrl, isPlaying, player, onFullScreen]);
if (urlError || !videoSource) {
return (
@@ -119,15 +139,11 @@ const ChatVideoPlayerComponent: React.FC<ChatVideoPlayerProps> = ({
delayLongPress={300}
style={styles.container}
>
<Video
ref={videoRef}
source={{ uri: videoSource }}
<VideoView
player={player}
style={styles.video}
resizeMode={ResizeMode.COVER}
isLooping={false}
shouldPlay={false}
positionMillis={0}
onPlaybackStatusUpdate={handlePlaybackStatusUpdate}
contentFit="cover"
nativeControls={false}
/>
{/* Loading indicator */}
@@ -1,6 +1,6 @@
import React from 'react';
import React, { useEffect, useRef } from 'react';
import { View, StyleSheet, TouchableOpacity, Modal, ActivityIndicator, useWindowDimensions } from 'react-native';
import { Video, ResizeMode } from 'expo-av';
import { useVideoPlayer, VideoView } from 'expo-video';
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
@@ -27,6 +27,30 @@ const FullScreenVideoModalComponent: React.FC<FullScreenVideoModalProps> = ({
// Use safe area insets for button positioning to avoid notch/dynamic island
const buttonTop = insets.top + 10;
const player = useVideoPlayer(uri ? { uri } : null, (setupPlayer) => {
setupPlayer.loop = false;
});
// Swap the source when a different video is shown, without reloading on
// the initial mount (useVideoPlayer already loaded the first source).
const mountedUriRef = useRef(uri);
useEffect(() => {
if (uri && uri !== mountedUriRef.current) {
mountedUriRef.current = uri;
player.replace({ uri });
}
}, [uri, player]);
// Play while the modal is visible, pause otherwise (there is no
// declarative `shouldPlay` prop on `VideoView`).
useEffect(() => {
if (visible) {
player.play();
} else {
player.pause();
}
}, [visible, player]);
return (
<Modal
visible={visible}
@@ -75,13 +99,11 @@ const FullScreenVideoModalComponent: React.FC<FullScreenVideoModalProps> = ({
</TouchableOpacity>
{uri && (
<Video
source={{ uri }}
<VideoView
player={player}
style={{ width: windowWidth, height: windowHeight * 0.8 }}
resizeMode={ResizeMode.CONTAIN}
useNativeControls
shouldPlay={visible}
isLooping={false}
contentFit="contain"
nativeControls
/>
)}
</View>
@@ -3,7 +3,7 @@ import { View, Text, StyleSheet, TouchableOpacity, ActivityIndicator } from 'rea
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { Image } from 'expo-image';
import { Audio } from 'expo-av';
import { createAudioPlayer, type AudioPlayer } from 'expo-audio';
import Animated, { FadeInDown } from 'react-native-reanimated';
import { DecorativeSeparator } from '../../../components/ui';
@@ -237,7 +237,7 @@ const AudioDisplay: React.FC<{
}> = ({ responseSummary, userId, partnerName }) => {
const [audioUrls, setAudioUrls] = useState<Record<string, string>>({});
const [playingId, setPlayingId] = useState<string | null>(null);
const [sounds, setSounds] = useState<Record<string, Audio.Sound>>({});
const [sounds, setSounds] = useState<Record<string, AudioPlayer>>({});
useEffect(() => {
const fetchAudioUrls = async () => {
@@ -266,7 +266,7 @@ const AudioDisplay: React.FC<{
// Cleanup sounds on unmount
return () => {
Object.values(sounds).forEach(sound => {
sound.unloadAsync();
sound.remove();
});
};
}, [responseSummary]);
@@ -283,33 +283,31 @@ const AudioDisplay: React.FC<{
// If already playing this one, pause it
if (playingId === responderId && sounds[responderId]) {
await sounds[responderId].pauseAsync();
sounds[responderId].pause();
setPlayingId(null);
return;
}
// Stop any currently playing audio
if (playingId && sounds[playingId]) {
await sounds[playingId].stopAsync();
sounds[playingId].pause();
await sounds[playingId].seekTo(0);
}
// Play the selected audio
try {
let sound = sounds[responderId];
if (!sound) {
const { sound: newSound } = await Audio.Sound.createAsync(
{ uri: audioUrl },
{ shouldPlay: false },
(status) => {
if (status.isLoaded && status.didJustFinish) {
setPlayingId(null);
}
sound = createAudioPlayer({ uri: audioUrl });
sound.addListener('playbackStatusUpdate', (status) => {
if (status.didJustFinish) {
setPlayingId(null);
}
);
sound = newSound;
});
setSounds(prev => ({ ...prev, [responderId]: sound }));
}
await sound.playFromPositionAsync(0);
await sound.seekTo(0);
sound.play();
setPlayingId(responderId);
} catch (err) {
console.error('Failed to play audio:', err);
@@ -28,7 +28,7 @@ function imageMime(uri: string): string {
return "image/jpeg";
}
/** expo-av records .m4a on both platforms; web lands on webm. */
/** expo-audio records .m4a on both platforms; web lands on webm. */
function audioMime(uri: string): string {
const ext = uri.split(".").pop()?.toLowerCase() ?? "m4a";
if (ext === "webm") return "audio/webm";
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useState } from "react";
import { ActivityIndicator, Image, Pressable, StyleSheet, Text, View } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { Audio } from "expo-av";
import { createAudioPlayer, type AudioPlayer } from "expo-audio";
import { getMediaUrl } from "../../../lib/mediaApi";
import { colors, featureColors, radius, spacing, typography } from "../../../theme";
import type { DareProofType } from "../types";
@@ -21,7 +21,7 @@ export interface DareProofViewProps {
export function DareProofView({ proofMediaId, proofType }: DareProofViewProps) {
const [url, setUrl] = useState<string | null>(null);
const [failed, setFailed] = useState(false);
const [sound, setSound] = useState<Audio.Sound | null>(null);
const [sound, setSound] = useState<AudioPlayer | null>(null);
const [playing, setPlaying] = useState(false);
const [loadingAudio, setLoadingAudio] = useState(false);
@@ -35,32 +35,30 @@ export function DareProofView({ proofMediaId, proofType }: DareProofViewProps) {
}, [proofMediaId, proofType]);
useEffect(() => {
return () => { if (sound) void sound.unloadAsync(); };
return () => { if (sound) sound.remove(); };
}, [sound]);
const togglePlayback = useCallback(async () => {
try {
if (sound) {
const status = await sound.getStatusAsync();
if (status.isLoaded && status.isPlaying) {
await sound.pauseAsync();
if (sound.playing) {
sound.pause();
setPlaying(false);
} else {
await sound.replayAsync();
await sound.seekTo(0);
sound.play();
setPlaying(true);
}
return;
}
setLoadingAudio(true);
const { url: signedUrl } = await getMediaUrl(proofMediaId);
const created = await Audio.Sound.createAsync(
{ uri: signedUrl },
{ shouldPlay: true },
(status) => {
if (status.isLoaded && status.didJustFinish) setPlaying(false);
},
);
setSound(created.sound);
const created = createAudioPlayer({ uri: signedUrl });
created.addListener('playbackStatusUpdate', (status) => {
if (status.didJustFinish) setPlaying(false);
});
created.play();
setSound(created);
setPlaying(true);
} catch {
setFailed(true);
+270 -240
View File
@@ -1,240 +1,270 @@
import { useState, useRef, useCallback, useEffect } from 'react';
import { Alert } from 'react-native';
import { Audio } from 'expo-av';
export interface UseAudioRecorderOptions {
/** Maximum recording duration in seconds. Default: 60 */
maxDurationSeconds?: number;
/** Callback when recording completes (either manually or via auto-stop) */
onRecordingComplete?: (uri: string, durationSeconds: number) => void;
}
export type RecordingState = 'idle' | 'recording' | 'stopped';
const DEFAULT_OPTIONS: Required<Omit<UseAudioRecorderOptions, 'onRecordingComplete'>> = {
maxDurationSeconds: 60,
};
/**
* Hook for audio recording functionality using expo-av.
* Handles microphone permissions, recording lifecycle, and cleanup.
*
* @param options - Optional configuration for recording
* @returns Audio recorder state and controls
*/
export const useAudioRecorder = (options?: UseAudioRecorderOptions) => {
const settings = {
...DEFAULT_OPTIONS,
...options,
};
const [state, setState] = useState<RecordingState>('idle');
const [durationSeconds, setDurationSeconds] = useState(0);
const [recordingUri, setRecordingUri] = useState<string | null>(null);
// Refs to track recording instance and cleanup
const recordingRef = useRef<Audio.Recording | null>(null);
const onRecordingCompleteRef = useRef(options?.onRecordingComplete);
// Keep the callback ref up to date
useEffect(() => {
onRecordingCompleteRef.current = options?.onRecordingComplete;
}, [options?.onRecordingComplete]);
/**
* Request microphone permissions with user-friendly error handling.
* @returns true if granted, false otherwise
*/
const requestPermission = useCallback(async (): Promise<boolean> => {
const { status } = await Audio.requestPermissionsAsync();
if (status !== 'granted') {
Alert.alert(
'Microphone Permission',
'Please allow microphone access to record audio.'
);
return false;
}
return true;
}, []);
/**
* Stop the current recording and return the result.
* @returns Object with uri and duration, or null if no recording
*/
const stopRecording = useCallback(async (): Promise<{ uri: string; duration: number } | null> => {
const recording = recordingRef.current;
if (!recording) {
return null;
}
try {
// Get final status before stopping
const status = await recording.getStatusAsync();
const finalDurationMs = status.isRecording ? status.durationMillis : 0;
const finalDurationSeconds = Math.round(finalDurationMs / 1000);
// Stop and unload the recording
await recording.stopAndUnloadAsync();
// Reset audio mode to allow playback
await Audio.setAudioModeAsync({
allowsRecordingIOS: false,
playsInSilentModeIOS: true,
});
const uri = recording.getURI();
recordingRef.current = null;
if (uri) {
setRecordingUri(uri);
setState('stopped');
setDurationSeconds(finalDurationSeconds);
// Call the completion callback if provided
if (onRecordingCompleteRef.current) {
onRecordingCompleteRef.current(uri, finalDurationSeconds);
}
return { uri, duration: finalDurationSeconds };
}
setState('idle');
return null;
} catch (error) {
console.error('Error stopping recording:', error);
recordingRef.current = null;
setState('idle');
return null;
}
}, []);
/**
* Start a new audio recording.
* @returns true if recording started successfully, false otherwise
*/
const startRecording = useCallback(async (): Promise<boolean> => {
// Don't start if already recording
if (recordingRef.current) {
return false;
}
try {
// Request permission
const hasPermission = await requestPermission();
if (!hasPermission) {
return false;
}
// Configure audio mode for recording
await Audio.setAudioModeAsync({
allowsRecordingIOS: true,
playsInSilentModeIOS: true,
});
// Reset state
setDurationSeconds(0);
setRecordingUri(null);
setState('recording');
// Create and start the recording
const { recording } = await Audio.Recording.createAsync(
Audio.RecordingOptionsPresets.HIGH_QUALITY
);
recordingRef.current = recording;
// Set up status update callback to track duration
recording.setOnRecordingStatusUpdate((status) => {
if (status.isRecording) {
const currentSeconds = Math.round(status.durationMillis / 1000);
setDurationSeconds(currentSeconds);
// Auto-stop if max duration reached
if (currentSeconds >= settings.maxDurationSeconds) {
stopRecording();
}
}
});
// Set progress update interval (100ms for smooth duration updates)
recording.setProgressUpdateInterval(100);
return true;
} catch (error) {
console.error('Error starting recording:', error);
recordingRef.current = null;
setState('idle');
// Reset audio mode on error
try {
await Audio.setAudioModeAsync({
allowsRecordingIOS: false,
playsInSilentModeIOS: true,
});
} catch {
// Ignore errors during cleanup
}
return false;
}
}, [requestPermission, settings.maxDurationSeconds, stopRecording]);
/**
* Reset the recording state to idle.
* Cleans up any existing recording.
*/
const resetRecording = useCallback(() => {
// Clean up any existing recording
if (recordingRef.current) {
try {
recordingRef.current.stopAndUnloadAsync().catch(() => {
// Ignore errors during cleanup
});
} catch {
// Ignore errors during cleanup
}
recordingRef.current = null;
}
setState('idle');
setDurationSeconds(0);
setRecordingUri(null);
}, []);
// Cleanup on unmount
useEffect(() => {
return () => {
if (recordingRef.current) {
recordingRef.current.stopAndUnloadAsync().catch(() => {
// Ignore errors during cleanup
});
recordingRef.current = null;
}
// Reset audio mode
Audio.setAudioModeAsync({
allowsRecordingIOS: false,
playsInSilentModeIOS: true,
}).catch(() => {
// Ignore errors during cleanup
});
};
}, []);
return {
/** Current recording state: 'idle', 'recording', or 'stopped' */
state,
/** Current recording duration in seconds */
durationSeconds,
/** URI of the recorded audio file (available when state is 'stopped') */
recordingUri,
/** Start a new recording. Returns true if successful. */
startRecording,
/** Stop the current recording. Returns the uri and duration, or null. */
stopRecording,
/** Reset state to idle and clean up any recording. */
resetRecording,
/** Request microphone permission. Returns true if granted. */
requestPermission,
};
};
import { useState, useRef, useCallback, useEffect } from 'react';
import { Alert } from 'react-native';
import {
AudioModule,
RecordingPresets,
requestRecordingPermissionsAsync,
setAudioModeAsync,
type AudioRecorder as ExpoAudioRecorder,
} from 'expo-audio';
// eslint-plugin-import's namespace rule cannot statically resolve the
// `AudioRecorder` class exposed on the `AudioModule` native module instance,
// even though it is a valid, type-checked property. Alias it once here so
// the property is only dereferenced this way in a single, suppressed spot.
// eslint-disable-next-line import/namespace
const AudioRecorderClass = AudioModule.AudioRecorder;
export interface UseAudioRecorderOptions {
/** Maximum recording duration in seconds. Default: 60 */
maxDurationSeconds?: number;
/** Callback when recording completes (either manually or via auto-stop) */
onRecordingComplete?: (uri: string, durationSeconds: number) => void;
}
export type RecordingState = 'idle' | 'recording' | 'stopped';
const DEFAULT_OPTIONS: Required<Omit<UseAudioRecorderOptions, 'onRecordingComplete'>> = {
maxDurationSeconds: 60,
};
/** How often to poll the recorder for duration updates, in milliseconds. */
const DURATION_POLL_INTERVAL_MS = 100;
/**
* Hook for audio recording functionality using expo-audio.
* Handles microphone permissions, recording lifecycle, and cleanup.
*
* @param options - Optional configuration for recording
* @returns Audio recorder state and controls
*/
export const useAudioRecorder = (options?: UseAudioRecorderOptions) => {
const settings = {
...DEFAULT_OPTIONS,
...options,
};
const [state, setState] = useState<RecordingState>('idle');
const [durationSeconds, setDurationSeconds] = useState(0);
const [recordingUri, setRecordingUri] = useState<string | null>(null);
// Refs to track recording instance, duration polling, and cleanup
const recordingRef = useRef<ExpoAudioRecorder | null>(null);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const onRecordingCompleteRef = useRef(options?.onRecordingComplete);
// Keep the callback ref up to date
useEffect(() => {
onRecordingCompleteRef.current = options?.onRecordingComplete;
}, [options?.onRecordingComplete]);
const stopPolling = useCallback(() => {
if (pollRef.current) {
clearInterval(pollRef.current);
pollRef.current = null;
}
}, []);
/**
* Request microphone permissions with user-friendly error handling.
* @returns true if granted, false otherwise
*/
const requestPermission = useCallback(async (): Promise<boolean> => {
const { status } = await requestRecordingPermissionsAsync();
if (status !== 'granted') {
Alert.alert(
'Microphone Permission',
'Please allow microphone access to record audio.'
);
return false;
}
return true;
}, []);
/**
* Stop the current recording and return the result.
* @returns Object with uri and duration, or null if no recording
*/
const stopRecording = useCallback(async (): Promise<{ uri: string; duration: number } | null> => {
const recording = recordingRef.current;
if (!recording) {
return null;
}
try {
stopPolling();
// Get final status before stopping
const status = recording.getStatus();
const finalDurationMs = status.isRecording ? status.durationMillis : 0;
const finalDurationSeconds = Math.round(finalDurationMs / 1000);
// Stop the recording
await recording.stop();
// Reset audio mode to allow playback
await setAudioModeAsync({
allowsRecording: false,
playsInSilentMode: true,
});
const uri = recording.uri;
recordingRef.current = null;
if (uri) {
setRecordingUri(uri);
setState('stopped');
setDurationSeconds(finalDurationSeconds);
// Call the completion callback if provided
if (onRecordingCompleteRef.current) {
onRecordingCompleteRef.current(uri, finalDurationSeconds);
}
return { uri, duration: finalDurationSeconds };
}
setState('idle');
return null;
} catch (error) {
console.error('Error stopping recording:', error);
recordingRef.current = null;
setState('idle');
return null;
}
}, [stopPolling]);
/**
* Start a new audio recording.
* @returns true if recording started successfully, false otherwise
*/
const startRecording = useCallback(async (): Promise<boolean> => {
// Don't start if already recording
if (recordingRef.current) {
return false;
}
try {
// Request permission
const hasPermission = await requestPermission();
if (!hasPermission) {
return false;
}
// Configure audio mode for recording
await setAudioModeAsync({
allowsRecording: true,
playsInSilentMode: true,
});
// Reset state
setDurationSeconds(0);
setRecordingUri(null);
setState('recording');
// Create, prepare, and start the recording
const recording = new AudioRecorderClass(RecordingPresets.HIGH_QUALITY);
await recording.prepareToRecordAsync();
recording.record();
recordingRef.current = recording;
// Poll for duration updates (expo-audio has no push-based progress
// event equivalent to expo-av's setProgressUpdateInterval).
pollRef.current = setInterval(() => {
const status = recording.getStatus();
if (status.isRecording) {
const currentSeconds = Math.round(status.durationMillis / 1000);
setDurationSeconds(currentSeconds);
// Auto-stop if max duration reached
if (currentSeconds >= settings.maxDurationSeconds) {
stopRecording();
}
}
}, DURATION_POLL_INTERVAL_MS);
return true;
} catch (error) {
console.error('Error starting recording:', error);
recordingRef.current = null;
setState('idle');
// Reset audio mode on error
try {
await setAudioModeAsync({
allowsRecording: false,
playsInSilentMode: true,
});
} catch {
// Ignore errors during cleanup
}
return false;
}
}, [requestPermission, settings.maxDurationSeconds, stopRecording]);
/**
* Reset the recording state to idle.
* Cleans up any existing recording.
*/
const resetRecording = useCallback(() => {
stopPolling();
// Clean up any existing recording
if (recordingRef.current) {
try {
recordingRef.current.stop().catch(() => {
// Ignore errors during cleanup
});
} catch {
// Ignore errors during cleanup
}
recordingRef.current = null;
}
setState('idle');
setDurationSeconds(0);
setRecordingUri(null);
}, [stopPolling]);
// Cleanup on unmount
useEffect(() => {
return () => {
stopPolling();
if (recordingRef.current) {
recordingRef.current.stop().catch(() => {
// Ignore errors during cleanup
});
recordingRef.current = null;
}
// Reset audio mode
setAudioModeAsync({
allowsRecording: false,
playsInSilentMode: true,
}).catch(() => {
// Ignore errors during cleanup
});
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return {
/** Current recording state: 'idle', 'recording', or 'stopped' */
state,
/** Current recording duration in seconds */
durationSeconds,
/** URI of the recorded audio file (available when state is 'stopped') */
recordingUri,
/** Start a new recording. Returns true if successful. */
startRecording,
/** Stop the current recording. Returns the uri and duration, or null. */
stopRecording,
/** Reset state to idle and clean up any recording. */
resetRecording,
/** Request microphone permission. Returns true if granted. */
requestPermission,
};
};
+59 -10
View File
@@ -113,17 +113,66 @@ jest.mock('expo-file-system/legacy', () => ({
},
}));
jest.mock('expo-av', () => ({
Audio: {
requestPermissionsAsync: jest.fn(async () => ({ status: 'granted' })),
setAudioModeAsync: jest.fn(async () => undefined),
Recording: {
createAsync: jest.fn(),
},
RecordingOptionsPresets: {
HIGH_QUALITY: {},
},
jest.mock('expo-video', () => {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const React = require('react');
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { View } = require('react-native');
const createPlayer = () => ({
play: jest.fn(),
pause: jest.fn(),
replay: jest.fn(),
replace: jest.fn(),
replaceAsync: jest.fn(async () => undefined),
seekBy: jest.fn(),
addListener: jest.fn(() => ({ remove: jest.fn() })),
removeListener: jest.fn(),
playing: false,
muted: false,
loop: false,
currentTime: 0,
duration: 0,
status: 'idle',
});
return {
useVideoPlayer: jest.fn((source: unknown, setup?: (player: unknown) => void) => {
const player = createPlayer();
if (setup) setup(player);
return player;
}),
createVideoPlayer: jest.fn(() => createPlayer()),
VideoView: React.forwardRef((props: any, ref: any) =>
React.createElement(View, { ...props, ref })
),
};
});
jest.mock('expo-audio', () => ({
AudioModule: {
AudioRecorder: jest.fn(),
},
RecordingPresets: {
HIGH_QUALITY: {},
LOW_QUALITY: {},
},
requestRecordingPermissionsAsync: jest.fn(async () => ({ status: 'granted', granted: true })),
getRecordingPermissionsAsync: jest.fn(async () => ({ status: 'granted', granted: true })),
setAudioModeAsync: jest.fn(async () => undefined),
setIsAudioActiveAsync: jest.fn(async () => undefined),
useAudioPlayer: jest.fn(),
useAudioPlayerStatus: jest.fn(() => ({})),
createAudioPlayer: jest.fn(() => ({
play: jest.fn(),
pause: jest.fn(),
remove: jest.fn(),
seekTo: jest.fn(async () => undefined),
addListener: jest.fn(() => ({ remove: jest.fn() })),
currentTime: 0,
duration: 0,
playing: false,
})),
}));
jest.mock('react-native-compressor', () => ({
+19 -10
View File
@@ -207,7 +207,7 @@
"expo": "^56.0.0",
"expo-apple-authentication": "~56.0.4",
"expo-asset": "~56.0.24",
"expo-av": "~16.0.8",
"expo-audio": "~56.0.13",
"expo-blur": "~56.0.4",
"expo-build-properties": "~56.0.27",
"expo-clipboard": "~56.0.4",
@@ -231,6 +231,7 @@
"expo-status-bar": "~56.0.4",
"expo-system-ui": "~56.0.5",
"expo-updates": "~56.0.26",
"expo-video": "~56.1.4",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-native": "0.85.3",
@@ -13842,19 +13843,16 @@
"node": ">=10"
}
},
"node_modules/expo-av": {
"version": "16.0.8",
"node_modules/expo-audio": {
"version": "56.0.13",
"resolved": "https://registry.npmjs.org/expo-audio/-/expo-audio-56.0.13.tgz",
"integrity": "sha512-pfBmT/8OYbhrb37ECk+fC1EstLBFxMTioF4uZrAEeOj3uCgjproj1qYmkpsQLJIoEOMPSIw1iSfBEMl10v1DDA==",
"license": "MIT",
"peerDependencies": {
"expo": "*",
"expo-asset": "*",
"react": "*",
"react-native": "*",
"react-native-web": "*"
},
"peerDependenciesMeta": {
"react-native-web": {
"optional": true
}
"react-native": "*"
}
},
"node_modules/expo-blur": {
@@ -14832,6 +14830,17 @@
"node": ">=8.0"
}
},
"node_modules/expo-video": {
"version": "56.1.4",
"resolved": "https://registry.npmjs.org/expo-video/-/expo-video-56.1.4.tgz",
"integrity": "sha512-8mXZOnb7ZsWneDlcnRf0M2Ox9eb5dGnNiGiCkj9bwLPY+3jM2Pc1t5ymNUuhw8lSpT/rOnE0J0/81Y3+0k2Zcw==",
"license": "MIT",
"peerDependencies": {
"expo": "*",
"react": "*",
"react-native": "*"
}
},
"node_modules/expo/node_modules/@expo/cli": {
"version": "56.1.25",
"license": "MIT",