Vue 3 开发的 HLS 视频流播放组件+异常处理

基于 Vue 3 的 HLS 视频流播放组件,核心用于加载播放 M3U8 格式视频(如直播流),

需求:

能够加载海康的摄像头视频,后端转码成m3u8格式,要求一直播放,但是网络请求偶尔卡顿、中断,后端偶尔会断开,前端锁屏后会节流停止请求等问题,要求前端断联后还能重新请求,无论什么原因。所以前端只需要监听视频请求的切片流是否一直在连接请求,如果一段时间没有监听到请求出现异常,那么就重新请求视频接口,又防止loading效果不好,添加了异常前视频的最后一帧,作为loading背景,弱化了加载效果,并且在每次异常都添加了打印日志。

功能如下:
  1. 基础播放能力:依赖 hls.js 解析视频流,自动适配带宽选择码率,视频尺寸固定为 2588×1290;
  2. 状态与视觉管理:加载时显示旋转动画 + 提示文本,断流时用 Canvas 捕获当前帧避免黑屏,播放正常后隐藏缓冲层;
  3. 异常容错:监听 HLS 错误(如网络问题),自动切换低码率重试;定时检测分片加载(20 秒无新分片则重连),重试次数≤5 时短延迟重连,超次数则长延迟;
  4. 资源与生命周期管理:页面切换隐藏时销毁实例、停止定时器,组件卸载时清理事件 / 资源;页面恢复可见时重新加载视频。

先安装hls.js

bash 复制代码
# 使用 npm
npm install hls.js --save

# 使用 yarn
yarn add hls.js
完整代码如下:
ts 复制代码
<template>
  <div class="video-box">
    <!-- 加载状态 -->
    <div v-if="isLoading" class="loading-overlay">
      <div class="loader">
        <div class="spinner"></div>
        <p class="loading-text">{{ loadingText }}</p>
      </div>
    </div>
    <div class="video-cover"></div>
    <video ref="video" class="video-box1" muted></video>
    <canvas ref="bufferCanvas" class="video-buffer-canvas"></canvas>
  </div>
</template>
<script lang="ts" setup>
import Hls from "hls.js";
import { onMounted, onUnmounted, ref, nextTick } from "vue";

// 固定尺寸设置 - 2588×1290
const FIXED_WIDTH = 2588;
const FIXED_HEIGHT = 1290;

// 组件状态
const video = ref<HTMLVideoElement | any>(null);
const url = 'XXX.m3u8'; // 你的地址
const hlsInstance = ref<Hls | any>(null);
const isLoading = ref(true);
const loadingText = ref("正在加载视频...");
const bufferCanvas = ref<HTMLCanvasElement | any>(null);

// 心跳检测相关变量
const lastFragLoadedTime = ref(0); // 最后一次分片加载的时间戳
const heartbeatCheckTimer = ref<any>(null); // 心跳检测定时器
const MAX_NO_FRAG_DURATION = 20000; // 最大无新分片间隔(20秒)
const HEARTBEAT_CHECK_INTERVAL = 5000; // 心跳检测间隔
// 加载视频流
const loadStream = () => {
  if (!video.value) return;
  if (Hls.isSupported()) {
    setLoading(true); // 开始加载时显示loading
    startHeartbeatCheck(); // 开始心跳检测
    // 销毁已存在的实例
    if (hlsInstance.value) {
      hlsInstance.value.destroy();
    }

    hlsInstance.value = new Hls({
      startLevel: -1, // 自动选择适合当前带宽的码率
      maxBufferLength: 60, // 最大缓冲时长(秒),建议设为15-30秒,避免缓冲过多占用内存
    });

    // 媒体附加事件
    hlsInstance.value.on(Hls.Events.MEDIA_ATTACHED, () => {
      hlsInstance.value?.loadSource(url);
    });
    // 请求成功
    hlsInstance.value.on(Hls.Events.MANIFEST_PARSED, () => {
      video.value
        .play()
        .then(() => {
          consoleText(`${getCurrentTime()}: 播放成功`);

          retryCount.value = 1; // 重置重试次数
          setLoading(false); // 播放成功后隐藏loading
          hideCanvas(); // 隐藏Canvas
        })
        .catch((err: any) => {
          consoleText(`${getCurrentTime()}: 播放失败: ${err.message}`, true);
        });
    });
    // 错误处理
    hlsInstance.value.on(Hls.Events.ERROR, (_: any, data: any) => {
      consoleText(`${getCurrentTime()}: HLS错误类型: ${data.type}, 详情: ${data.details}`, true);
      if (data.type === Hls.ErrorTypes.NETWORK_ERROR && data.details === Hls.ErrorDetails.LEVEL_LOAD_ERROR) {
        const currentLevel = hlsInstance.value.level;
        // 若当前不是最低码率,切换到更低一级
        if (currentLevel > 0) {
          hlsInstance.value.startLoad(currentLevel - 1);
          consoleText(`${getCurrentTime()}: 当前码率加载失败,已切换到 ${currentLevel - 1} 级`, true);
        } else {
          consoleText(`${getCurrentTime()}: 最低码率仍加载异常,请检查网络`, true);
        }
      }
    });

    // 监听分片加载事件,更新最后加载时间
    hlsInstance.value.on(Hls.Events.FRAG_LOADED, () => {
      lastFragLoadedTime.value = Date.now();
    });

    hlsInstance.value.attachMedia(video.value);
  }
};

