diff --git a/app/page-backup.tsx b/app/page-backup.tsx new file mode 100644 index 0000000..ba3454e --- /dev/null +++ b/app/page-backup.tsx @@ -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([]); + const [hasSearched, setHasSearched] = useState(false); + const [availableSources, setAvailableSources] = useState([]); + const [currentSource, setCurrentSource] = useState(''); + 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(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(); + + 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 = { + 'dytt': '电影天堂', + 'ruyi': '如意', + 'baofeng': '暴风', + 'tianya': '天涯', + 'feifan': '非凡影视', + 'sanliuling': '360', + 'wolong': '卧龙', + 'jisu': '极速', + 'mozhua': '魔爪', + 'modu': '魔都', + 'zuida': '最大', + 'yinghua': '樱花', + 'baiduyun': '百度云', + 'wujin': '无尽', + 'wangwang': '旺旺', + 'ikun': 'iKun', + }; + return sourceNames[sourceId] || sourceId; + }; + + return ( +
+ {/* Glass Navbar */} + + + {/* Main Content */} +
+ {/* Hero Section with Search */} +
+

+ 发现精彩视频 +

+

+ 多源聚合 · 智能搜索 · 极致体验 +

+ + {/* Search Bar */} +
+
+ setQuery(e.target.value)} + placeholder="搜索电影、电视剧、综艺..." + className="text-lg pr-32" + disabled={loading} + /> + +
+ + {/* Loading Animation - Replaces search bar content */} + {loading && ( +
+ +
+ )} +
+
+ + {/* Results Section */} + {(results.length >= 1 || (!loading && results.length > 0)) && ( +
+
+
+

+ 搜索结果 +

+
+ {loading && ( + <> + + + + 已检测 {checkedVideos}/{totalVideos} + + + + + + 可用视频 {results.length}/{totalVideos} + + + + )} + {!loading && ( + {results.length} 个视频 + )} +
+
+ + {/* Available Sources */} + {availableSources.length > 0 && ( + +
+ + + 可用源 ({availableSources.length}): + + {availableSources.map((source) => ( + + {source.name} ({source.count}) + + ))} +
+
+ )} +
+ +
+ {results.map((video, index) => { + const videoUrl = `/player?${new URLSearchParams({ + id: video.vod_id, + source: video.source, + title: video.vod_name, + }).toString()}`; + + return ( + + + {/* Poster */} +
+ {video.vod_pic ? ( + {video.vod_name} + ) : ( +
+ +
+ )} + + {/* Source Badge - Top Left */} + {video.sourceName && ( +
+ + {video.sourceName} + +
+ )} + + {/* Overlay */} +
+
+ {video.type_name && ( + + {video.type_name} + + )} + {video.vod_year && ( +
+ + {video.vod_year} +
+ )} +
+
+
+ + {/* Info - Fixed height section */} +
+

+ {video.vod_name} +

+ {video.vod_remarks && ( +

+ {video.vod_remarks} +

+ )} +
+
+ + ); + })} +
+
+ )} + + {/* Empty State - Initial Homepage */} + {!loading && !hasSearched && ( +
+
+
+ +
+

+ 开始探索精彩内容 +

+

+ 在上方搜索框输入关键词,从 16 个视频源聚合搜索海量影视资源 +

+ + {/* Feature Cards */} +
+ +
+ +
+

极速搜索

+

多源并行,秒级响应

+
+ +
+ +
+

精准匹配

+

智能算法,结果精准

+
+ +
+ +
+

极致体验

+

流畅播放,完美适配

+
+
+
+
+ )} + + {/* No Results - After Search */} + {!loading && hasSearched && results.length === 0 && ( +
+
+ +
+

+ 未找到相关内容 +

+

+ 试试其他关键词或检查拼写 +

+ +
+ )} +
+
+ ); +} + +export default function Home() { + return ( + +
+ }> + +
+ ); +} diff --git a/app/page.tsx b/app/page.tsx index ba3454e..c8bf2a5 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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([]); - const [hasSearched, setHasSearched] = useState(false); - const [availableSources, setAvailableSources] = useState([]); - const [currentSource, setCurrentSource] = useState(''); - 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(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(); - - 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 = { - '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() { { - // Don't clear cache when clicking home - just reset the view - setQuery(''); - setResults([]); - setAvailableSources([]); - setHasSearched(false); - }} + onClick={handleReset} >
-

