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:
adminandClaude Opus 4.8 committed 2026-09-22 18:07:56 +08:00
1 parent 186e32caf1
commit 996e3fea39
70 files changed
+4844 -3070

No files matched your search

+72 -61
View File
@@ -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>
)
}
}