diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index b400d55..9252ef8 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -10715,6 +10715,262 @@ const LiveSourceConfig = ({ ); }; +// 网络直播配置组件 +const WebLiveConfig = ({ + config, + refreshConfig, +}: { + config: AdminConfig | null; + refreshConfig: () => Promise; +}) => { + const { alertModal, showAlert, hideAlert } = useAlertModal(); + const { isLoading, withLoading } = useLoadingState(); + const [webLiveSources, setWebLiveSources] = useState([]); + const [showAddForm, setShowAddForm] = useState(false); + const [editingSource, setEditingSource] = useState(null); + const [newSource, setNewSource] = useState({ + name: '', + platform: 'huya', + roomId: '', + }); + + useEffect(() => { + if (config?.WebLiveConfig) { + setWebLiveSources(config.WebLiveConfig); + } + }, [config]); + + const callApi = async (body: Record) => { + try { + const resp = await fetch('/api/admin/web-live', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!resp.ok) { + const data = await resp.json().catch(() => ({})); + throw new Error(data.error || `操作失败: ${resp.status}`); + } + await refreshConfig(); + } catch (err) { + showError(err instanceof Error ? err.message : '操作失败', showAlert); + throw err; + } + }; + + const handleAdd = () => { + if (!newSource.name || !newSource.platform || !newSource.roomId) return; + withLoading('addWebLive', async () => { + await callApi({ + action: 'add', + name: newSource.name, + platform: newSource.platform, + roomId: newSource.roomId, + }); + setNewSource({ name: '', platform: 'huya', roomId: '' }); + setShowAddForm(false); + }).catch(() => {}); + }; + + const handleEdit = () => { + if (!editingSource || !editingSource.name || !editingSource.roomId) return; + withLoading('editWebLive', async () => { + await callApi({ + action: 'edit', + key: editingSource.key, + name: editingSource.name, + platform: editingSource.platform, + roomId: editingSource.roomId, + }); + setEditingSource(null); + }).catch(() => {}); + }; + + const handleToggle = (key: string) => { + const target = webLiveSources.find((s) => s.key === key); + if (!target) return; + const action = target.disabled ? 'enable' : 'disable'; + withLoading(`toggleWebLive_${key}`, () => callApi({ action, key })).catch(() => {}); + }; + + const handleDelete = (key: string) => { + withLoading(`deleteWebLive_${key}`, () => callApi({ action: 'delete', key })).catch(() => {}); + }; + + if (!config) { + return
加载中...
; + } + + return ( +
+
+

网络直播列表

+ +
+ + {showAddForm && ( +
+
+ setNewSource((prev) => ({ ...prev, name: e.target.value }))} + className='px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' + /> + + setNewSource((prev) => ({ ...prev, roomId: e.target.value }))} + className='px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' + /> +
+
+ +
+
+ )} + + {editingSource && ( +
+
+
编辑: {editingSource.name}
+ +
+
+
+ + setEditingSource((prev: any) => prev ? { ...prev, name: e.target.value } : null)} + className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' + /> +
+
+ + +
+
+ + setEditingSource((prev: any) => prev ? { ...prev, roomId: e.target.value } : null)} + className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' + /> +
+
+
+ + +
+
+ )} + +
+ + + + + + + + + + + + {webLiveSources.map((source) => ( + + + + + + + + ))} + +
名称直播类型房间ID状态操作
{source.name}{source.platform === 'huya' ? '虎牙' : source.platform}{source.roomId} + + {!source.disabled ? '启用中' : '已禁用'} + + + + {source.from !== 'config' && ( + <> + + + + )} +
+
+ + +
+ ); +}; + function AdminPageClient() { const { alertModal, showAlert, hideAlert } = useAlertModal(); const { isLoading, withLoading } = useLoadingState(); @@ -10732,6 +10988,7 @@ function AdminPageClient() { xiaoyaConfig: false, aiConfig: false, liveSource: false, + webLive: false, siteConfig: false, registrationConfig: false, categoryConfig: false, @@ -11057,6 +11314,18 @@ function AdminPageClient() { + {/* 网络直播配置标签 */} + + } + isExpanded={expandedTabs.webLive} + onToggle={() => toggleTab('webLive')} + > + + + {/* 私人影库大类 */} s.key === key)) { + return NextResponse.json({ error: 'Key已存在' }, { status: 400 }); + } + + config.WebLiveConfig.push({ + key, + name, + platform, + roomId, + from: 'custom', + disabled: false, + }); + break; + } + + case 'delete': { + const { key } = body; + const source = config.WebLiveConfig.find((s) => s.key === key); + if (!source) { + return NextResponse.json({ error: '源不存在' }, { status: 404 }); + } + if (source.from === 'config') { + return NextResponse.json({ error: '无法删除配置文件中的源' }, { status: 400 }); + } + config.WebLiveConfig = config.WebLiveConfig.filter((s) => s.key !== key); + break; + } + + case 'enable': { + const { key } = body; + const source = config.WebLiveConfig.find((s) => s.key === key); + if (!source) { + return NextResponse.json({ error: '源不存在' }, { status: 404 }); + } + source.disabled = false; + break; + } + + case 'disable': { + const { key } = body; + const source = config.WebLiveConfig.find((s) => s.key === key); + if (!source) { + return NextResponse.json({ error: '源不存在' }, { status: 404 }); + } + source.disabled = true; + break; + } + + case 'edit': { + const { key, name, platform, roomId } = body; + const source = config.WebLiveConfig.find((s) => s.key === key); + if (!source) { + return NextResponse.json({ error: '源不存在' }, { status: 404 }); + } + if (source.from === 'config') { + return NextResponse.json({ error: '无法编辑配置文件中的源' }, { status: 400 }); + } + source.name = name; + source.platform = platform; + source.roomId = roomId; + break; + } + + case 'sort': { + const { keys } = body; + if (!Array.isArray(keys)) { + return NextResponse.json({ error: '无效的排序数据' }, { status: 400 }); + } + const sortedSources = keys + .map((key) => config.WebLiveConfig!.find((s) => s.key === key)) + .filter((s) => s !== undefined); + config.WebLiveConfig = sortedSources; + break; + } + + default: + return NextResponse.json({ error: '未知操作' }, { status: 400 }); + } + + await db.saveAdminConfig(config); + return NextResponse.json({ success: true }); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : '操作失败' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/web-live/sources/route.ts b/src/app/api/web-live/sources/route.ts new file mode 100644 index 0000000..88b435b --- /dev/null +++ b/src/app/api/web-live/sources/route.ts @@ -0,0 +1,19 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getConfig } from '@/lib/config'; + +export async function GET(request: NextRequest) { + try { + const config = await getConfig(); + if (!config?.WebLiveConfig) { + return NextResponse.json([]); + } + + const sources = config.WebLiveConfig.filter(s => !s.disabled); + return NextResponse.json(sources); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : '获取失败' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/web-live/stream/route.ts b/src/app/api/web-live/stream/route.ts new file mode 100644 index 0000000..ecfa0a9 --- /dev/null +++ b/src/app/api/web-live/stream/route.ts @@ -0,0 +1,33 @@ +import { NextRequest, NextResponse } from 'next/server'; + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const platform = searchParams.get('platform'); + const roomId = searchParams.get('roomId'); + + if (!platform || !roomId) { + return NextResponse.json({ error: '缺少参数' }, { status: 400 }); + } + + if (platform === 'huya') { + const res = await fetch(`https://mp.huya.com/cache.php?m=Live&do=profileRoom&roomid=${roomId}`); + const data = await res.json(); + + if (data.status === 200 && data.data?.liveStatus === 'ON') { + const stream = data.data.stream; + const url = `${stream.flv.multiLine[0].url}/${stream.flv.streamName}.${stream.flv.sFlvUrlSuffix}?${stream.flv.sFlvAntiCode}`; + return NextResponse.json({ url }); + } + + return NextResponse.json({ error: '直播未开启' }, { status: 404 }); + } + + return NextResponse.json({ error: '不支持的平台' }, { status: 400 }); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : '获取失败' }, + { status: 500 } + ); + } +} diff --git a/src/app/web-live/page.tsx b/src/app/web-live/page.tsx new file mode 100644 index 0000000..1e8588e --- /dev/null +++ b/src/app/web-live/page.tsx @@ -0,0 +1,112 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import PageLayout from '@/components/PageLayout'; + +let Artplayer: any = null; +let Hls: any = null; + +export default function WebLivePage() { + const artRef = useRef(null); + const artPlayerRef = useRef(null); + const [sources, setSources] = useState([]); + const [currentSource, setCurrentSource] = useState(null); + const [videoUrl, setVideoUrl] = useState(''); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + if (typeof window !== 'undefined') { + import('artplayer').then(mod => { Artplayer = mod.default; }); + import('hls.js').then(mod => { Hls = mod.default; }); + } + fetchSources(); + }, []); + + const fetchSources = async () => { + try { + const res = await fetch('/api/web-live/sources'); + if (res.ok) { + const data = await res.json(); + setSources(data); + } + } catch (err) { + console.error('获取直播源失败:', err); + } + }; + + function m3u8Loader(video: HTMLVideoElement, url: string) { + if (!Hls) return; + const hls = new Hls({ debug: false, enableWorker: true, lowLatencyMode: true }); + hls.loadSource(url); + hls.attachMedia(video); + (video as any).hls = hls; + } + + useEffect(() => { + if (!Artplayer || !Hls || !videoUrl || !artRef.current) return; + + if (artPlayerRef.current) { + artPlayerRef.current.destroy(); + } + + artPlayerRef.current = new Artplayer({ + container: artRef.current, + url: videoUrl, + isLive: true, + autoplay: true, + customType: { m3u8: m3u8Loader }, + icons: { loading: '' } + }); + + return () => { + if (artPlayerRef.current) { + artPlayerRef.current.destroy(); + artPlayerRef.current = null; + } + }; + }, [videoUrl]); + + const handleSourceClick = async (source: any) => { + setCurrentSource(source); + setIsLoading(true); + try { + const res = await fetch(`/api/web-live/stream?platform=${source.platform}&roomId=${source.roomId}`); + if (res.ok) { + const data = await res.json(); + setVideoUrl(data.url); + } + } catch (err) { + console.error('获取直播流失败:', err); + } finally { + setIsLoading(false); + } + }; + + return ( + +
+

网络直播

+
+
+
+

直播列表

+ {sources.map((source) => ( + + ))} +
+
+
+ + ); +} diff --git a/src/components/MobileBottomNav.tsx b/src/components/MobileBottomNav.tsx index d6a521e..ef4a440 100644 --- a/src/components/MobileBottomNav.tsx +++ b/src/components/MobileBottomNav.tsx @@ -55,6 +55,11 @@ const MobileBottomNav = ({ activePath }: MobileBottomNavProps) => { label: '电视直播', href: '/live', }, + { + icon: Radio, + label: '网络直播', + href: '/web-live', + }, ]); useEffect(() => { @@ -88,6 +93,11 @@ const MobileBottomNav = ({ activePath }: MobileBottomNavProps) => { label: '电视直播', href: '/live', }, + { + icon: Radio, + label: '网络直播', + href: '/web-live', + }, ]; // 如果配置了 OpenList 或 Emby,添加私人影库入口 diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 6c117ac..85ff0d7 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -147,6 +147,11 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => { label: '电视直播', href: '/live', }, + { + icon: Radio, + label: '网络直播', + href: '/web-live', + }, ]); useEffect(() => { @@ -179,6 +184,11 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => { label: '电视直播', href: '/live', }, + { + icon: Radio, + label: '网络直播', + href: '/web-live', + }, ]; // 如果配置了 OpenList 或 Emby,添加私人影库入口 diff --git a/src/lib/admin.types.ts b/src/lib/admin.types.ts index 778f1e8..9595e2f 100644 --- a/src/lib/admin.types.ts +++ b/src/lib/admin.types.ts @@ -95,6 +95,14 @@ export interface AdminConfig { channelNumber?: number; disabled?: boolean; }[]; + WebLiveConfig?: { + key: string; + name: string; + platform: string; // 直播平台类型,如 'huya' + roomId: string; // 房间ID + from: 'config' | 'custom'; + disabled?: boolean; + }[]; ThemeConfig?: { enableBuiltInTheme: boolean; // 是否启用内置主题 builtInTheme: string; // 内置主题名称