完善节目平台与直播监控体验
This commit is contained in:
1 parent
229a8d0791
commit
e50b8b480b
11 files changed
+642
-37
No files matched your search
@@ -1,3 +1,4 @@
|
|||||||
from .stream import Stream
|
from .stream import Stream
|
||||||
|
from .audit import ControlCommand, ControlSession, ViewerSession
|
||||||
|
|
||||||
__all__ = ["Stream"]
|
__all__ = ["Stream", "ViewerSession", "ControlSession", "ControlCommand"]
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class ViewerSession(Base):
|
||||||
|
__tablename__ = "viewer_sessions"
|
||||||
|
|
||||||
|
session_id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||||
|
stream_id: Mapped[int] = mapped_column(ForeignKey("streams.id"), index=True)
|
||||||
|
node_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||||
|
node_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||||
|
quality: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
playback_state: Mapped[str] = mapped_column(String(32), default="idle")
|
||||||
|
buffered_seconds: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
remote_adjusted: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
last_heartbeat: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class ControlSession(Base):
|
||||||
|
__tablename__ = "control_sessions"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
token: Mapped[str] = mapped_column(String(128), unique=True, default=lambda: uuid.uuid4().hex)
|
||||||
|
stream_id: Mapped[int] = mapped_column(ForeignKey("streams.id"), index=True)
|
||||||
|
operator: Mapped[str] = mapped_column(String(128), default="administrator")
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(DateTime, index=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class ControlCommand(Base):
|
||||||
|
__tablename__ = "control_commands"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
control_session_id: Mapped[int] = mapped_column(ForeignKey("control_sessions.id"), index=True)
|
||||||
|
stream_id: Mapped[int] = mapped_column(ForeignKey("streams.id"), index=True)
|
||||||
|
target_session_id: Mapped[str] = mapped_column(ForeignKey("viewer_sessions.session_id"), index=True)
|
||||||
|
operator: Mapped[str] = mapped_column(String(128), default="administrator")
|
||||||
|
action: Mapped[str] = mapped_column(String(32))
|
||||||
|
value: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(32), default="pending")
|
||||||
|
result: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
||||||
|
claimed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
+183
-21
@@ -3,10 +3,12 @@ import secrets
|
|||||||
import asyncio
|
import asyncio
|
||||||
import time
|
import time
|
||||||
import ntpath
|
import ntpath
|
||||||
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Depends, Request
|
from fastapi import APIRouter, HTTPException, Depends, Request
|
||||||
from fastapi.responses import FileResponse, StreamingResponse
|
from fastapi.responses import FileResponse, StreamingResponse
|
||||||
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@@ -16,6 +18,7 @@ from services.stream_service import StreamService
|
|||||||
from services.media_service import media_service
|
from services.media_service import media_service
|
||||||
from services.load_balancer import lb
|
from services.load_balancer import lb
|
||||||
from config import settings
|
from config import settings
|
||||||
|
from models.audit import ControlCommand, ControlSession, ViewerSession
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/streams", tags=["streams"])
|
router = APIRouter(prefix="/api/streams", tags=["streams"])
|
||||||
|
|
||||||
@@ -46,16 +49,31 @@ class StreamUpdate(BaseModel):
|
|||||||
|
|
||||||
class RemoteSessionCreate(BaseModel):
|
class RemoteSessionCreate(BaseModel):
|
||||||
expires_in: int = 300
|
expires_in: int = 300
|
||||||
|
operator: str = "administrator"
|
||||||
|
|
||||||
|
|
||||||
class RemoteCommand(BaseModel):
|
class RemoteCommand(BaseModel):
|
||||||
token: str
|
token: str
|
||||||
action: str
|
action: str
|
||||||
value: float | None = None
|
value: str | None = None
|
||||||
|
targets: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
_remote_sessions: dict[str, dict] = {}
|
class ViewerHeartbeat(BaseModel):
|
||||||
_remote_commands: dict[int, list[dict]] = {}
|
session_id: str
|
||||||
|
node_id: str | None = None
|
||||||
|
node_name: str | None = None
|
||||||
|
quality: str | None = None
|
||||||
|
playback_state: str = "idle"
|
||||||
|
buffered_seconds: float | None = None
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CommandResult(BaseModel):
|
||||||
|
result: str = "applied"
|
||||||
|
|
||||||
|
|
||||||
|
VIEWER_ONLINE_SECONDS = 45
|
||||||
|
|
||||||
|
|
||||||
def _media_path(filename: str | None) -> Path | None:
|
def _media_path(filename: str | None) -> Path | None:
|
||||||
@@ -136,6 +154,107 @@ async def list_streams(admin: bool = False, db: AsyncSession = Depends(get_db)):
|
|||||||
return [_serialize(s, include_private=admin) for s in streams]
|
return [_serialize(s, include_private=admin) for s in streams]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/catalog/events")
|
||||||
|
async def catalog_events(request: Request, db: AsyncSession = Depends(get_db)):
|
||||||
|
async def events():
|
||||||
|
previous = None
|
||||||
|
while not await request.is_disconnected():
|
||||||
|
streams = await StreamService(db).get_public_streams()
|
||||||
|
payload = json.dumps(
|
||||||
|
[_serialize(stream, include_private=False) for stream in streams],
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
if payload != previous:
|
||||||
|
yield f"event: catalog\ndata: {payload}\n\n"
|
||||||
|
previous = payload
|
||||||
|
else:
|
||||||
|
yield ": keep-alive\n\n"
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
|
||||||
|
return StreamingResponse(events(), media_type="text/event-stream", headers={
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{stream_id}/viewer-heartbeat")
|
||||||
|
async def viewer_heartbeat(stream_id: int, body: ViewerHeartbeat,
|
||||||
|
db: AsyncSession = Depends(get_db)):
|
||||||
|
stream = await StreamService(db).get_stream(stream_id)
|
||||||
|
if not stream:
|
||||||
|
raise HTTPException(status_code=404, detail="节目不存在")
|
||||||
|
session_id = body.session_id.strip()
|
||||||
|
if not session_id or len(session_id) > 64:
|
||||||
|
raise HTTPException(status_code=422, detail="观众会话 ID 无效")
|
||||||
|
viewer = await db.get(ViewerSession, session_id)
|
||||||
|
if viewer and viewer.stream_id != stream_id:
|
||||||
|
raise HTTPException(status_code=409, detail="观众会话已用于其他节目")
|
||||||
|
if not viewer:
|
||||||
|
viewer = ViewerSession(session_id=session_id, stream_id=stream_id)
|
||||||
|
db.add(viewer)
|
||||||
|
viewer.node_id = body.node_id
|
||||||
|
viewer.node_name = body.node_name
|
||||||
|
viewer.quality = body.quality
|
||||||
|
viewer.playback_state = body.playback_state[:32]
|
||||||
|
viewer.buffered_seconds = body.buffered_seconds
|
||||||
|
viewer.error = body.error[:1000] if body.error else None
|
||||||
|
viewer.last_heartbeat = datetime.utcnow()
|
||||||
|
await db.commit()
|
||||||
|
return {"ok": True, "remote_adjusted": viewer.remote_adjusted}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{stream_id}/viewer-sessions")
|
||||||
|
async def list_viewer_sessions(stream_id: int, db: AsyncSession = Depends(get_db),
|
||||||
|
_: bool = Depends(require_auth)):
|
||||||
|
cutoff = datetime.utcnow() - timedelta(seconds=VIEWER_ONLINE_SECONDS)
|
||||||
|
viewers = (await db.execute(select(ViewerSession).where(
|
||||||
|
ViewerSession.stream_id == stream_id,
|
||||||
|
ViewerSession.last_heartbeat >= cutoff,
|
||||||
|
).order_by(ViewerSession.last_heartbeat.desc()))).scalars().all()
|
||||||
|
return [{
|
||||||
|
"session_id": viewer.session_id,
|
||||||
|
"node_id": viewer.node_id,
|
||||||
|
"node_name": viewer.node_name,
|
||||||
|
"quality": viewer.quality,
|
||||||
|
"playback_state": viewer.playback_state,
|
||||||
|
"buffered_seconds": viewer.buffered_seconds,
|
||||||
|
"error": viewer.error,
|
||||||
|
"remote_adjusted": viewer.remote_adjusted,
|
||||||
|
"last_heartbeat": viewer.last_heartbeat.isoformat(),
|
||||||
|
} for viewer in viewers]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{stream_id}/control-audit")
|
||||||
|
async def control_audit(stream_id: int, db: AsyncSession = Depends(get_db),
|
||||||
|
_: bool = Depends(require_auth)):
|
||||||
|
commands = (await db.execute(select(ControlCommand).where(
|
||||||
|
ControlCommand.stream_id == stream_id,
|
||||||
|
).order_by(ControlCommand.created_at.desc()).limit(100))).scalars().all()
|
||||||
|
return [{
|
||||||
|
"id": command.id,
|
||||||
|
"operator": command.operator,
|
||||||
|
"target_session_id": command.target_session_id,
|
||||||
|
"action": command.action,
|
||||||
|
"value": command.value,
|
||||||
|
"status": command.status,
|
||||||
|
"result": command.result,
|
||||||
|
"created_at": command.created_at.isoformat(),
|
||||||
|
"claimed_at": command.claimed_at.isoformat() if command.claimed_at else None,
|
||||||
|
} for command in commands]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{stream_id}/remote-command/{command_id}/result")
|
||||||
|
async def report_command_result(stream_id: int, command_id: int, body: CommandResult,
|
||||||
|
session_id: str, db: AsyncSession = Depends(get_db)):
|
||||||
|
command = await db.get(ControlCommand, command_id)
|
||||||
|
if not command or command.stream_id != stream_id or command.target_session_id != session_id:
|
||||||
|
raise HTTPException(status_code=404, detail="控制命令不存在")
|
||||||
|
command.status = "completed"
|
||||||
|
command.result = body.result[:1000]
|
||||||
|
await db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/media-files")
|
@router.get("/media-files")
|
||||||
async def list_media_files(_: bool = Depends(require_auth)):
|
async def list_media_files(_: bool = Depends(require_auth)):
|
||||||
root = Path(settings.replay_media_dir)
|
root = Path(settings.replay_media_dir)
|
||||||
@@ -294,34 +413,77 @@ async def create_remote_session(stream_id: int, body: RemoteSessionCreate,
|
|||||||
stream = await StreamService(db).get_stream(stream_id)
|
stream = await StreamService(db).get_stream(stream_id)
|
||||||
if not stream or not stream.remote_control_enabled:
|
if not stream or not stream.remote_control_enabled:
|
||||||
raise HTTPException(status_code=409, detail="节目未启用远程控制")
|
raise HTTPException(status_code=409, detail="节目未启用远程控制")
|
||||||
token = secrets.token_urlsafe(24)
|
expires_in = min(max(body.expires_in, 30), 3600)
|
||||||
_remote_sessions[token] = {
|
session = ControlSession(
|
||||||
"stream_id": stream_id,
|
token=secrets.token_urlsafe(24),
|
||||||
"expires_at": time.time() + min(max(body.expires_in, 30), 3600),
|
stream_id=stream_id,
|
||||||
}
|
operator=body.operator.strip() or "administrator",
|
||||||
return {"token": token, "expires_in": min(max(body.expires_in, 30), 3600)}
|
expires_at=datetime.utcnow() + timedelta(seconds=expires_in),
|
||||||
|
)
|
||||||
|
db.add(session)
|
||||||
|
await db.commit()
|
||||||
|
return {"token": session.token, "expires_in": expires_in}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{stream_id}/remote-command")
|
@router.post("/{stream_id}/remote-command")
|
||||||
async def send_remote_command(stream_id: int, body: RemoteCommand,
|
async def send_remote_command(stream_id: int, body: RemoteCommand,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
_: bool = Depends(require_auth)):
|
_: bool = Depends(require_auth)):
|
||||||
session = _remote_sessions.get(body.token)
|
session = (await db.execute(select(ControlSession).where(
|
||||||
if not session or session["stream_id"] != stream_id or session["expires_at"] < time.time():
|
ControlSession.token == body.token,
|
||||||
|
ControlSession.stream_id == stream_id,
|
||||||
|
))).scalar_one_or_none()
|
||||||
|
if not session or session.expires_at < datetime.utcnow():
|
||||||
raise HTTPException(status_code=403, detail="远程控制会话无效或已过期")
|
raise HTTPException(status_code=403, detail="远程控制会话无效或已过期")
|
||||||
if body.action not in {"play", "pause", "seek"}:
|
stream = await StreamService(db).get_stream(stream_id)
|
||||||
|
if not stream or not stream.remote_control_enabled:
|
||||||
|
raise HTTPException(status_code=409, detail="节目未启用远程控制")
|
||||||
|
if body.action not in {"switch_node", "switch_quality", "restore_auto"}:
|
||||||
raise HTTPException(status_code=422, detail="不支持的远程命令")
|
raise HTTPException(status_code=422, detail="不支持的远程命令")
|
||||||
command = {"action": body.action, "value": body.value, "created_at": time.time()}
|
if not body.targets:
|
||||||
_remote_commands.setdefault(stream_id, []).append(command)
|
raise HTTPException(status_code=422, detail="至少选择一个观众会话")
|
||||||
return {"ok": True, "command": command}
|
viewers = (await db.execute(select(ViewerSession).where(
|
||||||
|
ViewerSession.stream_id == stream_id,
|
||||||
|
ViewerSession.session_id.in_(body.targets),
|
||||||
|
))).scalars().all()
|
||||||
|
found = {viewer.session_id for viewer in viewers}
|
||||||
|
if found != set(body.targets):
|
||||||
|
raise HTTPException(status_code=404, detail="部分观众会话不存在")
|
||||||
|
commands = []
|
||||||
|
for target in body.targets:
|
||||||
|
command = ControlCommand(
|
||||||
|
control_session_id=session.id,
|
||||||
|
stream_id=stream_id,
|
||||||
|
target_session_id=target,
|
||||||
|
operator=session.operator,
|
||||||
|
action=body.action,
|
||||||
|
value=body.value,
|
||||||
|
)
|
||||||
|
db.add(command)
|
||||||
|
commands.append(command)
|
||||||
|
await db.commit()
|
||||||
|
return {"ok": True, "count": len(commands), "command_ids": [command.id for command in commands]}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{stream_id}/remote-command")
|
@router.get("/{stream_id}/remote-command")
|
||||||
async def poll_remote_command(stream_id: int, token: str):
|
async def poll_remote_command(stream_id: int, session_id: str,
|
||||||
session = _remote_sessions.get(token)
|
db: AsyncSession = Depends(get_db)):
|
||||||
if not session or session["stream_id"] != stream_id or session["expires_at"] < time.time():
|
viewer = await db.get(ViewerSession, session_id)
|
||||||
raise HTTPException(status_code=403, detail="远程控制会话无效或已过期")
|
if not viewer or viewer.stream_id != stream_id:
|
||||||
commands = _remote_commands.get(stream_id, [])
|
raise HTTPException(status_code=404, detail="观众会话不存在")
|
||||||
return commands[-1] if commands else None
|
command = (await db.execute(select(ControlCommand).where(
|
||||||
|
ControlCommand.stream_id == stream_id,
|
||||||
|
ControlCommand.target_session_id == session_id,
|
||||||
|
ControlCommand.status == "pending",
|
||||||
|
).order_by(ControlCommand.created_at).limit(1))).scalar_one_or_none()
|
||||||
|
if not command:
|
||||||
|
return None
|
||||||
|
command.status = "claimed"
|
||||||
|
command.claimed_at = datetime.utcnow()
|
||||||
|
viewer.remote_adjusted = command.action != "restore_auto"
|
||||||
|
await db.commit()
|
||||||
|
return {"id": command.id, "action": command.action, "value": command.value,
|
||||||
|
"created_at": command.created_at.isoformat()}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{stream_id}/go-live")
|
@router.post("/{stream_id}/go-live")
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const WatchPage = lazy(() => import('./viewer/pages/WatchPage'))
|
|||||||
const AdminLayout = lazy(() => import('./admin/AdminLayout'))
|
const AdminLayout = lazy(() => import('./admin/AdminLayout'))
|
||||||
const LoginPage = lazy(() => import('./admin/LoginPage'))
|
const LoginPage = lazy(() => import('./admin/LoginPage'))
|
||||||
const Dashboard = lazy(() => import('./admin/pages/Dashboard'))
|
const Dashboard = lazy(() => import('./admin/pages/Dashboard'))
|
||||||
|
const LiveBoard = lazy(() => import('./admin/pages/LiveBoard'))
|
||||||
const StreamList = lazy(() => import('./admin/pages/StreamList'))
|
const StreamList = lazy(() => import('./admin/pages/StreamList'))
|
||||||
const StreamForm = lazy(() => import('./admin/pages/StreamForm'))
|
const StreamForm = lazy(() => import('./admin/pages/StreamForm'))
|
||||||
const ReplayEditor = lazy(() => import('./admin/pages/ReplayEditor'))
|
const ReplayEditor = lazy(() => import('./admin/pages/ReplayEditor'))
|
||||||
@@ -74,6 +75,7 @@ function AppRoutes() {
|
|||||||
</AdminGuard>
|
</AdminGuard>
|
||||||
}>
|
}>
|
||||||
<Route index element={<Dashboard />} />
|
<Route index element={<Dashboard />} />
|
||||||
|
<Route path="board" element={<LiveBoard />} />
|
||||||
<Route path="streams" element={<StreamList />} />
|
<Route path="streams" element={<StreamList />} />
|
||||||
<Route path="streams/new" element={<StreamForm />} />
|
<Route path="streams/new" element={<StreamForm />} />
|
||||||
<Route path="streams/:id/edit" element={<ReplayEditor />} />
|
<Route path="streams/:id/edit" element={<ReplayEditor />} />
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useState } from 'react'
|
|||||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom'
|
import { Outlet, useNavigate, useLocation } from 'react-router-dom'
|
||||||
import { Layout, Menu, Button, Typography } from 'antd'
|
import { Layout, Menu, Button, Typography } from 'antd'
|
||||||
import {
|
import {
|
||||||
DashboardOutlined, VideoCameraOutlined, PlusOutlined,
|
DashboardOutlined, VideoCameraOutlined, PlusOutlined, MonitorOutlined,
|
||||||
CloudServerOutlined, SettingOutlined, LogoutOutlined,
|
CloudServerOutlined, SettingOutlined, LogoutOutlined,
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import { clearToken } from '../api/client'
|
import { clearToken } from '../api/client'
|
||||||
@@ -13,6 +13,7 @@ const { Text } = Typography
|
|||||||
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
{ key: '/admin', icon: <DashboardOutlined />, label: '概览' },
|
{ key: '/admin', icon: <DashboardOutlined />, label: '概览' },
|
||||||
|
{ key: '/admin/board', icon: <MonitorOutlined />, 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: '边缘节点' },
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Button, Card, Col, Empty, Row, Select, Space, Statistic, Table, Tag, Typography, message } from 'antd'
|
||||||
|
import { ReloadOutlined, SignalFilled, TeamOutlined, WarningOutlined } from '@ant-design/icons'
|
||||||
|
import {
|
||||||
|
createRemoteSession,
|
||||||
|
fetchStreams,
|
||||||
|
fetchViewerSessions,
|
||||||
|
sendRemoteCommand,
|
||||||
|
type StreamInfo,
|
||||||
|
type ViewerSessionInfo,
|
||||||
|
} from '../../api/streams'
|
||||||
|
|
||||||
|
const { Title, Text } = Typography
|
||||||
|
|
||||||
|
export default function LiveBoard() {
|
||||||
|
const [streams, setStreams] = useState<StreamInfo[]>([])
|
||||||
|
const [streamId, setStreamId] = useState<number>()
|
||||||
|
const [sessions, setSessions] = useState<ViewerSessionInfo[]>([])
|
||||||
|
const [selected, setSelected] = useState<React.Key[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [quality, setQuality] = useState('auto')
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const programs = await fetchStreams(true)
|
||||||
|
setStreams(programs)
|
||||||
|
const nextId = streamId || programs.find(program => program.status === 'live')?.id
|
||||||
|
if (nextId) {
|
||||||
|
setStreamId(nextId)
|
||||||
|
setSessions(await fetchViewerSessions(nextId))
|
||||||
|
} else {
|
||||||
|
setSessions([])
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
message.error('看板数据加载失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load()
|
||||||
|
const timer = window.setInterval(load, 5000)
|
||||||
|
return () => window.clearInterval(timer)
|
||||||
|
}, [streamId])
|
||||||
|
|
||||||
|
async function controlViewers() {
|
||||||
|
if (!streamId || selected.length === 0) return
|
||||||
|
try {
|
||||||
|
const session = await createRemoteSession(streamId)
|
||||||
|
const action = quality === 'auto' ? 'restore_auto' : 'switch_quality'
|
||||||
|
await sendRemoteCommand(streamId, session.token, action, selected.map(String), quality === 'auto' ? undefined : quality)
|
||||||
|
message.success(`已向 ${selected.length} 个观众下发播放策略`)
|
||||||
|
} catch {
|
||||||
|
message.error('控制命令下发失败,请确认节目已启用审计式播放控制')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeSessions = sessions.filter(item => Date.now() - new Date(item.last_heartbeat).getTime() < 30000)
|
||||||
|
const buffering = activeSessions.filter(item => item.playback_state === 'buffering' || Number(item.buffered_seconds || 0) < 1)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Space direction="vertical" size={24} style={{ width: '100%' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 16, flexWrap: 'wrap' }}>
|
||||||
|
<div>
|
||||||
|
<Title level={2} style={{ margin: 0 }}>直播看板</Title>
|
||||||
|
<Text type="secondary">实时检查节目、观众会话、线路与播放健康度</Text>
|
||||||
|
</div>
|
||||||
|
<Space wrap>
|
||||||
|
<Select
|
||||||
|
value={streamId}
|
||||||
|
placeholder="选择直播节目"
|
||||||
|
style={{ minWidth: 220 }}
|
||||||
|
options={streams.map(program => ({ value: program.id, label: `${program.title} · ${program.status}` }))}
|
||||||
|
onChange={value => { setStreamId(value); setSelected([]) }}
|
||||||
|
/>
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={load}>刷新</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
<Col xs={24} sm={8}><Card><Statistic title="活跃观众" value={activeSessions.length} prefix={<TeamOutlined />} /></Card></Col>
|
||||||
|
<Col xs={24} sm={8}><Card><Statistic title="播放正常" value={Math.max(0, activeSessions.length - buffering.length)} prefix={<SignalFilled />} /></Card></Col>
|
||||||
|
<Col xs={24} sm={8}><Card><Statistic title="疑似卡顿" value={buffering.length} prefix={<WarningOutlined />} valueStyle={{ color: buffering.length ? '#cf5c36' : undefined }} /></Card></Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{!streamId ? <Empty description="当前没有可查看的直播节目" /> : (
|
||||||
|
<Card
|
||||||
|
title="审计式观众会话"
|
||||||
|
extra={
|
||||||
|
<Space wrap>
|
||||||
|
<Select value={quality} onChange={setQuality} style={{ width: 150 }} options={[
|
||||||
|
{ value: 'auto', label: '恢复自动' },
|
||||||
|
{ value: '1080p', label: '1080p' },
|
||||||
|
{ value: '720p', label: '720p' },
|
||||||
|
{ value: '480p', label: '480p' },
|
||||||
|
{ value: '360p', label: '360p' },
|
||||||
|
]} />
|
||||||
|
<Button type="primary" disabled={!selected.length} onClick={controlViewers}>批量切换</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Table
|
||||||
|
rowKey="session_id"
|
||||||
|
loading={loading}
|
||||||
|
dataSource={sessions}
|
||||||
|
scroll={{ x: 850 }}
|
||||||
|
rowSelection={{ selectedRowKeys: selected, onChange: setSelected }}
|
||||||
|
pagination={{ pageSize: 20 }}
|
||||||
|
columns={[
|
||||||
|
{ title: '会话', dataIndex: 'session_id', ellipsis: true, width: 180 },
|
||||||
|
{ title: '节点', dataIndex: 'node_name', render: value => value || '未知' },
|
||||||
|
{ title: '清晰度', dataIndex: 'quality', render: value => value || '自动' },
|
||||||
|
{ title: '状态', dataIndex: 'playback_state', render: value => <Tag color={value === 'playing' ? 'green' : value === 'buffering' ? 'orange' : 'default'}>{value}</Tag> },
|
||||||
|
{ title: '缓冲', dataIndex: 'buffered_seconds', render: value => `${Number(value || 0).toFixed(1)}s` },
|
||||||
|
{ title: '远程调整', dataIndex: 'remote_adjusted', render: value => value ? <Tag color="gold">已调整</Tag> : '否' },
|
||||||
|
{ title: '最后心跳', dataIndex: 'last_heartbeat', render: value => new Date(value).toLocaleTimeString() },
|
||||||
|
{ title: '错误', dataIndex: 'error', ellipsis: true, render: value => value || '-' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -78,15 +78,15 @@ export default function StreamForm() {
|
|||||||
style={{ marginBottom: 20, color: '#5E5A52' }}>返回列表</Button>
|
style={{ marginBottom: 20, color: '#5E5A52' }}>返回列表</Button>
|
||||||
|
|
||||||
<div style={{ background: '#fff', borderRadius: 12, border: '1px solid #EBE5DB', padding: 36 }}>
|
<div style={{ background: '#fff', borderRadius: 12, border: '1px solid #EBE5DB', padding: 36 }}>
|
||||||
<Title level={2} style={{ margin: '0 0 8px', fontWeight: 600 }}>创建直播</Title>
|
<Title level={2} style={{ margin: '0 0 8px', fontWeight: 600 }}>创建节目</Title>
|
||||||
<Text style={{ color: '#5E5A52', display: 'block', marginBottom: 36 }}>
|
<Text style={{ color: '#5E5A52', display: 'block', marginBottom: 36 }}>
|
||||||
填写直播信息,系统将生成推流码与 RTMP 地址
|
填写节目信息并预约播出,系统将生成推流码与 RTMP 地址
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Form layout="vertical" onFinish={handleSubmit} size="large" initialValues={{ transcode_profile: 'standard', homepage_visible: true, remote_control_enabled: false }}>
|
<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 }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="description" label="简介">
|
<Form.Item name="description" label="简介">
|
||||||
<TextArea rows={3} placeholder="直播内容简介(可选)" style={{ borderRadius: 8 }} />
|
<TextArea rows={3} placeholder="直播内容简介(可选)" style={{ borderRadius: 8 }} />
|
||||||
@@ -109,7 +109,7 @@ export default function StreamForm() {
|
|||||||
<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 }}>
|
||||||
创建直播
|
创建节目
|
||||||
</Button>
|
</Button>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@@ -74,6 +74,37 @@ export interface QualityInfo {
|
|||||||
height: number
|
height: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ViewerSessionInfo {
|
||||||
|
session_id: string
|
||||||
|
node_id?: string
|
||||||
|
node_name?: string
|
||||||
|
quality?: string
|
||||||
|
playback_state: string
|
||||||
|
buffered_seconds?: number
|
||||||
|
error?: string
|
||||||
|
remote_adjusted: boolean
|
||||||
|
last_heartbeat: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ControlAuditInfo {
|
||||||
|
id: number
|
||||||
|
operator: string
|
||||||
|
target_session_id: string
|
||||||
|
action: string
|
||||||
|
value?: string
|
||||||
|
status: string
|
||||||
|
result?: string
|
||||||
|
created_at: string
|
||||||
|
claimed_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoteCommandInfo {
|
||||||
|
id: number
|
||||||
|
action: 'switch_node' | 'switch_quality' | 'restore_auto'
|
||||||
|
value?: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchStreams(admin = false): Promise<StreamInfo[]> {
|
export async function fetchStreams(admin = false): Promise<StreamInfo[]> {
|
||||||
const { data } = await api.get('/streams', { params: admin ? { admin: true } : undefined })
|
const { data } = await api.get('/streams', { params: admin ? { admin: true } : undefined })
|
||||||
return data
|
return data
|
||||||
@@ -120,3 +151,36 @@ export async function getWatchUrls(streamId: number): Promise<WatchInfo> {
|
|||||||
export function getStreamStatusUrl(streamId: number): string {
|
export function getStreamStatusUrl(streamId: number): string {
|
||||||
return `/api/streams/${streamId}/events`
|
return `/api/streams/${streamId}/events`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function sendViewerHeartbeat(streamId: number, payload: Record<string, unknown>): Promise<{ remote_adjusted: boolean }> {
|
||||||
|
const { data } = await api.post(`/streams/${streamId}/viewer-heartbeat`, payload)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pollRemoteCommand(streamId: number, sessionId: string): Promise<RemoteCommandInfo | null> {
|
||||||
|
const { data } = await api.get(`/streams/${streamId}/remote-command`, { params: { session_id: sessionId } })
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reportRemoteCommandResult(streamId: number, commandId: number, sessionId: string, result: string): Promise<void> {
|
||||||
|
await api.post(`/streams/${streamId}/remote-command/${commandId}/result`, { result }, { params: { session_id: sessionId } })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchViewerSessions(streamId: number): Promise<ViewerSessionInfo[]> {
|
||||||
|
const { data } = await api.get(`/streams/${streamId}/viewer-sessions`)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchControlAudit(streamId: number): Promise<ControlAuditInfo[]> {
|
||||||
|
const { data } = await api.get(`/streams/${streamId}/control-audit`)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createRemoteSession(streamId: number, expiresIn = 300): Promise<{ token: string; expires_in: number }> {
|
||||||
|
const { data } = await api.post(`/streams/${streamId}/remote-session`, { expires_in: expiresIn })
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendRemoteCommand(streamId: number, token: string, action: string, targets: string[], value?: string): Promise<void> {
|
||||||
|
await api.post(`/streams/${streamId}/remote-command`, { token, action, targets, value })
|
||||||
|
}
|
||||||
@@ -16,11 +16,42 @@ export default function HomePage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true
|
let active = true
|
||||||
fetchStreams()
|
let pollTimer: ReturnType<typeof setInterval> | undefined
|
||||||
|
const load = () => fetchStreams()
|
||||||
.then(data => { if (active) setStreams(data) })
|
.then(data => { if (active) setStreams(data) })
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
.finally(() => { if (active) setLoading(false) })
|
.finally(() => { if (active) setLoading(false) })
|
||||||
return () => { active = false }
|
const startPolling = () => {
|
||||||
|
if (!pollTimer) pollTimer = setInterval(load, 10000)
|
||||||
|
}
|
||||||
|
const stopPolling = () => {
|
||||||
|
if (pollTimer) clearInterval(pollTimer)
|
||||||
|
pollTimer = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
void load()
|
||||||
|
const events = new EventSource('/api/streams/catalog/events')
|
||||||
|
events.addEventListener('catalog', event => {
|
||||||
|
if (!active) return
|
||||||
|
try {
|
||||||
|
const data = JSON.parse((event as MessageEvent<string>).data)
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
setStreams(data)
|
||||||
|
setLoading(false)
|
||||||
|
stopPolling()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
startPolling()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
events.onopen = stopPolling
|
||||||
|
events.onerror = startPolling
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
events.close()
|
||||||
|
stopPolling()
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const sorted = [...streams].sort((a, b) => {
|
const sorted = [...streams].sort((a, b) => {
|
||||||
|
|||||||
@@ -3,7 +3,15 @@ import { useParams, useNavigate } from 'react-router-dom'
|
|||||||
import { Select, Button, Typography, Tag, Spin, Empty, Alert } from 'antd'
|
import { Select, Button, Typography, Tag, Spin, Empty, Alert } 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 { getStreamStatusUrl, getWatchUrls, type WatchInfo, type LineInfo } from '../../api/streams'
|
import {
|
||||||
|
getStreamStatusUrl,
|
||||||
|
getWatchUrls,
|
||||||
|
pollRemoteCommand,
|
||||||
|
reportRemoteCommandResult,
|
||||||
|
sendViewerHeartbeat,
|
||||||
|
type WatchInfo,
|
||||||
|
type LineInfo,
|
||||||
|
} from '../../api/streams'
|
||||||
import { useBrand } from '../../brand/BrandContext'
|
import { useBrand } from '../../brand/BrandContext'
|
||||||
import './viewer.css'
|
import './viewer.css'
|
||||||
|
|
||||||
@@ -19,10 +27,83 @@ export default function WatchPage() {
|
|||||||
const [loadError, setLoadError] = useState('')
|
const [loadError, setLoadError] = useState('')
|
||||||
const [playbackError, setPlaybackError] = useState('')
|
const [playbackError, setPlaybackError] = useState('')
|
||||||
const [activeLine, setActiveLine] = useState<string>('')
|
const [activeLine, setActiveLine] = useState<string>('')
|
||||||
const [activeQuality, setActiveQuality] = useState<string>('')
|
const [activeQuality, setActiveQuality] = useState<string>('auto')
|
||||||
|
const [autoQuality, setAutoQuality] = useState<string>('')
|
||||||
|
const bufferEventsRef = useRef<number[]>([])
|
||||||
|
const bufferStartedRef = useRef<number | null>(null)
|
||||||
|
const playbackStartedRef = useRef<number>(0)
|
||||||
|
const [remoteAdjusted, setRemoteAdjusted] = useState(false)
|
||||||
const hlsRef = useRef<Hls | null>(null)
|
const hlsRef = useRef<Hls | null>(null)
|
||||||
|
const viewerSessionRef = useRef<string>('')
|
||||||
const currentLine = info?.lines.find(line => line.node_id === activeLine)
|
const currentLine = info?.lines.find(line => line.node_id === activeLine)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const storageKey = 'viewer-session-id'
|
||||||
|
const storedSessionId = window.localStorage.getItem(storageKey)
|
||||||
|
const sessionId = storedSessionId || (window.crypto?.randomUUID?.().replace(/-/g, '') ?? `${Date.now()}${Math.random().toString(16).slice(2)}`)
|
||||||
|
if (!storedSessionId) window.localStorage.setItem(storageKey, sessionId)
|
||||||
|
viewerSessionRef.current = sessionId.slice(0, 64)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const streamId = Number(id)
|
||||||
|
if (!Number.isInteger(streamId) || streamId <= 0 || !info) return
|
||||||
|
|
||||||
|
const heartbeat = async () => {
|
||||||
|
const video = videoRef.current
|
||||||
|
const bufferedSeconds = video && video.buffered.length
|
||||||
|
? Math.max(0, video.buffered.end(video.buffered.length - 1) - video.currentTime)
|
||||||
|
: 0
|
||||||
|
try {
|
||||||
|
const result = await sendViewerHeartbeat(streamId, {
|
||||||
|
session_id: viewerSessionRef.current,
|
||||||
|
node_id: activeLine || null,
|
||||||
|
node_name: currentLine?.node_name || null,
|
||||||
|
quality: activeQuality || null,
|
||||||
|
playback_state: video?.paused ? 'paused' : video?.readyState ? 'playing' : 'idle',
|
||||||
|
buffered_seconds: bufferedSeconds,
|
||||||
|
error: playbackError || null,
|
||||||
|
})
|
||||||
|
setRemoteAdjusted(result.remote_adjusted)
|
||||||
|
} catch {
|
||||||
|
// Telemetry must never interrupt playback.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pollCommand = async () => {
|
||||||
|
try {
|
||||||
|
const command = await pollRemoteCommand(streamId, viewerSessionRef.current)
|
||||||
|
if (!command) return
|
||||||
|
let result = 'applied'
|
||||||
|
if (command.action === 'switch_quality' && command.value) {
|
||||||
|
setActiveQuality(command.value)
|
||||||
|
setRemoteAdjusted(true)
|
||||||
|
} else if (command.action === 'switch_node' && command.value) {
|
||||||
|
setActiveLine(command.value)
|
||||||
|
setRemoteAdjusted(true)
|
||||||
|
} else if (command.action === 'restore_auto') {
|
||||||
|
setRemoteAdjusted(false)
|
||||||
|
setActiveLine(info.lines.length ? autoSelectLine(info.lines).node_id : '')
|
||||||
|
setActiveQuality('auto')
|
||||||
|
} else {
|
||||||
|
result = 'unsupported'
|
||||||
|
}
|
||||||
|
await reportRemoteCommandResult(streamId, command.id, viewerSessionRef.current, result)
|
||||||
|
} catch {
|
||||||
|
// Command polling is optional and must fail closed.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void heartbeat()
|
||||||
|
void pollCommand()
|
||||||
|
const heartbeatTimer = window.setInterval(heartbeat, 10000)
|
||||||
|
const commandTimer = window.setInterval(pollCommand, 3000)
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(heartbeatTimer)
|
||||||
|
window.clearInterval(commandTimer)
|
||||||
|
}
|
||||||
|
}, [id, info, activeLine, activeQuality, playbackError, currentLine?.node_name])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const streamId = Number(id)
|
const streamId = Number(id)
|
||||||
if (!Number.isInteger(streamId) || streamId <= 0) {
|
if (!Number.isInteger(streamId) || streamId <= 0) {
|
||||||
@@ -116,7 +197,8 @@ export default function WatchPage() {
|
|||||||
setPlaybackError('所选播放线路不可用')
|
setPlaybackError('所选播放线路不可用')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const quality = activeQuality ? line.qualities?.find(q => q.label === activeQuality) : line.qualities?.[0]
|
const selectedLabel = activeQuality === 'auto' ? (autoQuality || line.qualities?.[0]?.label) : activeQuality
|
||||||
|
const quality = selectedLabel ? line.qualities?.find(q => q.label === selectedLabel) : line.qualities?.[0]
|
||||||
const source = quality?.url || line.url || line.unicast_fallback
|
const source = quality?.url || line.url || line.unicast_fallback
|
||||||
if (!source) {
|
if (!source) {
|
||||||
setPlaybackError('当前节目没有可用的播放地址')
|
setPlaybackError('当前节目没有可用的播放地址')
|
||||||
@@ -141,7 +223,60 @@ export default function WatchPage() {
|
|||||||
hlsRef.current?.destroy()
|
hlsRef.current?.destroy()
|
||||||
hlsRef.current = null
|
hlsRef.current = null
|
||||||
}
|
}
|
||||||
}, [activeLine, activeQuality, info])
|
}, [activeLine, activeQuality, autoQuality, info])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!info?.qualities.length) return
|
||||||
|
setAutoQuality(current => current || info.qualities[0].label)
|
||||||
|
}, [info])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const video = videoRef.current
|
||||||
|
if (!video || !info || activeQuality !== 'auto') return
|
||||||
|
|
||||||
|
const qualityLabels = info.qualities.map(quality => quality.label)
|
||||||
|
const lowerQuality = () => {
|
||||||
|
setAutoQuality(current => {
|
||||||
|
const index = Math.max(0, qualityLabels.indexOf(current))
|
||||||
|
return qualityLabels[Math.min(index + 1, qualityLabels.length - 1)] || current
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const higherQuality = () => {
|
||||||
|
setAutoQuality(current => {
|
||||||
|
const index = Math.max(0, qualityLabels.indexOf(current))
|
||||||
|
return qualityLabels[Math.max(index - 1, 0)] || current
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const onPlaying = () => {
|
||||||
|
const now = Date.now()
|
||||||
|
if (!playbackStartedRef.current) playbackStartedRef.current = now
|
||||||
|
if (bufferStartedRef.current && now - playbackStartedRef.current > 3000) {
|
||||||
|
const duration = now - bufferStartedRef.current
|
||||||
|
bufferEventsRef.current = bufferEventsRef.current.filter(at => now - at <= 10000)
|
||||||
|
if (duration >= 2000 || bufferEventsRef.current.length >= 3) {
|
||||||
|
lowerQuality()
|
||||||
|
bufferEventsRef.current = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bufferStartedRef.current = null
|
||||||
|
}
|
||||||
|
const onWaiting = () => {
|
||||||
|
if (Date.now() - playbackStartedRef.current <= 3000) return
|
||||||
|
bufferStartedRef.current = Date.now()
|
||||||
|
bufferEventsRef.current.push(Date.now())
|
||||||
|
}
|
||||||
|
const recoveryTimer = window.setInterval(() => {
|
||||||
|
if (!video.paused && !video.seeking && video.readyState >= 3) higherQuality()
|
||||||
|
}, 60000)
|
||||||
|
|
||||||
|
video.addEventListener('waiting', onWaiting)
|
||||||
|
video.addEventListener('playing', onPlaying)
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(recoveryTimer)
|
||||||
|
video.removeEventListener('waiting', onWaiting)
|
||||||
|
video.removeEventListener('playing', onPlaying)
|
||||||
|
}
|
||||||
|
}, [activeQuality, info])
|
||||||
|
|
||||||
function jumpToChapter(time: number) {
|
function jumpToChapter(time: number) {
|
||||||
if (!videoRef.current) return
|
if (!videoRef.current) return
|
||||||
@@ -180,13 +315,17 @@ export default function WatchPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="watch-options">
|
<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 === 'auto' ? `自动(${autoQuality || '检测中'})` : activeQuality || '不可用'}</Tag>
|
||||||
|
{remoteAdjusted && <Tag color="gold" style={{ margin: 0 }}>播放由管理员临时调整</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 || undefined} onChange={setActiveQuality} className="watch-quality" 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={[
|
||||||
|
{ value: 'auto', label: `自动(${autoQuality || '检测中'})` },
|
||||||
|
...info.qualities.map(q => ({ value: q.label, label: q.label })),
|
||||||
|
]} placeholder="清晰度" />
|
||||||
<Select value={activeLine || undefined} onChange={setActiveLine} className="watch-line" size="small"
|
<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="线路" />
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
.viewer-shell {
|
.viewer-shell {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100vw;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
background: #f4f0e8;
|
background: #f4f0e8;
|
||||||
color: #211d18;
|
color: #211d18;
|
||||||
@@ -463,6 +465,32 @@ button.stream-card:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
|
.watch-shell,
|
||||||
|
.watch-stage,
|
||||||
|
.watch-content,
|
||||||
|
.watch-frame,
|
||||||
|
.watch-video {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100vw;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watch-header {
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watch-options {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.watch-quality,
|
||||||
|
.watch-line {
|
||||||
|
width: 100% !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
.viewer-nav { margin-bottom: 48px; }
|
.viewer-nav { margin-bottom: 48px; }
|
||||||
|
|
||||||
.viewer-directory-tools { align-items: stretch; flex-direction: column; }
|
.viewer-directory-tools { align-items: stretch; flex-direction: column; }
|
||||||
|
|||||||
Reference in new issue
Block a user