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) && (
)}
);
}