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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user