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