feat: Add video card component, desktop player controls, and introduce API utilities for search and data parsing.

This commit is contained in:
kuekhaoyang
2025-11-21 12:58:19 +08:00
parent 6e81559d90
commit a408f2270f
11 changed files with 499 additions and 450 deletions
+15 -145
View File
@@ -1,9 +1,7 @@
import React from 'react';
import { Icons } from '@/components/ui/Icon';
import { DesktopProgressBar } from './DesktopProgressBar';
import { DesktopVolumeControl } from './DesktopVolumeControl';
import { DesktopSpeedMenu } from './DesktopSpeedMenu';
import { DesktopMoreMenu } from './DesktopMoreMenu';
import { DesktopLeftControls } from './DesktopLeftControls';
import { DesktopRightControls } from './DesktopRightControls';
interface DesktopControlsProps {
showControls: boolean;
@@ -44,44 +42,17 @@ interface DesktopControlsProps {
speeds: number[];
}
export function DesktopControls({
showControls,
isPlaying,
currentTime,
duration,
volume,
isMuted,
isFullscreen,
playbackRate,
showSpeedMenu,
showMoreMenu,
showVolumeBar,
isPiPSupported,
isAirPlaySupported,
progressBarRef,
volumeBarRef,
onTogglePlay,
onSkipForward,
onSkipBackward,
onToggleMute,
onVolumeChange,
onVolumeMouseDown,
onToggleFullscreen,
onTogglePictureInPicture,
onShowAirPlayMenu,
onToggleSpeedMenu,
onToggleMoreMenu,
onSpeedChange,
onCopyLink,
onProgressClick,
onProgressMouseDown,
onSpeedMenuMouseEnter,
onSpeedMenuMouseLeave,
onMoreMenuMouseEnter,
onMoreMenuMouseLeave,
formatTime,
speeds
}: DesktopControlsProps) {
export function DesktopControls(props: DesktopControlsProps) {
const {
showControls,
currentTime,
duration,
progressBarRef,
onProgressClick,
onProgressMouseDown,
formatTime,
} = props;
return (
<div
className={`absolute bottom-0 left-0 right-0 transition-all duration-300 ${showControls ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-2'
@@ -100,109 +71,8 @@ export function DesktopControls({
{/* Controls Bar */}
<div className="bg-gradient-to-t from-black/90 via-black/70 to-transparent px-4 pb-4 pt-2">
<div className="flex items-center justify-between gap-4">
{/* Left Controls */}
<div className="flex items-center gap-3">
{/* Play/Pause */}
<button
onClick={onTogglePlay}
className="btn-icon"
aria-label={isPlaying ? 'Pause' : 'Play'}
>
{isPlaying ? <Icons.Pause size={20} /> : <Icons.Play size={20} />}
</button>
{/* Skip Backward 10s */}
<button
onClick={onSkipBackward}
className="btn-icon"
aria-label="Skip backward 10 seconds"
title="后退 10 秒"
>
<Icons.SkipBack size={20} />
</button>
{/* Skip Forward 10s */}
<button
onClick={onSkipForward}
className="btn-icon"
aria-label="Skip forward 10 seconds"
title="快进 10 秒"
>
<Icons.SkipForward size={20} />
</button>
{/* Volume */}
<DesktopVolumeControl
volumeBarRef={volumeBarRef}
volume={volume}
isMuted={isMuted}
showVolumeBar={showVolumeBar}
onToggleMute={onToggleMute}
onVolumeChange={onVolumeChange}
onVolumeMouseDown={onVolumeMouseDown}
/>
{/* Time */}
<span className="text-white text-sm font-medium tabular-nums">
{formatTime(currentTime)} / {formatTime(duration)}
</span>
</div>
{/* Right Controls */}
<div className="flex items-center gap-3">
{/* Playback Speed */}
<DesktopSpeedMenu
showSpeedMenu={showSpeedMenu}
playbackRate={playbackRate}
speeds={speeds}
onSpeedChange={onSpeedChange}
onToggleSpeedMenu={onToggleSpeedMenu}
onMouseEnter={onSpeedMenuMouseEnter}
onMouseLeave={onSpeedMenuMouseLeave}
/>
{/* Picture-in-Picture */}
{isPiPSupported && (
<button
onClick={onTogglePictureInPicture}
className="btn-icon"
aria-label="Picture-in-Picture"
title="画中画"
>
<Icons.PictureInPicture size={20} />
</button>
)}
{/* AirPlay */}
{isAirPlaySupported && (
<button
onClick={onShowAirPlayMenu}
className="btn-icon"
aria-label="AirPlay"
title="AirPlay"
>
<Icons.Airplay size={20} />
</button>
)}
{/* More Menu */}
<DesktopMoreMenu
showMoreMenu={showMoreMenu}
onToggleMoreMenu={onToggleMoreMenu}
onMouseEnter={onMoreMenuMouseEnter}
onMouseLeave={onMoreMenuMouseLeave}
onCopyLink={onCopyLink}
/>
{/* Fullscreen */}
<button
onClick={onToggleFullscreen}
className="btn-icon"
aria-label={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
>
{isFullscreen ? <Icons.Minimize size={20} /> : <Icons.Maximize size={20} />}
</button>
</div>
<DesktopLeftControls {...props} formatTime={formatTime} />
<DesktopRightControls {...props} />
</div>
</div>
</div>
@@ -0,0 +1,86 @@
import React from 'react';
import { Icons } from '@/components/ui/Icon';
import { DesktopVolumeControl } from './DesktopVolumeControl';
interface DesktopLeftControlsProps {
isPlaying: boolean;
currentTime: number;
duration: number;
volume: number;
isMuted: boolean;
showVolumeBar: boolean;
volumeBarRef: React.RefObject<HTMLDivElement | null>;
onTogglePlay: () => void;
onSkipForward: () => void;
onSkipBackward: () => void;
onToggleMute: () => void;
onVolumeChange: (e: React.MouseEvent<HTMLDivElement>) => void;
onVolumeMouseDown: (e: React.MouseEvent<HTMLDivElement>) => void;
formatTime: (seconds: number) => string;
}
export function DesktopLeftControls({
isPlaying,
currentTime,
duration,
volume,
isMuted,
showVolumeBar,
volumeBarRef,
onTogglePlay,
onSkipForward,
onSkipBackward,
onToggleMute,
onVolumeChange,
onVolumeMouseDown,
formatTime
}: DesktopLeftControlsProps) {
return (
<div className="flex items-center gap-3">
{/* Play/Pause */}
<button
onClick={onTogglePlay}
className="btn-icon"
aria-label={isPlaying ? 'Pause' : 'Play'}
>
{isPlaying ? <Icons.Pause size={20} /> : <Icons.Play size={20} />}
</button>
{/* Skip Backward 10s */}
<button
onClick={onSkipBackward}
className="btn-icon"
aria-label="Skip backward 10 seconds"
title="后退 10 秒"
>
<Icons.SkipBack size={20} />
</button>
{/* Skip Forward 10s */}
<button
onClick={onSkipForward}
className="btn-icon"
aria-label="Skip forward 10 seconds"
title="快进 10 秒"
>
<Icons.SkipForward size={20} />
</button>
{/* Volume */}
<DesktopVolumeControl
volumeBarRef={volumeBarRef}
volume={volume}
isMuted={isMuted}
showVolumeBar={showVolumeBar}
onToggleMute={onToggleMute}
onVolumeChange={onVolumeChange}
onVolumeMouseDown={onVolumeMouseDown}
/>
{/* Time */}
<span className="text-white text-sm font-medium tabular-nums">
{formatTime(currentTime)} / {formatTime(duration)}
</span>
</div>
);
}
@@ -0,0 +1,103 @@
import React from 'react';
import { Icons } from '@/components/ui/Icon';
import { DesktopSpeedMenu } from './DesktopSpeedMenu';
import { DesktopMoreMenu } from './DesktopMoreMenu';
interface DesktopRightControlsProps {
isFullscreen: boolean;
playbackRate: number;
showSpeedMenu: boolean;
showMoreMenu: boolean;
isPiPSupported: boolean;
isAirPlaySupported: boolean;
onToggleFullscreen: () => void;
onTogglePictureInPicture: () => void;
onShowAirPlayMenu: () => void;
onToggleSpeedMenu: () => void;
onToggleMoreMenu: () => void;
onSpeedChange: (speed: number) => void;
onCopyLink: () => void;
onSpeedMenuMouseEnter: () => void;
onSpeedMenuMouseLeave: () => void;
onMoreMenuMouseEnter: () => void;
onMoreMenuMouseLeave: () => void;
speeds: number[];
}
export function DesktopRightControls({
isFullscreen,
playbackRate,
showSpeedMenu,
showMoreMenu,
isPiPSupported,
isAirPlaySupported,
onToggleFullscreen,
onTogglePictureInPicture,
onShowAirPlayMenu,
onToggleSpeedMenu,
onToggleMoreMenu,
onSpeedChange,
onCopyLink,
onSpeedMenuMouseEnter,
onSpeedMenuMouseLeave,
onMoreMenuMouseEnter,
onMoreMenuMouseLeave,
speeds
}: DesktopRightControlsProps) {
return (
<div className="flex items-center gap-3">
{/* Playback Speed */}
<DesktopSpeedMenu
showSpeedMenu={showSpeedMenu}
playbackRate={playbackRate}
speeds={speeds}
onSpeedChange={onSpeedChange}
onToggleSpeedMenu={onToggleSpeedMenu}
onMouseEnter={onSpeedMenuMouseEnter}
onMouseLeave={onSpeedMenuMouseLeave}
/>
{/* Picture-in-Picture */}
{isPiPSupported && (
<button
onClick={onTogglePictureInPicture}
className="btn-icon"
aria-label="Picture-in-Picture"
title="画中画"
>
<Icons.PictureInPicture size={20} />
</button>
)}
{/* AirPlay */}
{isAirPlaySupported && (
<button
onClick={onShowAirPlayMenu}
className="btn-icon"
aria-label="AirPlay"
title="AirPlay"
>
<Icons.Airplay size={20} />
</button>
)}
{/* More Menu */}
<DesktopMoreMenu
showMoreMenu={showMoreMenu}
onToggleMoreMenu={onToggleMoreMenu}
onMouseEnter={onMoreMenuMouseEnter}
onMouseLeave={onMoreMenuMouseLeave}
onCopyLink={onCopyLink}
/>
{/* Fullscreen */}
<button
onClick={onToggleFullscreen}
className="btn-icon"
aria-label={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
>
{isFullscreen ? <Icons.Minimize size={20} /> : <Icons.Maximize size={20} />}
</button>
</div>
);
}
+149
View File
@@ -0,0 +1,149 @@
'use client';
import { memo } 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;
vod_name: string;
vod_pic?: string;
vod_remarks?: string;
vod_year?: string;
type_name?: string;
source: string;
sourceName?: string;
isNew?: boolean;
latency?: number;
}
interface VideoCardProps {
video: Video;
videoUrl: string;
cardId: string;
isActive: boolean;
onCardClick: (e: React.MouseEvent, cardId: string, videoUrl: string) => void;
}
export const VideoCard = memo<VideoCardProps>(({
video,
videoUrl,
cardId,
isActive,
onCardClick
}) => {
return (
<div
style={{
contain: 'layout style paint',
contentVisibility: 'auto',
}}
>
<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}
>
<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}
blur={false}
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"
onError={(e) => {
const target = e.currentTarget as HTMLImageElement;
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 */}
<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>
{/* Badge Container */}
<div className="absolute top-2 left-2 right-2 z-10 flex items-center justify-between gap-1">
{video.sourceName && (
<Badge variant="primary" className="bg-[var(--accent-color)] flex-shrink-0 max-w-[50%] truncate">
{video.sourceName}
</Badge>
)}
{video.latency !== undefined && (
<LatencyBadge latency={video.latency} className="flex-shrink-0" />
)}
</div>
{/* Overlay */}
<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',
}}
>
<div className="absolute bottom-0 left-0 right-0 p-3">
{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 */}
<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>
);
});
VideoCard.displayName = 'VideoCard';
+3 -148
View File
@@ -1,12 +1,7 @@
'use client';
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';
import { useState, useRef, useCallback, useMemo, memo } from 'react';
import { VideoCard } from './VideoCard';
interface Video {
vod_id: string;
@@ -18,7 +13,7 @@ interface Video {
source: string;
sourceName?: string;
isNew?: boolean;
latency?: number; // Response time in milliseconds
latency?: number;
}
interface VideoGridProps {
@@ -26,146 +21,6 @@ interface VideoGridProps {
className?: string;
}
// 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 (
<div
style={{
// CSS containment for better performance
contain: 'layout style paint',
contentVisibility: 'auto',
}}
>
<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}
>
<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
blur={false}
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} // 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
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>
{/* 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 */}
{video.sourceName && (
<Badge variant="primary" className="bg-[var(--accent-color)] flex-shrink-0 max-w-[50%] truncate">
{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={{
willChange: 'opacity',
}}
>
<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]">
{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>
);
});
VideoCard.displayName = 'VideoCard';
export const VideoGrid = memo(function VideoGrid({ videos, className = '' }: VideoGridProps) {
const [activeCardId, setActiveCardId] = useState<string | null>(null);
const [visibleCount, setVisibleCount] = useState(24);
+3 -76
View File
@@ -1,68 +1,17 @@
/**
* API Client for fetching video data from multiple sources
* Handles parallel requests, timeouts, retries, and data normalization
* Handles parallel requests and data normalization
*/
import type {
VideoSource,
VideoItem,
VideoDetail,
Episode,
ApiSearchResponse,
ApiDetailResponse,
} from '@/lib/types';
const REQUEST_TIMEOUT = 15000;
const MAX_RETRIES = 3;
const RETRY_DELAY = 1000;
/**
* Fetch with timeout support
*/
async function fetchWithTimeout(
url: string,
options: RequestInit = {},
timeout: number = REQUEST_TIMEOUT
): Promise<Response> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(url, {
...options,
signal: controller.signal,
});
clearTimeout(timeoutId);
return response;
} catch (error) {
clearTimeout(timeoutId);
throw error;
}
}
/**
* Retry logic wrapper
*/
async function withRetry<T>(
fn: () => Promise<T>,
retries: number = MAX_RETRIES
): Promise<T> {
let lastError: Error | null = null;
for (let i = 0; i <= retries; i++) {
try {
return await fn();
} catch (error) {
lastError = error as Error;
if (i < retries) {
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY * (i + 1)));
}
}
}
throw lastError;
}
import { fetchWithTimeout, withRetry } from './http-utils';
import { parseEpisodes } from './parsers';
/**
* Search videos from a single source
@@ -146,29 +95,7 @@ export async function searchVideos(
return Promise.all(searchPromises);
}
/**
* Parse episode URL string into structured array
*/
function parseEpisodes(playUrl: string): Episode[] {
if (!playUrl) return [];
try {
// Format: "Episode1$url1#Episode2$url2#..."
const episodes = playUrl.split('#').filter(Boolean);
return episodes.map((episode, index) => {
const [name, url] = episode.split('$');
return {
name: name || `Episode ${index + 1}`,
url: url || '',
index,
};
});
} catch (error) {
console.error('Failed to parse episodes:', error);
return [];
}
}
/**
* Get video detail from a single source
+56
View File
@@ -0,0 +1,56 @@
/**
* HTTP Utilities for API calls
* Handles timeouts and retries
*/
const REQUEST_TIMEOUT = 15000;
const MAX_RETRIES = 3;
const RETRY_DELAY = 1000;
/**
* Fetch with timeout support
*/
export async function fetchWithTimeout(
url: string,
options: RequestInit = {},
timeout: number = REQUEST_TIMEOUT
): Promise<Response> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(url, {
...options,
signal: controller.signal,
});
clearTimeout(timeoutId);
return response;
} catch (error) {
clearTimeout(timeoutId);
throw error;
}
}
/**
* Retry logic wrapper
*/
export async function withRetry<T>(
fn: () => Promise<T>,
retries: number = MAX_RETRIES
): Promise<T> {
let lastError: Error | null = null;
for (let i = 0; i <= retries; i++) {
try {
return await fn();
} catch (error) {
lastError = error as Error;
if (i < retries) {
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY * (i + 1)));
}
}
}
throw lastError;
}
+29
View File
@@ -0,0 +1,29 @@
/**
* API Response Parsers
*/
import type { Episode } from '@/lib/types';
/**
* Parse episode URL string into structured array
*/
export function parseEpisodes(playUrl: string): Episode[] {
if (!playUrl) return [];
try {
// Format: "Episode1$url1#Episode2$url2#..."
const episodes = playUrl.split('#').filter(Boolean);
return episodes.map((episode, index) => {
const [name, url] = episode.split('$');
return {
name: name || `Episode ${index + 1}`,
url: url || '',
index,
};
});
} catch (error) {
console.error('Failed to parse episodes:', error);
return [];
}
}
+3 -38
View File
@@ -4,6 +4,7 @@ import { useState, useRef, useCallback } from 'react';
import { getSourceName, SOURCE_IDS } from '@/lib/utils/source-names';
import { calculateRelevanceScore } from '@/lib/utils/search';
import { sortVideos } from '@/lib/utils/sort';
import { binaryInsertVideos } from '@/lib/utils/sorted-insert';
import type { SortOption } from '@/lib/store/settings-store';
interface Video {
@@ -119,44 +120,8 @@ export function useParallelSearch(
}));
// Optimized: Insert new videos in sorted position instead of re-sorting entire array
setResults((prev) => {
if (prev.length === 0) return newVideos;
// Binary insert for better performance with combined sorting
const combined = [...prev];
for (const video of newVideos) {
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);
const midRelevance = combined[mid].relevanceScore || 0;
const midLatency = combined[mid].latency || 99999;
// Compare by relevance first
if (midRelevance > relevanceScore) {
left = mid + 1;
} 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);
}
return combined;
});
// Optimized: Insert new videos in sorted position
setResults((prev) => binaryInsertVideos(prev, newVideos));
// Update source stats
if (!sourcesMap.has(data.source)) {
+2 -43
View File
@@ -3,6 +3,7 @@
*/
import type { VideoSource } from '@/lib/types';
import { DEFAULT_SOURCES } from '@/lib/api/default-sources';
export type SortOption =
| 'default'
@@ -36,49 +37,7 @@ export const sortOptions: Record<SortOption, string> = {
'name-desc': '按名称(Z-A',
};
export const getDefaultSources = (): VideoSource[] => {
// Import from video-sources to get all 38 default sources
return [
{ id: 'feifan', name: '非凡资源', baseUrl: 'http://ffzy5.tv/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 1 },
{ id: 'wolong', name: '卧龙资源', baseUrl: 'https://wolongzyw.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 2 },
{ id: 'zuida', name: '最大资源', baseUrl: 'https://api.zuidapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 3 },
{ id: 'baiduyun', name: '百度云资源', baseUrl: 'https://api.apibdzy.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 4 },
{ id: 'baofeng', name: '暴风资源', baseUrl: 'https://bfzyapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 5 },
{ id: 'jisu', name: '极速资源', baseUrl: 'https://jszyapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 6 },
{ id: 'tianya', name: '天涯资源', baseUrl: 'https://tyyszy.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 7 },
{ id: 'wujin', name: '无尽资源', baseUrl: 'https://api.wujinapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 8 },
{ id: 'modu', name: '魔都资源', baseUrl: 'https://www.mdzyapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 9 },
{ id: 'sanliuling', name: '360资源', baseUrl: 'https://360zy.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 10 },
{ id: 'dytt', name: '电影天堂', baseUrl: 'http://caiji.dyttzyapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 11 },
{ id: 'ruyi', name: '如意资源', baseUrl: 'https://cj.rycjapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 12 },
{ id: 'wangwang', name: '旺旺资源', baseUrl: 'https://wwzy.tv/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 13 },
{ id: 'hongniu', name: '红牛资源', baseUrl: 'https://www.hongniuzy2.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 14 },
{ id: 'guangsu', name: '光速资源', baseUrl: 'https://api.guangsuapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 15 },
{ id: 'ikun', name: 'iKun资源', baseUrl: 'https://ikunzyapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 16 },
{ id: 'youku', name: '优酷资源', baseUrl: 'https://api.ukuapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 17 },
{ id: 'huya', name: '虎牙资源', baseUrl: 'https://www.huyaapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 18 },
{ id: 'xinlang', name: '新浪资源', baseUrl: 'http://api.xinlangapi.com/xinlangapi.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 19 },
{ id: 'lezi', name: '乐子资源', baseUrl: 'https://cj.lziapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 20 },
{ id: 'haihua', name: '海豚资源', baseUrl: 'https://hhzyapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 21 },
{ id: 'jiangyu', name: '鲸鱼资源', baseUrl: 'https://jyzyapi.com/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 22 },
{ id: 'yilingba', name: '1080资源', baseUrl: 'https://api.1080zyku.com/inc/api_mac10.php', searchPath: '', detailPath: '', enabled: true, priority: 23 },
{ id: 'aidan', name: '爱蛋资源', baseUrl: 'https://lovedan.net/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 24 },
{ id: 'leba', name: '乐播资源', baseUrl: 'https://lbapi9.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 25 },
{ id: 'moduzy', name: '魔都影视', baseUrl: 'https://www.moduzy.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 26 },
{ id: 'feifanapi', name: '非凡API', baseUrl: 'https://api.ffzyapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 27 },
{ id: 'feifancj', name: '非凡采集', baseUrl: 'http://cj.ffzyapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 28 },
{ id: 'feifancj2', name: '非凡采集HTTPS', baseUrl: 'https://cj.ffzyapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 29 },
{ id: 'feifan1', name: '非凡线路1', baseUrl: 'http://ffzy1.tv/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 30 },
{ id: 'wolong2', name: '卧龙采集', baseUrl: 'https://collect.wolongzyw.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 31 },
{ id: 'baofeng2', name: '暴风APP', baseUrl: 'https://app.bfzyapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 32 },
{ id: 'wujin2', name: '无尽ME', baseUrl: 'https://api.wujinapi.me/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 33 },
{ id: 'tianyazy', name: '天涯海角', baseUrl: 'https://tyyszyapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 34 },
{ id: 'guangsu2', name: '光速HTTP', baseUrl: 'http://api.guangsuapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 35 },
{ id: 'xinlang2', name: '新浪HTTPS', baseUrl: 'https://api.xinlangapi.com/xinlangapi.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 36 },
{ id: 'yilingba2', name: '1080JSON', baseUrl: 'https://api.1080zyku.com/inc/apijson.php', searchPath: '', detailPath: '', enabled: true, priority: 37 },
{ id: 'lezi2', name: '乐子HTTP', baseUrl: 'http://cj.lziapi.com/api.php/provide/vod', searchPath: '', detailPath: '', enabled: true, priority: 38 },
];
};
export const getDefaultSources = (): VideoSource[] => DEFAULT_SOURCES;
export const settingsStore = {
getSettings(): AppSettings {
+50
View File
@@ -0,0 +1,50 @@
/**
* Binary insert utility for sorted arrays
*/
interface Video {
relevanceScore?: number;
latency?: number;
}
/**
* Insert videos into sorted array using binary search
* Sorts by: 1) relevance score (DESC), 2) latency (ASC)
*/
export function binaryInsertVideos<T extends Video>(existing: T[], newVideos: T[]): T[] {
if (existing.length === 0) return newVideos;
const combined = [...existing];
for (const video of newVideos) {
const relevanceScore = video.relevanceScore || 0;
const latency = video.latency || 99999;
// Find insert position using binary search
let left = 0;
let right = combined.length;
while (left < right) {
const mid = Math.floor((left + right) / 2);
const midRelevance = combined[mid].relevanceScore || 0;
const midLatency = combined[mid].latency || 99999;
if (midRelevance > relevanceScore) {
left = mid + 1;
} else if (midRelevance < relevanceScore) {
right = mid;
} else {
// Same relevance, compare by latency
if (midLatency < latency) {
left = mid + 1;
} else {
right = mid;
}
}
}
combined.splice(left, 0, video);
}
return combined;
}