feat 直播流优化
This commit is contained in:
@@ -5,6 +5,10 @@
|
||||
"ComputedRef": true,
|
||||
"DirectiveBinding": true,
|
||||
"EffectScope": true,
|
||||
"ElLoading": true,
|
||||
"ElMessage": true,
|
||||
"ElMessageBox": true,
|
||||
"ElNotification": true,
|
||||
"ExtractDefaultPropTypes": true,
|
||||
"ExtractPropTypes": true,
|
||||
"ExtractPublicPropTypes": true,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
if (data.details === 'manifestLoadError' || data.details === 'manifestLoadTimeOut') {
|
||||
console.error('网络错误');
|
||||
// if (onStall) {
|
||||
// onStall();
|
||||
// }
|
||||
} else {
|
||||
console.warn('[HLS] 网络错误,自动恢复...');
|
||||
hls.startLoad();
|
||||
hls.once(Hls.Events.FRAG_LOADED, () => {
|
||||
//分片加载成功后播放
|
||||
try {
|
||||
video.play().catch(() => {});
|
||||
} catch (e) {}
|
||||
});
|
||||
ElMessage.error('网络错误, 请检查网络或重试');
|
||||
}
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user