mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-16 17:23:43 +08:00
feat: add latency tracking and display in video search results; optimize scrolling performance
This commit is contained in:
@@ -55,29 +55,34 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// Search all sources in PARALLEL - don't wait for all to finish
|
||||
const searchPromises = sources.map(async (source: any) => {
|
||||
const startTime = performance.now(); // Track start time
|
||||
try {
|
||||
console.log(`[Search Parallel] Searching source: ${source.id} (${getSourceDisplayName(source.id)})`);
|
||||
|
||||
// Search this source
|
||||
const result = await searchVideos(query.trim(), [source], page);
|
||||
const endTime = performance.now(); // Track end time
|
||||
const latency = Math.round(endTime - startTime); // Calculate latency in ms
|
||||
const videos = result[0]?.results || [];
|
||||
|
||||
completedSources++;
|
||||
totalVideosFound += videos.length;
|
||||
|
||||
console.log(`[Search Parallel] Source ${source.id} completed: ${videos.length} videos found`);
|
||||
console.log(`[Search Parallel] Source ${source.id} completed in ${latency}ms: ${videos.length} videos found`);
|
||||
|
||||
// Stream videos immediately as they arrive
|
||||
// Stream videos immediately as they arrive WITH latency data
|
||||
if (videos.length > 0) {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
type: 'videos',
|
||||
videos: videos.map((video: any) => ({
|
||||
...video,
|
||||
sourceDisplayName: getSourceDisplayName(source.id),
|
||||
latency, // Add latency to each video
|
||||
})),
|
||||
source: source.id,
|
||||
completedSources,
|
||||
totalSources: sources.length
|
||||
totalSources: sources.length,
|
||||
latency, // Also include at source level
|
||||
})}\n\n`));
|
||||
}
|
||||
|
||||
@@ -90,8 +95,10 @@ export async function POST(request: NextRequest) {
|
||||
})}\n\n`));
|
||||
|
||||
} catch (error) {
|
||||
const endTime = performance.now();
|
||||
const latency = Math.round(endTime - startTime);
|
||||
// Log error but continue with other sources
|
||||
console.error(`[Search Parallel] Source ${source.id} failed:`, error);
|
||||
console.error(`[Search Parallel] Source ${source.id} failed after ${latency}ms:`, error);
|
||||
completedSources++;
|
||||
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
@import "tailwindcss";
|
||||
@import "./scroll-optimization.css";
|
||||
|
||||
:root {
|
||||
--font-family-system: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Liberation Sans", sans-serif;
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Performance Optimization CSS
|
||||
* Improves scrolling performance for video grids
|
||||
*/
|
||||
|
||||
/* Enable smooth scrolling with hardware acceleration */
|
||||
* {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Optimize scrolling container */
|
||||
.video-grid-container {
|
||||
/* Use momentum scrolling on iOS */
|
||||
-webkit-overflow-scrolling: touch;
|
||||
|
||||
/* Force GPU layer for scrolling */
|
||||
transform: translate3d(0, 0, 0);
|
||||
will-change: auto;
|
||||
}
|
||||
|
||||
/* Optimize video cards for rendering */
|
||||
.video-card-wrapper {
|
||||
/* CSS containment for isolation */
|
||||
contain: layout style paint;
|
||||
|
||||
/* Content visibility for lazy rendering */
|
||||
content-visibility: auto;
|
||||
|
||||
/* Reduce layout shifts */
|
||||
contain-intrinsic-size: auto 400px;
|
||||
}
|
||||
|
||||
/* Reduce repaints on images */
|
||||
.video-card-image {
|
||||
/* Force GPU rendering */
|
||||
transform: translate3d(0, 0, 0);
|
||||
|
||||
/* Prevent unnecessary repaints */
|
||||
will-change: auto;
|
||||
|
||||
/* Optimize image decoding */
|
||||
image-rendering: auto;
|
||||
}
|
||||
|
||||
/* Optimize badges and overlays */
|
||||
.video-card-badge {
|
||||
/* Force GPU layer */
|
||||
transform: translate3d(0, 0, 0);
|
||||
|
||||
/* No backdrop filter during scroll */
|
||||
will-change: auto;
|
||||
}
|
||||
|
||||
/* Disable expensive effects during scroll */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.video-card:not(:hover) .expensive-effect {
|
||||
/* Disable blur effects when not hovering */
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Optimize for mobile devices */
|
||||
@media (max-width: 768px) {
|
||||
/* Reduce visual complexity on mobile */
|
||||
.video-card {
|
||||
/* Simpler rendering */
|
||||
will-change: auto;
|
||||
}
|
||||
|
||||
/* Disable expensive hover effects on mobile */
|
||||
.video-card-overlay {
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Grid optimization */
|
||||
.optimized-grid {
|
||||
/* Grid-specific containment */
|
||||
contain: layout style;
|
||||
|
||||
/* Prevent layout thrashing */
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* Passive event listeners hint */
|
||||
html {
|
||||
/* Hint to browser for passive touch events */
|
||||
touch-action: pan-y;
|
||||
}
|
||||
@@ -42,7 +42,7 @@ export const SourceBadges = memo(function SourceBadges({
|
||||
return (
|
||||
<Card
|
||||
hover={false}
|
||||
className={`p-4 animate-fade-in ${className}`}
|
||||
className={`p-4 animate-fade-in bg-[var(--bg-color)]/50 backdrop-blur-none saturate-100 shadow-sm border-[var(--glass-border)] ${className}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex items-center gap-2 shrink-0 pt-1">
|
||||
@@ -71,7 +71,7 @@ export const SourceBadges = memo(function SourceBadges({
|
||||
focus:outline-none
|
||||
${isSelected
|
||||
? 'bg-[var(--accent-color)] text-white border-[var(--accent-color)] shadow-md'
|
||||
: 'bg-[var(--glass-bg)] text-[var(--text-color)] backdrop-blur-[10px] border-[var(--glass-border)] hover:border-[var(--accent-color)]'
|
||||
: 'bg-[var(--glass-bg)] text-[var(--text-color)] border-[var(--glass-border)] hover:border-[var(--accent-color)]'
|
||||
}
|
||||
`}
|
||||
>
|
||||
|
||||
@@ -52,7 +52,7 @@ export function TypeBadgeItem({
|
||||
focus:outline-none
|
||||
${isSelected
|
||||
? 'bg-[var(--accent-color)] text-white border-[var(--accent-color)] shadow-md'
|
||||
: 'bg-[var(--glass-bg)] text-[var(--text-color)] backdrop-blur-[10px] border-[var(--glass-border)] hover:border-[var(--accent-color)]'
|
||||
: 'bg-[var(--glass-bg)] text-[var(--text-color)] border-[var(--glass-border)] hover:border-[var(--accent-color)]'
|
||||
}
|
||||
`}
|
||||
>
|
||||
|
||||
@@ -41,7 +41,7 @@ export const TypeBadges = memo(function TypeBadges({
|
||||
return (
|
||||
<Card
|
||||
hover={false}
|
||||
className={`p-4 animate-fade-in ${className}`}
|
||||
className={`p-4 animate-fade-in bg-[var(--bg-color)]/50 backdrop-blur-none saturate-100 shadow-sm border-[var(--glass-border)] ${className}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex items-center gap-2 shrink-0 pt-1">
|
||||
|
||||
+171
-77
@@ -1,10 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useCallback, useMemo, memo } from 'react';
|
||||
import { useState, useRef, useCallback, useMemo, memo, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { LatencyBadge } from '@/components/ui/LatencyBadge';
|
||||
|
||||
interface Video {
|
||||
vod_id: string;
|
||||
@@ -16,6 +18,7 @@ interface Video {
|
||||
source: string;
|
||||
sourceName?: string;
|
||||
isNew?: boolean;
|
||||
latency?: number; // Response time in milliseconds
|
||||
}
|
||||
|
||||
interface VideoGridProps {
|
||||
@@ -38,34 +41,65 @@ const VideoCard = memo(({
|
||||
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}` : ''}`}
|
||||
<div
|
||||
style={{
|
||||
// CSS containment for better performance
|
||||
contain: 'layout style paint',
|
||||
contentVisibility: 'auto',
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
className="p-0 overflow-hidden group cursor-pointer flex flex-col h-full"
|
||||
<Link
|
||||
key={cardId}
|
||||
href={videoUrl}
|
||||
onClick={(e) => onCardClick(e, cardId, videoUrl)}
|
||||
role="listitem"
|
||||
aria-label={`${video.vod_name}${video.vod_remarks ? ` - ${video.vod_remarks}` : ''}`}
|
||||
prefetch={false}
|
||||
>
|
||||
{/* 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)]" />
|
||||
<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={false} // Disable default hover scale to improve performance
|
||||
style={{
|
||||
willChange: 'transform',
|
||||
transform: 'translate3d(0,0,0)',
|
||||
backfaceVisibility: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* 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 ? (
|
||||
<Image
|
||||
src={video.vod_pic}
|
||||
alt={video.vod_name}
|
||||
fill
|
||||
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
|
||||
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
|
||||
const target = e.currentTarget as HTMLImageElement;
|
||||
// Since next/image manages the src, we might need a state or a different approach for fallback
|
||||
// For simplicity in this performance fix, we'll rely on the parent div background or a separate placeholder component
|
||||
// But actually, we can just use a simple img tag for fallback if next/image fails,
|
||||
// or better: use a state to switch to fallback.
|
||||
// However, inside a memoized component, adding state might be heavy.
|
||||
// Let's stick to a simple CSS hide for now or use the unoptimized prop if it fails? No.
|
||||
// Let's just hide it and let the background icon show.
|
||||
target.style.opacity = '0';
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<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" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Source Badge - Top Left */}
|
||||
{video.sourceName && (
|
||||
@@ -76,35 +110,45 @@ const VideoCard = memo(({
|
||||
</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 && (
|
||||
{/* Latency Badge - Top Right */}
|
||||
{video.latency !== undefined && (
|
||||
<div className="absolute top-2 right-2 z-10">
|
||||
<LatencyBadge latency={video.latency} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Overlay - Show on hover (desktop) or when active (mobile) - Simplified for performance */}
|
||||
{isActive && (
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60" // Removed gradient for solid semi-transparent overlay
|
||||
style={{
|
||||
willChange: 'opacity',
|
||||
}}
|
||||
>
|
||||
<div className="absolute bottom-0 left-0 right-0 p-3">
|
||||
{/* Mobile indicator when active */}
|
||||
<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>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
</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">
|
||||
<h4 className="font-semibold text-sm text-[var(--text-color)] line-clamp-2 min-h-[2.5rem]">
|
||||
{video.vod_name}
|
||||
</h4>
|
||||
{video.vod_remarks && (
|
||||
@@ -115,6 +159,7 @@ const VideoCard = memo(({
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -122,13 +167,31 @@ VideoCard.displayName = 'VideoCard';
|
||||
|
||||
export const VideoGrid = memo(function VideoGrid({ videos, className = '' }: VideoGridProps) {
|
||||
const [activeCardId, setActiveCardId] = useState<string | null>(null);
|
||||
const [visibleCount, setVisibleCount] = useState(24);
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
const observerRef = useRef<IntersectionObserver | null>(null);
|
||||
|
||||
if (videos.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleCardClick = (e: React.MouseEvent, videoId: string, videoUrl: string) => {
|
||||
// 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);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Memoize the click handler to prevent re-renders
|
||||
const handleCardClick = useCallback((e: React.MouseEvent, videoId: string, videoUrl: string) => {
|
||||
// Check if it's a mobile device
|
||||
const isMobile = window.innerWidth < 1024; // lg breakpoint
|
||||
|
||||
@@ -144,36 +207,67 @@ export const VideoGrid = memo(function VideoGrid({ videos, className = '' }: Vid
|
||||
}
|
||||
}
|
||||
// On desktop, let the Link work normally
|
||||
};
|
||||
}, [activeCardId]);
|
||||
|
||||
// Memoize video items to prevent unnecessary re-computations
|
||||
const videoItems = useMemo(() => {
|
||||
return videos.map((video, index) => {
|
||||
const videoUrl = `/player?${new URLSearchParams({
|
||||
id: video.vod_id,
|
||||
source: video.source,
|
||||
title: video.vod_name,
|
||||
}).toString()}`;
|
||||
|
||||
const cardId = `${video.vod_id}-${index}`;
|
||||
|
||||
return {
|
||||
video,
|
||||
videoUrl,
|
||||
cardId,
|
||||
};
|
||||
});
|
||||
}, [videos]);
|
||||
|
||||
const visibleItems = videoItems.slice(0, visibleCount);
|
||||
|
||||
return (
|
||||
<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="视频搜索结果"
|
||||
>
|
||||
{videos.map((video, index) => {
|
||||
const videoUrl = `/player?${new URLSearchParams({
|
||||
id: video.vod_id,
|
||||
source: video.source,
|
||||
title: video.vod_name,
|
||||
}).toString()}`;
|
||||
|
||||
const cardId = `${video.vod_id}-${index}`;
|
||||
const isActive = activeCardId === cardId;
|
||||
|
||||
return (
|
||||
<VideoCard
|
||||
key={cardId}
|
||||
video={video}
|
||||
videoUrl={videoUrl}
|
||||
cardId={cardId}
|
||||
isActive={isActive}
|
||||
onCardClick={handleCardClick}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</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',
|
||||
contain: 'layout style paint',
|
||||
contentVisibility: 'auto',
|
||||
}}
|
||||
>
|
||||
{visibleItems.map(({ video, videoUrl, cardId }) => {
|
||||
const isActive = activeCardId === cardId;
|
||||
|
||||
return (
|
||||
<VideoCard
|
||||
key={cardId}
|
||||
video={video}
|
||||
videoUrl={videoUrl}
|
||||
cardId={cardId}
|
||||
isActive={isActive}
|
||||
onCardClick={handleCardClick}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Load more trigger */}
|
||||
{visibleCount < videoItems.length && (
|
||||
<div
|
||||
ref={loadMoreRef}
|
||||
className="h-20 w-full flex items-center justify-center opacity-0 pointer-events-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
+15
-7
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { memo } from 'react';
|
||||
|
||||
interface BadgeProps {
|
||||
children: React.ReactNode;
|
||||
@@ -8,7 +8,7 @@ interface BadgeProps {
|
||||
iconPosition?: 'left' | 'right';
|
||||
}
|
||||
|
||||
export function Badge({
|
||||
const BadgeComponent = memo(function Badge({
|
||||
children,
|
||||
variant = 'primary',
|
||||
className = '',
|
||||
@@ -17,18 +17,19 @@ export function Badge({
|
||||
}: BadgeProps) {
|
||||
const variants = {
|
||||
primary: "bg-[var(--accent-color)] text-white shadow-[var(--shadow-sm)]",
|
||||
secondary: "bg-[var(--glass-bg)] backdrop-blur-[10px] [-webkit-backdrop-filter:blur(10px)] border border-[var(--glass-border)] text-[var(--text-color)]",
|
||||
secondary: "bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)]",
|
||||
};
|
||||
|
||||
const iconElement = icon && (
|
||||
<span
|
||||
className={`inline-flex items-center justify-center transition-transform duration-200 ${
|
||||
className={`inline-flex items-center justify-center ${
|
||||
iconPosition === 'left' ? 'mr-1' : 'ml-1'
|
||||
}`}
|
||||
style={{
|
||||
width: '0.875em',
|
||||
height: '0.875em',
|
||||
transform: 'translateZ(0)'
|
||||
transform: 'translateZ(0)',
|
||||
willChange: 'auto',
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
@@ -42,15 +43,22 @@ export function Badge({
|
||||
px-2 py-0.5 md:px-3 md:py-1
|
||||
rounded-[var(--radius-full)]
|
||||
text-[10px] md:text-xs font-semibold
|
||||
transition-all duration-200
|
||||
${variants[variant]}
|
||||
${className}
|
||||
`}
|
||||
style={{
|
||||
transform: 'translateZ(0)',
|
||||
willChange: 'auto',
|
||||
}}
|
||||
>
|
||||
{icon && iconPosition === 'left' && iconElement}
|
||||
{children}
|
||||
{icon && iconPosition === 'right' && iconElement}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Export both named and default for compatibility
|
||||
export const Badge = BadgeComponent;
|
||||
export { BadgeComponent as default };
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* LatencyBadge - Display latency with color coding
|
||||
* Following Liquid Glass design system
|
||||
*/
|
||||
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import { getLatencyInfo } from '@/lib/utils/latency';
|
||||
|
||||
interface LatencyBadgeProps {
|
||||
latency: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const LatencyBadge = memo(function LatencyBadge({ latency, className = '' }: LatencyBadgeProps) {
|
||||
// Memoize the latency info calculation
|
||||
const info = useMemo(() => getLatencyInfo(latency), [latency]);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`
|
||||
inline-flex items-center justify-center
|
||||
px-2 py-0.5 md:px-3 md:py-1
|
||||
rounded-[var(--radius-full)]
|
||||
text-[10px] md:text-xs font-mono font-semibold
|
||||
border
|
||||
${className}
|
||||
`}
|
||||
style={{
|
||||
backgroundColor: `${info.color}30`,
|
||||
borderColor: info.color,
|
||||
color: info.color,
|
||||
willChange: 'auto',
|
||||
transform: 'translate3d(0,0,0)',
|
||||
}}
|
||||
title={`Response time: ${info.label} (${info.level})`}
|
||||
aria-label={`Latency: ${info.label}`}
|
||||
>
|
||||
{info.label}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
@@ -18,6 +18,7 @@ interface Video {
|
||||
vod_actor?: string;
|
||||
vod_director?: string;
|
||||
relevanceScore?: number;
|
||||
latency?: number; // Response time in milliseconds
|
||||
}
|
||||
|
||||
export interface ParallelSearchResult {
|
||||
@@ -120,21 +121,33 @@ export function useParallelSearch(
|
||||
setResults((prev) => {
|
||||
if (prev.length === 0) return newVideos;
|
||||
|
||||
// Binary insert for better performance
|
||||
// Binary insert for better performance with combined sorting
|
||||
const combined = [...prev];
|
||||
for (const video of newVideos) {
|
||||
const score = video.relevanceScore || 0;
|
||||
let insertIndex = combined.length;
|
||||
const relevanceScore = video.relevanceScore || 0;
|
||||
const latency = video.latency || 99999; // Default high latency for sorting
|
||||
|
||||
// Find insert position using binary search
|
||||
// Sort by: 1) relevance score (DESC), 2) latency (ASC)
|
||||
let left = 0;
|
||||
let right = combined.length;
|
||||
while (left < right) {
|
||||
const mid = Math.floor((left + right) / 2);
|
||||
if ((combined[mid].relevanceScore || 0) >= score) {
|
||||
const midRelevance = combined[mid].relevanceScore || 0;
|
||||
const midLatency = combined[mid].latency || 99999;
|
||||
|
||||
// Compare by relevance first
|
||||
if (midRelevance > relevanceScore) {
|
||||
left = mid + 1;
|
||||
} else {
|
||||
} else if (midRelevance < relevanceScore) {
|
||||
right = mid;
|
||||
} else {
|
||||
// Same relevance, compare by latency (lower is better)
|
||||
if (midLatency < latency) {
|
||||
left = mid + 1;
|
||||
} else {
|
||||
right = mid;
|
||||
}
|
||||
}
|
||||
}
|
||||
combined.splice(left, 0, video);
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface VideoItem {
|
||||
vod_director?: string;
|
||||
vod_content?: string;
|
||||
source: string;
|
||||
latency?: number; // Response time in milliseconds
|
||||
}
|
||||
|
||||
// Episode Information
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Latency utilities for displaying response times
|
||||
* Following Liquid Glass design system principles
|
||||
*/
|
||||
|
||||
export interface LatencyInfo {
|
||||
value: number;
|
||||
label: string;
|
||||
color: string;
|
||||
level: 'excellent' | 'good' | 'fair' | 'slow';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get latency information with color coding
|
||||
* @param latency - Response time in milliseconds
|
||||
* @returns Formatted latency info with color and level
|
||||
*/
|
||||
export function getLatencyInfo(latency: number): LatencyInfo {
|
||||
let level: LatencyInfo['level'];
|
||||
let color: string;
|
||||
|
||||
if (latency < 500) {
|
||||
level = 'excellent';
|
||||
color = '#34c759'; // Green
|
||||
} else if (latency < 1000) {
|
||||
level = 'good';
|
||||
color = '#30d158'; // Light green
|
||||
} else if (latency < 2000) {
|
||||
level = 'fair';
|
||||
color = '#ff9500'; // Orange
|
||||
} else {
|
||||
level = 'slow';
|
||||
color = '#ff3b30'; // Red
|
||||
}
|
||||
|
||||
return {
|
||||
value: latency,
|
||||
label: formatLatency(latency),
|
||||
color,
|
||||
level,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format latency for display
|
||||
* @param latency - Response time in milliseconds
|
||||
* @returns Formatted string in milliseconds (e.g., "345ms", "1240ms")
|
||||
*/
|
||||
export function formatLatency(latency: number): string {
|
||||
return `${latency}ms`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get latency emoji indicator
|
||||
* @param latency - Response time in milliseconds
|
||||
* @returns Emoji representing speed
|
||||
*/
|
||||
export function getLatencyEmoji(latency: number): string {
|
||||
if (latency < 500) return '⚡'; // Excellent
|
||||
if (latency < 1000) return '✨'; // Good
|
||||
if (latency < 2000) return '⏱️'; // Fair
|
||||
return '🐌'; // Slow
|
||||
}
|
||||
@@ -46,6 +46,46 @@ const nextConfig: NextConfig = {
|
||||
protocol: 'https',
|
||||
hostname: '**.cn',
|
||||
},
|
||||
{
|
||||
protocol: 'http',
|
||||
hostname: '**.net',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: '**.net',
|
||||
},
|
||||
{
|
||||
protocol: 'http',
|
||||
hostname: '**.org',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: '**.org',
|
||||
},
|
||||
{
|
||||
protocol: 'http',
|
||||
hostname: '**.tv',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: '**.tv',
|
||||
},
|
||||
{
|
||||
protocol: 'http',
|
||||
hostname: '**.io',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: '**.io',
|
||||
},
|
||||
{
|
||||
protocol: 'http',
|
||||
hostname: '**.xyz',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: '**.xyz',
|
||||
},
|
||||
],
|
||||
// Add image optimization for better performance
|
||||
formats: ['image/webp'],
|
||||
|
||||
Reference in New Issue
Block a user