// 设置加载状态
const setLoading = (status: boolean, text?: string) => {
  isLoading.value = status;
  loadingText.value = text || (status ? "正在加载视频..." : "");
};

const retryCount = ref(1);
const maxRetries = 5;
// 启动心跳检测
const startHeartbeatCheck = () => {
  // 先清除已存在的定时器
  if (heartbeatCheckTimer.value) {
    clearInterval(heartbeatCheckTimer.value);
  }

  // 记录初始时间
  lastFragLoadedTime.value = Date.now();
  consoleText(`${getCurrentTime()}: 启动分片心跳检测,最大无分片间隔${MAX_NO_FRAG_DURATION / 1000}秒`);
  // 设置定时器,定期检查
  heartbeatCheckTimer.value = setInterval(() => {
    const currentTime = Date.now();
    const timeSinceLastFrag = currentTime - lastFragLoadedTime.value;
    // 检查是否超过最大无分片时间
    if (timeSinceLastFrag > MAX_NO_FRAG_DURATION) {
      consoleText(`${getCurrentTime()}: 超过${MAX_NO_FRAG_DURATION / 1000}秒未加载新分片,触发重新连接`, true);
      reloadStream();
    }
  }, HEARTBEAT_CHECK_INTERVAL);
};

// 重新加载流
const reloadStream = () => {
  const delay = maxRetries >= retryCount.value ? 2000 : 1000 * 60;
  setLoading(true, `正在重新连接...`);

  // 捕获当前帧
  const frameCaptured = captureCurrentFrame();
  // consoleText(`${getCurrentTime()}: ${frameCaptured ? "成功捕获视频帧" : "无法捕获视频帧"}`);
  if (!frameCaptured && delay > 1000) {
    setTimeout(() => {
      const retryCapture = captureCurrentFrame();
      // consoleText(`${getCurrentTime()}: 重试捕获视频帧: ${retryCapture ? "成功" : "失败"}`);
    }, 1000);
  }

  if (hlsInstance.value) {
    hlsInstance.value.destroy();
    hlsInstance.value = null;
  }
  // 重置视频元素
  if (video.value) {
    video.value.pause();
    video.value.src = "";
  }
  consoleText(`${getCurrentTime()}: ${delay}ms重新启动监控${retryCount.value}次`);
  retryCount.value++;
  // 延迟后重新加载
  setTimeout(() => {
    loadStream();
  }, delay);
};
const textContent = ref("");
// 日志打印
const consoleText = (text: string, error = false) => {
  if (error) {
    console.log("%c [error]: ", "background: pink", text);
  } else {
    console.log(text);
  }
  textContent.value += text + "\n";
};

const getCurrentTime = () => {
  const date = new Date();
  return date.toLocaleString("zh-CN", {
    year: "numeric",
    month: "2-digit",
    day: "2-digit",
    hour: "2-digit",
    minute: "2-digit",
    second: "2-digit",
  });
};
const downloadTimer: any = ref(null); // 下载日志
// 组件挂载时初始化
onMounted(() => {
  nextTick(() => {
    if (bufferCanvas.value && video.value) {
      initCanvasSize();
    }
  });

  loadStream();
  document.removeEventListener("visibilitychange", handleVisibilityChange);
  document.addEventListener("visibilitychange", handleVisibilityChange);
  // 清除可能存在的旧定时器
  if (downloadTimer.value) {
    clearInterval(downloadTimer.value);
  }
});

// 初始化Canvas尺寸(固定尺寸)
const initCanvasSize = () => {
  // 固定Canvas画布尺寸(像素尺寸)
  bufferCanvas.value.width = FIXED_WIDTH;
  bufferCanvas.value.height = FIXED_HEIGHT;

  // 固定Canvas显示尺寸(CSS尺寸)
  bufferCanvas.value.style.width = `${FIXED_WIDTH}px`;
  bufferCanvas.value.style.height = `${FIXED_HEIGHT}px`;
};

