feat: improve replay workflow and viewer UI
Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
28 files changed
+1496
-227
No files matched your search
@@ -0,0 +1,283 @@
|
||||
import { forwardRef, 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'
|
||||
|
||||
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')}`
|
||||
}
|
||||
|
||||
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 [fullscreen, setFullscreen] = useState(false)
|
||||
|
||||
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
|
||||
},
|
||||
}), [])
|
||||
|
||||
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)
|
||||
|
||||
video.addEventListener('timeupdate', update)
|
||||
video.addEventListener('durationchange', update)
|
||||
video.addEventListener('progress', update)
|
||||
video.addEventListener('play', onPlay)
|
||||
video.addEventListener('pause', onPause)
|
||||
video.addEventListener('waiting', onWaiting)
|
||||
video.addEventListener('playing', onPlaying)
|
||||
return () => {
|
||||
video.removeEventListener('timeupdate', update)
|
||||
video.removeEventListener('durationchange', update)
|
||||
video.removeEventListener('progress', update)
|
||||
video.removeEventListener('play', onPlay)
|
||||
video.removeEventListener('pause', onPause)
|
||||
video.removeEventListener('waiting', onWaiting)
|
||||
video.removeEventListener('playing', onPlaying)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const listener = () => setFullscreen(document.fullscreenElement === rootRef.current)
|
||||
document.addEventListener('fullscreenchange', listener)
|
||||
return () => document.removeEventListener('fullscreenchange', listener)
|
||||
}, [])
|
||||
|
||||
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))
|
||||
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 配置')
|
||||
})
|
||||
} else if (live) {
|
||||
const line = lines.find(item => item.node_id === activeLine)
|
||||
const label = activeQuality === 'auto' ? autoQuality : activeQuality
|
||||
const quality = line?.qualities?.find(item => item.label === label)
|
||||
const source = quality?.url || line?.url || line?.unicast_fallback
|
||||
if (!source) {
|
||||
setLoading(false)
|
||||
onError?.('当前没有可用的播放地址')
|
||||
} else if (Hls.isSupported()) {
|
||||
const hls = new Hls({ enableWorker: true, lowLatencyMode: false })
|
||||
hlsRef.current = hls
|
||||
hls.loadSource(source)
|
||||
hls.attachMedia(video)
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => setLoading(false))
|
||||
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()
|
||||
} else {
|
||||
setLoading(false)
|
||||
onError?.('当前浏览器不支持 HLS 播放')
|
||||
}
|
||||
}
|
||||
|
||||
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])
|
||||
|
||||
const togglePlayback = () => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
if (video.paused) void video.play().catch(() => onError?.('浏览器阻止了自动播放,请再次点击播放'))
|
||||
else video.pause()
|
||||
}
|
||||
|
||||
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
|
||||
setVolume(value)
|
||||
}
|
||||
|
||||
const toggleFullscreen = async () => {
|
||||
if (!rootRef.current) return
|
||||
if (document.fullscreenElement) await document.exitFullscreen()
|
||||
else await rootRef.current.requestFullscreen()
|
||||
}
|
||||
|
||||
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>
|
||||
<div className="custom-player-row">
|
||||
<button type="button" onClick={togglePlayback} aria-label={playing ? '暂停' : '播放'}>
|
||||
{playing ? <PauseOutlined /> : <PlayCircleOutlined />}
|
||||
</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>
|
||||
<span className="custom-player-spacer" />
|
||||
{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>
|
||||
)}
|
||||
{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>)}
|
||||
</select>
|
||||
)}
|
||||
<button type="button" onClick={() => void toggleFullscreen()} aria-label={fullscreen ? '退出全屏' : '全屏'}>
|
||||
{fullscreen ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export default VideoPlayer
|
||||
Reference in new issue
Block a user