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
+7
View File
@@ -5,6 +5,8 @@
- Orion is the api server which lives in the `go/` folder
- `cpp/` points to our prototype of a C++ Qt widgets client
Whenever implementing anything, make sure to take into account best practices without over-engineering.
## Electron App
### Quality
We care about overall architectural quality and keeping consistent patterns according to best practices.
@@ -16,3 +18,8 @@ As an example, we have as high of a bar as a product team like Linear, which out
### Design system
Whenever possible, we should use the design system components. If we need to add a new component from the available ones in [shadcn](https://ui.shadcn.com/docs/components), we should add it to the design system (using `shadcn add _`) and use it in the app.
### Implementation completeness
When adding a feature on the client side, make sure that the api actually supports it by just checking orion implementation all the way through.
IF you find that the API is poorly designed, please suggest changes to improve the client experience.
+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,
}),
}));