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
|
- Orion is the api server which lives in the `go/` folder
|
||||||
- `cpp/` points to our prototype of a C++ Qt widgets client
|
- `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
|
## Electron App
|
||||||
### Quality
|
### Quality
|
||||||
We care about overall architectural quality and keeping consistent patterns according to best practices.
|
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
|
### 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.
|
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 { useEffect } from "react";
|
||||||
import { HashRouter, Routes, Route } from "react-router-dom";
|
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 { useAuthStore } from "@/stores/auth-store";
|
||||||
import { LoginPage } from "@/features/auth/login-page";
|
import { LoginPage } from "@/features/auth/login-page";
|
||||||
import { StreamsPage } from "@/pages/streams-page";
|
import { StreamsPage } from "@/pages/streams-page";
|
||||||
import { StreamPlayerPage } from "@/pages/stream-player-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 App = () => {
|
||||||
const status = useAuthStore((s) => s.status);
|
const status = useAuthStore((s) => s.status);
|
||||||
const restoreSession = useAuthStore((s) => s.restoreSession);
|
const restoreSession = useAuthStore((s) => s.restoreSession);
|
||||||
@@ -25,14 +55,7 @@ const App = () => {
|
|||||||
return <LoginPage />;
|
return <LoginPage />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return <AuthenticatedApp />;
|
||||||
<HashRouter>
|
|
||||||
<Routes>
|
|
||||||
<Route path="/" element={<StreamsPage />} />
|
|
||||||
<Route path="/streams/:streamId" element={<StreamPlayerPage />} />
|
|
||||||
</Routes>
|
|
||||||
</HashRouter>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
@@ -128,6 +128,10 @@ class ApiClient {
|
|||||||
await this.request<void>("POST", `/particles/${particleId}/seen`);
|
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> {
|
async markSeenBatch(data: MarkSeenBatchRequest): Promise<void> {
|
||||||
await this.request<void>("POST", "/particles/seen", data);
|
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 type { MediaParticleData, StreamParticle } from "@/api/types";
|
||||||
import { apiClient } from "@/api/client";
|
import { apiClient } from "@/api/client";
|
||||||
import { usePlaybackStore } from "@/stores/playback-store";
|
import { usePlaybackStore } from "@/stores/playback-store";
|
||||||
@@ -17,9 +17,13 @@ export function MediaParticleView({
|
|||||||
(s) => s.downloadUrlCache[particle.id],
|
(s) => s.downloadUrlCache[particle.id],
|
||||||
);
|
);
|
||||||
const cacheDownloadUrl = usePlaybackStore((s) => s.cacheDownloadUrl);
|
const cacheDownloadUrl = usePlaybackStore((s) => s.cacheDownloadUrl);
|
||||||
|
const paused = usePlaybackStore((s) => s.paused);
|
||||||
const [url, setUrl] = useState<string | null>(cachedUrl ?? null);
|
const [url, setUrl] = useState<string | null>(cachedUrl ?? null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (cachedUrl) {
|
if (cachedUrl) {
|
||||||
setUrl(cachedUrl);
|
setUrl(cachedUrl);
|
||||||
@@ -43,6 +47,17 @@ export function MediaParticleView({
|
|||||||
};
|
};
|
||||||
}, [particle.id, cachedUrl, cacheDownloadUrl]);
|
}, [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) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="text-muted-foreground flex items-center justify-center text-sm">
|
<div className="text-muted-foreground flex items-center justify-center text-sm">
|
||||||
@@ -61,18 +76,19 @@ export function MediaParticleView({
|
|||||||
if (isAudio) {
|
if (isAudio) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<video
|
<video
|
||||||
|
ref={videoRef}
|
||||||
src={url}
|
src={url}
|
||||||
autoPlay
|
autoPlay
|
||||||
playsInline
|
playsInline
|
||||||
onEnded={onEnded}
|
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 { MediaParticleView } from "./media-particle-view";
|
||||||
import { TextParticleView } from "./text-particle-view";
|
import { TextParticleView } from "./text-particle-view";
|
||||||
import { FallbackParticleView } from "./fallback-particle-view";
|
import { FallbackParticleView } from "./fallback-particle-view";
|
||||||
|
import { AckButton } from "./ack-button";
|
||||||
|
|
||||||
interface ParticleRendererProps {
|
interface ParticleRendererProps {
|
||||||
particle: StreamParticle;
|
particle: StreamParticle;
|
||||||
@@ -53,6 +54,9 @@ export function ParticleRenderer({
|
|||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
>
|
>
|
||||||
{renderContent()}
|
{renderContent()}
|
||||||
|
<div className="absolute right-4 bottom-16">
|
||||||
|
<AckButton particleId={particle.id} acks={particle.acks} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Progress } from "@/components/ui/progress";
|
|
||||||
|
|
||||||
interface PlaybackControlsProps {
|
interface PlaybackControlsProps {
|
||||||
total: number;
|
total: number;
|
||||||
@@ -7,8 +6,6 @@ interface PlaybackControlsProps {
|
|||||||
onGoTo: (index: number) => void;
|
onGoTo: (index: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DOT_THRESHOLD = 15;
|
|
||||||
|
|
||||||
export function PlaybackControls({
|
export function PlaybackControls({
|
||||||
total,
|
total,
|
||||||
current,
|
current,
|
||||||
@@ -16,37 +13,27 @@ export function PlaybackControls({
|
|||||||
}: PlaybackControlsProps) {
|
}: PlaybackControlsProps) {
|
||||||
if (total === 0) return null;
|
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 (
|
return (
|
||||||
<div className="px-4 py-2">
|
<div className="flex w-full items-center gap-0.5 px-1">
|
||||||
<Progress value={percent} className="h-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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,32 @@
|
|||||||
import type { StreamParticle, TextParticleData } from "@/api/types";
|
import type { StreamParticle, TextParticleData } from "@/api/types";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface TextParticleViewProps {
|
interface TextParticleViewProps {
|
||||||
particle: StreamParticle;
|
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) {
|
export function TextParticleView({ particle }: TextParticleViewProps) {
|
||||||
const data = particle.data as TextParticleData;
|
const data = particle.data as TextParticleData;
|
||||||
|
const style = getTextStyle(data.content.length);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollArea className="h-full w-full">
|
<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">
|
||||||
<div className="flex min-h-full items-center justify-center p-8">
|
<p
|
||||||
<p className="max-w-2xl text-center text-2xl leading-relaxed">
|
className={cn(
|
||||||
{data.content}
|
"max-w-2xl text-center leading-relaxed text-white",
|
||||||
</p>
|
style.size,
|
||||||
</div>
|
style.weight,
|
||||||
</ScrollArea>
|
)}
|
||||||
|
>
|
||||||
|
{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 { useRecordingStore } from "@/stores/recording-store";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export function ReplyIndicator() {
|
export function ReplyIndicator() {
|
||||||
const status = useRecordingStore((s) => s.status);
|
const recordingMode = useRecordingStore((s) => s.recordingMode);
|
||||||
|
const setRecordingMode = useRecordingStore((s) => s.setRecordingMode);
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="text-muted-foreground text-xs">
|
<div className="flex items-center gap-2">
|
||||||
Hold{" "}
|
<button
|
||||||
<kbd
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setRecordingMode(recordingMode === "video" ? "audio" : "video")
|
||||||
|
}
|
||||||
className={cn(
|
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"
|
||||||
|
}
|
||||||
>
|
>
|
||||||
`
|
{recordingMode === "video" ? (
|
||||||
</kbd>{" "}
|
<Video className="h-3.5 w-3.5" />
|
||||||
to reply
|
) : (
|
||||||
|
<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>
|
</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 { usePlaybackStore } from "@/stores/playback-store";
|
||||||
import { useRecordingStore } from "@/stores/recording-store";
|
import { useRecordingStore } from "@/stores/recording-store";
|
||||||
|
|
||||||
const PREFERRED_MIME = "video/webm;codecs=vp9,opus";
|
const VIDEO_PREFERRED_MIME = "video/webm;codecs=vp9,opus";
|
||||||
const FALLBACK_MIME = "video/webm";
|
const VIDEO_FALLBACK_MIME = "video/webm";
|
||||||
|
const AUDIO_PREFERRED_MIME = "audio/webm;codecs=opus";
|
||||||
|
const AUDIO_FALLBACK_MIME = "audio/webm";
|
||||||
|
|
||||||
function getMediaMime(): string {
|
function getMediaMime(mode: "video" | "audio"): string {
|
||||||
if (MediaRecorder.isTypeSupported(PREFERRED_MIME)) return PREFERRED_MIME;
|
if (mode === "audio") {
|
||||||
return FALLBACK_MIME;
|
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) {
|
export function useRecorder(streamId: string | null) {
|
||||||
@@ -17,10 +25,13 @@ export function useRecorder(streamId: string | null) {
|
|||||||
const streamRef = useRef<MediaStream | null>(null);
|
const streamRef = useRef<MediaStream | null>(null);
|
||||||
const chunksRef = useRef<Blob[]>([]);
|
const chunksRef = useRef<Blob[]>([]);
|
||||||
const startTimeRef = useRef<number>(0);
|
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 setStatus = useRecordingStore((s) => s.setStatus);
|
||||||
const setError = useRecordingStore((s) => s.setError);
|
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 resetRecording = useRecordingStore((s) => s.reset);
|
||||||
const addParticleToStream = useAppStore((s) => s.addParticleToStream);
|
const addParticleToStream = useAppStore((s) => s.addParticleToStream);
|
||||||
|
|
||||||
@@ -29,27 +40,31 @@ export function useRecorder(streamId: string | null) {
|
|||||||
streamRef.current = null;
|
streamRef.current = null;
|
||||||
recorderRef.current = null;
|
recorderRef.current = null;
|
||||||
chunksRef.current = [];
|
chunksRef.current = [];
|
||||||
}, []);
|
setMediaStream(null);
|
||||||
|
}, [setMediaStream]);
|
||||||
|
|
||||||
const upload = useCallback(
|
const confirmSend = useCallback(async () => {
|
||||||
async (blob: Blob, durationMs: number) => {
|
if (!streamId) return;
|
||||||
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 fileName = `recording-${Date.now()}.webm`;
|
||||||
|
|
||||||
const { object, upload_url } = await apiClient.prepareUpload({
|
const { object, upload_url } = await apiClient.prepareUpload({
|
||||||
file_name: fileName,
|
file_name: fileName,
|
||||||
content_type: mimeType,
|
content_type: mimeType,
|
||||||
size_bytes: blob.size,
|
size_bytes: reviewBlob.size,
|
||||||
});
|
});
|
||||||
|
|
||||||
await fetch(upload_url, {
|
await fetch(upload_url, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": mimeType },
|
headers: { "Content-Type": mimeType },
|
||||||
body: blob,
|
body: reviewBlob,
|
||||||
});
|
});
|
||||||
|
|
||||||
await apiClient.confirmUpload(object.id);
|
await apiClient.confirmUpload(object.id);
|
||||||
@@ -58,14 +73,13 @@ export function useRecorder(streamId: string | null) {
|
|||||||
type: "media",
|
type: "media",
|
||||||
data: {
|
data: {
|
||||||
object_id: object.id,
|
object_id: object.id,
|
||||||
duration_ms: durationMs,
|
duration_ms: reviewDurationMs,
|
||||||
mime_type: mimeType,
|
mime_type: mimeType,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
addParticleToStream(streamId, particle);
|
addParticleToStream(streamId, particle);
|
||||||
|
|
||||||
// Also add to playback store's particle list
|
|
||||||
const playbackState = usePlaybackStore.getState();
|
const playbackState = usePlaybackStore.getState();
|
||||||
if (playbackState.streamId === streamId) {
|
if (playbackState.streamId === streamId) {
|
||||||
usePlaybackStore.setState({
|
usePlaybackStore.setState({
|
||||||
@@ -74,24 +88,33 @@ export function useRecorder(streamId: string | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
resetRecording();
|
resetRecording();
|
||||||
},
|
} catch (err) {
|
||||||
[streamId, setStatus, resetRecording, addParticleToStream],
|
setError(err instanceof Error ? err.message : "Upload failed");
|
||||||
);
|
}
|
||||||
|
}, [streamId, setStatus, setError, resetRecording, addParticleToStream]);
|
||||||
|
|
||||||
const startRecording = useCallback(async () => {
|
const startRecording = useCallback(async () => {
|
||||||
if (status !== "idle") return;
|
const currentStatus = useRecordingStore.getState().status;
|
||||||
|
if (currentStatus !== "idle") return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const mediaStream = await navigator.mediaDevices.getUserMedia({
|
const constraints =
|
||||||
video: true,
|
recordingMode === "video"
|
||||||
audio: true,
|
? { video: true, audio: true }
|
||||||
});
|
: { audio: true };
|
||||||
|
|
||||||
|
setStatus("recording");
|
||||||
|
|
||||||
|
const mediaStream =
|
||||||
|
await navigator.mediaDevices.getUserMedia(constraints);
|
||||||
|
|
||||||
streamRef.current = mediaStream;
|
streamRef.current = mediaStream;
|
||||||
|
setMediaStream(mediaStream);
|
||||||
chunksRef.current = [];
|
chunksRef.current = [];
|
||||||
startTimeRef.current = Date.now();
|
startTimeRef.current = Date.now();
|
||||||
|
|
||||||
const mime = getMediaMime();
|
const mime = getMediaMime(recordingMode);
|
||||||
|
mimeRef.current = mime;
|
||||||
const recorder = new MediaRecorder(mediaStream, { mimeType: mime });
|
const recorder = new MediaRecorder(mediaStream, { mimeType: mime });
|
||||||
recorderRef.current = recorder;
|
recorderRef.current = recorder;
|
||||||
|
|
||||||
@@ -105,23 +128,28 @@ export function useRecorder(streamId: string | null) {
|
|||||||
stopTracks();
|
stopTracks();
|
||||||
|
|
||||||
if (blob.size > 0) {
|
if (blob.size > 0) {
|
||||||
upload(blob, durationMs).catch((err) => {
|
setReviewBlob(blob, durationMs);
|
||||||
setError(err instanceof Error ? err.message : "Upload failed");
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
resetRecording();
|
resetRecording();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
recorder.start();
|
recorder.start();
|
||||||
setStatus("recording");
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
stopTracks();
|
stopTracks();
|
||||||
setError(
|
setError(
|
||||||
err instanceof Error ? err.message : "Failed to start recording",
|
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(() => {
|
const stopRecording = useCallback(() => {
|
||||||
if (recorderRef.current?.state === "recording") {
|
if (recorderRef.current?.state === "recording") {
|
||||||
@@ -130,6 +158,13 @@ export function useRecorder(streamId: string | null) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const cancelRecording = useCallback(() => {
|
const cancelRecording = useCallback(() => {
|
||||||
|
const currentStatus = useRecordingStore.getState().status;
|
||||||
|
|
||||||
|
if (currentStatus === "reviewing") {
|
||||||
|
resetRecording();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (recorderRef.current) {
|
if (recorderRef.current) {
|
||||||
recorderRef.current.ondataavailable = null;
|
recorderRef.current.ondataavailable = null;
|
||||||
recorderRef.current.onstop = null;
|
recorderRef.current.onstop = null;
|
||||||
@@ -141,34 +176,6 @@ export function useRecorder(streamId: string | null) {
|
|||||||
resetRecording();
|
resetRecording();
|
||||||
}, [stopTracks, 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
|
// Cleanup on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -176,5 +183,5 @@ export function useRecorder(streamId: string | null) {
|
|||||||
};
|
};
|
||||||
}, [stopTracks]);
|
}, [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 { useParams, useNavigate } from "react-router-dom";
|
||||||
import { ArrowLeft } from "lucide-react";
|
import { ArrowLeft } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useAppStore } from "@/stores/app-store";
|
import { useAppStore } from "@/stores/app-store";
|
||||||
import { usePlaybackStore } from "@/stores/playback-store";
|
import { usePlaybackStore } from "@/stores/playback-store";
|
||||||
|
import { useRecordingStore } from "@/stores/recording-store";
|
||||||
import { ParticleRenderer } from "@/features/playback/particle-renderer";
|
import { ParticleRenderer } from "@/features/playback/particle-renderer";
|
||||||
import { PlaybackControls } from "@/features/playback/playback-controls";
|
import { PlaybackControls } from "@/features/playback/playback-controls";
|
||||||
import { ReplyIndicator } from "@/features/recording/reply-indicator";
|
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";
|
import { useRecorder } from "@/features/recording/use-recorder";
|
||||||
|
|
||||||
export function StreamPlayerPage() {
|
export function StreamPlayerPage() {
|
||||||
const { streamId } = useParams<{ streamId: string }>();
|
const { streamId } = useParams<{ streamId: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const networks = useAppStore((s) => s.networks);
|
const networks = useAppStore((s) => s.networks);
|
||||||
|
const [composingText, setComposingText] = useState(false);
|
||||||
|
const [showRecordingOverlay, setShowRecordingOverlay] = useState(false);
|
||||||
|
|
||||||
const particles = usePlaybackStore((s) => s.particles);
|
const particles = usePlaybackStore((s) => s.particles);
|
||||||
const currentIndex = usePlaybackStore((s) => s.currentIndex);
|
const currentIndex = usePlaybackStore((s) => s.currentIndex);
|
||||||
@@ -22,8 +27,11 @@ export function StreamPlayerPage() {
|
|||||||
const prev = usePlaybackStore((s) => s.prev);
|
const prev = usePlaybackStore((s) => s.prev);
|
||||||
const goTo = usePlaybackStore((s) => s.goTo);
|
const goTo = usePlaybackStore((s) => s.goTo);
|
||||||
const reset = usePlaybackStore((s) => s.reset);
|
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
|
// Find the stream across all networks
|
||||||
const stream = networks
|
const stream = networks
|
||||||
@@ -46,10 +54,51 @@ export function StreamPlayerPage() {
|
|||||||
};
|
};
|
||||||
}, [stream?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [stream?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// Keyboard navigation
|
const handleRecordingOverlayClose = useCallback(() => {
|
||||||
|
setShowRecordingOverlay(false);
|
||||||
|
resume();
|
||||||
|
}, [resume]);
|
||||||
|
|
||||||
|
// Unified keyboard handling
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(e: KeyboardEvent) => {
|
(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();
|
e.preventDefault();
|
||||||
next();
|
next();
|
||||||
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
|
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
|
||||||
@@ -57,15 +106,47 @@ export function StreamPlayerPage() {
|
|||||||
prev();
|
prev();
|
||||||
} else if (e.key === "Escape") {
|
} else if (e.key === "Escape") {
|
||||||
navigate("/");
|
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(() => {
|
useEffect(() => {
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
window.addEventListener("keyup", handleKeyUp);
|
||||||
}, [handleKeyDown]);
|
return () => {
|
||||||
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
window.removeEventListener("keyup", handleKeyUp);
|
||||||
|
};
|
||||||
|
}, [handleKeyDown, handleKeyUp]);
|
||||||
|
|
||||||
if (!stream) {
|
if (!stream) {
|
||||||
return (
|
return (
|
||||||
@@ -77,16 +158,42 @@ export function StreamPlayerPage() {
|
|||||||
|
|
||||||
if (particles.length === 0) {
|
if (particles.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen flex-col">
|
<div className="flex h-screen flex-col bg-black text-white">
|
||||||
<Header name={stream.name} onBack={() => navigate("/")} />
|
{/* 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">
|
<div className="flex flex-1 items-center justify-center">
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
No particles yet. Hold ` to record the first one.
|
No particles yet. Hold ` to record the first one.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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 />
|
<ReplyIndicator />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{composingText && streamId && (
|
||||||
|
<TextComposeOverlay
|
||||||
|
streamId={streamId}
|
||||||
|
onClose={() => setComposingText(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showRecordingOverlay && (
|
||||||
|
<RecordingOverlay onClose={handleRecordingOverlayClose} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -94,26 +201,9 @@ export function StreamPlayerPage() {
|
|||||||
const currentParticle = particles[currentIndex];
|
const currentParticle = particles[currentIndex];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen flex-col bg-black text-white">
|
<div className="relative h-screen bg-black text-white">
|
||||||
{/* Top bar */}
|
{/* Particle content — fills entire viewport */}
|
||||||
<div className="relative z-10 flex items-center justify-between px-3 py-2">
|
<div className="absolute inset-0">
|
||||||
<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">
|
|
||||||
{currentParticle && (
|
{currentParticle && (
|
||||||
<ParticleRenderer
|
<ParticleRenderer
|
||||||
particle={currentParticle}
|
particle={currentParticle}
|
||||||
@@ -123,16 +213,42 @@ export function StreamPlayerPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom bar */}
|
{/* Top overlay: progress bars + back button */}
|
||||||
<div className="relative z-10 flex items-center justify-between px-4 py-3">
|
<div className="pointer-events-none absolute top-0 right-0 left-0 z-10">
|
||||||
<span className="text-muted-foreground text-xs">
|
<div className="pointer-events-auto">
|
||||||
{currentParticle?.created_by_email}
|
<PlaybackControls
|
||||||
</span>
|
total={particles.length}
|
||||||
<ReplyIndicator />
|
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>
|
</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" && (
|
{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">
|
<div className="flex flex-col items-center gap-4">
|
||||||
<p className="text-sm text-white">End of stream</p>
|
<p className="text-sm text-white">End of stream</p>
|
||||||
<Button
|
<Button
|
||||||
@@ -145,23 +261,19 @@ export function StreamPlayerPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Header({
|
{/* Text compose overlay */}
|
||||||
name,
|
{composingText && streamId && (
|
||||||
onBack,
|
<TextComposeOverlay
|
||||||
}: {
|
streamId={streamId}
|
||||||
name: string;
|
onClose={() => setComposingText(false)}
|
||||||
onBack: () => void;
|
/>
|
||||||
}) {
|
)}
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2 border-b px-3 py-2">
|
{/* Recording overlay */}
|
||||||
<Button variant="ghost" size="icon" onClick={onBack}>
|
{showRecordingOverlay && (
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<RecordingOverlay onClose={handleRecordingOverlayClose} />
|
||||||
</Button>
|
)}
|
||||||
<span className="text-sm font-medium">{name}</span>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { useEffect } from "react";
|
|
||||||
import { Plus, LogOut } from "lucide-react";
|
import { Plus, LogOut } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
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";
|
import { CreateStreamDialog } from "@/features/streams/create-stream-dialog";
|
||||||
|
|
||||||
export function StreamsPage() {
|
export function StreamsPage() {
|
||||||
const fetchStartup = useAppStore((s) => s.fetchStartup);
|
|
||||||
const isLoading = useAppStore((s) => s.isLoading);
|
|
||||||
const networks = useAppStore((s) => s.networks);
|
const networks = useAppStore((s) => s.networks);
|
||||||
const selectedNetworkId = useAppStore((s) => s.selectedNetworkId);
|
const selectedNetworkId = useAppStore((s) => s.selectedNetworkId);
|
||||||
const setSelectedNetwork = useAppStore((s) => s.setSelectedNetwork);
|
const setSelectedNetwork = useAppStore((s) => s.setSelectedNetwork);
|
||||||
const signOut = useAuthStore((s) => s.signOut);
|
const signOut = useAuthStore((s) => s.signOut);
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchStartup();
|
|
||||||
}, [fetchStartup]);
|
|
||||||
|
|
||||||
const selectedNetwork = selectedNetworkId
|
const selectedNetwork = selectedNetworkId
|
||||||
? networks.find((n) => n.id === selectedNetworkId)
|
? networks.find((n) => n.id === selectedNetworkId)
|
||||||
: null;
|
: null;
|
||||||
@@ -96,15 +89,9 @@ export function StreamsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stream list */}
|
{/* Stream list */}
|
||||||
{isLoading ? (
|
<ScrollArea className="flex-1">
|
||||||
<div className="flex flex-1 items-center justify-center">
|
<StreamList />
|
||||||
<p className="text-muted-foreground text-sm">Loading...</p>
|
</ScrollArea>
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<ScrollArea className="flex-1">
|
|
||||||
<StreamList />
|
|
||||||
</ScrollArea>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { apiClient } from "@/api/client";
|
import { apiClient } from "@/api/client";
|
||||||
import type {
|
import type {
|
||||||
|
AckInfo,
|
||||||
NetworkWithStreams,
|
NetworkWithStreams,
|
||||||
Stream,
|
Stream,
|
||||||
StreamParticle,
|
StreamParticle,
|
||||||
@@ -16,6 +17,7 @@ interface AppState {
|
|||||||
addStream: (networkId: string, stream: Stream) => void;
|
addStream: (networkId: string, stream: Stream) => void;
|
||||||
addParticleToStream: (streamId: string, particle: StreamParticle) => void;
|
addParticleToStream: (streamId: string, particle: StreamParticle) => void;
|
||||||
markParticlesSeen: (particleIds: string[]) => void;
|
markParticlesSeen: (particleIds: string[]) => void;
|
||||||
|
ackParticle: (particleId: string, email: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAppStore = create<AppState>((set, get) => ({
|
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) => {
|
markParticlesSeen: (particleIds) => {
|
||||||
const idSet = new Set(particleIds);
|
const idSet = new Set(particleIds);
|
||||||
set({
|
set({
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ interface PlaybackState {
|
|||||||
particles: StreamParticle[];
|
particles: StreamParticle[];
|
||||||
currentIndex: number;
|
currentIndex: number;
|
||||||
status: PlaybackStatus;
|
status: PlaybackStatus;
|
||||||
|
paused: boolean;
|
||||||
downloadUrlCache: Record<string, string>;
|
downloadUrlCache: Record<string, string>;
|
||||||
|
|
||||||
initStream: (
|
initStream: (
|
||||||
@@ -18,6 +19,8 @@ interface PlaybackState {
|
|||||||
next: () => void;
|
next: () => void;
|
||||||
prev: () => void;
|
prev: () => void;
|
||||||
goTo: (index: number) => void;
|
goTo: (index: number) => void;
|
||||||
|
pause: () => void;
|
||||||
|
resume: () => void;
|
||||||
cacheDownloadUrl: (particleId: string, url: string) => void;
|
cacheDownloadUrl: (particleId: string, url: string) => void;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}
|
}
|
||||||
@@ -27,6 +30,7 @@ export const usePlaybackStore = create<PlaybackState>((set, get) => ({
|
|||||||
particles: [],
|
particles: [],
|
||||||
currentIndex: 0,
|
currentIndex: 0,
|
||||||
status: "idle",
|
status: "idle",
|
||||||
|
paused: false,
|
||||||
downloadUrlCache: {},
|
downloadUrlCache: {},
|
||||||
|
|
||||||
initStream: (streamId, particles, startIndex) => {
|
initStream: (streamId, particles, startIndex) => {
|
||||||
@@ -42,26 +46,29 @@ export const usePlaybackStore = create<PlaybackState>((set, get) => ({
|
|||||||
next: () => {
|
next: () => {
|
||||||
const { currentIndex, particles } = get();
|
const { currentIndex, particles } = get();
|
||||||
if (currentIndex < particles.length - 1) {
|
if (currentIndex < particles.length - 1) {
|
||||||
set({ currentIndex: currentIndex + 1 });
|
set({ currentIndex: currentIndex + 1, paused: false });
|
||||||
} else {
|
} else {
|
||||||
set({ status: "ended" });
|
set({ status: "ended", paused: false });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
prev: () => {
|
prev: () => {
|
||||||
const { currentIndex } = get();
|
const { currentIndex } = get();
|
||||||
if (currentIndex > 0) {
|
if (currentIndex > 0) {
|
||||||
set({ currentIndex: currentIndex - 1, status: "playing" });
|
set({ currentIndex: currentIndex - 1, status: "playing", paused: false });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
goTo: (index) => {
|
goTo: (index) => {
|
||||||
const { particles } = get();
|
const { particles } = get();
|
||||||
if (index >= 0 && index < particles.length) {
|
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) => {
|
cacheDownloadUrl: (particleId, url) => {
|
||||||
set({
|
set({
|
||||||
downloadUrlCache: { ...get().downloadUrlCache, [particleId]: url },
|
downloadUrlCache: { ...get().downloadUrlCache, [particleId]: url },
|
||||||
@@ -74,6 +81,7 @@ export const usePlaybackStore = create<PlaybackState>((set, get) => ({
|
|||||||
particles: [],
|
particles: [],
|
||||||
currentIndex: 0,
|
currentIndex: 0,
|
||||||
status: "idle",
|
status: "idle",
|
||||||
|
paused: false,
|
||||||
downloadUrlCache: {},
|
downloadUrlCache: {},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,21 +1,54 @@
|
|||||||
import { create } from "zustand";
|
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 {
|
interface RecordingState {
|
||||||
status: RecordingStatus;
|
status: RecordingStatus;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
mediaStream: MediaStream | null;
|
||||||
|
recordingMode: RecordingMode;
|
||||||
|
reviewBlob: Blob | null;
|
||||||
|
reviewDurationMs: number;
|
||||||
|
|
||||||
setStatus: (status: RecordingStatus) => void;
|
setStatus: (status: RecordingStatus) => void;
|
||||||
setError: (error: string) => void;
|
setError: (error: string) => void;
|
||||||
|
setMediaStream: (stream: MediaStream | null) => void;
|
||||||
|
setRecordingMode: (mode: RecordingMode) => void;
|
||||||
|
setReviewBlob: (blob: Blob, durationMs: number) => void;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useRecordingStore = create<RecordingState>((set) => ({
|
export const useRecordingStore = create<RecordingState>((set) => ({
|
||||||
status: "idle",
|
status: "idle",
|
||||||
error: null,
|
error: null,
|
||||||
|
mediaStream: null,
|
||||||
|
recordingMode: loadRecordingMode(),
|
||||||
|
reviewBlob: null,
|
||||||
|
reviewDurationMs: 0,
|
||||||
|
|
||||||
setStatus: (status) => set({ status, error: null }),
|
setStatus: (status) => set({ status, error: null }),
|
||||||
setError: (error) => set({ status: "error", error }),
|
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