feat: Canvas + WebCodecs 预览播放器核心实现 #1436

Merged
auto-approve-bot merged 1 commits from feat/canvas-webcodecs-player into develop 2026-08-19 12:01:04 +08:00
4 changed files with 637 additions and 96 deletions
+10
View File
@@ -12,6 +12,7 @@
"@tanstack/react-query": "^5.45.0",
"antd": "^5.18.0",
"axios": "^1.7.2",
"mp4box": "^2.4.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.24.0",
@@ -4623,6 +4624,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/mp4box": {
"version": "2.4.1",
"resolved": "https://registry.npmmirror.com/mp4box/-/mp4box-2.4.1.tgz",
"integrity": "sha512-0HGX7nXoDIX6FKLVl4a3wtYjBlwqsN3xuQC3GXzNtKp98FXUOhDSq623azsz8DG5ptd9ZXcXodDkgbdMZOjWvw==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=20.8.1"
}
},
"node_modules/mrmime": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
+1
View File
@@ -23,6 +23,7 @@
"@tanstack/react-query": "^5.45.0",
"antd": "^5.18.0",
"axios": "^1.7.2",
"mp4box": "^2.4.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.24.0",
@@ -1,31 +1,43 @@
/**
* video
* <video>
* display: none/block
* Canvas + WebCodecs
*
* .xx-preview-video PreviewVideoPanel
* .xx-preview-video CSS
*
* - WebCodecs Canvas +
* - fallback video
*
* API assets, template, videoRatio, ready, voiceAudioUrl
*/
import React, { useMemo, useCallback, useState, useRef, useEffect } from "react"
import { PlayCircleOutlined, PauseCircleOutlined, SoundOutlined } from "@ant-design/icons"
import {
PlayCircleOutlined,
PauseCircleOutlined,
SoundOutlined,
LoadingOutlined,
} from "@ant-design/icons"
import type { AssetItem } from "@/api/assets"
import type { EditingTemplate } from "@/api/editing-planner"
import { useSegmentScheduler, type PlaybackSegment } from "../hooks/useSegmentScheduler"
import { useCanvasPlayer, isWebCodecsSupported } from "../hooks/useCanvasPlayer"
interface FrontendPreviewPlayerProps {
/** 选中的素材列表 */
assets: AssetItem[]
/** 当前模板(用于获取片段时长配置) */
template: EditingTemplate | null
/** 视频比例 */
videoRatio: string
/** 是否准备好播放(素材已加载) */
ready: boolean
/** 配音音频 URL */
voiceAudioUrl?: string
titleSettings?: {
title: string
size: number
font: string
color: string
position: "top" | "center" | "bottom"
bold?: boolean
italic?: boolean
stroke?: boolean
shadow?: boolean
}
}
/** 格式化时间 mm:ss */
function formatTime(seconds: number): string {
const m = Math.floor(seconds / 60)
const s = Math.floor(seconds % 60)
@@ -33,8 +45,7 @@ function formatTime(seconds: number): string {
}
/**
*
*
*
*/
function buildPlaybackSegments(
assets: AssetItem[],
@@ -54,20 +65,9 @@ function buildPlaybackSegments(
const startTime = 0
const endTime = Math.min(startTime + segDuration, assetDuration)
const videoUrl = asset.file_url || asset.storage_key
console.log(
`[buildPlaybackSegments] 片段 ${i}: assetId=${asset.id}, videoUrl=${videoUrl?.substring(0, 80)}, file_url=${!!asset.file_url}`,
)
segments.push({
assetId: asset.id,
videoUrl,
startTime,
endTime,
order: i,
})
segments.push({ assetId: asset.id, videoUrl, startTime, endTime, order: i })
})
return segments
@@ -79,28 +79,70 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
videoRatio: _videoRatio,
ready,
voiceAudioUrl,
titleSettings,
}) => {
const segments = useMemo(() => buildPlaybackSegments(assets, template), [assets, template])
const useWebCodecs = isWebCodecsSupported()
// ── 两条路径共用同一个 canvas ref(fallback 路径不使用) ──
const canvasRef = useRef<HTMLCanvasElement>(null)
// ── Canvas 播放器(WebCodecs 路径) ──
const canvasTitle = titleSettings
? {
text: titleSettings.title || "标题预览",
fontSize: titleSettings.size,
fontFamily: titleSettings.font || "思源黑体",
color: titleSettings.color || "#ffffff",
position: titleSettings.position || "bottom",
bold: titleSettings.bold,
stroke: titleSettings.stroke,
shadow: titleSettings.shadow,
}
: undefined
const canvasSegments = useMemo(
() =>
segments.map((s) => ({
assetId: s.assetId,
videoUrl: s.videoUrl,
startTime: s.startTime,
endTime: s.endTime,
})),
[segments],
)
const { state: canvasState, controls: canvasControls } = useCanvasPlayer(
canvasRef,
canvasSegments,
useWebCodecs ? canvasTitle : undefined,
)
// ── Video 播放器(fallback 路径) ──
const {
isPlaying,
currentTime,
totalDuration,
currentSegmentIndex,
canPlay,
togglePlayPause,
seekTo,
isPlaying: videoIsPlaying,
currentTime: videoCurrentTime,
totalDuration: videoTotalDuration,
currentSegmentIndex: videoCurrentSegIdx,
canPlay: videoCanPlay,
togglePlayPause: videoTogglePlayPause,
seekTo: videoSeekTo,
videoRefs,
} = useSegmentScheduler(segments)
// 选择哪条路径的状态
const isPlaying = useWebCodecs ? canvasState.isPlaying : videoIsPlaying
const currentTime = useWebCodecs ? canvasState.currentTime : videoCurrentTime
const totalDuration = useWebCodecs ? canvasState.duration : videoTotalDuration
const canPlay = useWebCodecs ? canvasState.isReady : videoCanPlay
const isBuffering = useWebCodecs ? canvasState.isBuffering : false
// ── 配音音频同步 ──
const audioRef = useRef<HTMLAudioElement | null>(null)
const prevIsPlayingRef = useRef(false)
// 创建/更新 Audio 元素
useEffect(() => {
if (!voiceAudioUrl) {
// 没有配音,清理已有 audio
if (audioRef.current) {
audioRef.current.pause()
audioRef.current.src = ""
@@ -108,7 +150,6 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
}
return
}
if (!audioRef.current) {
audioRef.current = new Audio()
audioRef.current.preload = "auto"
@@ -118,59 +159,52 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
}
}, [voiceAudioUrl])
// 同步播放状态
useEffect(() => {
const audio = audioRef.current
if (!audio || !audio.src) return
if (isPlaying && !prevIsPlayingRef.current) {
// 刚进入播放
audio.currentTime = currentTime
audio.play().catch(() => {})
} else if (!isPlaying && prevIsPlayingRef.current) {
// 刚暂停
audio.pause()
}
prevIsPlayingRef.current = isPlaying
}, [isPlaying, currentTime])
// 片段切换时同步音频 — 将 audio.currentTime 对齐到视频全局时间
// 片段切换时同步音频(仅 fallback 路径需要)
const segmentSyncKey = useWebCodecs ? -1 : videoCurrentSegIdx
useEffect(() => {
const audio = audioRef.current
if (!audio || !audio.src || !isPlaying) return
// 用视频当前的全局时间对齐音频
audio.currentTime = currentTime
}, [currentSegmentIndex, currentTime, isPlaying])
}, [segmentSyncKey, isPlaying, currentTime])
// seek 时同步音频
const handleSeekTo = useCallback(
(time: number) => {
seekTo(time)
if (useWebCodecs) {
canvasControls.seek(time)
} else {
videoSeekTo(time)
}
const audio = audioRef.current
if (audio && audio.src) {
audio.currentTime = time
}
},
[seekTo],
[useWebCodecs, canvasControls, videoSeekTo],
)
// 播放结束时暂停音频
useEffect(() => {
if (!isPlaying) {
const audio = audioRef.current
if (audio) audio.pause()
}
}, [isPlaying])
// 清理
useEffect(() => {
return () => {
if (audioRef.current) {
audioRef.current.pause()
audioRef.current.src = ""
const handleTogglePlay = useCallback(() => {
if (useWebCodecs) {
if (canvasState.isPlaying) {
canvasControls.pause()
} else {
canvasControls.play()
}
} else {
videoTogglePlayPause()
}
}, [])
}, [useWebCodecs, canvasState.isPlaying, canvasControls, videoTogglePlayPause])
// ── 进度条拖拽 ──
const [isDragging, setIsDragging] = useState(false)
@@ -213,7 +247,26 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
const progressPercent = totalDuration > 0 ? (currentTime / totalDuration) * 100 : 0
// ── 未就绪状态 ──
// ── Canvas ResizeObserver ──
const canvasContainerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const container = canvasContainerRef.current
const canvas = canvasRef.current
if (!container || !canvas) return
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width, height } = entry.contentRect
if (width > 0 && height > 0) {
canvas.width = width * window.devicePixelRatio
canvas.height = height * window.devicePixelRatio
}
}
})
ro.observe(container)
return () => ro.disconnect()
}, [])
// ── 未就绪 ──
if (!ready || !assets.length) {
return (
<div
@@ -250,48 +303,79 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
zIndex: 1,
}}
>
<PlayCircleOutlined
style={{ fontSize: 48, color: "var(--text-tertiary)", marginBottom: 12 }}
/>
<p className="xx-preview-empty-title"></p>
<p className="xx-preview-empty-desc"></p>
{isBuffering ? (
<>
<LoadingOutlined style={{ fontSize: 48, color: "#fff", marginBottom: 12 }} spin />
<p style={{ color: "rgba(255,255,255,0.8)" }}>...</p>
</>
) : (
<>
<PlayCircleOutlined
style={{ fontSize: 48, color: "var(--text-tertiary)", marginBottom: 12 }}
/>
<p className="xx-preview-empty-title"></p>
<p className="xx-preview-empty-desc"></p>
</>
)}
</div>
)
}
return (
<>
{/* video video
display load */}
{segments.map((seg, i) => (
<video
key={seg.assetId}
muted
ref={(el) => {
videoRefs.current[i] = el
}}
preload="auto"
src={seg.videoUrl}
{/* ── Canvas 渲染层(WebCodecs 路径) ── */}
{useWebCodecs && (
<div
ref={canvasContainerRef}
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
objectFit: "contain",
background: "#000",
zIndex: 1,
opacity: i === currentSegmentIndex ? 1 : 0,
pointerEvents: i === currentSegmentIndex ? "auto" : "none",
background: "#000",
}}
playsInline
/>
))}
>
<canvas
ref={canvasRef}
style={{
width: "100%",
height: "100%",
objectFit: "contain",
}}
/>
</div>
)}
{/* 播放按钮覆盖层 */}
{/* ── Video 渲染层(fallback 路径) ── */}
{!useWebCodecs &&
segments.map((seg, i) => (
<video
key={seg.assetId}
muted
ref={(el) => {
videoRefs.current[i] = el
}}
preload="auto"
src={seg.videoUrl}
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
objectFit: "contain",
background: "#000",
zIndex: 1,
opacity: i === videoCurrentSegIdx ? 1 : 0,
pointerEvents: i === videoCurrentSegIdx ? "auto" : "none",
}}
playsInline
/>
))}
{/* 播放按钮 */}
{!isPlaying && (
<button
className="xx-preview-play-btn"
onClick={togglePlayPause}
onClick={handleTogglePlay}
style={{
position: "absolute",
top: "50%",
@@ -309,7 +393,6 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
color: "#fff",
fontSize: 28,
zIndex: 10,
transition: "opacity 0.2s",
}}
>
<PlayCircleOutlined />
@@ -330,10 +413,10 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
zIndex: 10,
}}
>
{currentSegmentIndex + 1}/{segments.length}
{useWebCodecs ? "Canvas" : `片段 ${videoCurrentSegIdx + 1}/${segments.length}`}
</div>
{/* 控制条 — 绝对定位在底部 */}
{/* 控制条 */}
<div
className="xx-preview-controls"
style={{
@@ -349,9 +432,8 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
zIndex: 10,
}}
>
{/* 播放/暂停 */}
<button
onClick={togglePlayPause}
onClick={handleTogglePlay}
style={{
background: "none",
border: "none",
@@ -366,7 +448,6 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
</button>
{/* 时间 */}
<span
style={{
fontSize: 12,
@@ -378,7 +459,6 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
{formatTime(currentTime)} / {formatTime(totalDuration)}
</span>
{/* 进度条 */}
<div
ref={progressRef}
onMouseDown={handleMouseDown}
@@ -0,0 +1,450 @@
/**
* Canvas + WebCodecs Hook
* MP4 mp4box.js VideoDecoder Canvas
* Web Audio API
*
* WebCodecs hasSupport=false fallback
*/
import { useRef, useCallback, useEffect, useState } from "react"
import { createFile } from "mp4box"
import type { Movie, Sample } from "mp4box"
// ── 帧队列(环形缓冲区) ──
interface FrameEntry {
frame: VideoFrame
pts: number // 显示时间戳(秒)
duration: number // 帧持续时长(秒)
}
class FrameQueue {
private frames: FrameEntry[] = []
private maxSize: number
constructor(maxSize = 5) {
this.maxSize = maxSize
}
push(entry: FrameEntry) {
// 如果队列已满,丢弃最旧的帧
while (this.frames.length >= this.maxSize) {
const old = this.frames.shift()
old?.frame.close()
}
this.frames.push(entry)
}
/** 获取当前时间戳应显示的帧 */
getCurrentFrame(timestamp: number): VideoFrame | null {
// 找到 pts <= timestamp 的最新帧
let best: FrameEntry | null = null
let bestIdx = -1
for (let i = 0; i < this.frames.length; i++) {
const f = this.frames[i]
if (f.pts <= timestamp + 0.01) {
// 10ms 容差
best = f
bestIdx = i
}
}
// 释放已消费帧之前的所有帧
for (let i = 0; i < bestIdx; i++) {
this.frames[i].frame.close()
}
if (bestIdx >= 0) {
this.frames = this.frames.slice(bestIdx)
}
return best?.frame ?? null
}
clear() {
for (const f of this.frames) {
f.frame.close()
}
this.frames = []
}
get size() {
return this.frames.length
}
}
// ── 播放器状态 ──
export interface CanvasPlayerState {
/** 是否支持 WebCodecs */
hasSupport: boolean
/** 是否正在播放 */
isPlaying: boolean
/** 当前播放时间(秒) */
currentTime: number
/** 总时长(秒) */
duration: number
/** 是否已加载(可以播放) */
isReady: boolean
/** 是否正在缓冲 */
isBuffering: boolean
}
export interface CanvasPlayerControls {
play: () => void
pause: () => void
seek: (time: number) => void
destroy: () => void
}
interface SegmentSource {
assetId: string
videoUrl: string
startTime: number
endTime: number
}
/** 检测浏览器是否支持 WebCodecs VideoDecoder */
export function isWebCodecsSupported(): boolean {
return typeof window !== "undefined" && "VideoDecoder" in window && "VideoFrame" in window
}
/**
* useCanvasPlayer Canvas + WebCodecs
*/
export function useCanvasPlayer(
canvasRef: React.RefObject<HTMLCanvasElement | null>,
segments: SegmentSource[],
titleSettings?: {
text: string
fontSize: number
fontFamily: string
color: string
position: "top" | "center" | "bottom"
bold?: boolean
stroke?: boolean
shadow?: boolean
},
) {
const [state, setState] = useState<CanvasPlayerState>({
hasSupport: isWebCodecsSupported(),
isPlaying: false,
currentTime: 0,
duration: 0,
isReady: false,
isBuffering: false,
})
// ── 内部引用 ──
const decoderRef = useRef<VideoDecoder | null>(null)
const frameQueueRef = useRef(new FrameQueue(5))
const audioCtxRef = useRef<AudioContext | null>(null)
const audioSourceRef = useRef<AudioBufferSourceNode | null>(null)
const rafRef = useRef<number>(0)
const playStartRef = useRef<number>(0)
const playStartOffsetRef = useRef<number>(0)
const segmentDataRef = useRef<Map<string, ArrayBuffer>>(new Map())
const videoTrackRef = useRef<{ id: number; timescale: number; codec: string } | null>(null)
const isDestroyedRef = useRef(false)
// 计算总时长
const totalDuration = segments.reduce((sum, seg) => sum + (seg.endTime - seg.startTime), 0)
// ── 加载 MP4 文件数据 ──
const loadSegment = useCallback(async (segment: SegmentSource): Promise<void> => {
if (isDestroyedRef.current) return
if (segmentDataRef.current.has(segment.assetId)) return
setState((s) => ({ ...s, isBuffering: true }))
try {
const resp = await fetch(segment.videoUrl)
const buffer = await resp.arrayBuffer()
segmentDataRef.current.set(segment.assetId, buffer)
} catch (err) {
console.error("[useCanvasPlayer] Failed to fetch segment:", err)
} finally {
setState((s) => ({ ...s, isBuffering: false }))
}
}, [])
// ── 初始化 VideoDecoder ──
const initDecoder = useCallback(async (codec: string, width: number, height: number) => {
if (!isWebCodecsSupported()) return false
const decoder = new VideoDecoder({
output: (frame: VideoFrame) => {
frameQueueRef.current.push({
frame,
pts: frame.timestamp / 1_000_000,
duration: (frame.duration ?? 0) / 1_000_000,
})
},
error: (e: DOMException) => {
console.error("[useCanvasPlayer] Decoder error:", e)
},
})
try {
await decoder.configure({
codec,
codedWidth: width,
codedHeight: height,
})
decoderRef.current = decoder
return true
} catch (err) {
console.error("[useCanvasPlayer] Decoder configure failed:", err)
return false
}
}, [])
// ── 解封装 MP4 并送入解码器 ──
const demuxAndDecode = useCallback(
async (buffer: ArrayBuffer) => {
const mp4File = createFile()
mp4File.onReady = (info: Movie) => {
const videoTrack = info.videoTracks[0]
if (!videoTrack) {
console.error("[useCanvasPlayer] No video track found")
return
}
videoTrackRef.current = {
id: videoTrack.id,
timescale: videoTrack.timescale,
codec: videoTrack.codec,
}
const width = videoTrack.track_width || 1280
const height = videoTrack.track_height || 720
initDecoder(videoTrack.codec, width, height)
mp4File.setExtractionOptions(videoTrack.id, null, {
nbSamples: videoTrack.nb_samples,
})
mp4File.start()
}
mp4File.onSamples = (trackId: number, _user: unknown, samples: Sample[]) => {
if (isDestroyedRef.current) return
const videoTrack = videoTrackRef.current
if (!videoTrack || trackId !== videoTrack.id) return
const decoder = decoderRef.current
if (!decoder || decoder.state === "closed") return
for (const sample of samples) {
if (!sample.data) continue
const chunk = new EncodedVideoChunk({
type: sample.is_sync ? "key" : "delta",
timestamp: (sample.cts / videoTrack.timescale) * 1_000_000,
duration: (sample.duration / videoTrack.timescale) * 1_000_000,
data: sample.data.buffer as ArrayBuffer,
})
try {
decoder.decode(chunk)
} catch (e) {
console.warn("[useCanvasPlayer] Decode chunk error:", e)
}
}
}
mp4File.onError = (_module: string, message: string) => {
console.error(`[useCanvasPlayer] MP4Box error: ${message}`)
}
// mp4box 需要在 buffer 上设置 fileStart 属性
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(buffer as any).fileStart = 0
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mp4File.appendBuffer(buffer as any)
},
[initDecoder],
)
// ── 标题绘制 ──
const drawTitle = useCallback(
(
ctx: CanvasRenderingContext2D,
canvas: HTMLCanvasElement,
title: NonNullable<typeof titleSettings>,
) => {
const fontSize = (title.fontSize / 720) * canvas.height
ctx.font = `${title.bold ? "bold" : "normal"} ${fontSize}px ${title.fontFamily}`
ctx.fillStyle = title.color
ctx.textAlign = "center"
let y: number
switch (title.position) {
case "top":
y = fontSize + canvas.height * 0.08
break
case "bottom":
y = canvas.height - canvas.height * 0.08
break
case "center":
default:
y = canvas.height / 2
break
}
if (title.shadow) {
ctx.shadowColor = "rgba(0,0,0,0.8)"
ctx.shadowBlur = 4
ctx.shadowOffsetX = 2
ctx.shadowOffsetY = 2
}
if (title.stroke) {
ctx.strokeStyle = "#000000"
ctx.lineWidth = 1
ctx.strokeText(title.text, canvas.width / 2, y)
}
ctx.fillText(title.text, canvas.width / 2, y)
// 重置阴影
ctx.shadowColor = "transparent"
ctx.shadowBlur = 0
ctx.shadowOffsetX = 0
ctx.shadowOffsetY = 0
},
[],
)
// ── Canvas 渲染循环 ──
const renderFrame = useCallback(() => {
if (isDestroyedRef.current) return
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext("2d")
if (!ctx) return
const elapsed = (performance.now() - playStartRef.current) / 1000
const currentTime = Math.min(playStartOffsetRef.current + elapsed, totalDuration)
const frame = frameQueueRef.current.getCurrentFrame(currentTime)
if (frame) {
ctx.clearRect(0, 0, canvas.width, canvas.height)
ctx.drawImage(frame, 0, 0, canvas.width, canvas.height)
}
if (titleSettings?.text) {
drawTitle(ctx, canvas, titleSettings)
}
setState((s) => {
if (Math.abs(s.currentTime - currentTime) > 0.1) {
return { ...s, currentTime }
}
return s
})
if (currentTime >= totalDuration) {
setState((s) => ({ ...s, isPlaying: false }))
return
}
rafRef.current = requestAnimationFrame(renderFrame)
}, [canvasRef, totalDuration, titleSettings, drawTitle])
// ── 播放控制 ──
const play = useCallback(async () => {
if (!state.hasSupport || isDestroyedRef.current) return
if (!audioCtxRef.current) {
audioCtxRef.current = new AudioContext()
}
setState((s) => ({ ...s, isPlaying: true }))
playStartRef.current = performance.now()
playStartOffsetRef.current = state.currentTime
rafRef.current = requestAnimationFrame(renderFrame)
}, [state.hasSupport, state.currentTime, renderFrame])
const pause = useCallback(() => {
setState((s) => ({ ...s, isPlaying: false }))
cancelAnimationFrame(rafRef.current)
if (audioSourceRef.current && audioCtxRef.current) {
audioSourceRef.current.stop()
audioSourceRef.current = null
}
}, [])
const seek = useCallback(
(time: number) => {
const clampedTime = Math.max(0, Math.min(time, totalDuration))
setState((s) => ({ ...s, currentTime: clampedTime }))
playStartOffsetRef.current = clampedTime
playStartRef.current = performance.now()
},
[totalDuration],
)
const destroy = useCallback(() => {
isDestroyedRef.current = true
cancelAnimationFrame(rafRef.current)
if (decoderRef.current && decoderRef.current.state !== "closed") {
decoderRef.current.close()
}
frameQueueRef.current.clear()
if (audioSourceRef.current) {
audioSourceRef.current.stop()
audioSourceRef.current = null
}
if (audioCtxRef.current) {
audioCtxRef.current.close()
audioCtxRef.current = null
}
segmentDataRef.current.clear()
}, [])
// ── 预加载下一个片段 ──
const preloadNext = useCallback(
async (currentIndex: number) => {
const nextIdx = currentIndex + 1
if (nextIdx >= segments.length) return
const next = segments[nextIdx]
if (segmentDataRef.current.has(next.assetId)) return
await loadSegment(next)
},
[segments, loadSegment],
)
// ── 初始化:加载第一个片段并解封装 ──
useEffect(() => {
if (!state.hasSupport || segments.length === 0) return
const init = async () => {
await loadSegment(segments[0])
const buffer = segmentDataRef.current.get(segments[0].assetId)
if (buffer) {
await demuxAndDecode(buffer)
setState((s) => ({ ...s, duration: totalDuration, isReady: true }))
preloadNext(0)
}
}
init()
return () => {
destroy()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [segments, state.hasSupport])
return {
state: { ...state, duration: totalDuration },
controls: { play, pause, seek, destroy } satisfies CanvasPlayerControls,
preloadNext,
}
}
export default useCanvasPlayer