2026-05-19 18:56:33 +08:00
|
|
|
|
<template>
|
2026-06-05 16:10:14 +08:00
|
|
|
|
<div
|
|
|
|
|
|
class="live-video-box"
|
|
|
|
|
|
:class="{ 'no-title': !showTitle }"
|
|
|
|
|
|
:style="{ height }"
|
|
|
|
|
|
>
|
2026-05-19 18:56:33 +08:00
|
|
|
|
<!-- 有视频时显示播放器 -->
|
|
|
|
|
|
<div v-if="videoUrl" class="video-container">
|
|
|
|
|
|
<!-- 视频标题和关闭按钮 -->
|
2026-06-05 16:10:14 +08:00
|
|
|
|
<div v-if="showTitle" class="live-video-title">
|
2026-05-19 18:56:33 +08:00
|
|
|
|
{{ videoData?.flnm }}
|
|
|
|
|
|
<div class="title-actions">
|
|
|
|
|
|
<CloseOutlined @click="handleClose" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div :id="playerId" class="video-player"></div>
|
2026-06-18 09:29:15 +08:00
|
|
|
|
<!-- 自定义错误提示 -->
|
|
|
|
|
|
<div v-if="showError" class="custom-error-overlay">
|
|
|
|
|
|
<div class="error-content">
|
|
|
|
|
|
<i class="iconfont icon-jiankong error-icon"></i>
|
|
|
|
|
|
<div class="error-text">数据源错误,无法加载视频</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-05-19 18:56:33 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 无视频时显示占位 -->
|
|
|
|
|
|
<div v-else class="video-empty">
|
|
|
|
|
|
<i class="iconfont icon-jiankong"></i>
|
|
|
|
|
|
<div>请选择视频</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
2026-06-05 16:10:14 +08:00
|
|
|
|
import { ref, onMounted, onUnmounted, watch, computed } from 'vue';
|
|
|
|
|
|
import { CloseOutlined } from '@ant-design/icons-vue';
|
|
|
|
|
|
import videojs from 'video.js';
|
|
|
|
|
|
import 'video.js/dist/video-js.css';
|
|
|
|
|
|
const baseUrl = import.meta.env.VITE_APP_ATTACHMENT_URL;
|
2026-05-19 18:56:33 +08:00
|
|
|
|
|
2026-06-18 09:29:15 +08:00
|
|
|
|
// 错误提示显示状态
|
|
|
|
|
|
const showError = ref(false);
|
|
|
|
|
|
|
2026-05-19 18:56:33 +08:00
|
|
|
|
// 注册 HLS 支持(video.js 8.x 内置了 HLS 支持)
|
|
|
|
|
|
|
|
|
|
|
|
interface VideoData {
|
|
|
|
|
|
flnm?: string;
|
|
|
|
|
|
stnm?: string;
|
|
|
|
|
|
url?: string;
|
|
|
|
|
|
urls?: string[];
|
|
|
|
|
|
[key: string]: any;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-05 16:10:14 +08:00
|
|
|
|
const props = withDefaults(
|
|
|
|
|
|
defineProps<{
|
|
|
|
|
|
videoData?: VideoData | null;
|
|
|
|
|
|
height?: string;
|
|
|
|
|
|
monitorType?: 'live' | 'record'; // 监控类型:live-实时视频, record-录像
|
|
|
|
|
|
showTitle?: boolean; // 是否显示标题栏
|
|
|
|
|
|
}>(),
|
|
|
|
|
|
{
|
|
|
|
|
|
showTitle: true
|
|
|
|
|
|
}
|
|
|
|
|
|
);
|
2026-05-19 18:56:33 +08:00
|
|
|
|
|
|
|
|
|
|
const emit = defineEmits<{
|
|
|
|
|
|
close: [nodeKey?: string];
|
|
|
|
|
|
}>();
|
|
|
|
|
|
|
|
|
|
|
|
// 生成唯一ID
|
|
|
|
|
|
const playerId = `video-player-${Math.random().toString(36).substr(2, 9)}`;
|
|
|
|
|
|
let player: any = null;
|
|
|
|
|
|
|
|
|
|
|
|
// 获取第一个有效的URL
|
|
|
|
|
|
const videoUrl = computed(() => {
|
2026-06-05 16:10:14 +08:00
|
|
|
|
if (!props.videoData) return '';
|
2026-05-19 18:56:33 +08:00
|
|
|
|
|
|
|
|
|
|
// 优先使用url字段
|
|
|
|
|
|
if (props.videoData.url) return props.videoData.url;
|
|
|
|
|
|
|
|
|
|
|
|
// 其次使用urls数组的第一个
|
|
|
|
|
|
if (
|
|
|
|
|
|
props.videoData.urls &&
|
|
|
|
|
|
Array.isArray(props.videoData.urls) &&
|
|
|
|
|
|
props.videoData.urls.length > 0
|
|
|
|
|
|
) {
|
|
|
|
|
|
return props.videoData.urls[0];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-05 16:10:14 +08:00
|
|
|
|
return '';
|
2026-05-19 18:56:33 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 初始化 video.js 播放器
|
|
|
|
|
|
const initPlayer = () => {
|
|
|
|
|
|
if (!videoUrl.value) return;
|
|
|
|
|
|
|
|
|
|
|
|
// 销毁旧播放器
|
|
|
|
|
|
if (player) {
|
|
|
|
|
|
player.dispose();
|
|
|
|
|
|
player = null;
|
|
|
|
|
|
}
|
2026-06-18 09:29:15 +08:00
|
|
|
|
|
|
|
|
|
|
// 重试计数器(用于限制重试次数)
|
|
|
|
|
|
let retryCount = 0;
|
|
|
|
|
|
const maxRetries = 5;
|
|
|
|
|
|
|
2026-05-19 18:56:33 +08:00
|
|
|
|
try {
|
|
|
|
|
|
// 创建 video 元素
|
2026-06-05 16:10:14 +08:00
|
|
|
|
const videoElement = document.createElement('video');
|
|
|
|
|
|
videoElement.className = 'video-js vjs-default-skin vjs-big-play-centered';
|
|
|
|
|
|
videoElement.setAttribute('id', playerId);
|
|
|
|
|
|
videoElement.setAttribute('playsinline', 'playsinline');
|
|
|
|
|
|
videoElement.setAttribute('webkit-playsinline', 'webkit-playsinline');
|
2026-05-19 18:56:33 +08:00
|
|
|
|
|
|
|
|
|
|
// 将 video 元素添加到容器
|
|
|
|
|
|
const container = document.getElementById(playerId);
|
|
|
|
|
|
if (container) {
|
2026-06-05 16:10:14 +08:00
|
|
|
|
container.innerHTML = '';
|
2026-05-19 18:56:33 +08:00
|
|
|
|
container.appendChild(videoElement);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 根据 monitorType 判断是否为直播流
|
|
|
|
|
|
// live: 实时视频流(m3u8),显示直播UI
|
|
|
|
|
|
// record: 录像文件,显示进度条
|
2026-06-05 16:10:14 +08:00
|
|
|
|
const isLiveStream = props.monitorType === 'live';
|
2026-05-19 18:56:33 +08:00
|
|
|
|
|
|
|
|
|
|
// 根据 URL 后缀或监控类型确定视频 MIME Type
|
|
|
|
|
|
const getVideoType = (url: string): string => {
|
|
|
|
|
|
const lowerUrl = url.toLowerCase();
|
|
|
|
|
|
|
|
|
|
|
|
// 优先根据 URL 后缀判断
|
2026-06-05 16:10:14 +08:00
|
|
|
|
if (lowerUrl.includes('.m3u8')) {
|
|
|
|
|
|
return 'application/x-mpegURL'; // HLS 流
|
|
|
|
|
|
} else if (lowerUrl.includes('.mp4')) {
|
|
|
|
|
|
return 'video/mp4';
|
|
|
|
|
|
} else if (lowerUrl.includes('.webm')) {
|
|
|
|
|
|
return 'video/webm';
|
|
|
|
|
|
} else if (lowerUrl.includes('.ogg') || lowerUrl.includes('.ogv')) {
|
|
|
|
|
|
return 'video/ogg';
|
|
|
|
|
|
} else if (lowerUrl.includes('.avi')) {
|
|
|
|
|
|
return 'video/x-msvideo';
|
|
|
|
|
|
} else if (lowerUrl.includes('.mov')) {
|
|
|
|
|
|
return 'video/quicktime';
|
|
|
|
|
|
} else if (lowerUrl.includes('.wmv')) {
|
|
|
|
|
|
return 'video/x-ms-wmv';
|
|
|
|
|
|
} else if (lowerUrl.includes('.flv')) {
|
|
|
|
|
|
return 'video/x-flv';
|
2026-05-19 18:56:33 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 如果 URL 中没有明确后缀,根据监控类型判断
|
2026-06-05 16:10:14 +08:00
|
|
|
|
return isLiveStream ? 'application/x-mpegURL' : 'video/mp4';
|
2026-05-19 18:56:33 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const videoType = getVideoType(videoUrl.value);
|
|
|
|
|
|
|
|
|
|
|
|
// 初始化 video.js
|
|
|
|
|
|
player = videojs(videoElement, {
|
|
|
|
|
|
autoplay: true,
|
|
|
|
|
|
muted: true,
|
|
|
|
|
|
controls: true,
|
|
|
|
|
|
fluid: false,
|
2026-06-05 16:10:14 +08:00
|
|
|
|
width: '100%',
|
|
|
|
|
|
height: '100%',
|
|
|
|
|
|
preload: 'auto',
|
2026-05-19 18:56:33 +08:00
|
|
|
|
playbackRates: [0.5, 1, 1.5, 2],
|
|
|
|
|
|
liveui: isLiveStream, // 直播流显示直播UI
|
|
|
|
|
|
// 录像模式下禁用直播相关功能
|
|
|
|
|
|
inactivityTimeout: 0, // 禁用不活动超时
|
|
|
|
|
|
controlBar: {
|
|
|
|
|
|
children: [
|
2026-06-05 16:10:14 +08:00
|
|
|
|
'playToggle',
|
|
|
|
|
|
'volumePanel',
|
|
|
|
|
|
'currentTimeDisplay',
|
|
|
|
|
|
'timeDivider',
|
|
|
|
|
|
'durationDisplay',
|
|
|
|
|
|
'progressControl',
|
|
|
|
|
|
'liveDisplay',
|
|
|
|
|
|
'remainingTimeDisplay',
|
|
|
|
|
|
'customControlSpacer',
|
|
|
|
|
|
'playbackRateMenuButton',
|
|
|
|
|
|
'fullscreenToggle'
|
|
|
|
|
|
]
|
2026-05-19 18:56:33 +08:00
|
|
|
|
},
|
|
|
|
|
|
html5: {
|
|
|
|
|
|
hls: {
|
|
|
|
|
|
enableLowInitialPlaylist: true,
|
2026-06-05 16:10:14 +08:00
|
|
|
|
smoothQualityChange: true
|
|
|
|
|
|
}
|
2026-05-19 18:56:33 +08:00
|
|
|
|
},
|
|
|
|
|
|
sources: [
|
|
|
|
|
|
{
|
|
|
|
|
|
src: videoUrl.value,
|
2026-06-05 16:10:14 +08:00
|
|
|
|
type: videoType // 指定视频类型
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
2026-05-19 18:56:33 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 如果是录像模式,隐藏直播指示器
|
|
|
|
|
|
if (!isLiveStream) {
|
|
|
|
|
|
player.ready(() => {
|
2026-06-05 16:10:14 +08:00
|
|
|
|
const liveDisplay = player.controlBar.getChild('liveDisplay');
|
2026-05-19 18:56:33 +08:00
|
|
|
|
if (liveDisplay) {
|
2026-06-05 16:10:14 +08:00
|
|
|
|
liveDisplay.el().style.display = 'none';
|
2026-05-19 18:56:33 +08:00
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 监听错误事件
|
2026-06-05 16:10:14 +08:00
|
|
|
|
player.on('error', () => {
|
2026-05-19 18:56:33 +08:00
|
|
|
|
const error = player.error();
|
2026-06-05 16:10:14 +08:00
|
|
|
|
console.error('播放器错误:', error);
|
2026-05-19 18:56:33 +08:00
|
|
|
|
|
2026-06-18 09:29:15 +08:00
|
|
|
|
// 检查是否已达到最大重试次数
|
|
|
|
|
|
if (retryCount >= maxRetries) {
|
|
|
|
|
|
console.warn(`已达到最大重试次数(${maxRetries}次),停止重试`);
|
|
|
|
|
|
// 显示自定义错误提示层
|
|
|
|
|
|
showError.value = true;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 增加重试计数
|
|
|
|
|
|
retryCount++;
|
|
|
|
|
|
console.log(`视频加载失败,正在进行第 ${retryCount}/${maxRetries} 次重试...`);
|
|
|
|
|
|
|
|
|
|
|
|
// 立即显示自定义错误提示层
|
|
|
|
|
|
showError.value = true;
|
|
|
|
|
|
|
|
|
|
|
|
// 延迟后重试
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (player && videoUrl.value) {
|
|
|
|
|
|
// 隐藏错误提示层(重试时)
|
|
|
|
|
|
showError.value = false;
|
|
|
|
|
|
player.src({
|
|
|
|
|
|
src: videoUrl.value,
|
|
|
|
|
|
type: videoType
|
|
|
|
|
|
});
|
|
|
|
|
|
player.load();
|
|
|
|
|
|
player.play();
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.error('重试加载视频失败:', e);
|
|
|
|
|
|
// 如果重试失败且未达到最大次数,继续重试
|
|
|
|
|
|
if (retryCount < maxRetries) {
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
if (player) {
|
|
|
|
|
|
player.error(); // 触发下一次错误处理
|
2026-05-19 18:56:33 +08:00
|
|
|
|
}
|
2026-06-18 09:29:15 +08:00
|
|
|
|
}, 100);
|
|
|
|
|
|
}
|
2026-05-19 18:56:33 +08:00
|
|
|
|
}
|
2026-06-18 09:29:15 +08:00
|
|
|
|
}, 3000);
|
2026-05-19 18:56:33 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 监听加载完成
|
2026-06-18 09:29:15 +08:00
|
|
|
|
player.on('loadeddata', () => {
|
|
|
|
|
|
// 加载成功时重置重试计数器
|
|
|
|
|
|
retryCount = 0;
|
|
|
|
|
|
// 隐藏错误提示
|
|
|
|
|
|
showError.value = false;
|
|
|
|
|
|
});
|
2026-05-19 18:56:33 +08:00
|
|
|
|
|
|
|
|
|
|
// 监听播放状态
|
2026-06-05 16:10:14 +08:00
|
|
|
|
player.on('play', () => {});
|
2026-05-19 18:56:33 +08:00
|
|
|
|
|
|
|
|
|
|
// 监听暂停
|
2026-06-05 16:10:14 +08:00
|
|
|
|
player.on('pause', () => {});
|
2026-05-19 18:56:33 +08:00
|
|
|
|
|
|
|
|
|
|
// 监听等待(缓冲)
|
2026-06-05 16:10:14 +08:00
|
|
|
|
player.on('waiting', () => {});
|
2026-05-19 18:56:33 +08:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
// 静默处理错误
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 关闭视频
|
|
|
|
|
|
const handleClose = () => {
|
|
|
|
|
|
// 传递节点的 key 给父组件
|
2026-06-05 16:10:14 +08:00
|
|
|
|
emit('close', props.videoData?.key);
|
2026-05-19 18:56:33 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 监听视频URL变化
|
|
|
|
|
|
watch(
|
|
|
|
|
|
videoUrl,
|
|
|
|
|
|
(newUrl, oldUrl) => {
|
|
|
|
|
|
if (newUrl && newUrl !== oldUrl) {
|
|
|
|
|
|
// URL 变化时延迟初始化,确保 DOM 已更新
|
|
|
|
|
|
setTimeout(() => initPlayer(), 200);
|
|
|
|
|
|
} else if (newUrl && !oldUrl) {
|
|
|
|
|
|
// 首次加载
|
|
|
|
|
|
setTimeout(() => initPlayer(), 100);
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
{ immediate: true }
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
|
if (videoUrl.value) {
|
|
|
|
|
|
initPlayer();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
onUnmounted(() => {
|
|
|
|
|
|
// 清理播放器
|
|
|
|
|
|
if (player) {
|
|
|
|
|
|
player.dispose();
|
|
|
|
|
|
player = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped lang="scss">
|
|
|
|
|
|
.live-video-box {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
background-color: #000;
|
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
.live-video-title {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
height: 40px;
|
|
|
|
|
|
background-color: #2f6b98;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
padding: 8px 16px;
|
|
|
|
|
|
color: #fff;
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
|
|
|
|
|
|
.title-actions {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
|
|
|
|
|
|
span {
|
|
|
|
|
|
font-size: 16px;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
transition: opacity 0.2s;
|
|
|
|
|
|
|
|
|
|
|
|
&:hover {
|
|
|
|
|
|
opacity: 0.8;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.video-container {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
height: calc(100% - 40px);
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
|
|
|
|
|
|
.video-player {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
|
|
|
|
|
|
// video.js 样式优化
|
|
|
|
|
|
:deep(.video-js) {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
background-color: #000;
|
|
|
|
|
|
|
|
|
|
|
|
video {
|
|
|
|
|
|
object-fit: contain;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-05 16:10:14 +08:00
|
|
|
|
&.no-title {
|
|
|
|
|
|
.video-container {
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-18 09:29:15 +08:00
|
|
|
|
.custom-error-overlay {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
top: 0;
|
|
|
|
|
|
left: 0;
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
background-color: rgba(0, 0, 0);
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
z-index: 10;
|
|
|
|
|
|
|
|
|
|
|
|
.error-content {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
|
|
|
|
|
|
.error-icon {
|
|
|
|
|
|
font-size: 48px;
|
|
|
|
|
|
margin-bottom: 12px;
|
|
|
|
|
|
opacity: 0.5;
|
|
|
|
|
|
color: #999;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.error-text {
|
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
color: #fff;
|
|
|
|
|
|
text-align: center;
|
|
|
|
|
|
margin-top: 12px;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-19 18:56:33 +08:00
|
|
|
|
.video-empty {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
color: #666;
|
|
|
|
|
|
background-color: #1a1a1a;
|
|
|
|
|
|
|
|
|
|
|
|
i {
|
|
|
|
|
|
font-size: 48px;
|
|
|
|
|
|
margin-bottom: 12px;
|
|
|
|
|
|
opacity: 0.5;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
div {
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
color: #999;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
</style>
|