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

@@ -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(); });