import type { LivestreamStore } from "@streamplace/core"; import { Link } from "@tanstack/react-router"; import { ChevronRight } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { useStore as useZustandStore } from "zustand"; import { useShallow } from "zustand/react/shallow"; import useAvatars from "../../hooks/use-avatars"; import { captureError } from "../../lib/log"; import { useStore } from "../../lib/store"; import { getStreamplaceUrl } from "../../lib/streamplace-url"; import { Player } from "../player/player"; import { validateOfflineRecommendation } from "./offline-recommendation"; // Replaces the player when the stream is offline. Renders the // streamer's banner as a blurred background with an offline badge, // avatar, and handle on top. Sits in the same DOM position as the // Player (absolute inset-0 inside the VideoSection container) so the // transition between live and offline is just an opacity swap. // // When the stream has been live before, fetches a recommendation via // the user's PDS and renders a "watch them instead" card linking to // the recommended streamer's page. The recommendation is shown only // after the fetch settles, so the user always sees the OFFLINE // state immediately. export function PlayerOffline({ store, user, }: { store: LivestreamStore; user: string; }) { const { t } = useTranslation("common"); const profile = useZustandStore(store, (s) => s.profile); // Subscribe to the app's agent fields so the effect re-runs when // loadOAuthClient finishes setting up the anon (or authed) agent. // Without this, a fast-firing WebSocket profile delivery can race // the OAuth client init and we end up stuck with no agent. const { pdsAgent, anonPDSAgent } = useStore( useShallow((s) => ({ pdsAgent: s.pdsAgent, anonPDSAgent: s.anonPDSAgent, })), ); const avatars = useAvatars(profile?.did ? [profile.did] : []); const detailed = profile?.did ? avatars[profile.did] : null; const banner = detailed?.banner; const avatar = detailed?.avatar || profile?.avatar; const handle = profile?.handle || user; const [recommendation, setRecommendation] = useState<{ did: string; source: string; } | null>(null); useEffect(() => { if (!profile?.did) return; if (!pdsAgent && !anonPDSAgent) return; setRecommendation(null); const getRecommendations = useStore.getState().getRecommendations; let mounted = true; const fetchRec = async () => { try { const result = await getRecommendations(profile.did); if (!mounted) return; const first = result.recommendations?.find( (r) => r.$type === "place.stream.live.getRecommendations#livestreamRecommendation" && (r as { did?: string }).did, ) as { did?: string; source?: string } | undefined; if (first?.did && first.did !== profile.did) { setRecommendation({ did: first.did, source: first.source ?? "" }); } } catch { // Silent: recommendations are best-effort. The OFFLINE state // is still useful on its own. } }; fetchRec(); return () => { mounted = false; }; }, [profile?.did, pdsAgent, anonPDSAgent]); // Look up the recommended streamer's profile so the card can // show their avatar and handle. const recAvatars = useAvatars(recommendation ? [recommendation.did] : []); const recDetailed = recommendation ? recAvatars[recommendation.did] : null; const validatedRecommendation = validateOfflineRecommendation( recommendation, recDetailed, profile?.did, ); const recAvatar = validatedRecommendation ? recDetailed?.avatar : undefined; return (
{validatedRecommendation ? ( <> Looks like @{handle} is offline, but they recommend checking out: > ) : ( <> Looks like @{handle} is offline right now. Check back later. > )}
{avatar && (@{handle}
Looks like{" "} @{handle} is offline, but they recommend checking out:
) : (Looks like @{handle} is offline right now. Check back later.
)} {avatar && (@{handle}
You're going to @{handle}