From a858a518763fd67f464e1f77288d0e8d98b3eee5 Mon Sep 17 00:00:00 2001 From: shinya Date: Thu, 10 Jul 2025 23:33:47 +0800 Subject: [PATCH] feat: add resolution and speed info --- next.config.js | 2 +- src/app/play/page.tsx | 243 ++++++++++++++++++++++++++--- src/components/EpisodeSelector.tsx | 217 ++++++++++++++++++++++++-- src/components/VideoCard.tsx | 16 +- src/lib/utils.ts | 154 ++++++++++++++++++ 5 files changed, 585 insertions(+), 47 deletions(-) diff --git a/next.config.js b/next.config.js index 7c854de..db8e508 100644 --- a/next.config.js +++ b/next.config.js @@ -6,7 +6,7 @@ const nextConfig = { dirs: ['src'], }, - reactStrictMode: true, + reactStrictMode: false, swcMinify: true, // Uncoment to add domain whitelist diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index fa43d43..f17d204 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -21,6 +21,7 @@ import { fetchVideoDetail, } from '@/lib/fetchVideoDetail.client'; import { SearchResult } from '@/lib/types'; +import { getVideoResolutionFromM3u8 } from '@/lib/utils'; import EpisodeSelector from '@/components/EpisodeSelector'; import PageLayout from '@/components/PageLayout'; @@ -41,7 +42,7 @@ function PlayPageClient() { // ----------------------------------------------------------------------------- const [loading, setLoading] = useState(true); const [loadingStage, setLoadingStage] = useState< - 'searching' | 'fetching' | 'ready' + 'searching' | 'preferring' | 'fetching' | 'ready' >('searching'); const [loadingMessage, setLoadingMessage] = useState('正在搜索播放源...'); const [error, setError] = useState(null); @@ -58,6 +59,10 @@ function PlayPageClient() { } return true; }); + const blockAdEnabledRef = useRef(blockAdEnabled); + useEffect(() => { + blockAdEnabledRef.current = blockAdEnabled; + }, [blockAdEnabled]); // 视频基本信息 const [videoTitle, setVideoTitle] = useState(searchParams.get('title') || ''); @@ -68,6 +73,15 @@ function PlayPageClient() { searchParams.get('source') || '' ); const [currentId, setCurrentId] = useState(searchParams.get('id') || ''); + + // 是否需要优选 + const [needPrefer, setNeedPrefer] = useState( + searchParams.get('prefer') === 'true' + ); + const needPreferRef = useRef(needPrefer); + useEffect(() => { + needPreferRef.current = needPrefer; + }, [needPrefer]); // 集数相关 const initialIndex = parseInt(searchParams.get('index') || '1') - 1; // 转换为0基数组索引 const [currentEpisodeIndex, setCurrentEpisodeIndex] = useState(initialIndex); @@ -114,6 +128,11 @@ function PlayPageClient() { null ); + // 保存优选时的测速结果,避免EpisodeSelector重复测速 + const [precomputedVideoInfo, setPrecomputedVideoInfo] = useState< + Map + >(new Map()); + // 折叠状态(仅在 lg 及以上屏幕有效) const [isEpisodeSelectorCollapsed, setIsEpisodeSelectorCollapsed] = useState(false); @@ -128,6 +147,169 @@ function PlayPageClient() { // ----------------------------------------------------------------------------- // 工具函数(Utils) // ----------------------------------------------------------------------------- + + // 播放源优选函数 + const preferBestSource = async ( + sources: SearchResult[] + ): Promise => { + if (sources.length === 1) return sources[0]; + + // 将播放源均分为两批,并发测速各批,避免一次性过多请求 + const batchSize = Math.ceil(sources.length / 2); + const allResults: Array<{ + source: SearchResult; + testResult: { quality: string; loadSpeed: string; pingTime: number }; + score: number; + } | null> = []; + + for (let start = 0; start < sources.length; start += batchSize) { + const batchSources = sources.slice(start, start + batchSize); + const batchResults = await Promise.all( + batchSources.map(async (source) => { + try { + // 检查是否有第一集的播放地址 + if (!source.episodes || source.episodes.length === 0) { + console.warn(`播放源 ${source.source_name} 没有可用的播放地址`); + return null; + } + + const firstEpisodeUrl = source.episodes[0]; + const testResult = await getVideoResolutionFromM3u8( + firstEpisodeUrl + ); + + return { + source, + testResult, + score: calculateSourceScore(testResult), + }; + } catch (error) { + return null; + } + }) + ); + allResults.push(...batchResults); + } + + // 等待所有测速完成,包含成功和失败的结果 + // 保存所有测速结果到 precomputedVideoInfo,供 EpisodeSelector 使用(包含错误结果) + const newVideoInfoMap = new Map< + string, + { + quality: string; + loadSpeed: string; + pingTime: number; + hasError?: boolean; + } + >(); + allResults.forEach((result, index) => { + const source = sources[index]; + const sourceKey = `${source.source}-${source.id}`; + + if (result) { + // 成功的结果 + newVideoInfoMap.set(sourceKey, result.testResult); + } + }); + + // 过滤出成功的结果用于优选计算 + const successfulResults = allResults.filter(Boolean) as Array<{ + source: SearchResult; + testResult: { quality: string; loadSpeed: string; pingTime: number }; + score: number; + }>; + + setPrecomputedVideoInfo(newVideoInfoMap); + + if (successfulResults.length === 0) { + console.warn('所有播放源测速都失败,使用第一个播放源'); + return sources[0]; + } + + // 按综合评分排序,选择最佳播放源 + successfulResults.sort((a, b) => b.score - a.score); + + console.log('播放源评分排序结果:'); + successfulResults.forEach((result, index) => { + console.log( + `${index + 1}. ${ + result.source.source_name + } - 评分: ${result.score.toFixed(2)} (${result.testResult.quality}, ${ + result.testResult.loadSpeed + }, ${result.testResult.pingTime}ms)` + ); + }); + + return successfulResults[0].source; + }; + + // 计算播放源综合评分 + const calculateSourceScore = (testResult: { + quality: string; + loadSpeed: string; + pingTime: number; + }): number => { + let score = 0; + + // 分辨率评分 (40% 权重) + const qualityScore = (() => { + switch (testResult.quality) { + case '4K': + return 100; + case '2K': + return 85; + case '1080p': + return 75; + case '720p': + return 60; + case '480p': + return 40; + case 'SD': + return 20; + default: + return 30; + } + })(); + score += qualityScore * 0.4; + + // 下载速度评分 (40% 权重) + const speedScore = (() => { + const speedStr = testResult.loadSpeed; + if (speedStr === '未知' || speedStr === '测量中...') return 30; + + // 解析速度值 + const match = speedStr.match(/^([\d.]+)\s*(KB\/s|MB\/s)$/); + if (!match) return 30; + + const value = parseFloat(match[1]); + const unit = match[2]; + const speedKBps = unit === 'MB/s' ? value * 1024 : value; + + // 根据速度给分 + if (speedKBps >= 5120) return 100; // ≥5MB/s + if (speedKBps >= 2048) return 85; // ≥2MB/s + if (speedKBps >= 1024) return 70; // ≥1MB/s + if (speedKBps >= 512) return 55; // ≥512KB/s + if (speedKBps >= 256) return 40; // ≥256KB/s + return 25; // <256KB/s + })(); + score += speedScore * 0.4; + + // 网络延迟评分 (20% 权重) + const pingScore = (() => { + const ping = testResult.pingTime; + if (ping <= 50) return 100; // ≤50ms + if (ping <= 100) return 85; // ≤100ms + if (ping <= 200) return 70; // ≤200ms + if (ping <= 500) return 50; // ≤500ms + if (ping <= 1000) return 30; // ≤1s + return 15; // >1s + })(); + score += pingScore * 0.2; + + return Math.round(score * 100) / 100; // 保留两位小数 + }; + // 更新视频地址 const updateVideoUrl = ( detailData: VideoDetail | null, @@ -222,29 +404,17 @@ function PlayPageClient() { updateVideoUrl(detail, currentEpisodeIndex); }, [detail, currentEpisodeIndex]); - // 确保初始状态与URL参数同步 - useEffect(() => { - const urlSource = searchParams.get('source'); - const urlId = searchParams.get('id'); - - if (urlSource && urlSource !== currentSource) { - setCurrentSource(urlSource); - } - if (urlId && urlId !== currentId) { - setCurrentId(urlId); - } - }, [searchParams, currentSource, currentId]); - // 获取视频详情 useEffect(() => { const fetchDetailAsync = async () => { + console.log('fetchDetailAsync', currentSource, currentId, videoTitle); if (!currentSource && !currentId && !videoTitle) { setError('缺少必要参数'); setLoading(false); return; } - if (!currentSource && !currentId) { + if ((!currentSource && !currentId) || needPreferRef.current) { // 只包含视频标题,搜索视频 setLoading(true); setLoadingStage('searching'); @@ -256,15 +426,21 @@ function PlayPageClient() { setLoading(false); return; } - setCurrentSource(searchResults[0].source); - setCurrentId(searchResults[0].id); - setVideoYear(searchResults[0].year); + // 对播放源做优选 + setLoadingStage('preferring'); + setLoadingMessage('⚡ 正在优选最佳播放源...'); + const preferredSource = await preferBestSource(searchResults); + setNeedPrefer(false); + setCurrentSource(preferredSource.source); + setCurrentId(preferredSource.id); + setVideoYear(preferredSource.year); + // 替换URL参数 const newUrl = new URL(window.location.href); - newUrl.searchParams.set('source', searchResults[0].source); - newUrl.searchParams.set('id', searchResults[0].id); - newUrl.searchParams.set('year', searchResults[0].year); - newUrl.searchParams.delete('douban_id'); + 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; } @@ -898,7 +1074,7 @@ function PlayPageClient() { maxBufferSize: 60 * 1000 * 1000, // 约 60MB,超出后触发清理 /* 自定义loader */ - loader: blockAdEnabled + loader: blockAdEnabledRef.current ? CustomHlsJsLoader : Hls.DefaultConfig.loader, }); @@ -1075,6 +1251,7 @@ function PlayPageClient() {
{loadingStage === 'searching' && '🔍'} + {loadingStage === 'preferring' && '⚡'} {loadingStage === 'fetching' && '🎬'} {loadingStage === 'ready' && '✨'}
@@ -1102,6 +1279,17 @@ function PlayPageClient() {
+
@@ -1224,7 +1414,7 @@ function PlayPageClient() { return ( -
+
{/* 第一行:影片标题 */}

@@ -1318,6 +1508,7 @@ function PlayPageClient() { onSearchSources={handleSearchSources} sourceSearchLoading={sourceSearchLoading} sourceSearchError={sourceSearchError} + precomputedVideoInfo={precomputedVideoInfo} />

diff --git a/src/components/EpisodeSelector.tsx b/src/components/EpisodeSelector.tsx index b84caa9..419c0c9 100644 --- a/src/components/EpisodeSelector.tsx +++ b/src/components/EpisodeSelector.tsx @@ -10,6 +10,15 @@ import React, { } from 'react'; import { SearchResult } from '@/lib/types'; +import { getVideoResolutionFromM3u8 } from '@/lib/utils'; + +// 定义视频信息类型 +interface VideoInfo { + quality: string; + loadSpeed: string; + pingTime: number; + hasError?: boolean; // 添加错误状态标识 +} interface EpisodeSelectorProps { /** 总集数 */ @@ -30,6 +39,8 @@ interface EpisodeSelectorProps { onSearchSources?: (query: string) => void; sourceSearchLoading?: boolean; sourceSearchError?: string | null; + /** 预计算的测速结果,避免重复测速 */ + precomputedVideoInfo?: Map; } /** @@ -48,10 +59,32 @@ const EpisodeSelector: React.FC = ({ onSearchSources, sourceSearchLoading = false, sourceSearchError = null, + precomputedVideoInfo, }) => { const router = useRouter(); const pageCount = Math.ceil(totalEpisodes / episodesPerPage); + // 存储每个源的视频信息 + const [videoInfoMap, setVideoInfoMap] = useState>( + new Map() + ); + const [attemptedSources, setAttemptedSources] = useState>( + new Set() + ); + + // 使用 ref 来避免闭包问题 + const attemptedSourcesRef = useRef>(new Set()); + const videoInfoMapRef = useRef>(new Map()); + + // 同步状态到 ref + useEffect(() => { + attemptedSourcesRef.current = attemptedSources; + }, [attemptedSources]); + + useEffect(() => { + videoInfoMapRef.current = videoInfoMap; + }, [videoInfoMap]); + // 主要的 tab 状态:'episodes' 或 'sources' // 当只有一集时默认展示 "换源",并隐藏 "选集" 标签 const [activeTab, setActiveTab] = useState<'episodes' | 'sources'>( @@ -65,6 +98,94 @@ const EpisodeSelector: React.FC = ({ // 是否倒序显示 const [descending, setDescending] = useState(false); + // 获取视频信息的函数 - 移除 attemptedSources 依赖避免不必要的重新创建 + const getVideoInfo = useCallback(async (source: SearchResult) => { + const sourceKey = `${source.source}-${source.id}`; + + // 使用 ref 获取最新的状态,避免闭包问题 + if (attemptedSourcesRef.current.has(sourceKey)) { + return; + } + + // 获取第一集的URL + const firstEpisodeUrl = source.episodes?.[0]; + if (!firstEpisodeUrl) return; + + // 标记为已尝试 + setAttemptedSources((prev) => new Set(prev).add(sourceKey)); + + try { + const info = await getVideoResolutionFromM3u8(firstEpisodeUrl); + setVideoInfoMap((prev) => new Map(prev).set(sourceKey, info)); + } catch (error) { + // 失败时保存错误状态 + setVideoInfoMap((prev) => + new Map(prev).set(sourceKey, { + quality: '错误', + loadSpeed: '未知', + pingTime: 0, + hasError: true, + }) + ); + } + }, []); + + // 当有预计算结果时,先合并到videoInfoMap中 + useEffect(() => { + if (precomputedVideoInfo && precomputedVideoInfo.size > 0) { + // 原子性地更新两个状态,避免时序问题 + setVideoInfoMap((prev) => { + const newMap = new Map(prev); + precomputedVideoInfo.forEach((value, key) => { + newMap.set(key, value); + }); + return newMap; + }); + + setAttemptedSources((prev) => { + const newSet = new Set(prev); + precomputedVideoInfo.forEach((info, key) => { + if (!info.hasError) { + newSet.add(key); + } + }); + return newSet; + }); + + // 同步更新 ref,确保 getVideoInfo 能立即看到更新 + precomputedVideoInfo.forEach((info, key) => { + if (!info.hasError) { + attemptedSourcesRef.current.add(key); + } + }); + } + }, [precomputedVideoInfo]); + + // 当切换到换源tab并且有源数据时,异步获取视频信息 - 移除 attemptedSources 依赖避免循环触发 + useEffect(() => { + const fetchVideoInfosInBatches = async () => { + if (activeTab !== 'sources' || availableSources.length === 0) return; + + // 筛选出尚未测速的播放源 + const pendingSources = availableSources.filter((source) => { + const sourceKey = `${source.source}-${source.id}`; + return !attemptedSourcesRef.current.has(sourceKey); + }); + + if (pendingSources.length === 0) return; + + const batchSize = Math.ceil(pendingSources.length / 2); + + for (let start = 0; start < pendingSources.length; start += batchSize) { + const batch = pendingSources.slice(start, start + batchSize); + await Promise.all(batch.map(getVideoInfo)); + } + }; + + fetchVideoInfosInBatches(); + // 依赖项保持与之前一致 + }, [activeTab, availableSources, getVideoInfo]); + // 升序分页标签 const categoriesAsc = useMemo(() => { return Array.from({ length: pageCount }, (_, i) => { @@ -342,23 +463,85 @@ const EpisodeSelector: React.FC = ({
{/* 信息区域 */} -
-
-
-

- {source.title} -

-
- - {source.source_name} - -
- {source.episodes.length > 1 && ( - - 共 {source.episodes.length} 集 - - )} -
+
+ {/* 标题和分辨率 - 顶部 */} +
+

+ {source.title} +

+ {(() => { + const sourceKey = `${source.source}-${source.id}`; + const videoInfo = videoInfoMap.get(sourceKey); + + if (videoInfo) { + if (videoInfo.hasError) { + return ( +
+ 检测失败 +
+ ); + } else { + // 根据分辨率设置不同颜色:1080p及以上为绿色,720p及以下为黄色 + const isHighRes = [ + '4K', + '2K', + '1080p', + ].includes(videoInfo.quality); + const textColorClasses = isHighRes + ? 'text-green-600 dark:text-green-400' + : 'text-yellow-600 dark:text-yellow-400'; + + return ( +
+ {videoInfo.quality} +
+ ); + } + } + + return null; + })()} +
+ + {/* 源名称和集数信息 - 垂直居中 */} +
+ + {source.source_name} + + {source.episodes.length > 1 && ( + + {source.episodes.length} 集 + + )} +
+ + {/* 网络信息 - 底部 */} +
+ {(() => { + const sourceKey = `${source.source}-${source.id}`; + const videoInfo = videoInfoMap.get(sourceKey); + + if (videoInfo && !videoInfo.hasError) { + return ( +
+
+ {videoInfo.loadSpeed} +
+
+ {videoInfo.pingTime}ms +
+
+ ); + } + + return ( +
+ 无测速数据 +
+ ); // 占位div + })()}
diff --git a/src/components/VideoCard.tsx b/src/components/VideoCard.tsx index 42aafae..ab0e35b 100644 --- a/src/components/VideoCard.tsx +++ b/src/components/VideoCard.tsx @@ -174,10 +174,20 @@ export default function VideoCard({ router.push( `/play?source=${actualSource}&id=${actualId}&title=${encodeURIComponent( actualTitle - )}${actualYear ? `&year=${actualYear}` : ''}` + )}${actualYear ? `&year=${actualYear}` : ''}${ + isAggregate ? '&prefer=true' : '' + }` ); } - }, [from, actualSource, actualId, router, actualTitle, actualYear]); + }, [ + from, + actualSource, + actualId, + router, + actualTitle, + actualYear, + isAggregate, + ]); const config = useMemo(() => { const configs = { @@ -249,7 +259,7 @@ export default function VideoCard({ /> {/* 悬浮层 - 添加渐变动画效果 */} -
+
{config.showPlayButton && ( 视频质量等级和网络信息 + */ +export async function getVideoResolutionFromM3u8(m3u8Url: string): Promise<{ + quality: string; // 如720p、1080p等 + loadSpeed: string; // 自动转换为KB/s或MB/s + pingTime: number; // 网络延迟(毫秒) +}> { + try { + // 直接使用m3u8 URL作为视频源,避免CORS问题 + return new Promise((resolve, reject) => { + const video = document.createElement('video'); + video.muted = true; + video.preload = 'metadata'; + + // 测量网络延迟(ping时间) - 使用m3u8 URL而不是ts文件 + const pingStart = performance.now(); + let pingTime = 0; + + // 测量ping时间(使用m3u8 URL) + fetch(m3u8Url, { method: 'HEAD', mode: 'no-cors' }) + .then(() => { + pingTime = performance.now() - pingStart; + }) + .catch(() => { + pingTime = performance.now() - pingStart; // 记录到失败为止的时间 + }); + + // 固定使用hls.js加载 + const hls = new Hls(); + + // 设置超时处理 + const timeout = setTimeout(() => { + hls.destroy(); + video.remove(); + reject(new Error('Timeout loading video metadata')); + }, 4000); + + video.onerror = () => { + clearTimeout(timeout); + hls.destroy(); + video.remove(); + reject(new Error('Failed to load video metadata')); + }; + + let actualLoadSpeed = '未知'; + let hasSpeedCalculated = false; + let hasMetadataLoaded = false; + + let fragmentStartTime = 0; + + // 检查是否可以返回结果 + const checkAndResolve = () => { + if ( + hasMetadataLoaded && + (hasSpeedCalculated || actualLoadSpeed !== '未知') + ) { + const width = video.videoWidth; + if (width && width > 0) { + clearTimeout(timeout); + hls.destroy(); + video.remove(); + + // 根据视频宽度判断视频质量等级,使用经典分辨率的宽度作为分割点 + const quality = + width >= 3840 + ? '4K' // 4K: 3840x2160 + : width >= 2560 + ? '2K' // 2K: 2560x1440 + : width >= 1920 + ? '1080p' // 1080p: 1920x1080 + : width >= 1280 + ? '720p' // 720p: 1280x720 + : width >= 854 + ? '480p' + : 'SD'; // 480p: 854x480 + + resolve({ + quality, + loadSpeed: actualLoadSpeed, + pingTime: Math.round(pingTime), + }); + } + } + }; + + // 监听片段加载开始 + hls.on(Hls.Events.FRAG_LOADING, () => { + fragmentStartTime = performance.now(); + }); + + // 监听片段加载完成,只需首个分片即可计算速度 + hls.on(Hls.Events.FRAG_LOADED, (event: any, data: any) => { + if ( + fragmentStartTime > 0 && + data && + data.payload && + !hasSpeedCalculated + ) { + const loadTime = performance.now() - fragmentStartTime; + const size = data.payload.byteLength || 0; + + if (loadTime > 0 && size > 0) { + const speedKBps = size / 1024 / (loadTime / 1000); + + // 立即计算速度,无需等待更多分片 + const avgSpeedKBps = speedKBps; + + if (avgSpeedKBps >= 1024) { + actualLoadSpeed = `${(avgSpeedKBps / 1024).toFixed(1)} MB/s`; + } else { + actualLoadSpeed = `${avgSpeedKBps.toFixed(1)} KB/s`; + } + hasSpeedCalculated = true; + checkAndResolve(); // 尝试返回结果 + } + } + }); + + hls.loadSource(m3u8Url); + hls.attachMedia(video); + + // 监听hls.js错误 + hls.on(Hls.Events.ERROR, (event: any, data: any) => { + console.error('HLS错误:', data); + if (data.fatal) { + clearTimeout(timeout); + hls.destroy(); + video.remove(); + reject(new Error(`HLS播放失败: ${data.type}`)); + } + }); + + // 监听视频元数据加载完成 + video.onloadedmetadata = () => { + hasMetadataLoaded = true; + checkAndResolve(); // 尝试返回结果 + }; + }); + } catch (error) { + throw new Error( + `Error getting video resolution: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } +}