feat:切换dplayer播放器

This commit is contained in:
小张 2025-08-20 11:15:12 +08:00
parent b37cdd3ccc
commit 5b82a9b044
4 changed files with 547 additions and 20 deletions

43
package-lock.json generated
View File

@ -11,6 +11,7 @@
"@vicons/ionicons5": "^0.13.0", "@vicons/ionicons5": "^0.13.0",
"axios": "^1.11.0", "axios": "^1.11.0",
"ckplayer": "^3.1.2", "ckplayer": "^3.1.2",
"dplayer": "^1.27.1",
"naive-ui": "^2.42.0", "naive-ui": "^2.42.0",
"pinia": "^3.0.3", "pinia": "^3.0.3",
"quill": "^2.0.3", "quill": "^2.0.3",
@ -20,6 +21,7 @@
"vue-router": "^4.5.1" "vue-router": "^4.5.1"
}, },
"devDependencies": { "devDependencies": {
"@types/dplayer": "^1.25.5",
"@types/node": "^24.0.15", "@types/node": "^24.0.15",
"@vitejs/plugin-vue": "^6.0.0", "@vitejs/plugin-vue": "^6.0.0",
"typescript": "^5.8.3", "typescript": "^5.8.3",
@ -1398,6 +1400,13 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/@types/dplayer": {
"version": "1.25.5",
"resolved": "https://registry.npmjs.org/@types/dplayer/-/dplayer-1.25.5.tgz",
"integrity": "sha512-p/7O94dHDo0Irn2KWIqFE+fBCA4DS7QL3jfCOjCUPBAOgppyyTjmNZjKEfiJa1M3n1oVQqG7xnPwhiIuCqOzkQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz",
@ -1777,6 +1786,12 @@
"proxy-from-env": "^1.1.0" "proxy-from-env": "^1.1.0"
} }
}, },
"node_modules/balloon-css": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/balloon-css/-/balloon-css-1.2.0.tgz",
"integrity": "sha512-urXwkHgwp6GsXVF+it01485Z2Cj4pnW02ICnM0TemOlkKmCNnDLmyy+ZZiRXBpwldUXO+aRNr7Hdia4CBvXJ5A==",
"license": "MIT"
},
"node_modules/birpc": { "node_modules/birpc": {
"version": "2.5.0", "version": "2.5.0",
"resolved": "https://registry.npmmirror.com/birpc/-/birpc-2.5.0.tgz", "resolved": "https://registry.npmmirror.com/birpc/-/birpc-2.5.0.tgz",
@ -2139,6 +2154,28 @@
"node": ">=0.4.0" "node": ">=0.4.0"
} }
}, },
"node_modules/dplayer": {
"version": "1.27.1",
"resolved": "https://registry.npmjs.org/dplayer/-/dplayer-1.27.1.tgz",
"integrity": "sha512-2laBMXs5V1B9zPwJ7eAIw/OBo+Xjvy03i4GHTk3Cg+IWbrq8rKMFO0fFr6ClAYotYOCcFGOvaJDkOZcgKllsCA==",
"license": "MIT",
"dependencies": {
"axios": "1.2.3",
"balloon-css": "^1.0.3",
"promise-polyfill": "8.3.0"
}
},
"node_modules/dplayer/node_modules/axios": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.2.3.tgz",
"integrity": "sha512-pdDkMYJeuXLZ6Xj/Q5J3Phpe+jbGdsSzlQaFVkMQzRUL05+6+tetX8TV3p4HrU4kzuO9bt+io/yGQxuyxA/xcw==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.0",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
@ -3207,6 +3244,12 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/promise-polyfill": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-8.3.0.tgz",
"integrity": "sha512-H5oELycFml5yto/atYqmjyigJoAo3+OXwolYiH7OfQuYlAqhxNvTfiNMbV9hsC6Yp83yE5r2KTVmtrG6R9i6Pg==",
"license": "MIT"
},
"node_modules/proxy-from-env": { "node_modules/proxy-from-env": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz",

View File

@ -16,6 +16,7 @@
"@vicons/ionicons5": "^0.13.0", "@vicons/ionicons5": "^0.13.0",
"axios": "^1.11.0", "axios": "^1.11.0",
"ckplayer": "^3.1.2", "ckplayer": "^3.1.2",
"dplayer": "^1.27.1",
"naive-ui": "^2.42.0", "naive-ui": "^2.42.0",
"pinia": "^3.0.3", "pinia": "^3.0.3",
"quill": "^2.0.3", "quill": "^2.0.3",
@ -25,6 +26,7 @@
"vue-router": "^4.5.1" "vue-router": "^4.5.1"
}, },
"devDependencies": { "devDependencies": {
"@types/dplayer": "^1.25.5",
"@types/node": "^24.0.15", "@types/node": "^24.0.15",
"@vitejs/plugin-vue": "^6.0.0", "@vitejs/plugin-vue": "^6.0.0",
"typescript": "^5.8.3", "typescript": "^5.8.3",

View File

@ -0,0 +1,456 @@
<template>
<div class="dplayer-wrapper">
<div :id="playerId" class="dplayer-container"></div>
<!-- 原生视频播放器回退 -->
<video
v-if="error && videoUrl"
class="fallback-video"
:src="videoUrl"
:poster="poster"
controls
preload="auto"
@play="emit('play')"
@pause="emit('pause')"
@ended="emit('ended')"
>
您的浏览器不支持视频播放
</video>
<div v-if="loading" class="loading-overlay">
<div class="loading-spinner"></div>
<p>正在加载视频...</p>
</div>
<div v-if="error && !videoUrl" class="error-overlay">
<p>视频加载失败</p>
<button @click="retryLoad" class="retry-btn">重试</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import DPlayer from 'dplayer'
interface Props {
videoUrl?: string
poster?: string
title?: string
description?: string
autoplay?: boolean
showControls?: boolean
qualities?: Array<{
label: string
value: string
url: string
}>
currentQuality?: string
}
const props = withDefaults(defineProps<Props>(), {
videoUrl: '',
poster: '',
title: '视频播放',
description: '',
autoplay: false,
showControls: true,
qualities: () => [],
currentQuality: '360p'
})
const emit = defineEmits<{
play: []
pause: []
ended: []
error: [error: Event]
}>()
const playerId = ref(`dplayer_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`)
const dplayer = ref<DPlayer | null>(null)
const loading = ref(false)
const error = ref(false)
// URL
watch(() => props.videoUrl, (newUrl) => {
if (newUrl) {
// F12
// console.log('🎬 DPlayer URL changed:', newUrl)
nextTick(() => {
initDPlayer(newUrl)
})
}
}, { immediate: true })
// HLS.js
const loadHLSScript = (): Promise<void> => {
return new Promise((resolve, reject) => {
if ((window as any).Hls) {
resolve()
return
}
const script = document.createElement('script')
script.src = 'https://cdn.jsdelivr.net/npm/hls.js@1.4.14/dist/hls.min.js'
script.onload = () => {
console.log('✅ HLS.js loaded')
resolve()
}
script.onerror = () => {
console.error('❌ Failed to load HLS.js')
reject(new Error('Failed to load HLS.js'))
}
document.head.appendChild(script)
})
}
// DPlayer
const initDPlayer = async (url: string) => {
if (!url) return
loading.value = true
error.value = false
try {
//
if (dplayer.value) {
try {
dplayer.value.destroy()
} catch (e) {
console.warn('销毁播放器失败:', e)
}
dplayer.value = null
}
await nextTick()
const container = document.getElementById(playerId.value)
if (!container) {
console.error('❌ 容器未找到:', playerId.value)
error.value = true
loading.value = false
return
}
const isHLS = url.includes('.m3u8')
// console.log('🎬 DPlayer:', { url, isHLS })
// HLSHLS.js
if (isHLS) {
await loadHLSScript()
}
// DPlayer - 使
const options: any = {
container: container,
autoplay: props.autoplay,
theme: '#1890ff',
loop: false,
lang: 'zh-cn',
screenshot: true,
hotkey: true,
preload: 'auto',
volume: 0.8,
mutex: true,
video: {
url: url,
pic: props.poster || '',
type: 'normal'
}
}
// console.log('🎬 DPlayer:', options)
// HLS
if (isHLS && (window as any).Hls && (window as any).Hls.isSupported()) {
// console.log('🔧 使HLS.js')
options.video.type = 'hls'
options.video.customType = {
hls: function(video: HTMLVideoElement, _player: DPlayer) {
// console.log('🎬 HLS.js')
const hls = new (window as any).Hls({
debug: false,
enableWorker: false, // Worker
lowLatencyMode: false,
// - bufferAppendError
maxBufferLength: 20, //
maxMaxBufferLength: 40, //
maxBufferSize: 30 * 1000 * 1000, // 30MB
maxBufferHole: 0.3, //
highBufferWatchdogPeriod: 1, //
nudgeOffset: 0.05, //
nudgeMaxRetry: 2, //
//
fragLoadingTimeOut: 20000,
fragLoadingMaxRetry: 3,
manifestLoadingTimeOut: 10000,
manifestLoadingMaxRetry: 2,
//
progressive: false,
//
maxLoadingDelay: 2
})
hls.on((window as any).Hls.Events.MANIFEST_PARSED, () => {
// console.log(' HLS manifest')
})
//
let bufferErrorCount = 0
let networkErrorCount = 0
let lastErrorTime = 0
const maxRetries = 3
const errorCooldown = 5000 // 5
hls.on((window as any).Hls.Events.ERROR, (_event: any, data: any) => {
const now = Date.now()
// bufferAppendError -
if (data.details === 'bufferAppendError') {
bufferErrorCount++
if (data.fatal && bufferErrorCount <= maxRetries) {
//
setTimeout(() => {
if (hls && !hls.destroyed) {
hls.recoverMediaError()
}
}, 50 * bufferErrorCount) //
}
return //
}
//
if (now - lastErrorTime < errorCooldown) {
return //
}
lastErrorTime = now
//
if (data.fatal) {
switch (data.type) {
case (window as any).Hls.ErrorTypes.NETWORK_ERROR:
networkErrorCount++
if (networkErrorCount <= maxRetries) {
console.log(`🔄 网络错误,重试 ${networkErrorCount}/${maxRetries}`)
setTimeout(() => {
if (hls && !hls.destroyed) {
hls.startLoad()
}
}, 1000 * networkErrorCount)
} else {
console.error('❌ 网络连接失败')
error.value = true
loading.value = false
}
break
case (window as any).Hls.ErrorTypes.MEDIA_ERROR:
console.log('🔄 媒体错误,尝试恢复')
setTimeout(() => {
if (hls && !hls.destroyed) {
hls.recoverMediaError()
}
}, 500)
break
default:
console.error('❌ 播放器错误:', data.details)
error.value = true
loading.value = false
break
}
}
//
})
hls.loadSource(video.src)
hls.attachMedia(video)
//
;(video as any).hlsInstance = hls
}
}
}
//
// console.log('🔨 DPlayer...')
dplayer.value = new DPlayer(options)
//
bindEvents()
// console.log(' DPlayer')
loading.value = false
} catch (err) {
console.error('❌ DPlayer初始化失败:', err)
error.value = true
loading.value = false
emit('error', new Event('error'))
}
}
//
const bindEvents = () => {
if (!dplayer.value) return
;(dplayer.value as any).on('play', () => {
// console.log(' ')
emit('play')
})
;(dplayer.value as any).on('pause', () => {
// console.log(' ')
emit('pause')
})
;(dplayer.value as any).on('ended', () => {
// console.log(' ')
emit('ended')
})
;(dplayer.value as any).on('error', (error: Event) => {
//
if (dplayer.value && dplayer.value.video) {
const video = dplayer.value.video
// HLS
const isHLSInitError = video.error === null &&
video.networkState === 2 &&
video.readyState === 0 &&
video.currentSrc.includes('blob:')
if (isHLSInitError) {
console.log(' HLS初始化过程中的正常状态转换忽略此错误')
return //
}
console.error('🚨 DPlayer错误:', error)
console.error('📹 视频错误详情:', {
error: video.error,
networkState: video.networkState,
readyState: video.readyState,
currentSrc: video.currentSrc
})
if (video.error) {
const errorMessages = {
1: 'MEDIA_ERR_ABORTED - 加载中止',
2: 'MEDIA_ERR_NETWORK - 网络错误',
3: 'MEDIA_ERR_DECODE - 解码错误',
4: 'MEDIA_ERR_SRC_NOT_SUPPORTED - 格式不支持'
}
console.error('❌ 错误代码:', video.error.code, '-', errorMessages[video.error.code as keyof typeof errorMessages])
//
emit('error', error)
}
} else {
console.error('🚨 DPlayer错误:', error)
emit('error', error)
}
})
}
//
const retryLoad = () => {
if (props.videoUrl) {
initDPlayer(props.videoUrl)
}
}
// DPlayer
//
const play = () => dplayer.value?.play()
const pause = () => dplayer.value?.pause()
const seek = (time: number) => dplayer.value?.seek(time)
//
onMounted(() => {
if (props.videoUrl) {
initDPlayer(props.videoUrl)
}
})
onUnmounted(() => {
if (dplayer.value) {
try {
dplayer.value.destroy()
} catch (e) {
console.warn('销毁DPlayer失败:', e)
}
dplayer.value = null
}
})
defineExpose({ play, pause, seek, retry: retryLoad, player: dplayer })
</script>
<style scoped>
.dplayer-wrapper {
position: relative;
width: 100%;
height: 100%;
background: #000;
border-radius: 8px;
overflow: hidden;
}
.dplayer-container {
width: 100%;
height: 100%;
min-height: 400px;
}
.loading-overlay,
.error-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;
z-index: 10;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid rgba(255, 255, 255, 0.3);
border-top: 3px solid #1890ff;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 16px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.retry-btn {
margin-top: 16px;
padding: 8px 16px;
background: #1890ff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.retry-btn:hover {
background: #40a9ff;
}
/* 回退视频播放器 */
.fallback-video {
width: 100%;
height: 100%;
min-height: 400px;
background: #000;
border-radius: 8px;
}
</style>

View File

@ -34,9 +34,23 @@
<div class="video-player-section"> <div class="video-player-section">
<div class="video-player enrolled"> <div class="video-player enrolled">
<div class="video-container"> <div class="video-container">
<!-- CKPlayer 容器 --> <!-- DPlayer 视频播放器 -->
<div v-if="currentVideoUrl" id="ckplayer_container" class="ckplayer-container"> <DPlayerVideo
</div> v-if="currentVideoUrl"
ref="dplayerRef"
:video-url="currentVideoUrl"
:poster="course?.coverImage || course?.thumbnail || ''"
:title="currentSection?.name || '课程视频'"
:description="currentSection?.name || ''"
:autoplay="false"
:show-controls="true"
:qualities="videoQualities"
:current-quality="currentQuality"
@play="onVideoPlay"
@pause="onVideoPause"
@ended="onVideoEnded"
@error="onVideoError"
/>
<div v-else class="video-placeholder" <div v-else class="video-placeholder"
:style="{ backgroundImage: course?.coverImage || course?.thumbnail ? `url(${course.coverImage || course.thumbnail})` : '' }"> :style="{ backgroundImage: course?.coverImage || course?.thumbnail ? `url(${course.coverImage || course.thumbnail})` : '' }">
<div class="placeholder-content"> <div class="placeholder-content">
@ -454,6 +468,7 @@ import type { Course, CourseSection, SectionVideo, VideoQuality, CourseComment,
import SafeAvatar from '@/components/common/SafeAvatar.vue' import SafeAvatar from '@/components/common/SafeAvatar.vue'
import LearningProgressStats from '@/components/common/LearningProgressStats.vue' import LearningProgressStats from '@/components/common/LearningProgressStats.vue'
import NotesModal from '@/components/common/NotesModal.vue' import NotesModal from '@/components/common/NotesModal.vue'
import DPlayerVideo from '@/components/DPlayerVideo.vue'
// CKPlayer // CKPlayer
declare global { declare global {
@ -477,6 +492,7 @@ const FORCE_LOCAL_VIDEO = true
const currentSection = ref<CourseSection | null>(null) const currentSection = ref<CourseSection | null>(null)
const currentVideoUrl = ref<string>('') const currentVideoUrl = ref<string>('')
const ckplayer = ref<any>(null) const ckplayer = ref<any>(null)
const dplayerRef = ref<InstanceType<typeof DPlayerVideo>>()
// //
const currentVideo = ref<SectionVideo | null>(null) const currentVideo = ref<SectionVideo | null>(null)
@ -691,8 +707,9 @@ const loadCourseSections = async () => {
if (firstVideo) { if (firstVideo) {
currentSection.value = firstVideo currentSection.value = firstVideo
currentVideoUrl.value = getVideoUrl(firstVideo) currentVideoUrl.value = getVideoUrl(firstVideo)
await nextTick() // DPlayerURL
initCKPlayer(currentVideoUrl.value) // await nextTick()
// initCKPlayer(currentVideoUrl.value)
} }
} }
} else { } else {
@ -735,7 +752,8 @@ const loadMockData = () => {
if (firstVideo) { if (firstVideo) {
currentSection.value = firstVideo currentSection.value = firstVideo
currentVideoUrl.value = getVideoUrl(firstVideo) currentVideoUrl.value = getVideoUrl(firstVideo)
setTimeout(() => initCKPlayer(currentVideoUrl.value), 0) // DPlayerURL
// setTimeout(() => initCKPlayer(currentVideoUrl.value), 0)
} }
} }
} }
@ -1048,8 +1066,8 @@ const handleVideoPlay = async (section: CourseSection) => {
// DOM // DOM
await nextTick() await nextTick()
// CKPlayer // DPlayerURL
initCKPlayer(videoUrl) // initCKPlayer(videoUrl)
// //
if (!section.completed) { if (!section.completed) {
@ -1061,8 +1079,11 @@ const handleVideoPlay = async (section: CourseSection) => {
} }
} }
// CKPlayer // CKPlayer - DPlayer
const initCKPlayer = (url: string) => { const initCKPlayer = (_url: string) => {
console.log('⚠️ CKPlayer已被DPlayer替换跳过初始化')
return
/*
// //
if (ckplayer.value) { if (ckplayer.value) {
try { try {
@ -1126,6 +1147,7 @@ const initCKPlayer = (url: string) => {
} catch (error) { } catch (error) {
console.error('Failed to initialize CKPlayer:', error) console.error('Failed to initialize CKPlayer:', error)
} }
*/
} }
// CKPlayer // CKPlayer
@ -1203,18 +1225,22 @@ const handleExam = (section: CourseSection) => {
}) })
} }
// // DPlayer
// const handleVideoLoadStart = () => { const onVideoPlay = () => {
// console.log('') console.log('▶️ 视频开始播放')
// } }
// const handleVideoCanPlay = () => { const onVideoPause = () => {
// console.log('') console.log('⏸️ 视频暂停')
// } }
// const handleVideoError = (event: Event) => { const onVideoEnded = () => {
// console.error(':', event) console.log('⏹️ 视频播放结束')
// } }
const onVideoError = (error: Event) => {
console.error('🚨 视频播放错误:', error)
}
// //
const initializeEnrolledState = () => { const initializeEnrolledState = () => {