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