From 3a350a87572c785508905335bb330a2957767609 Mon Sep 17 00:00:00 2001 From: Adithya Date: Mon, 1 Jun 2026 15:37:21 -0700 Subject: [PATCH] refactor: clean up imports and formatting in ShortsScreen component --- app/(camera)/shorts.tsx | 273 +++++++++++++++------------------------- 1 file changed, 104 insertions(+), 169 deletions(-) diff --git a/app/(camera)/shorts.tsx b/app/(camera)/shorts.tsx index 9df9aee..caee3b7 100644 --- a/app/(camera)/shorts.tsx +++ b/app/(camera)/shorts.tsx @@ -27,7 +27,6 @@ import { router, useLocalSearchParams } from "expo-router"; import * as React from "react"; import { Alert, - AppState, Platform, StyleSheet, TouchableOpacity, @@ -36,7 +35,10 @@ import { import { useFocusEffect } from "@react-navigation/native"; import { DraftStorage } from "@/utils/draftStorage"; import { fileStore } from "@/utils/fileStore"; -import { Gesture, GestureDetector } from "react-native-gesture-handler"; +import { + Gesture, + GestureDetector, +} from "react-native-gesture-handler"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import Animated, { runOnJS, @@ -66,17 +68,14 @@ export default function ShortsScreen() { const draftMode = (mode === "upload" ? "upload" : "camera") as | "camera" | "upload"; - + // Store per-draft config only when QR includes draftId (required for upload) - const serverNotSetupForUpload = - useLocalSearchParams<{ serverNotSetupForUpload?: string }>() - .serverNotSetupForUpload === "true"; + const serverNotSetupForUpload = useLocalSearchParams<{ serverNotSetupForUpload?: string }>().serverNotSetupForUpload === "true"; React.useEffect(() => { const storeConfig = async () => { if (server && draftId) { try { - const { storeUploadConfigForDraft } = - await import("@/utils/uploadConfig"); + const { storeUploadConfigForDraft } = await import("@/utils/uploadConfig"); await storeUploadConfigForDraft(draftId, server, token ?? undefined); console.log("✅ Stored upload config for draft", draftId); } catch (error) { @@ -92,28 +91,27 @@ export default function ShortsScreen() { Alert.alert( "Server not set up for upload", "Server is not properly set up for upload.", - [{ text: "OK" }], + [{ text: "OK" }] ); } }, [serverNotSetupForUpload]); const cameraRef = React.useRef(null); - + // Use a stable ref callback to avoid CameraView remounting on every render // This prevents the camera from being recreated on each state update const cameraRefCallback = React.useCallback((ref: CameraView | null) => { cameraRef.current = ref; }, []); - + // Camera remount key - increment to force CameraView to remount // This is needed on Android when returning from screens that use media players const [cameraKey, setCameraKey] = React.useState(0); - + // Track if we've navigated to another screen that uses video/media // Set to true before navigating, checked on focus to decide if remount needed const needsCameraRemountRef = React.useRef(false); - - const [maxDurationLimitSeconds, setMaxDurationLimitSeconds] = - React.useState(60); + + const [maxDurationLimitSeconds, setMaxDurationLimitSeconds] = React.useState(60); const [activeRecordingDurationSeconds, setActiveRecordingDurationSeconds] = React.useState(0); @@ -158,23 +156,22 @@ export default function ShortsScreen() { const currentTouchY = useSharedValue(0); const isHoldRecording = useSharedValue(false); const recordingModeShared = useSharedValue(""); - const dragToZoomActive = useSharedValue(false); // Create derived value to suppress onAnimatedValueUpdate warnings // This creates proper listeners for shared values that are updated from JS // The derived value ensures listeners are registered before values are updated // We reference all shared values here to create listeners useDerivedValue(() => { - // Read all shared values to create listeners (intentional reads) - return ( - (isHoldRecording.value ? 1 : 0) + - (recordingModeShared.value ? 1 : 0) + - currentZoom.value + - savedZoom.value + - currentTouchY.value + - initialTouchY.value + - (dragToZoomActive.value ? 1 : 0) + // Read all shared values to create listeners (void = intentional read for dependency) + void ( + isHoldRecording.value, + recordingModeShared.value, + currentZoom.value, + savedZoom.value, + currentTouchY.value, + initialTouchY.value ); + return 0; // Return dummy value }); // Calculate effective duration: trimmed duration if trim points exist, otherwise original duration @@ -194,15 +191,14 @@ export default function ShortsScreen() { const totalRecordedDurationSeconds = recordingSegments.reduce( (total, segment) => total + getEffectiveDuration(segment), - 0, + 0 ); - const { activateRecordingSession, deactivateRecordingSession } = - useAudioSession(); + const { activateRecordingSession, deactivateRecordingSession } = useAudioSession(); - const handleRecordingStart = ( + const handleRecordingStart = async ( mode: "tap" | "hold", - remainingTime: number, + remainingTime: number ) => { setActiveRecordingDurationSeconds(0); setIsRecording(true); @@ -211,11 +207,12 @@ export default function ShortsScreen() { // Only set isHoldRecording for hold mode isHoldRecording.value = mode === "hold"; recordingModeShared.value = mode; + await activateRecordingSession(); }; const handleRecordingProgress = ( currentDuration: number, - remainingTime: number, + remainingTime: number ) => { setActiveRecordingDurationSeconds(currentDuration); }; @@ -223,11 +220,14 @@ export default function ShortsScreen() { const handleRecordingComplete = async ( videoUri: string | null, mode: "tap" | "hold", - recordedDurationSeconds: number, + recordedDurationSeconds: number ) => { setActiveRecordingDurationSeconds(0); setIsRecording(false); + // Release microphone and let background apps resume audio. + deactivateRecordingSession(); + // Reset shared values and screen touch state isHoldRecording.value = false; recordingModeShared.value = ""; @@ -235,24 +235,19 @@ export default function ShortsScreen() { if (videoUri && recordedDurationSeconds > 0) { let actualDuration = recordedDurationSeconds; - + try { const startTime = Date.now(); actualDuration = await Promise.race([ VideoConcatModule.getDuration(videoUri), - new Promise((_, reject) => - setTimeout(() => reject(new Error("Timeout")), 500), - ), + new Promise((_, reject) => + setTimeout(() => reject(new Error('Timeout')), 500) + ) ]); const elapsed = Date.now() - startTime; - console.log( - `[Shorts] Native duration: ${actualDuration.toFixed(2)}s (${elapsed}ms), timestamp: ${recordedDurationSeconds.toFixed(2)}s`, - ); + console.log(`[Shorts] Native duration: ${actualDuration.toFixed(2)}s (${elapsed}ms), timestamp: ${recordedDurationSeconds.toFixed(2)}s`); } catch (error) { - console.warn( - `[Shorts] Native duration failed, using timestamp: ${recordedDurationSeconds.toFixed(2)}s`, - error, - ); + console.warn(`[Shorts] Native duration failed, using timestamp: ${recordedDurationSeconds.toFixed(2)}s`, error); } const newSegment: RecordingSegment = { @@ -267,10 +262,7 @@ export default function ShortsScreen() { // Restore loaded duration when draft is loaded React.useEffect(() => { - if ( - savedDurationLimitSeconds !== null && - savedDurationLimitSeconds !== maxDurationLimitSeconds - ) { + if (savedDurationLimitSeconds !== null && savedDurationLimitSeconds !== maxDurationLimitSeconds) { setMaxDurationLimitSeconds(savedDurationLimitSeconds); } }, [savedDurationLimitSeconds, maxDurationLimitSeconds]); @@ -290,64 +282,32 @@ export default function ShortsScreen() { recordingModeShared.value = ""; } // isHoldRecording and recordingModeShared are Reanimated shared refs (stable), omit from deps - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, [isRecording]); - // Audio session lifecycle — kept separate from draft reload so transient state - // changes (currentDraftId, maxDurationLimitSeconds) don't thrash the AVAudioSession - // between segments. Reconfiguring mid-session was killing the mic on segments 2+. - // - // Also handles app foreground: when the OS or another app reclaims the audio - // session while pulse is suspended (incoming call, Siri, switching apps), we - // re-activate and remount the camera so the mic re-attaches to a fresh session. - useFocusEffect( - React.useCallback(() => { - activateRecordingSession(); - - let lastState: string = AppState.currentState; - const sub = AppState.addEventListener("change", (next) => { - if ( - (lastState === "background" || lastState === "inactive") && - next === "active" - ) { - activateRecordingSession(); - setCameraKey((prev) => prev + 1); - } - lastState = next; - }); - - return () => { - sub.remove(); - deactivateRecordingSession(); - }; - }, [activateRecordingSession, deactivateRecordingSession]), - ); - useFocusEffect( React.useCallback(() => { - // Reset all gesture and recording state when screen comes into focus - // This handles deeplink navigation, app backgrounding, and navigation transitions - setScreenTouchActive(false); - isHoldRecording.value = false; - recordingModeShared.value = ""; - dragToZoomActive.value = false; - - // Force camera remount when returning from a screen that used a video player. - // needsCameraRemountRef is set before navigating to such screens. - // On iOS this is required to re-attach the mic after AVAudioSession was - // flipped to playback mode by expo-video; on Android it avoids camera/video conflicts. - if (needsCameraRemountRef.current) { - setCameraKey((prev) => prev + 1); + // On Android, force camera remount ONLY when returning from a screen that uses video + // needsCameraRemountRef is explicitly set before navigating to such screens + if (Platform.OS === "android" && needsCameraRemountRef.current) { + setCameraKey(prev => prev + 1); needsCameraRemountRef.current = false; } + // Claim audio focus as soon as the camera screen mounts. + // On Android this calls AudioManager.requestAudioFocus(AUDIOFOCUS_GAIN) + // which stops Spotify / podcasts / etc. BEFORE the user taps Record. + // This avoids a race condition where recordAsync() would start before + // the async setIsAudioActiveAsync(true) had completed. + // activateRecordingSession(); + const reloadDraft = async () => { const draftToReload = draftId || currentDraftId; if (draftToReload) { try { const draft = await DraftStorage.getDraftById( draftToReload, - "camera", + "camera" ); if (draft) { if (draft.segments) { @@ -355,10 +315,8 @@ export default function ShortsScreen() { fileStore.convertSegmentsToAbsolute(draft.segments); setRecordingSegments(segmentsWithAbsolutePaths); } - if ( - draft.maxDurationLimitSeconds !== undefined && - draft.maxDurationLimitSeconds !== maxDurationLimitSeconds - ) { + if (draft.maxDurationLimitSeconds !== undefined && + draft.maxDurationLimitSeconds !== maxDurationLimitSeconds) { setMaxDurationLimitSeconds(draft.maxDurationLimitSeconds); } } @@ -368,31 +326,29 @@ export default function ShortsScreen() { } }; reloadDraft(); - }, [ - draftId, - currentDraftId, - setRecordingSegments, - maxDurationLimitSeconds, - isHoldRecording, - recordingModeShared, - dragToZoomActive, - ]), + + // Ensure clean audio state when the user leaves the camera screen + // (e.g. navigated away mid-session or app backgrounded during recording). + return () => { + deactivateRecordingSession(); + }; + }, [draftId, currentDraftId, setRecordingSegments, maxDurationLimitSeconds, deactivateRecordingSession]) ); const handleTimeSelect = (newDurationLimitSeconds: number) => { // Check if current segments exceed the new duration limit (using effective durations) const currentRecordedDurationSeconds = recordingSegments.reduce( (total, seg) => total + getEffectiveDuration(seg), - 0, + 0 ); if (currentRecordedDurationSeconds > newDurationLimitSeconds) { Alert.alert( "Duration Too Low", `Current segments (${Math.round( - currentRecordedDurationSeconds, + currentRecordedDurationSeconds )}s) exceed ${newDurationLimitSeconds}s limit. Undo segments first.`, - [{ text: "OK", style: "default" }], + [{ text: "OK", style: "default" }] ); return; } @@ -431,11 +387,10 @@ export default function ShortsScreen() { const handlePreview = () => { if (currentDraftId && recordingSegments.length > 0) { - // Force camera remount on return. The preview's video player reconfigures - // AVAudioSession (iOS) / AudioFocus (Android) to playback mode, and the - // already-mounted camera won't re-attach to the mic when we flip the - // session back to record. Remounting reattaches audio input cleanly. - needsCameraRemountRef.current = true; + // Mark that we need to remount camera when returning (video player will be used) + if (Platform.OS === "android") { + needsCameraRemountRef.current = true; + } router.push({ pathname: "/preview-new", params: { draftId: currentDraftId, ...(videoid && { videoid }) }, @@ -445,6 +400,10 @@ export default function ShortsScreen() { const handleReorderSegments = () => { if (currentDraftId) { + // Mark that we need to remount camera when returning (video thumbnails/previews used) + if (Platform.OS === "android") { + needsCameraRemountRef.current = true; + } router.push({ pathname: "/reordersegments", params: { draftId: currentDraftId }, @@ -472,7 +431,6 @@ export default function ShortsScreen() { // Screen-level touch handler for continuous hold recording with drag-to-zoom const panGesture = Gesture.Pan() .onBegin((event) => { - dragToZoomActive.value = false; runOnJS(setScreenTouchActive)(true); // Store initial touch position for zoom calculation initialTouchY.value = event.y; @@ -484,16 +442,6 @@ export default function ShortsScreen() { // Only apply zoom during hold recording if (isHoldRecording.value && recordingModeShared.value === "hold") { const deltaY = initialTouchY.value - event.y; // Negative = down, Positive = up - const movementDistance = Math.abs(event.translationY); - const DRAG_THRESHOLD = 10; - - if (!dragToZoomActive.value && movementDistance < DRAG_THRESHOLD) { - return; - } - - if (!dragToZoomActive.value) { - dragToZoomActive.value = true; - } // Convert pixel movement to zoom change with same sensitivity as pinch // Scale factor adjusted for touch movement (roughly 300px = full zoom range) @@ -504,17 +452,16 @@ export default function ShortsScreen() { const newZoom = Math.min( 0.5, - Math.max(0, savedZoom.value + zoomChange), + Math.max(0, savedZoom.value + zoomChange) ); currentZoom.value = newZoom; runOnJS(setZoom)(newZoom); } }) .onFinalize(() => { - dragToZoomActive.value = false; - // Always reset screenTouchActive when gesture ends (whether recording or not) + // Ensure screenTouchActive is reset on any gesture end (cancel, fail, or end) runOnJS(setScreenTouchActive)(false); - // Save zoom state on finalize only if recording in hold mode + // Save zoom state on finalize if (isHoldRecording.value && recordingModeShared.value === "hold") { savedZoom.value = currentZoom.value; } @@ -549,60 +496,51 @@ export default function ShortsScreen() { // Get the actual video duration from native module let videoFileDurationSeconds = 0; if (asset.duration) { - videoFileDurationSeconds = - asset.duration > 1000 ? asset.duration / 1000 : asset.duration; + videoFileDurationSeconds = asset.duration > 1000 ? asset.duration / 1000 : asset.duration; } - + try { const startTime = Date.now(); const nativeDuration = await Promise.race([ VideoConcatModule.getDuration(asset.uri), - new Promise((_, reject) => - setTimeout(() => reject(new Error("Timeout")), 500), - ), + new Promise((_, reject) => + setTimeout(() => reject(new Error('Timeout')), 500) + ) ]); const elapsed = Date.now() - startTime; videoFileDurationSeconds = nativeDuration; - const pickerDuration = asset.duration - ? asset.duration > 1000 - ? asset.duration / 1000 - : asset.duration - : 0; - console.log( - `[Shorts] Library video native duration: ${nativeDuration.toFixed(2)}s (${elapsed}ms), picker: ${pickerDuration.toFixed(2)}s`, - ); + const pickerDuration = asset.duration ? (asset.duration > 1000 ? asset.duration / 1000 : asset.duration) : 0; + console.log(`[Shorts] Library video native duration: ${nativeDuration.toFixed(2)}s (${elapsed}ms), picker: ${pickerDuration.toFixed(2)}s`); } catch (error) { - console.warn( - `[Shorts] Native duration failed for library video, using picker value: ${videoFileDurationSeconds.toFixed(2)}s`, - error, - ); + console.warn(`[Shorts] Native duration failed for library video, using picker value: ${videoFileDurationSeconds.toFixed(2)}s`, error); } // Generate thumbnail (not currently used, but may be needed in future) - await VideoThumbnails.getThumbnailAsync(asset.uri, { - time: 1000, // 1 second into the video - quality: 0.8, - }).catch(() => null); + await VideoThumbnails.getThumbnailAsync( + asset.uri, + { + time: 1000, // 1 second into the video + quality: 0.8, + } + ).catch(() => null); // Check if adding this video would exceed the total duration limit (using effective durations) const currentRecordedDurationSeconds = recordingSegments.reduce( (total, seg) => total + getEffectiveDuration(seg), - 0, + 0 ); - const projectedTotalDurationSeconds = - currentRecordedDurationSeconds + videoFileDurationSeconds; + const projectedTotalDurationSeconds = currentRecordedDurationSeconds + videoFileDurationSeconds; if (projectedTotalDurationSeconds > maxDurationLimitSeconds) { - const remainingTime = - maxDurationLimitSeconds - currentRecordedDurationSeconds; + const remainingTime = maxDurationLimitSeconds - currentRecordedDurationSeconds; Alert.alert( "Video Too Long", `Video (${Math.round( - videoFileDurationSeconds, + videoFileDurationSeconds )}s) exceeds ${maxDurationLimitSeconds}s limit. Remaining: ${Math.round( - remainingTime, + remainingTime )}s`, - [{ text: "OK" }], + [{ text: "OK" }] ); return; } @@ -641,7 +579,10 @@ export default function ShortsScreen() { ? scaleChange * 0.4 // Zoom in : scaleChange * 0.7; // Zoom out (more sensitive) - const newZoom = Math.min(0.5, Math.max(0, savedZoom.value + zoomChange)); + const newZoom = Math.min( + 0.5, + Math.max(0, savedZoom.value + zoomChange) + ); currentZoom.value = newZoom; runOnJS(setZoom)(newZoom); }) @@ -667,7 +608,7 @@ export default function ShortsScreen() { {...(Platform.OS === "ios" ? { videoStabilizationMode: mapToNativeVideoStabilization( - videoStabilizationMode, + videoStabilizationMode ), } : {})} @@ -722,7 +663,7 @@ export default function ShortsScreen() { {(() => { const totalSeconds = Math.round( - totalRecordedDurationSeconds + activeRecordingDurationSeconds, + totalRecordedDurationSeconds + activeRecordingDurationSeconds ); const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; @@ -777,17 +718,11 @@ export default function ShortsScreen() { ))} {recordingSegments.length > 0 && !isRecording && ( - + )} {redoStack.length > 0 && !isRecording && ( - + )} {recordingSegments.length > 0 && currentDraftId && !isRecording && (