mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
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:
1 parent
5e28117662
commit
b8cb2addff
11 files changed
+524
-387
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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', () => ({
|
||||
|
||||
Reference in new issue
Block a user