From 23c3d3a81f5d12c7987e7ca287eaffbc5f55a5a2 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Mon, 19 Jan 2026 16:49:21 +0800 Subject: [PATCH 01/10] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E7=A6=81=E7=94=A8?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=8C=B9=E9=85=8D=E5=BC=B9=E5=B9=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/play/page.tsx | 13 +++++++++++++ src/components/UserMenu.tsx | 39 +++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index 91d3126..4f2635e 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -607,6 +607,13 @@ function PlayPageClient() { return; } + // 检查是否禁用了自动装填弹幕 + const disableAutoLoad = localStorage.getItem('disableAutoLoadDanmaku') === 'true'; + if (disableAutoLoad) { + console.log('[弹幕] 已禁用自动装填弹幕,跳过自动加载'); + return; + } + // 检查集数是否有效且是否已改变 if (currentEpisodeIndex < 0 || !videoTitle) { return; @@ -3644,6 +3651,9 @@ function PlayPageClient() { // 预加载下一集弹幕(完全复制 loadDanmakuForCurrentEpisode 的逻辑) const preloadNextEpisodeDanmaku = async () => { try { + const disableAutoLoad = localStorage.getItem('disableAutoLoadDanmaku') === 'true'; + if (disableAutoLoad) return; + const title = videoTitleRef.current; if (!title) { return; @@ -3970,6 +3980,9 @@ function PlayPageClient() { // 自动搜索并加载弹幕 const autoSearchDanmaku = async () => { + const disableAutoLoad = localStorage.getItem('disableAutoLoadDanmaku') === 'true'; + if (disableAutoLoad) return; + const title = videoTitleRef.current; if (!title) { console.warn('视频标题为空,无法自动搜索弹幕'); diff --git a/src/components/UserMenu.tsx b/src/components/UserMenu.tsx index 221794f..c23a0f3 100644 --- a/src/components/UserMenu.tsx +++ b/src/components/UserMenu.tsx @@ -113,6 +113,7 @@ export const UserMenu: React.FC = () => { const [bufferStrategy, setBufferStrategy] = useState('medium'); const [nextEpisodePreCache, setNextEpisodePreCache] = useState(true); const [nextEpisodeDanmakuPreload, setNextEpisodeDanmakuPreload] = useState(true); + const [disableAutoLoadDanmaku, setDisableAutoLoadDanmaku] = useState(false); const [searchTraditionalToSimplified, setSearchTraditionalToSimplified] = useState(false); // 邮件通知设置 @@ -402,6 +403,11 @@ export const UserMenu: React.FC = () => { setNextEpisodeDanmakuPreload(savedNextEpisodeDanmakuPreload === 'true'); } + const savedDisableAutoLoadDanmaku = localStorage.getItem('disableAutoLoadDanmaku'); + if (savedDisableAutoLoadDanmaku !== null) { + setDisableAutoLoadDanmaku(savedDisableAutoLoadDanmaku === 'true'); + } + // 加载首页模块配置 const savedHomeModules = localStorage.getItem('homeModules'); if (savedHomeModules !== null) { @@ -758,6 +764,13 @@ export const UserMenu: React.FC = () => { } }; + const handleDisableAutoLoadDanmakuToggle = (value: boolean) => { + setDisableAutoLoadDanmaku(value); + if (typeof window !== 'undefined') { + localStorage.setItem('disableAutoLoadDanmaku', String(value)); + } + }; + const handleSearchTraditionalToSimplifiedToggle = (value: boolean) => { setSearchTraditionalToSimplified(value); if (typeof window !== 'undefined') { @@ -857,6 +870,7 @@ export const UserMenu: React.FC = () => { setBufferStrategy('medium'); setNextEpisodePreCache(true); setNextEpisodeDanmakuPreload(true); + setDisableAutoLoadDanmaku(false); setHomeModules(defaultHomeModules); setSearchTraditionalToSimplified(false); @@ -875,6 +889,7 @@ export const UserMenu: React.FC = () => { localStorage.setItem('bufferStrategy', 'medium'); localStorage.setItem('nextEpisodePreCache', 'true'); localStorage.setItem('nextEpisodeDanmakuPreload', 'true'); + localStorage.setItem('disableAutoLoadDanmaku', 'false'); localStorage.setItem('homeModules', JSON.stringify(defaultHomeModules)); localStorage.setItem('searchTraditionalToSimplified', 'false'); window.dispatchEvent(new CustomEvent('homeModulesUpdated')); @@ -1814,6 +1829,30 @@ export const UserMenu: React.FC = () => { {isDanmakuSectionOpen && (
+ {/* 禁用自动装填弹幕 */} +
+
+

