Next.js+Plyr视频播放故障:MP4报错与Webm控制栏异常
解决Next.js + Plyr视频播放异常问题
一、MP4视频播放异常
问题描述
切换至编码为video/mp4; codecs="avc1.640020,mp4a.40.2"的MP4视频时,触发AbortError: The play() request was interrupted by a call to pause()错误,未主动调用pause()或点击暂停按钮。在Firefox中播放5秒后,还会出现NS_ERROR_OUT_OF_MEMORY (0x8007000e)错误(设备有24GB内存,视频仅15MB)。
原因分析
- play()调用时机过早:视频缓冲未完成就触发播放请求,浏览器会自动中断操作。
- MediaSource缓冲逻辑缺陷:一次性加载完整MP4视频到SourceBuffer,Firefox对非分段MP4的MediaSource兼容性较差,引发内存溢出。
- endOfStream时机错误:固定5秒后强制结束流,此时视频可能仍在缓冲,导致播放异常。
解决方案
- 等待
SourceBuffer的updateend事件确认缓冲完成后,再调用play()。 - 确保MP4视频为分段格式(fMP4),或对大文件实现分段加载,提升Firefox兼容性。
- 将
endOfStream()移至updateend回调中,确保缓冲完成后再结束流。 - 监听视频
canplay事件,确认视频就绪后再触发播放。
二、Webm视频控制栏异常
问题描述
播放编码为video/webm; codecs="vp9,opus"的Webm视频时,视频可正常播放,但控制栏(播放/暂停、进度条等)消失,仅双击进入全屏后才显示。
原因分析
- Plyr初始化时机不匹配:Plyr在视频元素初始状态时绑定控件,后续通过MediaSource动态设置
src后,未检测到视频元数据变化,控件未正确渲染。 - Webm元数据加载延迟:Webm视频元数据加载较慢,Plyr初始化时视频未就绪,导致控件无法正常显示。
解决方案
- 在视频
loadedmetadata事件触发后,调用Plyr实例的refresh()方法,强制更新控件状态。 - 调整Plyr初始化逻辑,确保在视频
src设置完成后完成初始化或刷新。 - 检查CSS样式,避免意外隐藏Plyr控件。
修改后的完整代码
"use client"; import { useEffect, useRef, useState, useCallback, } from "react"; import { Loader2, AlertCircle, Lock } from "lucide-react"; import { Button } from "@/components/ui/button"; interface R2VideoPlayerProps { detailId: number; autoPlay?: boolean; className?: string; } type PlayerState = | { status: "idle" } | { status: "loading" } | { status: "playing" } | { status: "error"; message: string } | { status: "devtools" }; export function R2VideoPlayer({ detailId, autoPlay = false, className, }: R2VideoPlayerProps) { const videoRef = useRef<HTMLVideoElement>(null); const mediaSourceRef = useRef<MediaSource | null>(null); // eslint-disable-next-line @typescript-eslint/no-explicit-any const plyrRef = useRef<any>(null); const [state, setState] = useState<PlayerState>({ status: "idle" }); // 初始化或刷新Plyr实例 const initOrRefreshPlyr = useCallback(async () => { const video = videoRef.current; if (!video) return; if (plyrRef.current) { plyrRef.current.refresh(); return; } const [{ default: Plyr }] = await Promise.all([ import("plyr"), import("plyr/dist/plyr.css"), ]); const player = new Plyr(video, { controls: [ "play", "progress", "current-time", "duration", "mute", "volume", "settings", "fullscreen", ], settings: ["speed"], speed: { selected: 1, options: [0.5, 0.75, 1, 1.25, 1.5, 2] }, clickToPlay: false, disableContextMenu: true, keyboard: { focused: true, global: false }, tooltips: { controls: true, seek: true }, i18n: { speed: "Tốc độ", normal: "Bình thường", quality: "Chất lượng", loop: { start: "Bắt đầu", end: "Kết thúc", all: "Tất cả", reset: "Đặt lại", none: "Không" }, }, }); plyrRef.current = player; }, []); useEffect(() => { const video = videoRef.current; if (!video) return; const handleLoadedMetadata = () => { initOrRefreshPlyr(); }; video.addEventListener("loadedmetadata", handleLoadedMetadata); return () => { video.removeEventListener("loadedmetadata", handleLoadedMetadata); if (plyrRef.current) { plyrRef.current.destroy(); plyrRef.current = null; } }; }, [initOrRefreshPlyr]); const startPlayback = useCallback(async () => { const video = videoRef.current; if (!video) return; setState({ status: "loading" }); const ms = new MediaSource(); mediaSourceRef.current = ms; video.src = URL.createObjectURL(ms); ms.addEventListener("sourceopen", async () => { try { // 根据需要切换MP4/WebM的SourceBuffer // const sb = ms.addSourceBuffer('video/mp4; codecs="avc1.640020,mp4a.40.2"'); const sb = ms.addSourceBuffer('video/webm; codecs="vp9,opus"'); // const res = await fetch('你的MP4视频链接'); const res = await fetch('https://raw.githubusercontent.com/chromium/chromium/b4b3566f27d2814fbba1b115639eb7801dd691cf/media/test/data/bear-vp9-opus.webm'); if (!res.ok) throw new Error(`R2 fetch failed: HTTP ${res.status}`); const arrayBuffer = await res.arrayBuffer(); sb.addEventListener('updateend', () => { ms.endOfStream(); video.addEventListener('canplay', async () => { setState({ status: "playing" }); if (autoPlay) { await video.play().catch((e) => {console.error(e)}); } }, { once: true }); }, { once: true }); sb.appendBuffer(arrayBuffer); } catch (err) { console.error(err); if (ms.readyState === "open") ms.endOfStream("decode"); setState({ status: "error", message: (err as Error).message }); } }); ms.addEventListener("sourceclose", () => { URL.revokeObjectURL(video.src); }); }, [detailId, autoPlay]); useEffect(() => { return () => { if (mediaSourceRef.current?.readyState === "open") { mediaSourceRef.current.endOfStream(); } }; }, []); return ( <div className={`relative aspect-video overflow-hidden rounded-xl bg-black ${className ?? ""}`} onContextMenu={(e) => e.preventDefault()} > {state.status === "idle" && ( <div className="absolute inset-0 z-20 flex flex-col items-center justify-center gap-3"> <Button onClick={startPlayback} size="lg" className="rounded-full"> ▶ Phát video </Button> </div> )} {state.status === "loading" && ( <div className="absolute inset-0 z-20 flex items-center justify-center bg-black/80"> <Loader2 className="size-10 animate-spin text-white" /> </div> )} {state.status === "error" && ( <div className="absolute inset-0 z-20 flex flex-col items-center justify-center gap-3 bg-black/90 p-4 text-center"> <AlertCircle className="size-8 text-destructive" /> <p className="text-sm text-white">{state.message}</p> <Button size="sm" variant="outline" onClick={() => setState({ status: "idle" })} > Thử lại </Button> </div> )} {state.status === "devtools" && ( <div className="absolute inset-0 z-50 flex flex-col items-center justify-center gap-3 bg-black/95 p-6 text-center"> <Lock className="size-10 text-amber-400" /> <p className="text-base font-semibold text-white"> Vui lòng đóng DevTools để tiếp tục xem </p> <p className="text-xs text-white/60"> Video được bảo vệ bản quyền. Không được ghi lại trái phép. </p> <Button size="sm" onClick={() => setState({ status: "idle" })} className="mt-2" > Tiếp tục </Button> </div> )} <div className="absolute inset-0"> <video ref={videoRef} className="h-full w-full" controlsList="nodownload" disablePictureInPicture playsInline /> </div> </div> ); }
内容的提问来源于stack exchange,提问作者SoT
相关产品推荐
相关产品推荐

