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:
1 parent
186e32caf1
commit
996e3fea39
70 files changed
+4844
-3070
No files matched your search
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user