|
|
@@ -1,39 +1,60 @@
|
|
|
<template>
|
|
|
<div class="video-container">
|
|
|
<div class="box-video">
|
|
|
- <video
|
|
|
- class="full-box-video"
|
|
|
- ref="videoRef"
|
|
|
- :controls="true"
|
|
|
- @timeupdate="handleTimeUpdate"
|
|
|
- @seeked="handleSeeked"
|
|
|
- @ended="handleVideoEnded"
|
|
|
- ></video>
|
|
|
+ <!-- 根据contentType决定显示视频、图片、PPT -->
|
|
|
+ <!-- 视频 -->
|
|
|
+ <template v-if="contentType === 'video'">
|
|
|
+ <video
|
|
|
+ class="full-box-video"
|
|
|
+ ref="videoRef"
|
|
|
+ :controls="true"
|
|
|
+ @timeupdate="handleTimeUpdate"
|
|
|
+ @seeked="handleSeeked"
|
|
|
+ @ended="handleVideoEnded"
|
|
|
+ ></video>
|
|
|
+ </template>
|
|
|
+ <!-- 图片 -->
|
|
|
+ <template v-else-if="contentType === 'image'">
|
|
|
+ <div class="image-container">
|
|
|
+ <img :src="imagePath" alt="课程图片" class="course-image" />
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ <!-- PPT -->
|
|
|
+ <template v-else-if="contentType === 'ppt' || contentType === 'pptx'">
|
|
|
+ <div class="ppt-box">
|
|
|
+ <VueOfficePptx class="ppt-container" :src="pptPath" @error="handlePptError" @rendered="handlePptRendered" />
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
</div>
|
|
|
|
|
|
- <!-- 视频切换按钮 -->
|
|
|
+ <!-- 视频切换按钮 - 始终显示 -->
|
|
|
<div class="video-switch">
|
|
|
<div class="caret-left" @click="playPreviousVideo">
|
|
|
<el-button type="warning" round>
|
|
|
- <img :src="leftImg" alt="Left" />
|
|
|
- 上一节</el-button
|
|
|
+ <img :src="leftImg" alt="Left" />上一节</el-button
|
|
|
>
|
|
|
</div>
|
|
|
<div class="caret-right" @click="playNextVideo">
|
|
|
<el-button type="warning" round
|
|
|
- >下一节
|
|
|
- <img :src="rightImg" alt="Right" />
|
|
|
- </el-button>
|
|
|
+ >下一节<img :src="rightImg" alt="Right" />
|
|
|
+ </el-button>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
</template>
|
|
|
|
|
|
<script setup>
|
|
|
-import { ref, onMounted, onBeforeUnmount, defineProps, defineEmits, watch } from 'vue'
|
|
|
+import {
|
|
|
+ ref,
|
|
|
+ onMounted,
|
|
|
+ onBeforeUnmount,
|
|
|
+ defineProps,
|
|
|
+ defineEmits,
|
|
|
+ watch,
|
|
|
+ nextTick
|
|
|
+} from 'vue'
|
|
|
|
|
|
import { videoPlay as Vue3VideoPlay } from 'vue3-video-play'
|
|
|
-// import 'vue3-video-play/dist/style.css'
|
|
|
import Hls from 'hls.js'
|
|
|
import { ElMessage } from 'element-plus'
|
|
|
// 导入全局年级id
|
|
|
@@ -42,12 +63,18 @@ import { globalState } from '@/utils/globalState.js'
|
|
|
import leftImg from '@/assets/icon/backward.png'
|
|
|
import rightImg from '@/assets/icon/f-backward.png'
|
|
|
import { saveRecord } from '@/api/personalized/index.js'
|
|
|
+// PPT
|
|
|
+import VueOfficePptx from '@vue-office/pptx'
|
|
|
+// import '@vue-office/pptx/lib/index.css'
|
|
|
|
|
|
// 定义props
|
|
|
const props = defineProps({
|
|
|
- videoPath: { type: String, required: true },
|
|
|
+ contentType: { type: String, required: true }, // contentType类型
|
|
|
+ videoPath: { type: String }, // 变为可选
|
|
|
+ imagePath: { type: String }, // 图片路径
|
|
|
+ pptPath: { type: String }, // PPT路径
|
|
|
courseId: { type: String, required: true },
|
|
|
- typeId: { type: String, required: true }, // 添加typeId
|
|
|
+ typeId: { type: String, required: true },
|
|
|
courseConfigList: { type: Array, default: () => [] },
|
|
|
allIndices: { type: Array, default: () => [] },
|
|
|
currentIndex: { type: String, required: true }
|
|
|
@@ -74,7 +101,7 @@ const targetProgresses = [10, 50, 100]
|
|
|
// 定义节流函数
|
|
|
const throttle = (fn, delay) => {
|
|
|
let lastCall = 0
|
|
|
- return function(...args) {
|
|
|
+ return function (...args) {
|
|
|
const now = Date.now()
|
|
|
if (now - lastCall >= delay) {
|
|
|
lastCall = now
|
|
|
@@ -87,11 +114,14 @@ const throttle = (fn, delay) => {
|
|
|
const saveProgress = throttle(async (progress, currentTime) => {
|
|
|
try {
|
|
|
// 保存到localStorage,下次加载视频续播
|
|
|
- localStorage.setItem(`videoProgress_${props.courseId}`, JSON.stringify({
|
|
|
- progress: progress,
|
|
|
- currentTime: currentTime,
|
|
|
- timestamp: Date.now()
|
|
|
- }))
|
|
|
+ localStorage.setItem(
|
|
|
+ `videoProgress_${props.courseId}`,
|
|
|
+ JSON.stringify({
|
|
|
+ progress: progress,
|
|
|
+ currentTime: currentTime,
|
|
|
+ timestamp: Date.now()
|
|
|
+ })
|
|
|
+ )
|
|
|
|
|
|
// 保存视频进度接口
|
|
|
await saveRecord({
|
|
|
@@ -108,11 +138,12 @@ const saveProgress = throttle(async (progress, currentTime) => {
|
|
|
}, THROTTLE_TIME)
|
|
|
|
|
|
// 处理视频时间更新事件
|
|
|
-const handleTimeUpdate = (ev) => {
|
|
|
+const handleTimeUpdate = ev => {
|
|
|
if (!videoRef.value) return
|
|
|
const currentTime = parseInt(ev.target.currentTime)
|
|
|
const duration = videoRef.value.duration || 0
|
|
|
- const progressPercentage = duration > 0 ? Math.round((currentTime / duration) * 100) : 0
|
|
|
+ const progressPercentage =
|
|
|
+ duration > 0 ? Math.round((currentTime / duration) * 100) : 0
|
|
|
|
|
|
// 更新最后播放进度
|
|
|
lastPlayProgress.value = progressPercentage
|
|
|
@@ -135,8 +166,8 @@ const handleTimeUpdate = (ev) => {
|
|
|
// 触发父组件的时间更新事件
|
|
|
emits('timeUpdate', { currentTime, progressPercentage })
|
|
|
|
|
|
- if (!props.courseConfigList.length) return
|
|
|
- props.courseConfigList.forEach(courseCofig => {
|
|
|
+ if (!props.courseConfigList.length) return
|
|
|
+ props.courseConfigList.forEach(courseCofig => {
|
|
|
//暂停时间
|
|
|
let time = courseCofig.ccTime
|
|
|
// 检查是否到达时间点且还未暂停过
|
|
|
@@ -147,7 +178,11 @@ const handleTimeUpdate = (ev) => {
|
|
|
// 只有当存在问题内容时才触发弹窗
|
|
|
if (courseCofig.ccQuestContent) {
|
|
|
// 触发父组件显示试题
|
|
|
- emits('timeUpdate', { currentTime, progressPercentage, courseConfig: courseCofig })
|
|
|
+ emits('timeUpdate', {
|
|
|
+ currentTime,
|
|
|
+ progressPercentage,
|
|
|
+ courseConfig: courseCofig
|
|
|
+ })
|
|
|
}
|
|
|
}
|
|
|
})
|
|
|
@@ -164,13 +199,26 @@ const handleVideoEnded = () => {
|
|
|
if (!savedProgress.value.includes(100)) {
|
|
|
saveProgress(100, videoRef.value.duration)
|
|
|
}
|
|
|
- emits('videoEnded')
|
|
|
+ // emits('videoEnded')
|
|
|
+}
|
|
|
+
|
|
|
+// 处理PPT渲染完成事件
|
|
|
+const handlePptRendered = () => {
|
|
|
+ console.log('PPT渲染完成')
|
|
|
+}
|
|
|
+// PPT错误处理事件
|
|
|
+const handlePptError = (error) => {
|
|
|
+ console.error('PPT加载错误:', error)
|
|
|
+ ElMessage.error('PPT加载失败,请检查文件路径或格式')
|
|
|
}
|
|
|
|
|
|
// 播放下一个视频
|
|
|
const playNextVideo = () => {
|
|
|
const currentIndexInList = props.allIndices.indexOf(props.currentIndex)
|
|
|
- if (currentIndexInList !== -1 && currentIndexInList < props.allIndices.length - 1) {
|
|
|
+ if (
|
|
|
+ currentIndexInList !== -1 &&
|
|
|
+ currentIndexInList < props.allIndices.length - 1
|
|
|
+ ) {
|
|
|
const nextIndex = props.allIndices[currentIndexInList + 1]
|
|
|
emits('switchVideo', nextIndex)
|
|
|
// 重置暂停索引
|
|
|
@@ -191,43 +239,47 @@ const playPreviousVideo = () => {
|
|
|
|
|
|
// 初始化视频播放器
|
|
|
const initVideoPlayer = () => {
|
|
|
- if (!videoRef.value) {
|
|
|
- console.error('视频元素未找到')
|
|
|
- return
|
|
|
- }
|
|
|
+ if (props.contentType !== 'video') return
|
|
|
+ // 使用nextTick确保DOM已经更新
|
|
|
+ nextTick(() => {
|
|
|
+ if (!videoRef.value) {
|
|
|
+ console.error('视频元素未找到')
|
|
|
+ return
|
|
|
+ }
|
|
|
|
|
|
- // 清理之前的HLS实例
|
|
|
- if (hlsRef.value) {
|
|
|
- hlsRef.value.destroy()
|
|
|
- hlsRef.value = null
|
|
|
- }
|
|
|
+ // 清理之前的HLS实例
|
|
|
+ if (hlsRef.value) {
|
|
|
+ hlsRef.value.destroy()
|
|
|
+ hlsRef.value = null
|
|
|
+ }
|
|
|
|
|
|
- // 检查视频路径是否是m3u8格式
|
|
|
- if (props.videoPath && props.videoPath.toLowerCase().endsWith('.m3u8')) {
|
|
|
- // 使用HLS播放
|
|
|
- if (Hls.isSupported()) {
|
|
|
- hlsRef.value = new Hls()
|
|
|
- hlsRef.value.loadSource(props.videoPath)
|
|
|
- hlsRef.value.attachMedia(videoRef.value)
|
|
|
- hlsRef.value.on(Hls.Events.MANIFEST_PARSED, () => {
|
|
|
+ // 检查视频路径是否是m3u8格式
|
|
|
+ if (props.videoPath && props.videoPath.toLowerCase().endsWith('.m3u8')) {
|
|
|
+ // 使用HLS播放
|
|
|
+ if (Hls.isSupported()) {
|
|
|
+ hlsRef.value = new Hls()
|
|
|
+ hlsRef.value.loadSource(props.videoPath)
|
|
|
+ hlsRef.value.attachMedia(videoRef.value)
|
|
|
+ hlsRef.value.on(Hls.Events.MANIFEST_PARSED, () => {
|
|
|
+ tryPlayVideo()
|
|
|
+ })
|
|
|
+ hlsRef.value.on(Hls.Events.ERROR, (event, data) => {
|
|
|
+ console.error('HLS错误:', data)
|
|
|
+ ElMessage.error('视频加载失败,请稍后重试')
|
|
|
+ })
|
|
|
+ } else if (videoRef.value.canPlayType('application/vnd.apple.mpegurl')) {
|
|
|
+ // 对于不支持HLS但支持原生m3u8的浏览器
|
|
|
+ videoRef.value.src = props.videoPath
|
|
|
tryPlayVideo()
|
|
|
- })
|
|
|
- hlsRef.value.on(Hls.Events.ERROR, (event, data) => {
|
|
|
- console.error('HLS错误:', data)
|
|
|
- ElMessage.error('视频加载失败,请稍后重试')
|
|
|
- })
|
|
|
- } else if (videoRef.value.canPlayType('application/vnd.apple.mpegurl')) {
|
|
|
- // 对于不支持HLS但支持原生m3u8的浏览器
|
|
|
+ } else {
|
|
|
+ ElMessage.error('您的浏览器不支持播放m3u8格式视频')
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ // 普通视频播放
|
|
|
videoRef.value.src = props.videoPath
|
|
|
tryPlayVideo()
|
|
|
- } else {
|
|
|
- ElMessage.error('您的浏览器不支持播放m3u8格式视频')
|
|
|
}
|
|
|
- } else {
|
|
|
- // 普通视频播放
|
|
|
- videoRef.value.src = props.videoPath
|
|
|
- tryPlayVideo()
|
|
|
- }
|
|
|
+ })
|
|
|
}
|
|
|
|
|
|
// 尝试播放视频,处理浏览器自动播放限制
|
|
|
@@ -242,13 +294,6 @@ const tryPlayVideo = () => {
|
|
|
setTimeout(() => {
|
|
|
setLastPlayPosition()
|
|
|
}, 1000)
|
|
|
-
|
|
|
- // const playPromise = videoRef.value.play()
|
|
|
- // if (playPromise !== undefined) {
|
|
|
- // playPromise.catch(error => {
|
|
|
- // console.error('视频播放失败,可能是浏览器自动播放限制:', error)
|
|
|
- // })
|
|
|
- // }
|
|
|
}
|
|
|
|
|
|
// 在视频加载完成后设置上次播放进度
|
|
|
@@ -277,9 +322,12 @@ onMounted(() => {
|
|
|
initVideoPlayer()
|
|
|
})
|
|
|
|
|
|
-// 监听videoPath变化
|
|
|
-watch(() => props.videoPath, () => {
|
|
|
- initVideoPlayer()
|
|
|
+// 监听contentType和videoPath变化
|
|
|
+watch([() => props.contentType, () => props.videoPath], () => {
|
|
|
+ // 当contentType变为video或videoPath变化时,重新初始化
|
|
|
+ if (props.contentType === 'video') {
|
|
|
+ initVideoPlayer()
|
|
|
+ }
|
|
|
})
|
|
|
|
|
|
// 组件卸载时
|
|
|
@@ -304,7 +352,7 @@ onBeforeUnmount(() => {
|
|
|
display: flex;
|
|
|
justify-content: center;
|
|
|
align-items: center;
|
|
|
- .d-player-wrap{
|
|
|
+ .d-player-wrap {
|
|
|
height: rpx(289);
|
|
|
width: 68.5%;
|
|
|
border-radius: rpx(12);
|
|
|
@@ -313,9 +361,45 @@ onBeforeUnmount(() => {
|
|
|
}
|
|
|
.full-box-video {
|
|
|
width: 70%;
|
|
|
+ height: 100%;
|
|
|
object-fit: cover;
|
|
|
border-radius: rpx(12);
|
|
|
}
|
|
|
+.ppt-box{
|
|
|
+ width: 100%;
|
|
|
+ height: rpx(300);
|
|
|
+ display: flex;
|
|
|
+ justify-content: center;
|
|
|
+ align-items: center;
|
|
|
+}
|
|
|
+.ppt-container {
|
|
|
+ width: 70%;
|
|
|
+ height: 100%;
|
|
|
+ border-radius: rpx(12);
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+
|
|
|
+.ppt-container ::v-deep(.pptx-preview-wrapper) {
|
|
|
+ // 滚动条整体样式
|
|
|
+ &::-webkit-scrollbar {
|
|
|
+ width: rpx(0); // 滚动条宽度
|
|
|
+ height: rpx(0);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 滚动条滑块样式
|
|
|
+ &::-webkit-scrollbar-thumb {
|
|
|
+ background-color: #e2ddfc; // 滑块颜色
|
|
|
+ border-radius: rpx(4); // 滑块圆角
|
|
|
+ }
|
|
|
+
|
|
|
+ // 滚动条轨道样式
|
|
|
+ &::-webkit-scrollbar-track {
|
|
|
+ background-color: rgba(143, 116, 255, 0.2); // 轨道颜色
|
|
|
+ border-radius: rpx(4); // 轨道圆角
|
|
|
+ }
|
|
|
+ // border-radius: rpx(12);
|
|
|
+}
|
|
|
+
|
|
|
/* 隐藏 Chrome 视频控件的渐变背景等默认样式 */
|
|
|
video::-webkit-media-controls-panel {
|
|
|
background: transparent !important; /* 去掉背景渐变,设为透明 */
|
|
|
@@ -327,7 +411,19 @@ video::-webkit-media-controls-panel {
|
|
|
margin-top: rpx(5);
|
|
|
margin-bottom: rpx(15);
|
|
|
}
|
|
|
-
|
|
|
+.image-container {
|
|
|
+ width: 100%;
|
|
|
+ height: rpx(300);
|
|
|
+ display: flex;
|
|
|
+ justify-content: center;
|
|
|
+ align-items: center;
|
|
|
+}
|
|
|
+.course-image {
|
|
|
+ max-width: 70%;
|
|
|
+ max-height: rpx(289);
|
|
|
+ border-radius: rpx(12);
|
|
|
+ object-fit: contain;
|
|
|
+}
|
|
|
.caret-right,
|
|
|
.caret-left {
|
|
|
width: rpx(50);
|
|
|
@@ -351,4 +447,4 @@ video::-webkit-media-controls-panel {
|
|
|
.caret-left img {
|
|
|
width: rpx(12);
|
|
|
}
|
|
|
-</style>
|
|
|
+</style>
|