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
+318
-181
No files matched your search
@@ -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);
|
||||||
@@ -43,21 +42,38 @@ const ChatVideoPlayerComponent: React.FC<ChatVideoPlayerProps> = ({
|
|||||||
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);
|
||||||
|
|||||||
@@ -1,6 +1,19 @@
|
|||||||
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,
|
||||||
|
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 {
|
export interface UseAudioRecorderOptions {
|
||||||
/** Maximum recording duration in seconds. Default: 60 */
|
/** Maximum recording duration in seconds. Default: 60 */
|
||||||
@@ -15,8 +28,11 @@ const DEFAULT_OPTIONS: Required<Omit<UseAudioRecorderOptions, 'onRecordingComple
|
|||||||
maxDurationSeconds: 60,
|
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-av.
|
* Hook for audio recording functionality using expo-audio.
|
||||||
* Handles microphone permissions, recording lifecycle, and cleanup.
|
* Handles microphone permissions, recording lifecycle, and cleanup.
|
||||||
*
|
*
|
||||||
* @param options - Optional configuration for recording
|
* @param options - Optional configuration for recording
|
||||||
@@ -32,8 +48,9 @@ export const useAudioRecorder = (options?: UseAudioRecorderOptions) => {
|
|||||||
const [durationSeconds, setDurationSeconds] = useState(0);
|
const [durationSeconds, setDurationSeconds] = useState(0);
|
||||||
const [recordingUri, setRecordingUri] = useState<string | null>(null);
|
const [recordingUri, setRecordingUri] = useState<string | null>(null);
|
||||||
|
|
||||||
// Refs to track recording instance and cleanup
|
// Refs to track recording instance, duration polling, and cleanup
|
||||||
const recordingRef = useRef<Audio.Recording | null>(null);
|
const recordingRef = useRef<ExpoAudioRecorder | null>(null);
|
||||||
|
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
const onRecordingCompleteRef = useRef(options?.onRecordingComplete);
|
const onRecordingCompleteRef = useRef(options?.onRecordingComplete);
|
||||||
|
|
||||||
// Keep the callback ref up to date
|
// Keep the callback ref up to date
|
||||||
@@ -41,12 +58,19 @@ export const useAudioRecorder = (options?: UseAudioRecorderOptions) => {
|
|||||||
onRecordingCompleteRef.current = options?.onRecordingComplete;
|
onRecordingCompleteRef.current = options?.onRecordingComplete;
|
||||||
}, [options?.onRecordingComplete]);
|
}, [options?.onRecordingComplete]);
|
||||||
|
|
||||||
|
const stopPolling = useCallback(() => {
|
||||||
|
if (pollRef.current) {
|
||||||
|
clearInterval(pollRef.current);
|
||||||
|
pollRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Request microphone permissions with user-friendly error handling.
|
* Request microphone permissions with user-friendly error handling.
|
||||||
* @returns true if granted, false otherwise
|
* @returns true if granted, false otherwise
|
||||||
*/
|
*/
|
||||||
const requestPermission = useCallback(async (): Promise<boolean> => {
|
const requestPermission = useCallback(async (): Promise<boolean> => {
|
||||||
const { status } = await Audio.requestPermissionsAsync();
|
const { status } = await requestRecordingPermissionsAsync();
|
||||||
if (status !== 'granted') {
|
if (status !== 'granted') {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
'Microphone Permission',
|
'Microphone Permission',
|
||||||
@@ -68,21 +92,23 @@ export const useAudioRecorder = (options?: UseAudioRecorderOptions) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
stopPolling();
|
||||||
|
|
||||||
// Get final status before stopping
|
// Get final status before stopping
|
||||||
const status = await recording.getStatusAsync();
|
const status = recording.getStatus();
|
||||||
const finalDurationMs = status.isRecording ? status.durationMillis : 0;
|
const finalDurationMs = status.isRecording ? status.durationMillis : 0;
|
||||||
const finalDurationSeconds = Math.round(finalDurationMs / 1000);
|
const finalDurationSeconds = Math.round(finalDurationMs / 1000);
|
||||||
|
|
||||||
// Stop and unload the recording
|
// Stop the recording
|
||||||
await recording.stopAndUnloadAsync();
|
await recording.stop();
|
||||||
|
|
||||||
// Reset audio mode to allow playback
|
// Reset audio mode to allow playback
|
||||||
await Audio.setAudioModeAsync({
|
await setAudioModeAsync({
|
||||||
allowsRecordingIOS: false,
|
allowsRecording: false,
|
||||||
playsInSilentModeIOS: true,
|
playsInSilentMode: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const uri = recording.getURI();
|
const uri = recording.uri;
|
||||||
recordingRef.current = null;
|
recordingRef.current = null;
|
||||||
|
|
||||||
if (uri) {
|
if (uri) {
|
||||||
@@ -106,7 +132,7 @@ export const useAudioRecorder = (options?: UseAudioRecorderOptions) => {
|
|||||||
setState('idle');
|
setState('idle');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}, []);
|
}, [stopPolling]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start a new audio recording.
|
* Start a new audio recording.
|
||||||
@@ -126,9 +152,9 @@ export const useAudioRecorder = (options?: UseAudioRecorderOptions) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Configure audio mode for recording
|
// Configure audio mode for recording
|
||||||
await Audio.setAudioModeAsync({
|
await setAudioModeAsync({
|
||||||
allowsRecordingIOS: true,
|
allowsRecording: true,
|
||||||
playsInSilentModeIOS: true,
|
playsInSilentMode: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reset state
|
// Reset state
|
||||||
@@ -136,15 +162,17 @@ export const useAudioRecorder = (options?: UseAudioRecorderOptions) => {
|
|||||||
setRecordingUri(null);
|
setRecordingUri(null);
|
||||||
setState('recording');
|
setState('recording');
|
||||||
|
|
||||||
// Create and start the recording
|
// Create, prepare, and start the recording
|
||||||
const { recording } = await Audio.Recording.createAsync(
|
const recording = new AudioRecorderClass(RecordingPresets.HIGH_QUALITY);
|
||||||
Audio.RecordingOptionsPresets.HIGH_QUALITY
|
await recording.prepareToRecordAsync();
|
||||||
);
|
recording.record();
|
||||||
|
|
||||||
recordingRef.current = recording;
|
recordingRef.current = recording;
|
||||||
|
|
||||||
// Set up status update callback to track duration
|
// Poll for duration updates (expo-audio has no push-based progress
|
||||||
recording.setOnRecordingStatusUpdate((status) => {
|
// event equivalent to expo-av's setProgressUpdateInterval).
|
||||||
|
pollRef.current = setInterval(() => {
|
||||||
|
const status = recording.getStatus();
|
||||||
if (status.isRecording) {
|
if (status.isRecording) {
|
||||||
const currentSeconds = Math.round(status.durationMillis / 1000);
|
const currentSeconds = Math.round(status.durationMillis / 1000);
|
||||||
setDurationSeconds(currentSeconds);
|
setDurationSeconds(currentSeconds);
|
||||||
@@ -154,10 +182,7 @@ export const useAudioRecorder = (options?: UseAudioRecorderOptions) => {
|
|||||||
stopRecording();
|
stopRecording();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
}, DURATION_POLL_INTERVAL_MS);
|
||||||
|
|
||||||
// Set progress update interval (100ms for smooth duration updates)
|
|
||||||
recording.setProgressUpdateInterval(100);
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -167,9 +192,9 @@ export const useAudioRecorder = (options?: UseAudioRecorderOptions) => {
|
|||||||
|
|
||||||
// Reset audio mode on error
|
// Reset audio mode on error
|
||||||
try {
|
try {
|
||||||
await Audio.setAudioModeAsync({
|
await setAudioModeAsync({
|
||||||
allowsRecordingIOS: false,
|
allowsRecording: false,
|
||||||
playsInSilentModeIOS: true,
|
playsInSilentMode: true,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore errors during cleanup
|
// Ignore errors during cleanup
|
||||||
@@ -184,10 +209,12 @@ export const useAudioRecorder = (options?: UseAudioRecorderOptions) => {
|
|||||||
* Cleans up any existing recording.
|
* Cleans up any existing recording.
|
||||||
*/
|
*/
|
||||||
const resetRecording = useCallback(() => {
|
const resetRecording = useCallback(() => {
|
||||||
|
stopPolling();
|
||||||
|
|
||||||
// Clean up any existing recording
|
// Clean up any existing recording
|
||||||
if (recordingRef.current) {
|
if (recordingRef.current) {
|
||||||
try {
|
try {
|
||||||
recordingRef.current.stopAndUnloadAsync().catch(() => {
|
recordingRef.current.stop().catch(() => {
|
||||||
// Ignore errors during cleanup
|
// Ignore errors during cleanup
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
@@ -199,26 +226,29 @@ export const useAudioRecorder = (options?: UseAudioRecorderOptions) => {
|
|||||||
setState('idle');
|
setState('idle');
|
||||||
setDurationSeconds(0);
|
setDurationSeconds(0);
|
||||||
setRecordingUri(null);
|
setRecordingUri(null);
|
||||||
}, []);
|
}, [stopPolling]);
|
||||||
|
|
||||||
// Cleanup on unmount
|
// Cleanup on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
|
stopPolling();
|
||||||
|
|
||||||
if (recordingRef.current) {
|
if (recordingRef.current) {
|
||||||
recordingRef.current.stopAndUnloadAsync().catch(() => {
|
recordingRef.current.stop().catch(() => {
|
||||||
// Ignore errors during cleanup
|
// Ignore errors during cleanup
|
||||||
});
|
});
|
||||||
recordingRef.current = null;
|
recordingRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset audio mode
|
// Reset audio mode
|
||||||
Audio.setAudioModeAsync({
|
setAudioModeAsync({
|
||||||
allowsRecordingIOS: false,
|
allowsRecording: false,
|
||||||
playsInSilentModeIOS: true,
|
playsInSilentMode: true,
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
// Ignore errors during cleanup
|
// Ignore errors during cleanup
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -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', () => ({
|
||||||
|
|||||||
Generated
+19
-10
@@ -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",
|
||||||
|
|||||||
Reference in new issue
Block a user