import { Avatar, ContentRights, ContentWarnings, formatActivity, formatHandle, formatHandleWithAt, IconButton, layout, LiveBadge, PlayerUI, ShareSheet, Text, useAuthor, useAvatar, useDID, useLivestreamStore, usePlayerStore, useTheme, useTitle, useViews, zero, } from "@streamplace/components"; import { motion, statusColors, } from "@streamplace/components/src/lib/theme/tokens"; import AQLink from "components/aqlink"; import FollowButton from "components/follow-button"; import { ChevronDown, ChevronLeft, ChevronRight, ChevronUp, } from "lucide-react-native"; import { useRef, useState } from "react"; import { Linking, Pressable, ScrollView, View } from "react-native"; import Animated, { useAnimatedStyle, useSharedValue, withTiming, } from "react-native-reanimated"; import type { place } from "streamplace"; import { LANG_TAG_PREFIX, LANGUAGES } from "../live-dashboard/livestream-panel"; import { KebabMenu } from "./desktop-ui/kebab"; const { gap, pb, pt, px, py, r, borders } = zero; const ATMOCO_STREAMS = [ { handle: "stream1.atmosphereconf.org", label: "Great Hall" }, { handle: "stream2.atmosphereconf.org", label: "Performance Theatre" }, { handle: "stream3.atmosphereconf.org", label: "Room 2301" }, ]; function AtMoCoNav({ currentHandle }: { currentHandle: string }) { const { theme } = useTheme(); return ( Switch streams: {ATMOCO_STREAMS.map((stream) => { const isActive = currentHandle === stream.handle; return ( {stream.label} ); })} ); } /** * The metadata block below the player — YouTube grammar: title on top, * streamer identity row (avatar with live ring, handle, follow) beneath, * viewer count and actions on the right. */ export function BottomMetadata({ setShowChat, showChat, compact = false, }: { setShowChat: (show: boolean) => void; showChat: boolean; /** Tighter paddings and no chat toggle — mobile below-player placement */ compact?: boolean; }) { const { profile, did, title, avatarUri, isLive, views, activityLabel, tags, hasMeta, contentWarnings, contentRights, displayName, handleStr, streamerName, streamerSubtitle, } = useStreamMeta(); const { theme } = useTheme(); if (compact) { return ; } return ( {/* LIVE status + watching count (YouTube-style live indicator) */} {isLive ? ( ) : null} {/* Title */} {title ? ( {title} ) : null} {/* Left: streamer identity row */} { if (profile?.handle) { Linking.openURL( `https://bsky.app/profile/${formatHandle(profile)}`, ); } }} style={{ flexShrink: 1, minWidth: 0 }} > {streamerName} {streamerSubtitle ? ( {streamerSubtitle} ) : null} {did && profile && ( )} {/* Right: viewers + actions */} {!isLive ? : null} {isLive && ( setShowChat(!showChat)} > {showChat ? ( ) : ( )} )} {/* Tags */} {hasMeta && tags && tags.length > 0 && ( {tags.map((tag) => ( {tag} ))} )} {/* Content metadata */} {(contentWarnings.length > 0 || (contentRights && Object.keys(contentRights).length > 0)) && ( {contentRights && ( )} )} {/* Atmosphere Conference inter-stream nav. TODO: remove after conf */} {profile?.handle && ATMOCO_STREAMS.some((s) => s.handle === profile.handle) && ( )} ); } /** Shared derived stream metadata for both metadata layouts. */ function useStreamMeta() { const profile = useAuthor(); const ls = useLivestreamStore((x) => x.livestream); const segment = useLivestreamStore((x) => x.segment); const mode = usePlayerStore((x) => x.mode); const did = useDID(); const title = useTitle(); const avatarUri = useAvatar(); const views = useViews(); const contentWarnings = (segment?.contentWarnings?.warnings as string[]) || []; const contentRights = segment?.contentRights; const activity = ls?.record.activity as | ((place.stream.defs.ActivityGame | place.stream.defs.ActivityLabel) & { $type?: string; }) | undefined; const activityLabel = activity ? formatActivity(activity) : null; const rawTags = ls?.record.tags as string[] | undefined; const tags = rawTags?.map((tag) => { if (tag.startsWith(LANG_TAG_PREFIX)) { const code = tag.slice(LANG_TAG_PREFIX.length); return LANGUAGES.find((l) => l.code === code)?.native ?? tag; } return tag; }); const hasMeta = activityLabel || (tags && tags.length > 0); const isLive = mode === "live"; // YouTube leads with the channel's display name; the handle is secondary. const displayName = profile?.displayName?.trim(); const handleStr = profile ? formatHandleWithAt(profile) : "@user"; const streamerName = displayName || handleStr; const streamerSubtitle = [displayName ? handleStr : null, activityLabel] .filter(Boolean) .join(" · "); return { profile, did, title, avatarUri, isLive, views, activityLabel, tags, hasMeta, contentWarnings, contentRights, displayName, handleStr, streamerName, streamerSubtitle, }; } /** * Compact stream summary for mobile: status row, one-line title, handle row, * sitting transparently on the page background. Tapping the title or the * chevron expands tags, actions, and content notices; only that explicit * expansion changes the page geometry. */ function CompactStreamInfo() { const { theme, zero: z } = useTheme(); const { profile, did, title, avatarUri, isLive, views, activityLabel, tags, contentWarnings, contentRights, handleStr, } = useStreamMeta(); const [expanded, setExpanded] = useState(false); const expandHeight = useSharedValue(0); const contentHeight = useRef(0); const setExpandedAnimated = (next: boolean) => { setExpanded(next); expandHeight.value = withTiming(next ? contentHeight.current : 0, { duration: motion.base, }); }; const expandedStyle = useAnimatedStyle(() => ({ height: expandHeight.value, opacity: Math.min(1, expandHeight.value / 48), })); const chevronColor = theme.colors.text2; const expandLabel = "Expand stream info"; const collapseLabel = "Collapse stream info"; return ( {/* single-line summary — tap to expand */} {!expanded && ( setExpandedAnimated(!expanded)} accessibilityRole="button" accessibilityLabel="Stream info" style={{ flex: 1 }} > {title || handleStr} {handleStr} )} {expanded && } {isLive ? ( {(views ?? 0) > 0 && ( {views} )} ) : null} setExpandedAnimated(!expanded)} accessibilityRole="button" accessibilityLabel={expanded ? collapseLabel : expandLabel} > {expanded ? ( ) : ( )} {/* expanded details */} { const h = e.nativeEvent.layout.height; contentHeight.current = h; if (expanded && Math.abs(expandHeight.value - h) > 1) { expandHeight.value = h; } }} > {title ? ( {title} ) : null} { if (profile?.handle) { Linking.openURL( `https://bsky.app/profile/${formatHandle(profile)}`, ); } }} style={{ alignSelf: "flex-start" }} > {handleStr} {did && profile && ( )} {tags && tags.length > 0 && ( {activityLabel ? ( {activityLabel} ) : null} {tags.map((tag) => ( {tag} ))} )} {(contentWarnings.length > 0 || (contentRights && Object.keys(contentRights).length > 0)) && ( {contentRights && ( )} )} {/* Atmosphere Conference inter-stream nav. TODO: remove after conf */} {profile?.handle && ATMOCO_STREAMS.some((s) => s.handle === profile.handle) && ( )} ); }