修复观看页白屏与异常状态

This commit is contained in:
admin committed 2026-08-17 16:39:50 +08:00
1 parent e83b56b22f
commit 229a8d0791
2 files changed
+125 -44

No files matched your search

+30 -2
View File
@@ -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 { 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 zhCN from 'antd/locale/zh_CN'
import theme from './theme/anthropic' import theme from './theme/anthropic'
import { BrandProvider, useBrand } from './brand/BrandContext' 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 NodeManager = lazy(() => import('./admin/pages/NodeManager'))
const SettingsPage = lazy(() => import('./admin/pages/Settings')) 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 (
<Result
status="error"
title="页面加载失败"
subTitle="页面遇到异常,请重新加载后再试。"
extra={<Button type="primary" onClick={() => window.location.reload()}>重新加载</Button>}
/>
)
}
return this.props.children
}
}
function AdminGuard({ children }: { children: React.ReactNode }) { function AdminGuard({ children }: { children: React.ReactNode }) {
const [authed, setAuthed] = useState<boolean | null>(null) const [authed, setAuthed] = useState<boolean | null>(null)
@@ -37,6 +63,7 @@ function AppRoutes() {
useEffect(() => { document.title = brand.brand_name }, [brand.brand_name]) useEffect(() => { document.title = brand.brand_name }, [brand.brand_name])
return ( return (
<BrowserRouter> <BrowserRouter>
<PageErrorBoundary>
<Suspense fallback={<div className="route-loading" role="status" aria-label="页面加载中" />}> <Suspense fallback={<div className="route-loading" role="status" aria-label="页面加载中" />}>
<Routes> <Routes>
<Route path="/watch/:id" element={<WatchPage />} /> <Route path="/watch/:id" element={<WatchPage />} />
@@ -56,6 +83,7 @@ function AppRoutes() {
<Route path="/" element={<HomePage />} /> <Route path="/" element={<HomePage />} />
</Routes> </Routes>
</Suspense> </Suspense>
</PageErrorBoundary>
</BrowserRouter> </BrowserRouter>
) )
} }
+95 -42
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { useParams, useNavigate } from 'react-router-dom' 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 { ArrowLeftOutlined, WifiOutlined, SignalFilled } from '@ant-design/icons'
import Hls from 'hls.js' import Hls from 'hls.js'
import { getStreamStatusUrl, getWatchUrls, type WatchInfo, type LineInfo } from '../../api/streams' import { getStreamStatusUrl, getWatchUrls, type WatchInfo, type LineInfo } from '../../api/streams'
@@ -16,26 +16,67 @@ export default function WatchPage() {
const videoRef = useRef<HTMLVideoElement>(null) const videoRef = useRef<HTMLVideoElement>(null)
const [info, setInfo] = useState<WatchInfo | null>(null) const [info, setInfo] = useState<WatchInfo | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState('')
const [playbackError, setPlaybackError] = useState('')
const [activeLine, setActiveLine] = useState<string>('') const [activeLine, setActiveLine] = useState<string>('')
const [activeQuality, setActiveQuality] = useState<string>('') const [activeQuality, setActiveQuality] = useState<string>('')
const hlsRef = useRef<Hls | null>(null) const hlsRef = useRef<Hls | null>(null)
const currentLine = info?.lines.find(line => line.node_id === activeLine) const currentLine = info?.lines.find(line => line.node_id === activeLine)
useEffect(() => { useEffect(() => {
if (!id) return const streamId = Number(id)
if (!Number.isInteger(streamId) || streamId <= 0) {
setLoading(false)
setLoadError('节目地址无效')
return
}
let pollTimer: ReturnType<typeof setInterval> | undefined let pollTimer: ReturnType<typeof setInterval> | undefined
let disposed = false let disposed = false
const startPolling = () => { const load = async () => {
if (pollTimer || disposed) return try {
loadInfo() const data = await getWatchUrls(streamId)
pollTimer = setInterval(loadInfo, 5000) 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 startPolling = () => {
const events = new EventSource(getStreamStatusUrl(Number(id))) if (pollTimer || disposed) return
events.addEventListener('status', loadInfo) void load()
events.addEventListener('deleted', () => setInfo(null)) 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.onerror = () => {
events.close() events.close()
startPolling() startPolling()
@@ -47,20 +88,6 @@ export default function WatchPage() {
} }
}, [id]) }, [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 { function autoSelectLine(lines: LineInfo[]): LineInfo {
const enabled = lines.filter(l => l.weight > 0) const enabled = lines.filter(l => l.weight > 0)
if (enabled.length === 0) return lines[0] if (enabled.length === 0) return lines[0]
@@ -71,28 +98,44 @@ export default function WatchPage() {
} }
useEffect(() => { useEffect(() => {
if (!info || !videoRef.current) return const video = videoRef.current
if (info.replay_url && info.status !== 'live') { if (!info || !video) return
setPlaybackError('')
hlsRef.current?.destroy() hlsRef.current?.destroy()
hlsRef.current = null hlsRef.current = null
videoRef.current.src = info.replay_url video.removeAttribute('src')
video.load()
if (info.replay_url && info.status !== 'live') {
video.src = info.replay_url
video.load()
return return
} }
if (!activeLine) return if (info.status !== 'live' || !activeLine) return
const line = info.lines.find(l => l.node_id === activeLine) const line = info.lines.find(l => l.node_id === activeLine)
if (!line) return if (!line) {
const quality = activeQuality ? line.qualities.find(q => q.label === activeQuality) : line.qualities[0] setPlaybackError('所选播放线路不可用')
if (!quality) return return
hlsRef.current?.destroy() }
hlsRef.current = null 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()) { if (Hls.isSupported()) {
const hi = new Hls({ enableWorker: false, lowLatencyMode: false }) const hi = new Hls({ enableWorker: false, lowLatencyMode: false })
hi.loadSource(quality.url); hi.attachMedia(videoRef.current!) hi.loadSource(source)
hi.on(Hls.Events.MANIFEST_PARSED, () => { videoRef.current?.play().catch(() => {}) }) hi.attachMedia(video)
hi.on(Hls.Events.ERROR, (_e, d) => { if (d.fatal) message.warning('播放异常,尝试切换线路...') }) hi.on(Hls.Events.MANIFEST_PARSED, () => { video.play().catch(() => {}) })
hi.on(Hls.Events.ERROR, (_e, detail) => {
if (detail.fatal) setPlaybackError('播放流加载失败,请尝试切换线路或稍后重试')
})
hlsRef.current = hi hlsRef.current = hi
} else if (videoRef.current!.canPlayType('application/vnd.apple.mpegurl')) { } else if (video.canPlayType('application/vnd.apple.mpegurl')) {
videoRef.current!.src = quality.url video.src = source
video.load()
} else {
setPlaybackError('当前浏览器不支持此播放格式')
} }
return () => { return () => {
hlsRef.current?.destroy() hlsRef.current?.destroy()
@@ -115,7 +158,9 @@ export default function WatchPage() {
) )
if (!info) return ( if (!info) return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#F9F7F4' }}> <div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#F9F7F4' }}>
<Empty description="未找到该直播" /> <Empty description={loadError || '未找到该直播'}>
<Button onClick={() => window.location.reload()}>重新加载</Button>
</Empty>
</div> </div>
) )
@@ -152,12 +197,20 @@ export default function WatchPage() {
<div className="watch-content"> <div className="watch-content">
<div className="watch-frame"> <div className="watch-frame">
<video ref={videoRef} controls autoPlay playsInline className="watch-video" /> <video ref={videoRef} controls autoPlay playsInline className="watch-video" />
{info.status !== 'live' && !info.replay_url && ( {playbackError && (
<div className="watch-waiting"> <div className="watch-waiting">
<Spin size="large" /> <Alert type="error" showIcon message={playbackError} />
<Text>等待主播开播...</Text>
</div> </div>
)} )}
{!playbackError && info.status !== 'live' && !info.replay_url && (
<div className="watch-waiting">
{info.status === 'pending' && <Spin size="large" />}
<Text>{info.status === 'ended' ? '节目已结束,暂无回放' : '等待主播开播...'}</Text>
</div>
)}
{!playbackError && info.status === 'live' && info.lines.length === 0 && (
<div className="watch-waiting"><Text>直播中,暂无可用播放线路</Text></div>
)}
</div> </div>
{(info.description || info.chapters.length > 0) && ( {(info.description || info.chapters.length > 0) && (
<aside className="watch-details"> <aside className="watch-details">