ViedeoPlayer.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656
  1. <template>
  2. <!-- 容器元素:全屏时需要将整个容器全屏,这样自定义控制条才能显示在视频上方 -->
  3. <div
  4. ref="videoContainer"
  5. class="custom-video-container"
  6. @mousemove="triggerControlsTimeout"
  7. @mouseleave="hideControlsImmediately"
  8. >
  9. <!-- 视频元素:移除了 controls 属性 -->
  10. <video
  11. ref="videoPlayer"
  12. class="video-element"
  13. :src="videoSrc"
  14. @click="togglePlay"
  15. @timeupdate="onTimeUpdate"
  16. @loadedmetadata="onLoadedMetadata"
  17. @ended="onVideoEnded"
  18. @loadeddata="HandleVideoLoaded"
  19. @waiting="handleVideoWaiting"
  20. @playing="handleVideoPlaying"
  21. ></video>
  22. <!-- 自定义控制栏:当 hover 或移动鼠标时显示 -->
  23. <div :class="['controls-bar', { 'controls-hidden': !showControls }]">
  24. <!-- 按钮及状态栏 -->
  25. <div class="controls-row">
  26. <div class="left-controls">
  27. <!-- 播放/暂停按钮 -->
  28. <div @click="togglePlay" class="control-btn small_icon">
  29. <img class="play_pause" :src="isPlaying ? pauseIcon : playIcon" alt="">
  30. </div>
  31. <!-- 时间显示 -->
  32. <span class="time-display">
  33. {{ formatTime(currentTime) }} / {{ formatTime(duration) }}
  34. </span>
  35. </div>
  36. <div class="right-controls">
  37. <!-- 音量控制 -->
  38. <div class="volume-control">
  39. <div @click="toggleMute" class="control-btn">
  40. <img class="volume" :src="isMuted ? novolumeIcon : volumeIcon" alt="">
  41. </div>
  42. <input
  43. type="range"
  44. min="0"
  45. max="1"
  46. step="0.05"
  47. :value="isMuted ? 0 : volume"
  48. @input="onVolumeChange"
  49. class="volume-slider"
  50. :style="{
  51. background: `linear-gradient(to right, white 0%, white ${!isMuted ? (volume) * 100 : 0}%, #555 ${!isMuted ? (volume) * 100 : 0}%, #555 100%)`
  52. }"
  53. />
  54. </div>
  55. <!-- 全屏按钮 -->
  56. <div @click="toggleFullscreen" class="control-btn small_icon">
  57. <img class="full_screen" :src="isFullscreen ? exitFullScreenIcon : fullScreenIcon" alt="" @click="HandleFullScreen">
  58. </div>
  59. <!-- 倍数控制 -->
  60. <div class="speed-container" @mouseleave="showSpeedMenu = false">
  61. <div class="control-btn speed-btn" @click="showSpeedMenu = !showSpeedMenu">
  62. {{ currentSpeed === 1 ? '倍速' : currentSpeed + 'X' }}
  63. </div>
  64. <ul class="speed-menu" v-if="showSpeedMenu">
  65. <li
  66. v-for="speed in speedOptions"
  67. :key="speed"
  68. :class="{ active: currentSpeed === speed }"
  69. @click="setSpeed(speed)"
  70. >
  71. {{ speed }}x
  72. </li>
  73. </ul>
  74. </div>
  75. </div>
  76. </div>
  77. <!-- 进度条 -->
  78. <div class="progress-container">
  79. <input
  80. type="range"
  81. min="0"
  82. :max="duration"
  83. step="0.1"
  84. :value="currentTime"
  85. @input="onSeek"
  86. class="progress-slider"
  87. :style="{
  88. background: `linear-gradient(to right, white 0%, white ${duration ? (currentTime / duration) * 100 : 0}%, #555 ${duration ? (currentTime / duration) * 100 : 0}%, #555 100%)`
  89. }"
  90. />
  91. </div>
  92. </div>
  93. </div>
  94. </template>
  95. <script setup>
  96. import { ref, onMounted, onUnmounted, watch } from 'vue';
  97. import fullScreenIcon from '@/assets/icon/fullScreen.png'
  98. import exitFullScreenIcon from '@/assets/icon/exitFullScreen.png'
  99. import playIcon from '@/assets/icon/play.png'
  100. import pauseIcon from '@/assets/icon/pause.png'
  101. import volumeIcon from '@/assets/icon/volume.png'
  102. import novolumeIcon from '@/assets/icon/novolume.png'
  103. import { ElLoading } from 'element-plus'
  104. const props = defineProps({
  105. videoSrc:{
  106. type:String,
  107. },
  108. initRecord:{
  109. type:null,
  110. required:false,
  111. },
  112. allowDragOrShowPage:{
  113. type:null,
  114. required:false,
  115. default:true,
  116. },
  117. isIntroduceOverFlow:{
  118. type:Boolean,
  119. default:false,
  120. }
  121. })
  122. const emit = defineEmits(['processChange','pptLoaded','manulFullScreen'])
  123. const currentVideoTime = ref(0) //只记录用户的前进,不记录回退
  124. // DOM 引用
  125. const videoContainer = ref(null);
  126. const videoPlayer = defineModel('videoPlayer')
  127. // 状态变量
  128. const isPlaying = defineModel('play');
  129. const currentTime = ref(0);
  130. const duration = ref(0);
  131. const volume = ref(1); // 范围 0 到 1
  132. const isMuted = ref(false);
  133. const isFullscreen = ref(false);
  134. const showControls = ref(true);
  135. let bufferLoading = null; // 用于缓存/卡顿提示的 Loading 实例
  136. let controlsTimeout = null;
  137. const videoProcessUpdateFlag = ref() //进度更新状态标识
  138. const currentSpeed = ref(1) //默认视频倍数
  139. // 倍速可选列表
  140. const speedOptions = [2.0, 1.5, 1.25, 1.0, 0.75, 0.5]
  141. // 控制菜单显示隐藏
  142. const showSpeedMenu = ref(false)
  143. // 切换倍速函数
  144. const setSpeed = (speed) => {
  145. if (!videoPlayer.value) return
  146. videoPlayer.value.playbackRate = speed // 核心:修改原生属性
  147. currentSpeed.value = speed
  148. showSpeedMenu.value = false // 选择后关闭菜单
  149. }
  150. const HandleVideoLoaded = ()=>{
  151. console.log('视频加载了----')
  152. videoPlayer.value.currentTime = currentVideoTime.value
  153. currentTime.value = currentVideoTime.value
  154. // 禁用右击事件
  155. videoPlayer.value.addEventListener('contextmenu', (event) => {
  156. event.preventDefault();
  157. });
  158. }
  159. // =================== 新增:网络卡顿/缓存处理逻辑 ===================
  160. // 当视频由于缺少数据而暂停(卡顿缓冲)时触发
  161. const handleVideoWaiting = () => {
  162. console.log('视频正在缓冲,请等待...');
  163. // 避免重复创建 Loading
  164. if (!bufferLoading && videoContainer.value) {
  165. bufferLoading = ElLoading.service({
  166. target: videoContainer.value, // 只覆盖当前视频容器,不全屏遮挡整个页面
  167. lock: true,
  168. text: '视频缓冲中,请稍候...',
  169. background: 'rgba(0, 0, 0, 0.6)',
  170. });
  171. }
  172. };
  173. // 当视频在因缓冲停止后准备好再次播放时触发
  174. const handleVideoPlaying = () => {
  175. console.log('缓冲结束,继续播放');
  176. if (bufferLoading) {
  177. bufferLoading.close();
  178. bufferLoading = null;
  179. }
  180. };
  181. // ===================================================================
  182. // 1. 播放 / 暂停切换
  183. const togglePlay = () => {
  184. if (!videoPlayer.value) return;
  185. if (isPlaying.value) {
  186. videoPlayer.value.pause();
  187. isPlaying.value = false;
  188. } else {
  189. videoPlayer.value.play();
  190. isPlaying.value = true;
  191. }
  192. };
  193. // 2. 视频播放进度更新
  194. const onTimeUpdate = () => {
  195. if (videoPlayer.value) {
  196. currentTime.value = videoPlayer.value.currentTime;
  197. const video = videoPlayer.value;
  198. const currentTimer = Math.floor(video?.currentTime); // 当前播放时间(秒)
  199. const duration = Math.floor(video?.duration); // 视频总时长(秒)
  200. // 播放的结尾的时候再触发一次 更新
  201. if(videoProcessUpdateFlag.value && currentTimer !== duration){
  202. return
  203. }
  204. // 只记录用户的前进,不记录回放
  205. if(currentVideoTime.value < currentTimer){
  206. currentVideoTime.value = currentTimer
  207. }
  208. emit('processChange',{type:'video',
  209. videoDuration:duration,
  210. currentVideoTime:currentTimer,
  211. latestWatchTime:currentVideoTime.value}) //记录 用户的实际播放位置 和 最新的前进进度
  212. // 获取视频播放倍数
  213. // 要保证 同步频率 和视频的播放速度一致 或者更快(整数倍)
  214. videoProcessUpdateFlag.value = setTimeout(()=>{
  215. console.log('进度更新了')
  216. console.log('视频当前时间-总时长',currentTimer,'-',duration)
  217. clearTimeout(videoProcessUpdateFlag.value)
  218. videoProcessUpdateFlag.value = null
  219. /** 遇到小数的情况下
  220. * floor:
  221. 同步相对快
  222. 速率相对慢
  223. ceil:
  224. 同步相对慢
  225. 速率相对快
  226. */
  227. // 这个后续过程中是否会有问题,待确认 —— 待完善
  228. // 确保视频前进1s同步一次
  229. },Math.floor(1000 / currentSpeed.value))
  230. }
  231. };
  232. // 3. 视频元数据加载完毕(获取总时长)
  233. const onLoadedMetadata = () => {
  234. if (videoPlayer.value) {
  235. duration.value = videoPlayer.value.duration;
  236. }
  237. };
  238. // 4. 手动调整进度条
  239. const onSeek = (event) => {
  240. // console.log('拖拽开始',videoPlayer.value.currentTime)
  241. console.log('历史播放记录',currentVideoTime.value)
  242. const targetTime = parseFloat(event.target.value);
  243. console.log('targetTime',targetTime)
  244. if (videoPlayer.value) {
  245. // 只能拖拽已观看部分,不能拖拽未观看部分
  246. if(targetTime > currentVideoTime.value && !props?.allowDragOrShowPage){
  247. videoPlayer.value.currentTime = currentVideoTime.value
  248. currentTime.value = currentVideoTime.value
  249. }else{
  250. videoPlayer.value.currentTime = targetTime;
  251. currentTime.value = targetTime;
  252. }
  253. }
  254. };
  255. // 5. 音量调节
  256. const onVolumeChange = (event) => {
  257. const targetVolume = parseFloat(event.target.value);
  258. volume.value = targetVolume;
  259. if (videoPlayer.value) {
  260. videoPlayer.value.volume = targetVolume;
  261. if (targetVolume > 0) {
  262. isMuted.value = false;
  263. videoPlayer.value.muted = false;
  264. }
  265. }
  266. };
  267. // 6. 静音/取消静音
  268. const toggleMute = () => {
  269. if (!videoPlayer.value) return;
  270. isMuted.value = !isMuted.value;
  271. videoPlayer.value.muted = isMuted.value;
  272. };
  273. // 7. 视频播放结束时的重置
  274. const onVideoEnded = () => {
  275. console.log('视频播放结束了---')
  276. isPlaying.value = false;
  277. currentTime.value = 0;
  278. };
  279. // 8. 全屏控制
  280. const toggleFullscreen = () => {
  281. emit('manulFullScreen')
  282. if (!document.fullscreenElement) {
  283. isFullscreen.value = true;
  284. }else{
  285. isFullscreen.value = false;
  286. }
  287. };
  288. // 监听系统的全屏状态改变(比如用户按 Esc 键退出全屏时,能同步状态)
  289. const handleFullscreenChange = () => {
  290. isFullscreen.value = !!document.fullscreenElement;
  291. };
  292. // 9. 控制条自动隐藏逻辑
  293. const triggerControlsTimeout = () => {
  294. showControls.value = true;
  295. if (controlsTimeout) clearTimeout(controlsTimeout);
  296. controlsTimeout = setTimeout(() => {
  297. if (isPlaying.value) {
  298. showControls.value = false;
  299. }
  300. }, 2500); // 2.5秒无鼠标移动且在播放时隐藏
  301. };
  302. const hideControlsImmediately = () => {
  303. if (isPlaying.value) {
  304. showControls.value = false;
  305. }
  306. };
  307. // 格式化时间为 MM:SS 或 HH:MM:SS
  308. const formatTime = (timeInSeconds) => {
  309. if (isNaN(timeInSeconds)) return '00:00';
  310. const hours = Math.floor(timeInSeconds / 3600);
  311. const minutes = Math.floor((timeInSeconds % 3600) / 60);
  312. const seconds = Math.floor(timeInSeconds % 60);
  313. const formattedMinutes = String(minutes).padStart(2, '0');
  314. const formattedSeconds = String(seconds).padStart(2, '0');
  315. if (hours > 0) {
  316. const formattedHours = String(hours).padStart(2, '0');
  317. return `${formattedHours}:${formattedMinutes}:${formattedSeconds}`;
  318. }
  319. return `${formattedMinutes}:${formattedSeconds}`;
  320. };
  321. watch(()=>props.initRecord,(newVal)=>{
  322. console.log('进度重置了',newVal)
  323. // 重置视频的进度
  324. currentVideoTime.value = newVal
  325. })
  326. watch(()=>props.isIntroduceOverFlow,(newVal)=>{
  327. console.log('是否溢出改变了',newVal)
  328. if(!videoPlayer.value) return
  329. console.log('是否溢出改变了',newVal)
  330. if(newVal){
  331. // 溢出了
  332. videoPlayer.value.style.maxHeight = 'calc(100vh - 100px)'
  333. }else{
  334. //
  335. videoPlayer.value.style.maxHeight = 'calc(100vh - 60px - 191px - 20px )'
  336. }
  337. },{
  338. immediate:true,
  339. flush:'post'
  340. })
  341. onMounted(() => {
  342. document.addEventListener('fullscreenchange', handleFullscreenChange);
  343. });
  344. onUnmounted(() => {
  345. document.removeEventListener('fullscreenchange', handleFullscreenChange);
  346. if (controlsTimeout) clearTimeout(controlsTimeout);
  347. // 组件销毁时若还存在局部 Loading 强制关闭
  348. if (bufferLoading) {
  349. bufferLoading.close();
  350. }
  351. });
  352. </script>
  353. <style scoped lang="scss">
  354. .custom-video-container {
  355. border-radius:4px;
  356. display: flex;
  357. justify-content: center;
  358. align-items: center;
  359. position: relative;
  360. width: 100%;
  361. height: 100%;
  362. background-color: #000;
  363. overflow: hidden;
  364. .video-element {
  365. width: 100%;
  366. max-width: 100%;
  367. max-height: calc(100vh - 60px - 200px - 20px );
  368. display: block;
  369. cursor: pointer;
  370. }
  371. /* 自定义控制条基础样式 */
  372. .controls-bar {
  373. position: absolute;
  374. bottom: 0;
  375. left: 0;
  376. right: 0;
  377. background: rgba(0, 0, 0, 0.7);
  378. color: #fff;
  379. padding: 10px 10px 15px;
  380. display: flex;
  381. flex-direction: column;
  382. gap: 8px;
  383. transition: opacity 0.3s ease, transform 0.3s ease;
  384. z-index: 10;
  385. .volume-slider,
  386. .progress-slider {
  387. width: 100%;
  388. cursor: pointer;
  389. height: 4px;
  390. -webkit-appearance: none;
  391. /* 移除固定的 background: #555,交由标签上的内联 style 动态控制 */
  392. outline: none;
  393. border-radius: 2px;
  394. transition: background 0.1s;
  395. /* 1. 针对 Webkit 内核 (Chrome, Safari, Edge) 的圆点设置 */
  396. &::-webkit-slider-thumb {
  397. -webkit-appearance: none;
  398. width: 12px;
  399. height: 12px;
  400. border-radius: 50%;
  401. background: white; /* 改为白色 */
  402. cursor: pointer;
  403. opacity: 0; /* 默认完全透明(隐藏) */
  404. transition: opacity 0.15s ease-in-out;
  405. }
  406. /* 2. 针对 Firefox 内核的圆点设置 (保持体验一致) */
  407. &::-moz-range-thumb {
  408. width: 12px;
  409. height: 12px;
  410. border: none;
  411. border-radius: 50%;
  412. background: white;
  413. cursor: pointer;
  414. opacity: 0;
  415. transition: opacity 0.15s ease-in-out;
  416. }
  417. /* 3. 当鼠标悬浮在进度条容器或 input 上时,显示白色圆点 */
  418. &:hover {
  419. &::-webkit-slider-thumb {
  420. opacity: 1; /* 悬浮显示 */
  421. }
  422. &::-moz-range-thumb {
  423. opacity: 1;
  424. }
  425. }
  426. }
  427. /* 进度条样式 */
  428. .progress-container {
  429. width: 100%;
  430. display: flex;
  431. align-items: center;
  432. }
  433. /* 按钮及信息布局 */
  434. .controls-row {
  435. display: flex;
  436. justify-content: space-between;
  437. align-items: center;
  438. .left-controls, .right-controls {
  439. display: flex;
  440. align-items: center;
  441. gap: 15px;
  442. .control-btn {
  443. display: flex;
  444. justify-content: center;
  445. align-items: center;
  446. background: none;
  447. color: #fff;
  448. cursor: pointer;
  449. font-size: 14px;
  450. padding: 5px 10px;
  451. border-radius: 20px;
  452. transition: background 0.2s;
  453. &.small_icon{
  454. width: 28px;
  455. height: 28px;
  456. border-radius: 14px;
  457. }
  458. &:hover {
  459. background: rgba(255, 255, 255, 0.2);
  460. }
  461. }
  462. }
  463. .time-display {
  464. font-size: 12px;
  465. }
  466. /* 音量滑动条 */
  467. .volume-control {
  468. display: flex;
  469. align-items: center;
  470. gap: 5px;
  471. .volume-slider {
  472. width: 60px;
  473. cursor: pointer;
  474. }
  475. }
  476. .speed-container {
  477. position: relative;
  478. display: flex;
  479. align-items: center;
  480. .speed-btn {
  481. font-size: 14px;
  482. font-weight: bold;
  483. min-width: 45px;
  484. color: #fff;
  485. }
  486. .speed-menu {
  487. position: absolute;
  488. bottom: 30px; /* 弹出在按钮上方 */
  489. left: 50%;
  490. transform: translateX(-50%);
  491. background: rgba(28, 28, 28, 0.9);
  492. border-radius: 4px;
  493. padding: 5px 0;
  494. list-style: none;
  495. margin: 0;
  496. min-width: 60px;
  497. box-shadow: 0 2px 10px rgba(0,0,0,0.5);
  498. z-index: 10;
  499. li {
  500. padding: 6px 12px;
  501. font-size: 12px;
  502. color: #ccc;
  503. text-align: center;
  504. cursor: pointer;
  505. transition: all 0.2s;
  506. &:hover {
  507. background: rgba(255, 255, 255, 0.1);
  508. color: #fff;
  509. }
  510. &.active {
  511. color: #007bff; /* 当前选中的倍速高亮 */
  512. font-weight: bold;
  513. }
  514. }
  515. }
  516. }
  517. .volume,
  518. .full_screen,
  519. .play_pause{
  520. width: 15px;
  521. }
  522. }
  523. /* 隐藏控制条时的样式 */
  524. &.controls-hidden {
  525. opacity: 0;
  526. transform: translateY(100%);
  527. pointer-events: none; /* 隐藏时不能阻挡点击事件 */
  528. }
  529. }
  530. }
  531. </style>