feat: initial conversational flow (#37)

* chore: only set visibility for container particles

* create reusable controls indicator for reply or new

* compress the size of top bar

* refactor: restructure state, routing, and more

* introduce stream compose flow

* feat: compose new stream full flow

* implement stream player

* fix: prevent redirect for signed object urls

* fix: implement stream playback cleaner structure

* refactor: layout file name

* feat: show stream name in breadcrumbs

* chore: tweak padding

* chore: adjust position of audio bars

* feat: show latest particle preview in stream list

* fix: remove console log

* refactor: reorder classes

* fix: avoid passing in updated_at to firestore particle

* refactor: extract properties for container particles to flat fields in firestore

* make the stream previews look alive

* feat: show audio bars during audio clip playback

* feat: order streams by last child creation

* feat: playback where I left off

* chore: remove unused store

* fix: recording mode not using shared state

* chore: clean unused variable

* remove unused imports

* fix: improve controls indicator immersion

* feat: show playback progress in bar & auto-play text

* feat: auto-exit stream on playback completion

* fix: jittery media playback progress

* fix: navigate during state change is invalid with react router

* fix: buggy exit progress when changing clips

* feat: add app icon

* update package.json info

* feat: only show streams visible to me

* feat: show seen indicator on particles

* fix: prevent unnecessary effects

* fix: play new particle after playback is ended

* use contols indicator for exit timer
This commit was merged in pull request #37.
This commit is contained in:
Arjun Patel
2026-03-19 16:29:40 -07:00
committed by GitHub
parent 990137b829
commit 4b66d8e185
48 changed files with 2112 additions and 1482 deletions
+284
View File
@@ -0,0 +1,284 @@
import { useCallback, useEffect, useEffectEvent, useRef, useState } from "react";
import { useAuthStore } from "@/stores/auth-store";
import { useCreateParticle, useCreateStreamParticle } from "@/hooks/use-create-particle";
import { useRecorder } from "@/features/compose/use-recorder";
import { particlePath } from "@/lib/particle-path";
import type { ParticlePath } from "@/lib/particle-path";
import { RecordingOverlay } from "@/features/compose/recording-overlay";
import { TextComposeStep } from "@/features/compose/text-compose-step";
import { ConfigureStreamStep } from "@/features/compose/configure-stream-step";
import { apiClient } from "@/api/client";
import { useMediaSettingsStore } from "@/stores/media-settings-store";
type ComposeStep = "idle" | "recording" | "reviewing" | "typing" | "configuring";
interface ComposeOverlayProps {
networkId: string;
// Optional target path for reply mode. If not provided, compose creates a new stream.
targetPath?: ParticlePath;
onActiveChange?: (active: boolean) => void;
}
/**
* Self-contained compose overlay. Each consumer renders its own instance
* with props that determine the mode (new stream vs. reply).
*/
export function ComposeOverlay({
networkId,
targetPath,
onActiveChange,
}: ComposeOverlayProps) {
const [step, setStep] = useState<ComposeStep>("idle");
const [error, setError] = useState<string | null>(null);
const [textContent, setTextContent] = useState("");
const [mediaStream, setMediaStream] = useState<MediaStream | null>(null);
const [reviewBlob, setReviewBlob] = useState<Blob | null>(null);
const [reviewDurationMs, setReviewDurationMs] = useState(0);
const [reviewMimeType, setReviewMimeType] = useState<string | null>(null);
const recordingMode = useMediaSettingsStore((s) => s.recordingMode);
const userEmail = useAuthStore((s) => s.user?.email);
const createParticle = useCreateParticle();
const createStream = useCreateStreamParticle();
// Refs to avoid stale closures in keyboard handler
const stepRef = useRef(step);
stepRef.current = step;
// Notify parent when active state changes
useEffect(() => {
onActiveChange?.(step !== "idle");
}, [step, onActiveChange]);
const cancel = useCallback(() => {
setStep("idle");
setError(null);
setTextContent("");
setMediaStream(null);
setReviewBlob(null);
setReviewDurationMs(0);
setReviewMimeType(null);
}, []);
const { startRecording, stopRecording, cancelRecording } = useRecorder({
mode: recordingMode,
onStreamReady: (stream) => setMediaStream(stream),
onStreamCleanup: () => setMediaStream(null),
onFinish: (blob, durationMs, mimeType) => {
setStep("reviewing");
setReviewBlob(blob);
setReviewDurationMs(durationMs);
setReviewMimeType(mimeType);
},
onError: (message) => setError(message),
});
// --- Submission ---
const uploadMedia = useCallback(
async (blob: Blob, mimeType: string) => {
const ext = "webm";
const fileName = `recording-${Date.now()}.${ext}`;
const { object_id, upload_url, upload_headers } =
await apiClient.prepareUpload({
network_id: networkId,
name: fileName,
content_type: mimeType,
content_length: blob.size,
});
await fetch(upload_url, {
method: "PUT",
headers: upload_headers,
body: blob,
});
await apiClient.confirmUpload(object_id);
return { object_id, size_bytes: blob.size };
},
[networkId],
);
const createChildParticle = useCallback(
async (path: ParticlePath) => {
if (!userEmail) return;
if (textContent.trim()) {
await createParticle.mutateAsync({
path,
type: "text",
properties: { content: textContent },
createdByEmail: userEmail,
});
} else if (reviewBlob && reviewMimeType) {
const { object_id, size_bytes } = await uploadMedia(
reviewBlob,
reviewMimeType,
);
await createParticle.mutateAsync({
path,
type: "media",
properties: {
object_id,
mime_type: reviewMimeType,
duration_ms: reviewDurationMs,
size_bytes,
},
createdByEmail: userEmail,
});
}
},
[
userEmail,
textContent,
reviewBlob,
reviewMimeType,
reviewDurationMs,
createParticle,
uploadMedia,
],
);
// Reply mode: create particle directly under targetPath
const onSubmitReply = useEffectEvent(async () => {
if (!targetPath || !userEmail) return;
await createChildParticle(targetPath);
cancel();
});
// New stream mode: create stream + first child
const handleStreamSubmit = useCallback(
async (streamName: string, visibleTo: string[]) => {
if (!userEmail) return;
const streamId = await createStream.mutateAsync({
networkId,
properties: {
name: streamName,
status: "open",
},
createdByEmail: userEmail,
visibleTo,
});
const streamChildrenPath = particlePath(networkId, [streamId]);
await createChildParticle(streamChildrenPath);
cancel();
},
[networkId, userEmail, createParticle, createChildParticle, cancel],
);
// --- Keyboard handling ---
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const currentStep = stepRef.current;
if (currentStep === "typing" || currentStep === "configuring") return;
const target = e.target as HTMLElement;
if (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable
) {
return;
}
switch (currentStep) {
case "idle": {
if (e.key === "`" && !e.repeat) {
e.preventDefault();
setStep("recording");
startRecording();
} else if (e.key === "t" || e.key === "T") {
e.preventDefault();
setStep("typing");
}
break;
}
case "recording": {
if (e.key === "q" || e.key === "Q" || e.key === "Escape") {
e.preventDefault();
cancelRecording();
cancel();
}
break;
}
case "reviewing": {
if (e.key === "q" || e.key === "Q" || e.key === "Escape") {
e.preventDefault();
cancelRecording();
cancel();
} else if (e.key === "Enter") {
e.preventDefault();
if (targetPath) {
onSubmitReply();
} else {
setStep("configuring");
}
}
break;
}
}
};
const handleKeyUp = (e: KeyboardEvent) => {
if (stepRef.current === "recording" && e.key === "`") {
e.preventDefault();
stopRecording();
}
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
};
}, [targetPath, startRecording, stopRecording, cancelRecording, cancel]);
// --- Render ---
if (step === "idle") return null;
const handleTextAdvance = targetPath
? onSubmitReply
: () => setStep("configuring");
return (
<>
{(step === "recording" || step === "reviewing") && (
<RecordingOverlay
step={step}
mediaStream={mediaStream}
recordingMode={recordingMode}
reviewBlob={reviewBlob}
error={error}
onClose={cancel}
/>
)}
{step === "typing" && (
<TextComposeStep
textContent={textContent}
onTextChange={setTextContent}
onAdvance={handleTextAdvance}
onCancel={cancel}
/>
)}
{!targetPath && step === "configuring" && (
<ConfigureStreamStep
networkId={networkId}
onCancel={cancel}
onSubmit={handleStreamSubmit}
/>
)}
</>
);
}
@@ -0,0 +1,168 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { useNetworks } from "@/hooks/use-networks";
import { cn } from "@/lib/utils";
import { generateRandomName } from "@/lib/random-name";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { ScrollArea } from "@/components/ui/scroll-area";
interface ConfigureStreamStepProps {
networkId: string | null;
onCancel: () => void;
onSubmit: (streamName: string, visibleTo: string[]) => void;
}
export function ConfigureStreamStep({
networkId,
onCancel,
onSubmit,
}: ConfigureStreamStepProps) {
const { data: networks } = useNetworks();
const network = networks?.find((n) => n.id === networkId);
const members = network?.humans ?? [];
const [name, setName] = useState(() => generateRandomName());
const [everyone, setEveryone] = useState(true);
const [selectedEmails, setSelectedEmails] = useState<Set<string>>(new Set());
const toggleMember = useCallback((email: string) => {
setSelectedEmails((prev) => {
const next = new Set(prev);
if (next.has(email)) next.delete(email);
else next.add(email);
return next;
});
}, []);
const buildVisibleTo = useCallback((): string[] => {
if (everyone && networkId) return [`network:${networkId}`];
return Array.from(selectedEmails).map((e) => `human:${e}`);
}, [everyone, networkId, selectedEmails]);
const handleSubmit = useCallback(() => {
if (!name.trim() || !networkId) return;
onSubmit(name.trim(), buildVisibleTo());
}, [name, networkId, onSubmit, buildVisibleTo]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
switch (e.key) {
case "Escape":
e.preventDefault();
onCancel();
return;
case "Enter":
if (e.metaKey || e.ctrlKey) {
e.preventDefault();
handleSubmit();
}
return;
}
},
[onCancel, handleSubmit, members, name, toggleMember],
);
return (
<div
className="absolute inset-0 z-50 flex flex-col bg-black/90 pt-16"
onKeyDown={handleKeyDown}
tabIndex={-1}
>
<div className="mx-auto w-full max-w-sm space-y-4 px-6">
{/* Stream name */}
<div>
<Label className="mb-1 text-xs text-white/50">Stream name</Label>
<Input
type="text"
value={name}
onChange={(e) => {
setName(e.target.value);
}}
placeholder="Give it a name..."
className="border-white/10 bg-white/5 text-white placeholder-white/30 focus-visible:border-white/30 focus-visible:ring-0"
/>
</div>
{/* Visibility */}
<div>
<Label className="mb-1 text-xs text-white/50">Visible to</Label>
<div className="rounded-md border border-white/10">
{/* Everyone in network */}
<div
role="button"
onClick={() => setEveryone((prev) => !prev)}
className={cn(
"flex w-full cursor-pointer items-center gap-2.5 px-3 py-2 text-left text-sm transition-colors",
"text-white/70 hover:bg-white/5",
)}
>
<Checkbox
checked={everyone}
onCheckedChange={(checked) => setEveryone(checked === true)}
tabIndex={-1}
className="pointer-events-none"
/>
<span className="font-medium">Everyone in network</span>
</div>
{/* Per-member selection */}
{!everyone && members.length > 0 && (
<ScrollArea className="max-h-48">
<div className="space-y-0.5 p-1">
{members.map((member, index) => {
const isSelected = selectedEmails.has(member.email);
const initials = member.email_prefix
.slice(0, 2)
.toUpperCase();
return (
<div
key={member.email}
role="button"
onClick={() => toggleMember(member.email)}
className={cn(
"flex w-full cursor-pointer items-center gap-2.5 rounded px-3 py-1.5 text-left text-sm transition-colors",
"text-white/70 hover:bg-white/5",
)}
>
<Checkbox
checked={isSelected}
tabIndex={-1}
className="pointer-events-none"
/>
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-white/10 text-[10px] font-medium">
{initials}
</span>
<span className="flex-1 truncate">
{member.email_prefix}
</span>
</div>
);
})}
</div>
</ScrollArea>
)}
</div>
</div>
</div>
{/* Keyboard hints */}
<div className="absolute bottom-8 left-0 right-0 flex items-center justify-center gap-4 text-sm text-white/50">
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Esc
</kbd>{" "}
cancel
</span>
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
+Enter
</kbd>{" "}
create
</span>
</div>
</div >
);
}
@@ -0,0 +1,69 @@
import { Video, Mic } from "lucide-react";
import { cn } from "@/lib/utils";
import { useMediaSettingsStore } from "@/stores/media-settings-store";
import { Button } from "@/components/ui/button";
import { PropsWithChildren } from "react";
interface ControlsIndicatorProps extends PropsWithChildren {
type: "reply" | "new";
}
export default function ControlsIndicator({ type, children }: ControlsIndicatorProps) {
const recordingMode = useMediaSettingsStore((s) => s.recordingMode);
const setRecordingMode = useMediaSettingsStore((s) => s.setRecordingMode);
return (
<div className="flex items-center gap-2 text-xs rounded-full pl-1 pr-3 py-1 bg-black/30 backdrop-blur-sm m-2">
<Button
onClick={() =>
setRecordingMode(recordingMode === "video" ? "audio" : "video")
}
title={
recordingMode === "video"
? "Switch to audio-only"
: "Switch to video"
}
variant="secondary"
className={cn(
"text-xs rounded-full",
"text-muted-foreground hover:text-white/90",
)}
>
{recordingMode === "video" ? (
<>
<Video className="h-3.5 w-3.5" />
<p>Video</p>
</>
) : (
<>
<Mic className="h-3.5 w-3.5" />
<span>Audio</span>
</>
)}
</Button>
{children ? <div className="flex-1">{children}</div> :
<div className="flex-1" />
}
{children && <span className="text-muted-foreground">·</span>}
<div className="text-muted-foreground">
Hold{" "}
<kbd
className={cn(
"bg-muted rounded px-1.5 py-0.5 font-mono",
)}
>
`
</kbd>{" "}
to {type === "reply" ? "reply " : "start "}
· Press{" "}
<kbd
className={cn(
"bg-muted rounded px-1.5 py-0.5 font-mono",
)}
>
T
</kbd>{" "}
for text
</div>
</div>
);
}
@@ -0,0 +1,218 @@
import { useEffect, useRef, useState } from "react";
import type { RecordingMode } from "@/hooks/use-recording-mode";
import { AudioLevelBars } from "@/components/audio/audio-level-bars";
import { useAudioSource } from "@/components/audio/use-audio-source";
interface RecordingOverlayProps {
step: "recording" | "reviewing";
mediaStream: MediaStream | null;
recordingMode: RecordingMode;
reviewBlob: Blob | null;
error: string | null;
onClose: () => void;
}
function RecordingTimer() {
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setElapsed((prev) => prev + 1);
}, 1000);
return () => clearInterval(interval);
}, []);
const minutes = Math.floor(elapsed / 60);
const seconds = elapsed % 60;
const display = `${minutes}:${seconds.toString().padStart(2, "0")}`;
return (
<div className="flex items-center gap-2">
<span className="h-2.5 w-2.5 animate-pulse rounded-full bg-red-500" />
<span className="font-mono text-sm text-white/80">{display}</span>
</div>
);
}
function ReviewPlayback({
blob,
isVideo,
}: {
blob: Blob;
isVideo: boolean;
}) {
const urlRef = useRef<string | null>(null);
const [objectUrl, setObjectUrl] = useState<string | null>(null);
const audioElRef = useRef<HTMLAudioElement | null>(null);
const [audioEl, setAudioEl] = useState<HTMLAudioElement | null>(null);
const audioSource = useAudioSource(isVideo ? null : audioEl);
useEffect(() => {
const url = URL.createObjectURL(blob);
urlRef.current = url;
setObjectUrl(url);
return () => {
URL.revokeObjectURL(url);
urlRef.current = null;
};
}, [blob]);
if (!objectUrl) return null;
if (isVideo) {
return (
<video
src={objectUrl}
autoPlay
loop
playsInline
className="absolute inset-0 h-full w-full -scale-x-100 object-cover"
/>
);
}
return (
<div className="flex flex-col items-center gap-3">
<audio
ref={(el) => {
audioElRef.current = el;
setAudioEl(el);
}}
src={objectUrl}
autoPlay
loop
/>
{audioSource ? (
<AudioLevelBars sourceNode={audioSource.sourceNode} />
) : (
<span className="text-sm text-white/60">Playing back audio...</span>
)}
</div>
);
}
export function RecordingOverlay({
step,
mediaStream,
recordingMode,
reviewBlob,
error,
onClose,
}: RecordingOverlayProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const recordingAudioSource = useAudioSource(mediaStream ?? null);
// Set video srcObject for live preview
useEffect(() => {
if (videoRef.current && mediaStream && recordingMode === "video") {
videoRef.current.srcObject = mediaStream;
}
}, [mediaStream, recordingMode]);
// Auto-close after error with a brief delay
useEffect(() => {
if (!error) return;
const timeout = setTimeout(onClose, 1500);
return () => clearTimeout(timeout);
}, [error, onClose]);
const isReviewing = step === "reviewing";
const isRecording = step === "recording";
const isLoading = isRecording && !mediaStream;
return (
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-black/90">
{/* Loading state */}
{isLoading && (
<div className="z-10 flex flex-col items-center gap-2">
<span className="animate-pulse text-sm text-white/60">
{recordingMode === "video"
? "Starting camera..."
: "Starting mic..."}
</span>
</div>
)}
{/* Camera preview (video mode, recording) */}
{isRecording && recordingMode === "video" && mediaStream && (
<video
ref={videoRef}
muted
autoPlay
playsInline
className="absolute inset-0 h-full w-full -scale-x-100 object-cover"
/>
)}
{/* Review playback */}
{isReviewing && reviewBlob && (
<ReviewPlayback
blob={reviewBlob}
isVideo={recordingMode === "video"}
/>
)}
{/* Top center: recording indicator */}
<div className="absolute top-8 z-10">
{isRecording && !isLoading ? (
<RecordingTimer />
) : isReviewing ? (
<div className="flex items-center gap-2">
<span className="text-sm text-white/80">Review recording</span>
</div>
) : null}
</div>
{/* Bottom center: audio level bars (recording with active stream) */}
{isRecording && recordingAudioSource && (
<div className="z-10 absolute bottom-15">
<AudioLevelBars sourceNode={recordingAudioSource.sourceNode} />
</div>
)}
{/* Bottom center: keyboard hints */}
{isRecording && !isLoading && (
<div className="absolute bottom-8 z-10 flex items-center gap-4 text-sm text-white/50">
<span>
Release{" "}
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
`
</kbd>{" "}
to review
</span>
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Q
</kbd>{" "}
cancel
</span>
</div>
)}
{isReviewing && (
<div className="absolute bottom-8 z-10 flex items-center gap-4 text-sm text-white/50">
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Enter
</kbd>{" "}
next
</span>
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Q
</kbd>{" "}
cancel
</span>
</div>
)}
{/* Error state */}
{error && (
<div className="z-10 text-sm text-red-400">
{error}
</div>
)}
</div>
);
}
@@ -0,0 +1,76 @@
import { useEffect, useRef, useCallback } from "react";
import { cn } from "@/lib/utils";
interface TextComposeStepProps {
textContent: string;
onTextChange: (text: string) => void;
onAdvance: () => void;
onCancel: () => void;
}
function getTextStyle(length: number) {
if (length < 50) return { size: "text-5xl", weight: "font-semibold" };
if (length < 150) return { size: "text-3xl", weight: "font-semibold" };
if (length < 300) return { size: "text-2xl", weight: "font-normal" };
return { size: "text-lg", weight: "font-normal" };
}
export function TextComposeStep({
textContent,
onTextChange,
onAdvance,
onCancel,
}: TextComposeStepProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
textareaRef.current?.focus();
}, []);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onCancel();
} else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
if (textContent.trim()) onAdvance();
}
},
[onCancel, onAdvance, textContent],
);
const style = getTextStyle(textContent.length);
return (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-black/90">
<textarea
ref={textareaRef}
value={textContent}
onChange={(e) => onTextChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a message..."
className={cn(
"w-full max-w-2xl resize-none border-none bg-transparent p-8 text-center text-white placeholder-white/40 outline-none",
style.size,
style.weight,
)}
rows={4}
/>
<div className="absolute bottom-8 flex items-center gap-4 text-sm text-white/50">
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Esc
</kbd>{" "}
cancel
</span>
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
+Enter
</kbd>{" "}
next
</span>
</div>
</div>
);
}
+124
View File
@@ -0,0 +1,124 @@
import { useCallback, useEffect, useRef } from "react";
import type { RecordingMode } from "@/hooks/use-recording-mode";
const VIDEO_PREFERRED_MIME = "video/webm;codecs=vp9,opus";
const VIDEO_FALLBACK_MIME = "video/webm";
const AUDIO_PREFERRED_MIME = "audio/webm;codecs=opus";
const AUDIO_FALLBACK_MIME = "audio/webm";
function getMediaMime(mode: "video" | "audio"): string {
if (mode === "audio") {
return MediaRecorder.isTypeSupported(AUDIO_PREFERRED_MIME)
? AUDIO_PREFERRED_MIME
: AUDIO_FALLBACK_MIME;
}
return MediaRecorder.isTypeSupported(VIDEO_PREFERRED_MIME)
? VIDEO_PREFERRED_MIME
: VIDEO_FALLBACK_MIME;
}
interface UseRecorderOptions {
mode: RecordingMode;
onStreamReady: (stream: MediaStream) => void;
onStreamCleanup: () => void;
onFinish: (blob: Blob, durationMs: number, mimeType: string) => void;
onError: (message: string) => void;
}
/**
* Manages MediaRecorder lifecycle. Pure media utility — knows nothing
* about application state. The consumer provides callbacks for all outputs.
*/
export function useRecorder({
mode,
onStreamReady,
onStreamCleanup,
onFinish,
onError,
}: UseRecorderOptions) {
const recorderRef = useRef<MediaRecorder | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const chunksRef = useRef<Blob[]>([]);
const startTimeRef = useRef<number>(0);
// Refs to avoid stale closures in MediaRecorder event handlers
const onStreamCleanupRef = useRef(onStreamCleanup);
const onFinishRef = useRef(onFinish);
const onErrorRef = useRef(onError);
useEffect(() => {
onStreamCleanupRef.current = onStreamCleanup;
onFinishRef.current = onFinish;
onErrorRef.current = onError;
});
const stopTracks = useCallback(() => {
streamRef.current?.getTracks().forEach((t) => t.stop());
streamRef.current = null;
recorderRef.current = null;
chunksRef.current = [];
onStreamCleanupRef.current();
}, []);
const startRecording = useCallback(async () => {
try {
const constraints =
mode === "video" ? { video: true, audio: true } : { audio: true };
const mediaStream =
await navigator.mediaDevices.getUserMedia(constraints);
streamRef.current = mediaStream;
onStreamReady(mediaStream);
chunksRef.current = [];
startTimeRef.current = Date.now();
const mime = getMediaMime(mode);
const recorder = new MediaRecorder(mediaStream, { mimeType: mime });
recorderRef.current = recorder;
recorder.ondataavailable = (e) => {
if (e.data.size > 0) chunksRef.current.push(e.data);
};
recorder.onstop = () => {
const durationMs = Date.now() - startTimeRef.current;
const blob = new Blob(chunksRef.current, { type: mime });
stopTracks();
if (blob.size > 0) {
onFinishRef.current(blob, durationMs, mime);
}
};
recorder.start();
} catch (err) {
stopTracks();
onErrorRef.current(
err instanceof Error ? err.message : "Failed to start recording",
);
}
}, [mode, onStreamReady, stopTracks]);
const stopRecording = useCallback(() => {
if (recorderRef.current?.state === "recording") {
recorderRef.current.stop();
}
}, []);
const cancelRecording = useCallback(() => {
if (recorderRef.current) {
recorderRef.current.ondataavailable = null;
recorderRef.current.onstop = null;
if (recorderRef.current.state === "recording") {
recorderRef.current.stop();
}
}
stopTracks();
}, [stopTracks]);
useEffect(() => {
return () => stopTracks();
}, [stopTracks]);
return { startRecording, stopRecording, cancelRecording };
}