value || '未知' },
+ { title: '清晰度', dataIndex: 'quality', render: value => value || '自动' },
+ { title: '状态', dataIndex: 'playback_state', render: value => {value} },
+ { title: '缓冲', dataIndex: 'buffered_seconds', render: value => `${Number(value || 0).toFixed(1)}s` },
+ { title: '远程调整', dataIndex: 'remote_adjusted', render: value => value ? 已调整 : '否' },
+ { title: '最后心跳', dataIndex: 'last_heartbeat', render: value => new Date(value).toLocaleTimeString() },
+ { title: '错误', dataIndex: 'error', ellipsis: true, render: value => value || '-' },
+ ]}
+ />
+
+ )}
+
+
+ )
+}
diff --git a/frontend/src/admin/pages/StreamForm.tsx b/frontend/src/admin/pages/StreamForm.tsx
index d91a795..5f46b98 100644
--- a/frontend/src/admin/pages/StreamForm.tsx
+++ b/frontend/src/admin/pages/StreamForm.tsx
@@ -78,15 +78,15 @@ export default function StreamForm() {
style={{ marginBottom: 20, color: '#5E5A52' }}>返回列表
-
创建直播
+
创建节目
- 填写直播信息,系统将生成推流码与 RTMP 地址
+ 填写节目信息并预约播出,系统将生成推流码与 RTMP 地址
-
+
+
@@ -109,7 +109,7 @@ export default function StreamForm() {
diff --git a/frontend/src/api/streams.ts b/frontend/src/api/streams.ts
index 6ab0e71..e80c36d 100644
--- a/frontend/src/api/streams.ts
+++ b/frontend/src/api/streams.ts
@@ -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 {
const { data } = await api.get('/streams', { params: admin ? { admin: true } : undefined })
return data
@@ -120,3 +151,36 @@ export async function getWatchUrls(streamId: number): Promise {
export function getStreamStatusUrl(streamId: number): string {
return `/api/streams/${streamId}/events`
}
+
+export async function sendViewerHeartbeat(streamId: number, payload: Record): 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 {
+ 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 {
+ await api.post(`/streams/${streamId}/remote-command/${commandId}/result`, { result }, { params: { session_id: sessionId } })
+}
+
+export async function fetchViewerSessions(streamId: number): Promise {
+ const { data } = await api.get(`/streams/${streamId}/viewer-sessions`)
+ return data
+}
+
+export async function fetchControlAudit(streamId: number): Promise {
+ 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 {
+ await api.post(`/streams/${streamId}/remote-command`, { token, action, targets, value })
+}
diff --git a/frontend/src/viewer/pages/HomePage.tsx b/frontend/src/viewer/pages/HomePage.tsx
index 52669b8..a59a658 100644
--- a/frontend/src/viewer/pages/HomePage.tsx
+++ b/frontend/src/viewer/pages/HomePage.tsx
@@ -16,11 +16,42 @@ export default function HomePage() {
useEffect(() => {
let active = true
- fetchStreams()
+ let pollTimer: ReturnType | 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).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) => {
diff --git a/frontend/src/viewer/pages/WatchPage.tsx b/frontend/src/viewer/pages/WatchPage.tsx
index 0bf947f..223f1b7 100644
--- a/frontend/src/viewer/pages/WatchPage.tsx
+++ b/frontend/src/viewer/pages/WatchPage.tsx
@@ -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('')
- const [activeQuality, setActiveQuality] = useState('')
+ const [activeQuality, setActiveQuality] = useState('auto')
+ const [autoQuality, setAutoQuality] = useState('')
+ const bufferEventsRef = useRef([])
+ const bufferStartedRef = useRef(null)
+ const playbackStartedRef = useRef(0)
+ const [remoteAdjusted, setRemoteAdjusted] = useState(false)
const hlsRef = useRef(null)
+ const viewerSessionRef = useRef('')
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() {
节点:{currentLine?.node_name || info.selected_node?.name || '不可用'}
- 清晰度:{activeQuality || '不可用'}
+ 清晰度:{activeQuality === 'auto' ? `自动(${autoQuality || '检测中'})` : activeQuality || '不可用'}
+ {remoteAdjusted && 播放由管理员临时调整}
{info.multicast_available ? '组播' : '单播'}