From 0abeae5bdab5ba6a27887c81aa3a9729fa3febe6 Mon Sep 17 00:00:00 2001 From: shinya Date: Mon, 7 Jul 2025 13:27:48 +0800 Subject: [PATCH] feat: change source tab --- src/app/new-play/page.tsx | 172 ++++++++++++++- src/components/EpisodeSelector.tsx | 337 ++++++++++++++++++++++------- 2 files changed, 427 insertions(+), 82 deletions(-) diff --git a/src/app/new-play/page.tsx b/src/app/new-play/page.tsx index 46da32d..f8d4014 100644 --- a/src/app/new-play/page.tsx +++ b/src/app/new-play/page.tsx @@ -6,6 +6,7 @@ import { useSearchParams } from 'next/navigation'; import { Suspense, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { + deletePlayRecord, generateStorageKey, getAllPlayRecords, savePlayRecord, @@ -14,10 +15,13 @@ import { type VideoDetail, fetchVideoDetail, } from '@/lib/fetchVideoDetail.client'; +import { SearchResult } from '@/lib/types'; import EpisodeSelector from '@/components/EpisodeSelector'; import PageLayout from '@/components/PageLayout'; +// 直接从 types.ts 导入 SearchResult 接口 + function PlayPageClient() { const searchParams = useSearchParams(); @@ -50,6 +54,13 @@ function PlayPageClient() { // 用于记录是否需要在播放器 ready 后跳转到指定进度 const resumeTimeRef = useRef(null); + // 换源相关状态 + const [availableSources, setAvailableSources] = useState([]); + const [sourceSearchLoading, setSourceSearchLoading] = useState(false); + const [sourceSearchError, setSourceSearchError] = useState( + null + ); + const currentSourceRef = useRef(currentSource); const currentIdRef = useRef(currentId); const videoTitleRef = useRef(videoTitle); @@ -266,6 +277,140 @@ function PlayPageClient() { initFromHistory(); }, []); + // 处理换源搜索 + const handleSearchSources = async (query: string) => { + if (!query.trim()) { + setAvailableSources([]); + return; + } + + setSourceSearchLoading(true); + setSourceSearchError(null); + + try { + const response = await fetch( + `/api/search?q=${encodeURIComponent(query.trim())}` + ); + if (!response.ok) { + throw new Error('搜索失败'); + } + const data = await response.json(); + + // 处理搜索结果:每个数据源只展示一个,优先展示与title同名的结果 + const processedResults: SearchResult[] = []; + const sourceMap = new Map(); + + // 按数据源分组 + data.results?.forEach((result: SearchResult) => { + if (!sourceMap.has(result.source)) { + sourceMap.set(result.source, []); + } + const list = sourceMap.get(result.source); + if (list) { + list.push(result); + } + }); + + // 为每个数据源选择最佳结果 + sourceMap.forEach((results) => { + if (results.length === 0) return; + + // 只选择和当前视频标题完全匹配的结果,如果有年份,还需要年份完全匹配 + const exactMatch = results.find( + (result) => + result.title.toLowerCase() === videoTitle.toLowerCase() && + (videoYear + ? result.year.toLowerCase() === videoYear.toLowerCase() + : true) && + detail?.episodes.length && + ((detail?.episodes.length === 1 && result.episodes.length === 1) || + (detail?.episodes.length > 1 && result.episodes.length > 1)) + ); + + if (exactMatch) { + processedResults.push(exactMatch); + return; + } + }); + + // 直接使用 SearchResult 格式 + setAvailableSources(processedResults); + } catch (err) { + setSourceSearchError(err instanceof Error ? err.message : '搜索失败'); + setAvailableSources([]); + } finally { + setSourceSearchLoading(false); + } + }; + + // 处理换源 + const handleSourceChange = async ( + newSource: string, + newId: string, + newTitle: string + ) => { + try { + // 记录当前播放进度(仅在同一集数切换时恢复) + const currentPlayTime = + artPlayerRef.current?.video?.currentTime || + artPlayerRef.current?.currentTime || + 0; + console.log('换源前当前播放时间:', currentPlayTime); + + // 显示加载状态 + setError(null); + + // 清除前一个历史记录 + if (currentSource && currentId) { + try { + await deletePlayRecord(currentSource, currentId); + console.log('已清除前一个播放记录'); + } catch (err) { + console.error('清除播放记录失败:', err); + } + } + + // 获取新源的详情 + const newDetail = await fetchVideoDetail({ + source: newSource, + id: newId, + fallbackTitle: newTitle.trim(), + fallbackYear: videoYear, + }); + + // 尝试跳转到当前正在播放的集数 + let targetIndex = currentEpisodeIndex; + + // 如果当前集数超出新源的范围,则跳转到第一集 + if (!newDetail.episodes || targetIndex >= newDetail.episodes.length) { + targetIndex = 0; + } + + // 如果仍然是同一集数且播放进度有效,则在播放器就绪后恢复到原始进度 + if (targetIndex === currentEpisodeIndex && currentPlayTime > 1) { + resumeTimeRef.current = currentPlayTime; + } else { + // 否则从头开始播放,防止影响后续选集逻辑 + resumeTimeRef.current = 0; + } + + // 更新URL参数(不刷新页面) + const newUrl = new URL(window.location.href); + newUrl.searchParams.set('source', newSource); + newUrl.searchParams.set('id', newId); + window.history.replaceState({}, '', newUrl.toString()); + + setVideoTitle(newDetail.title || newTitle); + setVideoCover(newDetail.poster); + setCurrentSource(newSource); + setCurrentId(newId); + setDetail(newDetail); + setCurrentEpisodeIndex(targetIndex); + } catch (err) { + setError(err instanceof Error ? err.message : '换源失败'); + } + }; + // 处理集数切换 const handleEpisodeChange = (episodeNumber: number) => { if (episodeNumber >= 0 && episodeNumber < totalEpisodes) { @@ -515,11 +660,15 @@ function PlayPageClient() { // 监听播放器事件 artPlayerRef.current.on('ready', () => { setError(null); + }); + // 监听视频可播放事件,这时恢复播放进度更可靠 + artPlayerRef.current.on('video:canplay', () => { // 若存在需要恢复的播放进度,则跳转 if (resumeTimeRef.current && resumeTimeRef.current > 0) { try { artPlayerRef.current.video.currentTime = resumeTimeRef.current; + console.log('成功恢复播放进度到:', resumeTimeRef.current); } catch (err) { console.warn('恢复播放进度失败:', err); } @@ -652,29 +801,42 @@ function PlayPageClient() { return ( -
+
{/* 第一行:影片标题 */}

{videoTitle || '影片标题'} + {totalEpisodes > 1 && ( + + {` > 第 ${currentEpisodeIndex + 1} 集`} + + )}

{/* 第二行:播放器和选集 */}
{/* 播放器 */} -
+
- {/* 选集 */} -
+ {/* 选集和换源 */} +
diff --git a/src/components/EpisodeSelector.tsx b/src/components/EpisodeSelector.tsx index ff63a51..84f01b6 100644 --- a/src/components/EpisodeSelector.tsx +++ b/src/components/EpisodeSelector.tsx @@ -1,3 +1,5 @@ +/* eslint-disable @next/next/no-img-element */ + import React, { useCallback, useEffect, @@ -6,6 +8,8 @@ import React, { useState, } from 'react'; +import { SearchResult } from '@/lib/types'; + interface EpisodeSelectorProps { /** 总集数 */ totalEpisodes: number; @@ -15,22 +19,42 @@ interface EpisodeSelectorProps { value?: number; /** 用户点击选集后的回调 */ onChange?: (episodeNumber: number) => void; - /** 额外 className */ - className?: string; + /** 换源相关 */ + onSourceChange?: (source: string, id: string, title: string) => void; + currentSource?: string; + currentId?: string; + videoTitle?: string; + videoYear?: string; + availableSources?: SearchResult[]; + onSearchSources?: (query: string) => void; + sourceSearchLoading?: boolean; + sourceSearchError?: string | null; } /** - * 选集组件,支持分页与自动滚动聚焦当前分页标签。 + * 选集组件,支持分页、自动滚动聚焦当前分页标签,以及换源功能。 */ const EpisodeSelector: React.FC = ({ totalEpisodes, episodesPerPage = 50, value = 1, onChange, - className = '', + onSourceChange, + currentSource, + currentId, + videoTitle, + availableSources = [], + onSearchSources, + sourceSearchLoading = false, + sourceSearchError = null, }) => { const pageCount = Math.ceil(totalEpisodes / episodesPerPage); + // 主要的 tab 状态:'episodes' 或 'sources' + const [activeTab, setActiveTab] = useState<'episodes' | 'sources'>( + 'episodes' + ); + // 当前分页索引(0 开始) const initialPage = Math.floor((value - 1) / episodesPerPage); const [currentPage, setCurrentPage] = useState(initialPage); @@ -65,6 +89,15 @@ const EpisodeSelector: React.FC = ({ } }, [currentPage, pageCount]); + // 处理换源tab点击,只在点击时才搜索 + const handleSourceTabClick = () => { + setActiveTab('sources'); + // 只在点击时搜索,且只搜索一次 + if (availableSources.length === 0 && videoTitle && onSearchSources) { + onSearchSources(videoTitle); + } + }; + const handleCategoryClick = useCallback((index: number) => { setCurrentPage(index); }, []); @@ -76,6 +109,13 @@ const EpisodeSelector: React.FC = ({ [onChange] ); + const handleSourceClick = useCallback( + (source: SearchResult) => { + onSourceChange?.(source.source, source.id, source.title); + }, + [onSourceChange] + ); + const currentStart = currentPage * episodesPerPage + 1; const currentEnd = Math.min( currentStart + episodesPerPage - 1, @@ -83,89 +123,232 @@ const EpisodeSelector: React.FC = ({ ); return ( -
- {/* 分类标签 */} -
-
-
- {categories.map((label, idx) => { - const isActive = idx === currentPage; +
+ {/* 主要的 Tab 切换 - 无缝融入设计 */} +
+
setActiveTab('episodes')} + className={`flex-1 py-3 px-6 text-center cursor-pointer transition-all duration-200 font-medium + ${ + activeTab === 'episodes' + ? 'text-green-500 dark:text-green-400' + : 'text-gray-700 hover:text-green-600 bg-black/5 dark:bg-white/5 dark:text-gray-300 dark:hover:text-green-400 hover:bg-black/3 dark:hover:bg-white/3' + } + `.trim()} + > + 选集 +
+
+ 换源 +
+
+ + {/* 选集 Tab 内容 */} + {activeTab === 'episodes' && ( + <> + {/* 分类标签 */} +
+
+
+ {categories.map((label, idx) => { + const isActive = idx === currentPage; + return ( + + ); + })} +
+
+ {/* 向上/向下按钮 */} + +
+ + {/* 集数网格 */} +
+ {(() => { + const len = currentEnd - currentStart + 1; + const episodes = Array.from({ length: len }, (_, i) => + descending ? currentEnd - i : currentStart + i + ); + return episodes; + })().map((episodeNumber) => { + const isActive = episodeNumber === value; return ( ); })}
-
- {/* 向上/向下按钮占位,可根据实际需求添加功能 */} - -
+ + )} - {/* 集数网格 */} -
- {(() => { - const len = currentEnd - currentStart + 1; - const episodes = Array.from({ length: len }, (_, i) => - descending ? currentEnd - i : currentStart + i - ); - return episodes; - })().map((episodeNumber) => { - const isActive = episodeNumber === value; - return ( - - ); - })} -
+ {/* 换源 Tab 内容 */} + {activeTab === 'sources' && ( +
+ {sourceSearchLoading && ( +
+
+ + 搜索中... + +
+ )} + + {sourceSearchError && ( +
+
+
⚠️
+

+ {sourceSearchError} +

+
+
+ )} + + {!sourceSearchLoading && + !sourceSearchError && + availableSources.length === 0 && ( +
+
+
📺
+

+ 暂无可用的换源 +

+
+
+ )} + + {!sourceSearchLoading && + !sourceSearchError && + availableSources.length > 0 && ( +
+ {availableSources + .sort((a, b) => { + const aIsCurrent = + a.source?.toString() === currentSource?.toString() && + a.id?.toString() === currentId?.toString(); + const bIsCurrent = + b.source?.toString() === currentSource?.toString() && + b.id?.toString() === currentId?.toString(); + if (aIsCurrent && !bIsCurrent) return -1; + if (!aIsCurrent && bIsCurrent) return 1; + return 0; + }) + .map((source) => { + const isCurrentSource = + source.source?.toString() === currentSource?.toString() && + source.id?.toString() === currentId?.toString(); + return ( +
+ !isCurrentSource && handleSourceClick(source) + } + className={`flex items-start gap-3 p-3 rounded-lg cursor-pointer transition-all duration-200 + ${ + isCurrentSource + ? 'bg-green-500/10 dark:bg-green-500/20 border-green-500/30 border' + : 'hover:bg-gray-200/50 dark:hover:bg-white/10 hover:scale-[1.02]' + }`.trim()} + > + {/* 封面 */} +
+ {source.episodes && source.episodes.length > 0 && ( + {source.title} { + const target = e.target as HTMLImageElement; + target.style.display = 'none'; + }} + /> + )} +
+ + {/* 信息区域 */} +
+
+
+

+ {source.title} +

+
+ + {source.source_name} + +
+ {source.episodes.length > 1 && ( + + 共 {source.episodes.length} 集 + + )} +
+
+
+
+ ); + })} +
+ )} +
+ )}
); };