feat:完善监控播放器

This commit is contained in:
zjc
2025-02-27 21:00:12 +08:00
parent 2bfd46a636
commit 3832e3617f
4 changed files with 406 additions and 77 deletions

View File

@@ -10,11 +10,13 @@
:key="index" :key="index"
@click="handleItem(item)" @click="handleItem(item)"
> >
<div> <HlsPlayer :url="item.hlsUrl" />
<div class="item-unfollow" @click.stop="handleUnfollow(item.id, index)">取消关注</div>
<!-- <div>
<p class="item-title--primary"> <p class="item-title--primary">
{{ item.cameraName || item.cameraIndexCode }} {{ item.cameraName || item.cameraIndexCode }}
</p> </p>
<div class="item-unfollow" @click.stop="handleUnfollow(item.id, index)">取消关注</div>
<video <video
class="item-img" class="item-img"
:id="'video' + index" :id="'video' + index"
@@ -25,7 +27,7 @@
> >
<source src="" type="application/x-mpegURL" /> <source src="" type="application/x-mpegURL" />
</video> </video>
</div> </div> -->
</li> </li>
</ul> </ul>
</div> </div>
@@ -65,25 +67,25 @@
pageSize: 5 pageSize: 5
}) })
list.value = res.data list.value = res.data
nextTick(() => { // nextTick(() => {
list.value.forEach(async (item, index) => { // list.value.forEach(async (item, index) => {
var video = document.getElementById(`video${index}`) // var video = document.getElementById(`video${index}`)
const hls = new Hls({ // const hls = new Hls({
enableWorker: false, // 禁用 Worker 来避免额外的线程 // enableWorker: false, // 禁用 Worker 来避免额外的线程
enableSoftwareAES: true, // 使用软件解码器以避免硬件解码的额外请求 // enableSoftwareAES: true, // 使用软件解码器以避免硬件解码的额外请求
cache: true, // 启用缓存 // cache: true, // 启用缓存
maxBufferLength: 10, // 最大缓冲长度(秒) // maxBufferLength: 10, // 最大缓冲长度(秒)
maxMaxBufferLength: 15, // 缓冲区长度的上限 // maxMaxBufferLength: 15, // 缓冲区长度的上限
maxBufferSize: 20 * 1000 * 1000 // 最大缓冲大小(字节) // maxBufferSize: 20 * 1000 * 1000 // 最大缓冲大小(字节)
}) // })
hls.loadSource(item.hlsUrl) // hls.loadSource(item.hlsUrl)
hls.attachMedia(video) // hls.attachMedia(video)
hls.on(Hls.Events.MANIFEST_PARSED, () => { // hls.on(Hls.Events.MANIFEST_PARSED, () => {
video.play() // video.play()
}) // })
hlsRefs.push(hls) // hlsRefs.push(hls)
}) // })
}) // })
} }
// 释放hls实例 // 释放hls实例
@@ -177,6 +179,7 @@
} }
.item { .item {
position: relative;
margin-bottom: vh(10); margin-bottom: vh(10);
padding: vw(10); padding: vw(10);
background-size: 100% 100%; background-size: 100% 100%;
@@ -186,11 +189,11 @@
position: absolute; position: absolute;
right: vw(4); right: vw(4);
top: vw(4); top: vw(4);
z-index: 99; z-index: 99999;
width: vw(64); width: vw(64);
height: vh(24); height: vw(30);
text-align: center; text-align: center;
line-height: vh(24); line-height: vw(30);
font-weight: 400; font-weight: 400;
font-size: vw(12); font-size: vw(12);
color: #ffffff; color: #ffffff;
@@ -202,10 +205,6 @@
display: block; display: block;
} }
} }
& > div {
position: relative;
}
&-title { &-title {
position: absolute; position: absolute;
bottom: 0; bottom: 0;

View File

@@ -0,0 +1,325 @@
<template>
<div v-show="isActive" class="myVideo-container">
<video
class="myVideo"
ref="videoElement"
muted
:style="videoStyle"
playsinline
:controls="false"
disablePictureInPicture
></video>
<div v-if="loading" class="loading-overlay pointer-events-none">
<div class="loading-text">{{ loadingText }}</div>
</div>
</div>
</template>
<script>
import Hls from 'hls.js'
export default {
name: 'HlsPlayer',
props: {
url: {
type: String,
default: ''
},
isActive: {
type: Boolean,
default: true
},
width: {
type: [String, Number],
default: '100%'
},
height: {
type: [String, Number],
default: '100%'
}
},
data() {
return {
hls: null,
video: null,
loading: false,
loadingText: '加载中...',
retryCount: 0,
maxRetries: 3,
isReady: false,
playAttempts: 0,
maxPlayAttempts: 3,
cleanupTimeout: null
}
},
computed: {
videoStyle() {
const style = {}
if (this.width) {
style.width = typeof this.width === 'number' ? `${this.width}px` : this.width
}
if (this.height) {
style.height = typeof this.height === 'number' ? `${this.height}px` : this.height
}
return style
}
},
watch: {
isActive: {
handler(newValue) {
if (newValue) {
this.$nextTick(this.initializePlayer)
} else {
this.immediateCleanup()
}
},
immediate: true
},
url(newUrl) {
if (newUrl && this.isActive) {
this.initializePlayer()
}
}
},
mounted() {
this.video = this.$refs.videoElement
this.registerVideoEvents()
},
methods: {
registerVideoEvents() {
const events = ['canplay', 'waiting', 'playing', 'error', 'stalled', 'suspend']
events.forEach((event) => {
this.video.addEventListener(event, this.handleVideoEvent)
})
},
handleVideoEvent(event) {
switch (event.type) {
case 'canplay':
this.isReady = true
break
case 'waiting':
this.showLoadingIndicator('缓冲中...')
break
case 'playing':
this.loading = false
break
case 'error':
this.handlePlaybackError()
break
case 'stalled':
this.showLoadingIndicator('播放卡顿...')
break
case 'suspend':
this.cleanupNetworkResources()
break
}
},
immediateCleanup() {
// 立即停止网络请求
if (this.hls) {
this.hls.stopLoad()
this.hls.detachMedia()
}
// 延迟完全清理以避免播放卡顿
this.cleanupTimeout = setTimeout(() => {
// 仅在不重新初始化播放器时才调用 fullCleanup
if (!this.url || !this.isActive) {
this.fullCleanup()
}
}, 1000)
},
fullCleanup() {
if (this.hls) {
this.hls.destroy()
this.hls = null
}
if (this.video) {
this.video.pause()
this.video.removeAttribute('src')
this.video.load()
}
this.loading = false
this.retryCount = 0
this.isReady = false
this.playAttempts = 0
if (this.cleanupTimeout) {
clearTimeout(this.cleanupTimeout)
}
},
initializePlayer() {
if (!this.isActive || !this.url) return
// 如果是重新初始化播放器,先清理已存在的资源
if (this.hls) {
this.immediateCleanup()
}
this.hls = new Hls({
// 内存优化配置
maxBufferSize: 15 * 1000 * 1000, // 降低缓冲区大小15MB
maxBufferLength: 50, // 更小的缓冲窗口
liveSyncDuration: 1, // 紧跟直播点
liveMaxLatencyDuration: 5, // 最大延迟5秒
liveDurationInfinity: true,
lowLatencyMode: false, // 启用低延迟模式
maxMaxBufferLength: 60,
backBufferLength: 1, // 减少保留的缓冲数据
manifestLoadingTimeOut: 10000,
manifestLoadingMaxRetry: 5,
fragLoadingTimeOut: 5000,
fragLoadingMaxRetry: 2,
enableWorker: true, // 启用Web Worker
recycleVideoFrames: true, // 启用帧回收
startLevel: -1,
autoStartLoad: true,
maxBufferHole: 2, // 允许更大的时间缺口
highBufferWatchdogPeriod: 4, // 延长监控周期
nudgeMaxRetry: 5, // 增加微调重试次数
nudgeOffset: 0.05, // 微调步长(秒)
jumpGaps: false,
stallThreshold: 1000
})
this.hls.attachMedia(this.video)
this.hls.loadSource(this.url)
// 事件处理
this.hls.on(Hls.Events.MANIFEST_PARSED, () => {
this.loading = false
this.retryCount = 0
this.safePlay()
})
this.hls.on(Hls.Events.ERROR, (event, data) => {
this.handleHlsError(data)
})
this.hls.on(Hls.Events.BUFFER_EOS, () => {
this.cleanupNetworkResources()
})
},
cleanupNetworkResources() {
// 清理已播放的缓冲数据
if (this.hls && this.video) {
try {
const currentTime = this.video.currentTime
this.hls.mediaBuffer = null
if (this.hls.bufferTimer) {
clearInterval(this.hls.bufferTimer)
}
this.hls.flushBuffer()
this.video.currentTime = currentTime
} catch (e) {
console.warn('Buffer cleanup error:', e)
}
}
},
handleHlsError(data) {
console.error('HLS Error:', data)
if (data.fatal) {
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
this.retryLoad()
break
case Hls.ErrorTypes.MEDIA_ERROR:
this.hls.recoverMediaError()
break
default:
this.fullCleanup()
break
}
}
},
retryLoad() {
if (this.retryCount < this.maxRetries) {
this.retryCount++
this.showLoadingIndicator(`重试第 ${this.retryCount} 次...`)
setTimeout(() => this.initializePlayer(), 2000)
} else {
this.showLoadingIndicator('视频加载失败')
this.fullCleanup()
}
},
showLoadingIndicator(text) {
this.loading = true
this.loadingText = text
},
async safePlay() {
if (!this.video || this.playAttempts >= this.maxPlayAttempts) return
try {
this.playAttempts++
if (this.video.readyState >= 2) {
await this.video.play()
this.playAttempts = 0
} else {
setTimeout(this.safePlay, 500)
}
} catch (error) {
console.warn('播放失败:', error)
setTimeout(this.safePlay, 1000)
}
}
},
beforeDestroy() {
// 移除所有事件监听
if (this.video) {
const events = ['canplay', 'waiting', 'playing', 'error', 'stalled', 'suspend']
events.forEach((event) => {
this.video.removeEventListener(event, this.handleVideoEvent)
})
}
this.fullCleanup()
}
}
</script>
<style scoped lang="scss">
.myVideo-container {
position: relative;
height: 100%;
}
.myVideo {
width: 100%;
height: auto;
// aspect-ratio: 16/9;
/* border: 1px solid #ccc; */
border-radius: vw(5);
background-color: #000;
}
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.3);
display: flex;
justify-content: center;
align-items: center;
}
.loading-text {
color: white;
padding: vw(10);
border-radius: vw(4);
background: rgba(0, 0, 0, 0.6);
}
</style>

