Add stream card rendering in feeds with action headers and pause-on-scroll

Stream events (kind 30311) now render properly in mixed feeds via NoteCard
with inline LiveStreamPlayer for live streams, thumbnail for ended/planned,
title + summary clickable to open stream details, and 'is streaming' /
'streamed' action headers. LiveStreamPlayer pauses when scrolled out of view.
This commit is contained in:
Chad Curtis
2026-02-21 04:54:00 -06:00
parent 9ffe893d59
commit 08787c542e
2 changed files with 180 additions and 2 deletions
+19
View File
@@ -93,6 +93,25 @@ export function LiveStreamPlayer({ src, poster, className }: LiveStreamPlayerPro
};
}, [src]);
// Pause video when scrolled out of view
useEffect(() => {
const video = videoRef.current;
const container = containerRef.current;
if (!video || !container) return;
const observer = new IntersectionObserver(
([entry]) => {
if (!entry.isIntersecting && !video.paused) {
video.pause();
}
},
{ threshold: 0.25 },
);
observer.observe(container);
return () => observer.disconnect();
}, []);
// Track playback & buffering state
useEffect(() => {
const video = videoRef.current;
+161 -2
View File
@@ -1,7 +1,8 @@
import { Link, useNavigate } from 'react-router-dom';
import { MessageCircle, Zap, MoreHorizontal, Play } from 'lucide-react';
import { MessageCircle, Zap, MoreHorizontal, Play, Radio, Users } from 'lucide-react';
import { RepostIcon } from '@/components/icons/RepostIcon';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { NoteContent } from '@/components/NoteContent';
import { VideoPlayer } from '@/components/VideoPlayer';
@@ -14,6 +15,7 @@ import { FoundLogContent } from '@/components/FoundLogContent';
import { ColorMomentContent } from '@/components/ColorMomentContent';
import { FollowPackContent } from '@/components/FollowPackContent';
import { ArticleContent } from '@/components/ArticleContent';
import { LiveStreamPlayer } from '@/components/LiveStreamPlayer';
import { ChestIcon } from '@/components/icons/ChestIcon';
import { ReplyContext } from '@/components/ReplyContext';
import { Nip05Badge } from '@/components/Nip05Badge';
@@ -178,7 +180,8 @@ export function NoteCard({ event, className, repostedBy, compact }: NoteCardProp
const isColor = event.kind === 3367;
const isFollowPack = event.kind === 39089 || event.kind === 30000;
const isArticle = event.kind === 30023;
const isTextNote = !isVine && !isPoll && !isGeocache && !isFoundLog && !isColor && !isFollowPack && !isArticle;
const isStream = event.kind === 30311;
const isTextNote = !isVine && !isPoll && !isGeocache && !isFoundLog && !isColor && !isFollowPack && !isArticle && !isStream;
// Kind 1 specific
const images = useMemo(() => isTextNote ? extractImages(event.content) : [], [event.content, isTextNote]);
@@ -231,6 +234,11 @@ export function NoteCard({ event, className, repostedBy, compact }: NoteCardProp
<TreasureHeader pubkey={event.pubkey} variant={isGeocache ? 'hid' : 'found'} />
)}
{/* Stream header — "<radio> <name> is streaming / streamed" */}
{isStream && (
<StreamHeader pubkey={event.pubkey} isLive={getTag(event.tags, 'status') === 'live'} />
)}
{/* Header: avatar + name/handle stacked */}
<div className="flex items-center gap-3">
{author.isLoading ? (
@@ -307,6 +315,8 @@ export function NoteCard({ event, className, repostedBy, compact }: NoteCardProp
<FollowPackContent event={event} />
) : isArticle ? (
<ArticleContent event={event} preview className="mt-2" />
) : isStream ? (
<StreamContent event={event} />
) : (
<>
<div className="mt-2 break-words overflow-hidden">
@@ -483,6 +493,125 @@ function VineMedia({ imeta, hashtags }: { imeta?: { url?: string; thumbnail?: st
);
}
/** Stream status badge config. */
function getStreamStatusConfig(status: string | undefined) {
switch (status) {
case 'live':
return { label: 'LIVE', className: 'bg-red-600 hover:bg-red-600 text-white border-red-600' };
case 'ended':
return { label: 'ENDED', className: 'bg-muted text-muted-foreground border-border' };
case 'planned':
return { label: 'PLANNED', className: 'bg-blue-600/90 hover:bg-blue-600/90 text-white border-blue-600' };
default:
return { label: status?.toUpperCase() || 'UNKNOWN', className: 'bg-muted text-muted-foreground border-border' };
}
}
/** Inline content for kind 30311 live stream events. */
function StreamContent({ event }: { event: NostrEvent }) {
const navigate = useNavigate();
const title = getTag(event.tags, 'title') || 'Untitled Stream';
const summary = getTag(event.tags, 'summary');
const imageUrl = getTag(event.tags, 'image');
const streamingUrl = getTag(event.tags, 'streaming');
const status = getTag(event.tags, 'status');
const currentParticipants = getTag(event.tags, 'current_participants');
const statusConfig = getStreamStatusConfig(status);
const isLive = status === 'live' && !!streamingUrl;
const encodedId = useMemo(() => {
const dTag = getTag(event.tags, 'd') || '';
return nip19.naddrEncode({ kind: event.kind, pubkey: event.pubkey, identifier: dTag });
}, [event]);
return (
<div className="mt-2 space-y-2">
{/* Stream player / thumbnail */}
<div className="rounded-xl overflow-hidden border border-border">
{isLive ? (
// Inline live player — clicks on the player are intercepted so they don't navigate away
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<div className="relative" onClick={(e) => e.stopPropagation()}>
<LiveStreamPlayer src={streamingUrl} poster={imageUrl} />
{/* Status + viewer overlay on top of the player */}
<div className="absolute top-2 left-2 z-10 flex items-center gap-2 pointer-events-none">
<Badge variant="outline" className={cn('text-[10px]', statusConfig.className)}>
<div className="size-1.5 bg-white rounded-full animate-pulse mr-1" />
{statusConfig.label}
</Badge>
{currentParticipants && (
<span className="flex items-center gap-1 bg-black/60 text-white text-xs px-2 py-0.5 rounded">
<Users className="size-3" />
{currentParticipants}
</span>
)}
</div>
</div>
) : imageUrl ? (
<div className="relative w-full aspect-video overflow-hidden bg-muted">
<img
src={imageUrl}
alt=""
className="w-full h-full object-cover"
loading="lazy"
onError={(e) => {
(e.currentTarget.parentElement as HTMLElement).style.display = 'none';
}}
/>
<div className="absolute top-2 left-2">
<Badge variant="outline" className={cn('text-[10px]', statusConfig.className)}>
{statusConfig.label}
</Badge>
</div>
{currentParticipants && (
<div className="absolute bottom-2 right-2 flex items-center gap-1 bg-black/60 text-white text-xs px-2 py-0.5 rounded">
<Users className="size-3" />
{currentParticipants}
</div>
)}
</div>
) : (
// No image, no live stream — show a minimal placeholder with status
<div className="flex items-center gap-3 px-3 py-2.5 bg-muted/40">
<Radio className="size-4 text-primary shrink-0" />
<Badge variant="outline" className={cn('text-[10px]', statusConfig.className)}>
{status === 'live' && <div className="size-1.5 bg-white rounded-full animate-pulse mr-1" />}
{statusConfig.label}
</Badge>
{currentParticipants && (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Users className="size-3" />
{currentParticipants}
</span>
)}
</div>
)}
</div>
{/* Title + summary — clickable to open stream details */}
<button
type="button"
className="flex items-start gap-2 text-left w-full group"
onClick={(e) => {
e.stopPropagation();
navigate(`/${encodedId}`);
}}
>
<Radio className="size-4 text-primary shrink-0 mt-0.5" />
<div className="min-w-0 flex-1">
<h3 className="font-semibold text-sm leading-snug line-clamp-2 group-hover:underline">
{title}
</h3>
{summary && (
<p className="text-xs text-muted-foreground line-clamp-2 mt-0.5">{summary}</p>
)}
</div>
</button>
</div>
);
}
function RepostHeader({ pubkey }: { pubkey: string }) {
const author = useAuthor(pubkey);
const name = author.data?.metadata?.name || genUserName(pubkey);
@@ -511,6 +640,36 @@ function RepostHeader({ pubkey }: { pubkey: string }) {
);
}
function StreamHeader({ pubkey, isLive }: { pubkey: string; isLive: boolean }) {
const author = useAuthor(pubkey);
const name = author.data?.metadata?.name || genUserName(pubkey);
const url = useMemo(() => getProfileUrl(pubkey, author.data?.metadata), [pubkey, author.data?.metadata]);
return (
<div className="flex items-center gap-3 text-xs text-muted-foreground mb-3 min-w-0">
<div className="w-11 shrink-0 flex justify-end">
<Radio className={cn("size-4 translate-y-px", isLive ? "text-primary" : "text-muted-foreground")} />
</div>
<div className="flex items-center min-w-0">
{author.isLoading ? (
<Skeleton className="h-3 w-20 inline-block" />
) : (
<Link
to={url}
className="font-medium hover:underline mr-1 truncate"
onClick={(e) => e.stopPropagation()}
>
{author.data?.event ? <EmojifiedText tags={author.data.event.tags}>{name}</EmojifiedText> : name}
</Link>
)}
<span className={cn("shrink-0", author.isLoading && 'ml-1')}>
{isLive ? 'is streaming' : 'streamed'}
</span>
</div>
</div>
);
}
function TreasureHeader({ pubkey, variant }: { pubkey: string; variant: 'hid' | 'found' }) {
const author = useAuthor(pubkey);
const name = author.data?.metadata?.name || genUserName(pubkey);