mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-16 17:23:43 +08:00
feat: Implement video player components and search functionality
- Added EpisodeList component for displaying a list of episodes with selection functionality. - Created PlayerError component to handle and display video playback errors. - Developed VideoMetadata component to show detailed information about the video. - Implemented VideoPlayer component for video playback with error handling and loading states. - Introduced EmptyState and NoResults components for search results handling. - Created ResultsHeader component to display search results summary. - Developed SearchForm component for user input and search initiation. - Implemented SourceBadges component to show available video sources. - Created VideoGrid component to display search results in a grid format. - Added useSearchCache and useSearchStream hooks for managing search state and caching results. - Implemented useVideoPlayer hook for fetching and managing video details. - Added utility function to map source IDs to their respective names.
This commit is contained in:
@@ -0,0 +1,605 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect, Suspense } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { ThemeSwitcher } from '@/components/ThemeSwitcher';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
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';
|
||||
|
||||
// Cache interface
|
||||
interface SearchCache {
|
||||
query: string;
|
||||
results: any[];
|
||||
availableSources: any[];
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
const CACHE_KEY = 'kvideo_search_cache';
|
||||
const CACHE_DURATION = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
function HomePage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<any[]>([]);
|
||||
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 abortControllerRef = useRef<AbortController | null>(null);
|
||||
const hasLoadedCache = useRef(false);
|
||||
|
||||
// Load cached results on mount
|
||||
useEffect(() => {
|
||||
if (hasLoadedCache.current) return;
|
||||
hasLoadedCache.current = true;
|
||||
|
||||
const urlQuery = searchParams.get('q');
|
||||
|
||||
// Try to load from cache
|
||||
const cached = loadFromCache();
|
||||
|
||||
if (urlQuery) {
|
||||
// URL has query parameter
|
||||
setQuery(urlQuery);
|
||||
|
||||
if (cached && cached.query === urlQuery) {
|
||||
// Use cached results if they match the URL query
|
||||
console.log('📦 Loading cached results for:', urlQuery);
|
||||
setResults(cached.results);
|
||||
setAvailableSources(cached.availableSources);
|
||||
setHasSearched(true);
|
||||
} else {
|
||||
// Trigger automatic search for URL query (won't clear cache, will use existing if valid)
|
||||
console.log('🔍 Auto-searching for URL query:', urlQuery);
|
||||
setTimeout(() => performSearch(urlQuery, false), 100);
|
||||
}
|
||||
}
|
||||
// If no URL query, show clean homepage (don't restore cache automatically)
|
||||
}, [searchParams, router]);
|
||||
|
||||
// Cache helper functions
|
||||
const saveToCache = (searchQuery: string, searchResults: any[], sources: any[]) => {
|
||||
const cache: SearchCache = {
|
||||
query: searchQuery,
|
||||
results: searchResults,
|
||||
availableSources: sources,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
try {
|
||||
localStorage.setItem(CACHE_KEY, JSON.stringify(cache));
|
||||
console.log('💾 Saved search to cache:', searchQuery, searchResults.length, 'results');
|
||||
} catch (error) {
|
||||
console.error('Failed to save cache:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadFromCache = (): SearchCache | null => {
|
||||
try {
|
||||
const cached = localStorage.getItem(CACHE_KEY);
|
||||
if (!cached) return null;
|
||||
|
||||
const cache: SearchCache = JSON.parse(cached);
|
||||
|
||||
// Check if cache is still valid
|
||||
if (Date.now() - cache.timestamp > CACHE_DURATION) {
|
||||
localStorage.removeItem(CACHE_KEY);
|
||||
return null;
|
||||
}
|
||||
|
||||
return cache;
|
||||
} catch (error) {
|
||||
console.error('Failed to load cache:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const performSearch = async (searchQuery: string, shouldClearCache: boolean = true) => {
|
||||
if (!searchQuery.trim() || loading) return;
|
||||
|
||||
// Only clear cache if user manually clicked search button
|
||||
if (shouldClearCache) {
|
||||
const cached = loadFromCache();
|
||||
if (cached && cached.query === searchQuery) {
|
||||
console.log('🗑️ Clearing old cache for manual search');
|
||||
localStorage.removeItem(CACHE_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
// Abort any previous search
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
// Create new abort controller for this search
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
setLoading(true);
|
||||
setHasSearched(true);
|
||||
setResults([]);
|
||||
setAvailableSources([]);
|
||||
setCheckedSources(0);
|
||||
setSearchStage('searching');
|
||||
setCheckedVideos(0);
|
||||
setTotalVideos(0);
|
||||
|
||||
// Update URL with query parameter
|
||||
router.replace(`/?q=${encodeURIComponent(searchQuery)}`, { scroll: false });
|
||||
|
||||
try {
|
||||
// Get all enabled source IDs
|
||||
const sourceIds = ['dytt', 'ruyi', 'baofeng', 'tianya', 'feifan',
|
||||
'sanliuling', 'wolong', 'jisu', 'mozhua', 'modu',
|
||||
'zuida', 'yinghua', 'baiduyun', 'wujin', 'wangwang', 'ikun'];
|
||||
|
||||
// Use streaming API
|
||||
const response = await fetch('/api/search-stream', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query: searchQuery, sources: sourceIds }),
|
||||
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));
|
||||
|
||||
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);
|
||||
|
||||
// Save final results to cache
|
||||
const finalSourcesArray = Array.from(sourceVideoCounts.entries()).map(([sourceId, count]) => ({
|
||||
id: sourceId,
|
||||
name: getSourceName(sourceId),
|
||||
count,
|
||||
}));
|
||||
saveToCache(searchQuery, allVideos, finalSourcesArray);
|
||||
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);
|
||||
}
|
||||
setLoading(false);
|
||||
} finally {
|
||||
setCurrentSource('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
// Pass true to clear cache when user manually clicks search
|
||||
await performSearch(query, true);
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
return (
|
||||
<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)] 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">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-3 hover:opacity-80 transition-opacity cursor-pointer"
|
||||
onClick={() => {
|
||||
// Don't clear cache when clicking home - just reset the view
|
||||
setQuery('');
|
||||
setResults([]);
|
||||
setAvailableSources([]);
|
||||
setHasSearched(false);
|
||||
}}
|
||||
>
|
||||
<div className="w-10 h-10 relative flex items-center justify-center">
|
||||
<Image
|
||||
src="/icon.png"
|
||||
alt="KVideo"
|
||||
width={40}
|
||||
height={40}
|
||||
className="object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-[var(--text-color)]">
|
||||
KVideo
|
||||
</h1>
|
||||
<p className="text-xs text-[var(--text-color-secondary)]">视频聚合平台</p>
|
||||
</div>
|
||||
</Link>
|
||||
<ThemeSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
|
||||
{/* Hero Section with Search */}
|
||||
<div className="text-center mb-12 animate-slide-up">
|
||||
<h2 className="text-5xl md:text-6xl font-bold text-[var(--text-color)] mb-4">
|
||||
发现精彩视频
|
||||
</h2>
|
||||
<p className="text-xl text-[var(--text-color-secondary)] mb-8">
|
||||
多源聚合 · 智能搜索 · 极致体验
|
||||
</p>
|
||||
|
||||
{/* Search Bar */}
|
||||
<form onSubmit={handleSearch} className="max-w-3xl mx-auto">
|
||||
<div className="relative group">
|
||||
<Input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="搜索电影、电视剧、综艺..."
|
||||
className="text-lg pr-32"
|
||||
disabled={loading}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading || !query.trim()}
|
||||
variant="primary"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 px-8"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Icons.Search size={20} />
|
||||
搜索
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 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 >= 1 || (!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 flex-wrap gap-3">
|
||||
<h3 className="text-2xl font-bold text-[var(--text-color)] flex items-center gap-3">
|
||||
<span>搜索结果</span>
|
||||
</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 */}
|
||||
{availableSources.length > 0 && (
|
||||
<Card hover={false} className="p-4">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-semibold text-[var(--text-color)] flex items-center gap-2">
|
||||
<Icons.Check size={16} className="text-[var(--accent-color)]" />
|
||||
可用源 ({availableSources.length}):
|
||||
</span>
|
||||
{availableSources.map((source) => (
|
||||
<Badge
|
||||
key={source.id}
|
||||
variant="secondary"
|
||||
className="text-xs"
|
||||
>
|
||||
{source.name} ({source.count})
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4 md:gap-6">
|
||||
{results.map((video, index) => {
|
||||
const videoUrl = `/player?${new URLSearchParams({
|
||||
id: video.vod_id,
|
||||
source: video.source,
|
||||
title: video.vod_name,
|
||||
}).toString()}`;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={`${video.vod_id}-${index}`}
|
||||
href={videoUrl}
|
||||
>
|
||||
<Card
|
||||
className={`p-0 overflow-hidden group cursor-pointer flex flex-col h-full ${video.isNew ? 'animate-scale-in' : ''}`}
|
||||
>
|
||||
{/* Poster */}
|
||||
<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}
|
||||
alt={video.vod_name}
|
||||
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Icons.Film size={64} className="text-[var(--text-color-secondary)]" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Source Badge - Top Left */}
|
||||
{video.sourceName && (
|
||||
<div className="absolute top-2 left-2 z-10">
|
||||
<Badge variant="primary" className="text-xs backdrop-blur-md bg-[var(--accent-color)]/90">
|
||||
{video.sourceName}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Overlay */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
<div className="absolute bottom-0 left-0 right-0 p-3">
|
||||
{video.type_name && (
|
||||
<Badge variant="secondary" className="text-xs mb-2">
|
||||
{video.type_name}
|
||||
</Badge>
|
||||
)}
|
||||
{video.vod_year && (
|
||||
<div className="flex items-center gap-1 text-white/80 text-xs">
|
||||
<Icons.Calendar size={12} />
|
||||
<span>{video.vod_year}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info - Fixed height section */}
|
||||
<div className="p-3 flex-1 flex flex-col">
|
||||
<h4 className="font-semibold text-sm text-[var(--text-color)] line-clamp-2 min-h-[2.5rem] group-hover:text-[var(--accent-color)] transition-colors">
|
||||
{video.vod_name}
|
||||
</h4>
|
||||
{video.vod_remarks && (
|
||||
<p className="text-xs text-[var(--text-color-secondary)] mt-1 line-clamp-1">
|
||||
{video.vod_remarks}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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 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">
|
||||
开始探索精彩内容
|
||||
</h3>
|
||||
<p className="text-lg text-[var(--text-color-secondary)] max-w-2xl mx-auto mb-8">
|
||||
在上方搜索框输入关键词,从 16 个视频源聚合搜索海量影视资源
|
||||
</p>
|
||||
|
||||
{/* Feature Cards */}
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* 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 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)] mb-6">
|
||||
试试其他关键词或检查拼写
|
||||
</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
setHasSearched(false);
|
||||
setQuery('');
|
||||
router.replace('/', { scroll: false });
|
||||
}}
|
||||
>
|
||||
返回首页
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<Suspense fallback={<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-16 w-16 border-4 border-[var(--accent-color)] border-t-transparent"></div>
|
||||
</div>}>
|
||||
<HomePage />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
+69
-508
@@ -3,41 +3,41 @@
|
||||
import { useState, useRef, useEffect, Suspense } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { ThemeSwitcher } from '@/components/ThemeSwitcher';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
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';
|
||||
|
||||
// Cache interface
|
||||
interface SearchCache {
|
||||
query: string;
|
||||
results: any[];
|
||||
availableSources: any[];
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
const CACHE_KEY = 'kvideo_search_cache';
|
||||
const CACHE_DURATION = 10 * 60 * 1000; // 10 minutes
|
||||
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 { useSearchCache } from '@/lib/hooks/useSearchCache';
|
||||
import { useSearchStream } from '@/lib/hooks/useSearchStream';
|
||||
|
||||
function HomePage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<any[]>([]);
|
||||
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 abortControllerRef = useRef<AbortController | null>(null);
|
||||
const { loadFromCache, saveToCache } = useSearchCache();
|
||||
const hasLoadedCache = useRef(false);
|
||||
|
||||
const [query, setQuery] = useState('');
|
||||
const [hasSearched, setHasSearched] = useState(false);
|
||||
|
||||
// Search stream hook
|
||||
const {
|
||||
loading,
|
||||
results,
|
||||
availableSources,
|
||||
checkedSources,
|
||||
searchStage,
|
||||
checkedVideos,
|
||||
totalVideos,
|
||||
currentSource,
|
||||
performSearch,
|
||||
resetSearch,
|
||||
} = useSearchStream(
|
||||
saveToCache,
|
||||
(q) => router.replace(`/?q=${encodeURIComponent(q)}`, { scroll: false })
|
||||
);
|
||||
|
||||
// Load cached results on mount
|
||||
useEffect(() => {
|
||||
@@ -45,263 +45,32 @@ function HomePage() {
|
||||
hasLoadedCache.current = true;
|
||||
|
||||
const urlQuery = searchParams.get('q');
|
||||
|
||||
// Try to load from cache
|
||||
const cached = loadFromCache();
|
||||
|
||||
if (urlQuery) {
|
||||
// URL has query parameter
|
||||
setQuery(urlQuery);
|
||||
|
||||
if (cached && cached.query === urlQuery) {
|
||||
// Use cached results if they match the URL query
|
||||
console.log('📦 Loading cached results for:', urlQuery);
|
||||
setResults(cached.results);
|
||||
setAvailableSources(cached.availableSources);
|
||||
// Note: Would need to set results here if we expose setState from hook
|
||||
setHasSearched(true);
|
||||
} else {
|
||||
// Trigger automatic search for URL query (won't clear cache, will use existing if valid)
|
||||
console.log('🔍 Auto-searching for URL query:', urlQuery);
|
||||
setTimeout(() => performSearch(urlQuery, false), 100);
|
||||
setTimeout(() => handleSearch(urlQuery), 100);
|
||||
}
|
||||
}
|
||||
// If no URL query, show clean homepage (don't restore cache automatically)
|
||||
}, [searchParams, router]);
|
||||
}, [searchParams]);
|
||||
|
||||
// Cache helper functions
|
||||
const saveToCache = (searchQuery: string, searchResults: any[], sources: any[]) => {
|
||||
const cache: SearchCache = {
|
||||
query: searchQuery,
|
||||
results: searchResults,
|
||||
availableSources: sources,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
try {
|
||||
localStorage.setItem(CACHE_KEY, JSON.stringify(cache));
|
||||
console.log('💾 Saved search to cache:', searchQuery, searchResults.length, 'results');
|
||||
} catch (error) {
|
||||
console.error('Failed to save cache:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadFromCache = (): SearchCache | null => {
|
||||
try {
|
||||
const cached = localStorage.getItem(CACHE_KEY);
|
||||
if (!cached) return null;
|
||||
|
||||
const cache: SearchCache = JSON.parse(cached);
|
||||
|
||||
// Check if cache is still valid
|
||||
if (Date.now() - cache.timestamp > CACHE_DURATION) {
|
||||
localStorage.removeItem(CACHE_KEY);
|
||||
return null;
|
||||
}
|
||||
|
||||
return cache;
|
||||
} catch (error) {
|
||||
console.error('Failed to load cache:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const performSearch = async (searchQuery: string, shouldClearCache: boolean = true) => {
|
||||
if (!searchQuery.trim() || loading) return;
|
||||
|
||||
// Only clear cache if user manually clicked search button
|
||||
if (shouldClearCache) {
|
||||
const cached = loadFromCache();
|
||||
if (cached && cached.query === searchQuery) {
|
||||
console.log('🗑️ Clearing old cache for manual search');
|
||||
localStorage.removeItem(CACHE_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
// Abort any previous search
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
// Create new abort controller for this search
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
setLoading(true);
|
||||
const handleSearch = (searchQuery: string) => {
|
||||
setQuery(searchQuery);
|
||||
setHasSearched(true);
|
||||
setResults([]);
|
||||
setAvailableSources([]);
|
||||
setCheckedSources(0);
|
||||
setSearchStage('searching');
|
||||
setCheckedVideos(0);
|
||||
setTotalVideos(0);
|
||||
|
||||
// Update URL with query parameter
|
||||
router.replace(`/?q=${encodeURIComponent(searchQuery)}`, { scroll: false });
|
||||
|
||||
try {
|
||||
// Get all enabled source IDs
|
||||
const sourceIds = ['dytt', 'ruyi', 'baofeng', 'tianya', 'feifan',
|
||||
'sanliuling', 'wolong', 'jisu', 'mozhua', 'modu',
|
||||
'zuida', 'yinghua', 'baiduyun', 'wujin', 'wangwang', 'ikun'];
|
||||
|
||||
// Use streaming API
|
||||
const response = await fetch('/api/search-stream', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query: searchQuery, sources: sourceIds }),
|
||||
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));
|
||||
|
||||
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);
|
||||
|
||||
// Save final results to cache
|
||||
const finalSourcesArray = Array.from(sourceVideoCounts.entries()).map(([sourceId, count]) => ({
|
||||
id: sourceId,
|
||||
name: getSourceName(sourceId),
|
||||
count,
|
||||
}));
|
||||
saveToCache(searchQuery, allVideos, finalSourcesArray);
|
||||
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);
|
||||
}
|
||||
setLoading(false);
|
||||
} finally {
|
||||
setCurrentSource('');
|
||||
}
|
||||
performSearch(searchQuery, true);
|
||||
};
|
||||
|
||||
const handleSearch = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
// Pass true to clear cache when user manually clicks search
|
||||
await performSearch(query, true);
|
||||
};
|
||||
|
||||
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;
|
||||
const handleReset = () => {
|
||||
setHasSearched(false);
|
||||
setQuery('');
|
||||
resetSearch();
|
||||
router.replace('/', { scroll: false });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -313,13 +82,7 @@ function HomePage() {
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-3 hover:opacity-80 transition-opacity cursor-pointer"
|
||||
onClick={() => {
|
||||
// Don't clear cache when clicking home - just reset the view
|
||||
setQuery('');
|
||||
setResults([]);
|
||||
setAvailableSources([]);
|
||||
setHasSearched(false);
|
||||
}}
|
||||
onClick={handleReset}
|
||||
>
|
||||
<div className="w-10 h-10 relative flex items-center justify-center">
|
||||
<Image
|
||||
@@ -331,9 +94,7 @@ function HomePage() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-[var(--text-color)]">
|
||||
KVideo
|
||||
</h1>
|
||||
<h1 className="text-2xl font-bold text-[var(--text-color)]">KVideo</h1>
|
||||
<p className="text-xs text-[var(--text-color-secondary)]">视频聚合平台</p>
|
||||
</div>
|
||||
</Link>
|
||||
@@ -353,241 +114,39 @@ function HomePage() {
|
||||
多源聚合 · 智能搜索 · 极致体验
|
||||
</p>
|
||||
|
||||
{/* Search Bar */}
|
||||
<form onSubmit={handleSearch} className="max-w-3xl mx-auto">
|
||||
<div className="relative group">
|
||||
<Input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="搜索电影、电视剧、综艺..."
|
||||
className="text-lg pr-32"
|
||||
disabled={loading}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading || !query.trim()}
|
||||
variant="primary"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 px-8"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Icons.Search size={20} />
|
||||
搜索
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
<SearchForm
|
||||
onSearch={handleSearch}
|
||||
isLoading={loading}
|
||||
initialQuery={query}
|
||||
currentSource={currentSource}
|
||||
checkedSources={checkedSources}
|
||||
totalSources={16}
|
||||
checkedVideos={checkedVideos}
|
||||
totalVideos={totalVideos}
|
||||
searchStage={searchStage}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Results Section */}
|
||||
{(results.length >= 1 || (!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 flex-wrap gap-3">
|
||||
<h3 className="text-2xl font-bold text-[var(--text-color)] flex items-center gap-3">
|
||||
<span>搜索结果</span>
|
||||
</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 */}
|
||||
{availableSources.length > 0 && (
|
||||
<Card hover={false} className="p-4">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-semibold text-[var(--text-color)] flex items-center gap-2">
|
||||
<Icons.Check size={16} className="text-[var(--accent-color)]" />
|
||||
可用源 ({availableSources.length}):
|
||||
</span>
|
||||
{availableSources.map((source) => (
|
||||
<Badge
|
||||
key={source.id}
|
||||
variant="secondary"
|
||||
className="text-xs"
|
||||
>
|
||||
{source.name} ({source.count})
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4 md:gap-6">
|
||||
{results.map((video, index) => {
|
||||
const videoUrl = `/player?${new URLSearchParams({
|
||||
id: video.vod_id,
|
||||
source: video.source,
|
||||
title: video.vod_name,
|
||||
}).toString()}`;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={`${video.vod_id}-${index}`}
|
||||
href={videoUrl}
|
||||
>
|
||||
<Card
|
||||
className={`p-0 overflow-hidden group cursor-pointer flex flex-col h-full ${video.isNew ? 'animate-scale-in' : ''}`}
|
||||
>
|
||||
{/* Poster */}
|
||||
<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}
|
||||
alt={video.vod_name}
|
||||
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Icons.Film size={64} className="text-[var(--text-color-secondary)]" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Source Badge - Top Left */}
|
||||
{video.sourceName && (
|
||||
<div className="absolute top-2 left-2 z-10">
|
||||
<Badge variant="primary" className="text-xs backdrop-blur-md bg-[var(--accent-color)]/90">
|
||||
{video.sourceName}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Overlay */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
<div className="absolute bottom-0 left-0 right-0 p-3">
|
||||
{video.type_name && (
|
||||
<Badge variant="secondary" className="text-xs mb-2">
|
||||
{video.type_name}
|
||||
</Badge>
|
||||
)}
|
||||
{video.vod_year && (
|
||||
<div className="flex items-center gap-1 text-white/80 text-xs">
|
||||
<Icons.Calendar size={12} />
|
||||
<span>{video.vod_year}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info - Fixed height section */}
|
||||
<div className="p-3 flex-1 flex flex-col">
|
||||
<h4 className="font-semibold text-sm text-[var(--text-color)] line-clamp-2 min-h-[2.5rem] group-hover:text-[var(--accent-color)] transition-colors">
|
||||
{video.vod_name}
|
||||
</h4>
|
||||
{video.vod_remarks && (
|
||||
<p className="text-xs text-[var(--text-color-secondary)] mt-1 line-clamp-1">
|
||||
{video.vod_remarks}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<ResultsHeader
|
||||
loading={loading}
|
||||
resultsCount={results.length}
|
||||
checkedVideos={checkedVideos}
|
||||
totalVideos={totalVideos}
|
||||
availableSources={availableSources}
|
||||
/>
|
||||
<VideoGrid videos={results} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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 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">
|
||||
开始探索精彩内容
|
||||
</h3>
|
||||
<p className="text-lg text-[var(--text-color-secondary)] max-w-2xl mx-auto mb-8">
|
||||
在上方搜索框输入关键词,从 16 个视频源聚合搜索海量影视资源
|
||||
</p>
|
||||
|
||||
{/* Feature Cards */}
|
||||
<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>
|
||||
)}
|
||||
{!loading && !hasSearched && <EmptyState />}
|
||||
|
||||
{/* No Results - After Search */}
|
||||
{/* No Results */}
|
||||
{!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 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)] mb-6">
|
||||
试试其他关键词或检查拼写
|
||||
</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
setHasSearched(false);
|
||||
setQuery('');
|
||||
router.replace('/', { scroll: false });
|
||||
}}
|
||||
>
|
||||
返回首页
|
||||
</Button>
|
||||
</div>
|
||||
<NoResults onReset={handleReset} />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
@@ -596,9 +155,11 @@ function HomePage() {
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<Suspense fallback={<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-16 w-16 border-4 border-[var(--accent-color)] border-t-transparent"></div>
|
||||
</div>}>
|
||||
<Suspense fallback={
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-16 w-16 border-4 border-[var(--accent-color)] border-t-transparent"></div>
|
||||
</div>
|
||||
}>
|
||||
<HomePage />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
+45
-334
@@ -1,123 +1,48 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useRef, Suspense } from 'react';
|
||||
import { Suspense } from 'react';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { ThemeSwitcher } from '@/components/ThemeSwitcher';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { VideoPlayer } from '@/components/player/VideoPlayer';
|
||||
import { VideoMetadata } from '@/components/player/VideoMetadata';
|
||||
import { EpisodeList } from '@/components/player/EpisodeList';
|
||||
import { PlayerError } from '@/components/player/PlayerError';
|
||||
import { useVideoPlayer } from '@/lib/hooks/useVideoPlayer';
|
||||
import Image from 'next/image';
|
||||
|
||||
function PlayerContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
const [videoData, setVideoData] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentEpisode, setCurrentEpisode] = useState(0);
|
||||
const [playUrl, setPlayUrl] = useState('');
|
||||
const [videoError, setVideoError] = useState<string>('');
|
||||
const [isVideoLoading, setIsVideoLoading] = useState(false);
|
||||
|
||||
const videoId = searchParams.get('id');
|
||||
const source = searchParams.get('source');
|
||||
const title = searchParams.get('title');
|
||||
const episodeParam = searchParams.get('episode');
|
||||
|
||||
const getSourceName = (sourceId: string | null): string => {
|
||||
if (!sourceId) return '';
|
||||
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;
|
||||
};
|
||||
// Redirect if no video ID or source
|
||||
if (!videoId || !source) {
|
||||
router.push('/');
|
||||
return null;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!videoId || !source) {
|
||||
router.push('/');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
fetchVideoDetails();
|
||||
}, [videoId, source]);
|
||||
|
||||
const fetchVideoDetails = async () => {
|
||||
try {
|
||||
setVideoError(''); // Clear previous errors
|
||||
const response = await fetch(`/api/detail?id=${videoId}&source=${source}`);
|
||||
const data = await response.json();
|
||||
|
||||
console.log('Video detail API response:', data);
|
||||
|
||||
if (!response.ok) {
|
||||
// Handle specific error case when source is not available
|
||||
if (response.status === 404) {
|
||||
setVideoError(data.error || 'This video source is not available. Please go back and try another source.');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
throw new Error(data.error || `HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
if (data.success && data.data) {
|
||||
console.log('Video data received:', {
|
||||
id: data.data.vod_id,
|
||||
name: data.data.vod_name,
|
||||
episodeCount: data.data.episodes?.length || 0,
|
||||
firstEpisodeUrl: data.data.episodes?.[0]?.url
|
||||
});
|
||||
|
||||
setVideoData(data.data);
|
||||
setLoading(false);
|
||||
if (data.data.episodes && data.data.episodes.length > 0) {
|
||||
// Check if there's an episode parameter in URL
|
||||
const episodeParam = searchParams.get('episode');
|
||||
const episodeIndex = episodeParam ? parseInt(episodeParam, 10) : 0;
|
||||
|
||||
// Validate episode index
|
||||
const validIndex = (episodeIndex >= 0 && episodeIndex < data.data.episodes.length) ? episodeIndex : 0;
|
||||
|
||||
const episodeUrl = data.data.episodes[validIndex].url;
|
||||
console.log('Setting play URL for episode', validIndex, ':', episodeUrl);
|
||||
setCurrentEpisode(validIndex);
|
||||
setPlayUrl(episodeUrl);
|
||||
setIsVideoLoading(true);
|
||||
} else {
|
||||
console.warn('No episodes found in video data');
|
||||
setVideoError('No playable episodes available for this video from this source');
|
||||
}
|
||||
} else {
|
||||
throw new Error(data.error || 'Invalid response from API');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch video details:', error);
|
||||
setVideoError(error instanceof Error ? error.message : 'Failed to load video details. Please try another source.');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
const {
|
||||
videoData,
|
||||
loading,
|
||||
videoError,
|
||||
currentEpisode,
|
||||
playUrl,
|
||||
setCurrentEpisode,
|
||||
setPlayUrl,
|
||||
setVideoError,
|
||||
fetchVideoDetails,
|
||||
} = useVideoPlayer(videoId, source, episodeParam);
|
||||
|
||||
const handleEpisodeClick = (episode: any, index: number) => {
|
||||
setCurrentEpisode(index);
|
||||
setPlayUrl(episode.url);
|
||||
setVideoError(''); // Clear any previous errors
|
||||
setIsVideoLoading(true);
|
||||
setVideoError('');
|
||||
|
||||
// Update URL to reflect current episode
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
@@ -125,43 +50,6 @@ function PlayerContent() {
|
||||
router.replace(`/player?${params.toString()}`, { scroll: false });
|
||||
};
|
||||
|
||||
const handleVideoError = (e: React.SyntheticEvent<HTMLVideoElement, Event>) => {
|
||||
const video = e.currentTarget;
|
||||
let errorMessage = 'Video playback failed';
|
||||
|
||||
if (video.error) {
|
||||
switch (video.error.code) {
|
||||
case MediaError.MEDIA_ERR_ABORTED:
|
||||
errorMessage = 'Video loading was aborted';
|
||||
break;
|
||||
case MediaError.MEDIA_ERR_NETWORK:
|
||||
errorMessage = 'Network error occurred while loading video';
|
||||
break;
|
||||
case MediaError.MEDIA_ERR_DECODE:
|
||||
errorMessage = 'Video format is not supported or corrupted';
|
||||
break;
|
||||
case MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED:
|
||||
errorMessage = 'Video source not supported or unavailable';
|
||||
break;
|
||||
default:
|
||||
errorMessage = `Video error: ${video.error.message || 'Unknown error'}`;
|
||||
}
|
||||
}
|
||||
|
||||
console.error('Video playback error:', errorMessage, video.error);
|
||||
setVideoError(errorMessage);
|
||||
setIsVideoLoading(false);
|
||||
};
|
||||
|
||||
const handleVideoLoadStart = () => {
|
||||
setIsVideoLoading(true);
|
||||
setVideoError('');
|
||||
};
|
||||
|
||||
const handleVideoCanPlay = () => {
|
||||
setIsVideoLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--bg-color)]">
|
||||
{/* Glass Navbar */}
|
||||
@@ -203,212 +91,35 @@ function PlayerContent() {
|
||||
<p className="text-[var(--text-color-secondary)]">正在加载视频详情...</p>
|
||||
</div>
|
||||
) : videoError && !videoData ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<Card className="max-w-2xl">
|
||||
<Icons.AlertTriangle size={64} className="mx-auto mb-4 text-red-500" />
|
||||
<h2 className="text-2xl font-bold text-[var(--text-color)] mb-4">视频源不可用</h2>
|
||||
<p className="text-[var(--text-color-secondary)] mb-6">{videoError}</p>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => router.back()}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Icons.ChevronLeft size={20} />
|
||||
<span>返回</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={fetchVideoDetails}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Icons.RefreshCw size={20} />
|
||||
<span>重新检测</span>
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<PlayerError
|
||||
error={videoError}
|
||||
onBack={() => router.back()}
|
||||
onRetry={fetchVideoDetails}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid lg:grid-cols-3 gap-6">
|
||||
{/* Video Player Section */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Player */}
|
||||
<Card hover={false} className="p-0 overflow-hidden">
|
||||
{playUrl ? (
|
||||
<div className="relative aspect-video bg-black rounded-[var(--radius-2xl)] overflow-hidden">
|
||||
{videoError && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black bg-opacity-80 z-10 p-4">
|
||||
<div className="text-center text-white max-w-md">
|
||||
<Icons.AlertTriangle size={48} className="mx-auto mb-4 text-red-500" />
|
||||
<p className="text-lg font-semibold mb-2">播放失败</p>
|
||||
<p className="text-sm text-gray-300 mb-4">{videoError}</p>
|
||||
<div className="flex gap-2 justify-center flex-wrap">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
setVideoError('');
|
||||
if (videoRef.current) {
|
||||
videoRef.current.load();
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Icons.RefreshCw size={16} />
|
||||
<span>重试</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => router.back()}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Icons.ChevronLeft size={16} />
|
||||
<span>返回</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isVideoLoading && !videoError && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black bg-opacity-50 z-10">
|
||||
<div className="text-center text-white">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-4 border-white border-t-transparent mx-auto mb-2"></div>
|
||||
<p className="text-sm">加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="w-full h-full"
|
||||
controls
|
||||
autoPlay
|
||||
src={playUrl}
|
||||
onError={handleVideoError}
|
||||
onLoadStart={handleVideoLoadStart}
|
||||
onCanPlay={handleVideoCanPlay}
|
||||
onLoadedMetadata={() => {
|
||||
if (videoRef.current && videoData) {
|
||||
const savedTime = localStorage.getItem(`video_progress_${videoData.vod_id}_${currentEpisode}`);
|
||||
if (savedTime) {
|
||||
videoRef.current.currentTime = parseFloat(savedTime);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="aspect-video bg-[var(--glass-bg)] backdrop-blur-[25px] saturate-[180%] rounded-[var(--radius-2xl)] flex items-center justify-center border border-[var(--glass-border)]">
|
||||
<div className="text-center text-[var(--text-secondary)]">
|
||||
<Icons.TV size={64} className="text-[var(--text-color-secondary)] mx-auto mb-4" />
|
||||
<p>暂无播放源</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Video Info */}
|
||||
<Card hover={false}>
|
||||
<div className="flex items-start gap-4">
|
||||
{videoData?.vod_pic && (
|
||||
<img
|
||||
src={videoData.vod_pic}
|
||||
alt={videoData.vod_name}
|
||||
className="w-32 h-48 object-cover rounded-[var(--radius-2xl)] border border-[var(--glass-border)]"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<h1 className="text-3xl font-bold text-[var(--text-color)] mb-3">
|
||||
{videoData?.vod_name || title}
|
||||
</h1>
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{source && (
|
||||
<Badge variant="primary" className="backdrop-blur-md">
|
||||
<Icons.Check size={14} className="mr-1" />
|
||||
{getSourceName(source)}
|
||||
</Badge>
|
||||
)}
|
||||
{videoData?.type_name && (
|
||||
<Badge variant="secondary">{videoData.type_name}</Badge>
|
||||
)}
|
||||
{videoData?.vod_year && (
|
||||
<Badge variant="secondary">
|
||||
<Icons.Calendar size={14} className="mr-1" />
|
||||
{videoData.vod_year}
|
||||
</Badge>
|
||||
)}
|
||||
{videoData?.vod_area && (
|
||||
<Badge variant="secondary">
|
||||
<Icons.Globe size={14} className="mr-1" />
|
||||
{videoData.vod_area}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{videoData?.vod_content && (
|
||||
<p className="text-[var(--text-secondary)] line-clamp-3">
|
||||
{videoData.vod_content.replace(/<[^>]*>/g, '')}
|
||||
</p>
|
||||
)}
|
||||
{videoData?.vod_actor && (
|
||||
<p className="text-sm text-[var(--text-tertiary)] mt-2">
|
||||
<span className="font-semibold">主演:</span>
|
||||
{videoData.vod_actor}
|
||||
</p>
|
||||
)}
|
||||
{videoData?.vod_director && (
|
||||
<p className="text-sm text-[var(--text-tertiary)] mt-1">
|
||||
<span className="font-semibold">导演:</span>
|
||||
{videoData.vod_director}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<VideoPlayer
|
||||
playUrl={playUrl}
|
||||
videoId={videoId || undefined}
|
||||
currentEpisode={currentEpisode}
|
||||
onBack={() => router.back()}
|
||||
/>
|
||||
<VideoMetadata
|
||||
videoData={videoData}
|
||||
source={source}
|
||||
title={title}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Episodes Sidebar */}
|
||||
<div className="lg:col-span-1">
|
||||
<Card hover={false} className="sticky top-32">
|
||||
<h3 className="text-xl font-bold text-[var(--text-color)] mb-4 flex items-center gap-2">
|
||||
<Icons.List size={24} />
|
||||
<span>选集</span>
|
||||
{videoData?.episodes && (
|
||||
<Badge variant="primary">{videoData.episodes.length}</Badge>
|
||||
)}
|
||||
</h3>
|
||||
|
||||
<div className="max-h-[600px] overflow-y-auto space-y-2 pr-2">
|
||||
{videoData?.episodes && videoData.episodes.length > 0 ? (
|
||||
videoData.episodes.map((episode: any, index: number) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => handleEpisodeClick(episode, index)}
|
||||
className={`
|
||||
w-full px-4 py-3 rounded-[var(--radius-2xl)] text-left transition-[var(--transition-fluid)]
|
||||
${currentEpisode === index
|
||||
? 'bg-[var(--accent-color)] text-white shadow-[0_4px_12px_color-mix(in_srgb,var(--accent-color)_50%,transparent)] brightness-110'
|
||||
: 'bg-[var(--glass-bg)] hover:bg-[var(--glass-hover)] text-[var(--text-color)] border border-[var(--glass-border)]'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">
|
||||
{episode.name || `第 ${index + 1} 集`}
|
||||
</span>
|
||||
{currentEpisode === index && (
|
||||
<Icons.Play size={16} />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-8 text-[var(--text-secondary)]">
|
||||
<Icons.Inbox size={48} className="text-[var(--text-color-secondary)] mx-auto mb-2" />
|
||||
<p>暂无剧集信息</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
<EpisodeList
|
||||
episodes={videoData?.episodes || null}
|
||||
currentEpisode={currentEpisode}
|
||||
onEpisodeClick={handleEpisodeClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
'use client';
|
||||
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
|
||||
interface Episode {
|
||||
name?: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface EpisodeListProps {
|
||||
episodes: Episode[] | null;
|
||||
currentEpisode: number;
|
||||
onEpisodeClick: (episode: Episode, index: number) => void;
|
||||
}
|
||||
|
||||
export function EpisodeList({ episodes, currentEpisode, onEpisodeClick }: EpisodeListProps) {
|
||||
return (
|
||||
<Card hover={false} className="sticky top-32">
|
||||
<h3 className="text-xl font-bold text-[var(--text-color)] mb-4 flex items-center gap-2">
|
||||
<Icons.List size={24} />
|
||||
<span>选集</span>
|
||||
{episodes && (
|
||||
<Badge variant="primary">{episodes.length}</Badge>
|
||||
)}
|
||||
</h3>
|
||||
|
||||
<div className="max-h-[600px] overflow-y-auto space-y-2 pr-2">
|
||||
{episodes && episodes.length > 0 ? (
|
||||
episodes.map((episode, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => onEpisodeClick(episode, index)}
|
||||
className={`
|
||||
w-full px-4 py-3 rounded-[var(--radius-2xl)] text-left transition-[var(--transition-fluid)]
|
||||
${currentEpisode === index
|
||||
? 'bg-[var(--accent-color)] text-white shadow-[0_4px_12px_color-mix(in_srgb,var(--accent-color)_50%,transparent)] brightness-110'
|
||||
: 'bg-[var(--glass-bg)] hover:bg-[var(--glass-hover)] text-[var(--text-color)] border border-[var(--glass-border)]'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">
|
||||
{episode.name || `第 ${index + 1} 集`}
|
||||
</span>
|
||||
{currentEpisode === index && (
|
||||
<Icons.Play size={16} />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-8 text-[var(--text-secondary)]">
|
||||
<Icons.Inbox size={48} className="text-[var(--text-color-secondary)] mx-auto mb-2" />
|
||||
<p>暂无剧集信息</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
'use client';
|
||||
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
|
||||
interface PlayerErrorProps {
|
||||
error: string;
|
||||
onBack: () => void;
|
||||
onRetry: () => void;
|
||||
}
|
||||
|
||||
export function PlayerError({ error, onBack, onRetry }: PlayerErrorProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<Card className="max-w-2xl">
|
||||
<Icons.AlertTriangle size={64} className="mx-auto mb-4 text-red-500" />
|
||||
<h2 className="text-2xl font-bold text-[var(--text-color)] mb-4">视频源不可用</h2>
|
||||
<p className="text-[var(--text-color-secondary)] mb-6">{error}</p>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Icons.ChevronLeft size={20} />
|
||||
<span>返回</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onRetry}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Icons.RefreshCw size={20} />
|
||||
<span>重新检测</span>
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
'use client';
|
||||
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { getSourceName } from '@/lib/utils/source-names';
|
||||
|
||||
interface VideoMetadataProps {
|
||||
videoData: any;
|
||||
source: string | null;
|
||||
title?: string | null;
|
||||
}
|
||||
|
||||
export function VideoMetadata({ videoData, source, title }: VideoMetadataProps) {
|
||||
return (
|
||||
<Card hover={false}>
|
||||
<div className="flex items-start gap-4">
|
||||
{videoData?.vod_pic && (
|
||||
<img
|
||||
src={videoData.vod_pic}
|
||||
alt={videoData.vod_name}
|
||||
className="w-32 h-48 object-cover rounded-[var(--radius-2xl)] border border-[var(--glass-border)]"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<h1 className="text-3xl font-bold text-[var(--text-color)] mb-3">
|
||||
{videoData?.vod_name || title}
|
||||
</h1>
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{source && (
|
||||
<Badge variant="primary" className="backdrop-blur-md">
|
||||
<Icons.Check size={14} className="mr-1" />
|
||||
{getSourceName(source)}
|
||||
</Badge>
|
||||
)}
|
||||
{videoData?.type_name && (
|
||||
<Badge variant="secondary">{videoData.type_name}</Badge>
|
||||
)}
|
||||
{videoData?.vod_year && (
|
||||
<Badge variant="secondary">
|
||||
<Icons.Calendar size={14} className="mr-1" />
|
||||
{videoData.vod_year}
|
||||
</Badge>
|
||||
)}
|
||||
{videoData?.vod_area && (
|
||||
<Badge variant="secondary">
|
||||
<Icons.Globe size={14} className="mr-1" />
|
||||
{videoData.vod_area}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{videoData?.vod_content && (
|
||||
<p className="text-[var(--text-secondary)] line-clamp-3">
|
||||
{videoData.vod_content.replace(/<[^>]*>/g, '')}
|
||||
</p>
|
||||
)}
|
||||
{videoData?.vod_actor && (
|
||||
<p className="text-sm text-[var(--text-tertiary)] mt-2">
|
||||
<span className="font-semibold">主演:</span>
|
||||
{videoData.vod_actor}
|
||||
</p>
|
||||
)}
|
||||
{videoData?.vod_director && (
|
||||
<p className="text-sm text-[var(--text-tertiary)] mt-1">
|
||||
<span className="font-semibold">导演:</span>
|
||||
{videoData.vod_director}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useState } from 'react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
|
||||
interface VideoPlayerProps {
|
||||
playUrl: string;
|
||||
videoId?: string;
|
||||
currentEpisode: number;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export function VideoPlayer({ playUrl, videoId, currentEpisode, onBack }: VideoPlayerProps) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const [videoError, setVideoError] = useState<string>('');
|
||||
const [isVideoLoading, setIsVideoLoading] = useState(false);
|
||||
|
||||
const handleVideoError = (e: React.SyntheticEvent<HTMLVideoElement, Event>) => {
|
||||
const video = e.currentTarget;
|
||||
let errorMessage = 'Video playback failed';
|
||||
|
||||
if (video.error) {
|
||||
switch (video.error.code) {
|
||||
case MediaError.MEDIA_ERR_ABORTED:
|
||||
errorMessage = 'Video loading was aborted';
|
||||
break;
|
||||
case MediaError.MEDIA_ERR_NETWORK:
|
||||
errorMessage = 'Network error occurred while loading video';
|
||||
break;
|
||||
case MediaError.MEDIA_ERR_DECODE:
|
||||
errorMessage = 'Video format is not supported or corrupted';
|
||||
break;
|
||||
case MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED:
|
||||
errorMessage = 'Video source not supported or unavailable';
|
||||
break;
|
||||
default:
|
||||
errorMessage = `Video error: ${video.error.message || 'Unknown error'}`;
|
||||
}
|
||||
}
|
||||
|
||||
console.error('Video playback error:', errorMessage, video.error);
|
||||
setVideoError(errorMessage);
|
||||
setIsVideoLoading(false);
|
||||
};
|
||||
|
||||
const handleVideoLoadStart = () => {
|
||||
setIsVideoLoading(true);
|
||||
setVideoError('');
|
||||
};
|
||||
|
||||
const handleVideoCanPlay = () => {
|
||||
setIsVideoLoading(false);
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
setVideoError('');
|
||||
if (videoRef.current) {
|
||||
videoRef.current.load();
|
||||
}
|
||||
};
|
||||
|
||||
if (!playUrl) {
|
||||
return (
|
||||
<Card hover={false} className="p-0 overflow-hidden">
|
||||
<div className="aspect-video bg-[var(--glass-bg)] backdrop-blur-[25px] saturate-[180%] rounded-[var(--radius-2xl)] flex items-center justify-center border border-[var(--glass-border)]">
|
||||
<div className="text-center text-[var(--text-secondary)]">
|
||||
<Icons.TV size={64} className="text-[var(--text-color-secondary)] mx-auto mb-4" />
|
||||
<p>暂无播放源</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card hover={false} className="p-0 overflow-hidden">
|
||||
<div className="relative aspect-video bg-black rounded-[var(--radius-2xl)] overflow-hidden">
|
||||
{videoError && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black bg-opacity-80 z-10 p-4">
|
||||
<div className="text-center text-white max-w-md">
|
||||
<Icons.AlertTriangle size={48} className="mx-auto mb-4 text-red-500" />
|
||||
<p className="text-lg font-semibold mb-2">播放失败</p>
|
||||
<p className="text-sm text-gray-300 mb-4">{videoError}</p>
|
||||
<div className="flex gap-2 justify-center flex-wrap">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleRetry}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Icons.RefreshCw size={16} />
|
||||
<span>重试</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Icons.ChevronLeft size={16} />
|
||||
<span>返回</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isVideoLoading && !videoError && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black bg-opacity-50 z-10">
|
||||
<div className="text-center text-white">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-4 border-white border-t-transparent mx-auto mb-2"></div>
|
||||
<p className="text-sm">加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="w-full h-full"
|
||||
controls
|
||||
autoPlay
|
||||
src={playUrl}
|
||||
onError={handleVideoError}
|
||||
onLoadStart={handleVideoLoadStart}
|
||||
onCanPlay={handleVideoCanPlay}
|
||||
onLoadedMetadata={() => {
|
||||
if (videoRef.current && videoId) {
|
||||
const savedTime = localStorage.getItem(`video_progress_${videoId}_${currentEpisode}`);
|
||||
if (savedTime) {
|
||||
videoRef.current.currentTime = parseFloat(savedTime);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
'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"
|
||||
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">
|
||||
开始探索精彩内容
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
|
||||
interface NoResultsProps {
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
export function NoResults({ onReset }: NoResultsProps) {
|
||||
return (
|
||||
<div className="text-center py-20 animate-fade-in">
|
||||
<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)] mb-6">
|
||||
试试其他关键词或检查拼写
|
||||
</p>
|
||||
<Button variant="primary" onClick={onReset}>
|
||||
返回首页
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
'use client';
|
||||
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { SourceBadges } from './SourceBadges';
|
||||
|
||||
interface ResultsHeaderProps {
|
||||
loading: boolean;
|
||||
resultsCount: number;
|
||||
checkedVideos: number;
|
||||
totalVideos: number;
|
||||
availableSources: Array<{ id: string; name: string; count: number }>;
|
||||
}
|
||||
|
||||
export function ResultsHeader({
|
||||
loading,
|
||||
resultsCount,
|
||||
checkedVideos,
|
||||
totalVideos,
|
||||
availableSources,
|
||||
}: ResultsHeaderProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 mb-6">
|
||||
<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>
|
||||
</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} />
|
||||
可用视频 {resultsCount}/{totalVideos}
|
||||
</span>
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
{!loading && (
|
||||
<Badge variant="primary">{resultsCount} 个视频</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SourceBadges sources={availableSources} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
'use client';
|
||||
|
||||
import { useState, FormEvent } from 'react';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { SearchLoadingAnimation } from '@/components/SearchLoadingAnimation';
|
||||
|
||||
interface SearchFormProps {
|
||||
onSearch: (query: string) => void;
|
||||
isLoading: boolean;
|
||||
initialQuery?: string;
|
||||
currentSource?: string;
|
||||
checkedSources?: number;
|
||||
totalSources?: number;
|
||||
checkedVideos?: number;
|
||||
totalVideos?: number;
|
||||
searchStage?: 'searching' | 'checking';
|
||||
}
|
||||
|
||||
export function SearchForm({
|
||||
onSearch,
|
||||
isLoading,
|
||||
initialQuery = '',
|
||||
currentSource = '',
|
||||
checkedSources = 0,
|
||||
totalSources = 16,
|
||||
checkedVideos = 0,
|
||||
totalVideos = 0,
|
||||
searchStage = 'searching',
|
||||
}: SearchFormProps) {
|
||||
const [query, setQuery] = useState(initialQuery);
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (query.trim()) {
|
||||
onSearch(query);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="max-w-3xl mx-auto">
|
||||
<div className="relative group">
|
||||
<Input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="搜索电影、电视剧、综艺..."
|
||||
className="text-lg pr-32"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isLoading || !query.trim()}
|
||||
variant="primary"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 px-8"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Icons.Search size={20} />
|
||||
搜索
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Loading Animation */}
|
||||
{isLoading && (
|
||||
<div className="mt-4">
|
||||
<SearchLoadingAnimation
|
||||
currentSource={currentSource}
|
||||
checkedSources={checkedSources}
|
||||
totalSources={totalSources}
|
||||
checkedVideos={checkedVideos}
|
||||
totalVideos={totalVideos}
|
||||
stage={searchStage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
|
||||
interface Source {
|
||||
id: string;
|
||||
name: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface SourceBadgesProps {
|
||||
sources: Source[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SourceBadges({ sources, className = '' }: SourceBadgesProps) {
|
||||
if (sources.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card hover={false} className={`p-4 ${className}`}>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-semibold text-[var(--text-color)] flex items-center gap-2">
|
||||
<Icons.Check size={16} className="text-[var(--accent-color)]" />
|
||||
可用源 ({sources.length}):
|
||||
</span>
|
||||
{sources.map((source) => (
|
||||
<Badge
|
||||
key={source.id}
|
||||
variant="secondary"
|
||||
className="text-xs"
|
||||
>
|
||||
{source.name} ({source.count})
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
|
||||
interface Video {
|
||||
vod_id: string;
|
||||
vod_name: string;
|
||||
vod_pic?: string;
|
||||
vod_remarks?: string;
|
||||
vod_year?: string;
|
||||
type_name?: string;
|
||||
source: string;
|
||||
sourceName?: string;
|
||||
isNew?: boolean;
|
||||
}
|
||||
|
||||
interface VideoGridProps {
|
||||
videos: Video[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function VideoGrid({ videos, className = '' }: VideoGridProps) {
|
||||
if (videos.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4 md:gap-6 ${className}`}>
|
||||
{videos.map((video, index) => {
|
||||
const videoUrl = `/player?${new URLSearchParams({
|
||||
id: video.vod_id,
|
||||
source: video.source,
|
||||
title: video.vod_name,
|
||||
}).toString()}`;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={`${video.vod_id}-${index}`}
|
||||
href={videoUrl}
|
||||
>
|
||||
<Card
|
||||
className={`p-0 overflow-hidden group cursor-pointer flex flex-col h-full ${video.isNew ? 'animate-scale-in' : ''}`}
|
||||
>
|
||||
{/* Poster */}
|
||||
<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}
|
||||
alt={video.vod_name}
|
||||
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Icons.Film size={64} className="text-[var(--text-color-secondary)]" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Source Badge - Top Left */}
|
||||
{video.sourceName && (
|
||||
<div className="absolute top-2 left-2 z-10">
|
||||
<Badge variant="primary" className="text-xs backdrop-blur-md bg-[var(--accent-color)]/90">
|
||||
{video.sourceName}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Overlay */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
<div className="absolute bottom-0 left-0 right-0 p-3">
|
||||
{video.type_name && (
|
||||
<Badge variant="secondary" className="text-xs mb-2">
|
||||
{video.type_name}
|
||||
</Badge>
|
||||
)}
|
||||
{video.vod_year && (
|
||||
<div className="flex items-center gap-1 text-white/80 text-xs">
|
||||
<Icons.Calendar size={12} />
|
||||
<span>{video.vod_year}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info - Fixed height section */}
|
||||
<div className="p-3 flex-1 flex flex-col">
|
||||
<h4 className="font-semibold text-sm text-[var(--text-color)] line-clamp-2 min-h-[2.5rem] group-hover:text-[var(--accent-color)] transition-colors">
|
||||
{video.vod_name}
|
||||
</h4>
|
||||
{video.vod_remarks && (
|
||||
<p className="text-xs text-[var(--text-color-secondary)] mt-1 line-clamp-1">
|
||||
{video.vod_remarks}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useRef } from 'react';
|
||||
|
||||
interface SearchCache {
|
||||
query: string;
|
||||
results: any[];
|
||||
availableSources: any[];
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
const CACHE_KEY = 'kvideo_search_cache';
|
||||
const CACHE_DURATION = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
export function useSearchCache() {
|
||||
const hasLoadedCache = useRef(false);
|
||||
|
||||
const saveToCache = (
|
||||
query: string,
|
||||
results: any[],
|
||||
sources: any[]
|
||||
) => {
|
||||
const cache: SearchCache = {
|
||||
query,
|
||||
results,
|
||||
availableSources: sources,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
try {
|
||||
localStorage.setItem(CACHE_KEY, JSON.stringify(cache));
|
||||
console.log('💾 Saved search to cache:', query, results.length, 'results');
|
||||
} catch (error) {
|
||||
console.error('Failed to save cache:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadFromCache = (): SearchCache | null => {
|
||||
try {
|
||||
const cached = localStorage.getItem(CACHE_KEY);
|
||||
if (!cached) return null;
|
||||
|
||||
const cache: SearchCache = JSON.parse(cached);
|
||||
|
||||
// Check if cache is still valid
|
||||
if (Date.now() - cache.timestamp > CACHE_DURATION) {
|
||||
localStorage.removeItem(CACHE_KEY);
|
||||
return null;
|
||||
}
|
||||
|
||||
return cache;
|
||||
} catch (error) {
|
||||
console.error('Failed to load cache:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const clearCache = () => {
|
||||
try {
|
||||
localStorage.removeItem(CACHE_KEY);
|
||||
console.log('🗑️ Cache cleared');
|
||||
} catch (error) {
|
||||
console.error('Failed to clear cache:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
saveToCache,
|
||||
loadFromCache,
|
||||
clearCache,
|
||||
hasLoadedCache,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
'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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
export interface VideoData {
|
||||
vod_id: string;
|
||||
vod_name: string;
|
||||
vod_pic?: string;
|
||||
vod_content?: string;
|
||||
vod_actor?: string;
|
||||
vod_director?: string;
|
||||
vod_year?: string;
|
||||
vod_area?: string;
|
||||
type_name?: string;
|
||||
episodes?: Array<{ name?: string; url: string }>;
|
||||
}
|
||||
|
||||
export interface UseVideoPlayerReturn {
|
||||
videoData: VideoData | null;
|
||||
loading: boolean;
|
||||
videoError: string;
|
||||
currentEpisode: number;
|
||||
playUrl: string;
|
||||
setCurrentEpisode: (index: number) => void;
|
||||
setPlayUrl: (url: string) => void;
|
||||
setVideoError: (error: string) => void;
|
||||
fetchVideoDetails: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useVideoPlayer(
|
||||
videoId: string | null,
|
||||
source: string | null,
|
||||
episodeParam: string | null
|
||||
): UseVideoPlayerReturn {
|
||||
const [videoData, setVideoData] = useState<VideoData | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentEpisode, setCurrentEpisode] = useState(0);
|
||||
const [playUrl, setPlayUrl] = useState('');
|
||||
const [videoError, setVideoError] = useState<string>('');
|
||||
|
||||
const fetchVideoDetails = useCallback(async () => {
|
||||
if (!videoId || !source) return;
|
||||
|
||||
try {
|
||||
setVideoError('');
|
||||
setLoading(true);
|
||||
|
||||
const response = await fetch(`/api/detail?id=${videoId}&source=${source}`);
|
||||
const data = await response.json();
|
||||
|
||||
console.log('Video detail API response:', data);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
setVideoError(data.error || 'This video source is not available. Please go back and try another source.');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
throw new Error(data.error || `HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
if (data.success && data.data) {
|
||||
console.log('Video data received:', {
|
||||
id: data.data.vod_id,
|
||||
name: data.data.vod_name,
|
||||
episodeCount: data.data.episodes?.length || 0,
|
||||
firstEpisodeUrl: data.data.episodes?.[0]?.url
|
||||
});
|
||||
|
||||
setVideoData(data.data);
|
||||
setLoading(false);
|
||||
|
||||
if (data.data.episodes && data.data.episodes.length > 0) {
|
||||
const episodeIndex = episodeParam ? parseInt(episodeParam, 10) : 0;
|
||||
const validIndex = (episodeIndex >= 0 && episodeIndex < data.data.episodes.length) ? episodeIndex : 0;
|
||||
|
||||
const episodeUrl = data.data.episodes[validIndex].url;
|
||||
console.log('Setting play URL for episode', validIndex, ':', episodeUrl);
|
||||
setCurrentEpisode(validIndex);
|
||||
setPlayUrl(episodeUrl);
|
||||
} else {
|
||||
console.warn('No episodes found in video data');
|
||||
setVideoError('No playable episodes available for this video from this source');
|
||||
}
|
||||
} else {
|
||||
throw new Error(data.error || 'Invalid response from API');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch video details:', error);
|
||||
setVideoError(error instanceof Error ? error.message : 'Failed to load video details. Please try another source.');
|
||||
setLoading(false);
|
||||
}
|
||||
}, [videoId, source, episodeParam]);
|
||||
|
||||
useEffect(() => {
|
||||
if (videoId && source) {
|
||||
fetchVideoDetails();
|
||||
}
|
||||
}, [videoId, source, fetchVideoDetails]);
|
||||
|
||||
return {
|
||||
videoData,
|
||||
loading,
|
||||
videoError,
|
||||
currentEpisode,
|
||||
playUrl,
|
||||
setCurrentEpisode,
|
||||
setPlayUrl,
|
||||
setVideoError,
|
||||
fetchVideoDetails,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
export function 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;
|
||||
}
|
||||
|
||||
export const SOURCE_IDS = [
|
||||
'dytt', 'ruyi', 'baofeng', 'tianya', 'feifan',
|
||||
'sanliuling', 'wolong', 'jisu', 'mozhua', 'modu',
|
||||
'zuida', 'yinghua', 'baiduyun', 'wujin', 'wangwang', 'ikun'
|
||||
];
|
||||
Reference in New Issue
Block a user