feat: complete program platform experience

This commit is contained in:
admin committed 2026-08-19 17:25:00 +08:00
1 parent e50b8b480b
commit 383c066d86
9 files changed
+417 -65

No files matched your search

+95 -2
View File
@@ -1,5 +1,9 @@
from fastapi import APIRouter, Depends import os
from pydantic import BaseModel import shutil
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from typing import Optional from typing import Optional
from config import settings from config import settings
from routes.auth import require_auth from routes.auth import require_auth
@@ -13,6 +17,17 @@ class BrandUpdate(BaseModel):
brand_logo_url: Optional[str] = None brand_logo_url: Optional[str] = None
class TranscodeQuality(BaseModel):
label: str = Field(min_length=1, max_length=32)
width: int = Field(ge=160, le=7680)
height: int = Field(ge=90, le=4320)
bitrate: str = Field(pattern=r"^[1-9][0-9]*(?:k|M)$")
class TranscodeProfilesUpdate(BaseModel):
profiles: dict[str, list[TranscodeQuality]]
@router.get("/brand") @router.get("/brand")
async def get_brand(): async def get_brand():
return { return {
@@ -31,3 +46,81 @@ async def update_brand(body: BrandUpdate, _: bool = Depends(require_auth)):
if body.brand_logo_url is not None: if body.brand_logo_url is not None:
settings.brand_logo_url = body.brand_logo_url settings.brand_logo_url = body.brand_logo_url
return {"ok": True} return {"ok": True}
@router.get("/transcode-profiles")
async def get_transcode_profiles(_: bool = Depends(require_auth)):
return settings.transcode_profiles
@router.put("/transcode-profiles")
async def update_transcode_profiles(
body: TranscodeProfilesUpdate,
_: bool = Depends(require_auth),
):
if not body.profiles:
raise HTTPException(status_code=422, detail="至少需要一个转码配置")
normalized: dict[str, list[dict]] = {}
for raw_name, qualities in body.profiles.items():
name = raw_name.strip()
if not name or len(name) > 64:
raise HTTPException(status_code=422, detail="转码配置名称无效")
if not qualities:
raise HTTPException(status_code=422, detail=f"转码配置 {name} 至少需要一个清晰度")
labels = [quality.label.strip() for quality in qualities]
if any(not label for label in labels) or len(labels) != len(set(labels)):
raise HTTPException(status_code=422, detail=f"转码配置 {name} 的清晰度名称无效或重复")
normalized[name] = [
{
"label": quality.label.strip(),
"width": quality.width,
"height": quality.height,
"bitrate": quality.bitrate,
}
for quality in sorted(qualities, key=lambda item: item.height, reverse=True)
]
settings.transcode_profiles = normalized
settings.qualities = next(iter(normalized.values()))
return {"ok": True, "profiles": normalized}
@router.get("/system-metrics")
async def get_system_metrics(_: bool = Depends(require_auth)):
load_1m, load_5m, load_15m = os.getloadavg()
memory_total = 0
memory_available = 0
meminfo = Path("/proc/meminfo")
if meminfo.exists():
values = {}
for line in meminfo.read_text(encoding="ascii").splitlines():
key, _, value = line.partition(":")
if value:
values[key] = int(value.strip().split()[0]) * 1024
memory_total = values.get("MemTotal", 0)
memory_available = values.get("MemAvailable", 0)
metrics_path = Path(settings.replay_media_dir).resolve()
disk_path = metrics_path if metrics_path.exists() else Path.cwd()
disk = shutil.disk_usage(disk_path)
cpu_count = os.cpu_count() or 1
return {
"cpu": {
"count": cpu_count,
"load_1m": load_1m,
"load_5m": load_5m,
"load_15m": load_15m,
"load_percent": round(min(100, load_1m / cpu_count * 100), 1),
},
"memory": {
"total": memory_total,
"available": memory_available,
"used_percent": round((1 - memory_available / memory_total) * 100, 1) if memory_total else 0,
},
"disk": {
"total": disk.total,
"free": disk.free,
"used_percent": round(disk.used / disk.total * 100, 1) if disk.total else 0,
},
}
+71
View File
@@ -0,0 +1,71 @@
import unittest
from datetime import datetime
from types import SimpleNamespace
from routes.streams import _serialize
class StreamAccessSerializationTest(unittest.TestCase):
def make_stream(self, **overrides):
values = {
"id": 1,
"title": "Hidden program",
"description": None,
"cover_url": None,
"status": SimpleNamespace(value="pending"),
"scheduled_at": datetime(2026, 8, 20, 12, 0),
"started_at": None,
"ended_at": None,
"multicast_enabled": False,
"replay_file": None,
"chapters_json": "[]",
"transcode_profile": "default",
"homepage_visible": False,
"remote_control_enabled": True,
"stream_key": "private-key",
"created_at": datetime(2026, 8, 19, 12, 0),
}
values.update(overrides)
return SimpleNamespace(**values)
def test_public_serialization_excludes_private_stream_key(self):
payload = _serialize(self.make_stream(), include_private=False)
self.assertNotIn("stream_key", payload)
self.assertFalse(payload["homepage_visible"])
self.assertTrue(payload["remote_control_enabled"])
def test_admin_serialization_includes_private_stream_key(self):
payload = _serialize(self.make_stream(), include_private=True)
self.assertEqual(payload["stream_key"], "private-key")
def test_program_statuses_are_preserved(self):
for status in ("pending", "live", "ended", "cancelled"):
with self.subTest(status=status):
payload = _serialize(self.make_stream(status=SimpleNamespace(value=status)), include_private=False)
self.assertEqual(payload["status"], status)
class AccessContractTest(unittest.TestCase):
def test_hidden_program_uses_private_direct_url_contract(self):
stream = StreamAccessSerializationTest().make_stream(homepage_visible=False)
public_payload = _serialize(stream, include_private=False)
admin_payload = _serialize(stream, include_private=True)
self.assertFalse(public_payload["homepage_visible"])
self.assertNotIn("stream_key", public_payload)
self.assertEqual(admin_payload["stream_key"], "private-key")
def test_remote_control_flag_is_enforced_by_contract(self):
enabled = _serialize(
StreamAccessSerializationTest().make_stream(remote_control_enabled=True),
include_private=False,
)
disabled = _serialize(
StreamAccessSerializationTest().make_stream(remote_control_enabled=False),
include_private=False,
)
self.assertTrue(enabled["remote_control_enabled"])
self.assertFalse(disabled["remote_control_enabled"])
if __name__ == "__main__":
unittest.main()
+33
View File
@@ -0,0 +1,33 @@
import asyncio
import unittest
from pydantic import ValidationError
from routes.settings_route import TranscodeQuality, get_system_metrics
class SettingsRouteTest(unittest.TestCase):
def test_transcode_quality_accepts_supported_values(self):
quality = TranscodeQuality(label="高清", width=1920, height=1080, bitrate="5000k")
self.assertEqual(quality.width, 1920)
self.assertEqual(quality.bitrate, "5000k")
def test_transcode_quality_rejects_invalid_bitrate_and_dimensions(self):
invalid_values = [
dict(label="高清", width=100, height=1080, bitrate="5000k"),
dict(label="高清", width=1920, height=1080, bitrate="5000"),
dict(label="", width=1920, height=1080, bitrate="5M"),
]
for value in invalid_values:
with self.subTest(value=value), self.assertRaises(ValidationError):
TranscodeQuality(**value)
def test_system_metrics_contains_bounded_resource_values(self):
metrics = asyncio.run(get_system_metrics(True))
self.assertGreaterEqual(metrics["cpu"]["count"], 1)
self.assertGreaterEqual(metrics["cpu"]["load_percent"], 0)
self.assertLessEqual(metrics["cpu"]["load_percent"], 100)
self.assertGreaterEqual(metrics["memory"]["used_percent"], 0)
self.assertLessEqual(metrics["memory"]["used_percent"], 100)
self.assertGreater(metrics["disk"]["total"], 0)
self.assertGreaterEqual(metrics["disk"]["free"], 0)
+16 -1
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Button, Card, Col, Empty, Row, Select, Space, Statistic, Table, Tag, Typography, message } from 'antd' 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 { DashboardOutlined, DatabaseOutlined, FullscreenExitOutlined, FullscreenOutlined, ReloadOutlined, SignalFilled, TeamOutlined, WarningOutlined } from '@ant-design/icons'
import { getSystemMetrics, type SystemMetrics } from '../../api/system'
import { import {
createRemoteSession, createRemoteSession,
fetchStreams, fetchStreams,
@@ -18,7 +19,9 @@ export default function LiveBoard() {
const [sessions, setSessions] = useState<ViewerSessionInfo[]>([]) const [sessions, setSessions] = useState<ViewerSessionInfo[]>([])
const [selected, setSelected] = useState<React.Key[]>([]) const [selected, setSelected] = useState<React.Key[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [systemMetrics, setSystemMetrics] = useState<SystemMetrics | null>(null)
const [quality, setQuality] = useState('auto') const [quality, setQuality] = useState('auto')
const [wallMode, setWallMode] = useState(false)
async function load() { async function load() {
try { try {
@@ -34,6 +37,11 @@ export default function LiveBoard() {
} catch { } catch {
message.error('看板数据加载失败') message.error('看板数据加载失败')
} finally { } finally {
try {
setSystemMetrics(await getSystemMetrics())
} catch {
setSystemMetrics(null)
}
setLoading(false) setLoading(false)
} }
} }
@@ -75,6 +83,7 @@ export default function LiveBoard() {
options={streams.map(program => ({ value: program.id, label: `${program.title} · ${program.status}` }))} options={streams.map(program => ({ value: program.id, label: `${program.title} · ${program.status}` }))}
onChange={value => { setStreamId(value); setSelected([]) }} onChange={value => { setStreamId(value); setSelected([]) }}
/> />
<Button icon={wallMode ? <FullscreenExitOutlined /> : <FullscreenOutlined />} onClick={() => setWallMode(value => !value)}>{wallMode ? '退出大屏' : '大屏模式'}</Button>
<Button icon={<ReloadOutlined />} onClick={load}>刷新</Button> <Button icon={<ReloadOutlined />} onClick={load}>刷新</Button>
</Space> </Space>
</div> </div>
@@ -85,6 +94,12 @@ export default function LiveBoard() {
<Col xs={24} sm={8}><Card><Statistic title="疑似卡顿" value={buffering.length} prefix={<WarningOutlined />} valueStyle={{ color: buffering.length ? '#cf5c36' : undefined }} /></Card></Col> <Col xs={24} sm={8}><Card><Statistic title="疑似卡顿" value={buffering.length} prefix={<WarningOutlined />} valueStyle={{ color: buffering.length ? '#cf5c36' : undefined }} /></Card></Col>
</Row> </Row>
<Row gutter={[16, 16]}>
<Col xs={24} sm={8}><Card><Statistic title="CPU 使用率" value={systemMetrics?.cpu_percent ?? 0} precision={1} suffix="%" prefix={<DashboardOutlined />} /></Card></Col>
<Col xs={24} sm={8}><Card><Statistic title="内存使用率" value={systemMetrics?.memory_percent ?? 0} precision={1} suffix="%" prefix={<DatabaseOutlined />} /></Card></Col>
<Col xs={24} sm={8}><Card><Statistic title="存储使用率" value={systemMetrics?.disk_percent ?? 0} precision={1} suffix="%" prefix={<DatabaseOutlined />} /></Card></Col>
</Row>
{!streamId ? <Empty description="当前没有可查看的直播节目" /> : ( {!streamId ? <Empty description="当前没有可查看的直播节目" /> : (
<Card <Card
title="审计式观众会话" title="审计式观众会话"
+11
View File
@@ -0,0 +1,11 @@
.settings-page { width: 100%; max-width: 960px; min-width: 0; }
.settings-card { margin-top: 20px; padding: 24px; overflow: hidden; background: #fff; border: 1px solid #ebe5db; border-radius: 12px; }
.profile-block { display: grid; gap: 12px; margin: 20px 0; min-width: 0; }
.quality-row { display: grid; grid-template-columns: minmax(130px, 1.4fr) repeat(2, minmax(115px, .8fr)) minmax(120px, 1fr) auto; gap: 10px; align-items: center; min-width: 0; }
.quality-row > * { min-width: 0; max-width: 100%; }
@media (max-width: 720px) {
.settings-card { padding: 16px; }
.quality-row { grid-template-columns: 1fr 1fr; }
.quality-row > :first-child, .quality-row > :nth-child(4), .quality-row > :last-child { grid-column: 1 / -1; width: 100%; }
.settings-page .ant-select, .settings-page .ant-input-number { width: 100% !important; }
}
+88 -54
View File
@@ -1,10 +1,14 @@
import { useState, useEffect } from 'react' import { useEffect, useState } from 'react'
import { Select, Switch, Form, Input, Button, Typography, message } from 'antd' import { Button, Form, Input, InputNumber, Select, Switch, Typography, message } from 'antd'
import api from '../../api/client' import api from '../../api/client'
import './Settings.css'
import { useBrand } from '../../brand/BrandContext' import { useBrand } from '../../brand/BrandContext'
const { Title, Text } = Typography const { Title, Text } = Typography
type Quality = { label: string; width: number; height: number; bitrate: string }
type Profiles = Record<string, Quality[]>
const strategies = [ const strategies = [
{ value: 'subnet', label: '子网优先(推荐)' }, { value: 'subnet', label: '子网优先(推荐)' },
{ value: 'weighted', label: '权重均衡' }, { value: 'weighted', label: '权重均衡' },
@@ -17,77 +21,107 @@ export default function SettingsPage() {
const [strategy, setStrategy] = useState('subnet') const [strategy, setStrategy] = useState('subnet')
const [multicastEnabled, setMulticastEnabled] = useState(true) const [multicastEnabled, setMulticastEnabled] = useState(true)
const [multicastAvailable, setMulticastAvailable] = useState(false) const [multicastAvailable, setMulticastAvailable] = useState(false)
const [saving, setSaving] = useState(false)
const [brandName, setBrandName] = useState(brand.brand_name) const [brandName, setBrandName] = useState(brand.brand_name)
const [brandTagline, setBrandTagline] = useState(brand.brand_tagline) const [brandTagline, setBrandTagline] = useState(brand.brand_tagline)
const [brandLogoUrl, setBrandLogoUrl] = useState(brand.brand_logo_url) const [brandLogoUrl, setBrandLogoUrl] = useState(brand.brand_logo_url)
const [brandSaving, setBrandSaving] = useState(false) const [profiles, setProfiles] = useState<Profiles>({})
const [saving, setSaving] = useState(false)
useEffect(() => { loadSettings() }, []) useEffect(() => {
useEffect(() => { setBrandName(brand.brand_name); setBrandTagline(brand.brand_tagline); setBrandLogoUrl(brand.brand_logo_url) }, [brand]) void Promise.all([api.get('/nodes/strategy'), api.get('/settings/transcode-profiles')])
.then(([nodes, transcode]) => {
setStrategy(nodes.data.strategy)
setMulticastEnabled(nodes.data.multicast_enabled)
setMulticastAvailable(nodes.data.multicast_available)
setProfiles(transcode.data.profiles || {})
})
.catch(() => message.error('系统设置加载失败'))
}, [])
async function loadSettings() { useEffect(() => {
try { const { data } = await api.get('/nodes/strategy'); setStrategy(data.strategy); setMulticastEnabled(data.multicast_enabled); setMulticastAvailable(data.multicast_available) } catch { /* */ } setBrandName(brand.brand_name)
setBrandTagline(brand.brand_tagline)
setBrandLogoUrl(brand.brand_logo_url)
}, [brand])
function replaceProfile(profile: string, qualities: Quality[]) {
setProfiles(current => Object.assign({}, current, Object.fromEntries([[profile, qualities]])))
} }
async function saveStrategy(val: string) {
function updateQuality(profile: string, index: number, patch: Partial<Quality>) {
replaceProfile(profile, profiles[profile].map((quality, i) => i === index ? { ...quality, ...patch } : quality))
}
function addQuality(profile: string) {
replaceProfile(profile, [...profiles[profile], { label: '新清晰度', width: 1280, height: 720, bitrate: '2000k' }])
}
function removeQuality(profile: string, index: number) {
replaceProfile(profile, profiles[profile].filter((_, i) => i !== index))
}
async function saveAll() {
setSaving(true) setSaving(true)
try { await api.put('/nodes/strategy', { strategy: val, multicast_enabled: multicastEnabled }); setStrategy(val); message.success('策略已更新') } try {
catch { message.error('更新失败') } await Promise.all([
api.put('/nodes/strategy', { strategy, multicast_enabled: multicastEnabled }),
api.put('/settings/brand', { brand_name: brandName, brand_tagline: brandTagline, brand_logo_url: brandLogoUrl }),
api.put('/settings/transcode-profiles', { profiles }),
])
await refresh()
message.success('设置已保存')
} catch {
message.error('保存失败,请检查转码参数')
} finally {
setSaving(false) setSaving(false)
} }
async function toggleMulticast(val: boolean) {
setMulticastEnabled(val)
try { await api.put('/nodes/strategy', { strategy, multicast_enabled: val }); message.success('已更新') } catch { message.error('更新失败') }
}
async function saveBrand() {
setBrandSaving(true)
try { await api.put('/settings/brand', { brand_name: brandName, brand_tagline: brandTagline, brand_logo_url: brandLogoUrl }); await refresh(); message.success('品牌设置已保存') }
catch { message.error('保存失败') }
setBrandSaving(false)
} }
return ( return (
<div style={{ maxWidth: 720 }}> <div className="settings-page">
<div style={{ marginBottom: 32 }}> <Title level={2}>系统设置</Title>
<Title level={2} style={{ margin: '0 0 4px', fontWeight: 600, fontSize: 28 }}>系统设置</Title> <Text type="secondary">配置分发策略、转码清晰度与平台品牌</Text>
<Text style={{ color: '#949088' }}>配置负载均衡策略与品牌信息</Text>
</div>
<div style={{ background: '#fff', borderRadius: 12, border: '1px solid #EBE5DB', padding: 32, marginBottom: 24 }}> <section className="settings-card">
<Title level={4} style={{ margin: '0 0 24px', fontWeight: 600 }}>负载均衡</Title> <Title level={4}>负载均衡</Title>
<Form layout="vertical"> <Form layout="vertical">
<Form.Item label="均衡策略" help="根据客户端 IP 所属子网自动选择最近的边缘节点"> <Form.Item label="均衡策略"><Select value={strategy} onChange={setStrategy} options={strategies} /></Form.Item>
<Select value={strategy} onChange={saveStrategy} options={strategies} loading={saving} style={{ width: 280, borderRadius: 8 }} />
</Form.Item>
<Form.Item label="组播"> <Form.Item label="组播">
<Switch checked={multicastEnabled} onChange={toggleMulticast} /> <Switch checked={multicastEnabled} onChange={setMulticastEnabled} />
<Text type="secondary" style={{ marginLeft: 12 }}> <Text type="secondary"> {multicastAvailable ? '可用' : '不可用,将回退至单播'}</Text>
{multicastAvailable ? '可用 — 优先使用组播分发' : '不可用 — 回退至单播'}
</Text>
</Form.Item> </Form.Item>
</Form> </Form>
</div> </section>
<div style={{ background: '#fff', borderRadius: 12, border: '1px solid #EBE5DB', padding: 32 }}> <section className="settings-card">
<Title level={4} style={{ margin: '0 0 24px', fontWeight: 600 }}>品牌设置</Title> <Title level={4}>自定义转码配置</Title>
<Form layout="vertical"> {Object.entries(profiles).map(([profile, qualities]) => (
<Form.Item label="平台名称"> <div className="profile-block" key={profile}>
<Input value={brandName} onChange={e => setBrandName(e.target.value)} size="large" style={{ borderRadius: 8 }} /> <Text strong>{profile}</Text>
</Form.Item> {qualities.map((quality, index) => (
<Form.Item label="标语"> <div className="quality-row" key={profile + '-' + index}>
<Input value={brandTagline} onChange={e => setBrandTagline(e.target.value)} size="large" style={{ borderRadius: 8 }} /> <Input value={quality.label} onChange={e => updateQuality(profile, index, { label: e.target.value })} />
</Form.Item> <InputNumber value={quality.width} min={160} max={7680} addonAfter="宽" onChange={v => updateQuality(profile, index, { width: v || 160 })} />
<Form.Item label="Logo 图片地址"> <InputNumber value={quality.height} min={90} max={4320} addonAfter="高" onChange={v => updateQuality(profile, index, { height: v || 90 })} />
<Input value={brandLogoUrl} onChange={e => setBrandLogoUrl(e.target.value)} style={{ borderRadius: 8 }} placeholder="https://..." /> <Input value={quality.bitrate} placeholder="2000k" onChange={e => updateQuality(profile, index, { bitrate: e.target.value })} />
</Form.Item> <Button danger disabled={qualities.length <= 1} onClick={() => removeQuality(profile, index)}>删除</Button>
<Form.Item>
<Button type="primary" loading={brandSaving} onClick={saveBrand} size="large" style={{ borderRadius: 8 }}>
保存品牌设置
</Button>
<Text type="secondary" style={{ marginLeft: 12, fontSize: 13 }}>保存后全站立即生效</Text>
</Form.Item>
</Form>
</div> </div>
))}
<Button onClick={() => addQuality(profile)}>添加清晰度</Button>
</div>
))}
</section>
<section className="settings-card">
<Title level={4}>品牌设置</Title>
<Form layout="vertical">
<Form.Item label="平台名称"><Input value={brandName} onChange={e => setBrandName(e.target.value)} /></Form.Item>
<Form.Item label="标语"><Input value={brandTagline} onChange={e => setBrandTagline(e.target.value)} /></Form.Item>
<Form.Item label="Logo 图片地址"><Input value={brandLogoUrl} onChange={e => setBrandLogoUrl(e.target.value)} /></Form.Item>
</Form>
</section>
<Button type="primary" size="large" loading={saving} onClick={saveAll}>保存全部设置</Button>
</div> </div>
) )
} }
+14
View File
@@ -0,0 +1,14 @@
import api from './client'
export interface SystemMetrics {
cpu_percent: number
memory_percent: number
disk_percent: number
disk_total_bytes: number
disk_used_bytes: number
}
export async function getSystemMetrics(): Promise<SystemMetrics> {
const { data } = await api.get<SystemMetrics>('/settings/system-metrics')
return data
}
+55 -8
View File
@@ -29,10 +29,12 @@ export default function WatchPage() {
const [activeLine, setActiveLine] = useState<string>('') const [activeLine, setActiveLine] = useState<string>('')
const [activeQuality, setActiveQuality] = useState<string>('auto') const [activeQuality, setActiveQuality] = useState<string>('auto')
const [autoQuality, setAutoQuality] = useState<string>('') const [autoQuality, setAutoQuality] = useState<string>('')
const bufferEventsRef = useRef<number[]>([]) const bufferEventsRef = useRef<Array<{ at: number; duration: number }>>([])
const bufferStartedRef = useRef<number | null>(null) const bufferStartedRef = useRef<number | null>(null)
const playbackStartedRef = useRef<number>(0) const playbackStartedRef = useRef<number>(0)
const [remoteAdjusted, setRemoteAdjusted] = useState(false) const [remoteAdjusted, setRemoteAdjusted] = useState(false)
const [showPlaybackStats, setShowPlaybackStats] = useState(false)
const [playbackStats, setPlaybackStats] = useState({ buffered: 0, currentTime: 0, droppedFrames: 0, resolution: '未知' })
const hlsRef = useRef<Hls | null>(null) const hlsRef = useRef<Hls | null>(null)
const viewerSessionRef = useRef<string>('') const viewerSessionRef = useRef<string>('')
const currentLine = info?.lines.find(line => line.node_id === activeLine) const currentLine = info?.lines.find(line => line.node_id === activeLine)
@@ -182,6 +184,14 @@ export default function WatchPage() {
const video = videoRef.current const video = videoRef.current
if (!info || !video) return if (!info || !video) return
setPlaybackError('') setPlaybackError('')
const previousTime = Number.isFinite(video.currentTime) ? video.currentTime : 0
const wasPaused = video.paused
const restorePlaybackState = () => {
if (previousTime > 0 && Number.isFinite(video.duration)) {
video.currentTime = Math.min(previousTime, Math.max(0, video.duration - 0.1))
}
if (!wasPaused) video.play().catch(() => {})
}
hlsRef.current?.destroy() hlsRef.current?.destroy()
hlsRef.current = null hlsRef.current = null
video.removeAttribute('src') video.removeAttribute('src')
@@ -208,13 +218,14 @@ export default function WatchPage() {
const hi = new Hls({ enableWorker: false, lowLatencyMode: false }) const hi = new Hls({ enableWorker: false, lowLatencyMode: false })
hi.loadSource(source) hi.loadSource(source)
hi.attachMedia(video) hi.attachMedia(video)
hi.on(Hls.Events.MANIFEST_PARSED, () => { video.play().catch(() => {}) }) hi.on(Hls.Events.MANIFEST_PARSED, restorePlaybackState)
hi.on(Hls.Events.ERROR, (_e, detail) => { hi.on(Hls.Events.ERROR, (_e, detail) => {
if (detail.fatal) setPlaybackError('播放流加载失败,请尝试切换线路或稍后重试') if (detail.fatal) setPlaybackError('播放流加载失败,请尝试切换线路或稍后重试')
}) })
hlsRef.current = hi hlsRef.current = hi
} else if (video.canPlayType('application/vnd.apple.mpegurl')) { } else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = source video.src = source
video.addEventListener('loadedmetadata', restorePlaybackState, { once: true })
video.load() video.load()
} else { } else {
setPlaybackError('当前浏览器不支持此播放格式') setPlaybackError('当前浏览器不支持此播放格式')
@@ -250,10 +261,12 @@ export default function WatchPage() {
const onPlaying = () => { const onPlaying = () => {
const now = Date.now() const now = Date.now()
if (!playbackStartedRef.current) playbackStartedRef.current = now if (!playbackStartedRef.current) playbackStartedRef.current = now
if (bufferStartedRef.current && now - playbackStartedRef.current > 3000) { if (bufferStartedRef.current && now - playbackStartedRef.current > 10000) {
const duration = now - bufferStartedRef.current const duration = now - bufferStartedRef.current
bufferEventsRef.current = bufferEventsRef.current.filter(at => now - at <= 10000) bufferEventsRef.current.push({ at: now, duration })
if (duration >= 2000 || bufferEventsRef.current.length >= 3) { bufferEventsRef.current = bufferEventsRef.current.filter(event => now - event.at <= 10000)
const accumulatedBuffering = bufferEventsRef.current.reduce((total, event) => total + event.duration, 0)
if (accumulatedBuffering >= 2000 || bufferEventsRef.current.length >= 3) {
lowerQuality() lowerQuality()
bufferEventsRef.current = [] bufferEventsRef.current = []
} }
@@ -261,9 +274,9 @@ export default function WatchPage() {
bufferStartedRef.current = null bufferStartedRef.current = null
} }
const onWaiting = () => { const onWaiting = () => {
if (Date.now() - playbackStartedRef.current <= 3000) return const now = Date.now()
bufferStartedRef.current = Date.now() if (!playbackStartedRef.current || now - playbackStartedRef.current <= 10000 || bufferStartedRef.current) return
bufferEventsRef.current.push(Date.now()) bufferStartedRef.current = now
} }
const recoveryTimer = window.setInterval(() => { const recoveryTimer = window.setInterval(() => {
if (!video.paused && !video.seeking && video.readyState >= 3) higherQuality() if (!video.paused && !video.seeking && video.readyState >= 3) higherQuality()
@@ -278,6 +291,26 @@ export default function WatchPage() {
} }
}, [activeQuality, info]) }, [activeQuality, info])
useEffect(() => {
const video = videoRef.current
if (!video) return
const updateStats = () => {
const buffered = video.buffered.length
? Math.max(0, video.buffered.end(video.buffered.length - 1) - video.currentTime)
: 0
const quality = video.getVideoPlaybackQuality?.()
setPlaybackStats({
buffered,
currentTime: video.currentTime || 0,
droppedFrames: quality?.droppedVideoFrames || 0,
resolution: video.videoWidth && video.videoHeight ? `${video.videoWidth}×${video.videoHeight}` : '未知',
})
}
updateStats()
const timer = window.setInterval(updateStats, 1000)
return () => window.clearInterval(timer)
}, [activeLine, activeQuality, autoQuality])
function jumpToChapter(time: number) { function jumpToChapter(time: number) {
if (!videoRef.current) return if (!videoRef.current) return
videoRef.current.currentTime = time videoRef.current.currentTime = time
@@ -335,7 +368,21 @@ export default function WatchPage() {
<main className="watch-stage"> <main className="watch-stage">
<div className="watch-content"> <div className="watch-content">
<div className="watch-frame"> <div className="watch-frame">
<div className="watch-player" onContextMenu={event => { event.preventDefault(); setShowPlaybackStats(value => !value) }}>
<video ref={videoRef} controls autoPlay playsInline className="watch-video" /> <video ref={videoRef} controls autoPlay playsInline className="watch-video" />
{showPlaybackStats && (
<section className="playback-stats" aria-label="播放统计信息">
<div><span>播放状态</span><strong>{videoRef.current?.paused ? '暂停' : '播放中'}</strong></div>
<div><span>线路</span><strong>{currentLine?.node_name || '不可用'}</strong></div>
<div><span>清晰度</span><strong>{activeQuality === 'auto' ? `自动(${autoQuality || '检测中'})` : activeQuality}</strong></div>
<div><span>分辨率</span><strong>{playbackStats.resolution}</strong></div>
<div><span>时间码</span><strong>{playbackStats.currentTime.toFixed(1)} 秒</strong></div>
<div><span>缓冲</span><strong>{playbackStats.buffered.toFixed(1)} 秒</strong></div>
<div><span>丢帧</span><strong>{playbackStats.droppedFrames}</strong></div>
<div className="playback-stats-url"><span>播放地址</span><strong>{videoRef.current?.currentSrc || '加载中'}</strong></div>
</section>
)}
</div>
{playbackError && ( {playbackError && (
<div className="watch-waiting"> <div className="watch-waiting">
<Alert type="error" showIcon message={playbackError} /> <Alert type="error" showIcon message={playbackError} />
+34
View File
@@ -365,6 +365,40 @@ button.stream-card:focus-visible {
background: #080706; background: #080706;
} }
.watch-player {
position: relative;
width: 100%;
height: 100%;
min-width: 0;
}
.playback-stats {
position: absolute;
top: 16px;
right: 16px;
z-index: 3;
width: min(360px, calc(100% - 32px));
padding: 14px;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 10px;
background: rgba(18, 16, 13, 0.94);
color: #eee7dc;
box-shadow: 0 14px 38px rgba(0, 0, 0, 0.34);
backdrop-filter: blur(10px);
}
.playback-stats div {
display: grid;
grid-template-columns: 92px minmax(0, 1fr);
gap: 10px;
padding: 4px 0;
font-size: 12px;
}
.playback-stats span { color: #a99f92; }
.playback-stats strong { min-width: 0; overflow-wrap: anywhere; font-weight: 500; }
.playback-stats-url { border-top: 1px solid rgba(255, 255, 255, 0.1); margin-top: 6px; padding-top: 10px !important; }
.watch-video { .watch-video {
width: 100%; width: 100%;
height: 100%; height: 100%;