From 229a8d079174767efc1c6e582db1fab454a8f1ca Mon Sep 17 00:00:00 2001 From: admin Date: Mon, 17 Aug 2026 16:39:50 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=A7=82=E7=9C=8B=E9=A1=B5?= =?UTF-8?q?=E7=99=BD=E5=B1=8F=E4=B8=8E=E5=BC=82=E5=B8=B8=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.tsx | 40 ++++++- frontend/src/viewer/pages/WatchPage.tsx | 143 ++++++++++++++++-------- 2 files changed, 132 insertions(+), 51 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 51fb299..7a30e8d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,6 @@ -import { lazy, Suspense, useState, useEffect } from 'react' +import { Component, lazy, Suspense, useState, useEffect, type ErrorInfo, type ReactNode } from 'react' import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' -import { ConfigProvider } from 'antd' +import { Button, ConfigProvider, Result } from 'antd' import zhCN from 'antd/locale/zh_CN' import theme from './theme/anthropic' import { BrandProvider, useBrand } from './brand/BrandContext' @@ -18,6 +18,32 @@ const ReplayEditor = lazy(() => import('./admin/pages/ReplayEditor')) const NodeManager = lazy(() => import('./admin/pages/NodeManager')) const SettingsPage = lazy(() => import('./admin/pages/Settings')) +class PageErrorBoundary extends Component<{ children: ReactNode }, { failed: boolean }> { + state = { failed: false } + + static getDerivedStateFromError() { + return { failed: true } + } + + componentDidCatch(error: Error, info: ErrorInfo) { + console.error('Page render failed', error, info) + } + + render() { + if (this.state.failed) { + return ( + window.location.reload()}>重新加载} + /> + ) + } + return this.props.children + } +} + function AdminGuard({ children }: { children: React.ReactNode }) { const [authed, setAuthed] = useState(null) @@ -37,8 +63,9 @@ function AppRoutes() { useEffect(() => { document.title = brand.brand_name }, [brand.brand_name]) return ( - }> - + + }> + } /> } /> } /> } /> - - + + + ) } diff --git a/frontend/src/viewer/pages/WatchPage.tsx b/frontend/src/viewer/pages/WatchPage.tsx index 81fddb7..0bf947f 100644 --- a/frontend/src/viewer/pages/WatchPage.tsx +++ b/frontend/src/viewer/pages/WatchPage.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef } from 'react' import { useParams, useNavigate } from 'react-router-dom' -import { Select, Button, Typography, Tag, Spin, Empty, message } from 'antd' +import { Select, Button, Typography, Tag, Spin, Empty, Alert } from 'antd' import { ArrowLeftOutlined, WifiOutlined, SignalFilled } from '@ant-design/icons' import Hls from 'hls.js' import { getStreamStatusUrl, getWatchUrls, type WatchInfo, type LineInfo } from '../../api/streams' @@ -16,26 +16,67 @@ export default function WatchPage() { const videoRef = useRef(null) const [info, setInfo] = useState(null) const [loading, setLoading] = useState(true) + const [loadError, setLoadError] = useState('') + const [playbackError, setPlaybackError] = useState('') const [activeLine, setActiveLine] = useState('') const [activeQuality, setActiveQuality] = useState('') const hlsRef = useRef(null) const currentLine = info?.lines.find(line => line.node_id === activeLine) useEffect(() => { - if (!id) return + const streamId = Number(id) + if (!Number.isInteger(streamId) || streamId <= 0) { + setLoading(false) + setLoadError('节目地址无效') + return + } let pollTimer: ReturnType | undefined let disposed = false - const startPolling = () => { - if (pollTimer || disposed) return - loadInfo() - pollTimer = setInterval(loadInfo, 5000) + const load = async () => { + try { + const data = await getWatchUrls(streamId) + if (disposed) return + const next = { + ...data, + lines: Array.isArray(data.lines) ? data.lines : [], + qualities: Array.isArray(data.qualities) ? data.qualities : [], + chapters: Array.isArray(data.chapters) ? data.chapters : [], + } + setInfo(next) + setLoadError('') + setActiveLine(current => { + if (current && next.lines.some(line => line.node_id === current)) return current + return next.lines.length > 0 ? autoSelectLine(next.lines).node_id : '' + }) + setActiveQuality(current => { + if (current && next.qualities.some(quality => quality.label === current)) return current + return next.qualities[0]?.label ?? '' + }) + } catch { + if (!disposed) { + setInfo(null) + setLoadError('无法加载节目,请稍后重试') + } + } finally { + if (!disposed) setLoading(false) + } } - loadInfo() - const events = new EventSource(getStreamStatusUrl(Number(id))) - events.addEventListener('status', loadInfo) - events.addEventListener('deleted', () => setInfo(null)) + const startPolling = () => { + if (pollTimer || disposed) return + void load() + pollTimer = setInterval(load, 5000) + } + + void load() + const events = new EventSource(getStreamStatusUrl(streamId)) + events.addEventListener('status', load) + events.addEventListener('deleted', () => { + setInfo(null) + setLoadError('该节目已不存在') + setLoading(false) + }) events.onerror = () => { events.close() startPolling() @@ -47,20 +88,6 @@ export default function WatchPage() { } }, [id]) - async function loadInfo() { - if (!id) return - try { - const data = await getWatchUrls(parseInt(id)) - setInfo(data) - if (!activeLine && data.lines.length > 0) { - const best = autoSelectLine(data.lines) - setActiveLine(best.node_id) - if (data.qualities.length > 0) setActiveQuality(data.qualities[0].label) - } - } catch { } - setLoading(false) - } - function autoSelectLine(lines: LineInfo[]): LineInfo { const enabled = lines.filter(l => l.weight > 0) if (enabled.length === 0) return lines[0] @@ -71,28 +98,44 @@ export default function WatchPage() { } useEffect(() => { - if (!info || !videoRef.current) return - if (info.replay_url && info.status !== 'live') { - hlsRef.current?.destroy() - hlsRef.current = null - videoRef.current.src = info.replay_url - return - } - if (!activeLine) return - const line = info.lines.find(l => l.node_id === activeLine) - if (!line) return - const quality = activeQuality ? line.qualities.find(q => q.label === activeQuality) : line.qualities[0] - if (!quality) return + const video = videoRef.current + if (!info || !video) return + setPlaybackError('') hlsRef.current?.destroy() hlsRef.current = null + video.removeAttribute('src') + video.load() + if (info.replay_url && info.status !== 'live') { + video.src = info.replay_url + video.load() + return + } + if (info.status !== 'live' || !activeLine) return + const line = info.lines.find(l => l.node_id === activeLine) + if (!line) { + setPlaybackError('所选播放线路不可用') + return + } + const quality = activeQuality ? line.qualities?.find(q => q.label === activeQuality) : line.qualities?.[0] + const source = quality?.url || line.url || line.unicast_fallback + if (!source) { + setPlaybackError('当前节目没有可用的播放地址') + return + } if (Hls.isSupported()) { const hi = new Hls({ enableWorker: false, lowLatencyMode: false }) - hi.loadSource(quality.url); hi.attachMedia(videoRef.current!) - hi.on(Hls.Events.MANIFEST_PARSED, () => { videoRef.current?.play().catch(() => {}) }) - hi.on(Hls.Events.ERROR, (_e, d) => { if (d.fatal) message.warning('播放异常,尝试切换线路...') }) + hi.loadSource(source) + hi.attachMedia(video) + hi.on(Hls.Events.MANIFEST_PARSED, () => { video.play().catch(() => {}) }) + hi.on(Hls.Events.ERROR, (_e, detail) => { + if (detail.fatal) setPlaybackError('播放流加载失败,请尝试切换线路或稍后重试') + }) hlsRef.current = hi - } else if (videoRef.current!.canPlayType('application/vnd.apple.mpegurl')) { - videoRef.current!.src = quality.url + } else if (video.canPlayType('application/vnd.apple.mpegurl')) { + video.src = source + video.load() + } else { + setPlaybackError('当前浏览器不支持此播放格式') } return () => { hlsRef.current?.destroy() @@ -115,7 +158,9 @@ export default function WatchPage() { ) if (!info) return (
- + + +
) @@ -152,12 +197,20 @@ export default function WatchPage() {
{(info.description || info.chapters.length > 0) && (