完善节目平台与直播监控体验
This commit is contained in:
1 parent
229a8d0791
commit
e50b8b480b
11 files changed
+642
-37
No files matched your search
@@ -12,6 +12,7 @@ const WatchPage = lazy(() => import('./viewer/pages/WatchPage'))
|
||||
const AdminLayout = lazy(() => import('./admin/AdminLayout'))
|
||||
const LoginPage = lazy(() => import('./admin/LoginPage'))
|
||||
const Dashboard = lazy(() => import('./admin/pages/Dashboard'))
|
||||
const LiveBoard = lazy(() => import('./admin/pages/LiveBoard'))
|
||||
const StreamList = lazy(() => import('./admin/pages/StreamList'))
|
||||
const StreamForm = lazy(() => import('./admin/pages/StreamForm'))
|
||||
const ReplayEditor = lazy(() => import('./admin/pages/ReplayEditor'))
|
||||
@@ -74,6 +75,7 @@ function AppRoutes() {
|
||||
</AdminGuard>
|
||||
}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="board" element={<LiveBoard />} />
|
||||
<Route path="streams" element={<StreamList />} />
|
||||
<Route path="streams/new" element={<StreamForm />} />
|
||||
<Route path="streams/:id/edit" element={<ReplayEditor />} />
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState } from 'react'
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom'
|
||||
import { Layout, Menu, Button, Typography } from 'antd'
|
||||
import {
|
||||
DashboardOutlined, VideoCameraOutlined, PlusOutlined,
|
||||
DashboardOutlined, VideoCameraOutlined, PlusOutlined, MonitorOutlined,
|
||||
CloudServerOutlined, SettingOutlined, LogoutOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { clearToken } from '../api/client'
|
||||
@@ -13,6 +13,7 @@ const { Text } = Typography
|
||||
|
||||
const menuItems = [
|
||||
{ key: '/admin', icon: <DashboardOutlined />, label: '概览' },
|
||||
{ key: '/admin/board', icon: <MonitorOutlined />, label: '直播看板' },
|
||||
{ key: '/admin/streams', icon: <VideoCameraOutlined />, label: '节目列表' },
|
||||
{ key: '/admin/streams/new', icon: <PlusOutlined />, 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>
|
||||
|
||||
<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 }}>
|
||||
填写直播信息,系统将生成推流码与 RTMP 地址
|
||||
填写节目信息并预约播出,系统将生成推流码与 RTMP 地址
|
||||
</Text>
|
||||
|
||||
<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 }} />
|
||||
<Form.Item name="title" label="节目标题"
|
||||
rules={[{ required: true, message: '请输入节目标题' }]}>
|
||||
<Input placeholder="输入节目标题" style={{ borderRadius: 8 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="简介">
|
||||
<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' }} />
|
||||
<Form.Item style={{ marginBottom: 0 }}>
|
||||
<Button type="primary" htmlType="submit" loading={loading} block style={{ height: 48, borderRadius: 8 }}>
|
||||
创建直播
|
||||
创建节目
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
@@ -74,6 +74,37 @@ export interface QualityInfo {
|
||||
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[]> {
|
||||
const { data } = await api.get('/streams', { params: admin ? { admin: true } : undefined })
|
||||
return data
|
||||
@@ -120,3 +151,36 @@ export async function getWatchUrls(streamId: number): Promise<WatchInfo> {
|
||||
export function getStreamStatusUrl(streamId: number): string {
|
||||
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(() => {
|
||||
let active = true
|
||||
fetchStreams()
|
||||
let pollTimer: ReturnType<typeof setInterval> | undefined
|
||||
const load = () => fetchStreams()
|
||||
.then(data => { if (active) setStreams(data) })
|
||||
.catch(() => {})
|
||||
.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) => {
|
||||
|
||||
@@ -3,7 +3,15 @@ import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { Select, Button, Typography, Tag, Spin, Empty, Alert } from 'antd'
|
||||
import { ArrowLeftOutlined, WifiOutlined, SignalFilled } from '@ant-design/icons'
|
||||
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 './viewer.css'
|
||||
|
||||
@@ -19,10 +27,83 @@ export default function WatchPage() {
|
||||
const [loadError, setLoadError] = useState('')
|
||||
const [playbackError, setPlaybackError] = useState('')
|
||||
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 viewerSessionRef = useRef<string>('')
|
||||
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(() => {
|
||||
const streamId = Number(id)
|
||||
if (!Number.isInteger(streamId) || streamId <= 0) {
|
||||
@@ -116,7 +197,8 @@ export default function WatchPage() {
|
||||
setPlaybackError('所选播放线路不可用')
|
||||
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
|
||||
if (!source) {
|
||||
setPlaybackError('当前节目没有可用的播放地址')
|
||||
@@ -141,7 +223,60 @@ export default function WatchPage() {
|
||||
hlsRef.current?.destroy()
|
||||
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) {
|
||||
if (!videoRef.current) return
|
||||
@@ -180,13 +315,17 @@ export default function WatchPage() {
|
||||
</div>
|
||||
<div className="watch-options">
|
||||
<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 }}>
|
||||
<SignalFilled style={{ color: info.multicast_available ? '#5B8C5A' : '#5E5A52', fontSize: 12 }} />
|
||||
{info.multicast_available ? '组播' : '单播'}
|
||||
</Text>
|
||||
<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"
|
||||
options={info.lines.map(l => ({ value: l.node_id, label: l.node_name }))}
|
||||
prefix={<WifiOutlined />} placeholder="线路" />
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
.viewer-shell {
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
overflow-x: hidden;
|
||||
background: #f4f0e8;
|
||||
color: #211d18;
|
||||
@@ -463,6 +465,32 @@ button.stream-card:focus-visible {
|
||||
}
|
||||
|
||||
@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-directory-tools { align-items: stretch; flex-direction: column; }
|
||||
|
||||
Reference in new issue
Block a user