vue3 视频播放刻度条

319 阅读5分钟

实现一个视频播放刻度条

具体效果

444.gif

思路

  • 刻度点的实现
  • 刻度条时间刻度
  • 刻度条视频存在的时间刻度
  • 刻度条滑块

刻度点的实现

由于刻度点比分块多1, 所以遍历的时候加上1。

<div class="point-wrapper">
  <div
    class="point-grip-item"
      v-for="(_, index) in grid + 1"
      :key="index"
      :style="{
        ...pointerStyle,
        height: index % 2 == 0 ? '10px' : '5px'
      }"
     ></div>
</div>

刻度条时间刻度

通过计算获取需要展示的时间刻度数组。想要具体某一天,使用下述getDateArray方法,通过传入开始时间00:00:00与结束时间23:59:59来计算获取刻度数组, 时间间隔默认给120分钟,因为传入24:00:00的时间通过new Date转成时间戳会变成00:00:00的时间所以通过23:59:59代替,后面再转换过来。遍历绘制刻度,由于单位通过vw转换,无法确切知道时间刻度所占宽度,通过获取宽度去计算,让其居中显示。

<div class="time-number-wrapper" :style="timeNumStyle">
   <div class="time-number-ct">
      <div
         class="time-number"
         v-for="(_, index) in grid"
         :key="index"
         :style="{ width: `${single}px` }"
         :class="{
            'first-time-number': index == 0
         }"
       >
           <span ref="timeRef" :style="timeStyle">{{
              index == 0
                ? dateArr[0]
                : (index + 1) % spaceNum == 0
                ? dateArr[Math.ceil((index + 1) / spaceNum)]
                : ''
            }}</span>
      </div>
   </div>
</div>

刻度条存在的时间刻度

计算存在视频的起始时间位置与所占长度

 <!-- 存在视频的时间段绘制 -->
<div class="scale-active">
  <div
    class="scale-active-time"
      v-for="(timeArr, index) in newActiveTime"
      :key="index"
      :style="{
        left: `${timeArr[0]}px`
      }"
  >
    <div class="scale-active-item" :style="{ width: timeArr[1] + 'px' }">       </div>
  </div>
</div>
 let curNewActiveTime: Array<any> = []
  props.activeTime.forEach((item: any) => {
    let t1: number = dateToGrid(item[0], grid.value)
    let t2: number = dateToGrid(item[1], grid.value)
    // 初始位置距左边距离
    let left = t1 * single.value
    // 时间在刻度上长度
    let width: number = +(t2 - t1).toFixed(2) * single.value
    curNewActiveTime.push([left, width])
  })
  newActiveTime.value = curNewActiveTime
/**
 * 返回24小时内时间数组,默认30分钟分隔
 * @param startDate
 * @param endDate
 * @param space 分钟 默认30
 * @returns
 */
export function getDateArray(startDate: Date, endDate: Date, space: number) {
  endDate = endDate || new Date()
  startDate = startDate || new Date(new Date().getTime() - 60 * 60 * 1000)
  space = (space || 30) * 60 * 1000

  let endTime = endDate.getTime()
  let startTime = startDate.getTime()
  let mod = endTime - startTime
  if (mod <= space) {
    return
  }
  let dateArray = ['00:00']
  while (mod >= space) {
    let d = new Date()
    startTime = startTime + space
    d.setTime(startTime)
    let newd = dateFormat(d, 'HH:mm')
    dateArray.push(newd)
    mod = mod - space
  }
  let newend = dateFormat(endDate, 'HH:mm')
  dateArray.push(newend)

  let dateArrs = dateArray.sort((a, b) => {
    return Date.parse(a) - Date.parse(b)
  })
  if (dateArrs[dateArrs.length - 1] == '23:59') {
    dateArrs[dateArrs.length - 1] = '24:00'
  }
  return dateArrs
}

刻度条滑块

通过left来控制位置。

<div
  class="slider-btn"
  :style="{ left: left + 'px' }"
  ref="slideBar"
  @pointerdown="startDrag"
  @pointerup="stopDrag"
  @pointerleave="stopDrag"
></div>
// 开始拖拽
let startX: number = 0
const startDrag = (event: any) => {
  startX = event.clientX || event.touches[0].clientX
  slideBar.value.addEventListener('pointermove', onDrag)
}

// 滚动
const onDrag = (event: any) => {
  const currentX = event.clientX || event.touches[0].clientX
  left.value += currentX - startX
  startX = currentX
  // 避免越界
  if (left.value <= 0) {
    left.value = 0
  }

  let parentWidth = single.value * grid.value
  if (left.value >= parentWidth) {
    left.value = parentWidth
  }

  // 选中的值
  let value: string = (left.value / single.value).toFixed(2)
  if (+value > grid.value) value = grid.value.toString()
  let ct = +value * (86400 / grid.value)
  value = secondToDate(ct)

  // 滚动结束才返回时间点
  timer && clearTimeout(timer)
  timer = setTimeout(() => {
    // 返回刻度所在可播放时间点
    if (isInActiveTime(props.curDate, props.activeTime, value)) {
      emits('active-value', value)
    } else {
      // 返回普通时间点
      emits('value', value)
    }
  }, 500)
}

