VideoPlayer.vue 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782
  1. <template>
  2. <div class="video-container">
  3. <div class="box-video">
  4. <!-- 视频 -->
  5. <template v-if="contentType === 'video'">
  6. <div class="video-wrapper" :class="{ 'video-paused': isVideoPaused }">
  7. <video
  8. class="full-box-video"
  9. ref="videoRef"
  10. :controls="true"
  11. @timeupdate="handleTimeUpdate"
  12. @seeked="handleSeeked"
  13. @ended="handleVideoEnded"
  14. @loadedmetadata="handleLoadedMetadata"
  15. @pause="handleVideoPause"
  16. @play="handleVideoPlay"
  17. @click="handleVideoClick"
  18. controlslist="nodownload"
  19. ></video>
  20. <!-- 历史播放位置提示 -->
  21. <div
  22. v-if="showHistoryTip"
  23. class="history-tip"
  24. :style="{ left: historyTipPosition + '%' }"
  25. >
  26. <el-icon @click.stop="closeHistoryTip" class="close-icon"><CloseBold /></el-icon>
  27. <span @click="goToLastPosition">回到上次播放位置</span>
  28. </div>
  29. <!-- 章节要点小圆点 -->
  30. <div class="progress-markers" v-if="chapterMarkers.length > 0">
  31. <div
  32. v-for="marker in chapterMarkers"
  33. :key="marker.time"
  34. class="progress-marker"
  35. :style="{ left: marker.position + '%' }"
  36. @click="seekToTime(marker.time)"
  37. ></div>
  38. </div>
  39. </div>
  40. </template>
  41. </div>
  42. </div>
  43. </template>
  44. <script setup>
  45. import {
  46. ref,
  47. onMounted,
  48. onBeforeUnmount,
  49. defineProps,
  50. defineEmits,
  51. watch,
  52. nextTick
  53. } from 'vue'
  54. import { videoPlay as Vue3VideoPlay } from 'vue3-video-play'
  55. import Hls from 'hls.js'
  56. import { ElMessage } from 'element-plus'
  57. // 导入图标
  58. import { CloseBold, FullScreen, Close } from '@element-plus/icons-vue'
  59. // 定义props
  60. const props = defineProps({
  61. contentType: { type: String, required: true }, // contentType类型
  62. videoPath: { type: String }, // 变为可选
  63. courseId: { type: String, required: true },
  64. typeId: { type: String, required: true },
  65. courseConfigList: { type: Array, default: () => [] },
  66. allIndices: { type: Array, default: () => [] },
  67. currentIndex: { type: String, required: true }
  68. })
  69. // 定义emits
  70. const emits = defineEmits(['timeUpdate', 'videoEnded', 'switchVideo', 'saveProgress'])
  71. // 视频引用
  72. const videoRef = ref(null)
  73. // HLS实例
  74. const hlsRef = ref(null)
  75. // 记录已经暂停过的时间点索引
  76. const pausedIndices = ref([])
  77. // 记录已经保存的进度百分比
  78. const savedProgress = ref([])
  79. // 节流时间间隔(毫秒)
  80. const THROTTLE_TIME = 3000
  81. // 上次播放进度
  82. const lastPlayProgress = ref(0)
  83. // 定义进度数组
  84. const targetProgresses = [10, 50, 100]
  85. // 章节要点标记
  86. const chapterMarkers = ref([])
  87. // 历史播放位置提示状态
  88. const showHistoryTip = ref(false)
  89. // 上次播放位置时间
  90. const lastPlayTime = ref(0)
  91. // 历史播放提示位置百分比
  92. const historyTipPosition = ref(0)
  93. // 视频是否暂停状态
  94. const isVideoPaused = ref(false)
  95. // 是否处于网页全屏状态
  96. const isWebFullscreen = ref(false)
  97. // 定义节流函数
  98. const throttle = (fn, delay) => {
  99. let lastCall = 0
  100. return function (...args) {
  101. const now = Date.now()
  102. if (now - lastCall >= delay) {
  103. lastCall = now
  104. return fn.apply(this, args)
  105. }
  106. }
  107. }
  108. // 保存进度(带节流)
  109. const saveProgress = throttle(async (progress, currentTime) => {
  110. try {
  111. // 保存到localStorage,下次加载视频续播
  112. localStorage.setItem(
  113. `videoProgress_${props.courseId}`,
  114. JSON.stringify({
  115. progress: progress,
  116. currentTime: currentTime,
  117. timestamp: Date.now()
  118. })
  119. )
  120. // 通过emit事件通知父组件保存进度
  121. emits('saveProgress', "course", progress)
  122. savedProgress.value.push(progress)
  123. } catch (error) {
  124. console.error(`保存进度失败:`, error)
  125. }
  126. }, THROTTLE_TIME)
  127. // 处理视频元数据加载完成
  128. const handleLoadedMetadata = () => {
  129. if (!videoRef.value) return
  130. const duration = videoRef.value.duration
  131. if (duration && props.courseConfigList && props.courseConfigList.length) {
  132. // 根据courseConfigList生成章节标记,过滤掉ccTime不存在或无效的配置项
  133. chapterMarkers.value = props.courseConfigList
  134. .filter(config => config.ccTime !== undefined && config.ccTime !== null && config.ccTime !== '')
  135. .map(config => ({
  136. time: config.ccTime,
  137. position: (config.ccTime / duration) * 100
  138. }))
  139. } else {
  140. // 确保在courseConfigList为空时清空标记
  141. chapterMarkers.value = []
  142. }
  143. }
  144. // 跳转到指定时间点
  145. const seekToTime = (time) => {
  146. if (videoRef.value) {
  147. videoRef.value.currentTime = time
  148. // 清除已暂停索引,确保用户点击后可以再次触发暂停
  149. pausedIndices.value = pausedIndices.value.filter(t => t !== time)
  150. }
  151. }
  152. // 处理视频时间更新事件
  153. const handleTimeUpdate = ev => {
  154. if (!videoRef.value) return
  155. const currentTime = parseInt(ev.target.currentTime)
  156. const duration = videoRef.value.duration || 0
  157. // 如果章节标记为空且有课程配置,生成标记
  158. if (chapterMarkers.value.length === 0 && props.courseConfigList.length && duration > 0) {
  159. handleLoadedMetadata()
  160. }
  161. const progressPercentage =
  162. duration > 0 ? Math.round((currentTime / duration) * 100) : 0
  163. // 更新最后播放进度
  164. lastPlayProgress.value = progressPercentage
  165. // 检查是否达到目标进度点且尚未保存
  166. targetProgresses.some(target => {
  167. const isNearTarget = Math.abs(progressPercentage - target) <= 2
  168. const isNotSaved = !savedProgress.value.includes(target)
  169. if (isNearTarget && isNotSaved) {
  170. // 保存目标进度
  171. saveProgress(target, currentTime)
  172. return true
  173. }
  174. return false
  175. })
  176. // 使用节流保存进度
  177. saveProgress(progressPercentage, currentTime)
  178. // 触发父组件的时间更新事件
  179. emits('timeUpdate', { currentTime, progressPercentage })
  180. if (!props.courseConfigList.length) return
  181. props.courseConfigList.forEach(courseCofig => {
  182. // 暂停时间
  183. let time = courseCofig.ccTime
  184. // 检查是否到达时间点且还未暂停过
  185. if (currentTime === time && !pausedIndices.value.includes(time)) {
  186. // 无论 ccQuestSource 是什么值,都暂停视频
  187. videoRef.value.pause()
  188. // 记录暂停时间
  189. pausedIndices.value.push(currentTime)
  190. // 根据 ccQuestSource 的值决定是否显示问题弹框
  191. // 当 ccQuestSource 为 1 时,显示问题弹框
  192. if (courseCofig.ccQuestSource === '1' && courseCofig.ccQuestContent) {
  193. // 触发父组件显示试题
  194. emits('timeUpdate', {
  195. currentTime,
  196. progressPercentage,
  197. courseConfig: courseCofig
  198. })
  199. }
  200. }
  201. })
  202. }
  203. // 视频完成拖动进度条时触发的方法
  204. const handleSeeked = () => {
  205. pausedIndices.value = []
  206. }
  207. // 添加视频结束事件处理
  208. const handleVideoEnded = () => {
  209. // 视频结束时保存100%进度
  210. if (!savedProgress.value.includes(100)) {
  211. // 通过emit事件通知父组件保存进度
  212. emits('saveProgress', "course", 100)
  213. savedProgress.value.push(100)
  214. }
  215. emits('videoEnded')
  216. }
  217. // 在视频加载完成后设置上次播放进度
  218. const setLastPlayPosition = () => {
  219. if (!videoRef.value) return
  220. try {
  221. const savedData = localStorage.getItem(`videoProgress_${props.courseId}`)
  222. if (savedData) {
  223. const { currentTime, progress } = JSON.parse(savedData)
  224. if (currentTime && !isNaN(currentTime) && currentTime > 0) {
  225. // 存储上次播放位置但不自动跳转
  226. lastPlayTime.value = currentTime
  227. lastPlayProgress.value = progress
  228. // 计算历史提示位置
  229. const duration = videoRef.value.duration
  230. historyTipPosition.value = duration > 0 ? (currentTime / duration) * 100 : 0
  231. showHistoryTip.value = true
  232. // 检查是否已有保存的进度点
  233. if (progress >= 10) savedProgress.value.push(10)
  234. if (progress >= 50) savedProgress.value.push(50)
  235. if (progress >= 100) savedProgress.value.push(100)
  236. // 5秒后自动隐藏历史位置提示
  237. setTimeout(() => {
  238. showHistoryTip.value = false
  239. }, 5000)
  240. }
  241. }
  242. } catch (error) {
  243. console.error('读取上次播放进度失败:', error)
  244. }
  245. }
  246. // 跳转到上次播放位置
  247. const goToLastPosition = () => {
  248. if (videoRef.value && lastPlayTime.value > 0) {
  249. videoRef.value.currentTime = lastPlayTime.value
  250. showHistoryTip.value = false
  251. // 自动播放视频
  252. videoRef.value.play().catch(error => {
  253. console.error('自动播放失败:', error)
  254. })
  255. }
  256. }
  257. // 关闭历史播放位置提示
  258. const closeHistoryTip = () => {
  259. showHistoryTip.value = false
  260. }
  261. // 处理视频暂停事件
  262. const handleVideoPause = () => {
  263. isVideoPaused.value = true
  264. emits('videoPause') // 触发暂停事件
  265. }
  266. // 处理视频播放事件
  267. const handleVideoPlay = () => {
  268. isVideoPaused.value = false
  269. emits('videoPlay') // 触发播放事件
  270. }
  271. // 处理视频点击事件
  272. const handleVideoClick = (event) => {
  273. // 阻止浏览器默认的视频点击行为
  274. event.preventDefault();
  275. // 阻止事件冒泡,防止其他元素的点击事件影响
  276. event.stopPropagation();
  277. if (videoRef.value) {
  278. if (videoRef.value.paused) {
  279. videoRef.value.play();
  280. } else {
  281. videoRef.value.pause();
  282. }
  283. }
  284. }
  285. // 初始化视频播放器
  286. const initVideoPlayer = () => {
  287. if (props.contentType !== 'video') return
  288. // 使用nextTick确保DOM已经更新
  289. nextTick(() => {
  290. if (!videoRef.value) {
  291. console.error('视频元素未找到')
  292. return
  293. }
  294. // 清理之前的HLS实例
  295. if (hlsRef.value) {
  296. hlsRef.value.destroy()
  297. hlsRef.value = null
  298. }
  299. // 为视频元素添加键盘事件监听器
  300. addVideoKeyboardListener();
  301. // 检查视频路径是否是m3u8格式
  302. if (props.videoPath && props.videoPath.toLowerCase().endsWith('.m3u8')) {
  303. // 使用HLS播放
  304. if (Hls.isSupported()) {
  305. hlsRef.value = new Hls()
  306. hlsRef.value.loadSource(props.videoPath)
  307. hlsRef.value.attachMedia(videoRef.value)
  308. hlsRef.value.on(Hls.Events.MANIFEST_PARSED, () => {
  309. tryPlayVideo()
  310. })
  311. hlsRef.value.on(Hls.Events.ERROR, (event, data) => {
  312. console.error('HLS错误:', data)
  313. // 只对致命错误显示错误提示
  314. if (data.fatal) {
  315. ElMessage.error('视频加载失败,请稍后重试')
  316. }
  317. })
  318. } else if (videoRef.value.canPlayType('application/vnd.apple.mpegurl')) {
  319. // 对于不支持HLS但支持原生m3u8的浏览器
  320. videoRef.value.src = props.videoPath
  321. tryPlayVideo()
  322. } else {
  323. ElMessage.error('您的浏览器不支持播放m3u8格式视频')
  324. }
  325. } else {
  326. // 普通视频播放
  327. videoRef.value.src = props.videoPath
  328. tryPlayVideo()
  329. }
  330. })
  331. }
  332. // 尝试播放视频,处理浏览器自动播放限制
  333. const tryPlayVideo = () => {
  334. // 确保videoRef存在
  335. if (!videoRef.value) {
  336. console.error('视频元素未找到')
  337. return
  338. }
  339. // 自动播放视频
  340. videoRef.value.play().catch(error => {
  341. console.error('自动播放失败:', error)
  342. })
  343. // 在视频加载完成后设置上次播放进度
  344. setTimeout(() => {
  345. setLastPlayPosition()
  346. }, 1000)
  347. }
  348. // 处理空格键控制播放/暂停
  349. const handleKeyPress = (event) => {
  350. // 检查是否是空格键且视频元素存在
  351. if (event.code === 'Space' && videoRef.value) {
  352. event.preventDefault(); // 防止空格键默认行为
  353. if (videoRef.value.paused) {
  354. videoRef.value.play();
  355. } else {
  356. videoRef.value.pause();
  357. }
  358. }
  359. }
  360. // 为视频元素添加键盘事件监听器
  361. const addVideoKeyboardListener = () => {
  362. if (videoRef.value) {
  363. videoRef.value.addEventListener('keydown', handleKeyPress);
  364. }
  365. }
  366. // 移除视频元素的键盘事件监听器
  367. const removeVideoKeyboardListener = () => {
  368. if (videoRef.value) {
  369. videoRef.value.removeEventListener('keydown', handleKeyPress);
  370. }
  371. }
  372. // 处理视频全屏变化事件
  373. const handleFullscreenChange = () => {
  374. // 检查是否是浏览器默认的全屏模式
  375. const isFullscreen = !!(document.fullscreenElement ||
  376. document.webkitFullscreenElement ||
  377. document.mozFullScreenElement ||
  378. document.msFullscreenElement);
  379. if (isFullscreen) {
  380. // 如果是视频元素触发的全屏,退出默认全屏并调用自定义全屏
  381. const fullscreenElement = document.fullscreenElement ||
  382. document.webkitFullscreenElement ||
  383. document.mozFullScreenElement ||
  384. document.msFullscreenElement;
  385. if (fullscreenElement === videoRef.value) {
  386. // 退出浏览器默认全屏
  387. if (document.exitFullscreen) {
  388. document.exitFullscreen();
  389. } else if (document.webkitExitFullscreen) {
  390. document.webkitExitFullscreen();
  391. } else if (document.mozCancelFullScreen) {
  392. document.mozCancelFullScreen();
  393. } else if (document.msExitFullscreen) {
  394. document.msExitFullscreen();
  395. }
  396. // 调用自定义全屏方法
  397. toggleWebFullscreen();
  398. }
  399. }
  400. }
  401. // 组件挂载时
  402. onMounted(() => {
  403. initVideoPlayer()
  404. // 键盘事件监听
  405. document.addEventListener('keydown', handleKeyPress);
  406. // 添加全屏事件监听
  407. document.addEventListener('fullscreenchange', handleFullscreenChange);
  408. document.addEventListener('webkitfullscreenchange', handleFullscreenChange);
  409. document.addEventListener('mozfullscreenchange', handleFullscreenChange);
  410. document.addEventListener('MSFullscreenChange', handleFullscreenChange);
  411. })
  412. // 组件卸载时
  413. onBeforeUnmount(() => {
  414. if (hlsRef.value) {
  415. hlsRef.value.destroy()
  416. hlsRef.value = null
  417. }
  418. // 确保退出网页全屏
  419. if (isWebFullscreen.value) {
  420. const videoWrapper = document.querySelector('.video-wrapper')
  421. if (videoWrapper) {
  422. videoWrapper.style.position = ''
  423. videoWrapper.style.top = ''
  424. videoWrapper.style.left = ''
  425. videoWrapper.style.width = '70%'
  426. videoWrapper.style.height = '100%'
  427. videoWrapper.style.zIndex = ''
  428. videoWrapper.style.backgroundColor = ''
  429. }
  430. }
  431. // 移除键盘事件监听
  432. document.removeEventListener('keydown', handleKeyPress);
  433. // 移除视频元素的键盘事件监听器
  434. removeVideoKeyboardListener();
  435. // 移除全屏事件监听
  436. document.removeEventListener('fullscreenchange', handleFullscreenChange);
  437. document.removeEventListener('webkitfullscreenchange', handleFullscreenChange);
  438. document.removeEventListener('mozfullscreenchange', handleFullscreenChange);
  439. document.removeEventListener('MSFullscreenChange', handleFullscreenChange);
  440. })
  441. // 监听contentType和videoPath变化
  442. watch([() => props.contentType, () => props.videoPath], () => {
  443. // 当contentType变为video或videoPath变化时,重新初始化
  444. if (props.contentType === 'video') {
  445. initVideoPlayer()
  446. }
  447. })
  448. // 切换网页全屏
  449. const toggleWebFullscreen = () => {
  450. const videoWrapper = document.querySelector('.video-wrapper')
  451. if (!videoWrapper) return
  452. if (!isWebFullscreen.value) {
  453. // 进入网页全屏
  454. videoWrapper.style.position = 'fixed'
  455. videoWrapper.style.top = '0'
  456. videoWrapper.style.left = '0'
  457. videoWrapper.style.width = '100vw'
  458. videoWrapper.style.height = '100vh'
  459. videoWrapper.style.zIndex = '9999'
  460. videoWrapper.style.backgroundColor = 'black'
  461. videoWrapper.style.display = 'flex'
  462. videoWrapper.style.justifyContent = 'center'
  463. videoWrapper.style.alignItems = 'center'
  464. // 调整视频大小
  465. const video = videoWrapper.querySelector('.full-box-video')
  466. if (video) {
  467. video.style.width = '100%'
  468. video.style.height = '100%'
  469. video.style.objectFit = 'contain'
  470. video.focus();
  471. }
  472. isWebFullscreen.value = true
  473. } else {
  474. // 退出网页全屏
  475. videoWrapper.style.position = ''
  476. videoWrapper.style.top = ''
  477. videoWrapper.style.left = ''
  478. videoWrapper.style.width = '70%'
  479. videoWrapper.style.height = '100%'
  480. videoWrapper.style.zIndex = ''
  481. videoWrapper.style.backgroundColor = ''
  482. videoWrapper.style.display = 'flex'
  483. videoWrapper.style.justifyContent = 'center'
  484. videoWrapper.style.alignItems = 'center'
  485. // 恢复视频大小
  486. const video = videoWrapper.querySelector('.full-box-video')
  487. if (video) {
  488. video.style.width = '100%'
  489. video.style.height = '100%'
  490. video.style.objectFit = 'cover'
  491. video.focus();
  492. }
  493. isWebFullscreen.value = false
  494. }
  495. }
  496. </script>
  497. <style scoped lang="scss">
  498. @use 'sass:math';
  499. // 定义rpx转换函数
  500. @function rpx($px) {
  501. @return math.div($px, 750) * 100vw;
  502. }
  503. .box-video {
  504. width: 100%;
  505. height: rpx(300);
  506. display: flex;
  507. justify-content: center;
  508. align-items: center;
  509. .d-player-wrap {
  510. height: rpx(289);
  511. width: 68.5%;
  512. border-radius: rpx(12);
  513. object-fit: cover;
  514. }
  515. }
  516. .video-wrapper {
  517. position: relative;
  518. width: 70%;
  519. height: 100%;
  520. display: flex;
  521. justify-content: center;
  522. align-items: center;
  523. }
  524. .full-box-video {
  525. width: 100%;
  526. height: 100%;
  527. object-fit: cover;
  528. border-radius: rpx(12);
  529. }
  530. .ppt-box{
  531. width: 100%;
  532. height: rpx(300);
  533. display: flex;
  534. justify-content: center;
  535. align-items: center;
  536. position: relative;
  537. }
  538. .ppt-page-info {
  539. position: absolute;
  540. bottom: rpx(10); // 距离底部10rpx
  541. left: 50%; // 水平居中
  542. transform: translateX(-50%); // 水平居中调整
  543. background-color: rgba(0, 0, 0, 0.5);
  544. color: white;
  545. padding: rpx(3) rpx(8);
  546. border-radius: rpx(4);
  547. font-size: rpx(8);
  548. z-index: 10;
  549. }
  550. .ppt-container {
  551. width: 70%;
  552. height: 100%;
  553. border-radius: rpx(12);
  554. overflow: hidden;
  555. }
  556. .ppt-container ::v-deep(.pptx-preview-wrapper) {
  557. // 滚动条整体样式
  558. &::-webkit-scrollbar {
  559. width: rpx(0); // 滚动条宽度
  560. height: rpx(0);
  561. }
  562. // 滚动条滑块样式
  563. &::-webkit-scrollbar-thumb {
  564. background-color: #e2ddfc; // 滑块颜色
  565. border-radius: rpx(4); // 滑块圆角
  566. }
  567. // 滚动条轨道样式
  568. &::-webkit-scrollbar-track {
  569. background-color: rgba(143, 116, 255, 0.2); // 轨道颜色
  570. border-radius: rpx(4); // 轨道圆角
  571. }
  572. // border-radius: rpx(12);
  573. }
  574. .ppt-navigation {
  575. position: absolute;
  576. bottom: rpx(18);
  577. width: 70%;
  578. display: flex;
  579. justify-content: center;
  580. gap: rpx(20);
  581. padding: 0 rpx(10);
  582. box-sizing: border-box;
  583. }
  584. .ppt-btn {
  585. background-color: rgb(255, 255, 255, 0.5);
  586. color: white;
  587. border: 1px white solid;
  588. border-radius: rpx(12);
  589. font-size: rpx(7);
  590. cursor: pointer;
  591. transition: background-color 0.3s;
  592. display: flex;
  593. align-items: center;
  594. justify-content: center;
  595. height: rpx(15);
  596. }
  597. .ppt-btn:hover {
  598. background-color: rgba(0, 0, 0, 0.7);
  599. }
  600. .ppt-prev-btn, .ppt-next-btn {
  601. width: rpx(50);
  602. }
  603. /* 章节要点标记样式 */
  604. .progress-markers {
  605. position: absolute;
  606. bottom: 26px;
  607. left: 15px;
  608. right: 15px;
  609. height: 10px;
  610. pointer-events: none;
  611. z-index: 10;
  612. opacity: 0; /* 默认隐藏 */
  613. transition: opacity 0.3s ease; /* 过渡效果 */
  614. }
  615. .video-wrapper:hover .progress-markers {
  616. opacity: 1; /* 鼠标悬停在视频上时显示 */
  617. }
  618. /* 当视频控件可见时显示标记 */
  619. video::-webkit-media-controls-panel:not([hidden]) ~ .progress-markers {
  620. opacity: 1;
  621. }
  622. /* 当视频暂停时显示标记 */
  623. .video-paused .progress-markers {
  624. opacity: 1;
  625. }
  626. .progress-marker {position: absolute;
  627. width: rpx(4);
  628. height: rpx(4);
  629. background-color: orange;
  630. border-radius: 50%;
  631. transform: translateX(-50%);
  632. cursor: pointer;
  633. transition: all 0.3s ease;
  634. pointer-events: all;
  635. }
  636. .progress-marker:hover {
  637. width: rpx(6);
  638. height: rpx(6);
  639. }
  640. /* 历史播放位置提示样式 */
  641. .history-tip {
  642. position: absolute;
  643. bottom: rpx(22);
  644. transform: translateX(-50%);
  645. background-color: rgba(0, 0, 0, 0.5);
  646. color: white;
  647. padding: rpx(5) rpx(7);
  648. border-radius: rpx(4);
  649. font-size: rpx(6);
  650. cursor: pointer;
  651. transition: all 0.3s ease;
  652. z-index: 20;
  653. white-space: nowrap;
  654. display: flex;
  655. align-items: center;
  656. gap: rpx(2);
  657. }
  658. .history-tip .close-icon {
  659. cursor: pointer;
  660. font-size: rpx(7);
  661. transition: color 0.3s ease;
  662. }
  663. .history-tip .close-icon:hover {
  664. color: #ff4d4f;
  665. }
  666. .history-tip::after {
  667. content: '';
  668. position: absolute;
  669. top: 100%;
  670. left: 50%;
  671. transform: translateX(-50%);
  672. border: rpx(4) solid transparent;
  673. border-top-color: rgba(0, 0, 0, 0.5);
  674. width: 0;
  675. height: 0;
  676. }
  677. .history-tip:hover {
  678. background-color: rgba(0, 0, 0, 0.6);
  679. }
  680. /* 网页全屏按钮样式 */
  681. .fullscreen-btn {
  682. position: absolute;
  683. top: 10px;
  684. right: 10px;
  685. width: 30px;
  686. height: 30px;
  687. background-color: rgba(0, 0, 0, 0.5);
  688. color: white;
  689. border-radius: 4px;
  690. display: flex;
  691. justify-content: center;
  692. align-items: center;
  693. cursor: pointer;
  694. z-index: 15;
  695. transition: all 0.3s ease;
  696. }
  697. .fullscreen-btn:hover {
  698. background-color: rgba(0, 0, 0, 0.7);
  699. }
  700. /* 调整视频控件样式以适应我们的自定义标记 */
  701. video::-webkit-media-controls-timeline {
  702. margin-bottom: 10px;
  703. }
  704. video::-webkit-media-controls {
  705. overflow: visible !important;
  706. }
  707. video::-webkit-media-controls-panel {
  708. width: calc(100% + 30px); /* 扩展控件面板宽度,确保与自定义标记对齐 */
  709. background: transparent !important; /* 去掉背景渐变,设为透明 */
  710. }
  711. </style>