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
+31 -8
View File
@@ -1,10 +1,40 @@
import { useEffect } from "react";
import { HashRouter, Routes, Route } from "react-router-dom";
import { TooltipProvider } from "@/components/ui/tooltip";
import { useAppStore } from "@/stores/app-store";
import { useAuthStore } from "@/stores/auth-store";
import { LoginPage } from "@/features/auth/login-page";
import { StreamsPage } from "@/pages/streams-page";
import { StreamPlayerPage } from "@/pages/stream-player-page";
function AuthenticatedApp() {
const fetchStartup = useAppStore((s) => s.fetchStartup);
const isLoading = useAppStore((s) => s.isLoading);
useEffect(() => {
fetchStartup();
}, [fetchStartup]);
if (isLoading) {
return (
<div className="flex min-h-screen items-center justify-center">
<p className="text-muted-foreground text-sm">Loading...</p>
</div>
);
}
return (
<TooltipProvider>
<HashRouter>
<Routes>
<Route path="/" element={<StreamsPage />} />
<Route path="/streams/:streamId" element={<StreamPlayerPage />} />
</Routes>
</HashRouter>
</TooltipProvider>
);
}
const App = () => {
const status = useAuthStore((s) => s.status);
const restoreSession = useAuthStore((s) => s.restoreSession);
@@ -25,14 +55,7 @@ const App = () => {
return <LoginPage />;
}
return (
<HashRouter>
<Routes>
<Route path="/" element={<StreamsPage />} />
<Route path="/streams/:streamId" element={<StreamPlayerPage />} />
</Routes>
</HashRouter>
);
return <AuthenticatedApp />;
};
export default App;
+4
View File
@@ -128,6 +128,10 @@ class ApiClient {
await this.request<void>("POST", `/particles/${particleId}/seen`);
}
async ackParticle(particleId: string): Promise<void> {
await this.request<void>("POST", `/particles/${particleId}/ack`);
}
async markSeenBatch(data: MarkSeenBatchRequest): Promise<void> {
await this.request<void>("POST", "/particles/seen", data);
}
+55
View File
@@ -0,0 +1,55 @@
import * as React from "react"
import { Tooltip as TooltipPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 rounded-md px-3 py-1.5 text-xs bg-foreground text-background z-50 w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin)",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="size-2.5 rotate-45 rounded-[2px] bg-foreground fill-foreground z-50 translate-y-[calc(-50%_-_2px)]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
+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 };
}
+165 -53
View File
@@ -1,18 +1,23 @@
import { useEffect, useCallback } from "react";
import { useEffect, useCallback, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useAppStore } from "@/stores/app-store";
import { usePlaybackStore } from "@/stores/playback-store";
import { useRecordingStore } from "@/stores/recording-store";
import { ParticleRenderer } from "@/features/playback/particle-renderer";
import { PlaybackControls } from "@/features/playback/playback-controls";
import { ReplyIndicator } from "@/features/recording/reply-indicator";
import { TextComposeOverlay } from "@/features/recording/text-compose-overlay";
import { RecordingOverlay } from "@/features/recording/recording-overlay";
import { useRecorder } from "@/features/recording/use-recorder";
export function StreamPlayerPage() {
const { streamId } = useParams<{ streamId: string }>();
const navigate = useNavigate();
const networks = useAppStore((s) => s.networks);
const [composingText, setComposingText] = useState(false);
const [showRecordingOverlay, setShowRecordingOverlay] = useState(false);
const particles = usePlaybackStore((s) => s.particles);
const currentIndex = usePlaybackStore((s) => s.currentIndex);
@@ -22,8 +27,11 @@ export function StreamPlayerPage() {
const prev = usePlaybackStore((s) => s.prev);
const goTo = usePlaybackStore((s) => s.goTo);
const reset = usePlaybackStore((s) => s.reset);
const pause = usePlaybackStore((s) => s.pause);
const resume = usePlaybackStore((s) => s.resume);
useRecorder(streamId ?? null);
const { startRecording, stopRecording, cancelRecording, confirmSend } =
useRecorder(streamId ?? null);
// Find the stream across all networks
const stream = networks
@@ -46,10 +54,51 @@ export function StreamPlayerPage() {
};
}, [stream?.id]); // eslint-disable-line react-hooks/exhaustive-deps
// Keyboard navigation
const handleRecordingOverlayClose = useCallback(() => {
setShowRecordingOverlay(false);
resume();
}, [resume]);
// Unified keyboard handling
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
if (composingText) return;
const recStatus = useRecordingStore.getState().status;
// Q/Esc during recording or reviewing: cancel immediately
if (
(e.key === "q" || e.key === "Q" || e.key === "Escape") &&
(recStatus === "recording" || recStatus === "reviewing")
) {
e.preventDefault();
cancelRecording();
setShowRecordingOverlay(false);
resume();
return;
}
// Enter or backtick during reviewing: send
if (
(e.key === "Enter" || e.key === "`") &&
recStatus === "reviewing"
) {
e.preventDefault();
confirmSend();
return;
}
// Block navigation while recording/uploading
if (recStatus === "recording" || recStatus === "uploading") {
return;
}
if (e.key === "`" && !e.repeat) {
e.preventDefault();
pause();
startRecording();
setShowRecordingOverlay(true);
} else if (e.key === "ArrowRight" || e.key === "ArrowDown") {
e.preventDefault();
next();
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
@@ -57,15 +106,47 @@ export function StreamPlayerPage() {
prev();
} else if (e.key === "Escape") {
navigate("/");
} else if (e.key === "t" || e.key === "T") {
e.preventDefault();
setComposingText(true);
}
},
[next, prev, navigate],
[
composingText,
next,
prev,
navigate,
pause,
resume,
startRecording,
cancelRecording,
confirmSend,
],
);
const handleKeyUp = useCallback(
(e: KeyboardEvent) => {
if (composingText) return;
const recStatus = useRecordingStore.getState().status;
if (e.key === "`" && recStatus === "recording") {
e.preventDefault();
stopRecording();
// Transitions to reviewing — overlay stays open
}
},
[composingText, stopRecording],
);
useEffect(() => {
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [handleKeyDown]);
window.addEventListener("keyup", handleKeyUp);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
};
}, [handleKeyDown, handleKeyUp]);
if (!stream) {
return (
@@ -77,16 +158,42 @@ export function StreamPlayerPage() {
if (particles.length === 0) {
return (
<div className="flex h-screen flex-col">
<Header name={stream.name} onBack={() => navigate("/")} />
<div className="flex h-screen flex-col bg-black text-white">
{/* Top overlay */}
<div className="pointer-events-none absolute top-0 right-0 left-0 z-10 px-2 pt-2">
<div className="pointer-events-auto inline-flex">
<Button
variant="ghost"
size="icon"
className="rounded-full bg-black/30 text-white backdrop-blur-sm hover:bg-black/50"
onClick={() => navigate("/")}
>
<ArrowLeft className="h-4 w-4" />
</Button>
</div>
</div>
<div className="flex flex-1 items-center justify-center">
<p className="text-muted-foreground text-sm">
No particles yet. Hold ` to record the first one.
</p>
</div>
<div className="flex justify-center border-t py-3">
<div className="absolute right-0 bottom-0 left-0 z-10 flex items-center justify-between px-4 py-3">
<span className="text-xs font-medium text-white/70">
{stream.name}
</span>
<ReplyIndicator />
</div>
{composingText && streamId && (
<TextComposeOverlay
streamId={streamId}
onClose={() => setComposingText(false)}
/>
)}
{showRecordingOverlay && (
<RecordingOverlay onClose={handleRecordingOverlayClose} />
)}
</div>
);
}
@@ -94,26 +201,9 @@ export function StreamPlayerPage() {
const currentParticle = particles[currentIndex];
return (
<div className="flex h-screen flex-col bg-black text-white">
{/* Top bar */}
<div className="relative z-10 flex items-center justify-between px-3 py-2">
<Button
variant="ghost"
size="icon"
className="text-white hover:bg-white/10"
onClick={() => navigate("/")}
>
<ArrowLeft className="h-4 w-4" />
</Button>
<span className="text-xs font-medium">{stream.name}</span>
<div className="w-9" /> {/* Spacer for centering */}
</div>
{/* Progress */}
<PlaybackControls total={particles.length} current={currentIndex} onGoTo={goTo} />
{/* Particle content */}
<div className="flex-1 overflow-hidden">
<div className="relative h-screen bg-black text-white">
{/* Particle content — fills entire viewport */}
<div className="absolute inset-0">
{currentParticle && (
<ParticleRenderer
particle={currentParticle}
@@ -123,16 +213,42 @@ export function StreamPlayerPage() {
)}
</div>
{/* Bottom bar */}
<div className="relative z-10 flex items-center justify-between px-4 py-3">
<span className="text-muted-foreground text-xs">
{currentParticle?.created_by_email}
</span>
<ReplyIndicator />
{/* Top overlay: progress bars + back button */}
<div className="pointer-events-none absolute top-0 right-0 left-0 z-10">
<div className="pointer-events-auto">
<PlaybackControls
total={particles.length}
current={currentIndex}
onGoTo={goTo}
/>
</div>
<div className="pointer-events-auto mt-1 inline-flex px-2">
<Button
variant="ghost"
size="icon"
className="rounded-full bg-black/30 text-white backdrop-blur-sm hover:bg-black/50"
onClick={() => navigate("/")}
>
<ArrowLeft className="h-4 w-4" />
</Button>
</div>
</div>
{/* Bottom overlay: stream info + reply */}
<div className="absolute right-0 bottom-0 left-0 z-10 flex justify-center px-3 pb-3">
<div className="flex w-full items-center justify-between rounded-full bg-black/30 px-4 py-2 backdrop-blur-sm">
<span className="text-xs font-medium text-white/70">
{stream.name}
<span className="text-white/40"> &gt; </span>
{currentParticle?.created_by_email}
</span>
<ReplyIndicator />
</div>
</div>
{/* End of stream overlay */}
{status === "ended" && (
<div className="absolute inset-0 flex items-center justify-center bg-black/80">
<div className="absolute inset-0 z-20 flex items-center justify-center bg-black/80">
<div className="flex flex-col items-center gap-4">
<p className="text-sm text-white">End of stream</p>
<Button
@@ -145,23 +261,19 @@ export function StreamPlayerPage() {
</div>
</div>
)}
</div>
);
}
function Header({
name,
onBack,
}: {
name: string;
onBack: () => void;
}) {
return (
<div className="flex items-center gap-2 border-b px-3 py-2">
<Button variant="ghost" size="icon" onClick={onBack}>
<ArrowLeft className="h-4 w-4" />
</Button>
<span className="text-sm font-medium">{name}</span>
{/* Text compose overlay */}
{composingText && streamId && (
<TextComposeOverlay
streamId={streamId}
onClose={() => setComposingText(false)}
/>
)}
{/* Recording overlay */}
{showRecordingOverlay && (
<RecordingOverlay onClose={handleRecordingOverlayClose} />
)}
</div>
);
}
+3 -16
View File
@@ -1,4 +1,3 @@
import { useEffect } from "react";
import { Plus, LogOut } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
@@ -17,18 +16,12 @@ import { StreamList } from "@/features/streams/stream-list";
import { CreateStreamDialog } from "@/features/streams/create-stream-dialog";
export function StreamsPage() {
const fetchStartup = useAppStore((s) => s.fetchStartup);
const isLoading = useAppStore((s) => s.isLoading);
const networks = useAppStore((s) => s.networks);
const selectedNetworkId = useAppStore((s) => s.selectedNetworkId);
const setSelectedNetwork = useAppStore((s) => s.setSelectedNetwork);
const signOut = useAuthStore((s) => s.signOut);
const user = useAuthStore((s) => s.user);
useEffect(() => {
fetchStartup();
}, [fetchStartup]);
const selectedNetwork = selectedNetworkId
? networks.find((n) => n.id === selectedNetworkId)
: null;
@@ -96,15 +89,9 @@ export function StreamsPage() {
</div>
{/* Stream list */}
{isLoading ? (
<div className="flex flex-1 items-center justify-center">
<p className="text-muted-foreground text-sm">Loading...</p>
</div>
) : (
<ScrollArea className="flex-1">
<StreamList />
</ScrollArea>
)}
<ScrollArea className="flex-1">
<StreamList />
</ScrollArea>
</div>
);
}
+17
View File
@@ -1,6 +1,7 @@
import { create } from "zustand";
import { apiClient } from "@/api/client";
import type {
AckInfo,
NetworkWithStreams,
Stream,
StreamParticle,
@@ -16,6 +17,7 @@ interface AppState {
addStream: (networkId: string, stream: Stream) => void;
addParticleToStream: (streamId: string, particle: StreamParticle) => void;
markParticlesSeen: (particleIds: string[]) => void;
ackParticle: (particleId: string, email: string) => void;
}
export const useAppStore = create<AppState>((set, get) => ({
@@ -66,6 +68,21 @@ export const useAppStore = create<AppState>((set, get) => ({
});
},
ackParticle: (particleId, email) => {
const ack: AckInfo = { email, acked_at: new Date().toISOString() };
set({
networks: get().networks.map((n) => ({
...n,
streams: n.streams.map((s) => ({
...s,
particles: s.particles.map((p) =>
p.id === particleId ? { ...p, acks: [...p.acks, ack] } : p,
),
})),
})),
});
},
markParticlesSeen: (particleIds) => {
const idSet = new Set(particleIds);
set({
+12 -4
View File
@@ -8,6 +8,7 @@ interface PlaybackState {
particles: StreamParticle[];
currentIndex: number;
status: PlaybackStatus;
paused: boolean;
downloadUrlCache: Record<string, string>;
initStream: (
@@ -18,6 +19,8 @@ interface PlaybackState {
next: () => void;
prev: () => void;
goTo: (index: number) => void;
pause: () => void;
resume: () => void;
cacheDownloadUrl: (particleId: string, url: string) => void;
reset: () => void;
}
@@ -27,6 +30,7 @@ export const usePlaybackStore = create<PlaybackState>((set, get) => ({
particles: [],
currentIndex: 0,
status: "idle",
paused: false,
downloadUrlCache: {},
initStream: (streamId, particles, startIndex) => {
@@ -42,26 +46,29 @@ export const usePlaybackStore = create<PlaybackState>((set, get) => ({
next: () => {
const { currentIndex, particles } = get();
if (currentIndex < particles.length - 1) {
set({ currentIndex: currentIndex + 1 });
set({ currentIndex: currentIndex + 1, paused: false });
} else {
set({ status: "ended" });
set({ status: "ended", paused: false });
}
},
prev: () => {
const { currentIndex } = get();
if (currentIndex > 0) {
set({ currentIndex: currentIndex - 1, status: "playing" });
set({ currentIndex: currentIndex - 1, status: "playing", paused: false });
}
},
goTo: (index) => {
const { particles } = get();
if (index >= 0 && index < particles.length) {
set({ currentIndex: index, status: "playing" });
set({ currentIndex: index, status: "playing", paused: false });
}
},
pause: () => set({ paused: true }),
resume: () => set({ paused: false }),
cacheDownloadUrl: (particleId, url) => {
set({
downloadUrlCache: { ...get().downloadUrlCache, [particleId]: url },
@@ -74,6 +81,7 @@ export const usePlaybackStore = create<PlaybackState>((set, get) => ({
particles: [],
currentIndex: 0,
status: "idle",
paused: false,
downloadUrlCache: {},
});
},
+35 -2
View File
@@ -1,21 +1,54 @@
import { create } from "zustand";
type RecordingStatus = "idle" | "recording" | "uploading" | "error";
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 }),
reset: () => set({ status: "idle", error: null }),
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,
}),
}));