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 (
{banner ? (
) : (
)}
{/* Under lg: stacked, 4:3 outer. Video on top, OFFLINE state below. */}
{validatedRecommendation ? (
) : null}
{t("offline")}

{validatedRecommendation ? ( <> Looks like @{handle} is offline, but they recommend checking out: ) : ( <> Looks like @{handle} is offline right now. Check back later. )}

{avatar && (

@{handle}

)}
{/* lg and above: side-by-side. Left text panel 5:3, right video panel 16:9. */}
{validatedRecommendation ? (

Looks like{" "} @{handle} is offline, but they recommend checking out:

) : (

Looks like @{handle} is offline right now. Check back later.

)} {avatar && (

@{handle}

)}
{validatedRecommendation ? (
) : null}
); } // Embeds a small live-player preview for the recommended streamer. // Clicking anywhere on the preview navigates to the streamer's page // (a transparent Link overlay sits on top of the Player so the // Player's own click-to-play/pause handler never fires). function RecommendationEmbed({ did, handle, avatar, }: { did: string; handle: string; avatar?: string; }) { const { playlistUrl, thumbnailUrl } = useMemo(() => { const base = getStreamplaceUrl(); return { playlistUrl: `${base}/xrpc/place.stream.playback.getLivePlaylist?streamer=${encodeURIComponent(did)}`, thumbnailUrl: `${base}/api/playback/${encodeURIComponent(did)}/stream.jpg`, }; }, [did]); return (
captureError(message, { user: did, source: "recommendation-embed" }) } /> {/* Transparent Link overlay so clicks navigate to the streamer's page instead of toggling the embedded player's play/pause. */}
{avatar ? ( ) : (
)}

You're going to @{handle}

); }