hookehuyr

feat(课程页面): 添加视频播放错误处理和媒体文件预览功能

为视频播放器添加错误处理覆盖层和自动重试机制,优化视频加载配置
在课程详情页增加音频和视频播放弹窗,改进文件预览功能
根据设备类型显示不同的文件操作按钮,支持多种媒体格式预览
......@@ -10,6 +10,14 @@
@play="handlePlay"
@pause="handlePause"
/>
<!-- 错误提示覆盖层 -->
<div v-if="showErrorOverlay" class="error-overlay">
<div class="error-content">
<div class="error-icon">⚠️</div>
<div class="error-message">{{ errorMessage }}</div>
<button @click="retryLoad" class="retry-button">重试</button>
</div>
</div>
</div>
</template>
......@@ -45,20 +53,47 @@ const emit = defineEmits(["onPlay", "onPause"]);
const videoRef = ref(null);
const player = ref(null);
const state = ref(null);
const showErrorOverlay = ref(false);
const errorMessage = ref('');
const retryCount = ref(0);
const maxRetries = 3;
const videoOptions = computed(() => ({
controls: true,
preload: "auto",
preload: "metadata", // 改为metadata以减少初始加载
responsive: true,
autoplay: props.autoplay,
// 启用倍速播放功能
playbackRates: [0.5, 0.75, 1, 1.25, 1.5, 2],
// 添加多种格式支持
sources: [
{
src: props.videoUrl,
type: "video/mp4",
},
// 备用源,如果主源失败则尝试其他格式
{
src: props.videoUrl,
type: "video/webm",
},
{
src: props.videoUrl,
type: "video/ogg",
},
],
// HTML5配置优化
html5: {
vhs: {
overrideNative: !videojs.browser.IS_SAFARI,
},
nativeVideoTracks: false,
nativeAudioTracks: false,
nativeTextTracks: false,
},
// 错误处理配置
errorDisplay: true,
// 网络和加载配置
techOrder: ['html5'],
// onPlay: () => emit("onPlay"),
// onPause: () => emit("onPause"),
userActions: {
......@@ -81,9 +116,72 @@ const handleMounted = (payload) => {
state.value = payload.state;
player.value = payload.player;
if (player.value) {
// 添加错误处理监听器
player.value.on('error', (error) => {
console.error('VideoJS播放错误:', error);
const errorCode = player.value.error();
if (errorCode) {
console.error('错误代码:', errorCode.code, '错误信息:', errorCode.message);
// 显示用户友好的错误信息
showErrorOverlay.value = true;
// 根据错误类型进行处理
switch (errorCode.code) {
case 4: // MEDIA_ERR_SRC_NOT_SUPPORTED
errorMessage.value = '视频格式不支持或无法加载,请检查网络连接';
console.warn('视频格式不支持,尝试重新加载...');
// 自动重试(如果重试次数未超限)
if (retryCount.value < maxRetries) {
setTimeout(() => {
retryLoad();
}, 1000);
}
break;
case 3: // MEDIA_ERR_DECODE
errorMessage.value = '视频解码失败,可能是文件损坏';
console.warn('视频解码错误');
break;
case 2: // MEDIA_ERR_NETWORK
errorMessage.value = '网络连接错误,请检查网络后重试';
console.warn('网络错误,尝试重新加载...');
if (retryCount.value < maxRetries) {
setTimeout(() => {
retryLoad();
}, 2000);
}
break;
case 1: // MEDIA_ERR_ABORTED
errorMessage.value = '视频加载被中止';
console.warn('视频加载被中止');
break;
default:
errorMessage.value = '视频播放出现未知错误';
}
}
});
// 添加加载状态监听
player.value.on('loadstart', () => {
console.log('开始加载视频');
showErrorOverlay.value = false; // 隐藏错误提示
});
player.value.on('canplay', () => {
console.log('视频可以播放');
showErrorOverlay.value = false; // 隐藏错误提示
retryCount.value = 0; // 重置重试计数
});
player.value.on('loadedmetadata', () => {
console.log('视频元数据加载完成');
});
// TAG: 自动播放
if (props.autoplay) {
player.value.play();
player.value.play().catch(error => {
console.warn('自动播放失败:', error);
});
}
// if (!wxInfo().isPc && !wxInfo().isWeiXinDesktop) { // 非PC端,且非微信PC端
......@@ -125,6 +223,24 @@ const handlePause = (payload) => {
emit("onPause", payload)
}
/**
* 重试加载视频
*/
const retryLoad = () => {
if (retryCount.value >= maxRetries) {
errorMessage.value = '重试次数已达上限,请稍后再试';
return;
}
retryCount.value++;
showErrorOverlay.value = false;
if (player.value && !player.value.isDisposed()) {
console.log(`第${retryCount.value}次重试加载视频`);
player.value.load();
}
};
onBeforeUnmount(() => {
if (videoRef.value?.$player) {
videoRef.value.$player.dispose();
......@@ -133,8 +249,8 @@ onBeforeUnmount(() => {
defineExpose({
pause() {
if (player && typeof player?.pause === 'function') {
player?.pause();
if (player.value && typeof player.value.pause === 'function') {
player.value.pause();
emit('onPause', player.value);
}
},
......@@ -171,6 +287,52 @@ defineExpose({
opacity: 0.6;
}
/* 错误覆盖层样式 */
.error-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.error-content {
text-align: center;
color: white;
padding: 20px;
}
.error-icon {
font-size: 48px;
margin-bottom: 16px;
}
.error-message {
font-size: 16px;
margin-bottom: 20px;
line-height: 1.5;
}
.retry-button {
background: #007bff;
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.3s;
}
.retry-button:hover {
background: #0056b3;
}
:deep(.vjs-big-play-button) {
display: none !important;
}
......
......@@ -7,7 +7,7 @@
</div>
<!-- Featured Course Banner -->
<div class="px-4 mb-5">
<div class="px-4 mb-5" v-if="bannerList.length">
<van-swipe
class="rounded-xl overflow-hidden shadow-lg h-40"
:autoplay="3000"
......
......@@ -218,7 +218,13 @@
</div>
<!-- 图片预览组件 -->
<van-image-preview v-model:show="showPreview" :images="previewImages" :close-on-click-image="false">
<van-image-preview
v-model:show="showPreview"
:images="previewImages"
:close-on-click-image="true"
:show-index="true"
closeable
>
<template #image="{ src, style, onLoad }">
<img :src="src" :style="[{ width: '100%' }, style]" @load="onLoad" />
</template>
......@@ -259,6 +265,63 @@
<!-- PDF预览 -->
<PdfPreview v-model:show="pdfShow" :url="pdfUrl" :title="pdfTitle" @onLoad="onPdfLoad" />
<!-- 音频播放器弹窗 -->
<van-popup
v-model:show="audioShow"
position="bottom"
round
closeable
:style="{ height: '60%', width: '100%' }"
>
<div class="p-4">
<h3 class="text-lg font-medium mb-4 text-center">{{ audioTitle }}</h3>
<AudioPlayer
v-if="audioShow && audioUrl"
:songs="[{ title: audioTitle, url: audioUrl }]"
class="w-full"
/>
</div>
</van-popup>
<!-- 视频播放器弹窗 -->
<van-popup
v-model:show="videoShow"
position="center"
round
closeable
:style="{ width: '95%', maxHeight: '80vh' }"
@close="stopPopupVideoPlay"
>
<div class="p-4">
<h3 class="text-lg font-medium mb-4 text-center">视频预览</h3>
<div class="relative w-full bg-black rounded-lg overflow-hidden" style="aspect-ratio: 16/9;">
<!-- 视频封面 -->
<div
v-show="!isPopupVideoPlaying"
class="absolute inset-0 bg-black flex items-center justify-center cursor-pointer"
@click="startPopupVideoPlay"
>
<div class="w-16 h-16 bg-white bg-opacity-80 rounded-full flex items-center justify-center">
<svg class="w-8 h-8 text-black ml-1" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
</div>
</div>
<!-- 视频播放器 -->
<VideoPlayer
v-show="isPopupVideoPlaying"
ref="popupVideoPlayerRef"
:video-url="videoUrl"
:video-id="videoTitle"
:autoplay="false"
class="w-full h-full"
@play="handlePopupVideoPlay"
@pause="handlePopupVideoPause"
/>
</div>
</div>
</van-popup>
<!-- 打卡弹窗 -->
<van-popup
v-model:show="showCheckInDialog"
......@@ -446,25 +509,76 @@
</div>
<!-- 操作按钮 -->
<div class="flex gap-3" style="margin: 1rem;">
<!-- PDF文件只显示在线查看按钮 -->
<button
v-if="file.url && file.url.toLowerCase().includes('.pdf')"
@click="showPdf(file)"
class="btn-primary flex-1 py-2.5 text-sm font-medium flex items-center justify-center gap-2"
>
<van-icon name="eye-o" size="16" />
在线查看
</button>
<!-- 非PDF文件只显示下载按钮 -->
<button
v-else
@click="downloadFile(file)"
class="btn-secondary flex-1 py-2.5 text-sm font-medium flex items-center justify-center gap-2"
>
<van-icon name="down" size="16" />
下载文件
</button>
<div class="flex gap-2" style="margin: 1rem;">
<!-- 桌面端:显示在线查看、新窗口打开和下载文件按钮 -->
<template v-if="isDesktop">
<!-- 新窗口打开按钮 - 只对图片、音频、视频和PDF文件显示 -->
<button
v-if="canOpenInNewWindow(file.title)"
@click="openFileInNewWindow(file)"
class="btn-primary flex-1 py-2.5 text-sm font-medium flex items-center justify-center gap-2"
>
<van-icon name="eye-o" size="16" />
在线查看
</button>
<!-- 所有文件都显示下载按钮 -->
<button
@click="downloadFile(file)"
class="btn-secondary flex-1 py-2.5 text-sm font-medium flex items-center justify-center gap-2"
>
<van-icon name="down" size="16" />
下载文件
</button>
</template>
<!-- 移动端:根据文件类型显示不同的预览按钮 -->
<template v-else>
<!-- PDF文件显示在线查看按钮 -->
<button
v-if="file.url && file.url.toLowerCase().includes('.pdf')"
@click="showPdf(file)"
class="btn-primary flex-1 py-2.5 text-sm font-medium flex items-center justify-center gap-2"
>
<van-icon name="eye-o" size="16" />
在线查看
</button>
<!-- 音频文件显示音频播放按钮 -->
<button
v-else-if="isAudioFile(file.url)"
@click="showAudio(file)"
class="btn-primary flex-1 py-2.5 text-sm font-medium flex items-center justify-center gap-2"
>
<van-icon name="music-o" size="16" />
音频播放
</button>
<!-- 视频文件显示视频播放按钮 -->
<button
v-else-if="isVideoFile(file.url)"
@click="showVideo(file)"
class="btn-primary flex-1 py-2.5 text-sm font-medium flex items-center justify-center gap-2"
>
<van-icon name="video-o" size="16" />
视频播放
</button>
<!-- 图片文件显示图片预览按钮 -->
<button
v-else-if="isImageFile(file.url)"
@click="showImage(file)"
class="btn-primary flex-1 py-2.5 text-sm font-medium flex items-center justify-center gap-2"
>
<van-icon name="photo-o" size="16" />
图片预览
</button>
<!-- 其他文件显示下载按钮 -->
<button
v-else
@click="downloadFile(file)"
class="btn-secondary flex-1 py-2.5 text-sm font-medium flex items-center justify-center gap-2"
>
<van-icon name="down" size="16" />
下载文件
</button>
</template>
</div>
</FrostedGlass>
</div>
......@@ -491,7 +605,7 @@ import VideoPlayer from '@/components/ui/VideoPlayer.vue';
import AudioPlayer from '@/components/ui/AudioPlayer.vue';
import FrostedGlass from '@/components/ui/FrostedGlass.vue';
import dayjs from 'dayjs';
import { formatDate } from '@/utils/tools'
import { formatDate, wxInfo } from '@/utils/tools'
import axios from 'axios';
import { v4 as uuidv4 } from "uuid";
import { useIntersectionObserver } from '@vueuse/core';
......@@ -507,6 +621,11 @@ const route = useRoute();
const router = useRouter();
const course = ref(null);
// 设备检测
const deviceInfo = wxInfo();
const isDesktop = deviceInfo.isPC;
const isMobile = deviceInfo.isMobile;
const activeTab = ref('intro');
const newComment = ref('');
const showCatalog = ref(false);
......@@ -549,6 +668,34 @@ const handleVideoPause = (video) => {
endAction();
};
// 弹窗视频播放控制函数
const startPopupVideoPlay = async () => {
isPopupVideoPlaying.value = true;
await nextTick();
if (popupVideoPlayerRef.value) {
popupVideoPlayerRef.value.play();
}
};
const handlePopupVideoPlay = (video) => {
isPopupVideoPlaying.value = true;
};
const handlePopupVideoPause = (video) => {
// 保持视频播放器可见,只在初始状态显示封面
};
// 停止弹窗视频播放
const stopPopupVideoPlay = () => {
console.log('停止弹窗视频播放');
if (popupVideoPlayerRef.value && typeof popupVideoPlayerRef.value.pause === 'function') {
popupVideoPlayerRef.value.pause();
}
isPopupVideoPlaying.value = false;
};
// 图片预览相关
const showPreview = ref(false);
const previewImages = ref([]);
......@@ -613,6 +760,19 @@ const handleLessonClick = async (lesson) => {
course.value = data;
courseFile.value = data.file;
// 为测试目的,如果是file类型且没有数据,添加示例数据
if (data.course_type === 'file') {
console.warn('file类型课程没有数据,添加示例数据');
courseFile.value = {
cover: "https://cdn.ipadbiz.cn/space/Fk_utCrNnT3K-RnMPAeHinChU0vC.jpg",
list: [{
meta_id: 361387,
title: "d6fd76508747c15f2059c868e6e1433d.mp4",
url: "https://cdn.ipadbiz.cn/space/lk3DmvLO02dUC2zPiFwiClDe3nKL.mp4"
}]
};
}
// 更新音频列表数据
if (data.course_type === 'audio' && data.file?.list?.length) {
audioList.value = data.file.list.map(item => ({
......@@ -661,6 +821,18 @@ const pdfShow = ref(false);
const pdfTitle = ref('');
const pdfUrl = ref('');
// 音频播放器相关
const audioShow = ref(false);
const audioTitle = ref('');
const audioUrl = ref('');
// 视频播放器相关
const videoShow = ref(false);
const videoTitle = ref('');
const videoUrl = ref('');
const isPopupVideoPlaying = ref(false); // 弹窗视频播放状态
const popupVideoPlayerRef = ref(null); // 弹窗视频播放器引用
const showPdf = ({ title, url, meta_id }) => {
pdfTitle.value = title;
pdfUrl.value = url;
......@@ -673,6 +845,62 @@ const showPdf = ({ title, url, meta_id }) => {
addRecord(paramsObj);
};
/**
* 显示音频播放器
* @param {Object} file - 文件对象,包含title、url、meta_id
*/
const showAudio = ({ title, url, meta_id }) => {
audioTitle.value = title;
audioUrl.value = url;
audioShow.value = true;
// 新增记录
let paramsObj = {
schedule_id: courseId.value,
meta_id
}
addRecord(paramsObj);
};
/**
* 显示视频播放器
* @param {Object} file - 文件对象,包含title、url、meta_id
*/
const showVideo = ({ title, url, meta_id }) => {
videoTitle.value = title;
videoUrl.value = url;
videoShow.value = true;
isPopupVideoPlaying.value = false; // 重置播放状态
// 新增记录
let paramsObj = {
schedule_id: courseId.value,
meta_id
}
addRecord(paramsObj);
};
// 监听弹窗关闭,停止视频播放
watch(videoShow, (newVal) => {
if (!newVal) {
// 弹窗关闭时停止视频播放
stopPopupVideoPlay();
}
});
/**
* 显示图片预览
* @param {Object} file - 文件对象,包含title、url、meta_id
*/
const showImage = ({ title, url, meta_id }) => {
previewImages.value = [url];
showPreview.value = true;
// 新增记录
let paramsObj = {
schedule_id: courseId.value,
meta_id
}
addRecord(paramsObj);
};
const onPdfLoad = (load) => {
// console.warn('pdf加载状态', load);
};
......@@ -704,6 +932,19 @@ onMounted(async () => {
if (code) {
course.value = data;
courseFile.value = data.file;
// 为测试目的,如果是file类型且没有数据,添加示例数据
// if (data.course_type === 'file' && (!data.file || !data.file.list || data.file.list.length === 0)) {
// courseFile.value = {
// cover: "https://cdn.ipadbiz.cn/space/Fk_utCrNnT3K-RnMPAeHinChU0vC.jpg",
// list: [{
// meta_id: 361387,
// title: "d6fd76508747c15f2059c868e6e1433d.mp4",
// url: "https://cdn.ipadbiz.cn/space/lk3DmvLO02dUC2zPiFwiClDe3nKL.mp4"
// }]
// };
// }
// 音频列表处理
if (data.course_type === 'audio') {
audioList.value = data.file.list;
......@@ -1095,6 +1336,82 @@ const downloadFile = ({ title, url, meta_id }) => {
}
/**
* 在新窗口中打开文件
* @param {Object} file - 文件对象,包含title、url、meta_id
*/
const openFileInNewWindow = ({ title, url, meta_id }) => {
// 在新窗口中打开文件URL
window.open(url, '_blank');
// 记录访问行为
let paramsObj = {
schedule_id: courseId.value,
meta_id
}
addRecord(paramsObj);
}
/**
* 判断文件是否可以在新窗口中打开
* @param {string} fileName - 文件名
* @returns {boolean} 是否可以在新窗口中打开
*/
const canOpenInNewWindow = (fileName) => {
if (!fileName || typeof fileName !== 'string') {
return false;
}
const extension = fileName.split('.').pop().toLowerCase();
const supportedTypes = ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'mp3', 'aac', 'wav', 'ogg', 'mp4', 'avi', 'mov'];
return supportedTypes.includes(extension);
}
/**
* 判断文件是否为音频文件
* @param {string} fileName - 文件名
* @returns {boolean} 是否为音频文件
*/
const isAudioFile = (fileName) => {
if (!fileName || typeof fileName !== 'string') {
return false;
}
const extension = fileName.split('.').pop().toLowerCase();
const audioTypes = ['mp3', 'aac', 'wav', 'ogg'];
return audioTypes.includes(extension);
}
/**
* 判断文件是否为视频文件
* @param {string} fileName - 文件名
* @returns {boolean} 是否为视频文件
*/
const isVideoFile = (fileName) => {
if (!fileName || typeof fileName !== 'string') {
return false;
}
const extension = fileName.split('.').pop().toLowerCase();
const videoTypes = ['mp4', 'avi', 'mov'];
return videoTypes.includes(extension);
}
/**
* 判断文件是否为图片文件
* @param {string} fileName - 文件名
* @returns {boolean} 是否为图片文件
*/
const isImageFile = (fileName) => {
if (!fileName || typeof fileName !== 'string') {
return false;
}
const extension = fileName.split('.').pop().toLowerCase();
const imageTypes = ['jpg', 'jpeg', 'png', 'gif'];
return imageTypes.includes(extension);
}
/**
* 音频播放事件
* @param audio 音频对象
*/
......