feat 直播流优化

This commit is contained in:
Ironsp
2026-07-22 11:50:57 +08:00
parent aaa223f41d
commit bbd23051e7
5 changed files with 205 additions and 54 deletions

View File

@@ -5,6 +5,10 @@
"ComputedRef": true,
"DirectiveBinding": true,
"EffectScope": true,
"ElLoading": true,
"ElMessage": true,
"ElMessageBox": true,
"ElNotification": true,
"ExtractDefaultPropTypes": true,
"ExtractPropTypes": true,
"ExtractPublicPropTypes": true,

View File

@@ -39,7 +39,7 @@
</template>
<div class="monitor-video-wrap">
<div class="monitor-video-stack">
<video ref="monitorVideoRef" class="monitor-video" preload="metadata" :src="monitorVideoUrl">您的浏览器不支持视频播放</video>
<video ref="monitorVideoRef" class="monitor-video" preload="metadata">您的浏览器不支持视频播放</video>
</div>
</div>
</el-dialog>
@@ -57,7 +57,7 @@ import { addDemo } from '@/api/video/index';
const { proxy } = getCurrentInstance() as ComponentInternalInstance;
import { nextTick } from 'vue';
import { nextTick, onUnmounted } from 'vue';
import { useVideosStore } from '@/store/modules/video';
const videosStore = useVideosStore();
const monitoringArea = ref<string>('');
@@ -111,6 +111,20 @@ const parseNoticeItem = (notice: any) => {
//查看画面
const currentDeviceId = ref<string | number>();
/** 刷新视频(卡死时自动调用) */
const handleRefresh = async () => {
if (!currentDeviceId.value) return;
destroyHls(monitorVideoRef);
monitorVideoUrl.value = '';
await videosStore.getVideos(currentDeviceId.value);
monitorVideoUrl.value = videosStore.hisUrl || '';
if (monitorVideoUrl.value) {
await nextTick();
initHls(monitorVideoUrl, monitorVideoRef, () => dialog.visible, handleRefresh);
}
};
const handleSee = async (row: any) => {
currentDeviceId.value = row?.deviceId;
const videoId = row?.deviceId;
@@ -124,7 +138,7 @@ const handleSee = async (row: any) => {
monitoringArea.value = row.monitoringArea;
dialog.visible = true;
await nextTick();
initHls(monitorVideoUrl, monitorVideoRef);
initHls(monitorVideoUrl, monitorVideoRef, () => dialog.visible, handleRefresh);
await handleAction(row.id);
await getList();
};
@@ -219,9 +233,14 @@ onMounted(() => {
onBeforeRouteLeave(() => {
dialog.visible = false;
destroyHls(monitorVideoRef);
monitorVideoUrl.value = '';
});
onUnmounted(() => {
destroyHls(monitorVideoRef);
});
onMounted(() => {
getList();
console.log('111111111111111111111_warningList.value', warningList.value);

View File

@@ -31,11 +31,11 @@ export const initSSE = (url: any) => {
if (!data.value) return;
// 打印接收到的 SSE 消息
console.log('=== SSE 接收到消息 ===');
console.log('消息内容:', data.value);
console.log('消息类型:', typeof data.value);
console.log('接收时间:', new Date().toLocaleString());
console.log('=====================');
// console.log('=== SSE 接收到消息 ===');
// console.log('消息内容:', data.value);
// console.log('消息类型:', typeof data.value);
// console.log('接收时间:', new Date().toLocaleString());
// console.log('=====================');
useNoticeStore().addNotice({
message: data.value,

View File

@@ -1,100 +1,228 @@
import Hls from 'hls.js';
import { log } from 'node:console';
let hlsInstance: Hls | null = null;
// let hlsRetryCount = 0;
export const initHls = (monitorVideoUrl, monitorVideoRef, isDialogVisible?: () => boolean) => {
/**
* 每个播放器实例维护自己的状态,避免全局单例互相踩踏
*/
interface HlsPlayerState {
hls: Hls;
video: HTMLVideoElement;
/** live edge 追赶定时器 */
syncTimer: ReturnType<typeof setInterval> | null;
/** 卡死检测定时器 */
stallTimer: ReturnType<typeof setInterval> | null;
/** 连续加载失败计数 */
consecutiveFailures: number;
/** 是否已销毁 */
destroyed: boolean;
}
const playerStates = new WeakMap<HTMLVideoElement, HlsPlayerState>();
/**
* 直播流追赶逻辑:检测播放位置与 live edge 的差距,超过阈值就 seek
*/
function startLiveSync(state: HlsPlayerState) {
const { hls, video } = state;
state.syncTimer = setInterval(() => {
if (state.destroyed) return;
try {
const buffered = video.buffered;
if (buffered.length === 0) return;
// 取最后一个 buffer range 的 end 作为 live edge
const liveEdge = buffered.end(buffered.length - 1);
const currentTime = video.currentTime;
const gap = liveEdge - currentTime;
// 超过 6 秒就 seek 到 live edge 附近
if (gap > 6) {
video.currentTime = liveEdge - 2;
}
} catch {
// ignore
}
}, 2000);
}
function stopLiveSync(state: HlsPlayerState) {
if (state.syncTimer) {
clearInterval(state.syncTimer);
state.syncTimer = null;
}
if (state.stallTimer) {
clearInterval(state.stallTimer);
state.stallTimer = null;
}
}
export const initHls = (monitorVideoUrl, monitorVideoRef, isDialogVisible?: () => boolean, onStall?: () => void) => {
console.log('isDialogVisible', isDialogVisible);
const url = monitorVideoUrl.value;
const video = monitorVideoRef.value;
if (!url || !video) return;
// 销毁已有实例
if (hlsInstance) {
// 销毁该 video 元素上已有实例(不再操作全局变量)
const existingState = playerStates.get(video);
if (existingState) {
try {
hlsInstance.destroy();
} catch (e) {
stopLiveSync(existingState);
existingState.destroyed = true;
existingState.hls.destroy();
} catch {
// ignore
}
hlsInstance = null;
playerStates.delete(video);
}
if (Hls.isSupported()) {
const hls = new Hls({
// ★ ZLM 注册流需要时间,配置自动重试
manifestLoadingMaxRetry: 5, // m3u8 加载最多重试20次
manifestLoadingRetryDelay: 500, // 每次重试间隔500ms
manifestLoadingTimeOut: 5000, // m3u8 请求超时5秒
levelLoadingMaxRetry: 2, // ts 分片加载最多重试20次
manifestLoadingMaxRetry: 10,
manifestLoadingRetryDelay: 500,
manifestLoadingTimeOut: 5000,
levelLoadingMaxRetry: 5,
levelLoadingRetryDelay: 500,
lowLatencyMode: false, // 关掉低延迟模式,更稳定
startLevel: -1 // 自动选择码率
lowLatencyMode: false,
startLevel: -1,
// ★ 直播流关键配置
liveSyncDurationCount: 3,
liveDurationInfinity: true,
// 缓冲区上限,防止无限增长导致内存爆掉
maxBufferLength: 30,
maxMaxBufferLength: 60
});
hls.loadSource(url);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, () => {
// 自动播放(若浏览器允许)
try {
video.play().catch(() => {});
} catch (e) {}
// 加载成功,重置重试计数
// hlsRetryCount = 0;
} catch {
// ignore
}
});
// ★ 错误自动恢复hls.js 拿到空 m3u8 或 ts 加载失败时自动重试
const state: HlsPlayerState = {
hls,
video,
syncTimer: null,
stallTimer: null,
consecutiveFailures: 0,
destroyed: false
};
playerStates.set(video, state);
/**
* 完善的错误处理:覆盖致命 + 非致命错误
*/
hls.on(Hls.Events.ERROR, (event, data) => {
if (data.fatal && (!isDialogVisible || isDialogVisible())) {
// 对话框已关闭则不再恢复
if (isDialogVisible && !isDialogVisible()) return;
if (data.fatal) {
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
hls.startLoad();
hls.once(Hls.Events.FRAG_LOADED, () => {
//分片加载成功后播放
try {
video.play().catch(() => {});
} catch (e) {}
});
ElMessage.error('网络错误, 请检查网络或重试');
if (data.details === 'manifestLoadError' || data.details === 'manifestLoadTimeOut') {
console.error('网络错误');
// if (onStall) {
// onStall();
// }
} else {
console.warn('[HLS] 网络错误,自动恢复...');
hls.startLoad();
}
break;
case Hls.ErrorTypes.MEDIA_ERROR:
// 媒体解码错误尝试恢复
console.warn('[HLS] 媒体解码错误尝试恢复...');
hls.recoverMediaError();
break;
default:
// 其他致命错误 → 销毁,让下一次 initHls 重建
console.error('[HLS] 不可恢复的错误,销毁实例');
state.destroyed = true;
stopLiveSync(state);
hls.destroy();
hlsInstance = null;
playerStates.delete(video);
break;
}
} else {
// ★ 非致命错误也处理,防止问题累积
// 记录加载失败
state.consecutiveFailures++;
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
// 分片加载失败等 → 重新加载
if (data.details === 'fragLoadError' || data.details === 'fragLoadTimeOut') {
console.warn(`[HLS] 分片加载问题 (${data.details}),第 ${state.consecutiveFailures} 次失败,尝试恢复...`);
hls.startLoad(hls.currentLevel);
}
break;
case Hls.ErrorTypes.MEDIA_ERROR:
if (data.details === 'bufferAppendError' || data.details === 'bufferFullError') {
console.warn(`[HLS] 缓冲区问题 (${data.details}),尝试恢复...`);
hls.recoverMediaError();
}
break;
default:
console.warn(`[HLS] 非致命错误: ${data.type} - ${data.details}`);
break;
}
}
});
hlsInstance = hls;
/**
* 卡死检测:每 10 秒检查一次是否还在正常加载分片
* 如果连续多次加载失败,触发 onStall 回调(自动刷新)
*/
state.stallTimer = setInterval(() => {
if (state.destroyed || !onStall) return;
if (state.consecutiveFailures >= 3) {
console.warn(`[HLS] 连续 ${state.consecutiveFailures} 次加载失败,触发自动刷新`);
state.consecutiveFailures = 0;
onStall();
}
}, 10000);
// 播放开始后启动 live edge 追赶
hls.on(Hls.Events.FRAG_BUFFERED, () => {
// 加载成功,重置失败计数
state.consecutiveFailures = 0;
if (!state.syncTimer) {
startLiveSync(state);
}
});
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
// Safari/原生支持 HLS
// Safari 原生 HLS
video.src = url;
video.addEventListener('loadedmetadata', () => {
try {
video.play().catch(() => {});
} catch (e) {}
} catch {
// ignore
}
});
} else {
//proxy?.$modal.msgWarning('当前浏览器不支持 HLS 播放');
ElMessage.error('当前浏览器不支持 HLS 播放');
}
};
export const destroyHls = (monitorVideoRef) => {
try {
if (hlsInstance) {
hlsInstance.destroy();
hlsInstance = null;
}
} catch (e) {}
const video = monitorVideoRef.value;
if (video) {
// 销毁该 video 元素上的 HLS 实例
const state = playerStates.get(video);
if (state) {
try {
stopLiveSync(state);
state.destroyed = true;
state.hls.destroy();
} catch {
// ignore
}
playerStates.delete(video);
}
// 清理原生 src避免残留
try {
video.pause();
video.removeAttribute('src');
// 对某些浏览器,需要调用 load() 来重置 media element
if (typeof video.load === 'function') video.load();
} catch (e) {
console.log('Error in destroyHls:', e);

View File

@@ -241,7 +241,7 @@
</template>
<div class="monitor-video-wrap">
<div class="monitor-video-stack">
<video ref="monitorVideoRef" class="monitor-video" preload="metadata" :src="monitorVideoUrl">您的浏览器不支持视频播放</video>
<video ref="monitorVideoRef" class="monitor-video" preload="metadata">您的浏览器不支持视频播放</video>
<div v-show="monitorDrawCollecting" class="monitor-draw-overlay" @click.stop="onMonitorDrawClick" />
<div v-if="monitorRectPx" class="monitor-draw-rect" :style="drawRectStyle" />
<div v-if="monitorRectPx" class="monitor-draw-label" :style="drawLabelStyle">当前报警区域</div>
@@ -471,7 +471,7 @@ const handleSee = async (row?: any) => {
// 等待 DOM 更新后初始化 Hls
await nextTick();
initHls(monitorVideoUrl, monitorVideoRef, () => dialog1.visible);
initHls(monitorVideoUrl, monitorVideoRef, () => dialog1.visible, handleRefresh);
// 等待视频加载完成后初始化 ResizeObserver以便能正确获取视频尺寸
initVideoResizeObserver();
@@ -803,7 +803,7 @@ const handleRefresh = async () => {
monitorVideoUrl.value = videosStore.hisUrl || '';
if (monitorVideoUrl.value) {
await nextTick();
initHls(monitorVideoUrl, monitorVideoRef, () => dialog1.visible);
initHls(monitorVideoUrl, monitorVideoRef, () => dialog1.visible, handleRefresh);
}
};