feat: list streams and story-mode catchup

This commit is contained in:
talksik
2026-02-21 09:46:04 -08:00
parent b5f90709de
commit 0cd74c0a8a
33 changed files with 2304 additions and 41 deletions
@@ -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<string | null>(cachedUrl ?? null);
const [error, setError] = useState<string | null>(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 (
<div className="text-muted-foreground flex items-center justify-center text-sm">
{error}
</div>
);
}
if (!url) {
return <Skeleton className="h-full w-full rounded-none" />;
}
const data = particle.data as MediaParticleData;
const isAudio = data.mime_type?.startsWith("audio/");
if (isAudio) {
return (
<div className="flex h-full w-full items-center justify-center">
<audio src={url} autoPlay onEnded={onEnded} controls />
</div>
);
}
return (
<video
src={url}
autoPlay
playsInline
onEnded={onEnded}
className="h-full w-full object-contain"
/>
);
}