From 0c9844d9ce3e45cbe1d6ef77f688dc9bf071ec0a Mon Sep 17 00:00:00 2001 From: kuekhaoyang Date: Tue, 18 Nov 2025 17:43:09 +0800 Subject: [PATCH] refactor: optimize performance in various components; adjust animation intervals, memoize components, and improve sorting logic --- components/SearchLoadingAnimation.tsx | 9 +- components/ThemeProvider.tsx | 70 ++++++++- components/home/MovieCard.tsx | 5 +- components/home/TagManager.tsx | 2 +- components/player/DesktopVideoPlayer.tsx | 16 +- components/search/TypeBadges.tsx | 5 +- components/search/VideoGrid.tsx | 184 +++++++++++++---------- lib/hooks/useInfiniteScroll.ts | 2 +- lib/hooks/useParallelSearch.ts | 27 +++- 9 files changed, 213 insertions(+), 107 deletions(-) diff --git a/components/SearchLoadingAnimation.tsx b/components/SearchLoadingAnimation.tsx index 776cb20..e2ebbe5 100644 --- a/components/SearchLoadingAnimation.tsx +++ b/components/SearchLoadingAnimation.tsx @@ -25,7 +25,7 @@ export function SearchLoadingAnimation({ const progress = totalSources > 0 ? (checkedSources / totalSources) * 100 : 0; const isComplete = progress >= 100; - // Animation pause/resume logic + // Animation pause/resume logic - Optimized interval useEffect(() => { if (isPaused || isComplete) { if (dotIntervalRef.current) { @@ -37,7 +37,7 @@ export function SearchLoadingAnimation({ dotIntervalRef.current = setInterval(() => { setDots((prev) => (prev.length >= 3 ? '' : prev + '.')); - }, 500); + }, 600); // Increased from 500ms to 600ms for better performance return () => { if (dotIntervalRef.current) { @@ -90,10 +90,9 @@ export function SearchLoadingAnimation({ className="h-1 bg-[color-mix(in_srgb,var(--glass-bg)_50%,transparent)] overflow-hidden rounded-[var(--radius-full)]" >
{/* Shimmer Effect - Optimized for GPU with contain for better performance */} diff --git a/components/ThemeProvider.tsx b/components/ThemeProvider.tsx index 054660d..3899718 100644 --- a/components/ThemeProvider.tsx +++ b/components/ThemeProvider.tsx @@ -15,8 +15,11 @@ const ThemeContext = createContext(undefined); export function ThemeProvider({ children }: { children: React.ReactNode }) { const [theme, setTheme] = useState('system'); const [actualTheme, setActualTheme] = useState<'light' | 'dark'>('dark'); + const [mounted, setMounted] = useState(false); + const transitionRef = React.useRef(null); useEffect(() => { + setMounted(true); // Load saved theme const saved = localStorage.getItem('theme') as Theme; if (saved) { @@ -25,6 +28,8 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) { }, []); useEffect(() => { + if (!mounted) return; + const applyTheme = (newTheme?: 'light' | 'dark') => { const themeToApply = newTheme || (theme === 'system' ? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') @@ -35,13 +40,43 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) { }; const applyThemeWithTransition = () => { + // Abort previous transition if it exists + if (transitionRef.current) { + try { + transitionRef.current.skipTransition(); + } catch (e) { + // Ignore if transition already finished + } + } + + // Check if document is visible - skip transition if hidden + if (document.hidden) { + applyTheme(); + return; + } + // Check if View Transition API is supported // @ts-ignore - View Transition API is experimental if (typeof document.startViewTransition === 'function') { - // @ts-ignore - document.startViewTransition(() => { + try { + // @ts-ignore + transitionRef.current = document.startViewTransition(() => { + applyTheme(); + }); + + // Clear ref after transition completes or fails + if (transitionRef.current) { + transitionRef.current.finished + .then(() => { transitionRef.current = null; }) + .catch((error: Error) => { + // Silently handle transition errors (visibility changes, etc.) + transitionRef.current = null; + }); + } + } catch (error) { + // Fallback if transition fails to start applyTheme(); - }); + } } else { // Fallback for browsers that don't support View Transition API applyTheme(); @@ -59,9 +94,34 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) { } }; + // Listen for visibility changes to abort transitions + const handleVisibilityChange = () => { + if (document.hidden && transitionRef.current) { + try { + transitionRef.current.skipTransition(); + } catch (e) { + // Ignore + } + transitionRef.current = null; + } + }; + mediaQuery.addEventListener('change', handleSystemThemeChange); - return () => mediaQuery.removeEventListener('change', handleSystemThemeChange); - }, [theme]); + document.addEventListener('visibilitychange', handleVisibilityChange); + + return () => { + mediaQuery.removeEventListener('change', handleSystemThemeChange); + document.removeEventListener('visibilitychange', handleVisibilityChange); + // Abort any pending transition on unmount + if (transitionRef.current) { + try { + transitionRef.current.skipTransition(); + } catch (e) { + // Ignore + } + } + }; + }, [theme, mounted]); return ( diff --git a/components/home/MovieCard.tsx b/components/home/MovieCard.tsx index 291f7f3..baac390 100644 --- a/components/home/MovieCard.tsx +++ b/components/home/MovieCard.tsx @@ -3,6 +3,7 @@ * Displays movie poster, title, and rating */ +import { memo } from 'react'; import Image from 'next/image'; import Link from 'next/link'; import { Card } from '@/components/ui/Card'; @@ -21,7 +22,7 @@ interface MovieCardProps { onMovieClick: (movie: DoubanMovie) => void; } -export function MovieCard({ movie, onMovieClick }: MovieCardProps) { +export const MovieCard = memo(function MovieCard({ movie, onMovieClick }: MovieCardProps) { return ( ); -} +}); diff --git a/components/home/TagManager.tsx b/components/home/TagManager.tsx index 91b9256..38d0a23 100644 --- a/components/home/TagManager.tsx +++ b/components/home/TagManager.tsx @@ -89,7 +89,7 @@ export function TagManager({
); -} +}); diff --git a/lib/hooks/useInfiniteScroll.ts b/lib/hooks/useInfiniteScroll.ts index cd2e206..5b3c162 100644 --- a/lib/hooks/useInfiniteScroll.ts +++ b/lib/hooks/useInfiniteScroll.ts @@ -34,7 +34,7 @@ export function useInfiniteScroll({ onLoadMore(nextPage); } }, - { threshold: 0.1, rootMargin: '400px' } + { threshold: 0.1, rootMargin: '200px' } // Reduced from 400px to 200px ); prefetchObserver.observe(prefetchRef.current); diff --git a/lib/hooks/useParallelSearch.ts b/lib/hooks/useParallelSearch.ts index f1079d9..703cee8 100644 --- a/lib/hooks/useParallelSearch.ts +++ b/lib/hooks/useParallelSearch.ts @@ -116,11 +116,30 @@ export function useParallelSearch( console.log(`[useParallelSearch] Received ${newVideos.length} videos from source ${data.source}`); - // Add videos and sort by relevance + // Optimized: Insert new videos in sorted position instead of re-sorting entire array setResults((prev) => { - const combined = [...prev, ...newVideos]; - // Sort by relevance score (highest first) - return combined.sort((a, b) => (b.relevanceScore || 0) - (a.relevanceScore || 0)); + if (prev.length === 0) return newVideos; + + // Binary insert for better performance + const combined = [...prev]; + for (const video of newVideos) { + const score = video.relevanceScore || 0; + let insertIndex = combined.length; + + // Find insert position using binary search + let left = 0; + let right = combined.length; + while (left < right) { + const mid = Math.floor((left + right) / 2); + if ((combined[mid].relevanceScore || 0) >= score) { + left = mid + 1; + } else { + right = mid; + } + } + combined.splice(left, 0, video); + } + return combined; }); // Update source stats