完善跨平台回放与实时状态更新
This commit is contained in:
1 parent
a493ef2309
commit
3060828774
11 files changed
+244
-37
No files matched your search
@@ -14,6 +14,7 @@ const LoginPage = lazy(() => import('./admin/LoginPage'))
|
||||
const Dashboard = lazy(() => import('./admin/pages/Dashboard'))
|
||||
const StreamList = lazy(() => import('./admin/pages/StreamList'))
|
||||
const StreamForm = lazy(() => import('./admin/pages/StreamForm'))
|
||||
const ReplayEditor = lazy(() => import('./admin/pages/ReplayEditor'))
|
||||
const NodeManager = lazy(() => import('./admin/pages/NodeManager'))
|
||||
const SettingsPage = lazy(() => import('./admin/pages/Settings'))
|
||||
|
||||
@@ -48,6 +49,7 @@ function AppRoutes() {
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="streams" element={<StreamList />} />
|
||||
<Route path="streams/new" element={<StreamForm />} />
|
||||
<Route path="streams/:id/edit" element={<ReplayEditor />} />
|
||||
<Route path="nodes" element={<NodeManager />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { Alert, Button, Input, InputNumber, Select, Space, Spin, Typography, message } from 'antd'
|
||||
import { ArrowDownOutlined, ArrowLeftOutlined, ArrowUpOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons'
|
||||
import { fetchMediaFiles, getStream, updateStream, type Chapter, type StreamInfo } from '../../api/streams'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
|
||||
export default function ReplayEditor() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const [stream, setStream] = useState<StreamInfo | null>(null)
|
||||
const [mediaFiles, setMediaFiles] = useState<string[]>([])
|
||||
const [replayFile, setReplayFile] = useState<string>()
|
||||
const [chapters, setChapters] = useState<Chapter[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const streamId = Number(id)
|
||||
if (!Number.isInteger(streamId)) return
|
||||
Promise.all([getStream(streamId), fetchMediaFiles()])
|
||||
.then(([program, files]) => {
|
||||
setStream(program)
|
||||
setReplayFile(program.replay_file)
|
||||
setChapters(program.chapters || [])
|
||||
setMediaFiles(files)
|
||||
})
|
||||
.catch(() => message.error('无法载入节目回放配置'))
|
||||
.finally(() => setLoading(false))
|
||||
}, [id])
|
||||
|
||||
const move = (index: number, offset: number) => {
|
||||
setChapters(current => {
|
||||
const target = index + offset
|
||||
if (target < 0 || target >= current.length) return current
|
||||
const next = [...current]
|
||||
;[next[index], next[target]] = [next[target], next[index]]
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const addChapter = () => {
|
||||
const time = Math.max(0, Math.round((videoRef.current?.currentTime || 0) * 10) / 10)
|
||||
setChapters(current => [...current, { title: `章节 ${current.length + 1}`, time }])
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
if (!stream || !replayFile) return message.warning('请先选择回放文件')
|
||||
setSaving(true)
|
||||
try {
|
||||
await updateStream(stream.id, { replay_file: replayFile, chapters })
|
||||
message.success('回放和章节已保存')
|
||||
} catch {
|
||||
message.error('保存失败,请检查章节标题、时间和媒体文件')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div style={{ padding: 48, textAlign: 'center' }}><Spin /></div>
|
||||
if (!stream) return <Alert type="error" message="节目不存在" />
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 980, margin: '0 auto', padding: '8px 0 48px' }}>
|
||||
<Button type="text" icon={<ArrowLeftOutlined />} onClick={() => navigate('/admin/streams')} style={{ marginBottom: 20 }}>返回节目列表</Button>
|
||||
<Title level={2} style={{ marginBottom: 4 }}>回放与章节</Title>
|
||||
<Text type="secondary">{stream.title}</Text>
|
||||
{stream.status !== 'ended' ? (
|
||||
<Alert style={{ marginTop: 24 }} type="warning" showIcon message="节目结束后才能配置回放和章节" />
|
||||
) : (
|
||||
<Space direction="vertical" size={24} style={{ width: '100%', marginTop: 24 }}>
|
||||
<Select
|
||||
showSearch
|
||||
value={replayFile}
|
||||
placeholder="选择媒体目录中的回放文件"
|
||||
options={mediaFiles.map(file => ({ value: file, label: file }))}
|
||||
onChange={setReplayFile}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<video
|
||||
ref={videoRef}
|
||||
controls
|
||||
src={replayFile ? `/api/streams/${stream.id}/replay?preview=${encodeURIComponent(replayFile)}` : undefined}
|
||||
style={{ width: '100%', maxHeight: 520, borderRadius: 12, background: '#111' }}
|
||||
/>
|
||||
<Button icon={<PlusOutlined />} onClick={addChapter} disabled={!replayFile}>按当前播放时间添加章节</Button>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{chapters.map((chapter, index) => (
|
||||
<Space key={index} wrap style={{ width: '100%' }}>
|
||||
<Input
|
||||
value={chapter.title}
|
||||
placeholder="章节标题"
|
||||
onChange={event => setChapters(current => current.map((item, i) => i === index ? { ...item, title: event.target.value } : item))}
|
||||
style={{ width: 320, maxWidth: '100%' }}
|
||||
/>
|
||||
<InputNumber
|
||||
min={0}
|
||||
step={0.1}
|
||||
value={chapter.time}
|
||||
addonAfter="秒"
|
||||
onChange={value => setChapters(current => current.map((item, i) => i === index ? { ...item, time: Number(value || 0) } : item))}
|
||||
/>
|
||||
<Button icon={<ArrowUpOutlined />} disabled={index === 0} onClick={() => move(index, -1)} />
|
||||
<Button icon={<ArrowDownOutlined />} disabled={index === chapters.length - 1} onClick={() => move(index, 1)} />
|
||||
<Button danger icon={<DeleteOutlined />} onClick={() => setChapters(current => current.filter((_, i) => i !== index))} />
|
||||
</Space>
|
||||
))}
|
||||
</Space>
|
||||
<Button type="primary" loading={saving} onClick={save}>保存回放与章节</Button>
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Form, Input, Button, DatePicker, Typography, message, Space, Alert, Select, Switch } from 'antd'
|
||||
import { CopyOutlined, ArrowLeftOutlined, CheckCircleOutlined } from '@ant-design/icons'
|
||||
import { createStream, fetchMediaFiles, type Chapter } from '../../api/streams'
|
||||
import { createStream } from '../../api/streams'
|
||||
import axios from 'axios'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
@@ -10,15 +10,9 @@ 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 {
|
||||
@@ -27,8 +21,6 @@ export default function StreamForm() {
|
||||
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,
|
||||
@@ -105,21 +97,6 @@ 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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Table, Button, Tag, Space, Typography, message, Tooltip, Popconfirm } from 'antd'
|
||||
import { PlusOutlined, PlayCircleOutlined, StopOutlined, DeleteOutlined, LinkOutlined, EyeOutlined } from '@ant-design/icons'
|
||||
import { PlusOutlined, PlayCircleOutlined, StopOutlined, DeleteOutlined, LinkOutlined, EyeOutlined, EditOutlined } from '@ant-design/icons'
|
||||
import { fetchStreams, deleteStream, goLive, endStream, type StreamInfo } from '../../api/streams'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
@@ -57,6 +57,7 @@ export default function StreamList() {
|
||||
<Tooltip title="结束"><Button size="small" danger icon={<StopOutlined />} onClick={() => handleEnd(r.id)} /></Tooltip>
|
||||
)}
|
||||
<Tooltip title="观看"><Button size="small" icon={<EyeOutlined />} onClick={() => window.open(`/watch/${r.id}`, '_blank')} /></Tooltip>
|
||||
<Tooltip title="编辑"><Button size="small" icon={<EditOutlined />} onClick={() => navigate(`/admin/streams/${r.id}/edit`)} /></Tooltip>
|
||||
<Popconfirm title="确定删除?" onConfirm={() => handleDelete(r.id)}>
|
||||
<Tooltip title="删除"><Button size="small" danger icon={<DeleteOutlined />} /></Tooltip>
|
||||
</Popconfirm>
|
||||
|
||||
@@ -116,3 +116,7 @@ export async function getWatchUrls(streamId: number): Promise<WatchInfo> {
|
||||
const { data } = await api.get(`/streams/${streamId}/watch`)
|
||||
return data
|
||||
}
|
||||
|
||||
export function getStreamStatusUrl(streamId: number): string {
|
||||
return `/api/streams/${streamId}/events`
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { useParams, useNavigate } from 'react-router-dom'
|
||||
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'
|
||||
import { getStreamStatusUrl, getWatchUrls, type WatchInfo, type LineInfo } from '../../api/streams'
|
||||
import { useBrand } from '../../brand/BrandContext'
|
||||
import './viewer.css'
|
||||
|
||||
@@ -24,8 +24,13 @@ export default function WatchPage() {
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
loadInfo()
|
||||
const timer = setInterval(loadInfo, 15000)
|
||||
return () => clearInterval(timer)
|
||||
const events = new EventSource(getStreamStatusUrl(Number(id)))
|
||||
events.addEventListener('status', loadInfo)
|
||||
events.addEventListener('deleted', () => setInfo(null))
|
||||
events.onerror = () => {
|
||||
events.close()
|
||||
}
|
||||
return () => events.close()
|
||||
}, [id])
|
||||
|
||||
async function loadInfo() {
|
||||
|
||||
Reference in new issue
Block a user