-
Notifications
You must be signed in to change notification settings - Fork 5
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,7 +1,7 @@ | ||
| import CameraControls from "@/components/CameraControls"; | ||
| import CloseButton from "@/components/CloseButton"; | ||
| import UploadCloseButton from "@/components/UploadCloseButton"; | ||
| import RecordButton from "@/components/RecordButton"; | ||
| import RecordButton, { RecordButtonRef } from "@/components/RecordButton"; | ||
| import RecordingProgressBar, { | ||
| RecordingSegment, | ||
| } from "@/components/RecordingProgressBar"; | ||
|
|
@@ -26,12 +26,15 @@ | |
| import * as React from "react"; | ||
| import { | ||
| Alert, | ||
| AppState, | ||
| AppStateStatus, | ||
| Platform, | ||
| StyleSheet, | ||
| TouchableOpacity, | ||
| View, | ||
| } from "react-native"; | ||
| import { useFocusEffect } from "@react-navigation/native"; | ||
| import { setIsAudioActiveAsync } from "expo-audio"; | ||
| import { DraftStorage } from "@/utils/draftStorage"; | ||
| import { fileStore } from "@/utils/fileStore"; | ||
| import { | ||
|
|
@@ -81,6 +84,7 @@ | |
| storeConfig(); | ||
| }, [server, token]); | ||
| const cameraRef = React.useRef<CameraView>(null); | ||
| const recordButtonRef = React.useRef<RecordButtonRef>(null); | ||
|
|
||
| // Use a stable ref callback to avoid CameraView remounting on every render | ||
| // This prevents the camera from being recreated on each state update | ||
|
|
@@ -154,12 +158,12 @@ | |
| useDerivedValue(() => { | ||
| // Read all shared values to create listeners | ||
| // This prevents warnings when values are updated from JS | ||
| isHoldRecording.value; | ||
| recordingModeShared.value; | ||
| currentZoom.value; | ||
| savedZoom.value; | ||
| currentTouchY.value; | ||
| initialTouchY.value; | ||
| return 0; // Return dummy value | ||
| }); | ||
|
|
||
|
|
@@ -264,8 +268,83 @@ | |
| isHoldRecording.value = false; | ||
| recordingModeShared.value = ""; | ||
| } | ||
| }, [isRecording]); | ||
|
|
||
| // Track previous app state for Android (to detect genuine background transitions) | ||
| const appStateRef = React.useRef<AppStateStatus>(AppState.currentState); | ||
| const lastBackgroundTimeRef = React.useRef<number>(0); | ||
|
|
||
| // Handle app state changes during recording (background/foreground interruptions) | ||
| 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); | ||
| } | ||
| } | ||
|
Comment on lines
+308
to
+316
|
||
| } 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); | ||
| } | ||
|
Comment on lines
+311
to
+323
|
||
|
|
||
| // 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]); | ||
|
adithya1012 marked this conversation as resolved.
Comment on lines
+278
to
+346
|
||
|
|
||
| useFocusEffect( | ||
| React.useCallback(() => { | ||
| // On Android, force camera remount ONLY when returning from a screen that uses video | ||
|
|
@@ -353,6 +432,29 @@ | |
| updateVideoStabilizationMode(mode); | ||
| }; | ||
|
|
||
| const handleCameraMountError = (error: { message: string }) => { | ||
| console.error("[ShortsScreen] ❌ Camera mount error:", error); | ||
| Alert.alert( | ||
| "Camera Error", | ||
| "Failed to start camera. Please try again.", | ||
| [ | ||
| { | ||
| text: "Retry", | ||
| onPress: () => { | ||
| console.log("[ShortsScreen] 🔄 Retrying camera mount..."); | ||
| // Force camera remount | ||
| setCameraKey((prev) => prev + 1); | ||
| }, | ||
| }, | ||
| { text: "OK" }, | ||
| ] | ||
| ); | ||
| }; | ||
|
|
||
| const handleCameraReady = () => { | ||
| console.log("[ShortsScreen] ✅ Camera ready"); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| }; | ||
|
|
||
| const handlePreview = () => { | ||
| if (currentDraftId && recordingSegments.length > 0) { | ||
| // Mark that we need to remount camera when returning (video player will be used) | ||
|
|
@@ -574,6 +676,8 @@ | |
| facing={cameraFacing} | ||
| enableTorch={torchEnabled} | ||
| zoom={zoom} | ||
| onMountError={handleCameraMountError} | ||
| onCameraReady={handleCameraReady} | ||
| {...(Platform.OS === "ios" | ||
| ? { | ||
| videoStabilizationMode: mapToNativeVideoStabilization( | ||
|
|
@@ -642,6 +746,7 @@ | |
| </View> | ||
|
|
||
| <RecordButton | ||
| ref={recordButtonRef} | ||
| cameraRef={cameraRef} | ||
| maxDuration={180} | ||
| totalDuration={maxDurationLimitSeconds} | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,8 +1,13 @@ | ||||||||||
| import { CameraView } from "expo-camera"; | ||||||||||
| import { setAudioModeAsync } from "expo-audio"; | ||||||||||
| import * as React from "react"; | ||||||||||
| import { Animated, StyleSheet, TouchableOpacity, View } from "react-native"; | ||||||||||
| import VideoConcatModule from "@/modules/video-concat"; | ||||||||||
|
|
||||||||||
| export interface RecordButtonRef { | ||||||||||
| reset: () => void; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| interface RecordButtonProps { | ||||||||||
| cameraRef: React.RefObject<CameraView | null>; | ||||||||||
| maxDuration?: number; | ||||||||||
|
|
@@ -26,7 +31,7 @@ | |||||||||
| screenTouchActive?: boolean; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| export default function RecordButton({ | ||||||||||
| const RecordButton = React.forwardRef<RecordButtonRef, RecordButtonProps>(({ | ||||||||||
| cameraRef, | ||||||||||
| maxDuration = 180, | ||||||||||
| onRecordingStart, | ||||||||||
|
|
@@ -40,7 +45,7 @@ | |||||||||
| onButtonTouchStart, | ||||||||||
| onButtonTouchEnd, | ||||||||||
| screenTouchActive = false, | ||||||||||
| }: RecordButtonProps) { | ||||||||||
| }, ref) => { | ||||||||||
| const [isRecording, setIsRecording] = React.useState(false); | ||||||||||
| const [recordingMode, setRecordingMode] = React.useState< | ||||||||||
| "tap" | "hold" | null | ||||||||||
|
|
@@ -105,9 +110,23 @@ | |||||||||
| } | ||||||||||
| }, [screenTouchActive, buttonInitiatedRecording, isRecording, recordingMode]); // eslint-disable-line react-hooks/exhaustive-deps | ||||||||||
|
|
||||||||||
| const startRecording = (mode: "tap" | "hold") => { | ||||||||||
| const startRecording = async (mode: "tap" | "hold") => { | ||||||||||
| if (!cameraRef.current || isRecording || remainingTime <= 0) return; | ||||||||||
|
|
||||||||||
| // 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 | ||||||||||
| } | ||||||||||
|
Comment on lines
+116
to
+128
|
||||||||||
|
|
||||||||||
| setIsRecording(true); | ||||||||||
| setRecordingMode(mode); | ||||||||||
| setButtonInitiatedRecording(true); | ||||||||||
|
|
@@ -180,8 +199,17 @@ | |||||||||
| .catch(async (error) => { | ||||||||||
| const recordingDuration = (Date.now() - recordingStartTimeRef.current) / 1000; | ||||||||||
|
|
||||||||||
| if (!error.message?.includes("stopped")) { | ||||||||||
| console.log("[RecordButton] Recording failed"); | ||||||||||
| // 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); | ||||||||||
| } | ||||||||||
|
Comment on lines
+202
to
213
|
||||||||||
| onRecordingComplete?.(null, mode, recordingDuration); | ||||||||||
| return null; | ||||||||||
|
|
@@ -245,6 +273,43 @@ | |||||||||
| }).start(); | ||||||||||
| }; | ||||||||||
|
|
||||||||||
| // Reset all animations and states to initial values | ||||||||||
| const reset = React.useCallback(() => { | ||||||||||
| // Stop any ongoing animations | ||||||||||
| if (pulsingRef.current) { | ||||||||||
| pulsingRef.current.stop(); | ||||||||||
| } | ||||||||||
| if (progressIntervalRef.current) { | ||||||||||
| clearInterval(progressIntervalRef.current); | ||||||||||
| progressIntervalRef.current = null; | ||||||||||
| } | ||||||||||
| if (holdTimeoutRef.current) { | ||||||||||
| clearTimeout(holdTimeoutRef.current); | ||||||||||
| holdTimeoutRef.current = null; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| // Reset animation values to initial state | ||||||||||
| scaleAnim.setValue(1); | ||||||||||
| borderRadiusAnim.setValue(30); | ||||||||||
| outerBorderScaleAnim.setValue(1); | ||||||||||
|
|
||||||||||
| // Reset state | ||||||||||
| setIsHoldingForRecord(false); | ||||||||||
| setIsRecording(false); | ||||||||||
| setRecordingMode(null); | ||||||||||
| setButtonInitiatedRecording(false); | ||||||||||
| isHoldingRef.current = false; | ||||||||||
| manuallyStoppedRef.current = false; | ||||||||||
| recordingPromiseRef.current = null; | ||||||||||
|
||||||||||
| recordingPromiseRef.current = null; | |
| recordingPromiseRef.current = null; | |
| recordingStartTimeRef.current = 0; |
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. |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -17,6 +17,7 @@ | |
| "@react-navigation/elements": "^2.3.8", | ||
| "@react-navigation/native": "^7.1.6", | ||
| "expo": "~53.0.23", | ||
| "expo-audio": "~0.4.9", | ||
|
||
| "expo-blur": "~14.1.5", | ||
| "expo-camera": "~16.1.11", | ||
| "expo-constants": "~17.1.6", | ||
|
|
||
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.