diff --git a/backend/config.py b/backend/config.py index 1918c81..eac1292 100644 --- a/backend/config.py +++ b/backend/config.py @@ -17,6 +17,7 @@ class Settings(BaseSettings): media_origin_api_port: int = 9997 media_origin_hls_port: int = 8888 replay_media_dir: str = str(APP_DIR.parent / "media") + hls_runtime_dir: str = str(APP_DIR.parent / "runtime" / "hls") hls_segment_seconds: int = 4 edge_nodes: list[dict] = [ diff --git a/backend/database.py b/backend/database.py index 18cdaba..5a11a8b 100644 --- a/backend/database.py +++ b/backend/database.py @@ -23,6 +23,7 @@ async def init_db(): if settings.database_url.startswith("sqlite"): columns = { "replay_file": "VARCHAR(500)", + "replay_url": "VARCHAR(1000)", "chapters_json": "TEXT", "transcode_profile": "VARCHAR(64) NOT NULL DEFAULT 'standard'", "renditions_json": "TEXT", diff --git a/backend/main.py b/backend/main.py index 2a09ac4..c88386e 100644 --- a/backend/main.py +++ b/backend/main.py @@ -9,8 +9,11 @@ import httpx import asyncio import time import psutil -from database import init_db +from database import init_db, session_factory 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.settings_route import router as settings_router from config import settings @@ -60,6 +63,16 @@ async def monitor_origin(): async def lifespan(app: FastAPI): load_nodes() 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()) yield origin_monitor.cancel() diff --git a/backend/models/stream.py b/backend/models/stream.py index a806a55..ec05957 100644 --- a/backend/models/stream.py +++ b/backend/models/stream.py @@ -21,6 +21,7 @@ class Stream(Base): description: Mapped[str | None] = mapped_column(Text, 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_url: Mapped[str | None] = mapped_column(String(1000), nullable=True) chapters_json: Mapped[str | None] = mapped_column(Text, nullable=True) transcode_profile: Mapped[str] = mapped_column(String(64), default="standard") renditions_json: Mapped[str | None] = mapped_column(Text, nullable=True) diff --git a/backend/routes/streams.py b/backend/routes/streams.py index d1831fe..f52513b 100644 --- a/backend/routes/streams.py +++ b/backend/routes/streams.py @@ -43,6 +43,7 @@ class StreamCreate(BaseModel): cover_url: Optional[str] = None scheduled_at: Optional[str] = None replay_file: Optional[str] = None + replay_url: Optional[str] = None chapters: list[dict] = Field(default_factory=list) transcode_profile: str = "standard" renditions: Optional[list[RenditionInput]] = None @@ -56,6 +57,7 @@ class StreamUpdate(BaseModel): cover_url: Optional[str] = None scheduled_at: Optional[str] = None replay_file: Optional[str] = None + replay_url: Optional[str] = None chapters: Optional[list[dict]] = None transcode_profile: Optional[str] = None renditions: Optional[list[RenditionInput]] = None @@ -108,11 +110,23 @@ def _media_path(filename: str | None) -> Path | None: 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: return { "stream_id": stream.id, "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 "[]"), } @@ -169,6 +183,7 @@ def _serialize(s, include_private: bool = True): "ended_at": str(s.ended_at) if s.ended_at else None, "multicast_enabled": s.multicast_enabled, "replay_file": s.replay_file, + "replay_url": s.replay_url, "chapters": json.loads(s.chapters_json or "[]"), "transcode_profile": s.transcode_profile, "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: raise HTTPException(status_code=422, detail="节目标题不能为空") _media_path(body.replay_file) + _valid_replay_url(body.replay_url) if body.transcode_profile not in settings.transcode_profiles: 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 @@ -328,6 +344,7 @@ async def create_stream(body: StreamCreate, db: AsyncSession = Depends(get_db), cover_url=body.cover_url, scheduled_at=body.scheduled_at, replay_file=body.replay_file, + replay_url=body.replay_url, chapters=body.chapters, transcode_profile=body.transcode_profile, 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) if not existing: 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} - if ("replay_file" in updates or "chapters" in updates) and existing.status.value != "ended": + updates = body.model_dump(exclude_unset=True) + replay_fields = {"replay_file", "replay_url", "chapters"} + if replay_fields.intersection(updates) and existing.status.value != "ended": raise HTTPException(status_code=409, detail="仅已结束的节目可以配置回放和章节") - if "replay_file" in updates: + + if updates.get("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: raise HTTPException(status_code=422, detail="转码配置不存在") 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 not updates.get("replay_file") and not existing.replay_file: - raise HTTPException(status_code=409, detail="请先选择回放文件再编辑章节") - chapters = _normalize_chapters(updates.pop("chapters")) - updates["chapters_json"] = json.dumps(chapters, ensure_ascii=False) + chapters = updates.pop("chapters") + if chapters and not (final_replay_file or final_replay_url): + raise HTTPException(status_code=409, detail="请先配置回放来源再编辑章节") + updates["chapters_json"] = json.dumps(_normalize_chapters(chapters or []), ensure_ascii=False) if updates: await svc.update_stream(stream_id, **updates) return {"ok": True} @@ -413,7 +444,8 @@ async def get_watch_urls(stream_id: int, request: Request, db: AsyncSession = De "qualities": urls["qualities"], "description": stream.description, "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 "[]"), "remote_control_enabled": stream.remote_control_enabled, } diff --git a/backend/services/stream_service.py b/backend/services/stream_service.py index 1ad8603..7b5bb9d 100644 --- a/backend/services/stream_service.py +++ b/backend/services/stream_service.py @@ -29,6 +29,7 @@ class StreamService: cover_url: str | None = None, scheduled_at: str | None = None, replay_file: str | None = None, + replay_url: str | None = None, chapters: list[dict] | None = None, transcode_profile: str = "standard", homepage_visible: bool = True, @@ -45,6 +46,7 @@ class StreamService: description=description, cover_url=cover_url, replay_file=replay_file, + replay_url=replay_url, chapters_json=json.dumps(chapters or [], ensure_ascii=False), transcode_profile=transcode_profile, homepage_visible=homepage_visible, diff --git a/backend/tests/test_replay_workflow.py b/backend/tests/test_replay_workflow.py index 56f10ac..d71bb85 100644 --- a/backend/tests/test_replay_workflow.py +++ b/backend/tests/test_replay_workflow.py @@ -49,7 +49,7 @@ class ReplayWorkflowTest(unittest.TestCase): _normalize_chapters(chapters) 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"): stream.status = SimpleNamespace(value=status) self.assertEqual(_status_payload(stream)["status"], status) @@ -57,5 +57,19 @@ class ReplayWorkflowTest(unittest.TestCase): stream.replay_file = "session.mp4" stream.chapters_json = json.dumps([{"title": "Intro", "time": 0}]) 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}]) + + 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") diff --git a/edge-agent/build.sh b/edge-agent/build.sh old mode 100755 new mode 100644 diff --git a/edge-agent/install-autostart.sh b/edge-agent/install-autostart.sh old mode 100755 new mode 100644 diff --git a/frontend/package.json b/frontend/package.json index c0716f0..9f12799 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,7 +15,9 @@ "hls.js": "^1.5.14", "react": "^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": { "@types/react": "^18.3.8", diff --git a/frontend/src/admin/AdminLayout.tsx b/frontend/src/admin/AdminLayout.tsx index 848775d..7ef7dc6 100644 --- a/frontend/src/admin/AdminLayout.tsx +++ b/frontend/src/admin/AdminLayout.tsx @@ -47,25 +47,20 @@ export default function AdminLayout() { const handleLogout = () => { clearToken(); navigate('/admin/login') } return ( - + -
+
+ {brand.brand_name.slice(0, 1)} - {collapsed ? brand.brand_name.slice(0, 2) : brand.brand_name} + {brand.brand_name}
-
- {brand.brand_tagline} +
+
直播控制中心{brand.brand_tagline}
- +
@@ -89,4 +80,4 @@ export default function AdminLayout() { ) -} \ No newline at end of file +} diff --git a/frontend/src/admin/LoginPage.tsx b/frontend/src/admin/LoginPage.tsx index 041d540..24f6550 100644 --- a/frontend/src/admin/LoginPage.tsx +++ b/frontend/src/admin/LoginPage.tsx @@ -27,9 +27,9 @@ export default function LoginPage() { }}>
diff --git a/frontend/src/admin/pages/Dashboard.tsx b/frontend/src/admin/pages/Dashboard.tsx index e035275..2f58c0d 100644 --- a/frontend/src/admin/pages/Dashboard.tsx +++ b/frontend/src/admin/pages/Dashboard.tsx @@ -36,19 +36,21 @@ export default function Dashboard() { ] return ( -
-
+
+
{brand.brand_name} - 管理控制台 + 管理控制台 · 实时刷新
-
最近节目 diff --git a/frontend/src/admin/pages/NodeManager.tsx b/frontend/src/admin/pages/NodeManager.tsx index c858374..55a751d 100644 --- a/frontend/src/admin/pages/NodeManager.tsx +++ b/frontend/src/admin/pages/NodeManager.tsx @@ -89,24 +89,24 @@ export default function NodeManager() { {strategy && ( -
+
-
+
-
+
/ {nodes.length}} valueStyle={{ color: onlineCount > 0 ? '#5B8C5A' : '#B54A4A', fontSize: 20 }} />
-
+
0 ? '#B54A4A' : '#949088', fontSize: 20 }} prefix={(nodes.length - onlineCount) > 0 ? : undefined} /> @@ -115,7 +115,7 @@ export default function NodeManager() { )} -
+
节点列表 diff --git a/frontend/src/admin/pages/ReplayEditor.tsx b/frontend/src/admin/pages/ReplayEditor.tsx index 07dc2de..9a86517 100644 --- a/frontend/src/admin/pages/ReplayEditor.tsx +++ b/frontend/src/admin/pages/ReplayEditor.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react' 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 { fetchMediaFiles, getStream, updateStream, type Chapter, type StreamInfo } from '../../api/streams' @@ -12,7 +12,13 @@ export default function ReplayEditor() { const videoRef = useRef(null) const [stream, setStream] = useState(null) const [mediaFiles, setMediaFiles] = useState([]) + const [replaySource, setReplaySource] = useState<'local' | 'remote'>('local') const [replayFile, setReplayFile] = useState() + const [replayUrl, setReplayUrl] = useState('') + const [previewUrl, setPreviewUrl] = useState() + const [previewLoading, setPreviewLoading] = useState(false) + const [previewError, setPreviewError] = useState('') + const previewObjectUrlRef = useRef() const [chapters, setChapters] = useState([]) const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) @@ -23,7 +29,9 @@ export default function ReplayEditor() { Promise.all([getStream(streamId), fetchMediaFiles()]) .then(([program, files]) => { setStream(program) - setReplayFile(program.replay_file) + setReplaySource(program.replay_url ? 'remote' : 'local') + setReplayFile(program.replay_file || undefined) + setReplayUrl(program.replay_url || '') setChapters(program.chapters || []) setMediaFiles(files) }) @@ -31,6 +39,60 @@ export default function ReplayEditor() { .finally(() => setLoading(false)) }, [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) => { setChapters(current => { const target = index + offset diff --git a/frontend/src/admin/pages/Settings.css b/frontend/src/admin/pages/Settings.css index 2c63e8c..3cd1e1b 100644 --- a/frontend/src/admin/pages/Settings.css +++ b/frontend/src/admin/pages/Settings.css @@ -1,5 +1,5 @@ .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; } .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%; } diff --git a/frontend/src/admin/pages/StreamForm.tsx b/frontend/src/admin/pages/StreamForm.tsx index 6223ddc..b8d2f29 100644 --- a/frontend/src/admin/pages/StreamForm.tsx +++ b/frontend/src/admin/pages/StreamForm.tsx @@ -45,7 +45,7 @@ export default function StreamForm() { 将以下信息提供给推流端即可开始直播
-
+
推流码 @@ -78,7 +78,7 @@ export default function StreamForm() { -
+
创建节目 填写节目信息并预约播出,系统将生成推流码与 RTMP 地址 @@ -138,7 +138,7 @@ export default function StreamForm() { -
+
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); } diff --git a/frontend/src/pageTransitions.css b/frontend/src/pageTransitions.css index aad2a30..ab18fce 100644 --- a/frontend/src/pageTransitions.css +++ b/frontend/src/pageTransitions.css @@ -15,15 +15,15 @@ display: grid; place-items: center; border-radius: 24px; - background: linear-gradient(135deg, rgba(255,255,255,0.92), rgba(248,244,238,0.78)); + background: #070a10; } .route-loading span { width: 28px; height: 28px; border-radius: 999px; - border: 3px solid rgba(194, 105, 58, 0.2); - border-top-color: #C2693A; + border: 3px solid rgba(91, 140, 255, 0.18); + border-top-color: #5B8CFF; animation: route-loading-spin 760ms linear infinite; } @@ -77,3 +77,40 @@ 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; + } +} diff --git a/frontend/src/theme/anthropic.ts b/frontend/src/theme/anthropic.ts index ef36cc4..7538969 100644 --- a/frontend/src/theme/anthropic.ts +++ b/frontend/src/theme/anthropic.ts @@ -2,27 +2,27 @@ import type { ThemeConfig } from 'antd' const theme: ThemeConfig = { token: { - fontFamily: `"Source Serif 4", "Noto Serif SC", Georgia, serif`, - fontSize: 15, - lineHeight: 1.65, - colorPrimary: '#C2693A', - colorSuccess: '#5B8C5A', - colorWarning: '#C28B3A', - colorError: '#B54A4A', - colorInfo: '#6B8CA8', - colorTextBase: '#1C1A17', - colorTextSecondary: '#5E5A52', - colorTextTertiary: '#949088', - colorBgBase: '#F9F7F4', - colorBgContainer: '#FFFFFF', - colorBgLayout: '#F9F7F4', - colorBgElevated: '#FAFAF8', - colorBorder: '#EBE5DB', - colorBorderSecondary: '#F3EFE9', - colorFill: '#F3F0EC', - colorFillSecondary: '#F7F5F1', - borderRadius: 6, - borderRadiusLG: 10, + fontFamily: `Inter, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif`, + fontSize: 14, + lineHeight: 1.6, + colorPrimary: '#5B8CFF', + colorSuccess: '#2DD4A7', + colorWarning: '#F6B94A', + colorError: '#FF647C', + colorInfo: '#62B5FF', + colorTextBase: '#F4F7FB', + colorTextSecondary: '#AAB4C5', + colorTextTertiary: '#748096', + colorBgBase: '#070A10', + colorBgContainer: '#10151F', + colorBgLayout: '#070A10', + colorBgElevated: '#171D29', + colorBorder: '#273044', + colorBorderSecondary: '#1D2636', + colorFill: '#1D2432', + colorFillSecondary: '#171D29', + borderRadius: 10, + borderRadiusLG: 16, borderRadiusSM: 4, wireframe: false, fontWeightStrong: 600, @@ -38,18 +38,18 @@ const theme: ThemeConfig = { }, components: { Layout: { - headerBg: '#FFFFFF', + headerBg: '#10151F', headerHeight: 60, headerPadding: '0 48px', - siderBg: '#FFFFFF', - bodyBg: '#F9F7F4', + siderBg: '#10151F', + bodyBg: '#070A10', }, Menu: { itemBg: 'transparent', - itemSelectedBg: '#F3F0EC', - itemSelectedColor: '#1C1A17', - itemActiveBg: '#F7F5F1', - itemColor: '#5E5A52', + itemSelectedBg: '#1D2636', + itemSelectedColor: '#F4F7FB', + itemActiveBg: '#171D29', + itemColor: '#AAB4C5', itemHeight: 44, }, Card: { @@ -62,14 +62,14 @@ const theme: ThemeConfig = { primaryShadow: '0 2px 6px rgba(194,105,58,0.1)', }, Table: { - headerBg: '#F7F5F1', - headerColor: '#5E5A52', - rowHoverBg: '#F5F3F0', - borderColor: '#EBE5DB', + headerBg: '#171D29', + headerColor: '#AAB4C5', + rowHoverBg: '#171D29', + borderColor: '#273044', }, Tag: { borderRadiusSM: 4 }, Modal: { borderRadiusLG: 12, paddingLG: 28 }, - Tabs: { inkBarColor: '#C2693A', itemActiveColor: '#C2693A', itemHoverColor: '#D4A782' }, + Tabs: { inkBarColor: '#5B8CFF', itemActiveColor: '#5B8CFF', itemHoverColor: '#8AABFF' }, Select: { borderRadius: 6 }, Input: { borderRadius: 6 }, }, diff --git a/frontend/src/viewer/components/Markdown.tsx b/frontend/src/viewer/components/Markdown.tsx new file mode 100644 index 0000000..c8853f8 --- /dev/null +++ b/frontend/src/viewer/components/Markdown.tsx @@ -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 }) => ( + + ), +} + +/** + * 安全的 Markdown 渲染。react-markdown 默认不输出 raw HTML, + * 简介中的 HTML 会被当作纯文本处理,不会执行,天然免疫 XSS。 + */ +export default function Markdown({ content, className }: { content: string; className?: string }) { + return ( +
+ + {content} + +
+ ) +} + +/** + * 把 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() +} diff --git a/frontend/src/viewer/components/VideoPlayer.tsx b/frontend/src/viewer/components/VideoPlayer.tsx new file mode 100644 index 0000000..2fbbe42 --- /dev/null +++ b/frontend/src/viewer/components/VideoPlayer.tsx @@ -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 + 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(function VideoPlayer({ + live, + replayUrl, + lines, + qualities, + activeLine, + activeQuality, + autoQuality, + chapters = [], + onLineChange, + onQualityChange, + onError, +}, ref) { + const rootRef = useRef(null) + const videoRef = useRef(null) + const hlsRef = useRef(null) + const objectUrlRef = useRef() + 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 ( +
+
@@ -369,17 +306,30 @@ export default function WatchPage() {
{ event.preventDefault(); setShowPlaybackStats(value => !value) }}> -
@@ -400,7 +350,7 @@ export default function WatchPage() {
{(info.description || info.chapters.length > 0) && (