From 0cd74c0a8ace0a43b6445b7b5c72cdcdb431adda Mon Sep 17 00:00:00 2001 From: talksik Date: Sat, 21 Feb 2026 09:46:04 -0800 Subject: [PATCH] feat: list streams and story-mode catchup --- CLAUDE.md | 5 + js/package.json | 1 + js/src/App.tsx | 17 +- js/src/api/client.ts | 81 ++++++ js/src/api/types.ts | 155 ++++++++++- js/src/components/ui/badge.tsx | 45 +++ js/src/components/ui/card.tsx | 94 +++++++ js/src/components/ui/dialog.tsx | 155 +++++++++++ js/src/components/ui/dropdown-menu.tsx | 261 ++++++++++++++++++ js/src/components/ui/progress.tsx | 31 +++ js/src/components/ui/scroll-area.tsx | 53 ++++ js/src/components/ui/select.tsx | 186 +++++++++++++ js/src/components/ui/separator.tsx | 26 ++ js/src/components/ui/skeleton.tsx | 13 + .../playback/fallback-particle-view.tsx | 61 ++++ .../features/playback/media-particle-view.tsx | 78 ++++++ .../features/playback/particle-renderer.tsx | 58 ++++ .../features/playback/playback-controls.tsx | 52 ++++ .../features/playback/text-particle-view.tsx | 20 ++ js/src/features/recording/reply-indicator.tsx | 38 +++ js/src/features/recording/use-recorder.ts | 180 ++++++++++++ .../features/streams/create-stream-dialog.tsx | 112 ++++++++ js/src/features/streams/stream-list.tsx | 63 +++++ js/src/lib/stream-utils.ts | 35 +++ js/src/lib/time-utils.ts | 21 ++ js/src/main.ts | 2 +- js/src/pages/home-page.tsx | 26 -- js/src/pages/stream-player-page.tsx | 167 +++++++++++ js/src/pages/streams-page.tsx | 110 ++++++++ js/src/stores/app-store.ts | 76 ++++- js/src/stores/playback-store.ts | 80 ++++++ js/src/stores/recording-store.ts | 21 ++ js/yarn.lock | 22 +- 33 files changed, 2304 insertions(+), 41 deletions(-) create mode 100644 js/src/components/ui/badge.tsx create mode 100644 js/src/components/ui/card.tsx create mode 100644 js/src/components/ui/dialog.tsx create mode 100644 js/src/components/ui/dropdown-menu.tsx create mode 100644 js/src/components/ui/progress.tsx create mode 100644 js/src/components/ui/scroll-area.tsx create mode 100644 js/src/components/ui/select.tsx create mode 100644 js/src/components/ui/separator.tsx create mode 100644 js/src/components/ui/skeleton.tsx create mode 100644 js/src/features/playback/fallback-particle-view.tsx create mode 100644 js/src/features/playback/media-particle-view.tsx create mode 100644 js/src/features/playback/particle-renderer.tsx create mode 100644 js/src/features/playback/playback-controls.tsx create mode 100644 js/src/features/playback/text-particle-view.tsx create mode 100644 js/src/features/recording/reply-indicator.tsx create mode 100644 js/src/features/recording/use-recorder.ts create mode 100644 js/src/features/streams/create-stream-dialog.tsx create mode 100644 js/src/features/streams/stream-list.tsx create mode 100644 js/src/lib/stream-utils.ts create mode 100644 js/src/lib/time-utils.ts delete mode 100644 js/src/pages/home-page.tsx create mode 100644 js/src/pages/stream-player-page.tsx create mode 100644 js/src/pages/streams-page.tsx create mode 100644 js/src/stores/playback-store.ts create mode 100644 js/src/stores/recording-store.ts diff --git a/CLAUDE.md b/CLAUDE.md index f3078e2..3cb51b1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,5 +1,10 @@ # Project Rules +## Architecture +- Electron typescript/react app is in `js/` folder +- Orion is the api server which lives in the `go/` folder +- `cpp/` points to our prototype of a C++ Qt widgets client + ## Electron App ### Quality We care about overall architectural quality and keeping consistent patterns according to best practices. diff --git a/js/package.json b/js/package.json index 260732d..d4f05b8 100644 --- a/js/package.json +++ b/js/package.json @@ -51,6 +51,7 @@ "radix-ui": "^1.4.3", "react": "^19.2.4", "react-dom": "^19.2.4", + "react-router-dom": "^7.13.0", "shadcn": "^3.8.5", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.0", diff --git a/js/src/App.tsx b/js/src/App.tsx index c495695..cfcb70d 100644 --- a/js/src/App.tsx +++ b/js/src/App.tsx @@ -1,7 +1,9 @@ import { useEffect } from "react"; +import { HashRouter, Routes, Route } from "react-router-dom"; import { useAuthStore } from "@/stores/auth-store"; import { LoginPage } from "@/features/auth/login-page"; -import { HomePage } from "@/pages/home-page"; +import { StreamsPage } from "@/pages/streams-page"; +import { StreamPlayerPage } from "@/pages/stream-player-page"; const App = () => { const status = useAuthStore((s) => s.status); @@ -19,11 +21,18 @@ const App = () => { ); } - if (status === "authenticated") { - return ; + if (status !== "authenticated") { + return ; } - return ; + return ( + + + } /> + } /> + + + ); }; export default App; diff --git a/js/src/api/client.ts b/js/src/api/client.ts index d294bd1..d963922 100644 --- a/js/src/api/client.ts +++ b/js/src/api/client.ts @@ -1,10 +1,17 @@ import { useSessionStore } from "@/stores/session-store"; import type { + CreateStreamParticleRequest, + CreateStreamRequest, Human, + MarkSeenBatchRequest, + PrepareUploadRequest, + PrepareUploadResponse, RequestCodeRequest, SignInRequest, SignInResponse, StartupResponse, + Stream, + StreamParticle, } from "./types"; export class ApiError extends Error { @@ -67,6 +74,8 @@ class ApiClient { return response.json() as Promise; } + // --- Auth --- + async requestCode(data: RequestCodeRequest): Promise { await this.request("POST", "/auth/request-code", data); } @@ -83,9 +92,81 @@ class ApiClient { await this.request("POST", "/auth/sign-out"); } + // --- Startup --- + async startup(): Promise { return this.request("GET", "/startup"); } + + // --- Streams --- + + async createStream( + networkId: string, + data: CreateStreamRequest, + ): Promise { + return this.request( + "POST", + `/networks/${networkId}/streams`, + data, + ); + } + + async createStreamParticle( + streamId: string, + data: CreateStreamParticleRequest, + ): Promise { + return this.request( + "POST", + `/streams/${streamId}/particles`, + data, + ); + } + + // --- Particles --- + + async markSeen(particleId: string): Promise { + await this.request("POST", `/particles/${particleId}/seen`); + } + + async markSeenBatch(data: MarkSeenBatchRequest): Promise { + await this.request("POST", "/particles/seen", data); + } + + async getParticleDownloadUrl(particleId: string): Promise { + const token = this.config.getToken(); + const headers: Record = {}; + if (token) { + headers["Authorization"] = `Bearer ${token}`; + } + + const response = await fetch( + `${this.config.baseUrl}/particles/${particleId}/download`, + { headers, redirect: "follow" }, + ); + + if (response.status === 401) { + this.config.onUnauthorized(); + throw new ApiError(401, "Unauthorized"); + } + + if (!response.ok) { + throw new ApiError(response.status, "Failed to get download URL"); + } + + return response.url; + } + + // --- Depot --- + + async prepareUpload( + data: PrepareUploadRequest, + ): Promise { + return this.request("POST", "/depot/upload", data); + } + + async confirmUpload(objectId: string): Promise { + await this.request("POST", `/depot/objects/${objectId}/confirm`); + } } export const apiClient = new ApiClient({ diff --git a/js/src/api/types.ts b/js/src/api/types.ts index 12f03ab..e3e9a93 100644 --- a/js/src/api/types.ts +++ b/js/src/api/types.ts @@ -1,11 +1,156 @@ +// --- Core entities --- + export interface Human { - id: string; + id: string | null; email: string; email_prefix: string; - created_at: string; - updated_at: string; + created_at: string | null; } +export type StreamStatus = "open" | "closed" | "unspecified"; + +export interface AckInfo { + email: string; + acked_at: string; +} + +// --- Particle types --- + +export type ParticleType = + | "media" + | "text" + | "quest" + | "paper" + | "file" + | "folder"; + +export interface MediaParticleData { + object_id: string; + duration_ms: number; + mime_type: string; +} + +export interface TextParticleData { + content: string; +} + +export interface QuestParticleData { + title: string; + description: string; + status?: string; + assigned_to?: string; + due_date?: string; +} + +export interface PaperParticleData { + title: string; + content: string; +} + +export interface FileParticleData { + object_id: string; + filename: string; + mime_type: string; + size: number; +} + +export interface FolderParticleData { + name: string; + color?: string; +} + +export interface ParticleDataMap { + media: MediaParticleData; + text: TextParticleData; + quest: QuestParticleData; + paper: PaperParticleData; + file: FileParticleData; + folder: FolderParticleData; +} + +export function getParticleData( + particle: StreamParticle, + type: T, +): ParticleDataMap[T] { + return particle.data as ParticleDataMap[T]; +} + +export interface StreamParticle { + id: string; + type: ParticleType; + data: unknown; + created_by_email: string; + seen: boolean; + acks: AckInfo[]; + updated_at: string; + created_at: string; +} + +export interface Stream { + id: string; + name: string; + description: string; + status: StreamStatus; + members?: string[]; + particles: StreamParticle[]; + unseen_count: number; +} + +export interface Network { + id: string; + name: string; + admin_human: Human; + humans: Human[]; + open_stream_count: number; + open_stream_capacity: number; + created_at: string; +} + +export interface NetworkWithStreams extends Network { + streams: Stream[]; +} + +// --- Depot types --- + +export interface PrepareUploadRequest { + file_name: string; + content_type: string; + size_bytes: number; +} + +export interface PrepareUploadResponse { + object: DepotObject; + upload_url: string; +} + +export interface DepotObject { + id: string; + status: string; + content_type: string; + size_bytes: number; + created_at: string; +} + +// --- Stream mutation types --- + +export interface CreateStreamRequest { + name: string; + description: string; + visibility: "network_all" | "custom"; + member_emails?: string[]; +} + +export interface CreateStreamParticleRequest { + type: ParticleType; + data: unknown; +} + +export interface MarkSeenBatchRequest { + particle_ids: string[]; +} + +// --- Auth types --- + export interface RequestCodeRequest { email: string; } @@ -20,6 +165,8 @@ export interface SignInResponse { token: string; } +// --- Startup --- + export interface StartupResponse { - networks: unknown[]; + networks: NetworkWithStreams[]; } diff --git a/js/src/components/ui/badge.tsx b/js/src/components/ui/badge.tsx new file mode 100644 index 0000000..086ee43 --- /dev/null +++ b/js/src/components/ui/badge.tsx @@ -0,0 +1,45 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { Slot } from "radix-ui" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "h-5 gap-1 rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! inline-flex items-center justify-center w-fit whitespace-nowrap shrink-0 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive overflow-hidden group/badge", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", + secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80", + destructive: "bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20", + outline: "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground", + ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50", + link: "text-primary underline-offset-4 hover:underline", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant = "default", + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot.Root : "span" + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/js/src/components/ui/card.tsx b/js/src/components/ui/card.tsx new file mode 100644 index 0000000..84bfaa4 --- /dev/null +++ b/js/src/components/ui/card.tsx @@ -0,0 +1,94 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Card({ + className, + size = "default", + ...props +}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) { + return ( +
img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl group/card flex flex-col", className)} + {...props} + /> + ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} diff --git a/js/src/components/ui/dialog.tsx b/js/src/components/ui/dialog.tsx new file mode 100644 index 0000000..8e93105 --- /dev/null +++ b/js/src/components/ui/dialog.tsx @@ -0,0 +1,155 @@ +"use client" + +import * as React from "react" +import { Dialog as DialogPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { XIcon } from "lucide-react" + +function Dialog({ + ...props +}: React.ComponentProps) { + return +} + +function DialogTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function DialogPortal({ + ...props +}: React.ComponentProps) { + return +} + +function DialogClose({ + ...props +}: React.ComponentProps) { + return +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + + + )} + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogFooter({ + className, + showCloseButton = false, + children, + ...props +}: React.ComponentProps<"div"> & { + showCloseButton?: boolean +}) { + return ( +
+ {children} + {showCloseButton && ( + + + + )} +
+ ) +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/js/src/components/ui/dropdown-menu.tsx b/js/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..26173d7 --- /dev/null +++ b/js/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,261 @@ +import * as React from "react" +import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" +import { CheckIcon, ChevronRightIcon } from "lucide-react" + +function DropdownMenu({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuContent({ + className, + align = "start", + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function DropdownMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean + variant?: "default" | "destructive" +}) { + return ( + + ) +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuRadioItem({ + className, + children, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + ) +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +function DropdownMenuSub({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +} diff --git a/js/src/components/ui/progress.tsx b/js/src/components/ui/progress.tsx new file mode 100644 index 0000000..d73312c --- /dev/null +++ b/js/src/components/ui/progress.tsx @@ -0,0 +1,31 @@ +"use client" + +import * as React from "react" +import { Progress as ProgressPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Progress({ + className, + value, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { Progress } diff --git a/js/src/components/ui/scroll-area.tsx b/js/src/components/ui/scroll-area.tsx new file mode 100644 index 0000000..605ef01 --- /dev/null +++ b/js/src/components/ui/scroll-area.tsx @@ -0,0 +1,53 @@ +import * as React from "react" +import { ScrollArea as ScrollAreaPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function ScrollArea({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + + + ) +} + +function ScrollBar({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { ScrollArea, ScrollBar } diff --git a/js/src/components/ui/select.tsx b/js/src/components/ui/select.tsx new file mode 100644 index 0000000..e22c281 --- /dev/null +++ b/js/src/components/ui/select.tsx @@ -0,0 +1,186 @@ +"use client" + +import * as React from "react" +import { Select as SelectPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" +import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react" + +function Select({ + ...props +}: React.ComponentProps) { + return +} + +function SelectGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectValue({ + ...props +}: React.ComponentProps) { + return +} + +function SelectTrigger({ + className, + size = "default", + children, + ...props +}: React.ComponentProps & { + size?: "sm" | "default" +}) { + return ( + + {children} + + + + + ) +} + +function SelectContent({ + className, + children, + position = "item-aligned", + align = "center", + ...props +}: React.ComponentProps) { + return ( + + + + + {children} + + + + + ) +} + +function SelectLabel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function SelectSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectScrollUpButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function SelectScrollDownButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue, +} diff --git a/js/src/components/ui/separator.tsx b/js/src/components/ui/separator.tsx new file mode 100644 index 0000000..fb11887 --- /dev/null +++ b/js/src/components/ui/separator.tsx @@ -0,0 +1,26 @@ +import * as React from "react" +import { Separator as SeparatorPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Separator({ + className, + orientation = "horizontal", + decorative = true, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Separator } diff --git a/js/src/components/ui/skeleton.tsx b/js/src/components/ui/skeleton.tsx new file mode 100644 index 0000000..41bcbf9 --- /dev/null +++ b/js/src/components/ui/skeleton.tsx @@ -0,0 +1,13 @@ +import { cn } from "@/lib/utils" + +function Skeleton({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { Skeleton } diff --git a/js/src/features/playback/fallback-particle-view.tsx b/js/src/features/playback/fallback-particle-view.tsx new file mode 100644 index 0000000..234af19 --- /dev/null +++ b/js/src/features/playback/fallback-particle-view.tsx @@ -0,0 +1,61 @@ +import type { StreamParticle } from "@/api/types"; +import { getParticleData } from "@/api/types"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { FileIcon, HelpCircleIcon, ScrollTextIcon, BookOpenIcon } from "lucide-react"; + +const TYPE_META: Record = { + quest: { icon: ScrollTextIcon, label: "Quest" }, + paper: { icon: BookOpenIcon, label: "Paper" }, + file: { icon: FileIcon, label: "File" }, +}; + +interface FallbackParticleViewProps { + particle: StreamParticle; +} + +export function FallbackParticleView({ particle }: FallbackParticleViewProps) { + const meta = TYPE_META[particle.type] ?? { + icon: HelpCircleIcon, + label: particle.type, + }; + const Icon = meta.icon; + const title = (() => { + switch (particle.type) { + case "quest": + return getParticleData(particle, "quest").title; + case "paper": + return getParticleData(particle, "paper").title; + case "file": + return getParticleData(particle, "file").filename; + case "folder": + return getParticleData(particle, "folder").name; + default: + return null; + } + })(); + + return ( +
+ + + +
+ {meta.label} + {title && {title}} +
+
+ +

