| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755 |
- <template>
- <div class="video-container">
- <div class="box-video">
- <!-- 视频 -->
- <template v-if="contentType === 'video'">
- <div class="video-wrapper" :class="{ 'video-paused': isVideoPaused }">
- <video
- class="full-box-video"
- ref="videoRef"
- :controls="true"
- :controlsList="'nofullscreen'"
- @timeupdate="handleTimeUpdate"
- @seeked="handleSeeked"
- @ended="handleVideoEnded"
- @loadedmetadata="handleLoadedMetadata"
- @pause="handleVideoPause"
- @play="handleVideoPlay"
- @click="handleVideoClick"
- ></video>
- <!-- 历史播放位置提示 -->
- <div
- v-if="showHistoryTip"
- class="history-tip"
- :style="{ left: historyTipPosition + '%' }"
- >
- <el-icon @click.stop="closeHistoryTip" class="close-icon"><CloseBold /></el-icon>
- <span @click="goToLastPosition">回到上次播放位置</span>
- </div>
- <!-- 章节要点小圆点 -->
- <div class="progress-markers" v-if="chapterMarkers.length > 0">
- <div
- v-for="marker in chapterMarkers"
- :key="marker.time"
- class="progress-marker"
- :style="{ left: marker.position + '%' }"
- @click="seekToTime(marker.time)"
- ></div>
- </div>
- <!-- 网页全屏按钮 -->
- <el-tooltip
- :content="isWebFullscreen ? '退出网页全屏' : '进入网页全屏'"
- placement="left"
- effect="dark"
- >
- <div class="fullscreen-btn" @click="toggleWebFullscreen">
- <el-icon v-if="!isWebFullscreen"><FullScreen /></el-icon>
- <el-icon v-else><Close /></el-icon>
- </div>
- </el-tooltip>
- </div>
- </template>
- </div>
- </div>
- </template>
- <script setup>
- import {
- ref,
- onMounted,
- onBeforeUnmount,
- defineProps,
- defineEmits,
- watch,
- nextTick
- } from 'vue'
- import { videoPlay as Vue3VideoPlay } from 'vue3-video-play'
- import Hls from 'hls.js'
- import { ElMessage } from 'element-plus'
- // 导入图标
- import { CloseBold, FullScreen, Close } from '@element-plus/icons-vue'
- // 定义props
- const props = defineProps({
- contentType: { type: String, required: true }, // contentType类型
- videoPath: { type: String }, // 变为可选
- courseId: { type: String, required: true },
- typeId: { type: String, required: true },
- courseConfigList: { type: Array, default: () => [] },
- allIndices: { type: Array, default: () => [] },
- currentIndex: { type: String, required: true }
- })
- // 定义emits
- const emits = defineEmits(['timeUpdate', 'videoEnded', 'switchVideo', 'saveProgress'])
- // 视频引用
- const videoRef = ref(null)
- // HLS实例
- const hlsRef = ref(null)
- // 记录已经暂停过的时间点索引
- const pausedIndices = ref([])
- // 记录已经保存的进度百分比
- const savedProgress = ref([])
- // 节流时间间隔(毫秒)
- const THROTTLE_TIME = 3000
- // 上次播放进度
- const lastPlayProgress = ref(0)
- // 定义进度数组
- const targetProgresses = [10, 50, 100]
- // 章节要点标记
- const chapterMarkers = ref([])
- // 历史播放位置提示状态
- const showHistoryTip = ref(false)
- // 上次播放位置时间
- const lastPlayTime = ref(0)
- // 历史播放提示位置百分比
- const historyTipPosition = ref(0)
- // 视频是否暂停状态
- const isVideoPaused = ref(false)
- // 是否处于网页全屏状态
- const isWebFullscreen = ref(false)
- // 定义节流函数
- const throttle = (fn, delay) => {
- let lastCall = 0
- return function (...args) {
- const now = Date.now()
- if (now - lastCall >= delay) {
- lastCall = now
- return fn.apply(this, args)
- }
- }
- }
- // 保存进度(带节流)
- const saveProgress = throttle(async (progress, currentTime) => {
- try {
- // 保存到localStorage,下次加载视频续播
- localStorage.setItem(
- `videoProgress_${props.courseId}`,
- JSON.stringify({
- progress: progress,
- currentTime: currentTime,
- timestamp: Date.now()
- })
- )
- // 通过emit事件通知父组件保存进度
- emits('saveProgress', "course", progress)
- savedProgress.value.push(progress)
- } catch (error) {
- console.error(`保存进度失败:`, error)
- }
- }, THROTTLE_TIME)
- // 处理视频元数据加载完成
- const handleLoadedMetadata = () => {
- if (!videoRef.value) return
- const duration = videoRef.value.duration
- if (duration && props.courseConfigList && props.courseConfigList.length) {
- // 根据courseConfigList生成章节标记,过滤掉ccTime不存在或无效的配置项
- chapterMarkers.value = props.courseConfigList
- .filter(config => config.ccTime !== undefined && config.ccTime !== null && config.ccTime !== '')
- .map(config => ({
- time: config.ccTime,
- position: (config.ccTime / duration) * 100
- }))
- } else {
- // 确保在courseConfigList为空时清空标记
- chapterMarkers.value = []
- }
- }
- // 跳转到指定时间点
- const seekToTime = (time) => {
- if (videoRef.value) {
- videoRef.value.currentTime = time
- // 清除暂停索引,用户点击后可以再次触发暂停
- pausedIndices.value = pausedIndices.value.filter(t => t !== time)
- }
- }
- // 处理视频时间更新事件
- const handleTimeUpdate = ev => {
- if (!videoRef.value) return
- const currentTime = parseInt(ev.target.currentTime)
- const duration = videoRef.value.duration || 0
- // 如果章节标记为空且有课程配置,生成标记
- if (chapterMarkers.value.length === 0 && props.courseConfigList.length && duration > 0) {
- handleLoadedMetadata()
- }
- const progressPercentage =
- duration > 0 ? Math.round((currentTime / duration) * 100) : 0
- // 更新最后播放进度
- lastPlayProgress.value = progressPercentage
- // 检查是否达到目标进度点且尚未保存
- targetProgresses.some(target => {
- const isNearTarget = Math.abs(progressPercentage - target) <= 2
- const isNotSaved = !savedProgress.value.includes(target)
- if (isNearTarget && isNotSaved) {
- // 保存目标进度
- saveProgress(target, currentTime)
- return true
- }
- return false
- })
- // 使用节流保存进度
- saveProgress(progressPercentage, currentTime)
- // 触发父组件的时间更新事件
- emits('timeUpdate', { currentTime, progressPercentage })
- if (!props.courseConfigList.length) return
- props.courseConfigList.forEach(courseCofig => {
- // 暂停时间
- let time = courseCofig.ccTime
- // 检查是否到达时间点且还未暂停过
- if (currentTime === time && !pausedIndices.value.includes(time)) {
- videoRef.value.pause()
- // 记录暂停时间
- pausedIndices.value.push(currentTime)
- // 根据 ccQuestSource 的值决定是否显示问题弹框
- // 当 ccQuestSource 为 1 时,显示问题弹框
- if (courseCofig.ccQuestSource === '1' && courseCofig.ccQuestContent) {
- // 触发父组件显示试题
- emits('timeUpdate', {
- currentTime,
- progressPercentage,
- courseConfig: courseCofig
- })
- }
- }
- })
- }
- // 视频完成拖动进度条时触发的方法
- const handleSeeked = () => {
- pausedIndices.value = []
- }
- // 视频结束事件处理
- const handleVideoEnded = () => {
- // 视频结束时保存100%进度
- if (!savedProgress.value.includes(100)) {
- // 通过emit事件通知父组件保存进度
- emits('saveProgress', "course", 100)
- savedProgress.value.push(100)
- }
- emits('videoEnded')
- }
- // 在视频加载完成后设置上次播放进度
- const setLastPlayPosition = () => {
- if (!videoRef.value) return
- try {
- const savedData = localStorage.getItem(`videoProgress_${props.courseId}`)
- if (savedData) {
- const { currentTime, progress } = JSON.parse(savedData)
- if (currentTime && !isNaN(currentTime) && currentTime > 0) {
- // 存储上次播放位置但不自动跳转
- lastPlayTime.value = currentTime
- lastPlayProgress.value = progress
- // 计算历史提示位置
- const duration = videoRef.value.duration
- historyTipPosition.value = duration > 0 ? (currentTime / duration) * 100 : 0
- showHistoryTip.value = true
-
- // 检查是否已有保存的进度点
- if (progress >= 10) savedProgress.value.push(10)
- if (progress >= 50) savedProgress.value.push(50)
- if (progress >= 100) savedProgress.value.push(100)
-
- // 5秒后自动隐藏历史位置提示
- setTimeout(() => {
- showHistoryTip.value = false
- }, 5000)
- }
- }
- } catch (error) {
- console.error('读取上次播放进度失败:', error)
- }
- }
- // 跳转到上次播放位置
- const goToLastPosition = () => {
- if (videoRef.value && lastPlayTime.value > 0) {
- videoRef.value.currentTime = lastPlayTime.value
- showHistoryTip.value = false
- // 自动播放视频
- videoRef.value.play().catch(error => {
- console.error('自动播放失败:', error)
- })
- }
- }
- // 关闭历史播放位置提示
- const closeHistoryTip = () => {
- showHistoryTip.value = false
- }
- // 处理视频暂停事件
- const handleVideoPause = () => {
- isVideoPaused.value = true
- }
- // 处理视频播放事件
- const handleVideoPlay = () => {
- isVideoPaused.value = false
- }
- // 处理视频点击事件
- const handleVideoClick = () => {
- if (videoRef.value) {
- if (videoRef.value.paused) {
- videoRef.value.play();
- } else {
- videoRef.value.pause();
- }
- }
- }
- // 初始化视频播放器
- const initVideoPlayer = () => {
- if (props.contentType !== 'video') return
- // 使用nextTick确保DOM已经更新
- nextTick(() => {
- if (!videoRef.value) {
- console.error('视频元素未找到')
- return
- }
- // 清理之前的HLS实例
- if (hlsRef.value) {
- hlsRef.value.destroy()
- hlsRef.value = null
- }
- // 为视频元素添加键盘事件监听器
- addVideoKeyboardListener();
- // 检查视频路径是否是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)
- // 只对致命错误显示错误提示
- if (data.fatal) {
- ElMessage.error('视频加载失败,请稍后重试')
- }
- })
- } else if (videoRef.value.canPlayType('application/vnd.apple.mpegurl')) {
- // 对于不支持HLS但支持原生m3u8的浏览器
- videoRef.value.src = props.videoPath
- tryPlayVideo()
- } else {
- ElMessage.error('您的浏览器不支持播放m3u8格式视频')
- }
- } else {
- // 普通视频播放
- videoRef.value.src = props.videoPath
- tryPlayVideo()
- }
- })
- }
- // 尝试播放视频,处理浏览器自动播放限制
- const tryPlayVideo = () => {
- // 确保videoRef存在
- if (!videoRef.value) {
- console.error('视频元素未找到')
- return
- }
- // 自动播放视频
- videoRef.value.play().catch(error => {
- console.error('自动播放失败:', error)
- })
- // 在视频加载完成后设置上次播放进度
- setTimeout(() => {
- setLastPlayPosition()
- }, 1000)
- }
- // 处理空格键控制播放/暂停
- const handleKeyPress = (event) => {
- // 检查是否是空格键且视频元素存在
- if (event.code === 'Space' && videoRef.value) {
- event.preventDefault(); // 防止空格键默认行为
- if (videoRef.value.paused) {
- videoRef.value.play();
- } else {
- videoRef.value.pause();
- }
- }
- }
- // 为视频元素添加键盘事件监听器
- const addVideoKeyboardListener = () => {
- if (videoRef.value) {
- videoRef.value.addEventListener('keydown', handleKeyPress);
- }
- }
- // 移除视频元素的键盘事件监听器
- const removeVideoKeyboardListener = () => {
- if (videoRef.value) {
- videoRef.value.removeEventListener('keydown', handleKeyPress);
- }
- }
- // 组件挂载时
- onMounted(() => {
- initVideoPlayer()
- // 键盘事件监听
- document.addEventListener('keydown', handleKeyPress);
- // 为视频元素添加键盘事件监听器
- addVideoKeyboardListener();
- })
- // 组件卸载时
- onBeforeUnmount(() => {
- if (hlsRef.value) {
- hlsRef.value.destroy()
- hlsRef.value = null
- }
- // 移除键盘事件监听
- document.removeEventListener('keydown', handleKeyPress);
- // 移除视频元素的键盘事件监听器
- removeVideoKeyboardListener();
- })
- // 监听contentType和videoPath变化
- watch([() => props.contentType, () => props.videoPath], () => {
- // 当contentType变为video或videoPath变化时,重新初始化
- if (props.contentType === 'video') {
- initVideoPlayer()
- }
- })
- // 切换网页全屏
- const toggleWebFullscreen = () => {
- const videoWrapper = document.querySelector('.video-wrapper')
- if (!videoWrapper) return
-
- if (!isWebFullscreen.value) {
- // 进入网页全屏
- videoWrapper.style.position = 'fixed'
- videoWrapper.style.top = '0'
- videoWrapper.style.left = '0'
- videoWrapper.style.width = '100vw'
- videoWrapper.style.height = '100vh'
- videoWrapper.style.zIndex = '9999'
- videoWrapper.style.backgroundColor = 'black'
- videoWrapper.style.display = 'flex'
- videoWrapper.style.justifyContent = 'center'
- videoWrapper.style.alignItems = 'center'
-
- // 调整视频大小
- const video = videoWrapper.querySelector('.full-box-video')
- if (video) {
- video.style.width = '100%'
- video.style.height = '100%'
- video.style.objectFit = 'contain'
- }
-
- isWebFullscreen.value = true
- } else {
- // 退出网页全屏
- videoWrapper.style.position = ''
- videoWrapper.style.top = ''
- videoWrapper.style.left = ''
- videoWrapper.style.width = '70%'
- videoWrapper.style.height = '100%'
- videoWrapper.style.zIndex = ''
- videoWrapper.style.backgroundColor = ''
- videoWrapper.style.display = 'flex'
- videoWrapper.style.justifyContent = 'center'
- videoWrapper.style.alignItems = 'center'
-
- // 恢复视频大小
- const video = videoWrapper.querySelector('.full-box-video')
- if (video) {
- video.style.width = '100%'
- video.style.height = '100%'
- video.style.objectFit = 'cover'
- }
-
- isWebFullscreen.value = false
- }
- }
- // 组件卸载时
- onBeforeUnmount(() => {
- if (hlsRef.value) {
- hlsRef.value.destroy()
- hlsRef.value = null
- }
-
- // 确保退出网页全屏
- if (isWebFullscreen.value) {
- const videoWrapper = document.querySelector('.video-wrapper')
- if (videoWrapper) {
- videoWrapper.style.position = ''
- videoWrapper.style.top = ''
- videoWrapper.style.left = ''
- videoWrapper.style.width = '70%'
- videoWrapper.style.height = '100%'
- videoWrapper.style.zIndex = ''
- videoWrapper.style.backgroundColor = ''
- }
- }
-
- // 移除键盘事件监听
- document.removeEventListener('keydown', handleKeyPress);
- // 移除视频元素的键盘事件监听器
- removeVideoKeyboardListener();
- })
- </script>
- <style scoped lang="scss">
- @use 'sass:math';
- // 定义rpx转换函数
- @function rpx($px) {
- @return math.div($px, 750) * 100vw;
- }
- .box-video {
- width: 100%;
- height: rpx(300);
- display: flex;
- justify-content: center;
- align-items: center;
- .d-player-wrap {
- height: rpx(289);
- width: 68.5%;
- border-radius: rpx(12);
- object-fit: cover;
- }
- }
- .video-wrapper {
- position: relative;
- width: 70%;
- height: 100%;
- display: flex;
- justify-content: center;
- align-items: center;
- }
- .full-box-video {
- width: 100%;
- height: 100%;
- object-fit: cover;
- border-radius: rpx(12);
- }
- .ppt-box{
- width: 100%;
- height: rpx(300);
- display: flex;
- justify-content: center;
- align-items: center;
- position: relative;
- }
- .ppt-page-info {
- position: absolute;
- bottom: rpx(10); // 距离底部10rpx
- left: 50%; // 水平居中
- transform: translateX(-50%); // 水平居中调整
- background-color: rgba(0, 0, 0, 0.5);
- color: white;
- padding: rpx(3) rpx(8);
- border-radius: rpx(4);
- font-size: rpx(8);
- z-index: 10;
- }
- .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);
- }
- .ppt-navigation {
- position: absolute;
- bottom: rpx(18);
- width: 70%;
- display: flex;
- justify-content: center;
- gap: rpx(20);
- padding: 0 rpx(10);
- box-sizing: border-box;
- }
- .ppt-btn {
- background-color: rgb(255, 255, 255, 0.5);
- color: white;
- border: 1px white solid;
- border-radius: rpx(12);
- font-size: rpx(7);
- cursor: pointer;
- transition: background-color 0.3s;
- display: flex;
- align-items: center;
- justify-content: center;
- height: rpx(15);
- }
- .ppt-btn:hover {
- background-color: rgba(0, 0, 0, 0.7);
- }
- .ppt-prev-btn, .ppt-next-btn {
- width: rpx(50);
- }
- /* 章节要点标记样式 */
- .progress-markers {
- position: absolute;
- bottom: 26px;
- left: 15px;
- right: 15px;
- height: 10px;
- pointer-events: none;
- z-index: 10;
- opacity: 0; /* 默认隐藏 */
- transition: opacity 0.3s ease; /* 过渡效果 */
- }
- .video-wrapper:hover .progress-markers {
- opacity: 1; /* 鼠标悬停在视频上时显示 */
- }
- /* 当视频控件可见时显示标记 */
- video::-webkit-media-controls-panel:not([hidden]) ~ .progress-markers {
- opacity: 1;
- }
- /* 当视频暂停时显示标记 */
- .video-paused .progress-markers {
- opacity: 1;
- }
- .progress-marker {position: absolute;
- width: rpx(4);
- height: rpx(4);
- background-color: orange;
- border-radius: 50%;
- transform: translateX(-50%);
- cursor: pointer;
- transition: all 0.3s ease;
- pointer-events: all;
- }
- .progress-marker:hover {
- width: rpx(6);
- height: rpx(6);
- }
- /* 历史播放位置提示样式 */
- .history-tip {
- position: absolute;
- bottom: rpx(22);
- transform: translateX(-50%);
- background-color: rgba(0, 0, 0, 0.5);
- color: white;
- padding: rpx(5) rpx(7);
- border-radius: rpx(4);
- font-size: rpx(6);
- cursor: pointer;
- transition: all 0.3s ease;
- z-index: 20;
- white-space: nowrap;
- display: flex;
- align-items: center;
- gap: rpx(2);
- }
- .history-tip .close-icon {
- cursor: pointer;
- font-size: rpx(7);
- transition: color 0.3s ease;
- }
- .history-tip .close-icon:hover {
- color: #ff4d4f;
- }
- .history-tip::after {
- content: '';
- position: absolute;
- top: 100%;
- left: 50%;
- transform: translateX(-50%);
- border: rpx(4) solid transparent;
- border-top-color: rgba(0, 0, 0, 0.5);
- width: 0;
- height: 0;
- }
- .history-tip:hover {
- background-color: rgba(0, 0, 0, 0.6);
- }
- /* 网页全屏按钮样式 */
- .fullscreen-btn {
- position: absolute;
- top: 10px;
- right: 10px;
- width: 30px;
- height: 30px;
- background-color: rgba(0, 0, 0, 0.5);
- color: white;
- border-radius: 4px;
- display: flex;
- justify-content: center;
- align-items: center;
- cursor: pointer;
- z-index: 15;
- transition: all 0.3s ease;
- }
- .fullscreen-btn:hover {
- background-color: rgba(0, 0, 0, 0.7);
- }
- /* 调整视频控件样式以适应我们的自定义标记 */
- video::-webkit-media-controls-timeline {
- margin-bottom: 10px;
- }
- video::-webkit-media-controls {
- overflow: visible !important;
- }
- video::-webkit-media-controls-panel {
- width: calc(100% + 30px); /* 扩展控件面板宽度,确保与自定义标记对齐 */
- background: transparent !important; /* 去掉背景渐变,设为透明 */
- }
- </style>
|