feat: improve replay workflow and viewer UI
Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
28 files changed
+1496
-227
No files matched your search
@@ -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] = [
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
File mode changed.
Executable → Regular
File mode changed.
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -89,4 +80,4 @@ export default function AdminLayout() {
|
|||||||
</Layout>
|
</Layout>
|
||||||
</Layout>
|
</Layout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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 }}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,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%; }
|
||||||
|
|||||||
@@ -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 }}>
|
||||||
创建节目
|
创建节目
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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); }
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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); }
|
||||||
Reference in new issue
Block a user