WholeProcessPlatform/frontend/src/views/shiPinJianKong/components/LiveVideoBox.vue
2026-06-18 09:29:15 +08:00

433 lines
10 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div
class="live-video-box"
:class="{ 'no-title': !showTitle }"
:style="{ height }"
>
<!-- 有视频时显示播放器 -->
<div v-if="videoUrl" class="video-container">
<!-- 视频标题和关闭按钮 -->
<div v-if="showTitle" class="live-video-title">
{{ videoData?.flnm }}
<div class="title-actions">
<CloseOutlined @click="handleClose" />
</div>
</div>
<div :id="playerId" class="video-player"></div>
<!-- 自定义错误提示 -->
<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>
</div>
<!-- 无视频时显示占位 -->
<div v-else class="video-empty">
<i class="iconfont icon-jiankong"></i>
<div>请选择视频</div>
</div>
</div>
</template>
<script setup lang="ts">
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;
// 错误提示显示状态
const showError = ref(false);
// 注册 HLS 支持video.js 8.x 内置了 HLS 支持)
interface VideoData {
flnm?: string;
stnm?: string;
url?: string;
urls?: string[];
[key: string]: any;
}
const props = withDefaults(
defineProps<{
videoData?: VideoData | null;
height?: string;
monitorType?: 'live' | 'record'; // 监控类型live-实时视频, record-录像
showTitle?: boolean; // 是否显示标题栏
}>(),
{
showTitle: true
}
);
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(() => {
if (!props.videoData) return '';
// 优先使用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];
}
return '';
});
// 初始化 video.js 播放器
const initPlayer = () => {
if (!videoUrl.value) return;
// 销毁旧播放器
if (player) {
player.dispose();
player = null;
}
// 重试计数器(用于限制重试次数)
let retryCount = 0;
const maxRetries = 5;
try {
// 创建 video 元素
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');
// 将 video 元素添加到容器
const container = document.getElementById(playerId);
if (container) {
container.innerHTML = '';
container.appendChild(videoElement);
}
// 根据 monitorType 判断是否为直播流
// live: 实时视频流m3u8显示直播UI
// record: 录像文件,显示进度条
const isLiveStream = props.monitorType === 'live';
// 根据 URL 后缀或监控类型确定视频 MIME Type
const getVideoType = (url: string): string => {
const lowerUrl = url.toLowerCase();
// 优先根据 URL 后缀判断
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';
}
// 如果 URL 中没有明确后缀,根据监控类型判断
return isLiveStream ? 'application/x-mpegURL' : 'video/mp4';
};
const videoType = getVideoType(videoUrl.value);
// 初始化 video.js
player = videojs(videoElement, {
autoplay: true,
muted: true,
controls: true,
fluid: false,
width: '100%',
height: '100%',
preload: 'auto',
playbackRates: [0.5, 1, 1.5, 2],
liveui: isLiveStream, // 直播流显示直播UI
// 录像模式下禁用直播相关功能
inactivityTimeout: 0, // 禁用不活动超时
controlBar: {
children: [
'playToggle',
'volumePanel',
'currentTimeDisplay',
'timeDivider',
'durationDisplay',
'progressControl',
'liveDisplay',
'remainingTimeDisplay',
'customControlSpacer',
'playbackRateMenuButton',
'fullscreenToggle'
]
},
html5: {
hls: {
enableLowInitialPlaylist: true,
smoothQualityChange: true
}
},
sources: [
{
src: videoUrl.value,
type: videoType // 指定视频类型
}
]
});
// 如果是录像模式,隐藏直播指示器
if (!isLiveStream) {
player.ready(() => {
const liveDisplay = player.controlBar.getChild('liveDisplay');
if (liveDisplay) {
liveDisplay.el().style.display = 'none';
}
});
}
// 监听错误事件
player.on('error', () => {
const error = player.error();
console.error('播放器错误:', error);
// 检查是否已达到最大重试次数
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(); // 触发下一次错误处理
}
}, 100);
}
}
}, 3000);
});
// 监听加载完成
player.on('loadeddata', () => {
// 加载成功时重置重试计数器
retryCount = 0;
// 隐藏错误提示
showError.value = false;
});
// 监听播放状态
player.on('play', () => {});
// 监听暂停
player.on('pause', () => {});
// 监听等待(缓冲)
player.on('waiting', () => {});
} catch (error) {
// 静默处理错误
}
};
// 关闭视频
const handleClose = () => {
// 传递节点的 key 给父组件
emit('close', props.videoData?.key);
};
// 监听视频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;
}
}
}
}
&.no-title {
.video-container {
height: 100%;
}
}
.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;
}
}
}
.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>