基于 Vue 3 的 HLS 视频流播放组件,核心用于加载播放 M3U8 格式视频(如直播流),
需求:
能够加载海康的摄像头视频,后端转码成m3u8格式,要求一直播放,但是网络请求偶尔卡顿、中断,后端偶尔会断开,前端锁屏后会节流停止请求等问题,要求前端断联后还能重新请求,无论什么原因。所以前端只需要监听视频请求的切片流是否一直在连接请求,如果一段时间没有监听到请求出现异常,那么就重新请求视频接口,又防止loading效果不好,添加了异常前视频的最后一帧,作为loading背景,弱化了加载效果,并且在每次异常都添加了打印日志。
功能如下:
- 基础播放能力:依赖 hls.js 解析视频流,自动适配带宽选择码率,视频尺寸固定为 2588×1290;
- 状态与视觉管理:加载时显示旋转动画 + 提示文本,断流时用 Canvas 捕获当前帧避免黑屏,播放正常后隐藏缓冲层;
- 异常容错:监听 HLS 错误(如网络问题),自动切换低码率重试;定时检测分片加载(20 秒无新分片则重连),重试次数≤5 时短延迟重连,超次数则长延迟;
- 资源与生命周期管理:页面切换隐藏时销毁实例、停止定时器,组件卸载时清理事件 / 资源;页面恢复可见时重新加载视频。
先安装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>