diff --git a/js/mobile/app.config.ts b/js/mobile/app.config.ts index 0035fd5..5f48413 100644 --- a/js/mobile/app.config.ts +++ b/js/mobile/app.config.ts @@ -40,6 +40,7 @@ const config: ExpoConfig = { "Flowy uses your camera to record video messages.", microphonePermission: "Flowy uses your microphone to record voice and video messages.", + recordAudioAndroid: true, }, ], [ diff --git a/js/mobile/package.json b/js/mobile/package.json index dce3b4d..b3de624 100644 --- a/js/mobile/package.json +++ b/js/mobile/package.json @@ -19,13 +19,13 @@ "clsx": "^2.1.1", "expo": "~54.0.0", "expo-audio": "~1.0.13", - "expo-camera": "~17.0.8", + "expo-camera": "~17.0.10", "expo-constants": "~18.0.13", "expo-file-system": "~19.0.16", "expo-haptics": "~15.0.7", "expo-secure-store": "~15.0.8", - "expo-video": "~3.0.10", "expo-status-bar": "~3.0.9", + "expo-video": "~3.0.10", "firebase": "^12.10.0", "lucide-react-native": "^0.575.0", "nativewind": "^4.1.23", diff --git a/js/mobile/src/features/compose/AudioRecordingOverlay.tsx b/js/mobile/src/features/compose/AudioRecordingOverlay.tsx index 027e21a..c0bf7db 100644 --- a/js/mobile/src/features/compose/AudioRecordingOverlay.tsx +++ b/js/mobile/src/features/compose/AudioRecordingOverlay.tsx @@ -1,193 +1,125 @@ -import { useEffect, useRef, useState } from "react"; -import { StyleSheet, Text, View } from "react-native"; +import { useEffect, useRef } from "react"; +import { Pressable, StyleSheet, Text, View } from "react-native"; import { Mic } from "lucide-react-native"; import { RecordingPresets, + setAudioModeAsync, useAudioRecorder, useAudioRecorderState, } from "expo-audio"; -import Animated, { - Easing, - useAnimatedStyle, - useSharedValue, - withRepeat, - withTiming, -} from "react-native-reanimated"; -import { useEvent } from "@/hooks/use-event"; import { logError } from "@/lib/errors"; const MAX_DURATION_S = 60; interface AudioRecordingOverlayProps { - cancelProgress: { value: number }; onComplete: (result: { uri: string; durationMs: number }) => void; onCancel: () => void; - onRecordingStarted: () => void; - /** - * The parent owns the gesture that drives commit/cancel. To avoid race - * conditions when the user releases right as the overlay is mounting, we - * expose a `pendingAction` prop that's flushed once recording is live. - */ - pendingAction: "commit" | "cancel" | null; } -/** - * Voice-only recording. Mounts an `expo-audio` recorder, kicks it off as soon - * as `prepareToRecordAsync` resolves, and waits for the parent to flush a - * commit/cancel action. The shared `cancelProgress` value lights the cancel - * hint as the user drags up. - */ export function AudioRecordingOverlay({ - cancelProgress, onComplete, onCancel, - onRecordingStarted, - pendingAction, }: AudioRecordingOverlayProps) { const recorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY); - const recorderState = useAudioRecorderState(recorder, 100); - const [elapsedMs, setElapsedMs] = useState(0); - const startedAtRef = useRef(null); - const finalizedRef = useRef<"pending" | "completed" | "cancelled">( - "pending", - ); + const state = useAudioRecorderState(recorder, 250); + const finalizedRef = useRef(false); - const onCompleteStable = useEvent(onComplete); - const onCancelStable = useEvent(onCancel); - const onStartedStable = useEvent(onRecordingStarted); - - // Spin up the recorder. useEffect(() => { - let cancelled = false; + let active = true; (async () => { try { + await setAudioModeAsync({ + allowsRecording: true, + playsInSilentMode: true, + }); await recorder.prepareToRecordAsync(); - if (cancelled) return; + if (!active) return; recorder.record(); - startedAtRef.current = Date.now(); - onStartedStable(); } catch (err) { - logError(err, { scope: "compose.audio.prepare" }); - if (!cancelled) onCancelStable(); + logError(err, { scope: "compose.audio.start" }); + if (active) onCancel(); } })(); + return () => { - cancelled = true; - }; - // recorder identity is stable for the component's lifetime - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - // Cap at 60s — match the desktop limit and the PRD. - useEffect(() => { - const interval = setInterval(() => { - if (startedAtRef.current === null) return; - const elapsed = Date.now() - startedAtRef.current; - setElapsedMs(elapsed); - if (elapsed >= MAX_DURATION_S * 1000) { - void finalize("commit"); + active = false; + if (!finalizedRef.current) { + finalizedRef.current = true; + recorder.stop().catch(() => {}); } - }, 100); - return () => clearInterval(interval); + void setAudioModeAsync({ + allowsRecording: false, + playsInSilentMode: true, + }).catch((err) => logError(err, { scope: "compose.audio.exit" })); + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const finalize = useEvent(async (kind: "commit" | "cancel") => { - if (finalizedRef.current !== "pending") return; - finalizedRef.current = kind === "commit" ? "completed" : "cancelled"; + const elapsedMs = state.durationMillis ?? 0; + + useEffect(() => { + if (elapsedMs >= MAX_DURATION_S * 1000 && !finalizedRef.current) { + void finish("commit"); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [elapsedMs]); + + const finish = async (kind: "commit" | "cancel") => { + if (finalizedRef.current) return; + finalizedRef.current = true; + const durationMs = state.durationMillis ?? 0; try { await recorder.stop(); } catch (err) { logError(err, { scope: "compose.audio.stop" }); } if (kind === "cancel") { - onCancelStable(); + onCancel(); return; } const uri = recorder.uri; if (!uri) { - onCancelStable(); + onCancel(); return; } - const durationMs = - startedAtRef.current !== null ? Date.now() - startedAtRef.current : 0; - onCompleteStable({ uri, durationMs }); - }); - - // Flush parent-issued action when it arrives. - useEffect(() => { - if (pendingAction === "commit") void finalize("commit"); - if (pendingAction === "cancel") void finalize("cancel"); - }, [pendingAction, finalize]); - - // Safety: stop recording on unmount if we never finalized. - useEffect(() => { - return () => { - if (finalizedRef.current === "pending") { - finalizedRef.current = "cancelled"; - recorder.stop().catch(() => {}); - } - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + onComplete({ uri, durationMs }); + }; const elapsedSec = Math.floor(elapsedMs / 1000); - const isRecording = recorderState.isRecording; return ( - + + + + + - Voice message + {state.isRecording ? "Recording" : "Starting…"} {elapsedSec.toString().padStart(2, "0")}s · max {MAX_DURATION_S}s - + + + void finish("cancel")} + accessibilityLabel="Cancel recording" + className="rounded-full bg-white/15 px-6 py-3" + > + Cancel + + void finish("commit")} + accessibilityLabel="Stop recording" + className="rounded-full bg-white px-7 py-3" + > + Stop + + ); } - -function PulsingMic({ active }: { active: boolean }) { - const scale = useSharedValue(1); - useEffect(() => { - if (!active) return; - scale.value = withRepeat( - withTiming(1.15, { duration: 700, easing: Easing.inOut(Easing.quad) }), - -1, - true, - ); - }, [active, scale]); - const style = useAnimatedStyle(() => ({ - transform: [{ scale: scale.value }], - })); - return ( - - - - - - ); -} - -function CancelHint({ - cancelProgress, -}: { - cancelProgress: { value: number }; -}) { - const style = useAnimatedStyle(() => { - const progress = Math.max(0, Math.min(cancelProgress.value, 1)); - return { opacity: 0.5 + progress * 0.5 }; - }); - return ( - - Slide up to cancel - - ); -} diff --git a/js/mobile/src/features/compose/ComposeDock.tsx b/js/mobile/src/features/compose/ComposeDock.tsx index fbc13dc..4b2f80a 100644 --- a/js/mobile/src/features/compose/ComposeDock.tsx +++ b/js/mobile/src/features/compose/ComposeDock.tsx @@ -1,28 +1,15 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { Pressable, Text, View } from "react-native"; import { Mic, Type as TypeIcon, Video as VideoIcon } from "lucide-react-native"; import * as Haptics from "expo-haptics"; -import { setAudioModeAsync } from "expo-audio"; import { useCameraPermissions, useMicrophonePermissions, } from "expo-camera"; -import { - Gesture, - GestureDetector, -} from "react-native-gesture-handler"; -import Animated, { - Easing, - runOnJS, - useAnimatedStyle, - useSharedValue, - withTiming, -} from "react-native-reanimated"; import { toast } from "sonner-native"; import { cn } from "@/lib/utils"; import { useEvent } from "@/hooks/use-event"; -import { logError } from "@/lib/errors"; -import { useSuspendPlayback } from "@/hooks/use-suspend-playback"; +import { usePlaybackPauseStore } from "@/stores/playback-pause-store"; import { useAuthStore } from "@/stores/auth-store"; import { createTextParticle, @@ -39,11 +26,9 @@ import { AudioRecordingOverlay } from "./AudioRecordingOverlay"; import { ReviewSheet } from "./ReviewSheet"; type RecordingMode = "video" | "audio"; -type PendingAction = "commit" | "cancel" | null; type ComposeUiState = | { kind: "idle" } - | { kind: "warming-up"; mode: RecordingMode } | { kind: "recording"; mode: RecordingMode } | { kind: "review"; @@ -53,9 +38,6 @@ type ComposeUiState = } | { kind: "uploading" }; -const CANCEL_THRESHOLD_PX = 120; -const SHORT_RELEASE_MS = 350; - interface SubmitMediaParams { fileUri: string; mimeType: string; @@ -65,30 +47,12 @@ interface SubmitMediaParams { interface ComposeDockProps { networkId: string; - /** Where new particles are created. Defaults to the current stream. */ targetPath: ParticlePath; - /** - * When true, the dock won't broadcast composing state — used by the - * new-stream screen where there's no presence channel to broadcast to. - */ silentPresence?: boolean; - /** - * Optional: replace the default media upload logic. NewStreamScreen uses - * this to create the stream + first particle in one shot. The dock still - * owns the review-sheet / haptic / state-machine flow; only the final - * persistence step is swapped. - */ submitMedia?: (params: SubmitMediaParams) => Promise; - /** Optional: replace the default text-particle creation. */ submitText?: (content: string) => Promise; } -/** - * Bottom dock + recording orchestrator. The hold-to-record gesture lives on - * the FAB and drives every state transition. `cancelProgress` is a worklet - * shared value the overlays read for live cancel-pill animations. Playback - * is suspended via the pause-store while the dock is anything but idle. - */ export function ComposeDock({ networkId, targetPath, @@ -101,28 +65,21 @@ export function ComposeDock({ const [mode, setMode] = useState("video"); const [ui, setUi] = useState({ kind: "idle" }); const [textOpen, setTextOpen] = useState(false); - // Both overlays read this prop to know how to finalize. It's set on release - // and cleared as the overlay unmounts. - const [pendingAction, setPendingAction] = useState(null); const [camPerm, requestCamPerm] = useCameraPermissions(); const [micPerm, requestMicPerm] = useMicrophonePermissions(); - // Suspend underlying playback whenever the dock isn't idle. Mirrors how - // desktop's compose-overlay flips the playback-pause-store. + // Tell StreamView to fully unmount its expo-video player while we record. + // That player otherwise holds the iOS AVAudioSession and crashes the camera. + const setComposing = usePlaybackPauseStore((s) => s.setComposing); const isComposing = ui.kind !== "idle" || textOpen; - useSuspendPlayback(isComposing, "compose"); + useEffect(() => { + setComposing(isComposing); + return () => setComposing(false); + }, [isComposing, setComposing]); - // Broadcast composing state over Pusher so other viewers see the dot. - // When silentPresence is set we skip — the dock renders outside a stream - // (e.g. NewStreamScreen) where no presence channel exists yet. useComposingBroadcast({ ui, textOpen, silent: silentPresence }); - // Worklet-side flag the overlays read for live cancel UI. - const cancelProgress = useSharedValue(0); - // Press start time, kept on a ref so worklets can read it via runOnJS. - const pressStartedAtRef = useRef(null); - const ensurePermissions = useCallback( async (forVideo: boolean): Promise => { if (forVideo) { @@ -142,49 +99,14 @@ export function ComposeDock({ [camPerm, micPerm, requestCamPerm, requestMicPerm], ); - // ----- Lifecycle handlers ----- - - const beginRecording = useEvent(async () => { + const startRecording = useEvent(async () => { if (ui.kind !== "idle") return; - pressStartedAtRef.current = Date.now(); const ok = await ensurePermissions(mode === "video"); - if (!ok) { - pressStartedAtRef.current = null; - return; - } - // iOS requires the audio session to allow recording before either the - // audio recorder or the camera's audio track will work. - try { - await setAudioModeAsync({ - allowsRecording: true, - playsInSilentMode: true, - }); - } catch (err) { - logError(err, { scope: "compose.audio.setMode.record" }); - } + if (!ok) return; void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); - setUi({ kind: "warming-up", mode }); + setUi({ kind: "recording", mode }); }); - // Restore the audio session to playback-only when we leave recording, - // otherwise iOS keeps routing playback through the earpiece. - const restoreAudioMode = useCallback(() => { - setAudioModeAsync({ - allowsRecording: false, - playsInSilentMode: true, - }).catch((err) => - logError(err, { scope: "compose.audio.setMode.restore" }), - ); - }, []); - - const handleRecordingStarted = useCallback(() => { - setUi((prev) => - prev.kind === "warming-up" - ? { kind: "recording", mode: prev.mode } - : prev, - ); - }, []); - const handleRecordingComplete = useCallback( ({ uri, durationMs }: { uri: string; durationMs: number }) => { void Haptics.selectionAsync(); @@ -192,70 +114,13 @@ export function ComposeDock({ const m = "mode" in prev ? prev.mode : mode; return { kind: "review", mode: m, uri, durationMs }; }); - setPendingAction(null); - pressStartedAtRef.current = null; - restoreAudioMode(); }, - [mode, restoreAudioMode], + [mode], ); const handleRecordingCancel = useCallback(() => { setUi({ kind: "idle" }); - setPendingAction(null); - pressStartedAtRef.current = null; - cancelProgress.value = 0; - restoreAudioMode(); - }, [cancelProgress, restoreAudioMode]); - - const finalizeFromGesture = useEvent(({ cancelled }: { cancelled: boolean }) => { - const heldMs = pressStartedAtRef.current - ? Date.now() - pressStartedAtRef.current - : 0; - - // Recording never warmed up to "recording" — drop back to idle without - // shipping anything (permission denied, or mount race). - if (ui.kind === "warming-up") { - setUi({ kind: "idle" }); - pressStartedAtRef.current = null; - cancelProgress.value = 0; - restoreAudioMode(); - return; - } - if (ui.kind !== "recording") return; - - const wantCancel = cancelled || heldMs < SHORT_RELEASE_MS; - if (wantCancel) { - void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - } - setPendingAction(wantCancel ? "cancel" : "commit"); - cancelProgress.value = 0; - }); - - // ----- Gesture: hold-to-record on FAB ----- - - const pan = Gesture.Pan() - .minDistance(0) - .activateAfterLongPress(120) - .onStart(() => { - "worklet"; - runOnJS(beginRecording)(); - }) - .onUpdate((e) => { - "worklet"; - const upPx = Math.max(0, -e.translationY); - cancelProgress.value = Math.min(upPx / CANCEL_THRESHOLD_PX, 1); - }) - .onEnd((e) => { - "worklet"; - const cancelled = e.translationY < -CANCEL_THRESHOLD_PX; - runOnJS(finalizeFromGesture)({ cancelled }); - }) - .onFinalize(() => { - "worklet"; - cancelProgress.value = 0; - }); - - // ----- Submit handlers ----- + }, []); const sendReview = useEvent(async () => { if (ui.kind !== "review" || !userId) return; @@ -286,8 +151,6 @@ export function ComposeDock({ setUi({ kind: "idle" }); } catch (err) { void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); - // Restore the review sheet so the user can retry without losing their - // take. The sheet's own toast already surfaced the error message. setUi(captured); throw err; } @@ -311,35 +174,10 @@ export function ComposeDock({ void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); }); - // ----- FAB animations ----- - - const fabScale = useSharedValue(1); - useEffect(() => { - const target = ui.kind === "recording" ? 1.18 : 1; - fabScale.value = withTiming(target, { - duration: 220, - easing: Easing.out(Easing.cubic), - }); - }, [ui.kind, fabScale]); - - const fabStyle = useAnimatedStyle(() => ({ - transform: [{ scale: fabScale.value }], - })); - - const ringStyle = useAnimatedStyle(() => { - const opacity = ui.kind === "recording" ? 1 : 0; - const armed = cancelProgress.value > 0.5; - return { - opacity, - borderColor: armed - ? "rgba(239, 68, 68, 0.95)" - : "rgba(255,255,255,0.95)", - }; - }); - - // ----- Render ----- - - const dockHidden = ui.kind === "review" || ui.kind === "uploading"; + const dockHidden = + ui.kind === "review" || + ui.kind === "uploading" || + ui.kind === "recording"; return ( <> @@ -370,38 +208,16 @@ export function ComposeDock({ - - - - - - + + + - {ui.kind === "recording" - ? "Release to send · slide up to cancel" - : "Hold to record"} + Tap to record @@ -420,22 +236,16 @@ export function ComposeDock({ ) : null} - {ui.kind === "warming-up" || ui.kind === "recording" ? ( + {ui.kind === "recording" ? ( ui.mode === "video" ? ( ) : ( ) ) : null} @@ -459,12 +269,6 @@ export function ComposeDock({ ); } -/** - * Reads the dock UI state and tells the StreamPresenceContext when to start - * or stop broadcasting composing_start/composing_stop. The hook intentionally - * uses optional context access so the dock can also be hosted on the - * new-stream screen, where there's no provider above us. - */ function useComposingBroadcast({ ui, textOpen, @@ -474,9 +278,6 @@ function useComposingBroadcast({ textOpen: boolean; silent: boolean; }) { - // Optional consumer — `useStreamComposingBroadcast` throws when used - // outside its provider, so we trap that here. Cheap because the throw - // happens once per mount. let broadcast: ReturnType | null; try { broadcast = useStreamComposingBroadcast(); @@ -485,11 +286,7 @@ function useComposingBroadcast({ } const mode: ComposingMode | null = - ui.kind === "recording" || ui.kind === "warming-up" - ? "recording" - : textOpen - ? "typing" - : null; + ui.kind === "recording" ? "recording" : textOpen ? "typing" : null; useEffect(() => { if (silent || !broadcast) return; diff --git a/js/mobile/src/features/compose/ReviewSheet.tsx b/js/mobile/src/features/compose/ReviewSheet.tsx index e61ba54..e54c2f3 100644 --- a/js/mobile/src/features/compose/ReviewSheet.tsx +++ b/js/mobile/src/features/compose/ReviewSheet.tsx @@ -35,6 +35,7 @@ export function ReviewSheet({ const player = useVideoPlayer(uri ?? "", (p) => { p.loop = true; p.muted = false; + p.audioMixingMode = "mixWithOthers"; }); const [submitting, setSubmitting] = useState(false); diff --git a/js/mobile/src/features/compose/VideoRecordingOverlay.tsx b/js/mobile/src/features/compose/VideoRecordingOverlay.tsx index 697074c..459e8a4 100644 --- a/js/mobile/src/features/compose/VideoRecordingOverlay.tsx +++ b/js/mobile/src/features/compose/VideoRecordingOverlay.tsx @@ -1,219 +1,81 @@ import { useEffect, useRef, useState } from "react"; -import { Dimensions, StyleSheet, Text, View } from "react-native"; -import { CameraView, type CameraView as CameraViewRef } from "expo-camera"; -import Animated, { - Easing, - useAnimatedStyle, - useSharedValue, - withTiming, -} from "react-native-reanimated"; -import { useEvent } from "@/hooks/use-event"; +import { Pressable, StyleSheet, Text, View } from "react-native"; +import { CameraView } from "expo-camera"; import { logError } from "@/lib/errors"; const MAX_DURATION_S = 60; -const FAB_BOTTOM_INSET = 96; - -// iOS race: `onCameraReady` fires before the underlying AVCaptureSession is -// fully configured to write a movie file, so the first `recordAsync` call -// can throw "Camera is not ready yet" even though we waited for the -// callback. Empirically a small settle delay + one retry covers it. -const READY_SETTLE_MS = 250; -const RETRY_DELAY_MS = 400; interface VideoRecordingOverlayProps { - /** 0..1 — drives the cancel-pill animation as the user drags up. */ - cancelProgress: { value: number }; onComplete: (result: { uri: string; durationMs: number }) => void; onCancel: () => void; - /** Fires once recordAsync has actually started writing the file. */ - onRecordingStarted: () => void; - /** - * Set by the parent on release: "commit" stops + ships, "cancel" stops + - * discards. Mirrors the audio overlay's contract so both lifecycles look - * identical to the dock. - */ - pendingAction: "commit" | "cancel" | null; } -/** - * Full-screen camera preview that auto-starts a recording on mount and stops - * when the parent supplies `pendingAction`. The overlay does NOT own the - * touch gesture — the ComposeDock holds the Pan that started this recording. - * We just visualize cancel progress. - */ export function VideoRecordingOverlay({ - cancelProgress, onComplete, onCancel, - onRecordingStarted, - pendingAction, }: VideoRecordingOverlayProps) { - const cameraRef = useRef(null); - const [elapsedMs, setElapsedMs] = useState(0); + const cameraRef = useRef(null); const [cameraReady, setCameraReady] = useState(false); - const [warming, setWarming] = useState(true); - - // Lifecycle refs — refs over state so async closures see the latest value - // without re-running effects. + const [recording, setRecording] = useState(false); + const [elapsedMs, setElapsedMs] = useState(0); const startedAtRef = useRef(null); - const recordingActiveRef = useRef(false); - const completionRef = useRef<"pending" | "completed" | "cancelled">( - "pending", - ); + const cancelledRef = useRef(false); - const onCompleteStable = useEvent(onComplete); - const onCancelStable = useEvent(onCancel); - const onStartedStable = useEvent(onRecordingStarted); - - const handleCameraReady = useEvent(() => { - setCameraReady(true); - }); - - // ---- Recording lifecycle ---- - // The flow is intentionally a single effect that runs once `cameraReady` - // flips: settle, attempt, on race-error retry once. Any path back to - // failure routes through onCancelStable so the dock returns to idle. - useEffect(() => { - if (!cameraReady) return; - let cancelled = false; - - const tryStart = async () => { - // Settle delay — the AVCaptureSession needs a beat after onCameraReady. - await wait(READY_SETTLE_MS); - if (cancelled || completionRef.current !== "pending") return; - - const cam = cameraRef.current; - if (!cam) { - onCancelStable(); - return; - } - - const attempt = async (): Promise<{ uri: string } | null> => { - try { - // recordAsync resolves only when stopRecording is called (or - // maxDuration hits). We start a 100ms watchdog so the parent - // doesn't see "recording" until we know recordAsync didn't - // synchronously reject. - startedAtRef.current = Date.now(); - recordingActiveRef.current = true; - const recordPromise = cam.recordAsync({ - maxDuration: MAX_DURATION_S, - }); - - const startedTimer = setTimeout(() => { - if (completionRef.current === "pending") { - setWarming(false); - onStartedStable(); - } - }, 100); - - const result = await recordPromise; - clearTimeout(startedTimer); - return result ?? null; - } catch (err) { - recordingActiveRef.current = false; - throw err; - } - }; - - try { - const result = await attempt(); - if (cancelled) return; - finalizeWithResult(result); - } catch (err) { - const msg = (err as Error)?.message ?? ""; - const isReadyRace = - msg.includes("not ready") || - msg.includes("Camera is not ready"); - - if (!isReadyRace) { - logError(err, { scope: "compose.video.recordAsync" }); - if (!cancelled) onCancelStable(); - return; - } - - // One retry. The native session usually settles within ~400ms after - // the first throw; longer than that and it's not the ready-race. - await wait(RETRY_DELAY_MS); - if (cancelled || completionRef.current !== "pending") return; - try { - const result = await attempt(); - if (cancelled) return; - finalizeWithResult(result); - } catch (err2) { - logError(err2, { - scope: "compose.video.recordAsync.retry", - }); - if (!cancelled) onCancelStable(); - } - } - }; - - const finalizeWithResult = (result: { uri: string } | null) => { - recordingActiveRef.current = false; - if (completionRef.current === "cancelled") return; - completionRef.current = "completed"; - const durationMs = - startedAtRef.current !== null - ? Date.now() - startedAtRef.current - : 0; - if (result?.uri) { - onCompleteStable({ uri: result.uri, durationMs }); - } else { - onCancelStable(); - } - }; - - void tryStart(); - - return () => { - cancelled = true; - }; - }, [cameraReady, onCancelStable, onCompleteStable, onStartedStable]); - - // ---- Parent-issued action: commit / cancel ---- - useEffect(() => { - if (!pendingAction) return; - - if (pendingAction === "cancel") { - completionRef.current = "cancelled"; - } - - // If recording hasn't actually started yet (still warming or retrying), - // there's nothing to stop on the camera; route directly to cancel so - // the parent isn't left waiting for a recordAsync that may take - // another retry cycle to ever start. - if (!recordingActiveRef.current) { - onCancelStable(); - return; - } - - cameraRef.current?.stopRecording(); - }, [pendingAction, onCancelStable]); - - // Safety: if the overlay unmounts mid-recording, cancel the file. useEffect(() => { return () => { - if (completionRef.current === "pending") { - completionRef.current = "cancelled"; - if (recordingActiveRef.current) { - cameraRef.current?.stopRecording(); - } - } + cancelledRef.current = true; }; }, []); - // Elapsed counter: 100ms tick. + const startRecording = async () => { + const cam = cameraRef.current; + if (!cam || recording || !cameraReady) return; + + setRecording(true); + startedAtRef.current = Date.now(); + + let result: { uri: string } | undefined; + try { + result = await cam.recordAsync({ maxDuration: MAX_DURATION_S }); + } catch (err) { + if (cancelledRef.current) return; + logError(err, { scope: "compose.video.recordAsync" }); + onCancel(); + return; + } + if (cancelledRef.current) return; + + const durationMs = + startedAtRef.current !== null ? Date.now() - startedAtRef.current : 0; + if (result?.uri) { + onComplete({ uri: result.uri, durationMs }); + } else { + onCancel(); + } + }; + + const stopRecording = () => { + cameraRef.current?.stopRecording(); + }; + + const cancel = () => { + cancelledRef.current = true; + if (recording) { + cameraRef.current?.stopRecording(); + } + onCancel(); + }; + useEffect(() => { + if (!recording) return; const interval = setInterval(() => { if (startedAtRef.current === null) return; setElapsedMs(Date.now() - startedAtRef.current); - }, 100); + }, 250); return () => clearInterval(interval); - }, []); + }, [recording]); const elapsedSec = Math.floor(elapsedMs / 1000); - const elapsedRatio = Math.min(elapsedMs / (MAX_DURATION_S * 1000), 1); return ( @@ -222,130 +84,55 @@ export function VideoRecordingOverlay({ style={StyleSheet.absoluteFill} facing="front" mode="video" - videoQuality="1080p" - onCameraReady={handleCameraReady} mute={false} + onCameraReady={() => setCameraReady(true)} /> - - - ); -} - -function wait(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -function RecChrome({ - warming, - elapsedSec, - elapsedRatio, - cancelProgress, -}: { - warming: boolean; - elapsedSec: number; - elapsedRatio: number; - cancelProgress: { value: number }; -}) { - const screenHeight = Dimensions.get("window").height; - const fabCenterY = screenHeight - FAB_BOTTOM_INSET - 36; - - const cancelPillStyle = useAnimatedStyle(() => { - const progress = Math.max(0, Math.min(cancelProgress.value, 1)); - return { - opacity: progress, - transform: [{ translateY: -40 * progress }], - }; - }); - - return ( - <> - {/* Top REC / warming indicator. */} - - {warming ? ( - - - - Getting ready… - - - ) : ( + {recording ? ( + - + REC · {elapsedSec.toString().padStart(2, "0")}s - )} - - - {/* Cancel pill — slides in as user drags up. */} - - - - Slide up to cancel - - - - - {/* Tiny duration counter near the FAB. */} - {!warming ? ( - - - {Math.round(elapsedRatio * 60)}s / {MAX_DURATION_S}s - ) : null} - - ); -} -function PulsingDot({ color = "white" }: { color?: string }) { - const opacity = useSharedValue(1); - useEffect(() => { - const loop = () => { - opacity.value = withTiming( - 0.3, - { duration: 600, easing: Easing.inOut(Easing.quad) }, - () => { - opacity.value = withTiming( - 1, - { duration: 600, easing: Easing.inOut(Easing.quad) }, - () => loop(), - ); - }, - ); - }; - loop(); - }, [opacity]); - const style = useAnimatedStyle(() => ({ opacity: opacity.value })); - return ( - + + + Cancel + + {recording ? ( + + Stop + + ) : ( + + + + )} + + ); } diff --git a/js/mobile/src/features/stream-view/MediaParticleView.tsx b/js/mobile/src/features/stream-view/MediaParticleView.tsx index 9de0270..5e2b0fb 100644 --- a/js/mobile/src/features/stream-view/MediaParticleView.tsx +++ b/js/mobile/src/features/stream-view/MediaParticleView.tsx @@ -111,6 +111,11 @@ function PlayableMediaView({ p.loop = false; p.muted = false; p.timeUpdateEventInterval = 0.15; + // Don't take exclusive ownership of the iOS AVAudioSession. Without this + // the player blocks expo-camera from acquiring the session for video + // recording (audio works because expo-audio deactivates other sessions + // natively before claiming the session). + p.audioMixingMode = "mixWithOthers"; }); // Drive play/pause from the suspender store. The player itself is forgiving diff --git a/js/mobile/src/features/stream-view/StreamView.tsx b/js/mobile/src/features/stream-view/StreamView.tsx index 61026ac..1a644dc 100644 --- a/js/mobile/src/features/stream-view/StreamView.tsx +++ b/js/mobile/src/features/stream-view/StreamView.tsx @@ -28,6 +28,7 @@ import { useNetwork } from "@/hooks/use-networks"; import { useStreamPlayback } from "@/hooks/use-stream-playback"; import { useSuspendPlayback } from "@/hooks/use-suspend-playback"; import { + selectIsComposing, selectIsPaused, usePlaybackPauseStore, } from "@/stores/playback-pause-store"; @@ -90,6 +91,7 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) { useStreamPlayback(streamParticle, path); const paused = usePlaybackPauseStore(selectIsPaused); + const composing = usePlaybackPauseStore(selectIsComposing); const [progress, setProgress] = useState(0); const userId = useAuthStore((s) => s.user?.id) ?? ""; @@ -344,7 +346,13 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) { chrome occupies so scrollable content doesn't slip under. */} - {currentParticle ? renderParticle(currentParticle) : null} + {/* While composing we fully unmount the particle so the + underlying expo-video player releases the AVAudioSession. + Otherwise it contends with expo-camera and crashes the + app when video recording starts. */} + {currentParticle && !composing + ? renderParticle(currentParticle) + : null} diff --git a/js/mobile/src/stores/playback-pause-store.ts b/js/mobile/src/stores/playback-pause-store.ts index cd5ae56..8885adf 100644 --- a/js/mobile/src/stores/playback-pause-store.ts +++ b/js/mobile/src/stores/playback-pause-store.ts @@ -7,12 +7,15 @@ import { create } from "zustand"; */ interface PlaybackPauseState { activeIds: Record; + composing: boolean; add: (id: string, label: string) => void; remove: (id: string) => void; + setComposing: (composing: boolean) => void; } export const usePlaybackPauseStore = create((set) => ({ activeIds: {}, + composing: false, add: (id, label) => set((s) => ({ activeIds: { ...s.activeIds, [id]: label } })), remove: (id) => @@ -21,7 +24,10 @@ export const usePlaybackPauseStore = create((set) => ({ const { [id]: _, ...rest } = s.activeIds; return { activeIds: rest }; }), + setComposing: (composing) => set({ composing }), })); export const selectIsPaused = (s: PlaybackPauseState) => - Object.keys(s.activeIds).length > 0; + Object.keys(s.activeIds).length > 0 || s.composing; + +export const selectIsComposing = (s: PlaybackPauseState) => s.composing; diff --git a/js/mobile/yarn.lock b/js/mobile/yarn.lock index fdf793a..2945124 100644 --- a/js/mobile/yarn.lock +++ b/js/mobile/yarn.lock @@ -2998,7 +2998,7 @@ expo-build-properties@~1.0.10: ajv "^8.11.0" semver "^7.6.0" -expo-camera@~17.0.8: +expo-camera@~17.0.10: version "17.0.10" resolved "https://registry.yarnpkg.com/expo-camera/-/expo-camera-17.0.10.tgz#b3a217f0eb811a6e3522c2aff9f42be578aa6456" integrity sha512-w1RBw83mAGVk4BPPwNrCZyFop0VLiVSRE3c2V9onWbdFwonpRhzmB4drygG8YOUTl1H3wQvALJHyMPTbgsK1Jg==