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
+419 -67

No files matched your search

+95 -2
View File
@@ -1,5 +1,9 @@
from fastapi import APIRouter, Depends
from pydantic import BaseModel
import os
import shutil
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from typing import Optional
from config import settings
from routes.auth import require_auth
@@ -13,6 +17,17 @@ class BrandUpdate(BaseModel):
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")
async def get_brand():
return {
@@ -31,3 +46,81 @@ async def update_brand(body: BrandUpdate, _: bool = Depends(require_auth)):
if body.brand_logo_url is not None:
settings.brand_logo_url = body.brand_logo_url
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 { 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 {
createRemoteSession,
fetchStreams,
@@ -18,7 +19,9 @@ export default function LiveBoard() {
const [sessions, setSessions] = useState<ViewerSessionInfo[]>([])
const [selected, setSelected] = useState<React.Key[]>([])
const [loading, setLoading] = useState(true)
const [systemMetrics, setSystemMetrics] = useState<SystemMetrics | null>(null)
const [quality, setQuality] = useState('auto')
const [wallMode, setWallMode] = useState(false)
async function load() {
try {
@@ -34,6 +37,11 @@ export default function LiveBoard() {
} catch {
message.error('看板数据加载失败')
} finally {
try {
setSystemMetrics(await getSystemMetrics())
} catch {
setSystemMetrics(null)
}
setLoading(false)
}
}
@@ -75,6 +83,7 @@ export default function LiveBoard() {
options={streams.map(program => ({ value: program.id, label: `${program.title} · ${program.status}` }))}
onChange={value => { setStreamId(value); setSelected([]) }}
/>
<Button icon={wallMode ? <FullscreenExitOutlined /> : <FullscreenOutlined />} onClick={() => setWallMode(value => !value)}>{wallMode ? '退出大屏' : '大屏模式'}</Button>
<Button icon={<ReloadOutlined />} onClick={load}>刷新</Button>
</Space>
</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>
</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="当前没有可查看的直播节目" /> : (
<Card
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; }
}
+89 -55
View File
@@ -1,10 +1,14 @@
import { useState, useEffect } from 'react'
import { Select, Switch, Form, Input, Button, Typography, message } from 'antd'
import { useEffect, useState } from 'react'
import { Button, Form, Input, InputNumber, Select, Switch, Typography, message } from 'antd'
import api from '../../api/client'
import './Settings.css'
import { useBrand } from '../../brand/BrandContext'
const { Title, Text } = Typography
type Quality = { label: string; width: number; height: number; bitrate: string }
type Profiles = Record<string, Quality[]>
const strategies = [
{ value: 'subnet', label: '子网优先(推荐)' },
{ value: 'weighted', label: '权重均衡' },
@@ -17,77 +21,107 @@ export default function SettingsPage() {
const [strategy, setStrategy] = useState('subnet')
const [multicastEnabled, setMulticastEnabled] = useState(true)
const [multicastAvailable, setMulticastAvailable] = useState(false)
const [saving, setSaving] = useState(false)
const [brandName, setBrandName] = useState(brand.brand_name)
const [brandTagline, setBrandTagline] = useState(brand.brand_tagline)
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(() => { setBrandName(brand.brand_name); setBrandTagline(brand.brand_tagline); setBrandLogoUrl(brand.brand_logo_url) }, [brand])
useEffect(() => {
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() {
try { const { data } = await api.get('/nodes/strategy'); setStrategy(data.strategy); setMulticastEnabled(data.multicast_enabled); setMulticastAvailable(data.multicast_available) } catch { /* */ }
useEffect(() => {
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)
try { await api.put('/nodes/strategy', { strategy: val, multicast_enabled: multicastEnabled }); setStrategy(val); message.success('策略已更新') }
catch { message.error('更新失败') }
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)
try {
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)
}
}
return (
<div style={{ maxWidth: 720 }}>
<div style={{ marginBottom: 32 }}>
<Title level={2} style={{ margin: '0 0 4px', fontWeight: 600, fontSize: 28 }}>系统设置</Title>
<Text style={{ color: '#949088' }}>配置负载均衡策略与品牌信息</Text>
</div>
<div className="settings-page">
<Title level={2}>系统设置</Title>
<Text type="secondary">配置分发策略、转码清晰度与平台品牌</Text>
<div style={{ background: '#fff', borderRadius: 12, border: '1px solid #EBE5DB', padding: 32, marginBottom: 24 }}>
<Title level={4} style={{ margin: '0 0 24px', fontWeight: 600 }}>负载均衡</Title>
<section className="settings-card">
<Title level={4}>负载均衡</Title>
<Form layout="vertical">
<Form.Item label="均衡策略" help="根据客户端 IP 所属子网自动选择最近的边缘节点">
<Select value={strategy} onChange={saveStrategy} options={strategies} loading={saving} style={{ width: 280, borderRadius: 8 }} />
</Form.Item>
<Form.Item label="均衡策略"><Select value={strategy} onChange={setStrategy} options={strategies} /></Form.Item>
<Form.Item label="组播">
<Switch checked={multicastEnabled} onChange={toggleMulticast} />
<Text type="secondary" style={{ marginLeft: 12 }}>
{multicastAvailable ? '可用 — 优先使用组播分发' : '不可用 — 回退至单播'}
</Text>
<Switch checked={multicastEnabled} onChange={setMulticastEnabled} />
<Text type="secondary"> {multicastAvailable ? '可用' : '不可用,将回退至单播'}</Text>
</Form.Item>
</Form>
</div>
</section>
<div style={{ background: '#fff', borderRadius: 12, border: '1px solid #EBE5DB', padding: 32 }}>
<Title level={4} style={{ margin: '0 0 24px', fontWeight: 600 }}>品牌设置</Title>
<section className="settings-card">
<Title level={4}>自定义转码配置</Title>
{Object.entries(profiles).map(([profile, qualities]) => (
<div className="profile-block" key={profile}>
<Text strong>{profile}</Text>
{qualities.map((quality, index) => (
<div className="quality-row" key={profile + '-' + index}>
<Input value={quality.label} onChange={e => updateQuality(profile, index, { label: e.target.value })} />
<InputNumber value={quality.width} min={160} max={7680} addonAfter="宽" onChange={v => updateQuality(profile, index, { width: v || 160 })} />
<InputNumber value={quality.height} min={90} max={4320} addonAfter="高" onChange={v => updateQuality(profile, index, { height: v || 90 })} />
<Input value={quality.bitrate} placeholder="2000k" onChange={e => updateQuality(profile, index, { bitrate: e.target.value })} />
<Button danger disabled={qualities.length <= 1} onClick={() => removeQuality(profile, index)}>删除</Button>
</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)} size="large" style={{ borderRadius: 8 }} />
</Form.Item>
<Form.Item label="标语">
<Input value={brandTagline} onChange={e => setBrandTagline(e.target.value)} size="large" style={{ borderRadius: 8 }} />
</Form.Item>
<Form.Item label="Logo 图片地址">
<Input value={brandLogoUrl} onChange={e => setBrandLogoUrl(e.target.value)} style={{ borderRadius: 8 }} placeholder="https://..." />
</Form.Item>
<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.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>
</div>
</section>
<Button type="primary" size="large" loading={saving} onClick={saveAll}>保存全部设置</Button>
</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
}
+56 -9
View File
@@ -29,10 +29,12 @@ export default function WatchPage() {
const [activeLine, setActiveLine] = useState<string>('')
const [activeQuality, setActiveQuality] = useState<string>('auto')
const [autoQuality, setAutoQuality] = useState<string>('')
const bufferEventsRef = useRef<number[]>([])
const bufferEventsRef = useRef<Array<{ at: number; duration: number }>>([])
const bufferStartedRef = useRef<number | null>(null)
const playbackStartedRef = useRef<number>(0)
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 viewerSessionRef = useRef<string>('')
const currentLine = info?.lines.find(line => line.node_id === activeLine)
@@ -182,6 +184,14 @@ export default function WatchPage() {
const video = videoRef.current
if (!info || !video) return
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 = null
video.removeAttribute('src')
@@ -208,13 +218,14 @@ export default function WatchPage() {
const hi = new Hls({ enableWorker: false, lowLatencyMode: false })
hi.loadSource(source)
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) => {
if (detail.fatal) setPlaybackError('播放流加载失败,请尝试切换线路或稍后重试')
})
hlsRef.current = hi
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = source
video.addEventListener('loadedmetadata', restorePlaybackState, { once: true })
video.load()
} else {
setPlaybackError('当前浏览器不支持此播放格式')
@@ -250,10 +261,12 @@ export default function WatchPage() {
const onPlaying = () => {
const now = Date.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
bufferEventsRef.current = bufferEventsRef.current.filter(at => now - at <= 10000)
if (duration >= 2000 || bufferEventsRef.current.length >= 3) {
bufferEventsRef.current.push({ at: now, duration })
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()
bufferEventsRef.current = []
}
@@ -261,9 +274,9 @@ export default function WatchPage() {
bufferStartedRef.current = null
}
const onWaiting = () => {
if (Date.now() - playbackStartedRef.current <= 3000) return
bufferStartedRef.current = Date.now()
bufferEventsRef.current.push(Date.now())
const now = Date.now()
if (!playbackStartedRef.current || now - playbackStartedRef.current <= 10000 || bufferStartedRef.current) return
bufferStartedRef.current = now
}
const recoveryTimer = window.setInterval(() => {
if (!video.paused && !video.seeking && video.readyState >= 3) higherQuality()
@@ -278,6 +291,26 @@ export default function WatchPage() {
}
}, [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) {
if (!videoRef.current) return
videoRef.current.currentTime = time
@@ -335,7 +368,21 @@ export default function WatchPage() {
<main className="watch-stage">
<div className="watch-content">
<div className="watch-frame">
<video ref={videoRef} controls autoPlay playsInline className="watch-video" />
<div className="watch-player" onContextMenu={event => { event.preventDefault(); setShowPlaybackStats(value => !value) }}>
<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 && (
<div className="watch-waiting">
<Alert type="error" showIcon message={playbackError} />
+34
View File
@@ -365,6 +365,40 @@ button.stream-card:focus-visible {
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 {
width: 100%;
height: 100%;