修复观看页白屏与异常状态
This commit is contained in:
1 parent
e83b56b22f
commit
229a8d0791
2 files changed
+125
-44
No files matched your search
+30
-2
@@ -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 (
|
||||
<Result
|
||||
status="error"
|
||||
title="页面加载失败"
|
||||
subTitle="页面遇到异常,请重新加载后再试。"
|
||||
extra={<Button type="primary" onClick={() => window.location.reload()}>重新加载</Button>}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
function AdminGuard({ children }: { children: React.ReactNode }) {
|
||||
const [authed, setAuthed] = useState<boolean | null>(null)
|
||||
|
||||
@@ -37,6 +63,7 @@ function AppRoutes() {
|
||||
useEffect(() => { document.title = brand.brand_name }, [brand.brand_name])
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<PageErrorBoundary>
|
||||
<Suspense fallback={<div className="route-loading" role="status" aria-label="页面加载中" />}>
|
||||
<Routes>
|
||||
<Route path="/watch/:id" element={<WatchPage />} />
|
||||
@@ -56,6 +83,7 @@ function AppRoutes() {
|
||||
<Route path="/" element={<HomePage />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</PageErrorBoundary>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<HTMLVideoElement>(null)
|
||||
const [info, setInfo] = useState<WatchInfo | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadError, setLoadError] = useState('')
|
||||
const [playbackError, setPlaybackError] = useState('')
|
||||
const [activeLine, setActiveLine] = useState<string>('')
|
||||
const [activeQuality, setActiveQuality] = useState<string>('')
|
||||
const hlsRef = useRef<Hls | null>(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<typeof setInterval> | 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') {
|
||||
const video = videoRef.current
|
||||
if (!info || !video) return
|
||||
setPlaybackError('')
|
||||
hlsRef.current?.destroy()
|
||||
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
|
||||
}
|
||||
if (!activeLine) return
|
||||
if (info.status !== 'live' || !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
|
||||
hlsRef.current?.destroy()
|
||||
hlsRef.current = null
|
||||
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 (
|
||||
<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>
|
||||
)
|
||||
|
||||
@@ -152,12 +197,20 @@ export default function WatchPage() {
|
||||
<div className="watch-content">
|
||||
<div className="watch-frame">
|
||||
<video ref={videoRef} controls autoPlay playsInline className="watch-video" />
|
||||
{info.status !== 'live' && !info.replay_url && (
|
||||
{playbackError && (
|
||||
<div className="watch-waiting">
|
||||
<Spin size="large" />
|
||||
<Text>等待主播开播...</Text>
|
||||
<Alert type="error" showIcon message={playbackError} />
|
||||
</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>
|
||||
{(info.description || info.chapters.length > 0) && (
|
||||
<aside className="watch-details">
|
||||
|
||||
Reference in new issue
Block a user