mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-14 16:23:43 +08:00
Implement streaming search API and enhance video availability checks with loading animation
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* 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 = 5; // Smaller batches for faster response
|
||||
|
||||
// Process videos in smaller 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',
|
||||
},
|
||||
});
|
||||
}
|
||||
+59
-73
@@ -7,7 +7,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { searchVideos } from '@/lib/api/client';
|
||||
import { getEnabledSources, getSourceById } from '@/lib/api/video-sources';
|
||||
import { checkMultipleSources, filterByAvailableSources } from '@/lib/utils/source-checker';
|
||||
import { checkMultipleVideos } from '@/lib/utils/source-checker';
|
||||
import type { SearchRequest, SearchResult } from '@/lib/types';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
@@ -68,53 +68,46 @@ export async function POST(request: NextRequest) {
|
||||
return sourceNames[sourceId] || sourceId;
|
||||
};
|
||||
|
||||
// Check source availability by testing sample videos
|
||||
console.log(`🔍 Checking availability of ${searchResults.length} sources...`);
|
||||
const sourcesWithVideos = searchResults
|
||||
.filter(result => result.results.length > 0)
|
||||
.map(result => ({
|
||||
sourceId: result.source,
|
||||
sourceName: getSourceName(result.source),
|
||||
videos: result.results.slice(0, 3), // Use first 3 videos as samples
|
||||
}));
|
||||
|
||||
const availabilityResults = await checkMultipleSources(sourcesWithVideos);
|
||||
|
||||
const availableCount = availabilityResults.filter(r => r.isAvailable).length;
|
||||
console.log(`✅ ${availableCount} out of ${availabilityResults.length} sources are available`);
|
||||
|
||||
// Filter results to only include videos from available sources
|
||||
// Get all videos from all sources
|
||||
const allVideos = searchResults.flatMap(r => r.results);
|
||||
const availableVideos = filterByAvailableSources(allVideos, availabilityResults);
|
||||
|
||||
// Group available videos back by source
|
||||
const availableSources = availabilityResults
|
||||
.filter(r => r.isAvailable)
|
||||
.map(r => {
|
||||
const sourceVideos = availableVideos.filter(v => v.source === r.sourceId);
|
||||
return {
|
||||
source: r.sourceId,
|
||||
results: sourceVideos,
|
||||
responseTime: searchResults.find(sr => sr.source === r.sourceId)?.responseTime,
|
||||
};
|
||||
});
|
||||
// Check each video individually
|
||||
const availableVideos = await checkMultipleVideos(allVideos, 10);
|
||||
|
||||
// Format response
|
||||
const response: SearchResult[] = availableSources.map(result => ({
|
||||
results: result.results,
|
||||
source: result.source,
|
||||
responseTime: result.responseTime,
|
||||
// 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,
|
||||
availableSources: availableCount,
|
||||
totalSources: availabilityResults.length,
|
||||
sourceAvailability: availabilityResults,
|
||||
sourceStats, // Include real counts per source
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Search API error:', error);
|
||||
@@ -187,53 +180,46 @@ export async function GET(request: NextRequest) {
|
||||
return sourceNames[sourceId] || sourceId;
|
||||
};
|
||||
|
||||
// Check source availability by testing sample videos
|
||||
console.log(`🔍 [GET] Checking availability of ${searchResults.length} sources...`);
|
||||
const sourcesWithVideos = searchResults
|
||||
.filter(result => result.results.length > 0)
|
||||
.map(result => ({
|
||||
sourceId: result.source,
|
||||
sourceName: getSourceName(result.source),
|
||||
videos: result.results.slice(0, 3), // Use first 3 videos as samples
|
||||
}));
|
||||
|
||||
const availabilityResults = await checkMultipleSources(sourcesWithVideos);
|
||||
|
||||
const availableCount = availabilityResults.filter(r => r.isAvailable).length;
|
||||
console.log(`✅ [GET] ${availableCount} out of ${availabilityResults.length} sources are available`);
|
||||
|
||||
// Filter results to only include videos from available sources
|
||||
// Get all videos from all sources
|
||||
const allVideos = searchResults.flatMap(r => r.results);
|
||||
const availableVideos = filterByAvailableSources(allVideos, availabilityResults);
|
||||
|
||||
// Group available videos back by source
|
||||
const availableSources = availabilityResults
|
||||
.filter(r => r.isAvailable)
|
||||
.map(r => {
|
||||
const sourceVideos = availableVideos.filter(v => v.source === r.sourceId);
|
||||
return {
|
||||
source: r.sourceId,
|
||||
results: sourceVideos,
|
||||
responseTime: searchResults.find(sr => sr.source === r.sourceId)?.responseTime,
|
||||
};
|
||||
});
|
||||
// Check each video individually
|
||||
const availableVideos = await checkMultipleVideos(allVideos, 10);
|
||||
|
||||
// Format response
|
||||
const response: SearchResult[] = availableSources.map(result => ({
|
||||
results: result.results,
|
||||
source: result.source,
|
||||
responseTime: result.responseTime,
|
||||
// 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,
|
||||
availableSources: availableCount,
|
||||
totalSources: availabilityResults.length,
|
||||
sourceAvailability: availabilityResults,
|
||||
sourceStats, // Include real counts per source
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Search API error:', error);
|
||||
|
||||
@@ -141,6 +141,57 @@ body.dark,
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin-slow {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes spin-reverse {
|
||||
from { transform: rotate(360deg); }
|
||||
to { transform: rotate(0deg); }
|
||||
}
|
||||
|
||||
@keyframes bounce-subtle {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-5px); }
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(100%); }
|
||||
}
|
||||
|
||||
@keyframes scale-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.9) translateY(10px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% {
|
||||
transform: translateY(0) translateX(0);
|
||||
opacity: 0.3;
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-20px) translateX(10px);
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes gradient-x {
|
||||
0%, 100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.4s ease-out;
|
||||
}
|
||||
@@ -157,6 +208,35 @@ body.dark,
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.animate-spin-slow {
|
||||
animation: spin-slow 3s linear infinite;
|
||||
}
|
||||
|
||||
.animate-spin-reverse {
|
||||
animation: spin-reverse 2s linear infinite;
|
||||
}
|
||||
|
||||
.animate-bounce-subtle {
|
||||
animation: bounce-subtle 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-shimmer {
|
||||
animation: shimmer 2s infinite;
|
||||
}
|
||||
|
||||
.animate-scale-in {
|
||||
animation: scale-in 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
||||
}
|
||||
|
||||
.animate-float {
|
||||
animation: float 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-gradient-x {
|
||||
background-size: 200% 200%;
|
||||
animation: gradient-x 3s ease infinite;
|
||||
}
|
||||
|
||||
/* Liquid Glass Components */
|
||||
.glass-card {
|
||||
background: var(--glass-bg);
|
||||
|
||||
+210
-70
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ThemeSwitcher } from '@/components/ThemeSwitcher';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
@@ -8,67 +8,173 @@ import { Input } from '@/components/ui/Input';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { SearchLoadingAnimation } from '@/components/SearchLoadingAnimation';
|
||||
import Image from 'next/image';
|
||||
|
||||
export default function Home() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [availableSources, setAvailableSources] = useState<Array<{id: string, name: string, count: number}>>([]);
|
||||
const [validationStatus, setValidationStatus] = useState<string>('');
|
||||
const [hasSearched, setHasSearched] = useState(false);
|
||||
const [availableSources, setAvailableSources] = useState<any[]>([]);
|
||||
const [currentSource, setCurrentSource] = useState<string>('');
|
||||
const [checkedSources, setCheckedSources] = useState(0);
|
||||
const [searchStage, setSearchStage] = useState<'searching' | 'checking'>('searching');
|
||||
const [checkedVideos, setCheckedVideos] = useState(0);
|
||||
const [totalVideos, setTotalVideos] = useState(0);
|
||||
const router = useRouter();
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const handleSearch = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!query.trim()) return;
|
||||
if (!query.trim() || loading) return; // Prevent multiple searches
|
||||
|
||||
// Abort any previous search
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
// Create new abort controller for this search
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
setLoading(true);
|
||||
setValidationStatus('搜索中...');
|
||||
setHasSearched(true);
|
||||
setResults([]);
|
||||
setAvailableSources([]);
|
||||
setCheckedSources(0);
|
||||
setSearchStage('searching');
|
||||
setCheckedVideos(0);
|
||||
setTotalVideos(0);
|
||||
|
||||
try {
|
||||
// Get all enabled source IDs
|
||||
const sourceIds = ['custom_0', 'custom_1', 'custom_2', 'custom_3', 'custom_4',
|
||||
'custom_5', 'custom_6', 'custom_7', 'custom_8', 'custom_9',
|
||||
'custom_10', 'custom_11', 'custom_12', 'custom_13', 'custom_14', 'custom_15'];
|
||||
|
||||
const response = await fetch('/api/search', {
|
||||
// Use streaming API
|
||||
const response = await fetch('/api/search-stream', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query, sources: sourceIds }),
|
||||
signal: abortControllerRef.current.signal,
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setValidationStatus(`已检测 ${data.totalSources || 0} 个源,${data.availableSources || 0} 个可用`);
|
||||
|
||||
// Filter out sources with no results and add source names
|
||||
const resultsWithSources = data.sources
|
||||
.filter((s: any) => s.results.length > 0)
|
||||
.flatMap((s: any) =>
|
||||
s.results.map((result: any) => ({
|
||||
...result,
|
||||
sourceName: getSourceName(s.source),
|
||||
}))
|
||||
);
|
||||
setResults(resultsWithSources);
|
||||
|
||||
// Track available sources
|
||||
const sourcesWithResults = data.sources
|
||||
.filter((s: any) => s.results.length > 0)
|
||||
.map((s: any) => ({
|
||||
id: s.source,
|
||||
name: getSourceName(s.source),
|
||||
count: s.results.length,
|
||||
}));
|
||||
setAvailableSources(sourcesWithResults);
|
||||
|
||||
// Clear validation status after 3 seconds
|
||||
setTimeout(() => setValidationStatus(''), 3000);
|
||||
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));
|
||||
|
||||
switch (data.type) {
|
||||
case 'progress':
|
||||
if (data.stage === 'searching') {
|
||||
setSearchStage('searching');
|
||||
setCheckedSources(data.checkedSources);
|
||||
} else if (data.stage === 'checking') {
|
||||
setSearchStage('checking');
|
||||
setCheckedVideos(data.checkedVideos);
|
||||
setTotalVideos(data.totalVideos);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'videos':
|
||||
// Add new videos immediately - NO DELAY
|
||||
const newVideos = data.videos.map((video: any) => ({
|
||||
...video,
|
||||
sourceName: getSourceName(video.source),
|
||||
isNew: true,
|
||||
addedAt: Date.now(), // Track when video was added
|
||||
}));
|
||||
|
||||
console.log('📹 收到新视频:', newVideos.length, '个');
|
||||
|
||||
// Add to allVideos array
|
||||
allVideos.push(...newVideos);
|
||||
|
||||
console.log('🎬 当前总视频数:', allVideos.length);
|
||||
|
||||
// Update state with all videos
|
||||
setResults([...allVideos]);
|
||||
|
||||
// Update progress
|
||||
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);
|
||||
});
|
||||
|
||||
// Update available sources display
|
||||
const sourcesArray = Array.from(sourceVideoCounts.entries()).map(([sourceId, count]) => ({
|
||||
id: sourceId,
|
||||
name: getSourceName(sourceId),
|
||||
count,
|
||||
}));
|
||||
setAvailableSources(sourcesArray);
|
||||
|
||||
// Remove animation flag only for these new videos after delay
|
||||
setTimeout(() => {
|
||||
setResults(prev => prev.map(v => {
|
||||
// Only remove isNew flag from videos that were just added
|
||||
const wasJustAdded = newVideos.some((nv: any) =>
|
||||
nv.vod_id === v.vod_id && nv.source === v.source && nv.addedAt === v.addedAt
|
||||
);
|
||||
if (wasJustAdded) {
|
||||
return { ...v, isNew: false };
|
||||
}
|
||||
return v;
|
||||
}));
|
||||
}, 300);
|
||||
break;
|
||||
|
||||
case 'complete':
|
||||
setCheckedVideos(data.totalVideos);
|
||||
setLoading(false);
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
throw new Error(data.error);
|
||||
}
|
||||
} catch (err) {
|
||||
// Skip invalid JSON lines
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Only show error if not aborted by user
|
||||
if (error.name !== 'AbortError') {
|
||||
console.error('Search error:', error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Search error:', error);
|
||||
setValidationStatus('搜索失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
} finally {
|
||||
setCurrentSource('');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -95,6 +201,12 @@ export default function Home() {
|
||||
};
|
||||
|
||||
const handleVideoClick = (video: any) => {
|
||||
// Abort ongoing search when user clicks a video
|
||||
if (abortControllerRef.current && loading) {
|
||||
abortControllerRef.current.abort();
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
id: video.vod_id,
|
||||
source: video.source,
|
||||
@@ -107,7 +219,7 @@ export default function Home() {
|
||||
<div className="min-h-screen">
|
||||
{/* Glass Navbar */}
|
||||
<nav className="sticky top-4 z-50 mx-4 mt-4 mb-8">
|
||||
<div className="max-w-7xl mx-auto bg-[var(--glass-bg)] backdrop-blur-[25px] saturate-[180%] [-webkit-backdrop-filter:blur(25px)_saturate(180%)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-md)] px-6 py-4 transition-all duration-[var(--transition-fluid)]">
|
||||
<div className="max-w-7xl mx-auto bg-[var(--glass-bg)] backdrop-blur-[25px] saturate-[180%] [-webkit-backdrop-filter:blur(25px)_saturate(180%)] border border-[var(--glass-border)] shadow-[var(--shadow-md)] px-6 py-4 transition-all duration-[var(--transition-fluid)]" style={{ borderRadius: 'var(--radius-2xl)' }}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 relative flex items-center justify-center">
|
||||
@@ -151,47 +263,66 @@ export default function Home() {
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="搜索电影、电视剧、综艺..."
|
||||
className="text-lg pr-32"
|
||||
disabled={loading}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
disabled={loading || !query.trim()}
|
||||
variant="primary"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 px-8"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none"/>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"/>
|
||||
</svg>
|
||||
检测中...
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-2">
|
||||
<Icons.Search size={20} />
|
||||
搜索
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-2">
|
||||
<Icons.Search size={20} />
|
||||
搜索
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
{/* Validation Status */}
|
||||
{validationStatus && (
|
||||
<div className="mt-3 text-sm text-[var(--text-color-secondary)] animate-fade-in">
|
||||
{validationStatus}
|
||||
|
||||
{/* Loading Animation - Replaces search bar content */}
|
||||
{loading && (
|
||||
<div className="mt-4">
|
||||
<SearchLoadingAnimation
|
||||
currentSource={currentSource}
|
||||
checkedSources={checkedSources}
|
||||
totalSources={16}
|
||||
checkedVideos={checkedVideos}
|
||||
totalVideos={totalVideos}
|
||||
stage={searchStage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Results Section */}
|
||||
{results.length > 0 && (
|
||||
{(results.length >= 10 || (!loading && results.length > 0)) && (
|
||||
<div className="animate-fade-in">
|
||||
<div className="flex flex-col gap-4 mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<h3 className="text-2xl font-bold text-[var(--text-color)] flex items-center gap-3">
|
||||
<span>搜索结果</span>
|
||||
<Badge variant="primary">{results.length} 个视频</Badge>
|
||||
</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
{loading && (
|
||||
<>
|
||||
<Badge variant="secondary" className="text-sm">
|
||||
<span className="flex items-center gap-2">
|
||||
<Icons.Search size={14} />
|
||||
已检测 {checkedVideos}/{totalVideos}
|
||||
</span>
|
||||
</Badge>
|
||||
<Badge variant="primary" className="text-sm">
|
||||
<span className="flex items-center gap-2">
|
||||
<Icons.Check size={14} />
|
||||
可用视频 {results.length}/{totalVideos}
|
||||
</span>
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
{!loading && (
|
||||
<Badge variant="primary">{results.length} 个视频</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Available Sources */}
|
||||
@@ -221,10 +352,10 @@ export default function Home() {
|
||||
<Card
|
||||
key={`${video.vod_id}-${index}`}
|
||||
onClick={() => handleVideoClick(video)}
|
||||
className="p-0 overflow-hidden group"
|
||||
className={`p-0 overflow-hidden group cursor-pointer ${video.isNew ? 'animate-scale-in' : ''}`}
|
||||
>
|
||||
{/* Poster */}
|
||||
<div className="relative aspect-[2/3] bg-[color-mix(in_srgb,var(--glass-bg)_50%,transparent)] overflow-hidden rounded-t-[var(--radius-2xl)]">
|
||||
<div className="relative aspect-[2/3] bg-[color-mix(in_srgb,var(--glass-bg)_50%,transparent)] overflow-hidden" style={{ borderRadius: 'var(--radius-2xl) var(--radius-2xl) 0 0' }}>
|
||||
{video.vod_pic ? (
|
||||
<img
|
||||
src={video.vod_pic}
|
||||
@@ -282,11 +413,11 @@ export default function Home() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!loading && results.length === 0 && !query && (
|
||||
{/* Empty State - Initial Homepage */}
|
||||
{!loading && !hasSearched && (
|
||||
<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 rounded-[var(--radius-full)] bg-[var(--glass-bg)] backdrop-blur-xl border border-[var(--glass-border)] mb-6">
|
||||
<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" style={{ borderRadius: '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">
|
||||
@@ -324,18 +455,27 @@ export default function Home() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No Results */}
|
||||
{!loading && results.length === 0 && query && (
|
||||
{/* No Results - After Search */}
|
||||
{!loading && hasSearched && results.length === 0 && (
|
||||
<div className="text-center py-20 animate-fade-in">
|
||||
<div className="inline-flex items-center justify-center w-32 h-32 rounded-[var(--radius-full)] bg-[var(--glass-bg)] backdrop-blur-xl border border-[var(--glass-border)] mb-6">
|
||||
<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" style={{ borderRadius: 'var(--radius-full)' }}>
|
||||
<Icons.Search 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)]">
|
||||
<p className="text-lg text-[var(--text-color-secondary)] mb-6">
|
||||
试试其他关键词或检查拼写
|
||||
</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
setHasSearched(false);
|
||||
setQuery('');
|
||||
}}
|
||||
>
|
||||
返回首页
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface SearchLoadingAnimationProps {
|
||||
currentSource?: string;
|
||||
checkedSources?: number;
|
||||
totalSources?: number;
|
||||
checkedVideos?: number;
|
||||
totalVideos?: number;
|
||||
stage?: 'searching' | 'checking';
|
||||
}
|
||||
|
||||
export function SearchLoadingAnimation({
|
||||
currentSource,
|
||||
checkedSources = 0,
|
||||
totalSources = 16,
|
||||
checkedVideos = 0,
|
||||
totalVideos = 0,
|
||||
stage = 'searching'
|
||||
}: SearchLoadingAnimationProps) {
|
||||
const [dots, setDots] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const dotInterval = setInterval(() => {
|
||||
setDots((prev) => (prev.length >= 3 ? '' : prev + '.'));
|
||||
}, 500);
|
||||
return () => clearInterval(dotInterval);
|
||||
}, []);
|
||||
|
||||
// Calculate unified progress (0-100%)
|
||||
// Stage 1: Search sources (0-60%)
|
||||
// Stage 2: Check videos (60-100%)
|
||||
let progress = 0;
|
||||
let statusText = '';
|
||||
|
||||
if (stage === 'searching') {
|
||||
progress = totalSources > 0 ? (checkedSources / totalSources) * 60 : 0;
|
||||
statusText = `${checkedSources}/${totalSources} 个源`;
|
||||
} else if (stage === 'checking') {
|
||||
progress = 60 + (totalVideos > 0 ? (checkedVideos / totalVideos) * 40 : 0);
|
||||
statusText = `${checkedVideos}/${totalVideos} 个视频`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-3 animate-fade-in">
|
||||
{/* Loading Message with Icon */}
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
{/* Spinning Icon */}
|
||||
<svg className="w-5 h-5 animate-spin-slow" viewBox="0 0 24 24">
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
fill="none"
|
||||
stroke="var(--accent-color)"
|
||||
strokeWidth="3"
|
||||
strokeDasharray="60 40"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<span className="text-sm font-medium text-[var(--text-color-secondary)]">
|
||||
{stage === 'searching' ? '正在搜索视频源' : '正在检测视频可用性'}{dots}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar - Unified 0-100% */}
|
||||
<div className="w-full">
|
||||
<div
|
||||
className="h-1 bg-[color-mix(in_srgb,var(--glass-bg)_50%,transparent)] overflow-hidden"
|
||||
style={{ borderRadius: 'var(--radius-full)' }}
|
||||
>
|
||||
<div
|
||||
className="h-full bg-[var(--accent-color)] transition-all duration-500 ease-out relative"
|
||||
style={{
|
||||
width: `${progress}%`,
|
||||
borderRadius: 'var(--radius-full)'
|
||||
}}
|
||||
>
|
||||
{/* Shimmer Effect */}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/30 to-transparent animate-shimmer"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Info - Real-time count */}
|
||||
<div className="flex items-center justify-between mt-2 text-xs text-[var(--text-color-secondary)]">
|
||||
<span>{statusText}</span>
|
||||
<span className="font-medium">{Math.round(progress)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -49,7 +49,6 @@ async function checkVideoUrl(url: string, retries = MAX_RETRIES): Promise<boolea
|
||||
} catch (error) {
|
||||
// If last attempt, return false
|
||||
if (attempt === retries) {
|
||||
console.error(`Failed to check URL after ${retries + 1} attempts:`, error);
|
||||
return false;
|
||||
}
|
||||
// Wait before retry
|
||||
@@ -77,12 +76,60 @@ function extractFirstVideoUrl(video: any): string | null {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to extract video URL:', error);
|
||||
// Silent error
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a single video is playable
|
||||
*/
|
||||
export async function checkVideoAvailability(video: any): Promise<boolean> {
|
||||
const videoUrl = extractFirstVideoUrl(video);
|
||||
|
||||
if (!videoUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return await checkVideoUrl(videoUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check multiple videos in parallel with concurrency limit
|
||||
*/
|
||||
export async function checkMultipleVideos(
|
||||
videos: any[],
|
||||
concurrency: number = 10,
|
||||
onProgress?: (checked: number, total: number) => void
|
||||
): Promise<any[]> {
|
||||
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);
|
||||
const results = await Promise.all(
|
||||
batch.map(async (video) => {
|
||||
const isAvailable = await checkVideoAvailability(video);
|
||||
checkedCount++;
|
||||
|
||||
// Report progress
|
||||
if (onProgress) {
|
||||
onProgress(checkedCount, videos.length);
|
||||
}
|
||||
|
||||
return isAvailable ? video : null;
|
||||
})
|
||||
);
|
||||
|
||||
// Add available videos to result
|
||||
availableVideos.push(...results.filter(v => v !== null));
|
||||
}
|
||||
|
||||
return availableVideos;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a source is available by testing a sample video
|
||||
*/
|
||||
@@ -110,12 +157,9 @@ export async function checkSourceAvailability(
|
||||
|
||||
if (!videoUrl) continue;
|
||||
|
||||
console.log(`Checking source ${sourceName} with URL:`, videoUrl.substring(0, 50) + '...');
|
||||
|
||||
const isAvailable = await checkVideoUrl(videoUrl);
|
||||
|
||||
if (isAvailable) {
|
||||
console.log(`✅ Source ${sourceName} is AVAILABLE (checked in ${Date.now() - startTime}ms)`);
|
||||
return {
|
||||
sourceId,
|
||||
sourceName,
|
||||
@@ -126,7 +170,6 @@ export async function checkSourceAvailability(
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`❌ Source ${sourceName} is UNAVAILABLE (checked in ${Date.now() - startTime}ms)`);
|
||||
return {
|
||||
sourceId,
|
||||
sourceName,
|
||||
|
||||
Reference in New Issue
Block a user