diff --git a/src/components/LiveStreamPlayer.tsx b/src/components/LiveStreamPlayer.tsx index 812c37cc..ed511243 100644 --- a/src/components/LiveStreamPlayer.tsx +++ b/src/components/LiveStreamPlayer.tsx @@ -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; diff --git a/src/components/NoteCard.tsx b/src/components/NoteCard.tsx index 204c15b9..d06938ed 100644 --- a/src/components/NoteCard.tsx +++ b/src/components/NoteCard.tsx @@ -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 )} + {/* Stream header — " is streaming / streamed" */} + {isStream && ( + + )} + {/* Header: avatar + name/handle stacked */}
{author.isLoading ? ( @@ -307,6 +315,8 @@ export function NoteCard({ event, className, repostedBy, compact }: NoteCardProp ) : isArticle ? ( + ) : isStream ? ( + ) : ( <>
@@ -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 ( +
+ {/* Stream player / thumbnail */} +
+ {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 +
e.stopPropagation()}> + + {/* Status + viewer overlay on top of the player */} +
+ +
+ {statusConfig.label} + + {currentParticipants && ( + + + {currentParticipants} + + )} +
+
+ ) : imageUrl ? ( +
+ { + (e.currentTarget.parentElement as HTMLElement).style.display = 'none'; + }} + /> +
+ + {statusConfig.label} + +
+ {currentParticipants && ( +
+ + {currentParticipants} +
+ )} +
+ ) : ( + // No image, no live stream — show a minimal placeholder with status +
+ + + {status === 'live' &&
} + {statusConfig.label} + + {currentParticipants && ( + + + {currentParticipants} + + )} +
+ )} +
+ + {/* Title + summary — clickable to open stream details */} + +
+ ); +} + 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 ( +
+
+ +
+
+ {author.isLoading ? ( + + ) : ( + e.stopPropagation()} + > + {author.data?.event ? {name} : name} + + )} + + {isLive ? 'is streaming' : 'streamed'} + +
+
+ ); +} + function TreasureHeader({ pubkey, variant }: { pubkey: string; variant: 'hid' | 'found' }) { const author = useAuthor(pubkey); const name = author.data?.metadata?.name || genUserName(pubkey);