introduce stream compose flow
This commit is contained in:
+1
-1
@@ -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>
|
||||
);
|
||||
}
|
||||
+4
-4
@@ -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">
|
||||
+25
-46
@@ -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>
|
||||
+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 { 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]);
|
||||
}
|
||||
@@ -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";
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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