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 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(function VideoPlayer( { live, replayUrl, lines, qualities, activeLine, activeQuality, autoQuality, chapters = [], onLineChange, onQualityChange, onError, }, ref, ) { const rootRef = useRef(null) const videoRef = useRef(null) const hlsRef = useRef(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(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//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 (
{ 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" >