From ad9e75bc8c82da5c127ad96327acf5b8f9ea3da9 Mon Sep 17 00:00:00 2001
From: kuekhaoyang
Date: Mon, 17 Nov 2025 13:51:03 +0800
Subject: [PATCH] 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.
---
app/page-backup.tsx | 605 ++++++++++++++++++++++++++++
app/page.tsx | 577 ++++----------------------
app/player/page.tsx | 379 +++--------------
components/player/EpisodeList.tsx | 62 +++
components/player/PlayerError.tsx | 41 ++
components/player/VideoMetadata.tsx | 73 ++++
components/player/VideoPlayer.tsx | 138 +++++++
components/search/EmptyState.tsx | 49 +++
components/search/NoResults.tsx | 30 ++
components/search/ResultsHeader.tsx | 54 +++
components/search/SearchForm.tsx | 80 ++++
components/search/SourceBadges.tsx | 42 ++
components/search/VideoGrid.tsx | 106 +++++
lib/hooks/useSearchCache.ts | 71 ++++
lib/hooks/useSearchStream.ts | 182 +++++++++
lib/hooks/useVideoPlayer.ts | 112 +++++
lib/utils/source-names.ts | 27 ++
17 files changed, 1786 insertions(+), 842 deletions(-)
create mode 100644 app/page-backup.tsx
create mode 100644 components/player/EpisodeList.tsx
create mode 100644 components/player/PlayerError.tsx
create mode 100644 components/player/VideoMetadata.tsx
create mode 100644 components/player/VideoPlayer.tsx
create mode 100644 components/search/EmptyState.tsx
create mode 100644 components/search/NoResults.tsx
create mode 100644 components/search/ResultsHeader.tsx
create mode 100644 components/search/SearchForm.tsx
create mode 100644 components/search/SourceBadges.tsx
create mode 100644 components/search/VideoGrid.tsx
create mode 100644 lib/hooks/useSearchCache.ts
create mode 100644 lib/hooks/useSearchStream.ts
create mode 100644 lib/hooks/useVideoPlayer.ts
create mode 100644 lib/utils/source-names.ts
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 */}
+
+
+
+ {/* 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 ? (
+

+ ) : (
+
+
+
+ )}
+
+ {/* 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 */}
-
+
{/* 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 ? (
-

- ) : (
-
-
-
- )}
-
- {/* 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 || 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 || 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 (
+
+ );
+}
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 ? (
+

+ ) : (
+
+
+
+ )}
+
+ {/* 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'
+];