- KVideo -

+

KVideo

视频聚合平台

@@ -353,241 +114,39 @@ function HomePage() { 多源聚合 · 智能搜索 · 极致体验

- {/* Search Bar */} -
-
- setQuery(e.target.value)} - placeholder="搜索电影、电视剧、综艺..." - className="text-lg pr-32" - disabled={loading} - /> - -
- - {/* Loading Animation - Replaces search bar content */} - {loading && ( -
- -
- )} -
+ {/* Results Section */} {(results.length >= 1 || (!loading && results.length > 0)) && (
-
-
-

- 搜索结果 -

-
- {loading && ( - <> - - - - 已检测 {checkedVideos}/{totalVideos} - - - - - - 可用视频 {results.length}/{totalVideos} - - - - )} - {!loading && ( - {results.length} 个视频 - )} -
-
- - {/* Available Sources */} - {availableSources.length > 0 && ( - -
- - - 可用源 ({availableSources.length}): - - {availableSources.map((source) => ( - - {source.name} ({source.count}) - - ))} -
-
- )} -
- -
- {results.map((video, index) => { - const videoUrl = `/player?${new URLSearchParams({ - id: video.vod_id, - source: video.source, - title: video.vod_name, - }).toString()}`; - - return ( - - - {/* Poster */} -
- {video.vod_pic ? ( - {video.vod_name} - ) : ( -
- -
- )} - - {/* Source Badge - Top Left */} - {video.sourceName && ( -
- - {video.sourceName} - -
- )} - - {/* Overlay */} -
-
- {video.type_name && ( - - {video.type_name} - - )} - {video.vod_year && ( -
- - {video.vod_year} -
- )} -
-
-
- - {/* Info - Fixed height section */} -
-

- {video.vod_name} -

- {video.vod_remarks && ( -

- {video.vod_remarks} -

- )} -
-
- - ); - })} -
+ +
)} {/* Empty State - Initial Homepage */} - {!loading && !hasSearched && ( -
-
-
- -
-

- 开始探索精彩内容 -

-

- 在上方搜索框输入关键词,从 16 个视频源聚合搜索海量影视资源 -

- - {/* Feature Cards */} -
- -
- -
-

极速搜索

-

多源并行,秒级响应

-
- -
- -
-

精准匹配

-

智能算法,结果精准

-
- -
- -
-

极致体验

-

流畅播放,完美适配

-
-
-
-
- )} + {!loading && !hasSearched && } - {/* No Results - After Search */} + {/* No Results */} {!loading && hasSearched && results.length === 0 && ( -
-
- -
-

- 未找到相关内容 -

-

- 试试其他关键词或检查拼写 -

- -
+ )} @@ -596,9 +155,11 @@ function HomePage() { export default function Home() { return ( - -
- }> + +
+ + }>
); diff --git a/app/player/page.tsx b/app/player/page.tsx index afe7a7b..10c84dd 100644 --- a/app/player/page.tsx +++ b/app/player/page.tsx @@ -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(null); - - const [videoData, setVideoData] = useState(null); - const [loading, setLoading] = useState(false); - const [currentEpisode, setCurrentEpisode] = useState(0); - const [playUrl, setPlayUrl] = useState(''); - const [videoError, setVideoError] = useState(''); - 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 = { - '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) => { - 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 (
{/* Glass Navbar */} @@ -203,212 +91,35 @@ function PlayerContent() {

正在加载视频详情...

) : videoError && !videoData ? ( -
- - -

视频源不可用

-

{videoError}

-
- - -
-
-
+ router.back()} + onRetry={fetchVideoDetails} + /> ) : (
{/* Video Player Section */}
- {/* Player */} - - {playUrl ? ( -
- {videoError && ( -
-
- -

播放失败

-

{videoError}

-
- - -
-
-
- )} - - {isVideoLoading && !videoError && ( -
-
-
-

加载中...

-
-
- )} - -
- ) : ( -
-
- -

暂无播放源

-
-
- )} -
- - {/* Video Info */} - -
- {videoData?.vod_pic && ( - {videoData.vod_name} - )} -
-

- {videoData?.vod_name || title} -

-
- {source && ( - - - {getSourceName(source)} - - )} - {videoData?.type_name && ( - {videoData.type_name} - )} - {videoData?.vod_year && ( - - - {videoData.vod_year} - - )} - {videoData?.vod_area && ( - - - {videoData.vod_area} - - )} -
- {videoData?.vod_content && ( -

- {videoData.vod_content.replace(/<[^>]*>/g, '')} -

- )} - {videoData?.vod_actor && ( -

- 主演: - {videoData.vod_actor} -

- )} - {videoData?.vod_director && ( -

- 导演: - {videoData.vod_director} -

- )} -
-
-
+ router.back()} + /> +
{/* Episodes Sidebar */}
- -

