mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-19 10:43:43 +08:00
feat: Add new animations and optimize global CSS for improved performance and readability.
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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'
|
||||
}}>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
@@ -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`}
|
||||
|
||||
Reference in New Issue
Block a user