| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656 |
- <template>
- <!-- 容器元素:全屏时需要将整个容器全屏,这样自定义控制条才能显示在视频上方 -->
- <div
- ref="videoContainer"
- class="custom-video-container"
- @mousemove="triggerControlsTimeout"
- @mouseleave="hideControlsImmediately"
- >
- <!-- 视频元素:移除了 controls 属性 -->
-
- <video
- ref="videoPlayer"
- class="video-element"
- :src="videoSrc"
- @click="togglePlay"
- @timeupdate="onTimeUpdate"
- @loadedmetadata="onLoadedMetadata"
- @ended="onVideoEnded"
- @loadeddata="HandleVideoLoaded"
- @waiting="handleVideoWaiting"
- @playing="handleVideoPlaying"
- ></video>
- <!-- 自定义控制栏:当 hover 或移动鼠标时显示 -->
- <div :class="['controls-bar', { 'controls-hidden': !showControls }]">
-
-
- <!-- 按钮及状态栏 -->
- <div class="controls-row">
- <div class="left-controls">
- <!-- 播放/暂停按钮 -->
- <div @click="togglePlay" class="control-btn small_icon">
- <img class="play_pause" :src="isPlaying ? pauseIcon : playIcon" alt="">
- </div>
- <!-- 时间显示 -->
- <span class="time-display">
- {{ formatTime(currentTime) }} / {{ formatTime(duration) }}
- </span>
- </div>
- <div class="right-controls">
- <!-- 音量控制 -->
- <div class="volume-control">
- <div @click="toggleMute" class="control-btn">
- <img class="volume" :src="isMuted ? novolumeIcon : volumeIcon" alt="">
- </div>
-
- <input
- type="range"
- min="0"
- max="1"
- step="0.05"
- :value="isMuted ? 0 : volume"
- @input="onVolumeChange"
- class="volume-slider"
- :style="{
- background: `linear-gradient(to right, white 0%, white ${!isMuted ? (volume) * 100 : 0}%, #555 ${!isMuted ? (volume) * 100 : 0}%, #555 100%)`
- }"
- />
- </div>
- <!-- 全屏按钮 -->
- <div @click="toggleFullscreen" class="control-btn small_icon">
- <img class="full_screen" :src="isFullscreen ? exitFullScreenIcon : fullScreenIcon" alt="" @click="HandleFullScreen">
- </div>
- <!-- 倍数控制 -->
- <div class="speed-container" @mouseleave="showSpeedMenu = false">
- <div class="control-btn speed-btn" @click="showSpeedMenu = !showSpeedMenu">
- {{ currentSpeed === 1 ? '倍速' : currentSpeed + 'X' }}
- </div>
-
- <ul class="speed-menu" v-if="showSpeedMenu">
- <li
- v-for="speed in speedOptions"
- :key="speed"
- :class="{ active: currentSpeed === speed }"
- @click="setSpeed(speed)"
- >
- {{ speed }}x
- </li>
- </ul>
- </div>
-
-
- </div>
- </div>
- <!-- 进度条 -->
- <div class="progress-container">
- <input
- type="range"
- min="0"
- :max="duration"
- step="0.1"
- :value="currentTime"
- @input="onSeek"
- class="progress-slider"
- :style="{
- background: `linear-gradient(to right, white 0%, white ${duration ? (currentTime / duration) * 100 : 0}%, #555 ${duration ? (currentTime / duration) * 100 : 0}%, #555 100%)`
- }"
- />
- </div>
- </div>
- </div>
- </template>
- <script setup>
- import { ref, onMounted, onUnmounted, watch } from 'vue';
- import fullScreenIcon from '@/assets/icon/fullScreen.png'
- import exitFullScreenIcon from '@/assets/icon/exitFullScreen.png'
- import playIcon from '@/assets/icon/play.png'
- import pauseIcon from '@/assets/icon/pause.png'
- import volumeIcon from '@/assets/icon/volume.png'
- import novolumeIcon from '@/assets/icon/novolume.png'
- import { ElLoading } from 'element-plus'
- const props = defineProps({
- videoSrc:{
- type:String,
- },
- initRecord:{
- type:null,
- required:false,
- },
- allowDragOrShowPage:{
- type:null,
- required:false,
- default:true,
- },
- isIntroduceOverFlow:{
- type:Boolean,
- default:false,
- }
- })
- const emit = defineEmits(['processChange','pptLoaded','manulFullScreen'])
- const currentVideoTime = ref(0) //只记录用户的前进,不记录回退
- // DOM 引用
- const videoContainer = ref(null);
- const videoPlayer = defineModel('videoPlayer')
- // 状态变量
- const isPlaying = defineModel('play');
- const currentTime = ref(0);
- const duration = ref(0);
- const volume = ref(1); // 范围 0 到 1
- const isMuted = ref(false);
- const isFullscreen = ref(false);
- const showControls = ref(true);
- let bufferLoading = null; // 用于缓存/卡顿提示的 Loading 实例
- let controlsTimeout = null;
- const videoProcessUpdateFlag = ref() //进度更新状态标识
- const currentSpeed = ref(1) //默认视频倍数
- // 倍速可选列表
- const speedOptions = [2.0, 1.5, 1.25, 1.0, 0.75, 0.5]
- // 控制菜单显示隐藏
- const showSpeedMenu = ref(false)
- // 切换倍速函数
- const setSpeed = (speed) => {
- if (!videoPlayer.value) return
-
- videoPlayer.value.playbackRate = speed // 核心:修改原生属性
- currentSpeed.value = speed
- showSpeedMenu.value = false // 选择后关闭菜单
- }
- const HandleVideoLoaded = ()=>{
- console.log('视频加载了----')
- videoPlayer.value.currentTime = currentVideoTime.value
- currentTime.value = currentVideoTime.value
- // 禁用右击事件
- videoPlayer.value.addEventListener('contextmenu', (event) => {
- event.preventDefault();
- });
- }
- // =================== 新增:网络卡顿/缓存处理逻辑 ===================
- // 当视频由于缺少数据而暂停(卡顿缓冲)时触发
- const handleVideoWaiting = () => {
- console.log('视频正在缓冲,请等待...');
- // 避免重复创建 Loading
- if (!bufferLoading && videoContainer.value) {
- bufferLoading = ElLoading.service({
- target: videoContainer.value, // 只覆盖当前视频容器,不全屏遮挡整个页面
- lock: true,
- text: '视频缓冲中,请稍候...',
- background: 'rgba(0, 0, 0, 0.6)',
- });
- }
- };
- // 当视频在因缓冲停止后准备好再次播放时触发
- const handleVideoPlaying = () => {
- console.log('缓冲结束,继续播放');
- if (bufferLoading) {
- bufferLoading.close();
- bufferLoading = null;
- }
- };
- // ===================================================================
- // 1. 播放 / 暂停切换
- const togglePlay = () => {
- if (!videoPlayer.value) return;
- if (isPlaying.value) {
- videoPlayer.value.pause();
- isPlaying.value = false;
- } else {
- videoPlayer.value.play();
- isPlaying.value = true;
- }
- };
- // 2. 视频播放进度更新
- const onTimeUpdate = () => {
- if (videoPlayer.value) {
- currentTime.value = videoPlayer.value.currentTime;
- const video = videoPlayer.value;
- const currentTimer = Math.floor(video?.currentTime); // 当前播放时间(秒)
- const duration = Math.floor(video?.duration); // 视频总时长(秒)
- // 播放的结尾的时候再触发一次 更新
- if(videoProcessUpdateFlag.value && currentTimer !== duration){
- return
- }
- // 只记录用户的前进,不记录回放
- if(currentVideoTime.value < currentTimer){
- currentVideoTime.value = currentTimer
- }
- emit('processChange',{type:'video',
- videoDuration:duration,
- currentVideoTime:currentTimer,
- latestWatchTime:currentVideoTime.value}) //记录 用户的实际播放位置 和 最新的前进进度
- // 获取视频播放倍数
- // 要保证 同步频率 和视频的播放速度一致 或者更快(整数倍)
- videoProcessUpdateFlag.value = setTimeout(()=>{
- console.log('进度更新了')
- console.log('视频当前时间-总时长',currentTimer,'-',duration)
- clearTimeout(videoProcessUpdateFlag.value)
- videoProcessUpdateFlag.value = null
- /** 遇到小数的情况下
- * floor:
- 同步相对快
- 速率相对慢
- ceil:
- 同步相对慢
- 速率相对快
- */
- // 这个后续过程中是否会有问题,待确认 —— 待完善
- // 确保视频前进1s同步一次
- },Math.floor(1000 / currentSpeed.value))
- }
- };
- // 3. 视频元数据加载完毕(获取总时长)
- const onLoadedMetadata = () => {
- if (videoPlayer.value) {
- duration.value = videoPlayer.value.duration;
- }
- };
- // 4. 手动调整进度条
- const onSeek = (event) => {
- // console.log('拖拽开始',videoPlayer.value.currentTime)
- console.log('历史播放记录',currentVideoTime.value)
- const targetTime = parseFloat(event.target.value);
- console.log('targetTime',targetTime)
- if (videoPlayer.value) {
- // 只能拖拽已观看部分,不能拖拽未观看部分
- if(targetTime > currentVideoTime.value && !props?.allowDragOrShowPage){
- videoPlayer.value.currentTime = currentVideoTime.value
- currentTime.value = currentVideoTime.value
- }else{
- videoPlayer.value.currentTime = targetTime;
- currentTime.value = targetTime;
- }
- }
- };
- // 5. 音量调节
- const onVolumeChange = (event) => {
- const targetVolume = parseFloat(event.target.value);
- volume.value = targetVolume;
- if (videoPlayer.value) {
- videoPlayer.value.volume = targetVolume;
- if (targetVolume > 0) {
- isMuted.value = false;
- videoPlayer.value.muted = false;
- }
- }
- };
- // 6. 静音/取消静音
- const toggleMute = () => {
- if (!videoPlayer.value) return;
- isMuted.value = !isMuted.value;
- videoPlayer.value.muted = isMuted.value;
- };
- // 7. 视频播放结束时的重置
- const onVideoEnded = () => {
- console.log('视频播放结束了---')
- isPlaying.value = false;
- currentTime.value = 0;
- };
- // 8. 全屏控制
- const toggleFullscreen = () => {
- emit('manulFullScreen')
- if (!document.fullscreenElement) {
- isFullscreen.value = true;
- }else{
- isFullscreen.value = false;
- }
- };
- // 监听系统的全屏状态改变(比如用户按 Esc 键退出全屏时,能同步状态)
- const handleFullscreenChange = () => {
- isFullscreen.value = !!document.fullscreenElement;
- };
- // 9. 控制条自动隐藏逻辑
- const triggerControlsTimeout = () => {
- showControls.value = true;
- if (controlsTimeout) clearTimeout(controlsTimeout);
- controlsTimeout = setTimeout(() => {
- if (isPlaying.value) {
- showControls.value = false;
- }
- }, 2500); // 2.5秒无鼠标移动且在播放时隐藏
- };
- const hideControlsImmediately = () => {
- if (isPlaying.value) {
- showControls.value = false;
- }
- };
- // 格式化时间为 MM:SS 或 HH:MM:SS
- const formatTime = (timeInSeconds) => {
- if (isNaN(timeInSeconds)) return '00:00';
- const hours = Math.floor(timeInSeconds / 3600);
- const minutes = Math.floor((timeInSeconds % 3600) / 60);
- const seconds = Math.floor(timeInSeconds % 60);
- const formattedMinutes = String(minutes).padStart(2, '0');
- const formattedSeconds = String(seconds).padStart(2, '0');
- if (hours > 0) {
- const formattedHours = String(hours).padStart(2, '0');
- return `${formattedHours}:${formattedMinutes}:${formattedSeconds}`;
- }
- return `${formattedMinutes}:${formattedSeconds}`;
- };
- watch(()=>props.initRecord,(newVal)=>{
- console.log('进度重置了',newVal)
- // 重置视频的进度
- currentVideoTime.value = newVal
- })
- watch(()=>props.isIntroduceOverFlow,(newVal)=>{
- console.log('是否溢出改变了',newVal)
- if(!videoPlayer.value) return
- console.log('是否溢出改变了',newVal)
- if(newVal){
- // 溢出了
- videoPlayer.value.style.maxHeight = 'calc(100vh - 100px)'
- }else{
- //
- videoPlayer.value.style.maxHeight = 'calc(100vh - 60px - 191px - 20px )'
- }
- },{
- immediate:true,
- flush:'post'
- })
- onMounted(() => {
- document.addEventListener('fullscreenchange', handleFullscreenChange);
- });
- onUnmounted(() => {
- document.removeEventListener('fullscreenchange', handleFullscreenChange);
- if (controlsTimeout) clearTimeout(controlsTimeout);
- // 组件销毁时若还存在局部 Loading 强制关闭
- if (bufferLoading) {
- bufferLoading.close();
- }
- });
- </script>
- <style scoped lang="scss">
- .custom-video-container {
- border-radius:4px;
- display: flex;
- justify-content: center;
- align-items: center;
- position: relative;
- width: 100%;
- height: 100%;
- background-color: #000;
- overflow: hidden;
- .video-element {
- width: 100%;
- max-width: 100%;
- max-height: calc(100vh - 60px - 200px - 20px );
- display: block;
- cursor: pointer;
- }
- /* 自定义控制条基础样式 */
- .controls-bar {
- position: absolute;
- bottom: 0;
- left: 0;
- right: 0;
- background: rgba(0, 0, 0, 0.7);
- color: #fff;
- padding: 10px 10px 15px;
- display: flex;
- flex-direction: column;
- gap: 8px;
- transition: opacity 0.3s ease, transform 0.3s ease;
- z-index: 10;
- .volume-slider,
- .progress-slider {
- width: 100%;
- cursor: pointer;
- height: 4px;
- -webkit-appearance: none;
- /* 移除固定的 background: #555,交由标签上的内联 style 动态控制 */
- outline: none;
- border-radius: 2px;
- transition: background 0.1s;
- /* 1. 针对 Webkit 内核 (Chrome, Safari, Edge) 的圆点设置 */
- &::-webkit-slider-thumb {
- -webkit-appearance: none;
- width: 12px;
- height: 12px;
- border-radius: 50%;
- background: white; /* 改为白色 */
- cursor: pointer;
- opacity: 0; /* 默认完全透明(隐藏) */
- transition: opacity 0.15s ease-in-out;
- }
- /* 2. 针对 Firefox 内核的圆点设置 (保持体验一致) */
- &::-moz-range-thumb {
- width: 12px;
- height: 12px;
- border: none;
- border-radius: 50%;
- background: white;
- cursor: pointer;
- opacity: 0;
- transition: opacity 0.15s ease-in-out;
- }
- /* 3. 当鼠标悬浮在进度条容器或 input 上时,显示白色圆点 */
- &:hover {
- &::-webkit-slider-thumb {
- opacity: 1; /* 悬浮显示 */
- }
- &::-moz-range-thumb {
- opacity: 1;
- }
- }
- }
- /* 进度条样式 */
- .progress-container {
- width: 100%;
- display: flex;
- align-items: center;
-
- }
-
- /* 按钮及信息布局 */
- .controls-row {
- display: flex;
- justify-content: space-between;
- align-items: center;
- .left-controls, .right-controls {
- display: flex;
- align-items: center;
- gap: 15px;
- .control-btn {
- display: flex;
- justify-content: center;
- align-items: center;
- background: none;
- color: #fff;
- cursor: pointer;
- font-size: 14px;
- padding: 5px 10px;
- border-radius: 20px;
- transition: background 0.2s;
- &.small_icon{
- width: 28px;
- height: 28px;
- border-radius: 14px;
- }
- &:hover {
- background: rgba(255, 255, 255, 0.2);
- }
- }
- }
- .time-display {
- font-size: 12px;
- }
- /* 音量滑动条 */
- .volume-control {
- display: flex;
- align-items: center;
- gap: 5px;
- .volume-slider {
- width: 60px;
- cursor: pointer;
- }
- }
- .speed-container {
- position: relative;
- display: flex;
- align-items: center;
- .speed-btn {
- font-size: 14px;
- font-weight: bold;
- min-width: 45px;
- color: #fff;
- }
- .speed-menu {
- position: absolute;
- bottom: 30px; /* 弹出在按钮上方 */
- left: 50%;
- transform: translateX(-50%);
- background: rgba(28, 28, 28, 0.9);
- border-radius: 4px;
- padding: 5px 0;
- list-style: none;
- margin: 0;
- min-width: 60px;
- box-shadow: 0 2px 10px rgba(0,0,0,0.5);
- z-index: 10;
- li {
- padding: 6px 12px;
- font-size: 12px;
- color: #ccc;
- text-align: center;
- cursor: pointer;
- transition: all 0.2s;
- &:hover {
- background: rgba(255, 255, 255, 0.1);
- color: #fff;
- }
- &.active {
- color: #007bff; /* 当前选中的倍速高亮 */
- font-weight: bold;
- }
- }
- }
- }
- .volume,
- .full_screen,
- .play_pause{
- width: 15px;
- }
- }
- /* 隐藏控制条时的样式 */
- &.controls-hidden {
- opacity: 0;
- transform: translateY(100%);
- pointer-events: none; /* 隐藏时不能阻挡点击事件 */
- }
- }
- }
- </style>
|