OL-LearnPlatform/src/components/VideoPlayer.vue

707 lines
16 KiB
Vue
Raw Normal View History

2025-07-28 09:51:21 +08:00
<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>