feat: compose new stream full flow
This commit is contained in:
@@ -0,0 +1,31 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Checkbox as CheckboxPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { CheckIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Checkbox({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
data-slot="checkbox"
|
||||||
|
className={cn(
|
||||||
|
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator
|
||||||
|
data-slot="checkbox-indicator"
|
||||||
|
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
|
||||||
|
>
|
||||||
|
<CheckIcon
|
||||||
|
/>
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Checkbox }
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
import { useComposeStore } from "@/stores/compose-store";
|
import { useComposeStore } from "@/stores/compose-store";
|
||||||
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
|
import { useCreateParticle } from "@/hooks/use-create-particle";
|
||||||
|
import { particlePath, toFirestoreChildrenPath } from "@/lib/particle-path";
|
||||||
import { RecordingOverlay } from "@/features/compose/recording-overlay";
|
import { RecordingOverlay } from "@/features/compose/recording-overlay";
|
||||||
import { TextComposeStep } from "@/features/compose/text-compose-step";
|
import { TextComposeStep } from "@/features/compose/text-compose-step";
|
||||||
import { ConfigureStreamStep } from "@/features/compose/configure-stream-step";
|
import { ConfigureStreamStep } from "@/features/compose/configure-stream-step";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders the current compose step as a fullscreen overlay.
|
* Renders the current compose step as a fullscreen overlay.
|
||||||
@@ -19,6 +23,31 @@ export function ComposeOverlay() {
|
|||||||
const reviewBlob = useComposeStore((s) => s.reviewBlob);
|
const reviewBlob = useComposeStore((s) => s.reviewBlob);
|
||||||
const error = useComposeStore((s) => s.error);
|
const error = useComposeStore((s) => s.error);
|
||||||
|
|
||||||
|
const userEmail = useAuthStore((s) => s.user?.email);
|
||||||
|
const createParticle = useCreateParticle();
|
||||||
|
|
||||||
|
const handleStreamSubmit = useCallback(
|
||||||
|
async (streamName: string, visibleTo: string[]) => {
|
||||||
|
if (!networkId || !userEmail) return;
|
||||||
|
|
||||||
|
const collectionPath = toFirestoreChildrenPath(particlePath(networkId));
|
||||||
|
|
||||||
|
await createParticle.mutateAsync({
|
||||||
|
collectionPath,
|
||||||
|
type: "stream",
|
||||||
|
properties: {
|
||||||
|
name: streamName,
|
||||||
|
status: "open",
|
||||||
|
visible_to: visibleTo,
|
||||||
|
},
|
||||||
|
createdByEmail: userEmail,
|
||||||
|
});
|
||||||
|
|
||||||
|
cancel();
|
||||||
|
},
|
||||||
|
[networkId, userEmail, createParticle, cancel],
|
||||||
|
);
|
||||||
|
|
||||||
if (step === "idle") return null;
|
if (step === "idle") return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -42,7 +71,11 @@ export function ComposeOverlay() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{step === "configuring" && (
|
{step === "configuring" && (
|
||||||
<ConfigureStreamStep networkId={networkId} onCancel={cancel} />
|
<ConfigureStreamStep
|
||||||
|
networkId={networkId}
|
||||||
|
onCancel={cancel}
|
||||||
|
onSubmit={handleStreamSubmit}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,43 +1,30 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { useNetworks } from "@/hooks/use-networks";
|
import { useNetworks } from "@/hooks/use-networks";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Check } from "lucide-react";
|
import { generateRandomName } from "@/lib/random-name";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
|
||||||
interface ConfigureStreamStepProps {
|
interface ConfigureStreamStepProps {
|
||||||
networkId: string | null;
|
networkId: string | null;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
|
onSubmit: (streamName: string, visibleTo: string[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ConfigureStreamStep({
|
export function ConfigureStreamStep({
|
||||||
networkId,
|
networkId,
|
||||||
onCancel,
|
onCancel,
|
||||||
|
onSubmit,
|
||||||
}: ConfigureStreamStepProps) {
|
}: ConfigureStreamStepProps) {
|
||||||
|
|
||||||
const { data: networks } = useNetworks();
|
const { data: networks } = useNetworks();
|
||||||
const network = networks?.find((n) => n.id === networkId);
|
const network = networks?.find((n) => n.id === networkId);
|
||||||
const members = network?.humans ?? [];
|
const members = network?.humans ?? [];
|
||||||
|
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState(() => generateRandomName());
|
||||||
|
const [everyone, setEveryone] = useState(true);
|
||||||
const [selectedEmails, setSelectedEmails] = useState<Set<string>>(new Set());
|
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) => {
|
const toggleMember = useCallback((email: string) => {
|
||||||
setSelectedEmails((prev) => {
|
setSelectedEmails((prev) => {
|
||||||
@@ -48,11 +35,15 @@ export function ConfigureStreamStep({
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSubmit = useCallback(async () => {
|
const buildVisibleTo = useCallback((): string[] => {
|
||||||
|
if (everyone && networkId) return [`network:${networkId}`];
|
||||||
|
return Array.from(selectedEmails).map((e) => `human:${e}`);
|
||||||
|
}, [everyone, networkId, selectedEmails]);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(() => {
|
||||||
if (!name.trim() || !networkId) return;
|
if (!name.trim() || !networkId) return;
|
||||||
// TODO: create stream particle, then attach recorded/text content
|
onSubmit(name.trim(), buildVisibleTo());
|
||||||
onCancel();
|
}, [name, networkId, onSubmit, buildVisibleTo]);
|
||||||
}, [name, networkId, onCancel]);
|
|
||||||
|
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(e: React.KeyboardEvent) => {
|
(e: React.KeyboardEvent) => {
|
||||||
@@ -66,124 +57,105 @@ export function ConfigureStreamStep({
|
|||||||
if (e.metaKey || e.ctrlKey) {
|
if (e.metaKey || e.ctrlKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleSubmit();
|
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;
|
return;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onCancel, handleSubmit, focusedIndex, members, name, toggleMember],
|
[onCancel, handleSubmit, members, name, toggleMember],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
className="absolute inset-0 z-50 flex flex-col bg-black/90 pt-16"
|
||||||
className="absolute inset-0 z-50 flex items-center justify-center bg-black/90"
|
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
<div className="w-full max-w-sm space-y-4 px-6">
|
<div className="mx-auto w-full max-w-sm space-y-4 px-6">
|
||||||
{/* Stream name */}
|
{/* Stream name */}
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs font-medium text-white/50">
|
<Label className="mb-1 text-xs text-white/50">Stream name</Label>
|
||||||
Stream name
|
<Input
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
ref={nameRef}
|
|
||||||
type="text"
|
type="text"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setName(e.target.value);
|
setName(e.target.value);
|
||||||
setFocusedIndex(-1);
|
|
||||||
}}
|
}}
|
||||||
onFocus={() => setFocusedIndex(-1)}
|
|
||||||
placeholder="Give it a name..."
|
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"
|
className="border-white/10 bg-white/5 text-white placeholder-white/30 focus-visible:border-white/30 focus-visible:ring-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Member selection */}
|
{/* Visibility */}
|
||||||
{members.length > 0 && (
|
<div>
|
||||||
<div>
|
<Label className="mb-1 text-xs text-white/50">Visible to</Label>
|
||||||
<label className="mb-1 block text-xs font-medium text-white/50">
|
<div className="rounded-md border border-white/10">
|
||||||
Visible to
|
{/* Everyone in network */}
|
||||||
</label>
|
<div
|
||||||
<div className="space-y-0.5 rounded-md border border-white/10 p-1">
|
role="button"
|
||||||
{members.map((member, index) => {
|
onClick={() => setEveryone((prev) => !prev)}
|
||||||
const isSelected = selectedEmails.has(member.email);
|
className={cn(
|
||||||
const isFocused = focusedIndex === index;
|
"flex w-full cursor-pointer items-center gap-2.5 px-3 py-2 text-left text-sm transition-colors",
|
||||||
const initials = member.email_prefix
|
"text-white/70 hover:bg-white/5",
|
||||||
.slice(0, 2)
|
)}
|
||||||
.toUpperCase();
|
>
|
||||||
|
<Checkbox
|
||||||
return (
|
checked={everyone}
|
||||||
<button
|
onCheckedChange={(checked) => setEveryone(checked === true)}
|
||||||
key={member.email}
|
tabIndex={-1}
|
||||||
type="button"
|
className="pointer-events-none"
|
||||||
onClick={() => toggleMember(member.email)}
|
/>
|
||||||
className={cn(
|
<span className="font-medium">Everyone in network</span>
|
||||||
"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>
|
||||||
|
|
||||||
|
{/* Per-member selection */}
|
||||||
|
{!everyone && members.length > 0 && (
|
||||||
|
<ScrollArea className="max-h-48">
|
||||||
|
<div className="space-y-0.5 p-1">
|
||||||
|
{members.map((member, index) => {
|
||||||
|
const isSelected = selectedEmails.has(member.email);
|
||||||
|
const initials = member.email_prefix
|
||||||
|
.slice(0, 2)
|
||||||
|
.toUpperCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={member.email}
|
||||||
|
role="button"
|
||||||
|
onClick={() => toggleMember(member.email)}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full cursor-pointer items-center gap-2.5 rounded px-3 py-1.5 text-left text-sm transition-colors",
|
||||||
|
"text-white/70 hover:bg-white/5",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={isSelected}
|
||||||
|
tabIndex={-1}
|
||||||
|
className="pointer-events-none"
|
||||||
|
/>
|
||||||
|
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-white/10 text-[10px] font-medium">
|
||||||
|
{initials}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 truncate">
|
||||||
|
{member.email_prefix}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Keyboard hints */}
|
{/* Keyboard hints */}
|
||||||
<div className="absolute bottom-8 flex items-center gap-4 text-sm text-white/50">
|
<div className="absolute bottom-8 left-0 right-0 flex items-center justify-center gap-4 text-sm text-white/50">
|
||||||
<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">
|
||||||
Esc
|
Esc
|
||||||
</kbd>{" "}
|
</kbd>{" "}
|
||||||
cancel
|
cancel
|
||||||
</span>
|
</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>
|
<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">
|
||||||
⌘+Enter
|
⌘+Enter
|
||||||
@@ -191,6 +163,6 @@ export function ConfigureStreamStep({
|
|||||||
create
|
create
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div >
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,8 +12,22 @@ import { useParams } from "react-router-dom";
|
|||||||
*/
|
*/
|
||||||
export function useComposeKeyboard() {
|
export function useComposeKeyboard() {
|
||||||
const networkId = useParams()["networkId"];
|
const networkId = useParams()["networkId"];
|
||||||
|
const recordingMode = useComposeStore((s) => s.recordingMode);
|
||||||
|
const setMediaStream = useComposeStore((s) => s.setMediaStream);
|
||||||
|
const finishRecording = useComposeStore((s) => s.finishRecording);
|
||||||
|
const setError = useComposeStore((s) => s.setError);
|
||||||
|
const beginRecording = useComposeStore((s) => s.startRecording);
|
||||||
|
const beginTyping = useComposeStore((s) => s.startTyping);
|
||||||
|
const cancel = useComposeStore((s) => s.cancel);
|
||||||
|
const advanceToConfigure = useComposeStore((s) => s.advanceToConfigure);
|
||||||
|
|
||||||
const { startRecording, stopRecording, cancelRecording } = useRecorder();
|
const { startRecording, stopRecording, cancelRecording } = useRecorder({
|
||||||
|
mode: recordingMode,
|
||||||
|
onStreamReady: (stream) => setMediaStream(stream),
|
||||||
|
onStreamCleanup: () => setMediaStream(null),
|
||||||
|
onFinish: (blob, durationMs) => finishRecording(blob, durationMs),
|
||||||
|
onError: (message) => setError(message),
|
||||||
|
});
|
||||||
|
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(e: KeyboardEvent) => {
|
(e: KeyboardEvent) => {
|
||||||
@@ -37,11 +51,11 @@ export function useComposeKeyboard() {
|
|||||||
if (!networkId) return;
|
if (!networkId) return;
|
||||||
if (e.key === "`" && !e.repeat) {
|
if (e.key === "`" && !e.repeat) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
useComposeStore.getState().startRecording(networkId);
|
beginRecording(networkId);
|
||||||
startRecording();
|
startRecording();
|
||||||
} else if (e.key === "t" || e.key === "T") {
|
} else if (e.key === "t" || e.key === "T") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
useComposeStore.getState().startTyping(networkId);
|
beginTyping(networkId);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -50,7 +64,7 @@ export function useComposeKeyboard() {
|
|||||||
if (e.key === "q" || e.key === "Q" || e.key === "Escape") {
|
if (e.key === "q" || e.key === "Q" || e.key === "Escape") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
cancelRecording();
|
cancelRecording();
|
||||||
useComposeStore.getState().cancel();
|
cancel();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -59,16 +73,16 @@ export function useComposeKeyboard() {
|
|||||||
if (e.key === "q" || e.key === "Q" || e.key === "Escape") {
|
if (e.key === "q" || e.key === "Q" || e.key === "Escape") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
cancelRecording();
|
cancelRecording();
|
||||||
useComposeStore.getState().cancel();
|
cancel();
|
||||||
} else if (e.key === "Enter") {
|
} else if (e.key === "Enter") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
useComposeStore.getState().advanceToConfigure();
|
advanceToConfigure();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[networkId, startRecording, cancelRecording],
|
[networkId, startRecording, cancelRecording, beginRecording, beginTyping, cancel, advanceToConfigure],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleKeyUp = useCallback(
|
const handleKeyUp = useCallback(
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useRef } from "react";
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
import { useComposeStore } from "@/stores/compose-store";
|
import type { RecordingMode } from "@/stores/compose-store";
|
||||||
|
|
||||||
const VIDEO_PREFERRED_MIME = "video/webm;codecs=vp9,opus";
|
const VIDEO_PREFERRED_MIME = "video/webm;codecs=vp9,opus";
|
||||||
const VIDEO_FALLBACK_MIME = "video/webm";
|
const VIDEO_FALLBACK_MIME = "video/webm";
|
||||||
@@ -17,47 +17,62 @@ function getMediaMime(mode: "video" | "audio"): string {
|
|||||||
: VIDEO_FALLBACK_MIME;
|
: VIDEO_FALLBACK_MIME;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface UseRecorderOptions {
|
||||||
|
mode: RecordingMode;
|
||||||
|
onStreamReady: (stream: MediaStream) => void;
|
||||||
|
onStreamCleanup: () => void;
|
||||||
|
onFinish: (blob: Blob, durationMs: number) => void;
|
||||||
|
onError: (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages MediaRecorder lifecycle and writes results to compose-store.
|
* Manages MediaRecorder lifecycle. Pure media utility — knows nothing
|
||||||
*
|
* about application state. The consumer provides callbacks for all outputs.
|
||||||
* Does NOT handle uploads or particle creation — that responsibility
|
|
||||||
* belongs to the configure step after the user finalizes stream metadata.
|
|
||||||
*/
|
*/
|
||||||
export function useRecorder() {
|
export function useRecorder({
|
||||||
|
mode,
|
||||||
|
onStreamReady,
|
||||||
|
onStreamCleanup,
|
||||||
|
onFinish,
|
||||||
|
onError,
|
||||||
|
}: UseRecorderOptions) {
|
||||||
const recorderRef = useRef<MediaRecorder | null>(null);
|
const recorderRef = useRef<MediaRecorder | null>(null);
|
||||||
const streamRef = useRef<MediaStream | null>(null);
|
const streamRef = useRef<MediaStream | null>(null);
|
||||||
const chunksRef = useRef<Blob[]>([]);
|
const chunksRef = useRef<Blob[]>([]);
|
||||||
const startTimeRef = useRef<number>(0);
|
const startTimeRef = useRef<number>(0);
|
||||||
|
|
||||||
const recordingMode = useComposeStore((s) => s.recordingMode);
|
// Refs to avoid stale closures in MediaRecorder event handlers
|
||||||
const setMediaStream = useComposeStore((s) => s.setMediaStream);
|
const onStreamCleanupRef = useRef(onStreamCleanup);
|
||||||
const setError = useComposeStore((s) => s.setError);
|
const onFinishRef = useRef(onFinish);
|
||||||
const finishRecording = useComposeStore((s) => s.finishRecording);
|
const onErrorRef = useRef(onError);
|
||||||
|
useEffect(() => {
|
||||||
|
onStreamCleanupRef.current = onStreamCleanup;
|
||||||
|
onFinishRef.current = onFinish;
|
||||||
|
onErrorRef.current = onError;
|
||||||
|
});
|
||||||
|
|
||||||
const stopTracks = useCallback(() => {
|
const stopTracks = useCallback(() => {
|
||||||
streamRef.current?.getTracks().forEach((t) => t.stop());
|
streamRef.current?.getTracks().forEach((t) => t.stop());
|
||||||
streamRef.current = null;
|
streamRef.current = null;
|
||||||
recorderRef.current = null;
|
recorderRef.current = null;
|
||||||
chunksRef.current = [];
|
chunksRef.current = [];
|
||||||
setMediaStream(null);
|
onStreamCleanupRef.current();
|
||||||
}, [setMediaStream]);
|
}, []);
|
||||||
|
|
||||||
const startRecording = useCallback(async () => {
|
const startRecording = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const constraints =
|
const constraints =
|
||||||
recordingMode === "video"
|
mode === "video" ? { video: true, audio: true } : { audio: true };
|
||||||
? { video: true, audio: true }
|
|
||||||
: { audio: true };
|
|
||||||
|
|
||||||
const mediaStream =
|
const mediaStream =
|
||||||
await navigator.mediaDevices.getUserMedia(constraints);
|
await navigator.mediaDevices.getUserMedia(constraints);
|
||||||
|
|
||||||
streamRef.current = mediaStream;
|
streamRef.current = mediaStream;
|
||||||
setMediaStream(mediaStream);
|
onStreamReady(mediaStream);
|
||||||
chunksRef.current = [];
|
chunksRef.current = [];
|
||||||
startTimeRef.current = Date.now();
|
startTimeRef.current = Date.now();
|
||||||
|
|
||||||
const mime = getMediaMime(recordingMode);
|
const mime = getMediaMime(mode);
|
||||||
const recorder = new MediaRecorder(mediaStream, { mimeType: mime });
|
const recorder = new MediaRecorder(mediaStream, { mimeType: mime });
|
||||||
recorderRef.current = recorder;
|
recorderRef.current = recorder;
|
||||||
|
|
||||||
@@ -71,18 +86,18 @@ export function useRecorder() {
|
|||||||
stopTracks();
|
stopTracks();
|
||||||
|
|
||||||
if (blob.size > 0) {
|
if (blob.size > 0) {
|
||||||
finishRecording(blob, durationMs);
|
onFinishRef.current(blob, durationMs);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
recorder.start();
|
recorder.start();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
stopTracks();
|
stopTracks();
|
||||||
setError(
|
onErrorRef.current(
|
||||||
err instanceof Error ? err.message : "Failed to start recording",
|
err instanceof Error ? err.message : "Failed to start recording",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [recordingMode, setMediaStream, setError, finishRecording, stopTracks]);
|
}, [mode, onStreamReady, stopTracks]);
|
||||||
|
|
||||||
const stopRecording = useCallback(() => {
|
const stopRecording = useCallback(() => {
|
||||||
if (recorderRef.current?.state === "recording") {
|
if (recorderRef.current?.state === "recording") {
|
||||||
|
|||||||
@@ -1,26 +1,73 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { Radio } from "lucide-react";
|
||||||
import { useLiveParticleChildren } from "@/hooks/use-particle";
|
import { useLiveParticleChildren } from "@/hooks/use-particle";
|
||||||
import type { ParticlePath } from "@/lib/particle-path";
|
import { parseParticlePath, type ParticlePath } from "@/lib/particle-path";
|
||||||
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import { Small } from "@/components/ui/typography";
|
||||||
import ControlsIndicator from "@/features/compose/controls-indicator";
|
import ControlsIndicator from "@/features/compose/controls-indicator";
|
||||||
|
import type { Particle, StreamProperties } from "@/api/types";
|
||||||
|
|
||||||
|
function StreamRow({
|
||||||
|
particle,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
particle: Particle & { type: "stream"; properties: StreamProperties };
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
const initials = particle.properties.name.slice(0, 2).toUpperCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-accent"
|
||||||
|
>
|
||||||
|
<Avatar>
|
||||||
|
<AvatarFallback className="bg-primary/10 text-primary font-medium">
|
||||||
|
{initials}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-medium">
|
||||||
|
{particle.properties.name}
|
||||||
|
</p>
|
||||||
|
<div className="text-muted-foreground flex items-center gap-1">
|
||||||
|
<Radio className="size-3" />
|
||||||
|
<Small className="text-muted-foreground">
|
||||||
|
{particle.properties.status}
|
||||||
|
</Small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface ParticleListViewProps {
|
interface ParticleListViewProps {
|
||||||
path: ParticlePath;
|
path: ParticlePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Grid/list of child particles for a container (folder, stream root, or network root).
|
* List of stream particles for a container (network root, folder, etc.).
|
||||||
*/
|
*/
|
||||||
export function ParticleListView({ path }: ParticleListViewProps) {
|
export function ParticleListView({ path }: ParticleListViewProps) {
|
||||||
const { children, isLoading } = useLiveParticleChildren(path);
|
const { children, isLoading } = useLiveParticleChildren(path);
|
||||||
|
const { networkId } = parseParticlePath(path);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const streams = useMemo(
|
||||||
|
() => children.filter((c) => c.type === "stream"),
|
||||||
|
[children],
|
||||||
|
);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return <Progress />;
|
||||||
<div className="flex h-full items-center justify-center">
|
|
||||||
<p className="text-muted-foreground text-sm">Loading particles...</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (children.length === 0) {
|
if (streams.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full items-center justify-center gap-2">
|
<div className="flex flex-col h-full items-center justify-center gap-2">
|
||||||
<ControlsIndicator type={"new"} />
|
<ControlsIndicator type={"new"} />
|
||||||
@@ -29,16 +76,18 @@ export function ParticleListView({ path }: ParticleListViewProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 gap-3 p-4">
|
<ScrollArea className="h-full">
|
||||||
{children.map((child) => (
|
<div className="py-1">
|
||||||
<div
|
{streams.map((stream, index) => (
|
||||||
key={child.id}
|
<div key={stream.id}>
|
||||||
className="rounded-lg border p-3 text-sm"
|
<StreamRow
|
||||||
>
|
particle={stream}
|
||||||
<p className="font-medium">{child.id}</p>
|
onClick={() => navigate(`/${networkId}/${stream.id}`)}
|
||||||
<p className="text-muted-foreground text-xs">{child.type}</p>
|
/>
|
||||||
</div>
|
{index < streams.length - 1 && <Separator className="mx-4" />}
|
||||||
))}
|
</div>
|
||||||
</div>
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ interface CreateParticleParams<T extends ParticleType = ParticleType> {
|
|||||||
type: T;
|
type: T;
|
||||||
properties: ParticlePropertiesMap[T];
|
properties: ParticlePropertiesMap[T];
|
||||||
createdByEmail: string;
|
createdByEmail: string;
|
||||||
visibleTo: string[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCreateParticle() {
|
export function useCreateParticle() {
|
||||||
@@ -18,7 +17,6 @@ export function useCreateParticle() {
|
|||||||
params.type,
|
params.type,
|
||||||
params.properties,
|
params.properties,
|
||||||
params.createdByEmail,
|
params.createdByEmail,
|
||||||
params.visibleTo,
|
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
const ADJECTIVES = [
|
||||||
|
"amber", "bold", "calm", "crisp", "dark", "eager", "faint", "gentle",
|
||||||
|
"hasty", "icy", "jade", "keen", "lush", "misty", "nimble", "opal",
|
||||||
|
"pale", "quiet", "rapid", "sharp", "taut", "vivid", "warm", "zesty",
|
||||||
|
"bright", "clear", "deep", "fresh", "grand", "swift",
|
||||||
|
];
|
||||||
|
|
||||||
|
const NOUNS = [
|
||||||
|
"arrow", "bloom", "cedar", "drift", "ember", "flint", "grove", "harbor",
|
||||||
|
"iris", "jewel", "knoll", "lake", "moss", "nova", "orbit", "petal",
|
||||||
|
"quartz", "ridge", "spark", "trail", "vale", "wave", "yarn", "zenith",
|
||||||
|
"brook", "cliff", "delta", "frost", "glow", "reef",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function generateRandomName(): string {
|
||||||
|
const adj = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)];
|
||||||
|
const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)];
|
||||||
|
return `${adj}-${noun}`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user