-
Notifications
You must be signed in to change notification settings - Fork 4
Add Audio Interruption Handling for Camera Recording #264
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
- Added expo-audio dependency to manage audio recording and permissions. - Updated app.json to include microphone permission description for audio recording. - Modified AndroidManifest.xml to request MODIFY_AUDIO_SETTINGS permission. - Implemented audio session configuration in RecordButton for better interruption handling. - Enhanced ShortsScreen to manage audio state during app lifecycle changes. - Added error handling for camera mount issues and improved user feedback. This update improves the audio recording experience and ensures proper handling of audio interruptions during recording sessions.
adithya1012
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hello @morepriyam 👋
I tried testing this branch locally on Android, but it doesn’t seem to be working as expected.
Observed behavior
The app builds successfully, but video recording fails on Android. Below are the logs I’m seeing:
Android Bundled 114ms node_modules/expo-router/entry.js (1 module)
LOG 🔗 Deeplink params: {}
LOG [ShortsScreen] ✅ Camera ready
LOG [RecordButton] ✅ Audio session configured successfully
LOG [RecordButton] 🔄 Reset all animations and states
LOG [ShortsScreen] 🔄 Reset all animations and states
LOG [RecordButton] 🔄 Reset all animations and states
LOG [ShortsScreen] 🔄 Reset all animations and states
ERROR [RecordButton] ❌ Recording failed (unexpected):
[Error: Video recording failed: Video recording Failed: Unknown error]
LOG [RecordButton] 🔄 Reset all animations and states
LOG [ShortsScreen] 🔄 Reset all animations and states
Screenshot
Steps to reproduce (Android)
git checkout 257-audio-issues-in-cameranpm installnpx expo run:android
Please let me know if I’m missing any setup steps on Android.
I’m also looking into this from my side to see if I can identify or fix the issue.
Thanks!
On Android, React Native's AppState incorrectly triggers background/active transitions when any UI overlay appears causing recording to fail with "Video recording Failed: Unknown error". Changes: - Track previous app state to detect genuine background transitions - Add 500ms threshold to filter rapid false state changes on Android - Use 'focus' event on Android instead of 'change' event for more reliable detection - Only reset recording state on genuine background→active transitions
Fix: Android recording fails due to rapid AppState switchingProblemWhen starting a recording on Android, the app immediately fails with: The logs showed This caused the recording to be stopped immediately after starting, resulting in failure. Root CauseThis is a known issue with React Native's On Android, React Native uses the Activity's
This is different from iOS, where In our case, the audio session configuration ( SolutionBased on research from multiple Stack Overflow threads and GitHub issues:
The fix implements three safeguards:
Code Changes// Track previous app state for Android (to detect genuine background transitions)
const appStateRef = React.useRef<AppStateStatus>(AppState.currentState);
const lastBackgroundTimeRef = React.useRef<number>(0);
// In the effect:
// 1. Filter rapid state changes on Android (< 500ms)
if (Platform.OS === "android") {
if (nextAppState === "background" || nextAppState === "inactive") {
lastBackgroundTimeRef.current = Date.now();
} else if (nextAppState === "active" && prevState !== "active") {
const timeInBackground = Date.now() - lastBackgroundTimeRef.current;
if (timeInBackground < 500) {
// Ignore rapid false trigger
return;
}
}
}
// 2. Only act on genuine transitions
if (prevState === nextAppState) return;
if (nextAppState === "active" && prevState.match(/inactive|background/)) {
// Handle genuine return from background
}
// 3. Use focus event on Android
const eventType = Platform.OS === "android" ? "focus" : "change";
const subscription = AppState.addEventListener(eventType, handleAppStateChange);Testing
Related IssuesFixes #257 |
|
The first comment on the PR should be a summary of the PR at the latest moment with a screenshot(s) and video short. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This PR implements audio interruption handling for video recording by integrating expo-audio for audio session management and AppState monitoring to handle background transitions. The goal is to automatically stop recordings when the app is backgrounded and reset all UI states when returning to the foreground.
- Added expo-audio (~0.4.9) for audio session configuration with exclusive audio focus
- Implemented AppState monitoring to stop recordings when app goes to background/inactive
- Added reset functionality to RecordButton component via forwardRef to clean up animations and states
Reviewed changes
Copilot reviewed 5 out of 7 changed files in this pull request and generated 11 comments.
Show a summary per file
| File | Description |
|---|---|
| package.json | Added expo-audio dependency (~0.4.9) |
| package-lock.json | Resolved expo-audio dependency with version 0.4.9 |
| ios/Podfile.lock | Added ExpoAudio iOS native dependency |
| components/RecordButton.tsx | Added audio session configuration, forwardRef with reset method, and enhanced error handling for interruptions |
| app/(camera)/shorts.tsx | Implemented AppState listener to stop recording on background, reset states on foreground, with Android-specific rapid state change filtering |
| app.json | Added expo-audio plugin configuration with microphone permissions |
| android/app/src/main/AndroidManifest.xml | Added MODIFY_AUDIO_SETTINGS permission for audio session management |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Use 'focus' event on Android to avoid false triggers, 'change' on iOS | ||
| const eventType = Platform.OS === "android" ? "focus" : "change"; | ||
| const subscription = AppState.addEventListener(eventType, handleAppStateChange); | ||
| return () => subscription.remove(); | ||
| }, [isRecording]); |
Copilot
AI
Jan 6, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Using "focus" event type for Android's AppState listener is unusual and may not work as expected. The AppState API typically uses "change" for both platforms. The "focus" event is more commonly associated with blur events in React Native, not AppState changes. According to React Native documentation, AppState.addEventListener should use "change" event type on both platforms. This could cause the handler to never be called on Android. Consider using "change" event for both platforms and relying on the rapid state change detection logic (lines 286-296) to filter out false triggers instead.
| // Use 'focus' event on Android to avoid false triggers, 'change' on iOS | |
| const eventType = Platform.OS === "android" ? "focus" : "change"; | |
| const subscription = AppState.addEventListener(eventType, handleAppStateChange); | |
| return () => subscription.remove(); | |
| }, [isRecording]); | |
| // Use 'change' event on both platforms; rely on timing logic above to filter false triggers | |
| const subscription = AppState.addEventListener("change", handleAppStateChange); | |
| return () => subscription.remove(); | |
| }, [isRecording]); | |
| }, [isRecording]); |
| React.useEffect(() => { | ||
| if (!isRecording) return; | ||
|
|
||
| const handleAppStateChange = async (nextAppState: AppStateStatus) => { | ||
| const prevState = appStateRef.current; | ||
|
|
||
| // On Android, ignore rapid state changes (less than 500ms in background) | ||
| // This prevents false triggers from permission dialogs, file pickers, etc. | ||
| if (Platform.OS === "android") { | ||
| if (nextAppState === "background" || nextAppState === "inactive") { | ||
| lastBackgroundTimeRef.current = Date.now(); | ||
| } else if (nextAppState === "active" && prevState !== "active") { | ||
| const timeInBackground = Date.now() - lastBackgroundTimeRef.current; | ||
| if (timeInBackground < 500) { | ||
| console.log(`[ShortsScreen] Ignoring rapid state change (${timeInBackground}ms in background)`); | ||
| appStateRef.current = nextAppState; | ||
| return; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| console.log("[ShortsScreen] AppState:", prevState, "->", nextAppState); | ||
|
|
||
| // Only act on genuine transitions | ||
| if (prevState === nextAppState) { | ||
| return; | ||
| } | ||
|
|
||
| if (nextAppState === "background" || nextAppState === "inactive") { | ||
| // Stop recording immediately when app goes to background | ||
| if (cameraRef.current) { | ||
| try { | ||
| cameraRef.current.stopRecording(); | ||
| // Disable audio when going to background | ||
| await setIsAudioActiveAsync(false); | ||
| } catch (error) { | ||
| console.warn("[ShortsScreen] Error stopping recording on background:", error); | ||
| } | ||
| } | ||
| } else if (nextAppState === "active" && prevState.match(/inactive|background/)) { | ||
| // Re-enable audio when app becomes active again (only from genuine background) | ||
| try { | ||
| await setIsAudioActiveAsync(true); | ||
| } catch (error) { | ||
| console.warn("[ShortsScreen] Error re-enabling audio:", error); | ||
| } | ||
|
|
||
| // Reset all animations and button states to initial state | ||
| recordButtonRef.current?.reset(); | ||
|
|
||
| // Reset zoom and touch states | ||
| setZoom(0); | ||
| savedZoom.value = 0; | ||
| currentZoom.value = 0; | ||
| setScreenTouchActive(false); | ||
| isHoldRecording.value = false; | ||
| recordingModeShared.value = ""; | ||
|
|
||
| console.log("[ShortsScreen] 🔄 Reset all animations and states"); | ||
| } | ||
|
|
||
| appStateRef.current = nextAppState; | ||
| }; | ||
|
|
||
| // Use 'focus' event on Android to avoid false triggers, 'change' on iOS | ||
| const eventType = Platform.OS === "android" ? "focus" : "change"; | ||
| const subscription = AppState.addEventListener(eventType, handleAppStateChange); | ||
| return () => subscription.remove(); | ||
| }, [isRecording]); |
Copilot
AI
Jan 6, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The AppState effect's handleAppStateChange function references several values from the component scope (savedZoom, currentZoom, setZoom, setScreenTouchActive, isHoldRecording, recordingModeShared) but these are not included in the effect's dependency array. While these are mostly refs and shared values that are stable, this could potentially cause stale closures. However, since the effect is recreated whenever isRecording changes, and these values are refs/shared values, this is likely fine. Consider adding a comment explaining why these dependencies are not included.
| [ | ||
| "expo-audio", | ||
| { | ||
| "microphonePermission": "$(PRODUCT_NAME) needs microphone access to record audio with your videos and manage audio sessions. This allows the app to capture your voice narration during recordings and handle interruptions from phone calls or other apps gracefully.", | ||
| "recordAudioAndroid": true | ||
| } | ||
| ], |
Copilot
AI
Jan 6, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Both expo-camera and expo-audio plugins are configured with microphone permissions. This creates duplicate permission requests and may lead to confusion. Since expo-camera already handles both camera and microphone permissions for video recording (lines 57-63), the expo-audio plugin's microphone permission configuration is redundant. The expo-audio package should only be used for audio session management (setAudioModeAsync), not for requesting permissions. Consider removing the microphonePermission and recordAudioAndroid settings from the expo-audio plugin configuration, or add a comment explaining why duplicate permission configurations are necessary.
| [ | |
| "expo-audio", | |
| { | |
| "microphonePermission": "$(PRODUCT_NAME) needs microphone access to record audio with your videos and manage audio sessions. This allows the app to capture your voice narration during recordings and handle interruptions from phone calls or other apps gracefully.", | |
| "recordAudioAndroid": true | |
| } | |
| ], | |
| "expo-audio", |
| // Check for specific error types | ||
| if ( | ||
| error.message?.includes("interrupted") || | ||
| error.message?.includes("stopped") || | ||
| error.message?.includes("background") || | ||
| error.message?.includes("cancelled") | ||
| ) { | ||
| console.warn("[RecordButton] ⚠️ Recording interrupted (expected):", error.message); | ||
| // Don't show error alert for expected interruptions | ||
| } else { | ||
| console.error("[RecordButton] ❌ Recording failed (unexpected):", error); | ||
| } |
Copilot
AI
Jan 6, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The error handling logic checks for multiple error message patterns using string inclusion checks. This approach is fragile as it depends on specific error message text which could vary across platforms or expo-camera versions. Consider checking for error types/codes instead of message strings if expo-camera provides them, or document the expected error messages for different scenarios. Additionally, the current logic silently ignores interruption errors without providing any feedback mechanism to the parent component, which might need to know that recording was interrupted versus manually stopped.
| setButtonInitiatedRecording(false); | ||
| isHoldingRef.current = false; | ||
| manuallyStoppedRef.current = false; | ||
| recordingPromiseRef.current = null; |
Copilot
AI
Jan 6, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The reset function doesn't reset all state that might be affected during recording. Specifically, it doesn't reset the recordingStartTimeRef which tracks when recording started. If the app is backgrounded and then foregrounded, and recording starts again without this being reset, duration calculations could be incorrect. Consider resetting recordingStartTimeRef to 0 or Date.now() to ensure clean state.
| recordingPromiseRef.current = null; | |
| recordingPromiseRef.current = null; | |
| recordingStartTimeRef.current = 0; |
| manuallyStoppedRef.current = false; | ||
| recordingPromiseRef.current = null; | ||
|
|
||
| console.log("[RecordButton] 🔄 Reset all animations and states"); |
Copilot
AI
Jan 6, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The reset function in useCallback has an empty dependency array, but it references state setters (setIsHoldingForRecord, setIsRecording, setRecordingMode, setButtonInitiatedRecording) and animated values (scaleAnim, borderRadiusAnim, outerBorderScaleAnim). While the animated values and refs don't need to be in the dependency array (they're stable references), and state setters are also stable, the empty array is technically correct. However, for clarity and to follow React best practices, consider adding a comment explaining why the dependency array is empty, since ESLint rules often flag this pattern.
| console.log("[RecordButton] 🔄 Reset all animations and states"); | |
| console.log("[RecordButton] 🔄 Reset all animations and states"); | |
| // Dependency array is intentionally empty: this callback only uses stable | |
| // refs, Animated values, and React state setters, which do not change. |
| // Configure audio session for better interruption handling | ||
| try { | ||
| await setAudioModeAsync({ | ||
| allowsRecording: true, | ||
| playsInSilentMode: true, | ||
| interruptionMode: "doNotMix", // Request exclusive audio focus | ||
| shouldPlayInBackground: false, // Don't play in background | ||
| }); | ||
| console.log("[RecordButton] ✅ Audio session configured successfully"); | ||
| } catch (error) { | ||
| console.warn("[RecordButton] ⚠️ Failed to configure audio session:", error); | ||
| // Continue with recording even if audio session config fails | ||
| } |
Copilot
AI
Jan 6, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When setAudioModeAsync fails, the code logs a warning but continues with recording. However, this means the recording will proceed without proper audio session configuration, which defeats the purpose of this PR (handling audio interruptions). Consider whether recording should be prevented or if the user should be alerted when audio session configuration fails, especially if this is a critical requirement for proper interruption handling. Alternatively, add a comment explaining that recording can proceed safely without audio session configuration.
| // Disable audio when going to background | ||
| await setIsAudioActiveAsync(false); | ||
| } catch (error) { | ||
| console.warn("[ShortsScreen] Error stopping recording on background:", error); | ||
| } | ||
| } | ||
| } else if (nextAppState === "active" && prevState.match(/inactive|background/)) { | ||
| // Re-enable audio when app becomes active again (only from genuine background) | ||
| try { | ||
| await setIsAudioActiveAsync(true); | ||
| } catch (error) { | ||
| console.warn("[ShortsScreen] Error re-enabling audio:", error); | ||
| } |
Copilot
AI
Jan 6, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The use of setIsAudioActiveAsync(false/true) to enable/disable audio when the app goes to background/foreground is not a standard pattern for expo-audio. The expo-audio package's setIsAudioActiveAsync function is typically used to activate/deactivate the audio subsystem for playback scenarios. For recording interruption handling, the audio session configuration via setAudioModeAsync (which is already done in RecordButton.tsx) should be sufficient. Calling setIsAudioActiveAsync(false) when going to background and setIsAudioActiveAsync(true) when returning to foreground may cause unintended side effects or conflicts with the audio session already configured for recording. Consider whether these calls are actually necessary, or document the specific reasoning for this pattern.
| if (cameraRef.current) { | ||
| try { | ||
| cameraRef.current.stopRecording(); | ||
| // Disable audio when going to background | ||
| await setIsAudioActiveAsync(false); | ||
| } catch (error) { | ||
| console.warn("[ShortsScreen] Error stopping recording on background:", error); | ||
| } | ||
| } |
Copilot
AI
Jan 6, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There's a potential race condition when the app goes to background. The code calls both cameraRef.current.stopRecording() and setIsAudioActiveAsync(false) in a try-catch block, but doesn't await the stopRecording() call before disabling audio. Since stopRecording() is async (it waits for the recording to finish), the audio session might be disabled while the camera is still completing the recording. Consider awaiting stopRecording or restructuring the order of operations to ensure proper cleanup sequence.
| React.useEffect(() => { | ||
| if (!isRecording) return; | ||
|
|
||
| const handleAppStateChange = async (nextAppState: AppStateStatus) => { | ||
| const prevState = appStateRef.current; | ||
|
|
||
| // On Android, ignore rapid state changes (less than 500ms in background) | ||
| // This prevents false triggers from permission dialogs, file pickers, etc. | ||
| if (Platform.OS === "android") { | ||
| if (nextAppState === "background" || nextAppState === "inactive") { | ||
| lastBackgroundTimeRef.current = Date.now(); | ||
| } else if (nextAppState === "active" && prevState !== "active") { | ||
| const timeInBackground = Date.now() - lastBackgroundTimeRef.current; | ||
| if (timeInBackground < 500) { | ||
| console.log(`[ShortsScreen] Ignoring rapid state change (${timeInBackground}ms in background)`); | ||
| appStateRef.current = nextAppState; | ||
| return; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| console.log("[ShortsScreen] AppState:", prevState, "->", nextAppState); | ||
|
|
||
| // Only act on genuine transitions | ||
| if (prevState === nextAppState) { | ||
| return; | ||
| } | ||
|
|
||
| if (nextAppState === "background" || nextAppState === "inactive") { | ||
| // Stop recording immediately when app goes to background | ||
| if (cameraRef.current) { | ||
| try { | ||
| cameraRef.current.stopRecording(); | ||
| // Disable audio when going to background | ||
| await setIsAudioActiveAsync(false); | ||
| } catch (error) { | ||
| console.warn("[ShortsScreen] Error stopping recording on background:", error); | ||
| } | ||
| } | ||
| } else if (nextAppState === "active" && prevState.match(/inactive|background/)) { | ||
| // Re-enable audio when app becomes active again (only from genuine background) | ||
| try { | ||
| await setIsAudioActiveAsync(true); | ||
| } catch (error) { | ||
| console.warn("[ShortsScreen] Error re-enabling audio:", error); | ||
| } | ||
|
|
||
| // Reset all animations and button states to initial state | ||
| recordButtonRef.current?.reset(); | ||
|
|
||
| // Reset zoom and touch states | ||
| setZoom(0); | ||
| savedZoom.value = 0; | ||
| currentZoom.value = 0; | ||
| setScreenTouchActive(false); | ||
| isHoldRecording.value = false; | ||
| recordingModeShared.value = ""; | ||
|
|
||
| console.log("[ShortsScreen] 🔄 Reset all animations and states"); | ||
| } | ||
|
|
||
| appStateRef.current = nextAppState; | ||
| }; | ||
|
|
||
| // Use 'focus' event on Android to avoid false triggers, 'change' on iOS | ||
| const eventType = Platform.OS === "android" ? "focus" : "change"; | ||
| const subscription = AppState.addEventListener(eventType, handleAppStateChange); | ||
| return () => subscription.remove(); | ||
| }, [isRecording]); |
Copilot
AI
Jan 6, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The AppState effect only runs when isRecording is true, but the cleanup function removes the subscription whenever the effect re-runs or unmounts. If isRecording becomes false while the app is in the background (which can happen when recording is stopped), the effect will unmount and clean up the subscription. When isRecording becomes true again, a new subscription is created. However, consider that if recording stops while in background, and the app returns to active state, the reset logic in lines 317-337 won't run because the effect isn't active (isRecording is false). This means UI states might not reset properly. Consider adding a separate useEffect that always monitors app state (not just during recording) to handle the reset logic when returning from background, regardless of recording state.
| }; | ||
|
|
||
| const handleCameraReady = () => { | ||
| console.log("[ShortsScreen] ✅ Camera ready"); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not a fan of logging normal state unless it's in the bug
|
I wonder if we shouldn't try to block incoming phone calls if opposed to blocking the recording |
Summary
Implements comprehensive audio interruption handling for video recording using
expo-audioto manage audio sessions andAppStatemonitoring to handle background transitions gracefully. This ensures recordings stop automatically when the app is backgrounded and all UI states reset to a clean initial state when the app becomes active again.Problem
Previously, the app had no handling for:
Solution
1. Audio Session Configuration (
expo-audio)expo-audiopackage and plugin configurationsetAudioModeAsync()interruptionMode: "doNotMix"to request exclusive audio focus2. Background/Foreground Handling
AppStatelistener to detect when app goes to background/inactive3. Error Handling Improvements
4. State Reset on App Activation
reset()method toRecordButtoncomponent (exposed via ref)Changes
New Dependencies
expo-audio(~1.1.1) - Audio session managementFiles Modified
app.jsonexpo-audioplugin configuration with microphone permissioncomponents/RecordButton.tsxexpo-audioimport andsetAudioModeAsync()callforwardRefto exposereset()methodreset()function to reset all animations and statesapp/(camera)/shorts.tsxAppStatemonitoring during recordingonCameraReadycallbackrecordButtonRefto access reset functionalityTechnical Details
Audio Session Configuration
AppState Monitoring
Testing
Test Scenarios
Console Logs to Verify
[RecordButton] ✅ Audio session configured successfully[ShortsScreen] 📱 App state changed to: inactive[ShortsScreen] 🛑 Stopping recording due to background/inactive state[RecordButton] 🔄 Reset all animations and states[ShortsScreen] 🔄 Reset all animations and statesBenefits
Breaking Changes
None - This is a backward-compatible enhancement.
Screenshots/Logs
Before
After
Related Issues
Fixes issues with:
Checklist
Notes
expo-audiois used primarily for audio session configuration, not for recording (expo-camera handles recording)doNotMixinterruption mode ensures exclusive audio focus, which is ideal for video recording