+ 禁用自动装填弹幕 +

+

+ 开启后,播放页面不会自动匹配弹幕,只能手动匹配 +

+
+ +
+ {/* 下集弹幕预加载 */}
From dc7b3602b55de9afc3cab2acc1dbffff6e1c5375 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Tue, 20 Jan 2026 09:15:01 +0800 Subject: [PATCH 02/10] =?UTF-8?q?localstorage=E6=96=B9=E5=BC=8F=E7=A7=BB?= =?UTF-8?q?=E9=99=A4=E7=AE=A1=E7=90=86=E9=9D=A2=E6=9D=BF=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/UserMenu.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/UserMenu.tsx b/src/components/UserMenu.tsx index c23a0f3..799df77 100644 --- a/src/components/UserMenu.tsx +++ b/src/components/UserMenu.tsx @@ -925,7 +925,8 @@ export const UserMenu: React.FC = () => { // 检查是否显示管理面板按钮 const showAdminPanel = - authInfo?.role === 'owner' || authInfo?.role === 'admin'; + (authInfo?.role === 'owner' || authInfo?.role === 'admin') && + storageType !== 'localstorage'; // 检查是否显示离线下载按钮 const showOfflineDownload = From 5e9ecc20f05b713586e508a44f9487d91374a2ab Mon Sep 17 00:00:00 2001 From: mtvpls Date: Tue, 20 Jan 2026 09:32:11 +0800 Subject: [PATCH 03/10] =?UTF-8?q?=E7=9B=B4=E6=92=AD=E6=94=B9=E5=90=8D?= =?UTF-8?q?=E7=94=B5=E8=A7=86=E7=9B=B4=E6=92=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/admin/page.tsx | 4 ++-- src/components/MobileBottomNav.tsx | 4 ++-- src/components/Sidebar.tsx | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 212205c..b400d55 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -11045,9 +11045,9 @@ function AdminPageClient() { - {/* 直播源配置标签 */} + {/* 电视直播源配置标签 */} } diff --git a/src/components/MobileBottomNav.tsx b/src/components/MobileBottomNav.tsx index f2083ed..d6a521e 100644 --- a/src/components/MobileBottomNav.tsx +++ b/src/components/MobileBottomNav.tsx @@ -52,7 +52,7 @@ const MobileBottomNav = ({ activePath }: MobileBottomNavProps) => { }, { icon: Radio, - label: '直播', + label: '电视直播', href: '/live', }, ]); @@ -85,7 +85,7 @@ const MobileBottomNav = ({ activePath }: MobileBottomNavProps) => { }, { icon: Radio, - label: '直播', + label: '电视直播', href: '/live', }, ]; diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index a208ae7..6c117ac 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -144,7 +144,7 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => { }, { icon: Radio, - label: '直播', + label: '电视直播', href: '/live', }, ]); @@ -176,7 +176,7 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => { }, { icon: Radio, - label: '直播', + label: '电视直播', href: '/live', }, ]; From 78f7c68ef5f4b5ede84793721368ed72c88f7321 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Tue, 20 Jan 2026 11:48:41 +0800 Subject: [PATCH 04/10] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E7=BD=91=E7=BB=9C?= =?UTF-8?q?=E7=9B=B4=E6=92=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/admin/page.tsx | 269 ++++++++++++++++++++++++++ src/app/api/admin/web-live/route.ts | 124 ++++++++++++ src/app/api/web-live/sources/route.ts | 19 ++ src/app/api/web-live/stream/route.ts | 33 ++++ src/app/web-live/page.tsx | 112 +++++++++++ src/components/MobileBottomNav.tsx | 10 + src/components/Sidebar.tsx | 10 + src/lib/admin.types.ts | 8 + 8 files changed, 585 insertions(+) create mode 100644 src/app/api/admin/web-live/route.ts create mode 100644 src/app/api/web-live/sources/route.ts create mode 100644 src/app/api/web-live/stream/route.ts create mode 100644 src/app/web-live/page.tsx 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; // 内置主题名称 From 1b566550af85d9596aa456ae19db59f97d331a1b Mon Sep 17 00:00:00 2001 From: mtvpls Date: Tue, 20 Jan 2026 12:29:17 +0800 Subject: [PATCH 05/10] =?UTF-8?q?=E7=BD=91=E7=BB=9C=E7=9B=B4=E6=92=AD?= =?UTF-8?q?=E6=BA=90=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + pnpm-lock.yaml | 21 ++++ src/app/web-live/page.tsx | 242 ++++++++++++++++++++++++++++++++++---- 3 files changed, 239 insertions(+), 25 deletions(-) diff --git a/package.json b/package.json index c8d455e..4f06a62 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "cheerio": "^1.1.2", "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 2ef9976..03ccd35 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,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.23.26(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -3186,6 +3189,9 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} + 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'} @@ -3455,6 +3461,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'} @@ -6158,6 +6167,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==} @@ -10176,6 +10188,8 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 + es6-promise@4.2.8: {} + escalade@3.2.0: {} escape-string-regexp@2.0.0: {} @@ -10527,6 +10541,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 @@ -13960,6 +13979,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/web-live/page.tsx b/src/app/web-live/page.tsx index 1e8588e..126d3ef 100644 --- a/src/app/web-live/page.tsx +++ b/src/app/web-live/page.tsx @@ -2,35 +2,50 @@ import { useEffect, useRef, useState } from 'react'; import PageLayout from '@/components/PageLayout'; +import { Radio } from 'lucide-react'; let Artplayer: any = null; let Hls: any = null; +let flvjs: any = null; export default function WebLivePage() { const artRef = useRef(null); const artPlayerRef = useRef(null); + const [loading, setLoading] = useState(true); + const [loadingStage, setLoadingStage] = useState<'loading' | 'fetching' | 'ready'>('loading'); + const [loadingMessage, setLoadingMessage] = useState('正在加载直播源...'); const [sources, setSources] = useState([]); const [currentSource, setCurrentSource] = useState(null); const [videoUrl, setVideoUrl] = useState(''); - const [isLoading, setIsLoading] = useState(false); + const [activeTab, setActiveTab] = useState<'rooms' | 'platforms'>('rooms'); + const [isChannelListCollapsed, setIsChannelListCollapsed] = useState(false); useEffect(() => { if (typeof window !== 'undefined') { import('artplayer').then(mod => { Artplayer = mod.default; }); import('hls.js').then(mod => { Hls = mod.default; }); + import('flv.js').then(mod => { flvjs = mod.default; }); } fetchSources(); }, []); const fetchSources = async () => { try { + setLoading(true); + setLoadingStage('loading'); + setLoadingMessage('正在加载直播源...'); const res = await fetch('/api/web-live/sources'); if (res.ok) { + setLoadingStage('fetching'); const data = await res.json(); setSources(data); + setLoadingStage('ready'); + await new Promise(resolve => setTimeout(resolve, 500)); } } catch (err) { console.error('获取直播源失败:', err); + } finally { + setLoading(false); } }; @@ -42,8 +57,16 @@ export default function WebLivePage() { (video as any).hls = hls; } + function flvLoader(video: HTMLVideoElement, url: string) { + if (!flvjs) return; + const flvPlayer = flvjs.createPlayer({ type: 'flv', url, isLive: true }); + flvPlayer.attachMediaElement(video); + flvPlayer.load(); + (video as any).flvPlayer = flvPlayer; + } + useEffect(() => { - if (!Artplayer || !Hls || !videoUrl || !artRef.current) return; + if (!Artplayer || !Hls || !flvjs || !videoUrl || !artRef.current) return; if (artPlayerRef.current) { artPlayerRef.current.destroy(); @@ -54,7 +77,10 @@ export default function WebLivePage() { url: videoUrl, isLive: true, autoplay: true, - customType: { m3u8: m3u8Loader }, + customType: { + m3u8: m3u8Loader, + flv: flvLoader + }, icons: { loading: '' } }); @@ -68,7 +94,6 @@ export default function WebLivePage() { 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) { @@ -77,33 +102,200 @@ export default function WebLivePage() { } } catch (err) { console.error('获取直播流失败:', err); - } finally { - setIsLoading(false); } }; + const platforms = Array.from(new Set(sources.map(s => s.platform))); + + if (loading) { + return ( + +
+
+ {/* 动画直播图标 */} +
+
+
📺
+ {/* 旋转光环 */} +
+
+ + {/* 浮动粒子效果 */} +
+
+
+
+
+
+ + {/* 进度指示器 */} +
+
+
+
+
+
+ + {/* 进度条 */} +
+
+
+
+ + {/* 加载消息 */} +
+

+ {loadingMessage} +

+
+
+
+
+ ); + } + return ( -
-

网络直播

-
-
-
-

直播列表

- {sources.map((source) => ( - - ))} + + + + {isChannelListCollapsed ? '显示' : '隐藏'} + +
+ +
+ +
+
+
+
+
+
+ +
+
+
+
setActiveTab('rooms')} + className={`flex-1 py-3 px-6 text-center cursor-pointer transition-all duration-200 font-medium ${activeTab === 'rooms' ? 'text-green-600 dark:text-green-400' : 'text-gray-700 hover:text-green-600 bg-black/5 dark:bg-white/5 dark:text-gray-300 dark:hover:text-green-400 hover:bg-black/3 dark:hover:bg-white/3'}`} + > + 房间 +
+
setActiveTab('platforms')} + className={`flex-1 py-3 px-6 text-center cursor-pointer transition-all duration-200 font-medium ${activeTab === 'platforms' ? 'text-green-600 dark:text-green-400' : 'text-gray-700 hover:text-green-600 bg-black/5 dark:bg-white/5 dark:text-gray-300 dark:hover:text-green-400 hover:bg-black/3 dark:hover:bg-white/3'}`} + > + 平台 +
+
+ + {activeTab === 'rooms' && ( +
+ {sources.length > 0 ? ( + sources.map((source) => { + const isActive = source.key === currentSource?.key; + return ( + + ); + }) + ) : ( +
+
+ +
+

暂无可用房间

+
+ )} +
+ )} + + {activeTab === 'platforms' && ( +
+
+ {platforms.length > 0 ? ( + platforms.map((platform) => ( +
+
+ +
+
+
+ {platform === 'huya' ? '虎牙' : platform} +
+
+ {sources.filter(s => s.platform === platform).length} 个房间 +
+
+
+ )) + ) : ( +
+
+ +
+

暂无可用平台

+
+ )} +
+
+ )} +
+
From 16e56dbb3387cf11adbc6d1b6586ac5d8948a4c9 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Tue, 20 Jan 2026 12:55:50 +0800 Subject: [PATCH 06/10] =?UTF-8?q?=E7=BD=91=E7=BB=9C=E7=9B=B4=E6=92=AD?= =?UTF-8?q?=E6=BA=90=E5=88=87=E6=8D=A2=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/web-live/stream/route.ts | 52 ++++++++++++++++++++---- src/app/web-live/page.tsx | 61 ++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 7 deletions(-) diff --git a/src/app/api/web-live/stream/route.ts b/src/app/api/web-live/stream/route.ts index ecfa0a9..03a2fe0 100644 --- a/src/app/api/web-live/stream/route.ts +++ b/src/app/api/web-live/stream/route.ts @@ -1,4 +1,28 @@ import { NextRequest, NextResponse } from 'next/server'; +import crypto from 'crypto'; + +function getAntiCode(oldAntiCode: string, streamName: string): string { + const paramsT = 100; + const sdkVersion = 2403051612; + const t13 = Date.now(); + const sdkSid = t13; + const initUuid = (Math.floor(t13 % 10000000000 * 1000) + Math.floor(1000 * Math.random())) % 4294967295; + const uid = Math.floor(Math.random() * (1400009999999 - 1400000000000 + 1)) + 1400000000000; + const seqId = uid + sdkSid; + const targetUnixTime = Math.floor((t13 + 110624) / 1000); + const wsTime = targetUnixTime.toString(16).toLowerCase(); + + const urlQuery = new URLSearchParams(oldAntiCode); + const fm = urlQuery.get('fm'); + if (!fm) return oldAntiCode; + + const wsSecretPf = Buffer.from(decodeURIComponent(fm), 'base64').toString().split('_')[0]; + const wsSecretHash = crypto.createHash('md5').update(`${seqId}|${urlQuery.get('ctype')}|${paramsT}`).digest('hex'); + const wsSecret = `${wsSecretPf}_${uid}_${streamName}_${wsSecretHash}_${wsTime}`; + const wsSecretMd5 = crypto.createHash('md5').update(wsSecret).digest('hex'); + + return `wsSecret=${wsSecretMd5}&wsTime=${wsTime}&seqid=${seqId}&ctype=${urlQuery.get('ctype')}&ver=1&fs=${urlQuery.get('fs')}&uuid=${initUuid}&u=${uid}&t=${paramsT}&sv=${sdkVersion}&sdk_sid=${sdkSid}&codec=264`; +} export async function GET(request: NextRequest) { try { @@ -11,16 +35,30 @@ export async function GET(request: NextRequest) { } if (platform === 'huya') { - const res = await fetch(`https://mp.huya.com/cache.php?m=Live&do=profileRoom&roomid=${roomId}`); - const data = await res.json(); + const res = await fetch(`https://www.huya.com/${roomId}`, { + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' + } + }); + const html = await res.text(); - 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 }); + const match = html.match(/stream:\s*(\{"data".*?),"iWebDefaultBitRate"/); + if (!match) { + return NextResponse.json({ error: '未找到直播数据' }, { status: 404 }); } - return NextResponse.json({ error: '直播未开启' }, { status: 404 }); + const jsonData = JSON.parse(match[1] + '}'); + const streamInfo = jsonData.data?.[0]?.gameStreamInfoList?.[0]; + + if (!streamInfo) { + return NextResponse.json({ error: '直播未开启' }, { status: 404 }); + } + + const { sFlvUrl, sStreamName, sFlvUrlSuffix, sFlvAntiCode } = streamInfo; + const newAntiCode = getAntiCode(sFlvAntiCode, sStreamName); + const url = `${sFlvUrl}/${sStreamName}.${sFlvUrlSuffix}?${newAntiCode}`; + + return NextResponse.json({ url }); } return NextResponse.json({ error: '不支持的平台' }, { status: 400 }); diff --git a/src/app/web-live/page.tsx b/src/app/web-live/page.tsx index 126d3ef..d931286 100644 --- a/src/app/web-live/page.tsx +++ b/src/app/web-live/page.tsx @@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from 'react'; import PageLayout from '@/components/PageLayout'; import { Radio } from 'lucide-react'; +import Head from 'next/head'; let Artplayer: any = null; let Hls: any = null; @@ -19,14 +20,25 @@ export default function WebLivePage() { const [videoUrl, setVideoUrl] = useState(''); const [activeTab, setActiveTab] = useState<'rooms' | 'platforms'>('rooms'); const [isChannelListCollapsed, setIsChannelListCollapsed] = useState(false); + const [isVideoLoading, setIsVideoLoading] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); useEffect(() => { + const meta = document.createElement('meta'); + meta.name = 'referrer'; + meta.content = 'no-referrer'; + document.head.appendChild(meta); + if (typeof window !== 'undefined') { import('artplayer').then(mod => { Artplayer = mod.default; }); import('hls.js').then(mod => { Hls = mod.default; }); import('flv.js').then(mod => { flvjs = mod.default; }); } fetchSources(); + + return () => { + document.head.removeChild(meta); + }; }, []); const fetchSources = async () => { @@ -61,6 +73,11 @@ export default function WebLivePage() { if (!flvjs) return; const flvPlayer = flvjs.createPlayer({ type: 'flv', url, isLive: true }); flvPlayer.attachMediaElement(video); + flvPlayer.on(flvjs.Events.ERROR, (errorType: string, errorDetail: string) => { + console.error('FLV.js error:', errorType, errorDetail); + setErrorMessage(`播放失败: ${errorType} - ${errorDetail}`); + setVideoUrl(''); + }); flvPlayer.load(); (video as any).flvPlayer = flvPlayer; } @@ -94,14 +111,22 @@ export default function WebLivePage() { const handleSourceClick = async (source: any) => { setCurrentSource(source); + setIsVideoLoading(true); + setErrorMessage(null); 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); + } else { + const data = await res.json(); + setErrorMessage(data.error || '获取直播流失败'); } } catch (err) { console.error('获取直播流失败:', err); + setErrorMessage(err instanceof Error ? err.message : '获取直播流失败'); + } finally { + setIsVideoLoading(false); } }; @@ -210,6 +235,42 @@ export default function WebLivePage() {
+ + {errorMessage && ( +
+
+
+
+
⚠️
+
+
+
+
+

获取直播流失败

+
+

{errorMessage}

+
+

请尝试其他房间

+
+
+
+ )} + + {isVideoLoading && ( +
+
+
+
+
📺
+
+
+
+
+

🔄 加载中...

+
+
+
+ )}
From e2a94fee2d7e60e7016431371e03f23012295bd3 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Tue, 20 Jan 2026 13:31:52 +0800 Subject: [PATCH 07/10] =?UTF-8?q?huya=E6=B5=81=E4=BB=A3=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/web-live/proxy/[filename]/route.ts | 36 +++++++++++++++++++ src/app/api/web-live/stream/route.ts | 5 +-- src/app/web-live/page.tsx | 1 + 3 files changed, 40 insertions(+), 2 deletions(-) create mode 100644 src/app/api/web-live/proxy/[filename]/route.ts diff --git a/src/app/api/web-live/proxy/[filename]/route.ts b/src/app/api/web-live/proxy/[filename]/route.ts new file mode 100644 index 0000000..9bcce73 --- /dev/null +++ b/src/app/api/web-live/proxy/[filename]/route.ts @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from 'next/server'; + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const url = searchParams.get('url'); + + if (!url) { + return NextResponse.json({ error: '缺少URL参数' }, { status: 400 }); + } + + const streamRes = await fetch(url, { + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + 'Referer': 'https://www.huya.com/' + } + }); + + if (!streamRes.ok) { + return NextResponse.json({ error: '无法获取直播流' }, { status: 404 }); + } + + return new NextResponse(streamRes.body, { + headers: { + 'Content-Type': streamRes.headers.get('Content-Type') || 'application/octet-stream', + 'Cache-Control': 'no-cache', + 'Access-Control-Allow-Origin': '*' + } + }); + } 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 index 03a2fe0..c5a627a 100644 --- a/src/app/api/web-live/stream/route.ts +++ b/src/app/api/web-live/stream/route.ts @@ -56,9 +56,10 @@ export async function GET(request: NextRequest) { const { sFlvUrl, sStreamName, sFlvUrlSuffix, sFlvAntiCode } = streamInfo; const newAntiCode = getAntiCode(sFlvAntiCode, sStreamName); - const url = `${sFlvUrl}/${sStreamName}.${sFlvUrlSuffix}?${newAntiCode}`; + const streamUrl = `${sFlvUrl}/${sStreamName}.${sFlvUrlSuffix}?${newAntiCode}`; + const proxyUrl = `/api/web-live/proxy/proxy.flv?url=${encodeURIComponent(streamUrl)}`; - return NextResponse.json({ url }); + return NextResponse.json({ url: proxyUrl }); } return NextResponse.json({ error: '不支持的平台' }, { status: 400 }); diff --git a/src/app/web-live/page.tsx b/src/app/web-live/page.tsx index d931286..69460cc 100644 --- a/src/app/web-live/page.tsx +++ b/src/app/web-live/page.tsx @@ -94,6 +94,7 @@ export default function WebLivePage() { url: videoUrl, isLive: true, autoplay: true, + fullscreen: true, customType: { m3u8: m3u8Loader, flv: flvLoader From f31a9b0430aa703460c0720e54fc4fc9226b720a Mon Sep 17 00:00:00 2001 From: mtvpls Date: Tue, 20 Jan 2026 15:14:48 +0800 Subject: [PATCH 08/10] =?UTF-8?q?=E6=9B=B4=E6=8D=A2=E7=9B=B4=E6=92=AD?= =?UTF-8?q?=E5=9B=BE=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/admin/page.tsx | 3 ++- src/components/MobileBottomNav.tsx | 10 +++++----- src/components/Sidebar.tsx | 10 +++++----- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 9252ef8..57757b2 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -33,6 +33,7 @@ import { ExternalLink, FileText, FolderOpen, + Globe, Mail, Palette, Settings, @@ -11318,7 +11319,7 @@ function AdminPageClient() { + } isExpanded={expandedTabs.webLive} onToggle={() => toggleTab('webLive')} diff --git a/src/components/MobileBottomNav.tsx b/src/components/MobileBottomNav.tsx index ef4a440..37f6b6a 100644 --- a/src/components/MobileBottomNav.tsx +++ b/src/components/MobileBottomNav.tsx @@ -2,7 +2,7 @@ 'use client'; -import { Cat, Clover, Film, FolderOpen, Home, Radio, Star, Tv, Users } from 'lucide-react'; +import { Cat, Clover, Film, FolderOpen, Globe, Home, Star, Tv, Users } from 'lucide-react'; import Link from 'next/link'; import { usePathname, useSearchParams } from 'next/navigation'; import { useEffect, useState } from 'react'; @@ -51,12 +51,12 @@ const MobileBottomNav = ({ activePath }: MobileBottomNavProps) => { href: '/douban?type=show', }, { - icon: Radio, + icon: Tv, label: '电视直播', href: '/live', }, { - icon: Radio, + icon: Globe, label: '网络直播', href: '/web-live', }, @@ -89,12 +89,12 @@ const MobileBottomNav = ({ activePath }: MobileBottomNavProps) => { href: '/douban?type=show', }, { - icon: Radio, + icon: Tv, label: '电视直播', href: '/live', }, { - icon: Radio, + icon: Globe, label: '网络直播', href: '/web-live', }, diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 85ff0d7..afad341 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -2,7 +2,7 @@ 'use client'; -import { Cat, Clover, Film, FolderOpen, Home, Menu, Radio, Search, Star, Tv, Users } from 'lucide-react'; +import { Cat, Clover, Film, FolderOpen, Globe, Home, Menu, Search, Star, Tv, Users } from 'lucide-react'; import Link from 'next/link'; import { usePathname, useSearchParams } from 'next/navigation'; import { @@ -143,12 +143,12 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => { href: '/douban?type=show', }, { - icon: Radio, + icon: Tv, label: '电视直播', href: '/live', }, { - icon: Radio, + icon: Globe, label: '网络直播', href: '/web-live', }, @@ -180,12 +180,12 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => { href: '/douban?type=show', }, { - icon: Radio, + icon: Tv, label: '电视直播', href: '/live', }, { - icon: Radio, + icon: Globe, label: '网络直播', href: '/web-live', }, From 037a7e72497d9e27c3a5ff10d4191b0133aecdc8 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Tue, 20 Jan 2026 15:43:21 +0800 Subject: [PATCH 09/10] =?UTF-8?q?weblive=E5=A4=96=E9=83=A8=E6=92=AD?= =?UTF-8?q?=E6=94=BE=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/admin/web-live/route.ts | 2 +- src/app/web-live/page.tsx | 189 +++++++++++++++++++++++++++- 2 files changed, 188 insertions(+), 3 deletions(-) diff --git a/src/app/api/admin/web-live/route.ts b/src/app/api/admin/web-live/route.ts index 374f473..9464bbb 100644 --- a/src/app/api/admin/web-live/route.ts +++ b/src/app/api/admin/web-live/route.ts @@ -104,7 +104,7 @@ export async function POST(request: NextRequest) { } const sortedSources = keys .map((key) => config.WebLiveConfig!.find((s) => s.key === key)) - .filter((s) => s !== undefined); + .filter((s): s is NonNullable => s !== undefined); config.WebLiveConfig = sortedSources; break; } diff --git a/src/app/web-live/page.tsx b/src/app/web-live/page.tsx index 69460cc..eef87dc 100644 --- a/src/app/web-live/page.tsx +++ b/src/app/web-live/page.tsx @@ -131,6 +131,13 @@ export default function WebLivePage() { } }; + const getRoomUrl = (source: any) => { + if (source.platform === 'huya') { + return `https://huya.com/${source.roomId}`; + } + return ''; + }; + const platforms = Array.from(new Set(sources.map(s => s.platform))); if (loading) { @@ -273,6 +280,180 @@ export default function WebLivePage() {
)}
+ + {/* 外部播放器按钮 */} + {currentSource && ( +
+
+
+ {/* 网页播放 */} + + + {/* PotPlayer */} + + + {/* VLC */} + + + {/* MPV */} + + + {/* MX Player */} + + + {/* nPlayer */} + + + {/* IINA */} + +
+
+
+ )}
@@ -332,8 +513,12 @@ export default function WebLivePage() { {platforms.length > 0 ? ( platforms.map((platform) => (
-
- +
+ {platform === 'huya' ? ( + 虎牙 + ) : ( + + )}
From 6cdef3f7e26fe65aa8583cd3673730e58fcf083e Mon Sep 17 00:00:00 2001 From: mtvpls Date: Tue, 20 Jan 2026 19:27:00 +0800 Subject: [PATCH 10/10] =?UTF-8?q?=E7=BD=91=E7=BB=9C=E7=9B=B4=E6=92=AD?= =?UTF-8?q?=E6=BA=90=E9=85=8D=E7=BD=AE=E5=88=97=E8=A1=A8=EF=BC=8C=E7=A7=BB?= =?UTF-8?q?=E5=8A=A8=E7=AB=AF=E9=80=82=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/admin/page.tsx | 77 ++++++++++++++++++++++-------------------- 1 file changed, 41 insertions(+), 36 deletions(-) diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 57757b2..570ca4b 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -10908,50 +10908,55 @@ const WebLiveConfig = ({ - - - - - + + + + + {webLiveSources.map((source) => ( - - - - + + + - ))}
名称直播类型房间ID状态操作名称直播类型房间ID状态操作
{source.name}{source.platform === 'huya' ? '虎牙' : source.platform}{source.roomId} - + +
{source.name}
+
{source.platform === 'huya' ? '虎牙' : source.platform} · {source.roomId}
+
{source.platform === 'huya' ? '虎牙' : source.platform}{source.roomId} + {!source.disabled ? '启用中' : '已禁用'} - - {source.from !== 'config' && ( - <> - - - - )} + +
+ + {source.from !== 'config' && ( + <> + + + + )} +