mirror of
https://github.com/MoonTechLab/LunaTV.git
synced 2026-02-22 01:46:09 +08:00
feat: video loading stage
This commit is contained in:
@@ -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<NodeJS.Timeout | null>(null);
|
||||
const lastSaveTimeRef = useRef<number>(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<SearchResult[]> => {
|
||||
// 根据搜索词获取全部源信息
|
||||
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<SearchResult[]> => {
|
||||
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<string, SearchResult[]>();
|
||||
|
||||
// 按数据源分组
|
||||
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() {
|
||||
<div className='flex justify-center space-x-2 mb-4'>
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full transition-all duration-500 ${
|
||||
loadingStage === 'searching'
|
||||
loadingStage === 'searching' || loadingStage === 'fetching'
|
||||
? 'bg-green-500 scale-125'
|
||||
: loadingStage === 'preferring' ||
|
||||
loadingStage === 'fetching' ||
|
||||
loadingStage === 'ready'
|
||||
? 'bg-green-500'
|
||||
: 'bg-gray-300'
|
||||
@@ -1356,15 +1285,6 @@ function PlayPageClient() {
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full transition-all duration-500 ${
|
||||
loadingStage === 'preferring'
|
||||
? 'bg-green-500 scale-125'
|
||||
: loadingStage === 'fetching' || loadingStage === 'ready'
|
||||
? 'bg-green-500'
|
||||
: 'bg-gray-300'
|
||||
}`}
|
||||
></div>
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full transition-all duration-500 ${
|
||||
loadingStage === 'fetching'
|
||||
? 'bg-green-500 scale-125'
|
||||
: loadingStage === 'ready'
|
||||
? 'bg-green-500'
|
||||
@@ -1386,12 +1306,11 @@ function PlayPageClient() {
|
||||
className='h-full bg-gradient-to-r from-green-500 to-emerald-600 rounded-full transition-all duration-1000 ease-out'
|
||||
style={{
|
||||
width:
|
||||
loadingStage === 'searching'
|
||||
? '25%'
|
||||
loadingStage === 'searching' ||
|
||||
loadingStage === 'fetching'
|
||||
? '33%'
|
||||
: loadingStage === 'preferring'
|
||||
? '50%'
|
||||
: loadingStage === 'fetching'
|
||||
? '75%'
|
||||
? '66%'
|
||||
: '100%',
|
||||
}}
|
||||
></div>
|
||||
@@ -1544,14 +1463,54 @@ function PlayPageClient() {
|
||||
>
|
||||
{/* 播放器 */}
|
||||
<div
|
||||
className={`h-full transition-all duration-300 ease-in-out ${
|
||||
className={`h-full transition-all duration-300 ease-in-out rounded-xl border border-white/0 dark:border-white/30 ${
|
||||
isEpisodeSelectorCollapsed ? 'col-span-1' : 'md:col-span-3'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
ref={artRef}
|
||||
className='bg-black w-full h-[300px] lg:h-full rounded-xl overflow-hidden border border-white/0 dark:border-white/30 shadow-lg'
|
||||
></div>
|
||||
<div className='relative w-full h-[300px] lg:h-full'>
|
||||
<div
|
||||
ref={artRef}
|
||||
className='bg-black w-full h-full rounded-xl overflow-hidden shadow-lg'
|
||||
></div>
|
||||
|
||||
{/* 换源加载蒙层 */}
|
||||
{isVideoLoading && (
|
||||
<div className='absolute inset-0 bg-black/85 backdrop-blur-sm rounded-xl flex items-center justify-center z-[9999] transition-all duration-300'>
|
||||
<div className='text-center max-w-md mx-auto px-6'>
|
||||
{/* 动画影院图标 */}
|
||||
<div className='relative mb-8'>
|
||||
<div className='relative mx-auto w-24 h-24 bg-gradient-to-r from-green-500 to-emerald-600 rounded-2xl shadow-2xl flex items-center justify-center transform hover:scale-105 transition-transform duration-300'>
|
||||
<div className='text-white text-4xl'>🎬</div>
|
||||
{/* 旋转光环 */}
|
||||
<div className='absolute -inset-2 bg-gradient-to-r from-green-500 to-emerald-600 rounded-2xl opacity-20 animate-spin'></div>
|
||||
</div>
|
||||
|
||||
{/* 浮动粒子效果 */}
|
||||
<div className='absolute top-0 left-0 w-full h-full pointer-events-none'>
|
||||
<div className='absolute top-2 left-2 w-2 h-2 bg-green-400 rounded-full animate-bounce'></div>
|
||||
<div
|
||||
className='absolute top-4 right-4 w-1.5 h-1.5 bg-emerald-400 rounded-full animate-bounce'
|
||||
style={{ animationDelay: '0.5s' }}
|
||||
></div>
|
||||
<div
|
||||
className='absolute bottom-3 left-6 w-1 h-1 bg-lime-400 rounded-full animate-bounce'
|
||||
style={{ animationDelay: '1s' }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 换源消息 */}
|
||||
<div className='space-y-2'>
|
||||
<p className='text-xl font-semibold text-white animate-pulse'>
|
||||
{videoLoadingStage === 'sourceChanging'
|
||||
? '🔄 切换播放源...'
|
||||
: '🔄 视频加载中...'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 选集和换源 - 在移动端始终显示,在 lg 及以上可折叠 */}
|
||||
@@ -1571,7 +1530,6 @@ function PlayPageClient() {
|
||||
currentId={currentId}
|
||||
videoTitle={searchTitle || videoTitle}
|
||||
availableSources={availableSources}
|
||||
onSearchSources={handleSearchSources}
|
||||
sourceSearchLoading={sourceSearchLoading}
|
||||
sourceSearchError={sourceSearchError}
|
||||
precomputedVideoInfo={precomputedVideoInfo}
|
||||
|
||||
@@ -41,27 +41,44 @@ function SearchPageClient() {
|
||||
const map = new Map<string, SearchResult[]>();
|
||||
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);
|
||||
|
||||
@@ -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<EpisodeSelectorProps> = ({
|
||||
currentId,
|
||||
videoTitle,
|
||||
availableSources = [],
|
||||
onSearchSources,
|
||||
sourceSearchLoading = false,
|
||||
sourceSearchError = null,
|
||||
precomputedVideoInfo,
|
||||
@@ -207,11 +205,25 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
||||
// 当分页切换时,将激活的分页标签滚动到视口中间
|
||||
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<EpisodeSelectorProps> = ({
|
||||
// 处理换源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<EpisodeSelectorProps> = ({
|
||||
[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,
|
||||
|
||||
@@ -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<VideoDetail> {
|
||||
): Promise<SearchResult> {
|
||||
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<VideoDetail> {
|
||||
): Promise<SearchResult> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -62,27 +62,6 @@ export interface IStorage {
|
||||
setAdminConfig(config: AdminConfig): Promise<void>;
|
||||
}
|
||||
|
||||
// 视频详情数据结构
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user