完善节目管理与回放播放体验
This commit is contained in:
1 parent
b265518084
commit
a493ef2309
14 files changed
+444
-94
No files matched your search
@@ -10,6 +10,8 @@ class Settings(BaseSettings):
|
|||||||
media_origin_rtmp_port: int = 1935
|
media_origin_rtmp_port: int = 1935
|
||||||
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 = "/workspace/media"
|
||||||
|
hls_segment_seconds: int = 4
|
||||||
|
|
||||||
edge_nodes: list[dict] = [
|
edge_nodes: list[dict] = [
|
||||||
{
|
{
|
||||||
@@ -34,6 +36,15 @@ class Settings(BaseSettings):
|
|||||||
{"label": "360p", "width": 640, "height": 360, "bitrate": "600k"},
|
{"label": "360p", "width": 640, "height": 360, "bitrate": "600k"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
transcode_profiles: dict[str, list[dict]] = {
|
||||||
|
"standard": qualities,
|
||||||
|
"bandwidth-saver": [
|
||||||
|
{"label": "720p", "width": 1280, "height": 720, "bitrate": "1800k"},
|
||||||
|
{"label": "480p", "width": 854, "height": 480, "bitrate": "800k"},
|
||||||
|
{"label": "360p", "width": 640, "height": 360, "bitrate": "450k"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
multicast_enabled: bool = True
|
multicast_enabled: bool = True
|
||||||
multicast_address_base: str = "239.0.0.1"
|
multicast_address_base: str = "239.0.0.1"
|
||||||
multicast_port_base: int = 5000
|
multicast_port_base: int = 5000
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
|
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
|
||||||
from sqlalchemy.orm import DeclarativeBase
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
from config import settings
|
from config import settings
|
||||||
|
|
||||||
@@ -19,3 +20,16 @@ async def get_db() -> AsyncSession:
|
|||||||
async def init_db():
|
async def init_db():
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
if settings.database_url.startswith("sqlite"):
|
||||||
|
columns = {
|
||||||
|
"replay_file": "VARCHAR(500)",
|
||||||
|
"chapters_json": "TEXT",
|
||||||
|
"transcode_profile": "VARCHAR(64) NOT NULL DEFAULT 'standard'",
|
||||||
|
"homepage_visible": "BOOLEAN NOT NULL DEFAULT 1",
|
||||||
|
"remote_control_enabled": "BOOLEAN NOT NULL DEFAULT 0",
|
||||||
|
}
|
||||||
|
rows = await conn.execute(text("PRAGMA table_info(streams)"))
|
||||||
|
existing = {row[1] for row in rows}
|
||||||
|
for name, definition in columns.items():
|
||||||
|
if name not in existing:
|
||||||
|
await conn.execute(text(f"ALTER TABLE streams ADD COLUMN {name} {definition}"))
|
||||||
+1
-1
@@ -66,7 +66,7 @@ async def lifespan(app: FastAPI):
|
|||||||
transcode_mgr.stop_all()
|
transcode_mgr.stop_all()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="StreamCore", lifespan=lifespan)
|
app = FastAPI(title="ProgramCore", lifespan=lifespan)
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ class Stream(Base):
|
|||||||
title: Mapped[str] = mapped_column(String(200), nullable=False)
|
title: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||||
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)
|
||||||
|
chapters_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
transcode_profile: Mapped[str] = mapped_column(String(64), default="standard")
|
||||||
|
homepage_visible: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
remote_control_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
stream_key: Mapped[str] = mapped_column(String(64), unique=True, default=lambda: uuid.uuid4().hex)
|
stream_key: Mapped[str] = mapped_column(String(64), unique=True, default=lambda: uuid.uuid4().hex)
|
||||||
status: Mapped[StreamStatus] = mapped_column(Enum(StreamStatus), default=StreamStatus.pending)
|
status: Mapped[StreamStatus] = mapped_column(Enum(StreamStatus), default=StreamStatus.pending)
|
||||||
scheduled_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
scheduled_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
|||||||
+143
-35
@@ -1,6 +1,12 @@
|
|||||||
from fastapi import APIRouter, HTTPException, Depends, Query, Request
|
import json
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Depends, Request
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, Field
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from routes.auth import require_auth
|
from routes.auth import require_auth
|
||||||
@@ -17,6 +23,11 @@ class StreamCreate(BaseModel):
|
|||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
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
|
||||||
|
chapters: list[dict] = Field(default_factory=list)
|
||||||
|
transcode_profile: str = "standard"
|
||||||
|
homepage_visible: bool = True
|
||||||
|
remote_control_enabled: bool = False
|
||||||
|
|
||||||
|
|
||||||
class StreamUpdate(BaseModel):
|
class StreamUpdate(BaseModel):
|
||||||
@@ -24,6 +35,55 @@ class StreamUpdate(BaseModel):
|
|||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
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
|
||||||
|
chapters: Optional[list[dict]] = None
|
||||||
|
transcode_profile: Optional[str] = None
|
||||||
|
homepage_visible: Optional[bool] = None
|
||||||
|
remote_control_enabled: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class RemoteSessionCreate(BaseModel):
|
||||||
|
expires_in: int = 300
|
||||||
|
|
||||||
|
|
||||||
|
class RemoteCommand(BaseModel):
|
||||||
|
token: str
|
||||||
|
action: str
|
||||||
|
value: float | None = None
|
||||||
|
|
||||||
|
|
||||||
|
_remote_sessions: dict[str, dict] = {}
|
||||||
|
_remote_commands: dict[int, list[dict]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _media_path(filename: str | None) -> Path | None:
|
||||||
|
if not filename:
|
||||||
|
return None
|
||||||
|
root = Path(settings.replay_media_dir).resolve()
|
||||||
|
candidate = (root / filename).resolve()
|
||||||
|
if root not in candidate.parents or not candidate.is_file():
|
||||||
|
raise HTTPException(status_code=422, detail="回放文件不在允许的媒体目录中")
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize(s, include_private: bool = True):
|
||||||
|
data = {
|
||||||
|
"id": s.id, "title": s.title, "description": s.description,
|
||||||
|
"cover_url": s.cover_url, "status": s.status.value,
|
||||||
|
"scheduled_at": str(s.scheduled_at) if s.scheduled_at else None,
|
||||||
|
"started_at": str(s.started_at) if s.started_at else None,
|
||||||
|
"ended_at": str(s.ended_at) if s.ended_at else None,
|
||||||
|
"multicast_enabled": s.multicast_enabled,
|
||||||
|
"replay_file": s.replay_file,
|
||||||
|
"chapters": json.loads(s.chapters_json or "[]"),
|
||||||
|
"transcode_profile": s.transcode_profile,
|
||||||
|
"homepage_visible": s.homepage_visible,
|
||||||
|
"remote_control_enabled": s.remote_control_enabled,
|
||||||
|
"created_at": str(s.created_at),
|
||||||
|
}
|
||||||
|
if include_private:
|
||||||
|
data["stream_key"] = s.stream_key
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
def _get_client_ip(request: Request) -> str | None:
|
def _get_client_ip(request: Request) -> str | None:
|
||||||
@@ -39,25 +99,18 @@ def _get_client_ip(request: Request) -> str | None:
|
|||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
async def list_streams(db: AsyncSession = Depends(get_db)):
|
async def list_streams(admin: bool = False, db: AsyncSession = Depends(get_db)):
|
||||||
svc = StreamService(db)
|
svc = StreamService(db)
|
||||||
streams = await svc.get_all_streams()
|
streams = await svc.get_all_streams() if admin else await svc.get_public_streams()
|
||||||
return [
|
return [_serialize(s, include_private=admin) for s in streams]
|
||||||
{
|
|
||||||
"id": s.id,
|
|
||||||
"title": s.title,
|
@router.get("/media-files")
|
||||||
"description": s.description,
|
async def list_media_files(_: bool = Depends(require_auth)):
|
||||||
"cover_url": s.cover_url,
|
root = Path(settings.replay_media_dir)
|
||||||
"stream_key": s.stream_key,
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
"status": s.status.value,
|
extensions = {".mp4", ".m4v", ".mov", ".webm", ".mkv"}
|
||||||
"scheduled_at": str(s.scheduled_at) if s.scheduled_at else None,
|
return [p.name for p in root.iterdir() if p.is_file() and p.suffix.lower() in extensions]
|
||||||
"started_at": str(s.started_at) if s.started_at else None,
|
|
||||||
"ended_at": str(s.ended_at) if s.ended_at else None,
|
|
||||||
"multicast_enabled": s.multicast_enabled,
|
|
||||||
"created_at": str(s.created_at),
|
|
||||||
}
|
|
||||||
for s in streams
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("")
|
@router.post("")
|
||||||
@@ -65,7 +118,10 @@ async def create_stream(body: StreamCreate, db: AsyncSession = Depends(get_db),
|
|||||||
_: bool = Depends(require_auth)):
|
_: bool = Depends(require_auth)):
|
||||||
title = body.title.strip()
|
title = body.title.strip()
|
||||||
if not title:
|
if not title:
|
||||||
raise HTTPException(status_code=422, detail="直播标题不能为空")
|
raise HTTPException(status_code=422, detail="节目标题不能为空")
|
||||||
|
_media_path(body.replay_file)
|
||||||
|
if body.transcode_profile not in settings.transcode_profiles:
|
||||||
|
raise HTTPException(status_code=422, detail="转码配置不存在")
|
||||||
svc = StreamService(db)
|
svc = StreamService(db)
|
||||||
try:
|
try:
|
||||||
stream = await svc.create_stream(
|
stream = await svc.create_stream(
|
||||||
@@ -73,12 +129,17 @@ async def create_stream(body: StreamCreate, db: AsyncSession = Depends(get_db),
|
|||||||
description=body.description,
|
description=body.description,
|
||||||
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,
|
||||||
|
chapters=body.chapters,
|
||||||
|
transcode_profile=body.transcode_profile,
|
||||||
|
homepage_visible=body.homepage_visible,
|
||||||
|
remote_control_enabled=body.remote_control_enabled,
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
await db.rollback()
|
await db.rollback()
|
||||||
raise HTTPException(status_code=500, detail="创建直播失败,请检查日期格式和数据库状态") from exc
|
raise HTTPException(status_code=500, detail="创建节目失败,请检查日期格式和数据库状态") from exc
|
||||||
rtmp_url = media_service.get_rtmp_url(stream.stream_key)
|
rtmp_url = media_service.get_rtmp_url(stream.stream_key)
|
||||||
return {
|
return {
|
||||||
"id": stream.id,
|
"id": stream.id,
|
||||||
@@ -95,19 +156,7 @@ async def get_stream(stream_id: int, db: AsyncSession = Depends(get_db)):
|
|||||||
s = await svc.get_stream(stream_id)
|
s = await svc.get_stream(stream_id)
|
||||||
if not s:
|
if not s:
|
||||||
raise HTTPException(status_code=404, detail="Stream not found")
|
raise HTTPException(status_code=404, detail="Stream not found")
|
||||||
return {
|
return _serialize(s)
|
||||||
"id": s.id,
|
|
||||||
"title": s.title,
|
|
||||||
"description": s.description,
|
|
||||||
"cover_url": s.cover_url,
|
|
||||||
"stream_key": s.stream_key,
|
|
||||||
"status": s.status.value,
|
|
||||||
"scheduled_at": str(s.scheduled_at) if s.scheduled_at else None,
|
|
||||||
"started_at": str(s.started_at) if s.started_at else None,
|
|
||||||
"ended_at": str(s.ended_at) if s.ended_at else None,
|
|
||||||
"multicast_enabled": s.multicast_enabled,
|
|
||||||
"created_at": str(s.created_at),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{stream_id}")
|
@router.put("/{stream_id}")
|
||||||
@@ -118,6 +167,12 @@ async def update_stream(stream_id: int, body: StreamUpdate, db: AsyncSession = D
|
|||||||
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 = {k: v for k, v in body.model_dump().items() if v is not None}
|
||||||
|
if "replay_file" in updates:
|
||||||
|
_media_path(updates["replay_file"])
|
||||||
|
if "transcode_profile" in updates and updates["transcode_profile"] not in settings.transcode_profiles:
|
||||||
|
raise HTTPException(status_code=422, detail="转码配置不存在")
|
||||||
|
if "chapters" in updates:
|
||||||
|
updates["chapters_json"] = json.dumps(updates.pop("chapters"), 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}
|
||||||
@@ -150,9 +205,62 @@ async def get_watch_urls(stream_id: int, request: Request, db: AsyncSession = De
|
|||||||
"selected_node": urls.get("selected_node"),
|
"selected_node": urls.get("selected_node"),
|
||||||
"lines": urls["lines"],
|
"lines": urls["lines"],
|
||||||
"qualities": urls["qualities"],
|
"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,
|
||||||
|
"chapters": json.loads(stream.chapters_json or "[]"),
|
||||||
|
"remote_control_enabled": stream.remote_control_enabled,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{stream_id}/replay")
|
||||||
|
async def serve_replay(stream_id: int, db: AsyncSession = Depends(get_db)):
|
||||||
|
stream = await StreamService(db).get_stream(stream_id)
|
||||||
|
if not stream:
|
||||||
|
raise HTTPException(status_code=404, detail="Program not found")
|
||||||
|
path = _media_path(stream.replay_file)
|
||||||
|
if not path:
|
||||||
|
raise HTTPException(status_code=404, detail="Replay not available")
|
||||||
|
return FileResponse(path)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{stream_id}/remote-session")
|
||||||
|
async def create_remote_session(stream_id: int, body: RemoteSessionCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_: bool = Depends(require_auth)):
|
||||||
|
stream = await StreamService(db).get_stream(stream_id)
|
||||||
|
if not stream or not stream.remote_control_enabled:
|
||||||
|
raise HTTPException(status_code=409, detail="节目未启用远程控制")
|
||||||
|
token = secrets.token_urlsafe(24)
|
||||||
|
_remote_sessions[token] = {
|
||||||
|
"stream_id": stream_id,
|
||||||
|
"expires_at": time.time() + min(max(body.expires_in, 30), 3600),
|
||||||
|
}
|
||||||
|
return {"token": token, "expires_in": min(max(body.expires_in, 30), 3600)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{stream_id}/remote-command")
|
||||||
|
async def send_remote_command(stream_id: int, body: RemoteCommand,
|
||||||
|
_: bool = Depends(require_auth)):
|
||||||
|
session = _remote_sessions.get(body.token)
|
||||||
|
if not session or session["stream_id"] != stream_id or session["expires_at"] < time.time():
|
||||||
|
raise HTTPException(status_code=403, detail="远程控制会话无效或已过期")
|
||||||
|
if body.action not in {"play", "pause", "seek"}:
|
||||||
|
raise HTTPException(status_code=422, detail="不支持的远程命令")
|
||||||
|
command = {"action": body.action, "value": body.value, "created_at": time.time()}
|
||||||
|
_remote_commands.setdefault(stream_id, []).append(command)
|
||||||
|
return {"ok": True, "command": command}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{stream_id}/remote-command")
|
||||||
|
async def poll_remote_command(stream_id: int, token: str):
|
||||||
|
session = _remote_sessions.get(token)
|
||||||
|
if not session or session["stream_id"] != stream_id or session["expires_at"] < time.time():
|
||||||
|
raise HTTPException(status_code=403, detail="远程控制会话无效或已过期")
|
||||||
|
commands = _remote_commands.get(stream_id, [])
|
||||||
|
return commands[-1] if commands else None
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{stream_id}/go-live")
|
@router.post("/{stream_id}/go-live")
|
||||||
async def stream_go_live(stream_id: int, db: AsyncSession = Depends(get_db),
|
async def stream_go_live(stream_id: int, db: AsyncSession = Depends(get_db),
|
||||||
_: bool = Depends(require_auth)):
|
_: bool = Depends(require_auth)):
|
||||||
|
|||||||
@@ -16,7 +16,12 @@ class StreamService:
|
|||||||
|
|
||||||
async def create_stream(self, title: str, description: str | None = None,
|
async def create_stream(self, title: str, description: str | None = None,
|
||||||
cover_url: str | None = None,
|
cover_url: str | None = None,
|
||||||
scheduled_at: str | None = None) -> Stream:
|
scheduled_at: str | None = None,
|
||||||
|
replay_file: str | None = None,
|
||||||
|
chapters: list[dict] | None = None,
|
||||||
|
transcode_profile: str = "standard",
|
||||||
|
homepage_visible: bool = True,
|
||||||
|
remote_control_enabled: bool = False) -> Stream:
|
||||||
import uuid
|
import uuid
|
||||||
scheduled_datetime = None
|
scheduled_datetime = None
|
||||||
if scheduled_at:
|
if scheduled_at:
|
||||||
@@ -28,6 +33,11 @@ class StreamService:
|
|||||||
title=title,
|
title=title,
|
||||||
description=description,
|
description=description,
|
||||||
cover_url=cover_url,
|
cover_url=cover_url,
|
||||||
|
replay_file=replay_file,
|
||||||
|
chapters_json=json.dumps(chapters or [], ensure_ascii=False),
|
||||||
|
transcode_profile=transcode_profile,
|
||||||
|
homepage_visible=homepage_visible,
|
||||||
|
remote_control_enabled=remote_control_enabled,
|
||||||
stream_key=uuid.uuid4().hex,
|
stream_key=uuid.uuid4().hex,
|
||||||
status=StreamStatus.pending,
|
status=StreamStatus.pending,
|
||||||
scheduled_at=scheduled_datetime,
|
scheduled_at=scheduled_datetime,
|
||||||
@@ -43,6 +53,14 @@ class StreamService:
|
|||||||
)
|
)
|
||||||
return result.scalars().all()
|
return result.scalars().all()
|
||||||
|
|
||||||
|
async def get_public_streams(self):
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(Stream)
|
||||||
|
.where(Stream.homepage_visible.is_(True))
|
||||||
|
.order_by(Stream.created_at.desc())
|
||||||
|
)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
async def get_stream(self, stream_id: int):
|
async def get_stream(self, stream_id: int):
|
||||||
result = await self.db.execute(
|
result = await self.db.execute(
|
||||||
select(Stream).where(Stream.id == stream_id)
|
select(Stream).where(Stream.id == stream_id)
|
||||||
@@ -77,7 +95,7 @@ class StreamService:
|
|||||||
started_at=datetime.utcnow(),
|
started_at=datetime.utcnow(),
|
||||||
)
|
)
|
||||||
rtmp_url = media_service.get_rtmp_url(stream_key)
|
rtmp_url = media_service.get_rtmp_url(stream_key)
|
||||||
transcode_mgr.start_transcode(stream_key, rtmp_url)
|
transcode_mgr.start_transcode(stream_key, rtmp_url, stream.transcode_profile)
|
||||||
|
|
||||||
async def set_stream_ended(self, stream_key: str):
|
async def set_stream_ended(self, stream_key: str):
|
||||||
stream = await self.get_stream_by_key(stream_key)
|
stream = await self.get_stream_by_key(stream_key)
|
||||||
@@ -91,6 +109,10 @@ class StreamService:
|
|||||||
transcode_mgr.stop_transcode(stream_key)
|
transcode_mgr.stop_transcode(stream_key)
|
||||||
|
|
||||||
async def get_watch_urls(self, stream_key: str, client_ip: str | None = None):
|
async def get_watch_urls(self, stream_key: str, client_ip: str | None = None):
|
||||||
|
stream = await self.get_stream_by_key(stream_key)
|
||||||
|
if not stream:
|
||||||
|
return {"lines": [], "qualities": [], "multicast_available": False, "selected_node": None}
|
||||||
|
qualities = settings.transcode_profiles.get(stream.transcode_profile, settings.qualities)
|
||||||
best_node = lb.select_best_node(client_ip)
|
best_node = lb.select_best_node(client_ip)
|
||||||
if not best_node:
|
if not best_node:
|
||||||
best_node = lb.get_enabled_nodes()[0] if lb.get_enabled_nodes() else None
|
best_node = lb.get_enabled_nodes()[0] if lb.get_enabled_nodes() else None
|
||||||
@@ -106,7 +128,7 @@ class StreamService:
|
|||||||
"url": f"{base_hls}/index.m3u8",
|
"url": f"{base_hls}/index.m3u8",
|
||||||
"qualities": [],
|
"qualities": [],
|
||||||
}
|
}
|
||||||
for quality in settings.qualities:
|
for quality in qualities:
|
||||||
label = quality["label"]
|
label = quality["label"]
|
||||||
line_info["qualities"].append({
|
line_info["qualities"].append({
|
||||||
"label": label,
|
"label": label,
|
||||||
@@ -118,7 +140,7 @@ class StreamService:
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"lines": lines,
|
"lines": lines,
|
||||||
"qualities": [{"label": q["label"], "width": q["width"], "height": q["height"]} for q in settings.qualities],
|
"qualities": [{"label": q["label"], "width": q["width"], "height": q["height"]} for q in qualities],
|
||||||
"multicast_available": multicast_detector.is_available(),
|
"multicast_available": multicast_detector.is_available(),
|
||||||
"selected_node": {
|
"selected_node": {
|
||||||
"id": best_node["id"],
|
"id": best_node["id"],
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import asyncio
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import os
|
import os
|
||||||
from config import settings
|
from config import settings
|
||||||
@@ -7,12 +6,15 @@ from config import settings
|
|||||||
class TranscodeManager:
|
class TranscodeManager:
|
||||||
_processes: dict[str, list[subprocess.Popen]] = {}
|
_processes: dict[str, list[subprocess.Popen]] = {}
|
||||||
|
|
||||||
def start_transcode(self, stream_key: str, input_url: str):
|
def start_transcode(self, stream_key: str, input_url: str, profile: str = "standard"):
|
||||||
if stream_key in self._processes:
|
if stream_key in self._processes:
|
||||||
return
|
return
|
||||||
|
|
||||||
procs = []
|
procs = []
|
||||||
for quality in settings.qualities:
|
qualities = settings.transcode_profiles.get(profile, settings.qualities)
|
||||||
|
segment_seconds = max(1, settings.hls_segment_seconds)
|
||||||
|
keyframe_frames = segment_seconds * 30
|
||||||
|
for quality in qualities:
|
||||||
label = quality["label"]
|
label = quality["label"]
|
||||||
width = quality["width"]
|
width = quality["width"]
|
||||||
height = quality["height"]
|
height = quality["height"]
|
||||||
@@ -25,11 +27,16 @@ class TranscodeManager:
|
|||||||
"-c:v", "libx264", "-preset", "ultrafast",
|
"-c:v", "libx264", "-preset", "ultrafast",
|
||||||
"-b:v", bitrate,
|
"-b:v", bitrate,
|
||||||
"-s", f"{width}x{height}",
|
"-s", f"{width}x{height}",
|
||||||
|
"-sc_threshold", "0",
|
||||||
|
"-g", str(keyframe_frames),
|
||||||
|
"-keyint_min", str(keyframe_frames),
|
||||||
|
"-force_key_frames", f"expr:gte(t,n_forced*{segment_seconds})",
|
||||||
|
"-copyts", "-start_at_zero",
|
||||||
"-c:a", "aac", "-b:a", "128k",
|
"-c:a", "aac", "-b:a", "128k",
|
||||||
"-f", "hls",
|
"-f", "hls",
|
||||||
"-hls_time", "4",
|
"-hls_time", str(segment_seconds),
|
||||||
"-hls_list_size", "6",
|
"-hls_list_size", "6",
|
||||||
"-hls_flags", "delete_segments",
|
"-hls_flags", "delete_segments+independent_segments+program_date_time",
|
||||||
"-hls_segment_filename", f"{hls_dir}/segment_%03d.ts",
|
"-hls_segment_filename", f"{hls_dir}/segment_%03d.ts",
|
||||||
f"{hls_dir}/index.m3u8",
|
f"{hls_dir}/index.m3u8",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ const { Text } = Typography
|
|||||||
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
{ key: '/admin', icon: <DashboardOutlined />, label: '概览' },
|
{ key: '/admin', icon: <DashboardOutlined />, label: '概览' },
|
||||||
{ key: '/admin/streams', icon: <VideoCameraOutlined />, label: '直播列表' },
|
{ key: '/admin/streams', icon: <VideoCameraOutlined />, label: '节目列表' },
|
||||||
{ key: '/admin/streams/new', icon: <PlusOutlined />, label: '创建直播' },
|
{ key: '/admin/streams/new', icon: <PlusOutlined />, label: '创建节目' },
|
||||||
{ key: '/admin/nodes', icon: <CloudServerOutlined />, label: '边缘节点' },
|
{ key: '/admin/nodes', icon: <CloudServerOutlined />, label: '边缘节点' },
|
||||||
{ key: '/admin/settings', icon: <SettingOutlined />, label: '系统设置' },
|
{ key: '/admin/settings', icon: <SettingOutlined />, label: '系统设置' },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export default function Dashboard() {
|
|||||||
useEffect(() => { loadData(); const t = setInterval(loadData, 10000); return () => clearInterval(t) }, [])
|
useEffect(() => { loadData(); const t = setInterval(loadData, 10000); return () => clearInterval(t) }, [])
|
||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
try { setStreams(await fetchStreams()) } catch { /* */ }
|
try { setStreams(await fetchStreams(true)) } catch { /* */ }
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@ export default function Dashboard() {
|
|||||||
border: '1px solid #EBE5DB',
|
border: '1px solid #EBE5DB',
|
||||||
borderTop: '3px solid #5B8C5A',
|
borderTop: '3px solid #5B8C5A',
|
||||||
}}>
|
}}>
|
||||||
<Statistic title="直播中" value={liveCount} loading={loading}
|
<Statistic title="播出中" value={liveCount} loading={loading}
|
||||||
prefix={<SignalFilled style={{ color: '#5B8C5A', fontSize: 20 }} />} />
|
prefix={<SignalFilled style={{ color: '#5B8C5A', fontSize: 20 }} />} />
|
||||||
</div>
|
</div>
|
||||||
</Col>
|
</Col>
|
||||||
@@ -71,7 +71,7 @@ export default function Dashboard() {
|
|||||||
border: '1px solid #EBE5DB',
|
border: '1px solid #EBE5DB',
|
||||||
borderTop: '3px solid #C2693A',
|
borderTop: '3px solid #C2693A',
|
||||||
}}>
|
}}>
|
||||||
<Statistic title="直播总数" value={streams.length} loading={loading}
|
<Statistic title="节目总数" value={streams.length} loading={loading}
|
||||||
prefix={<PlayCircleOutlined style={{ color: '#C2693A', fontSize: 20 }} />} />
|
prefix={<PlayCircleOutlined style={{ color: '#C2693A', fontSize: 20 }} />} />
|
||||||
</div>
|
</div>
|
||||||
</Col>
|
</Col>
|
||||||
@@ -82,10 +82,10 @@ export default function Dashboard() {
|
|||||||
border: '1px solid #EBE5DB',
|
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>
|
||||||
<Table dataSource={streams.slice(0, 10)} columns={columns}
|
<Table dataSource={streams.slice(0, 10)} columns={columns}
|
||||||
rowKey="id" loading={loading} pagination={false} size="middle"
|
rowKey="id" loading={loading} pagination={false} size="middle"
|
||||||
locale={{ emptyText: '暂无直播数据' }} />
|
locale={{ emptyText: '暂无节目数据' }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { Form, Input, Button, DatePicker, Typography, message, Space, Alert } from 'antd'
|
import { Form, Input, Button, DatePicker, Typography, message, Space, Alert, Select, Switch } from 'antd'
|
||||||
import { CopyOutlined, ArrowLeftOutlined, CheckCircleOutlined } from '@ant-design/icons'
|
import { CopyOutlined, ArrowLeftOutlined, CheckCircleOutlined } from '@ant-design/icons'
|
||||||
import { createStream } from '../../api/streams'
|
import { createStream, fetchMediaFiles, type Chapter } from '../../api/streams'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
|
||||||
const { Title, Text } = Typography
|
const { Title, Text } = Typography
|
||||||
@@ -10,13 +10,29 @@ const { TextArea } = Input
|
|||||||
|
|
||||||
export default function StreamForm() {
|
export default function StreamForm() {
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [mediaFiles, setMediaFiles] = useState<string[]>([])
|
||||||
|
const [chapters, setChapters] = useState<Chapter[]>([])
|
||||||
const [result, setResult] = useState<{ rtmp_url: string; stream_key: string } | null>(null)
|
const [result, setResult] = useState<{ rtmp_url: string; stream_key: string } | null>(null)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMediaFiles().then(setMediaFiles).catch(() => message.warning('无法读取服务器回放目录'))
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleSubmit = async (values: any) => {
|
const handleSubmit = async (values: any) => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const res = await createStream({ title: values.title, description: values.description, cover_url: values.cover_url, scheduled_at: values.scheduled_at?.toISOString() })
|
const res = await createStream({
|
||||||
|
title: values.title,
|
||||||
|
description: values.description,
|
||||||
|
cover_url: values.cover_url,
|
||||||
|
scheduled_at: values.scheduled_at?.toISOString(),
|
||||||
|
replay_file: values.replay_file,
|
||||||
|
chapters: chapters.filter(chapter => chapter.title.trim() && chapter.time >= 0),
|
||||||
|
transcode_profile: values.transcode_profile,
|
||||||
|
homepage_visible: values.homepage_visible,
|
||||||
|
remote_control_enabled: values.remote_control_enabled,
|
||||||
|
})
|
||||||
setResult({ rtmp_url: res.rtmp_url, stream_key: res.stream_key })
|
setResult({ rtmp_url: res.rtmp_url, stream_key: res.stream_key })
|
||||||
message.success('直播已创建')
|
message.success('直播已创建')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -75,7 +91,7 @@ export default function StreamForm() {
|
|||||||
填写直播信息,系统将生成推流码与 RTMP 地址
|
填写直播信息,系统将生成推流码与 RTMP 地址
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Form layout="vertical" onFinish={handleSubmit} size="large">
|
<Form layout="vertical" onFinish={handleSubmit} size="large" initialValues={{ transcode_profile: 'standard', homepage_visible: true, remote_control_enabled: false }}>
|
||||||
<Form.Item name="title" label="直播标题"
|
<Form.Item name="title" label="直播标题"
|
||||||
rules={[{ required: true, message: '请输入直播标题' }]}>
|
rules={[{ required: true, message: '请输入直播标题' }]}>
|
||||||
<Input placeholder="输入直播标题" style={{ borderRadius: 8 }} />
|
<Input placeholder="输入直播标题" style={{ borderRadius: 8 }} />
|
||||||
@@ -89,6 +105,30 @@ export default function StreamForm() {
|
|||||||
<Form.Item name="scheduled_at" label="预定开播时间">
|
<Form.Item name="scheduled_at" label="预定开播时间">
|
||||||
<DatePicker showTime style={{ width: '100%', borderRadius: 8 }} placeholder="选择日期(可选)" />
|
<DatePicker showTime style={{ width: '100%', borderRadius: 8 }} placeholder="选择日期(可选)" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item name="replay_file" label="服务器回放文件" extra="只能选择已配置媒体目录中的文件">
|
||||||
|
<Select allowClear placeholder="不使用回放" options={mediaFiles.map(file => ({ value: file, label: file }))} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="回放章节与时间点">
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }}>
|
||||||
|
{chapters.map((chapter, index) => (
|
||||||
|
<Space key={index} style={{ width: '100%' }}>
|
||||||
|
<Input value={chapter.title} placeholder="章节名称" onChange={event => setChapters(current => current.map((item, itemIndex) => itemIndex === index ? { ...item, title: event.target.value } : item))} />
|
||||||
|
<Input type="number" min={0} value={chapter.time} addonAfter="秒" onChange={event => setChapters(current => current.map((item, itemIndex) => itemIndex === index ? { ...item, time: Number(event.target.value) } : item))} />
|
||||||
|
<Button danger onClick={() => setChapters(current => current.filter((_, itemIndex) => itemIndex !== index))}>删除</Button>
|
||||||
|
</Space>
|
||||||
|
))}
|
||||||
|
<Button onClick={() => setChapters(current => [...current, { title: '', time: 0 }])}>添加章节</Button>
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="transcode_profile" label="转码配置">
|
||||||
|
<Select options={[{ value: 'standard', label: '标准多档' }, { value: 'bandwidth-saver', label: '带宽节省' }]} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="homepage_visible" label="首页公开" valuePropName="checked">
|
||||||
|
<Switch checkedChildren="公开" unCheckedChildren="仅私有链接" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="remote_control_enabled" label="允许审计式观众播放控制" valuePropName="checked" extra="默认关闭,仅在明确启用后允许创建限时控制会话">
|
||||||
|
<Switch checkedChildren="启用" unCheckedChildren="关闭" />
|
||||||
|
</Form.Item>
|
||||||
<div style={{ borderTop: '1px solid #EBE5DB', margin: '8px 0 24px' }} />
|
<div style={{ borderTop: '1px solid #EBE5DB', margin: '8px 0 24px' }} />
|
||||||
<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 }}>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export default function StreamList() {
|
|||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try { setStreams(await fetchStreams()) } catch { message.error('加载失败') }
|
try { setStreams(await fetchStreams(true)) } catch { message.error('加载失败') }
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
async function handleDelete(id: number) {
|
async function handleDelete(id: number) {
|
||||||
@@ -49,7 +49,7 @@ export default function StreamList() {
|
|||||||
<Space size={4}>
|
<Space size={4}>
|
||||||
<Tooltip title="复制推流码">
|
<Tooltip title="复制推流码">
|
||||||
<Button size="small" icon={<LinkOutlined />}
|
<Button size="small" icon={<LinkOutlined />}
|
||||||
onClick={() => { navigator.clipboard.writeText(r.stream_key); message.success('已复制') }} />
|
onClick={() => { if (r.stream_key) navigator.clipboard.writeText(r.stream_key); message.success('已复制') }} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{r.status !== 'live' ? (
|
{r.status !== 'live' ? (
|
||||||
<Tooltip title="开播"><Button size="small" type="primary" ghost icon={<PlayCircleOutlined />} onClick={() => handleGoLive(r.id)} /></Tooltip>
|
<Tooltip title="开播"><Button size="small" type="primary" ghost icon={<PlayCircleOutlined />} onClick={() => handleGoLive(r.id)} /></Tooltip>
|
||||||
@@ -69,11 +69,11 @@ export default function StreamList() {
|
|||||||
<div>
|
<div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 28 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 28 }}>
|
||||||
<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 style={{ color: '#949088', fontSize: 14 }}>管理直播、排期与回放节目</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={{
|
||||||
@@ -82,7 +82,7 @@ export default function StreamList() {
|
|||||||
}}>
|
}}>
|
||||||
<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"
|
||||||
locale={{ emptyText: '暂无直播,创建第一条直播吧' }} />
|
locale={{ emptyText: '暂无节目,创建第一个节目吧' }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,13 +5,23 @@ export interface StreamInfo {
|
|||||||
title: string
|
title: string
|
||||||
description?: string
|
description?: string
|
||||||
cover_url?: string
|
cover_url?: string
|
||||||
stream_key: string
|
stream_key?: string
|
||||||
status: string
|
status: string
|
||||||
scheduled_at?: string
|
scheduled_at?: string
|
||||||
started_at?: string
|
started_at?: string
|
||||||
ended_at?: string
|
ended_at?: string
|
||||||
multicast_enabled: boolean
|
multicast_enabled: boolean
|
||||||
created_at: string
|
created_at: string
|
||||||
|
replay_file?: string
|
||||||
|
chapters: Chapter[]
|
||||||
|
transcode_profile: string
|
||||||
|
homepage_visible: boolean
|
||||||
|
remote_control_enabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Chapter {
|
||||||
|
title: string
|
||||||
|
time: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StreamCreateReq {
|
export interface StreamCreateReq {
|
||||||
@@ -19,6 +29,11 @@ export interface StreamCreateReq {
|
|||||||
description?: string
|
description?: string
|
||||||
cover_url?: string
|
cover_url?: string
|
||||||
scheduled_at?: string
|
scheduled_at?: string
|
||||||
|
replay_file?: string
|
||||||
|
chapters?: Chapter[]
|
||||||
|
transcode_profile?: string
|
||||||
|
homepage_visible?: boolean
|
||||||
|
remote_control_enabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WatchInfo {
|
export interface WatchInfo {
|
||||||
@@ -29,6 +44,11 @@ export interface WatchInfo {
|
|||||||
selected_node?: { id: string; name: string } | null
|
selected_node?: { id: string; name: string } | null
|
||||||
lines: LineInfo[]
|
lines: LineInfo[]
|
||||||
qualities: QualityInfo[]
|
qualities: QualityInfo[]
|
||||||
|
description?: string
|
||||||
|
scheduled_at?: string
|
||||||
|
replay_url?: string
|
||||||
|
chapters: Chapter[]
|
||||||
|
remote_control_enabled: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LineInfo {
|
export interface LineInfo {
|
||||||
@@ -54,8 +74,13 @@ export interface QualityInfo {
|
|||||||
height: number
|
height: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchStreams(): Promise<StreamInfo[]> {
|
export async function fetchStreams(admin = false): Promise<StreamInfo[]> {
|
||||||
const { data } = await api.get('/streams')
|
const { data } = await api.get('/streams', { params: admin ? { admin: true } : undefined })
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchMediaFiles(): Promise<string[]> {
|
||||||
|
const { data } = await api.get('/streams/media-files')
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { useParams, useNavigate } from 'react-router-dom'
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
import { Select, Button, Typography, Space, Tag, Spin, Empty, message } from 'antd'
|
import { Select, Button, Typography, Tag, Spin, Empty, message } from 'antd'
|
||||||
import { ArrowLeftOutlined, WifiOutlined, SignalFilled } from '@ant-design/icons'
|
import { ArrowLeftOutlined, WifiOutlined, SignalFilled } from '@ant-design/icons'
|
||||||
import Hls from 'hls.js'
|
import Hls from 'hls.js'
|
||||||
import { getWatchUrls, type WatchInfo, type LineInfo } from '../../api/streams'
|
import { getWatchUrls, type WatchInfo, type LineInfo } from '../../api/streams'
|
||||||
@@ -52,7 +52,14 @@ export default function WatchPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!info || !activeLine || !videoRef.current) return
|
if (!info || !videoRef.current) return
|
||||||
|
if (info.replay_url && info.status !== 'live') {
|
||||||
|
hlsRef.current?.destroy()
|
||||||
|
hlsRef.current = null
|
||||||
|
videoRef.current.src = info.replay_url
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!activeLine) return
|
||||||
const line = info.lines.find(l => l.node_id === activeLine)
|
const line = info.lines.find(l => l.node_id === activeLine)
|
||||||
if (!line) return
|
if (!line) return
|
||||||
const quality = activeQuality ? line.qualities.find(q => q.label === activeQuality) : line.qualities[0]
|
const quality = activeQuality ? line.qualities.find(q => q.label === activeQuality) : line.qualities[0]
|
||||||
@@ -74,6 +81,12 @@ export default function WatchPage() {
|
|||||||
}
|
}
|
||||||
}, [activeLine, activeQuality, info])
|
}, [activeLine, activeQuality, info])
|
||||||
|
|
||||||
|
function jumpToChapter(time: number) {
|
||||||
|
if (!videoRef.current) return
|
||||||
|
videoRef.current.currentTime = time
|
||||||
|
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])
|
||||||
|
|
||||||
if (loading) return (
|
if (loading) return (
|
||||||
@@ -91,55 +104,61 @@ export default function WatchPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="watch-shell">
|
<div className="watch-shell">
|
||||||
{/* Top bar */}
|
|
||||||
<header className="watch-header">
|
<header className="watch-header">
|
||||||
<Space size={16}>
|
<div className="watch-heading">
|
||||||
<Button type="text" icon={<ArrowLeftOutlined />} style={{ color: '#949088' }}
|
<Button type="text" icon={<ArrowLeftOutlined />} style={{ color: '#949088' }}
|
||||||
onClick={() => navigate('/')}>{brand.brand_name}</Button>
|
onClick={() => navigate('/')}>{brand.brand_name}</Button>
|
||||||
<div style={{ width: 1, height: 18, background: 'rgba(235,229,219,0.1)' }} />
|
<span className="watch-divider" />
|
||||||
<Text style={{ color: '#EBE5DB', fontSize: 14, fontWeight: 500 }}>{info.title}</Text>
|
<Text className="watch-title">{info.title}</Text>
|
||||||
<Tag color={info.status === 'live' ? 'green' : 'default'} style={{ fontWeight: 600, fontSize: 11 }}>
|
<Tag color={info.status === 'live' ? 'green' : 'default'} style={{ fontWeight: 600, fontSize: 11 }}>
|
||||||
{statusLabel}
|
{statusLabel}
|
||||||
</Tag>
|
</Tag>
|
||||||
</Space>
|
</div>
|
||||||
<Space size={16}>
|
<div className="watch-options">
|
||||||
<Tag style={{ margin: 0 }}>节点:{currentLine?.node_name || info.selected_node?.name || '不可用'}</Tag>
|
<Tag style={{ margin: 0 }}>节点:{currentLine?.node_name || info.selected_node?.name || '不可用'}</Tag>
|
||||||
<Tag style={{ margin: 0 }}>清晰度:{activeQuality || '不可用'}</Tag>
|
<Tag style={{ margin: 0 }}>清晰度:{activeQuality || '不可用'}</Tag>
|
||||||
<Text style={{ color: '#949088', fontSize: 13, display: 'flex', alignItems: 'center', gap: 6 }}>
|
<Text style={{ color: '#949088', fontSize: 13, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
<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} onChange={setActiveQuality} style={{ width: 100 }} size="small"
|
<Select value={activeQuality || undefined} onChange={setActiveQuality} className="watch-quality" size="small"
|
||||||
options={info.qualities.map(q => ({ value: q.label, label: q.label }))} placeholder="清晰度" />
|
options={info.qualities.map(q => ({ value: q.label, label: q.label }))} placeholder="清晰度" />
|
||||||
<Select value={activeLine} onChange={setActiveLine} style={{ width: 150 }} size="small"
|
<Select value={activeLine || undefined} onChange={setActiveLine} className="watch-line" size="small"
|
||||||
options={info.lines.map(l => ({ value: l.node_id, label: l.node_name }))}
|
options={info.lines.map(l => ({ value: l.node_id, label: l.node_name }))}
|
||||||
prefix={<WifiOutlined />} placeholder="线路" />
|
prefix={<WifiOutlined />} placeholder="线路" />
|
||||||
</Space>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Video */}
|
|
||||||
<main className="watch-stage">
|
<main className="watch-stage">
|
||||||
|
<div className="watch-content">
|
||||||
<div className="watch-frame">
|
<div className="watch-frame">
|
||||||
<video ref={videoRef} controls autoPlay playsInline
|
<video ref={videoRef} controls autoPlay playsInline className="watch-video" />
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'contain', display: 'block' }} />
|
{info.status !== 'live' && !info.replay_url && (
|
||||||
{info.status !== 'live' && (
|
<div className="watch-waiting">
|
||||||
<div style={{
|
|
||||||
position: 'absolute', inset: 0,
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
background: 'rgba(10,10,8,0.92)',
|
|
||||||
}}>
|
|
||||||
<Space direction="vertical" align="center" size={16}>
|
|
||||||
<Spin size="large" />
|
<Spin size="large" />
|
||||||
<Text style={{
|
<Text>等待主播开播...</Text>
|
||||||
color: '#949088', fontSize: 17,
|
|
||||||
fontFamily: '"Source Serif 4", "Noto Serif SC", Georgia, serif',
|
|
||||||
}}>
|
|
||||||
等待主播开播...
|
|
||||||
</Text>
|
|
||||||
</Space>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{(info.description || info.chapters.length > 0) && (
|
||||||
|
<aside className="watch-details">
|
||||||
|
{info.description && <p>{info.description}</p>}
|
||||||
|
{info.chapters.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<h2>节目章节</h2>
|
||||||
|
<div className="chapter-list">
|
||||||
|
{info.chapters.map((chapter, index) => (
|
||||||
|
<button key={`${chapter.time}-${index}`} type="button" onClick={() => jumpToChapter(chapter.time)}>
|
||||||
|
<span>{chapter.title}</span>
|
||||||
|
<time>{Math.floor(chapter.time / 60)}:{String(chapter.time % 60).padStart(2, '0')}</time>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -219,6 +219,28 @@ button.stream-card:focus-visible {
|
|||||||
background: #1d1a16;
|
background: #1d1a16;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.watch-heading,
|
||||||
|
.watch-options {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watch-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 18px;
|
||||||
|
background: rgba(235, 229, 219, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.watch-title {
|
||||||
|
color: #ebe5db;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watch-quality { width: 100px; }
|
||||||
|
.watch-line { width: 150px; }
|
||||||
|
|
||||||
.watch-stage {
|
.watch-stage {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -227,6 +249,11 @@ button.stream-card:focus-visible {
|
|||||||
padding: 36px;
|
padding: 36px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.watch-content {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1280px;
|
||||||
|
}
|
||||||
|
|
||||||
.watch-frame {
|
.watch-frame {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -239,6 +266,52 @@ button.stream-card:focus-visible {
|
|||||||
background: #080706;
|
background: #080706;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.watch-video {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watch-waiting {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 16px;
|
||||||
|
background: rgba(10, 10, 8, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
.watch-waiting .ant-typography { color: #949088; font-size: 17px; }
|
||||||
|
|
||||||
|
.watch-details {
|
||||||
|
margin-top: 24px;
|
||||||
|
padding: 24px;
|
||||||
|
border: 1px solid #3d3730;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #1d1a16;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watch-details p { margin: 0 0 20px; color: #b7aea2; line-height: 1.7; }
|
||||||
|
.watch-details h2 { margin: 0 0 12px; font-size: 16px; }
|
||||||
|
.chapter-list { display: grid; gap: 8px; }
|
||||||
|
.chapter-list button {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid #3d3730;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #24201b;
|
||||||
|
color: #eee7dc;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.chapter-list button:hover { border-color: #806d56; background: #2b261f; }
|
||||||
|
.chapter-list time { color: #949088; font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.watch-header {
|
.watch-header {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@@ -246,6 +319,27 @@ button.stream-card:focus-visible {
|
|||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.watch-heading,
|
||||||
|
.watch-options {
|
||||||
|
width: 100%;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watch-title {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watch-options .ant-tag,
|
||||||
|
.watch-options .ant-typography { display: none; }
|
||||||
|
|
||||||
|
.watch-quality,
|
||||||
|
.watch-line { flex: 1; width: auto; min-width: 120px; }
|
||||||
|
|
||||||
.watch-stage {
|
.watch-stage {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
padding: 20px 0;
|
padding: 20px 0;
|
||||||
@@ -256,6 +350,11 @@ button.stream-card:focus-visible {
|
|||||||
border-left: 0;
|
border-left: 0;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.watch-details {
|
||||||
|
margin: 16px;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
|
|||||||
Reference in new issue
Block a user