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
+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;
}