- - 选集 - {videoData?.episodes && ( - {videoData.episodes.length} - )} -

- -
- {videoData?.episodes && videoData.episodes.length > 0 ? ( - videoData.episodes.map((episode: any, index: number) => ( - - )) - ) : ( -
- -

暂无剧集信息

-
- )} -
-
+
)} diff --git a/components/player/EpisodeList.tsx b/components/player/EpisodeList.tsx new file mode 100644 index 0000000..06efc66 --- /dev/null +++ b/components/player/EpisodeList.tsx @@ -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 ( + +

+ + 选集 + {episodes && ( + {episodes.length} + )} +

+ +
+ {episodes && episodes.length > 0 ? ( + episodes.map((episode, index) => ( + + )) + ) : ( +
+ +

暂无剧集信息

+
+ )} +
+
+ ); +} diff --git a/components/player/PlayerError.tsx b/components/player/PlayerError.tsx new file mode 100644 index 0000000..db3f6cf --- /dev/null +++ b/components/player/PlayerError.tsx @@ -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 ( +
+ + +

视频源不可用

+

{error}

+
+ + +
+
+
+ ); +} diff --git a/components/player/VideoMetadata.tsx b/components/player/VideoMetadata.tsx new file mode 100644 index 0000000..5926fd8 --- /dev/null +++ b/components/player/VideoMetadata.tsx @@ -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 ( + +
+ {videoData?.vod_pic && ( + {videoData.vod_name} + )} +
+

+ {videoData?.vod_name || title} +

+
+ {source && ( + + + {getSourceName(source)} + + )} + {videoData?.type_name && ( + {videoData.type_name} + )} + {videoData?.vod_year && ( + + + {videoData.vod_year} + + )} + {videoData?.vod_area && ( + + + {videoData.vod_area} + + )} +
+ {videoData?.vod_content && ( +

+ {videoData.vod_content.replace(/<[^>]*>/g, '')} +

+ )} + {videoData?.vod_actor && ( +

+ 主演: + {videoData.vod_actor} +

+ )} + {videoData?.vod_director && ( +

+ 导演: + {videoData.vod_director} +

+ )} +
+
+
+ ); +} diff --git a/components/player/VideoPlayer.tsx b/components/player/VideoPlayer.tsx new file mode 100644 index 0000000..5b03311 --- /dev/null +++ b/components/player/VideoPlayer.tsx @@ -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(null); + const [videoError, setVideoError] = useState(''); + const [isVideoLoading, setIsVideoLoading] = useState(false); + + const handleVideoError = (e: React.SyntheticEvent) => { + 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 ( + +
+
+ +

暂无播放源

+
+
+
+ ); + } + + return ( + +
+ {videoError && ( +
+
+ +

播放失败

+

{videoError}

+
+ + +
+
+
+ )} + + {isVideoLoading && !videoError && ( +
+
+
+

加载中...

+
+
+ )} + +
+
+ ); +} diff --git a/components/search/EmptyState.tsx b/components/search/EmptyState.tsx new file mode 100644 index 0000000..a2a9683 --- /dev/null +++ b/components/search/EmptyState.tsx @@ -0,0 +1,49 @@ +'use client'; + +import { Card } from '@/components/ui/Card'; +import { Icons } from '@/components/ui/Icon'; + +export function EmptyState() { + return ( +
+
+
+ +
+

+ 开始探索精彩内容 +

+

+ 在上方搜索框输入关键词,从 16 个视频源聚合搜索海量影视资源 +

+ +
+ +
+ +
+

极速搜索

+

多源并行,秒级响应

+
+ +
+ +
+

精准匹配

+

智能算法,结果精准

+
+ +
+ +
+

极致体验

+

流畅播放,完美适配

+
+
+
+
+ ); +} diff --git a/components/search/NoResults.tsx b/components/search/NoResults.tsx new file mode 100644 index 0000000..dce67b7 --- /dev/null +++ b/components/search/NoResults.tsx @@ -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 ( +
+
+ +
+

+ 未找到相关内容 +

+

+ 试试其他关键词或检查拼写 +

+ +
+ ); +} diff --git a/components/search/ResultsHeader.tsx b/components/search/ResultsHeader.tsx new file mode 100644 index 0000000..4ebdae9 --- /dev/null +++ b/components/search/ResultsHeader.tsx @@ -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 ( +
+
+

+ 搜索结果 +

+
+ {loading && ( + <> + + + + 已检测 {checkedVideos}/{totalVideos} + + + + + + 可用视频 {resultsCount}/{totalVideos} + + + + )} + {!loading && ( + {resultsCount} 个视频 + )} +
+
+ + +
+ ); +} diff --git a/components/search/SearchForm.tsx b/components/search/SearchForm.tsx new file mode 100644 index 0000000..10f5d15 --- /dev/null +++ b/components/search/SearchForm.tsx @@ -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 ( +
+
+ setQuery(e.target.value)} + placeholder="搜索电影、电视剧、综艺..." + className="text-lg pr-32" + disabled={isLoading} + /> + +
+ + {/* Loading Animation */} + {isLoading && ( +
+ +
+ )} +
+ ); +} diff --git a/components/search/SourceBadges.tsx b/components/search/SourceBadges.tsx new file mode 100644 index 0000000..4225a98 --- /dev/null +++ b/components/search/SourceBadges.tsx @@ -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 ( + +
+ + + 可用源 ({sources.length}): + + {sources.map((source) => ( + + {source.name} ({source.count}) + + ))} +
+
+ ); +} diff --git a/components/search/VideoGrid.tsx b/components/search/VideoGrid.tsx new file mode 100644 index 0000000..e6f69c5 --- /dev/null +++ b/components/search/VideoGrid.tsx @@ -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 ( +
+ {videos.map((video, index) => { + const videoUrl = `/player?${new URLSearchParams({ + id: video.vod_id, + source: video.source, + title: video.vod_name, + }).toString()}`; + + return ( + + + {/* Poster */} +
+ {video.vod_pic ? ( + {video.vod_name} + ) : ( +
+ +
+ )} + + {/* Source Badge - Top Left */} + {video.sourceName && ( +
+ + {video.sourceName} + +
+ )} + + {/* Overlay */} +
+
+ {video.type_name && ( + + {video.type_name} + + )} + {video.vod_year && ( +
+ + {video.vod_year} +
+ )} +
+
+
+ + {/* Info - Fixed height section */} +
+

+ {video.vod_name} +

+ {video.vod_remarks && ( +

+ {video.vod_remarks} +

+ )} +
+
+ + ); + })} +
+ ); +} diff --git a/lib/hooks/useSearchCache.ts b/lib/hooks/useSearchCache.ts new file mode 100644 index 0000000..c96aab6 --- /dev/null +++ b/lib/hooks/useSearchCache.ts @@ -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, + }; +} diff --git a/lib/hooks/useSearchStream.ts b/lib/hooks/useSearchStream.ts new file mode 100644 index 0000000..1c9541b --- /dev/null +++ b/lib/hooks/useSearchStream.ts @@ -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; + 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([]); + const [availableSources, setAvailableSources] = useState([]); + 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(''); + const abortControllerRef = useRef(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(); + + 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, + }; +} diff --git a/lib/hooks/useVideoPlayer.ts b/lib/hooks/useVideoPlayer.ts new file mode 100644 index 0000000..0963e8e --- /dev/null +++ b/lib/hooks/useVideoPlayer.ts @@ -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; +} + +export function useVideoPlayer( + videoId: string | null, + source: string | null, + episodeParam: string | null +): UseVideoPlayerReturn { + const [videoData, setVideoData] = useState(null); + const [loading, setLoading] = useState(false); + const [currentEpisode, setCurrentEpisode] = useState(0); + const [playUrl, setPlayUrl] = useState(''); + const [videoError, setVideoError] = useState(''); + + 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, + }; +} diff --git a/lib/utils/source-names.ts b/lib/utils/source-names.ts new file mode 100644 index 0000000..612c045 --- /dev/null +++ b/lib/utils/source-names.ts @@ -0,0 +1,27 @@ +export function getSourceName(sourceId: string): string { + const sourceNames: Record = { + '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' +];