feat: consolidate on the shadcn frontend and harden playback

Frontend
- Replace the Ant Design app with the shadcn/ui app as the single frontend,
  keeping the portfolio side-by-side revertible in this commit.
- Drop the abandoned mdui experiment and the AntD tree so there is one app,
  one router and one design system to maintain.

Live playback
- Stop racing the encoder. lowLatencyMode + liveSyncDurationCount:1 targeted a
  single 4s segment, so playback ran at up to 1.10x and the forward buffer
  bottomed out at 0.1s, re-buffering every segment. Measured over 40s:
  buffer min 0.1s -> 8.9s, rate 1.00 constant, 0 stalls.
- Remove the 落后 labels and the page-level edge-snapping timer that fought
  hls.js over two different definitions of "latency".
- Live can no longer be paused: the control is not rendered while it plays, so
  the icon can never misreport state.
- Fix autoplay: muted autoplay started silently and never unmuted, and the
  gesture that restored sound also toggled playback off. One owner now.

Source ("原画") rendition
- Publish the publisher's stream remuxed losslessly via -c:v copy alongside the
  transcoded ones, so a source option exists even when every ladder entry
  downsizes.
- Give renditions an ASCII path slug: the HLS path is part of the URL and
  MediaMTX rejects non-alphanumeric paths, so 原画 could never be served.

Backend
- Supervise MediaMTX from the backend: start it at boot and restart it on exit,
  never killing an origin it did not start.
- Clear stale HLS output before restarting an encoder; an ENDLIST-less playlist
  left by a dead encoder hangs hls.js forever.
- Mark JSON timestamps as UTC. utcnow().isoformat() emitted a naive string that
  JS read as local time, so every viewer heartbeat looked 8 hours old in UTC+8
  and the live board's active-viewer count was permanently zero.
- Rebind a viewer session when it arrives on another stream instead of failing,
  and survive the StrictMode double-heartbeat race on first insert.
- Replace the Linux-only /proc and os.getloadavg metrics with psutil.
- Complete MediaMTX's cookieCheck redirect inside /hls proxying, and hold the
  httpx client open for the streamed body.

Housekeeping
- Ignore RainOSS (it stores accessKeySecret), runtime output, TLS keys and
  .venv; remove generated screenshots, load test scripts and stray files.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
adminandClaude Opus 4.8 committed 2026-09-22 18:07:56 +08:00
1 parent 186e32caf1
commit 996e3fea39
70 files changed
+4844 -3070

No files matched your search

