diff --git a/app/api/search-parallel/route.ts b/app/api/search-parallel/route.ts index 2ea3e44..133a4c3 100644 --- a/app/api/search-parallel/route.ts +++ b/app/api/search-parallel/route.ts @@ -55,29 +55,34 @@ export async function POST(request: NextRequest) { // Search all sources in PARALLEL - don't wait for all to finish const searchPromises = sources.map(async (source: any) => { + const startTime = performance.now(); // Track start time try { console.log(`[Search Parallel] Searching source: ${source.id} (${getSourceDisplayName(source.id)})`); // Search this source const result = await searchVideos(query.trim(), [source], page); + const endTime = performance.now(); // Track end time + const latency = Math.round(endTime - startTime); // Calculate latency in ms const videos = result[0]?.results || []; completedSources++; totalVideosFound += videos.length; - console.log(`[Search Parallel] Source ${source.id} completed: ${videos.length} videos found`); + console.log(`[Search Parallel] Source ${source.id} completed in ${latency}ms: ${videos.length} videos found`); - // Stream videos immediately as they arrive + // Stream videos immediately as they arrive WITH latency data if (videos.length > 0) { controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'videos', videos: videos.map((video: any) => ({ ...video, sourceDisplayName: getSourceDisplayName(source.id), + latency, // Add latency to each video })), source: source.id, completedSources, - totalSources: sources.length + totalSources: sources.length, + latency, // Also include at source level })}\n\n`)); } @@ -90,8 +95,10 @@ export async function POST(request: NextRequest) { })}\n\n`)); } catch (error) { + const endTime = performance.now(); + const latency = Math.round(endTime - startTime); // Log error but continue with other sources - console.error(`[Search Parallel] Source ${source.id} failed:`, error); + console.error(`[Search Parallel] Source ${source.id} failed after ${latency}ms:`, error); completedSources++; controller.enqueue(encoder.encode(`data: ${JSON.stringify({ diff --git a/app/globals.css b/app/globals.css index 3b73464..38fae51 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,4 +1,5 @@ @import "tailwindcss"; +@import "./scroll-optimization.css"; :root { --font-family-system: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Liberation Sans", sans-serif; diff --git a/app/scroll-optimization.css b/app/scroll-optimization.css new file mode 100644 index 0000000..495095a --- /dev/null +++ b/app/scroll-optimization.css @@ -0,0 +1,94 @@ +/** + * Performance Optimization CSS + * Improves scrolling performance for video grids + */ + +/* Enable smooth scrolling with hardware acceleration */ +* { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* Optimize scrolling container */ +.video-grid-container { + /* Use momentum scrolling on iOS */ + -webkit-overflow-scrolling: touch; + + /* Force GPU layer for scrolling */ + transform: translate3d(0, 0, 0); + will-change: auto; +} + +/* Optimize video cards for rendering */ +.video-card-wrapper { + /* CSS containment for isolation */ + contain: layout style paint; + + /* Content visibility for lazy rendering */ + content-visibility: auto; + + /* Reduce layout shifts */ + contain-intrinsic-size: auto 400px; +} + +/* Reduce repaints on images */ +.video-card-image { + /* Force GPU rendering */ + transform: translate3d(0, 0, 0); + + /* Prevent unnecessary repaints */ + will-change: auto; + + /* Optimize image decoding */ + image-rendering: auto; +} + +/* Optimize badges and overlays */ +.video-card-badge { + /* Force GPU layer */ + transform: translate3d(0, 0, 0); + + /* No backdrop filter during scroll */ + will-change: auto; +} + +/* Disable expensive effects during scroll */ +@media (prefers-reduced-motion: no-preference) { + .video-card:not(:hover) .expensive-effect { + /* Disable blur effects when not hovering */ + backdrop-filter: none; + -webkit-backdrop-filter: none; + } +} + +/* Optimize for mobile devices */ +@media (max-width: 768px) { + /* Reduce visual complexity on mobile */ + .video-card { + /* Simpler rendering */ + will-change: auto; + } + + /* Disable expensive hover effects on mobile */ + .video-card-overlay { + backdrop-filter: none; + -webkit-backdrop-filter: none; + } +} + +/* Grid optimization */ +.optimized-grid { + /* Grid-specific containment */ + contain: layout style; + + /* Prevent layout thrashing */ + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 1rem; +} + +/* Passive event listeners hint */ +html { + /* Hint to browser for passive touch events */ + touch-action: pan-y; +} diff --git a/components/search/SourceBadges.tsx b/components/search/SourceBadges.tsx index 585fd59..d7497a7 100644 --- a/components/search/SourceBadges.tsx +++ b/components/search/SourceBadges.tsx @@ -42,7 +42,7 @@ export const SourceBadges = memo(function SourceBadges({ return (
@@ -71,7 +71,7 @@ export const SourceBadges = memo(function SourceBadges({ focus:outline-none ${isSelected ? 'bg-[var(--accent-color)] text-white border-[var(--accent-color)] shadow-md' - : 'bg-[var(--glass-bg)] text-[var(--text-color)] backdrop-blur-[10px] border-[var(--glass-border)] hover:border-[var(--accent-color)]' + : 'bg-[var(--glass-bg)] text-[var(--text-color)] border-[var(--glass-border)] hover:border-[var(--accent-color)]' } `} > diff --git a/components/search/TypeBadgeItem.tsx b/components/search/TypeBadgeItem.tsx index 8c2dd75..874f741 100644 --- a/components/search/TypeBadgeItem.tsx +++ b/components/search/TypeBadgeItem.tsx @@ -52,7 +52,7 @@ export function TypeBadgeItem({ focus:outline-none ${isSelected ? 'bg-[var(--accent-color)] text-white border-[var(--accent-color)] shadow-md' - : 'bg-[var(--glass-bg)] text-[var(--text-color)] backdrop-blur-[10px] border-[var(--glass-border)] hover:border-[var(--accent-color)]' + : 'bg-[var(--glass-bg)] text-[var(--text-color)] border-[var(--glass-border)] hover:border-[var(--accent-color)]' } `} > diff --git a/components/search/TypeBadges.tsx b/components/search/TypeBadges.tsx index a9f0214..990317d 100644 --- a/components/search/TypeBadges.tsx +++ b/components/search/TypeBadges.tsx @@ -41,7 +41,7 @@ export const TypeBadges = memo(function TypeBadges({ return (
diff --git a/components/search/VideoGrid.tsx b/components/search/VideoGrid.tsx index e6b61c1..e56f4ee 100644 --- a/components/search/VideoGrid.tsx +++ b/components/search/VideoGrid.tsx @@ -1,10 +1,12 @@ 'use client'; -import { useState, useRef, useCallback, useMemo, memo } from 'react'; +import { useState, useRef, useCallback, useMemo, memo, useEffect } from 'react'; import Link from 'next/link'; +import Image from 'next/image'; import { Card } from '@/components/ui/Card'; import { Badge } from '@/components/ui/Badge'; import { Icons } from '@/components/ui/Icon'; +import { LatencyBadge } from '@/components/ui/LatencyBadge'; interface Video { vod_id: string; @@ -16,6 +18,7 @@ interface Video { source: string; sourceName?: string; isNew?: boolean; + latency?: number; // Response time in milliseconds } interface VideoGridProps { @@ -38,34 +41,65 @@ const VideoCard = memo(({ onCardClick: (e: React.MouseEvent, cardId: string, videoUrl: string) => void; }) => { return ( - onCardClick(e, cardId, videoUrl)} - role="listitem" - aria-label={`${video.vod_name}${video.vod_remarks ? ` - ${video.vod_remarks}` : ''}`} +
- onCardClick(e, cardId, videoUrl)} + role="listitem" + aria-label={`${video.vod_name}${video.vod_remarks ? ` - ${video.vod_remarks}` : ''}`} + prefetch={false} > - {/* Poster */} -
- {video.vod_pic ? ( - {video.vod_name} { - e.currentTarget.src = '/placeholder-poster.svg'; - }} - /> - ) : ( -
- + + {/* Poster */} +
+ {video.vod_pic ? ( + {video.vod_name} { + // Fallback for next/image error is tricky because it doesn't expose the img element directly in the same way + // But we can try to hide it or show a placeholder + const target = e.currentTarget as HTMLImageElement; + // Since next/image manages the src, we might need a state or a different approach for fallback + // For simplicity in this performance fix, we'll rely on the parent div background or a separate placeholder component + // But actually, we can just use a simple img tag for fallback if next/image fails, + // or better: use a state to switch to fallback. + // However, inside a memoized component, adding state might be heavy. + // Let's stick to a simple CSS hide for now or use the unoptimized prop if it fails? No. + // Let's just hide it and let the background icon show. + target.style.opacity = '0'; + }} + /> + ) : ( +
+ +
+ )} + + {/* Fallback Icon (always rendered behind image, visible if image fails/loads) */} +
+
- )} {/* Source Badge - Top Left */} {video.sourceName && ( @@ -76,35 +110,45 @@ const VideoCard = memo(({
)} - {/* Overlay - Show on hover (desktop) or when active (mobile) */} -
-
- {/* Mobile indicator when active */} - {isActive && ( + {/* Latency Badge - Top Right */} + {video.latency !== undefined && ( +
+ +
+ )} + + {/* Overlay - Show on hover (desktop) or when active (mobile) - Simplified for performance */} + {isActive && ( +
+
+ {/* Mobile indicator when active */}
再次点击播放 →
- )} - {video.type_name && ( - - {video.type_name} - - )} - {video.vod_year && ( -
- - {video.vod_year} -
- )} + {video.type_name && ( + + {video.type_name} + + )} + {video.vod_year && ( +
+ + {video.vod_year} +
+ )} +
-
+ )}
{/* Info - Fixed height section */}
-

+

{video.vod_name}

{video.vod_remarks && ( @@ -115,6 +159,7 @@ const VideoCard = memo(({
+
); }); @@ -122,13 +167,31 @@ VideoCard.displayName = 'VideoCard'; export const VideoGrid = memo(function VideoGrid({ videos, className = '' }: VideoGridProps) { const [activeCardId, setActiveCardId] = useState(null); + const [visibleCount, setVisibleCount] = useState(24); const gridRef = useRef(null); + const observerRef = useRef(null); if (videos.length === 0) { return null; } - const handleCardClick = (e: React.MouseEvent, videoId: string, videoUrl: string) => { + // Callback ref for the load more trigger to handle dynamic mounting/unmounting + const loadMoreRef = useCallback((node: HTMLDivElement | null) => { + if (observerRef.current) observerRef.current.disconnect(); + + if (node) { + observerRef.current = new IntersectionObserver(entries => { + if (entries[0].isIntersecting) { + setVisibleCount(prev => prev + 24); + } + }, { rootMargin: '400px' }); + + observerRef.current.observe(node); + } + }, []); + + // Memoize the click handler to prevent re-renders + const handleCardClick = useCallback((e: React.MouseEvent, videoId: string, videoUrl: string) => { // Check if it's a mobile device const isMobile = window.innerWidth < 1024; // lg breakpoint @@ -144,36 +207,67 @@ export const VideoGrid = memo(function VideoGrid({ videos, className = '' }: Vid } } // On desktop, let the Link work normally - }; + }, [activeCardId]); + + // Memoize video items to prevent unnecessary re-computations + const videoItems = useMemo(() => { + return videos.map((video, index) => { + const videoUrl = `/player?${new URLSearchParams({ + id: video.vod_id, + source: video.source, + title: video.vod_name, + }).toString()}`; + + const cardId = `${video.vod_id}-${index}`; + + return { + video, + videoUrl, + cardId, + }; + }); + }, [videos]); + + const visibleItems = videoItems.slice(0, visibleCount); return ( -
- {videos.map((video, index) => { - const videoUrl = `/player?${new URLSearchParams({ - id: video.vod_id, - source: video.source, - title: video.vod_name, - }).toString()}`; - - const cardId = `${video.vod_id}-${index}`; - const isActive = activeCardId === cardId; - - return ( - - ); - })} -
+ <> +
+ {visibleItems.map(({ video, videoUrl, cardId }) => { + const isActive = activeCardId === cardId; + + return ( + + ); + })} +
+ + {/* Load more trigger */} + {visibleCount < videoItems.length && ( +