feat: video loading stage

This commit is contained in:
shinya
2025-07-13 00:04:06 +08:00
parent 323c1ba953
commit db217eb1e8
5 changed files with 280 additions and 329 deletions

View File

@@ -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}

View File

@@ -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);