refactor: update useSegmentScheduler.ts
This commit is contained in:
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* 素材片段调度器 Hook
|
||||
* 控制原生 <video> 元素按时间线依次播放素材片段(入点→出点)
|
||||
* 实现前端预览播放,替代后端 FFmpeg 渲染
|
||||
*/
|
||||
import { useState, useRef, useCallback, useEffect, useMemo } from "react"
|
||||
|
||||
/** 单个播放片段 */
|
||||
export interface PlaybackSegment {
|
||||
/** 素材 ID */
|
||||
assetId: string
|
||||
/** 素材视频 URL */
|
||||
videoUrl: string
|
||||
/** 片段在素材中的入点(秒) */
|
||||
startTime: number
|
||||
/** 片段在素材中的出点(秒) */
|
||||
endTime: number
|
||||
/** 片段在时间线中的顺序 */
|
||||
order: number
|
||||
}
|
||||
|
||||
/** 调度器返回 */
|
||||
export interface SegmentSchedulerState {
|
||||
/** 是否正在播放 */
|
||||
isPlaying: boolean
|
||||
/** 当前播放的全局时间(秒) */
|
||||
currentTime: number
|
||||
/** 总时长(秒) */
|
||||
totalDuration: number
|
||||
/** 当前片段索引(在 segments 数组中的位置) */
|
||||
currentSegmentIndex: number
|
||||
/** 当前片段的本地播放时间 */
|
||||
segmentLocalTime: number
|
||||
/** 是否已播完 */
|
||||
isEnded: boolean
|
||||
/** 是否可以播放(至少有 1 个片段) */
|
||||
canPlay: boolean
|
||||
/** 播放 */
|
||||
play: () => void
|
||||
/** 暂停 */
|
||||
pause: () => void
|
||||
/** 切换播放/暂停 */
|
||||
togglePlayPause: () => void
|
||||
/** 跳转到全局时间 */
|
||||
seekTo: (time: number) => void
|
||||
/** 绑定到 <video> 元素 */
|
||||
videoRef: React.RefObject<HTMLVideoElement | null>
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据全局时间定位对应的片段和本地时间
|
||||
*/
|
||||
function findSegmentAtTime(
|
||||
segments: PlaybackSegment[],
|
||||
globalTime: number,
|
||||
): { index: number; localTime: number } {
|
||||
let accumulated = 0
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const seg = segments[i]
|
||||
const segDuration = seg.endTime - seg.startTime
|
||||
if (globalTime < accumulated + segDuration || i === segments.length - 1) {
|
||||
return { index: i, localTime: seg.startTime + (globalTime - accumulated) }
|
||||
}
|
||||
accumulated += segDuration
|
||||
}
|
||||
return { index: segments.length - 1, localTime: segments[segments.length - 1].endTime }
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算每个片段的全局起始时间
|
||||
*/
|
||||
function buildTimeline(segments: PlaybackSegment[]): number[] {
|
||||
const starts: number[] = []
|
||||
let acc = 0
|
||||
for (const seg of segments) {
|
||||
starts.push(acc)
|
||||
acc += seg.endTime - seg.startTime
|
||||
}
|
||||
return starts
|
||||
}
|
||||
|
||||
/**
|
||||
* useSegmentScheduler — 素材片段调度器
|
||||
*/
|
||||
export function useSegmentScheduler(
|
||||
segments: PlaybackSegment[],
|
||||
): SegmentSchedulerState {
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null)
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
const [currentSegmentIndex, setCurrentSegmentIndex] = useState(0)
|
||||
const [isEnded, setIsEnded] = useState(false)
|
||||
const rafRef = useRef<number>(0)
|
||||
const isSeekingRef = useRef(false)
|
||||
|
||||
// 预加载用的隐藏 video 元素
|
||||
const preloadVideoRef = useRef<HTMLVideoElement | null>(null)
|
||||
|
||||
// 计算时间线
|
||||
const timelineStarts = useMemo(() => buildTimeline(segments), [segments])
|
||||
const totalDuration = useMemo(
|
||||
() => segments.reduce((sum, seg) => sum + (seg.endTime - seg.startTime), 0),
|
||||
[segments],
|
||||
)
|
||||
|
||||
const canPlay = segments.length > 0
|
||||
|
||||
// 当前片段信息
|
||||
const currentSegment = segments[currentSegmentIndex] || null
|
||||
const segmentLocalTime = currentSegment
|
||||
? currentTime - (timelineStarts[currentSegmentIndex] || 0) + currentSegment.startTime
|
||||
: 0
|
||||
|
||||
/** 切换到指定片段 */
|
||||
const switchToSegment = useCallback(
|
||||
(index: number, seekToLocalTime?: number) => {
|
||||
const video = videoRef.current
|
||||
if (!video || index >= segments.length) return
|
||||
|
||||
const seg = segments[index]
|
||||
video.src = seg.videoUrl
|
||||
|
||||
const localTime = seekToLocalTime ?? seg.startTime
|
||||
// 等待 src 设置后再设置 currentTime
|
||||
const onLoaded = () => {
|
||||
video.currentTime = localTime
|
||||
video.removeEventListener("loadedmetadata", onLoaded)
|
||||
}
|
||||
video.addEventListener("loadedmetadata", onLoaded)
|
||||
|
||||
setCurrentSegmentIndex(index)
|
||||
|
||||
// 预加载下一段
|
||||
if (index + 1 < segments.length) {
|
||||
const nextSeg = segments[index + 1]
|
||||
if (!preloadVideoRef.current) {
|
||||
preloadVideoRef.current = document.createElement("video")
|
||||
preloadVideoRef.current.preload = "auto"
|
||||
}
|
||||
preloadVideoRef.current.src = nextSeg.videoUrl
|
||||
}
|
||||
},
|
||||
[segments],
|
||||
)
|
||||
|
||||
/** 播放循环 — 检测片段边界并切换 */
|
||||
const tick = useCallback(() => {
|
||||
const video = videoRef.current
|
||||
if (!video || isSeekingRef.current) {
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
return
|
||||
}
|
||||
|
||||
const seg = segments[currentSegmentIndex]
|
||||
if (!seg) return
|
||||
|
||||
// 检查是否到达出点(容差 0.15s)
|
||||
if (video.currentTime >= seg.endTime - 0.15) {
|
||||
const nextIndex = currentSegmentIndex + 1
|
||||
if (nextIndex < segments.length) {
|
||||
// 切到下一段
|
||||
switchToSegment(nextIndex)
|
||||
const accumulatedTime =
|
||||
(timelineStarts[currentSegmentIndex] || 0) + (seg.endTime - seg.startTime)
|
||||
setCurrentTime(accumulatedTime)
|
||||
} else {
|
||||
// 播放结束
|
||||
video.pause()
|
||||
setIsPlaying(false)
|
||||
setIsEnded(true)
|
||||
setCurrentTime(totalDuration)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// 更新全局时间
|
||||
const globalTime =
|
||||
(timelineStarts[currentSegmentIndex] || 0) + (video.currentTime - seg.startTime)
|
||||
setCurrentTime(Math.max(0, Math.min(globalTime, totalDuration)))
|
||||
}
|
||||
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
}, [segments, currentSegmentIndex, timelineStarts, totalDuration, switchToSegment])
|
||||
|
||||
/** 播放 */
|
||||
const play = useCallback(async () => {
|
||||
const video = videoRef.current
|
||||
if (!video || !canPlay) return
|
||||
|
||||
setIsEnded(false)
|
||||
|
||||
// 如果还没设置 src(首次播放),先加载第一段
|
||||
if (!video.src || video.src === "") {
|
||||
switchToSegment(0)
|
||||
}
|
||||
|
||||
try {
|
||||
await video.play()
|
||||
setIsPlaying(true)
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
} catch {
|
||||
// 自动播放可能被浏览器阻止,忽略
|
||||
console.warn("[useSegmentScheduler] auto-play blocked by browser")
|
||||
}
|
||||
}, [canPlay, switchToSegment, tick])
|
||||
|
||||
/** 暂停 */
|
||||
const pause = useCallback(() => {
|
||||
const video = videoRef.current
|
||||
if (video) video.pause()
|
||||
setIsPlaying(false)
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
}, [])
|
||||
|
||||
/** 切换播放/暂停 */
|
||||
const togglePlayPause = useCallback(() => {
|
||||
if (isPlaying) {
|
||||
pause()
|
||||
} else {
|
||||
if (isEnded) {
|
||||
// 播放结束后再次播放,从头开始
|
||||
setIsEnded(false)
|
||||
switchToSegment(0, segments[0]?.startTime)
|
||||
const video = videoRef.current
|
||||
if (video) {
|
||||
const onSeeked = () => {
|
||||
video.play().catch(() => {})
|
||||
setIsPlaying(true)
|
||||
setCurrentTime(0)
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
video.removeEventListener("seeked", onSeeked)
|
||||
}
|
||||
video.addEventListener("seeked", onSeeked)
|
||||
}
|
||||
} else {
|
||||
play()
|
||||
}
|
||||
}
|
||||
}, [isPlaying, isEnded, pause, play, switchToSegment, segments, tick])
|
||||
|
||||
/** 跳转到指定全局时间 */
|
||||
const seekTo = useCallback(
|
||||
(time: number) => {
|
||||
const video = videoRef.current
|
||||
if (!video || !canPlay) return
|
||||
|
||||
const clampedTime = Math.max(0, Math.min(time, totalDuration))
|
||||
const { index, localTime } = findSegmentAtTime(segments, clampedTime)
|
||||
|
||||
isSeekingRef.current = true
|
||||
|
||||
// 如果片段变了,需要切换 src
|
||||
if (index !== currentSegmentIndex) {
|
||||
switchToSegment(index, localTime)
|
||||
// switchToSegment 会设置 src 并在 loadedmetadata 后设置 currentTime
|
||||
// 所以这里不需要再设置
|
||||
} else {
|
||||
video.currentTime = localTime
|
||||
}
|
||||
|
||||
setCurrentTime(clampedTime)
|
||||
setCurrentSegmentIndex(index)
|
||||
setIsEnded(false)
|
||||
|
||||
// 延迟恢复 tick 检测
|
||||
setTimeout(() => {
|
||||
isSeekingRef.current = false
|
||||
}, 200)
|
||||
},
|
||||
[canPlay, totalDuration, segments, currentSegmentIndex, switchToSegment],
|
||||
)
|
||||
|
||||
// 组件卸载时清理
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
if (preloadVideoRef.current) {
|
||||
preloadVideoRef.current.src = ""
|
||||
preloadVideoRef.current = null
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 片段列表变化时重置
|
||||
useEffect(() => {
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
setIsPlaying(false)
|
||||
setCurrentTime(0)
|
||||
setCurrentSegmentIndex(0)
|
||||
setIsEnded(false)
|
||||
const video = videoRef.current
|
||||
if (video) {
|
||||
video.pause()
|
||||
video.src = ""
|
||||
}
|
||||
}, [segments])
|
||||
|
||||
return {
|
||||
isPlaying,
|
||||
currentTime,
|
||||
totalDuration,
|
||||
currentSegmentIndex,
|
||||
segmentLocalTime,
|
||||
isEnded,
|
||||
canPlay,
|
||||
play,
|
||||
pause,
|
||||
togglePlayPause,
|
||||
seekTo,
|
||||
videoRef,
|
||||
}
|
||||
}
|
||||
|
||||
export default useSegmentScheduler
|
||||
Reference in New Issue
Block a user