Files
live/frontend/src/viewer/components/VideoPlayer.tsx
T
adminandClaude Opus 4.8 996e3fea39 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]>
2026-09-22 18:07:56 +08:00

705 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'
import Hls from 'hls.js'
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
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')}`
}
/** "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 [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)
// 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
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)
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)
}
}, [])
// 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()
hlsRef.current?.destroy()
hlsRef.current = null
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) {
video.src = replayUrl
video.load()
void attemptAutoplay(video)
} else if (live) {
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?.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()) {
// 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)
// 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)
onError?.('播放流加载失败,请切换线路或稍后重试')
}
})
} 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 播放')
}
}
return () => {
controller.abort()
hlsRef.current?.destroy()
hlsRef.current = null
}
}, [activeLine, activeQuality, autoQuality, lines, live, onError, replayUrl, attemptAutoplay, measureSource])
const togglePlayback = () => {
const video = videoRef.current
if (!video) return
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) => {
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
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 () => {
// 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}
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>
)}
{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>
<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="flex-1" />
{live && lines.length > 1 && (
<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 && 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 ? <Minimize className="h-5 w-5" /> : <Maximize className="h-5 w-5" />}
</button>
</div>
</div>
</div>
)
})
export default VideoPlayer