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]);
// 清理播放器资源