fix: broken record
This commit is contained in:
@@ -40,6 +40,7 @@ const config: ExpoConfig = {
|
|||||||
"Flowy uses your camera to record video messages.",
|
"Flowy uses your camera to record video messages.",
|
||||||
microphonePermission:
|
microphonePermission:
|
||||||
"Flowy uses your microphone to record voice and video messages.",
|
"Flowy uses your microphone to record voice and video messages.",
|
||||||
|
recordAudioAndroid: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -19,13 +19,13 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"expo": "~54.0.0",
|
"expo": "~54.0.0",
|
||||||
"expo-audio": "~1.0.13",
|
"expo-audio": "~1.0.13",
|
||||||
"expo-camera": "~17.0.8",
|
"expo-camera": "~17.0.10",
|
||||||
"expo-constants": "~18.0.13",
|
"expo-constants": "~18.0.13",
|
||||||
"expo-file-system": "~19.0.16",
|
"expo-file-system": "~19.0.16",
|
||||||
"expo-haptics": "~15.0.7",
|
"expo-haptics": "~15.0.7",
|
||||||
"expo-secure-store": "~15.0.8",
|
"expo-secure-store": "~15.0.8",
|
||||||
"expo-video": "~3.0.10",
|
|
||||||
"expo-status-bar": "~3.0.9",
|
"expo-status-bar": "~3.0.9",
|
||||||
|
"expo-video": "~3.0.10",
|
||||||
"firebase": "^12.10.0",
|
"firebase": "^12.10.0",
|
||||||
"lucide-react-native": "^0.575.0",
|
"lucide-react-native": "^0.575.0",
|
||||||
"nativewind": "^4.1.23",
|
"nativewind": "^4.1.23",
|
||||||
|
|||||||
@@ -1,193 +1,125 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { StyleSheet, Text, View } from "react-native";
|
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||||
import { Mic } from "lucide-react-native";
|
import { Mic } from "lucide-react-native";
|
||||||
import {
|
import {
|
||||||
RecordingPresets,
|
RecordingPresets,
|
||||||
|
setAudioModeAsync,
|
||||||
useAudioRecorder,
|
useAudioRecorder,
|
||||||
useAudioRecorderState,
|
useAudioRecorderState,
|
||||||
} from "expo-audio";
|
} 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";
|
import { logError } from "@/lib/errors";
|
||||||
|
|
||||||
const MAX_DURATION_S = 60;
|
const MAX_DURATION_S = 60;
|
||||||
|
|
||||||
interface AudioRecordingOverlayProps {
|
interface AudioRecordingOverlayProps {
|
||||||
cancelProgress: { value: number };
|
|
||||||
onComplete: (result: { uri: string; durationMs: number }) => void;
|
onComplete: (result: { uri: string; durationMs: number }) => void;
|
||||||
onCancel: () => 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({
|
export function AudioRecordingOverlay({
|
||||||
cancelProgress,
|
|
||||||
onComplete,
|
onComplete,
|
||||||
onCancel,
|
onCancel,
|
||||||
onRecordingStarted,
|
|
||||||
pendingAction,
|
|
||||||
}: AudioRecordingOverlayProps) {
|
}: AudioRecordingOverlayProps) {
|
||||||
const recorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY);
|
const recorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY);
|
||||||
const recorderState = useAudioRecorderState(recorder, 100);
|
const state = useAudioRecorderState(recorder, 250);
|
||||||
const [elapsedMs, setElapsedMs] = useState(0);
|
const finalizedRef = useRef(false);
|
||||||
const startedAtRef = useRef<number | null>(null);
|
|
||||||
const finalizedRef = useRef<"pending" | "completed" | "cancelled">(
|
|
||||||
"pending",
|
|
||||||
);
|
|
||||||
|
|
||||||
const onCompleteStable = useEvent(onComplete);
|
|
||||||
const onCancelStable = useEvent(onCancel);
|
|
||||||
const onStartedStable = useEvent(onRecordingStarted);
|
|
||||||
|
|
||||||
// Spin up the recorder.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let active = true;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
|
await setAudioModeAsync({
|
||||||
|
allowsRecording: true,
|
||||||
|
playsInSilentMode: true,
|
||||||
|
});
|
||||||
await recorder.prepareToRecordAsync();
|
await recorder.prepareToRecordAsync();
|
||||||
if (cancelled) return;
|
if (!active) return;
|
||||||
recorder.record();
|
recorder.record();
|
||||||
startedAtRef.current = Date.now();
|
|
||||||
onStartedStable();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logError(err, { scope: "compose.audio.prepare" });
|
logError(err, { scope: "compose.audio.start" });
|
||||||
if (!cancelled) onCancelStable();
|
if (active) onCancel();
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
active = false;
|
||||||
};
|
if (!finalizedRef.current) {
|
||||||
// recorder identity is stable for the component's lifetime
|
finalizedRef.current = true;
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
recorder.stop().catch(() => {});
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 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");
|
|
||||||
}
|
}
|
||||||
}, 100);
|
void setAudioModeAsync({
|
||||||
return () => clearInterval(interval);
|
allowsRecording: false,
|
||||||
|
playsInSilentMode: true,
|
||||||
|
}).catch((err) => logError(err, { scope: "compose.audio.exit" }));
|
||||||
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const finalize = useEvent(async (kind: "commit" | "cancel") => {
|
const elapsedMs = state.durationMillis ?? 0;
|
||||||
if (finalizedRef.current !== "pending") return;
|
|
||||||
finalizedRef.current = kind === "commit" ? "completed" : "cancelled";
|
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 {
|
try {
|
||||||
await recorder.stop();
|
await recorder.stop();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logError(err, { scope: "compose.audio.stop" });
|
logError(err, { scope: "compose.audio.stop" });
|
||||||
}
|
}
|
||||||
if (kind === "cancel") {
|
if (kind === "cancel") {
|
||||||
onCancelStable();
|
onCancel();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const uri = recorder.uri;
|
const uri = recorder.uri;
|
||||||
if (!uri) {
|
if (!uri) {
|
||||||
onCancelStable();
|
onCancel();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const durationMs =
|
onComplete({ uri, 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
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const elapsedSec = Math.floor(elapsedMs / 1000);
|
const elapsedSec = Math.floor(elapsedMs / 1000);
|
||||||
const isRecording = recorderState.isRecording;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={StyleSheet.absoluteFill}
|
style={StyleSheet.absoluteFill}
|
||||||
className="bg-black items-center justify-center px-8"
|
className="bg-black items-center justify-center px-8"
|
||||||
>
|
>
|
||||||
<PulsingMic active={isRecording} />
|
<View className="bg-red-500/30 h-32 w-32 items-center justify-center rounded-full">
|
||||||
|
<View className="bg-red-500/60 h-24 w-24 items-center justify-center rounded-full">
|
||||||
|
<Mic color="white" size={42} strokeWidth={1.5} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
<Text className="text-white mt-6 text-lg font-semibold">
|
<Text className="text-white mt-6 text-lg font-semibold">
|
||||||
Voice message
|
{state.isRecording ? "Recording" : "Starting…"}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-white/60 mt-1 text-sm">
|
<Text className="text-white/60 mt-1 text-sm">
|
||||||
{elapsedSec.toString().padStart(2, "0")}s · max {MAX_DURATION_S}s
|
{elapsedSec.toString().padStart(2, "0")}s · max {MAX_DURATION_S}s
|
||||||
</Text>
|
</Text>
|
||||||
<CancelHint cancelProgress={cancelProgress} />
|
|
||||||
|
<View className="absolute inset-x-0 bottom-12 flex-row items-center justify-around px-8">
|
||||||
|
<Pressable
|
||||||
|
onPress={() => void finish("cancel")}
|
||||||
|
accessibilityLabel="Cancel recording"
|
||||||
|
className="rounded-full bg-white/15 px-6 py-3"
|
||||||
|
>
|
||||||
|
<Text className="text-white text-base font-medium">Cancel</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => void finish("commit")}
|
||||||
|
accessibilityLabel="Stop recording"
|
||||||
|
className="rounded-full bg-white px-7 py-3"
|
||||||
|
>
|
||||||
|
<Text className="text-black text-base font-semibold">Stop</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 (
|
|
||||||
<Animated.View
|
|
||||||
style={style}
|
|
||||||
className="bg-red-500/30 h-32 w-32 items-center justify-center rounded-full"
|
|
||||||
>
|
|
||||||
<View className="bg-red-500/60 h-24 w-24 items-center justify-center rounded-full">
|
|
||||||
<Mic color="white" size={42} strokeWidth={1.5} />
|
|
||||||
</View>
|
|
||||||
</Animated.View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<Animated.View style={style} className="absolute bottom-32">
|
|
||||||
<Text className="text-white/70 text-sm">Slide up to cancel</Text>
|
|
||||||
</Animated.View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 { Pressable, Text, View } from "react-native";
|
||||||
import { Mic, Type as TypeIcon, Video as VideoIcon } from "lucide-react-native";
|
import { Mic, Type as TypeIcon, Video as VideoIcon } from "lucide-react-native";
|
||||||
import * as Haptics from "expo-haptics";
|
import * as Haptics from "expo-haptics";
|
||||||
import { setAudioModeAsync } from "expo-audio";
|
|
||||||
import {
|
import {
|
||||||
useCameraPermissions,
|
useCameraPermissions,
|
||||||
useMicrophonePermissions,
|
useMicrophonePermissions,
|
||||||
} from "expo-camera";
|
} 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 { toast } from "sonner-native";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useEvent } from "@/hooks/use-event";
|
import { useEvent } from "@/hooks/use-event";
|
||||||
import { logError } from "@/lib/errors";
|
import { usePlaybackPauseStore } from "@/stores/playback-pause-store";
|
||||||
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
|
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import {
|
import {
|
||||||
createTextParticle,
|
createTextParticle,
|
||||||
@@ -39,11 +26,9 @@ import { AudioRecordingOverlay } from "./AudioRecordingOverlay";
|
|||||||
import { ReviewSheet } from "./ReviewSheet";
|
import { ReviewSheet } from "./ReviewSheet";
|
||||||
|
|
||||||
type RecordingMode = "video" | "audio";
|
type RecordingMode = "video" | "audio";
|
||||||
type PendingAction = "commit" | "cancel" | null;
|
|
||||||
|
|
||||||
type ComposeUiState =
|
type ComposeUiState =
|
||||||
| { kind: "idle" }
|
| { kind: "idle" }
|
||||||
| { kind: "warming-up"; mode: RecordingMode }
|
|
||||||
| { kind: "recording"; mode: RecordingMode }
|
| { kind: "recording"; mode: RecordingMode }
|
||||||
| {
|
| {
|
||||||
kind: "review";
|
kind: "review";
|
||||||
@@ -53,9 +38,6 @@ type ComposeUiState =
|
|||||||
}
|
}
|
||||||
| { kind: "uploading" };
|
| { kind: "uploading" };
|
||||||
|
|
||||||
const CANCEL_THRESHOLD_PX = 120;
|
|
||||||
const SHORT_RELEASE_MS = 350;
|
|
||||||
|
|
||||||
interface SubmitMediaParams {
|
interface SubmitMediaParams {
|
||||||
fileUri: string;
|
fileUri: string;
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
@@ -65,30 +47,12 @@ interface SubmitMediaParams {
|
|||||||
|
|
||||||
interface ComposeDockProps {
|
interface ComposeDockProps {
|
||||||
networkId: string;
|
networkId: string;
|
||||||
/** Where new particles are created. Defaults to the current stream. */
|
|
||||||
targetPath: ParticlePath;
|
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;
|
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<void>;
|
submitMedia?: (params: SubmitMediaParams) => Promise<void>;
|
||||||
/** Optional: replace the default text-particle creation. */
|
|
||||||
submitText?: (content: string) => Promise<void>;
|
submitText?: (content: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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({
|
export function ComposeDock({
|
||||||
networkId,
|
networkId,
|
||||||
targetPath,
|
targetPath,
|
||||||
@@ -101,28 +65,21 @@ export function ComposeDock({
|
|||||||
const [mode, setMode] = useState<RecordingMode>("video");
|
const [mode, setMode] = useState<RecordingMode>("video");
|
||||||
const [ui, setUi] = useState<ComposeUiState>({ kind: "idle" });
|
const [ui, setUi] = useState<ComposeUiState>({ kind: "idle" });
|
||||||
const [textOpen, setTextOpen] = useState(false);
|
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<PendingAction>(null);
|
|
||||||
|
|
||||||
const [camPerm, requestCamPerm] = useCameraPermissions();
|
const [camPerm, requestCamPerm] = useCameraPermissions();
|
||||||
const [micPerm, requestMicPerm] = useMicrophonePermissions();
|
const [micPerm, requestMicPerm] = useMicrophonePermissions();
|
||||||
|
|
||||||
// Suspend underlying playback whenever the dock isn't idle. Mirrors how
|
// Tell StreamView to fully unmount its expo-video player while we record.
|
||||||
// desktop's compose-overlay flips the playback-pause-store.
|
// That player otherwise holds the iOS AVAudioSession and crashes the camera.
|
||||||
|
const setComposing = usePlaybackPauseStore((s) => s.setComposing);
|
||||||
const isComposing = ui.kind !== "idle" || textOpen;
|
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 });
|
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<number | null>(null);
|
|
||||||
|
|
||||||
const ensurePermissions = useCallback(
|
const ensurePermissions = useCallback(
|
||||||
async (forVideo: boolean): Promise<boolean> => {
|
async (forVideo: boolean): Promise<boolean> => {
|
||||||
if (forVideo) {
|
if (forVideo) {
|
||||||
@@ -142,49 +99,14 @@ export function ComposeDock({
|
|||||||
[camPerm, micPerm, requestCamPerm, requestMicPerm],
|
[camPerm, micPerm, requestCamPerm, requestMicPerm],
|
||||||
);
|
);
|
||||||
|
|
||||||
// ----- Lifecycle handlers -----
|
const startRecording = useEvent(async () => {
|
||||||
|
|
||||||
const beginRecording = useEvent(async () => {
|
|
||||||
if (ui.kind !== "idle") return;
|
if (ui.kind !== "idle") return;
|
||||||
pressStartedAtRef.current = Date.now();
|
|
||||||
const ok = await ensurePermissions(mode === "video");
|
const ok = await ensurePermissions(mode === "video");
|
||||||
if (!ok) {
|
if (!ok) return;
|
||||||
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" });
|
|
||||||
}
|
|
||||||
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
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(
|
const handleRecordingComplete = useCallback(
|
||||||
({ uri, durationMs }: { uri: string; durationMs: number }) => {
|
({ uri, durationMs }: { uri: string; durationMs: number }) => {
|
||||||
void Haptics.selectionAsync();
|
void Haptics.selectionAsync();
|
||||||
@@ -192,70 +114,13 @@ export function ComposeDock({
|
|||||||
const m = "mode" in prev ? prev.mode : mode;
|
const m = "mode" in prev ? prev.mode : mode;
|
||||||
return { kind: "review", mode: m, uri, durationMs };
|
return { kind: "review", mode: m, uri, durationMs };
|
||||||
});
|
});
|
||||||
setPendingAction(null);
|
|
||||||
pressStartedAtRef.current = null;
|
|
||||||
restoreAudioMode();
|
|
||||||
},
|
},
|
||||||
[mode, restoreAudioMode],
|
[mode],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRecordingCancel = useCallback(() => {
|
const handleRecordingCancel = useCallback(() => {
|
||||||
setUi({ kind: "idle" });
|
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 () => {
|
const sendReview = useEvent(async () => {
|
||||||
if (ui.kind !== "review" || !userId) return;
|
if (ui.kind !== "review" || !userId) return;
|
||||||
@@ -286,8 +151,6 @@ export function ComposeDock({
|
|||||||
setUi({ kind: "idle" });
|
setUi({ kind: "idle" });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
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);
|
setUi(captured);
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
@@ -311,35 +174,10 @@ export function ComposeDock({
|
|||||||
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ----- FAB animations -----
|
const dockHidden =
|
||||||
|
ui.kind === "review" ||
|
||||||
const fabScale = useSharedValue(1);
|
ui.kind === "uploading" ||
|
||||||
useEffect(() => {
|
ui.kind === "recording";
|
||||||
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";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -370,38 +208,16 @@ export function ComposeDock({
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
<View className="items-center">
|
<View className="items-center">
|
||||||
<GestureDetector gesture={pan}>
|
<Pressable
|
||||||
<Animated.View
|
onPress={startRecording}
|
||||||
style={fabStyle}
|
disabled={ui.kind !== "idle"}
|
||||||
className="h-20 w-20 items-center justify-center rounded-full bg-white"
|
accessibilityLabel={`Record ${mode}`}
|
||||||
>
|
className="h-20 w-20 items-center justify-center rounded-full bg-white"
|
||||||
<Animated.View
|
>
|
||||||
pointerEvents="none"
|
<View className="h-6 w-6 rounded bg-black" />
|
||||||
style={[
|
</Pressable>
|
||||||
{
|
|
||||||
position: "absolute",
|
|
||||||
top: -6,
|
|
||||||
left: -6,
|
|
||||||
right: -6,
|
|
||||||
bottom: -6,
|
|
||||||
borderRadius: 999,
|
|
||||||
borderWidth: 3,
|
|
||||||
},
|
|
||||||
ringStyle,
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<View
|
|
||||||
className={cn(
|
|
||||||
"h-6 w-6 rounded",
|
|
||||||
ui.kind === "recording" ? "bg-red-500" : "bg-black",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Animated.View>
|
|
||||||
</GestureDetector>
|
|
||||||
<Text className="text-white/60 mt-2 text-xs">
|
<Text className="text-white/60 mt-2 text-xs">
|
||||||
{ui.kind === "recording"
|
Tap to record
|
||||||
? "Release to send · slide up to cancel"
|
|
||||||
: "Hold to record"}
|
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -420,22 +236,16 @@ export function ComposeDock({
|
|||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{ui.kind === "warming-up" || ui.kind === "recording" ? (
|
{ui.kind === "recording" ? (
|
||||||
ui.mode === "video" ? (
|
ui.mode === "video" ? (
|
||||||
<VideoRecordingOverlay
|
<VideoRecordingOverlay
|
||||||
cancelProgress={cancelProgress}
|
|
||||||
onComplete={handleRecordingComplete}
|
onComplete={handleRecordingComplete}
|
||||||
onCancel={handleRecordingCancel}
|
onCancel={handleRecordingCancel}
|
||||||
onRecordingStarted={handleRecordingStarted}
|
|
||||||
pendingAction={pendingAction}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<AudioRecordingOverlay
|
<AudioRecordingOverlay
|
||||||
cancelProgress={cancelProgress}
|
|
||||||
onComplete={handleRecordingComplete}
|
onComplete={handleRecordingComplete}
|
||||||
onCancel={handleRecordingCancel}
|
onCancel={handleRecordingCancel}
|
||||||
onRecordingStarted={handleRecordingStarted}
|
|
||||||
pendingAction={pendingAction}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
) : null}
|
) : 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({
|
function useComposingBroadcast({
|
||||||
ui,
|
ui,
|
||||||
textOpen,
|
textOpen,
|
||||||
@@ -474,9 +278,6 @@ function useComposingBroadcast({
|
|||||||
textOpen: boolean;
|
textOpen: boolean;
|
||||||
silent: 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<typeof useStreamComposingBroadcast> | null;
|
let broadcast: ReturnType<typeof useStreamComposingBroadcast> | null;
|
||||||
try {
|
try {
|
||||||
broadcast = useStreamComposingBroadcast();
|
broadcast = useStreamComposingBroadcast();
|
||||||
@@ -485,11 +286,7 @@ function useComposingBroadcast({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mode: ComposingMode | null =
|
const mode: ComposingMode | null =
|
||||||
ui.kind === "recording" || ui.kind === "warming-up"
|
ui.kind === "recording" ? "recording" : textOpen ? "typing" : null;
|
||||||
? "recording"
|
|
||||||
: textOpen
|
|
||||||
? "typing"
|
|
||||||
: null;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (silent || !broadcast) return;
|
if (silent || !broadcast) return;
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export function ReviewSheet({
|
|||||||
const player = useVideoPlayer(uri ?? "", (p) => {
|
const player = useVideoPlayer(uri ?? "", (p) => {
|
||||||
p.loop = true;
|
p.loop = true;
|
||||||
p.muted = false;
|
p.muted = false;
|
||||||
|
p.audioMixingMode = "mixWithOthers";
|
||||||
});
|
});
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
|||||||
@@ -1,219 +1,81 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Dimensions, StyleSheet, Text, View } from "react-native";
|
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||||
import { CameraView, type CameraView as CameraViewRef } from "expo-camera";
|
import { CameraView } from "expo-camera";
|
||||||
import Animated, {
|
|
||||||
Easing,
|
|
||||||
useAnimatedStyle,
|
|
||||||
useSharedValue,
|
|
||||||
withTiming,
|
|
||||||
} from "react-native-reanimated";
|
|
||||||
import { useEvent } from "@/hooks/use-event";
|
|
||||||
import { logError } from "@/lib/errors";
|
import { logError } from "@/lib/errors";
|
||||||
|
|
||||||
const MAX_DURATION_S = 60;
|
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 {
|
interface VideoRecordingOverlayProps {
|
||||||
/** 0..1 — drives the cancel-pill animation as the user drags up. */
|
|
||||||
cancelProgress: { value: number };
|
|
||||||
onComplete: (result: { uri: string; durationMs: number }) => void;
|
onComplete: (result: { uri: string; durationMs: number }) => void;
|
||||||
onCancel: () => 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({
|
export function VideoRecordingOverlay({
|
||||||
cancelProgress,
|
|
||||||
onComplete,
|
onComplete,
|
||||||
onCancel,
|
onCancel,
|
||||||
onRecordingStarted,
|
|
||||||
pendingAction,
|
|
||||||
}: VideoRecordingOverlayProps) {
|
}: VideoRecordingOverlayProps) {
|
||||||
const cameraRef = useRef<CameraViewRef>(null);
|
const cameraRef = useRef<CameraView>(null);
|
||||||
const [elapsedMs, setElapsedMs] = useState(0);
|
|
||||||
const [cameraReady, setCameraReady] = useState(false);
|
const [cameraReady, setCameraReady] = useState(false);
|
||||||
const [warming, setWarming] = useState(true);
|
const [recording, setRecording] = useState(false);
|
||||||
|
const [elapsedMs, setElapsedMs] = useState(0);
|
||||||
// Lifecycle refs — refs over state so async closures see the latest value
|
|
||||||
// without re-running effects.
|
|
||||||
const startedAtRef = useRef<number | null>(null);
|
const startedAtRef = useRef<number | null>(null);
|
||||||
const recordingActiveRef = useRef(false);
|
const cancelledRef = useRef(false);
|
||||||
const completionRef = useRef<"pending" | "completed" | "cancelled">(
|
|
||||||
"pending",
|
|
||||||
);
|
|
||||||
|
|
||||||
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(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (completionRef.current === "pending") {
|
cancelledRef.current = true;
|
||||||
completionRef.current = "cancelled";
|
|
||||||
if (recordingActiveRef.current) {
|
|
||||||
cameraRef.current?.stopRecording();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 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(() => {
|
useEffect(() => {
|
||||||
|
if (!recording) return;
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
if (startedAtRef.current === null) return;
|
if (startedAtRef.current === null) return;
|
||||||
setElapsedMs(Date.now() - startedAtRef.current);
|
setElapsedMs(Date.now() - startedAtRef.current);
|
||||||
}, 100);
|
}, 250);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, []);
|
}, [recording]);
|
||||||
|
|
||||||
const elapsedSec = Math.floor(elapsedMs / 1000);
|
const elapsedSec = Math.floor(elapsedMs / 1000);
|
||||||
const elapsedRatio = Math.min(elapsedMs / (MAX_DURATION_S * 1000), 1);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={StyleSheet.absoluteFill} className="bg-black">
|
<View style={StyleSheet.absoluteFill} className="bg-black">
|
||||||
@@ -222,130 +84,55 @@ export function VideoRecordingOverlay({
|
|||||||
style={StyleSheet.absoluteFill}
|
style={StyleSheet.absoluteFill}
|
||||||
facing="front"
|
facing="front"
|
||||||
mode="video"
|
mode="video"
|
||||||
videoQuality="1080p"
|
|
||||||
onCameraReady={handleCameraReady}
|
|
||||||
mute={false}
|
mute={false}
|
||||||
|
onCameraReady={() => setCameraReady(true)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<RecChrome
|
{recording ? (
|
||||||
warming={warming}
|
<View
|
||||||
elapsedSec={elapsedSec}
|
pointerEvents="none"
|
||||||
elapsedRatio={elapsedRatio}
|
className="absolute top-0 left-0 right-0 items-center pt-16"
|
||||||
cancelProgress={cancelProgress}
|
>
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function wait(ms: number): Promise<void> {
|
|
||||||
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. */}
|
|
||||||
<View
|
|
||||||
pointerEvents="none"
|
|
||||||
className="absolute top-0 left-0 right-0 items-center pt-16"
|
|
||||||
>
|
|
||||||
{warming ? (
|
|
||||||
<View className="bg-black/70 flex-row items-center gap-2 rounded-full px-3 py-1.5">
|
|
||||||
<PulsingDot color="white" />
|
|
||||||
<Text className="text-white text-xs font-medium">
|
|
||||||
Getting ready…
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<View className="bg-red-500/90 flex-row items-center gap-2 rounded-full px-3 py-1.5">
|
<View className="bg-red-500/90 flex-row items-center gap-2 rounded-full px-3 py-1.5">
|
||||||
<PulsingDot color="white" />
|
<View className="h-2 w-2 rounded-full bg-white" />
|
||||||
<Text className="text-white text-xs font-semibold tracking-wide">
|
<Text className="text-white text-xs font-semibold tracking-wide">
|
||||||
REC · {elapsedSec.toString().padStart(2, "0")}s
|
REC · {elapsedSec.toString().padStart(2, "0")}s
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Cancel pill — slides in as user drags up. */}
|
|
||||||
<Animated.View
|
|
||||||
pointerEvents="none"
|
|
||||||
style={[
|
|
||||||
{
|
|
||||||
position: "absolute",
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
top: fabCenterY - 96,
|
|
||||||
alignItems: "center",
|
|
||||||
},
|
|
||||||
cancelPillStyle,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<View className="bg-black/70 px-4 py-2 rounded-full border border-white/20">
|
|
||||||
<Text className="text-white text-sm font-medium">
|
|
||||||
Slide up to cancel
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</Animated.View>
|
|
||||||
|
|
||||||
{/* Tiny duration counter near the FAB. */}
|
|
||||||
{!warming ? (
|
|
||||||
<View
|
|
||||||
pointerEvents="none"
|
|
||||||
className="absolute bottom-32 left-0 right-0 items-center"
|
|
||||||
>
|
|
||||||
<Text className="text-white/60 text-xs">
|
|
||||||
{Math.round(elapsedRatio * 60)}s / {MAX_DURATION_S}s
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PulsingDot({ color = "white" }: { color?: string }) {
|
<View className="absolute inset-x-0 bottom-12 flex-row items-center justify-around px-8">
|
||||||
const opacity = useSharedValue(1);
|
<Pressable
|
||||||
useEffect(() => {
|
onPress={cancel}
|
||||||
const loop = () => {
|
accessibilityLabel="Cancel"
|
||||||
opacity.value = withTiming(
|
className="rounded-full bg-white/15 px-6 py-3"
|
||||||
0.3,
|
>
|
||||||
{ duration: 600, easing: Easing.inOut(Easing.quad) },
|
<Text className="text-white text-base font-medium">Cancel</Text>
|
||||||
() => {
|
</Pressable>
|
||||||
opacity.value = withTiming(
|
{recording ? (
|
||||||
1,
|
<Pressable
|
||||||
{ duration: 600, easing: Easing.inOut(Easing.quad) },
|
onPress={stopRecording}
|
||||||
() => loop(),
|
accessibilityLabel="Stop recording"
|
||||||
);
|
className="rounded-full bg-white px-7 py-3"
|
||||||
},
|
>
|
||||||
);
|
<Text className="text-black text-base font-semibold">Stop</Text>
|
||||||
};
|
</Pressable>
|
||||||
loop();
|
) : (
|
||||||
}, [opacity]);
|
<Pressable
|
||||||
const style = useAnimatedStyle(() => ({ opacity: opacity.value }));
|
onPress={startRecording}
|
||||||
return (
|
disabled={!cameraReady}
|
||||||
<Animated.View
|
accessibilityLabel="Start recording"
|
||||||
className="h-2 w-2 rounded-full"
|
className={
|
||||||
style={[{ backgroundColor: color }, style]}
|
cameraReady
|
||||||
/>
|
? "h-20 w-20 items-center justify-center rounded-full bg-white"
|
||||||
|
: "h-20 w-20 items-center justify-center rounded-full bg-white/40"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<View className="h-16 w-16 rounded-full bg-red-500" />
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,6 +111,11 @@ function PlayableMediaView({
|
|||||||
p.loop = false;
|
p.loop = false;
|
||||||
p.muted = false;
|
p.muted = false;
|
||||||
p.timeUpdateEventInterval = 0.15;
|
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
|
// Drive play/pause from the suspender store. The player itself is forgiving
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { useNetwork } from "@/hooks/use-networks";
|
|||||||
import { useStreamPlayback } from "@/hooks/use-stream-playback";
|
import { useStreamPlayback } from "@/hooks/use-stream-playback";
|
||||||
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
|
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
|
||||||
import {
|
import {
|
||||||
|
selectIsComposing,
|
||||||
selectIsPaused,
|
selectIsPaused,
|
||||||
usePlaybackPauseStore,
|
usePlaybackPauseStore,
|
||||||
} from "@/stores/playback-pause-store";
|
} from "@/stores/playback-pause-store";
|
||||||
@@ -90,6 +91,7 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
|
|||||||
useStreamPlayback(streamParticle, path);
|
useStreamPlayback(streamParticle, path);
|
||||||
|
|
||||||
const paused = usePlaybackPauseStore(selectIsPaused);
|
const paused = usePlaybackPauseStore(selectIsPaused);
|
||||||
|
const composing = usePlaybackPauseStore(selectIsComposing);
|
||||||
const [progress, setProgress] = useState(0);
|
const [progress, setProgress] = useState(0);
|
||||||
const userId = useAuthStore((s) => s.user?.id) ?? "";
|
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. */}
|
chrome occupies so scrollable content doesn't slip under. */}
|
||||||
<StreamSafeAreaProvider top={chromeTop} bottom={chromeBottom}>
|
<StreamSafeAreaProvider top={chromeTop} bottom={chromeBottom}>
|
||||||
<View className="flex-1">
|
<View className="flex-1">
|
||||||
{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}
|
||||||
</View>
|
</View>
|
||||||
</StreamSafeAreaProvider>
|
</StreamSafeAreaProvider>
|
||||||
|
|
||||||
|
|||||||
@@ -7,12 +7,15 @@ import { create } from "zustand";
|
|||||||
*/
|
*/
|
||||||
interface PlaybackPauseState {
|
interface PlaybackPauseState {
|
||||||
activeIds: Record<string, string>;
|
activeIds: Record<string, string>;
|
||||||
|
composing: boolean;
|
||||||
add: (id: string, label: string) => void;
|
add: (id: string, label: string) => void;
|
||||||
remove: (id: string) => void;
|
remove: (id: string) => void;
|
||||||
|
setComposing: (composing: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const usePlaybackPauseStore = create<PlaybackPauseState>((set) => ({
|
export const usePlaybackPauseStore = create<PlaybackPauseState>((set) => ({
|
||||||
activeIds: {},
|
activeIds: {},
|
||||||
|
composing: false,
|
||||||
add: (id, label) =>
|
add: (id, label) =>
|
||||||
set((s) => ({ activeIds: { ...s.activeIds, [id]: label } })),
|
set((s) => ({ activeIds: { ...s.activeIds, [id]: label } })),
|
||||||
remove: (id) =>
|
remove: (id) =>
|
||||||
@@ -21,7 +24,10 @@ export const usePlaybackPauseStore = create<PlaybackPauseState>((set) => ({
|
|||||||
const { [id]: _, ...rest } = s.activeIds;
|
const { [id]: _, ...rest } = s.activeIds;
|
||||||
return { activeIds: rest };
|
return { activeIds: rest };
|
||||||
}),
|
}),
|
||||||
|
setComposing: (composing) => set({ composing }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const selectIsPaused = (s: PlaybackPauseState) =>
|
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;
|
||||||
|
|||||||
+1
-1
@@ -2998,7 +2998,7 @@ expo-build-properties@~1.0.10:
|
|||||||
ajv "^8.11.0"
|
ajv "^8.11.0"
|
||||||
semver "^7.6.0"
|
semver "^7.6.0"
|
||||||
|
|
||||||
expo-camera@~17.0.8:
|
expo-camera@~17.0.10:
|
||||||
version "17.0.10"
|
version "17.0.10"
|
||||||
resolved "https://registry.yarnpkg.com/expo-camera/-/expo-camera-17.0.10.tgz#b3a217f0eb811a6e3522c2aff9f42be578aa6456"
|
resolved "https://registry.yarnpkg.com/expo-camera/-/expo-camera-17.0.10.tgz#b3a217f0eb811a6e3522c2aff9f42be578aa6456"
|
||||||
integrity sha512-w1RBw83mAGVk4BPPwNrCZyFop0VLiVSRE3c2V9onWbdFwonpRhzmB4drygG8YOUTl1H3wQvALJHyMPTbgsK1Jg==
|
integrity sha512-w1RBw83mAGVk4BPPwNrCZyFop0VLiVSRE3c2V9onWbdFwonpRhzmB4drygG8YOUTl1H3wQvALJHyMPTbgsK1Jg==
|
||||||
|
|||||||
Reference in New Issue
Block a user