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]>
705 lines
28 KiB
TypeScript
705 lines
28 KiB
TypeScript
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
|