diff --git a/js/web/src/hooks/use-video-record.ts b/js/web/src/hooks/use-video-record.ts new file mode 100644 index 000000000..7dccc638d --- /dev/null +++ b/js/web/src/hooks/use-video-record.ts @@ -0,0 +1,98 @@ +// Fetches a single video record by at:// URI for the VOD page. +import { useEffect, useState } from "react"; +import { place } from "streamplace"; +import { useStore } from "../lib/store"; + +export function useVideoRecord(user: string, tid: string) { + const streamplaceUrl = useStore((state) => state.url); + const anonPDSAgent = useStore((state) => state.anonPDSAgent); + const [record, setRecord] = useState(null); + const [author, setAuthor] = useState<{ + did: string; + handle?: string; + displayName?: string; + avatar?: string; + } | null>(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + + async function fetchRecord() { + setLoading(true); + setError(null); + + try { + // Use the anonPDSAgent if available, otherwise fetch directly. + let data: any; + if (anonPDSAgent) { + const res = await anonPDSAgent.api.com.atproto.repo.getRecord({ + repo: user, + collection: "place.stream.video", + rkey: tid, + }); + data = res.data; + } else { + const uri = `at://${user}/place.stream.video/${tid}`; + const res = await fetch( + `${streamplaceUrl}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(user)}&collection=place.stream.video&rkey=${tid}`, + ); + if (!res.ok) throw new Error(`Failed to fetch video: ${res.status}`); + data = await res.json(); + } + + if (cancelled) return; + setRecord(data.value as place.stream.video.Main); + + // Try to get profile info from the repo itself. + // The AT Protocol getRecord doesn't include author info, so we + // do a lightweight resolveHandle + getProfile if available. + try { + if (anonPDSAgent) { + const profile = await anonPDSAgent.getProfile({ actor: user }); + if (!cancelled) { + setAuthor({ + did: profile.data.did, + handle: profile.data.handle, + displayName: profile.data.displayName, + avatar: profile.data.avatar, + }); + } + } else { + // If no agent, we can still fetch the profile via the public API. + const profileRes = await fetch( + `${streamplaceUrl}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(user)}`, + ); + if (profileRes.ok) { + const profileData = await profileRes.json(); + if (!cancelled) { + setAuthor({ + did: profileData.did, + handle: profileData.handle, + displayName: profileData.displayName, + avatar: profileData.avatar, + }); + } + } + } + } catch { + // Profile fetch is best-effort; the page still works without it. + } + } catch (e: any) { + if (!cancelled) { + setError(e?.message ?? "Failed to load video"); + } + } finally { + if (!cancelled) setLoading(false); + } + } + + fetchRecord(); + return () => { + cancelled = true; + }; + }, [user, tid, anonPDSAgent, streamplaceUrl]); + + return { record, author, loading, error }; +} diff --git a/js/web/src/routes/$user/video/$tid.tsx b/js/web/src/routes/$user/video/$tid.tsx index 331aefcfc..b7e1bb769 100644 --- a/js/web/src/routes/$user/video/$tid.tsx +++ b/js/web/src/routes/$user/video/$tid.tsx @@ -1,5 +1,7 @@ import { VideoSectionInner } from "@/components/stream/video-section"; +import { useVideoRecord } from "@/hooks/use-video-record"; import { getStreamplaceUrl } from "@/lib/streamplace-url"; +import { formatDuration } from "@/lib/video"; import { createFileRoute } from "@tanstack/react-router"; import { useMemo } from "react"; @@ -9,6 +11,7 @@ export const Route = createFileRoute("/$user/video/$tid")({ function VodPage() { const { user, tid } = Route.useParams(); + const { record, author, loading, error } = useVideoRecord(user, tid); const { playlistUrl, thumbnailUrl } = useMemo(() => { const base = getStreamplaceUrl(); @@ -19,6 +22,17 @@ function VodPage() { }; }, [user, tid]); + const title = record?.title || "Untitled"; + const description = record?.description; + const createdAt = record?.createdAt + ? new Date(record.createdAt).toLocaleDateString(undefined, { + year: "numeric", + month: "long", + day: "numeric", + }) + : null; + const duration = record?.durationMs ? formatDuration(record.durationMs) : null; + return (
@@ -35,12 +49,45 @@ function VodPage() {
-
- @{user} -
-

- Video playback +

+ {loading ? "Loading..." : title}

+ +
+ + {author?.displayName || author?.handle || user} + + {(author?.displayName || author?.handle) && ( + @ + )} + + {author?.handle || user} + +
+ + {duration && ( + + {duration} + + )} + + {createdAt && ( + + {createdAt} + + )} + + {description && ( +

+ {description} +

+ )} + + {error && ( +

+ {error} +

+ )}