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:
kuekhaoyang
2025-11-17 13:51:03 +08:00
parent 1fde3bf911
commit ad9e75bc8c
17 changed files with 1786 additions and 842 deletions
+605
View File
@@ -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
View File
@@ -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
View File
@@ -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>
)}
+62
View File
@@ -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>
);
}
+41
View File
@@ -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>
);
}
+73
View File
@@ -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>
);
}
+138
View File
@@ -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>
);
}
+49
View File
@@ -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>
);
}
+30
View File
@@ -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>
);
}
+54
View File
@@ -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>
);
}
+80
View File
@@ -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>
);
}
+42
View File
@@ -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>
);
}
+106
View File
@@ -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>
);
}
+71
View File
@@ -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,
};
}
+182
View File
@@ -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,
};
}
+112
View File
@@ -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,
};
}
+27
View File
@@ -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'
];