diff --git a/js/web/src/components/stream/player-offline.tsx b/js/web/src/components/stream/player-offline.tsx index 615171caf..0e3f2f995 100644 --- a/js/web/src/components/stream/player-offline.tsx +++ b/js/web/src/components/stream/player-offline.tsx @@ -1,13 +1,22 @@ import type { LivestreamStore } from "@streamplace/core"; +import { Link } from "@tanstack/react-router"; +import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { useStore } from "zustand"; +import { useStore as useZustandStore } from "zustand"; import useAvatars from "../../hooks/use-avatars"; +import { useStore } from "../../lib/store"; // 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, @@ -16,13 +25,56 @@ export function PlayerOffline({ user: string; }) { const { t } = useTranslation("common"); - const profile = useStore(store, (s) => s.profile); + const profile = useZustandStore(store, (s) => s.profile); 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; + 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) { + 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; + }; + // The captured useAppStore.getState().getRecommendations is stable + // for the lifetime of the app, so depending on profile.did is + // enough. + }, [profile?.did]); + + // 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 recAvatar = recDetailed?.avatar; + const recHandle = recDetailed?.handle; + return (
@{handle}
-- {t("user-not-streaming-check-back", { user: handle })} -
+ {recommendation ? ( + + {recAvatar ? ( ++ {t("user-not-streaming-check-back", { user: handle })} +
+ )}