mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-14 16:23:43 +08:00
refactor: optimize performance in various components; adjust animation intervals, memoize components, and improve sorting logic
This commit is contained in:
@@ -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 */}
|
||||
|
||||
@@ -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,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>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user