From 21400e9f7a038a0d5a08647ef54618bcca040e1f Mon Sep 17 00:00:00 2001 From: shinya Date: Mon, 25 Aug 2025 13:39:51 +0800 Subject: [PATCH] feat: support flv and mp4 --- package.json | 1 + pnpm-lock.yaml | 21 ++ src/app/api/live/precheck/route.ts | 48 +++++ src/app/api/proxy/m3u8/route.ts | 43 ++-- src/app/live/page.tsx | 327 +++++++++++++++++------------ 5 files changed, 286 insertions(+), 154 deletions(-) create mode 100644 src/app/api/live/precheck/route.ts diff --git a/package.json b/package.json index e5be487..6e18f53 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "bs58": "^6.0.0", "clsx": "^2.0.0", "crypto-js": "^4.2.0", + "flv.js": "^1.6.2", "framer-motion": "^12.18.1", "he": "^1.2.0", "hls.js": "^1.6.10", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cdc5c6c..915f639 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,9 @@ importers: crypto-js: specifier: ^4.2.0 version: 4.2.0 + flv.js: + specifier: ^1.6.2 + version: 1.6.2 framer-motion: specifier: ^12.18.1 version: 12.18.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -2627,6 +2630,9 @@ packages: es6-object-assign@1.1.0: resolution: {integrity: sha512-MEl9uirslVwqQU369iHNWZXsI8yaZYGg/D65aOgZkeyFJwHYSxilf7rQzXKI7DdDuBPrBXbfk3sl9hJhmd5AUw==} + es6-promise@4.2.8: + resolution: {integrity: sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==} + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -2881,6 +2887,9 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + flv.js@1.6.2: + resolution: {integrity: sha512-xre4gUbX1MPtgQRKj2pxJENp/RnaHaxYvy3YToVVCrSmAWUu85b9mug6pTXF6zakUjNP2lFWZ1rkSX7gxhB/2A==} + for-each@0.3.5: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} @@ -5124,6 +5133,9 @@ packages: webpack-cli: optional: true + webworkify-webpack@2.1.5: + resolution: {integrity: sha512-2akF8FIyUvbiBBdD+RoHpoTbHMQF2HwjcxfDvgztAX5YwbZNyrtfUMgvfgFVsgDhDPVTlkbb5vyasqDHfIDPQw==} + whatwg-encoding@1.0.5: resolution: {integrity: sha512-b5lim54JOPN9HtzvK9HFXvBma/rnfFeqsic0hSpjtDbVxR3dJKLc+KB4V6GgiGOvl7CY/KNh8rxSo9DKQrnUEw==} @@ -8198,6 +8210,8 @@ snapshots: es6-object-assign@1.1.0: {} + es6-promise@4.2.8: {} + escalade@3.2.0: {} escape-string-regexp@2.0.0: {} @@ -8539,6 +8553,11 @@ snapshots: flatted@3.3.3: {} + flv.js@1.6.2: + dependencies: + es6-promise: 4.2.8 + webworkify-webpack: 2.1.5 + for-each@0.3.5: dependencies: is-callable: 1.2.7 @@ -11127,6 +11146,8 @@ snapshots: - esbuild - uglify-js + webworkify-webpack@2.1.5: {} + whatwg-encoding@1.0.5: dependencies: iconv-lite: 0.4.24 diff --git a/src/app/api/live/precheck/route.ts b/src/app/api/live/precheck/route.ts new file mode 100644 index 0000000..0334f13 --- /dev/null +++ b/src/app/api/live/precheck/route.ts @@ -0,0 +1,48 @@ +import { getConfig } from '@/lib/config'; +import { NextRequest, NextResponse } from 'next/server'; + +export const runtime = 'nodejs'; + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const url = searchParams.get('url'); + const source = searchParams.get('moontv-source'); + + if (!url) { + return NextResponse.json({ error: 'Missing url' }, { status: 400 }); + } + const config = await getConfig(); + const liveSource = config.LiveConfig?.find((s: any) => s.key === source); + if (!liveSource) { + return NextResponse.json({ error: 'Source not found' }, { status: 404 }); + } + const ua = liveSource.ua || 'AptvPlayer/1.4.10'; + + try { + const decodedUrl = decodeURIComponent(url); + + const response = await fetch(decodedUrl, { + cache: 'no-cache', + redirect: 'follow', + credentials: 'same-origin', + headers: { + 'User-Agent': ua, + }, + }); + + if (!response.ok) { + return NextResponse.json({ error: 'Failed to fetch' }, { status: 500 }); + } + + const contentType = response.headers.get('Content-Type'); + if (contentType?.includes('video/mp4')) { + return NextResponse.json({ success: true, type: 'mp4' }, { status: 200 }); + } + if (contentType?.includes('video/x-flv')) { + return NextResponse.json({ success: true, type: 'flv' }, { status: 200 }); + } + return NextResponse.json({ success: true, type: 'm3u8' }, { status: 200 }); + } catch (error) { + return NextResponse.json({ error: 'Failed to fetch' }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/api/proxy/m3u8/route.ts b/src/app/api/proxy/m3u8/route.ts index 02b48db..edd746d 100644 --- a/src/app/api/proxy/m3u8/route.ts +++ b/src/app/api/proxy/m3u8/route.ts @@ -39,24 +39,41 @@ export async function GET(request: Request) { return NextResponse.json({ error: 'Failed to fetch m3u8' }, { status: 500 }); } - // 获取最终的响应URL(处理重定向后的URL) - const finalUrl = response.url; - const m3u8Content = await response.text(); + // rewrite m3u8 + if (response.headers.get('Content-Type')?.includes('application/vnd.apple.mpegurl')) { + // 获取最终的响应URL(处理重定向后的URL) + const finalUrl = response.url; + const m3u8Content = await response.text(); - // 使用最终的响应URL作为baseUrl,而不是原始的请求URL - const baseUrl = getBaseUrl(finalUrl); + // 使用最终的响应URL作为baseUrl,而不是原始的请求URL + const baseUrl = getBaseUrl(finalUrl); - // 重写 M3U8 内容 - const modifiedContent = rewriteM3U8Content(m3u8Content, baseUrl, request, allowCORS); + // 重写 M3U8 内容 + const modifiedContent = rewriteM3U8Content(m3u8Content, baseUrl, request, allowCORS); + const headers = new Headers(); + headers.set('Content-Type', 'application/vnd.apple.mpegurl'); + headers.set('Access-Control-Allow-Origin', '*'); + headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + headers.set('Access-Control-Allow-Headers', 'Content-Type, Range, Origin, Accept'); + headers.set('Cache-Control', 'no-cache'); + headers.set('Access-Control-Expose-Headers', 'Content-Length, Content-Range'); + return new Response(modifiedContent, { headers }); + } + // just proxy const headers = new Headers(); - headers.set('Content-Type', 'application/vnd.apple.mpegurl'); + headers.set('Content-Type', response.headers.get('Content-Type') || 'application/vnd.apple.mpegurl'); headers.set('Access-Control-Allow-Origin', '*'); headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); headers.set('Access-Control-Allow-Headers', 'Content-Type, Range, Origin, Accept'); headers.set('Cache-Control', 'no-cache'); headers.set('Access-Control-Expose-Headers', 'Content-Length, Content-Range'); - return new Response(modifiedContent, { headers }); + + // 直接返回视频流 + return new Response(response.body, { + status: 200, + headers, + }); } catch (error) { return NextResponse.json({ error: 'Failed to fetch m3u8' }, { status: 500 }); } @@ -87,9 +104,7 @@ function rewriteM3U8Content(content: string, baseUrl: string, req: Request, allo // 处理 TS 片段 URL 和其他媒体文件 if (line && !line.startsWith('#')) { const resolvedUrl = resolveUrl(baseUrl, line); - // 检查是否为 mp4 格式 - const isMp4 = resolvedUrl.toLowerCase().includes('.mp4') || resolvedUrl.toLowerCase().includes('mp4'); - const proxyUrl = (isMp4 || allowCORS) ? resolvedUrl : `${proxyBase}/segment?url=${encodeURIComponent(resolvedUrl)}`; + const proxyUrl = allowCORS ? resolvedUrl : `${proxyBase}/segment?url=${encodeURIComponent(resolvedUrl)}`; rewrittenLines.push(proxyUrl); continue; } @@ -133,9 +148,7 @@ function rewriteMapUri(line: string, baseUrl: string, proxyBase: string) { if (uriMatch) { const originalUri = uriMatch[1]; const resolvedUrl = resolveUrl(baseUrl, originalUri); - // 检查是否为 mp4 格式,如果是则走 proxyBase - const isMp4 = resolvedUrl.toLowerCase().includes('.mp4') || resolvedUrl.toLowerCase().includes('mp4'); - const proxyUrl = isMp4 ? `${proxyBase}/segment?url=${encodeURIComponent(resolvedUrl)}` : `${proxyBase}/segment?url=${encodeURIComponent(resolvedUrl)}`; + const proxyUrl = `${proxyBase}/segment?url=${encodeURIComponent(resolvedUrl)}`; return line.replace(uriMatch[0], `URI="${proxyUrl}"`); } return line; diff --git a/src/app/live/page.tsx b/src/app/live/page.tsx index 6c61c8b..0db9b0b 100644 --- a/src/app/live/page.tsx +++ b/src/app/live/page.tsx @@ -17,6 +17,7 @@ import PageLayout from '@/components/PageLayout'; declare global { interface HTMLVideoElement { hls?: any; + flv?: any; } } @@ -388,7 +389,6 @@ function LivePageClient() { if (response.ok) { const result = await response.json(); if (result.success) { - console.log('节目单信息:', result.data); // 清洗EPG数据,去除重叠的节目 const cleanedData = { ...result.data, @@ -417,6 +417,9 @@ function LivePageClient() { if (artPlayerRef.current.video && artPlayerRef.current.video.hls) { artPlayerRef.current.video.hls.destroy(); } + if (artPlayerRef.current.video && artPlayerRef.current.video.flv) { + artPlayerRef.current.video.flv.destroy(); + } // 销毁 ArtPlayer 实例 artPlayerRef.current.destroy(); @@ -535,154 +538,200 @@ function LivePageClient() { } } - // 播放器初始化 - useEffect(() => { - if ( - !Artplayer || - !Hls || - !videoUrl || - !artRef.current || - !currentChannel - ) { + function m3u8Loader(video: HTMLVideoElement, url: string) { + if (!Hls) { + console.error('HLS.js 未加载'); return; } - console.log('视频URL:', videoUrl); - - // 销毁之前的播放器实例并创建新的 - if (artPlayerRef.current) { - cleanupPlayer(); + if (video.hls) { + video.hls.destroy(); } + const hls = new Hls({ + debug: false, + enableWorker: true, + lowLatencyMode: true, + maxBufferLength: 30, + backBufferLength: 30, + maxBufferSize: 60 * 1000 * 1000, + loader: CustomHlsJsLoader, + }); + hls.loadSource(url); + hls.attachMedia(video); + video.hls = hls; + + hls.on(Hls.Events.ERROR, function (event: any, data: any) { + console.error('HLS Error:', event, data); + + if (data.fatal) { + switch (data.type) { + case Hls.ErrorTypes.NETWORK_ERROR: + hls.startLoad(); + break; + case Hls.ErrorTypes.MEDIA_ERROR: + hls.recoverMediaError(); + break; + default: + hls.destroy(); + break; + } + } + }); + } + + async function flvLoader(video: HTMLVideoElement, url: string) { try { - // 创建新的播放器实例 - Artplayer.USE_RAF = true; + const flvjs = await import('flv.js'); + const flv = flvjs.default as any; - artPlayerRef.current = new Artplayer({ - container: artRef.current, - url: videoUrl.toLowerCase().endsWith('.mp4') ? videoUrl : `/api/proxy/m3u8?url=${encodeURIComponent(videoUrl)}&moontv-source=${currentSourceRef.current?.key || ''}`, - poster: currentChannel.logo, - volume: 0.7, - isLive: true, // 设置为直播模式 - muted: false, - autoplay: true, - pip: true, - autoSize: false, - autoMini: false, - screenshot: false, - setting: false, - loop: false, - flip: false, - playbackRate: false, - aspectRatio: false, - fullscreen: true, - fullscreenWeb: true, - subtitleOffset: false, - miniProgressBar: false, - mutex: true, - playsInline: true, - autoPlayback: false, - airplay: true, - theme: '#22c55e', - lang: 'zh-cn', - hotkey: false, - fastForward: false, // 直播不需要快进 - autoOrientation: true, - lock: true, - moreVideoAttr: { - crossOrigin: 'anonymous', - preload: 'metadata', - }, - type: videoUrl.toLowerCase().endsWith('.mp4') ? 'mp4' : 'm3u8', - // HLS 支持配置 - customType: { - m3u8: function (video: HTMLVideoElement, url: string) { - if (!Hls) { - console.error('HLS.js 未加载'); - return; - } - - if (video.hls) { - video.hls.destroy(); - } - const hls = new Hls({ - debug: false, - enableWorker: true, - lowLatencyMode: true, - maxBufferLength: 30, - backBufferLength: 30, - maxBufferSize: 60 * 1000 * 1000, - loader: CustomHlsJsLoader, - }); - - hls.loadSource(url); - hls.attachMedia(video); - video.hls = hls; - - hls.on(Hls.Events.ERROR, function (event: any, data: any) { - console.error('HLS Error:', event, data); - - if (data.fatal) { - switch (data.type) { - case Hls.ErrorTypes.NETWORK_ERROR: - hls.startLoad(); - break; - case Hls.ErrorTypes.MEDIA_ERROR: - hls.recoverMediaError(); - break; - default: - hls.destroy(); - break; - } - } - }); - }, - }, - icons: { - loading: - '', - }, - }); - - // 监听播放器事件 - artPlayerRef.current.on('ready', () => { - setError(null); - setIsVideoLoading(false); - - }); - - artPlayerRef.current.on('loadstart', () => { - setIsVideoLoading(true); - }); - - artPlayerRef.current.on('loadeddata', () => { - setIsVideoLoading(false); - }); - - artPlayerRef.current.on('canplay', () => { - setIsVideoLoading(false); - }); - - artPlayerRef.current.on('waiting', () => { - setIsVideoLoading(true); - }); - - artPlayerRef.current.on('error', (err: any) => { - console.error('播放器错误:', err); - }); - - if (artPlayerRef.current?.video) { - const finalUrl = videoUrl.toLowerCase().endsWith('.mp4') ? videoUrl : `/api/proxy/m3u8?url=${encodeURIComponent(videoUrl)}`; - ensureVideoSource( - artPlayerRef.current.video as HTMLVideoElement, - finalUrl - ); + if (!flv.isSupported()) { + console.error('Flv.js 未支持'); + return; } - } catch (err) { - console.error('创建播放器失败:', err); - // 不设置错误,只记录日志 + if (video.flv) { + video.flv.destroy(); + } + + const flvPlayer = flv.createPlayer({ + type: 'flv', + url: url, + }); + flvPlayer.attachMediaElement(video); + flvPlayer.load(); + video.flv = flvPlayer; + } catch (error) { + console.error('加载 Flv.js 失败:', error); } + } + + // 播放器初始化 + useEffect(() => { + const preload = async () => { + if ( + !Artplayer || + !Hls || + !videoUrl || + !artRef.current || + !currentChannel + ) { + return; + } + + console.log('视频URL:', videoUrl); + + // 销毁之前的播放器实例并创建新的 + if (artPlayerRef.current) { + cleanupPlayer(); + } + + // precheck type + let type = 'm3u8'; + const precheckUrl = `/api/live/precheck?url=${encodeURIComponent(videoUrl)}&moontv-source=${currentSourceRef.current?.key || ''}`; + const precheckResponse = await fetch(precheckUrl); + if (!precheckResponse.ok) { + console.error('预检查失败:', precheckResponse.statusText); + return; + } + const precheckResult = await precheckResponse.json(); + if (precheckResult.success) { + type = precheckResult.type; + } + + const customType = type === 'flv' ? { + flv: flvLoader, + } : type === 'mp4' ? {} : { + m3u8: m3u8Loader, + }; + try { + // 创建新的播放器实例 + Artplayer.USE_RAF = true; + + artPlayerRef.current = new Artplayer({ + container: artRef.current, + url: `/api/proxy/m3u8?url=${encodeURIComponent(videoUrl)}&moontv-source=${currentSourceRef.current?.key || ''}`, + poster: currentChannel.logo, + volume: 0.7, + isLive: true, // 设置为直播模式 + muted: false, + autoplay: true, + pip: true, + autoSize: false, + autoMini: false, + screenshot: false, + setting: false, + loop: true, + flip: false, + playbackRate: false, + aspectRatio: false, + fullscreen: true, + fullscreenWeb: true, + subtitleOffset: false, + miniProgressBar: false, + mutex: true, + playsInline: true, + autoPlayback: false, + airplay: true, + theme: '#22c55e', + lang: 'zh-cn', + hotkey: false, + fastForward: false, // 直播不需要快进 + autoOrientation: true, + lock: true, + moreVideoAttr: { + crossOrigin: 'anonymous', + preload: 'metadata', + }, + type: type, + customType: customType, + icons: { + loading: + '', + }, + }); + + // 监听播放器事件 + artPlayerRef.current.on('ready', () => { + setError(null); + setIsVideoLoading(false); + + }); + + artPlayerRef.current.on('loadstart', () => { + setIsVideoLoading(true); + }); + + artPlayerRef.current.on('loadeddata', () => { + setIsVideoLoading(false); + }); + + artPlayerRef.current.on('canplay', () => { + setIsVideoLoading(false); + }); + + artPlayerRef.current.on('waiting', () => { + setIsVideoLoading(true); + }); + + artPlayerRef.current.on('error', (err: any) => { + console.error('播放器错误:', err); + }); + + if (artPlayerRef.current?.video) { + const finalUrl = `/api/proxy/m3u8?url=${encodeURIComponent(videoUrl)}`; + ensureVideoSource( + artPlayerRef.current.video as HTMLVideoElement, + finalUrl + ); + } + + } catch (err) { + console.error('创建播放器失败:', err); + // 不设置错误,只记录日志 + } + } + preload(); }, [Artplayer, Hls, videoUrl, currentChannel, loading]); // 清理播放器资源