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 ? (
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 ? (
) : (
-
+
)}
-
-
-
- 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 (
+
+
+ {blurhash && (
+
+ )}
+ {thumbnail ? (
+
setImgLoaded(true)}
+ />
+ ) : (
+
+ )}
+
+
+ {title}
+
+ );
+}
+
+// ── 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' && (
-
setFeedTab('global')}
- >
- Switch to Global
-
- )}
) : (
-
- {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 ? (
-
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)}
-
-
- );
-}