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);
}
};