mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-18 10:13:43 +08:00
refactor: remove unused accessibility utilities and hooks
- Deleted accessibility index file and related exports. - Removed `useClickOutside` and `useClickOutsideMultiple` hooks for handling outside clicks. - Eliminated `useSearchStream` hook and its associated logic for managing search state. - Cleaned up `useMediaQuery` by removing predefined breakpoints and related utility hooks. - Removed `source-checker` utility for checking video source availability. - Refactored `client.ts` to simplify error handling and remove unused functions. - Streamlined `video-sources.ts` by removing custom source management functions. - Deleted `search-history-store.ts` and related search history management logic. - Removed unused types and interfaces from `types/index.ts`. - Cleaned up search utilities, focusing on relevance scoring and optimization.
This commit is contained in:
@@ -1,172 +0,0 @@
|
||||
/**
|
||||
* Streaming Search API Route
|
||||
* Returns results progressively as they become available
|
||||
*/
|
||||
|
||||
import { NextRequest } from 'next/server';
|
||||
import { searchVideos } from '@/lib/api/client';
|
||||
import { getSourceById } from '@/lib/api/video-sources';
|
||||
import { checkVideoAvailability } from '@/lib/utils/source-checker';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { query, sources: sourceIds, page = 1 } = body;
|
||||
|
||||
// Validate input
|
||||
if (!query || typeof query !== 'string' || query.trim().length === 0) {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ error: 'Invalid query' })}\n\n`));
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// Get source configurations
|
||||
const sources = sourceIds
|
||||
.map((id: string) => getSourceById(id))
|
||||
.filter((source: any): source is NonNullable<typeof source> => source !== undefined);
|
||||
|
||||
if (sources.length === 0) {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ error: 'No valid sources' })}\n\n`));
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// Send progress: searching sources
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
type: 'progress',
|
||||
stage: 'searching',
|
||||
checkedSources: 0,
|
||||
totalSources: sourceIds.length
|
||||
})}\n\n`));
|
||||
|
||||
// Perform search with progress tracking for each source
|
||||
let checkedSourcesCount = 0;
|
||||
const searchResults = await Promise.all(
|
||||
sources.map(async (source: any) => {
|
||||
try {
|
||||
const result = await searchVideos(query.trim(), [source], page);
|
||||
checkedSourcesCount++;
|
||||
|
||||
// Send progress update after each source completes
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
type: 'progress',
|
||||
stage: 'searching',
|
||||
checkedSources: checkedSourcesCount,
|
||||
totalSources: sourceIds.length
|
||||
})}\n\n`));
|
||||
|
||||
return result[0];
|
||||
} catch (error) {
|
||||
checkedSourcesCount++;
|
||||
|
||||
// Still send progress even on error
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
type: 'progress',
|
||||
stage: 'searching',
|
||||
checkedSources: checkedSourcesCount,
|
||||
totalSources: sourceIds.length
|
||||
})}\n\n`));
|
||||
|
||||
return {
|
||||
results: [],
|
||||
source: source.id,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Get all videos from all sources
|
||||
const allVideos = searchResults.flatMap(r => r.results);
|
||||
|
||||
if (allVideos.length === 0) {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
type: 'complete',
|
||||
totalResults: 0
|
||||
})}\n\n`));
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// Send progress: start checking videos
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
type: 'progress',
|
||||
stage: 'checking',
|
||||
checkedVideos: 0,
|
||||
totalVideos: allVideos.length
|
||||
})}\n\n`));
|
||||
|
||||
const availableVideos: any[] = [];
|
||||
let checkedCount = 0;
|
||||
const concurrency = 10; // Check 10 videos at a time
|
||||
|
||||
// Process videos in batches for immediate feedback
|
||||
for (let i = 0; i < allVideos.length; i += concurrency) {
|
||||
const batch = allVideos.slice(i, i + concurrency);
|
||||
|
||||
const results = await Promise.all(
|
||||
batch.map(async (video) => {
|
||||
const isAvailable = await checkVideoAvailability(video);
|
||||
return isAvailable ? video : null;
|
||||
})
|
||||
);
|
||||
|
||||
// Add available videos
|
||||
const newAvailableVideos = results.filter(v => v !== null);
|
||||
availableVideos.push(...newAvailableVideos);
|
||||
|
||||
checkedCount += batch.length;
|
||||
|
||||
// ALWAYS send update after each batch (even if no new videos)
|
||||
if (newAvailableVideos.length > 0) {
|
||||
// Send new videos immediately
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
type: 'videos',
|
||||
videos: newAvailableVideos,
|
||||
checkedVideos: checkedCount,
|
||||
totalVideos: allVideos.length,
|
||||
availableCount: availableVideos.length
|
||||
})}\n\n`));
|
||||
}
|
||||
|
||||
// Always send progress update
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
type: 'progress',
|
||||
stage: 'checking',
|
||||
checkedVideos: checkedCount,
|
||||
totalVideos: allVideos.length,
|
||||
availableCount: availableVideos.length
|
||||
})}\n\n`));
|
||||
}
|
||||
|
||||
// Send completion
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
type: 'complete',
|
||||
totalResults: availableVideos.length,
|
||||
checkedVideos: allVideos.length,
|
||||
totalVideos: allVideos.length
|
||||
})}\n\n`));
|
||||
|
||||
controller.close();
|
||||
} catch (error) {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
type: 'error',
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
})}\n\n`));
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
/**
|
||||
* Search API Route
|
||||
* Handles video search requests and aggregates results from multiple sources
|
||||
* Now with automatic source availability detection
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { searchVideos } from '@/lib/api/client';
|
||||
import { getEnabledSources, getSourceById } from '@/lib/api/video-sources';
|
||||
import { checkMultipleVideos } from '@/lib/utils/source-checker';
|
||||
import type { SearchRequest, SearchResult } from '@/lib/types';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body: SearchRequest = await request.json();
|
||||
const { query, sources: sourceIds, page = 1 } = body;
|
||||
|
||||
// Validate input
|
||||
if (!query || typeof query !== 'string' || query.trim().length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid or missing query parameter' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!sourceIds || !Array.isArray(sourceIds) || sourceIds.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'At least one source must be specified' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get source configurations
|
||||
const sources = sourceIds
|
||||
.map((id: string) => getSourceById(id))
|
||||
.filter((source): source is NonNullable<typeof source> => source !== undefined);
|
||||
|
||||
if (sources.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No valid sources found' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Perform parallel search across sources
|
||||
const searchResults = await searchVideos(query.trim(), sources, page);
|
||||
|
||||
// Get source name mapping
|
||||
const getSourceName = (sourceId: string): string => {
|
||||
const sourceNames: Record<string, string> = {
|
||||
'dytt': '电影天堂',
|
||||
'ruyi': '如意',
|
||||
'baofeng': '暴风',
|
||||
'tianya': '天涯',
|
||||
'feifan': '非凡影视',
|
||||
'sanliuling': '360',
|
||||
'wolong': '卧龙',
|
||||
'jisu': '极速',
|
||||
'mozhua': '魔爪',
|
||||
'modu': '魔都',
|
||||
'zuida': '最大',
|
||||
'yinghua': '樱花',
|
||||
'baiduyun': '百度云',
|
||||
'wujin': '无尽',
|
||||
'wangwang': '旺旺',
|
||||
'ikun': 'iKun',
|
||||
};
|
||||
return sourceNames[sourceId] || sourceId;
|
||||
};
|
||||
|
||||
// Get all videos from all sources
|
||||
const allVideos = searchResults.flatMap(r => r.results);
|
||||
|
||||
// Check each video individually with improved accuracy (reduced concurrency)
|
||||
const availableVideos = await checkMultipleVideos(allVideos, 8);
|
||||
|
||||
// Group available videos by source
|
||||
const videosBySource = new Map<string, any[]>();
|
||||
for (const video of availableVideos) {
|
||||
const sourceId = video.source;
|
||||
if (!videosBySource.has(sourceId)) {
|
||||
videosBySource.set(sourceId, []);
|
||||
}
|
||||
videosBySource.get(sourceId)!.push(video);
|
||||
}
|
||||
|
||||
// Build response with actual video counts per source
|
||||
const response: SearchResult[] = Array.from(videosBySource.entries()).map(([sourceId, videos]) => ({
|
||||
results: videos,
|
||||
source: sourceId,
|
||||
responseTime: searchResults.find(sr => sr.source === sourceId)?.responseTime,
|
||||
}));
|
||||
|
||||
// Calculate source statistics
|
||||
const sourceStats = sourceIds.map(sourceId => {
|
||||
const count = videosBySource.get(sourceId)?.length || 0;
|
||||
return {
|
||||
sourceId,
|
||||
sourceName: getSourceName(sourceId),
|
||||
count,
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
query: query.trim(),
|
||||
page,
|
||||
sources: response,
|
||||
totalResults: availableVideos.length,
|
||||
sourceStats, // Include real counts per source
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Search API error:', error);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Internal server error',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Support GET method for simple queries
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const query = searchParams.get('q') || searchParams.get('query');
|
||||
const sourcesParam = searchParams.get('sources');
|
||||
const page = parseInt(searchParams.get('page') || '1', 10);
|
||||
|
||||
if (!query) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing query parameter' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Use all enabled sources if not specified
|
||||
const sourceIds = sourcesParam
|
||||
? sourcesParam.split(',')
|
||||
: getEnabledSources().map(s => s.id);
|
||||
|
||||
// Get source configurations
|
||||
const sources = sourceIds
|
||||
.map((id: string) => getSourceById(id))
|
||||
.filter((source): source is NonNullable<typeof source> => source !== undefined);
|
||||
|
||||
if (sources.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No valid sources found' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Perform search
|
||||
const searchResults = await searchVideos(query.trim(), sources, page);
|
||||
|
||||
// Get source name mapping
|
||||
const getSourceName = (sourceId: string): string => {
|
||||
const sourceNames: Record<string, string> = {
|
||||
'dytt': '电影天堂',
|
||||
'ruyi': '如意',
|
||||
'baofeng': '暴风',
|
||||
'tianya': '天涯',
|
||||
'feifan': '非凡影视',
|
||||
'sanliuling': '360',
|
||||
'wolong': '卧龙',
|
||||
'jisu': '极速',
|
||||
'mozhua': '魔爪',
|
||||
'modu': '魔都',
|
||||
'zuida': '最大',
|
||||
'yinghua': '樱花',
|
||||
'baiduyun': '百度云',
|
||||
'wujin': '无尽',
|
||||
'wangwang': '旺旺',
|
||||
'ikun': 'iKun',
|
||||
};
|
||||
return sourceNames[sourceId] || sourceId;
|
||||
};
|
||||
|
||||
// Get all videos from all sources
|
||||
const allVideos = searchResults.flatMap(r => r.results);
|
||||
|
||||
// Check each video individually with improved accuracy (reduced concurrency)
|
||||
const availableVideos = await checkMultipleVideos(allVideos, 8);
|
||||
|
||||
// Group available videos by source
|
||||
const videosBySource = new Map<string, any[]>();
|
||||
for (const video of availableVideos) {
|
||||
const sourceId = video.source;
|
||||
if (!videosBySource.has(sourceId)) {
|
||||
videosBySource.set(sourceId, []);
|
||||
}
|
||||
videosBySource.get(sourceId)!.push(video);
|
||||
}
|
||||
|
||||
// Build response with actual video counts per source
|
||||
const response: SearchResult[] = Array.from(videosBySource.entries()).map(([sourceId, videos]) => ({
|
||||
results: videos,
|
||||
source: sourceId,
|
||||
responseTime: searchResults.find(sr => sr.source === sourceId)?.responseTime,
|
||||
}));
|
||||
|
||||
// Calculate source statistics
|
||||
const sourceStats = sourceIds.map(sourceId => {
|
||||
const count = videosBySource.get(sourceId)?.length || 0;
|
||||
return {
|
||||
sourceId,
|
||||
sourceName: getSourceName(sourceId),
|
||||
count,
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
query: query.trim(),
|
||||
page,
|
||||
sources: response,
|
||||
totalResults: availableVideos.length,
|
||||
sourceStats, // Include real counts per source
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Search API error:', error);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Internal server error',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import Image from 'next/image';
|
||||
import { ThemeSwitcher } from '@/components/ThemeSwitcher';
|
||||
import { SearchForm } from '@/components/search/SearchForm';
|
||||
import { VideoGrid } from '@/components/search/VideoGrid';
|
||||
import { EmptyState } from '@/components/search/EmptyState';
|
||||
import { NoResults } from '@/components/search/NoResults';
|
||||
import { ResultsHeader } from '@/components/search/ResultsHeader';
|
||||
import { TypeBadges } from '@/components/search/TypeBadges';
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
|
||||
export function EmptyState() {
|
||||
return (
|
||||
<div className="text-center py-20 animate-fade-in">
|
||||
<div className="mb-8">
|
||||
<div
|
||||
className="inline-flex items-center justify-center w-32 h-32 bg-[var(--glass-bg)] backdrop-blur-xl border border-[var(--glass-border)] mb-6 rounded-[var(--radius-full)]"
|
||||
>
|
||||
<Icons.Film size={64} className="text-[var(--text-color-secondary)]" />
|
||||
</div>
|
||||
<h3 className="text-3xl font-bold text-[var(--text-color)] mb-4">
|
||||
开始探索精彩内容
|
||||
</h3>
|
||||
<p className="text-lg text-[var(--text-color-secondary)] max-w-2xl mx-auto mb-8">
|
||||
在上方搜索框输入关键词,从 16 个视频源聚合搜索海量影视资源
|
||||
</p>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-6 max-w-4xl mx-auto mt-12">
|
||||
<Card hover={false} className="text-center p-6">
|
||||
<div className="flex items-center justify-center mb-4">
|
||||
<Icons.Zap size={48} className="text-[var(--accent-color)]" />
|
||||
</div>
|
||||
<h4 className="font-semibold text-[var(--text-color)] mb-2">极速搜索</h4>
|
||||
<p className="text-sm text-[var(--text-color-secondary)]">多源并行,秒级响应</p>
|
||||
</Card>
|
||||
<Card hover={false} className="text-center p-6">
|
||||
<div className="flex items-center justify-center mb-4">
|
||||
<Icons.Target size={48} className="text-[var(--accent-color)]" />
|
||||
</div>
|
||||
<h4 className="font-semibold text-[var(--text-color)] mb-2">精准匹配</h4>
|
||||
<p className="text-sm text-[var(--text-color-secondary)]">智能算法,结果精准</p>
|
||||
</Card>
|
||||
<Card hover={false} className="text-center p-6">
|
||||
<div className="flex items-center justify-center mb-4">
|
||||
<Icons.Sparkles size={48} className="text-[var(--accent-color)]" />
|
||||
</div>
|
||||
<h4 className="font-semibold text-[var(--text-color)] mb-2">极致体验</h4>
|
||||
<p className="text-sm text-[var(--text-color-secondary)]">流畅播放,完美适配</p>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,8 +5,6 @@ import { Input } from '@/components/ui/Input';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { SearchLoadingAnimation } from '@/components/SearchLoadingAnimation';
|
||||
import { SearchHistoryDropdown } from '@/components/search/SearchHistoryDropdown';
|
||||
import { useSearchHistoryStore } from '@/lib/store/search-history-store';
|
||||
|
||||
interface SearchFormProps {
|
||||
onSearch: (query: string) => void;
|
||||
@@ -28,10 +26,7 @@ export function SearchForm({
|
||||
totalSources = 16,
|
||||
}: SearchFormProps) {
|
||||
const [query, setQuery] = useState(initialQuery);
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
const [inputRect, setInputRect] = useState<DOMRect | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const { addSearchHistory } = useSearchHistoryStore();
|
||||
|
||||
// Update query when initialQuery changes
|
||||
useEffect(() => {
|
||||
@@ -41,9 +36,7 @@ export function SearchForm({
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (query.trim() && !isLoading) {
|
||||
addSearchHistory(query.trim());
|
||||
onSearch(query);
|
||||
setShowHistory(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -52,20 +45,6 @@ export function SearchForm({
|
||||
if (onClear) {
|
||||
onClear();
|
||||
}
|
||||
setShowHistory(false);
|
||||
};
|
||||
|
||||
const handleInputFocus = () => {
|
||||
if (inputRef.current) {
|
||||
setInputRect(inputRef.current.getBoundingClientRect());
|
||||
setShowHistory(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleHistorySelect = (selectedQuery: string) => {
|
||||
setQuery(selectedQuery);
|
||||
setShowHistory(false);
|
||||
onSearch(selectedQuery);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -76,13 +55,8 @@ export function SearchForm({
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onFocus={handleInputFocus}
|
||||
placeholder="搜索电影、电视剧、综艺..."
|
||||
className="text-base sm:text-lg pr-24 md:pr-32 truncate"
|
||||
role="combobox"
|
||||
aria-expanded={showHistory && !isLoading}
|
||||
aria-controls="search-history-listbox"
|
||||
aria-autocomplete="list"
|
||||
aria-label="搜索视频内容"
|
||||
/>
|
||||
{query && (
|
||||
@@ -107,14 +81,6 @@ export function SearchForm({
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Search History Dropdown */}
|
||||
<SearchHistoryDropdown
|
||||
isVisible={showHistory && !isLoading}
|
||||
onSelect={handleHistorySelect}
|
||||
onClose={() => setShowHistory(false)}
|
||||
inputRect={inputRect}
|
||||
/>
|
||||
|
||||
{/* Loading Animation */}
|
||||
{isLoading && (
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
/**
|
||||
* Search History Dropdown Component
|
||||
* 搜索历史下拉组件
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import { useSearchHistoryStore } from '@/lib/store/search-history-store';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { useKeyboardNavigation } from '@/lib/hooks/useKeyboardNavigation';
|
||||
|
||||
interface SearchHistoryDropdownProps {
|
||||
isVisible: boolean;
|
||||
onSelect: (query: string) => void;
|
||||
onClose: () => void;
|
||||
inputRect: DOMRect | null;
|
||||
}
|
||||
|
||||
export function SearchHistoryDropdown({
|
||||
isVisible,
|
||||
onSelect,
|
||||
onClose,
|
||||
inputRect,
|
||||
}: SearchHistoryDropdownProps) {
|
||||
const { searchHistory, removeSearchHistory, clearSearchHistory } = useSearchHistoryStore();
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [focusedIndex, setFocusedIndex] = useState(-1);
|
||||
const itemRefs = useRef<(HTMLAnchorElement | null)[]>([]);
|
||||
|
||||
// Keyboard navigation
|
||||
useKeyboardNavigation({
|
||||
enabled: isVisible,
|
||||
containerRef: dropdownRef,
|
||||
currentIndex: focusedIndex,
|
||||
itemCount: searchHistory.length,
|
||||
orientation: 'vertical',
|
||||
onNavigate: useCallback((index: number) => {
|
||||
setFocusedIndex(index);
|
||||
itemRefs.current[index]?.focus();
|
||||
}, []),
|
||||
onSelect: useCallback((index: number) => {
|
||||
if (searchHistory[index]) {
|
||||
onSelect(searchHistory[index].query);
|
||||
}
|
||||
}, [searchHistory, onSelect]),
|
||||
onEscape: useCallback(() => {
|
||||
onClose();
|
||||
}, [onClose]),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (isVisible) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
// Reset focus when dropdown opens
|
||||
setFocusedIndex(-1);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isVisible, onClose]);
|
||||
|
||||
if (!isVisible || searchHistory.length === 0) return null;
|
||||
|
||||
const style = inputRect
|
||||
? {
|
||||
position: 'fixed' as const,
|
||||
top: `${inputRect.bottom + 8}px`,
|
||||
left: `${inputRect.left}px`,
|
||||
width: `${inputRect.width}px`,
|
||||
}
|
||||
: {};
|
||||
|
||||
const handleItemClick = (query: string, event: React.MouseEvent) => {
|
||||
// Check if middle mouse button (opens in new tab)
|
||||
if (event.button === 1 || event.ctrlKey || event.metaKey) {
|
||||
event.preventDefault();
|
||||
window.open(`/?q=${encodeURIComponent(query)}`, '_blank');
|
||||
return;
|
||||
}
|
||||
onSelect(query);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
id="search-history-listbox"
|
||||
role="listbox"
|
||||
aria-label="搜索历史建议"
|
||||
style={style}
|
||||
className="z-[9999] bg-[var(--glass-bg)] backdrop-blur-[25px] saturate-[180%] rounded-[var(--radius-2xl)] shadow-[var(--shadow-md)] border border-[var(--glass-border)] p-2 opacity-0 animate-[slideIn_0.2s_ease-out_forwards]"
|
||||
>
|
||||
<div className="flex items-center justify-between px-3 py-2 mb-1">
|
||||
<span className="text-sm font-medium text-[var(--text-color-secondary)]">
|
||||
搜索历史
|
||||
</span>
|
||||
<button
|
||||
onClick={clearSearchHistory}
|
||||
className="text-xs text-[var(--text-color-secondary)] hover:text-[var(--accent-color)] transition-colors"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[300px] overflow-y-auto space-y-1">
|
||||
{searchHistory.map((item, index) => (
|
||||
<div
|
||||
key={item.timestamp}
|
||||
role="option"
|
||||
aria-selected={focusedIndex === index}
|
||||
className={`group flex items-center gap-3 px-3 py-2.5 rounded-[var(--radius-2xl)] hover:bg-[color-mix(in_srgb,var(--accent-color)_15%,transparent)] transition-all cursor-pointer ${
|
||||
focusedIndex === index ? 'bg-[color-mix(in_srgb,var(--accent-color)_15%,transparent)] ring-2 ring-[var(--accent-color)] ring-inset' : ''
|
||||
}`}
|
||||
>
|
||||
<Icons.Clock
|
||||
size={16}
|
||||
className="text-[var(--text-color-secondary)] flex-shrink-0"
|
||||
/>
|
||||
<a
|
||||
ref={(el) => { itemRefs.current[index] = el; }}
|
||||
href={`/?q=${encodeURIComponent(item.query)}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleItemClick(item.query, e as any);
|
||||
}}
|
||||
onAuxClick={(e) => handleItemClick(item.query, e as any)}
|
||||
onFocus={() => setFocusedIndex(index)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onSelect(item.query);
|
||||
}
|
||||
}}
|
||||
tabIndex={0}
|
||||
className="flex-1 text-sm text-[var(--text-color)] hover:text-[var(--accent-color)] transition-colors truncate focus:outline-none"
|
||||
title={item.query}
|
||||
>
|
||||
{item.query}
|
||||
</a>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeSearchHistory(item.query);
|
||||
}}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-[var(--glass-bg)] rounded-full"
|
||||
aria-label="删除"
|
||||
>
|
||||
<Icons.X size={14} className="text-[var(--text-color-secondary)]" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
* Get all focusable elements within a container
|
||||
* 获取容器内所有可聚焦的元素
|
||||
*/
|
||||
export function getFocusableElements(container: HTMLElement): HTMLElement[] {
|
||||
function getFocusableElements(container: HTMLElement): HTMLElement[] {
|
||||
const selector = [
|
||||
'a[href]',
|
||||
'button:not([disabled])',
|
||||
@@ -77,37 +77,3 @@ export function trapFocus(container: HTMLElement): () => void {
|
||||
container.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore focus to a previously focused element
|
||||
* 恢复焦点到之前聚焦的元素
|
||||
*
|
||||
* @param element The element to restore focus to
|
||||
*/
|
||||
export function restoreFocus(element: HTMLElement | null): void {
|
||||
if (!element) return;
|
||||
|
||||
// Use requestAnimationFrame to ensure the element is ready
|
||||
requestAnimationFrame(() => {
|
||||
if (element && typeof element.focus === 'function') {
|
||||
element.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save and restore focus for a component lifecycle
|
||||
* 保存并恢复组件生命周期的焦点
|
||||
*
|
||||
* Usage:
|
||||
* const focusManager = saveFocus();
|
||||
* // ... do something that changes focus
|
||||
* focusManager.restore();
|
||||
*/
|
||||
export function saveFocus() {
|
||||
const previouslyFocused = document.activeElement as HTMLElement | null;
|
||||
|
||||
return {
|
||||
restore: () => restoreFocus(previouslyFocused),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
/**
|
||||
* Focus Trap Utility
|
||||
* Traps keyboard focus within a specified container for accessibility
|
||||
*/
|
||||
|
||||
const FOCUSABLE_ELEMENTS = [
|
||||
'a[href]',
|
||||
'button:not([disabled])',
|
||||
'textarea:not([disabled])',
|
||||
'input:not([disabled])',
|
||||
'select:not([disabled])',
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
].join(',');
|
||||
|
||||
export function getFocusableElements(container: HTMLElement): HTMLElement[] {
|
||||
return Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_ELEMENTS)).filter(
|
||||
(el) => !el.hasAttribute('disabled') && el.offsetParent !== null
|
||||
);
|
||||
}
|
||||
|
||||
export function trapFocus(container: HTMLElement): () => void {
|
||||
const focusableElements = getFocusableElements(container);
|
||||
|
||||
if (focusableElements.length === 0) return () => {};
|
||||
|
||||
const firstElement = focusableElements[0];
|
||||
const lastElement = focusableElements[focusableElements.length - 1];
|
||||
|
||||
// Store the element that had focus before the trap
|
||||
const previouslyFocusedElement = document.activeElement as HTMLElement;
|
||||
|
||||
// Focus the first element
|
||||
firstElement.focus();
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Tab') return;
|
||||
|
||||
if (e.shiftKey) {
|
||||
// Shift + Tab
|
||||
if (document.activeElement === firstElement) {
|
||||
e.preventDefault();
|
||||
lastElement.focus();
|
||||
}
|
||||
} else {
|
||||
// Tab
|
||||
if (document.activeElement === lastElement) {
|
||||
e.preventDefault();
|
||||
firstElement.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
container.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
container.removeEventListener('keydown', handleKeyDown);
|
||||
// Restore focus to the previously focused element
|
||||
if (previouslyFocusedElement && typeof previouslyFocusedElement.focus === 'function') {
|
||||
previouslyFocusedElement.focus();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function restoreFocus(element: HTMLElement | null) {
|
||||
if (element && typeof element.focus === 'function') {
|
||||
element.focus();
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
/**
|
||||
* Accessibility Utilities Index
|
||||
* 可访问性工具库索引 - Centralized exports for all accessibility utilities
|
||||
*/
|
||||
|
||||
// Focus Management
|
||||
export {
|
||||
trapFocus,
|
||||
} from './focus-management';
|
||||
+12
-76
@@ -10,7 +10,6 @@ import type {
|
||||
Episode,
|
||||
ApiSearchResponse,
|
||||
ApiDetailResponse,
|
||||
ApiError,
|
||||
} from '@/lib/types';
|
||||
|
||||
const REQUEST_TIMEOUT = 15000;
|
||||
@@ -115,11 +114,12 @@ async function searchVideosBySource(
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Search failed for source ${source.name}:`, error);
|
||||
throw createApiError(
|
||||
'SEARCH_FAILED',
|
||||
`Failed to search from ${source.name}`,
|
||||
source.id
|
||||
);
|
||||
throw {
|
||||
code: 'SEARCH_FAILED',
|
||||
message: `Failed to search from ${source.name}`,
|
||||
source: source.id,
|
||||
retryable: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,24 +170,6 @@ function parseEpisodes(playUrl: string): Episode[] {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract M3U8 URLs from various formats
|
||||
*/
|
||||
function extractM3U8Urls(playUrl: string): string[] {
|
||||
const urls: string[] = [];
|
||||
|
||||
// Split by common delimiters
|
||||
const parts = playUrl.split(/[#$]/);
|
||||
|
||||
for (const part of parts) {
|
||||
if (part.includes('.m3u8') || part.startsWith('http')) {
|
||||
urls.push(part.trim());
|
||||
}
|
||||
}
|
||||
|
||||
return urls;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get video detail from a single source
|
||||
*/
|
||||
@@ -259,11 +241,12 @@ export async function getVideoDetail(
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Detail fetch failed for source ${source.name}:`, error);
|
||||
throw createApiError(
|
||||
'DETAIL_FAILED',
|
||||
`Failed to fetch video detail from ${source.name}`,
|
||||
source.id
|
||||
);
|
||||
throw {
|
||||
code: 'DETAIL_FAILED',
|
||||
message: `Failed to fetch video detail from ${source.name}`,
|
||||
source: source.id,
|
||||
retryable: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,50 +267,3 @@ export async function getVideoDetailCustom(
|
||||
|
||||
return getVideoDetail(id, customSource);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test if a video URL is accessible
|
||||
*/
|
||||
export async function testVideoUrl(url: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetchWithTimeout(url, { method: 'HEAD' }, 5000);
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create standardized API error
|
||||
*/
|
||||
function createApiError(
|
||||
code: string,
|
||||
message: string,
|
||||
source?: string
|
||||
): ApiError {
|
||||
return {
|
||||
code,
|
||||
message,
|
||||
source,
|
||||
retryable: code === 'TIMEOUT' || code === 'NETWORK_ERROR',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize video data across different API formats
|
||||
*/
|
||||
export function normalizeVideoData(data: any, sourceId: string): VideoItem {
|
||||
return {
|
||||
vod_id: data.vod_id || data.id,
|
||||
vod_name: data.vod_name || data.name || data.title,
|
||||
vod_pic: data.vod_pic || data.pic || data.poster || data.image,
|
||||
type_name: data.type_name || data.type || data.category,
|
||||
vod_remarks: data.vod_remarks || data.remarks || data.note,
|
||||
vod_year: data.vod_year || data.year,
|
||||
vod_area: data.vod_area || data.area || data.region,
|
||||
vod_actor: data.vod_actor || data.actor,
|
||||
vod_director: data.vod_director || data.director,
|
||||
vod_content: data.vod_content || data.content || data.description,
|
||||
source: sourceId,
|
||||
};
|
||||
}
|
||||
|
||||
+2
-209
@@ -3,10 +3,7 @@
|
||||
* Handles third-party video API sources with validation and health checks
|
||||
*/
|
||||
|
||||
import type { VideoSource, CustomSourceConfig } from '@/lib/types';
|
||||
|
||||
const STORAGE_KEY = 'kvideo_custom_sources';
|
||||
const HEALTH_CHECK_TIMEOUT = 5000;
|
||||
import type { VideoSource } from '@/lib/types';
|
||||
|
||||
// Default predefined video sources - Real Chinese video APIs
|
||||
const DEFAULT_SOURCES: VideoSource[] = [
|
||||
@@ -156,214 +153,10 @@ const DEFAULT_SOURCES: VideoSource[] = [
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Get all video sources (default + custom)
|
||||
*/
|
||||
export function getAllSources(): VideoSource[] {
|
||||
const customSources = getCustomSources();
|
||||
return [...DEFAULT_SOURCES, ...customSources].filter(s => s.enabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get enabled sources sorted by priority
|
||||
*/
|
||||
export function getEnabledSources(): VideoSource[] {
|
||||
return getAllSources()
|
||||
.filter(source => source.enabled !== false)
|
||||
.sort((a, b) => (a.priority || 999) - (b.priority || 999));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get source by ID
|
||||
*/
|
||||
export function getSourceById(id: string): VideoSource | undefined {
|
||||
return getAllSources().find(source => source.id === id);
|
||||
return DEFAULT_SOURCES.find(source => source.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get custom sources from localStorage
|
||||
*/
|
||||
export function getCustomSources(): VideoSource[] {
|
||||
if (typeof window === 'undefined') return [];
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (!stored) return [];
|
||||
|
||||
const config: CustomSourceConfig = JSON.parse(stored);
|
||||
return config.sources || [];
|
||||
} catch (error) {
|
||||
console.error('Failed to load custom sources:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save custom sources to localStorage
|
||||
*/
|
||||
export function saveCustomSources(sources: VideoSource[]): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
try {
|
||||
const config: CustomSourceConfig = {
|
||||
sources,
|
||||
lastUpdated: Date.now(),
|
||||
};
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(config));
|
||||
} catch (error) {
|
||||
console.error('Failed to save custom sources:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new custom source
|
||||
*/
|
||||
export function addCustomSource(source: VideoSource): boolean {
|
||||
try {
|
||||
validateSource(source);
|
||||
const customSources = getCustomSources();
|
||||
|
||||
// Check for duplicate ID
|
||||
if (customSources.some(s => s.id === source.id)) {
|
||||
throw new Error('Source with this ID already exists');
|
||||
}
|
||||
|
||||
customSources.push(source);
|
||||
saveCustomSources(customSources);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to add custom source:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing custom source
|
||||
*/
|
||||
export function updateCustomSource(id: string, updates: Partial<VideoSource>): boolean {
|
||||
try {
|
||||
const customSources = getCustomSources();
|
||||
const index = customSources.findIndex(s => s.id === id);
|
||||
|
||||
if (index === -1) {
|
||||
throw new Error('Source not found');
|
||||
}
|
||||
|
||||
customSources[index] = { ...customSources[index], ...updates };
|
||||
validateSource(customSources[index]);
|
||||
saveCustomSources(customSources);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to update custom source:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a custom source
|
||||
*/
|
||||
export function removeCustomSource(id: string): boolean {
|
||||
try {
|
||||
const customSources = getCustomSources();
|
||||
const filtered = customSources.filter(s => s.id !== id);
|
||||
saveCustomSources(filtered);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to remove custom source:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate source configuration
|
||||
*/
|
||||
export function validateSource(source: VideoSource): void {
|
||||
if (!source.id || !source.name) {
|
||||
throw new Error('Source must have id and name');
|
||||
}
|
||||
|
||||
if (!source.baseUrl) {
|
||||
throw new Error('Source must have baseUrl');
|
||||
}
|
||||
|
||||
try {
|
||||
new URL(source.baseUrl);
|
||||
} catch {
|
||||
throw new Error('Invalid baseUrl format');
|
||||
}
|
||||
|
||||
if (!source.searchPath || !source.detailPath) {
|
||||
throw new Error('Source must have searchPath and detailPath');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform health check on a source
|
||||
*/
|
||||
export async function healthCheckSource(source: VideoSource): Promise<{
|
||||
healthy: boolean;
|
||||
responseTime?: number;
|
||||
error?: string;
|
||||
}> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), HEALTH_CHECK_TIMEOUT);
|
||||
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
const url = `${source.baseUrl}${source.searchPath}?ac=list&pg=1`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: source.headers || {},
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeout);
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
healthy: false,
|
||||
responseTime,
|
||||
error: `HTTP ${response.status}`,
|
||||
};
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.code !== 1 && data.code !== 0) {
|
||||
return {
|
||||
healthy: false,
|
||||
responseTime,
|
||||
error: 'Invalid API response format',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
healthy: true,
|
||||
responseTime,
|
||||
};
|
||||
} catch (error) {
|
||||
clearTimeout(timeout);
|
||||
return {
|
||||
healthy: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check multiple sources in parallel
|
||||
*/
|
||||
export async function healthCheckSources(sources: VideoSource[]): Promise<
|
||||
Map<string, { healthy: boolean; responseTime?: number; error?: string }>
|
||||
> {
|
||||
const results = await Promise.all(
|
||||
sources.map(async source => {
|
||||
const result = await healthCheckSource(source);
|
||||
return { sourceId: source.id, result };
|
||||
})
|
||||
);
|
||||
|
||||
return new Map(results.map(({ sourceId, result }) => [sourceId, result]));
|
||||
}
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
/**
|
||||
* useClickOutside Hook
|
||||
* 点击外部关闭 Hook - 检测元素外部的点击事件
|
||||
*
|
||||
* 遵循 Liquid Glass 设计系统原则:
|
||||
* - 可访问性优先(支持 Escape 键关闭)
|
||||
* - 性能优化(使用事件委托)
|
||||
* - 触摸设备友好
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { RefObject, useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* 使用点击外部关闭 Hook
|
||||
* @param ref - 要监听的元素引用
|
||||
* @param handler - 点击外部时触发的回调函数
|
||||
* @param enabled - 是否启用监听(默认为 true)
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function Dropdown() {
|
||||
* const [isOpen, setIsOpen] = useState(false);
|
||||
* const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
*
|
||||
* useClickOutside(dropdownRef, () => {
|
||||
* setIsOpen(false);
|
||||
* });
|
||||
*
|
||||
* return (
|
||||
* <div ref={dropdownRef}>
|
||||
* <button onClick={() => setIsOpen(!isOpen)}>Toggle</button>
|
||||
* {isOpen && <div>Dropdown Content</div>}
|
||||
* </div>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useClickOutside<T extends HTMLElement = HTMLElement>(
|
||||
ref: RefObject<T>,
|
||||
handler: () => void,
|
||||
enabled: boolean = true
|
||||
): void {
|
||||
useEffect(() => {
|
||||
// 如果未启用或 ref 未挂载,则不执行
|
||||
if (!enabled || !ref.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理点击事件
|
||||
* 检查点击目标是否在元素外部
|
||||
*/
|
||||
const handleClickOutside = (event: MouseEvent | TouchEvent) => {
|
||||
const target = event.target as Node;
|
||||
|
||||
// 如果点击的目标不在 ref 元素内部,触发 handler
|
||||
if (ref.current && !ref.current.contains(target)) {
|
||||
handler();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理 Escape 键
|
||||
* 增强可访问性,允许键盘用户关闭
|
||||
*/
|
||||
const handleEscapeKey = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
handler();
|
||||
}
|
||||
};
|
||||
|
||||
// 延迟添加事件监听器,避免立即触发
|
||||
// 这样可以防止触发 handler 的同时又注册监听器导致立即关闭
|
||||
const timeoutId = setTimeout(() => {
|
||||
// 监听鼠标点击和触摸事件
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
document.addEventListener('touchstart', handleClickOutside);
|
||||
// 监听 Escape 键
|
||||
document.addEventListener('keydown', handleEscapeKey);
|
||||
}, 0);
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener('touchstart', handleClickOutside);
|
||||
document.removeEventListener('keydown', handleEscapeKey);
|
||||
};
|
||||
}, [ref, handler, enabled]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 扩展版本:支持多个引用
|
||||
* 当需要排除多个元素时使用
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function DropdownWithTrigger() {
|
||||
* const [isOpen, setIsOpen] = useState(false);
|
||||
* const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
* const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
*
|
||||
* useClickOutsideMultiple([dropdownRef, triggerRef], () => {
|
||||
* setIsOpen(false);
|
||||
* });
|
||||
*
|
||||
* return (
|
||||
* <>
|
||||
* <button ref={triggerRef} onClick={() => setIsOpen(!isOpen)}>
|
||||
* Toggle
|
||||
* </button>
|
||||
* {isOpen && (
|
||||
* <div ref={dropdownRef}>Dropdown Content</div>
|
||||
* )}
|
||||
* </>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useClickOutsideMultiple<T extends HTMLElement = HTMLElement>(
|
||||
refs: RefObject<T>[],
|
||||
handler: () => void,
|
||||
enabled: boolean = true
|
||||
): void {
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleClickOutside = (event: MouseEvent | TouchEvent) => {
|
||||
const target = event.target as Node;
|
||||
|
||||
// 检查点击是否在任何一个 ref 元素内部
|
||||
const isClickInside = refs.some(ref =>
|
||||
ref.current && ref.current.contains(target)
|
||||
);
|
||||
|
||||
// 如果点击在所有元素外部,触发 handler
|
||||
if (!isClickInside) {
|
||||
handler();
|
||||
}
|
||||
};
|
||||
|
||||
const handleEscapeKey = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
handler();
|
||||
}
|
||||
};
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
document.addEventListener('touchstart', handleClickOutside);
|
||||
document.addEventListener('keydown', handleEscapeKey);
|
||||
}, 0);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener('touchstart', handleClickOutside);
|
||||
document.removeEventListener('keydown', handleEscapeKey);
|
||||
};
|
||||
}, [refs, handler, enabled]);
|
||||
}
|
||||
@@ -79,41 +79,3 @@ export function useMediaQuery(query: string): boolean {
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* 预定义的断点常量(基于 Tailwind CSS)
|
||||
* 与 Liquid Glass 设计系统的响应式规范一致
|
||||
*/
|
||||
export const BREAKPOINTS = {
|
||||
sm: '(min-width: 640px)',
|
||||
md: '(min-width: 768px)',
|
||||
lg: '(min-width: 1024px)',
|
||||
xl: '(min-width: 1280px)',
|
||||
'2xl': '(min-width: 1536px)',
|
||||
|
||||
// 反向查询(小于某个断点)
|
||||
maxSm: '(max-width: 639px)',
|
||||
maxMd: '(max-width: 767px)',
|
||||
maxLg: '(max-width: 1023px)',
|
||||
maxXl: '(max-width: 1279px)',
|
||||
max2xl: '(max-width: 1535px)',
|
||||
|
||||
// 特殊查询
|
||||
mobile: '(max-width: 768px)',
|
||||
tablet: '(min-width: 768px) and (max-width: 1024px)',
|
||||
desktop: '(min-width: 1024px)',
|
||||
touch: '(hover: none) and (pointer: coarse)',
|
||||
darkMode: '(prefers-color-scheme: dark)',
|
||||
lightMode: '(prefers-color-scheme: light)',
|
||||
reducedMotion: '(prefers-reduced-motion: reduce)',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 便捷的断点检测 Hooks
|
||||
*/
|
||||
export const useIsMobile = () => useMediaQuery(BREAKPOINTS.mobile);
|
||||
export const useIsTablet = () => useMediaQuery(BREAKPOINTS.tablet);
|
||||
export const useIsDesktop = () => useMediaQuery(BREAKPOINTS.desktop);
|
||||
export const useIsTouchDevice = () => useMediaQuery(BREAKPOINTS.touch);
|
||||
export const usePrefersDarkMode = () => useMediaQuery(BREAKPOINTS.darkMode);
|
||||
export const usePrefersReducedMotion = () => useMediaQuery(BREAKPOINTS.reducedMotion);
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import { getSourceName, SOURCE_IDS } from '@/lib/utils/source-names';
|
||||
|
||||
export interface SearchStreamResult {
|
||||
loading: boolean;
|
||||
results: any[];
|
||||
availableSources: any[];
|
||||
checkedSources: number;
|
||||
searchStage: 'searching' | 'checking';
|
||||
checkedVideos: number;
|
||||
totalVideos: number;
|
||||
currentSource: string;
|
||||
performSearch: (query: string, shouldClearCache?: boolean) => Promise<void>;
|
||||
resetSearch: () => void;
|
||||
}
|
||||
|
||||
export function useSearchStream(
|
||||
onCacheUpdate: (query: string, results: any[], sources: any[]) => void,
|
||||
onUrlUpdate: (query: string) => void
|
||||
): SearchStreamResult {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [results, setResults] = useState<any[]>([]);
|
||||
const [availableSources, setAvailableSources] = useState<any[]>([]);
|
||||
const [checkedSources, setCheckedSources] = useState(0);
|
||||
const [searchStage, setSearchStage] = useState<'searching' | 'checking'>('searching');
|
||||
const [checkedVideos, setCheckedVideos] = useState(0);
|
||||
const [totalVideos, setTotalVideos] = useState(0);
|
||||
const [currentSource, setCurrentSource] = useState<string>('');
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const performSearch = useCallback(async (searchQuery: string, shouldClearCache: boolean = true) => {
|
||||
if (!searchQuery.trim() || loading) return;
|
||||
|
||||
// Abort any ongoing search
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
// Reset state
|
||||
setLoading(true);
|
||||
setResults([]);
|
||||
setAvailableSources([]);
|
||||
setCheckedSources(0);
|
||||
setSearchStage('searching');
|
||||
setCheckedVideos(0);
|
||||
setTotalVideos(0);
|
||||
|
||||
// Update URL
|
||||
onUrlUpdate(searchQuery);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/search-stream', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query: searchQuery, sources: SOURCE_IDS }),
|
||||
signal: abortControllerRef.current.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Search failed');
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
if (!reader) throw new Error('No response stream');
|
||||
|
||||
let buffer = '';
|
||||
const allVideos: any[] = [];
|
||||
const sourceVideoCounts = new Map<string, number>();
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6));
|
||||
|
||||
if (data.type === 'progress') {
|
||||
if (data.stage === 'searching') {
|
||||
setSearchStage('searching');
|
||||
setCheckedSources(data.checkedSources);
|
||||
} else if (data.stage === 'checking') {
|
||||
setSearchStage('checking');
|
||||
setCheckedVideos(data.checkedVideos);
|
||||
setTotalVideos(data.totalVideos);
|
||||
}
|
||||
} else if (data.type === 'videos') {
|
||||
const newVideos = data.videos.map((video: any) => ({
|
||||
...video,
|
||||
sourceName: getSourceName(video.source),
|
||||
isNew: true,
|
||||
addedAt: Date.now(),
|
||||
}));
|
||||
|
||||
allVideos.push(...newVideos);
|
||||
setResults([...allVideos]);
|
||||
setCheckedVideos(data.checkedVideos);
|
||||
setTotalVideos(data.totalVideos);
|
||||
|
||||
// Update source counts
|
||||
newVideos.forEach((video: any) => {
|
||||
const count = sourceVideoCounts.get(video.source) || 0;
|
||||
sourceVideoCounts.set(video.source, count + 1);
|
||||
});
|
||||
|
||||
const sourcesArray = Array.from(sourceVideoCounts.entries()).map(([sourceId, count]) => ({
|
||||
id: sourceId,
|
||||
name: getSourceName(sourceId),
|
||||
count,
|
||||
}));
|
||||
setAvailableSources(sourcesArray);
|
||||
|
||||
// Remove "new" animation after delay
|
||||
setTimeout(() => {
|
||||
setResults(prev => prev.map(v => {
|
||||
const wasJustAdded = newVideos.some((nv: any) =>
|
||||
nv.vod_id === v.vod_id && nv.source === v.source && nv.addedAt === v.addedAt
|
||||
);
|
||||
return wasJustAdded ? { ...v, isNew: false } : v;
|
||||
}));
|
||||
}, 300);
|
||||
} else if (data.type === 'complete') {
|
||||
setCheckedVideos(data.totalVideos);
|
||||
setLoading(false);
|
||||
|
||||
const finalSourcesArray = Array.from(sourceVideoCounts.entries()).map(([sourceId, count]) => ({
|
||||
id: sourceId,
|
||||
name: getSourceName(sourceId),
|
||||
count,
|
||||
}));
|
||||
|
||||
// Save to cache
|
||||
onCacheUpdate(searchQuery, allVideos, finalSourcesArray);
|
||||
} else if (data.type === 'error') {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
} catch (err) {
|
||||
// Skip invalid JSON lines
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.name !== 'AbortError') {
|
||||
console.error('Search error:', error);
|
||||
}
|
||||
setLoading(false);
|
||||
} finally {
|
||||
setCurrentSource('');
|
||||
}
|
||||
}, [loading, onCacheUpdate, onUrlUpdate]);
|
||||
|
||||
const resetSearch = useCallback(() => {
|
||||
setResults([]);
|
||||
setAvailableSources([]);
|
||||
setCheckedSources(0);
|
||||
setSearchStage('searching');
|
||||
setCheckedVideos(0);
|
||||
setTotalVideos(0);
|
||||
setCurrentSource('');
|
||||
}, []);
|
||||
|
||||
return {
|
||||
loading,
|
||||
results,
|
||||
availableSources,
|
||||
checkedSources,
|
||||
searchStage,
|
||||
checkedVideos,
|
||||
totalVideos,
|
||||
currentSource,
|
||||
performSearch,
|
||||
resetSearch,
|
||||
};
|
||||
}
|
||||
@@ -167,13 +167,3 @@ export const useHistoryStore = create<HistoryStore>()(
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Selector hooks
|
||||
export const useViewingHistory = () => useHistoryStore((state) => state.viewingHistory);
|
||||
export const useHistoryActions = () =>
|
||||
useHistoryStore((state) => ({
|
||||
addToHistory: state.addToHistory,
|
||||
updateProgress: state.updateProgress,
|
||||
removeFromHistory: state.removeFromHistory,
|
||||
clearHistory: state.clearHistory,
|
||||
}));
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
/**
|
||||
* Search History Store
|
||||
* 搜索历史记录存储
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
const MAX_SEARCH_HISTORY = 20;
|
||||
|
||||
export interface SearchHistoryItem {
|
||||
query: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface SearchHistoryStore {
|
||||
searchHistory: SearchHistoryItem[];
|
||||
|
||||
addSearchHistory: (query: string) => void;
|
||||
removeSearchHistory: (query: string) => void;
|
||||
clearSearchHistory: () => void;
|
||||
getSearchHistory: () => SearchHistoryItem[];
|
||||
}
|
||||
|
||||
export const useSearchHistoryStore = create<SearchHistoryStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
searchHistory: [],
|
||||
|
||||
addSearchHistory: (query: string) => {
|
||||
const trimmedQuery = query.trim();
|
||||
if (!trimmedQuery) return;
|
||||
|
||||
set((state) => {
|
||||
// 移除已存在的相同搜索
|
||||
const filtered = state.searchHistory.filter(
|
||||
(item) => item.query !== trimmedQuery
|
||||
);
|
||||
|
||||
// 添加新搜索到顶部
|
||||
const newHistory = [
|
||||
{ query: trimmedQuery, timestamp: Date.now() },
|
||||
...filtered,
|
||||
].slice(0, MAX_SEARCH_HISTORY);
|
||||
|
||||
return { searchHistory: newHistory };
|
||||
});
|
||||
},
|
||||
|
||||
removeSearchHistory: (query: string) => {
|
||||
set((state) => ({
|
||||
searchHistory: state.searchHistory.filter(
|
||||
(item) => item.query !== query
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
clearSearchHistory: () => {
|
||||
set({ searchHistory: [] });
|
||||
},
|
||||
|
||||
getSearchHistory: () => {
|
||||
return get().searchHistory;
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'kvideo-search-history',
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -53,24 +53,6 @@ export interface VideoDetail {
|
||||
source_code: string;
|
||||
}
|
||||
|
||||
// Playback State
|
||||
export interface PlayerState {
|
||||
currentVideo: {
|
||||
id: string | number;
|
||||
title: string;
|
||||
url: string;
|
||||
source: string;
|
||||
episodeIndex: number;
|
||||
} | null;
|
||||
episodes: Episode[];
|
||||
playbackPosition: number;
|
||||
duration: number;
|
||||
isPlaying: boolean;
|
||||
autoplayNext: boolean;
|
||||
volume: number;
|
||||
playbackRate: number;
|
||||
}
|
||||
|
||||
// History Entry
|
||||
export interface VideoHistoryItem {
|
||||
videoId: string | number;
|
||||
@@ -116,20 +98,6 @@ export interface ApiDetailResponse {
|
||||
}>;
|
||||
}
|
||||
|
||||
// Search Request/Response Types
|
||||
export interface SearchRequest {
|
||||
query: string;
|
||||
sources: string[];
|
||||
page?: number;
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
results: VideoItem[];
|
||||
source: string;
|
||||
responseTime?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Detail Request Types
|
||||
export interface DetailRequest {
|
||||
id: string | number;
|
||||
@@ -137,35 +105,3 @@ export interface DetailRequest {
|
||||
customApi?: string;
|
||||
}
|
||||
|
||||
// Source Speed Test Result
|
||||
export interface SourceSpeedResult {
|
||||
source: string;
|
||||
sourceName: string;
|
||||
speed: number; // milliseconds
|
||||
available: boolean;
|
||||
error?: string;
|
||||
videoDetail?: VideoDetail;
|
||||
}
|
||||
|
||||
// Error Types
|
||||
export interface ApiError {
|
||||
code: string;
|
||||
message: string;
|
||||
source?: string;
|
||||
retryable: boolean;
|
||||
}
|
||||
|
||||
// Progress Storage
|
||||
export interface VideoProgress {
|
||||
videoId: string | number;
|
||||
position: number;
|
||||
duration: number;
|
||||
timestamp: number;
|
||||
episodeIndex: number;
|
||||
}
|
||||
|
||||
// Custom Source Configuration
|
||||
export interface CustomSourceConfig {
|
||||
sources: VideoSource[];
|
||||
lastUpdated: number;
|
||||
}
|
||||
|
||||
+3
-256
@@ -1,263 +1,9 @@
|
||||
/**
|
||||
* Search Utilities
|
||||
* Debouncing, result merging, and search optimization
|
||||
* Search relevance scoring and optimization
|
||||
*/
|
||||
|
||||
import type { VideoItem, SearchResult } from '@/lib/types';
|
||||
|
||||
/**
|
||||
* Debounce function for search input
|
||||
*/
|
||||
export function debounce<T extends (...args: any[]) => any>(
|
||||
func: T,
|
||||
delay: number = 500
|
||||
): (...args: Parameters<T>) => void {
|
||||
let timeoutId: ReturnType<typeof setTimeout>;
|
||||
|
||||
return function (this: any, ...args: Parameters<T>) {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => func.apply(this, args), delay);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Throttle function for limiting function calls
|
||||
*/
|
||||
export function throttle<T extends (...args: any[]) => any>(
|
||||
func: T,
|
||||
limit: number
|
||||
): (...args: Parameters<T>) => void {
|
||||
let inThrottle: boolean;
|
||||
|
||||
return function (this: any, ...args: Parameters<T>) {
|
||||
if (!inThrottle) {
|
||||
func.apply(this, args);
|
||||
inThrottle = true;
|
||||
setTimeout(() => (inThrottle = false), limit);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge and deduplicate search results from multiple sources
|
||||
*/
|
||||
export function mergeSearchResults(results: SearchResult[]): VideoItem[] {
|
||||
const seenTitles = new Set<string>();
|
||||
const mergedResults: VideoItem[] = [];
|
||||
|
||||
// Process results by response time (fastest first)
|
||||
const sortedResults = [...results].sort((a, b) => {
|
||||
const timeA = a.responseTime || Infinity;
|
||||
const timeB = b.responseTime || Infinity;
|
||||
return timeA - timeB;
|
||||
});
|
||||
|
||||
for (const result of sortedResults) {
|
||||
if (result.error) continue;
|
||||
|
||||
for (const item of result.results) {
|
||||
// Normalize title for comparison
|
||||
const normalizedTitle = normalizeTitle(item.vod_name);
|
||||
|
||||
if (!seenTitles.has(normalizedTitle)) {
|
||||
seenTitles.add(normalizedTitle);
|
||||
mergedResults.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mergedResults;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize title for deduplication
|
||||
*/
|
||||
function normalizeTitle(title: string): string {
|
||||
return title
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^\w\s]/g, '') // Remove special characters
|
||||
.replace(/\s+/g, ' '); // Normalize whitespace
|
||||
}
|
||||
|
||||
/**
|
||||
* Group search results by source
|
||||
*/
|
||||
export function groupResultsBySource(
|
||||
results: SearchResult[]
|
||||
): Map<string, VideoItem[]> {
|
||||
const grouped = new Map<string, VideoItem[]>();
|
||||
|
||||
for (const result of results) {
|
||||
if (!result.error && result.results.length > 0) {
|
||||
grouped.set(result.source, result.results);
|
||||
}
|
||||
}
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter search results by criteria
|
||||
*/
|
||||
export interface SearchFilters {
|
||||
year?: string;
|
||||
area?: string;
|
||||
type?: string;
|
||||
keyword?: string;
|
||||
}
|
||||
|
||||
export function filterResults(
|
||||
results: VideoItem[],
|
||||
filters: SearchFilters
|
||||
): VideoItem[] {
|
||||
return results.filter(item => {
|
||||
if (filters.year && item.vod_year !== filters.year) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filters.area && item.vod_area !== filters.area) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filters.type && item.type_name !== filters.type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filters.keyword) {
|
||||
const keyword = filters.keyword.toLowerCase();
|
||||
const searchText = `${item.vod_name} ${item.vod_actor || ''} ${item.vod_director || ''}`.toLowerCase();
|
||||
if (!searchText.includes(keyword)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort search results
|
||||
*/
|
||||
export type SortOption = 'relevance' | 'year' | 'name' | 'updated';
|
||||
|
||||
export function sortResults(
|
||||
results: VideoItem[],
|
||||
sortBy: SortOption = 'relevance'
|
||||
): VideoItem[] {
|
||||
const sorted = [...results];
|
||||
|
||||
switch (sortBy) {
|
||||
case 'year':
|
||||
sorted.sort((a, b) => {
|
||||
const yearA = parseInt(a.vod_year || '0');
|
||||
const yearB = parseInt(b.vod_year || '0');
|
||||
return yearB - yearA;
|
||||
});
|
||||
break;
|
||||
|
||||
case 'name':
|
||||
sorted.sort((a, b) => a.vod_name.localeCompare(b.vod_name));
|
||||
break;
|
||||
|
||||
case 'updated':
|
||||
// Assuming vod_remarks contains update info
|
||||
sorted.sort((a, b) => {
|
||||
const remarkA = a.vod_remarks || '';
|
||||
const remarkB = b.vod_remarks || '';
|
||||
return remarkB.localeCompare(remarkA);
|
||||
});
|
||||
break;
|
||||
|
||||
case 'relevance':
|
||||
default:
|
||||
// Keep original order (sorted by API)
|
||||
break;
|
||||
}
|
||||
|
||||
return sorted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlight search query in text
|
||||
*/
|
||||
export function highlightQuery(text: string, query: string): string {
|
||||
if (!query || !text) return text;
|
||||
|
||||
const regex = new RegExp(`(${escapeRegex(query)})`, 'gi');
|
||||
return text.replace(regex, '<mark>$1</mark>');
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape special regex characters
|
||||
*/
|
||||
function escapeRegex(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract search suggestions from query
|
||||
*/
|
||||
export function getSearchSuggestions(
|
||||
query: string,
|
||||
history: string[]
|
||||
): string[] {
|
||||
if (!query) return [];
|
||||
|
||||
const normalizedQuery = query.toLowerCase();
|
||||
|
||||
return history
|
||||
.filter(item => item.toLowerCase().includes(normalizedQuery))
|
||||
.slice(0, 5);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save search query to history
|
||||
*/
|
||||
const SEARCH_HISTORY_KEY = 'kvideo_search_history';
|
||||
const MAX_SEARCH_HISTORY = 20;
|
||||
|
||||
export function saveSearchQuery(query: string): void {
|
||||
if (typeof window === 'undefined' || !query.trim()) return;
|
||||
|
||||
try {
|
||||
const history = getSearchHistory();
|
||||
|
||||
// Remove duplicate
|
||||
const filtered = history.filter(
|
||||
item => item.toLowerCase() !== query.toLowerCase()
|
||||
);
|
||||
|
||||
// Add to front
|
||||
const updated = [query, ...filtered].slice(0, MAX_SEARCH_HISTORY);
|
||||
|
||||
localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(updated));
|
||||
} catch (error) {
|
||||
console.error('Failed to save search query:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get search history
|
||||
*/
|
||||
export function getSearchHistory(): string[] {
|
||||
if (typeof window === 'undefined') return [];
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(SEARCH_HISTORY_KEY);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear search history
|
||||
*/
|
||||
export function clearSearchHistory(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
localStorage.removeItem(SEARCH_HISTORY_KEY);
|
||||
}
|
||||
import type { VideoItem } from '@/lib/types';
|
||||
|
||||
/**
|
||||
* Calculate search relevance score
|
||||
@@ -379,3 +125,4 @@ export function calculateRelevanceScore(item: VideoItem, query: string): number
|
||||
|
||||
return Math.max(0, score); // Ensure non-negative
|
||||
}
|
||||
|
||||
|
||||
@@ -1,326 +0,0 @@
|
||||
/**
|
||||
* Source Availability Checker
|
||||
* Pre-validates video sources during search to filter out unavailable ones
|
||||
*/
|
||||
|
||||
import { isValidUrlFormat } from './url-validator';
|
||||
|
||||
const CHECK_TIMEOUT = 5000; // 5 seconds per check (increased for more reliability)
|
||||
const MAX_RETRIES = 1; // Reduced retries to speed up detection
|
||||
const MIN_CONTENT_LENGTH = 1024; // Minimum content size to consider valid video
|
||||
|
||||
export interface SourceCheckResult {
|
||||
sourceId: string;
|
||||
sourceName: string;
|
||||
isAvailable: boolean;
|
||||
sampleUrl?: string;
|
||||
error?: string;
|
||||
checkedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a single video URL is accessible and actually contains video content
|
||||
* More accurate detection with multiple validation steps
|
||||
*/
|
||||
async function checkVideoUrl(url: string, retries = MAX_RETRIES): Promise<boolean> {
|
||||
if (!isValidUrlFormat(url)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), CHECK_TIMEOUT);
|
||||
|
||||
// First, try HEAD request to check if resource exists without downloading
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
method: 'HEAD',
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
|
||||
'Referer': new URL(url).origin,
|
||||
},
|
||||
});
|
||||
|
||||
// If HEAD request fails or returns bad status, try GET with Range
|
||||
if (!response.ok && response.status !== 206) {
|
||||
throw new Error('HEAD request failed');
|
||||
}
|
||||
} catch (headError) {
|
||||
// Fallback to GET with Range if HEAD fails
|
||||
response = await fetch(url, {
|
||||
method: 'GET',
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
|
||||
'Referer': new URL(url).origin,
|
||||
'Range': 'bytes=0-2048', // Fetch first 2KB to verify
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
// Check response status
|
||||
// 200 OK: Full content
|
||||
// 206 Partial Content: Range request successful
|
||||
// 403 Forbidden: Not accessible (should fail)
|
||||
// 404 Not Found: Video doesn't exist (should fail)
|
||||
if (!response.ok && response.status !== 206) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Additional validation checks
|
||||
const contentType = response.headers.get('content-type');
|
||||
const contentLength = response.headers.get('content-length');
|
||||
const acceptRanges = response.headers.get('accept-ranges');
|
||||
|
||||
// Check 1: Content type validation
|
||||
const hasValidContentType = contentType && (
|
||||
contentType.includes('video') ||
|
||||
contentType.includes('mpegurl') ||
|
||||
contentType.includes('m3u8') ||
|
||||
contentType.includes('application/vnd.apple.mpegurl') ||
|
||||
contentType.includes('octet-stream')
|
||||
);
|
||||
|
||||
// Check 2: Content length validation (should have some content)
|
||||
// For m3u8 files, length can be small, so we're more lenient
|
||||
const hasValidLength = !contentLength ||
|
||||
parseInt(contentLength) >= MIN_CONTENT_LENGTH ||
|
||||
(contentType && (contentType.includes('m3u8') || contentType.includes('mpegurl')));
|
||||
|
||||
// Check 3: For video files, check if server supports range requests (good sign)
|
||||
const supportsRanges = acceptRanges === 'bytes' || response.status === 206;
|
||||
|
||||
// Video must pass content type check AND either have valid length OR support ranges
|
||||
if (hasValidContentType && (hasValidLength || supportsRanges)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
// If last attempt, return false
|
||||
if (attempt === retries) {
|
||||
return false;
|
||||
}
|
||||
// Wait before retry
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract first playable URL from video data
|
||||
* More robust parsing with better error handling
|
||||
*/
|
||||
function extractFirstVideoUrl(video: any): string | null {
|
||||
if (!video.vod_play_url) return null;
|
||||
|
||||
try {
|
||||
// Format: "Episode1$url1#Episode2$url2#..." or sometimes "url1#url2#url3"
|
||||
const episodes = video.vod_play_url.split('#').filter((ep: string) => ep.trim());
|
||||
|
||||
for (const episode of episodes) {
|
||||
// Try different formats
|
||||
const parts = episode.split('$');
|
||||
|
||||
// Format 1: "Episode$url"
|
||||
if (parts.length >= 2) {
|
||||
const url = parts[1].trim();
|
||||
if (url && isValidUrlFormat(url)) {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
// Format 2: Just "url" without episode name
|
||||
else if (parts.length === 1) {
|
||||
const url = parts[0].trim();
|
||||
if (url && isValidUrlFormat(url)) {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error extracting video URL:', error);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a single video is playable
|
||||
* Returns false if URL is invalid or video fails validation
|
||||
*/
|
||||
export async function checkVideoAvailability(video: any): Promise<boolean> {
|
||||
// First check if video has required data
|
||||
if (!video || !video.vod_play_url) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const videoUrl = extractFirstVideoUrl(video);
|
||||
|
||||
if (!videoUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Perform thorough URL check
|
||||
const isAvailable = await checkVideoUrl(videoUrl);
|
||||
|
||||
// Log failed checks for debugging
|
||||
if (!isAvailable) {
|
||||
console.debug(`Video unavailable: ${video.vod_name || 'Unknown'} (${videoUrl.substring(0, 50)}...)`);
|
||||
}
|
||||
|
||||
return isAvailable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check multiple videos in parallel with concurrency limit
|
||||
* Improved error handling and progress reporting
|
||||
*/
|
||||
export async function checkMultipleVideos(
|
||||
videos: any[],
|
||||
concurrency: number = 8, // Reduced default concurrency for better accuracy
|
||||
onProgress?: (checked: number, total: number) => void
|
||||
): Promise<any[]> {
|
||||
if (!videos || videos.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const availableVideos: any[] = [];
|
||||
let checkedCount = 0;
|
||||
|
||||
// Process videos in batches to avoid overwhelming the system
|
||||
for (let i = 0; i < videos.length; i += concurrency) {
|
||||
const batch = videos.slice(i, i + concurrency);
|
||||
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
batch.map(async (video) => {
|
||||
try {
|
||||
const isAvailable = await checkVideoAvailability(video);
|
||||
checkedCount++;
|
||||
|
||||
// Report progress
|
||||
if (onProgress) {
|
||||
onProgress(checkedCount, videos.length);
|
||||
}
|
||||
|
||||
return isAvailable ? video : null;
|
||||
} catch (error) {
|
||||
// If individual check fails, mark as unavailable
|
||||
checkedCount++;
|
||||
if (onProgress) {
|
||||
onProgress(checkedCount, videos.length);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Add available videos to result
|
||||
availableVideos.push(...results.filter(v => v !== null));
|
||||
} catch (error) {
|
||||
console.error('Batch check error:', error);
|
||||
// Continue with next batch even if this one fails
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Checked ${videos.length} videos, ${availableVideos.length} available`);
|
||||
|
||||
return availableVideos;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a source is available by testing a sample video
|
||||
* Now checks more videos for better accuracy
|
||||
*/
|
||||
export async function checkSourceAvailability(
|
||||
sourceId: string,
|
||||
sourceName: string,
|
||||
sampleVideos: any[]
|
||||
): Promise<SourceCheckResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
// If no videos from this source, mark as unavailable
|
||||
if (!sampleVideos || sampleVideos.length === 0) {
|
||||
return {
|
||||
sourceId,
|
||||
sourceName,
|
||||
isAvailable: false,
|
||||
error: 'No videos found',
|
||||
checkedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
// Try to find at least one working video from up to 5 samples
|
||||
const samplesToCheck = Math.min(5, sampleVideos.length);
|
||||
let checkedCount = 0;
|
||||
|
||||
for (const video of sampleVideos.slice(0, samplesToCheck)) {
|
||||
checkedCount++;
|
||||
const videoUrl = extractFirstVideoUrl(video);
|
||||
|
||||
if (!videoUrl) {
|
||||
console.debug(`Source ${sourceName}: Video ${checkedCount} has no valid URL`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const isAvailable = await checkVideoUrl(videoUrl);
|
||||
|
||||
if (isAvailable) {
|
||||
console.log(`✓ Source ${sourceName} is available (verified with ${videoUrl.substring(0, 50)}...)`);
|
||||
return {
|
||||
sourceId,
|
||||
sourceName,
|
||||
isAvailable: true,
|
||||
sampleUrl: videoUrl,
|
||||
checkedAt: Date.now(),
|
||||
};
|
||||
} else {
|
||||
console.debug(`Source ${sourceName}: Video ${checkedCount}/${samplesToCheck} unavailable`);
|
||||
}
|
||||
}
|
||||
|
||||
console.warn(`✗ Source ${sourceName} is unavailable (checked ${checkedCount} videos)`);
|
||||
return {
|
||||
sourceId,
|
||||
sourceName,
|
||||
isAvailable: false,
|
||||
error: `All ${checkedCount} sample videos failed to load`,
|
||||
checkedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check multiple sources in parallel
|
||||
*/
|
||||
export async function checkMultipleSources(
|
||||
sourcesWithVideos: Array<{ sourceId: string; sourceName: string; videos: any[] }>
|
||||
): Promise<SourceCheckResult[]> {
|
||||
const checkPromises = sourcesWithVideos.map(({ sourceId, sourceName, videos }) =>
|
||||
checkSourceAvailability(sourceId, sourceName, videos)
|
||||
);
|
||||
|
||||
return Promise.all(checkPromises);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter search results to only include videos from available sources
|
||||
*/
|
||||
export function filterByAvailableSources(
|
||||
videos: any[],
|
||||
availableSources: SourceCheckResult[]
|
||||
): any[] {
|
||||
const availableSourceIds = new Set(
|
||||
availableSources
|
||||
.filter(s => s.isAvailable)
|
||||
.map(s => s.sourceId)
|
||||
);
|
||||
|
||||
return videos.filter(video => availableSourceIds.has(video.source));
|
||||
}
|
||||
Reference in New Issue
Block a user