完善节目管理与回放播放体验

This commit is contained in:
admin committed 2026-08-17 09:59:04 +08:00
1 parent b265518084
commit a493ef2309
14 files changed
+444 -94

No files matched your search

+11
View File
@@ -10,6 +10,8 @@ class Settings(BaseSettings):
media_origin_rtmp_port: int = 1935
media_origin_api_port: int = 9997
media_origin_hls_port: int = 8888
replay_media_dir: str = "/workspace/media"
hls_segment_seconds: int = 4
edge_nodes: list[dict] = [
{
@@ -34,6 +36,15 @@ class Settings(BaseSettings):
{"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_address_base: str = "239.0.0.1"
multicast_port_base: int = 5000
+14
View File
@@ -1,5 +1,6 @@
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy import text
from config import settings
@@ -19,3 +20,16 @@ async def get_db() -> AsyncSession:
async def init_db():
async with engine.begin() as conn:
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
View File
@@ -66,7 +66,7 @@ async def lifespan(app: FastAPI):
transcode_mgr.stop_all()
app = FastAPI(title="StreamCore", lifespan=lifespan)
app = FastAPI(title="ProgramCore", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
+5
View File
@@ -20,6 +20,11 @@ class Stream(Base):
title: Mapped[str] = mapped_column(String(200), nullable=False)
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)
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)
status: Mapped[StreamStatus] = mapped_column(Enum(StreamStatus), default=StreamStatus.pending)
scheduled_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+143 -35
View File
@@ -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 pydantic import BaseModel
from pydantic import BaseModel, Field
from typing import Optional
from database import get_db
from routes.auth import require_auth
@@ -17,6 +23,11 @@ class StreamCreate(BaseModel):
description: Optional[str] = None
cover_url: 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):
@@ -24,6 +35,55 @@ class StreamUpdate(BaseModel):
description: Optional[str] = None
cover_url: 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:
@@ -39,25 +99,18 @@ def _get_client_ip(request: Request) -> str | None:
@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)
streams = await svc.get_all_streams()
return [
{
"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),
}
for s in streams
]
streams = await svc.get_all_streams() if admin else await svc.get_public_streams()
return [_serialize(s, include_private=admin) for s in streams]
@router.get("/media-files")
async def list_media_files(_: bool = Depends(require_auth)):
root = Path(settings.replay_media_dir)
root.mkdir(parents=True, exist_ok=True)
extensions = {".mp4", ".m4v", ".mov", ".webm", ".mkv"}
return [p.name for p in root.iterdir() if p.is_file() and p.suffix.lower() in extensions]
@router.post("")
@@ -65,7 +118,10 @@ async def create_stream(body: StreamCreate, db: AsyncSession = Depends(get_db),
_: bool = Depends(require_auth)):
title = body.title.strip()
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)
try:
stream = await svc.create_stream(
@@ -73,12 +129,17 @@ async def create_stream(body: StreamCreate, db: AsyncSession = Depends(get_db),
description=body.description,
cover_url=body.cover_url,
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:
raise HTTPException(status_code=422, detail=str(exc)) from exc
except Exception as exc:
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)
return {
"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)
if not s:
raise HTTPException(status_code=404, detail="Stream not found")
return {
"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),
}
return _serialize(s)
@router.put("/{stream_id}")
@@ -118,6 +167,12 @@ async def update_stream(stream_id: int, body: StreamUpdate, db: AsyncSession = D
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:
_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:
await svc.update_stream(stream_id, **updates)
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"),
"lines": urls["lines"],
"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")
async def stream_go_live(stream_id: int, db: AsyncSession = Depends(get_db),
_: bool = Depends(require_auth)):
+26 -4
View File
@@ -16,7 +16,12 @@ class StreamService:
async def create_stream(self, title: str, description: 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
scheduled_datetime = None
if scheduled_at:
@@ -28,6 +33,11 @@ class StreamService:
title=title,
description=description,
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,
status=StreamStatus.pending,
scheduled_at=scheduled_datetime,
@@ -43,6 +53,14 @@ class StreamService:
)
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):
result = await self.db.execute(
select(Stream).where(Stream.id == stream_id)
@@ -77,7 +95,7 @@ class StreamService:
started_at=datetime.utcnow(),
)
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):
stream = await self.get_stream_by_key(stream_key)
@@ -91,6 +109,10 @@ class StreamService:
transcode_mgr.stop_transcode(stream_key)
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)
if not best_node:
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",
"qualities": [],
}
for quality in settings.qualities:
for quality in qualities:
label = quality["label"]
line_info["qualities"].append({
"label": label,
@@ -118,7 +140,7 @@ class StreamService:
return {
"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(),
"selected_node": {
"id": best_node["id"],
+12 -5
View File
@@ -1,4 +1,3 @@
import asyncio
import subprocess
import os
from config import settings
@@ -7,12 +6,15 @@ from config import settings
class TranscodeManager:
_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:
return
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"]
width = quality["width"]
height = quality["height"]
@@ -25,11 +27,16 @@ class TranscodeManager:
"-c:v", "libx264", "-preset", "ultrafast",
"-b:v", bitrate,
"-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",
"-f", "hls",
"-hls_time", "4",
"-hls_time", str(segment_seconds),
"-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",
f"{hls_dir}/index.m3u8",
]
+2 -2
View File
@@ -13,8 +13,8 @@ const { Text } = Typography
const menuItems = [
{ key: '/admin', icon: <DashboardOutlined />, label: '概览' },
{ key: '/admin/streams', icon: <VideoCameraOutlined />, label: '直播列表' },
{ key: '/admin/streams/new', icon: <PlusOutlined />, label: '创建直播' },
{ key: '/admin/streams', icon: <VideoCameraOutlined />, label: '节目列表' },
{ key: '/admin/streams/new', icon: <PlusOutlined />, label: '创建节目' },
{ key: '/admin/nodes', icon: <CloudServerOutlined />, label: '边缘节点' },
{ key: '/admin/settings', icon: <SettingOutlined />, label: '系统设置' },
]
+5 -5
View File
@@ -14,7 +14,7 @@ export default function Dashboard() {
useEffect(() => { loadData(); const t = setInterval(loadData, 10000); return () => clearInterval(t) }, [])
async function loadData() {
try { setStreams(await fetchStreams()) } catch { /* */ }
try { setStreams(await fetchStreams(true)) } catch { /* */ }
setLoading(false)
}
@@ -51,7 +51,7 @@ export default function Dashboard() {
border: '1px solid #EBE5DB',
borderTop: '3px solid #5B8C5A',
}}>
<Statistic title="直播中" value={liveCount} loading={loading}
<Statistic title="播出中" value={liveCount} loading={loading}
prefix={<SignalFilled style={{ color: '#5B8C5A', fontSize: 20 }} />} />
</div>
</Col>
@@ -71,7 +71,7 @@ export default function Dashboard() {
border: '1px solid #EBE5DB',
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 }} />} />
</div>
</Col>
@@ -82,10 +82,10 @@ export default function Dashboard() {
border: '1px solid #EBE5DB',
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}
rowKey="id" loading={loading} pagination={false} size="middle"
locale={{ emptyText: '暂无直播数据' }} />
locale={{ emptyText: '暂无节目数据' }} />
</div>
</div>
)
+45 -5
View File
@@ -1,8 +1,8 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
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 { createStream } from '../../api/streams'
import { createStream, fetchMediaFiles, type Chapter } from '../../api/streams'
import axios from 'axios'
const { Title, Text } = Typography
@@ -10,13 +10,29 @@ const { TextArea } = Input
export default function StreamForm() {
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 navigate = useNavigate()
useEffect(() => {
fetchMediaFiles().then(setMediaFiles).catch(() => message.warning('无法读取服务器回放目录'))
}, [])
const handleSubmit = async (values: any) => {
setLoading(true)
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 })
message.success('直播已创建')
} catch (error) {
@@ -75,7 +91,7 @@ export default function StreamForm() {
填写直播信息,系统将生成推流码与 RTMP 地址
</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="直播标题"
rules={[{ required: true, message: '请输入直播标题' }]}>
<Input placeholder="输入直播标题" style={{ borderRadius: 8 }} />
@@ -89,6 +105,30 @@ export default function StreamForm() {
<Form.Item name="scheduled_at" label="预定开播时间">
<DatePicker showTime style={{ width: '100%', borderRadius: 8 }} placeholder="选择日期(可选)" />
</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' }} />
<Form.Item style={{ marginBottom: 0 }}>
<Button type="primary" htmlType="submit" loading={loading} block style={{ height: 48, borderRadius: 8 }}>
+6 -6
View File
@@ -22,7 +22,7 @@ export default function StreamList() {
async function loadData() {
setLoading(true)
try { setStreams(await fetchStreams()) } catch { message.error('加载失败') }
try { setStreams(await fetchStreams(true)) } catch { message.error('加载失败') }
setLoading(false)
}
async function handleDelete(id: number) {
@@ -49,7 +49,7 @@ export default function StreamList() {
<Space size={4}>
<Tooltip title="复制推流码">
<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>
{r.status !== 'live' ? (
<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 style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 28 }}>
<div>
<Title level={2} style={{ margin: '0 0 4px', fontWeight: 600, fontSize: 28, letterSpacing: '-0.02em' }}>直播列表</Title>
<Text style={{ color: '#949088', fontSize: 14 }}>管理所有直播流</Text>
<Title level={2} style={{ margin: '0 0 4px', fontWeight: 600, fontSize: 28, letterSpacing: '-0.02em' }}>节目列表</Title>
<Text style={{ color: '#949088', fontSize: 14 }}>管理直播、排期与回放节目</Text>
</div>
<Button type="primary" icon={<PlusOutlined />} size="large" onClick={() => navigate('/admin/streams/new')}>
创建直播
创建节目
</Button>
</div>
<div style={{
@@ -82,7 +82,7 @@ export default function StreamList() {
}}>
<Table dataSource={streams} columns={columns} rowKey="id" loading={loading}
pagination={{ pageSize: 15, showSizeChanger: false }} size="middle"
locale={{ emptyText: '暂无直播,创建第一条直播吧' }} />
locale={{ emptyText: '暂无节目,创建第一个节目吧' }} />
</div>
</div>
)
+28 -3
View File
@@ -5,13 +5,23 @@ export interface StreamInfo {
title: string
description?: string
cover_url?: string
stream_key: string
stream_key?: string
status: string
scheduled_at?: string
started_at?: string
ended_at?: string
multicast_enabled: boolean
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 {
@@ -19,6 +29,11 @@ export interface StreamCreateReq {
description?: string
cover_url?: string
scheduled_at?: string
replay_file?: string
chapters?: Chapter[]
transcode_profile?: string
homepage_visible?: boolean
remote_control_enabled?: boolean
}
export interface WatchInfo {
@@ -29,6 +44,11 @@ export interface WatchInfo {
selected_node?: { id: string; name: string } | null
lines: LineInfo[]
qualities: QualityInfo[]
description?: string
scheduled_at?: string
replay_url?: string
chapters: Chapter[]
remote_control_enabled: boolean
}
export interface LineInfo {
@@ -54,8 +74,13 @@ export interface QualityInfo {
height: number
}
export async function fetchStreams(): Promise<StreamInfo[]> {
const { data } = await api.get('/streams')
export async function fetchStreams(admin = false): Promise<StreamInfo[]> {
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
}
+47 -28
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from 'react'
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 Hls from 'hls.js'
import { getWatchUrls, type WatchInfo, type LineInfo } from '../../api/streams'
@@ -52,7 +52,14 @@ export default function WatchPage() {
}
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)
if (!line) return
const quality = activeQuality ? line.qualities.find(q => q.label === activeQuality) : line.qualities[0]
@@ -74,6 +81,12 @@ export default function WatchPage() {
}
}, [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])
if (loading) return (
@@ -91,55 +104,61 @@ export default function WatchPage() {
return (
<div className="watch-shell">
{/* Top bar */}
<header className="watch-header">
<Space size={16}>
<div className="watch-heading">
<Button type="text" icon={<ArrowLeftOutlined />} style={{ color: '#949088' }}
onClick={() => navigate('/')}>{brand.brand_name}</Button>
<div style={{ width: 1, height: 18, background: 'rgba(235,229,219,0.1)' }} />
<Text style={{ color: '#EBE5DB', fontSize: 14, fontWeight: 500 }}>{info.title}</Text>
<span className="watch-divider" />
<Text className="watch-title">{info.title}</Text>
<Tag color={info.status === 'live' ? 'green' : 'default'} style={{ fontWeight: 600, fontSize: 11 }}>
{statusLabel}
</Tag>
</Space>
<Space size={16}>
</div>
<div className="watch-options">
<Tag style={{ margin: 0 }}>节点:{currentLine?.node_name || info.selected_node?.name || '不可用'}</Tag>
<Tag style={{ margin: 0 }}>清晰度:{activeQuality || '不可用'}</Tag>
<Text style={{ color: '#949088', fontSize: 13, display: 'flex', alignItems: 'center', gap: 6 }}>
<SignalFilled style={{ color: info.multicast_available ? '#5B8C5A' : '#5E5A52', fontSize: 12 }} />
{info.multicast_available ? '组播' : '单播'}
</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="清晰度" />
<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 }))}
prefix={<WifiOutlined />} placeholder="线路" />
</Space>
</div>
</header>
{/* Video */}
<main className="watch-stage">
<div className="watch-content">
<div className="watch-frame">
<video ref={videoRef} controls autoPlay playsInline
style={{ width: '100%', height: '100%', objectFit: 'contain', display: 'block' }} />
{info.status !== 'live' && (
<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}>
<video ref={videoRef} controls autoPlay playsInline className="watch-video" />
{info.status !== 'live' && !info.replay_url && (
<div className="watch-waiting">
<Spin size="large" />
<Text style={{
color: '#949088', fontSize: 17,
fontFamily: '"Source Serif 4", "Noto Serif SC", Georgia, serif',
}}>
等待主播开播...
</Text>
</Space>
<Text>等待主播开播...</Text>
</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>
</div>
)
+99
View File
@@ -219,6 +219,28 @@ button.stream-card:focus-visible {
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 {
flex: 1;
display: flex;
@@ -227,6 +249,11 @@ button.stream-card:focus-visible {
padding: 36px;
}
.watch-content {
width: 100%;
max-width: 1280px;
}
.watch-frame {
position: relative;
width: 100%;
@@ -239,6 +266,52 @@ button.stream-card:focus-visible {
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) {
.watch-header {
align-items: flex-start;
@@ -246,6 +319,27 @@ button.stream-card:focus-visible {
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 {
align-items: flex-start;
padding: 20px 0;
@@ -256,6 +350,11 @@ button.stream-card:focus-visible {
border-left: 0;
border-radius: 0;
}
.watch-details {
margin: 16px;
padding: 18px;
}
}
@media (max-width: 900px) {