feat: improve replay workflow and viewer UI

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
adminandClaude Fable 5 committed 2026-08-30 15:35:28 +08:00
1 parent 57b00affbd
commit 186e32caf1
28 files changed
+1496 -227

No files matched your search

+3 -1
View File
@@ -15,7 +15,9 @@
"hls.js": "^1.5.14",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2"
"react-markdown": "^10.1.0",
"react-router-dom": "^6.26.2",
"remark-gfm": "^4.0.1"
},
"devDependencies": {
"@types/react": "^18.3.8",
+12 -21
View File
@@ -47,25 +47,20 @@ export default function AdminLayout() {
const handleLogout = () => { clearToken(); navigate('/admin/login') }
return (
<Layout style={{ minHeight: '100vh' }}>
<Layout className="admin-shell apple-material-shell">
<Sider
collapsible collapsed={collapsed} onCollapse={setCollapsed}
width={220}
style={{
borderRight: '1px solid #EBE5DB',
background: '#fff',
}}
className="admin-sider"
theme="dark"
>
<div style={{
height: 60, display: 'flex', alignItems: 'center', justifyContent: 'center',
borderBottom: '1px solid #EBE5DB',
}}>
<div className="admin-brand">
<span className="admin-brand-mark">{brand.brand_name.slice(0, 1)}</span>
<Text strong style={{
fontSize: collapsed ? 14 : 17, letterSpacing: '-0.02em',
fontFamily: '"Source Serif 4", "Noto Serif SC", Georgia, serif',
color: '#C2693A',
display: collapsed ? 'none' : 'block', fontSize: 16, letterSpacing: '-0.02em',
color: '#F4F7FB', whiteSpace: 'nowrap',
}}>
{collapsed ? brand.brand_name.slice(0, 2) : brand.brand_name}
{brand.brand_name}
</Text>
</div>
<Menu mode="inline" selectedKeys={[selectedKey]} items={menuItems}
@@ -73,15 +68,11 @@ export default function AdminLayout() {
style={{ border: 'none', marginTop: 8 }} />
</Sider>
<Layout>
<Header style={{
background: '#fff', borderBottom: '1px solid #EBE5DB',
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '0 48px',
}}>
<Text style={{ color: '#949088', fontSize: 13 }}>{brand.brand_tagline}</Text>
<Header className="admin-header">
<div><Text strong style={{ color: '#F4F7FB' }}>直播控制中心</Text><Text style={{ color: '#748096', marginLeft: 12 }}>{brand.brand_tagline}</Text></div>
<Button type="text" icon={<LogoutOutlined />} onClick={handleLogout}>退出登录</Button>
</Header>
<Content style={{ padding: 40, overflow: 'auto' }}>
<Content className="admin-content">
<div className="admin-page-transition" key={location.pathname}>
<Outlet />
</div>
@@ -89,4 +80,4 @@ export default function AdminLayout() {
</Layout>
</Layout>
)
}
}
+2 -2
View File
@@ -27,9 +27,9 @@ export default function LoginPage() {
}}>
<div style={{ width: '100%', maxWidth: 420, padding: 24 }}>
<div style={{
background: '#fff',
background: 'rgba(18, 24, 36, 0.88)',
borderRadius: 14,
border: '1px solid #EBE5DB',
border: '1px solid rgba(255, 255, 255, 0.1)',
padding: 56,
}}>
<div style={{ textAlign: 'center', marginBottom: 40 }}>
+16 -12
View File
@@ -36,19 +36,21 @@ export default function Dashboard() {
]
return (
<div>
<div style={{ marginBottom: 36 }}>
<div className="admin-page admin-dashboard">
<div className="admin-page-heading">
<Title level={1} style={{ margin: '0 0 8px', fontWeight: 600, fontSize: 32, letterSpacing: '-0.03em' }}>
{brand.brand_name}
</Title>
<Text style={{ color: '#5E5A52', fontSize: 15 }}>管理控制台</Text>
<Text className="admin-page-kicker">管理控制台 · 实时刷新</Text>
</div>
<Row gutter={[24, 24]} style={{ marginBottom: 36 }}>
<Col xs={24} sm={8}>
<div style={{
background: '#fff', borderRadius: 12, padding: '28px 32px',
border: '1px solid #EBE5DB',
background: 'linear-gradient(145deg, rgba(26, 33, 47, 0.78), rgba(12, 17, 27, 0.72))', borderRadius: 18, padding: '28px 32px',
border: '1px solid rgba(255, 255, 255, 0.095)',
boxShadow: '0 18px 48px rgba(0, 0, 0, 0.26), inset 0 1px 0 rgba(255, 255, 255, 0.045)',
backdropFilter: 'blur(24px) saturate(135%)',
borderTop: '3px solid #5B8C5A',
}}>
<Statistic title="播出中" value={liveCount} loading={loading}
@@ -57,8 +59,10 @@ export default function Dashboard() {
</Col>
<Col xs={24} sm={8}>
<div style={{
background: '#fff', borderRadius: 12, padding: '28px 32px',
border: '1px solid #EBE5DB',
background: 'linear-gradient(145deg, rgba(26, 33, 47, 0.78), rgba(12, 17, 27, 0.72))', borderRadius: 18, padding: '28px 32px',
border: '1px solid rgba(255, 255, 255, 0.095)',
boxShadow: '0 18px 48px rgba(0, 0, 0, 0.26), inset 0 1px 0 rgba(255, 255, 255, 0.045)',
backdropFilter: 'blur(24px) saturate(135%)',
borderTop: '3px solid #6B8CA8',
}}>
<Statistic title="待开播" value={pendingCount} loading={loading}
@@ -67,8 +71,10 @@ export default function Dashboard() {
</Col>
<Col xs={24} sm={8}>
<div style={{
background: '#fff', borderRadius: 12, padding: '28px 32px',
border: '1px solid #EBE5DB',
background: 'linear-gradient(145deg, rgba(26, 33, 47, 0.78), rgba(12, 17, 27, 0.72))', borderRadius: 18, padding: '28px 32px',
border: '1px solid rgba(255, 255, 255, 0.095)',
boxShadow: '0 18px 48px rgba(0, 0, 0, 0.26), inset 0 1px 0 rgba(255, 255, 255, 0.045)',
backdropFilter: 'blur(24px) saturate(135%)',
borderTop: '3px solid #C2693A',
}}>
<Statistic title="节目总数" value={streams.length} loading={loading}
@@ -77,9 +83,7 @@ export default function Dashboard() {
</Col>
</Row>
<div style={{
background: '#fff', borderRadius: 12,
border: '1px solid #EBE5DB',
<div className="admin-glass-panel admin-table-panel" style={{
padding: '28px 32px',
}}>
<Title level={4} style={{ margin: '0 0 20px', fontWeight: 600 }}>最近节目</Title>
+5 -5
View File
@@ -89,24 +89,24 @@ export default function NodeManager() {
{strategy && (
<Row gutter={[24, 24]} style={{ marginBottom: 32 }}>
<Col xs={24} sm={6}>
<div style={{ background: '#fff', borderRadius: 12, border: '1px solid #EBE5DB', padding: '24px 28px' }}>
<div className="admin-glass-panel admin-stat-panel">
<Statistic title="均衡策略" value={sL[strategy.strategy] || strategy.strategy} valueStyle={{ fontSize: 20 }} />
</div>
</Col>
<Col xs={24} sm={6}>
<div style={{ background: '#fff', borderRadius: 12, border: '1px solid #EBE5DB', padding: '24px 28px' }}>
<div className="admin-glass-panel admin-stat-panel">
<Statistic title="组播状态" value={strategy.multicast_available ? '可用' : '不可用'}
valueStyle={{ color: strategy.multicast_available ? '#5B8C5A' : '#949088', fontSize: 20 }} />
</div>
</Col>
<Col xs={24} sm={6}>
<div style={{ background: '#fff', borderRadius: 12, border: '1px solid #EBE5DB', padding: '24px 28px' }}>
<div className="admin-glass-panel admin-stat-panel">
<Statistic title="在线节点" value={onlineCount} suffix={<Text style={{ fontSize: 14, color: '#949088' }}>/ {nodes.length}</Text>}
valueStyle={{ color: onlineCount > 0 ? '#5B8C5A' : '#B54A4A', fontSize: 20 }} />
</div>
</Col>
<Col xs={24} sm={6}>
<div style={{ background: '#fff', borderRadius: 12, border: '1px solid #EBE5DB', padding: '24px 28px' }}>
<div className="admin-glass-panel admin-stat-panel">
<Statistic title="离线节点" value={nodes.length - onlineCount}
valueStyle={{ color: (nodes.length - onlineCount) > 0 ? '#B54A4A' : '#949088', fontSize: 20 }}
prefix={(nodes.length - onlineCount) > 0 ? <WarningOutlined style={{ fontSize: 18 }} /> : undefined} />
@@ -115,7 +115,7 @@ export default function NodeManager() {
</Row>
)}
<div style={{ background: '#fff', borderRadius: 12, border: '1px solid #EBE5DB', padding: '8px 8px 0' }}>
<div className="admin-glass-panel admin-table-panel">
<div style={{ padding: '16px 16px 8px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Text strong style={{ fontSize: 16 }}>节点列表</Text>
<Button icon={<ReloadOutlined />} onClick={loadData} type="text">刷新</Button>
+64 -2
View File
@@ -1,6 +1,6 @@
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 { Alert, Button, Input, InputNumber, Segmented, 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'
@@ -12,7 +12,13 @@ export default function ReplayEditor() {
const videoRef = useRef<HTMLVideoElement>(null)
const [stream, setStream] = useState<StreamInfo | null>(null)
const [mediaFiles, setMediaFiles] = useState<string[]>([])
const [replaySource, setReplaySource] = useState<'local' | 'remote'>('local')
const [replayFile, setReplayFile] = useState<string>()
const [replayUrl, setReplayUrl] = useState('')
const [previewUrl, setPreviewUrl] = useState<string>()
const [previewLoading, setPreviewLoading] = useState(false)
const [previewError, setPreviewError] = useState('')
const previewObjectUrlRef = useRef<string>()
const [chapters, setChapters] = useState<Chapter[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
@@ -23,7 +29,9 @@ export default function ReplayEditor() {
Promise.all([getStream(streamId), fetchMediaFiles()])
.then(([program, files]) => {
setStream(program)
setReplayFile(program.replay_file)
setReplaySource(program.replay_url ? 'remote' : 'local')
setReplayFile(program.replay_file || undefined)
setReplayUrl(program.replay_url || '')
setChapters(program.chapters || [])
setMediaFiles(files)
})
@@ -31,6 +39,60 @@ export default function ReplayEditor() {
.finally(() => setLoading(false))
}, [id])
useEffect(() => {
if (previewObjectUrlRef.current) {
URL.revokeObjectURL(previewObjectUrlRef.current)
previewObjectUrlRef.current = undefined
}
setPreviewUrl(undefined)
setPreviewError('')
if (replaySource === 'local') {
setPreviewLoading(false)
if (stream && replayFile) {
setPreviewUrl(`/api/streams/${stream.id}/replay?preview=${encodeURIComponent(replayFile)}`)
}
return
}
const remoteUrl = replayUrl.trim()
if (!remoteUrl) {
setPreviewLoading(false)
return
}
const controller = new AbortController()
let disposed = false
setPreviewLoading(true)
void fetch(remoteUrl, { signal: controller.signal })
.then(response => {
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return response.blob()
})
.then(blob => {
if (disposed) return
const objectUrl = URL.createObjectURL(blob)
previewObjectUrlRef.current = objectUrl
setPreviewUrl(objectUrl)
})
.catch(error => {
if (error instanceof DOMException && error.name === 'AbortError') return
if (!disposed) setPreviewError('远端回放拉取失败,请检查地址及其 CORS 配置')
})
.finally(() => {
if (!disposed) setPreviewLoading(false)
})
return () => {
disposed = true
controller.abort()
if (previewObjectUrlRef.current) {
URL.revokeObjectURL(previewObjectUrlRef.current)
previewObjectUrlRef.current = undefined
}
}
}, [replaySource, replayFile, replayUrl, stream])
const move = (index: number, offset: number) => {
setChapters(current => {
const target = index + offset
+1 -1
View File
@@ -1,5 +1,5 @@
.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; }
.settings-card { margin-top: 20px; padding: 24px; overflow: hidden; background: linear-gradient(145deg, rgba(26, 33, 47, .78), rgba(12, 17, 27, .72)); border: 1px solid rgba(255, 255, 255, .095); border-radius: 18px; box-shadow: 0 18px 48px rgba(0, 0, 0, .26); backdrop-filter: blur(24px) saturate(135%); }
.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%; }
+3 -3
View File
@@ -45,7 +45,7 @@ export default function StreamForm() {
<Text style={{ color: '#5E5A52' }}>将以下信息提供给推流端即可开始直播</Text>
</div>
<div style={{ background: '#fff', borderRadius: 12, border: '1px solid #EBE5DB', padding: 32, marginBottom: 24 }}>
<div className="admin-glass-panel admin-form-panel" style={{ marginBottom: 24 }}>
<Space direction="vertical" size={24} style={{ width: '100%' }}>
<div>
<Text style={{ color: '#949088', fontSize: 12, textTransform: 'uppercase', letterSpacing: '0.08em', fontWeight: 600 }}>推流码</Text>
@@ -78,7 +78,7 @@ export default function StreamForm() {
<Button type="text" icon={<ArrowLeftOutlined />} onClick={() => navigate('/admin/streams')}
style={{ marginBottom: 20, color: '#5E5A52' }}>返回列表</Button>
<div style={{ background: '#fff', borderRadius: 12, border: '1px solid #EBE5DB', padding: 36 }}>
<div className="admin-glass-panel admin-form-panel">
<Title level={2} style={{ margin: '0 0 8px', fontWeight: 600 }}>创建节目</Title>
<Text style={{ color: '#5E5A52', display: 'block', marginBottom: 36 }}>
填写节目信息并预约播出,系统将生成推流码与 RTMP 地址
@@ -138,7 +138,7 @@ export default function StreamForm() {
<Form.Item name="remote_control_enabled" label="允许审计式观众播放控制" valuePropName="checked" extra="默认关闭,仅在明确启用后允许创建限时控制会话">
<Switch checkedChildren="启用" unCheckedChildren="关闭" />
</Form.Item>
<div style={{ borderTop: '1px solid #EBE5DB', margin: '8px 0 24px' }} />
<div className="admin-section-divider" />
<Form.Item style={{ marginBottom: 0 }}>
<Button type="primary" htmlType="submit" loading={loading} block style={{ height: 48, borderRadius: 8 }}>
创建节目
+5 -5
View File
@@ -67,19 +67,19 @@ export default function StreamList() {
]
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 28 }}>
<div className="admin-page admin-stream-list">
<div className="admin-page-heading admin-page-heading--actions">
<div>
<Title level={2} style={{ margin: '0 0 4px', fontWeight: 600, fontSize: 28, letterSpacing: '-0.02em' }}>节目列表</Title>
<Text style={{ color: '#949088', fontSize: 14 }}>管理直播、排期与回放节目</Text>
<Text className="admin-page-kicker">管理直播、排期与回放节目</Text>
</div>
<Button type="primary" icon={<PlusOutlined />} size="large" onClick={() => navigate('/admin/streams/new')}>
创建节目
</Button>
</div>
<div style={{
background: '#fff', borderRadius: 12,
border: '1px solid #EBE5DB', padding: '8px 8px 0',
background: 'rgba(18, 24, 36, 0.72)', borderRadius: 16,
border: '1px solid rgba(255, 255, 255, 0.095)', padding: '8px 8px 0',
}}>
<Table dataSource={streams} columns={columns} rowKey="id" loading={loading}
pagination={{ pageSize: 15, showSizeChanger: false }} size="middle"
+4 -1
View File
@@ -27,6 +27,7 @@ export interface StreamInfo {
multicast_enabled: boolean
created_at: string
replay_file?: string
replay_url?: string
chapters: Chapter[]
transcode_profile: string
renditions?: RenditionConfig[] | null
@@ -44,7 +45,8 @@ export interface StreamCreateReq {
description?: string
cover_url?: string
scheduled_at?: string
replay_file?: string
replay_file?: string | null
replay_url?: string | null
chapters?: Chapter[]
transcode_profile?: string
renditions?: RenditionConfig[]
@@ -63,6 +65,7 @@ export interface WatchInfo {
description?: string
scheduled_at?: string
replay_url?: string
replay_local_url?: string
chapters: Chapter[]
remote_control_enabled: boolean
}
+619 -24
View File
@@ -1,30 +1,30 @@
:root {
--bg-page: #F9F7F4;
--bg-surface: #FFFFFF;
--bg-surface-raised: #FAFAF8;
--bg-muted: #F3F0EC;
--bg-brand: #C2693A;
--bg-brand-light: #F6EEE5;
--bg-dark: #1A1814;
--bg-page: #070A10;
--bg-surface: #10151F;
--bg-surface-raised: #171D29;
--bg-muted: #1D2432;
--bg-brand: #5B8CFF;
--bg-brand-light: #17264A;
--bg-dark: #04060A;
--text-primary: #1C1A17;
--text-secondary: #5E5A52;
--text-tertiary: #949088;
--text-inverse: #F9F7F4;
--text-primary: #F4F7FB;
--text-secondary: #AAB4C5;
--text-tertiary: #748096;
--text-inverse: #05070B;
--border-default: #EBE5DB;
--border-light: #F3EFE9;
--border-brand: #D4A782;
--border-default: #273044;
--border-light: #1D2636;
--border-brand: #5B8CFF;
--shadow-sm: 0 1px 2px rgba(28,26,23,0.03);
--shadow-md: 0 4px 12px rgba(28,26,23,0.04);
--shadow-lg: 0 12px 32px rgba(28,26,23,0.06);
--shadow-sm: 0 2px 8px rgba(0,0,0,0.24);
--shadow-md: 0 12px 32px rgba(0,0,0,0.28);
--shadow-lg: 0 24px 64px rgba(0,0,0,0.36);
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--radius-sm: 8px;
--radius-md: 14px;
--radius-lg: 20px;
--font-sans: "Source Serif 4", "Noto Serif SC", Georgia, serif;
--font-sans: Inter, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
--font-mono: "SF Mono", "Fira Code", "Cascadia Code", monospace;
--space-0: 0;
@@ -58,9 +58,15 @@ body {
font-size: 15px;
line-height: 1.65;
color: var(--text-primary);
background: var(--bg-page);
background:
radial-gradient(circle at 85% -20%, rgba(91,140,255,0.18), transparent 34rem),
var(--bg-page);
min-width: 320px;
}
a { color: inherit; }
button, input, select, textarea { font: inherit; }
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-sans);
font-weight: 600;
@@ -69,12 +75,601 @@ h1, h2, h3, h4, h5, h6 {
}
::selection {
background: #EAD7C8;
color: #1C1A17;
background: #5B8CFF;
color: #FFFFFF;
}
.admin-shell { min-height: 100vh; background: transparent; }
.admin-sider { background: #0B1018 !important; border-right: 1px solid var(--border-light); }
.admin-brand { height: 72px; display: flex; align-items: center; gap: 12px; padding: 0 20px; border-bottom: 1px solid var(--border-light); }
.admin-brand-mark { width: 34px; height: 34px; border-radius: 11px; display: grid; place-items: center; background: linear-gradient(135deg,#5B8CFF,#7B61FF); color: white; font-weight: 800; box-shadow: 0 8px 24px rgba(91,140,255,.35); }
.admin-header { height: 72px !important; padding: 0 32px !important; display: flex; align-items: center; justify-content: space-between; background: rgba(10,14,22,.86) !important; border-bottom: 1px solid var(--border-light); backdrop-filter: blur(18px); }
.admin-content { padding: 32px !important; overflow: auto; }
.admin-content .ant-card { border-color: var(--border-default); box-shadow: var(--shadow-sm); }
.admin-page-transition { max-width: 1540px; margin: 0 auto; animation: admin-enter .24s ease-out; }
@keyframes admin-enter { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
@media (max-width: 768px) { .admin-header { padding: 0 16px !important; } .admin-content { padding: 16px !important; } }
@media (max-width: 600px) {
.replay-editor { overflow: hidden; }
.replay-editor video { max-width: 100%; }
.replay-chapter-row { display: flex !important; }
.replay-chapter-row .ant-input-number { flex: 1 1 140px; max-width: 100%; }
}
/* Unified admin dark surfaces */
.admin-content .ant-card,
.admin-content .ant-table,
.admin-content .ant-table-container,
.admin-content .ant-table-thead > tr > th,
.admin-content .ant-table-tbody > tr > td,
.admin-content .ant-descriptions-view,
.admin-content .ant-list,
.admin-content .ant-modal-content,
.admin-content .ant-drawer-content {
background: #10151f;
border-color: #273044;
color: #f4f7fb;
}
.admin-content .ant-table-tbody > tr:hover > td,
.admin-content .ant-table-row:hover > td {
background: #171d29 !important;
}
.admin-content .ant-typography,
.admin-content .ant-statistic-title,
.admin-content .ant-statistic-content,
.admin-content .ant-table-cell,
.admin-header .ant-btn {
color: #f4f7fb;
}
.admin-content .ant-typography-secondary,
.admin-content .ant-form-item-label > label,
.admin-content .ant-empty-description {
color: #8d99ad;
}
.admin-content .ant-input,
.admin-content .ant-input-affix-wrapper,
.admin-content .ant-select-selector,
.admin-content .ant-picker,
.admin-content .ant-input-number,
.admin-content .ant-segmented {
background: #0b0f17 !important;
border-color: #273044 !important;
color: #f4f7fb !important;
}
.admin-content .ant-card,
.admin-content .ant-table-wrapper,
.admin-content .ant-descriptions-view {
border-radius: 16px;
overflow: hidden;
}
.admin-content .ant-divider {
border-color: #273044;
}
@media (max-width: 768px) {
.admin-content { padding: 16px; }
.admin-header { padding-inline: 16px; }
.admin-content .ant-table-wrapper { overflow-x: auto; }
}
/* Global dark overlays and feedback */
.ant-dropdown-menu,
.ant-select-dropdown,
.ant-popover-inner,
.ant-picker-dropdown .ant-picker-panel-container,
.ant-message-notice-content,
.ant-notification-notice,
.ant-modal-content,
.ant-modal-header,
.ant-drawer-content,
.ant-drawer-header { background: #171d29 !important; color: #f4f7fb !important; border-color: #273044 !important; }
.ant-dropdown-menu-item,
.ant-dropdown-menu-submenu-title,
.ant-select-item,
.ant-popover-inner-content,
.ant-modal-title,
.ant-drawer-title,
.ant-message-notice-content,
.ant-notification-notice-message,
.ant-notification-notice-description { color: #f4f7fb !important; }
.ant-dropdown-menu-item:hover,
.ant-dropdown-menu-submenu-title:hover,
.ant-select-item-option-active:not(.ant-select-item-option-disabled) { background: #1d2636 !important; }
.ant-select-item-option-selected:not(.ant-select-item-option-disabled) { background: rgba(91,140,255,.2) !important; color: #fff !important; }
.ant-alert { background: #171d29 !important; border-color: #273044 !important; }
.ant-alert-message { color: #f4f7fb !important; }
.ant-alert-description { color: #aab4c5 !important; }
.ant-alert-error { background: rgba(255,100,124,.1) !important; border-color: rgba(255,100,124,.34) !important; }
.ant-alert-warning { background: rgba(246,185,74,.1) !important; border-color: rgba(246,185,74,.34) !important; }
.ant-alert-success { background: rgba(45,212,167,.1) !important; border-color: rgba(45,212,167,.34) !important; }
.ant-menu-dark,
.ant-menu-dark > .ant-menu,
.admin-sider .ant-menu { background: #10151f !important; color: #aab4c5 !important; }
/* Apple dark material system */
:root {
color-scheme: dark;
--apple-canvas: #08090d;
--apple-surface: rgba(31, 32, 38, 0.72);
--apple-surface-raised: rgba(42, 43, 50, 0.86);
--apple-fill: rgba(255, 255, 255, 0.065);
--apple-fill-hover: rgba(255, 255, 255, 0.09);
--apple-separator: rgba(255, 255, 255, 0.11);
--apple-separator-strong: rgba(255, 255, 255, 0.18);
--apple-label: rgba(255, 255, 255, 0.94);
--apple-secondary-label: rgba(235, 238, 245, 0.62);
--apple-accent: #80aaff;
--apple-focus: rgba(128, 170, 255, 0.38);
--apple-shadow: 0 18px 48px rgba(0, 0, 0, 0.34);
--apple-shadow-raised: 0 28px 80px rgba(0, 0, 0, 0.5);
--apple-material: blur(24px) saturate(145%);
--apple-ease: cubic-bezier(0.2, 0, 0, 1);
--apple-ease-settle: cubic-bezier(0.2, 0.8, 0.2, 1);
}
body {
color: var(--apple-label);
background:
radial-gradient(circle at 16% -8%, rgba(82, 119, 194, 0.2), transparent 32rem),
radial-gradient(circle at 90% 110%, rgba(80, 67, 142, 0.12), transparent 34rem),
var(--apple-canvas);
}
.ant-card,
.ant-table-wrapper .ant-table,
.ant-descriptions,
.ant-list,
.ant-tabs,
.ant-modal .ant-modal-content,
.ant-drawer .ant-drawer-content,
.ant-dropdown .ant-dropdown-menu,
.ant-select-dropdown,
.ant-picker-dropdown .ant-picker-panel-container {
border-color: var(--apple-separator);
background: var(--apple-surface);
box-shadow: var(--apple-shadow);
-webkit-backdrop-filter: var(--apple-material);
backdrop-filter: var(--apple-material);
}
.ant-card,
.ant-modal .ant-modal-content,
.ant-drawer .ant-drawer-content,
.ant-dropdown .ant-dropdown-menu,
.ant-select-dropdown,
.ant-picker-dropdown .ant-picker-panel-container {
border: 1px solid var(--apple-separator);
}
.ant-card,
.ant-table-wrapper,
.ant-list,
.ant-descriptions,
.ant-tabs,
.ant-btn,
.ant-input,
.ant-input-affix-wrapper,
.ant-input-number,
.ant-select-selector,
.ant-picker,
.ant-pagination-item,
.ant-pagination-prev,
.ant-pagination-next,
.ant-dropdown-menu-item,
.ant-tabs-tab {
transition-property: transform, opacity, color, border-color, background-color, box-shadow, filter;
transition-duration: 160ms;
transition-timing-function: var(--apple-ease);
}
.ant-card {
overflow: hidden;
border-radius: 20px;
}
.ant-card .ant-card-head,
.ant-modal .ant-modal-header,
.ant-drawer .ant-drawer-header {
border-color: var(--apple-separator);
background: transparent;
}
.ant-table-wrapper .ant-table-container,
.ant-table-wrapper .ant-table-cell,
.ant-table-wrapper .ant-table-thead > tr > th,
.ant-table-wrapper .ant-table-tbody > tr > td {
border-color: var(--apple-separator);
background: transparent;
}
.ant-table-wrapper .ant-table-thead > tr > th {
color: var(--apple-secondary-label);
background: rgba(255, 255, 255, 0.045);
font-size: 12px;
letter-spacing: 0.04em;
}
.ant-table-wrapper .ant-table-tbody > tr.ant-table-row:hover > td {
background: var(--apple-fill);
}
.ant-form-item-label > label,
.ant-descriptions .ant-descriptions-item-label,
.ant-list .ant-list-item-meta-description,
.ant-empty .ant-empty-description {
color: var(--apple-secondary-label);
}
.ant-descriptions .ant-descriptions-view,
.ant-list-bordered {
border-color: var(--apple-separator);
}
.ant-input,
.ant-input-affix-wrapper,
.ant-input-number,
.ant-select-selector,
.ant-picker,
.ant-input-search-button {
border-color: var(--apple-separator);
background: var(--apple-fill);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.ant-input:hover,
.ant-input-affix-wrapper:hover,
.ant-input-number:hover,
.ant-select:not(.ant-select-disabled):hover .ant-select-selector,
.ant-picker:hover {
border-color: var(--apple-separator-strong);
}
.ant-input:focus,
.ant-input-focused,
.ant-input-affix-wrapper-focused,
.ant-input-number-focused,
.ant-select-focused:not(.ant-select-disabled) .ant-select-selector,
.ant-picker-focused {
border-color: var(--apple-accent);
box-shadow: 0 0 0 3px var(--apple-focus);
}
.ant-dropdown-menu-item:hover,
.ant-dropdown-menu-submenu-title:hover,
.ant-select-item-option-active:not(.ant-select-item-option-disabled),
.ant-pagination-item:hover,
.ant-tabs-tab:hover {
color: var(--apple-label);
border-color: var(--apple-separator-strong);
background: var(--apple-fill-hover);
}
.ant-pagination-item-active,
.ant-select-item-option-selected:not(.ant-select-item-option-disabled) {
border-color: rgba(128, 170, 255, 0.48);
background: rgba(128, 170, 255, 0.16);
}
.ant-modal-mask,
.ant-drawer-mask {
background: rgba(2, 3, 7, 0.64) !important;
-webkit-backdrop-filter: blur(12px) saturate(115%);
backdrop-filter: blur(12px) saturate(115%);
transition-property: opacity, background-color, backdrop-filter;
transition-duration: 220ms;
transition-timing-function: var(--apple-ease);
}
.ant-modal .ant-modal-content,
.ant-drawer .ant-drawer-content {
background: var(--apple-surface-raised);
box-shadow: var(--apple-shadow-raised);
}
.ant-empty-image {
opacity: 0.62;
filter: grayscale(0.2) contrast(0.9);
}
.ant-btn:focus-visible,
.ant-menu-item:focus-visible,
.ant-tabs-tab:focus-visible,
.ant-pagination-item:focus-visible,
.ant-dropdown-menu-item:focus-visible {
outline: 3px solid var(--apple-focus);
outline-offset: 2px;
}
.apple-material-shell .ant-layout-sider,
.apple-material-shell .ant-layout-header,
.admin-login-panel {
border: 1px solid var(--apple-separator);
background: var(--apple-surface);
box-shadow: var(--apple-shadow);
-webkit-backdrop-filter: var(--apple-material);
backdrop-filter: var(--apple-material);
}
.admin-login-panel {
border-radius: 24px;
padding: 32px;
}
@media (hover: hover) and (pointer: fine) {
.ant-card:hover {
transform: translateY(-2px);
border-color: var(--apple-separator-strong);
box-shadow: var(--apple-shadow-raised);
}
.ant-btn:not(:disabled):hover,
.ant-pagination-item:hover {
transform: translateY(-1px);
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
}
.ant-card,
.ant-table-wrapper,
.ant-list,
.ant-descriptions,
.ant-tabs,
.ant-btn,
.ant-input,
.ant-input-affix-wrapper,
.ant-input-number,
.ant-select-selector,
.ant-picker,
.ant-pagination-item,
.ant-pagination-prev,
.ant-pagination-next,
.ant-dropdown-menu-item,
.ant-tabs-tab,
.ant-modal-mask,
.ant-drawer-mask {
animation: none !important;
transition-duration: 0.01ms !important;
}
.ant-card:hover,
.ant-btn:not(:disabled):hover,
.ant-pagination-item:hover {
transform: none;
}
}
/* Apple-inspired dark control-room surfaces */
.admin-shell,
.admin-content,
.admin-page-transition {
color-scheme: dark;
}
.admin-glass-panel,
.admin-content .ant-card,
.admin-content .ant-table-wrapper,
.admin-content .ant-descriptions,
.admin-content .ant-list,
.admin-content .ant-tabs-content-holder,
.admin-content .ant-collapse {
background: linear-gradient(145deg, rgba(26, 33, 47, 0.78), rgba(12, 17, 27, 0.72));
border: 1px solid rgba(255, 255, 255, 0.095);
border-radius: 18px;
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.26), inset 0 1px 0 rgba(255, 255, 255, 0.045);
-webkit-backdrop-filter: blur(24px) saturate(135%);
backdrop-filter: blur(24px) saturate(135%);
}
.admin-form-panel { padding: 32px; }
.admin-stat-panel { padding: 24px 28px; }
.admin-table-panel { padding: 8px 8px 0; overflow: hidden; }
.admin-content .ant-card,
.admin-content .ant-card-head,
.admin-content .ant-card-body,
.admin-content .ant-table,
.admin-content .ant-table-container,
.admin-content .ant-table-content,
.admin-content .ant-table-thead > tr > th,
.admin-content .ant-table-tbody > tr > td,
.admin-content .ant-descriptions-view,
.admin-content .ant-descriptions-row > th,
.admin-content .ant-descriptions-row > td,
.admin-content .ant-list-item,
.admin-content .ant-tabs-nav,
.admin-content .ant-collapse,
.admin-content .ant-collapse-item,
.admin-content .ant-collapse-header,
.admin-content .ant-collapse-content,
.admin-content .ant-collapse-content-box {
background: transparent !important;
border-color: rgba(255, 255, 255, 0.085) !important;
color: var(--text-primary) !important;
}
.admin-content .ant-table-thead > tr > th {
color: var(--text-secondary) !important;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.admin-content .ant-table-tbody > tr > td,
.admin-content .ant-descriptions-item-content,
.admin-content .ant-list-item-meta-description,
.admin-content .ant-form-item-extra,
.admin-content .ant-form-item-explain {
color: var(--text-secondary) !important;
}
.admin-content .ant-table-tbody > tr.ant-table-row:hover > td {
background: rgba(91, 140, 255, 0.075) !important;
}
.admin-content .ant-form-item-label > label,
.admin-content .ant-descriptions-item-label,
.admin-content .ant-list-item-meta-title,
.admin-content .ant-tabs-tab,
.admin-content .ant-collapse-header-text,
.admin-content .ant-empty-description {
color: var(--text-secondary) !important;
}
.admin-content .ant-tabs-tab-active .ant-tabs-tab-btn,
.admin-content .ant-list-item-meta-title,
.admin-content .ant-descriptions-title {
color: var(--text-primary) !important;
}
.admin-content :where(.ant-input, .ant-input-affix-wrapper, .ant-input-number, .ant-picker, .ant-select-selector, .ant-cascader-picker, .ant-mentions, .ant-upload-drag) {
background: rgba(7, 11, 18, 0.68) !important;
border-color: rgba(255, 255, 255, 0.11) !important;
color: var(--text-primary) !important;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.025);
transition: border-color 180ms cubic-bezier(0.23, 1, 0.32, 1), box-shadow 180ms cubic-bezier(0.23, 1, 0.32, 1), background-color 180ms cubic-bezier(0.23, 1, 0.32, 1), transform 160ms cubic-bezier(0.23, 1, 0.32, 1);
}
.admin-content :where(.ant-input, .ant-input-affix-wrapper, .ant-input-number, .ant-picker, .ant-select-selector):focus,
.admin-content :where(.ant-input-affix-wrapper, .ant-input-number, .ant-picker, .ant-select).ant-input-affix-wrapper-focused,
.admin-content .ant-select-focused .ant-select-selector {
border-color: rgba(116, 158, 255, 0.78) !important;
box-shadow: 0 0 0 3px rgba(91, 140, 255, 0.16) !important;
}
.ant-modal-mask,
.ant-drawer-mask {
background: rgba(0, 0, 0, 0.62) !important;
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
}
.ant-modal-content,
.ant-modal-header,
.ant-drawer-content,
.ant-drawer-header,
.ant-drawer-body,
.ant-dropdown-menu,
.ant-select-dropdown,
.ant-popover-inner,
.ant-tooltip-inner,
.ant-message-notice-content,
.ant-notification-notice {
background: rgba(18, 24, 36, 0.9) !important;
border-color: rgba(255, 255, 255, 0.1) !important;
color: var(--text-primary) !important;
box-shadow: 0 28px 80px rgba(0, 0, 0, 0.48), inset 0 1px 0 rgba(255, 255, 255, 0.055) !important;
-webkit-backdrop-filter: blur(28px) saturate(145%);
backdrop-filter: blur(28px) saturate(145%);
}
.ant-modal-content,
.ant-drawer-content,
.ant-dropdown-menu,
.ant-select-dropdown,
.ant-popover-inner {
border: 1px solid rgba(255, 255, 255, 0.1);
}
.ant-dropdown-menu-item,
.ant-select-item,
.ant-pagination-item,
.ant-pagination-prev .ant-pagination-item-link,
.ant-pagination-next .ant-pagination-item-link {
color: var(--text-secondary) !important;
transition: color 160ms cubic-bezier(0.23, 1, 0.32, 1), background-color 160ms cubic-bezier(0.23, 1, 0.32, 1), border-color 160ms cubic-bezier(0.23, 1, 0.32, 1), transform 160ms cubic-bezier(0.23, 1, 0.32, 1);
}
.ant-pagination-item,
.ant-pagination-prev .ant-pagination-item-link,
.ant-pagination-next .ant-pagination-item-link {
background: rgba(18, 24, 36, 0.72) !important;
border-color: rgba(255, 255, 255, 0.09) !important;
}
.ant-pagination-item-active {
background: rgba(91, 140, 255, 0.19) !important;
border-color: rgba(116, 158, 255, 0.66) !important;
}
.ant-empty-image svg ellipse,
.ant-empty-image svg path {
fill: rgba(111, 124, 149, 0.28) !important;
stroke: rgba(154, 168, 194, 0.44) !important;
}
.admin-content .ant-btn,
.admin-header .ant-btn,
.login-page .ant-btn {
transition: transform 160ms cubic-bezier(0.23, 1, 0.32, 1), background-color 180ms cubic-bezier(0.23, 1, 0.32, 1), border-color 180ms cubic-bezier(0.23, 1, 0.32, 1), color 180ms cubic-bezier(0.23, 1, 0.32, 1), box-shadow 180ms cubic-bezier(0.23, 1, 0.32, 1), opacity 160ms cubic-bezier(0.23, 1, 0.32, 1);
}
.admin-content .ant-btn:active,
.admin-header .ant-btn:active,
.login-page .ant-btn:active {
transform: scale(0.97);
}
.admin-content .ant-card,
.admin-glass-panel,
.admin-sider .ant-menu-item,
.admin-content .ant-collapse-content,
.ant-modal-content,
.ant-drawer-content,
.ant-dropdown-menu,
.ant-select-dropdown {
transition: transform 220ms cubic-bezier(0.23, 1, 0.32, 1), opacity 180ms cubic-bezier(0.23, 1, 0.32, 1), border-color 180ms cubic-bezier(0.23, 1, 0.32, 1), box-shadow 220ms cubic-bezier(0.23, 1, 0.32, 1), background-color 180ms cubic-bezier(0.23, 1, 0.32, 1);
}
@media (hover: hover) and (pointer: fine) {
.admin-content .ant-card:hover,
.admin-glass-panel:hover {
transform: translate3d(0, -2px, 0);
border-color: rgba(255, 255, 255, 0.15);
box-shadow: 0 24px 58px rgba(0, 0, 0, 0.34), inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.ant-dropdown-menu-item:hover,
.ant-select-item-option-active:not(.ant-select-item-option-disabled) {
background: rgba(91, 140, 255, 0.12) !important;
}
}
@media (max-width: 768px) {
.admin-form-panel,
.admin-stat-panel { padding: 20px; }
.admin-content .ant-card,
.admin-glass-panel { border-radius: 15px; }
}
@media (prefers-reduced-motion: reduce) {
.admin-content *,
.admin-header *,
.admin-sider *,
.login-page *,
.ant-modal-root *,
.ant-drawer *,
.ant-dropdown *,
.ant-select-dropdown * {
animation-duration: 1ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
transition-duration: 80ms !important;
}
.admin-content .ant-card:hover,
.admin-glass-panel:hover,
.admin-content .ant-btn:active,
.admin-header .ant-btn:active,
.login-page .ant-btn:active {
transform: none !important;
}
}
.admin-section-divider { margin: 8px 0 24px; border-top: 1px solid rgba(255,255,255,.095); }
+40 -3
View File
@@ -15,15 +15,15 @@
display: grid;
place-items: center;
border-radius: 24px;
background: linear-gradient(135deg, rgba(255,255,255,0.92), rgba(248,244,238,0.78));
background: #070a10;
}
.route-loading span {
width: 28px;
height: 28px;
border-radius: 999px;
border: 3px solid rgba(194, 105, 58, 0.2);
border-top-color: #C2693A;
border: 3px solid rgba(91, 140, 255, 0.18);
border-top-color: #5B8CFF;
animation: route-loading-spin 760ms linear infinite;
}
@@ -77,3 +77,40 @@
animation: none;
}
}
/* Apple route motion */
.app-route-transition,
.admin-page-transition {
transform-origin: 50% 24%;
animation: apple-page-enter 320ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
}
.route-loading {
transition-property: opacity, filter;
transition-duration: 180ms;
transition-timing-function: cubic-bezier(0.2, 0, 0, 1);
}
@keyframes apple-page-enter {
from {
opacity: 0;
transform: translate3d(0, 8px, 0) scale(0.995);
filter: blur(3px);
}
to {
opacity: 1;
transform: translate3d(0, 0, 0) scale(1);
filter: blur(0);
}
}
@media (prefers-reduced-motion: reduce) {
.app-route-transition,
.admin-page-transition,
.route-loading {
animation: none !important;
transform: none !important;
filter: none !important;
transition-duration: 0.01ms !important;
}
}
+33 -33
View File
@@ -2,27 +2,27 @@ import type { ThemeConfig } from 'antd'
const theme: ThemeConfig = {
token: {
fontFamily: `"Source Serif 4", "Noto Serif SC", Georgia, serif`,
fontSize: 15,
lineHeight: 1.65,
colorPrimary: '#C2693A',
colorSuccess: '#5B8C5A',
colorWarning: '#C28B3A',
colorError: '#B54A4A',
colorInfo: '#6B8CA8',
colorTextBase: '#1C1A17',
colorTextSecondary: '#5E5A52',
colorTextTertiary: '#949088',
colorBgBase: '#F9F7F4',
colorBgContainer: '#FFFFFF',
colorBgLayout: '#F9F7F4',
colorBgElevated: '#FAFAF8',
colorBorder: '#EBE5DB',
colorBorderSecondary: '#F3EFE9',
colorFill: '#F3F0EC',
colorFillSecondary: '#F7F5F1',
borderRadius: 6,
borderRadiusLG: 10,
fontFamily: `Inter, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif`,
fontSize: 14,
lineHeight: 1.6,
colorPrimary: '#5B8CFF',
colorSuccess: '#2DD4A7',
colorWarning: '#F6B94A',
colorError: '#FF647C',
colorInfo: '#62B5FF',
colorTextBase: '#F4F7FB',
colorTextSecondary: '#AAB4C5',
colorTextTertiary: '#748096',
colorBgBase: '#070A10',
colorBgContainer: '#10151F',
colorBgLayout: '#070A10',
colorBgElevated: '#171D29',
colorBorder: '#273044',
colorBorderSecondary: '#1D2636',
colorFill: '#1D2432',
colorFillSecondary: '#171D29',
borderRadius: 10,
borderRadiusLG: 16,
borderRadiusSM: 4,
wireframe: false,
fontWeightStrong: 600,
@@ -38,18 +38,18 @@ const theme: ThemeConfig = {
},
components: {
Layout: {
headerBg: '#FFFFFF',
headerBg: '#10151F',
headerHeight: 60,
headerPadding: '0 48px',
siderBg: '#FFFFFF',
bodyBg: '#F9F7F4',
siderBg: '#10151F',
bodyBg: '#070A10',
},
Menu: {
itemBg: 'transparent',
itemSelectedBg: '#F3F0EC',
itemSelectedColor: '#1C1A17',
itemActiveBg: '#F7F5F1',
itemColor: '#5E5A52',
itemSelectedBg: '#1D2636',
itemSelectedColor: '#F4F7FB',
itemActiveBg: '#171D29',
itemColor: '#AAB4C5',
itemHeight: 44,
},
Card: {
@@ -62,14 +62,14 @@ const theme: ThemeConfig = {
primaryShadow: '0 2px 6px rgba(194,105,58,0.1)',
},
Table: {
headerBg: '#F7F5F1',
headerColor: '#5E5A52',
rowHoverBg: '#F5F3F0',
borderColor: '#EBE5DB',
headerBg: '#171D29',
headerColor: '#AAB4C5',
rowHoverBg: '#171D29',
borderColor: '#273044',
},
Tag: { borderRadiusSM: 4 },
Modal: { borderRadiusLG: 12, paddingLG: 28 },
Tabs: { inkBarColor: '#C2693A', itemActiveColor: '#C2693A', itemHoverColor: '#D4A782' },
Tabs: { inkBarColor: '#5B8CFF', itemActiveColor: '#5B8CFF', itemHoverColor: '#8AABFF' },
Select: { borderRadius: 6 },
Input: { borderRadius: 6 },
},
@@ -0,0 +1,48 @@
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import type { Components } from 'react-markdown'
const components: Components = {
// 外部链接一律新窗口打开,避免观众被带离播放页
a: ({ node: _node, ...props }) => (
<a {...props} target="_blank" rel="noopener noreferrer" />
),
}
/**
* 安全的 Markdown 渲染。react-markdown 默认不输出 raw HTML,
* 简介中的 HTML 会被当作纯文本处理,不会执行,天然免疫 XSS。
*/
export default function Markdown({ content, className }: { content: string; className?: string }) {
return (
<div className={className || 'md-content'}>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
{content}
</ReactMarkdown>
</div>
)
}
/**
* 把 Markdown 摘要为纯文本,供列表卡片截断展示。
* 只做常见的标记剥离,足够生成一行预览。
*/
export function mdToText(source: string | undefined | null): string {
if (!source) return ''
return source
.replace(/```[\s\S]*?```/g, (block) => {
const firstLine = block.split('\n')[1]?.trim() ?? ''
return firstLine || ''
})
.replace(/`([^`]+)`/g, '$1')
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
.replace(/^#{1,6}\s+/gm, '')
.replace(/^>\s?/gm, '')
.replace(/^[-*+]\s+/gm, '')
.replace(/^\d+[.)]\s+/gm, '')
.replace(/(\*\*|__|\*|_|~~)/g, '')
.replace(/[ \t]+/g, ' ')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
@@ -0,0 +1,283 @@
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react'
import Hls from 'hls.js'
import {
FullscreenExitOutlined,
FullscreenOutlined,
PauseOutlined,
PlayCircleOutlined,
SoundOutlined,
} from '@ant-design/icons'
import type { Chapter, LineInfo, QualityInfo } from '../../api/streams'
import './player.css'
export interface VideoPlayerHandle {
seek: (seconds: number) => void
play: () => Promise<void>
pause: () => void
getVideo: () => HTMLVideoElement | null
}
interface VideoPlayerProps {
live: boolean
replayUrl?: string
lines: LineInfo[]
qualities: QualityInfo[]
activeLine: string
activeQuality: string
autoQuality?: string
chapters?: Chapter[]
onLineChange: (line: string) => void
onQualityChange: (quality: string) => void
onError?: (message: string) => void
}
function formatTime(seconds: number) {
if (!Number.isFinite(seconds)) return '00:00'
const value = Math.max(0, Math.floor(seconds))
const hours = Math.floor(value / 3600)
const minutes = Math.floor((value % 3600) / 60)
const secs = value % 60
return hours > 0
? `${hours}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`
: `${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`
}
const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(function VideoPlayer({
live,
replayUrl,
lines,
qualities,
activeLine,
activeQuality,
autoQuality,
chapters = [],
onLineChange,
onQualityChange,
onError,
}, ref) {
const rootRef = useRef<HTMLDivElement>(null)
const videoRef = useRef<HTMLVideoElement>(null)
const hlsRef = useRef<Hls | null>(null)
const objectUrlRef = useRef<string>()
const [loading, setLoading] = useState(false)
const [playing, setPlaying] = useState(false)
const [currentTime, setCurrentTime] = useState(0)
const [duration, setDuration] = useState(0)
const [buffered, setBuffered] = useState(0)
const [volume, setVolume] = useState(1)
const [fullscreen, setFullscreen] = useState(false)
useImperativeHandle(ref, () => ({
seek(seconds) {
if (videoRef.current) videoRef.current.currentTime = Math.max(0, seconds)
},
async play() {
await videoRef.current?.play()
},
pause() {
videoRef.current?.pause()
},
getVideo() {
return videoRef.current
},
}), [])
useEffect(() => {
const video = videoRef.current
if (!video) return
const update = () => {
setCurrentTime(video.currentTime || 0)
setDuration(Number.isFinite(video.duration) ? video.duration : 0)
const end = video.buffered.length ? video.buffered.end(video.buffered.length - 1) : 0
setBuffered(end)
}
const onPlay = () => setPlaying(true)
const onPause = () => setPlaying(false)
const onWaiting = () => setLoading(true)
const onPlaying = () => setLoading(false)
video.addEventListener('timeupdate', update)
video.addEventListener('durationchange', update)
video.addEventListener('progress', update)
video.addEventListener('play', onPlay)
video.addEventListener('pause', onPause)
video.addEventListener('waiting', onWaiting)
video.addEventListener('playing', onPlaying)
return () => {
video.removeEventListener('timeupdate', update)
video.removeEventListener('durationchange', update)
video.removeEventListener('progress', update)
video.removeEventListener('play', onPlay)
video.removeEventListener('pause', onPause)
video.removeEventListener('waiting', onWaiting)
video.removeEventListener('playing', onPlaying)
}
}, [])
useEffect(() => {
const listener = () => setFullscreen(document.fullscreenElement === rootRef.current)
document.addEventListener('fullscreenchange', listener)
return () => document.removeEventListener('fullscreenchange', listener)
}, [])
useEffect(() => {
const video = videoRef.current
if (!video) return
const controller = new AbortController()
let disposed = false
hlsRef.current?.destroy()
hlsRef.current = null
if (objectUrlRef.current) {
URL.revokeObjectURL(objectUrlRef.current)
objectUrlRef.current = undefined
}
video.removeAttribute('src')
video.load()
setLoading(Boolean(live || replayUrl))
onError?.('')
if (!live && replayUrl) {
void fetch(replayUrl, { signal: controller.signal })
.then(response => {
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return response.blob()
})
.then(blob => {
if (disposed) return
const objectUrl = URL.createObjectURL(blob)
objectUrlRef.current = objectUrl
video.src = objectUrl
video.load()
})
.catch(error => {
if (error instanceof DOMException && error.name === 'AbortError') return
setLoading(false)
onError?.('回放拉取失败,请检查远端地址及其 CORS 配置')
})
} else if (live) {
const line = lines.find(item => item.node_id === activeLine)
const label = activeQuality === 'auto' ? autoQuality : activeQuality
const quality = line?.qualities?.find(item => item.label === label)
const source = quality?.url || line?.url || line?.unicast_fallback
if (!source) {
setLoading(false)
onError?.('当前没有可用的播放地址')
} else if (Hls.isSupported()) {
const hls = new Hls({ enableWorker: true, lowLatencyMode: false })
hlsRef.current = hls
hls.loadSource(source)
hls.attachMedia(video)
hls.on(Hls.Events.MANIFEST_PARSED, () => setLoading(false))
hls.on(Hls.Events.ERROR, (_event, data) => {
if (data.fatal) {
setLoading(false)
onError?.('播放流加载失败,请切换线路或稍后重试')
}
})
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = source
video.load()
} else {
setLoading(false)
onError?.('当前浏览器不支持 HLS 播放')
}
}
return () => {
disposed = true
controller.abort()
hlsRef.current?.destroy()
hlsRef.current = null
if (objectUrlRef.current) {
URL.revokeObjectURL(objectUrlRef.current)
objectUrlRef.current = undefined
}
}
}, [activeLine, activeQuality, autoQuality, lines, live, onError, replayUrl])
const togglePlayback = () => {
const video = videoRef.current
if (!video) return
if (video.paused) void video.play().catch(() => onError?.('浏览器阻止了自动播放,请再次点击播放'))
else video.pause()
}
const seek = (value: number) => {
const video = videoRef.current
if (video) video.currentTime = value
}
const changeVolume = (value: number) => {
const video = videoRef.current
if (!video) return
video.volume = value
video.muted = value === 0
setVolume(value)
}
const toggleFullscreen = async () => {
if (!rootRef.current) return
if (document.fullscreenElement) await document.exitFullscreen()
else await rootRef.current.requestFullscreen()
}
return (
<div ref={rootRef} className="custom-player">
<video ref={videoRef} playsInline preload="metadata" onClick={togglePlayback} />
{loading && <div className="custom-player-loading" aria-label="媒体加载中"><span /></div>}
<div className="custom-player-controls">
<div className="custom-player-timeline">
<div className="custom-player-buffer" style={{ width: `${duration ? Math.min(100, buffered / duration * 100) : 0}%` }} />
<input
aria-label="播放进度"
type="range"
min={0}
max={duration || 0}
step="0.1"
value={Math.min(currentTime, duration || 0)}
onChange={event => seek(Number(event.target.value))}
/>
{duration > 0 && chapters.map((chapter, index) => (
<button
className="custom-player-chapter-mark"
key={`${chapter.time}-${index}`}
style={{ left: `${Math.min(100, chapter.time / duration * 100)}%` }}
title={chapter.title}
type="button"
onClick={() => seek(chapter.time)}
/>
))}
</div>
<div className="custom-player-row">
<button type="button" onClick={togglePlayback} aria-label={playing ? '暂停' : '播放'}>
{playing ? <PauseOutlined /> : <PlayCircleOutlined />}
</button>
<span className="custom-player-time">{formatTime(currentTime)} / {formatTime(duration)}</span>
<label className="custom-player-volume">
<SoundOutlined />
<input aria-label="音量" type="range" min={0} max={1} step="0.05" value={volume} onChange={event => changeVolume(Number(event.target.value))} />
</label>
<span className="custom-player-spacer" />
{live && lines.length > 1 && (
<select aria-label="播放线路" value={activeLine} onChange={event => onLineChange(event.target.value)}>
{lines.map(line => <option key={line.node_id} value={line.node_id}>{line.node_name}</option>)}
</select>
)}
{live && qualities.length > 0 && (
<select aria-label="清晰度" value={activeQuality} onChange={event => onQualityChange(event.target.value)}>
<option value="auto">自动{autoQuality ? ` (${autoQuality})` : ''}</option>
{qualities.map(quality => <option key={quality.label} value={quality.label}>{quality.label}</option>)}
</select>
)}
<button type="button" onClick={() => void toggleFullscreen()} aria-label={fullscreen ? '退出全屏' : '全屏'}>
{fullscreen ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
</button>
</div>
</div>
</div>
)
})
export default VideoPlayer
+169
View File
@@ -0,0 +1,169 @@
.custom-player {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
overflow: hidden;
background: #020408;
color: #f4f7fb;
}
.custom-player video {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
cursor: pointer;
}
.custom-player-loading {
position: absolute;
inset: 0;
display: grid;
place-items: center;
pointer-events: none;
background: rgba(2, 4, 8, 0.24);
}
.custom-player-loading span {
width: 38px;
height: 38px;
border: 3px solid rgba(255, 255, 255, 0.2);
border-top-color: #5b8cff;
border-radius: 50%;
animation: custom-player-spin 800ms linear infinite;
}
@keyframes custom-player-spin {
to { transform: rotate(360deg); }
}
.custom-player-controls {
position: absolute;
right: 0;
bottom: 0;
left: 0;
padding: 36px 16px 12px;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.86));
}
.custom-player-timeline {
position: relative;
height: 16px;
display: flex;
align-items: center;
}
.custom-player-timeline::before,
.custom-player-buffer {
position: absolute;
right: 0;
left: 0;
height: 4px;
border-radius: 999px;
content: '';
}
.custom-player-timeline::before {
background: rgba(255, 255, 255, 0.22);
}
.custom-player-buffer {
right: auto;
background: rgba(255, 255, 255, 0.46);
}
.custom-player-timeline input {
position: relative;
z-index: 2;
width: 100%;
margin: 0;
accent-color: #5b8cff;
cursor: pointer;
}
.custom-player-chapter-mark {
position: absolute;
z-index: 3;
width: 8px;
height: 8px;
padding: 0;
border: 2px solid #070a10;
border-radius: 50%;
background: #f6b94a;
transform: translateX(-50%);
cursor: pointer;
}
.custom-player-row {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.custom-player-row button {
display: grid;
width: 36px;
height: 36px;
padding: 0;
place-items: center;
border: 0;
border-radius: 8px;
background: transparent;
color: inherit;
font-size: 19px;
cursor: pointer;
}
.custom-player-row button:hover,
.custom-player-row button:focus-visible {
background: rgba(255, 255, 255, 0.12);
outline: none;
}
.custom-player-time {
min-width: 100px;
color: #e4e9f2;
font: 500 12px/1.2 Inter, sans-serif;
white-space: nowrap;
}
.custom-player-volume {
display: flex;
align-items: center;
gap: 7px;
}
.custom-player-volume input {
width: 76px;
accent-color: #5b8cff;
}
.custom-player-spacer {
flex: 1;
}
.custom-player-row select {
max-width: 150px;
height: 32px;
padding: 0 28px 0 9px;
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 8px;
background: rgba(10, 14, 22, 0.88);
color: #f4f7fb;
font-size: 12px;
}
.custom-player:fullscreen {
width: 100vw;
height: 100vh;
aspect-ratio: auto;
}
@media (max-width: 640px) {
.custom-player-controls { padding: 28px 10px 8px; }
.custom-player-volume input { display: none; }
.custom-player-time { min-width: 82px; font-size: 11px; }
.custom-player-row { gap: 4px; }
.custom-player-row select { max-width: 104px; }
}
+14 -22
View File
@@ -4,6 +4,7 @@ import { Skeleton } from 'antd'
import { SearchOutlined, VideoCameraOutlined } from '@ant-design/icons'
import { fetchStreams, type StreamInfo } from '../../api/streams'
import { useBrand } from '../../brand/BrandContext'
import { mdToText } from '../components/Markdown'
import './viewer.css'
export default function HomePage() {
@@ -60,32 +61,23 @@ export default function HomePage() {
})
const visibleStreams = sorted.filter(stream => {
const matchesFilter = filter === 'all' || stream.status === filter
const haystack = `${stream.title} ${stream.description || ''}`.toLowerCase()
const haystack = `${stream.title} ${mdToText(stream.description)}`.toLowerCase()
return matchesFilter && haystack.includes(query.trim().toLowerCase())
})
return (
<div className="viewer-shell">
<header className="viewer-hero">
<nav className="viewer-nav" aria-label="主导航">
<span>{brand.brand_name}</span>
<button className="viewer-button viewer-button--ghost" type="button" onClick={() => document.getElementById('programmes')?.scrollIntoView()}>
节目目录
</button>
</nav>
<div className="viewer-eyebrow">LIVE PROGRAMME</div>
{brand.brand_logo_url && (
<img src={brand.brand_logo_url} alt={brand.brand_name}
className="viewer-logo" />
)}
<h1 className="viewer-title">
{brand.brand_name}
</h1>
{brand.brand_tagline && (
<p className="viewer-tagline">
{brand.brand_tagline}
</p>
)}
<header className="viewer-header">
<div className="viewer-header-brand">
<div>
<h1>{brand.brand_name}</h1>
{brand.brand_tagline && <p>{brand.brand_tagline}</p>}
</div>
</div>
<div className="viewer-live-summary">
<span className="viewer-live-dot" />
{sorted.filter(stream => stream.status === 'live').length} 个直播中
</div>
</header>
<main className="viewer-main" id="programmes">
@@ -187,7 +179,7 @@ export default function HomePage() {
{s.title}
</h3>
<p>
{s.description || '暂无简介'}
{mdToText(s.description) || '暂无简介'}
</p>
</div>
</button>
+29 -79
View File
@@ -1,8 +1,9 @@
import { useState, useEffect, useRef } from 'react'
import { useState, useEffect, useRef, useCallback } from 'react'
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 { Button, Typography, Tag, Spin, Empty, Alert } from 'antd'
import { ArrowLeftOutlined, SignalFilled } from '@ant-design/icons'
import VideoPlayer, { type VideoPlayerHandle } from '../components/VideoPlayer'
import Markdown from '../components/Markdown'
import {
getStreamStatusUrl,
getWatchUrls,
@@ -21,7 +22,8 @@ export default function WatchPage() {
const { brand } = useBrand()
const navigate = useNavigate()
const { id } = useParams<{ id: string }>()
const videoRef = useRef<HTMLVideoElement>(null)
const playerRef = useRef<VideoPlayerHandle>(null)
const getVideo = useCallback(() => playerRef.current?.getVideo() ?? null, [])
const [info, setInfo] = useState<WatchInfo | null>(null)
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState('')
@@ -35,7 +37,6 @@ export default function WatchPage() {
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)
@@ -52,7 +53,7 @@ export default function WatchPage() {
if (!Number.isInteger(streamId) || streamId <= 0 || !info) return
const heartbeat = async () => {
const video = videoRef.current
const video = getVideo()
const bufferedSeconds = video && video.buffered.length
? Math.max(0, video.buffered.end(video.buffered.length - 1) - video.currentTime)
: 0
@@ -180,61 +181,6 @@ export default function WatchPage() {
return enabled[enabled.length - 1]
}
useEffect(() => {
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')
video.load()
if (info.replay_url && info.status !== 'live') {
video.src = info.replay_url
video.load()
return
}
if (info.status !== 'live' || !activeLine) return
const line = info.lines.find(l => l.node_id === activeLine)
if (!line) {
setPlaybackError('所选播放线路不可用')
return
}
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('当前节目没有可用的播放地址')
return
}
if (Hls.isSupported()) {
const hi = new Hls({ enableWorker: false, lowLatencyMode: false })
hi.loadSource(source)
hi.attachMedia(video)
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('当前浏览器不支持此播放格式')
}
return () => {
hlsRef.current?.destroy()
hlsRef.current = null
}
}, [activeLine, activeQuality, autoQuality, info])
useEffect(() => {
if (!info?.qualities.length) return
@@ -242,7 +188,7 @@ export default function WatchPage() {
}, [info])
useEffect(() => {
const video = videoRef.current
const video = getVideo()
if (!video || !info || activeQuality !== 'auto') return
const qualityLabels = info.qualities.map(quality => quality.label)
@@ -292,7 +238,7 @@ export default function WatchPage() {
}, [activeQuality, info])
useEffect(() => {
const video = videoRef.current
const video = getVideo()
if (!video) return
const updateStats = () => {
const buffered = video.buffered.length
@@ -312,9 +258,8 @@ export default function WatchPage() {
}, [activeLine, activeQuality, autoQuality])
function jumpToChapter(time: number) {
if (!videoRef.current) return
videoRef.current.currentTime = time
videoRef.current.play().catch(() => {})
playerRef.current?.seek(time)
void playerRef.current?.play().catch(() => {})
}
useEffect(() => { document.title = info ? `${info.title} - ${brand.brand_name}` : brand.brand_name }, [info, brand])
@@ -354,14 +299,6 @@ export default function WatchPage() {
<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={[
{ 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="线路" />
</div>
</header>
@@ -369,17 +306,30 @@ export default function WatchPage() {
<div className="watch-content">
<div className="watch-frame">
<div className="watch-player" onContextMenu={event => { event.preventDefault(); setShowPlaybackStats(value => !value) }}>
<video ref={videoRef} controls autoPlay playsInline className="watch-video" />
<VideoPlayer
ref={playerRef}
live={info.status === 'live'}
replayUrl={info.status !== 'live' ? (info.replay_url || info.replay_local_url) : undefined}
lines={info.lines}
qualities={info.qualities}
activeLine={activeLine}
activeQuality={activeQuality}
autoQuality={autoQuality}
chapters={info.chapters}
onLineChange={setActiveLine}
onQualityChange={setActiveQuality}
onError={setPlaybackError}
/>
{showPlaybackStats && (
<section className="playback-stats" aria-label="播放统计信息">
<div><span>播放状态</span><strong>{videoRef.current?.paused ? '暂停' : '播放中'}</strong></div>
<div><span>播放状态</span><strong>{getVideo()?.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>
<div className="playback-stats-url"><span>播放地址</span><strong>{getVideo()?.currentSrc || '加载中'}</strong></div>
</section>
)}
</div>
@@ -400,7 +350,7 @@ export default function WatchPage() {
</div>
{(info.description || info.chapters.length > 0) && (
<aside className="watch-details">
{info.description && <p>{info.description}</p>}
{info.description && <Markdown content={info.description} />}
{info.chapters.length > 0 && (
<section>
<h2>节目章节</h2>
+69
View File
@@ -1,4 +1,6 @@
.viewer-shell {
color: #F4F7FB;
background: radial-gradient(circle at 80% 0%, rgba(91,140,255,.16), transparent 32rem), #070A10;
min-height: 100vh;
width: 100%;
max-width: 100vw;
@@ -577,3 +579,70 @@ button.stream-card:focus-visible {
animation-iteration-count: 1 !important;
}
}
/* Unified dark viewer system and compact homepage header */
.viewer-shell {
min-height: 100vh;
color: #f4f7fb;
background: #070a10;
}
.viewer-header {
max-width: 1440px;
margin: 0 auto;
min-height: 76px;
padding: 14px 28px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
border-bottom: 1px solid #1d2636;
}
.viewer-header-brand { display: flex; align-items: center; gap: 12px; min-width: 0; }
.viewer-header-logo { width: 42px; height: 42px; border-radius: 12px; object-fit: cover; }
.viewer-header h1 { margin: 0; color: #f4f7fb; font-size: 20px; line-height: 1.2; }
.viewer-header p { margin: 3px 0 0; color: #748096; font-size: 13px; }
.viewer-live-summary { display: flex; align-items: center; gap: 8px; color: #aab4c5; font-size: 13px; white-space: nowrap; }
.viewer-live-dot { width: 8px; height: 8px; border-radius: 50%; background: #ff647c; box-shadow: 0 0 0 5px rgba(255,100,124,.12); }
.viewer-main { max-width: 1440px; margin: 0 auto; padding: 24px 28px 48px; }
.viewer-section-heading { margin-bottom: 18px; }
.viewer-section-heading h2 { color: #f4f7fb; }
.viewer-section-heading span { color: #748096; }
.viewer-directory-tools { margin-bottom: 22px; }
.viewer-search { background: #10151f; border-color: #273044; color: #748096; box-shadow: none; }
.viewer-search:focus-within { border-color: #5b8cff; box-shadow: 0 0 0 3px rgba(91,140,255,.14); }
.viewer-search input { color: #f4f7fb; }
.viewer-search input::placeholder { color: #748096; }
.viewer-filter { color: #aab4c5; }
.viewer-filter:hover { color: #f4f7fb; border-color: #273044; background: #171d29; }
.viewer-filter.is-active { color: #fff; border-color: #5b8cff; background: #5b8cff; }
.stream-card, .stream-card button { background: #10151f; color: #f4f7fb; border-color: #273044; }
.stream-card:hover { border-color: #3b4a66; box-shadow: 0 18px 44px rgba(0,0,0,.28); }
.stream-copy h3 { color: #f4f7fb; }
.stream-copy p { color: #748096; }
.viewer-footer { color: #748096; border-color: #1d2636; }
@media (max-width: 640px) {
.viewer-header { min-height: 68px; padding: 12px 16px; }
.viewer-header p { display: none; }
.viewer-main { padding: 18px 16px 36px; }
.viewer-directory-tools { align-items: stretch; }
}
/* Final dark-theme normalization for player surfaces */
.watch-shell, .watch-page { background: #070a10; color: #f4f7fb; }
.watch-header { background: #10151f; border-color: #273044; }
.watch-title, .watch-details h2 { color: #f4f7fb; }
.watch-main, .watch-stage { background: #070a10; }
.watch-player { background: #020408; border-color: #273044; box-shadow: 0 20px 60px rgba(0,0,0,.35); }
.playback-stats { background: rgba(10,14,22,.94); color: #f4f7fb; border-color: #273044; }
.playback-stats span, .watch-waiting .ant-typography, .chapter-list time { color: #748096; }
.watch-waiting { background: rgba(7,10,16,.94); }
.watch-details { background: #10151f; border-color: #273044; border-radius: 16px; }
.watch-details p { color: #aab4c5; }
.chapter-list button { background: #171d29; color: #f4f7fb; border-color: #273044; border-radius: 10px; }
.chapter-list button:hover { background: #1d2636; border-color: #5b8cff; box-shadow: 0 8px 24px rgba(0,0,0,.24); }
.chapter-list button:focus-visible { border-color: #5b8cff; box-shadow: 0 0 0 3px rgba(91,140,255,.18); }