diff --git a/src/pages/VideosFeedPage.tsx b/src/pages/VideosFeedPage.tsx
index 0ed9c9ab..6678c419 100644
--- a/src/pages/VideosFeedPage.tsx
+++ b/src/pages/VideosFeedPage.tsx
@@ -1,21 +1,17 @@
/**
- * VideosFeedPage — unified video + stream feed.
+ * VideosFeedPage — YouTube-style vertical video feed.
*
- * ┌─ 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
+ * Layout (top to bottom):
+ * ┌─ Follows | Global tabs ──────────────────────────┐
+ * ├─ Live Now strip (horizontal, compact) ────────────┤
+ * ├─ Video card (thumbnail + title + author + time) ──┤
+ * ├─ Video card ... │
+ * └───────────────────────────────────────────────────┘
*/
import { useState, useEffect, useMemo, useRef } from 'react';
import { Link } from 'react-router-dom';
-import { ArrowLeft, Film, Radio, Play, Clock, Eye } from 'lucide-react';
+import { ArrowLeft, Film, Radio, Play, Clock, Eye, Tv2 } from 'lucide-react';
import { useSeoMeta } from '@unhead/react';
import { nip19 } from 'nostr-tools';
import { Blurhash } from 'react-blurhash';
@@ -49,8 +45,8 @@ import { VineCard } from '@/pages/VinesFeedPage';
const videosDef = getExtraKindDef('videos')!;
-/** Items per page for video feeds — enough to fill the horizontal row with overflow. */
-const VIDEO_PAGE_SIZE = 12;
+/** Items per page for video feeds. */
+const VIDEO_PAGE_SIZE = 20;
type FeedTab = 'follows' | 'global';
@@ -61,7 +57,6 @@ 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) {
@@ -75,7 +70,6 @@ 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,
@@ -114,7 +108,7 @@ function TabButton({ label, active, onClick, disabled }: {
);
}
-// ── Author chip ───────────────────────────────────────────────────────────────
+// ── Author chip (inline, for video card row) ──────────────────────────────────
function CardAuthor({ pubkey }: { pubkey: string }) {
const author = useAuthor(pubkey);
@@ -142,12 +136,13 @@ function CardAuthor({ pubkey }: { pubkey: string }) {
);
}
-// ── Video grid card (kind 21) ─────────────────────────────────────────────────
+// ── Main video card (YouTube-style, stacked vertically) ───────────────────────
-function VideoGridCard({ event }: { event: NostrEvent }) {
+function VideoCard({ 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);
@@ -162,17 +157,31 @@ function VideoGridCard({ event }: { event: NostrEvent }) {
tabIndex={0}
onKeyDown={(e) => e.key === 'Enter' && onClick()}
>
-
+ {/* Thumbnail */}
+
{blurhash && (
-
+ style={{ width: '100%', height: '100%' }}
+ />
)}
{thumbnail ? (
setImgLoaded(true)}
/>
@@ -181,36 +190,106 @@ function VideoGridCard({ event }: { event: NostrEvent }) {
)}
-
+
+ {/* Play overlay on hover */}
+
+
+ {/* Duration badge */}
{dur && (
-
{dur}
+
+ {dur}
+
+ )}
+
+ {/* Short badge */}
+ {isShort && (
+
+ Short
+
)}
-
-
-
{title}
-
{timeAgo(event.created_at)}
+
+ {/* Info row */}
+
+ {/* Author avatar (large, left column like YouTube) */}
+
+
+ {/* Title + meta */}
+
+
+ {title}
+
+
+
+
+ {timeAgo(event.created_at)}
+
+
);
}
-function VideoSkeleton() {
+/** 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() {
return (
);
}
-// ── Live streams — targeted query: status=live only, limit 10 ─────────────────
+// ── Live streams — horizontal compact shelf ───────────────────────────────────
function useLiveStreams(tab: FeedTab) {
const { nostr } = useNostr();
@@ -235,7 +314,7 @@ function useLiveStreams(tab: FeedTab) {
});
}
-function LiveStreamCard({ event }: { event: NostrEvent }) {
+function LiveStreamChip({ event }: { event: NostrEvent }) {
const title = getTag(event.tags, 'title') || 'Untitled Stream';
const imageUrl = getTag(event.tags, 'image');
const viewers = getTag(event.tags, 'current_participants');
@@ -249,48 +328,38 @@ function LiveStreamCard({ 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}
)}
-
-
e.stopPropagation()} className="shrink-0 mt-0.5">
-
-
- {displayName[0]?.toUpperCase()}
-
-
-
-
{title}
-
{displayName}
-
-
+
{title}
+
{displayName}
);
}
@@ -300,59 +369,20 @@ function LiveStreamsStrip({ tab }: { tab: FeedTab }) {
if (liveEvents.length === 0) return null;
return (
-
-
+
+
- Live Now
-
{liveEvents.length}
-
-
- {liveEvents.map((e) => )}
+
Live now
+ {liveEvents.length} stream{liveEvents.length !== 1 ? 's' : ''}
+
+
+ {liveEvents.map((e) => )}
);
}
-// ── 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 ─────────────
+// ── Shorts player (full-screen snap, reuses VineCard) ─────────────────────────
function ShortsPlayer({
events,
@@ -366,7 +396,6 @@ function ShortsPlayer({
const [activeIndex, setActiveIndex] = useState(startIndex);
const containerRef = useRef
(null);
- // Scroll to startIndex on mount
useEffect(() => {
const container = containerRef.current;
if (!container) return;
@@ -374,7 +403,6 @@ function ShortsPlayer({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
- // IntersectionObserver syncs activeIndex as user scrolls
useEffect(() => {
const container = containerRef.current;
if (!container) return;
@@ -393,7 +421,6 @@ function ShortsPlayer({
return () => observer.disconnect();
}, [events]);
- // Keyboard nav + Escape to go back
useEffect(() => {
const handler = (e: KeyboardEvent) => {
const container = containerRef.current;
@@ -415,10 +442,8 @@ 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() {
@@ -491,17 +490,15 @@ export function VideosFeedPage() {
useEffect(() => { if (user) setFeedTab('follows'); }, [user]);
- // ── Follows: chronological, small page ──
+ // ── Follows: chronological ──
const followsQuery = useFeed('follows', { kinds: [21, 22] });
- // ── Global: sort:hot, limit 8/page ──
+ // ── Global: sort:hot ──
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
();
@@ -519,15 +516,13 @@ export function VideosFeedPage() {
});
}, [rawData?.pages, muteItems, feedTab]);
- const normalVideos = useMemo(() => videoEvents.filter((e) => e.kind === 21), [videoEvents]);
+ // Shorts (kind 22) get their own player when tapped
const shorts = useMemo(() => videoEvents.filter((e) => e.kind === 22), [videoEvents]);
-
const [shortsPlayerIndex, setShortsPlayerIndex] = useState(null);
const showSkeleton = isPending || (isLoading && !rawData);
- // 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.
+ // Shorts full-screen player
if (shortsPlayerIndex !== null) {
return (
- {/* Header */}
+
+ {/* ── Header ── */}
@@ -552,59 +547,126 @@ export function VideosFeedPage() {
- {/* Follows / Global tabs */}
+ {/* ── Tabs ── */}
setFeedTab('follows')} disabled={!user} />
setFeedTab('global')} />
- {/* Live streams strip — follows tab filters by followed authors */}
+ {/* ── Live strip ── */}
+ {/* ── Feed ── */}
{showSkeleton ? (
-
-
-
-
- {Array.from({ length: 4 }).map((_, i) => (
-
- ))}
-
-
+
+ {Array.from({ length: 5 }).map((_, i) => (
+
+ ))}
) : videoEvents.length === 0 ? (
-
+
- No videos yet.{feedTab === 'follows' ? ' Follow some creators or switch to Global.' : ' Check your relay connections or come back soon.'}
+ {feedTab === 'follows'
+ ? 'No videos from people you follow yet. Try Global.'
+ : 'No videos found. Check your relay connections or come back soon.'}
+ {feedTab === 'follows' && (
+
setFeedTab('global')}
+ >
+ Switch to Global
+
+ )}
) : (
-
- {/* 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 &&
}
+
+ {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
;
+ })}
)}
-
);
}
+
+/** 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)}
+
+
+ );
+}