feat 直播流优化
This commit is contained in:
@@ -5,6 +5,10 @@
|
|||||||
"ComputedRef": true,
|
"ComputedRef": true,
|
||||||
"DirectiveBinding": true,
|
"DirectiveBinding": true,
|
||||||
"EffectScope": true,
|
"EffectScope": true,
|
||||||
|
"ElLoading": true,
|
||||||
|
"ElMessage": true,
|
||||||
|
"ElMessageBox": true,
|
||||||
|
"ElNotification": true,
|
||||||
"ExtractDefaultPropTypes": true,
|
"ExtractDefaultPropTypes": true,
|
||||||
"ExtractPropTypes": true,
|
"ExtractPropTypes": true,
|
||||||
"ExtractPublicPropTypes": true,
|
"ExtractPublicPropTypes": true,
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
</template>
|
</template>
|
||||||
<div class="monitor-video-wrap">
|
<div class="monitor-video-wrap">
|
||||||
<div class="monitor-video-stack">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
@@ -57,7 +57,7 @@ import { addDemo } from '@/api/video/index';
|
|||||||
|
|
||||||
const { proxy } = getCurrentInstance() as ComponentInternalInstance;
|
const { proxy } = getCurrentInstance() as ComponentInternalInstance;
|
||||||
|
|
||||||
import { nextTick } from 'vue';
|
import { nextTick, onUnmounted } from 'vue';
|
||||||
import { useVideosStore } from '@/store/modules/video';
|
import { useVideosStore } from '@/store/modules/video';
|
||||||
const videosStore = useVideosStore();
|
const videosStore = useVideosStore();
|
||||||
const monitoringArea = ref<string>('');
|
const monitoringArea = ref<string>('');
|
||||||
@@ -111,6 +111,20 @@ const parseNoticeItem = (notice: any) => {
|
|||||||
|
|
||||||
//查看画面
|
//查看画面
|
||||||
const currentDeviceId = ref<string | number>();
|
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) => {
|
const handleSee = async (row: any) => {
|
||||||
currentDeviceId.value = row?.deviceId;
|
currentDeviceId.value = row?.deviceId;
|
||||||
const videoId = row?.deviceId;
|
const videoId = row?.deviceId;
|
||||||
@@ -124,7 +138,7 @@ const handleSee = async (row: any) => {
|
|||||||
monitoringArea.value = row.monitoringArea;
|
monitoringArea.value = row.monitoringArea;
|
||||||
dialog.visible = true;
|
dialog.visible = true;
|
||||||
await nextTick();
|
await nextTick();
|
||||||
initHls(monitorVideoUrl, monitorVideoRef);
|
initHls(monitorVideoUrl, monitorVideoRef, () => dialog.visible, handleRefresh);
|
||||||
await handleAction(row.id);
|
await handleAction(row.id);
|
||||||
await getList();
|
await getList();
|
||||||
};
|
};
|
||||||
@@ -219,9 +233,14 @@ onMounted(() => {
|
|||||||
|
|
||||||
onBeforeRouteLeave(() => {
|
onBeforeRouteLeave(() => {
|
||||||
dialog.visible = false;
|
dialog.visible = false;
|
||||||
|
destroyHls(monitorVideoRef);
|
||||||
monitorVideoUrl.value = '';
|
monitorVideoUrl.value = '';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
destroyHls(monitorVideoRef);
|
||||||
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
getList();
|
getList();
|
||||||
console.log('111111111111111111111_warningList.value', warningList.value);
|
console.log('111111111111111111111_warningList.value', warningList.value);
|
||||||
|
|||||||
@@ -31,11 +31,11 @@ export const initSSE = (url: any) => {
|
|||||||
if (!data.value) return;
|
if (!data.value) return;
|
||||||
|
|
||||||
// 打印接收到的 SSE 消息
|
// 打印接收到的 SSE 消息
|
||||||
console.log('=== SSE 接收到消息 ===');
|
// console.log('=== SSE 接收到消息 ===');
|
||||||
console.log('消息内容:', data.value);
|
// console.log('消息内容:', data.value);
|
||||||
console.log('消息类型:', typeof data.value);
|
// console.log('消息类型:', typeof data.value);
|
||||||
console.log('接收时间:', new Date().toLocaleString());
|
// console.log('接收时间:', new Date().toLocaleString());
|
||||||
console.log('=====================');
|
// console.log('=====================');
|
||||||
|
|
||||||
useNoticeStore().addNotice({
|
useNoticeStore().addNotice({
|
||||||
message: data.value,
|
message: data.value,
|
||||||
|
|||||||
@@ -1,100 +1,228 @@
|
|||||||
import Hls from 'hls.js';
|
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);
|
console.log('isDialogVisible', isDialogVisible);
|
||||||
const url = monitorVideoUrl.value;
|
const url = monitorVideoUrl.value;
|
||||||
const video = monitorVideoRef.value;
|
const video = monitorVideoRef.value;
|
||||||
if (!url || !video) return;
|
if (!url || !video) return;
|
||||||
|
|
||||||
// 销毁已有实例
|
// 先销毁该 video 元素上已有的实例(不再操作全局变量)
|
||||||
if (hlsInstance) {
|
const existingState = playerStates.get(video);
|
||||||
|
if (existingState) {
|
||||||
try {
|
try {
|
||||||
hlsInstance.destroy();
|
stopLiveSync(existingState);
|
||||||
} catch (e) {
|
existingState.destroyed = true;
|
||||||
|
existingState.hls.destroy();
|
||||||
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
hlsInstance = null;
|
playerStates.delete(video);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Hls.isSupported()) {
|
if (Hls.isSupported()) {
|
||||||
const hls = new Hls({
|
const hls = new Hls({
|
||||||
// ★ ZLM 注册流需要时间,配置自动重试
|
// ★ ZLM 注册流需要时间,配置自动重试
|
||||||
manifestLoadingMaxRetry: 5, // m3u8 加载最多重试20次
|
manifestLoadingMaxRetry: 10,
|
||||||
manifestLoadingRetryDelay: 500, // 每次重试间隔500ms
|
manifestLoadingRetryDelay: 500,
|
||||||
manifestLoadingTimeOut: 5000, // m3u8 请求超时5秒
|
manifestLoadingTimeOut: 5000,
|
||||||
levelLoadingMaxRetry: 2, // ts 分片加载最多重试20次
|
levelLoadingMaxRetry: 5,
|
||||||
levelLoadingRetryDelay: 500,
|
levelLoadingRetryDelay: 500,
|
||||||
lowLatencyMode: false, // 关掉低延迟模式,更稳定
|
lowLatencyMode: false,
|
||||||
startLevel: -1 // 自动选择码率
|
startLevel: -1,
|
||||||
|
// ★ 直播流关键配置
|
||||||
|
liveSyncDurationCount: 3,
|
||||||
|
liveDurationInfinity: true,
|
||||||
|
// 缓冲区上限,防止无限增长导致内存爆掉
|
||||||
|
maxBufferLength: 30,
|
||||||
|
maxMaxBufferLength: 60
|
||||||
});
|
});
|
||||||
|
|
||||||
hls.loadSource(url);
|
hls.loadSource(url);
|
||||||
hls.attachMedia(video);
|
hls.attachMedia(video);
|
||||||
|
|
||||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||||
// 自动播放(若浏览器允许)
|
|
||||||
try {
|
try {
|
||||||
video.play().catch(() => {});
|
video.play().catch(() => {});
|
||||||
} catch (e) {}
|
} catch {
|
||||||
// 加载成功,重置重试计数
|
// ignore
|
||||||
// hlsRetryCount = 0;
|
}
|
||||||
});
|
});
|
||||||
// ★ 错误自动恢复: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) => {
|
hls.on(Hls.Events.ERROR, (event, data) => {
|
||||||
if (data.fatal && (!isDialogVisible || isDialogVisible())) {
|
// 对话框已关闭则不再恢复
|
||||||
|
if (isDialogVisible && !isDialogVisible()) return;
|
||||||
|
|
||||||
|
if (data.fatal) {
|
||||||
switch (data.type) {
|
switch (data.type) {
|
||||||
case Hls.ErrorTypes.NETWORK_ERROR:
|
case Hls.ErrorTypes.NETWORK_ERROR:
|
||||||
|
if (data.details === 'manifestLoadError' || data.details === 'manifestLoadTimeOut') {
|
||||||
|
console.error('网络错误');
|
||||||
|
// if (onStall) {
|
||||||
|
// onStall();
|
||||||
|
// }
|
||||||
|
} else {
|
||||||
|
console.warn('[HLS] 网络错误,自动恢复...');
|
||||||
hls.startLoad();
|
hls.startLoad();
|
||||||
hls.once(Hls.Events.FRAG_LOADED, () => {
|
}
|
||||||
//分片加载成功后播放
|
|
||||||
try {
|
|
||||||
video.play().catch(() => {});
|
|
||||||
} catch (e) {}
|
|
||||||
});
|
|
||||||
ElMessage.error('网络错误, 请检查网络或重试');
|
|
||||||
break;
|
break;
|
||||||
case Hls.ErrorTypes.MEDIA_ERROR:
|
case Hls.ErrorTypes.MEDIA_ERROR:
|
||||||
// 媒体解码错误 → 尝试恢复
|
console.warn('[HLS] 媒体解码错误,尝试恢复...');
|
||||||
hls.recoverMediaError();
|
hls.recoverMediaError();
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
// 其他致命错误 → 销毁,让下一次 initHls 重建
|
console.error('[HLS] 不可恢复的错误,销毁实例');
|
||||||
|
state.destroyed = true;
|
||||||
|
stopLiveSync(state);
|
||||||
hls.destroy();
|
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;
|
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')) {
|
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||||
// Safari/原生支持 HLS
|
// Safari 原生 HLS
|
||||||
video.src = url;
|
video.src = url;
|
||||||
video.addEventListener('loadedmetadata', () => {
|
video.addEventListener('loadedmetadata', () => {
|
||||||
try {
|
try {
|
||||||
video.play().catch(() => {});
|
video.play().catch(() => {});
|
||||||
} catch (e) {}
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
//proxy?.$modal.msgWarning('当前浏览器不支持 HLS 播放');
|
|
||||||
ElMessage.error('当前浏览器不支持 HLS 播放');
|
ElMessage.error('当前浏览器不支持 HLS 播放');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const destroyHls = (monitorVideoRef) => {
|
export const destroyHls = (monitorVideoRef) => {
|
||||||
try {
|
|
||||||
if (hlsInstance) {
|
|
||||||
hlsInstance.destroy();
|
|
||||||
hlsInstance = null;
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
const video = monitorVideoRef.value;
|
const video = monitorVideoRef.value;
|
||||||
if (video) {
|
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,避免残留
|
// 清理原生 src,避免残留
|
||||||
try {
|
try {
|
||||||
video.pause();
|
video.pause();
|
||||||
video.removeAttribute('src');
|
video.removeAttribute('src');
|
||||||
// 对某些浏览器,需要调用 load() 来重置 media element
|
|
||||||
if (typeof video.load === 'function') video.load();
|
if (typeof video.load === 'function') video.load();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log('Error in destroyHls:', e);
|
console.log('Error in destroyHls:', e);
|
||||||
|
|||||||
@@ -241,7 +241,7 @@
|
|||||||
</template>
|
</template>
|
||||||
<div class="monitor-video-wrap">
|
<div class="monitor-video-wrap">
|
||||||
<div class="monitor-video-stack">
|
<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-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-rect" :style="drawRectStyle" />
|
||||||
<div v-if="monitorRectPx" class="monitor-draw-label" :style="drawLabelStyle">当前报警区域</div>
|
<div v-if="monitorRectPx" class="monitor-draw-label" :style="drawLabelStyle">当前报警区域</div>
|
||||||
@@ -471,7 +471,7 @@ const handleSee = async (row?: any) => {
|
|||||||
|
|
||||||
// 等待 DOM 更新后初始化 Hls
|
// 等待 DOM 更新后初始化 Hls
|
||||||
await nextTick();
|
await nextTick();
|
||||||
initHls(monitorVideoUrl, monitorVideoRef, () => dialog1.visible);
|
initHls(monitorVideoUrl, monitorVideoRef, () => dialog1.visible, handleRefresh);
|
||||||
// 等待视频加载完成后初始化 ResizeObserver,以便能正确获取视频尺寸
|
// 等待视频加载完成后初始化 ResizeObserver,以便能正确获取视频尺寸
|
||||||
initVideoResizeObserver();
|
initVideoResizeObserver();
|
||||||
|
|
||||||
@@ -803,7 +803,7 @@ const handleRefresh = async () => {
|
|||||||
monitorVideoUrl.value = videosStore.hisUrl || '';
|
monitorVideoUrl.value = videosStore.hisUrl || '';
|
||||||
if (monitorVideoUrl.value) {
|
if (monitorVideoUrl.value) {
|
||||||
await nextTick();
|
await nextTick();
|
||||||
initHls(monitorVideoUrl, monitorVideoRef, () => dialog1.visible);
|
initHls(monitorVideoUrl, monitorVideoRef, () => dialog1.visible, handleRefresh);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user