// 停止拖动
const stopDrag = () => {
  slideBar.value.removeEventListener('pointermove', onDrag)
  startX = 0
}

使用方式

可以通过传入playTime, 让滑块跳转到当前位置。

<videoSlider
  :isAuto="true"
  :activeTime="activeTime"
  @value="getValue"
  @active-value="getActiveValue"
></videoSlider>

<script setup lang="ts">
let activeTime = ref([
  ['03:00', '04:00'],
  ['06:00', '08:00'],
  ['09:00', '13:00']
])

// 滚动到无视频时间
function getValue(value: any) {
  console.log('暂停:', value)
}

// 播放滚动到的视频
function getActiveValue(value: any) {
  console.log('播放:', value)
}
</script>

所有代码如下

<template>
  <div class="video-slider" :style="{ background: stylesObj.bgoutside }">
    <div ref="scaleWrapper" class="horizontal-box">
      <!-- 滑动按钮  -->
      <div
        class="slider-btn"
        :style="{ left: left + 'px' }"
        ref="slideBar"
        @pointerdown="startDrag"
        @pointerup="stopDrag"
        @pointerleave="stopDrag"
      ></div>

      <div class="scroll-wrapper">
        <div class="scale-container" :style="ctStyle">
          <!-- 存在视频的时间段绘制 -->
          <div class="scale-active">
            <div
              class="scale-active-time"
              v-for="(timeArr, index) in newActiveTime"
              :key="index"
              :style="{
                left: `${timeArr[0]}px`
              }"
            >
              <div class="scale-active-item" :style="{ width: timeArr[1] + 'px' }"></div>
            </div>
          </div>

          <!-- 刻度点 -->
          <div class="point-wrapper">
            <div
              class="point-grip-item"
              v-for="(_, index) in grid + 1"
              :key="index"
              :style="{
                ...pointerStyle,
                height: index % 2 == 0 ? '10px' : '5px'
              }"
            ></div>
          </div>

          <!-- 标尺数时间显示,长度:每格长度*个数 -->
          <div class="time-number-wrapper" :style="timeNumStyle">
            <div class="time-number-ct">
              <div
                class="time-number"
                v-for="(_, index) in grid"
                :key="index"
                :style="{ width: `${single}px` }"
                :class="{
                  'first-time-number': index == 0
                }"
              >
                <span ref="timeRef" :style="timeStyle">{{
                  index == 0
                    ? dateArr[0]
                    : (index + 1) % spaceNum == 0
                      ? dateArr[Math.ceil((index + 1) / spaceNum)]
                      : ''
                }}</span>
              </div>
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<script lang="ts" setup>
import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue'
import { getDateArray, dateToGrid, secondToDate, isInActiveTime, dateFormat } from '@/utils/video'

const props = withDefaults(
  defineProps<{
    curDate?: string
    activeTime?: Array<Array<string>> // 存在视频的时间数组
    styles?: any // 自定义样式
    isAuto?: boolean // 自适应宽度
    playTime?: Array<string> // 当前播放时间
    spaceTime?: number //显示刻度的间隔时间
  }>(),
  {
    curDate: dateFormat(new Date(), 'YYYY-MM-DD'),
    activeTime: () => [],
    styles: () => ({}),
    isAuto: false,
    playTime: () => ['00:00:00', '00:00:00'],
    spaceTime: 120
  }
)

const emits = defineEmits<{
  (e: 'value', val: string): void
  (e: 'active-value', val: string): void
}>()

const scaleWrapper = ref()
const slideBar = ref()
const timeRef = ref()

let defaultStyles = ref({
  line: '#dbdbdb', // 刻度颜色
  bginner: '#fbfbfb', // 前景色颜色
  bgoutside: '#999', // 背景色颜色
  lineSelect: '#ea3639', // 选中线颜色
  fontColor: '#fff', // 刻度数字颜色
  fontSize: 12 // 字体大小
})

let single = ref(14) // 每个格子的实际行度(单位px ,相对默认值)
let grid = ref<number>(48) // 多少格
let stylesObj = ref<any>({}) // 样式
let dateArr = ref<Array<any>>([]) // 刻度上时间数组,30分钟分隔
let newActiveTime = ref<Array<any>>([]) // 可播放时间
let left = ref<number>(0) // 滚动位置
let timer: NodeJS.Timeout
let timeWidth = ref(0)

