707 lines
16 KiB
Vue
707 lines
16 KiB
Vue
<template>
|
||
<div class="video-player-wrapper">
|
||
<div class="video-container" ref="videoContainer">
|
||
<!-- 视频播放器 -->
|
||
<video
|
||
ref="videoElement"
|
||
class="video-element"
|
||
:poster="poster"
|
||
@loadedmetadata="onLoadedMetadata"
|
||
@timeupdate="onTimeUpdate"
|
||
@play="onPlay"
|
||
@pause="onPause"
|
||
@ended="onEnded"
|
||
@error="onError"
|
||
preload="metadata"
|
||
playsinline
|
||
webkit-playsinline
|
||
crossorigin="anonymous"
|
||
>
|
||
您的浏览器不支持视频播放。
|
||
</video>
|
||
|
||
<!-- 播放按钮覆盖层 -->
|
||
<div v-if="showPlayButton" class="play-overlay" @click="togglePlay">
|
||
<div class="play-button">
|
||
<svg width="80" height="80" viewBox="0 0 80 80">
|
||
<circle cx="40" cy="40" r="36" fill="rgba(0,0,0,0.7)" stroke="rgba(255,255,255,0.8)" stroke-width="2"/>
|
||
<path d="M32 24L32 56L56 40L32 24Z" fill="white"/>
|
||
</svg>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 加载状态 -->
|
||
<div v-if="loading" class="loading-overlay">
|
||
<div class="loading-spinner">
|
||
<svg width="40" height="40" viewBox="0 0 40 40">
|
||
<circle cx="20" cy="20" r="16" stroke="rgba(255,255,255,0.3)" stroke-width="3" fill="none"/>
|
||
<circle cx="20" cy="20" r="16" stroke="white" stroke-width="3" fill="none"
|
||
stroke-dasharray="100" stroke-dashoffset="75" stroke-linecap="round">
|
||
<animateTransform attributeName="transform" type="rotate" dur="1s" repeatCount="indefinite" values="0 20 20;360 20 20"/>
|
||
</circle>
|
||
</svg>
|
||
</div>
|
||
<p>加载中...</p>
|
||
</div>
|
||
|
||
<!-- 错误状态 -->
|
||
<div v-if="error" class="error-overlay">
|
||
<div class="error-content">
|
||
<svg width="60" height="60" viewBox="0 0 60 60" fill="none">
|
||
<circle cx="30" cy="30" r="25" stroke="#ff4757" stroke-width="3"/>
|
||
<path d="M20 20L40 40M40 20L20 40" stroke="#ff4757" stroke-width="3" stroke-linecap="round"/>
|
||
</svg>
|
||
<p>视频加载失败</p>
|
||
<button class="retry-button" @click="retryLoad">重试</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 自定义控制栏 -->
|
||
<div v-if="showControls && !error" class="video-controls" :class="{ 'controls-visible': controlsVisible }">
|
||
<!-- 进度条 -->
|
||
<div class="progress-container" @click="seekTo" @mousemove="showProgressPreview" @mouseleave="hideProgressPreview">
|
||
<div class="progress-track">
|
||
<div class="progress-buffer" :style="{ width: bufferPercent + '%' }"></div>
|
||
<div class="progress-played" :style="{ width: progressPercent + '%' }"></div>
|
||
<div class="progress-thumb" :style="{ left: progressPercent + '%' }"></div>
|
||
</div>
|
||
<!-- 进度预览 -->
|
||
<div v-if="showPreview" class="progress-preview" :style="{ left: previewPosition + '%' }">
|
||
{{ formatTime(previewTime) }}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 控制按钮 -->
|
||
<div class="controls-row">
|
||
<div class="controls-left">
|
||
<button class="control-btn play-btn" @click="togglePlay">
|
||
<svg v-if="!isPlaying" width="24" height="24" viewBox="0 0 24 24">
|
||
<path d="M8 5V19L19 12L8 5Z" fill="currentColor"/>
|
||
</svg>
|
||
<svg v-else width="24" height="24" viewBox="0 0 24 24">
|
||
<path d="M6 4H10V20H6V4ZM14 4H18V20H14V4Z" fill="currentColor"/>
|
||
</svg>
|
||
</button>
|
||
|
||
<div class="time-display">
|
||
<span class="current-time">{{ formatTime(currentTime) }}</span>
|
||
<span class="separator">/</span>
|
||
<span class="total-time">{{ formatTime(duration) }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="controls-right">
|
||
<!-- 音量控制 -->
|
||
<div class="volume-container">
|
||
<button class="control-btn volume-btn" @click="toggleMute">
|
||
<svg v-if="volume > 50 && !muted" width="20" height="20" viewBox="0 0 20 20">
|
||
<path d="M10 2L7 5H4V15H7L10 18V2Z" fill="currentColor"/>
|
||
<path d="M12 7C12.5 7.5 12.8 8.2 12.8 9S12.5 10.5 12 11" stroke="currentColor" stroke-width="1"/>
|
||
<path d="M14 5C15 6 15.5 7.5 15.5 9S15 12 14 13" stroke="currentColor" stroke-width="1"/>
|
||
</svg>
|
||
<svg v-else-if="volume > 0 && !muted" width="20" height="20" viewBox="0 0 20 20">
|
||
<path d="M10 2L7 5H4V15H7L10 18V2Z" fill="currentColor"/>
|
||
<path d="M12 7C12.5 7.5 12.8 8.2 12.8 9S12.5 10.5 12 11" stroke="currentColor" stroke-width="1"/>
|
||
</svg>
|
||
<svg v-else width="20" height="20" viewBox="0 0 20 20">
|
||
<path d="M10 2L7 5H4V15H7L10 18V2Z" fill="currentColor"/>
|
||
<path d="M12 7L16 11M16 7L12 11" stroke="currentColor" stroke-width="1.5"/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- 全屏按钮 -->
|
||
<button class="control-btn fullscreen-btn" @click="toggleFullscreen">
|
||
<svg width="20" height="20" viewBox="0 0 20 20">
|
||
<path d="M3 3H7V5H5V7H3V3ZM13 3H17V7H15V5H13V3ZM17 13V17H13V15H15V13H17ZM7 17H3V13H5V15H7V17Z" fill="currentColor"/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 视频信息 -->
|
||
<div v-if="title" class="video-info">
|
||
<h3 class="video-title">{{ title }}</h3>
|
||
<p v-if="description" class="video-description">{{ description }}</p>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
||
import Hls from 'hls.js'
|
||
|
||
// Props
|
||
interface Props {
|
||
videoUrl: string
|
||
title?: string
|
||
description?: string
|
||
poster?: string
|
||
autoplay?: boolean
|
||
showControls?: boolean
|
||
}
|
||
|
||
const props = withDefaults(defineProps<Props>(), {
|
||
autoplay: false,
|
||
showControls: true
|
||
})
|
||
|
||
// Emits
|
||
const emit = defineEmits<{
|
||
play: []
|
||
pause: []
|
||
ended: []
|
||
timeupdate: [time: number]
|
||
error: [error: Event]
|
||
}>()
|
||
|
||
// Refs
|
||
const videoElement = ref<HTMLVideoElement>()
|
||
const videoContainer = ref<HTMLDivElement>()
|
||
|
||
// HLS实例
|
||
let hls: Hls | null = null
|
||
|
||
// 播放状态
|
||
const isPlaying = ref(false)
|
||
const loading = ref(false)
|
||
const error = ref(false)
|
||
const currentTime = ref(0)
|
||
const duration = ref(0)
|
||
const volume = ref(100)
|
||
const muted = ref(false)
|
||
|
||
// 控制栏状态
|
||
const controlsVisible = ref(true)
|
||
const showPlayButton = ref(true)
|
||
const showPreview = ref(false)
|
||
const previewPosition = ref(0)
|
||
const previewTime = ref(0)
|
||
|
||
// 计算属性
|
||
const progressPercent = computed(() => {
|
||
if (duration.value === 0) return 0
|
||
return (currentTime.value / duration.value) * 100
|
||
})
|
||
|
||
const bufferPercent = computed(() => {
|
||
// 简化的缓冲进度,实际应该从video.buffered获取
|
||
return Math.min(progressPercent.value + 10, 100)
|
||
})
|
||
|
||
// 监听视频URL变化
|
||
watch(() => props.videoUrl, (newUrl) => {
|
||
console.log('VideoPlayer: 视频URL变化:', newUrl)
|
||
if (newUrl && videoElement.value) {
|
||
loadVideo()
|
||
}
|
||
})
|
||
|
||
// 视频事件处理
|
||
const onLoadedMetadata = () => {
|
||
if (videoElement.value) {
|
||
duration.value = videoElement.value.duration
|
||
loading.value = false
|
||
error.value = false
|
||
}
|
||
}
|
||
|
||
const onTimeUpdate = () => {
|
||
if (videoElement.value) {
|
||
currentTime.value = videoElement.value.currentTime
|
||
emit('timeupdate', currentTime.value)
|
||
}
|
||
}
|
||
|
||
const onPlay = () => {
|
||
isPlaying.value = true
|
||
showPlayButton.value = false
|
||
emit('play')
|
||
}
|
||
|
||
const onPause = () => {
|
||
isPlaying.value = false
|
||
showPlayButton.value = true
|
||
emit('pause')
|
||
}
|
||
|
||
const onEnded = () => {
|
||
isPlaying.value = false
|
||
showPlayButton.value = true
|
||
emit('ended')
|
||
}
|
||
|
||
const onError = (event: Event) => {
|
||
error.value = true
|
||
loading.value = false
|
||
emit('error', event)
|
||
}
|
||
|
||
// 控制方法
|
||
const togglePlay = async () => {
|
||
if (!videoElement.value) return
|
||
|
||
try {
|
||
if (isPlaying.value) {
|
||
videoElement.value.pause()
|
||
} else {
|
||
await videoElement.value.play()
|
||
}
|
||
} catch (err) {
|
||
console.error('播放控制失败:', err)
|
||
}
|
||
}
|
||
|
||
const seekTo = (event: MouseEvent) => {
|
||
if (!videoElement.value) return
|
||
|
||
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
|
||
const percent = (event.clientX - rect.left) / rect.width
|
||
const newTime = percent * duration.value
|
||
|
||
videoElement.value.currentTime = newTime
|
||
}
|
||
|
||
const showProgressPreview = (event: MouseEvent) => {
|
||
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
|
||
const percent = (event.clientX - rect.left) / rect.width
|
||
const time = percent * duration.value
|
||
|
||
previewPosition.value = Math.max(0, Math.min(100, percent * 100))
|
||
previewTime.value = Math.max(0, Math.min(duration.value, time))
|
||
showPreview.value = true
|
||
}
|
||
|
||
const hideProgressPreview = () => {
|
||
showPreview.value = false
|
||
}
|
||
|
||
const toggleMute = () => {
|
||
if (!videoElement.value) return
|
||
|
||
videoElement.value.muted = !videoElement.value.muted
|
||
muted.value = videoElement.value.muted
|
||
}
|
||
|
||
const toggleFullscreen = () => {
|
||
if (!videoContainer.value) return
|
||
|
||
if (!document.fullscreenElement) {
|
||
videoContainer.value.requestFullscreen()
|
||
} else {
|
||
document.exitFullscreen()
|
||
}
|
||
}
|
||
|
||
const formatTime = (seconds: number): string => {
|
||
const hours = Math.floor(seconds / 3600)
|
||
const minutes = Math.floor((seconds % 3600) / 60)
|
||
const secs = Math.floor(seconds % 60)
|
||
|
||
if (hours > 0) {
|
||
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
|
||
}
|
||
return `${minutes}:${secs.toString().padStart(2, '0')}`
|
||
}
|
||
|
||
const loadVideo = () => {
|
||
if (!videoElement.value || !props.videoUrl) return
|
||
|
||
loading.value = true
|
||
error.value = false
|
||
|
||
// 清理之前的HLS实例
|
||
if (hls) {
|
||
hls.destroy()
|
||
hls = null
|
||
}
|
||
|
||
// 检查是否是HLS视频
|
||
if (props.videoUrl.includes('.m3u8')) {
|
||
// 使用HLS.js加载HLS视频
|
||
if (Hls.isSupported()) {
|
||
hls = new Hls({
|
||
enableWorker: true,
|
||
lowLatencyMode: true,
|
||
backBufferLength: 90
|
||
})
|
||
|
||
hls.loadSource(props.videoUrl)
|
||
hls.attachMedia(videoElement.value)
|
||
|
||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||
console.log('HLS manifest parsed successfully')
|
||
loading.value = false
|
||
})
|
||
|
||
hls.on(Hls.Events.ERROR, (event, data) => {
|
||
console.error('HLS error:', data)
|
||
if (data.fatal) {
|
||
error.value = true
|
||
loading.value = false
|
||
emit('error', new Event('error'))
|
||
}
|
||
})
|
||
} else if (videoElement.value.canPlayType('application/vnd.apple.mpegurl')) {
|
||
// Safari原生支持HLS
|
||
videoElement.value.src = props.videoUrl
|
||
videoElement.value.load()
|
||
} else {
|
||
console.error('HLS not supported')
|
||
error.value = true
|
||
loading.value = false
|
||
}
|
||
} else {
|
||
// 普通视频文件
|
||
videoElement.value.src = props.videoUrl
|
||
videoElement.value.load()
|
||
}
|
||
}
|
||
|
||
const retryLoad = () => {
|
||
loadVideo()
|
||
}
|
||
|
||
// 自动隐藏控制栏
|
||
let hideControlsTimer: number | null = null
|
||
|
||
const showControls = () => {
|
||
controlsVisible.value = true
|
||
if (hideControlsTimer) {
|
||
clearTimeout(hideControlsTimer)
|
||
}
|
||
hideControlsTimer = window.setTimeout(() => {
|
||
if (isPlaying.value) {
|
||
controlsVisible.value = false
|
||
}
|
||
}, 3000)
|
||
}
|
||
|
||
const onMouseMove = () => {
|
||
showControls()
|
||
}
|
||
|
||
// 生命周期
|
||
onMounted(() => {
|
||
nextTick(() => {
|
||
if (props.videoUrl) {
|
||
loadVideo()
|
||
}
|
||
|
||
// 添加鼠标移动监听
|
||
if (videoContainer.value) {
|
||
videoContainer.value.addEventListener('mousemove', onMouseMove)
|
||
}
|
||
})
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
if (hideControlsTimer) {
|
||
clearTimeout(hideControlsTimer)
|
||
}
|
||
|
||
if (videoContainer.value) {
|
||
videoContainer.value.removeEventListener('mousemove', onMouseMove)
|
||
}
|
||
|
||
// 清理HLS实例
|
||
if (hls) {
|
||
hls.destroy()
|
||
hls = null
|
||
}
|
||
})
|
||
|
||
// 暴露方法给父组件
|
||
defineExpose({
|
||
play: () => videoElement.value?.play(),
|
||
pause: () => videoElement.value?.pause(),
|
||
seek: (time: number) => {
|
||
if (videoElement.value) {
|
||
videoElement.value.currentTime = time
|
||
}
|
||
},
|
||
setVolume: (vol: number) => {
|
||
if (videoElement.value) {
|
||
videoElement.value.volume = vol / 100
|
||
volume.value = vol
|
||
}
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.video-player-wrapper {
|
||
width: 100%;
|
||
background: #000;
|
||
border-radius: 8px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.video-container {
|
||
position: relative;
|
||
width: 100%;
|
||
aspect-ratio: 16/9;
|
||
background: #000;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.video-element {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: contain;
|
||
background: #000;
|
||
}
|
||
|
||
/* 播放按钮覆盖层 */
|
||
.play-overlay {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: rgba(0, 0, 0, 0.3);
|
||
cursor: pointer;
|
||
transition: opacity 0.3s;
|
||
}
|
||
|
||
.play-button {
|
||
transition: transform 0.3s;
|
||
}
|
||
|
||
.play-overlay:hover .play-button {
|
||
transform: scale(1.1);
|
||
}
|
||
|
||
/* 加载状态 */
|
||
.loading-overlay {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: rgba(0, 0, 0, 0.8);
|
||
color: white;
|
||
}
|
||
|
||
.loading-spinner {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
/* 错误状态 */
|
||
.error-overlay {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: rgba(0, 0, 0, 0.8);
|
||
color: white;
|
||
}
|
||
|
||
.error-content {
|
||
text-align: center;
|
||
}
|
||
|
||
.retry-button {
|
||
margin-top: 16px;
|
||
padding: 8px 16px;
|
||
background: #1890ff;
|
||
color: white;
|
||
border: none;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
transition: background-color 0.3s;
|
||
}
|
||
|
||
.retry-button:hover {
|
||
background: #40a9ff;
|
||
}
|
||
|
||
/* 视频控制栏 */
|
||
.video-controls {
|
||
position: absolute;
|
||
bottom: 0;
|
||
left: 0;
|
||
right: 0;
|
||
background: linear-gradient(to top, rgba(0,0,0,0.8) 0%, rgba(0,0,0,0.4) 50%, transparent 100%);
|
||
padding: 16px;
|
||
transform: translateY(100%);
|
||
transition: transform 0.3s ease;
|
||
}
|
||
|
||
.video-controls.controls-visible {
|
||
transform: translateY(0);
|
||
}
|
||
|
||
.video-container:hover .video-controls {
|
||
transform: translateY(0);
|
||
}
|
||
|
||
/* 进度条 */
|
||
.progress-container {
|
||
position: relative;
|
||
margin-bottom: 12px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.progress-track {
|
||
height: 4px;
|
||
background: rgba(255, 255, 255, 0.3);
|
||
border-radius: 2px;
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.progress-buffer {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
height: 100%;
|
||
background: rgba(255, 255, 255, 0.5);
|
||
transition: width 0.3s;
|
||
}
|
||
|
||
.progress-played {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
height: 100%;
|
||
background: #1890ff;
|
||
transition: width 0.1s;
|
||
}
|
||
|
||
.progress-thumb {
|
||
position: absolute;
|
||
top: 50%;
|
||
width: 12px;
|
||
height: 12px;
|
||
background: #1890ff;
|
||
border-radius: 50%;
|
||
transform: translate(-50%, -50%);
|
||
opacity: 0;
|
||
transition: opacity 0.3s;
|
||
}
|
||
|
||
.progress-container:hover .progress-thumb {
|
||
opacity: 1;
|
||
}
|
||
|
||
.progress-preview {
|
||
position: absolute;
|
||
bottom: 100%;
|
||
transform: translateX(-50%);
|
||
background: rgba(0, 0, 0, 0.8);
|
||
color: white;
|
||
padding: 4px 8px;
|
||
border-radius: 4px;
|
||
font-size: 12px;
|
||
margin-bottom: 8px;
|
||
pointer-events: none;
|
||
}
|
||
|
||
/* 控制按钮行 */
|
||
.controls-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.controls-left,
|
||
.controls-right {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
.control-btn {
|
||
background: none;
|
||
border: none;
|
||
color: white;
|
||
cursor: pointer;
|
||
padding: 8px;
|
||
border-radius: 4px;
|
||
transition: background-color 0.3s;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.control-btn:hover {
|
||
background: rgba(255, 255, 255, 0.2);
|
||
}
|
||
|
||
.play-btn {
|
||
padding: 12px;
|
||
}
|
||
|
||
.time-display {
|
||
color: white;
|
||
font-size: 14px;
|
||
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||
}
|
||
|
||
.separator {
|
||
margin: 0 4px;
|
||
opacity: 0.7;
|
||
}
|
||
|
||
.volume-container {
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
|
||
/* 视频信息 */
|
||
.video-info {
|
||
padding: 16px;
|
||
background: white;
|
||
}
|
||
|
||
.video-title {
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
color: #333;
|
||
margin: 0 0 8px 0;
|
||
}
|
||
|
||
.video-description {
|
||
font-size: 14px;
|
||
color: #666;
|
||
line-height: 1.5;
|
||
margin: 0;
|
||
}
|
||
|
||
/* 响应式设计 */
|
||
@media (max-width: 768px) {
|
||
.video-controls {
|
||
padding: 12px;
|
||
}
|
||
|
||
.controls-row {
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
|
||
.controls-left,
|
||
.controls-right {
|
||
width: 100%;
|
||
justify-content: center;
|
||
}
|
||
|
||
.time-display {
|
||
font-size: 12px;
|
||
}
|
||
}
|
||
</style>
|