// 捕获视频当前帧到Canvas
const captureCurrentFrame = () => {
  if (!video.value || !bufferCanvas.value) return false;
  if (video.value.paused || video.value.ended) return false;

  try {
    const ctx = bufferCanvas.value.getContext("2d");
    if (!ctx) return false;

    // 清除Canvas
    ctx.clearRect(0, 0, FIXED_WIDTH, FIXED_HEIGHT);

    // 直接使用固定尺寸绘制
    ctx.drawImage(
      video.value,
      0,
      0,
      video.value.videoWidth,
      video.value.videoHeight,
      0,
      0,
      FIXED_WIDTH,
      FIXED_HEIGHT // 目标Canvas区域(固定尺寸)
    );

    bufferCanvas.value.style.display = "block";
    return true;
  } catch (error) {
    consoleText(`捕获视频帧失败: ${error}`, true);
    return false;
  }
};

// 隐藏Canvas
const hideCanvas = () => {
  if (bufferCanvas.value) {
    bufferCanvas.value.style.display = "none";
  }
};

// 页面可见性处理
const handleVisibilityChange = () => {
  retryCount.value = 1;
  if (document.visibilityState === "visible") {
    consoleText(`${getCurrentTime()}: 页面变为可见状态`);
    loadStream();
  } else {
    consoleText(`${getCurrentTime()}: 卸载页面关闭监控请求`);
    hideVideo();
  }
};
// 卸载页面
const hideVideo = () => {
  // 停止心跳检测
  if (heartbeatCheckTimer.value) {
    clearInterval(heartbeatCheckTimer.value);
    heartbeatCheckTimer.value = null;
    consoleText(`${getCurrentTime()}: 停止分片心跳检测`);
  }
  if (hlsInstance.value) {
    hlsInstance.value.destroy();
    hlsInstance.value = null;
  }

  if (video.value) {
    video.value.pause();
    video.value.src = "";
  }
  hideCanvas();
};
// 组件卸载时清理
onUnmounted(() => {
  hideVideo();
  document.removeEventListener("visibilitychange", handleVisibilityChange);
  if (downloadTimer.value) {
    clearInterval(downloadTimer.value);
  }
});

</script>

<style lang="scss" scoped>
.video-box {
  width: 100%;
  height: 100%;
  position: absolute;
  display: flex;
  align-items: center;
  justify-content: center;

  .video-cover {
    width: 100%;
    height: 100%;
    box-shadow: inset 0 0 30px 40px #1c436a, 0 0 80px 70px #1c436a;
    position: absolute;
    top: 0;
    left: 0;
    z-index: 99;
  }

  .video-box1 {
    width: 100%;
    height: 100%;
    object-fit: fill;
  }
  .video-buffer-canvas {
    position: absolute;
    top: 0;
    left: 0;
    object-fit: fill;
    z-index: 1;
    width: 2988px;
    height: 1290px;
  }
  // 加载状态样式
  .loading-overlay {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background-color: rgba(0, 0, 0, 0.1);
    display: flex;
    align-items: center;
    justify-content: center;
    z-index: 10;
    flex-direction: column;

    .loader {
      text-align: center;
    }

    .spinner {
      width: 50px;
      height: 50px;
      border: 5px solid rgba(255, 255, 255, 0.3);
      border-radius: 50%;
      border-top-color: #ffffff;
      animation: spin 1s ease-in-out infinite;
      margin: 0 auto 15px;
    }

    .loading-text {
      color: #ffffff;
      font-size: 16px;
      font-weight: 500;
      text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
    }
  }
}

// 旋转动画
@keyframes spin {
  to {
    transform: rotate(360deg);
  }
}
</style>
相关推荐
卓码软件测评2 小时前
第三方软件登记测试机构:【软件登记测试机构HTML5测试技术】
前端·功能测试·测试工具·html·测试用例·html5
CS Beginner2 小时前
【html】canvas实现一个时钟
前端·html
林烈涛3 小时前
js判断变量是数组还是对象
开发语言·前端·javascript
Komorebi_99993 小时前
Unocss
开发语言·前端
goto_w4 小时前
前端实现复杂的Excel导出
前端·excel
Baklib梅梅4 小时前
2025文档管理软件推荐:效率、安全与协作全解析
前端·ruby on rails·前端框架·ruby
小时前端4 小时前
Vue基础10题:答错一道,别说你熟悉Vue
vue.js
卷Java5 小时前
小程序前端功能更新说明
java·前端·spring boot·微信小程序·小程序·uni-app
FogLetter5 小时前
前端性能救星:虚拟列表原理与实现,让你的10万条数据流畅如丝!
前端·性能优化