diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index 2b196c8..a1dbb9b 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -16,10 +16,7 @@ import { savePlayRecord, toggleFavorite, } from '@/lib/db.client'; -import { - type VideoDetail, - fetchVideoDetail, -} from '@/lib/fetchVideoDetail.client'; +import { type VideoDetail } from '@/lib/fetchVideoDetail.client'; import { SearchResult } from '@/lib/types'; import { getVideoResolutionFromM3u8 } from '@/lib/utils'; @@ -87,8 +84,7 @@ function PlayPageClient() { needPreferRef.current = needPrefer; }, [needPrefer]); // 集数相关 - const initialIndex = parseInt(searchParams.get('index') || '1') - 1; // 转换为0基数组索引 - const [currentEpisodeIndex, setCurrentEpisodeIndex] = useState(initialIndex); + const [currentEpisodeIndex, setCurrentEpisodeIndex] = useState(0); const currentSourceRef = useRef(currentSource); const currentIdRef = useRef(currentId); @@ -141,6 +137,12 @@ function PlayPageClient() { const [isEpisodeSelectorCollapsed, setIsEpisodeSelectorCollapsed] = useState(false); + // 换源加载状态 + const [isVideoLoading, setIsVideoLoading] = useState(true); + const [videoLoadingStage, setVideoLoadingStage] = useState< + 'initing' | 'sourceChanging' + >('initing'); + // 播放进度保存相关 const saveIntervalRef = useRef(null); const lastSaveTimeRef = useRef(0); @@ -445,114 +447,137 @@ function PlayPageClient() { updateVideoUrl(detail, currentEpisodeIndex); }, [detail, currentEpisodeIndex]); - // 获取视频详情 + // 进入页面时直接获取全部源信息 useEffect(() => { - const fetchDetailAsync = async () => { - console.log( - 'fetchDetailAsync', - currentSource, - currentId, - videoTitle, - searchTitle - ); + const fetchSourcesData = async (query: string): Promise => { + // 根据搜索词获取全部源信息 + try { + const response = await fetch( + `/api/search?q=${encodeURIComponent(query.trim())}` + ); + if (!response.ok) { + throw new Error('搜索失败'); + } + const data = await response.json(); + + // 处理搜索结果,根据规则过滤 + const results = data.results.filter( + (result: SearchResult) => + result.title.replaceAll(' ', '').toLowerCase() === + videoTitleRef.current.replaceAll(' ', '').toLowerCase() && + (videoYearRef.current + ? result.year.toLowerCase() === videoYearRef.current.toLowerCase() + : true) && + (searchType + ? (searchType === 'tv' && result.episodes.length > 1) || + (searchType === 'movie' && result.episodes.length === 1) + : true) + ); + if (results.length !== 0) { + setAvailableSources(results); + return results; + } + + // 未获取到任何内容,fallback 使用 source + id + if (!currentSource || !currentId) { + return []; + } + + const detailResponse = await fetch( + `/api/detail?source=${currentSource}&id=${currentId}` + ); + if (!detailResponse.ok) { + throw new Error('获取视频详情失败'); + } + const detailData = (await detailResponse.json()) as SearchResult; + results.push(detailData); + setAvailableSources(results); + return results; + } catch (err) { + setSourceSearchError(err instanceof Error ? err.message : '搜索失败'); + setAvailableSources([]); + return []; + } finally { + setSourceSearchLoading(false); + } + }; + + const initAll = async () => { if (!currentSource && !currentId && !videoTitle && !searchTitle) { setError('缺少必要参数'); setLoading(false); return; } + setLoading(true); + setLoadingStage(currentSource && currentId ? 'fetching' : 'searching'); + setLoadingMessage( + currentSource && currentId + ? '🎬 正在获取视频详情...' + : '🔍 正在搜索播放源...' + ); + const sourcesInfo = await fetchSourcesData(searchTitle || videoTitle); + if (sourcesInfo.length === 0) { + setError('未找到匹配结果'); + setLoading(false); + return; + } + + let needLoadSource = currentSource; + let needLoadId = currentId; if ((!currentSource && !currentId) || needPreferRef.current) { - // 只包含视频标题,搜索视频 - setLoading(true); - setLoadingStage('searching'); - setLoadingMessage('🔍 正在搜索播放源...'); - - const searchResults = await handleSearchSources( - searchTitle || videoTitle - ); - if (searchResults.length == 0) { - if (currentSource && currentId) { - // 跳过优选 - setNeedPrefer(false); - return; - } - setError('未找到匹配结果'); - setLoading(false); - return; - } - // 对播放源做优选 setLoadingStage('preferring'); setLoadingMessage('⚡ 正在优选最佳播放源...'); - const preferredSource = await preferBestSource(searchResults); + + const preferredSource = await preferBestSource(sourcesInfo); setNeedPrefer(false); setCurrentSource(preferredSource.source); setCurrentId(preferredSource.id); setVideoYear(preferredSource.year); - - // 替换URL参数 - const newUrl = new URL(window.location.href); - newUrl.searchParams.set('source', preferredSource.source); - newUrl.searchParams.set('id', preferredSource.id); - newUrl.searchParams.set('year', preferredSource.year); - newUrl.searchParams.delete('prefer'); - window.history.replaceState({}, '', newUrl.toString()); - return; + needLoadSource = preferredSource.source; + needLoadId = preferredSource.id; } - const fetchDetail = async () => { - try { - setLoadingStage('fetching'); - setLoadingMessage('🎬 正在获取视频详情...'); + console.log(sourcesInfo); + console.log(needLoadSource, needLoadId); + const detailData = sourcesInfo.find( + (source) => + source.source === needLoadSource && + source.id.toString() === needLoadId.toString() + ); + if (!detailData) { + setError('未找到匹配结果'); + setLoading(false); + return; + } + setVideoTitle(detailData.title || videoTitleRef.current); + setVideoYear(detailData.year); + setVideoCover(detailData.poster); + setDetail(detailData); + if (currentEpisodeIndex >= detailData.episodes.length) { + setCurrentEpisodeIndex(0); + } - const detailData = await fetchVideoDetail({ - source: currentSource, - id: currentId, - fallbackTitle: searchTitle || videoTitleRef.current.trim(), - }); + // 规范URL参数 + const newUrl = new URL(window.location.href); + newUrl.searchParams.set('source', needLoadSource); + newUrl.searchParams.set('id', needLoadId); + newUrl.searchParams.set('year', detailData.year); + newUrl.searchParams.set('title', detailData.title); + newUrl.searchParams.delete('prefer'); + window.history.replaceState({}, '', newUrl.toString()); - // 更新状态保存详情 - setVideoTitle(detailData.title || videoTitleRef.current); - setVideoYear(detailData.year); - setVideoCover(detailData.poster); - setDetail(detailData); + setLoadingStage('ready'); + setLoadingMessage('✨ 准备就绪,即将开始播放...'); - // 确保集数索引在有效范围内 - if (currentEpisodeIndex >= detailData.episodes.length) { - setCurrentEpisodeIndex(0); - } - - // 清理URL参数(移除index参数) - if (searchParams.has('index')) { - const newUrl = new URL(window.location.href); - newUrl.searchParams.set('year', detailData.year); - newUrl.searchParams.set( - 'title', - detailData.title || videoTitleRef.current - ); - newUrl.searchParams.delete('index'); - newUrl.searchParams.delete('position'); - window.history.replaceState({}, '', newUrl.toString()); - } - - setLoadingStage('ready'); - setLoadingMessage('✨ 准备就绪,即将开始播放...'); - - // 短暂延迟让用户看到完成状态 - setTimeout(() => { - setLoading(false); - }, 1000); - } catch (err) { - console.error('获取视频详情失败:', err); - setError(err instanceof Error ? err.message : '获取视频详情失败'); - setLoading(false); - } - }; - - fetchDetail(); + // 短暂延迟让用户看到完成状态 + setTimeout(() => { + setLoading(false); + }, 1000); }; - fetchDetailAsync(); - }, [currentSource, currentId, needPrefer]); + initAll(); + }, []); // 播放记录处理 useEffect(() => { @@ -565,33 +590,7 @@ function PlayPageClient() { const key = generateStorageKey(currentSource, currentId); const record = allRecords[key]; - // URL 参数 - const urlIndexParam = searchParams.get('index'); - const urlPositionParam = searchParams.get('position'); - - // 当index参数存在时的处理逻辑 - if (urlIndexParam) { - const urlIndex = parseInt(urlIndexParam, 10) - 1; - let targetTime = 0; // 默认从0开始 - - // 只有index参数和position参数都存在时才生效position - if (urlPositionParam) { - targetTime = parseInt(urlPositionParam, 10); - } else if (record && urlIndex === record.index - 1) { - // 如果有同集播放记录则跳转到播放记录处 - targetTime = record.play_time; - } - // 否则从0开始(targetTime已经是0) - - // 更新当前选集索引 - if (urlIndex !== currentEpisodeIndex) { - setCurrentEpisodeIndex(urlIndex); - } - - // 保存待恢复的播放进度,待播放器就绪后跳转 - resumeTimeRef.current = targetTime; - } else if (record) { - // 没有index参数但有播放记录时,使用原有逻辑 + if (record) { const targetIndex = record.index - 1; const targetTime = record.play_time; @@ -611,84 +610,6 @@ function PlayPageClient() { initFromHistory(); }, []); - // --------------------------------------------------------------------------- - // 换源搜索与切换 - // --------------------------------------------------------------------------- - // 处理换源搜索 - const handleSearchSources = async ( - query: string - ): Promise => { - 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 exactMatchs = results.filter( - (result) => - result.title.toLowerCase() === - videoTitleRef.current.toLowerCase() && - (videoYearRef.current - ? result.year.toLowerCase() === videoYearRef.current.toLowerCase() - : true) && - (detailRef.current - ? (detailRef.current.episodes.length === 1 && - result.episodes.length === 1) || - (detailRef.current.episodes.length > 1 && - result.episodes.length > 1) - : true) && - (searchType - ? (searchType === 'tv' && result.episodes.length > 1) || - (searchType === 'movie' && result.episodes.length === 1) - : true) - ); - if (exactMatchs.length > 0) { - processedResults.push(...exactMatchs); - } - }); - - setAvailableSources(processedResults); - return processedResults; - } catch (err) { - setSourceSearchError(err instanceof Error ? err.message : '搜索失败'); - setAvailableSources([]); - return []; - } finally { - setSourceSearchLoading(false); - } - }; - // 处理换源 const handleSourceChange = async ( newSource: string, @@ -696,13 +617,14 @@ function PlayPageClient() { newTitle: string ) => { try { + // 显示换源加载状态 + setVideoLoadingStage('sourceChanging'); + setIsVideoLoading(true); + // 记录当前播放进度(仅在同一集数切换时恢复) const currentPlayTime = artPlayerRef.current?.currentTime || 0; console.log('换源前当前播放时间:', currentPlayTime); - // 显示加载状态 - setError(null); - // 清除前一个历史记录 if (currentSourceRef.current && currentIdRef.current) { try { @@ -716,12 +638,13 @@ function PlayPageClient() { } } - // 获取新源的详情 - const newDetail = await fetchVideoDetail({ - source: newSource, - id: newId, - fallbackTitle: searchTitle || newTitle.trim(), - }); + const newDetail = availableSources.find( + (source) => source.source === newSource && source.id === newId + ); + if (!newDetail) { + setError('未找到匹配结果'); + return; + } // 尝试跳转到当前正在播放的集数 let targetIndex = currentEpisodeIndex; @@ -732,11 +655,13 @@ function PlayPageClient() { } // 如果仍然是同一集数且播放进度有效,则在播放器就绪后恢复到原始进度 - if (targetIndex === currentEpisodeIndex && currentPlayTime > 1) { - resumeTimeRef.current = currentPlayTime; - } else { - // 否则从头开始播放,防止影响后续选集逻辑 + if (targetIndex !== currentEpisodeIndex) { resumeTimeRef.current = 0; + } else if ( + (!resumeTimeRef.current || resumeTimeRef.current === 0) && + currentPlayTime > 1 + ) { + resumeTimeRef.current = currentPlayTime; } // 更新URL参数(不刷新页面) @@ -754,6 +679,8 @@ function PlayPageClient() { setDetail(newDetail); setCurrentEpisodeIndex(targetIndex); } catch (err) { + // 隐藏换源加载状态 + setIsVideoLoading(false); setError(err instanceof Error ? err.message : '换源失败'); } }; @@ -1243,8 +1170,8 @@ function PlayPageClient() { } catch (err) { console.warn('恢复播放进度失败:', err); } - resumeTimeRef.current = null; } + resumeTimeRef.current = null; setTimeout(() => { if ( @@ -1254,6 +1181,9 @@ function PlayPageClient() { } artPlayerRef.current.notice.show = ''; }, 0); + + // 隐藏换源加载状态 + setIsVideoLoading(false); }); artPlayerRef.current.on('error', (err: any) => { @@ -1344,10 +1274,9 @@ function PlayPageClient() {
-
@@ -1544,14 +1463,54 @@ function PlayPageClient() { > {/* 播放器 */}
-
+
+
+ + {/* 换源加载蒙层 */} + {isVideoLoading && ( +
+
+ {/* 动画影院图标 */} +
+
+
🎬
+ {/* 旋转光环 */} +
+
+ + {/* 浮动粒子效果 */} +
+
+
+
+
+
+ + {/* 换源消息 */} +
+

+ {videoLoadingStage === 'sourceChanging' + ? '🔄 切换播放源...' + : '🔄 视频加载中...'} +

+
+
+
+ )} +
{/* 选集和换源 - 在移动端始终显示,在 lg 及以上可折叠 */} @@ -1571,7 +1530,6 @@ function PlayPageClient() { currentId={currentId} videoTitle={searchTitle || videoTitle} availableSources={availableSources} - onSearchSources={handleSearchSources} sourceSearchLoading={sourceSearchLoading} sourceSearchError={sourceSearchError} precomputedVideoInfo={precomputedVideoInfo} diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx index 0531563..87d84a0 100644 --- a/src/app/search/page.tsx +++ b/src/app/search/page.tsx @@ -41,27 +41,44 @@ function SearchPageClient() { const map = new Map(); searchResults.forEach((item) => { // 使用 title + year + type 作为键,year 必然存在,但依然兜底 'unknown' - const key = `${item.title}-${item.year || 'unknown'}-${ - item.episodes.length === 1 ? 'movie' : 'tv' - }`; + const key = `${item.title.replaceAll(' ', '')}-${ + item.year || 'unknown' + }-${item.episodes.length === 1 ? 'movie' : 'tv'}`; const arr = map.get(key) || []; arr.push(item); map.set(key, arr); }); return Array.from(map.entries()).sort((a, b) => { // 优先排序:标题与搜索词完全一致的排在前面 - const aExactMatch = a[1][0].title === searchQuery.trim(); - const bExactMatch = b[1][0].title === searchQuery.trim(); + const aExactMatch = a[1][0].title + .replaceAll(' ', '') + .includes(searchQuery.trim().replaceAll(' ', '')); + const bExactMatch = b[1][0].title + .replaceAll(' ', '') + .includes(searchQuery.trim().replaceAll(' ', '')); if (aExactMatch && !bExactMatch) return -1; if (!aExactMatch && bExactMatch) return 1; - // 如果都匹配或都不匹配,则按原来的逻辑排序 - return a[1][0].year === b[1][0].year - ? a[0].localeCompare(b[0]) - : a[1][0].year > b[1][0].year - ? -1 - : 1; + // 年份排序 + if (a[1][0].year === b[1][0].year) { + return a[0].localeCompare(b[0]); + } else { + // 处理 unknown 的情况 + const aYear = a[1][0].year; + const bYear = b[1][0].year; + + if (aYear === 'unknown' && bYear === 'unknown') { + return 0; + } else if (aYear === 'unknown') { + return 1; // a 排在后面 + } else if (bYear === 'unknown') { + return -1; // b 排在后面 + } else { + // 都是数字年份,按数字大小排序(大的在前面) + return aYear > bYear ? -1 : 1; + } + } }); }, [searchResults]); @@ -105,11 +122,21 @@ function SearchPageClient() { if (!aExactMatch && bExactMatch) return 1; // 如果都匹配或都不匹配,则按原来的逻辑排序 - return a.year === b.year - ? a.title.localeCompare(b.title) - : a.year > b.year - ? -1 - : 1; + if (a.year === b.year) { + return a.title.localeCompare(b.title); + } else { + // 处理 unknown 的情况 + if (a.year === 'unknown' && b.year === 'unknown') { + return 0; + } else if (a.year === 'unknown') { + return 1; // a 排在后面 + } else if (b.year === 'unknown') { + return -1; // b 排在后面 + } else { + // 都是数字年份,按数字大小排序(大的在前面) + return parseInt(a.year) > parseInt(b.year) ? -1 : 1; + } + } }) ); setShowResults(true); diff --git a/src/components/EpisodeSelector.tsx b/src/components/EpisodeSelector.tsx index 719aa10..3c3edd1 100644 --- a/src/components/EpisodeSelector.tsx +++ b/src/components/EpisodeSelector.tsx @@ -36,7 +36,6 @@ interface EpisodeSelectorProps { videoTitle?: string; videoYear?: string; availableSources?: SearchResult[]; - onSearchSources?: (query: string) => void; sourceSearchLoading?: boolean; sourceSearchError?: string | null; /** 预计算的测速结果,避免重复测速 */ @@ -56,7 +55,6 @@ const EpisodeSelector: React.FC = ({ currentId, videoTitle, availableSources = [], - onSearchSources, sourceSearchLoading = false, sourceSearchError = null, precomputedVideoInfo, @@ -207,11 +205,25 @@ const EpisodeSelector: React.FC = ({ // 当分页切换时,将激活的分页标签滚动到视口中间 useEffect(() => { const btn = buttonRefs.current[currentPage]; - if (btn) { - btn.scrollIntoView({ + const container = categoryContainerRef.current; + if (btn && container) { + // 手动计算滚动位置,只滚动分页标签容器 + const containerRect = container.getBoundingClientRect(); + const btnRect = btn.getBoundingClientRect(); + const scrollLeft = container.scrollLeft; + + // 计算按钮相对于容器的位置 + const btnLeft = btnRect.left - containerRect.left + scrollLeft; + const btnWidth = btnRect.width; + const containerWidth = containerRect.width; + + // 计算目标滚动位置,使按钮居中 + const targetScrollLeft = btnLeft - (containerWidth - btnWidth) / 2; + + // 平滑滚动到目标位置 + container.scrollTo({ + left: targetScrollLeft, behavior: 'smooth', - inline: 'center', - block: 'nearest', }); } }, [currentPage, pageCount]); @@ -219,10 +231,6 @@ const EpisodeSelector: React.FC = ({ // 处理换源tab点击,只在点击时才搜索 const handleSourceTabClick = () => { setActiveTab('sources'); - // 只在点击时搜索,且只搜索一次 - if (availableSources.length === 0 && videoTitle && onSearchSources) { - onSearchSources(videoTitle); - } }; const handleCategoryClick = useCallback((index: number) => { @@ -243,20 +251,6 @@ const EpisodeSelector: React.FC = ({ [onSourceChange] ); - // 如果组件初始即显示 "换源",自动触发搜索一次 - useEffect(() => { - if ( - activeTab === 'sources' && - availableSources.length === 0 && - videoTitle && - onSearchSources - ) { - onSearchSources(videoTitle); - } - // 只在依赖变化时尝试,availableSources 长度变化可阻止重复搜索 - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [activeTab, availableSources.length, videoTitle]); - const currentStart = currentPage * episodesPerPage + 1; const currentEnd = Math.min( currentStart + episodesPerPage - 1, diff --git a/src/lib/downstream.ts b/src/lib/downstream.ts index 6241245..36e830d 100644 --- a/src/lib/downstream.ts +++ b/src/lib/downstream.ts @@ -1,5 +1,5 @@ import { API_CONFIG, ApiSite, getConfig } from '@/lib/config'; -import { SearchResult, VideoDetail } from '@/lib/types'; +import { SearchResult } from '@/lib/types'; import { cleanHtmlTags } from '@/lib/utils'; const config = getConfig(); @@ -77,7 +77,7 @@ export async function searchFromApi( }); return { - id: item.vod_id, + id: item.vod_id.toString(), title: item.vod_name.trim().replace(/\s+/g, ' '), poster: item.vod_pic, episodes, @@ -147,7 +147,7 @@ export async function searchFromApi( }); return { - id: item.vod_id, + id: item.vod_id.toString(), title: item.vod_name.trim().replace(/\s+/g, ' '), poster: item.vod_pic, episodes, @@ -193,7 +193,7 @@ const M3U8_PATTERN = /(https?:\/\/[^"'\s]+?\.m3u8)/g; export async function getDetailFromApi( apiSite: ApiSite, id: string -): Promise { +): Promise { if (apiSite.detail) { return handleSpecialSourceDetail(id, apiSite); } @@ -253,32 +253,26 @@ export async function getDetailFromApi( } return { - code: 200, + id: id.toString(), + title: videoDetail.vod_name, + poster: videoDetail.vod_pic, episodes, - detailUrl, - videoInfo: { - title: videoDetail.vod_name, - cover: videoDetail.vod_pic, - desc: cleanHtmlTags(videoDetail.vod_content), - type: videoDetail.type_name, - year: videoDetail.vod_year - ? videoDetail.vod_year.match(/\d{4}/)?.[0] || '' - : 'unknown', - area: videoDetail.vod_area, - director: videoDetail.vod_director, - actor: videoDetail.vod_actor, - remarks: videoDetail.vod_remarks, - source_name: apiSite.name, - source: apiSite.key, - id, - }, + source: apiSite.key, + source_name: apiSite.name, + class: videoDetail.vod_class, + year: videoDetail.vod_year + ? videoDetail.vod_year.match(/\d{4}/)?.[0] || '' + : 'unknown', + desc: cleanHtmlTags(videoDetail.vod_content), + type_name: videoDetail.type_name, + douban_id: videoDetail.vod_douban_id, }; } async function handleSpecialSourceDetail( id: string, apiSite: ApiSite -): Promise { +): Promise { const detailUrl = `${apiSite.detail}/index.php/vod/detail/id/${id}.html`; const controller = new AbortController(); @@ -335,17 +329,16 @@ async function handleSpecialSourceDetail( const yearText = yearMatch ? yearMatch[1] : 'unknown'; return { - code: 200, + id, + title: titleText, + poster: coverUrl, episodes: matches, - detailUrl, - videoInfo: { - title: titleText, - cover: coverUrl, - desc: descText, - source_name: apiSite.name, - source: apiSite.key, - year: yearText, - id, - }, + source: apiSite.key, + source_name: apiSite.name, + class: '', + year: yearText, + desc: descText, + type_name: '', + douban_id: 0, }; } diff --git a/src/lib/types.ts b/src/lib/types.ts index 5319125..f4b961c 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -62,27 +62,6 @@ export interface IStorage { setAdminConfig(config: AdminConfig): Promise; } -// 视频详情数据结构 -export interface VideoDetail { - code: number; - episodes: string[]; - detailUrl: string; - videoInfo: { - title: string; - cover?: string; - desc?: string; - type?: string; - year?: string; - area?: string; - director?: string; - actor?: string; - remarks?: string; - source_name: string; - source: string; - id: string; - }; -} - // 搜索结果数据结构 export interface SearchResult { id: string;