VideoPlayer.vue 21 KB

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