mobile v0.1 with deployment for ios (#191)
* stage 1: project init * stage 2: skeleton with navigation * step 2.5: streams list * step 4: stream playback experience * step 5-6: compose experience * fix: broken record * transcode media particles to mp4 * build: reproducible go generate * build: rename skaffold module for particle processor worker * infra: increase particle processor worker resources Was dealing with OOM errors * tweaks to mobile * log transcode work * view on desktop placeholder * tweak padding * cap video resolution to save on memory * infra: bump memory limits as insurance * ux improvements * update bundle id for mobile * config for mobile
This commit was merged in pull request #191.
This commit is contained in:
@@ -0,0 +1,298 @@
|
||||
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 {
|
||||
useCameraPermissions,
|
||||
useMicrophonePermissions,
|
||||
} from "expo-camera";
|
||||
import { toast } from "sonner-native";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEvent } from "@/hooks/use-event";
|
||||
import { usePlaybackPauseStore } from "@/stores/playback-pause-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import {
|
||||
createTextParticle,
|
||||
uploadMediaParticle,
|
||||
} from "@/lib/upload";
|
||||
import type { ParticlePath } from "@/lib/particle-path";
|
||||
import {
|
||||
useStreamComposingBroadcast,
|
||||
type ComposingMode,
|
||||
} from "@/features/stream-view/stream-presence-context";
|
||||
import { TextComposeModal } from "./TextComposeModal";
|
||||
import { VideoRecordingOverlay } from "./VideoRecordingOverlay";
|
||||
import { AudioRecordingOverlay } from "./AudioRecordingOverlay";
|
||||
import { ReviewSheet } from "./ReviewSheet";
|
||||
|
||||
type RecordingMode = "video" | "audio";
|
||||
|
||||
type ComposeUiState =
|
||||
| { kind: "idle" }
|
||||
| { kind: "recording"; mode: RecordingMode }
|
||||
| {
|
||||
kind: "review";
|
||||
mode: RecordingMode;
|
||||
uri: string;
|
||||
durationMs: number;
|
||||
}
|
||||
| { kind: "uploading" };
|
||||
|
||||
interface SubmitMediaParams {
|
||||
fileUri: string;
|
||||
mimeType: string;
|
||||
durationMs: number;
|
||||
source: "camera" | "screen";
|
||||
}
|
||||
|
||||
interface ComposeDockProps {
|
||||
networkId: string;
|
||||
targetPath: ParticlePath;
|
||||
silentPresence?: boolean;
|
||||
submitMedia?: (params: SubmitMediaParams) => Promise<void>;
|
||||
submitText?: (content: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function ComposeDock({
|
||||
networkId,
|
||||
targetPath,
|
||||
silentPresence = false,
|
||||
submitMedia,
|
||||
submitText: submitTextOverride,
|
||||
}: ComposeDockProps) {
|
||||
const userId = useAuthStore((s) => s.user?.id);
|
||||
|
||||
const [mode, setMode] = useState<RecordingMode>("video");
|
||||
const [ui, setUi] = useState<ComposeUiState>({ kind: "idle" });
|
||||
const [textOpen, setTextOpen] = useState(false);
|
||||
|
||||
const [camPerm, requestCamPerm] = useCameraPermissions();
|
||||
const [micPerm, requestMicPerm] = useMicrophonePermissions();
|
||||
|
||||
// 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;
|
||||
useEffect(() => {
|
||||
setComposing(isComposing);
|
||||
return () => setComposing(false);
|
||||
}, [isComposing, setComposing]);
|
||||
|
||||
useComposingBroadcast({ ui, textOpen, silent: silentPresence });
|
||||
|
||||
const ensurePermissions = useCallback(
|
||||
async (forVideo: boolean): Promise<boolean> => {
|
||||
if (forVideo) {
|
||||
const cam = camPerm?.granted ? camPerm : await requestCamPerm();
|
||||
if (!cam.granted) {
|
||||
toast.error("Camera permission is required to record video.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const mic = micPerm?.granted ? micPerm : await requestMicPerm();
|
||||
if (!mic.granted) {
|
||||
toast.error("Microphone permission is required to record.");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[camPerm, micPerm, requestCamPerm, requestMicPerm],
|
||||
);
|
||||
|
||||
const startRecording = useEvent(async () => {
|
||||
if (ui.kind !== "idle") return;
|
||||
const ok = await ensurePermissions(mode === "video");
|
||||
if (!ok) return;
|
||||
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
setUi({ kind: "recording", mode });
|
||||
});
|
||||
|
||||
const handleRecordingComplete = useCallback(
|
||||
({ uri, durationMs }: { uri: string; durationMs: number }) => {
|
||||
void Haptics.selectionAsync();
|
||||
setUi((prev) => {
|
||||
const m = "mode" in prev ? prev.mode : mode;
|
||||
return { kind: "review", mode: m, uri, durationMs };
|
||||
});
|
||||
},
|
||||
[mode],
|
||||
);
|
||||
|
||||
const handleRecordingCancel = useCallback(() => {
|
||||
setUi({ kind: "idle" });
|
||||
}, []);
|
||||
|
||||
const sendReview = useEvent(async () => {
|
||||
if (ui.kind !== "review" || !userId) return;
|
||||
const captured = ui;
|
||||
setUi({ kind: "uploading" });
|
||||
try {
|
||||
const mimeType =
|
||||
captured.mode === "audio" ? "audio/mp4" : "video/mp4";
|
||||
if (submitMedia) {
|
||||
await submitMedia({
|
||||
fileUri: captured.uri,
|
||||
mimeType,
|
||||
durationMs: captured.durationMs,
|
||||
source: "camera",
|
||||
});
|
||||
} else {
|
||||
await uploadMediaParticle({
|
||||
networkId,
|
||||
targetPath,
|
||||
fileUri: captured.uri,
|
||||
mimeType,
|
||||
durationMs: captured.durationMs,
|
||||
source: "camera",
|
||||
createdByHumanId: userId,
|
||||
});
|
||||
}
|
||||
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
setUi({ kind: "idle" });
|
||||
} catch (err) {
|
||||
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
||||
setUi(captured);
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
const retake = useCallback(() => setUi({ kind: "idle" }), []);
|
||||
const cancelReview = useCallback(() => setUi({ kind: "idle" }), []);
|
||||
|
||||
const submitText = useEvent(async (content: string) => {
|
||||
if (!userId) throw new Error("Not signed in.");
|
||||
if (submitTextOverride) {
|
||||
await submitTextOverride(content);
|
||||
} else {
|
||||
await createTextParticle({
|
||||
networkId,
|
||||
targetPath,
|
||||
content,
|
||||
createdByHumanId: userId,
|
||||
});
|
||||
}
|
||||
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
});
|
||||
|
||||
const dockHidden =
|
||||
ui.kind === "review" ||
|
||||
ui.kind === "uploading" ||
|
||||
ui.kind === "recording";
|
||||
|
||||
return (
|
||||
<>
|
||||
{!dockHidden ? (
|
||||
<View pointerEvents="box-none" className="absolute inset-x-0 bottom-0">
|
||||
<View
|
||||
pointerEvents="box-none"
|
||||
className="flex-row items-center justify-between px-8 pb-10"
|
||||
>
|
||||
<Pressable
|
||||
onPress={() =>
|
||||
setMode((m) => (m === "video" ? "audio" : "video"))
|
||||
}
|
||||
disabled={ui.kind !== "idle"}
|
||||
accessibilityLabel={`Switch to ${
|
||||
mode === "video" ? "audio" : "video"
|
||||
} mode`}
|
||||
className={cn(
|
||||
"h-11 w-11 items-center justify-center rounded-full bg-white/15",
|
||||
ui.kind !== "idle" && "opacity-40",
|
||||
)}
|
||||
>
|
||||
{mode === "video" ? (
|
||||
<VideoIcon color="white" size={20} strokeWidth={1.6} />
|
||||
) : (
|
||||
<Mic color="white" size={20} strokeWidth={1.6} />
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
<View className="items-center">
|
||||
<Pressable
|
||||
onPress={startRecording}
|
||||
disabled={ui.kind !== "idle"}
|
||||
accessibilityLabel={`Record ${mode}`}
|
||||
className="h-20 w-20 items-center justify-center rounded-full bg-white"
|
||||
>
|
||||
<View className="h-6 w-6 rounded bg-black" />
|
||||
</Pressable>
|
||||
<Text className="text-white/60 mt-2 text-xs">
|
||||
Tap to record
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
onPress={() => setTextOpen(true)}
|
||||
disabled={ui.kind !== "idle"}
|
||||
accessibilityLabel="Compose text"
|
||||
className={cn(
|
||||
"h-11 w-11 items-center justify-center rounded-full bg-white/15",
|
||||
ui.kind !== "idle" && "opacity-40",
|
||||
)}
|
||||
>
|
||||
<TypeIcon color="white" size={20} strokeWidth={1.6} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{ui.kind === "recording" ? (
|
||||
ui.mode === "video" ? (
|
||||
<VideoRecordingOverlay
|
||||
onComplete={handleRecordingComplete}
|
||||
onCancel={handleRecordingCancel}
|
||||
/>
|
||||
) : (
|
||||
<AudioRecordingOverlay
|
||||
onComplete={handleRecordingComplete}
|
||||
onCancel={handleRecordingCancel}
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
|
||||
<ReviewSheet
|
||||
open={ui.kind === "review"}
|
||||
uri={ui.kind === "review" ? ui.uri : null}
|
||||
mode={ui.kind === "review" ? ui.mode : null}
|
||||
durationMs={ui.kind === "review" ? ui.durationMs : 0}
|
||||
onSend={sendReview}
|
||||
onRetake={retake}
|
||||
onCancel={cancelReview}
|
||||
/>
|
||||
|
||||
<TextComposeModal
|
||||
open={textOpen}
|
||||
onClose={() => setTextOpen(false)}
|
||||
onSubmit={submitText}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function useComposingBroadcast({
|
||||
ui,
|
||||
textOpen,
|
||||
silent,
|
||||
}: {
|
||||
ui: ComposeUiState;
|
||||
textOpen: boolean;
|
||||
silent: boolean;
|
||||
}) {
|
||||
let broadcast: ReturnType<typeof useStreamComposingBroadcast> | null;
|
||||
try {
|
||||
broadcast = useStreamComposingBroadcast();
|
||||
} catch {
|
||||
broadcast = null;
|
||||
}
|
||||
|
||||
const mode: ComposingMode | null =
|
||||
ui.kind === "recording" ? "recording" : textOpen ? "typing" : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (silent || !broadcast) return;
|
||||
if (mode) {
|
||||
broadcast.startComposing(mode);
|
||||
return () => broadcast?.stopComposing();
|
||||
}
|
||||
}, [mode, silent, broadcast]);
|
||||
}
|
||||
Reference in New Issue
Block a user