电视直播增加测速功能

This commit is contained in:
mtvpls
2026-06-08 11:16:43 +08:00
rodzic 8406e1cb56
commit b3c6f7c170
+373 -12
Wyświetl plik
@@ -71,6 +71,17 @@ interface LiveSource {
proxyMode?: 'full' | 'm3u8-only' | 'direct'; // 代理模式
}
type LiveLineTestResult = {
status: 'testing' | 'ok' | 'fail';
type?: 'm3u8' | 'flv' | 'mp4' | 'unknown';
firstByteMs?: number;
speedKBps?: number;
bytesRead?: number;
testedAt?: number;
error?: string;
};
function LivePageClient() {
const router = useRouter();
const searchParams = useSearchParams();
@@ -141,6 +152,7 @@ function LivePageClient() {
// 搜索关键词
const [searchKeyword, setSearchKeyword] = useState('');
const [expandedMergedChannels, setExpandedMergedChannels] = useState<string[]>([]);
const [lineTestResults, setLineTestResults] = useState<Record<string, LiveLineTestResult>>({});
// 节目单信息
const [epgData, setEpgData] = useState<{
@@ -167,7 +179,7 @@ function LivePageClient() {
currentChannelId: currentChannel?.id || '',
currentChannelName: currentChannel?.name || '',
currentChannelUrl: currentChannel?.url || '',
onChannelChange: (channelId, channelUrl) => {
onChannelChange: (channelId, _channelUrl) => {
// 房员接收到频道切换指令
if (!currentChannels || !Array.isArray(currentChannels)) return;
const channel = currentChannels.find(c => c.id === channelId);
@@ -1183,7 +1195,10 @@ function LivePageClient() {
});
return order.map((key) => {
const item = mergedMap.get(key)!;
const item = mergedMap.get(key);
if (!item) {
return null;
}
if (item.channels.length === 1) {
return {
type: 'single',
@@ -1200,7 +1215,7 @@ function LivePageClient() {
logo: item.logo,
channels: item.channels,
};
});
}).filter((item): item is MergedChannelItem => item !== null);
}, [filteredChannels]);
const toggleMergedChannel = (key: string) => {
@@ -1211,6 +1226,282 @@ function LivePageClient() {
));
};
const getLineTestKey = (channel: LiveChannel) => {
return `${currentSourceRef.current?.key || currentSource?.key || ''}:${channel.id}:${channel.url}`;
};
const formatLineSpeed = (speedKBps?: number) => {
if (!speedKBps || speedKBps <= 0) return '';
if (speedKBps >= 1024) return `${(speedKBps / 1024).toFixed(1)} MB/s`;
return `${speedKBps.toFixed(0)} KB/s`;
};
const formatLineLatency = (firstByteMs?: number) => {
if (!firstByteMs || firstByteMs <= 0) return '';
return `${Math.round(firstByteMs)}ms`;
};
const getLineTestLabel = (channel: LiveChannel) => {
const result = lineTestResults[getLineTestKey(channel)];
if (!result) return '';
if (result.status === 'testing') return '测量中...';
if (result.status === 'fail') return '不可用';
const speed = formatLineSpeed(result.speedKBps);
const latency = formatLineLatency(result.firstByteMs);
if (speed && latency) return `${speed} · ${latency}`;
return speed || latency || '可用';
};
const getBestTestedLine = (channels: LiveChannel[]) => {
const okChannels = channels
.map((channel) => ({ channel, result: lineTestResults[getLineTestKey(channel)] }))
.filter((item): item is { channel: LiveChannel; result: LiveLineTestResult } => item.result?.status === 'ok');
if (okChannels.length === 0) return null;
return okChannels.sort((a, b) => {
const speedDiff = (b.result.speedKBps || 0) - (a.result.speedKBps || 0);
if (Math.abs(speedDiff) > 1) return speedDiff;
return (a.result.firstByteMs || Number.MAX_SAFE_INTEGER) - (b.result.firstByteMs || Number.MAX_SAFE_INTEGER);
})[0].channel;
};
const getPreferredLine = (channels: LiveChannel[]) => {
if (!channels || channels.length === 0) return null;
const activeLine = currentChannel ? channels.find((channel) => channel.id === currentChannel.id) : null;
return activeLine || getBestTestedLine(channels) || channels[0];
};
const getItemChannels = (item: MergedChannelItem) => {
return item.type === 'single' ? [item.channel] : item.channels;
};
const isTestingLineGroup = (channels: LiveChannel[]) => {
return channels.some((channel) => lineTestResults[getLineTestKey(channel)]?.status === 'testing');
};
const testLineGroup = (event: React.MouseEvent, channels: LiveChannel[]) => {
event.stopPropagation();
if (isTestingLineGroup(channels)) return;
handleTestLines(channels);
};
const testButtonClassName = (disabled: boolean) => (
`text-xs px-2 py-1 rounded border flex-shrink-0 ${disabled
? 'border-gray-200 dark:border-gray-700 text-gray-400 dark:text-gray-600 cursor-not-allowed opacity-60'
: 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:border-green-500 hover:text-green-600 dark:hover:text-green-400'
}`
);
const testButtonLabel = (disabled: boolean) => disabled ? '测速中' : '测速';
const resolveClientUrl = (baseUrl: string, relativePath: string) => {
try {
if (/^https?:\/\//i.test(relativePath)) return relativePath;
if (relativePath.startsWith('//')) {
const base = new URL(baseUrl, window.location.href);
return `${base.protocol}${relativePath}`;
}
return new URL(relativePath, new URL(baseUrl, window.location.href)).href;
} catch {
return relativePath;
}
};
const findFirstPlayableLine = (content: string) => {
return content
.split('\n')
.map((line) => line.trim())
.find((line) => line && !line.startsWith('#')) || '';
};
const getClientTestUrl = (rawUrl: string, source?: LiveSource | null) => {
const proxyMode = source?.proxyMode || 'full';
const lower = rawUrl.toLowerCase();
const path = lower.split('?')[0];
const isM3u = path.endsWith('.m3u8') || path.endsWith('.m3u') || lower.includes('.m3u8') || lower.includes('.m3u');
const isProgressive = path.endsWith('.flv') || path.endsWith('.mp4') || lower.includes('.flv?') || lower.includes('.mp4?');
if (isProgressive || proxyMode === 'direct') return rawUrl;
// 和实际播放链路保持一致:full 测代理后的分片,m3u8-only 测直连分片。
if (isM3u || !isProgressive) {
return `/api/proxy/m3u8?url=${encodeURIComponent(rawUrl)}&moontv-source=${encodeURIComponent(source?.key || '')}${proxyMode === 'm3u8-only' ? '&allowCORS=true' : ''}`;
}
return rawUrl;
};
const fetchTextByClient = async (url: string, signal: AbortSignal) => {
const startedAt = performance.now();
const response = await fetch(url, { cache: 'no-store', signal });
const firstByteMs = performance.now() - startedAt;
if (!response.ok) {
response.body?.cancel();
throw new Error(`HTTP ${response.status}`);
}
const text = await response.text();
return { text, finalUrl: response.url || url, firstByteMs };
};
const sampleByClient = async (url: string, signal: AbortSignal) => {
const startedAt = performance.now();
const response = await fetch(url, { cache: 'no-store', signal });
if (!response.ok) {
response.body?.cancel();
throw new Error(`HTTP ${response.status}`);
}
if (!response.body) throw new Error('empty body');
const reader = response.body.getReader();
let firstByteAt = 0;
let bytesRead = 0;
const sampleBytes = 512 * 1024;
try {
while (bytesRead < sampleBytes) {
const { done, value } = await reader.read();
if (done) break;
if (value?.byteLength) {
if (!firstByteAt) firstByteAt = performance.now();
bytesRead += value.byteLength;
}
}
} finally {
try {
reader.releaseLock();
} catch {
// ignore
}
try {
response.body.cancel();
} catch {
// ignore
}
}
const endedAt = performance.now();
const firstByteMs = (firstByteAt || endedAt) - startedAt;
const transferMs = Math.max(1, endedAt - (firstByteAt || startedAt));
const speedKBps = bytesRead > 0 ? (bytesRead / 1024) / (transferMs / 1000) : 0;
return {
firstByteMs: Math.round(firstByteMs),
speedKBps: Math.round(speedKBps * 10) / 10,
bytesRead,
};
};
const testLiveLine = async (channel: LiveChannel): Promise<LiveLineTestResult> => {
const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), 10000);
const source = currentSourceRef.current || currentSource;
const testUrl = getClientTestUrl(channel.url, source);
const lowerTestUrl = testUrl.toLowerCase();
try {
const shouldTreatAsM3u8 =
lowerTestUrl.includes('.m3u') ||
lowerTestUrl.includes('/api/proxy/m3u8');
if (shouldTreatAsM3u8) {
const manifest = await fetchTextByClient(testUrl, controller.signal);
let mediaLine = findFirstPlayableLine(manifest.text);
if (!mediaLine) throw new Error('empty m3u8');
let mediaUrl = resolveClientUrl(manifest.finalUrl, mediaLine);
if (manifest.text.includes('#EXT-X-STREAM-INF') || mediaUrl.toLowerCase().includes('.m3u')) {
const child = await fetchTextByClient(mediaUrl, controller.signal);
mediaLine = findFirstPlayableLine(child.text);
if (!mediaLine) throw new Error('empty child m3u8');
mediaUrl = resolveClientUrl(child.finalUrl, mediaLine);
}
const sample = await sampleByClient(mediaUrl, controller.signal);
return {
status: 'ok',
type: 'm3u8',
firstByteMs: Math.round(manifest.firstByteMs + sample.firstByteMs),
speedKBps: sample.speedKBps,
bytesRead: sample.bytesRead,
testedAt: Date.now(),
};
}
const sample = await sampleByClient(testUrl, controller.signal);
return {
status: 'ok',
type: lowerTestUrl.includes('.flv') ? 'flv' : lowerTestUrl.includes('.mp4') ? 'mp4' : 'unknown',
firstByteMs: sample.firstByteMs,
speedKBps: sample.speedKBps,
bytesRead: sample.bytesRead,
testedAt: Date.now(),
};
} catch (error) {
return {
status: 'fail',
type: 'unknown',
firstByteMs: 0,
speedKBps: 0,
bytesRead: 0,
testedAt: Date.now(),
error: error instanceof Error ? error.message : '客户端测速失败',
};
} finally {
window.clearTimeout(timeout);
}
};
const handleTestLines = async (channels: LiveChannel[]) => {
if (!channels.length) return;
const pendingChannels = channels;
const now = Date.now();
setLineTestResults((prev) => {
const next = { ...prev };
pendingChannels.forEach((channel) => {
next[getLineTestKey(channel)] = {
status: 'testing',
testedAt: now,
};
});
return next;
});
const maxConcurrency = Math.min(4, pendingChannels.length);
let nextIndex = 0;
const worker = async () => {
while (nextIndex < pendingChannels.length) {
const channel = pendingChannels[nextIndex++];
const key = getLineTestKey(channel);
try {
const result = await testLiveLine(channel);
setLineTestResults((prev) => ({ ...prev, [key]: result }));
} catch (error) {
setLineTestResults((prev) => ({
...prev,
[key]: {
status: 'fail',
testedAt: Date.now(),
error: error instanceof Error ? error.message : '测速失败',
},
}));
}
}
};
await Promise.all(Array.from({ length: maxConcurrency }, () => worker()));
};
const handlePlayLines = (channels: LiveChannel[]) => {
const preferred = getPreferredLine(channels);
if (preferred) handleChannelChange(preferred);
};
// 切换分组
const handleGroupChange = (group: string) => {
// 如果正在切换直播源,则禁用分组切换
@@ -2454,11 +2745,14 @@ function LivePageClient() {
if (item.type === 'single') {
const channel = item.channel;
const isActive = channel.id === currentChannel?.id;
const testLabel = getLineTestLabel(channel);
const testResult = lineTestResults[getLineTestKey(channel)];
const isTesting = isTestingLineGroup(getItemChannels(item));
return (
<button
key={channel.id}
data-channel-id={channel.id}
onClick={() => handleChannelChange(channel)}
onClick={() => handlePlayLines(getItemChannels(item))}
disabled={isSwitchingSource}
className={`w-full p-3 rounded-lg text-left transition-all duration-200 ${isSwitchingSource
? 'opacity-50 cursor-not-allowed'
@@ -2484,10 +2778,32 @@ function LivePageClient() {
<div className='text-sm font-medium text-gray-900 dark:text-gray-100 truncate' title={channel.name}>
{channel.name}
</div>
<div className='text-xs text-gray-500 dark:text-gray-400 mt-1' title={channel.group}>
{channel.group}
<div className='text-xs text-gray-500 dark:text-gray-400 mt-1 flex items-center gap-2' title={channel.group}>
<span>{channel.group}</span>
{testLabel && (
<>
<span>·</span>
<span className={
testResult?.status === 'ok'
? 'text-green-600 dark:text-green-400'
: testResult?.status === 'fail'
? 'text-red-500 dark:text-red-400'
: 'text-amber-600 dark:text-amber-400'
}>
{testLabel}
</span>
</>
)}
</div>
</div>
<span
role='button'
aria-disabled={isTesting}
onClick={(e) => testLineGroup(e, getItemChannels(item))}
className={testButtonClassName(isTesting)}
>
{testButtonLabel(isTesting)}
</span>
</div>
</button>
);
@@ -2496,6 +2812,10 @@ function LivePageClient() {
const isExpanded = expandedMergedChannels.includes(item.key);
const activeLineIndex = item.channels.findIndex(channel => channel.id === currentChannel?.id);
const hasActiveChild = activeLineIndex !== -1;
const bestLine = getBestTestedLine(item.channels);
const bestLineIndex = bestLine ? item.channels.findIndex(channel => channel.id === bestLine.id) : -1;
const isTestingLines = item.channels.some(channel => lineTestResults[getLineTestKey(channel)]?.status === 'testing');
const bestLineLabel = bestLine ? getLineTestLabel(bestLine) : '';
return (
<div
@@ -2505,7 +2825,7 @@ function LivePageClient() {
<button
type='button'
onClick={() => {
handleChannelChange(item.channels[0]);
handlePlayLines(getItemChannels(item));
}}
disabled={isSwitchingSource}
className={`w-full p-3 rounded-lg text-left transition-all duration-200 ${isSwitchingSource
@@ -2542,9 +2862,29 @@ function LivePageClient() {
<span>{`当前线路${activeLineIndex + 1}`}</span>
</>
)}
{isTestingLines && (
<>
<span>·</span>
<span className='text-amber-600 dark:text-amber-400'>...</span>
</>
)}
{!isTestingLines && bestLine && (
<>
<span>·</span>
<span className='text-green-600 dark:text-green-400'>{`推荐线路${bestLineIndex + 1}${bestLineLabel ? ` ${bestLineLabel}` : ''}`}</span>
</>
)}
</div>
</div>
<div className='flex flex-col items-end gap-2 flex-shrink-0'>
<span
role='button'
aria-disabled={isTestingLines}
onClick={(e) => testLineGroup(e, getItemChannels(item))}
className={testButtonClassName(isTestingLines)}
>
{testButtonLabel(isTestingLines)}
</span>
<span
onClick={(e) => {
e.stopPropagation();
@@ -2562,6 +2902,9 @@ function LivePageClient() {
<div className='pl-4 space-y-2'>
{item.channels.map((channel, index) => {
const isActive = channel.id === currentChannel?.id;
const testLabel = getLineTestLabel(channel);
const testResult = lineTestResults[getLineTestKey(channel)];
const isBestLine = bestLine?.id === channel.id;
return (
<button
key={channel.id}
@@ -2582,11 +2925,29 @@ function LivePageClient() {
<GitBranch className='w-4 h-4 text-gray-500 dark:text-gray-400' />
{`线路${index + 1}`}
</span>
{isActive && (
<span className='text-xs text-green-600 dark:text-green-400'>
</span>
)}
<div className='flex items-center gap-2 text-xs'>
{testLabel && (
<span className={
testResult?.status === 'ok'
? 'text-green-600 dark:text-green-400'
: testResult?.status === 'fail'
? 'text-red-500 dark:text-red-400'
: 'text-amber-600 dark:text-amber-400'
}>
{testLabel}
</span>
)}
{isBestLine && (
<span className='rounded bg-green-100 px-2 py-0.5 text-green-700 dark:bg-green-900/40 dark:text-green-300'>
</span>
)}
{isActive && (
<span className='text-green-600 dark:text-green-400'>
</span>
)}
</div>
</div>
</button>
);