refactor: optimize performance in various components; adjust animation intervals, memoize components, and improve sorting logic

This commit is contained in:
kuekhaoyang
2025-11-18 17:43:09 +08:00
parent 79515e2702
commit 0c9844d9ce
9 changed files with 213 additions and 107 deletions
+4 -5
View File
@@ -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)]"
>
<div
className="h-full bg-[var(--accent-color)] transition-all duration-500 ease-out relative will-change-transform rounded-[var(--radius-full)]"
className="h-full bg-[var(--accent-color)] transition-all duration-500 ease-out relative rounded-[var(--radius-full)]"
style={{
width: `${progress}%`,
transform: 'translateZ(0)'
width: `${progress}%`
}}
>
{/* Shimmer Effect - Optimized for GPU with contain for better performance */}
+65 -5
View File
@@ -15,8 +15,11 @@ const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>('system');
const [actualTheme, setActualTheme] = useState<'light' | 'dark'>('dark');
const [mounted, setMounted] = useState(false);
const transitionRef = React.useRef<any>(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 (
<ThemeContext.Provider value={{ theme, setTheme, actualTheme }}>
+3 -2
View File
@@ -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 (
<Link
href={`/?q=${encodeURIComponent(movie.title)}`}
@@ -72,4 +73,4 @@ export function MovieCard({ movie, onMovieClick }: MovieCardProps) {
</Card>
</Link>
);
}
});
+1 -1
View File
@@ -89,7 +89,7 @@ export function TagManager({
<button
onClick={() => onTagSelect(tag.id)}
className={`
px-6 py-2.5 text-sm font-semibold transition-all whitespace-nowrap will-change-transform rounded-[var(--radius-full)]
px-6 py-2.5 text-sm font-semibold transition-all whitespace-nowrap rounded-[var(--radius-full)]
${selectedTag === tag.id
? 'bg-[var(--accent-color)] text-white shadow-md scale-105'
: 'bg-[var(--glass-bg)] backdrop-blur-xl text-[var(--text-color)] border border-[var(--glass-border)] hover:border-[var(--accent-color)] hover:scale-105'
+7 -9
View File
@@ -94,23 +94,21 @@ export function DesktopVideoPlayer({
// Handle mouse movement to show controls (throttled for performance)
const handleMouseMove = () => {
// Throttle mouse move events to improve performance
// Throttle mouse move events to improve performance (200ms instead of 100ms)
if (mouseMoveThrottleRef.current) return;
mouseMoveThrottleRef.current = setTimeout(() => {
mouseMoveThrottleRef.current = null;
}, 100); // Throttle to max 10 times per second
}, 200); // Increased from 100ms to 200ms
setShowControls(true);
if (controlsTimeoutRef.current) {
clearTimeout(controlsTimeoutRef.current);
if (!showControls) {
setShowControls(true);
}
if (isPlaying && !showSpeedMenu) {
if (isPlaying && controlsTimeoutRef.current) {
clearTimeout(controlsTimeoutRef.current);
controlsTimeoutRef.current = setTimeout(() => setShowControls(false), 3000);
}
};
// Play/Pause toggle
}; // Play/Pause toggle
const togglePlay = () => {
if (!videoRef.current) return;
+3 -2
View File
@@ -7,6 +7,7 @@
'use client';
import { memo } from 'react';
import { Card } from '@/components/ui/Card';
import { Icons } from '@/components/ui/Icon';
import { TypeBadgeList } from './TypeBadgeList';
@@ -23,7 +24,7 @@ interface TypeBadgesProps {
className?: string;
}
export function TypeBadges({
export const TypeBadges = memo(function TypeBadges({
badges,
selectedTypes,
onToggleType,
@@ -71,4 +72,4 @@ export function TypeBadges({
)}
</Card>
);
}
});
+106 -78
View File
@@ -23,7 +23,104 @@ interface VideoGridProps {
className?: string;
}
export function VideoGrid({ videos, className = '' }: VideoGridProps) {
// Memoized VideoCard component to prevent unnecessary re-renders
const VideoCard = memo(({
video,
videoUrl,
cardId,
isActive,
onCardClick
}: {
video: Video;
videoUrl: string;
cardId: string;
isActive: boolean;
onCardClick: (e: React.MouseEvent, cardId: string, videoUrl: string) => void;
}) => {
return (
<Link
key={cardId}
href={videoUrl}
onClick={(e) => onCardClick(e, cardId, videoUrl)}
role="listitem"
aria-label={`${video.vod_name}${video.vod_remarks ? ` - ${video.vod_remarks}` : ''}`}
>
<Card
className={`p-0 overflow-hidden group cursor-pointer flex flex-col h-full ${video.isNew ? 'animate-scale-in' : ''}`}
>
{/* Poster */}
<div className="relative aspect-[2/3] bg-[color-mix(in_srgb,var(--glass-bg)_50%,transparent)] overflow-hidden rounded-[var(--radius-2xl)]">
{video.vod_pic ? (
<img
src={video.vod_pic}
alt={video.vod_name}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300 rounded-[var(--radius-2xl)]"
loading="lazy"
decoding="async"
onError={(e) => {
e.currentTarget.src = '/placeholder-poster.svg';
}}
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<Icons.Film size={64} className="text-[var(--text-color-secondary)]" />
</div>
)}
{/* Source Badge - Top Left */}
{video.sourceName && (
<div className="absolute top-2 left-2 z-10">
<Badge variant="primary" className="text-xs bg-[var(--accent-color)]">
{video.sourceName}
</Badge>
</div>
)}
{/* Overlay - Show on hover (desktop) or when active (mobile) */}
<div className={`absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent transition-opacity duration-300 ${
isActive ? 'opacity-100' : 'opacity-0 lg:group-hover:opacity-100'
}`}>
<div className="absolute bottom-0 left-0 right-0 p-3">
{/* Mobile indicator when active */}
{isActive && (
<div className="lg:hidden text-white/90 text-xs mb-2 font-medium">
</div>
)}
{video.type_name && (
<Badge variant="secondary" className="text-xs mb-2">
{video.type_name}
</Badge>
)}
{video.vod_year && (
<div className="flex items-center gap-1 text-white/80 text-xs">
<Icons.Calendar size={12} />
<span>{video.vod_year}</span>
</div>
)}
</div>
</div>
</div>
{/* Info - Fixed height section */}
<div className="p-3 flex-1 flex flex-col">
<h4 className="font-semibold text-sm text-[var(--text-color)] line-clamp-2 min-h-[2.5rem] group-hover:text-[var(--accent-color)] transition-colors">
{video.vod_name}
</h4>
{video.vod_remarks && (
<p className="text-xs text-[var(--text-color-secondary)] mt-1 line-clamp-1">
{video.vod_remarks}
</p>
)}
</div>
</Card>
</Link>
);
});
VideoCard.displayName = 'VideoCard';
export const VideoGrid = memo(function VideoGrid({ videos, className = '' }: VideoGridProps) {
const [activeCardId, setActiveCardId] = useState<string | null>(null);
const gridRef = useRef<HTMLDivElement>(null);
@@ -67,85 +164,16 @@ export function VideoGrid({ videos, className = '' }: VideoGridProps) {
const isActive = activeCardId === cardId;
return (
<Link
<VideoCard
key={cardId}
href={videoUrl}
onClick={(e) => handleCardClick(e, cardId, videoUrl)}
role="listitem"
aria-label={`${video.vod_name}${video.vod_remarks ? ` - ${video.vod_remarks}` : ''}`}
>
<Card
className={`p-0 overflow-hidden group cursor-pointer flex flex-col h-full ${video.isNew ? 'animate-scale-in' : ''}`}
>
{/* Poster */}
<div className="relative aspect-[2/3] bg-[color-mix(in_srgb,var(--glass-bg)_50%,transparent)] overflow-hidden rounded-[var(--radius-2xl)]">
{video.vod_pic ? (
<img
src={video.vod_pic}
alt={video.vod_name}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300 rounded-[var(--radius-2xl)]"
loading="lazy"
decoding="async"
onError={(e) => {
e.currentTarget.src = '/placeholder-poster.svg';
}}
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<Icons.Film size={64} className="text-[var(--text-color-secondary)]" />
</div>
)}
{/* Source Badge - Top Left */}
{video.sourceName && (
<div className="absolute top-2 left-2 z-10">
<Badge variant="primary" className="text-xs bg-[var(--accent-color)]">
{video.sourceName}
</Badge>
</div>
)}
{/* Overlay - Show on hover (desktop) or when active (mobile) */}
<div className={`absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent transition-opacity duration-300 ${
isActive ? 'opacity-100' : 'opacity-0 lg:group-hover:opacity-100'
}`}>
<div className="absolute bottom-0 left-0 right-0 p-3">
{/* Mobile indicator when active */}
{isActive && (
<div className="lg:hidden text-white/90 text-xs mb-2 font-medium">
</div>
)}
{video.type_name && (
<Badge variant="secondary" className="text-xs mb-2">
{video.type_name}
</Badge>
)}
{video.vod_year && (
<div className="flex items-center gap-1 text-white/80 text-xs">
<Icons.Calendar size={12} />
<span>{video.vod_year}</span>
</div>
)}
</div>
</div>
</div>
{/* Info - Fixed height section */}
<div className="p-3 flex-1 flex flex-col">
<h4 className="font-semibold text-sm text-[var(--text-color)] line-clamp-2 min-h-[2.5rem] group-hover:text-[var(--accent-color)] transition-colors">
{video.vod_name}
</h4>
{video.vod_remarks && (
<p className="text-xs text-[var(--text-color-secondary)] mt-1 line-clamp-1">
{video.vod_remarks}
</p>
)}
</div>
</Card>
</Link>
video={video}
videoUrl={videoUrl}
cardId={cardId}
isActive={isActive}
onCardClick={handleCardClick}
/>
);
})}
</div>
);
}
});
+1 -1
View File
@@ -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);
+23 -4
View File
@@ -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