feat: add resolution and speed info

This commit is contained in:
shinya
2025-07-10 23:33:47 +08:00
parent 3fba53069a
commit a858a51876
5 changed files with 585 additions and 47 deletions

View File

@@ -6,7 +6,7 @@ const nextConfig = {
dirs: ['src'],
},
reactStrictMode: true,
reactStrictMode: false,
swcMinify: true,
// Uncoment to add domain whitelist

View File

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

View File

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

View File

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

View File

@@ -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(/&nbsp;/g, ' ') // 将 &nbsp; 替换为空格
.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)
}`
);
}
}