修复观看页白屏与异常状态
This commit is contained in:
1 parent
e83b56b22f
commit
229a8d0791
2 files changed
+132
-51
No files matched your search
+34
-6
@@ -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,8 +63,9 @@ function AppRoutes() {
|
|||||||
useEffect(() => { document.title = brand.brand_name }, [brand.brand_name])
|
useEffect(() => { document.title = brand.brand_name }, [brand.brand_name])
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Suspense fallback={<div className="route-loading" role="status" aria-label="页面加载中" />}>
|
<PageErrorBoundary>
|
||||||
<Routes>
|
<Suspense fallback={<div className="route-loading" role="status" aria-label="页面加载中" />}>
|
||||||
|
<Routes>
|
||||||
<Route path="/watch/:id" element={<WatchPage />} />
|
<Route path="/watch/:id" element={<WatchPage />} />
|
||||||
<Route path="/admin/login" element={<LoginPage />} />
|
<Route path="/admin/login" element={<LoginPage />} />
|
||||||
<Route path="/admin" element={
|
<Route path="/admin" element={
|
||||||
@@ -54,8 +81,9 @@ function AppRoutes() {
|
|||||||
<Route path="settings" element={<SettingsPage />} />
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/" element={<HomePage />} />
|
<Route path="/" element={<HomePage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
</PageErrorBoundary>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
hlsRef.current?.destroy()
|
setPlaybackError('')
|
||||||
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
|
|
||||||
hlsRef.current?.destroy()
|
hlsRef.current?.destroy()
|
||||||
hlsRef.current = null
|
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()) {
|
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">
|
||||||
|
|||||||
Reference in new issue
Block a user