+ From {particle.created_by_email} +

+
+
+
+ ); +} diff --git a/js/src/features/playback/media-particle-view.tsx b/js/src/features/playback/media-particle-view.tsx new file mode 100644 index 0000000..9ed7fdd --- /dev/null +++ b/js/src/features/playback/media-particle-view.tsx @@ -0,0 +1,78 @@ +import { useEffect, useState } from "react"; +import type { MediaParticleData, StreamParticle } from "@/api/types"; +import { apiClient } from "@/api/client"; +import { usePlaybackStore } from "@/stores/playback-store"; +import { Skeleton } from "@/components/ui/skeleton"; + +interface MediaParticleViewProps { + particle: StreamParticle; + onEnded: () => void; +} + +export function MediaParticleView({ + particle, + onEnded, +}: MediaParticleViewProps) { + const cachedUrl = usePlaybackStore( + (s) => s.downloadUrlCache[particle.id], + ); + const cacheDownloadUrl = usePlaybackStore((s) => s.cacheDownloadUrl); + const [url, setUrl] = useState(cachedUrl ?? null); + const [error, setError] = useState(null); + + useEffect(() => { + if (cachedUrl) { + setUrl(cachedUrl); + return; + } + + let cancelled = false; + apiClient + .getParticleDownloadUrl(particle.id) + .then((downloadUrl) => { + if (cancelled) return; + cacheDownloadUrl(particle.id, downloadUrl); + setUrl(downloadUrl); + }) + .catch(() => { + if (!cancelled) setError("Failed to load media"); + }); + + return () => { + cancelled = true; + }; + }, [particle.id, cachedUrl, cacheDownloadUrl]); + + if (error) { + return ( +
+ {error} +
+ ); + } + + if (!url) { + return ; + } + + const data = particle.data as MediaParticleData; + const isAudio = data.mime_type?.startsWith("audio/"); + + if (isAudio) { + return ( +
+
+ ); + } + + return ( +