+542 -121
View File
@@ -1,14 +1,7 @@
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react'
import { forwardRef, useCallback, 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'
import { Loader2, Maximize, Minimize, Pause, Play, Volume2, VolumeX } from 'lucide-react'
import type { Chapter, LineInfo, QualityInfo } from '@/api/streams'
export interface VideoPlayerHandle {
seek: (seconds: number) => void
@@ -42,46 +35,161 @@ function formatTime(seconds: number) {
: `${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) {
/** "2.5Mbps" / "600kbps" for the source tier, whose rate is only known at runtime. */
function formatBitrate(value: number) {
if (!Number.isFinite(value) || value <= 0) return ''
return value >= 1000000 ? `${(value / 1000000).toFixed(1)}Mbps` : `${Math.round(value / 1000)}kbps`
}
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 [muted, setMuted] = useState(false)
const [fullscreen, setFullscreen] = useState(false)
// Autoplay gate: a browser that refuses to start playback on its own can only
// be unblocked by a real click, so the prompt has to be impossible to miss.
// `ready` keeps it hidden until there is actually something to play.
const [needsGesture, setNeedsGesture] = useState(false)
const [ready, setReady] = useState(false)
// Set when autoplay only succeeded after muting, so the click that clears the
// gate knows to restore sound.
const [mutedAttempt, setMutedAttempt] = useState(false)
// Measured size/bitrate of the source ("原画") stream. Nothing about it is known
// up front, and reading it off a different rendition would mislabel the option,
// so it is only filled in while the source playlist is the one playing.
const [sourceMeta, setSourceMeta] = useState('')
const sourceMetaRef = useRef({ isSource: false })
const sourceBitrateRef = useRef(0)
// Controls start visible, then fade after inactivity. Hiding them purely on
// :hover meant entering fullscreen showed a bare video with no controls until
// the pointer happened to move.
const [controlsVisible, setControlsVisible] = useState(true)
const hideTimerRef = useRef<number | null>(null)
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
},
}), [])
// Refresh the source-option label from what is actually decoding. A component
// function so both the once-registered media listeners and the source-switching
// effect can call it.
const measureSource = useCallback(() => {
const video = videoRef.current
if (!video || !sourceMetaRef.current.isSource) return
setSourceMeta(
[formatBitrate(sourceBitrateRef.current), video.videoWidth && video.videoHeight ? `${video.videoWidth}×${video.videoHeight}` : '']
.filter(Boolean)
.join(' · '),
)
}, [])
// Attempt autoplay, silently muting first if the browser only allows muted
// playback. Rejections other than NotAllowedError (AbortError from a source
// swap, etc.) leave the element alone — the 'canplay' handler covers those.
const attemptAutoplay = useCallback(async (video: HTMLVideoElement) => {
try {
await video.play()
setNeedsGesture(false)
} catch (error) {
if ((error as DOMException)?.name === 'NotAllowedError' && !video.muted) {
video.muted = true
setMuted(true)
setMutedAttempt(true)
try {
// Muted playback is granted, so the stream starts — but silently. Keep
// the prompt up until the viewer interacts, both to offer the sound and
// so the muted state is never mistaken for a broken stream.
await video.play()
setNeedsGesture(true)
return
} catch {
// fall through to the gate
}
} else if ((error as DOMException)?.name !== 'NotAllowedError') {
return
}
if (!video.paused) return
setNeedsGesture(true)
}
}, [])
const unmute = useCallback(() => {
const video = videoRef.current
if (!video) return
video.muted = false
if (video.volume === 0) video.volume = 1
setMuted(false)
setMutedAttempt(false)
}, [])
// The click that unblocks playback also has to unmute — otherwise the viewer
// sees a "muted" state they never asked for.
const resolveGate = () => {
const video = videoRef.current
setNeedsGesture(false)
if (!video) return
if (mutedAttempt) unmute()
void video.play().catch(() => setNeedsGesture(true))
}
// Muted autoplay always succeeds, so "we muted it to get playing at all" is a
// silent state, and the stream sounds broken until someone notices the small
// speaker icon. This deliberately restores sound from ONE place — the video's
// own click handler below — rather than a window-level gesture listener: a
// window listener fires on pointerdown, which runs before React's click handler,
// so by then mutedAttempt was already false and the click toggled playback off
// instead of unmuting. One owner, one ordering.
useImperativeHandle(
ref,
() => ({
seek(seconds) {
// Live playlists only retain a short window, so seeking far back lands
// outside the buffer and stalls. Clamp to what is actually available.
const video = videoRef.current
if (!video) return
const target = Math.max(0, seconds)
if (live && video.buffered.length) {
const start = video.buffered.start(0)
if (target < start) {
video.currentTime = start
return
}
}
video.currentTime = target
},
async play() {
await videoRef.current?.play()
},
pause() {
videoRef.current?.pause()
},
getVideo() {
return videoRef.current
},
}),
[],
)
// 基础事件监听
useEffect(() => {
const video = videoRef.current
if (!video) return
@@ -95,81 +203,211 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(function Vid
const onPlay = () => setPlaying(true)
const onPause = () => setPlaying(false)
const onWaiting = () => setLoading(true)
const onPlaying = () => setLoading(false)
const onPlaying = () => {
setLoading(false)
setNeedsGesture(false)
setMuted(video.muted)
}
const onCanPlay = () => {
setLoading(false)
setReady(true)
// Data is in and the element still is not playing: autoplay either lost the
// race against the previous source or was refused, so prompt the viewer.
if (video.paused) setNeedsGesture(true)
}
const onVolumeChange = () => {
setVolume(video.volume)
setMuted(video.muted)
if (!video.muted) setMutedAttempt(false)
}
const onResize = () => measureSource()
const onErrorEvent = () => {
setLoading(false)
const error = video.error
let msg = '视频播放出错'
if (error) {
switch (error.code) {
case 1:
msg = '视频加载中止'
break
case 2:
msg = '网络错误'
break
case 3:
msg = '视频解码失败'
break
case 4:
msg = '视频格式不支持或未找到'
break
default:
msg = `未知错误 (${error.code})`
}
if (error.message) msg += `: ${error.message}`
}
onError?.(msg)
}
video.addEventListener('timeupdate', update)
video.addEventListener('durationchange', update)
video.addEventListener('progress', update)
video.addEventListener('play', onPlay)
video.addEventListener('pause', onPause)
video.addEventListener('volumechange', onVolumeChange)
video.addEventListener('waiting', onWaiting)
video.addEventListener('playing', onPlaying)
video.addEventListener('canplay', onCanPlay)
video.addEventListener('resize', onResize)
video.addEventListener('loadedmetadata', onResize)
video.addEventListener('error', onErrorEvent)
return () => {
video.removeEventListener('timeupdate', update)
video.removeEventListener('durationchange', update)
video.removeEventListener('progress', update)
video.removeEventListener('play', onPlay)
video.removeEventListener('pause', onPause)
video.removeEventListener('volumechange', onVolumeChange)
video.removeEventListener('waiting', onWaiting)
video.removeEventListener('playing', onPlaying)
video.removeEventListener('canplay', onCanPlay)
video.removeEventListener('resize', onResize)
video.removeEventListener('loadedmetadata', onResize)
video.removeEventListener('error', onErrorEvent)
}
}, [onError, measureSource])
// 全屏监听
useEffect(() => {
const listener = () => {
setFullscreen(document.fullscreenElement === rootRef.current)
setControlsVisible(true)
}
document.addEventListener('fullscreenchange', listener)
// Older WebKit (iOS Safari) reports video fullscreen through its own event.
const video = videoRef.current
const onWebkitBegin = () => setFullscreen(true)
const onWebkitEnd = () => setFullscreen(false)
video?.addEventListener('webkitbeginfullscreen', onWebkitBegin)
video?.addEventListener('webkitendfullscreen', onWebkitEnd)
return () => {
document.removeEventListener('fullscreenchange', listener)
video?.removeEventListener('webkitbeginfullscreen', onWebkitBegin)
video?.removeEventListener('webkitendfullscreen', onWebkitEnd)
}
}, [])
useEffect(() => {
const listener = () => setFullscreen(document.fullscreenElement === rootRef.current)
document.addEventListener('fullscreenchange', listener)
return () => document.removeEventListener('fullscreenchange', listener)
// Fade the controls out after a few idle seconds, but never while paused —
// a paused player with no controls looks broken.
const scheduleHide = useCallback(() => {
if (hideTimerRef.current) window.clearTimeout(hideTimerRef.current)
hideTimerRef.current = window.setTimeout(() => {
if (!videoRef.current?.paused) setControlsVisible(false)
}, 3000)
}, [])
const revealControls = useCallback(() => {
setControlsVisible(true)
scheduleHide()
}, [scheduleHide])
useEffect(() => {
if (!playing) setControlsVisible(true)
return () => {
if (hideTimerRef.current) window.clearTimeout(hideTimerRef.current)
}
}, [playing])
// 切换源并自动播放
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))
// A new source has to prove itself playable again before autoplay is judged,
// and the old source's measurements must not be attributed to it.
setReady(false)
setMutedAttempt(false)
setSourceMeta('')
sourceMetaRef.current = { isSource: false }
sourceBitrateRef.current = 0
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 配置')
})
video.src = replayUrl
video.load()
void attemptAutoplay(video)
} else if (live) {
const line = lines.find(item => item.node_id === activeLine)
const line = lines.find((item) => item.node_id === activeLine)
// "自动" resolves to the rendition the owner's ladder picked. Each quality URL
// is a single-rendition media playlist, so this label is the only thing
// selecting a rendition — falling through to line.url would request a master
// playlist that the transcoder never writes (the line's own url is bare
// /hls/<key>/index.m3u8, which only MediaMTX itself could have served).
const label = activeQuality === 'auto' ? autoQuality : activeQuality
const quality = line?.qualities?.find(item => item.label === label)
const source = quality?.url || line?.url || line?.unicast_fallback
const quality = line?.qualities?.find((item) => item.label === label)
const source = quality?.url || line?.qualities?.[0]?.url || line?.url || line?.unicast_fallback
// The source rendition is the one advertised without dimensions, so its
// metadata is measured from the media rather than read off the API.
const sourceLabel = qualities.find((item) => !item.width || !item.height)?.label
// Every quality URL points at a single-rendition playlist, so no level list
// exists to name a level from the manifest; the selection decides it.
sourceMetaRef.current = { isSource: Boolean(sourceLabel) && label === sourceLabel }
const onFallbackLoaded = () => {
setLoading(false)
sourceBitrateRef.current = 0
measureSource()
}
if (!source) {
setLoading(false)
onError?.('当前没有可用的播放地址')
} else if (Hls.isSupported()) {
const hls = new Hls({ enableWorker: true, lowLatencyMode: false })
// Buffering is the thing to avoid; chase latency only after that. hls.js
// measures latency to its own target rather than to the segment edge, so
// anything set here (liveSyncDurationCount, rate) has to be interpreted in
// its terms or the player and the UI disagree about what "behind" means.
//
// Deliberately NOT enabled, all for the same reason — each pulls the target
// latency down to a single 4s segment, so playback races the encoder and
// re-buffers at the end of every segment:
// lowLatencyMode: true target drops to the last *partial* segment
// liveSyncDurationCount: 1 target = exactly one segment
// maxLiveSyncPlaybackRate speeds playback up to 1.5x to hold that
// backBufferLength: 0 nothing behind the playhead to survive a stall
// The default three-segment (~12s) cushion is what keeps playback smooth.
const hls = new Hls({
enableWorker: true,
// Keeps recent segments available locally, so a brief stall is served
// from the buffer instead of going back to the network.
backBufferLength: 30,
})
hlsRef.current = hls
hls.loadSource(source)
hls.attachMedia(video)
hls.on(Hls.Events.MANIFEST_PARSED, () => setLoading(false))
hls.on(Hls.Events.MANIFEST_PARSED, () => {
setLoading(false)
// For live, anchors playback to the end of the sliding window so a new
// viewer starts at the live edge instead of the oldest retained segment.
hls.startLoad(live ? -1 : 0)
void attemptAutoplay(video)
})
// A single-rendition playlist has exactly one level and no name, so the
// selection made above is what decides whether this is the source tier.
// Only refresh the measured bitrate/size here; do not re-answer that.
hls.on(Hls.Events.LEVEL_SWITCHED, (_event, data) => {
sourceBitrateRef.current = hls.levels?.[data.level]?.bitrate || 0
measureSource()
})
// The API is the authority on which rendition a label is; a manifest with no
// level names cannot override it. This only covers a fallback playlist whose
// rendition list we never received.
hls.on(Hls.Events.LEVEL_LOADED, (_event, data) => {
if (data.details.live && !qualities.length) onFallbackLoaded()
})
hls.on(Hls.Events.ERROR, (_event, data) => {
if (data.fatal) {
setLoading(false)
@@ -179,6 +417,13 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(function Vid
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = source
video.load()
void video
.play()
.then(onFallbackLoaded)
.catch(() => {
if (video.readyState >= 3) onFallbackLoaded()
else setNeedsGesture(true)
})
} else {
setLoading(false)
onError?.('当前浏览器不支持 HLS 播放')
@@ -186,22 +431,32 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(function Vid
}
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])
}, [activeLine, activeQuality, autoQuality, lines, live, onError, replayUrl, attemptAutoplay, measureSource])
const togglePlayback = () => {
const video = videoRef.current
if (!video) return
if (video.paused) void video.play().catch(() => onError?.('浏览器阻止了自动播放,请再次点击播放'))
else video.pause()
if (video.paused) {
setNeedsGesture(false)
void video.play().catch(() => setNeedsGesture(true))
return
}
// Live cannot be paused: there is no seekable timeline behind the playhead, so
// pausing just parks the viewer further and further behind a stream that keeps
// running. Resuming is still offered (autoplay may have been blocked), but
// nothing here ever stops a live stream.
if (!live) video.pause()
}
const toggleMute = () => {
const video = videoRef.current
if (!video) return
video.muted = !video.muted
if (!video.muted && video.volume === 0) video.volume = 1
}
const seek = (value: number) => {
@@ -214,65 +469,231 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(function Vid
if (!video) return
video.volume = value
video.muted = value === 0
if (value > 0) setMutedAttempt(false)
setVolume(value)
}
// 原画 是发布方原始流:分辨率与码率只有正在播放它时才测得到,所以先按"原始画质"占位。
const qualityOptions = qualities.map((quality) => {
const meta =
quality.width && quality.height
? `${quality.label} · ${quality.width}×${quality.height}`
: [quality.label, quality.label === activeQuality ? sourceMeta : ''].filter(Boolean).join(' · ')
return { value: quality.label, label: meta === quality.label ? `${quality.label} · 原始画质` : meta }
})
const autoLabel = qualityOptions.find((option) => option.value === autoQuality)?.label || autoQuality
const toggleFullscreen = async () => {
if (!rootRef.current) return
if (document.fullscreenElement) await document.exitFullscreen()
else await rootRef.current.requestFullscreen()
// Prefer the Fullscreen API on the player wrapper. It gives the true
// edge-to-edge experience and, unlike F11, also works on mobile browsers and
// when the page is embedded in an iframe (where F11's browser chrome is
// unavailable anyway). F11 remains available as the user's own shortcut.
const root = rootRef.current
if (!root) return
try {
if (document.fullscreenElement) {
await document.exitFullscreen()
} else if (typeof root.requestFullscreen === 'function') {
await root.requestFullscreen()
} else {
// iOS Safari has no element Fullscreen API; fall back to native video FS.
const video = videoRef.current as (HTMLVideoElement & { webkitEnterFullscreen?: () => void }) | null
video?.webkitEnterFullscreen?.()
}
} catch {
// Denied (e.g. permissions policy) — leave the current view as-is.
}
}
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
ref={rootRef}
onMouseMove={revealControls}
onMouseLeave={() => {
if (!videoRef.current?.paused) setControlsVisible(false)
}}
onTouchStart={revealControls}
className="group relative aspect-video w-full overflow-hidden rounded-lg bg-black [[:fullscreen]_&]:aspect-auto [[:fullscreen]_&]:h-screen [[:fullscreen]_&]:rounded-none [&:fullscreen]:aspect-auto [&:fullscreen]:h-screen [&:fullscreen]:w-screen [&:fullscreen]:rounded-none"
>
<video
ref={videoRef}
playsInline
preload="metadata"
// While the player is muted only because autoplay demanded it, a click on
// the picture means "give me the sound", not "pause". Handling it here
// rather than with a window-level listener keeps the two in a known order:
// the restore happens first, then this returns without toggling playback.
onClick={() => (mutedAttempt ? unmute() : togglePlayback())}
className="h-full w-full"
/>
{/* Persistent live badge — the stream state should be readable without
hovering, since "is this live?" is the first question a viewer asks. */}
{live && (
<div className="pointer-events-none absolute left-3 top-3 flex items-center gap-2">
<span className="flex items-center gap-1.5 rounded-full bg-black/60 px-2.5 py-1 text-xs font-semibold text-white backdrop-blur">
<span className="h-1.5 w-1.5 rounded-full bg-red-500" />
LIVE
</span>
</div>
<div className="custom-player-row">
<button type="button" onClick={togglePlayback} aria-label={playing ? '暂停' : '播放'}>
{playing ? <PauseOutlined /> : <PlayCircleOutlined />}
)}
{loading && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="h-10 w-10 animate-spin rounded-full border-2 border-white/30 border-t-white" />
</div>
)}
{/* Autoplay was refused: nothing plays until the viewer clicks, so this has
to be louder than the spinner. Sits under the control bar (z-10 vs z-20)
to keep the quality/line pickers reachable while it is up. */}
{needsGesture && ready && (
<button
type="button"
onClick={resolveGate}
className="absolute inset-0 z-10 flex cursor-pointer flex-col items-center justify-center gap-4 bg-black/70 text-white backdrop-blur-[2px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/70"
>
<span className="flex h-20 w-20 animate-pulse items-center justify-center rounded-full border-2 border-white/60 bg-white/15">
<Play className="h-9 w-9 translate-x-0.5 fill-current" />
</span>
<span className="text-xl font-semibold">{playing ? '已静音播放' : '点击播放'}</span>
<span className="max-w-xs text-sm text-white/70">
{mutedAttempt ? '点击开启声音' : '浏览器阻止了自动播放,请点击开始'}
</span>
</button>
)}
{/* Playing but only because we muted it: say so loudly, since a silent
stream reads as broken. Any interaction restores the sound too. */}
{mutedAttempt && !needsGesture && (
<button
type="button"
onClick={unmute}
className="absolute left-3 bottom-16 z-10 flex cursor-pointer items-center gap-2 rounded-full bg-black/70 px-3 py-1.5 text-xs font-medium text-white backdrop-blur transition-colors hover:bg-black/85"
>
<VolumeX className="h-4 w-4" />
已静音播放,点击开启声音
</button>
)}
{/* Controls */}
<div
className={
'absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/80 to-transparent p-3 transition-opacity duration-200 ' +
(controlsVisible ? 'opacity-100' : 'pointer-events-none opacity-0')
}
>
{/* VOD gets a scrubber; live gets a status strip — there is no seekable
timeline in a live stream, and rendering one implies you can rewind. */}
{!live && duration > 0 && (
<div className="relative mb-2 flex h-3 items-center">
<div className="absolute h-1 w-full rounded bg-white/20" />
<div
className="absolute h-1 rounded bg-white/40"
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={(e) => seek(Number(e.target.value))}
className="absolute h-3 w-full cursor-pointer appearance-none bg-transparent"
/>
{chapters.map((chapter, index) => (
<button
key={`${chapter.time}-${index}`}
className="absolute h-2 w-2 -translate-x-1/2 rounded-full bg-primary"
style={{ left: `${Math.min(100, (chapter.time / duration) * 100)}%` }}
title={chapter.title}
onClick={() => seek(chapter.time)}
/>
))}
</div>
)}
<div className="flex items-center gap-3 text-white">
{/* Play/pause mirrors the real state, so the icon is never a lie. A live
stream has nothing to pause, so once it is running this control has
no action left and is not rendered at all — showing either icon would
misrepresent something: a pause icon promises a pause that does not
happen, and a play icon makes a playing stream look stopped. It
reappears if playback stops, which is the one case it can still act. */}
{(!live || !playing) && (
<button type="button" onClick={togglePlayback} aria-label={playing ? '暂停' : '播放'}>
{loading ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : playing ? (
<Pause className="h-5 w-5" />
) : (
<Play className="h-5 w-5 fill-current" />
)}
</button>
)}
{live ? (
<span className="flex items-center gap-1.5 text-xs font-semibold">
<span className="h-1.5 w-1.5 rounded-full bg-red-500" />
LIVE
</span>
) : (
<span className="text-xs tabular-nums">
{formatTime(currentTime)} / {formatTime(duration)}
</span>
)}
<button type="button" onClick={toggleMute} aria-label={muted ? '取消静音' : '静音'}>
{muted ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
</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 className="flex items-center gap-2">
<input
aria-label="音量"
type="range"
min={0}
max={1}
step="0.05"
value={muted ? 0 : volume}
onChange={(e) => changeVolume(Number(e.target.value))}
className="h-1 w-16 cursor-pointer"
/>
</label>
<span className="custom-player-spacer" />
<span className="flex-1" />
{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
aria-label="播放线路"
value={activeLine}
onChange={(e) => onLineChange(e.target.value)}
className="rounded border border-white/30 bg-black/60 px-2 py-1 text-xs"
>
{lines.map((line) => (
<option key={line.node_id} value={line.node_id} className="bg-zinc-900">
{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>)}
{live && qualityOptions.length > 0 && (
<select
aria-label="清晰度"
value={activeQuality}
onChange={(e) => onQualityChange(e.target.value)}
className="rounded border border-white/30 bg-black/60 px-2 py-1 text-xs"
>
<option value="auto" className="bg-zinc-900">
自动{autoQuality ? ` (${autoLabel})` : ''}
</option>
{qualityOptions.map((option) => (
<option key={option.value} value={option.value} className="bg-zinc-900">
{option.label}
</option>
))}
</select>
)}
<button type="button" onClick={() => void toggleFullscreen()} aria-label={fullscreen ? '退出全屏' : '全屏'}>
{fullscreen ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
{fullscreen ? <Minimize className="h-5 w-5" /> : <Maximize className="h-5 w-5" />}
</button>
</div>
</div>