当前位置: 首页 > news >正文

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

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

需求:

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

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

先安装hls.js

# 使用 npm
npm install hls.js --save# 使用 yarn
yarn add hls.js
完整代码如下:
<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); // 开始加载时显示loadingstartHeartbeatCheck(); // 开始心跳检测// 销毁已存在的实例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); // 播放成功后隐藏loadinghideCanvas(); // 隐藏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;// 清除Canvasctx.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>
http://www.dtcms.com/a/419393.html

相关文章:

  • 前端核心框架vue之(路由核心案例篇3/5)
  • vue中不同的watch方法的坑
  • 网站首页排版设计广州网络公关公司
  • 批量重命名技巧:使用PowerShell一键整理图片文件命名规范
  • 手机版网站怎么做的企业解决方案架构师
  • 网站企业备案改个人备案专业微网站制作
  • 新天力科技以创新驱动发展,铸就食品包装容器行业领军者
  • crew AI笔记[7] - flow特性示例
  • 广州制作网站公司网站开发收税
  • 二阶可降阶微分方程的求解方法总结
  • 纯静态企业网站模板免费下载手机app编程
  • Redis在高并发场景中的核心优势
  • 教育网站 网页赏析网络营销推广的优缺点
  • 金溪县建设局网站品牌网站怎么建立
  • 中国气候政策不确定性数据(2000-2022)
  • 大发快三网站自做青海省城乡建设厅网站
  • 800G DR8与其他800G光模块的对比分析
  • 第四部分:VTK常用类详解(第100章 vtkHandleWidget句柄控件类)
  • Kafka 和 RabbitMQ 使用:消息队列的强大工具
  • 网站注册信息网站的建设有什么好处
  • 物理层-传输介质
  • npm 包构建与发布
  • 第四部分:VTK常用类详解(第99章 vtkBorderWidget边框控件类)
  • 如何播放 M3U8 格式的视频
  • 视频推拉流EasyDSS如何运用无人机直播技术高效排查焚烧烟火?
  • 常规网站服务器cms程序
  • tomcat创建bat启动,结合任务计划实现自动重启tomcat服务
  • 滨海网站建设wordpress .htaccess下载
  • CCS主题配置,
  • 08网站建设自己做电商网站吗