feat: implement more of playback and reply flow
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user