288 lines
6.7 KiB
Vue
288 lines
6.7 KiB
Vue
<template>
|
|
<div class="hls-player" ref="playerContainer">
|
|
<video
|
|
ref="videoRef"
|
|
class="video-element"
|
|
playsinline
|
|
webkit-playsinline
|
|
x5-playsinline
|
|
:muted="muted"
|
|
:poster="poster"
|
|
@click="togglePlay"
|
|
></video>
|
|
<!-- 控制条 -->
|
|
<div class="controls" v-if="controls">
|
|
<button @click.stop="togglePlay" class="control-btn">
|
|
{{ playing ? '暂停' : '播放' }}
|
|
</button>
|
|
<button @click.stop="toggleFullscreen" class="control-btn">
|
|
{{ isFullscreen ? '退出全屏' : '全屏' }}
|
|
</button>
|
|
<span class="time">{{ currentTimeDisplay }}</span>
|
|
</div>
|
|
<!-- 加载中或错误 -->
|
|
<div v-if="loading" class="loading">加载中...</div>
|
|
<div v-if="error" class="error">{{ error }}</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, onMounted, onBeforeUnmount, watch, computed, nextTick } from 'vue'
|
|
import Hls from 'hls.js'
|
|
|
|
const props = defineProps({
|
|
src: {
|
|
type: String,
|
|
required: true,
|
|
},
|
|
poster: {
|
|
type: String,
|
|
default: '',
|
|
},
|
|
muted: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
controls: {
|
|
type: Boolean,
|
|
default: true,
|
|
},
|
|
autoplay: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
})
|
|
|
|
const videoRef = ref<HTMLVideoElement | null>(null)
|
|
const playerContainer = ref<HTMLElement | null>(null)
|
|
let hls: Hls | null = null
|
|
const playing = ref(false)
|
|
const loading = ref(true)
|
|
const error = ref<string | null>(null)
|
|
const currentTime = ref(0)
|
|
const duration = ref(0)
|
|
|
|
// 全屏状态
|
|
const isFullscreen = ref(false)
|
|
|
|
// 播放/暂停
|
|
const togglePlay = () => {
|
|
if (!videoRef.value) return
|
|
if (playing.value) {
|
|
videoRef.value.pause()
|
|
} else {
|
|
videoRef.value.play().catch(err => {
|
|
// 自动播放被拒绝
|
|
console.warn('Play failed:', err)
|
|
})
|
|
}
|
|
}
|
|
|
|
// 全屏切换
|
|
const toggleFullscreen = () => {
|
|
if (!playerContainer.value) return
|
|
if (!document.fullscreenElement && !document.webkitFullscreenElement) {
|
|
// 进入全屏
|
|
const el = playerContainer.value
|
|
if (el.requestFullscreen) {
|
|
el.requestFullscreen()
|
|
} else if (el.webkitRequestFullscreen) {
|
|
el.webkitRequestFullscreen()
|
|
} else {
|
|
// 降级:尝试 video 原生全屏 (仅 iOS)
|
|
if (videoRef.value && videoRef.value.webkitEnterFullscreen) {
|
|
videoRef.value.webkitEnterFullscreen()
|
|
} else {
|
|
alert('您的浏览器不支持全屏')
|
|
}
|
|
}
|
|
} else {
|
|
// 退出全屏
|
|
if (document.exitFullscreen) {
|
|
document.exitFullscreen()
|
|
} else if (document.webkitExitFullscreen) {
|
|
document.webkitExitFullscreen()
|
|
}
|
|
}
|
|
}
|
|
|
|
// 监听全屏变化
|
|
const onFullscreenChange = () => {
|
|
isFullscreen.value = !!document.fullscreenElement || !!document.webkitFullscreenElement
|
|
}
|
|
|
|
// 更新播放状态
|
|
const updatePlayState = () => {
|
|
if (videoRef.value) {
|
|
playing.value = !videoRef.value.paused
|
|
}
|
|
}
|
|
|
|
// 时间格式化
|
|
const formatTime = (seconds: number) => {
|
|
if (isNaN(seconds)) return '00:00'
|
|
const m = Math.floor(seconds / 60)
|
|
const s = Math.floor(seconds % 60)
|
|
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
|
|
}
|
|
const currentTimeDisplay = computed(() => formatTime(currentTime.value))
|
|
|
|
// 初始化播放器
|
|
const initPlayer = () => {
|
|
if (!videoRef.value || !props.src) return
|
|
const video = videoRef.value
|
|
|
|
// 清理旧实例
|
|
if (hls) {
|
|
hls.destroy()
|
|
hls = null
|
|
}
|
|
loading.value = true
|
|
error.value = null
|
|
|
|
// 检查 HLS 支持
|
|
if (Hls.isSupported()) {
|
|
hls = new Hls({
|
|
enableWorker: true,
|
|
lowLatencyMode: true,
|
|
})
|
|
hls.loadSource(props.src)
|
|
hls.attachMedia(video)
|
|
|
|
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
|
loading.value = false
|
|
if (props.autoplay) {
|
|
video.play().catch(err => {
|
|
console.warn('Autoplay prevented:', err)
|
|
// 显示播放按钮让用户交互
|
|
})
|
|
}
|
|
})
|
|
|
|
hls.on(Hls.Events.ERROR, (event, data) => {
|
|
if (data.fatal) {
|
|
error.value = '播放出错,请刷新重试'
|
|
loading.value = false
|
|
}
|
|
})
|
|
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
|
// 原生 HLS 支持 (iOS Safari)
|
|
video.src = props.src
|
|
video.addEventListener('loadedmetadata', () => {
|
|
loading.value = false
|
|
if (props.autoplay) {
|
|
video.play().catch(() => {})
|
|
}
|
|
})
|
|
video.addEventListener('error', () => {
|
|
error.value = '视频加载失败'
|
|
loading.value = false
|
|
})
|
|
} else {
|
|
error.value = '您的浏览器不支持播放此视频'
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
// 监听 src 变化
|
|
watch(() => props.src, () => {
|
|
initPlayer()
|
|
}, { immediate: false })
|
|
|
|
// 生命周期
|
|
onMounted(() => {
|
|
initPlayer()
|
|
// 绑定事件
|
|
const video = videoRef.value
|
|
if (video) {
|
|
video.addEventListener('play', updatePlayState)
|
|
video.addEventListener('pause', updatePlayState)
|
|
video.addEventListener('timeupdate', () => {
|
|
currentTime.value = video.currentTime
|
|
})
|
|
video.addEventListener('durationchange', () => {
|
|
duration.value = video.duration
|
|
})
|
|
}
|
|
// 全屏事件监听
|
|
document.addEventListener('fullscreenchange', onFullscreenChange)
|
|
document.addEventListener('webkitfullscreenchange', onFullscreenChange)
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
if (hls) {
|
|
hls.destroy()
|
|
hls = null
|
|
}
|
|
const video = videoRef.value
|
|
if (video) {
|
|
video.removeEventListener('play', updatePlayState)
|
|
video.removeEventListener('pause', updatePlayState)
|
|
video.removeEventListener('timeupdate', () => {})
|
|
video.removeEventListener('durationchange', () => {})
|
|
}
|
|
document.removeEventListener('fullscreenchange', onFullscreenChange)
|
|
document.removeEventListener('webkitfullscreenchange', onFullscreenChange)
|
|
})
|
|
|
|
// 暴露方法 (供父组件调用)
|
|
defineExpose({
|
|
play: () => videoRef.value?.play(),
|
|
pause: () => videoRef.value?.pause(),
|
|
togglePlay,
|
|
toggleFullscreen,
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.hls-player {
|
|
position: relative;
|
|
width: 100%;
|
|
background: #000;
|
|
overflow: hidden;
|
|
}
|
|
.video-element {
|
|
width: 100%;
|
|
display: block;
|
|
background: #000;
|
|
}
|
|
.controls {
|
|
position: absolute;
|
|
bottom: 10px;
|
|
left: 10px;
|
|
right: 10px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
background: rgba(0,0,0,0.5);
|
|
padding: 8px 12px;
|
|
border-radius: 4px;
|
|
color: #fff;
|
|
}
|
|
.control-btn {
|
|
background: transparent;
|
|
border: none;
|
|
color: #fff;
|
|
font-size: 16px;
|
|
cursor: pointer;
|
|
padding: 4px 8px;
|
|
}
|
|
.time {
|
|
margin-left: auto;
|
|
font-size: 14px;
|
|
}
|
|
.loading, .error {
|
|
position: absolute;
|
|
top: 50%;
|
|
left: 50%;
|
|
transform: translate(-50%, -50%);
|
|
color: #fff;
|
|
background: rgba(0,0,0,0.6);
|
|
padding: 10px 20px;
|
|
border-radius: 4px;
|
|
}
|
|
.error {
|
|
background: rgba(255,0,0,0.6);
|
|
}
|
|
</style>
|