introduce stream compose flow
This commit is contained in:
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect } from "react";
|
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 { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { LoginPage } from "@/features/auth/login-page";
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+4
-4
@@ -1,5 +1,5 @@
|
|||||||
import { Video, Mic, ChevronDown } from "lucide-react";
|
import { Video, Mic } from "lucide-react";
|
||||||
import { useRecordingStore } from "@/stores/recording-store";
|
import { useComposeStore } from "@/stores/compose-store";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
@@ -7,8 +7,8 @@ interface ControlsIndicatorProps {
|
|||||||
type: "reply" | "new";
|
type: "reply" | "new";
|
||||||
}
|
}
|
||||||
export default function ControlsIndicator({ type }: ControlsIndicatorProps) {
|
export default function ControlsIndicator({ type }: ControlsIndicatorProps) {
|
||||||
const recordingMode = useRecordingStore((s) => s.recordingMode);
|
const recordingMode = useComposeStore((s) => s.recordingMode);
|
||||||
const setRecordingMode = useRecordingStore((s) => s.setRecordingMode);
|
const setRecordingMode = useComposeStore((s) => s.setRecordingMode);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 text-xs">
|
<div className="flex items-center gap-2 text-xs">
|
||||||
+25
-46
@@ -1,9 +1,14 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
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 { AudioLevelBars } from "@/components/audio/audio-level-bars";
|
||||||
import { useAudioSource } from "@/components/audio/use-audio-source";
|
import { useAudioSource } from "@/components/audio/use-audio-source";
|
||||||
|
|
||||||
interface RecordingOverlayProps {
|
interface RecordingOverlayProps {
|
||||||
|
step: ComposeStep;
|
||||||
|
mediaStream: MediaStream | null;
|
||||||
|
recordingMode: RecordingMode;
|
||||||
|
reviewBlob: Blob | null;
|
||||||
|
error: string | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,24 +92,17 @@ function ReviewPlayback({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RecordingOverlay({ onClose }: RecordingOverlayProps) {
|
export function RecordingOverlay({
|
||||||
const status = useRecordingStore((s) => s.status);
|
step,
|
||||||
const mediaStream = useRecordingStore((s) => s.mediaStream);
|
mediaStream,
|
||||||
const recordingMode = useRecordingStore((s) => s.recordingMode);
|
recordingMode,
|
||||||
const reviewBlob = useRecordingStore((s) => s.reviewBlob);
|
reviewBlob,
|
||||||
|
error,
|
||||||
|
onClose,
|
||||||
|
}: RecordingOverlayProps) {
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const hasBeenActiveRef = useRef(false);
|
|
||||||
const recordingAudioSource = useAudioSource(mediaStream ?? null);
|
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
|
// Set video srcObject for live preview
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (videoRef.current && mediaStream && recordingMode === "video") {
|
if (videoRef.current && mediaStream && recordingMode === "video") {
|
||||||
@@ -112,26 +110,15 @@ export function RecordingOverlay({ onClose }: RecordingOverlayProps) {
|
|||||||
}
|
}
|
||||||
}, [mediaStream, recordingMode]);
|
}, [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
|
// Auto-close after error with a brief delay
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status !== "error") return;
|
if (!error) return;
|
||||||
const timeout = setTimeout(onClose, 1500);
|
const timeout = setTimeout(onClose, 1500);
|
||||||
return () => clearTimeout(timeout);
|
return () => clearTimeout(timeout);
|
||||||
}, [status, onClose]);
|
}, [error, onClose]);
|
||||||
|
|
||||||
const isUploading = status === "uploading";
|
const isReviewing = step === "reviewing";
|
||||||
const isReviewing = status === "reviewing";
|
const isRecording = step === "recording";
|
||||||
const isRecording = status === "recording";
|
|
||||||
|
|
||||||
// Loading: status is recording but media stream hasn't arrived yet
|
|
||||||
const isLoading = isRecording && !mediaStream;
|
const isLoading = isRecording && !mediaStream;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -166,17 +153,9 @@ export function RecordingOverlay({ onClose }: RecordingOverlayProps) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Dimmed overlay when uploading */}
|
{/* Top center: recording indicator */}
|
||||||
{isUploading && <div className="absolute inset-0 bg-black/60" />}
|
|
||||||
|
|
||||||
{/* Top center: recording indicator / uploading */}
|
|
||||||
<div className="absolute top-8 z-10">
|
<div className="absolute top-8 z-10">
|
||||||
{isUploading ? (
|
{isRecording && !isLoading ? (
|
||||||
<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 ? (
|
|
||||||
<RecordingTimer />
|
<RecordingTimer />
|
||||||
) : isReviewing ? (
|
) : isReviewing ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -185,9 +164,9 @@ export function RecordingOverlay({ onClose }: RecordingOverlayProps) {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Center: audio level bars (recording with active stream) */}
|
{/* Bottom center: audio level bars (recording with active stream) */}
|
||||||
{isRecording && recordingAudioSource && (
|
{isRecording && recordingAudioSource && (
|
||||||
<div className="z-10">
|
<div className="z-10 absolute bottom-12">
|
||||||
<AudioLevelBars sourceNode={recordingAudioSource.sourceNode} />
|
<AudioLevelBars sourceNode={recordingAudioSource.sourceNode} />
|
||||||
</div>
|
</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">
|
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||||
Enter
|
Enter
|
||||||
</kbd>{" "}
|
</kbd>{" "}
|
||||||
to send
|
next
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
<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 */}
|
{/* Error state */}
|
||||||
{status === "error" && (
|
{error && (
|
||||||
<div className="z-10 text-sm text-red-400">
|
<div className="z-10 text-sm text-red-400">
|
||||||
{useRecordingStore.getState().error ?? "Recording failed"}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
+20
-40
@@ -1,11 +1,11 @@
|
|||||||
import { useState, useRef, useEffect, useCallback } from "react";
|
import { useEffect, useRef, useCallback } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { apiClient } from "@/api/client";
|
|
||||||
import { useAppStore } from "@/stores/app-store";
|
|
||||||
|
|
||||||
interface TextComposeOverlayProps {
|
interface TextComposeStepProps {
|
||||||
streamId: string;
|
textContent: string;
|
||||||
onClose: () => void;
|
onTextChange: (text: string) => void;
|
||||||
|
onAdvance: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTextStyle(length: number) {
|
function getTextStyle(length: number) {
|
||||||
@@ -15,61 +15,41 @@ function getTextStyle(length: number) {
|
|||||||
return { size: "text-lg", weight: "font-normal" };
|
return { size: "text-lg", weight: "font-normal" };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TextComposeOverlay({
|
export function TextComposeStep({
|
||||||
streamId,
|
textContent,
|
||||||
onClose,
|
onTextChange,
|
||||||
}: TextComposeOverlayProps) {
|
onAdvance,
|
||||||
const [content, setContent] = useState("");
|
onCancel,
|
||||||
const [sending, setSending] = useState(false);
|
}: TextComposeStepProps) {
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const addParticleToStream = useAppStore((s) => s.addParticleToStream);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
textareaRef.current?.focus();
|
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(
|
const handleKeyDown = useCallback(
|
||||||
(e: React.KeyboardEvent) => {
|
(e: React.KeyboardEvent) => {
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
onCancel();
|
||||||
onClose();
|
|
||||||
} else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
} else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleSend();
|
if (textContent.trim()) onAdvance();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onClose, handleSend],
|
[onCancel, onAdvance, textContent],
|
||||||
);
|
);
|
||||||
|
|
||||||
const style = getTextStyle(content.length);
|
const style = getTextStyle(textContent.length);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-black/90">
|
<div className="absolute inset-0 z-50 flex items-center justify-center bg-black/90">
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
value={content}
|
value={textContent}
|
||||||
onChange={(e) => setContent(e.target.value)}
|
onChange={(e) => onTextChange(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder="Type a message..."
|
placeholder="Type a message..."
|
||||||
disabled={sending}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full max-w-2xl resize-none border-none bg-transparent p-8 text-center text-white placeholder-white/40 outline-none",
|
"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.size,
|
||||||
@@ -86,9 +66,9 @@ export function TextComposeOverlay({
|
|||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||||
Cmd+Enter
|
⌘+Enter
|
||||||
</kbd>{" "}
|
</kbd>{" "}
|
||||||
send
|
next
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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]);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
} from "@/components/ui/breadcrumb";
|
} from "@/components/ui/breadcrumb";
|
||||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
import { useNetworks } from "@/hooks/use-networks";
|
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 }) {
|
function NetworkBreadcrumbContent({ networkId }: { networkId: string }) {
|
||||||
const { data: networks } = useNetworks();
|
const { data: networks } = useNetworks();
|
||||||
@@ -117,10 +119,15 @@ function TopBar() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function LayoutWithPath() {
|
export default function LayoutWithPath() {
|
||||||
|
useComposeKeyboard();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen flex-col">
|
<div className="flex h-screen flex-col">
|
||||||
<TopBar />
|
<TopBar />
|
||||||
<Outlet />
|
<div className="relative flex-1 overflow-hidden">
|
||||||
|
<Outlet />
|
||||||
|
<ComposeOverlay />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ export default function NetworkRoot() {
|
|||||||
const path = particlePath(networkId!, []);
|
const path = particlePath(networkId!, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 overflow-hidden">
|
<ParticleListView path={path} />
|
||||||
<ParticleListView path={path} />
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useLiveParticleChildren } from "@/hooks/use-particle";
|
import { useLiveParticleChildren } from "@/hooks/use-particle";
|
||||||
import type { ParticlePath } from "@/lib/particle-path";
|
import type { ParticlePath } from "@/lib/particle-path";
|
||||||
import ControlsIndicator from "@/features/send/controls-indicator";
|
import ControlsIndicator from "@/features/compose/controls-indicator";
|
||||||
|
|
||||||
interface ParticleListViewProps {
|
interface ParticleListViewProps {
|
||||||
path: ParticlePath;
|
path: ParticlePath;
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
}
|
|
||||||
@@ -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 }),
|
||||||
|
}));
|
||||||
@@ -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,
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
Reference in New Issue
Block a user