feat: improve replay workflow and viewer UI

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
adminandClaude Fable 5 committed 2026-08-30 15:35:28 +08:00
1 parent 57b00affbd
commit 186e32caf1
28 files changed
+1495 -226

No files matched your search

+1
View File
@@ -17,6 +17,7 @@ class Settings(BaseSettings):
media_origin_api_port: int = 9997 media_origin_api_port: int = 9997
media_origin_hls_port: int = 8888 media_origin_hls_port: int = 8888
replay_media_dir: str = str(APP_DIR.parent / "media") replay_media_dir: str = str(APP_DIR.parent / "media")
hls_runtime_dir: str = str(APP_DIR.parent / "runtime" / "hls")
hls_segment_seconds: int = 4 hls_segment_seconds: int = 4
edge_nodes: list[dict] = [ edge_nodes: list[dict] = [
+1
View File
@@ -23,6 +23,7 @@ async def init_db():
if settings.database_url.startswith("sqlite"): if settings.database_url.startswith("sqlite"):
columns = { columns = {
"replay_file": "VARCHAR(500)", "replay_file": "VARCHAR(500)",
"replay_url": "VARCHAR(1000)",
"chapters_json": "TEXT", "chapters_json": "TEXT",
"transcode_profile": "VARCHAR(64) NOT NULL DEFAULT 'standard'", "transcode_profile": "VARCHAR(64) NOT NULL DEFAULT 'standard'",
"renditions_json": "TEXT", "renditions_json": "TEXT",
+14 -1
View File
@@ -9,8 +9,11 @@ import httpx
import asyncio import asyncio
import time import time
import psutil import psutil
from database import init_db from database import init_db, session_factory
from services.transcoder import transcode_mgr from services.transcoder import transcode_mgr
from services.stream_service import StreamService
from services.media_service import media_service
from models.stream import StreamStatus
from routes import streams_router, nodes_router, auth_router from routes import streams_router, nodes_router, auth_router
from routes.settings_route import router as settings_router from routes.settings_route import router as settings_router
from config import settings from config import settings
@@ -60,6 +63,16 @@ async def monitor_origin():
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
load_nodes() load_nodes()
await init_db() await init_db()
os.makedirs(settings.hls_runtime_dir, exist_ok=True)
async with session_factory() as db:
service = StreamService(db)
for stream in await service.get_all_streams():
if stream.status == StreamStatus.live:
transcode_mgr.start_transcode(
stream.stream_key,
media_service.get_rtmp_url(stream.stream_key),
service.effective_renditions(stream),
)
origin_monitor = asyncio.create_task(monitor_origin()) origin_monitor = asyncio.create_task(monitor_origin())
yield yield
origin_monitor.cancel() origin_monitor.cancel()
+1
View File
@@ -21,6 +21,7 @@ class Stream(Base):
description: Mapped[str | None] = mapped_column(Text, nullable=True) description: Mapped[str | None] = mapped_column(Text, nullable=True)
cover_url: Mapped[str | None] = mapped_column(String(500), nullable=True) cover_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
replay_file: Mapped[str | None] = mapped_column(String(500), nullable=True) replay_file: Mapped[str | None] = mapped_column(String(500), nullable=True)
replay_url: Mapped[str | None] = mapped_column(String(1000), nullable=True)
chapters_json: Mapped[str | None] = mapped_column(Text, nullable=True) chapters_json: Mapped[str | None] = mapped_column(Text, nullable=True)
transcode_profile: Mapped[str] = mapped_column(String(64), default="standard") transcode_profile: Mapped[str] = mapped_column(String(64), default="standard")
renditions_json: Mapped[str | None] = mapped_column(Text, nullable=True) renditions_json: Mapped[str | None] = mapped_column(Text, nullable=True)
+42 -10
View File
@@ -43,6 +43,7 @@ class StreamCreate(BaseModel):
cover_url: Optional[str] = None cover_url: Optional[str] = None
scheduled_at: Optional[str] = None scheduled_at: Optional[str] = None
replay_file: Optional[str] = None replay_file: Optional[str] = None
replay_url: Optional[str] = None
chapters: list[dict] = Field(default_factory=list) chapters: list[dict] = Field(default_factory=list)
transcode_profile: str = "standard" transcode_profile: str = "standard"
renditions: Optional[list[RenditionInput]] = None renditions: Optional[list[RenditionInput]] = None
@@ -56,6 +57,7 @@ class StreamUpdate(BaseModel):
cover_url: Optional[str] = None cover_url: Optional[str] = None
scheduled_at: Optional[str] = None scheduled_at: Optional[str] = None
replay_file: Optional[str] = None replay_file: Optional[str] = None
replay_url: Optional[str] = None
chapters: Optional[list[dict]] = None chapters: Optional[list[dict]] = None
transcode_profile: Optional[str] = None transcode_profile: Optional[str] = None
renditions: Optional[list[RenditionInput]] = None renditions: Optional[list[RenditionInput]] = None
@@ -108,11 +110,23 @@ def _media_path(filename: str | None) -> Path | None:
return candidate return candidate
def _valid_replay_url(url: str | None) -> str | None:
if not url:
return None
url = url.strip()
if not (url.startswith("http://") or url.startswith("https://")):
raise HTTPException(status_code=422, detail="回放 URL 必须是 http(s) 地址")
if " " in url or "\n" in url or "\r" in url:
raise HTTPException(status_code=422, detail="回放 URL 格式无效")
return url
def _status_payload(stream) -> dict: def _status_payload(stream) -> dict:
return { return {
"stream_id": stream.id, "stream_id": stream.id,
"status": stream.status.value, "status": stream.status.value,
"replay_url": f"/api/streams/{stream.id}/replay" if stream.replay_file else None, "replay_local_url": f"/api/streams/{stream.id}/replay" if stream.replay_file else None,
"replay_url": stream.replay_url,
"chapters": json.loads(stream.chapters_json or "[]"), "chapters": json.loads(stream.chapters_json or "[]"),
} }
@@ -169,6 +183,7 @@ def _serialize(s, include_private: bool = True):
"ended_at": str(s.ended_at) if s.ended_at else None, "ended_at": str(s.ended_at) if s.ended_at else None,
"multicast_enabled": s.multicast_enabled, "multicast_enabled": s.multicast_enabled,
"replay_file": s.replay_file, "replay_file": s.replay_file,
"replay_url": s.replay_url,
"chapters": json.loads(s.chapters_json or "[]"), "chapters": json.loads(s.chapters_json or "[]"),
"transcode_profile": s.transcode_profile, "transcode_profile": s.transcode_profile,
"renditions": json.loads(getattr(s, "renditions_json", None)) if getattr(s, "renditions_json", None) else None, "renditions": json.loads(getattr(s, "renditions_json", None)) if getattr(s, "renditions_json", None) else None,
@@ -317,6 +332,7 @@ async def create_stream(body: StreamCreate, db: AsyncSession = Depends(get_db),
if not title: if not title:
raise HTTPException(status_code=422, detail="节目标题不能为空") raise HTTPException(status_code=422, detail="节目标题不能为空")
_media_path(body.replay_file) _media_path(body.replay_file)
_valid_replay_url(body.replay_url)
if body.transcode_profile not in settings.transcode_profiles: if body.transcode_profile not in settings.transcode_profiles:
raise HTTPException(status_code=422, detail="转码配置不存在") raise HTTPException(status_code=422, detail="转码配置不存在")
renditions = _normalize_renditions([item.model_dump() for item in body.renditions]) if body.renditions is not None else None renditions = _normalize_renditions([item.model_dump() for item in body.renditions]) if body.renditions is not None else None
@@ -328,6 +344,7 @@ async def create_stream(body: StreamCreate, db: AsyncSession = Depends(get_db),
cover_url=body.cover_url, cover_url=body.cover_url,
scheduled_at=body.scheduled_at, scheduled_at=body.scheduled_at,
replay_file=body.replay_file, replay_file=body.replay_file,
replay_url=body.replay_url,
chapters=body.chapters, chapters=body.chapters,
transcode_profile=body.transcode_profile, transcode_profile=body.transcode_profile,
renditions_json=json.dumps(renditions, ensure_ascii=False) if renditions is not None else None, renditions_json=json.dumps(renditions, ensure_ascii=False) if renditions is not None else None,
@@ -365,20 +382,34 @@ async def update_stream(stream_id: int, body: StreamUpdate, db: AsyncSession = D
existing = await svc.get_stream(stream_id) existing = await svc.get_stream(stream_id)
if not existing: if not existing:
raise HTTPException(status_code=404, detail="Stream not found") raise HTTPException(status_code=404, detail="Stream not found")
updates = {k: v for k, v in body.model_dump().items() if v is not None} updates = body.model_dump(exclude_unset=True)
if ("replay_file" in updates or "chapters" in updates) and existing.status.value != "ended": replay_fields = {"replay_file", "replay_url", "chapters"}
if replay_fields.intersection(updates) and existing.status.value != "ended":
raise HTTPException(status_code=409, detail="仅已结束的节目可以配置回放和章节") raise HTTPException(status_code=409, detail="仅已结束的节目可以配置回放和章节")
if "replay_file" in updates:
if updates.get("replay_file"):
_media_path(updates["replay_file"]) _media_path(updates["replay_file"])
if "replay_url" in updates and updates["replay_url"] is not None:
updates["replay_url"] = _valid_replay_url(updates["replay_url"])
final_replay_file = updates.get("replay_file", existing.replay_file)
final_replay_url = updates.get("replay_url", existing.replay_url)
if final_replay_file and final_replay_url:
raise HTTPException(status_code=409, detail="本地回放文件和远端回放地址只能选择一种")
if "transcode_profile" in updates and updates["transcode_profile"] not in settings.transcode_profiles: if "transcode_profile" in updates and updates["transcode_profile"] not in settings.transcode_profiles:
raise HTTPException(status_code=422, detail="转码配置不存在") raise HTTPException(status_code=422, detail="转码配置不存在")
if "renditions" in updates: if "renditions" in updates:
updates["renditions_json"] = json.dumps(_normalize_renditions(updates.pop("renditions")), ensure_ascii=False) renditions = updates.pop("renditions")
updates["renditions_json"] = (
json.dumps(_normalize_renditions(renditions), ensure_ascii=False)
if renditions is not None else None
)
if "chapters" in updates: if "chapters" in updates:
if not updates.get("replay_file") and not existing.replay_file: chapters = updates.pop("chapters")
raise HTTPException(status_code=409, detail="请先选择回放文件再编辑章节") if chapters and not (final_replay_file or final_replay_url):
chapters = _normalize_chapters(updates.pop("chapters")) raise HTTPException(status_code=409, detail="请先配置回放来源再编辑章节")
updates["chapters_json"] = json.dumps(chapters, ensure_ascii=False) updates["chapters_json"] = json.dumps(_normalize_chapters(chapters or []), ensure_ascii=False)
if updates: if updates:
await svc.update_stream(stream_id, **updates) await svc.update_stream(stream_id, **updates)
return {"ok": True} return {"ok": True}
@@ -413,7 +444,8 @@ async def get_watch_urls(stream_id: int, request: Request, db: AsyncSession = De
"qualities": urls["qualities"], "qualities": urls["qualities"],
"description": stream.description, "description": stream.description,
"scheduled_at": str(stream.scheduled_at) if stream.scheduled_at else None, "scheduled_at": str(stream.scheduled_at) if stream.scheduled_at else None,
"replay_url": f"/api/streams/{stream.id}/replay" if stream.replay_file else None, "replay_local_url": f"/api/streams/{stream.id}/replay" if stream.replay_file else None,
"replay_url": stream.replay_url,
"chapters": json.loads(stream.chapters_json or "[]"), "chapters": json.loads(stream.chapters_json or "[]"),
"remote_control_enabled": stream.remote_control_enabled, "remote_control_enabled": stream.remote_control_enabled,
} }
+2
View File
@@ -29,6 +29,7 @@ class StreamService:
cover_url: str | None = None, cover_url: str | None = None,
scheduled_at: str | None = None, scheduled_at: str | None = None,
replay_file: str | None = None, replay_file: str | None = None,
replay_url: str | None = None,
chapters: list[dict] | None = None, chapters: list[dict] | None = None,
transcode_profile: str = "standard", transcode_profile: str = "standard",
homepage_visible: bool = True, homepage_visible: bool = True,
@@ -45,6 +46,7 @@ class StreamService:
description=description, description=description,
cover_url=cover_url, cover_url=cover_url,
replay_file=replay_file, replay_file=replay_file,
replay_url=replay_url,
chapters_json=json.dumps(chapters or [], ensure_ascii=False), chapters_json=json.dumps(chapters or [], ensure_ascii=False),
transcode_profile=transcode_profile, transcode_profile=transcode_profile,
homepage_visible=homepage_visible, homepage_visible=homepage_visible,
+16 -2
View File
@@ -49,7 +49,7 @@ class ReplayWorkflowTest(unittest.TestCase):
_normalize_chapters(chapters) _normalize_chapters(chapters)
def test_status_payload_exposes_pending_live_and_ended_updates(self): def test_status_payload_exposes_pending_live_and_ended_updates(self):
stream = SimpleNamespace(id=7, replay_file=None, chapters_json="[]") stream = SimpleNamespace(id=7, replay_file=None, replay_url=None, chapters_json="[]")
for status in ("pending", "live", "ended"): for status in ("pending", "live", "ended"):
stream.status = SimpleNamespace(value=status) stream.status = SimpleNamespace(value=status)
self.assertEqual(_status_payload(stream)["status"], status) self.assertEqual(_status_payload(stream)["status"], status)
@@ -57,5 +57,19 @@ class ReplayWorkflowTest(unittest.TestCase):
stream.replay_file = "session.mp4" stream.replay_file = "session.mp4"
stream.chapters_json = json.dumps([{"title": "Intro", "time": 0}]) stream.chapters_json = json.dumps([{"title": "Intro", "time": 0}])
payload = _status_payload(stream) payload = _status_payload(stream)
self.assertEqual(payload["replay_url"], "/api/streams/7/replay") self.assertEqual(payload["replay_local_url"], "/api/streams/7/replay")
self.assertIsNone(payload["replay_url"])
self.assertEqual(payload["chapters"], [{"title": "Intro", "time": 0}]) self.assertEqual(payload["chapters"], [{"title": "Intro", "time": 0}])
stream.replay_file = None
stream.replay_url = "https://example.com/vod/master.m3u8"
payload = _status_payload(stream)
self.assertIsNone(payload["replay_local_url"])
self.assertEqual(payload["replay_url"], "https://example.com/vod/master.m3u8")
def test_replay_url_rejects_non_http(self):
from routes.streams import _valid_replay_url
for unsafe in ("ftp://example.com/a.mp4", "file:///etc/passwd", "javascript:alert(1)"):
with self.subTest(unsafe=unsafe), self.assertRaises(HTTPException):
_valid_replay_url(unsafe)
self.assertEqual(_valid_replay_url(" https://cdn.example.com/a.mp4 "), "https://cdn.example.com/a.mp4")
Executable → Regular
View File
File mode changed.
Executable → Regular
View File
File mode changed.
+3 -1
View File
@@ -15,7 +15,9 @@
"hls.js": "^1.5.14", "hls.js": "^1.5.14",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^6.26.2" "react-markdown": "^10.1.0",
"react-router-dom": "^6.26.2",
"remark-gfm": "^4.0.1"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.3.8", "@types/react": "^18.3.8",
+11 -20
View File
@@ -47,25 +47,20 @@ export default function AdminLayout() {
const handleLogout = () => { clearToken(); navigate('/admin/login') } const handleLogout = () => { clearToken(); navigate('/admin/login') }
return ( return (
<Layout style={{ minHeight: '100vh' }}> <Layout className="admin-shell apple-material-shell">
<Sider <Sider
collapsible collapsed={collapsed} onCollapse={setCollapsed} collapsible collapsed={collapsed} onCollapse={setCollapsed}
width={220} width={220}
style={{ className="admin-sider"
borderRight: '1px solid #EBE5DB', theme="dark"
background: '#fff',
}}
> >
<div style={{ <div className="admin-brand">
height: 60, display: 'flex', alignItems: 'center', justifyContent: 'center', <span className="admin-brand-mark">{brand.brand_name.slice(0, 1)}</span>
borderBottom: '1px solid #EBE5DB',
}}>
<Text strong style={{ <Text strong style={{
fontSize: collapsed ? 14 : 17, letterSpacing: '-0.02em', display: collapsed ? 'none' : 'block', fontSize: 16, letterSpacing: '-0.02em',
fontFamily: '"Source Serif 4", "Noto Serif SC", Georgia, serif', color: '#F4F7FB', whiteSpace: 'nowrap',
color: '#C2693A',
}}> }}>
{collapsed ? brand.brand_name.slice(0, 2) : brand.brand_name} {brand.brand_name}
</Text> </Text>
</div> </div>
<Menu mode="inline" selectedKeys={[selectedKey]} items={menuItems} <Menu mode="inline" selectedKeys={[selectedKey]} items={menuItems}
@@ -73,15 +68,11 @@ export default function AdminLayout() {
style={{ border: 'none', marginTop: 8 }} /> style={{ border: 'none', marginTop: 8 }} />
</Sider> </Sider>
<Layout> <Layout>
<Header style={{ <Header className="admin-header">
background: '#fff', borderBottom: '1px solid #EBE5DB', <div><Text strong style={{ color: '#F4F7FB' }}>直播控制中心</Text><Text style={{ color: '#748096', marginLeft: 12 }}>{brand.brand_tagline}</Text></div>
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '0 48px',
}}>
<Text style={{ color: '#949088', fontSize: 13 }}>{brand.brand_tagline}</Text>
<Button type="text" icon={<LogoutOutlined />} onClick={handleLogout}>退出登录</Button> <Button type="text" icon={<LogoutOutlined />} onClick={handleLogout}>退出登录</Button>
</Header> </Header>
<Content style={{ padding: 40, overflow: 'auto' }}> <Content className="admin-content">
<div className="admin-page-transition" key={location.pathname}> <div className="admin-page-transition" key={location.pathname}>
<Outlet /> <Outlet />
</div> </div>
+2 -2
View File
@@ -27,9 +27,9 @@ export default function LoginPage() {
}}> }}>
<div style={{ width: '100%', maxWidth: 420, padding: 24 }}> <div style={{ width: '100%', maxWidth: 420, padding: 24 }}>
<div style={{ <div style={{
background: '#fff', background: 'rgba(18, 24, 36, 0.88)',
borderRadius: 14, borderRadius: 14,
border: '1px solid #EBE5DB', border: '1px solid rgba(255, 255, 255, 0.1)',
padding: 56, padding: 56,
}}> }}>
<div style={{ textAlign: 'center', marginBottom: 40 }}> <div style={{ textAlign: 'center', marginBottom: 40 }}>
+16 -12
View File
@@ -36,19 +36,21 @@ export default function Dashboard() {
] ]
return ( return (
<div> <div className="admin-page admin-dashboard">
<div style={{ marginBottom: 36 }}> <div className="admin-page-heading">
<Title level={1} style={{ margin: '0 0 8px', fontWeight: 600, fontSize: 32, letterSpacing: '-0.03em' }}> <Title level={1} style={{ margin: '0 0 8px', fontWeight: 600, fontSize: 32, letterSpacing: '-0.03em' }}>
{brand.brand_name} {brand.brand_name}
</Title> </Title>
<Text style={{ color: '#5E5A52', fontSize: 15 }}>管理控制台</Text> <Text className="admin-page-kicker">管理控制台 · 实时刷新</Text>
</div> </div>
<Row gutter={[24, 24]} style={{ marginBottom: 36 }}> <Row gutter={[24, 24]} style={{ marginBottom: 36 }}>
<Col xs={24} sm={8}> <Col xs={24} sm={8}>
<div style={{ <div style={{
background: '#fff', borderRadius: 12, padding: '28px 32px', background: 'linear-gradient(145deg, rgba(26, 33, 47, 0.78), rgba(12, 17, 27, 0.72))', borderRadius: 18, padding: '28px 32px',
border: '1px solid #EBE5DB', border: '1px solid rgba(255, 255, 255, 0.095)',
boxShadow: '0 18px 48px rgba(0, 0, 0, 0.26), inset 0 1px 0 rgba(255, 255, 255, 0.045)',
backdropFilter: 'blur(24px) saturate(135%)',
borderTop: '3px solid #5B8C5A', borderTop: '3px solid #5B8C5A',
}}> }}>
<Statistic title="播出中" value={liveCount} loading={loading} <Statistic title="播出中" value={liveCount} loading={loading}
@@ -57,8 +59,10 @@ export default function Dashboard() {
</Col> </Col>
<Col xs={24} sm={8}> <Col xs={24} sm={8}>
<div style={{ <div style={{
background: '#fff', borderRadius: 12, padding: '28px 32px', background: 'linear-gradient(145deg, rgba(26, 33, 47, 0.78), rgba(12, 17, 27, 0.72))', borderRadius: 18, padding: '28px 32px',
border: '1px solid #EBE5DB', border: '1px solid rgba(255, 255, 255, 0.095)',
boxShadow: '0 18px 48px rgba(0, 0, 0, 0.26), inset 0 1px 0 rgba(255, 255, 255, 0.045)',
backdropFilter: 'blur(24px) saturate(135%)',
borderTop: '3px solid #6B8CA8', borderTop: '3px solid #6B8CA8',
}}> }}>
<Statistic title="待开播" value={pendingCount} loading={loading} <Statistic title="待开播" value={pendingCount} loading={loading}
@@ -67,8 +71,10 @@ export default function Dashboard() {
</Col> </Col>
<Col xs={24} sm={8}> <Col xs={24} sm={8}>
<div style={{ <div style={{
background: '#fff', borderRadius: 12, padding: '28px 32px', background: 'linear-gradient(145deg, rgba(26, 33, 47, 0.78), rgba(12, 17, 27, 0.72))', borderRadius: 18, padding: '28px 32px',
border: '1px solid #EBE5DB', border: '1px solid rgba(255, 255, 255, 0.095)',
boxShadow: '0 18px 48px rgba(0, 0, 0, 0.26), inset 0 1px 0 rgba(255, 255, 255, 0.045)',
backdropFilter: 'blur(24px) saturate(135%)',
borderTop: '3px solid #C2693A', borderTop: '3px solid #C2693A',
}}> }}>
<Statistic title="节目总数" value={streams.length} loading={loading} <Statistic title="节目总数" value={streams.length} loading={loading}
@@ -77,9 +83,7 @@ export default function Dashboard() {
</Col> </Col>
</Row> </Row>
<div style={{ <div className="admin-glass-panel admin-table-panel" style={{
background: '#fff', borderRadius: 12,
border: '1px solid #EBE5DB',
padding: '28px 32px', padding: '28px 32px',
}}> }}>
<Title level={4} style={{ margin: '0 0 20px', fontWeight: 600 }}>最近节目</Title> <Title level={4} style={{ margin: '0 0 20px', fontWeight: 600 }}>最近节目</Title>
+5 -5
View File
@@ -89,24 +89,24 @@ export default function NodeManager() {
{strategy && ( {strategy && (
<Row gutter={[24, 24]} style={{ marginBottom: 32 }}> <Row gutter={[24, 24]} style={{ marginBottom: 32 }}>
<Col xs={24} sm={6}> <Col xs={24} sm={6}>
<div style={{ background: '#fff', borderRadius: 12, border: '1px solid #EBE5DB', padding: '24px 28px' }}> <div className="admin-glass-panel admin-stat-panel">
<Statistic title="均衡策略" value={sL[strategy.strategy] || strategy.strategy} valueStyle={{ fontSize: 20 }} /> <Statistic title="均衡策略" value={sL[strategy.strategy] || strategy.strategy} valueStyle={{ fontSize: 20 }} />
</div> </div>
</Col> </Col>
<Col xs={24} sm={6}> <Col xs={24} sm={6}>
<div style={{ background: '#fff', borderRadius: 12, border: '1px solid #EBE5DB', padding: '24px 28px' }}> <div className="admin-glass-panel admin-stat-panel">
<Statistic title="组播状态" value={strategy.multicast_available ? '可用' : '不可用'} <Statistic title="组播状态" value={strategy.multicast_available ? '可用' : '不可用'}
valueStyle={{ color: strategy.multicast_available ? '#5B8C5A' : '#949088', fontSize: 20 }} /> valueStyle={{ color: strategy.multicast_available ? '#5B8C5A' : '#949088', fontSize: 20 }} />
</div> </div>
</Col> </Col>
<Col xs={24} sm={6}> <Col xs={24} sm={6}>
<div style={{ background: '#fff', borderRadius: 12, border: '1px solid #EBE5DB', padding: '24px 28px' }}> <div className="admin-glass-panel admin-stat-panel">
<Statistic title="在线节点" value={onlineCount} suffix={<Text style={{ fontSize: 14, color: '#949088' }}>/ {nodes.length}</Text>} <Statistic title="在线节点" value={onlineCount} suffix={<Text style={{ fontSize: 14, color: '#949088' }}>/ {nodes.length}</Text>}
valueStyle={{ color: onlineCount > 0 ? '#5B8C5A' : '#B54A4A', fontSize: 20 }} /> valueStyle={{ color: onlineCount > 0 ? '#5B8C5A' : '#B54A4A', fontSize: 20 }} />
</div> </div>
</Col> </Col>
<Col xs={24} sm={6}> <Col xs={24} sm={6}>
<div style={{ background: '#fff', borderRadius: 12, border: '1px solid #EBE5DB', padding: '24px 28px' }}> <div className="admin-glass-panel admin-stat-panel">
<Statistic title="离线节点" value={nodes.length - onlineCount} <Statistic title="离线节点" value={nodes.length - onlineCount}
valueStyle={{ color: (nodes.length - onlineCount) > 0 ? '#B54A4A' : '#949088', fontSize: 20 }} valueStyle={{ color: (nodes.length - onlineCount) > 0 ? '#B54A4A' : '#949088', fontSize: 20 }}
prefix={(nodes.length - onlineCount) > 0 ? <WarningOutlined style={{ fontSize: 18 }} /> : undefined} /> prefix={(nodes.length - onlineCount) > 0 ? <WarningOutlined style={{ fontSize: 18 }} /> : undefined} />
@@ -115,7 +115,7 @@ export default function NodeManager() {
</Row> </Row>
)} )}
<div style={{ background: '#fff', borderRadius: 12, border: '1px solid #EBE5DB', padding: '8px 8px 0' }}> <div className="admin-glass-panel admin-table-panel">
<div style={{ padding: '16px 16px 8px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div style={{ padding: '16px 16px 8px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Text strong style={{ fontSize: 16 }}>节点列表</Text> <Text strong style={{ fontSize: 16 }}>节点列表</Text>
<Button icon={<ReloadOutlined />} onClick={loadData} type="text">刷新</Button> <Button icon={<ReloadOutlined />} onClick={loadData} type="text">刷新</Button>
+64 -2
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { Alert, Button, Input, InputNumber, Select, Space, Spin, Typography, message } from 'antd' import { Alert, Button, Input, InputNumber, Segmented, Select, Space, Spin, Typography, message } from 'antd'
import { ArrowDownOutlined, ArrowLeftOutlined, ArrowUpOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons' import { ArrowDownOutlined, ArrowLeftOutlined, ArrowUpOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons'
import { fetchMediaFiles, getStream, updateStream, type Chapter, type StreamInfo } from '../../api/streams' import { fetchMediaFiles, getStream, updateStream, type Chapter, type StreamInfo } from '../../api/streams'
@@ -12,7 +12,13 @@ export default function ReplayEditor() {
const videoRef = useRef<HTMLVideoElement>(null) const videoRef = useRef<HTMLVideoElement>(null)
const [stream, setStream] = useState<StreamInfo | null>(null) const [stream, setStream] = useState<StreamInfo | null>(null)
const [mediaFiles, setMediaFiles] = useState<string[]>([]) const [mediaFiles, setMediaFiles] = useState<string[]>([])
const [replaySource, setReplaySource] = useState<'local' | 'remote'>('local')
const [replayFile, setReplayFile] = useState<string>() const [replayFile, setReplayFile] = useState<string>()
const [replayUrl, setReplayUrl] = useState('')
const [previewUrl, setPreviewUrl] = useState<string>()
const [previewLoading, setPreviewLoading] = useState(false)
const [previewError, setPreviewError] = useState('')
const previewObjectUrlRef = useRef<string>()
const [chapters, setChapters] = useState<Chapter[]>([]) const [chapters, setChapters] = useState<Chapter[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
@@ -23,7 +29,9 @@ export default function ReplayEditor() {
Promise.all([getStream(streamId), fetchMediaFiles()]) Promise.all([getStream(streamId), fetchMediaFiles()])
.then(([program, files]) => { .then(([program, files]) => {
setStream(program) setStream(program)
setReplayFile(program.replay_file) setReplaySource(program.replay_url ? 'remote' : 'local')
setReplayFile(program.replay_file || undefined)
setReplayUrl(program.replay_url || '')
setChapters(program.chapters || []) setChapters(program.chapters || [])
setMediaFiles(files) setMediaFiles(files)
}) })
@@ -31,6 +39,60 @@ export default function ReplayEditor() {
.finally(() => setLoading(false)) .finally(() => setLoading(false))
}, [id]) }, [id])
useEffect(() => {
if (previewObjectUrlRef.current) {
URL.revokeObjectURL(previewObjectUrlRef.current)
previewObjectUrlRef.current = undefined
}
setPreviewUrl(undefined)
setPreviewError('')
if (replaySource === 'local') {
setPreviewLoading(false)
if (stream && replayFile) {
setPreviewUrl(`/api/streams/${stream.id}/replay?preview=${encodeURIComponent(replayFile)}`)
}
return
}
const remoteUrl = replayUrl.trim()
if (!remoteUrl) {
setPreviewLoading(false)
return
}
const controller = new AbortController()
let disposed = false
setPreviewLoading(true)
void fetch(remoteUrl, { signal: controller.signal })
.then(response => {
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return response.blob()
})
.then(blob => {
if (disposed) return
const objectUrl = URL.createObjectURL(blob)
previewObjectUrlRef.current = objectUrl
setPreviewUrl(objectUrl)
})
.catch(error => {
if (error instanceof DOMException && error.name === 'AbortError') return
if (!disposed) setPreviewError('远端回放拉取失败,请检查地址及其 CORS 配置')
})
.finally(() => {
if (!disposed) setPreviewLoading(false)
})
return () => {
disposed = true
controller.abort()
if (previewObjectUrlRef.current) {
URL.revokeObjectURL(previewObjectUrlRef.current)
previewObjectUrlRef.current = undefined
}
}
}, [replaySource, replayFile, replayUrl, stream])
const move = (index: number, offset: number) => { const move = (index: number, offset: number) => {
setChapters(current => { setChapters(current => {
const target = index + offset const target = index + offset
+1 -1
View File
@@ -1,5 +1,5 @@
.settings-page { width: 100%; max-width: 960px; min-width: 0; } .settings-page { width: 100%; max-width: 960px; min-width: 0; }
.settings-card { margin-top: 20px; padding: 24px; overflow: hidden; background: #fff; border: 1px solid #ebe5db; border-radius: 12px; } .settings-card { margin-top: 20px; padding: 24px; overflow: hidden; background: linear-gradient(145deg, rgba(26, 33, 47, .78), rgba(12, 17, 27, .72)); border: 1px solid rgba(255, 255, 255, .095); border-radius: 18px; box-shadow: 0 18px 48px rgba(0, 0, 0, .26); backdrop-filter: blur(24px) saturate(135%); }
.profile-block { display: grid; gap: 12px; margin: 20px 0; min-width: 0; } .profile-block { display: grid; gap: 12px; margin: 20px 0; min-width: 0; }
.quality-row { display: grid; grid-template-columns: minmax(130px, 1.4fr) repeat(2, minmax(115px, .8fr)) minmax(120px, 1fr) auto; gap: 10px; align-items: center; min-width: 0; } .quality-row { display: grid; grid-template-columns: minmax(130px, 1.4fr) repeat(2, minmax(115px, .8fr)) minmax(120px, 1fr) auto; gap: 10px; align-items: center; min-width: 0; }
.quality-row > * { min-width: 0; max-width: 100%; } .quality-row > * { min-width: 0; max-width: 100%; }
+3 -3
View File
@@ -45,7 +45,7 @@ export default function StreamForm() {
<Text style={{ color: '#5E5A52' }}>将以下信息提供给推流端即可开始直播</Text> <Text style={{ color: '#5E5A52' }}>将以下信息提供给推流端即可开始直播</Text>
</div> </div>
<div style={{ background: '#fff', borderRadius: 12, border: '1px solid #EBE5DB', padding: 32, marginBottom: 24 }}> <div className="admin-glass-panel admin-form-panel" style={{ marginBottom: 24 }}>
<Space direction="vertical" size={24} style={{ width: '100%' }}> <Space direction="vertical" size={24} style={{ width: '100%' }}>
<div> <div>
<Text style={{ color: '#949088', fontSize: 12, textTransform: 'uppercase', letterSpacing: '0.08em', fontWeight: 600 }}>推流码</Text> <Text style={{ color: '#949088', fontSize: 12, textTransform: 'uppercase', letterSpacing: '0.08em', fontWeight: 600 }}>推流码</Text>
@@ -78,7 +78,7 @@ export default function StreamForm() {
<Button type="text" icon={<ArrowLeftOutlined />} onClick={() => navigate('/admin/streams')} <Button type="text" icon={<ArrowLeftOutlined />} onClick={() => navigate('/admin/streams')}
style={{ marginBottom: 20, color: '#5E5A52' }}>返回列表</Button> style={{ marginBottom: 20, color: '#5E5A52' }}>返回列表</Button>
<div style={{ background: '#fff', borderRadius: 12, border: '1px solid #EBE5DB', padding: 36 }}> <div className="admin-glass-panel admin-form-panel">
<Title level={2} style={{ margin: '0 0 8px', fontWeight: 600 }}>创建节目</Title> <Title level={2} style={{ margin: '0 0 8px', fontWeight: 600 }}>创建节目</Title>
<Text style={{ color: '#5E5A52', display: 'block', marginBottom: 36 }}> <Text style={{ color: '#5E5A52', display: 'block', marginBottom: 36 }}>
填写节目信息并预约播出,系统将生成推流码与 RTMP 地址 填写节目信息并预约播出,系统将生成推流码与 RTMP 地址
@@ -138,7 +138,7 @@ export default function StreamForm() {
<Form.Item name="remote_control_enabled" label="允许审计式观众播放控制" valuePropName="checked" extra="默认关闭,仅在明确启用后允许创建限时控制会话"> <Form.Item name="remote_control_enabled" label="允许审计式观众播放控制" valuePropName="checked" extra="默认关闭,仅在明确启用后允许创建限时控制会话">
<Switch checkedChildren="启用" unCheckedChildren="关闭" /> <Switch checkedChildren="启用" unCheckedChildren="关闭" />
</Form.Item> </Form.Item>
<div style={{ borderTop: '1px solid #EBE5DB', margin: '8px 0 24px' }} /> <div className="admin-section-divider" />
<Form.Item style={{ marginBottom: 0 }}> <Form.Item style={{ marginBottom: 0 }}>
<Button type="primary" htmlType="submit" loading={loading} block style={{ height: 48, borderRadius: 8 }}> <Button type="primary" htmlType="submit" loading={loading} block style={{ height: 48, borderRadius: 8 }}>
创建节目 创建节目
+5 -5
View File
@@ -67,19 +67,19 @@ export default function StreamList() {
] ]
return ( return (
<div> <div className="admin-page admin-stream-list">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 28 }}> <div className="admin-page-heading admin-page-heading--actions">
<div> <div>
<Title level={2} style={{ margin: '0 0 4px', fontWeight: 600, fontSize: 28, letterSpacing: '-0.02em' }}>节目列表</Title> <Title level={2} style={{ margin: '0 0 4px', fontWeight: 600, fontSize: 28, letterSpacing: '-0.02em' }}>节目列表</Title>
<Text style={{ color: '#949088', fontSize: 14 }}>管理直播、排期与回放节目</Text> <Text className="admin-page-kicker">管理直播、排期与回放节目</Text>
</div> </div>
<Button type="primary" icon={<PlusOutlined />} size="large" onClick={() => navigate('/admin/streams/new')}> <Button type="primary" icon={<PlusOutlined />} size="large" onClick={() => navigate('/admin/streams/new')}>
创建节目 创建节目
</Button> </Button>
</div> </div>
<div style={{ <div style={{
background: '#fff', borderRadius: 12, background: 'rgba(18, 24, 36, 0.72)', borderRadius: 16,
border: '1px solid #EBE5DB', padding: '8px 8px 0', border: '1px solid rgba(255, 255, 255, 0.095)', padding: '8px 8px 0',
}}> }}>
<Table dataSource={streams} columns={columns} rowKey="id" loading={loading} <Table dataSource={streams} columns={columns} rowKey="id" loading={loading}
pagination={{ pageSize: 15, showSizeChanger: false }} size="middle" pagination={{ pageSize: 15, showSizeChanger: false }} size="middle"
+4 -1
View File
@@ -27,6 +27,7 @@ export interface StreamInfo {
multicast_enabled: boolean multicast_enabled: boolean
created_at: string created_at: string
replay_file?: string replay_file?: string
replay_url?: string
chapters: Chapter[] chapters: Chapter[]
transcode_profile: string transcode_profile: string
renditions?: RenditionConfig[] | null renditions?: RenditionConfig[] | null
@@ -44,7 +45,8 @@ export interface StreamCreateReq {
description?: string description?: string
cover_url?: string cover_url?: string
scheduled_at?: string scheduled_at?: string
replay_file?: string replay_file?: string | null
replay_url?: string | null
chapters?: Chapter[] chapters?: Chapter[]
transcode_profile?: string transcode_profile?: string
renditions?: RenditionConfig[] renditions?: RenditionConfig[]
@@ -63,6 +65,7 @@ export interface WatchInfo {
description?: string description?: string
scheduled_at?: string scheduled_at?: string
replay_url?: string replay_url?: string
replay_local_url?: string
chapters: Chapter[] chapters: Chapter[]
remote_control_enabled: boolean remote_control_enabled: boolean
} }
+619 -24
View File
@@ -1,30 +1,30 @@
:root { :root {
--bg-page: #F9F7F4; --bg-page: #070A10;
--bg-surface: #FFFFFF; --bg-surface: #10151F;
--bg-surface-raised: #FAFAF8; --bg-surface-raised: #171D29;
--bg-muted: #F3F0EC; --bg-muted: #1D2432;
--bg-brand: #C2693A; --bg-brand: #5B8CFF;
--bg-brand-light: #F6EEE5; --bg-brand-light: #17264A;
--bg-dark: #1A1814; --bg-dark: #04060A;
--text-primary: #1C1A17; --text-primary: #F4F7FB;
--text-secondary: #5E5A52; --text-secondary: #AAB4C5;
--text-tertiary: #949088; --text-tertiary: #748096;
--text-inverse: #F9F7F4; --text-inverse: #05070B;
--border-default: #EBE5DB; --border-default: #273044;
--border-light: #F3EFE9; --border-light: #1D2636;
--border-brand: #D4A782; --border-brand: #5B8CFF;
--shadow-sm: 0 1px 2px rgba(28,26,23,0.03); --shadow-sm: 0 2px 8px rgba(0,0,0,0.24);
--shadow-md: 0 4px 12px rgba(28,26,23,0.04); --shadow-md: 0 12px 32px rgba(0,0,0,0.28);
--shadow-lg: 0 12px 32px rgba(28,26,23,0.06); --shadow-lg: 0 24px 64px rgba(0,0,0,0.36);
--radius-sm: 6px; --radius-sm: 8px;
--radius-md: 10px; --radius-md: 14px;
--radius-lg: 14px; --radius-lg: 20px;
--font-sans: "Source Serif 4", "Noto Serif SC", Georgia, serif; --font-sans: Inter, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
--font-mono: "SF Mono", "Fira Code", "Cascadia Code", monospace; --font-mono: "SF Mono", "Fira Code", "Cascadia Code", monospace;
--space-0: 0; --space-0: 0;
@@ -58,9 +58,15 @@ body {
font-size: 15px; font-size: 15px;
line-height: 1.65; line-height: 1.65;
color: var(--text-primary); color: var(--text-primary);
background: var(--bg-page); background:
radial-gradient(circle at 85% -20%, rgba(91,140,255,0.18), transparent 34rem),
var(--bg-page);
min-width: 320px;
} }
a { color: inherit; }
button, input, select, textarea { font: inherit; }
h1, h2, h3, h4, h5, h6 { h1, h2, h3, h4, h5, h6 {
font-family: var(--font-sans); font-family: var(--font-sans);
font-weight: 600; font-weight: 600;
@@ -69,12 +75,601 @@ h1, h2, h3, h4, h5, h6 {
} }
::selection { ::selection {
background: #EAD7C8; background: #5B8CFF;
color: #1C1A17; color: #FFFFFF;
} }
.admin-shell { min-height: 100vh; background: transparent; }
.admin-sider { background: #0B1018 !important; border-right: 1px solid var(--border-light); }
.admin-brand { height: 72px; display: flex; align-items: center; gap: 12px; padding: 0 20px; border-bottom: 1px solid var(--border-light); }
.admin-brand-mark { width: 34px; height: 34px; border-radius: 11px; display: grid; place-items: center; background: linear-gradient(135deg,#5B8CFF,#7B61FF); color: white; font-weight: 800; box-shadow: 0 8px 24px rgba(91,140,255,.35); }
.admin-header { height: 72px !important; padding: 0 32px !important; display: flex; align-items: center; justify-content: space-between; background: rgba(10,14,22,.86) !important; border-bottom: 1px solid var(--border-light); backdrop-filter: blur(18px); }
.admin-content { padding: 32px !important; overflow: auto; }
.admin-content .ant-card { border-color: var(--border-default); box-shadow: var(--shadow-sm); }
.admin-page-transition { max-width: 1540px; margin: 0 auto; animation: admin-enter .24s ease-out; }
@keyframes admin-enter { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
@media (max-width: 768px) { .admin-header { padding: 0 16px !important; } .admin-content { padding: 16px !important; } }
@media (max-width: 600px) { @media (max-width: 600px) {
.replay-editor { overflow: hidden; } .replay-editor { overflow: hidden; }
.replay-editor video { max-width: 100%; } .replay-editor video { max-width: 100%; }
.replay-chapter-row { display: flex !important; } .replay-chapter-row { display: flex !important; }
.replay-chapter-row .ant-input-number { flex: 1 1 140px; max-width: 100%; } .replay-chapter-row .ant-input-number { flex: 1 1 140px; max-width: 100%; }
} }
/* Unified admin dark surfaces */
.admin-content .ant-card,
.admin-content .ant-table,
.admin-content .ant-table-container,
.admin-content .ant-table-thead > tr > th,
.admin-content .ant-table-tbody > tr > td,
.admin-content .ant-descriptions-view,
.admin-content .ant-list,
.admin-content .ant-modal-content,
.admin-content .ant-drawer-content {
background: #10151f;
border-color: #273044;
color: #f4f7fb;
}
.admin-content .ant-table-tbody > tr:hover > td,
.admin-content .ant-table-row:hover > td {
background: #171d29 !important;
}
.admin-content .ant-typography,
.admin-content .ant-statistic-title,
.admin-content .ant-statistic-content,
.admin-content .ant-table-cell,
.admin-header .ant-btn {
color: #f4f7fb;
}
.admin-content .ant-typography-secondary,
.admin-content .ant-form-item-label > label,
.admin-content .ant-empty-description {
color: #8d99ad;
}
.admin-content .ant-input,
.admin-content .ant-input-affix-wrapper,
.admin-content .ant-select-selector,
.admin-content .ant-picker,
.admin-content .ant-input-number,
.admin-content .ant-segmented {
background: #0b0f17 !important;
border-color: #273044 !important;
color: #f4f7fb !important;
}
.admin-content .ant-card,
.admin-content .ant-table-wrapper,
.admin-content .ant-descriptions-view {
border-radius: 16px;
overflow: hidden;
}
.admin-content .ant-divider {
border-color: #273044;
}
@media (max-width: 768px) {
.admin-content { padding: 16px; }
.admin-header { padding-inline: 16px; }
.admin-content .ant-table-wrapper { overflow-x: auto; }
}
/* Global dark overlays and feedback */
.ant-dropdown-menu,
.ant-select-dropdown,
.ant-popover-inner,
.ant-picker-dropdown .ant-picker-panel-container,
.ant-message-notice-content,
.ant-notification-notice,
.ant-modal-content,
.ant-modal-header,
.ant-drawer-content,
.ant-drawer-header { background: #171d29 !important; color: #f4f7fb !important; border-color: #273044 !important; }
.ant-dropdown-menu-item,
.ant-dropdown-menu-submenu-title,
.ant-select-item,
.ant-popover-inner-content,
.ant-modal-title,
.ant-drawer-title,
.ant-message-notice-content,
.ant-notification-notice-message,
.ant-notification-notice-description { color: #f4f7fb !important; }
.ant-dropdown-menu-item:hover,
.ant-dropdown-menu-submenu-title:hover,
.ant-select-item-option-active:not(.ant-select-item-option-disabled) { background: #1d2636 !important; }
.ant-select-item-option-selected:not(.ant-select-item-option-disabled) { background: rgba(91,140,255,.2) !important; color: #fff !important; }
.ant-alert { background: #171d29 !important; border-color: #273044 !important; }
.ant-alert-message { color: #f4f7fb !important; }
.ant-alert-description { color: #aab4c5 !important; }
.ant-alert-error { background: rgba(255,100,124,.1) !important; border-color: rgba(255,100,124,.34) !important; }
.ant-alert-warning { background: rgba(246,185,74,.1) !important; border-color: rgba(246,185,74,.34) !important; }
.ant-alert-success { background: rgba(45,212,167,.1) !important; border-color: rgba(45,212,167,.34) !important; }
.ant-menu-dark,
.ant-menu-dark > .ant-menu,
.admin-sider .ant-menu { background: #10151f !important; color: #aab4c5 !important; }
/* Apple dark material system */
:root {
color-scheme: dark;
--apple-canvas: #08090d;
--apple-surface: rgba(31, 32, 38, 0.72);
--apple-surface-raised: rgba(42, 43, 50, 0.86);
--apple-fill: rgba(255, 255, 255, 0.065);
--apple-fill-hover: rgba(255, 255, 255, 0.09);
--apple-separator: rgba(255, 255, 255, 0.11);
--apple-separator-strong: rgba(255, 255, 255, 0.18);
--apple-label: rgba(255, 255, 255, 0.94);
--apple-secondary-label: rgba(235, 238, 245, 0.62);
--apple-accent: #80aaff;
--apple-focus: rgba(128, 170, 255, 0.38);
--apple-shadow: 0 18px 48px rgba(0, 0, 0, 0.34);
--apple-shadow-raised: 0 28px 80px rgba(0, 0, 0, 0.5);
--apple-material: blur(24px) saturate(145%);
--apple-ease: cubic-bezier(0.2, 0, 0, 1);
--apple-ease-settle: cubic-bezier(0.2, 0.8, 0.2, 1);
}
body {
color: var(--apple-label);
background:
radial-gradient(circle at 16% -8%, rgba(82, 119, 194, 0.2), transparent 32rem),
radial-gradient(circle at 90% 110%, rgba(80, 67, 142, 0.12), transparent 34rem),
var(--apple-canvas);
}
.ant-card,
.ant-table-wrapper .ant-table,
.ant-descriptions,
.ant-list,
.ant-tabs,
.ant-modal .ant-modal-content,
.ant-drawer .ant-drawer-content,
.ant-dropdown .ant-dropdown-menu,
.ant-select-dropdown,
.ant-picker-dropdown .ant-picker-panel-container {
border-color: var(--apple-separator);
background: var(--apple-surface);
box-shadow: var(--apple-shadow);
-webkit-backdrop-filter: var(--apple-material);
backdrop-filter: var(--apple-material);
}
.ant-card,
.ant-modal .ant-modal-content,
.ant-drawer .ant-drawer-content,
.ant-dropdown .ant-dropdown-menu,
.ant-select-dropdown,
.ant-picker-dropdown .ant-picker-panel-container {
border: 1px solid var(--apple-separator);
}
.ant-card,
.ant-table-wrapper,
.ant-list,
.ant-descriptions,
.ant-tabs,
.ant-btn,
.ant-input,
.ant-input-affix-wrapper,
.ant-input-number,
.ant-select-selector,
.ant-picker,
.ant-pagination-item,
.ant-pagination-prev,
.ant-pagination-next,
.ant-dropdown-menu-item,
.ant-tabs-tab {
transition-property: transform, opacity, color, border-color, background-color, box-shadow, filter;
transition-duration: 160ms;
transition-timing-function: var(--apple-ease);
}
.ant-card {
overflow: hidden;
border-radius: 20px;
}
.ant-card .ant-card-head,
.ant-modal .ant-modal-header,
.ant-drawer .ant-drawer-header {
border-color: var(--apple-separator);
background: transparent;
}
.ant-table-wrapper .ant-table-container,
.ant-table-wrapper .ant-table-cell,
.ant-table-wrapper .ant-table-thead > tr > th,
.ant-table-wrapper .ant-table-tbody > tr > td {
border-color: var(--apple-separator);
background: transparent;
}
.ant-table-wrapper .ant-table-thead > tr > th {
color: var(--apple-secondary-label);
background: rgba(255, 255, 255, 0.045);
font-size: 12px;
letter-spacing: 0.04em;
}
.ant-table-wrapper .ant-table-tbody > tr.ant-table-row:hover > td {
background: var(--apple-fill);
}
.ant-form-item-label > label,
.ant-descriptions .ant-descriptions-item-label,
.ant-list .ant-list-item-meta-description,
.ant-empty .ant-empty-description {
color: var(--apple-secondary-label);
}
.ant-descriptions .ant-descriptions-view,
.ant-list-bordered {
border-color: var(--apple-separator);
}
.ant-input,
.ant-input-affix-wrapper,
.ant-input-number,
.ant-select-selector,
.ant-picker,
.ant-input-search-button {
border-color: var(--apple-separator);
background: var(--apple-fill);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.ant-input:hover,
.ant-input-affix-wrapper:hover,
.ant-input-number:hover,
.ant-select:not(.ant-select-disabled):hover .ant-select-selector,
.ant-picker:hover {
border-color: var(--apple-separator-strong);
}
.ant-input:focus,
.ant-input-focused,
.ant-input-affix-wrapper-focused,
.ant-input-number-focused,
.ant-select-focused:not(.ant-select-disabled) .ant-select-selector,
.ant-picker-focused {
border-color: var(--apple-accent);
box-shadow: 0 0 0 3px var(--apple-focus);
}
.ant-dropdown-menu-item:hover,
.ant-dropdown-menu-submenu-title:hover,
.ant-select-item-option-active:not(.ant-select-item-option-disabled),
.ant-pagination-item:hover,
.ant-tabs-tab:hover {
color: var(--apple-label);
border-color: var(--apple-separator-strong);
background: var(--apple-fill-hover);
}
.ant-pagination-item-active,
.ant-select-item-option-selected:not(.ant-select-item-option-disabled) {
border-color: rgba(128, 170, 255, 0.48);
background: rgba(128, 170, 255, 0.16);
}
.ant-modal-mask,
.ant-drawer-mask {
background: rgba(2, 3, 7, 0.64) !important;
-webkit-backdrop-filter: blur(12px) saturate(115%);
backdrop-filter: blur(12px) saturate(115%);
transition-property: opacity, background-color, backdrop-filter;
transition-duration: 220ms;
transition-timing-function: var(--apple-ease);
}
.ant-modal .ant-modal-content,
.ant-drawer .ant-drawer-content {
background: var(--apple-surface-raised);
box-shadow: var(--apple-shadow-raised);
}
.ant-empty-image {
opacity: 0.62;
filter: grayscale(0.2) contrast(0.9);
}
.ant-btn:focus-visible,
.ant-menu-item:focus-visible,
.ant-tabs-tab:focus-visible,
.ant-pagination-item:focus-visible,
.ant-dropdown-menu-item:focus-visible {
outline: 3px solid var(--apple-focus);
outline-offset: 2px;
}
.apple-material-shell .ant-layout-sider,
.apple-material-shell .ant-layout-header,
.admin-login-panel {
border: 1px solid var(--apple-separator);
background: var(--apple-surface);
box-shadow: var(--apple-shadow);
-webkit-backdrop-filter: var(--apple-material);
backdrop-filter: var(--apple-material);
}
.admin-login-panel {
border-radius: 24px;
padding: 32px;
}
@media (hover: hover) and (pointer: fine) {
.ant-card:hover {
transform: translateY(-2px);
border-color: var(--apple-separator-strong);
box-shadow: var(--apple-shadow-raised);
}
.ant-btn:not(:disabled):hover,
.ant-pagination-item:hover {
transform: translateY(-1px);
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
}
.ant-card,
.ant-table-wrapper,
.ant-list,
.ant-descriptions,
.ant-tabs,
.ant-btn,
.ant-input,
.ant-input-affix-wrapper,
.ant-input-number,
.ant-select-selector,
.ant-picker,
.ant-pagination-item,
.ant-pagination-prev,
.ant-pagination-next,
.ant-dropdown-menu-item,
.ant-tabs-tab,
.ant-modal-mask,
.ant-drawer-mask {
animation: none !important;
transition-duration: 0.01ms !important;
}
.ant-card:hover,
.ant-btn:not(:disabled):hover,
.ant-pagination-item:hover {
transform: none;
}
}
/* Apple-inspired dark control-room surfaces */
.admin-shell,
.admin-content,
.admin-page-transition {
color-scheme: dark;
}
.admin-glass-panel,
.admin-content .ant-card,
.admin-content .ant-table-wrapper,
.admin-content .ant-descriptions,
.admin-content .ant-list,
.admin-content .ant-tabs-content-holder,
.admin-content .ant-collapse {
background: linear-gradient(145deg, rgba(26, 33, 47, 0.78), rgba(12, 17, 27, 0.72));
border: 1px solid rgba(255, 255, 255, 0.095);
border-radius: 18px;
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.26), inset 0 1px 0 rgba(255, 255, 255, 0.045);
-webkit-backdrop-filter: blur(24px) saturate(135%);
backdrop-filter: blur(24px) saturate(135%);
}
.admin-form-panel { padding: 32px; }
.admin-stat-panel { padding: 24px 28px; }
.admin-table-panel { padding: 8px 8px 0; overflow: hidden; }
.admin-content .ant-card,
.admin-content .ant-card-head,
.admin-content .ant-card-body,
.admin-content .ant-table,
.admin-content .ant-table-container,
.admin-content .ant-table-content,
.admin-content .ant-table-thead > tr > th,
.admin-content .ant-table-tbody > tr > td,
.admin-content .ant-descriptions-view,
.admin-content .ant-descriptions-row > th,
.admin-content .ant-descriptions-row > td,
.admin-content .ant-list-item,
.admin-content .ant-tabs-nav,
.admin-content .ant-collapse,
.admin-content .ant-collapse-item,
.admin-content .ant-collapse-header,
.admin-content .ant-collapse-content,
.admin-content .ant-collapse-content-box {
background: transparent !important;
border-color: rgba(255, 255, 255, 0.085) !important;
color: var(--text-primary) !important;
}
.admin-content .ant-table-thead > tr > th {
color: var(--text-secondary) !important;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.admin-content .ant-table-tbody > tr > td,
.admin-content .ant-descriptions-item-content,
.admin-content .ant-list-item-meta-description,
.admin-content .ant-form-item-extra,
.admin-content .ant-form-item-explain {
color: var(--text-secondary) !important;
}
.admin-content .ant-table-tbody > tr.ant-table-row:hover > td {
background: rgba(91, 140, 255, 0.075) !important;
}
.admin-content .ant-form-item-label > label,
.admin-content .ant-descriptions-item-label,
.admin-content .ant-list-item-meta-title,
.admin-content .ant-tabs-tab,
.admin-content .ant-collapse-header-text,
.admin-content .ant-empty-description {
color: var(--text-secondary) !important;
}
.admin-content .ant-tabs-tab-active .ant-tabs-tab-btn,
.admin-content .ant-list-item-meta-title,
.admin-content .ant-descriptions-title {
color: var(--text-primary) !important;
}
.admin-content :where(.ant-input, .ant-input-affix-wrapper, .ant-input-number, .ant-picker, .ant-select-selector, .ant-cascader-picker, .ant-mentions, .ant-upload-drag) {
background: rgba(7, 11, 18, 0.68) !important;
border-color: rgba(255, 255, 255, 0.11) !important;
color: var(--text-primary) !important;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.025);
transition: border-color 180ms cubic-bezier(0.23, 1, 0.32, 1), box-shadow 180ms cubic-bezier(0.23, 1, 0.32, 1), background-color 180ms cubic-bezier(0.23, 1, 0.32, 1), transform 160ms cubic-bezier(0.23, 1, 0.32, 1);
}
.admin-content :where(.ant-input, .ant-input-affix-wrapper, .ant-input-number, .ant-picker, .ant-select-selector):focus,
.admin-content :where(.ant-input-affix-wrapper, .ant-input-number, .ant-picker, .ant-select).ant-input-affix-wrapper-focused,
.admin-content .ant-select-focused .ant-select-selector {
border-color: rgba(116, 158, 255, 0.78) !important;
box-shadow: 0 0 0 3px rgba(91, 140, 255, 0.16) !important;
}
.ant-modal-mask,
.ant-drawer-mask {
background: rgba(0, 0, 0, 0.62) !important;
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
}
.ant-modal-content,
.ant-modal-header,
.ant-drawer-content,
.ant-drawer-header,
.ant-drawer-body,
.ant-dropdown-menu,
.ant-select-dropdown,
.ant-popover-inner,
.ant-tooltip-inner,
.ant-message-notice-content,
.ant-notification-notice {
background: rgba(18, 24, 36, 0.9) !important;
border-color: rgba(255, 255, 255, 0.1) !important;
color: var(--text-primary) !important;
box-shadow: 0 28px 80px rgba(0, 0, 0, 0.48), inset 0 1px 0 rgba(255, 255, 255, 0.055) !important;
-webkit-backdrop-filter: blur(28px) saturate(145%);
backdrop-filter: blur(28px) saturate(145%);
}
.ant-modal-content,
.ant-drawer-content,
.ant-dropdown-menu,
.ant-select-dropdown,
.ant-popover-inner {
border: 1px solid rgba(255, 255, 255, 0.1);
}
.ant-dropdown-menu-item,
.ant-select-item,
.ant-pagination-item,
.ant-pagination-prev .ant-pagination-item-link,
.ant-pagination-next .ant-pagination-item-link {
color: var(--text-secondary) !important;
transition: color 160ms cubic-bezier(0.23, 1, 0.32, 1), background-color 160ms cubic-bezier(0.23, 1, 0.32, 1), border-color 160ms cubic-bezier(0.23, 1, 0.32, 1), transform 160ms cubic-bezier(0.23, 1, 0.32, 1);
}
.ant-pagination-item,
.ant-pagination-prev .ant-pagination-item-link,
.ant-pagination-next .ant-pagination-item-link {
background: rgba(18, 24, 36, 0.72) !important;
border-color: rgba(255, 255, 255, 0.09) !important;
}
.ant-pagination-item-active {
background: rgba(91, 140, 255, 0.19) !important;
border-color: rgba(116, 158, 255, 0.66) !important;
}
.ant-empty-image svg ellipse,
.ant-empty-image svg path {
fill: rgba(111, 124, 149, 0.28) !important;
stroke: rgba(154, 168, 194, 0.44) !important;
}
.admin-content .ant-btn,
.admin-header .ant-btn,
.login-page .ant-btn {
transition: transform 160ms cubic-bezier(0.23, 1, 0.32, 1), background-color 180ms cubic-bezier(0.23, 1, 0.32, 1), border-color 180ms cubic-bezier(0.23, 1, 0.32, 1), color 180ms cubic-bezier(0.23, 1, 0.32, 1), box-shadow 180ms cubic-bezier(0.23, 1, 0.32, 1), opacity 160ms cubic-bezier(0.23, 1, 0.32, 1);
}
.admin-content .ant-btn:active,
.admin-header .ant-btn:active,
.login-page .ant-btn:active {
transform: scale(0.97);
}
.admin-content .ant-card,
.admin-glass-panel,
.admin-sider .ant-menu-item,
.admin-content .ant-collapse-content,
.ant-modal-content,
.ant-drawer-content,
.ant-dropdown-menu,
.ant-select-dropdown {
transition: transform 220ms cubic-bezier(0.23, 1, 0.32, 1), opacity 180ms cubic-bezier(0.23, 1, 0.32, 1), border-color 180ms cubic-bezier(0.23, 1, 0.32, 1), box-shadow 220ms cubic-bezier(0.23, 1, 0.32, 1), background-color 180ms cubic-bezier(0.23, 1, 0.32, 1);
}
@media (hover: hover) and (pointer: fine) {
.admin-content .ant-card:hover,
.admin-glass-panel:hover {
transform: translate3d(0, -2px, 0);
border-color: rgba(255, 255, 255, 0.15);
box-shadow: 0 24px 58px rgba(0, 0, 0, 0.34), inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.ant-dropdown-menu-item:hover,
.ant-select-item-option-active:not(.ant-select-item-option-disabled) {
background: rgba(91, 140, 255, 0.12) !important;
}
}
@media (max-width: 768px) {
.admin-form-panel,
.admin-stat-panel { padding: 20px; }
.admin-content .ant-card,
.admin-glass-panel { border-radius: 15px; }
}
@media (prefers-reduced-motion: reduce) {
.admin-content *,
.admin-header *,
.admin-sider *,
.login-page *,
.ant-modal-root *,
.ant-drawer *,
.ant-dropdown *,
.ant-select-dropdown * {
animation-duration: 1ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
transition-duration: 80ms !important;
}
.admin-content .ant-card:hover,
.admin-glass-panel:hover,
.admin-content .ant-btn:active,
.admin-header .ant-btn:active,
.login-page .ant-btn:active {
transform: none !important;
}
}
.admin-section-divider { margin: 8px 0 24px; border-top: 1px solid rgba(255,255,255,.095); }
+40 -3
View File
@@ -15,15 +15,15 @@
display: grid; display: grid;
place-items: center; place-items: center;
border-radius: 24px; border-radius: 24px;
background: linear-gradient(135deg, rgba(255,255,255,0.92), rgba(248,244,238,0.78)); background: #070a10;
} }
.route-loading span { .route-loading span {
width: 28px; width: 28px;
height: 28px; height: 28px;
border-radius: 999px; border-radius: 999px;
border: 3px solid rgba(194, 105, 58, 0.2); border: 3px solid rgba(91, 140, 255, 0.18);
border-top-color: #C2693A; border-top-color: #5B8CFF;
animation: route-loading-spin 760ms linear infinite; animation: route-loading-spin 760ms linear infinite;
} }
@@ -77,3 +77,40 @@
animation: none; animation: none;
} }
} }
/* Apple route motion */
.app-route-transition,
.admin-page-transition {
transform-origin: 50% 24%;
animation: apple-page-enter 320ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
}
.route-loading {
transition-property: opacity, filter;
transition-duration: 180ms;
transition-timing-function: cubic-bezier(0.2, 0, 0, 1);
}
@keyframes apple-page-enter {
from {
opacity: 0;
transform: translate3d(0, 8px, 0) scale(0.995);
filter: blur(3px);
}
to {
opacity: 1;
transform: translate3d(0, 0, 0) scale(1);
filter: blur(0);
}
}
@media (prefers-reduced-motion: reduce) {
.app-route-transition,
.admin-page-transition,
.route-loading {
animation: none !important;
transform: none !important;
filter: none !important;
transition-duration: 0.01ms !important;
}
}
+33 -33
View File
@@ -2,27 +2,27 @@ import type { ThemeConfig } from 'antd'
const theme: ThemeConfig = { const theme: ThemeConfig = {
token: { token: {
fontFamily: `"Source Serif 4", "Noto Serif SC", Georgia, serif`, fontFamily: `Inter, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif`,
fontSize: 15, fontSize: 14,
lineHeight: 1.65, lineHeight: 1.6,
colorPrimary: '#C2693A', colorPrimary: '#5B8CFF',
colorSuccess: '#5B8C5A', colorSuccess: '#2DD4A7',
colorWarning: '#C28B3A', colorWarning: '#F6B94A',
colorError: '#B54A4A', colorError: '#FF647C',
colorInfo: '#6B8CA8', colorInfo: '#62B5FF',
colorTextBase: '#1C1A17', colorTextBase: '#F4F7FB',
colorTextSecondary: '#5E5A52', colorTextSecondary: '#AAB4C5',
colorTextTertiary: '#949088', colorTextTertiary: '#748096',
colorBgBase: '#F9F7F4', colorBgBase: '#070A10',
colorBgContainer: '#FFFFFF', colorBgContainer: '#10151F',
colorBgLayout: '#F9F7F4', colorBgLayout: '#070A10',
colorBgElevated: '#FAFAF8', colorBgElevated: '#171D29',
colorBorder: '#EBE5DB', colorBorder: '#273044',
colorBorderSecondary: '#F3EFE9', colorBorderSecondary: '#1D2636',
colorFill: '#F3F0EC', colorFill: '#1D2432',
colorFillSecondary: '#F7F5F1', colorFillSecondary: '#171D29',
borderRadius: 6, borderRadius: 10,
borderRadiusLG: 10, borderRadiusLG: 16,
borderRadiusSM: 4, borderRadiusSM: 4,
wireframe: false, wireframe: false,
fontWeightStrong: 600, fontWeightStrong: 600,
@@ -38,18 +38,18 @@ const theme: ThemeConfig = {
}, },
components: { components: {
Layout: { Layout: {
headerBg: '#FFFFFF', headerBg: '#10151F',
headerHeight: 60, headerHeight: 60,
headerPadding: '0 48px', headerPadding: '0 48px',
siderBg: '#FFFFFF', siderBg: '#10151F',
bodyBg: '#F9F7F4', bodyBg: '#070A10',
}, },
Menu: { Menu: {
itemBg: 'transparent', itemBg: 'transparent',
itemSelectedBg: '#F3F0EC', itemSelectedBg: '#1D2636',
itemSelectedColor: '#1C1A17', itemSelectedColor: '#F4F7FB',
itemActiveBg: '#F7F5F1', itemActiveBg: '#171D29',
itemColor: '#5E5A52', itemColor: '#AAB4C5',
itemHeight: 44, itemHeight: 44,
}, },
Card: { Card: {
@@ -62,14 +62,14 @@ const theme: ThemeConfig = {
primaryShadow: '0 2px 6px rgba(194,105,58,0.1)', primaryShadow: '0 2px 6px rgba(194,105,58,0.1)',
}, },
Table: { Table: {
headerBg: '#F7F5F1', headerBg: '#171D29',
headerColor: '#5E5A52', headerColor: '#AAB4C5',
rowHoverBg: '#F5F3F0', rowHoverBg: '#171D29',
borderColor: '#EBE5DB', borderColor: '#273044',
}, },
Tag: { borderRadiusSM: 4 }, Tag: { borderRadiusSM: 4 },
Modal: { borderRadiusLG: 12, paddingLG: 28 }, Modal: { borderRadiusLG: 12, paddingLG: 28 },
Tabs: { inkBarColor: '#C2693A', itemActiveColor: '#C2693A', itemHoverColor: '#D4A782' }, Tabs: { inkBarColor: '#5B8CFF', itemActiveColor: '#5B8CFF', itemHoverColor: '#8AABFF' },
Select: { borderRadius: 6 }, Select: { borderRadius: 6 },
Input: { borderRadius: 6 }, Input: { borderRadius: 6 },
}, },
@@ -0,0 +1,48 @@
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import type { Components } from 'react-markdown'
const components: Components = {
// 外部链接一律新窗口打开,避免观众被带离播放页
a: ({ node: _node, ...props }) => (
<a {...props} target="_blank" rel="noopener noreferrer" />
),
}
/**
* 安全的 Markdown 渲染。react-markdown 默认不输出 raw HTML,
* 简介中的 HTML 会被当作纯文本处理,不会执行,天然免疫 XSS。
*/
export default function Markdown({ content, className }: { content: string; className?: string }) {
return (
<div className={className || 'md-content'}>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
{content}
</ReactMarkdown>
</div>
)
}
/**
* 把 Markdown 摘要为纯文本,供列表卡片截断展示。
* 只做常见的标记剥离,足够生成一行预览。
*/
export function mdToText(source: string | undefined | null): string {
if (!source) return ''
return source
.replace(/```[\s\S]*?```/g, (block) => {
const firstLine = block.split('\n')[1]?.trim() ?? ''
return firstLine || ''
})
.replace(/`([^`]+)`/g, '$1')
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
.replace(/^#{1,6}\s+/gm, '')
.replace(/^>\s?/gm, '')
.replace(/^[-*+]\s+/gm, '')
.replace(/^\d+[.)]\s+/gm, '')
.replace(/(\*\*|__|\*|_|~~)/g, '')
.replace(/[ \t]+/g, ' ')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
@@ -0,0 +1,283 @@
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react'
import Hls from 'hls.js'
import {
FullscreenExitOutlined,
FullscreenOutlined,
PauseOutlined,
PlayCircleOutlined,
SoundOutlined,
} from '@ant-design/icons'
import type { Chapter, LineInfo, QualityInfo } from '../../api/streams'
import './player.css'
export interface VideoPlayerHandle {
seek: (seconds: number) => void
play: () => Promise<void>
pause: () => void
getVideo: () => HTMLVideoElement | null
}
interface VideoPlayerProps {
live: boolean
replayUrl?: string
lines: LineInfo[]
qualities: QualityInfo[]
activeLine: string
activeQuality: string
autoQuality?: string
chapters?: Chapter[]
onLineChange: (line: string) => void
onQualityChange: (quality: string) => void
onError?: (message: string) => void
}
function formatTime(seconds: number) {
if (!Number.isFinite(seconds)) return '00:00'
const value = Math.max(0, Math.floor(seconds))
const hours = Math.floor(value / 3600)
const minutes = Math.floor((value % 3600) / 60)
const secs = value % 60
return hours > 0
? `${hours}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`
: `${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`
}
const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(function VideoPlayer({
live,
replayUrl,
lines,
qualities,
activeLine,
activeQuality,
autoQuality,
chapters = [],
onLineChange,
onQualityChange,
onError,
}, ref) {
const rootRef = useRef<HTMLDivElement>(null)
const videoRef = useRef<HTMLVideoElement>(null)
const hlsRef = useRef<Hls | null>(null)
const objectUrlRef = useRef<string>()
const [loading, setLoading] = useState(false)
const [playing, setPlaying] = useState(false)
const [currentTime, setCurrentTime] = useState(0)
const [duration, setDuration] = useState(0)
const [buffered, setBuffered] = useState(0)
const [volume, setVolume] = useState(1)
const [fullscreen, setFullscreen] = useState(false)
useImperativeHandle(ref, () => ({
seek(seconds) {
if (videoRef.current) videoRef.current.currentTime = Math.max(0, seconds)
},
async play() {
await videoRef.current?.play()
},
pause() {
videoRef.current?.pause()
},
getVideo() {
return videoRef.current
},
}), [])
useEffect(() => {
const video = videoRef.current
if (!video) return
const update = () => {
setCurrentTime(video.currentTime || 0)
setDuration(Number.isFinite(video.duration) ? video.duration : 0)
const end = video.buffered.length ? video.buffered.end(video.buffered.length - 1) : 0
setBuffered(end)
}
const onPlay = () => setPlaying(true)
const onPause = () => setPlaying(false)
const onWaiting = () => setLoading(true)
const onPlaying = () => setLoading(false)
video.addEventListener('timeupdate', update)
video.addEventListener('durationchange', update)
video.addEventListener('progress', update)
video.addEventListener('play', onPlay)
video.addEventListener('pause', onPause)
video.addEventListener('waiting', onWaiting)
video.addEventListener('playing', onPlaying)
return () => {
video.removeEventListener('timeupdate', update)
video.removeEventListener('durationchange', update)
video.removeEventListener('progress', update)
video.removeEventListener('play', onPlay)
video.removeEventListener('pause', onPause)
video.removeEventListener('waiting', onWaiting)
video.removeEventListener('playing', onPlaying)
}
}, [])
useEffect(() => {
const listener = () => setFullscreen(document.fullscreenElement === rootRef.current)
document.addEventListener('fullscreenchange', listener)
return () => document.removeEventListener('fullscreenchange', listener)
}, [])
useEffect(() => {
const video = videoRef.current
if (!video) return
const controller = new AbortController()
let disposed = false
hlsRef.current?.destroy()
hlsRef.current = null
if (objectUrlRef.current) {
URL.revokeObjectURL(objectUrlRef.current)
objectUrlRef.current = undefined
}
video.removeAttribute('src')
video.load()
setLoading(Boolean(live || replayUrl))
onError?.('')
if (!live && replayUrl) {
void fetch(replayUrl, { signal: controller.signal })
.then(response => {
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return response.blob()
})
.then(blob => {
if (disposed) return
const objectUrl = URL.createObjectURL(blob)
objectUrlRef.current = objectUrl
video.src = objectUrl
video.load()
})
.catch(error => {
if (error instanceof DOMException && error.name === 'AbortError') return
setLoading(false)
onError?.('回放拉取失败,请检查远端地址及其 CORS 配置')
})
} else if (live) {
const line = lines.find(item => item.node_id === activeLine)
const label = activeQuality === 'auto' ? autoQuality : activeQuality
const quality = line?.qualities?.find(item => item.label === label)
const source = quality?.url || line?.url || line?.unicast_fallback
if (!source) {
setLoading(false)
onError?.('当前没有可用的播放地址')
} else if (Hls.isSupported()) {
const hls = new Hls({ enableWorker: true, lowLatencyMode: false })
hlsRef.current = hls
hls.loadSource(source)
hls.attachMedia(video)
hls.on(Hls.Events.MANIFEST_PARSED, () => setLoading(false))
hls.on(Hls.Events.ERROR, (_event, data) => {
if (data.fatal) {
setLoading(false)
onError?.('播放流加载失败,请切换线路或稍后重试')
}
})
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = source
video.load()
} else {
setLoading(false)
onError?.('当前浏览器不支持 HLS 播放')
}
}
return () => {
disposed = true
controller.abort()
hlsRef.current?.destroy()
hlsRef.current = null
if (objectUrlRef.current) {
URL.revokeObjectURL(objectUrlRef.current)
objectUrlRef.current = undefined
}
}
}, [activeLine, activeQuality, autoQuality, lines, live, onError, replayUrl])
const togglePlayback = () => {
const video = videoRef.current
if (!video) return
if (video.paused) void video.play().catch(() => onError?.('浏览器阻止了自动播放,请再次点击播放'))
else video.pause()
}
const seek = (value: number) => {
const video = videoRef.current
if (video) video.currentTime = value
}
const changeVolume = (value: number) => {
const video = videoRef.current
if (!video) return
video.volume = value
video.muted = value === 0
setVolume(value)
}
const toggleFullscreen = async () => {
if (!rootRef.current) return
if (document.fullscreenElement) await document.exitFullscreen()
else await rootRef.current.requestFullscreen()
}
return (
<div ref={rootRef} className="custom-player">
<video ref={videoRef} playsInline preload="metadata" onClick={togglePlayback} />
{loading && <div className="custom-player-loading" aria-label="媒体加载中"><span /></div>}
<div className="custom-player-controls">
<div className="custom-player-timeline">
<div className="custom-player-buffer" style={{ width: `${duration ? Math.min(100, buffered / duration * 100) : 0}%` }} />
<input
aria-label="播放进度"
type="range"
min={0}
max={duration || 0}
step="0.1"
value={Math.min(currentTime, duration || 0)}
onChange={event => seek(Number(event.target.value))}
/>
{duration > 0 && chapters.map((chapter, index) => (
<button
className="custom-player-chapter-mark"
key={`${chapter.time}-${index}`}
style={{ left: `${Math.min(100, chapter.time / duration * 100)}%` }}
title={chapter.title}
type="button"
onClick={() => seek(chapter.time)}
/>
))}
</div>
<div className="custom-player-row">
<button type="button" onClick={togglePlayback} aria-label={playing ? '暂停' : '播放'}>
{playing ? <PauseOutlined /> : <PlayCircleOutlined />}
</button>
<span className="custom-player-time">{formatTime(currentTime)} / {formatTime(duration)}</span>
<label className="custom-player-volume">
<SoundOutlined />
<input aria-label="音量" type="range" min={0} max={1} step="0.05" value={volume} onChange={event => changeVolume(Number(event.target.value))} />
</label>
<span className="custom-player-spacer" />
{live && lines.length > 1 && (
<select aria-label="播放线路" value={activeLine} onChange={event => onLineChange(event.target.value)}>
{lines.map(line => <option key={line.node_id} value={line.node_id}>{line.node_name}</option>)}
</select>
)}
{live && qualities.length > 0 && (
<select aria-label="清晰度" value={activeQuality} onChange={event => onQualityChange(event.target.value)}>
<option value="auto">自动{autoQuality ? ` (${autoQuality})` : ''}</option>
{qualities.map(quality => <option key={quality.label} value={quality.label}>{quality.label}</option>)}
</select>
)}
<button type="button" onClick={() => void toggleFullscreen()} aria-label={fullscreen ? '退出全屏' : '全屏'}>
{fullscreen ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
</button>
</div>
</div>
</div>
)
})
export default VideoPlayer
+169
View File
@@ -0,0 +1,169 @@
.custom-player {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
overflow: hidden;
background: #020408;
color: #f4f7fb;
}
.custom-player video {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
cursor: pointer;
}
.custom-player-loading {
position: absolute;
inset: 0;
display: grid;
place-items: center;
pointer-events: none;
background: rgba(2, 4, 8, 0.24);
}
.custom-player-loading span {
width: 38px;
height: 38px;
border: 3px solid rgba(255, 255, 255, 0.2);
border-top-color: #5b8cff;
border-radius: 50%;
animation: custom-player-spin 800ms linear infinite;
}
@keyframes custom-player-spin {
to { transform: rotate(360deg); }
}
.custom-player-controls {
position: absolute;
right: 0;
bottom: 0;
left: 0;
padding: 36px 16px 12px;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.86));
}
.custom-player-timeline {
position: relative;
height: 16px;
display: flex;
align-items: center;
}
.custom-player-timeline::before,
.custom-player-buffer {
position: absolute;
right: 0;
left: 0;
height: 4px;
border-radius: 999px;
content: '';
}
.custom-player-timeline::before {
background: rgba(255, 255, 255, 0.22);
}
.custom-player-buffer {
right: auto;
background: rgba(255, 255, 255, 0.46);
}
.custom-player-timeline input {
position: relative;
z-index: 2;
width: 100%;
margin: 0;
accent-color: #5b8cff;
cursor: pointer;
}
.custom-player-chapter-mark {
position: absolute;
z-index: 3;
width: 8px;
height: 8px;
padding: 0;
border: 2px solid #070a10;
border-radius: 50%;
background: #f6b94a;
transform: translateX(-50%);
cursor: pointer;
}
.custom-player-row {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.custom-player-row button {
display: grid;
width: 36px;
height: 36px;
padding: 0;
place-items: center;
border: 0;
border-radius: 8px;
background: transparent;
color: inherit;
font-size: 19px;
cursor: pointer;
}
.custom-player-row button:hover,
.custom-player-row button:focus-visible {
background: rgba(255, 255, 255, 0.12);
outline: none;
}
.custom-player-time {
min-width: 100px;
color: #e4e9f2;
font: 500 12px/1.2 Inter, sans-serif;
white-space: nowrap;
}
.custom-player-volume {
display: flex;
align-items: center;
gap: 7px;
}
.custom-player-volume input {
width: 76px;
accent-color: #5b8cff;
}
.custom-player-spacer {
flex: 1;
}
.custom-player-row select {
max-width: 150px;
height: 32px;
padding: 0 28px 0 9px;
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 8px;
background: rgba(10, 14, 22, 0.88);
color: #f4f7fb;
font-size: 12px;
}
.custom-player:fullscreen {
width: 100vw;
height: 100vh;
aspect-ratio: auto;
}
@media (max-width: 640px) {
.custom-player-controls { padding: 28px 10px 8px; }
.custom-player-volume input { display: none; }
.custom-player-time { min-width: 82px; font-size: 11px; }
.custom-player-row { gap: 4px; }
.custom-player-row select { max-width: 104px; }
}
+14 -22
View File
@@ -4,6 +4,7 @@ import { Skeleton } from 'antd'
import { SearchOutlined, VideoCameraOutlined } from '@ant-design/icons' import { SearchOutlined, VideoCameraOutlined } from '@ant-design/icons'
import { fetchStreams, type StreamInfo } from '../../api/streams' import { fetchStreams, type StreamInfo } from '../../api/streams'
import { useBrand } from '../../brand/BrandContext' import { useBrand } from '../../brand/BrandContext'
import { mdToText } from '../components/Markdown'
import './viewer.css' import './viewer.css'
export default function HomePage() { export default function HomePage() {
@@ -60,32 +61,23 @@ export default function HomePage() {
}) })
const visibleStreams = sorted.filter(stream => { const visibleStreams = sorted.filter(stream => {
const matchesFilter = filter === 'all' || stream.status === filter const matchesFilter = filter === 'all' || stream.status === filter
const haystack = `${stream.title} ${stream.description || ''}`.toLowerCase() const haystack = `${stream.title} ${mdToText(stream.description)}`.toLowerCase()
return matchesFilter && haystack.includes(query.trim().toLowerCase()) return matchesFilter && haystack.includes(query.trim().toLowerCase())
}) })
return ( return (
<div className="viewer-shell"> <div className="viewer-shell">
<header className="viewer-hero"> <header className="viewer-header">
<nav className="viewer-nav" aria-label="主导航"> <div className="viewer-header-brand">
<span>{brand.brand_name}</span> <div>
<button className="viewer-button viewer-button--ghost" type="button" onClick={() => document.getElementById('programmes')?.scrollIntoView()}> <h1>{brand.brand_name}</h1>
节目目录 {brand.brand_tagline && <p>{brand.brand_tagline}</p>}
</button> </div>
</nav> </div>
<div className="viewer-eyebrow">LIVE PROGRAMME</div> <div className="viewer-live-summary">
{brand.brand_logo_url && ( <span className="viewer-live-dot" />
<img src={brand.brand_logo_url} alt={brand.brand_name} {sorted.filter(stream => stream.status === 'live').length} 个直播中
className="viewer-logo" /> </div>
)}
<h1 className="viewer-title">
{brand.brand_name}
</h1>
{brand.brand_tagline && (
<p className="viewer-tagline">
{brand.brand_tagline}
</p>
)}
</header> </header>
<main className="viewer-main" id="programmes"> <main className="viewer-main" id="programmes">
@@ -187,7 +179,7 @@ export default function HomePage() {
{s.title} {s.title}
</h3> </h3>
<p> <p>
{s.description || '暂无简介'} {mdToText(s.description) || '暂无简介'}
</p> </p>
</div> </div>
</button> </button>
+29 -79
View File
@@ -1,8 +1,9 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef, useCallback } from 'react'
import { useParams, useNavigate } from 'react-router-dom' import { useParams, useNavigate } from 'react-router-dom'
import { Select, Button, Typography, Tag, Spin, Empty, Alert } from 'antd' import { Button, Typography, Tag, Spin, Empty, Alert } from 'antd'
import { ArrowLeftOutlined, WifiOutlined, SignalFilled } from '@ant-design/icons' import { ArrowLeftOutlined, SignalFilled } from '@ant-design/icons'
import Hls from 'hls.js' import VideoPlayer, { type VideoPlayerHandle } from '../components/VideoPlayer'
import Markdown from '../components/Markdown'
import { import {
getStreamStatusUrl, getStreamStatusUrl,
getWatchUrls, getWatchUrls,
@@ -21,7 +22,8 @@ export default function WatchPage() {
const { brand } = useBrand() const { brand } = useBrand()
const navigate = useNavigate() const navigate = useNavigate()
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
const videoRef = useRef<HTMLVideoElement>(null) const playerRef = useRef<VideoPlayerHandle>(null)
const getVideo = useCallback(() => playerRef.current?.getVideo() ?? 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 [loadError, setLoadError] = useState('')
@@ -35,7 +37,6 @@ export default function WatchPage() {
const [remoteAdjusted, setRemoteAdjusted] = useState(false) const [remoteAdjusted, setRemoteAdjusted] = useState(false)
const [showPlaybackStats, setShowPlaybackStats] = useState(false) const [showPlaybackStats, setShowPlaybackStats] = useState(false)
const [playbackStats, setPlaybackStats] = useState({ buffered: 0, currentTime: 0, droppedFrames: 0, resolution: '未知' }) const [playbackStats, setPlaybackStats] = useState({ buffered: 0, currentTime: 0, droppedFrames: 0, resolution: '未知' })
const hlsRef = useRef<Hls | null>(null)
const viewerSessionRef = useRef<string>('') const viewerSessionRef = useRef<string>('')
const currentLine = info?.lines.find(line => line.node_id === activeLine) const currentLine = info?.lines.find(line => line.node_id === activeLine)
@@ -52,7 +53,7 @@ export default function WatchPage() {
if (!Number.isInteger(streamId) || streamId <= 0 || !info) return if (!Number.isInteger(streamId) || streamId <= 0 || !info) return
const heartbeat = async () => { const heartbeat = async () => {
const video = videoRef.current const video = getVideo()
const bufferedSeconds = video && video.buffered.length const bufferedSeconds = video && video.buffered.length
? Math.max(0, video.buffered.end(video.buffered.length - 1) - video.currentTime) ? Math.max(0, video.buffered.end(video.buffered.length - 1) - video.currentTime)
: 0 : 0
@@ -180,61 +181,6 @@ export default function WatchPage() {
return enabled[enabled.length - 1] return enabled[enabled.length - 1]
} }
useEffect(() => {
const video = videoRef.current
if (!info || !video) return
setPlaybackError('')
const previousTime = Number.isFinite(video.currentTime) ? video.currentTime : 0
const wasPaused = video.paused
const restorePlaybackState = () => {
if (previousTime > 0 && Number.isFinite(video.duration)) {
video.currentTime = Math.min(previousTime, Math.max(0, video.duration - 0.1))
}
if (!wasPaused) video.play().catch(() => {})
}
hlsRef.current?.destroy()
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 selectedLabel = activeQuality === 'auto' ? (autoQuality || line.qualities?.[0]?.label) : activeQuality
const quality = selectedLabel ? line.qualities?.find(q => q.label === selectedLabel) : 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(source)
hi.attachMedia(video)
hi.on(Hls.Events.MANIFEST_PARSED, restorePlaybackState)
hi.on(Hls.Events.ERROR, (_e, detail) => {
if (detail.fatal) setPlaybackError('播放流加载失败,请尝试切换线路或稍后重试')
})
hlsRef.current = hi
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = source
video.addEventListener('loadedmetadata', restorePlaybackState, { once: true })
video.load()
} else {
setPlaybackError('当前浏览器不支持此播放格式')
}
return () => {
hlsRef.current?.destroy()
hlsRef.current = null
}
}, [activeLine, activeQuality, autoQuality, info])
useEffect(() => { useEffect(() => {
if (!info?.qualities.length) return if (!info?.qualities.length) return
@@ -242,7 +188,7 @@ export default function WatchPage() {
}, [info]) }, [info])
useEffect(() => { useEffect(() => {
const video = videoRef.current const video = getVideo()
if (!video || !info || activeQuality !== 'auto') return if (!video || !info || activeQuality !== 'auto') return
const qualityLabels = info.qualities.map(quality => quality.label) const qualityLabels = info.qualities.map(quality => quality.label)
@@ -292,7 +238,7 @@ export default function WatchPage() {
}, [activeQuality, info]) }, [activeQuality, info])
useEffect(() => { useEffect(() => {
const video = videoRef.current const video = getVideo()
if (!video) return if (!video) return
const updateStats = () => { const updateStats = () => {
const buffered = video.buffered.length const buffered = video.buffered.length
@@ -312,9 +258,8 @@ export default function WatchPage() {
}, [activeLine, activeQuality, autoQuality]) }, [activeLine, activeQuality, autoQuality])
function jumpToChapter(time: number) { function jumpToChapter(time: number) {
if (!videoRef.current) return playerRef.current?.seek(time)
videoRef.current.currentTime = time void playerRef.current?.play().catch(() => {})
videoRef.current.play().catch(() => {})
} }
useEffect(() => { document.title = info ? `${info.title} - ${brand.brand_name}` : brand.brand_name }, [info, brand]) useEffect(() => { document.title = info ? `${info.title} - ${brand.brand_name}` : brand.brand_name }, [info, brand])
@@ -354,14 +299,6 @@ export default function WatchPage() {
<SignalFilled style={{ color: info.multicast_available ? '#5B8C5A' : '#5E5A52', fontSize: 12 }} /> <SignalFilled style={{ color: info.multicast_available ? '#5B8C5A' : '#5E5A52', fontSize: 12 }} />
{info.multicast_available ? '组播' : '单播'} {info.multicast_available ? '组播' : '单播'}
</Text> </Text>
<Select value={activeQuality || undefined} onChange={setActiveQuality} className="watch-quality" size="small"
options={[
{ value: 'auto', label: `自动(${autoQuality || '检测中'})` },
...info.qualities.map(q => ({ value: q.label, label: q.label })),
]} placeholder="清晰度" />
<Select value={activeLine || undefined} onChange={setActiveLine} className="watch-line" size="small"
options={info.lines.map(l => ({ value: l.node_id, label: l.node_name }))}
prefix={<WifiOutlined />} placeholder="线路" />
</div> </div>
</header> </header>
@@ -369,17 +306,30 @@ export default function WatchPage() {
<div className="watch-content"> <div className="watch-content">
<div className="watch-frame"> <div className="watch-frame">
<div className="watch-player" onContextMenu={event => { event.preventDefault(); setShowPlaybackStats(value => !value) }}> <div className="watch-player" onContextMenu={event => { event.preventDefault(); setShowPlaybackStats(value => !value) }}>
<video ref={videoRef} controls autoPlay playsInline className="watch-video" /> <VideoPlayer
ref={playerRef}
live={info.status === 'live'}
replayUrl={info.status !== 'live' ? (info.replay_url || info.replay_local_url) : undefined}
lines={info.lines}
qualities={info.qualities}
activeLine={activeLine}
activeQuality={activeQuality}
autoQuality={autoQuality}
chapters={info.chapters}
onLineChange={setActiveLine}
onQualityChange={setActiveQuality}
onError={setPlaybackError}
/>
{showPlaybackStats && ( {showPlaybackStats && (
<section className="playback-stats" aria-label="播放统计信息"> <section className="playback-stats" aria-label="播放统计信息">
<div><span>播放状态</span><strong>{videoRef.current?.paused ? '暂停' : '播放中'}</strong></div> <div><span>播放状态</span><strong>{getVideo()?.paused ? '暂停' : '播放中'}</strong></div>
<div><span>线路</span><strong>{currentLine?.node_name || '不可用'}</strong></div> <div><span>线路</span><strong>{currentLine?.node_name || '不可用'}</strong></div>
<div><span>清晰度</span><strong>{activeQuality === 'auto' ? `自动(${autoQuality || '检测中'})` : activeQuality}</strong></div> <div><span>清晰度</span><strong>{activeQuality === 'auto' ? `自动(${autoQuality || '检测中'})` : activeQuality}</strong></div>
<div><span>分辨率</span><strong>{playbackStats.resolution}</strong></div> <div><span>分辨率</span><strong>{playbackStats.resolution}</strong></div>
<div><span>时间码</span><strong>{playbackStats.currentTime.toFixed(1)} 秒</strong></div> <div><span>时间码</span><strong>{playbackStats.currentTime.toFixed(1)} 秒</strong></div>
<div><span>缓冲</span><strong>{playbackStats.buffered.toFixed(1)} 秒</strong></div> <div><span>缓冲</span><strong>{playbackStats.buffered.toFixed(1)} 秒</strong></div>
<div><span>丢帧</span><strong>{playbackStats.droppedFrames}</strong></div> <div><span>丢帧</span><strong>{playbackStats.droppedFrames}</strong></div>
<div className="playback-stats-url"><span>播放地址</span><strong>{videoRef.current?.currentSrc || '加载中'}</strong></div> <div className="playback-stats-url"><span>播放地址</span><strong>{getVideo()?.currentSrc || '加载中'}</strong></div>
</section> </section>
)} )}
</div> </div>
@@ -400,7 +350,7 @@ export default function WatchPage() {
</div> </div>
{(info.description || info.chapters.length > 0) && ( {(info.description || info.chapters.length > 0) && (
<aside className="watch-details"> <aside className="watch-details">
{info.description && <p>{info.description}</p>} {info.description && <Markdown content={info.description} />}
{info.chapters.length > 0 && ( {info.chapters.length > 0 && (
<section> <section>
<h2>节目章节</h2> <h2>节目章节</h2>
+69
View File
@@ -1,4 +1,6 @@
.viewer-shell { .viewer-shell {
color: #F4F7FB;
background: radial-gradient(circle at 80% 0%, rgba(91,140,255,.16), transparent 32rem), #070A10;
min-height: 100vh; min-height: 100vh;
width: 100%; width: 100%;
max-width: 100vw; max-width: 100vw;
@@ -577,3 +579,70 @@ button.stream-card:focus-visible {
animation-iteration-count: 1 !important; animation-iteration-count: 1 !important;
} }
} }
/* Unified dark viewer system and compact homepage header */
.viewer-shell {
min-height: 100vh;
color: #f4f7fb;
background: #070a10;
}
.viewer-header {
max-width: 1440px;
margin: 0 auto;
min-height: 76px;
padding: 14px 28px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
border-bottom: 1px solid #1d2636;
}
.viewer-header-brand { display: flex; align-items: center; gap: 12px; min-width: 0; }
.viewer-header-logo { width: 42px; height: 42px; border-radius: 12px; object-fit: cover; }
.viewer-header h1 { margin: 0; color: #f4f7fb; font-size: 20px; line-height: 1.2; }
.viewer-header p { margin: 3px 0 0; color: #748096; font-size: 13px; }
.viewer-live-summary { display: flex; align-items: center; gap: 8px; color: #aab4c5; font-size: 13px; white-space: nowrap; }
.viewer-live-dot { width: 8px; height: 8px; border-radius: 50%; background: #ff647c; box-shadow: 0 0 0 5px rgba(255,100,124,.12); }
.viewer-main { max-width: 1440px; margin: 0 auto; padding: 24px 28px 48px; }
.viewer-section-heading { margin-bottom: 18px; }
.viewer-section-heading h2 { color: #f4f7fb; }
.viewer-section-heading span { color: #748096; }
.viewer-directory-tools { margin-bottom: 22px; }
.viewer-search { background: #10151f; border-color: #273044; color: #748096; box-shadow: none; }
.viewer-search:focus-within { border-color: #5b8cff; box-shadow: 0 0 0 3px rgba(91,140,255,.14); }
.viewer-search input { color: #f4f7fb; }
.viewer-search input::placeholder { color: #748096; }
.viewer-filter { color: #aab4c5; }
.viewer-filter:hover { color: #f4f7fb; border-color: #273044; background: #171d29; }
.viewer-filter.is-active { color: #fff; border-color: #5b8cff; background: #5b8cff; }
.stream-card, .stream-card button { background: #10151f; color: #f4f7fb; border-color: #273044; }
.stream-card:hover { border-color: #3b4a66; box-shadow: 0 18px 44px rgba(0,0,0,.28); }
.stream-copy h3 { color: #f4f7fb; }
.stream-copy p { color: #748096; }
.viewer-footer { color: #748096; border-color: #1d2636; }
@media (max-width: 640px) {
.viewer-header { min-height: 68px; padding: 12px 16px; }
.viewer-header p { display: none; }
.viewer-main { padding: 18px 16px 36px; }
.viewer-directory-tools { align-items: stretch; }
}
/* Final dark-theme normalization for player surfaces */
.watch-shell, .watch-page { background: #070a10; color: #f4f7fb; }
.watch-header { background: #10151f; border-color: #273044; }
.watch-title, .watch-details h2 { color: #f4f7fb; }
.watch-main, .watch-stage { background: #070a10; }
.watch-player { background: #020408; border-color: #273044; box-shadow: 0 20px 60px rgba(0,0,0,.35); }
.playback-stats { background: rgba(10,14,22,.94); color: #f4f7fb; border-color: #273044; }
.playback-stats span, .watch-waiting .ant-typography, .chapter-list time { color: #748096; }
.watch-waiting { background: rgba(7,10,16,.94); }
.watch-details { background: #10151f; border-color: #273044; border-radius: 16px; }
.watch-details p { color: #aab4c5; }
.chapter-list button { background: #171d29; color: #f4f7fb; border-color: #273044; border-radius: 10px; }
.chapter-list button:hover { background: #1d2636; border-color: #5b8cff; box-shadow: 0 8px 24px rgba(0,0,0,.24); }
.chapter-list button:focus-visible { border-color: #5b8cff; box-shadow: 0 0 0 3px rgba(91,140,255,.18); }