mirror of
https://github.com/MoonTechLab/LunaTV.git
synced 2026-02-24 12:24:46 +08:00
feat: video loading stage
This commit is contained in:
@@ -16,10 +16,7 @@ import {
|
|||||||
savePlayRecord,
|
savePlayRecord,
|
||||||
toggleFavorite,
|
toggleFavorite,
|
||||||
} from '@/lib/db.client';
|
} from '@/lib/db.client';
|
||||||
import {
|
import { type VideoDetail } from '@/lib/fetchVideoDetail.client';
|
||||||
type VideoDetail,
|
|
||||||
fetchVideoDetail,
|
|
||||||
} from '@/lib/fetchVideoDetail.client';
|
|
||||||
import { SearchResult } from '@/lib/types';
|
import { SearchResult } from '@/lib/types';
|
||||||
import { getVideoResolutionFromM3u8 } from '@/lib/utils';
|
import { getVideoResolutionFromM3u8 } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -87,8 +84,7 @@ function PlayPageClient() {
|
|||||||
needPreferRef.current = needPrefer;
|
needPreferRef.current = needPrefer;
|
||||||
}, [needPrefer]);
|
}, [needPrefer]);
|
||||||
// 集数相关
|
// 集数相关
|
||||||
const initialIndex = parseInt(searchParams.get('index') || '1') - 1; // 转换为0基数组索引
|
const [currentEpisodeIndex, setCurrentEpisodeIndex] = useState(0);
|
||||||
const [currentEpisodeIndex, setCurrentEpisodeIndex] = useState(initialIndex);
|
|
||||||
|
|
||||||
const currentSourceRef = useRef(currentSource);
|
const currentSourceRef = useRef(currentSource);
|
||||||
const currentIdRef = useRef(currentId);
|
const currentIdRef = useRef(currentId);
|
||||||
@@ -141,6 +137,12 @@ function PlayPageClient() {
|
|||||||
const [isEpisodeSelectorCollapsed, setIsEpisodeSelectorCollapsed] =
|
const [isEpisodeSelectorCollapsed, setIsEpisodeSelectorCollapsed] =
|
||||||
useState(false);
|
useState(false);
|
||||||
|
|
||||||
|
// 换源加载状态
|
||||||
|
const [isVideoLoading, setIsVideoLoading] = useState(true);
|
||||||
|
const [videoLoadingStage, setVideoLoadingStage] = useState<
|
||||||
|
'initing' | 'sourceChanging'
|
||||||
|
>('initing');
|
||||||
|
|
||||||
// 播放进度保存相关
|
// 播放进度保存相关
|
||||||
const saveIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
const saveIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const lastSaveTimeRef = useRef<number>(0);
|
const lastSaveTimeRef = useRef<number>(0);
|
||||||
@@ -445,114 +447,137 @@ function PlayPageClient() {
|
|||||||
updateVideoUrl(detail, currentEpisodeIndex);
|
updateVideoUrl(detail, currentEpisodeIndex);
|
||||||
}, [detail, currentEpisodeIndex]);
|
}, [detail, currentEpisodeIndex]);
|
||||||
|
|
||||||
// 获取视频详情
|
// 进入页面时直接获取全部源信息
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchDetailAsync = async () => {
|
const fetchSourcesData = async (query: string): Promise<SearchResult[]> => {
|
||||||
console.log(
|
// 根据搜索词获取全部源信息
|
||||||
'fetchDetailAsync',
|
try {
|
||||||
currentSource,
|
const response = await fetch(
|
||||||
currentId,
|
`/api/search?q=${encodeURIComponent(query.trim())}`
|
||||||
videoTitle,
|
);
|
||||||
searchTitle
|
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) {
|
if (!currentSource && !currentId && !videoTitle && !searchTitle) {
|
||||||
setError('缺少必要参数');
|
setError('缺少必要参数');
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
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) {
|
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');
|
setLoadingStage('preferring');
|
||||||
setLoadingMessage('⚡ 正在优选最佳播放源...');
|
setLoadingMessage('⚡ 正在优选最佳播放源...');
|
||||||
const preferredSource = await preferBestSource(searchResults);
|
|
||||||
|
const preferredSource = await preferBestSource(sourcesInfo);
|
||||||
setNeedPrefer(false);
|
setNeedPrefer(false);
|
||||||
setCurrentSource(preferredSource.source);
|
setCurrentSource(preferredSource.source);
|
||||||
setCurrentId(preferredSource.id);
|
setCurrentId(preferredSource.id);
|
||||||
setVideoYear(preferredSource.year);
|
setVideoYear(preferredSource.year);
|
||||||
|
needLoadSource = preferredSource.source;
|
||||||
// 替换URL参数
|
needLoadId = preferredSource.id;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchDetail = async () => {
|
console.log(sourcesInfo);
|
||||||
try {
|
console.log(needLoadSource, needLoadId);
|
||||||
setLoadingStage('fetching');
|
const detailData = sourcesInfo.find(
|
||||||
setLoadingMessage('🎬 正在获取视频详情...');
|
(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({
|
// 规范URL参数
|
||||||
source: currentSource,
|
const newUrl = new URL(window.location.href);
|
||||||
id: currentId,
|
newUrl.searchParams.set('source', needLoadSource);
|
||||||
fallbackTitle: searchTitle || videoTitleRef.current.trim(),
|
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());
|
||||||
|
|
||||||
// 更新状态保存详情
|
setLoadingStage('ready');
|
||||||
setVideoTitle(detailData.title || videoTitleRef.current);
|
setLoadingMessage('✨ 准备就绪,即将开始播放...');
|
||||||
setVideoYear(detailData.year);
|
|
||||||
setVideoCover(detailData.poster);
|
|
||||||
setDetail(detailData);
|
|
||||||
|
|
||||||
// 确保集数索引在有效范围内
|
// 短暂延迟让用户看到完成状态
|
||||||
if (currentEpisodeIndex >= detailData.episodes.length) {
|
setTimeout(() => {
|
||||||
setCurrentEpisodeIndex(0);
|
setLoading(false);
|
||||||
}
|
}, 1000);
|
||||||
|
|
||||||
// 清理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();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchDetailAsync();
|
initAll();
|
||||||
}, [currentSource, currentId, needPrefer]);
|
}, []);
|
||||||
|
|
||||||
// 播放记录处理
|
// 播放记录处理
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -565,33 +590,7 @@ function PlayPageClient() {
|
|||||||
const key = generateStorageKey(currentSource, currentId);
|
const key = generateStorageKey(currentSource, currentId);
|
||||||
const record = allRecords[key];
|
const record = allRecords[key];
|
||||||
|
|
||||||
// URL 参数
|
if (record) {
|
||||||
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参数但有播放记录时,使用原有逻辑
|
|
||||||
const targetIndex = record.index - 1;
|
const targetIndex = record.index - 1;
|
||||||
const targetTime = record.play_time;
|
const targetTime = record.play_time;
|
||||||
|
|
||||||
@@ -611,84 +610,6 @@ function PlayPageClient() {
|
|||||||
initFromHistory();
|
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 (
|
const handleSourceChange = async (
|
||||||
newSource: string,
|
newSource: string,
|
||||||
@@ -696,13 +617,14 @@ function PlayPageClient() {
|
|||||||
newTitle: string
|
newTitle: string
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
|
// 显示换源加载状态
|
||||||
|
setVideoLoadingStage('sourceChanging');
|
||||||
|
setIsVideoLoading(true);
|
||||||
|
|
||||||
// 记录当前播放进度(仅在同一集数切换时恢复)
|
// 记录当前播放进度(仅在同一集数切换时恢复)
|
||||||
const currentPlayTime = artPlayerRef.current?.currentTime || 0;
|
const currentPlayTime = artPlayerRef.current?.currentTime || 0;
|
||||||
console.log('换源前当前播放时间:', currentPlayTime);
|
console.log('换源前当前播放时间:', currentPlayTime);
|
||||||
|
|
||||||
// 显示加载状态
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
// 清除前一个历史记录
|
// 清除前一个历史记录
|
||||||
if (currentSourceRef.current && currentIdRef.current) {
|
if (currentSourceRef.current && currentIdRef.current) {
|
||||||
try {
|
try {
|
||||||
@@ -716,12 +638,13 @@ function PlayPageClient() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取新源的详情
|
const newDetail = availableSources.find(
|
||||||
const newDetail = await fetchVideoDetail({
|
(source) => source.source === newSource && source.id === newId
|
||||||
source: newSource,
|
);
|
||||||
id: newId,
|
if (!newDetail) {
|
||||||
fallbackTitle: searchTitle || newTitle.trim(),
|
setError('未找到匹配结果');
|
||||||
});
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 尝试跳转到当前正在播放的集数
|
// 尝试跳转到当前正在播放的集数
|
||||||
let targetIndex = currentEpisodeIndex;
|
let targetIndex = currentEpisodeIndex;
|
||||||
@@ -732,11 +655,13 @@ function PlayPageClient() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 如果仍然是同一集数且播放进度有效,则在播放器就绪后恢复到原始进度
|
// 如果仍然是同一集数且播放进度有效,则在播放器就绪后恢复到原始进度
|
||||||
if (targetIndex === currentEpisodeIndex && currentPlayTime > 1) {
|
if (targetIndex !== currentEpisodeIndex) {
|
||||||
resumeTimeRef.current = currentPlayTime;
|
|
||||||
} else {
|
|
||||||
// 否则从头开始播放,防止影响后续选集逻辑
|
|
||||||
resumeTimeRef.current = 0;
|
resumeTimeRef.current = 0;
|
||||||
|
} else if (
|
||||||
|
(!resumeTimeRef.current || resumeTimeRef.current === 0) &&
|
||||||
|
currentPlayTime > 1
|
||||||
|
) {
|
||||||
|
resumeTimeRef.current = currentPlayTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新URL参数(不刷新页面)
|
// 更新URL参数(不刷新页面)
|
||||||
@@ -754,6 +679,8 @@ function PlayPageClient() {
|
|||||||
setDetail(newDetail);
|
setDetail(newDetail);
|
||||||
setCurrentEpisodeIndex(targetIndex);
|
setCurrentEpisodeIndex(targetIndex);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// 隐藏换源加载状态
|
||||||
|
setIsVideoLoading(false);
|
||||||
setError(err instanceof Error ? err.message : '换源失败');
|
setError(err instanceof Error ? err.message : '换源失败');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1243,8 +1170,8 @@ function PlayPageClient() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('恢复播放进度失败:', err);
|
console.warn('恢复播放进度失败:', err);
|
||||||
}
|
}
|
||||||
resumeTimeRef.current = null;
|
|
||||||
}
|
}
|
||||||
|
resumeTimeRef.current = null;
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (
|
if (
|
||||||
@@ -1254,6 +1181,9 @@ function PlayPageClient() {
|
|||||||
}
|
}
|
||||||
artPlayerRef.current.notice.show = '';
|
artPlayerRef.current.notice.show = '';
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
|
// 隐藏换源加载状态
|
||||||
|
setIsVideoLoading(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
artPlayerRef.current.on('error', (err: any) => {
|
artPlayerRef.current.on('error', (err: any) => {
|
||||||
@@ -1344,10 +1274,9 @@ function PlayPageClient() {
|
|||||||
<div className='flex justify-center space-x-2 mb-4'>
|
<div className='flex justify-center space-x-2 mb-4'>
|
||||||
<div
|
<div
|
||||||
className={`w-3 h-3 rounded-full transition-all duration-500 ${
|
className={`w-3 h-3 rounded-full transition-all duration-500 ${
|
||||||
loadingStage === 'searching'
|
loadingStage === 'searching' || loadingStage === 'fetching'
|
||||||
? 'bg-green-500 scale-125'
|
? 'bg-green-500 scale-125'
|
||||||
: loadingStage === 'preferring' ||
|
: loadingStage === 'preferring' ||
|
||||||
loadingStage === 'fetching' ||
|
|
||||||
loadingStage === 'ready'
|
loadingStage === 'ready'
|
||||||
? 'bg-green-500'
|
? 'bg-green-500'
|
||||||
: 'bg-gray-300'
|
: 'bg-gray-300'
|
||||||
@@ -1356,15 +1285,6 @@ function PlayPageClient() {
|
|||||||
<div
|
<div
|
||||||
className={`w-3 h-3 rounded-full transition-all duration-500 ${
|
className={`w-3 h-3 rounded-full transition-all duration-500 ${
|
||||||
loadingStage === 'preferring'
|
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'
|
? 'bg-green-500 scale-125'
|
||||||
: loadingStage === 'ready'
|
: loadingStage === 'ready'
|
||||||
? 'bg-green-500'
|
? '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'
|
className='h-full bg-gradient-to-r from-green-500 to-emerald-600 rounded-full transition-all duration-1000 ease-out'
|
||||||
style={{
|
style={{
|
||||||
width:
|
width:
|
||||||
loadingStage === 'searching'
|
loadingStage === 'searching' ||
|
||||||
? '25%'
|
loadingStage === 'fetching'
|
||||||
|
? '33%'
|
||||||
: loadingStage === 'preferring'
|
: loadingStage === 'preferring'
|
||||||
? '50%'
|
? '66%'
|
||||||
: loadingStage === 'fetching'
|
|
||||||
? '75%'
|
|
||||||
: '100%',
|
: '100%',
|
||||||
}}
|
}}
|
||||||
></div>
|
></div>
|
||||||
@@ -1544,14 +1463,54 @@ function PlayPageClient() {
|
|||||||
>
|
>
|
||||||
{/* 播放器 */}
|
{/* 播放器 */}
|
||||||
<div
|
<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'
|
isEpisodeSelectorCollapsed ? 'col-span-1' : 'md:col-span-3'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div
|
<div className='relative w-full h-[300px] lg:h-full'>
|
||||||
ref={artRef}
|
<div
|
||||||
className='bg-black w-full h-[300px] lg:h-full rounded-xl overflow-hidden border border-white/0 dark:border-white/30 shadow-lg'
|
ref={artRef}
|
||||||
></div>
|
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>
|
</div>
|
||||||
|
|
||||||
{/* 选集和换源 - 在移动端始终显示,在 lg 及以上可折叠 */}
|
{/* 选集和换源 - 在移动端始终显示,在 lg 及以上可折叠 */}
|
||||||
@@ -1571,7 +1530,6 @@ function PlayPageClient() {
|
|||||||
currentId={currentId}
|
currentId={currentId}
|
||||||
videoTitle={searchTitle || videoTitle}
|
videoTitle={searchTitle || videoTitle}
|
||||||
availableSources={availableSources}
|
availableSources={availableSources}
|
||||||
onSearchSources={handleSearchSources}
|
|
||||||
sourceSearchLoading={sourceSearchLoading}
|
sourceSearchLoading={sourceSearchLoading}
|
||||||
sourceSearchError={sourceSearchError}
|
sourceSearchError={sourceSearchError}
|
||||||
precomputedVideoInfo={precomputedVideoInfo}
|
precomputedVideoInfo={precomputedVideoInfo}
|
||||||
|
|||||||
@@ -41,27 +41,44 @@ function SearchPageClient() {
|
|||||||
const map = new Map<string, SearchResult[]>();
|
const map = new Map<string, SearchResult[]>();
|
||||||
searchResults.forEach((item) => {
|
searchResults.forEach((item) => {
|
||||||
// 使用 title + year + type 作为键,year 必然存在,但依然兜底 'unknown'
|
// 使用 title + year + type 作为键,year 必然存在,但依然兜底 'unknown'
|
||||||
const key = `${item.title}-${item.year || 'unknown'}-${
|
const key = `${item.title.replaceAll(' ', '')}-${
|
||||||
item.episodes.length === 1 ? 'movie' : 'tv'
|
item.year || 'unknown'
|
||||||
}`;
|
}-${item.episodes.length === 1 ? 'movie' : 'tv'}`;
|
||||||
const arr = map.get(key) || [];
|
const arr = map.get(key) || [];
|
||||||
arr.push(item);
|
arr.push(item);
|
||||||
map.set(key, arr);
|
map.set(key, arr);
|
||||||
});
|
});
|
||||||
return Array.from(map.entries()).sort((a, b) => {
|
return Array.from(map.entries()).sort((a, b) => {
|
||||||
// 优先排序:标题与搜索词完全一致的排在前面
|
// 优先排序:标题与搜索词完全一致的排在前面
|
||||||
const aExactMatch = a[1][0].title === searchQuery.trim();
|
const aExactMatch = a[1][0].title
|
||||||
const bExactMatch = b[1][0].title === searchQuery.trim();
|
.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;
|
||||||
if (!aExactMatch && bExactMatch) return 1;
|
if (!aExactMatch && bExactMatch) return 1;
|
||||||
|
|
||||||
// 如果都匹配或都不匹配,则按原来的逻辑排序
|
// 年份排序
|
||||||
return a[1][0].year === b[1][0].year
|
if (a[1][0].year === b[1][0].year) {
|
||||||
? a[0].localeCompare(b[0])
|
return a[0].localeCompare(b[0]);
|
||||||
: a[1][0].year > b[1][0].year
|
} else {
|
||||||
? -1
|
// 处理 unknown 的情况
|
||||||
: 1;
|
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]);
|
}, [searchResults]);
|
||||||
|
|
||||||
@@ -105,11 +122,21 @@ function SearchPageClient() {
|
|||||||
if (!aExactMatch && bExactMatch) return 1;
|
if (!aExactMatch && bExactMatch) return 1;
|
||||||
|
|
||||||
// 如果都匹配或都不匹配,则按原来的逻辑排序
|
// 如果都匹配或都不匹配,则按原来的逻辑排序
|
||||||
return a.year === b.year
|
if (a.year === b.year) {
|
||||||
? a.title.localeCompare(b.title)
|
return a.title.localeCompare(b.title);
|
||||||
: a.year > b.year
|
} else {
|
||||||
? -1
|
// 处理 unknown 的情况
|
||||||
: 1;
|
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);
|
setShowResults(true);
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ interface EpisodeSelectorProps {
|
|||||||
videoTitle?: string;
|
videoTitle?: string;
|
||||||
videoYear?: string;
|
videoYear?: string;
|
||||||
availableSources?: SearchResult[];
|
availableSources?: SearchResult[];
|
||||||
onSearchSources?: (query: string) => void;
|
|
||||||
sourceSearchLoading?: boolean;
|
sourceSearchLoading?: boolean;
|
||||||
sourceSearchError?: string | null;
|
sourceSearchError?: string | null;
|
||||||
/** 预计算的测速结果,避免重复测速 */
|
/** 预计算的测速结果,避免重复测速 */
|
||||||
@@ -56,7 +55,6 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
|||||||
currentId,
|
currentId,
|
||||||
videoTitle,
|
videoTitle,
|
||||||
availableSources = [],
|
availableSources = [],
|
||||||
onSearchSources,
|
|
||||||
sourceSearchLoading = false,
|
sourceSearchLoading = false,
|
||||||
sourceSearchError = null,
|
sourceSearchError = null,
|
||||||
precomputedVideoInfo,
|
precomputedVideoInfo,
|
||||||
@@ -207,11 +205,25 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
|||||||
// 当分页切换时,将激活的分页标签滚动到视口中间
|
// 当分页切换时,将激活的分页标签滚动到视口中间
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const btn = buttonRefs.current[currentPage];
|
const btn = buttonRefs.current[currentPage];
|
||||||
if (btn) {
|
const container = categoryContainerRef.current;
|
||||||
btn.scrollIntoView({
|
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',
|
behavior: 'smooth',
|
||||||
inline: 'center',
|
|
||||||
block: 'nearest',
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [currentPage, pageCount]);
|
}, [currentPage, pageCount]);
|
||||||
@@ -219,10 +231,6 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
|||||||
// 处理换源tab点击,只在点击时才搜索
|
// 处理换源tab点击,只在点击时才搜索
|
||||||
const handleSourceTabClick = () => {
|
const handleSourceTabClick = () => {
|
||||||
setActiveTab('sources');
|
setActiveTab('sources');
|
||||||
// 只在点击时搜索,且只搜索一次
|
|
||||||
if (availableSources.length === 0 && videoTitle && onSearchSources) {
|
|
||||||
onSearchSources(videoTitle);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCategoryClick = useCallback((index: number) => {
|
const handleCategoryClick = useCallback((index: number) => {
|
||||||
@@ -243,20 +251,6 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
|||||||
[onSourceChange]
|
[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 currentStart = currentPage * episodesPerPage + 1;
|
||||||
const currentEnd = Math.min(
|
const currentEnd = Math.min(
|
||||||
currentStart + episodesPerPage - 1,
|
currentStart + episodesPerPage - 1,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { API_CONFIG, ApiSite, getConfig } from '@/lib/config';
|
import { API_CONFIG, ApiSite, getConfig } from '@/lib/config';
|
||||||
import { SearchResult, VideoDetail } from '@/lib/types';
|
import { SearchResult } from '@/lib/types';
|
||||||
import { cleanHtmlTags } from '@/lib/utils';
|
import { cleanHtmlTags } from '@/lib/utils';
|
||||||
|
|
||||||
const config = getConfig();
|
const config = getConfig();
|
||||||
@@ -77,7 +77,7 @@ export async function searchFromApi(
|
|||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: item.vod_id,
|
id: item.vod_id.toString(),
|
||||||
title: item.vod_name.trim().replace(/\s+/g, ' '),
|
title: item.vod_name.trim().replace(/\s+/g, ' '),
|
||||||
poster: item.vod_pic,
|
poster: item.vod_pic,
|
||||||
episodes,
|
episodes,
|
||||||
@@ -147,7 +147,7 @@ export async function searchFromApi(
|
|||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: item.vod_id,
|
id: item.vod_id.toString(),
|
||||||
title: item.vod_name.trim().replace(/\s+/g, ' '),
|
title: item.vod_name.trim().replace(/\s+/g, ' '),
|
||||||
poster: item.vod_pic,
|
poster: item.vod_pic,
|
||||||
episodes,
|
episodes,
|
||||||
@@ -193,7 +193,7 @@ const M3U8_PATTERN = /(https?:\/\/[^"'\s]+?\.m3u8)/g;
|
|||||||
export async function getDetailFromApi(
|
export async function getDetailFromApi(
|
||||||
apiSite: ApiSite,
|
apiSite: ApiSite,
|
||||||
id: string
|
id: string
|
||||||
): Promise<VideoDetail> {
|
): Promise<SearchResult> {
|
||||||
if (apiSite.detail) {
|
if (apiSite.detail) {
|
||||||
return handleSpecialSourceDetail(id, apiSite);
|
return handleSpecialSourceDetail(id, apiSite);
|
||||||
}
|
}
|
||||||
@@ -253,32 +253,26 @@ export async function getDetailFromApi(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
code: 200,
|
id: id.toString(),
|
||||||
|
title: videoDetail.vod_name,
|
||||||
|
poster: videoDetail.vod_pic,
|
||||||
episodes,
|
episodes,
|
||||||
detailUrl,
|
source: apiSite.key,
|
||||||
videoInfo: {
|
source_name: apiSite.name,
|
||||||
title: videoDetail.vod_name,
|
class: videoDetail.vod_class,
|
||||||
cover: videoDetail.vod_pic,
|
year: videoDetail.vod_year
|
||||||
desc: cleanHtmlTags(videoDetail.vod_content),
|
? videoDetail.vod_year.match(/\d{4}/)?.[0] || ''
|
||||||
type: videoDetail.type_name,
|
: 'unknown',
|
||||||
year: videoDetail.vod_year
|
desc: cleanHtmlTags(videoDetail.vod_content),
|
||||||
? videoDetail.vod_year.match(/\d{4}/)?.[0] || ''
|
type_name: videoDetail.type_name,
|
||||||
: 'unknown',
|
douban_id: videoDetail.vod_douban_id,
|
||||||
area: videoDetail.vod_area,
|
|
||||||
director: videoDetail.vod_director,
|
|
||||||
actor: videoDetail.vod_actor,
|
|
||||||
remarks: videoDetail.vod_remarks,
|
|
||||||
source_name: apiSite.name,
|
|
||||||
source: apiSite.key,
|
|
||||||
id,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSpecialSourceDetail(
|
async function handleSpecialSourceDetail(
|
||||||
id: string,
|
id: string,
|
||||||
apiSite: ApiSite
|
apiSite: ApiSite
|
||||||
): Promise<VideoDetail> {
|
): Promise<SearchResult> {
|
||||||
const detailUrl = `${apiSite.detail}/index.php/vod/detail/id/${id}.html`;
|
const detailUrl = `${apiSite.detail}/index.php/vod/detail/id/${id}.html`;
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
@@ -335,17 +329,16 @@ async function handleSpecialSourceDetail(
|
|||||||
const yearText = yearMatch ? yearMatch[1] : 'unknown';
|
const yearText = yearMatch ? yearMatch[1] : 'unknown';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
code: 200,
|
id,
|
||||||
|
title: titleText,
|
||||||
|
poster: coverUrl,
|
||||||
episodes: matches,
|
episodes: matches,
|
||||||
detailUrl,
|
source: apiSite.key,
|
||||||
videoInfo: {
|
source_name: apiSite.name,
|
||||||
title: titleText,
|
class: '',
|
||||||
cover: coverUrl,
|
year: yearText,
|
||||||
desc: descText,
|
desc: descText,
|
||||||
source_name: apiSite.name,
|
type_name: '',
|
||||||
source: apiSite.key,
|
douban_id: 0,
|
||||||
year: yearText,
|
|
||||||
id,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,27 +62,6 @@ export interface IStorage {
|
|||||||
setAdminConfig(config: AdminConfig): Promise<void>;
|
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 {
|
export interface SearchResult {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user