feat: implement more of playback and reply flow

This commit is contained in:
talksik
2026-02-21 10:57:57 -08:00
parent 0cd74c0a8a
commit 59b973802d
18 changed files with 958 additions and 212 deletions
+84
View File
@@ -0,0 +1,84 @@
import { Heart } from "lucide-react";
import { useCallback } from "react";
import type { AckInfo } from "@/api/types";
import { apiClient } from "@/api/client";
import { useAuthStore } from "@/stores/auth-store";
import { useAppStore } from "@/stores/app-store";
import { cn } from "@/lib/utils";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
interface AckButtonProps {
particleId: string;
acks: AckInfo[];
}
function getInitials(email: string): string {
const prefix = email.split("@")[0];
const parts = prefix.split(/[._-]/);
if (parts.length >= 2) {
return (parts[0][0] + parts[1][0]).toUpperCase();
}
return prefix.slice(0, 2).toUpperCase();
}
export function AckButton({ particleId, acks }: AckButtonProps) {
const currentEmail = useAuthStore((s) => s.user?.email);
const ackParticle = useAppStore((s) => s.ackParticle);
const hasAcked = acks.some((a) => a.email === currentEmail);
const handleClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
if (hasAcked || !currentEmail) return;
ackParticle(particleId, currentEmail);
apiClient.ackParticle(particleId).catch(() => {});
},
[hasAcked, currentEmail, particleId, ackParticle],
);
const displayedAcks = acks.slice(0, 3);
return (
<div className="flex flex-col items-center gap-1.5">
<button
type="button"
onClick={handleClick}
className={cn(
"flex h-10 w-10 flex-col items-center justify-center rounded-full bg-black/30 backdrop-blur-sm transition-colors",
hasAcked
? "text-red-500"
: "text-white hover:bg-black/40",
)}
>
<Heart
className="h-4 w-4"
fill={hasAcked ? "currentColor" : "none"}
/>
<span className="mt-0.5 text-[10px] font-medium leading-none">
{acks.length}
</span>
</button>
{displayedAcks.length > 0 && (
<div className="flex flex-col items-center gap-1">
{displayedAcks.map((ack) => (
<Tooltip key={ack.email}>
<TooltipTrigger asChild>
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-white/20 text-[10px] font-medium text-white backdrop-blur-sm">
{getInitials(ack.email)}
</div>
</TooltipTrigger>
<TooltipContent side="left">
<p>{ack.email}</p>
</TooltipContent>
</Tooltip>
))}
</div>
)}
</div>
);
}
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import type { MediaParticleData, StreamParticle } from "@/api/types";
import { apiClient } from "@/api/client";
import { usePlaybackStore } from "@/stores/playback-store";
@@ -17,9 +17,13 @@ export function MediaParticleView({
(s) => s.downloadUrlCache[particle.id],
);
const cacheDownloadUrl = usePlaybackStore((s) => s.cacheDownloadUrl);
const paused = usePlaybackStore((s) => s.paused);
const [url, setUrl] = useState<string | null>(cachedUrl ?? null);
const [error, setError] = useState<string | null>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const audioRef = useRef<HTMLAudioElement>(null);
useEffect(() => {
if (cachedUrl) {
setUrl(cachedUrl);
@@ -43,6 +47,17 @@ export function MediaParticleView({
};
}, [particle.id, cachedUrl, cacheDownloadUrl]);
useEffect(() => {
const el = videoRef.current ?? audioRef.current;
if (!el) return;
if (paused) {
el.pause();
} else {
el.play().catch(() => {});
}
}, [paused]);
if (error) {
return (
<div className="text-muted-foreground flex items-center justify-center text-sm">
@@ -61,18 +76,19 @@ export function MediaParticleView({
if (isAudio) {
return (
<div className="flex h-full w-full items-center justify-center">
<audio src={url} autoPlay onEnded={onEnded} controls />
<audio ref={audioRef} src={url} autoPlay onEnded={onEnded} controls />
</div>
);
}
return (
<video
ref={videoRef}
src={url}
autoPlay
playsInline
onEnded={onEnded}
className="h-full w-full object-contain"
className="h-full w-full object-cover"
/>
);
}
@@ -6,6 +6,7 @@ import { usePlaybackStore } from "@/stores/playback-store";
import { MediaParticleView } from "./media-particle-view";
import { TextParticleView } from "./text-particle-view";
import { FallbackParticleView } from "./fallback-particle-view";
import { AckButton } from "./ack-button";
interface ParticleRendererProps {
particle: StreamParticle;
@@ -53,6 +54,9 @@ export function ParticleRenderer({
onClick={handleClick}
>
{renderContent()}
<div className="absolute right-4 bottom-16">
<AckButton particleId={particle.id} acks={particle.acks} />
</div>
</div>
);
}
+20 -33
View File
@@ -1,5 +1,4 @@
import { cn } from "@/lib/utils";
import { Progress } from "@/components/ui/progress";
interface PlaybackControlsProps {
total: number;
@@ -7,8 +6,6 @@ interface PlaybackControlsProps {
onGoTo: (index: number) => void;
}
const DOT_THRESHOLD = 15;
export function PlaybackControls({
total,
current,
@@ -16,37 +13,27 @@ export function PlaybackControls({
}: PlaybackControlsProps) {
if (total === 0) return null;
if (total <= DOT_THRESHOLD) {
return (
<div className="flex items-center justify-center gap-1 py-2">
{Array.from({ length: total }, (_, i) => (
<button
key={i}
onClick={(e) => {
e.stopPropagation();
onGoTo(i);
}}
className="p-1"
>
<div
className={cn(
"h-2.5 rounded-full transition-all",
i === current
? "bg-primary w-6"
: "bg-muted-foreground/30 hover:bg-muted-foreground/50 w-2.5",
)}
/>
</button>
))}
</div>
);
}
const percent = ((current + 1) / total) * 100;
return (
<div className="px-4 py-2">
<Progress value={percent} className="h-1" />
<div className="flex w-full items-center gap-0.5 px-1">
{Array.from({ length: total }, (_, i) => (
<button
key={i}
onClick={(e) => {
e.stopPropagation();
onGoTo(i);
}}
className="group relative h-3 flex-1"
>
{/* Track */}
<div
className={cn(
"absolute inset-x-0 top-1 h-1 rounded-full transition-all",
i <= current ? "bg-white/90" : "bg-white/30",
"group-hover:h-1.5 group-hover:top-0.5",
)}
/>
</button>
))}
</div>
);
}
@@ -1,20 +1,32 @@
import type { StreamParticle, TextParticleData } from "@/api/types";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
interface TextParticleViewProps {
particle: StreamParticle;
}
function getTextStyle(length: number) {
if (length < 50) return { size: "text-5xl", weight: "font-semibold" };
if (length < 150) return { size: "text-3xl", weight: "font-semibold" };
if (length < 300) return { size: "text-2xl", weight: "font-normal" };
return { size: "text-lg", weight: "font-normal" };
}
export function TextParticleView({ particle }: TextParticleViewProps) {
const data = particle.data as TextParticleData;
const style = getTextStyle(data.content.length);
return (
<ScrollArea className="h-full w-full">
<div className="flex min-h-full items-center justify-center p-8">
<p className="max-w-2xl text-center text-2xl leading-relaxed">
{data.content}
</p>
</div>
</ScrollArea>
<div className="flex h-full w-full items-center justify-center bg-gradient-to-b from-black/40 via-black/20 to-black/40 p-8">
<p
className={cn(
"max-w-2xl text-center leading-relaxed text-white",
style.size,
style.weight,
)}
>
{data.content}
</p>
</div>
);
}
@@ -0,0 +1,279 @@
import { useEffect, useRef, useState } from "react";
import { useRecordingStore } from "@/stores/recording-store";
interface RecordingOverlayProps {
onClose: () => void;
}
function AudioLevelBars({ mediaStream }: { mediaStream: MediaStream }) {
const audioRef = useRef<{ analyser: AnalyserNode; ctx: AudioContext } | null>(
null,
);
const [levels, setLevels] = useState([0, 0, 0]);
const rafRef = useRef<number>(0);
useEffect(() => {
const ctx = new AudioContext();
const source = ctx.createMediaStreamSource(mediaStream);
const analyser = ctx.createAnalyser();
analyser.fftSize = 256;
source.connect(analyser);
audioRef.current = { analyser, ctx };
const dataArray = new Uint8Array(analyser.fftSize);
function tick() {
analyser.getByteTimeDomainData(dataArray);
// Compute RMS of waveform (128 = silence baseline)
let sumSquares = 0;
for (let i = 0; i < dataArray.length; i++) {
const normalized = (dataArray[i] - 128) / 128;
sumSquares += normalized * normalized;
}
const rms = Math.sqrt(sumSquares / dataArray.length);
// VU meter: 3 bars with staggered thresholds
const bar0 = Math.min(1, rms * 3);
const bar1 = Math.max(0, Math.min(1, (rms - 0.1) * 3));
const bar2 = Math.max(0, Math.min(1, (rms - 0.25) * 3));
setLevels([bar0, bar1, bar2]);
rafRef.current = requestAnimationFrame(tick);
}
rafRef.current = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(rafRef.current);
ctx.close();
};
}, [mediaStream]);
return (
<div className="flex items-end gap-1.5">
{levels.map((level, i) => (
<div
key={i}
className="w-1.5 rounded-full bg-green-400 transition-all duration-75"
style={{ height: `${Math.max(6, level * 48)}px` }}
/>
))}
</div>
);
}
function RecordingTimer() {
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setElapsed((prev) => prev + 1);
}, 1000);
return () => clearInterval(interval);
}, []);
const minutes = Math.floor(elapsed / 60);
const seconds = elapsed % 60;
const display = `${minutes}:${seconds.toString().padStart(2, "0")}`;
return (
<div className="flex items-center gap-2">
<span className="h-2.5 w-2.5 animate-pulse rounded-full bg-red-500" />
<span className="font-mono text-sm text-white/80">{display}</span>
</div>
);
}
function ReviewPlayback({
blob,
isVideo,
}: {
blob: Blob;
isVideo: boolean;
}) {
const urlRef = useRef<string | null>(null);
const [objectUrl, setObjectUrl] = useState<string | null>(null);
useEffect(() => {
const url = URL.createObjectURL(blob);
urlRef.current = url;
setObjectUrl(url);
return () => {
URL.revokeObjectURL(url);
urlRef.current = null;
};
}, [blob]);
if (!objectUrl) return null;
if (isVideo) {
return (
<video
src={objectUrl}
autoPlay
loop
playsInline
className="absolute inset-0 h-full w-full -scale-x-100 object-cover"
/>
);
}
return (
<div className="flex flex-col items-center gap-3">
<audio src={objectUrl} autoPlay loop />
<span className="text-sm text-white/60">Playing back audio...</span>
</div>
);
}
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);
const videoRef = useRef<HTMLVideoElement>(null);
const hasBeenActiveRef = useRef(false);
// 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") {
videoRef.current.srcObject = mediaStream;
}
}, [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;
const timeout = setTimeout(onClose, 1500);
return () => clearTimeout(timeout);
}, [status, 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 isLoading = isRecording && !mediaStream;
return (
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-black/90">
{/* Loading state */}
{isLoading && (
<div className="z-10 flex flex-col items-center gap-2">
<span className="animate-pulse text-sm text-white/60">
{recordingMode === "video"
? "Starting camera..."
: "Starting mic..."}
</span>
</div>
)}
{/* Camera preview (video mode, recording) */}
{isRecording && recordingMode === "video" && mediaStream && (
<video
ref={videoRef}
muted
autoPlay
playsInline
className="absolute inset-0 h-full w-full -scale-x-100 object-cover"
/>
)}
{/* Review playback */}
{isReviewing && reviewBlob && (
<ReviewPlayback
blob={reviewBlob}
isVideo={recordingMode === "video"}
/>
)}
{/* Dimmed overlay when uploading */}
{isUploading && <div className="absolute inset-0 bg-black/60" />}
{/* Top center: recording indicator / uploading */}
<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 ? (
<RecordingTimer />
) : isReviewing ? (
<div className="flex items-center gap-2">
<span className="text-sm text-white/80">Review recording</span>
</div>
) : null}
</div>
{/* Center: audio level bars (recording with active stream) */}
{isRecording && mediaStream && (
<div className="z-10">
<AudioLevelBars mediaStream={mediaStream} />
</div>
)}
{/* Bottom center: keyboard hints */}
{isRecording && !isLoading && (
<div className="absolute bottom-8 z-10 flex items-center gap-4 text-sm text-white/50">
<span>
Release{" "}
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
`
</kbd>{" "}
to review
</span>
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Q
</kbd>{" "}
cancel
</span>
</div>
)}
{isReviewing && (
<div className="absolute bottom-8 z-10 flex items-center gap-4 text-sm text-white/50">
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Enter
</kbd>{" "}
to send
</span>
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Q
</kbd>{" "}
cancel
</span>
</div>
)}
{/* Error state */}
{status === "error" && (
<div className="z-10 text-sm text-red-400">
{useRecordingStore.getState().error ?? "Recording failed"}
</div>
)}
</div>
);
}
+41 -26
View File
@@ -1,38 +1,53 @@
import { Video, Mic } from "lucide-react";
import { useRecordingStore } from "@/stores/recording-store";
import { cn } from "@/lib/utils";
export function ReplyIndicator() {
const status = useRecordingStore((s) => s.status);
if (status === "uploading") {
return (
<div className="text-muted-foreground flex items-center gap-2 text-xs">
<span className="h-2 w-2 animate-pulse rounded-full bg-yellow-500" />
Uploading...
</div>
);
}
if (status === "recording") {
return (
<div className="flex items-center gap-2 text-xs text-red-400">
<span className="h-2 w-2 animate-pulse rounded-full bg-red-500" />
Recording... press Q to cancel
</div>
);
}
const recordingMode = useRecordingStore((s) => s.recordingMode);
const setRecordingMode = useRecordingStore((s) => s.setRecordingMode);
return (
<div className="text-muted-foreground text-xs">
Hold{" "}
<kbd
<div className="flex items-center gap-2">
<button
type="button"
onClick={() =>
setRecordingMode(recordingMode === "video" ? "audio" : "video")
}
className={cn(
"bg-muted rounded px-1.5 py-0.5 font-mono text-xs",
"rounded p-1 transition-colors hover:bg-white/20",
"text-white/60 hover:text-white/90",
)}
title={
recordingMode === "video"
? "Switch to audio-only"
: "Switch to video"
}
>
`
</kbd>{" "}
to reply
{recordingMode === "video" ? (
<Video className="h-3.5 w-3.5" />
) : (
<Mic className="h-3.5 w-3.5" />
)}
</button>
<div className="text-muted-foreground text-xs">
Hold{" "}
<kbd
className={cn(
"bg-muted rounded px-1.5 py-0.5 font-mono text-xs",
)}
>
`
</kbd>{" "}
to reply · Press{" "}
<kbd
className={cn(
"bg-muted rounded px-1.5 py-0.5 font-mono text-xs",
)}
>
T
</kbd>{" "}
to text
</div>
</div>
);
}
@@ -0,0 +1,96 @@
import { useState, useRef, useEffect, 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;
}
function getTextStyle(length: number) {
if (length < 50) return { size: "text-5xl", weight: "font-semibold" };
if (length < 150) return { size: "text-3xl", weight: "font-semibold" };
if (length < 300) return { size: "text-2xl", weight: "font-normal" };
return { size: "text-lg", weight: "font-normal" };
}
export function TextComposeOverlay({
streamId,
onClose,
}: TextComposeOverlayProps) {
const [content, setContent] = useState("");
const [sending, setSending] = useState(false);
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();
} else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
handleSend();
}
},
[onClose, handleSend],
);
const style = getTextStyle(content.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)}
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,
style.weight,
)}
rows={4}
/>
<div className="absolute bottom-8 flex items-center gap-4 text-sm text-white/50">
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Esc
</kbd>{" "}
cancel
</span>
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Cmd+Enter
</kbd>{" "}
send
</span>
</div>
</div>
);
}
+66 -59
View File
@@ -4,12 +4,20 @@ import { useAppStore } from "@/stores/app-store";
import { usePlaybackStore } from "@/stores/playback-store";
import { useRecordingStore } from "@/stores/recording-store";
const PREFERRED_MIME = "video/webm;codecs=vp9,opus";
const FALLBACK_MIME = "video/webm";
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(): string {
if (MediaRecorder.isTypeSupported(PREFERRED_MIME)) return PREFERRED_MIME;
return FALLBACK_MIME;
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) {
@@ -17,10 +25,13 @@ export function useRecorder(streamId: string | null) {
const streamRef = useRef<MediaStream | null>(null);
const chunksRef = useRef<Blob[]>([]);
const startTimeRef = useRef<number>(0);
const mimeRef = useRef<string>("");
const status = useRecordingStore((s) => s.status);
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 addParticleToStream = useAppStore((s) => s.addParticleToStream);
@@ -29,27 +40,31 @@ export function useRecorder(streamId: string | null) {
streamRef.current = null;
recorderRef.current = null;
chunksRef.current = [];
}, []);
setMediaStream(null);
}, [setMediaStream]);
const upload = useCallback(
async (blob: Blob, durationMs: number) => {
if (!streamId) return;
const confirmSend = useCallback(async () => {
if (!streamId) return;
setStatus("uploading");
const { reviewBlob, reviewDurationMs } = useRecordingStore.getState();
if (!reviewBlob) return;
const mimeType = blob.type || FALLBACK_MIME;
setStatus("uploading");
try {
const mimeType = reviewBlob.type || VIDEO_FALLBACK_MIME;
const fileName = `recording-${Date.now()}.webm`;
const { object, upload_url } = await apiClient.prepareUpload({
file_name: fileName,
content_type: mimeType,
size_bytes: blob.size,
size_bytes: reviewBlob.size,
});
await fetch(upload_url, {
method: "PUT",
headers: { "Content-Type": mimeType },
body: blob,
body: reviewBlob,
});
await apiClient.confirmUpload(object.id);
@@ -58,14 +73,13 @@ export function useRecorder(streamId: string | null) {
type: "media",
data: {
object_id: object.id,
duration_ms: durationMs,
duration_ms: reviewDurationMs,
mime_type: mimeType,
},
});
addParticleToStream(streamId, particle);
// Also add to playback store's particle list
const playbackState = usePlaybackStore.getState();
if (playbackState.streamId === streamId) {
usePlaybackStore.setState({
@@ -74,24 +88,33 @@ export function useRecorder(streamId: string | null) {
}
resetRecording();
},
[streamId, setStatus, resetRecording, addParticleToStream],
);
} catch (err) {
setError(err instanceof Error ? err.message : "Upload failed");
}
}, [streamId, setStatus, setError, resetRecording, addParticleToStream]);
const startRecording = useCallback(async () => {
if (status !== "idle") return;
const currentStatus = useRecordingStore.getState().status;
if (currentStatus !== "idle") return;
try {
const mediaStream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true,
});
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();
const mime = getMediaMime(recordingMode);
mimeRef.current = mime;
const recorder = new MediaRecorder(mediaStream, { mimeType: mime });
recorderRef.current = recorder;
@@ -105,23 +128,28 @@ export function useRecorder(streamId: string | null) {
stopTracks();
if (blob.size > 0) {
upload(blob, durationMs).catch((err) => {
setError(err instanceof Error ? err.message : "Upload failed");
});
setReviewBlob(blob, durationMs);
} else {
resetRecording();
}
};
recorder.start();
setStatus("recording");
} catch (err) {
stopTracks();
setError(
err instanceof Error ? err.message : "Failed to start recording",
);
}
}, [status, setStatus, setError, stopTracks, upload, resetRecording]);
}, [
recordingMode,
setStatus,
setError,
setMediaStream,
setReviewBlob,
stopTracks,
resetRecording,
]);
const stopRecording = useCallback(() => {
if (recorderRef.current?.state === "recording") {
@@ -130,6 +158,13 @@ export function useRecorder(streamId: string | null) {
}, []);
const cancelRecording = useCallback(() => {
const currentStatus = useRecordingStore.getState().status;
if (currentStatus === "reviewing") {
resetRecording();
return;
}
if (recorderRef.current) {
recorderRef.current.ondataavailable = null;
recorderRef.current.onstop = null;
@@ -141,34 +176,6 @@ export function useRecorder(streamId: string | null) {
resetRecording();
}, [stopTracks, resetRecording]);
// Keyboard bindings: backtick to record, q to cancel
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "`" && !e.repeat) {
e.preventDefault();
startRecording();
}
};
const handleKeyUp = (e: KeyboardEvent) => {
if (e.key === "`") {
e.preventDefault();
stopRecording();
}
if (e.key === "q" && status === "recording") {
e.preventDefault();
cancelRecording();
}
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
};
}, [startRecording, stopRecording, cancelRecording, status]);
// Cleanup on unmount
useEffect(() => {
return () => {
@@ -176,5 +183,5 @@ export function useRecorder(streamId: string | null) {
};
}, [stopTracks]);
return { status };
return { startRecording, stopRecording, cancelRecording, confirmSend };
}