@cometchat/chat-uikit-react-native
Version:
Ready-to-use Chat UI Components for React Native
1,337 lines (1,204 loc) • 47.2 kB
text/typescript
import { useReducer, useEffect, useRef, useCallback, useMemo } from 'react';
import { NativeModules, Platform, PermissionsAndroid, Alert, Linking, NativeEventEmitter } from 'react-native';
import {
RecorderAction,
RecorderReducerState,
UseAudioRecorderReturn,
AudioSegment,
} from './types';
// Create event emitter for FileManager
const FileManagerEmitter = new NativeEventEmitter(NativeModules.FileManager);
/**
* Initial state for the audio recorder reducer.
* @validates Requirements 9.1, 11.7
*/
const initialState: RecorderReducerState = {
state: 'idle',
duration: 0,
currentPosition: 0,
filePath: null,
error: null,
amplitudes: [],
segments: [],
isPreviewMode: false,
};
/**
* Reducer function for managing audio recorder state.
* Implements predictable state management with valid state transitions.
*
* @validates Requirements 9.1, 9.2, 9.5, 11.7
*/
function recorderReducer(
state: RecorderReducerState,
action: RecorderAction
): RecorderReducerState {
switch (action.type) {
case 'START_RECORDING':
// Only allow starting recording from idle or error states
if (state.state !== 'idle' && state.state !== 'error') return state;
return {
...state,
state: 'recording',
error: null,
amplitudes: [],
duration: 0,
currentPosition: 0,
segments: [],
};
case 'PAUSE_RECORDING':
if (state.state !== 'recording') return state;
return {
...state,
state: 'paused',
};
case 'RESUME_RECORDING':
if (state.state !== 'paused') return state;
return {
...state,
state: 'recording',
};
case 'STOP_RECORDING':
if (state.state !== 'recording' && state.state !== 'paused') return state;
return {
...state,
state: 'completed',
filePath: action.filePath,
};
case 'START_PLAYBACK':
if (state.state !== 'completed' && state.state !== 'paused') return state;
return {
...state,
state: 'playing',
currentPosition: 0,
isPreviewMode: state.state === 'paused', // Track if we started from paused (preview mode)
};
case 'PAUSE_PLAYBACK':
if (state.state !== 'playing') return state;
return {
...state,
// Return to paused if we were in preview mode, otherwise completed
state: state.isPreviewMode ? 'paused' : 'completed',
isPreviewMode: false,
};
case 'RESUME_PLAYBACK':
if (state.state !== 'completed') return state;
return {
...state,
state: 'playing',
isPreviewMode: false,
};
case 'PLAYBACK_COMPLETE':
if (state.state !== 'playing') return state;
return {
...state,
// Return to paused if we were in preview mode, otherwise completed
state: state.isPreviewMode ? 'paused' : 'completed',
currentPosition: state.duration,
isPreviewMode: false,
};
case 'SEEK':
return {
...state,
currentPosition: action.position,
};
case 'UPDATE_DURATION':
return {
...state,
duration: action.duration,
};
case 'UPDATE_POSITION':
return {
...state,
currentPosition: action.position,
};
case 'ADD_AMPLITUDE':
return {
...state,
amplitudes: [...state.amplitudes, action.amplitude],
};
case 'SET_ERROR':
return {
...state,
state: 'error',
error: action.error,
};
case 'RESET':
return initialState;
// Segment-related actions
case 'FINALIZE_SEGMENT':
return {
...state,
segments: [...state.segments, action.segment],
};
case 'START_NEW_SEGMENT':
return {
...state,
state: 'recording',
};
case 'CLEAR_SEGMENTS':
return {
...state,
segments: [],
};
default:
return state;
}
}
/**
* Custom hook for managing audio recording and playback.
* Wraps the FileManager native module with React state management.
*
* Features:
* - State management with useReducer for predictable state transitions
* - Duration timer that increments during recording
* - Simulated amplitude generation for waveform visualization
* - Cleanup on unmount to release native resources
*
* @validates Requirements 9.1, 9.2, 9.3, 10.1, 10.5
*/
export function useAudioRecorder(): UseAudioRecorderReturn {
const [reducerState, dispatch] = useReducer(recorderReducer, initialState);
// Refs for timers and intervals
const durationTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const amplitudeTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const playbackTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const recordingStartTimeRef = useRef<number>(0);
const pausedDurationRef = useRef<number>(0);
// Seek operation tracking to prevent race conditions
const seekOperationIdRef = useRef<number>(0);
const isSeekingRef = useRef<boolean>(false);
const seekDebounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingSeekRef = useRef<{ progress: number; operationId: number } | null>(null);
// Debug: Track last requested seek position to detect unexpected resets
const lastRequestedPositionRef = useRef<number>(0);
const lastRequestedProgressRef = useRef<number>(0);
/**
* Cleanup function to release all native audio resources.
* Called on unmount and when canceling recording.
*
* @validates Requirements 10.5
*/
const cleanup = useCallback(() => {
// Clear all timers
if (durationTimerRef.current) {
clearInterval(durationTimerRef.current);
durationTimerRef.current = null;
}
if (amplitudeTimerRef.current) {
clearInterval(amplitudeTimerRef.current);
amplitudeTimerRef.current = null;
}
if (playbackTimerRef.current) {
clearInterval(playbackTimerRef.current);
playbackTimerRef.current = null;
}
if (seekDebounceTimerRef.current) {
clearTimeout(seekDebounceTimerRef.current);
seekDebounceTimerRef.current = null;
}
// Release native resources
NativeModules.FileManager.releaseMediaResources(() => {
// Resources released
});
}, []);
/**
* Cleanup on unmount.
* @validates Requirements 10.5
*/
useEffect(() => {
return () => {
cleanup();
};
}, [cleanup]);
/**
* Duration timer effect - increments duration during recording state.
* Replicates the EXACT logic from the old Timer component in CometChatMediaRecorder/Timer.tsx:
* - Uses a local ref to track time in milliseconds
* - Increments by 1000ms every second when recording (not paused)
* - Freezes (stops incrementing) when paused - time value is preserved
* - Resets to 0 when going back to idle state
*
* The old Timer component:
* - Used `time` state in seconds, incremented by 1 every 1000ms
* - We use milliseconds for consistency with the rest of the codebase
*
* @validates Requirements 1.4, 3.1
*/
const timerValueRef = useRef<number>(0);
useEffect(() => {
if (reducerState.state === 'recording') {
// Not paused -> start counting (increment every 1 second like old Timer)
// This matches: intervalRef.current = setInterval(() => { setTime((prev) => prev + 1); }, 1000);
durationTimerRef.current = setInterval(() => {
timerValueRef.current += 1000; // Increment by 1000ms (1 second)
dispatch({ type: 'UPDATE_DURATION', duration: timerValueRef.current });
}, 1000);
} else if (reducerState.state === 'paused') {
// Paused -> clear interval, so time is frozen (but value preserved in timerValueRef)
// This matches the old Timer's behavior when paused=true
if (durationTimerRef.current) {
clearInterval(durationTimerRef.current);
durationTimerRef.current = null;
}
// Note: timerValueRef.current is NOT reset here - time is frozen but preserved
} else if (reducerState.state === 'idle') {
// Reset timer when going back to idle (like resetKey changing in old Timer)
timerValueRef.current = 0;
if (durationTimerRef.current) {
clearInterval(durationTimerRef.current);
durationTimerRef.current = null;
}
} else {
// For other states (completed, playing, error), just stop the interval
// but preserve the duration value
if (durationTimerRef.current) {
clearInterval(durationTimerRef.current);
durationTimerRef.current = null;
}
}
// Cleanup when unmounting or when effect re-runs
return () => {
if (durationTimerRef.current) {
clearInterval(durationTimerRef.current);
durationTimerRef.current = null;
}
};
}, [reducerState.state]);
/**
* Amplitude listening effect - receives real-time amplitude data from native module.
* The native module streams audio amplitude values (0.0 to 1.0) during recording.
* Uses WhatsApp-style amplitude processing for natural waveform visualization.
*
* @validates Requirements 2.1, 2.5
*/
useEffect(() => {
let subscription: { remove: () => void } | null = null;
if (reducerState.state === 'recording') {
// Listen for native amplitude events
subscription = FileManagerEmitter.addListener('audioAmplitude', (event: { amplitude: number }) => {
const amplitude = event.amplitude;
// WhatsApp-style amplitude processing:
// - Use the raw amplitude directly (already normalized 0-1 from native)
// - Apply minimal amplification to make quiet sounds visible
// - Keep the natural variation for authentic waveform look
// Minimum floor so bars are always visible, max cap at 1.0
// Use a gentler curve: sqrt for low values, linear for higher
let visualAmplitude: number;
if (amplitude < 0.05) {
// Very quiet - show minimal bar
visualAmplitude = 0.1 + amplitude * 2;
} else if (amplitude < 0.3) {
// Quiet to moderate - gentle boost
visualAmplitude = 0.2 + amplitude * 1.5;
} else {
// Moderate to loud - mostly linear
visualAmplitude = 0.35 + amplitude * 0.65;
}
// Clamp to valid range
const clampedAmplitude = Math.max(0.1, Math.min(1.0, visualAmplitude));
dispatch({ type: 'ADD_AMPLITUDE', amplitude: clampedAmplitude });
});
}
return () => {
if (subscription) {
subscription.remove();
}
};
}, [reducerState.state]);
/**
* Playback position timer effect - updates current position during playback.
* Uses native getPlaybackPosition for accurate sync with actual audio playback.
* Also updates duration from native to ensure waveform matches actual audio length.
* Ignores position updates during active seeking to prevent race conditions.
*
* @validates Requirements 4.3
*/
useEffect(() => {
let statusSubscription: { remove: () => void } | null = null;
if (reducerState.state === 'playing') {
// Listen for playback complete event from native
statusSubscription = FileManagerEmitter.addListener('status', (event: { state: string }) => {
// Ignore playback complete during seeking - it might be stale
if (event.state === 'playbackComplete' && !isSeekingRef.current) {
dispatch({ type: 'PLAYBACK_COMPLETE' });
if (playbackTimerRef.current) {
clearInterval(playbackTimerRef.current);
playbackTimerRef.current = null;
}
}
});
// Poll native for actual playback position and duration
playbackTimerRef.current = setInterval(() => {
// Skip position updates during active seeking to prevent race conditions
if (isSeekingRef.current) {
return;
}
NativeModules.FileManager.getPlaybackPosition((result: string) => {
// Double-check we're not seeking when the callback returns
if (isSeekingRef.current) {
return;
}
try {
const response = JSON.parse(result);
if (response.success && typeof response.position === 'number') {
const position = response.position;
const nativeDuration = response.duration;
// Update duration from native if available and different
// This ensures waveform matches actual audio length
if (typeof nativeDuration === 'number' && nativeDuration > 0 && nativeDuration !== reducerState.duration) {
dispatch({ type: 'UPDATE_DURATION', duration: nativeDuration });
}
// Use native duration for completion check if available
const effectiveDuration = (typeof nativeDuration === 'number' && nativeDuration > 0)
? nativeDuration
: reducerState.duration;
// Check if playback is complete (only if not seeking)
if (position >= effectiveDuration && !isSeekingRef.current) {
dispatch({ type: 'PLAYBACK_COMPLETE' });
if (playbackTimerRef.current) {
clearInterval(playbackTimerRef.current);
playbackTimerRef.current = null;
}
} else {
dispatch({ type: 'UPDATE_POSITION', position });
}
}
} catch (e) {
// Fallback to time-based estimation if native call fails
}
});
}, 100);
} else {
if (playbackTimerRef.current) {
clearInterval(playbackTimerRef.current);
playbackTimerRef.current = null;
}
}
return () => {
if (playbackTimerRef.current) {
clearInterval(playbackTimerRef.current);
playbackTimerRef.current = null;
}
if (statusSubscription) {
statusSubscription.remove();
}
};
}, [reducerState.state, reducerState.duration]);
/**
* Check and request microphone permission.
* Reuses existing permission handling patterns from CometChatMediaRecorder.
*
* @validates Requirements 10.4
*/
const checkMicrophonePermission = useCallback(async (): Promise<boolean> => {
if (Platform.OS === 'ios') {
// iOS handles permission in native module
return true;
}
// Android permission check
const hasPermission = await PermissionsAndroid.check(
PermissionsAndroid.PERMISSIONS.RECORD_AUDIO
);
if (hasPermission) {
return true;
}
// Request permission
const granted = await PermissionsAndroid.request(
PermissionsAndroid.PERMISSIONS.RECORD_AUDIO
);
return granted === PermissionsAndroid.RESULTS.GRANTED;
}, []);
/**
* Show permission alert with option to open settings.
*/
const showPermissionAlert = useCallback(() => {
Alert.alert(
'',
'Microphone permission is required to record audio. Please enable it in settings.',
[
{
style: 'cancel',
text: 'Cancel',
},
{
style: 'default',
text: 'Settings',
onPress: () => {
Linking.openSettings();
},
},
]
);
}, []);
/**
* Start recording audio.
* Wraps FileManager.startRecording native call.
*
* @validates Requirements 10.1
*/
const startRecording = useCallback(async (): Promise<void> => {
try {
const hasPermission = await checkMicrophonePermission();
if (!hasPermission) {
showPermissionAlert();
dispatch({ type: 'SET_ERROR', error: 'Microphone permission denied' });
return;
}
// Reset state for new recording
pausedDurationRef.current = 0;
recordingStartTimeRef.current = 0;
// Reset timer value for new recording (like resetKey in old Timer)
timerValueRef.current = 0;
return new Promise((resolve, reject) => {
NativeModules.FileManager.startRecording((result: string) => {
try {
const response = JSON.parse(result);
if (response.granted === false) {
showPermissionAlert();
dispatch({ type: 'SET_ERROR', error: 'Microphone permission denied' });
reject(new Error('Microphone permission denied'));
return;
}
if (response.success === false) {
dispatch({ type: 'SET_ERROR', error: response.error || 'Failed to start recording' });
reject(new Error(response.error || 'Failed to start recording'));
return;
}
dispatch({ type: 'START_RECORDING' });
resolve();
} catch (parseError) {
// If parsing fails, assume success (some platforms return non-JSON)
dispatch({ type: 'START_RECORDING' });
resolve();
}
});
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to start recording';
dispatch({ type: 'SET_ERROR', error: errorMessage });
throw error;
}
}, [checkMicrophonePermission, showPermissionAlert, reducerState.state]);
/**
* Pause recording.
* Wraps FileManager.finalizeSegment native call for segment-based recording.
* This finalizes the current segment so it can be previewed.
* Note: Android API < 24 does not support true pause.
*
* @validates Requirements 10.1, 10.3, 11.1
*/
const pauseRecording = useCallback(async (): Promise<void> => {
if (reducerState.state !== 'recording') {
return;
}
try {
// Save current amplitudes for this segment
const currentAmplitudes = [...reducerState.amplitudes];
const segmentDuration = reducerState.duration - getTotalSegmentsDuration();
return new Promise((resolve, reject) => {
NativeModules.FileManager.finalizeSegment((result: string) => {
try {
const response = JSON.parse(result);
if (response.success === false) {
// Fallback to legacy pause
NativeModules.FileManager.pauseRecording()
.then(() => {
pausedDurationRef.current = reducerState.duration;
dispatch({ type: 'PAUSE_RECORDING' });
resolve();
})
.catch((err: Error) => {
dispatch({ type: 'SET_ERROR', error: err.message });
reject(err);
});
return;
}
// Create segment from finalized recording
const segment: AudioSegment = {
id: `segment-${Date.now()}`,
filePath: response.segmentPath,
duration: segmentDuration > 0 ? segmentDuration : (response.duration * 1000) || 0,
amplitudes: currentAmplitudes.slice(
reducerState.segments.reduce((acc, s) => acc + s.amplitudes.length, 0)
),
createdAt: Date.now(),
};
dispatch({ type: 'FINALIZE_SEGMENT', segment });
pausedDurationRef.current = reducerState.duration;
dispatch({ type: 'PAUSE_RECORDING' });
resolve();
} catch (parseError) {
// Fallback to legacy pause
NativeModules.FileManager.pauseRecording()
.then(() => {
pausedDurationRef.current = reducerState.duration;
dispatch({ type: 'PAUSE_RECORDING' });
resolve();
})
.catch((err: Error) => {
dispatch({ type: 'SET_ERROR', error: err.message });
reject(err);
});
}
});
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to pause recording';
dispatch({ type: 'SET_ERROR', error: errorMessage });
throw error;
}
}, [reducerState.state, reducerState.duration, reducerState.amplitudes, reducerState.segments]);
/**
* Helper function to get total duration of all finalized segments.
*/
const getTotalSegmentsDuration = useCallback((): number => {
return reducerState.segments.reduce((acc, segment) => acc + segment.duration, 0);
}, [reducerState.segments]);
/**
* Resume recording.
* Wraps FileManager.resumeRecording native call.
*
* @validates Requirements 10.1, 10.3
*/
const resumeRecording = useCallback(async (): Promise<void> => {
if (reducerState.state !== 'paused') {
return;
}
try {
await NativeModules.FileManager.resumeRecording();
// Reset the recording start time for the new segment
recordingStartTimeRef.current = Date.now();
dispatch({ type: 'RESUME_RECORDING' });
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to resume recording';
dispatch({ type: 'SET_ERROR', error: errorMessage });
throw error;
}
}, [reducerState.state]);
/**
* Continue recording by starting a new segment.
* This is the WhatsApp-style flow: pause -> preview -> continue recording.
* Creates a new audio file for the next segment.
*
* @validates Requirements 11.2, 3.5
*/
const continueRecording = useCallback(async (): Promise<void> => {
if (reducerState.state !== 'paused') {
return;
}
try {
return new Promise((resolve, reject) => {
NativeModules.FileManager.startNewSegment((result: string) => {
try {
const response = JSON.parse(result);
if (response.success === false) {
// Fallback to legacy resume
NativeModules.FileManager.resumeRecording()
.then(() => {
recordingStartTimeRef.current = Date.now();
dispatch({ type: 'RESUME_RECORDING' });
resolve();
})
.catch((err: Error) => {
dispatch({ type: 'SET_ERROR', error: err.message });
reject(err);
});
return;
}
// Reset the recording start time for the new segment
recordingStartTimeRef.current = Date.now();
dispatch({ type: 'START_NEW_SEGMENT' });
resolve();
} catch (parseError) {
// Fallback to legacy resume
NativeModules.FileManager.resumeRecording()
.then(() => {
recordingStartTimeRef.current = Date.now();
dispatch({ type: 'RESUME_RECORDING' });
resolve();
})
.catch((err: Error) => {
dispatch({ type: 'SET_ERROR', error: err.message });
reject(err);
});
}
});
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to continue recording';
dispatch({ type: 'SET_ERROR', error: errorMessage });
throw error;
}
}, [reducerState.state, reducerState.segments.length]);
/**
* Stop recording and finalize all segments.
* If multiple segments exist, merges them into a single audio file.
* Wraps FileManager.releaseMediaResources and mergeSegments native calls.
*
* @validates Requirements 10.1, 11.4
*/
const stopRecording = useCallback(async (): Promise<string | null> => {
if (reducerState.state !== 'recording' && reducerState.state !== 'paused') {
return null;
}
// Calculate final duration before stopping
let finalDuration = reducerState.duration;
if (reducerState.state === 'recording' && recordingStartTimeRef.current > 0) {
finalDuration = pausedDurationRef.current + (Date.now() - recordingStartTimeRef.current);
}
return new Promise((resolve) => {
NativeModules.FileManager.releaseMediaResources((result: string) => {
try {
const response = JSON.parse(result);
let filePath = response.file || response.path || null;
// Check if we have multiple segments to merge
const allSegmentPaths = [...reducerState.segments.map(s => s.filePath)];
if (filePath && reducerState.state === 'recording') {
// Add the current recording as the last segment
allSegmentPaths.push(filePath);
}
if (allSegmentPaths.length > 1) {
// Merge all segments
NativeModules.FileManager.mergeSegments(allSegmentPaths, (mergeResult: string) => {
try {
const mergeResponse = JSON.parse(mergeResult);
if (mergeResponse.success && mergeResponse.mergedPath) {
filePath = mergeResponse.mergedPath;
}
} catch (mergeParseError) {
// Keep original filePath
}
finishStopRecording(filePath, finalDuration, response, resolve);
});
} else {
finishStopRecording(filePath, finalDuration, response, resolve);
}
} catch (parseError) {
dispatch({ type: 'SET_ERROR', error: 'Failed to parse recording result' });
resolve(null);
}
});
});
}, [reducerState.state, reducerState.duration, reducerState.segments]);
/**
* Helper function to finish stop recording process.
*/
const finishStopRecording = useCallback((
filePath: string | null,
finalDuration: number,
response: { duration?: number },
resolve: (value: string | null) => void
) => {
if (filePath) {
// Update duration from native if available (iOS provides this)
if (response.duration && response.duration > 0) {
const nativeDuration = response.duration * 1000;
dispatch({ type: 'UPDATE_DURATION', duration: nativeDuration });
} else {
// Use our calculated duration
dispatch({ type: 'UPDATE_DURATION', duration: finalDuration });
}
dispatch({ type: 'STOP_RECORDING', filePath });
resolve(filePath);
} else {
dispatch({ type: 'SET_ERROR', error: 'No file path returned' });
resolve(null);
}
}, []);
/**
* Start playback of recorded audio.
* Wraps FileManager.playAudio native call.
*
* @validates Requirements 10.1
*/
const startPlayback = useCallback(async (): Promise<void> => {
if (reducerState.state !== 'completed') {
return;
}
if (!reducerState.filePath) {
return;
}
return new Promise((resolve, reject) => {
NativeModules.FileManager.playAudio((result: string) => {
try {
const response = JSON.parse(result);
if (response.success === false) {
dispatch({ type: 'SET_ERROR', error: response.error || 'Failed to start playback' });
reject(new Error(response.error || 'Failed to start playback'));
return;
}
// Reset tracking refs since we're starting from 0
lastRequestedPositionRef.current = 0;
lastRequestedProgressRef.current = 0;
dispatch({ type: 'START_PLAYBACK' });
resolve();
} catch (parseError) {
// Assume success if parsing fails
dispatch({ type: 'START_PLAYBACK' });
resolve();
}
});
});
}, [reducerState.state, reducerState.filePath]);
/**
* Start playback preview while paused.
* Plays the finalized segments without stopping the recording session.
* For multiple segments, merges them first for gapless playback.
* This allows the user to preview what they've recorded while still being able to continue.
*
* @validates Requirements 3.4, 4.1
*/
const startPlaybackPreview = useCallback(async (): Promise<void> => {
if (reducerState.state !== 'paused') {
return;
}
// Get all segment paths to play
const segmentPaths = reducerState.segments.map(s => s.filePath);
if (segmentPaths.length === 0) {
return;
}
// Reset tracking refs since we're starting from 0
lastRequestedPositionRef.current = 0;
lastRequestedProgressRef.current = 0;
return new Promise((resolve, reject) => {
if (segmentPaths.length === 1) {
// Single segment - use playAudio directly with the segment path
NativeModules.FileManager.playAudio((result: string) => {
try {
const response = JSON.parse(result);
if (response.success === false) {
dispatch({ type: 'SET_ERROR', error: response.error || 'Failed to start playback' });
reject(new Error(response.error || 'Failed to start playback'));
return;
}
dispatch({ type: 'START_PLAYBACK' });
resolve();
} catch (parseError) {
dispatch({ type: 'START_PLAYBACK' });
resolve();
}
});
} else {
// Multiple segments - merge first for gapless playback, then play
NativeModules.FileManager.mergeSegments(segmentPaths, (mergeResult: string) => {
try {
const mergeResponse = JSON.parse(mergeResult);
if (mergeResponse.success && mergeResponse.mergedPath) {
// Play the merged file
NativeModules.FileManager.playAudio((playResult: string) => {
try {
const playResponse = JSON.parse(playResult);
if (playResponse.success === false) {
dispatch({ type: 'SET_ERROR', error: playResponse.error || 'Failed to start playback' });
reject(new Error(playResponse.error || 'Failed to start playback'));
return;
}
dispatch({ type: 'START_PLAYBACK' });
resolve();
} catch (parseError) {
dispatch({ type: 'START_PLAYBACK' });
resolve();
}
});
} else {
// Fallback to sequential playback if merge fails
NativeModules.FileManager.playSegments(segmentPaths, (result: string) => {
try {
const response = JSON.parse(result);
if (response.success === false) {
dispatch({ type: 'SET_ERROR', error: response.error || 'Failed to start playback' });
reject(new Error(response.error || 'Failed to start playback'));
return;
}
dispatch({ type: 'START_PLAYBACK' });
resolve();
} catch (parseError) {
dispatch({ type: 'START_PLAYBACK' });
resolve();
}
});
}
} catch (parseError) {
// Fallback to sequential playback
NativeModules.FileManager.playSegments(segmentPaths, (result: string) => {
dispatch({ type: 'START_PLAYBACK' });
resolve();
});
}
});
}
});
}, [reducerState.state, reducerState.segments]);
/**
* Pause playback.
* Wraps FileManager.pausePlaying native call.
*
* @validates Requirements 10.1
*/
const pausePlayback = useCallback(async (): Promise<void> => {
if (reducerState.state !== 'playing') {
return;
}
return new Promise((resolve) => {
NativeModules.FileManager.pausePlaying((result: string) => {
dispatch({ type: 'PAUSE_PLAYBACK' });
resolve();
});
});
}, [reducerState.state]);
/**
* Resume playback.
* Wraps FileManager.resumePlaying native call.
* Now works on both iOS and Android for instant resume from paused position.
*
* @validates Requirements 10.1
*/
const resumePlayback = useCallback(async (): Promise<void> => {
if (reducerState.state !== 'completed') {
return;
}
return new Promise((resolve) => {
// Both iOS and Android now support resumePlaying for instant resume
NativeModules.FileManager.resumePlaying((result: string) => {
dispatch({ type: 'RESUME_PLAYBACK' });
resolve();
});
});
}, [reducerState.state]);
/**
* Seek to a specific position in the recording.
* Position is expressed as a progress value from 0.0 to 1.0.
* Calls native seekTo for actual audio seeking during playback.
* Uses debouncing and operation tracking to prevent race conditions.
*
* @validates Requirements 5.1, 5.2, 5.3
*/
const seekTo = useCallback(async (progress: number): Promise<void> => {
// Clamp progress to valid range
const clampedProgress = Math.max(0, Math.min(1, progress));
const position = Math.floor(clampedProgress * reducerState.duration);
// Track last requested position for reset detection
lastRequestedPositionRef.current = position;
lastRequestedProgressRef.current = clampedProgress;
// Increment operation ID to track this seek
const operationId = ++seekOperationIdRef.current;
isSeekingRef.current = true;
// Update UI position immediately for smooth feedback
dispatch({ type: 'SEEK', position });
// If currently playing, debounce the native seek call
if (reducerState.state === 'playing') {
// Clear any pending debounced seek
if (seekDebounceTimerRef.current) {
clearTimeout(seekDebounceTimerRef.current);
}
// Store the pending seek
pendingSeekRef.current = { progress: clampedProgress, operationId };
// Debounce the actual native call by 100ms
return new Promise((resolve) => {
seekDebounceTimerRef.current = setTimeout(() => {
// Check if this is still the latest seek operation
if (operationId !== seekOperationIdRef.current) {
resolve();
return;
}
const finalPosition = Math.floor(clampedProgress * reducerState.duration);
NativeModules.FileManager.seekTo(finalPosition, (result: string) => {
// Only clear seeking flag if this is still the latest operation
if (operationId === seekOperationIdRef.current) {
// Clear seeking flag immediately since native has confirmed seek is complete
isSeekingRef.current = false;
pendingSeekRef.current = null;
}
resolve();
});
}, 100);
});
}
// Not playing, just update position and clear seeking flag
setTimeout(() => {
if (operationId === seekOperationIdRef.current) {
isSeekingRef.current = false;
}
}, 50);
}, [reducerState.duration, reducerState.state]);
/**
* Seek and start playback from a specific position.
* Used when tapping on waveform in preview/paused mode.
* Uses debouncing and operation tracking to prevent race conditions.
*
* @validates Requirements 5.1, 5.2
*/
const seekAndPlay = useCallback(async (progress: number): Promise<void> => {
// Clamp progress to valid range
const clampedProgress = Math.max(0, Math.min(1, progress));
const position = Math.floor(clampedProgress * reducerState.duration);
// Track last requested position for reset detection
lastRequestedPositionRef.current = position;
lastRequestedProgressRef.current = clampedProgress;
// Increment operation ID to track this seek
const operationId = ++seekOperationIdRef.current;
isSeekingRef.current = true;
// Update UI position immediately
dispatch({ type: 'SEEK', position });
// Clear any pending debounced seek
if (seekDebounceTimerRef.current) {
clearTimeout(seekDebounceTimerRef.current);
seekDebounceTimerRef.current = null;
}
// For single segment in completed state, use playFromPosition with debouncing
if (reducerState.state === 'completed' && reducerState.filePath) {
// Store the pending seek
pendingSeekRef.current = { progress: clampedProgress, operationId };
return new Promise((resolve, reject) => {
// Debounce by 150ms to allow rapid taps to settle
seekDebounceTimerRef.current = setTimeout(() => {
// Check if this is still the latest seek operation
if (operationId !== seekOperationIdRef.current) {
isSeekingRef.current = false;
resolve();
return;
}
const finalPosition = Math.floor(clampedProgress * reducerState.duration);
NativeModules.FileManager.playFromPosition(finalPosition, (result: string) => {
// Check if this is still the latest operation before updating state
if (operationId !== seekOperationIdRef.current) {
resolve();
return;
}
try {
const response = JSON.parse(result);
if (response.success) {
dispatch({ type: 'START_PLAYBACK' });
// Clear seeking flag immediately since native has confirmed seek is complete
isSeekingRef.current = false;
pendingSeekRef.current = null;
resolve();
} else {
isSeekingRef.current = false;
pendingSeekRef.current = null;
reject(new Error(response.error));
}
} catch (e) {
dispatch({ type: 'START_PLAYBACK' });
isSeekingRef.current = false;
pendingSeekRef.current = null;
resolve();
}
});
}, 150);
});
}
// For preview mode (paused with segments), merge segments first then play from position
if (reducerState.state === 'paused' && reducerState.segments.length > 0) {
const segmentPaths = reducerState.segments.map(s => s.filePath);
const finalPosition = Math.floor(clampedProgress * reducerState.duration);
// Store the pending seek
pendingSeekRef.current = { progress: clampedProgress, operationId };
return new Promise((resolve, reject) => {
// Debounce by 150ms to allow rapid taps to settle
seekDebounceTimerRef.current = setTimeout(() => {
// Check if this is still the latest seek operation
if (operationId !== seekOperationIdRef.current) {
isSeekingRef.current = false;
resolve();
return;
}
const seekPosition = Math.floor(clampedProgress * reducerState.duration);
if (segmentPaths.length === 1) {
// Single segment - play directly from position
NativeModules.FileManager.playFromPosition(seekPosition, (result: string) => {
if (operationId !== seekOperationIdRef.current) {
resolve();
return;
}
try {
const response = JSON.parse(result);
if (response.success) {
dispatch({ type: 'START_PLAYBACK' });
// Clear seeking flag immediately since native has confirmed seek is complete
isSeekingRef.current = false;
pendingSeekRef.current = null;
resolve();
} else {
isSeekingRef.current = false;
pendingSeekRef.current = null;
reject(new Error(response.error));
}
} catch (e) {
dispatch({ type: 'START_PLAYBACK' });
isSeekingRef.current = false;
pendingSeekRef.current = null;
resolve();
}
});
} else {
// Multiple segments - merge first, then play from position
NativeModules.FileManager.mergeSegments(segmentPaths, (mergeResult: string) => {
if (operationId !== seekOperationIdRef.current) {
resolve();
return;
}
try {
const mergeResponse = JSON.parse(mergeResult);
if (mergeResponse.success && mergeResponse.mergedPath) {
// Now play from the requested position
NativeModules.FileManager.playFromPosition(seekPosition, (playResult: string) => {
if (operationId !== seekOperationIdRef.current) {
resolve();
return;
}
try {
const playResponse = JSON.parse(playResult);
if (playResponse.success) {
dispatch({ type: 'START_PLAYBACK' });
// Clear seeking flag immediately since native has confirmed seek is complete
isSeekingRef.current = false;
pendingSeekRef.current = null;
resolve();
} else {
isSeekingRef.current = false;
pendingSeekRef.current = null;
reject(new Error(playResponse.error));
}
} catch (e) {
dispatch({ type: 'START_PLAYBACK' });
isSeekingRef.current = false;
pendingSeekRef.current = null;
resolve();
}
});
} else {
// Merge failed, fall back to starting from beginning
isSeekingRef.current = false;
pendingSeekRef.current = null;
// Reset the last requested position since we're starting from 0
lastRequestedPositionRef.current = 0;
lastRequestedProgressRef.current = 0;
NativeModules.FileManager.playSegments(segmentPaths, (result: string) => {
dispatch({ type: 'START_PLAYBACK' });
resolve();
});
}
} catch (e) {
// Merge parse error, fall back to starting from beginning
isSeekingRef.current = false;
pendingSeekRef.current = null;
lastRequestedPositionRef.current = 0;
lastRequestedProgressRef.current = 0;
NativeModules.FileManager.playSegments(segmentPaths, (result: string) => {
dispatch({ type: 'START_PLAYBACK' });
resolve();
});
}
});
}
}, 150);
});
}
// Clear seeking flag for other cases
isSeekingRef.current = false;
pendingSeekRef.current = null;
}, [reducerState.state, reducerState.duration, reducerState.filePath, reducerState.segments]);
/**
* Cancel recording and cleanup all resources.
* Deletes all segment files and releases native resources.
*
* @validates Requirements 7.2, 7.4, 10.5, 11.5
*/
const cancel = useCallback(async (): Promise<void> => {
// Clear all timers
if (durationTimerRef.current) {
clearInterval(durationTimerRef.current);
durationTimerRef.current = null;
}
if (amplitudeTimerRef.current) {
clearInterval(amplitudeTimerRef.current);
amplitudeTimerRef.current = null;
}
if (playbackTimerRef.current) {
clearInterval(playbackTimerRef.current);
playbackTimerRef.current = null;
}
if (seekDebounceTimerRef.current) {
clearTimeout(seekDebounceTimerRef.current);
seekDebounceTimerRef.current = null;
}
// Reset refs
pausedDurationRef.current = 0;
recordingStartTimeRef.current = 0;
timerValueRef.current = 0;
seekOperationIdRef.current = 0;
isSeekingRef.current = false;
pendingSeekRef.current = null;
lastRequestedPositionRef.current = 0;
lastRequestedProgressRef.current = 0;
// Delete all segment files if any
const segmentPaths = reducerState.segments.map(s => s.filePath);
if (segmentPaths.length > 0) {
return new Promise((resolve) => {
NativeModules.FileManager.deleteSegments(segmentPaths, (deleteSegmentsResult: string) => {
// Also delete the main file and release resources
NativeModules.FileManager.deleteFile((deleteResult: string) => {
NativeModules.FileManager.releaseMediaResources((releaseResult: string) => {
dispatch({ type: 'CLEAR_SEGMENTS' });
dispatch({ type: 'RESET' });
resolve();
});
});
});
});
}
// No segments, just delete the main file
return new Promise((resolve) => {
NativeModules.FileManager.deleteFile((deleteResult: string) => {
// Release media resources
NativeModules.FileManager.releaseMediaResources((releaseResult: string) => {
dispatch({ type: 'RESET' });
resolve();
});
});
});
}, [reducerState.state, reducerState.filePath, reducerState.segments]);
/**
* Computed properties for state checks.
* @validates Requirements 9.3, 11.7
*/
const isRecording = useMemo(() => reducerState.state === 'recording', [reducerState.state]);
const isPaused = useMemo(() => reducerState.state === 'paused', [reducerState.state]);
const isPlaying = useMemo(() => reducerState.state === 'playing', [reducerState.state]);
const isCompleted = useMemo(() => reducerState.state === 'completed', [reducerState.state]);
const hasRecording = useMemo(() => reducerState.duration > 0, [reducerState.duration]);
const hasMultipleSegments = useMemo(() => reducerState.segments.length > 1, [reducerState.segments.length]);
const canContinueRecording = useMemo(() => reducerState.state === 'paused', [reducerState.state]);
return {
// State
state: reducerState.state,
duration: reducerState.duration,
currentPosition: reducerState.currentPosition,
filePath: reducerState.filePath,
error: reducerState.error,
amplitudes: reducerState.amplitudes,
segments: reducerState.segments,
hasMultipleSegments,
// Actions
startRecording,
pauseRecording,
resumeRecording,
continueRecording,
stopRecording,
startPlayback,
startPlaybackPreview,
pausePlayback,
resumePlayback,
seekTo,
seekAndPlay,
cancel,
// Computed properties
isRecording,
isPaused,
isPlaying,
isCompleted,
hasRecording,
canContinueRecording,
};
}