nextTick(() => {
  watch(
    () => props.playTime,
    (newVal) => {
      setPointLocation(newVal)
    },
    {
      immediate: true
    }
  )

  timeWidth.value = timeRef.value[0].offsetWidth
})

// 标尺宽度
const ctStyle = computed(() => {
  return {
    width: single.value * grid.value + 'px'
  }
})

// 刻度点样式
const pointerStyle = computed(() => {
  return {
    width: single.value + 'px',
    borderColor: stylesObj.value.line
  }
})

// 刻度时间样式
const timeNumStyle = computed(() => {
  return {
    color: stylesObj.value.fontColor,
    width: single.value * grid.value + 'px',
    fontSize: stylesObj.value.fontSize + 'px'
  }
})

// 空几格显示刻度时间
const spaceNum = computed(() => {
  return ~~(props.spaceTime / 30)
})

// 时间刻度点居中
const timeStyle = computed(() => {
  let offset = (single.value * 2 - timeWidth.value) / 2
  return {
    marginLeft: `${offset}px`
  }
})

onMounted(() => {
  init()
})

onUnmounted(() => {
  stopDrag()
})

function init() {
  // 初始化
  let startTime = new Date(props.curDate + ' 00:00:00')
  let endTime = new Date(props.curDate + ' 23:59:59')
  dateArr.value = getDateArray(startTime, endTime, props.spaceTime) || []

  // 宽度自适应
  if (props.isAuto) {
    let parentWidth = scaleWrapper.value.parentNode.offsetWidth
    single.value = (parentWidth - 38) / grid.value
  }

  let curNewActiveTime: Array<any> = []
  props.activeTime.forEach((item: any) => {
    let t1: number = dateToGrid(item[0], grid.value)
    let t2: number = dateToGrid(item[1], grid.value)
    // 初始位置距左边距离
    let left = t1 * single.value
    // 时间在刻度上长度
    let width: number = +(t2 - t1).toFixed(2) * single.value
    curNewActiveTime.push([left, width])
  })
  newActiveTime.value = curNewActiveTime
  stylesObj = Object.assign(defaultStyles, props.styles)
}

// 开始拖拽
let startX: number = 0
const startDrag = (event: any) => {
  startX = event.clientX || event.touches[0].clientX
  slideBar.value.addEventListener('pointermove', onDrag)
}

// 滚动
const onDrag = (event: any) => {
  const currentX = event.clientX || event.touches[0].clientX
  left.value += currentX - startX
  // 避免越界
  if (left.value <= 0) {
    left.value = 0
  }

  let parentWidth = single.value * grid.value
  if (left.value >= parentWidth) {
    left.value = parentWidth
  }
  startX = currentX

  // 选中的值
  let value: string = (left.value / single.value).toFixed(2)
  if (+value > grid.value) value = grid.value.toString()
  let ct = +value * (86400 / grid.value)
  value = secondToDate(ct)

  // 滚动结束才返回时间点
  timer && clearTimeout(timer)
  timer = setTimeout(() => {
    // 返回刻度所在可播放时间点
    if (isInActiveTime(props.curDate, props.activeTime, value)) {
      emits('active-value', value)
    } else {
      // 返回普通时间点
      emits('value', value)
    }
  }, 500)
}

// 停止拖动
const stopDrag = () => {
  slideBar.value.removeEventListener('pointermove', onDrag)
  startX = 0
}

// 定位滑动位置
function setPointLocation(timeList: Array<string>) {
  let t: number = dateToGrid(timeList[0], grid.value)
  let t2: number = dateToGrid(timeList[1], grid.value)
  left.value = (t + (t2 - t) / 2) * single.value
}
</script>

<style lang="scss" scoped>
div,
text {
  box-sizing: border-box;
}

.video-slider {
  padding-left: 18px;
  .horizontal-box {
    position: relative;
    padding-top: 2px;
    height: 40px;
    .slider-btn {
      position: absolute;
      width: 24px;
      height: 24px;
      border-radius: 50%;
      border: 9px solid #00b4e2;
      z-index: 10;
      background-color: #fff;
      top: 2px;
      transform: translateX(-50%);
      cursor: grab;
    }

    .point-wrapper {
      display: flex;
      border-top: 1px solid #dddddd;
      z-index: 6;
    }

    .point-grip {
      position: relative;
      height: 50px;
      display: flex;

      &::before {
        content: '';
        position: absolute;
        top: 0;
        border-width: 1px;
        border-color: inherit;
        border-style: solid;
        height: 100%;
        transform: translateX(-50%);
        left: 0px;
      }

      &:last-child {
        &::after {
          content: '';
          position: absolute;
          top: 0;
          right: 0;
          border-width: 1px;
          border-color: inherit;
          border-style: solid;
          height: 100%;
        }
      }
    }

    .point-grip-item {
      height: 9px;
      padding-top: 5px;
      border-right: 3px solid #fff;
      &:first-of-type {
        width: 0px !important;
      }
    }

    .time-number-wrapper {
      position: relative;
      display: flex;
      text-align: center;
      z-index: 6;
      .time-number-ct {
        display: flex;
      }
    }

    .time-number {
      padding: 5px 0;
      color: #fff;
      font-size: 12px;
    }

    .first-time-number {
      transform: translateX(-70%);
    }

    .scale-active {
      position: relative;
      height: 8px;
      z-index: 5;
      transform: translateY(100%);
      background-color: #fff;
      .scale-active-time {
        position: absolute;
        height: 100%;
        .scale-active-item {
          height: 100%;
          background-color: #00b4e2;
        }
      }
    }
  }
}
</style>

