mirror of
https://github.com/MoonTechLab/LunaTV.git
synced 2026-02-21 00:44:41 +08:00
feat: add resolution and speed info
This commit is contained in:
@@ -6,7 +6,7 @@ const nextConfig = {
|
||||
dirs: ['src'],
|
||||
},
|
||||
|
||||
reactStrictMode: true,
|
||||
reactStrictMode: false,
|
||||
swcMinify: true,
|
||||
|
||||
// Uncoment to add domain whitelist
|
||||
|
||||
@@ -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<string | null>(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<string, { quality: string; loadSpeed: string; pingTime: number }>
|
||||
>(new Map());
|
||||
|
||||
// 折叠状态(仅在 lg 及以上屏幕有效)
|
||||
const [isEpisodeSelectorCollapsed, setIsEpisodeSelectorCollapsed] =
|
||||
useState(false);
|
||||
@@ -128,6 +147,169 @@ function PlayPageClient() {
|
||||
// -----------------------------------------------------------------------------
|
||||
// 工具函数(Utils)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// 播放源优选函数
|
||||
const preferBestSource = async (
|
||||
sources: SearchResult[]
|
||||
): Promise<SearchResult> => {
|
||||
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() {
|
||||
<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'>
|
||||
{loadingStage === 'searching' && '🔍'}
|
||||
{loadingStage === 'preferring' && '⚡'}
|
||||
{loadingStage === 'fetching' && '🎬'}
|
||||
{loadingStage === 'ready' && '✨'}
|
||||
</div>
|
||||
@@ -1102,6 +1279,17 @@ function PlayPageClient() {
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full transition-all duration-500 ${
|
||||
loadingStage === 'searching'
|
||||
? 'bg-green-500 scale-125'
|
||||
: loadingStage === 'preferring' ||
|
||||
loadingStage === 'fetching' ||
|
||||
loadingStage === 'ready'
|
||||
? 'bg-green-500'
|
||||
: 'bg-gray-300'
|
||||
}`}
|
||||
></div>
|
||||
<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'
|
||||
@@ -1133,9 +1321,11 @@ function PlayPageClient() {
|
||||
style={{
|
||||
width:
|
||||
loadingStage === 'searching'
|
||||
? '33%'
|
||||
? '25%'
|
||||
: loadingStage === 'preferring'
|
||||
? '50%'
|
||||
: loadingStage === 'fetching'
|
||||
? '66%'
|
||||
? '75%'
|
||||
: '100%',
|
||||
}}
|
||||
></div>
|
||||
@@ -1224,7 +1414,7 @@ function PlayPageClient() {
|
||||
|
||||
return (
|
||||
<PageLayout activePath='/play'>
|
||||
<div className='flex flex-col gap-3 py-4 px-5 lg:px-10 xl:px-20'>
|
||||
<div className='flex flex-col gap-3 py-4 px-5 lg:px-10'>
|
||||
{/* 第一行:影片标题 */}
|
||||
<div className='py-1'>
|
||||
<h1 className='text-xl font-semibold text-gray-900 dark:text-gray-100'>
|
||||
@@ -1318,6 +1508,7 @@ function PlayPageClient() {
|
||||
onSearchSources={handleSearchSources}
|
||||
sourceSearchLoading={sourceSearchLoading}
|
||||
sourceSearchError={sourceSearchError}
|
||||
precomputedVideoInfo={precomputedVideoInfo}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<string, VideoInfo>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,10 +59,32 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
||||
onSearchSources,
|
||||
sourceSearchLoading = false,
|
||||
sourceSearchError = null,
|
||||
precomputedVideoInfo,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const pageCount = Math.ceil(totalEpisodes / episodesPerPage);
|
||||
|
||||
// 存储每个源的视频信息
|
||||
const [videoInfoMap, setVideoInfoMap] = useState<Map<string, VideoInfo>>(
|
||||
new Map()
|
||||
);
|
||||
const [attemptedSources, setAttemptedSources] = useState<Set<string>>(
|
||||
new Set()
|
||||
);
|
||||
|
||||
// 使用 ref 来避免闭包问题
|
||||
const attemptedSourcesRef = useRef<Set<string>>(new Set());
|
||||
const videoInfoMapRef = useRef<Map<string, VideoInfo>>(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<EpisodeSelectorProps> = ({
|
||||
// 是否倒序显示
|
||||
const [descending, setDescending] = useState<boolean>(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<EpisodeSelectorProps> = ({
|
||||
</div>
|
||||
|
||||
{/* 信息区域 */}
|
||||
<div className='flex-1 min-w-0'>
|
||||
<div className='flex items-start justify-between'>
|
||||
<div className='flex-1 min-w-0'>
|
||||
<h3 className='font-medium text-base truncate text-gray-900 dark:text-gray-100'>
|
||||
{source.title}
|
||||
</h3>
|
||||
<div className='flex items-center gap-2 mt-1'>
|
||||
<span className='text-xs px-2 py-1 border border-gray-500/60 rounded text-gray-700 dark:text-gray-300'>
|
||||
{source.source_name}
|
||||
</span>
|
||||
</div>
|
||||
{source.episodes.length > 1 && (
|
||||
<span className='text-xs text-gray-500 dark:text-gray-400 mt-1 pl-[2px]'>
|
||||
共 {source.episodes.length} 集
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex-1 min-w-0 flex flex-col justify-between h-20'>
|
||||
{/* 标题和分辨率 - 顶部 */}
|
||||
<div className='flex items-start justify-between gap-2 h-6'>
|
||||
<h3 className='font-medium text-base truncate text-gray-900 dark:text-gray-100 leading-none'>
|
||||
{source.title}
|
||||
</h3>
|
||||
{(() => {
|
||||
const sourceKey = `${source.source}-${source.id}`;
|
||||
const videoInfo = videoInfoMap.get(sourceKey);
|
||||
|
||||
if (videoInfo) {
|
||||
if (videoInfo.hasError) {
|
||||
return (
|
||||
<div className='bg-gray-500/10 dark:bg-gray-400/20 text-red-600 dark:text-red-400 px-1.5 py-0 rounded text-xs flex-shrink-0'>
|
||||
检测失败
|
||||
</div>
|
||||
);
|
||||
} 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 (
|
||||
<div
|
||||
className={`bg-gray-500/10 dark:bg-gray-400/20 ${textColorClasses} px-1.5 py-0 rounded text-xs flex-shrink-0`}
|
||||
>
|
||||
{videoInfo.quality}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* 源名称和集数信息 - 垂直居中 */}
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-xs px-2 py-1 border border-gray-500/60 rounded text-gray-700 dark:text-gray-300'>
|
||||
{source.source_name}
|
||||
</span>
|
||||
{source.episodes.length > 1 && (
|
||||
<span className='text-xs text-gray-500 dark:text-gray-400 font-medium'>
|
||||
{source.episodes.length} 集
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 网络信息 - 底部 */}
|
||||
<div className='flex items-end h-6'>
|
||||
{(() => {
|
||||
const sourceKey = `${source.source}-${source.id}`;
|
||||
const videoInfo = videoInfoMap.get(sourceKey);
|
||||
|
||||
if (videoInfo && !videoInfo.hasError) {
|
||||
return (
|
||||
<div className='flex items-end gap-3 text-xs'>
|
||||
<div className='text-green-600 dark:text-green-400 font-medium text-xs'>
|
||||
{videoInfo.loadSpeed}
|
||||
</div>
|
||||
<div className='text-orange-600 dark:text-orange-400 font-medium text-xs'>
|
||||
{videoInfo.pingTime}ms
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='text-red-500/90 dark:text-red-400 font-medium text-xs'>
|
||||
无测速数据
|
||||
</div>
|
||||
); // 占位div
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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({
|
||||
/>
|
||||
|
||||
{/* 悬浮层 - 添加渐变动画效果 */}
|
||||
<div className='absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-center justify-center'>
|
||||
<div className='absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-center justify-center cursor-pointer'>
|
||||
{config.showPlayButton && (
|
||||
<PlayCircleIcon
|
||||
size={52}
|
||||
|
||||
154
src/lib/utils.ts
154
src/lib/utils.ts
@@ -1,3 +1,7 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
|
||||
|
||||
import Hls from 'hls.js';
|
||||
|
||||
export function cleanHtmlTags(text: string): string {
|
||||
if (!text) return '';
|
||||
return text
|
||||
@@ -8,3 +12,153 @@ export function cleanHtmlTags(text: string): string {
|
||||
.replace(/ /g, ' ') // 将 替换为空格
|
||||
.trim(); // 去掉首尾空格
|
||||
}
|
||||
|
||||
/**
|
||||
* 从m3u8地址获取视频质量等级和网络信息
|
||||
* @param m3u8Url m3u8播放列表的URL
|
||||
* @returns Promise<{quality: string, loadSpeed: string, pingTime: number}> 视频质量等级和网络信息
|
||||
*/
|
||||
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)
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user