diff --git a/src/pages/VideosFeedPage.tsx b/src/pages/VideosFeedPage.tsx index 6678c419..0ed9c9ab 100644 --- a/src/pages/VideosFeedPage.tsx +++ b/src/pages/VideosFeedPage.tsx @@ -1,17 +1,21 @@ /** - * VideosFeedPage — YouTube-style vertical video feed. + * VideosFeedPage — unified video + stream feed. * - * Layout (top to bottom): - * ┌─ Follows | Global tabs ──────────────────────────┐ - * ├─ Live Now strip (horizontal, compact) ────────────┤ - * ├─ Video card (thumbnail + title + author + time) ──┤ - * ├─ Video card ... │ - * └───────────────────────────────────────────────────┘ + * ┌─ Follows | Global tabs ─────────────────────┐ + * ├─ Live Now horizontal strip (live-only) ──────┤ + * ├─ Videos (kind 21) grid ──────────────────────┤ + * ├─ Shorts (kind 22) — inline snap-scroll ──────┤ + * │ (exactly like VinesFeedPage, within column) │ + * └──────────────────────────────────────────────┘ + * + * Global: sort:hot (ditto relay, limit 8/page) + * Follows: chronological (useFeed, limit 8/page via PAGE_SIZE override) + * Streams: live-only query, limit 10 */ import { useState, useEffect, useMemo, useRef } from 'react'; import { Link } from 'react-router-dom'; -import { ArrowLeft, Film, Radio, Play, Clock, Eye, Tv2 } from 'lucide-react'; +import { ArrowLeft, Film, Radio, Play, Clock, Eye } from 'lucide-react'; import { useSeoMeta } from '@unhead/react'; import { nip19 } from 'nostr-tools'; import { Blurhash } from 'react-blurhash'; @@ -45,8 +49,8 @@ import { VineCard } from '@/pages/VinesFeedPage'; const videosDef = getExtraKindDef('videos')!; -/** Items per page for video feeds. */ -const VIDEO_PAGE_SIZE = 20; +/** Items per page for video feeds — enough to fill the horizontal row with overflow. */ +const VIDEO_PAGE_SIZE = 12; type FeedTab = 'follows' | 'global'; @@ -57,6 +61,7 @@ function getTag(tags: string[][], name: string): string | undefined { } function parseVideoImeta(tags: string[][]): { url?: string; thumbnail?: string; duration?: string; blurhash?: string } { + // Standalone fallback tags (checked after imeta) const standaloneThumb = getTag(tags, 'thumb') ?? getTag(tags, 'image'); for (const tag of tags) { @@ -70,6 +75,7 @@ function parseVideoImeta(tags: string[][]): { url?: string; thumbnail?: string; if (parts.url) { return { url: parts.url, + // imeta uses "image" key for thumbnail; fall back to standalone tags thumbnail: parts.image ?? parts.thumb ?? standaloneThumb, duration: parts.duration, blurhash: parts.blurhash, @@ -108,7 +114,7 @@ function TabButton({ label, active, onClick, disabled }: { ); } -// ── Author chip (inline, for video card row) ────────────────────────────────── +// ── Author chip ─────────────────────────────────────────────────────────────── function CardAuthor({ pubkey }: { pubkey: string }) { const author = useAuthor(pubkey); @@ -136,13 +142,12 @@ function CardAuthor({ pubkey }: { pubkey: string }) { ); } -// ── Main video card (YouTube-style, stacked vertically) ─────────────────────── +// ── Video grid card (kind 21) ───────────────────────────────────────────────── -function VideoCard({ event }: { event: NostrEvent }) { +function VideoGridCard({ event }: { event: NostrEvent }) { const { thumbnail, duration, blurhash } = parseVideoImeta(event.tags); const title = getTag(event.tags, 'title') ?? (event.content.slice(0, 120) || 'Untitled'); const dur = fmtDuration(duration); - const isShort = event.kind === 22; const [imgLoaded, setImgLoaded] = useState(false); const noteId = nip19.noteEncode(event.id); @@ -157,31 +162,17 @@ function VideoCard({ event }: { event: NostrEvent }) { tabIndex={0} onKeyDown={(e) => e.key === 'Enter' && onClick()} > - {/* Thumbnail */} -
+
{blurhash && ( - + style={{ width: '100%', height: '100%' }} /> )} {thumbnail ? ( {title} setImgLoaded(true)} /> @@ -190,106 +181,36 @@ function VideoCard({ event }: { event: NostrEvent }) {
)} - - {/* Play overlay on hover */} -
+
- - {/* Duration badge */} {dur && ( -
- {dur} -
- )} - - {/* Short badge */} - {isShort && ( -
- Short -
+
{dur}
)}
- - {/* Info row */} -
- {/* Author avatar (large, left column like YouTube) */} - - - {/* Title + meta */} -
-

- {title} -

- -

- - {timeAgo(event.created_at)} -

-
+
+ +

{title}

+

{timeAgo(event.created_at)}

); } -/** Just the avatar circle for the YouTube-style left column. */ -function AuthorAvatarOnly({ pubkey }: { pubkey: string }) { - const author = useAuthor(pubkey); - const metadata = author.data?.metadata; - const displayName = getDisplayName(metadata, pubkey); - const profileUrl = useProfileUrl(pubkey, metadata); - - if (author.isLoading) return ; - - return ( - e.stopPropagation()}> - - - {displayName[0]?.toUpperCase()} - - - ); -} - -/** Just the author name line. */ -function AuthorNameOnly({ pubkey }: { pubkey: string }) { - const author = useAuthor(pubkey); - const metadata = author.data?.metadata; - const displayName = getDisplayName(metadata, pubkey); - const profileUrl = useProfileUrl(pubkey, metadata); - - if (author.isLoading) return ; - - return ( - e.stopPropagation()} - > - {displayName} - - ); -} - -function VideoCardSkeleton() { +function VideoSkeleton() { return (
-
- -
- - - -
-
+
+ +
); } -// ── Live streams — horizontal compact shelf ─────────────────────────────────── +// ── Live streams — targeted query: status=live only, limit 10 ───────────────── function useLiveStreams(tab: FeedTab) { const { nostr } = useNostr(); @@ -314,7 +235,7 @@ function useLiveStreams(tab: FeedTab) { }); } -function LiveStreamChip({ event }: { event: NostrEvent }) { +function LiveStreamCard({ event }: { event: NostrEvent }) { const title = getTag(event.tags, 'title') || 'Untitled Stream'; const imageUrl = getTag(event.tags, 'image'); const viewers = getTag(event.tags, 'current_participants'); @@ -328,38 +249,48 @@ function LiveStreamChip({ event }: { event: NostrEvent }) { const author = useAuthor(event.pubkey); const meta = author.data?.metadata; const displayName = getDisplayName(meta, event.pubkey); + const profileUrl = useProfileUrl(event.pubkey, meta); return (
e.key === 'Enter' && onClick()} > -
+
{imageUrl ? ( {title} ) : (
- +
)} -
- - - LIVE - +
+ + LIVE +
{viewers && ( -
- {viewers} +
+ {viewers}
)}
-

{title}

-

{displayName}

+
+ e.stopPropagation()} className="shrink-0 mt-0.5"> + + + {displayName[0]?.toUpperCase()} + + +
+

{title}

+

{displayName}

+
+
); } @@ -369,20 +300,59 @@ function LiveStreamsStrip({ tab }: { tab: FeedTab }) { if (liveEvents.length === 0) return null; return ( -
-
+
+

-

Live now

- {liveEvents.length} stream{liveEvents.length !== 1 ? 's' : ''} -

-
- {liveEvents.map((e) => )} + Live Now + {liveEvents.length} + +
+ {liveEvents.map((e) => )}
); } -// ── Shorts player (full-screen snap, reuses VineCard) ───────────────────────── +// ── Shorts grid thumbnail ───────────────────────────────────────────────────── + +function ShortThumb({ event, onClick }: { event: NostrEvent; onClick: () => void }) { + const { thumbnail, blurhash } = parseVideoImeta(event.tags); + const title = getTag(event.tags, 'title') ?? (event.content.slice(0, 60) || 'Short'); + const [imgLoaded, setImgLoaded] = useState(false); + + return ( + + ); +} + +// ── Shorts full-screen player (VineCard) with back-to-grid button ───────────── function ShortsPlayer({ events, @@ -396,6 +366,7 @@ function ShortsPlayer({ const [activeIndex, setActiveIndex] = useState(startIndex); const containerRef = useRef(null); + // Scroll to startIndex on mount useEffect(() => { const container = containerRef.current; if (!container) return; @@ -403,6 +374,7 @@ function ShortsPlayer({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // IntersectionObserver syncs activeIndex as user scrolls useEffect(() => { const container = containerRef.current; if (!container) return; @@ -421,6 +393,7 @@ function ShortsPlayer({ return () => observer.disconnect(); }, [events]); + // Keyboard nav + Escape to go back useEffect(() => { const handler = (e: KeyboardEvent) => { const container = containerRef.current; @@ -442,8 +415,10 @@ function ShortsPlayer({ return () => window.removeEventListener('keydown', handler); }, [onClose, activeIndex, events.length]); + // Same structure as VinesFeedPage: tab bar + snap container, filling the feed column return (
+ {/* Tab bar — same chrome as VinesTabBar, back button replaces tabs */}
+ + {/* Snap-scroll VineCard column — identical sizing to VinesFeedPage */}
void }) { + if (events.length === 0) return null; + + return ( +
+

+ Shorts +

+
+ {events.map((e, i) => ( +
+ onOpen(i)} /> +
+ ))} +
+
+ ); +} + // ── Page ────────────────────────────────────────────────────────────────────── export function VideosFeedPage() { @@ -490,15 +491,17 @@ export function VideosFeedPage() { useEffect(() => { if (user) setFeedTab('follows'); }, [user]); - // ── Follows: chronological ── + // ── Follows: chronological, small page ── const followsQuery = useFeed('follows', { kinds: [21, 22] }); - // ── Global: sort:hot ── + // ── Global: sort:hot, limit 8/page ── const globalQuery = useInfiniteHotFeed([21, 22], feedTab === 'global', VIDEO_PAGE_SIZE); const activeQuery = feedTab === 'follows' ? followsQuery : globalQuery; const { data: rawData, isPending, isLoading } = activeQuery; + + const videoEvents = useMemo(() => { if (!rawData?.pages) return []; const seen = new Set(); @@ -516,13 +519,15 @@ export function VideosFeedPage() { }); }, [rawData?.pages, muteItems, feedTab]); - // Shorts (kind 22) get their own player when tapped + const normalVideos = useMemo(() => videoEvents.filter((e) => e.kind === 21), [videoEvents]); const shorts = useMemo(() => videoEvents.filter((e) => e.kind === 22), [videoEvents]); + const [shortsPlayerIndex, setShortsPlayerIndex] = useState(null); const showSkeleton = isPending || (isLoading && !rawData); - // Shorts full-screen player + // When the shorts player is open, render it directly as the page root — + // same flex-1 column that VinesFeedPage uses, fully replacing the feed UI. if (shortsPlayerIndex !== null) { return ( - {/* ── Header ── */} +
+ {/* Header */}
@@ -547,126 +552,59 @@ export function VideosFeedPage() {
- {/* ── Tabs ── */} + {/* Follows / Global tabs */}
setFeedTab('follows')} disabled={!user} /> setFeedTab('global')} />
- {/* ── Live strip ── */} + {/* Live streams strip — follows tab filters by followed authors */} - {/* ── Feed ── */} {showSkeleton ? ( -
- {Array.from({ length: 5 }).map((_, i) => ( - - ))} +
+
+ +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ ))} +
+
) : videoEvents.length === 0 ? (
- +

- {feedTab === 'follows' - ? 'No videos from people you follow yet. Try Global.' - : 'No videos found. Check your relay connections or come back soon.'} + No videos yet.{feedTab === 'follows' ? ' Follow some creators or switch to Global.' : ' Check your relay connections or come back soon.'}

- {feedTab === 'follows' && ( - - )}
) : ( -
- {videoEvents.map((event) => { - if (event.kind === 22) { - // Shorts get a portrait card that opens the TikTok-style player - const shortIndex = shorts.indexOf(event); - return ( -
setShortsPlayerIndex(shortIndex)} - role="button" - tabIndex={0} - onKeyDown={(e) => e.key === 'Enter' && setShortsPlayerIndex(shortIndex)} - > - - -
- ); - } - return ; - })} +
+ {/* Normal videos — horizontal scroll row */} + {normalVideos.length > 0 && ( +
+

+ Videos +

+
+ {normalVideos.map((e) => ( +
+ +
+ ))} +
+
+ )} + + {/* Shorts — horizontal scroll row of portrait thumbs, tap opens player */} + {shorts.length > 0 && }
)} +
); } - -/** Portrait thumbnail (left column) for a short in the vertical feed. */ -function ShortCardLeft({ event }: { event: NostrEvent }) { - const { thumbnail, blurhash } = parseVideoImeta(event.tags); - const title = getTag(event.tags, 'title') ?? 'Short'; - const [imgLoaded, setImgLoaded] = useState(false); - - return ( -
- {blurhash && ( - - )} - {thumbnail ? ( - {title} setImgLoaded(true)} - /> - ) : ( -
- -
- )} -
-
- -
-
-
- Short -
-
- ); -} - -/** Text info (right column) for a short in the vertical feed. */ -function ShortCardRight({ event }: { event: NostrEvent }) { - const title = getTag(event.tags, 'title') ?? (event.content.slice(0, 80) || 'Short'); - - return ( -
-

- {title} -

- -

- - {timeAgo(event.created_at)} -

-
- ); -}