You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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)。

原因分析

  1. play()调用时机过早:视频缓冲未完成就触发播放请求,浏览器会自动中断操作。
  2. MediaSource缓冲逻辑缺陷:一次性加载完整MP4视频到SourceBuffer,Firefox对非分段MP4的MediaSource兼容性较差,引发内存溢出。
  3. endOfStream时机错误:固定5秒后强制结束流,此时视频可能仍在缓冲,导致播放异常。

解决方案

  1. 等待SourceBuffer的updateend事件确认缓冲完成后,再调用play()。
  2. 确保MP4视频为分段格式(fMP4),或对大文件实现分段加载,提升Firefox兼容性。
  3. 将endOfStream()移至updateend回调中,确保缓冲完成后再结束流。
  4. 监听视频canplay事件,确认视频就绪后再触发播放。

二、Webm视频控制栏异常

问题描述

播放编码为video/webm; codecs="vp9,opus"的Webm视频时,视频可正常播放,但控制栏(播放/暂停、进度条等)消失,仅双击进入全屏后才显示。

原因分析

  1. Plyr初始化时机不匹配:Plyr在视频元素初始状态时绑定控件,后续通过MediaSource动态设置src后,未检测到视频元数据变化,控件未正确渲染。
  2. Webm元数据加载延迟:Webm视频元数据加载较慢,Plyr初始化时视频未就绪,导致控件无法正常显示。

解决方案

  1. 在视频loadedmetadata事件触发后,调用Plyr实例的refresh()方法,强制更新控件状态。
  2. 调整Plyr初始化逻辑,确保在视频src设置完成后完成初始化或刷新。
  3. 检查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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.02 01:57:27