feat: consolidate on the shadcn frontend and harden playback
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]>
This commit is contained in:
1 parent
186e32caf1
commit
996e3fea39
70 files changed
+4844
-3070
No files matched your search
+72
-61
@@ -1,24 +1,23 @@
|
||||
import { Component, lazy, Suspense, useState, useEffect, type ErrorInfo, type ReactNode } from 'react'
|
||||
import { BrowserRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom'
|
||||
import { Button, ConfigProvider, Result } from 'antd'
|
||||
import zhCN from 'antd/locale/zh_CN'
|
||||
import theme from './theme/anthropic'
|
||||
import './pageTransitions.css'
|
||||
import { BrandProvider, useBrand } from './brand/BrandContext'
|
||||
import { verifyToken } from './api/auth'
|
||||
import { getToken } from './api/client'
|
||||
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 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 }
|
||||
@@ -34,24 +33,26 @@ class PageErrorBoundary extends Component<{ children: ReactNode }, { failed: boo
|
||||
render() {
|
||||
if (this.state.failed) {
|
||||
return (
|
||||
<Result
|
||||
status="error"
|
||||
title="页面加载失败"
|
||||
subTitle="页面遇到异常,请重新加载后再试。"
|
||||
extra={<Button type="primary" onClick={() => window.location.reload()}>重新加载</Button>}
|
||||
/>
|
||||
<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: React.ReactNode }) {
|
||||
function AdminGuard({ children }: { children: ReactNode }) {
|
||||
const [authed, setAuthed] = useState<boolean | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const token = getToken()
|
||||
if (!token) { setAuthed(false); return }
|
||||
if (!token) {
|
||||
setAuthed(false)
|
||||
return
|
||||
}
|
||||
verifyToken().then(setAuthed)
|
||||
}, [])
|
||||
|
||||
@@ -60,45 +61,55 @@ function AdminGuard({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
function AppRoutes() {
|
||||
const location = useLocation()
|
||||
const routeTransitionKey = location.pathname.startsWith('/admin') ? '/admin' : location.pathname
|
||||
function RouteShell() {
|
||||
const { brand } = useBrand()
|
||||
useEffect(() => { document.title = brand.brand_name }, [brand.brand_name])
|
||||
useEffect(() => {
|
||||
document.title = brand.brand_name
|
||||
}, [brand.brand_name])
|
||||
|
||||
return (
|
||||
<PageErrorBoundary>
|
||||
<Suspense fallback={<div className="route-loading" role="status" aria-label="页面加载中"><span /></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>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</PageErrorBoundary>
|
||||
<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 (
|
||||
<ConfigProvider theme={theme} locale={zhCN}>
|
||||
<BrandProvider>
|
||||
<BrowserRouter>
|
||||
<AppRoutes />
|
||||
<BrandProvider>
|
||||
<BrowserRouter>
|
||||
<RouteShell />
|
||||
<Toaster position="top-right" richColors closeButton />
|
||||
</BrowserRouter>
|
||||
</BrandProvider>
|
||||
</ConfigProvider>
|
||||
</BrandProvider>
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user