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