From b8cb2addff6bc8d7ac3a9fad43d30dc7cc0e0a18 Mon Sep 17 00:00:00 2001 From: jjbrunton Date: Mon, 31 Aug 2026 14:35:51 +0100 Subject: [PATCH] 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 --- apps/mobile/package.json | 3 +- .../__tests__/hooks/useAudioRecorder.test.ts | 80 +-- .../questions/QuestionCardAudio.tsx | 38 +- .../chat/components/ChatVideoPlayer.tsx | 86 +-- .../chat/components/FullScreenVideoModal.tsx | 38 +- .../features/chat/components/MatchCard.tsx | 28 +- .../dares/components/DareProofSheet.tsx | 2 +- .../dares/components/DareProofView.tsx | 28 +- apps/mobile/src/hooks/useAudioRecorder.ts | 510 +++++++++--------- apps/mobile/src/test/setup.ts | 69 ++- package-lock.json | 29 +- 11 files changed, 524 insertions(+), 387 deletions(-) diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 3afa143..e388b7b 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -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", diff --git a/apps/mobile/src/__tests__/hooks/useAudioRecorder.test.ts b/apps/mobile/src/__tests__/hooks/useAudioRecorder.test.ts index ac58182..11e3582 100644 --- a/apps/mobile/src/__tests__/hooks/useAudioRecorder.test.ts +++ b/apps/mobile/src/__tests__/hooks/useAudioRecorder.test.ts @@ -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 = {}) => ({ - 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(); }); diff --git a/apps/mobile/src/components/questions/QuestionCardAudio.tsx b/apps/mobile/src/components/questions/QuestionCardAudio.tsx index 96179c2..129ff1e 100644 --- a/apps/mobile/src/components/questions/QuestionCardAudio.tsx +++ b/apps/mobile/src/components/questions/QuestionCardAudio.tsx @@ -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(null); + const [sound, setSound] = useState(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'); diff --git a/apps/mobile/src/features/chat/components/ChatVideoPlayer.tsx b/apps/mobile/src/features/chat/components/ChatVideoPlayer.tsx index 03f89b9..0db66c0 100644 --- a/apps/mobile/src/features/chat/components/ChatVideoPlayer.tsx +++ b/apps/mobile/src/features/chat/components/ChatVideoPlayer.tsx @@ -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 = ({ urlError, onFullScreen, }) => { - const videoRef = useRef