353 lines
9.1 KiB
Vue
353 lines
9.1 KiB
Vue
<template>
|
||
<div v-show="isActive" class="myVideo-container">
|
||
<video
|
||
class="myVideo"
|
||
ref="videoElement"
|
||
muted
|
||
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) {
|
||
// console.log(newUrl,'77777777777777777777777777777777')
|
||
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: 0, // 降低缓冲区大小(15MB)
|
||
maxBufferLength: 0.1, // 更小的缓冲窗口
|
||
liveSyncDuration: 1, // 紧跟直播点
|
||
liveMaxLatencyDuration: 5, // 最大延迟5秒
|
||
liveDurationInfinity: true,
|
||
lowLatencyMode: true, // 启用低延迟模式
|
||
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) => {
|
||
console.log('核心视频错误',data.type)
|
||
// this.hls.startLoad(); //重连
|
||
if (data.type === Hls.ErrorTypes.BUFFER_STALLED_ERROR) {
|
||
console.error('缓冲停滞错误,尝试重新加载视频');
|
||
this.hls.startLoad(); // 尝试重新加载视频
|
||
}
|
||
this.handleHlsError(data)
|
||
})
|
||
|
||
this.hls.on(Hls.Events.BUFFER_EOS, () => {
|
||
this.cleanupNetworkResources()
|
||
})
|
||
},
|
||
initVideo() {
|
||
this.beforeDestroy()
|
||
this.hls = new Hls({
|
||
maxBufferLength: 30, // 最大缓冲长度(秒)
|
||
maxMaxBufferLength: 15, // 缓冲区长度的上限
|
||
maxBufferSize: 30 * 1000 * 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.hls.startLoad(); //重连
|
||
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: 100%;
|
||
// aspect-ratio: 16/9;
|
||
/* border: 1px solid #ccc; */
|
||
// border-radius: vw(5);
|
||
}
|
||
|
||
.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>
|