From 081e9fdb128410daf5bff7e28cc4ef85d55d9084 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Sat, 30 May 2026 13:36:54 +0800 Subject: [PATCH] =?UTF-8?q?tv=E7=9B=B4=E6=92=ADflv?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/live/precheck/route.ts | 29 +++++++- src/app/tv/live/play/page.tsx | 87 +++++++++++++++++++--- src/components/tv/player/TVNativeVideo.tsx | 11 ++- 3 files changed, 111 insertions(+), 16 deletions(-) diff --git a/src/app/api/live/precheck/route.ts b/src/app/api/live/precheck/route.ts index 3fc49fb..d5b9430 100644 --- a/src/app/api/live/precheck/route.ts +++ b/src/app/api/live/precheck/route.ts @@ -40,17 +40,38 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Failed to fetch', message: response.statusText }, { status: 500 }); } - const contentType = response.headers.get('Content-Type'); + const contentType = response.headers.get('Content-Type') || ''; + const normalizedContentType = contentType.toLowerCase(); + const finalUrl = response.url || decodedUrl; + const normalizedUrl = finalUrl.toLowerCase().split('?')[0]; if (response.body) { response.body.cancel(); } - if (contentType?.includes('video/mp4')) { + if (normalizedContentType.includes('video/mp4') || normalizedUrl.endsWith('.mp4')) { return NextResponse.json({ success: true, type: 'mp4' }, { status: 200 }); } - if (contentType?.includes('video/x-flv')) { + if ( + normalizedContentType.includes('video/x-flv') || + normalizedContentType.includes('video/flv') || + normalizedUrl.endsWith('.flv') + ) { return NextResponse.json({ success: true, type: 'flv' }, { status: 200 }); } - return NextResponse.json({ success: true, type: 'm3u8' }, { status: 200 }); + if ( + normalizedContentType.includes('mpegurl') || + normalizedContentType.includes('application/vnd.apple') || + normalizedUrl.endsWith('.m3u8') || + normalizedUrl.endsWith('.m3u') + ) { + return NextResponse.json({ success: true, type: 'm3u8' }, { status: 200 }); + } + return NextResponse.json( + { + error: 'Unsupported live stream type', + contentType, + }, + { status: 415 } + ); } catch (error) { return NextResponse.json({ error: 'Failed to fetch', message: error }, { status: 500 }); } diff --git a/src/app/tv/live/play/page.tsx b/src/app/tv/live/play/page.tsx index a854969..29ae2aa 100644 --- a/src/app/tv/live/play/page.tsx +++ b/src/app/tv/live/play/page.tsx @@ -12,6 +12,7 @@ import TVVirtualRemote from '@/components/tv/TVVirtualRemote'; type LiveSource = { key: string; name: string; proxyMode?: 'full' | 'm3u8-only' | 'direct' }; type LiveChannel = { id: string; tvgId?: string; name: string; logo?: string; group?: string; url: string }; type EpgProgram = { start: string; end: string; title: string }; +type TVPlayerSourceType = 'm3u8' | 'flv' | 'native'; function getLogoUrl(logo?: string, source?: string) { if (!logo) return ''; @@ -19,12 +20,51 @@ function getLogoUrl(logo?: string, source?: string) { return `/api/proxy/logo?url=${encodeURIComponent(logo)}&source=${encodeURIComponent(source)}`; } -async function resolveLiveUrl(rawUrl: string, source?: LiveSource | null) { - const proxyMode = source?.proxyMode || 'full'; +function getUrlSourceType(rawUrl: string): TVPlayerSourceType | 'unknown' { const lower = rawUrl.toLowerCase(); - const isM3u8 = lower.includes('.m3u8') || lower.includes('.m3u'); - if (!isM3u8 || proxyMode === 'direct') return rawUrl; - return `/api/proxy/m3u8?url=${encodeURIComponent(rawUrl)}&moontv-source=${encodeURIComponent(source?.key || '')}`; + const path = lower.split('?')[0]; + if (path.endsWith('.m3u8') || path.endsWith('.m3u') || lower.includes('.m3u8') || lower.includes('.m3u')) return 'm3u8'; + if (path.endsWith('.flv') || lower.includes('.flv?')) return 'flv'; + if (/\.(mp4|webm|ogv|ogg|mov)(\?.*)?$/.test(path)) return 'native'; + return 'unknown'; +} + +async function resolveLiveUrl(rawUrl: string, source?: LiveSource | null): Promise<{ url: string; type: TVPlayerSourceType }> { + const proxyMode = source?.proxyMode || 'full'; + const sourceType = getUrlSourceType(rawUrl); + + if (sourceType === 'm3u8') { + return { + type: 'm3u8', + url: proxyMode === 'direct' + ? rawUrl + : `/api/proxy/m3u8?url=${encodeURIComponent(rawUrl)}&moontv-source=${encodeURIComponent(source?.key || '')}`, + }; + } + if (sourceType === 'flv') return { type: 'flv', url: rawUrl }; + if (sourceType === 'native') return { type: 'native', url: rawUrl }; + + if (!source?.key) throw new Error('未知直播流格式'); + + const precheckRes = await fetch( + `/api/live/precheck?url=${encodeURIComponent(rawUrl)}&moontv-source=${encodeURIComponent(source.key)}`, + { cache: 'no-store' } + ); + if (!precheckRes.ok) throw new Error('不支持的直播流格式'); + const precheck = await precheckRes.json(); + + if (precheck?.type === 'flv') return { type: 'flv', url: rawUrl }; + if (precheck?.type === 'mp4') return { type: 'native', url: rawUrl }; + if (precheck?.type === 'm3u8') { + return { + type: 'm3u8', + url: proxyMode === 'direct' + ? rawUrl + : `/api/proxy/m3u8?url=${encodeURIComponent(rawUrl)}&moontv-source=${encodeURIComponent(source.key)}`, + }; + } + + throw new Error('不支持的直播流格式'); } function TVLivePlayClient() { @@ -38,6 +78,8 @@ function TVLivePlayClient() { const [channels, setChannels] = useState([]); const [channel, setChannel] = useState(null); const [videoUrl, setVideoUrl] = useState(''); + const [videoType, setVideoType] = useState(); + const [unsupportedError, setUnsupportedError] = useState(''); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const [showPanel, setShowPanel] = useState(true); @@ -112,9 +154,20 @@ function TVLivePlayClient() { let alive = true; if (!channel) return; setVideoUrl(''); + setVideoType(undefined); + setUnsupportedError(''); setPlaybackError(false); setRetryCount(0); - resolveLiveUrl(channel.url, source).then((url) => alive && setVideoUrl(url)); + resolveLiveUrl(channel.url, source) + .then(({ url, type }) => { + if (!alive) return; + setVideoType(type); + setVideoUrl(url); + }) + .catch((err) => { + if (!alive) return; + setUnsupportedError(err instanceof Error ? err.message : '不支持的直播流格式'); + }); if (source) { savePlayRecord(`live_${source.key}`, `live_${channel.id}`, { title: channel.name, @@ -162,7 +215,14 @@ function TVLivePlayClient() { setRetryCount((value) => value + 1); setPlaybackError(false); setVideoUrl(''); - resolveLiveUrl(channel.url, source).then(setVideoUrl).catch(() => setPlaybackError(true)); + setVideoType(undefined); + setUnsupportedError(''); + resolveLiveUrl(channel.url, source) + .then(({ url, type }) => { + setVideoType(type); + setVideoUrl(url); + }) + .catch((err) => setUnsupportedError(err instanceof Error ? err.message : '不支持的直播流格式')); }, 2200); return () => window.clearTimeout(timer); }, [channel, playbackError, retryCount, source]); @@ -328,7 +388,16 @@ function TVLivePlayClient() { return (
setShowPanel(true)}> - {videoUrl ? setPlaybackError(true)} /> :
正在解析直播地址...
} + {unsupportedError ? ( +
+
+ +

当前直播流格式不支持

+

{unsupportedError}

+ +
+
+ ) : videoUrl ? setPlaybackError(true)} /> :
正在解析直播地址...
} {playbackError && (
@@ -337,7 +406,7 @@ function TVLivePlayClient() {

当前频道播放失败

{retryCount < 3 ? `正在自动重连(${retryCount + 1}/3)...` : '可以重试当前频道,或打开频道面板切换频道/直播源。'}

- +
diff --git a/src/components/tv/player/TVNativeVideo.tsx b/src/components/tv/player/TVNativeVideo.tsx index f69fce3..56b5a7a 100644 --- a/src/components/tv/player/TVNativeVideo.tsx +++ b/src/components/tv/player/TVNativeVideo.tsx @@ -10,7 +10,10 @@ declare global { } } -function getSourceType(url: string): 'm3u8' | 'flv' | 'native' { +type SourceType = 'm3u8' | 'flv' | 'native'; + +function getSourceType(url: string, explicitType?: SourceType): SourceType { + if (explicitType) return explicitType; const lower = url.toLowerCase(); const path = lower.split('?')[0]; // 代理地址通常是 /api/proxy/vod/m3u8?url=...,真实 m3u8 在 query 中; @@ -61,6 +64,7 @@ export default function TVNativeVideo({ playbackRate = 1, startTime = 0, command, + sourceType, className = '', }: { url: string; @@ -75,6 +79,7 @@ export default function TVNativeVideo({ playbackRate?: number; startTime?: number; command?: number; + sourceType?: SourceType; className?: string; }) { const videoRef = useRef(null); @@ -164,7 +169,7 @@ export default function TVNativeVideo({ async function attach() { cleanup(); - const type = getSourceType(url); + const type = getSourceType(url, sourceType); try { if (type === 'm3u8' && !videoEl.canPlayType('application/vnd.apple.mpegurl')) { @@ -315,7 +320,7 @@ export default function TVNativeVideo({ clearBuffering(); cleanup(); }; - }, [url, live, startTime, adFilterEnabled, playbackRate]); + }, [url, live, startTime, adFilterEnabled, playbackRate, sourceType]); useEffect(() => { const video = videoRef.current;