View File

@@ -8,11 +8,13 @@
:z-index="9999" :z-index="9999"
destroy-on-close destroy-on-close
> >
<div v-if="src" class="dialog-box"> <div class="dialog-box">
<video class="video" ref="videoRef" muted autoplay controls> <div class="video">
<HlsPlayer :url="src" />
</div>
<!-- <video class="video" ref="videoRef" muted autoplay controls>
<source type="application/x-mpegURL" /> <source type="application/x-mpegURL" />
</video> </video> -->
<div class="action-box"> <div class="action-box">
<div class="action-item"> <div class="action-item">
<img src="@/assets/images/plus.png" title="焦距变大" @click="handleAction(Z00M_IN)" /> <img src="@/assets/images/plus.png" title="焦距变大" @click="handleAction(Z00M_IN)" />
@@ -33,7 +35,6 @@
</div> </div>
</div> </div>
</div> </div>
<p v-else class="none">暂无信号</p>
<img class="close" src="@/assets/images/close.png" @click="handleClose" /> <img class="close" src="@/assets/images/close.png" @click="handleClose" />
</el-dialog> </el-dialog>
</div> </div>
@@ -71,19 +72,19 @@
let webRtcServer = null let webRtcServer = null
let hlsRef = null let hlsRef = null
watch( // watch(
() => modelValue.value, // () => modelValue.value,
(val) => { // (val) => {
if (val) { // if (val) {
setTimeout(() => { // setTimeout(() => {
init() // init()
}, 1000) // }, 1000)
} // }
}, // },
{ // {
immediate: true // immediate: true
} // }
) // )
const handleAction = async (e) => { const handleAction = async (e) => {
if (e == STOP) { if (e == STOP) {
@@ -106,8 +107,10 @@
}) })
} }
const handleClose = () => { const handleClose = () => {
if (hlsRef) {
hlsRef.destroy() hlsRef.destroy()
hlsRef = null hlsRef = null
}
modelValue.value = false modelValue.value = false
} }
const init = () => { const init = () => {
@@ -179,6 +182,8 @@
position: absolute; position: absolute;
left: 50%; left: 50%;
top: 50%; top: 50%;
width: vw(1814);
height: vw(980);
color: #fff; color: #fff;
font-weight: bold; font-weight: bold;
font-size: vw(30); font-size: vw(30);

View File

@@ -21,14 +21,20 @@
<el-input placeholder="请输入内容" v-model="cameraName" clearable @input="onInput" /> <el-input placeholder="请输入内容" v-model="cameraName" clearable @input="onInput" />
<img class="search-icon" src="/src/assets/images/search-icon-1.png" alt="" /> <img class="search-icon" src="/src/assets/images/search-icon-1.png" alt="" />
</div> </div>
<div class="tree-box"> <div class="tree-box">
<div class="tree-item" v-for="(item, i) in regionList" :key="i"> <div class="tree-item" v-for="(item, i) in regionList" :key="i">
<div class="tree-item__node" @click="handleRegions(i)"> <div class="tree-item__node" @click="handleRegions(i)">
<img class="tree-item__icon" src="@/assets/images/node.png" alt="" /> <img
class="tree-item__icon"
:class="{ 'tree-item__icon-up': item.show }"
src="@/assets/images/arrow-down.png"
alt=""
/>
<span class="tree-item__name">{{ item.regions }}</span> <span class="tree-item__name">{{ item.regions }}</span>
</div> </div>
<div v-if="!item.show" class="tree-item__child"> <div v-if="item.show" class="tree-item__child">
<img class="tree-item-top__icon" src="@/assets/images/node.png" alt="" /> <!-- <img class="tree-item-top__icon" src="@/assets/images/node.png" alt="" /> -->
<div <div
class="tree-item__child-item" class="tree-item__child-item"
v-for="(resource, x) in item.videoResources" v-for="(resource, x) in item.videoResources"
@@ -179,6 +185,11 @@
const STOP = 'STOP' // 停止操作 const STOP = 'STOP' // 停止操作
let ACTION = '0' let ACTION = '0'
const props = {
value: 'id',
label: 'label',
children: 'children'
}
let command = ref('') let command = ref('')
let videoList = ref([]) let videoList = ref([])
let navList = ref([]) let navList = ref([])
@@ -377,24 +388,10 @@
] ]
} else { } else {
let res = await getVideoRegionsApi({ let res = await getVideoRegionsApi({
businessScenicArea: params.businessScenicArea, cameraName: cameraName.value,
cameraName: cameraName.value businessScenicArea: params.businessScenicArea
}) })
regionList.value = res.data regionList.value = res.data
console.log(regionList.value, 'regionList.value')
// .map((i) => {
// return {
// ...i,
// label: i.regions,
// children: i.videoResources.map((x) => {
// return {
// ...x,
// label: x.cameraName || x.cameraIndexCode,
// children: []
// }
// })
// }
// })
} }
} }
const onMonitorChange = () => { const onMonitorChange = () => {
@@ -517,7 +514,7 @@
cursor: pointer; cursor: pointer;
position: relative; position: relative;
padding-top: vh(20); padding-top: vh(20);
border-left: vw(2) solid #37d8fc; // border-left: vw(2) solid #37d8fc;
&:nth-child(1) { &:nth-child(1) {
padding-top: 0; padding-top: 0;
} }
@@ -529,7 +526,11 @@
&__icon { &__icon {
margin-left: vw(-8); margin-left: vw(-8);
width: vw(16); width: vw(16);
height: vw(16); height: auto;
}
&__icon-up {
@extend .tree-item__icon;
transform: rotate(180deg);
} }
&__name { &__name {
padding: 0 vw(20); padding: 0 vw(20);
@@ -544,8 +545,8 @@
&__child { &__child {
position: relative; position: relative;
margin-top: vh(20); margin-top: vh(20);
margin-left: vw(40); margin-left: vw(20);
border-left: vw(2) solid #37d8fc; // border-left: vw(2) solid #37d8fc;
} }
&-top__icon { &-top__icon {
position: absolute; position: absolute;
@@ -562,17 +563,16 @@
height: vw(16); height: vw(16);
} }
&__child-item { &__child-item {
cursor: pointer;
padding: vh(0) vw(20) vh(20) vw(20); padding: vh(0) vw(20) vh(20) vw(20);
display: block; cursor: pointer;
color: #999;
font-weight: 400; font-weight: 400;
font-size: vw(15); font-size: vw(15);
display: flex;
align-items: flex-start;
color: #ffffff;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
display: flex;
align-items: flex-start;
&:nth-last-of-type(1) { &:nth-last-of-type(1) {
padding: vh(0) vw(20); padding: vh(0) vw(20);
} }