OL-LearnPlatform/src/components/VideoPlayer.vue
username 8067376d43 www
2025-07-28 09:51:21 +08:00

707 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>