/**
 * 时间补0
 * @param val
 * @param len
 * @returns
 */
function padStart(val: string, len: number) {
  return val.padStart(len, '0')
}

/**
 * 时间戳转时间格式
 * @param date
 * @param fmt
 * @returns
 */
export function dateFormat(date: Date, fmt: string) {
  let ret
  const opt: Record<string, string> = {
    'Y+': date.getFullYear().toString(),
    'M+': (date.getMonth() + 1).toString(),
    'D+': date.getDate().toString(),
    'H+': date.getHours().toString(),
    'm+': date.getMinutes().toString(),
    's+': date.getSeconds().toString()
  }
  for (const k in opt) {
    ret = new RegExp('(' + k + ')').exec(fmt)
    if (ret) {
      fmt = fmt.replace(ret[1], ret[1].length == 1 ? opt[k] : padStart(opt[k], ret[1].length))
    }
  }
  return fmt
}

/**
 * 秒转时间
 * @param seconds
 * @returns
 */
export function secondToDate(seconds: any) {
  let h = padStart((~~(seconds / 3600)).toString(), 2)
  let m = padStart((~~((seconds / 60) % 60)).toString(), 2)
  let s = padStart((~~(seconds % 60)).toString(), 2)
  return `${h}:${m}:${s}`
}

/**
 * 时间转刻度位置
 * @param date
 * @param max 总共多少格刻度
 * @returns 当前时间占多少格
 */
export function dateToGrid(date: string, max: number) {
  let t: number = 0
  let arr = date.split(':')
  if (arr.length == 3) {
    t = +arr[0] * 3600 + +arr[1] * 60 + +arr[2]
  } else if (arr.length == 2) {
    t = +arr[0] * 3600 + +arr[1] * 60
  }
  return (t / 86400) * max
}

/**
 * 判断当前时间是否在时间段上
 * @param curTime
 * @param startTime
 * @param endTime
 * @returns
 */
function isInTimePeriod(curTime: string, startTime: string, endTime: string) {
  let curDate = new Date(curTime),
    beginDate = new Date(startTime),
    endDate = new Date(endTime)
  return curDate >= beginDate && curDate <= endDate
}

/**
 * 判断当前时间是否在存在视频的时间上
 * @param curDate
 * @param activeTime
 * @param value
 * @returns
 */
export function isInActiveTime(curDate: string, activeTime: Array<Array<string>>, time: string) {
  return activeTime.some((item: Array<string>) => {
    let startTime = `${curDate} ${item[0]}`
    let endTime = `${curDate} ${item[1]}`
    let curTime = `${curDate} ${time}`
    return isInTimePeriod(curTime, startTime, endTime)
  })
}

/**
 * 返回24小时内时间数组,默认30分钟分隔
 * @param startDate
 * @param endDate
 * @param space 分钟 默认30
 * @returns
 */
export function getDateArray(startDate: Date, endDate: Date, space: number) {
  endDate = endDate || new Date()
  startDate = startDate || new Date(new Date().getTime() - 60 * 60 * 1000)
  space = (space || 30) * 60 * 1000

  let endTime = endDate.getTime()
  let startTime = startDate.getTime()
  let mod = endTime - startTime
  if (mod <= space) {
    return
  }
  let dateArray = ['00:00']
  while (mod >= space) {
    let d = new Date()
    startTime = startTime + space
    d.setTime(startTime)
    let newd = dateFormat(d, 'HH:mm')
    dateArray.push(newd)
    mod = mod - space
  }
  let newend = dateFormat(endDate, 'HH:mm')
  dateArray.push(newend)

  let dateArrs = dateArray.sort((a, b) => {
    return Date.parse(a) - Date.parse(b)
  })
  if (dateArrs[dateArrs.length - 1] == '23:59') {
    dateArrs[dateArrs.length - 1] = '24:00'
  }
  return dateArrs
}