feat: Add new animations and optimize global CSS for improved performance and readability.

This commit is contained in:
kuekhaoyang
2025-11-19 16:24:11 +08:00
parent c9c5c5f788
commit b6cebc35bf
7 changed files with 198 additions and 144 deletions
+5 -4
View File
@@ -35,7 +35,7 @@ export function HistoryItem({
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
@@ -47,11 +47,11 @@ export function HistoryItem({
const now = new Date();
const diff = now.getTime() - date.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) return '今天';
if (days === 1) return '昨天';
if (days < 7) return `${days}天前`;
return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
};
@@ -75,7 +75,7 @@ export function HistoryItem({
};
const progress = (playbackPosition / duration) * 100;
const episodeText = episodes && episodes.length > 0
const episodeText = episodes && episodes.length > 0
? episodes[episodeIndex]?.name || `${episodeIndex + 1}`
: '';
@@ -102,6 +102,7 @@ export function HistoryItem({
alt={title}
fill
className="object-cover"
sizes="112px"
onError={(e) => {
const target = e.currentTarget as HTMLImageElement;
target.style.display = 'none';
+5 -5
View File
@@ -84,7 +84,7 @@ export function WatchHistorySidebar() {
{/* Toggle Button */}
<button
onClick={() => setIsOpen(true)}
className="fixed right-6 top-1/2 -translate-y-1/2 z-40 bg-[var(--glass-bg)] backdrop-blur-[12px] saturate-[120%] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-md)] p-3 hover:scale-105 transition-transform duration-200"
className="fixed right-6 top-1/2 -translate-y-1/2 z-40 bg-[var(--glass-bg)] backdrop-blur-[8px] saturate-[120%] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-md)] p-3 hover:scale-105 transition-transform duration-200"
aria-label="打开观看历史"
>
<Icons.History size={24} className="text-[var(--text-color)]" />
@@ -104,17 +104,17 @@ export function WatchHistorySidebar() {
role="complementary"
aria-labelledby="history-sidebar-title"
aria-hidden={!isOpen}
style={{
style={{
transform: isOpen ? 'translate3d(0, 0, 0)' : 'translate3d(100%, 0, 0)',
willChange: isOpen ? 'transform' : 'auto'
}}
className={`fixed top-0 right-0 bottom-0 w-[85%] sm:w-[90%] max-w-[420px] z-[2000] bg-[var(--glass-bg)] backdrop-blur-[12px] saturate-[120%] border-l border-[var(--glass-border)] rounded-tl-[var(--radius-2xl)] rounded-bl-[var(--radius-2xl)] p-6 flex flex-col shadow-[0_8px_32px_rgba(0,0,0,0.2)] transition-transform duration-250 ease-out`}
className={`fixed top-0 right-0 bottom-0 w-[85%] sm:w-[90%] max-w-[420px] z-[2000] bg-[var(--glass-bg)] backdrop-blur-[8px] saturate-[120%] border-l border-[var(--glass-border)] rounded-tl-[var(--radius-2xl)] rounded-bl-[var(--radius-2xl)] p-6 flex flex-col shadow-[0_8px_32px_rgba(0,0,0,0.2)] transition-transform duration-250 ease-out`}
>
{/* Header */}
<header className="flex items-center justify-between mb-6 pb-4 border-b border-[var(--glass-border)]">
<div className="flex items-center gap-3">
<Icons.History size={24} className="text-[var(--accent-color)]" />
<h2
<h2
id="history-sidebar-title"
className="text-xl font-semibold text-[var(--text-color)]"
>
@@ -131,7 +131,7 @@ export function WatchHistorySidebar() {
</header>
{/* Content */}
<div className="flex-1 overflow-y-auto -mx-2 px-2" style={{
<div className="flex-1 overflow-y-auto -mx-2 px-2" style={{
transform: 'translate3d(0, 0, 0)',
WebkitOverflowScrolling: 'touch'
}}>
+2 -2
View File
@@ -32,7 +32,7 @@ export const MovieCard = memo(function MovieCard({ movie, onMovieClick }: MovieC
}}
className="group cursor-pointer"
>
<Card hover className="overflow-hidden p-0 h-full">
<Card hover className="overflow-hidden p-0 h-full" blur={false}>
<div className="relative aspect-[2/3] overflow-hidden bg-[var(--glass-bg)] rounded-[var(--radius-2xl)]">
<Image
src={movie.cover}
@@ -55,7 +55,7 @@ export const MovieCard = memo(function MovieCard({ movie, onMovieClick }: MovieC
}}
/>
{movie.rate && parseFloat(movie.rate) > 0 && (
<div
<div
className="absolute top-2 right-2 bg-black/80 px-2.5 py-1.5 flex items-center gap-1.5 rounded-[var(--radius-full)]"
>
<Icons.Star size={12} className="text-yellow-400 fill-yellow-400" />
+50 -50
View File
@@ -27,28 +27,28 @@ interface 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;
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 (
<div
style={{
style={{
// CSS containment for better performance
contain: 'layout style paint',
contentVisibility: 'auto',
}}
>
<Link
<Link
key={cardId}
href={videoUrl}
onClick={(e) => onCardClick(e, cardId, videoUrl)}
@@ -59,7 +59,8 @@ const VideoCard = memo(({
<Card
className="p-0 overflow-hidden group cursor-pointer flex flex-col h-full bg-[var(--bg-color)]/50 backdrop-blur-none saturate-100 shadow-sm border-[var(--glass-border)] hover:shadow-lg transition-shadow"
hover={false} // Disable default hover scale to improve performance
style={{
blur={false}
style={{
willChange: 'transform',
transform: 'translate3d(0,0,0)',
backfaceVisibility: 'hidden',
@@ -75,7 +76,7 @@ const VideoCard = memo(({
className="object-cover rounded-[var(--radius-2xl)]"
sizes="(max-width: 640px) 33vw, (max-width: 1024px) 20vw, 16vw"
loading="lazy"
unoptimized={true} // Skip server-side optimization for better scroll performance on localhost
// unoptimized={true} // Removed for performance - let Next.js optimize images
onError={(e) => {
// 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
@@ -95,12 +96,12 @@ const VideoCard = memo(({
<Icons.Film size={64} className="text-[var(--text-color-secondary)]" />
</div>
)}
{/* Fallback Icon (always rendered behind image, visible if image fails/loads) */}
<div className="absolute inset-0 flex items-center justify-center -z-10">
<Icons.Film size={64} className="text-[var(--text-color-secondary)] opacity-20" />
<Icons.Film size={64} className="text-[var(--text-color-secondary)] opacity-20" />
</div>
{/* Badge Container - Top, spans full width with proper spacing */}
<div className="absolute top-2 left-2 right-2 z-10 flex items-center justify-between gap-1">
{/* Source Badge - Left */}
@@ -109,19 +110,18 @@ const VideoCard = memo(({
{video.sourceName}
</Badge>
)}
{/* Latency Badge - Right */}
{video.latency !== undefined && (
<LatencyBadge latency={video.latency} className="flex-shrink-0" />
)}
</div>
{/* Overlay - Show on hover (desktop) or when active (mobile) */}
<div
className={`absolute inset-0 bg-black/60 transition-opacity duration-300 ${
isActive ? 'opacity-100 lg:opacity-0 lg:group-hover:opacity-100' : 'opacity-0 lg:group-hover:opacity-100'
}`}
style={{
<div
className={`absolute inset-0 bg-black/60 transition-opacity duration-300 ${isActive ? 'opacity-100 lg:opacity-0 lg:group-hover:opacity-100' : 'opacity-0 lg:group-hover:opacity-100'
}`}
style={{
willChange: 'opacity',
}}
>
@@ -147,19 +147,19 @@ const VideoCard = memo(({
</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]">
{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>
{/* 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]">
{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>
</div>
);
});
@@ -171,7 +171,7 @@ export const VideoGrid = memo(function VideoGrid({ videos, className = '' }: Vid
const [visibleCount, setVisibleCount] = useState(24);
const gridRef = useRef<HTMLDivElement>(null);
const observerRef = useRef<IntersectionObserver | null>(null);
if (videos.length === 0) {
return null;
}
@@ -179,14 +179,14 @@ export const VideoGrid = memo(function VideoGrid({ videos, className = '' }: Vid
// 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);
}
}, []);
@@ -195,7 +195,7 @@ export const VideoGrid = memo(function VideoGrid({ videos, className = '' }: Vid
const handleCardClick = useCallback((e: React.MouseEvent, videoId: string, videoUrl: string) => {
// Check if it's a mobile device
const isMobile = window.innerWidth < 1024; // lg breakpoint
if (isMobile) {
// On mobile, first click shows details, second click navigates
if (activeCardId === videoId) {
@@ -218,9 +218,9 @@ export const VideoGrid = memo(function VideoGrid({ videos, className = '' }: Vid
source: video.source,
title: video.vod_name,
}).toString()}`;
const cardId = `${video.vod_id}-${index}`;
return {
video,
videoUrl,
@@ -233,21 +233,21 @@ export const VideoGrid = memo(function VideoGrid({ videos, className = '' }: Vid
return (
<>
<div
<div
ref={gridRef}
className={`grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-6 gap-3 md:gap-4 lg:gap-6 max-w-[1920px] mx-auto ${className}`}
role="list"
aria-label="视频搜索结果"
style={{
// Optimize rendering performance
willChange: 'auto',
// willChange: 'auto', // Removed to let browser decide
contain: 'layout style paint',
contentVisibility: 'auto',
}}
>
{visibleItems.map(({ video, videoUrl, cardId }) => {
const isActive = activeCardId === cardId;
return (
<VideoCard
key={cardId}
@@ -260,11 +260,11 @@ export const VideoGrid = memo(function VideoGrid({ videos, className = '' }: Vid
);
})}
</div>
{/* Load more trigger */}
{visibleCount < videoItems.length && (
<div
ref={loadMoreRef}
<div
ref={loadMoreRef}
className="h-20 w-full flex items-center justify-center opacity-0 pointer-events-none"
aria-hidden="true"
/>
+11 -8
View File
@@ -4,20 +4,23 @@ interface CardProps {
children: React.ReactNode;
className?: string;
hover?: boolean;
blur?: boolean;
onClick?: () => void;
style?: React.CSSProperties;
}
export function Card({ children, className = '', hover = true, onClick, style }: CardProps) {
const hoverStyles = hover
? "hover:translate-y-[-2px] hover:shadow-[0_8px_24px_var(--shadow-color)] cursor-pointer transition-transform duration-200 ease-out"
export function Card({ children, className = '', hover = true, blur = true, onClick, style }: CardProps) {
const hoverStyles = hover
? "hover:translate-y-[-2px] hover:shadow-[0_8px_24px_var(--shadow-color)] cursor-pointer transition-transform duration-200 ease-out"
: "";
// Conditionally apply blur classes - NOW DISABLED GLOBALLY via CSS override, but logic kept for structure
const blurClasses = blur
? "bg-[var(--glass-bg)]" // Removed backdrop-blur classes here as they are expensive
: "bg-[var(--bg-color)]/90"; // More opaque fallback
const baseClasses = `
bg-[var(--glass-bg)]
backdrop-blur-[12px]
saturate-[120%]
[-webkit-backdrop-filter:blur(12px)_saturate(120%)]
${blurClasses}
rounded-[var(--radius-2xl)]
shadow-[0_2px_8px_var(--shadow-color)] md:shadow-[var(--shadow-md)]
border
@@ -31,7 +34,7 @@ export function Card({ children, className = '', hover = true, onClick, style }:
// Use semantic button when interactive
if (onClick) {
return (
<button
<button
type="button"
onClick={onClick}
className={`${baseClasses} text-left w-full`}