From bbd23051e76175a237b5034dbf5cb3d6e9337c3f Mon Sep 17 00:00:00 2001 From: Ironsp <1522278303@qq.com> Date: Wed, 22 Jul 2026 11:50:57 +0800 Subject: [PATCH] =?UTF-8?q?feat=20=E7=9B=B4=E6=92=AD=E6=B5=81=E4=BC=98?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .eslintrc-auto-import.json | 4 + src/layout/components/notice/index.vue | 25 ++- src/utils/sse.ts | 10 +- src/utils/video.ts | 214 ++++++++++++++++++++----- src/views/equipmentList/list.vue | 6 +- 5 files changed, 205 insertions(+), 54 deletions(-) diff --git a/.eslintrc-auto-import.json b/.eslintrc-auto-import.json index 2ca3adf..d979925 100644 --- a/.eslintrc-auto-import.json +++ b/.eslintrc-auto-import.json @@ -5,6 +5,10 @@ "ComputedRef": true, "DirectiveBinding": true, "EffectScope": true, + "ElLoading": true, + "ElMessage": true, + "ElMessageBox": true, + "ElNotification": true, "ExtractDefaultPropTypes": true, "ExtractPropTypes": true, "ExtractPublicPropTypes": true, diff --git a/src/layout/components/notice/index.vue b/src/layout/components/notice/index.vue index 73f0338..6eab56b 100644 --- a/src/layout/components/notice/index.vue +++ b/src/layout/components/notice/index.vue @@ -39,7 +39,7 @@
- +
@@ -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(''); @@ -111,6 +111,20 @@ const parseNoticeItem = (notice: any) => { //查看画面 const currentDeviceId = ref(); + +/** 刷新视频(卡死时自动调用) */ +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); diff --git a/src/utils/sse.ts b/src/utils/sse.ts index b464be4..9a8523d 100644 --- a/src/utils/sse.ts +++ b/src/utils/sse.ts @@ -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, diff --git a/src/utils/video.ts b/src/utils/video.ts index 3e366af..3714063 100644 --- a/src/utils/video.ts +++ b/src/utils/video.ts @@ -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 | null; + /** 卡死检测定时器 */ + stallTimer: ReturnType | null; + /** 连续加载失败计数 */ + consecutiveFailures: number; + /** 是否已销毁 */ + destroyed: boolean; +} + +const playerStates = new WeakMap(); + +/** + * 直播流追赶逻辑:检测播放位置与 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); diff --git a/src/views/equipmentList/list.vue b/src/views/equipmentList/list.vue index ae7ee33..70a9372 100644 --- a/src/views/equipmentList/list.vue +++ b/src/views/equipmentList/list.vue @@ -241,7 +241,7 @@
- +
当前报警区域
@@ -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); } };