introduce stream compose flow

This commit is contained in:
talksik
2026-03-18 14:52:22 -07:00
parent 092d142a56
commit d316ba1e09
14 changed files with 616 additions and 340 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
import { useEffect } from "react";
import { HashRouter, Routes, Route, Outlet } from "react-router-dom";
import { HashRouter, Routes, Route } from "react-router-dom";
import { TooltipProvider } from "@/components/ui/tooltip";
import { useAuthStore } from "@/stores/auth-store";
import { LoginPage } from "@/features/auth/login-page";
@@ -0,0 +1,49 @@
import { useComposeStore } from "@/stores/compose-store";
import { RecordingOverlay } from "@/features/compose/recording-overlay";
import { TextComposeStep } from "@/features/compose/text-compose-step";
import { ConfigureStreamStep } from "@/features/compose/configure-stream-step";
/**
* Renders the current compose step as a fullscreen overlay.
* Returns null when idle — zero cost when not composing.
*/
export function ComposeOverlay() {
const step = useComposeStore((s) => s.step);
const cancel = useComposeStore((s) => s.cancel);
const textContent = useComposeStore((s) => s.textContent);
const setTextContent = useComposeStore((s) => s.setTextContent);
const advanceToConfigure = useComposeStore((s) => s.advanceToConfigure);
const networkId = useComposeStore((s) => s.networkId);
const mediaStream = useComposeStore((s) => s.mediaStream);
const recordingMode = useComposeStore((s) => s.recordingMode);
const reviewBlob = useComposeStore((s) => s.reviewBlob);
const error = useComposeStore((s) => s.error);
if (step === "idle") return null;
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={advanceToConfigure}
onCancel={cancel}
/>
)}
{step === "configuring" && (
<ConfigureStreamStep networkId={networkId} onCancel={cancel} />
)}
</>
);
}
@@ -0,0 +1,196 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { useNetworks } from "@/hooks/use-networks";
import { cn } from "@/lib/utils";
import { Check } from "lucide-react";
interface ConfigureStreamStepProps {
networkId: string | null;
onCancel: () => void;
}
export function ConfigureStreamStep({
networkId,
onCancel,
}: ConfigureStreamStepProps) {
const { data: networks } = useNetworks();
const network = networks?.find((n) => n.id === networkId);
const members = network?.humans ?? [];
const [name, setName] = useState("");
const [selectedEmails, setSelectedEmails] = useState<Set<string>>(new Set());
// -1 = name input is focused, 0+ = member list index
const [focusedIndex, setFocusedIndex] = useState(-1);
const nameRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
nameRef.current?.focus();
}, []);
// Return focus to the name input when navigating back up
useEffect(() => {
if (focusedIndex === -1) {
nameRef.current?.focus();
} else {
// Blur the input so arrow keys don't move the cursor
nameRef.current?.blur();
containerRef.current?.focus();
}
}, [focusedIndex]);
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 handleSubmit = useCallback(async () => {
if (!name.trim() || !networkId) return;
// TODO: create stream particle, then attach recorded/text content
onCancel();
}, [name, networkId, onCancel]);
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();
} else if (focusedIndex === -1 && name.trim() && members.length > 0) {
// Enter in name input → move to member list
e.preventDefault();
setFocusedIndex(0);
}
return;
case "ArrowDown":
e.preventDefault();
setFocusedIndex((i) => Math.min(i + 1, members.length - 1));
return;
case "ArrowUp":
e.preventDefault();
setFocusedIndex((i) => Math.max(i - 1, -1));
return;
case " ":
if (focusedIndex >= 0) {
e.preventDefault();
toggleMember(members[focusedIndex].email);
}
return;
}
},
[onCancel, handleSubmit, focusedIndex, members, name, toggleMember],
);
return (
<div
ref={containerRef}
className="absolute inset-0 z-50 flex items-center justify-center bg-black/90"
onKeyDown={handleKeyDown}
tabIndex={-1}
>
<div className="w-full max-w-sm space-y-4 px-6">
{/* Stream name */}
<div>
<label className="mb-1 block text-xs font-medium text-white/50">
Stream name
</label>
<input
ref={nameRef}
type="text"
value={name}
onChange={(e) => {
setName(e.target.value);
setFocusedIndex(-1);
}}
onFocus={() => setFocusedIndex(-1)}
placeholder="Give it a name..."
className="w-full rounded-md border border-white/10 bg-white/5 px-3 py-2 text-sm text-white placeholder-white/30 outline-none focus:border-white/30"
/>
</div>
{/* Member selection */}
{members.length > 0 && (
<div>
<label className="mb-1 block text-xs font-medium text-white/50">
Visible to
</label>
<div className="space-y-0.5 rounded-md border border-white/10 p-1">
{members.map((member, index) => {
const isSelected = selectedEmails.has(member.email);
const isFocused = focusedIndex === index;
const initials = member.email_prefix
.slice(0, 2)
.toUpperCase();
return (
<button
key={member.email}
type="button"
onClick={() => toggleMember(member.email)}
className={cn(
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm transition-colors",
isFocused
? "bg-white/10 text-white"
: "text-white/70 hover:bg-white/5",
)}
>
<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>
{isSelected && (
<Check className="h-3.5 w-3.5 text-white/70" />
)}
</button>
);
})}
</div>
</div>
)}
</div>
{/* Keyboard hints */}
<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">
</kbd>{" "}
navigate
</span>
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Space
</kbd>{" "}
toggle
</span>
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
+Enter
</kbd>{" "}
create
</span>
</div>
</div>
);
}
@@ -1,5 +1,5 @@
import { Video, Mic, ChevronDown } from "lucide-react";
import { useRecordingStore } from "@/stores/recording-store";
import { Video, Mic } from "lucide-react";
import { useComposeStore } from "@/stores/compose-store";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
@@ -7,8 +7,8 @@ interface ControlsIndicatorProps {
type: "reply" | "new";
}
export default function ControlsIndicator({ type }: ControlsIndicatorProps) {
const recordingMode = useRecordingStore((s) => s.recordingMode);
const setRecordingMode = useRecordingStore((s) => s.setRecordingMode);
const recordingMode = useComposeStore((s) => s.recordingMode);
const setRecordingMode = useComposeStore((s) => s.setRecordingMode);
return (
<div className="flex items-center gap-2 text-xs">
@@ -1,9 +1,14 @@
import { useEffect, useRef, useState } from "react";
import { useRecordingStore } from "@/stores/recording-store";
import type { ComposeStep, RecordingMode } from "@/stores/compose-store";
import { AudioLevelBars } from "@/components/audio/audio-level-bars";
import { useAudioSource } from "@/components/audio/use-audio-source";
interface RecordingOverlayProps {
step: ComposeStep;
mediaStream: MediaStream | null;
recordingMode: RecordingMode;
reviewBlob: Blob | null;
error: string | null;
onClose: () => void;
}
@@ -87,24 +92,17 @@ function ReviewPlayback({
);
}
export function RecordingOverlay({ onClose }: RecordingOverlayProps) {
const status = useRecordingStore((s) => s.status);
const mediaStream = useRecordingStore((s) => s.mediaStream);
const recordingMode = useRecordingStore((s) => s.recordingMode);
const reviewBlob = useRecordingStore((s) => s.reviewBlob);
export function RecordingOverlay({
step,
mediaStream,
recordingMode,
reviewBlob,
error,
onClose,
}: RecordingOverlayProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const hasBeenActiveRef = useRef(false);
const recordingAudioSource = useAudioSource(mediaStream ?? null);
// Track whether we've entered an active state at least once
if (
status === "recording" ||
status === "uploading" ||
status === "reviewing"
) {
hasBeenActiveRef.current = true;
}
// Set video srcObject for live preview
useEffect(() => {
if (videoRef.current && mediaStream && recordingMode === "video") {
@@ -112,26 +110,15 @@ export function RecordingOverlay({ onClose }: RecordingOverlayProps) {
}
}, [mediaStream, recordingMode]);
// Auto-close when status returns to idle after being active
useEffect(() => {
if (!hasBeenActiveRef.current) return;
if (status === "idle") {
onClose();
}
}, [status, onClose]);
// Auto-close after error with a brief delay
useEffect(() => {
if (status !== "error") return;
if (!error) return;
const timeout = setTimeout(onClose, 1500);
return () => clearTimeout(timeout);
}, [status, onClose]);
}, [error, onClose]);
const isUploading = status === "uploading";
const isReviewing = status === "reviewing";
const isRecording = status === "recording";
// Loading: status is recording but media stream hasn't arrived yet
const isReviewing = step === "reviewing";
const isRecording = step === "recording";
const isLoading = isRecording && !mediaStream;
return (
@@ -166,17 +153,9 @@ export function RecordingOverlay({ onClose }: RecordingOverlayProps) {
/>
)}
{/* Dimmed overlay when uploading */}
{isUploading && <div className="absolute inset-0 bg-black/60" />}
{/* Top center: recording indicator / uploading */}
{/* Top center: recording indicator */}
<div className="absolute top-8 z-10">
{isUploading ? (
<div className="flex items-center gap-2">
<span className="h-2.5 w-2.5 animate-pulse rounded-full bg-yellow-500" />
<span className="text-sm text-white/80">Sending...</span>
</div>
) : isRecording && !isLoading ? (
{isRecording && !isLoading ? (
<RecordingTimer />
) : isReviewing ? (
<div className="flex items-center gap-2">
@@ -185,9 +164,9 @@ export function RecordingOverlay({ onClose }: RecordingOverlayProps) {
) : null}
</div>
{/* Center: audio level bars (recording with active stream) */}
{/* Bottom center: audio level bars (recording with active stream) */}
{isRecording && recordingAudioSource && (
<div className="z-10">
<div className="z-10 absolute bottom-12">
<AudioLevelBars sourceNode={recordingAudioSource.sourceNode} />
</div>
)}
@@ -217,7 +196,7 @@ export function RecordingOverlay({ onClose }: RecordingOverlayProps) {
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Enter
</kbd>{" "}
to send
next
</span>
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
@@ -229,9 +208,9 @@ export function RecordingOverlay({ onClose }: RecordingOverlayProps) {
)}
{/* Error state */}
{status === "error" && (
{error && (
<div className="z-10 text-sm text-red-400">
{useRecordingStore.getState().error ?? "Recording failed"}
{error}
</div>
)}
</div>
@@ -1,11 +1,11 @@
import { useState, useRef, useEffect, useCallback } from "react";
import { useEffect, useRef, useCallback } from "react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/api/client";
import { useAppStore } from "@/stores/app-store";
interface TextComposeOverlayProps {
streamId: string;
onClose: () => void;
interface TextComposeStepProps {
textContent: string;
onTextChange: (text: string) => void;
onAdvance: () => void;
onCancel: () => void;
}
function getTextStyle(length: number) {
@@ -15,61 +15,41 @@ function getTextStyle(length: number) {
return { size: "text-lg", weight: "font-normal" };
}
export function TextComposeOverlay({
streamId,
onClose,
}: TextComposeOverlayProps) {
const [content, setContent] = useState("");
const [sending, setSending] = useState(false);
export function TextComposeStep({
textContent,
onTextChange,
onAdvance,
onCancel,
}: TextComposeStepProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const addParticleToStream = useAppStore((s) => s.addParticleToStream);
useEffect(() => {
textareaRef.current?.focus();
}, []);
const handleSend = useCallback(async () => {
const trimmed = content.trim();
if (!trimmed || sending) return;
setSending(true);
try {
const particle = await apiClient.createStreamParticle(streamId, {
type: "text",
data: { content: trimmed },
});
addParticleToStream(streamId, particle);
onClose();
} catch {
setSending(false);
}
}, [content, sending, streamId, addParticleToStream, onClose]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
onClose();
onCancel();
} else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
handleSend();
if (textContent.trim()) onAdvance();
}
},
[onClose, handleSend],
[onCancel, onAdvance, textContent],
);
const style = getTextStyle(content.length);
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={content}
onChange={(e) => setContent(e.target.value)}
value={textContent}
onChange={(e) => onTextChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a message..."
disabled={sending}
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,
@@ -86,9 +66,9 @@ export function TextComposeOverlay({
</span>
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Cmd+Enter
+Enter
</kbd>{" "}
send
next
</span>
</div>
</div>
@@ -0,0 +1,96 @@
import { useEffect, useCallback } from "react";
import { useComposeStore } from "@/stores/compose-store";
import { useRecorder } from "@/features/compose/use-recorder";
import { useParams } from "react-router-dom";
/**
* Global keyboard handler for the compose flow.
*
* Handles keys for idle, recording, and reviewing steps.
* The typing and configuring steps handle their own keyboard
* events via focused elements — this hook ignores those steps.
*/
export function useComposeKeyboard() {
const networkId = useParams()["networkId"];
const { startRecording, stopRecording, cancelRecording } = useRecorder();
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
const { step } = useComposeStore.getState();
// Typing and configuring steps own their focused keyboard events
if (step === "typing" || step === "configuring") return;
// Don't intercept if the user is typing in an unrelated input
const target = e.target as HTMLElement;
if (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable
) {
return;
}
switch (step) {
case "idle": {
if (!networkId) return;
if (e.key === "`" && !e.repeat) {
e.preventDefault();
useComposeStore.getState().startRecording(networkId);
startRecording();
} else if (e.key === "t" || e.key === "T") {
e.preventDefault();
useComposeStore.getState().startTyping(networkId);
}
break;
}
case "recording": {
if (e.key === "q" || e.key === "Q" || e.key === "Escape") {
e.preventDefault();
cancelRecording();
useComposeStore.getState().cancel();
}
break;
}
case "reviewing": {
if (e.key === "q" || e.key === "Q" || e.key === "Escape") {
e.preventDefault();
cancelRecording();
useComposeStore.getState().cancel();
} else if (e.key === "Enter") {
e.preventDefault();
useComposeStore.getState().advanceToConfigure();
}
break;
}
}
},
[networkId, startRecording, cancelRecording],
);
const handleKeyUp = useCallback(
(e: KeyboardEvent) => {
const { step } = useComposeStore.getState();
if (step === "recording" && e.key === "`") {
e.preventDefault();
stopRecording();
// finishRecording is called by the MediaRecorder onstop handler
// once the blob is ready — no need to call it here.
}
},
[stopRecording],
);
useEffect(() => {
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
};
}, [handleKeyDown, handleKeyUp]);
}
+109
View File
@@ -0,0 +1,109 @@
import { useCallback, useEffect, useRef } from "react";
import { useComposeStore } from "@/stores/compose-store";
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;
}
/**
* Manages MediaRecorder lifecycle and writes results to compose-store.
*
* Does NOT handle uploads or particle creation — that responsibility
* belongs to the configure step after the user finalizes stream metadata.
*/
export function useRecorder() {
const recorderRef = useRef<MediaRecorder | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const chunksRef = useRef<Blob[]>([]);
const startTimeRef = useRef<number>(0);
const recordingMode = useComposeStore((s) => s.recordingMode);
const setMediaStream = useComposeStore((s) => s.setMediaStream);
const setError = useComposeStore((s) => s.setError);
const finishRecording = useComposeStore((s) => s.finishRecording);
const stopTracks = useCallback(() => {
streamRef.current?.getTracks().forEach((t) => t.stop());
streamRef.current = null;
recorderRef.current = null;
chunksRef.current = [];
setMediaStream(null);
}, [setMediaStream]);
const startRecording = useCallback(async () => {
try {
const constraints =
recordingMode === "video"
? { video: true, audio: true }
: { audio: true };
const mediaStream =
await navigator.mediaDevices.getUserMedia(constraints);
streamRef.current = mediaStream;
setMediaStream(mediaStream);
chunksRef.current = [];
startTimeRef.current = Date.now();
const mime = getMediaMime(recordingMode);
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) {
finishRecording(blob, durationMs);
}
};
recorder.start();
} catch (err) {
stopTracks();
setError(
err instanceof Error ? err.message : "Failed to start recording",
);
}
}, [recordingMode, setMediaStream, setError, finishRecording, 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 };
}
+8 -1
View File
@@ -12,6 +12,8 @@ import {
} from "@/components/ui/breadcrumb";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { useNetworks } from "@/hooks/use-networks";
import { ComposeOverlay } from "@/features/compose/compose-overlay";
import { useComposeKeyboard } from "@/features/compose/use-compose-keyboard";
function NetworkBreadcrumbContent({ networkId }: { networkId: string }) {
const { data: networks } = useNetworks();
@@ -117,10 +119,15 @@ function TopBar() {
}
export default function LayoutWithPath() {
useComposeKeyboard();
return (
<div className="flex h-screen flex-col">
<TopBar />
<Outlet />
<div className="relative flex-1 overflow-hidden">
<Outlet />
<ComposeOverlay />
</div>
</div>
);
}
+1 -3
View File
@@ -11,8 +11,6 @@ export default function NetworkRoot() {
const path = particlePath(networkId!, []);
return (
<div className="flex-1 overflow-hidden">
<ParticleListView path={path} />
</div>
<ParticleListView path={path} />
);
}
@@ -1,6 +1,6 @@
import { useLiveParticleChildren } from "@/hooks/use-particle";
import type { ParticlePath } from "@/lib/particle-path";
import ControlsIndicator from "@/features/send/controls-indicator";
import ControlsIndicator from "@/features/compose/controls-indicator";
interface ParticleListViewProps {
path: ParticlePath;
-190
View File
@@ -1,190 +0,0 @@
import { useCallback, useEffect, useRef } from "react";
import { apiClient } from "@/api/client";
import { useAppStore } from "@/stores/app-store";
import { usePlaybackStore } from "@/stores/playback-store";
import { useRecordingStore } from "@/stores/recording-store";
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") {
if (MediaRecorder.isTypeSupported(AUDIO_PREFERRED_MIME))
return AUDIO_PREFERRED_MIME;
return AUDIO_FALLBACK_MIME;
}
if (MediaRecorder.isTypeSupported(VIDEO_PREFERRED_MIME))
return VIDEO_PREFERRED_MIME;
return VIDEO_FALLBACK_MIME;
}
export function useRecorder(
streamId: string | null,
networkId: string | null,
) {
const recorderRef = useRef<MediaRecorder | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const chunksRef = useRef<Blob[]>([]);
const startTimeRef = useRef<number>(0);
const mimeRef = useRef<string>("");
const recordingMode = useRecordingStore((s) => s.recordingMode);
const setStatus = useRecordingStore((s) => s.setStatus);
const setError = useRecordingStore((s) => s.setError);
const setMediaStream = useRecordingStore((s) => s.setMediaStream);
const setReviewBlob = useRecordingStore((s) => s.setReviewBlob);
const resetRecording = useRecordingStore((s) => s.reset);
const stopTracks = useCallback(() => {
streamRef.current?.getTracks().forEach((t) => t.stop());
streamRef.current = null;
recorderRef.current = null;
chunksRef.current = [];
setMediaStream(null);
}, [setMediaStream]);
const confirmSend = useCallback(async () => {
if (!streamId || !networkId) return;
const { reviewBlob, reviewDurationMs } = useRecordingStore.getState();
if (!reviewBlob) return;
setStatus("uploading");
try {
const mimeType = reviewBlob.type || VIDEO_FALLBACK_MIME;
const fileName = `recording-${Date.now()}.webm`;
const { object_id, upload_url } = await apiClient.prepareUpload({
network_id: networkId,
name: fileName,
content_type: mimeType,
content_length: reviewBlob.size,
});
await fetch(upload_url, {
method: "PUT",
headers: { "Content-Type": mimeType },
body: reviewBlob,
});
await apiClient.confirmUpload(object_id);
// const particle = await apiClient.createStreamParticle(streamId, {
// type: "media",
// data: {
// object_id,
// duration_ms: reviewDurationMs,
// mime_type: mimeType,
// },
// });
//
// addParticleToStream(streamId, particle);
// const playbackState = usePlaybackStore.getState();
// if (playbackState.streamId === streamId) {
// usePlaybackStore.setState({
// particles: [...playbackState.particles, particle],
// });
// }
resetRecording();
} catch (err) {
setError(err instanceof Error ? err.message : "Upload failed");
}
}, [streamId, networkId, setStatus, setError, resetRecording]);
const startRecording = useCallback(async () => {
const currentStatus = useRecordingStore.getState().status;
if (currentStatus !== "idle") return;
try {
const constraints =
recordingMode === "video"
? { video: true, audio: true }
: { audio: true };
setStatus("recording");
const mediaStream =
await navigator.mediaDevices.getUserMedia(constraints);
streamRef.current = mediaStream;
setMediaStream(mediaStream);
chunksRef.current = [];
startTimeRef.current = Date.now();
const mime = getMediaMime(recordingMode);
mimeRef.current = mime;
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) {
setReviewBlob(blob, durationMs);
} else {
resetRecording();
}
};
recorder.start();
} catch (err) {
stopTracks();
setError(
err instanceof Error ? err.message : "Failed to start recording",
);
}
}, [
recordingMode,
setStatus,
setError,
setMediaStream,
setReviewBlob,
stopTracks,
resetRecording,
]);
const stopRecording = useCallback(() => {
if (recorderRef.current?.state === "recording") {
recorderRef.current.stop();
}
}, []);
const cancelRecording = useCallback(() => {
const currentStatus = useRecordingStore.getState().status;
if (currentStatus === "reviewing") {
resetRecording();
return;
}
if (recorderRef.current) {
recorderRef.current.ondataavailable = null;
recorderRef.current.onstop = null;
if (recorderRef.current.state === "recording") {
recorderRef.current.stop();
}
}
stopTracks();
resetRecording();
}, [stopTracks, resetRecording]);
// Cleanup on unmount
useEffect(() => {
return () => {
stopTracks();
};
}, [stopTracks]);
return { startRecording, stopRecording, cancelRecording, confirmSend };
}
+106
View File
@@ -0,0 +1,106 @@
import { create } from "zustand";
export type ComposeStep =
| "idle"
| "recording"
| "reviewing"
| "typing"
| "configuring";
export type RecordingMode = "video" | "audio";
const RECORDING_MODE_KEY = "llink:recording-mode";
function loadRecordingMode(): RecordingMode {
const stored = localStorage.getItem(RECORDING_MODE_KEY);
return stored === "audio" ? "audio" : "video";
}
interface ComposeState {
// Flow
step: ComposeStep;
networkId: string | null;
error: string | null;
// Text compose
textContent: string;
// Recording
recordingMode: RecordingMode;
mediaStream: MediaStream | null;
reviewBlob: Blob | null;
reviewDurationMs: number;
// Transitions
startRecording: (networkId: string) => void;
finishRecording: (blob: Blob, durationMs: number) => void;
startTyping: (networkId: string) => void;
advanceToConfigure: () => void;
setTextContent: (text: string) => void;
cancel: () => void;
// Recording helpers (called by useRecorder)
setMediaStream: (stream: MediaStream | null) => void;
setRecordingMode: (mode: RecordingMode) => void;
setError: (error: string) => void;
}
export const useComposeStore = create<ComposeState>((set, get) => ({
step: "idle",
networkId: null,
error: null,
textContent: "",
recordingMode: loadRecordingMode(),
mediaStream: null,
reviewBlob: null,
reviewDurationMs: 0,
startRecording: (networkId) => {
if (get().step !== "idle") return;
set({
step: "recording",
networkId,
error: null,
reviewBlob: null,
reviewDurationMs: 0,
});
},
finishRecording: (blob, durationMs) => {
if (get().step !== "recording") return;
set({ step: "reviewing", reviewBlob: blob, reviewDurationMs: durationMs });
},
startTyping: (networkId) => {
if (get().step !== "idle") return;
set({ step: "typing", networkId, textContent: "", error: null });
},
advanceToConfigure: () => {
const { step } = get();
if (step !== "reviewing" && step !== "typing") return;
set({ step: "configuring" });
},
setTextContent: (textContent) => set({ textContent }),
cancel: () =>
set({
step: "idle",
networkId: null,
error: null,
textContent: "",
mediaStream: null,
reviewBlob: null,
reviewDurationMs: 0,
}),
setMediaStream: (mediaStream) => set({ mediaStream }),
setRecordingMode: (mode) => {
localStorage.setItem(RECORDING_MODE_KEY, mode);
set({ recordingMode: mode });
},
setError: (error) => set({ error }),
}));
-54
View File
@@ -1,54 +0,0 @@
import { create } from "zustand";
type RecordingStatus = "idle" | "recording" | "reviewing" | "uploading" | "error";
type RecordingMode = "video" | "audio";
const RECORDING_MODE_KEY = "llink:recording-mode";
function loadRecordingMode(): RecordingMode {
const stored = localStorage.getItem(RECORDING_MODE_KEY);
return stored === "audio" ? "audio" : "video";
}
interface RecordingState {
status: RecordingStatus;
error: string | null;
mediaStream: MediaStream | null;
recordingMode: RecordingMode;
reviewBlob: Blob | null;
reviewDurationMs: number;
setStatus: (status: RecordingStatus) => void;
setError: (error: string) => void;
setMediaStream: (stream: MediaStream | null) => void;
setRecordingMode: (mode: RecordingMode) => void;
setReviewBlob: (blob: Blob, durationMs: number) => void;
reset: () => void;
}
export const useRecordingStore = create<RecordingState>((set) => ({
status: "idle",
error: null,
mediaStream: null,
recordingMode: loadRecordingMode(),
reviewBlob: null,
reviewDurationMs: 0,
setStatus: (status) => set({ status, error: null }),
setError: (error) => set({ status: "error", error }),
setMediaStream: (mediaStream) => set({ mediaStream }),
setRecordingMode: (recordingMode) => {
localStorage.setItem(RECORDING_MODE_KEY, recordingMode);
set({ recordingMode });
},
setReviewBlob: (reviewBlob, reviewDurationMs) =>
set({ status: "reviewing", reviewBlob, reviewDurationMs }),
reset: () =>
set({
status: "idle",
error: null,
mediaStream: null,
reviewBlob: null,
reviewDurationMs: 0,
}),
}));