diff --git a/components/player/desktop/DesktopControls.tsx b/components/player/desktop/DesktopControls.tsx index 2339f85..f5d0f58 100644 --- a/components/player/desktop/DesktopControls.tsx +++ b/components/player/desktop/DesktopControls.tsx @@ -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 (
- {/* Left Controls */} -
- {/* Play/Pause */} - - - {/* Skip Backward 10s */} - - - {/* Skip Forward 10s */} - - - {/* Volume */} - - - {/* Time */} - - {formatTime(currentTime)} / {formatTime(duration)} - -
- - {/* Right Controls */} -
- {/* Playback Speed */} - - - {/* Picture-in-Picture */} - {isPiPSupported && ( - - )} - - {/* AirPlay */} - {isAirPlaySupported && ( - - )} - - {/* More Menu */} - - - {/* Fullscreen */} - -
+ +
diff --git a/components/player/desktop/DesktopLeftControls.tsx b/components/player/desktop/DesktopLeftControls.tsx new file mode 100644 index 0000000..674a7ad --- /dev/null +++ b/components/player/desktop/DesktopLeftControls.tsx @@ -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; + onTogglePlay: () => void; + onSkipForward: () => void; + onSkipBackward: () => void; + onToggleMute: () => void; + onVolumeChange: (e: React.MouseEvent) => void; + onVolumeMouseDown: (e: React.MouseEvent) => void; + formatTime: (seconds: number) => string; +} + +export function DesktopLeftControls({ + isPlaying, + currentTime, + duration, + volume, + isMuted, + showVolumeBar, + volumeBarRef, + onTogglePlay, + onSkipForward, + onSkipBackward, + onToggleMute, + onVolumeChange, + onVolumeMouseDown, + formatTime +}: DesktopLeftControlsProps) { + return ( +
+ {/* Play/Pause */} + + + {/* Skip Backward 10s */} + + + {/* Skip Forward 10s */} + + + {/* Volume */} + + + {/* Time */} + + {formatTime(currentTime)} / {formatTime(duration)} + +
+ ); +} diff --git a/components/player/desktop/DesktopRightControls.tsx b/components/player/desktop/DesktopRightControls.tsx new file mode 100644 index 0000000..a2b01b2 --- /dev/null +++ b/components/player/desktop/DesktopRightControls.tsx @@ -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 ( +
+ {/* Playback Speed */} + + + {/* Picture-in-Picture */} + {isPiPSupported && ( + + )} + + {/* AirPlay */} + {isAirPlaySupported && ( + + )} + + {/* More Menu */} + + + {/* Fullscreen */} + +
+ ); +} diff --git a/components/search/VideoCard.tsx b/components/search/VideoCard.tsx new file mode 100644 index 0000000..902a2bb --- /dev/null +++ b/components/search/VideoCard.tsx @@ -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(({ + video, + videoUrl, + cardId, + isActive, + onCardClick +}) => { + return ( +
+ onCardClick(e, cardId, videoUrl)} + role="listitem" + aria-label={`${video.vod_name}${video.vod_remarks ? ` - ${video.vod_remarks}` : ''}`} + prefetch={false} + > + + {/* Poster */} +
+ {video.vod_pic ? ( + {video.vod_name} { + const target = e.currentTarget as HTMLImageElement; + target.style.opacity = '0'; + }} + /> + ) : ( +
+ +
+ )} + + {/* Fallback Icon */} +
+ +
+ + {/* Badge Container */} +
+ {video.sourceName && ( + + {video.sourceName} + + )} + + {video.latency !== undefined && ( + + )} +
+ + {/* Overlay */} +
+
+ {isActive && ( +
+ 再次点击播放 → +
+ )} + {video.type_name && ( + + {video.type_name} + + )} + {video.vod_year && ( +
+ + {video.vod_year} +
+ )} +
+
+
+ + {/* Info */} +
+

+ {video.vod_name} +

+ {video.vod_remarks && ( +

+ {video.vod_remarks} +

+ )} +
+
+ +
+ ); +}); + +VideoCard.displayName = 'VideoCard'; diff --git a/components/search/VideoGrid.tsx b/components/search/VideoGrid.tsx index 0a07ed7..6ef6009 100644 --- a/components/search/VideoGrid.tsx +++ b/components/search/VideoGrid.tsx @@ -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 ( -
- onCardClick(e, cardId, videoUrl)} - role="listitem" - aria-label={`${video.vod_name}${video.vod_remarks ? ` - ${video.vod_remarks}` : ''}`} - prefetch={false} - > - - {/* Poster */} -
- {video.vod_pic ? ( - {video.vod_name} { - // 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'; - }} - /> - ) : ( -
- -
- )} - - {/* Fallback Icon (always rendered behind image, visible if image fails/loads) */} -
- -
- - {/* Badge Container - Top, spans full width with proper spacing */} -
- {/* Source Badge - Left */} - {video.sourceName && ( - - {video.sourceName} - - )} - - {/* Latency Badge - Right */} - {video.latency !== undefined && ( - - )} -
- - {/* Overlay - Show on hover (desktop) or when active (mobile) */} -
-
- {/* Mobile indicator when active */} - {isActive && ( -
- 再次点击播放 → -
- )} - {video.type_name && ( - - {video.type_name} - - )} - {video.vod_year && ( -
- - {video.vod_year} -
- )} -
-
-
- - {/* Info - Fixed height section */} -
-

- {video.vod_name} -

- {video.vod_remarks && ( -

- {video.vod_remarks} -

- )} -
-
- -
- ); -}); - -VideoCard.displayName = 'VideoCard'; - export const VideoGrid = memo(function VideoGrid({ videos, className = '' }: VideoGridProps) { const [activeCardId, setActiveCardId] = useState(null); const [visibleCount, setVisibleCount] = useState(24); diff --git a/lib/api/client.ts b/lib/api/client.ts index 5bae31d..4b85bf0 100644 --- a/lib/api/client.ts +++ b/lib/api/client.ts @@ -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 { - 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( - fn: () => Promise, - retries: number = MAX_RETRIES -): Promise { - 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 diff --git a/lib/api/http-utils.ts b/lib/api/http-utils.ts new file mode 100644 index 0000000..26f3db2 --- /dev/null +++ b/lib/api/http-utils.ts @@ -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 { + 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( + fn: () => Promise, + retries: number = MAX_RETRIES +): Promise { + 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; +} diff --git a/lib/api/parsers.ts b/lib/api/parsers.ts new file mode 100644 index 0000000..3899b45 --- /dev/null +++ b/lib/api/parsers.ts @@ -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 []; + } +} diff --git a/lib/hooks/useParallelSearch.ts b/lib/hooks/useParallelSearch.ts index 6e6be43..511fb68 100644 --- a/lib/hooks/useParallelSearch.ts +++ b/lib/hooks/useParallelSearch.ts @@ -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)) { diff --git a/lib/store/settings-store.ts b/lib/store/settings-store.ts index bac382d..f980c9a 100644 --- a/lib/store/settings-store.ts +++ b/lib/store/settings-store.ts @@ -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 = { '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 { diff --git a/lib/utils/sorted-insert.ts b/lib/utils/sorted-insert.ts new file mode 100644 index 0000000..0831625 --- /dev/null +++ b/lib/utils/sorted-insert.ts @@ -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(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; +}