Frontend
- Replace the Ant Design app with the shadcn/ui app as the single frontend,
keeping the portfolio side-by-side revertible in this commit.
- Drop the abandoned mdui experiment and the AntD tree so there is one app,
one router and one design system to maintain.
Live playback
- Stop racing the encoder. lowLatencyMode + liveSyncDurationCount:1 targeted a
single 4s segment, so playback ran at up to 1.10x and the forward buffer
bottomed out at 0.1s, re-buffering every segment. Measured over 40s:
buffer min 0.1s -> 8.9s, rate 1.00 constant, 0 stalls.
- Remove the 落后 labels and the page-level edge-snapping timer that fought
hls.js over two different definitions of "latency".
- Live can no longer be paused: the control is not rendered while it plays, so
the icon can never misreport state.
- Fix autoplay: muted autoplay started silently and never unmuted, and the
gesture that restored sound also toggled playback off. One owner now.
Source ("原画") rendition
- Publish the publisher's stream remuxed losslessly via -c:v copy alongside the
transcoded ones, so a source option exists even when every ladder entry
downsizes.
- Give renditions an ASCII path slug: the HLS path is part of the URL and
MediaMTX rejects non-alphanumeric paths, so 原画 could never be served.
Backend
- Supervise MediaMTX from the backend: start it at boot and restart it on exit,
never killing an origin it did not start.
- Clear stale HLS output before restarting an encoder; an ENDLIST-less playlist
left by a dead encoder hangs hls.js forever.
- Mark JSON timestamps as UTC. utcnow().isoformat() emitted a naive string that
JS read as local time, so every viewer heartbeat looked 8 hours old in UTC+8
and the live board's active-viewer count was permanently zero.
- Rebind a viewer session when it arrives on another stream instead of failing,
and survive the StrictMode double-heartbeat race on first insert.
- Replace the Linux-only /proc and os.getloadavg metrics with psutil.
- Complete MediaMTX's cookieCheck redirect inside /hls proxying, and hold the
httpx client open for the streamed body.
Housekeeping
- Ignore RainOSS (it stores accessKeySecret), runtime output, TLS keys and
.venv; remove generated screenshots, load test scripts and stray files.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
116 lines
3.9 KiB
TypeScript
116 lines
3.9 KiB
TypeScript
import { Component, lazy, Suspense, useEffect, useState, type ErrorInfo, type ReactNode } from 'react'
|
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
|
import { Toaster } from 'sonner'
|
|
import { Button } from '@/components/ui/button'
|
|
import { BrandProvider, useBrand } from '@/brand/BrandContext'
|
|
import { verifyToken } from '@/api/auth'
|
|
import { getToken } from '@/api/client'
|
|
|
|
const HomePage = lazy(() => import('@/viewer/pages/HomePage'))
|
|
const WatchPage = lazy(() => import('@/viewer/pages/WatchPage'))
|
|
const AdminLayout = lazy(() => import('@/admin/AdminLayout'))
|
|
const LoginPage = lazy(() => import('@/admin/LoginPage'))
|
|
const Dashboard = lazy(() => import('@/admin/pages/Dashboard'))
|
|
const LiveBoard = lazy(() => import('@/admin/pages/LiveBoard'))
|
|
const StreamList = lazy(() => import('@/admin/pages/StreamList'))
|
|
const StreamForm = lazy(() => import('@/admin/pages/StreamForm'))
|
|
const ReplayEditor = lazy(() => import('@/admin/pages/ReplayEditor'))
|
|
const NodeManager = lazy(() => import('@/admin/pages/NodeManager'))
|
|
const SettingsPage = lazy(() => import('@/admin/pages/Settings'))
|
|
const SwaggerPage = lazy(() => import('@/admin/pages/SwaggerPage'))
|
|
|
|
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 (
|
|
<div className="flex min-h-screen flex-col items-center justify-center gap-4 p-8">
|
|
<h1 className="text-2xl font-semibold">页面加载失败</h1>
|
|
<p className="text-muted-foreground">页面遇到异常,请重新加载后再试。</p>
|
|
<Button onClick={() => window.location.reload()}>重新加载</Button>
|
|
</div>
|
|
)
|
|
}
|
|
return this.props.children
|
|
}
|
|
}
|
|
|
|
function AdminGuard({ children }: { children: ReactNode }) {
|
|
const [authed, setAuthed] = useState<boolean | null>(null)
|
|
|
|
useEffect(() => {
|
|
const token = getToken()
|
|
if (!token) {
|
|
setAuthed(false)
|
|
return
|
|
}
|
|
verifyToken().then(setAuthed)
|
|
}, [])
|
|
|
|
if (authed === null) return null
|
|
if (!authed) return <Navigate to="/admin/login" replace />
|
|
return <>{children}</>
|
|
}
|
|
|
|
function RouteShell() {
|
|
const { brand } = useBrand()
|
|
useEffect(() => {
|
|
document.title = brand.brand_name
|
|
}, [brand.brand_name])
|
|
|
|
return (
|
|
<PageErrorBoundary>
|
|
<Suspense
|
|
fallback={
|
|
<div className="flex min-h-screen items-center justify-center">
|
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
|
</div>
|
|
}
|
|
>
|
|
<Routes>
|
|
<Route path="/watch/:id" element={<WatchPage />} />
|
|
<Route path="/admin/login" element={<LoginPage />} />
|
|
<Route
|
|
path="/admin"
|
|
element={
|
|
<AdminGuard>
|
|
<AdminLayout />
|
|
</AdminGuard>
|
|
}
|
|
>
|
|
<Route index element={<Dashboard />} />
|
|
<Route path="board" element={<LiveBoard />} />
|
|
<Route path="streams" element={<StreamList />} />
|
|
<Route path="streams/new" element={<StreamForm />} />
|
|
<Route path="streams/:id/edit" element={<ReplayEditor />} />
|
|
<Route path="nodes" element={<NodeManager />} />
|
|
<Route path="settings" element={<SettingsPage />} />
|
|
<Route path="swagger" element={<SwaggerPage />} />
|
|
</Route>
|
|
<Route path="/" element={<HomePage />} />
|
|
</Routes>
|
|
</Suspense>
|
|
</PageErrorBoundary>
|
|
)
|
|
}
|
|
|
|
export default function App() {
|
|
return (
|
|
<BrandProvider>
|
|
<BrowserRouter>
|
|
<RouteShell />
|
|
<Toaster position="top-right" richColors closeButton />
|
|
</BrowserRouter>
|
|
</BrandProvider>
|
|
)
|
|
}
|