vue.js组件之音视频播放器 - zhouted/zhouted.github.io GitHub Wiki

标签: javascript vue


音频播放器 ViewAudio.vue

<template>
  <div class="view-audio">
    <audio ref="audio"
      class="audio"
      :src="src"
      :autoplay="autoplay"
      :loop="loop"
      :muted="muted"
      controlsList="nodownload"
      @contextmenu="onContextMenu"
      @load="onReadyState"
      @loadstart="onReadyState"
      @loadedmetadata="onReadyState"
      @loadeddata="onReadyState"
      @durationchange="onReadyState"
      @seeked="onSeeked"
      @volumechange="onVolume"
      @ratechange="onRate"
      @pause="onPause"
      @play="onPlay"
      @playing="onPlaying"
      @progress="onProgress"
      @timeupdate="onTime"
      @ended="onEnded"
      @error="onError">
      <p>Your browser doesn't support HTML5 audio.</p>
      <slot></slot>
    </audio>
    <div class="audio-controls"
      ref="controls"
      v-if="controls">
      <div class="audio-btn"
        @click="clickPlay">
        <i class="iconfont"
          :class="isPlaying ? 'icon-pause' : ' icon-play'" />
      </div>
      <view-slider class="audio-progress"
        :value="currentTime"
        :max="duration"
        :format-tooltip="null"
        @input="onChangeCurrentTime"></view-slider>
      <div class="audio-time">
        {{ currentTime | formatTime }} / {{ duration | formatTime }}
      </div>
      <view-rates :value="playbackRate"
        @change="onChangeRate"></view-rates>
      <div class="audio-btn"
        @click="clickMuted">
        <i class="iconfont"
          :class="muteVolume ? 'icon-sound' : ' icon-sound-mute'" />
      </div>
      <view-slider class="audio-volume"
        :value="muteVolume"
        :format-tooltip="null"
        @input="onChangeVolume"></view-slider>
    </div>
  </div>
</template>
<script>
import ViewSlider from './ViewSlider.vue';
import ViewRates from './ViewRates.vue';
import { formatTime } from './ViewSlider.vue';

export default {
  name: 'ViewAudio',
  components: {
    [ViewSlider.name]: ViewSlider,
    [ViewRates.name]: ViewRates
  },
  props: {
    src: String,
    controls: {
      type: Boolean,
      default: true
    },
    autoplay: {
      type: Boolean,
      default: false
    },
    muted: {
      type: Boolean,
      default: false
    },
    loop: {
      type: Boolean,
      default: false
    }
  },
  data: function () {
    return {
      duration: 0, //时长
      currentTime: 0, //当前播放时长
      playbackRate: 1, //倍速
      volume: 0, //音量
      isMuted: false, //静音
      isPlaying: false, //播放中
      states: [] //记录视频初始化状态
    };
  },
  computed: {
    muteVolume() {
      return this.isMuted ? 0 : this.volume;
    }
  },
  filters: {
    formatTime: function (value) {
      return formatTime(value);
    }
  },
  methods: {
    onChangeCurrentTime(value) {
      this.$refs.audio.currentTime = value;
    },
    onChangeVolume(value) {
      this.$refs.audio.volume = value;
    },
    onChangeRate(value) {
      this.$refs.audio.playbackRate = value;
    },
    clickPlay() {
      const audio = this.$refs.audio;
      audio.paused ? audio.play() : audio.pause();
    },
    clickMuted() {
      this.$refs.audio.muted = !this.$refs.audio.muted;
    },
    onReadyState(e) {
      this.pushState(e);
      this.duration = e.target.duration;
      this.playbackRate = e.target.playbackRate;
      this.volume = e.target.volume;
      this.isMuted = e.target.muted;
    },
    onRate(e) {
      this.pushState(e);
      this.playbackRate = e.target.playbackRate;
    },
    onVolume(e) {
      this.pushState(e);
      this.volume = e.target.volume;
      this.isMuted = e.target.muted;
    },
    onSeeked(e) {
      this.pushState(e);
    },
    onContextMenu(e) {
      this.pushState(e);
      //e.preventDefault(); return false;//禁用右键菜单,避免用户下载视频(其实没什么用)
    },
    onPause(e) {
      this.pushState(e);
      this.isPlaying = !e.target.paused;
    },
    onPlay(e) {
      this.pushState(e);
      this.isPlaying = !e.target.paused;
    },
    onPlaying(e) {
      this.pushState(e);
      this.isPlaying = !e.target.paused;
    },
    onProgress(e) {
      // console.log(e);
    },
    onTime(e) {
      // console.log(e);
      this.currentTime = e.target.currentTime;
    },
    getCurrentTime() {
      return this.currentTime;
    },
    onEnded(e) {
      this.pushState(e);
    },
    onError(e) {
      this.pushState(e);
    },
    pushState(e) {
      // console.log(e);
      const states = this.states;
      const len = states.length;
      if (!len || states[len - 1] != e.type) {
        states.push(e.type);
      }
    },
    formatTime(value) {
      return formatTime(value);
    }
  }
};
</script>
<style lang="less">
.view-audio {
  .audio {
    margin: auto;
  }
  .audio-controls {
    position: relative;
    // top: 50%;
    // transform: translateY(-50%);
    display: flex;
    align-items: center;
    background-image: linear-gradient(rgba(0, 0, 0, 0.1), rgba(0, 0, 0, 0.9));
    color: #fff;
    font-size: 0.9rem;
    .audio-btn {
      flex: 0 0 2rem;
      padding: 0.5rem 0;
      border-radius: 50%;
      cursor: pointer;
    }
    .audio-btn:first-of-type {
      margin-left: 0.5rem;
    }
    .audio-btn:last-of-type {
      margin-right: 0.5rem;
    }
    .audio-btn:hover {
      // background-color: #333;
    }
    .audio-progress {
      flex: 12 0 2rem;
    }
    .audio-time {
      flex: 0 2 auto;
      margin: 0 1rem 0 0;
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
    }
    .audio-volume {
      flex: 1 0 1rem;
    }
  }
}
</style>

视频播放器 ViewVideo.vue

<template>
  <!-- @mousemove="onHoverVideo(1)" -->
  <div class="view-video">
    <video class="video"
      ref="video"
      :src="src"
      :poster="poster"
      :autoplay="autoplay"
      :loop="loop"
      :muted="muted"
      :playsinline="playsinline"
      :webkit-playsinline="playsinline"
      controlsList="nodownload"
      @contextmenu="onContextMenu"
      @click="clickPlay"
      @load="onReadyState"
      @loadstart="onReadyState"
      @loadedmetadata="onReadyState"
      @loadeddata="onReadyState"
      @durationchange="onReadyState"
      @seeked="onSeeked"
      @volumechange="onVolume"
      @ratechange="onRate"
      @pause="onPause"
      @play="onPlay"
      @playing="onPlaying"
      @progress="onProgress"
      @timeupdate="onTime"
      @ended="onEnded"
      @error="onError">
      <slot />
      <p>Your browser doesn't support HTML5 video.</p>
    </video>
    <transition name="controls">
      <div class="video-controls"
        ref="controls"
        v-show="showControls">
        <div class="video-btn"
          @click="clickPlay">
          <i class="iconfont"
            :class="isPlaying ? 'icon-pause' : ' icon-play'" />
        </div>
        <view-slider class="video-progress"
          :value="currentTime"
          :max="duration"
          :format-tooltip="formatTime"
          @change="onChangeCurrentTime"></view-slider>
        <div class="video-time">
          {{ currentTime | formatTime }} / {{ duration | formatTime }}
        </div>
        <view-rates :value="playbackRate"
          @change="onChangeRate"></view-rates>
        <div class="video-btn"
          @click="clickMuted">
          <i class="iconfont"
            :class="muteVolume ? 'icon-sound' : ' icon-sound-mute'" />
        </div>
        <view-slider class="video-volume"
          :value="muteVolume"
          @input="onChangeVolume"></view-slider>
        <div class="video-btn"
          @click="clickFullscreen">
          <i class="iconfont"
            :class="fullscreen ? 'icon-fullscreen-exit' : 'icon-fullscreen'" />
        </div>
      </div>
    </transition>
  </div>
</template>

<script>
//import { Button, Slider } from 'element-ui';
import ViewSlider from './ViewSlider.vue';
import ViewRates from './ViewRates.vue';
import { formatTime } from './ViewSlider.vue';

export default {
  name: 'ViewVideo',
  components: {
    [ViewSlider.name]: ViewSlider,
    [ViewRates.name]: ViewRates
  },
  props: {
    src: String,
    poster: String,
    autoplay: {
      type: Boolean,
      default: false
    },
    muted: {
      type: Boolean,
      default: false
    },
    loop: {
      type: Boolean,
      default: false
    },
    playsinline: {
      type: Boolean,
      default: false
    }
    // controls: {
    //   type: Boolean,
    //   default: false
    // }
  },
  data: function () {
    return {
      videoWidth: 0, //视频宽
      videoHeight: 0, //视频高
      duration: 0, //视频时长
      currentTime: 0, //当前播放时长
      playbackRate: 1, //倍速
      volume: 0, //音量
      isMuted: false, //静音
      isPlaying: false, //播放中
      fullscreen: false, //全屏显示
      states: [], //记录视频初始化状态
      pictureInPictureEnabled: false, //是否支持画中画
      adjustValue: 0,
      showRateList: false,
      showControls: 1
    };
  },
  computed: {
    muteVolume() {
      return this.isMuted ? 0 : this.volume;
    }
  },
  filters: {
    formatTime: function (value) {
      return formatTime(value);
    }
  },
  methods: {
    onChangeCurrentTime(value) {
      this.$refs.video.currentTime = value;
    },
    onChangeVolume(value) {
      this.$refs.video.volume = value;
    },
    onChangeRate(value) {
      this.$refs.video.playbackRate = Number.parseFloat(value);
    },
    clickPlay() {
      const video = this.$refs.video;
      video.paused ? video.play() : video.pause();
    },
    clickMuted() {
      this.$refs.video.muted = !this.$refs.video.muted;
    },
    //reference: https://www.wikimoe.com/?post=82
    clickFullscreen() {
      const vue = this;
      if (!vue.fullscreen) {
        const video = vue.$refs.video;
        const view = video.parentElement;
        if (view.requestFullscreen) {
          view.requestFullscreen();
        } else if (view.webkitRequestFullscreen) {
          view.webkitRequestFullscreen();
        } else if (view.mozRequestFullScreen) {
          view.mozRequestFullScreen();
        } else if (view.msRequestFullscreen) {
          view.msRequestFullscreen();
        } else if (video.webkitEnterFullScreen) {
          //iOS不支持非video元素全屏
          video.webkitEnterFullScreen();
        }
      } else {
        if (document.exitFullscreen) {
          document.exitFullscreen();
        } else if (document.webkitExitFullscreen) {
          document.webkitExitFullscreen();
        } else if (document.mozCancelFullScreen) {
          document.mozCancelFullScreen();
        } else if (document.msExitFullscreen) {
          document.msExitFullscreen();
        }
      }
    },
    onFullscreen() {
      this.fullscreen =
        document.fullscreen ||
        document.webkitIsFullScreen ||
        document.mozFullScreen ||
        !!document.msFullscreenElement;
    },
    clickInPicture() {
      if (!document.pictureInPictureElement) {
        this.$refs.video.requestPictureInPicture();
      } else {
        document.exitPictureInPicture();
      }
    },
    onReadyState(e) {
      this.pushState(e);
      this.videoWidth = e.target.videoWidth;
      this.videoHeight = e.target.videoHeight;
      this.duration = e.target.duration;
      this.playbackRate = e.target.playbackRate;
      this.volume = e.target.volume;
      this.isMuted = e.target.muted;
    },
    onRate(e) {
      this.pushState(e);
      this.playbackRate = e.target.playbackRate;
    },
    onVolume(e) {
      this.pushState(e);
      this.volume = e.target.volume;
      this.isMuted = e.target.muted;
    },
    onSeeked(e) {
      this.pushState(e);
    },
    onContextMenu(e) {
      this.pushState(e);
      //e.preventDefault(); return false;//禁用右键菜单,避免用户下载视频(其实没什么用)
    },
    onPause(e) {
      this.pushState(e);
      this.isPlaying = !e.target.paused;
    },
    onPlay(e) {
      this.pushState(e);
      this.isPlaying = !e.target.paused;
    },
    onPlaying(e) {
      this.pushState(e);
      this.isPlaying = !e.target.paused;
    },
    onProgress(e) {
      // console.log(e);
    },
    onTime(e) {
      // console.log(e);
      this.currentTime = e.target.currentTime;
    },
    getCurrentTime() {
      return this.currentTime;
    },
    onEnded(e) {
      this.pushState(e);
    },
    onError(e) {
      this.pushState(e);
    },
    onHoverVideo(value) {
      const vue = this;
      vue.showControls = value; //鼠标移动即显示controls
      if (!vue.showControlsTimer) {
        //过几秒钟没有鼠标活动即隐藏controls
        vue.showControlsTimer = setTimeout(() => {
          vue.showControlsTimer = null;
          if (vue.showControls < 0.1) {
            vue.showControls = 0;
          } else {
            vue.onHoverVideo(vue.showControls / 2, 'timer');
          }
        }, 1000);
      }
    },
    pushState(e) {
      // console.log(e);
      const states = this.states;
      const len = states.length;
      if (!len || states[len - 1] != e.type) {
        states.push(e.type);
      }
    },
    formatTime(value) {
      return formatTime(value);
    }
  },
  created() {
    this.pictureInPictureEnabled = document.pictureInPictureEnabled;
  },
  mounted() {
    //全屏事件
    document.addEventListener('fullscreenchange', () => {
      this.onFullscreen();
    });
    document.addEventListener('webkitfullscreenchange', () => {
      this.onFullscreen();
    });
    document.addEventListener('onmozfullscreenchange', () => {
      this.onFullscreen();
    });
    document.addEventListener('MSFullscreenChange', () => {
      this.onFullscreen();
    });
    // document.addEventListener('click', e => {
    //   //点击文档其他区域时隐藏弹出项
    //   const video = this.$refs.video;
    //   const view = video && video.parentElement;
    //   if (view && !view.contains(e.target)) {
    //     this.showControls = 0;
    //   }
    // });
  }
};
</script>

<style lang="less">
.view-video {
  position: relative;
  height: 516px;
  .video {
    width: 100%;
    height: 100%;
  }
  .video-controls {
    position: absolute;
    left: 0;
    bottom: 0;
    right: 0;
    display: flex;
    align-items: center;
    background-image: linear-gradient(rgba(0, 0, 0, 0.1), rgba(0, 0, 0, 0.9));
    color: #fff;
    font-size: 0.9rem;
    .video-btn {
      flex: 0 0 2rem;
      padding: 0.5rem 0;
      border-radius: 50%;
      cursor: pointer;
    }
    .video-btn:first-of-type {
      margin-left: 0.5rem;
    }
    .video-btn:last-of-type {
      margin-right: 0.5rem;
    }
    .video-btn:hover {
      // background-image: radial-gradient(#888, #000);
    }
    .video-progress {
      flex: 12 0 2rem;
    }
    .video-time {
      flex: 0 2 auto;
      margin: 0 1rem 0 0;
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
    }
    .video-volume {
      flex: 1 0 1rem;
    }
  }
  .controls-enter-active,
  .controls-leave-active {
    transition: opacity 1s ease;
  }
  .controls-enter,
  .controls-leave-to {
    opacity: 0;
  }
}
</style>

播放速度选择器 ViewRates.vue

<template>
  <div class="view-rate" ref="rates" @click="showRateList = !showRateList">
    <span>{{ value }}X</span>
    <div class="view-rate-list" v-show="showRateList">
      <div
        class="rate-item"
        v-for="(s, i) in rates"
        :key="i"
        @click="onChangeRate(s)"
      >
        <span>{{ s }}</span>
      </div>
    </div>
  </div>
</template>
<script>
export default {
  name: 'ViewRates',
  props: {
    value: Number,
    rates: {
      type: Array,
      default: function() {
        return ['10x', '2.0', '1.5', '1.2', '1.0', '0.8', '0.5'];
      }
    }
  },
  data: function() {
    return { showRateList: false };
  },
  computed: {},
  model: {
    prop: 'value',
    event: 'change'
  },
  methods: {
    onChangeRate(s) {
      this.$emit('change', Number.parseFloat(s));
    }
  },
  mounted() {
    document.addEventListener('click', e => {
      //点击文档其他区域时隐藏弹出项
      const rates = this.$refs.rates;
      if (rates && !rates.contains(e.target)) {
        this.showRateList = false;
      }
    });
  }
};
</script>
<style lang="less">
.view-rate {
  position: relative;
  flex: none;
  padding: 1rem 1rem;
  .view-rate-list {
    position: absolute;
    left: 0.25rem;
    bottom: 0;
    display: flex;
    flex-direction: column;
    background-image: linear-gradient(rgba(0, 0, 0, 0.1), rgba(0, 0, 0, 0.9));
    .rate-item {
      padding: 0.25rem 1rem;
      text-align: right;
    }
    .rate-item:hover {
      background-color: #fff;
      color: #050509;
    }
  }
  cursor: pointer;
}
</style>

播放进度条 ViewSilder.vue

<template>
  <div class="view-slider" :style="sliderStyles">
    <div
      class="view-slider-bak"
      :style="sliderBakStyles"
      @mousedown="onAdjust"
      @mousemove.prevent="onAdjust"
      @mouseup="onAdjust"
      @mouseleave="onAdjust"
    >
      <div class="view-slider-bar">
        <div class="view-slider-rate" :style="sliderRateStyles"></div>
      </div>
      <div
        class="view-slider-pos"
        :style="sliderPosStyles"
        @mouseout="onHoverPos"
        @mouseover="onHoverPos"
      >
        <div class="pos-tip" v-show="(hoverPos || adjusting) && tooltip">
          {{ tooltip }}
        </div>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  name: 'ViewSlider',
  props: {
    value: {
      type: Number,
      default: 0
    },
    max: {
      type: Number,
      default: 1
    },
    size: {
      type: Number,
      default: 1
    },
    formatTooltip: {
      type: Function
    }
  },
  data: function() {
    return {
      adjusting: false, //正在调节
      adjustValue: this.value,
      hoverPos: false
    };
  },
  model: {
    prop: 'value',
    event: 'changed'
  },
  watch: {
    value: function(newValue, oldValue) {
      console.debug(`ViewSilder::watch.value:${oldValue}-->${newValue}`);
      this.adjustValue = newValue;
    }
  },
  computed: {
    sliderRate() {
      return (100 * this.adjustValue) / this.max;
    },
    sliderRateStyles() {
      return { height: this.size / 2 + 'rem', width: this.sliderRate + '%' };
    },
    sliderPosStyles() {
      const styles = {
        top: this.size + 'rem',
        height: this.size + 'rem'
      };
      styles.width = styles.height;
      styles.left = this.sliderRate + '%';
      styles.transform = `translateX(-50%)`;
      styles.transform += `scale(${this.hoverPos || this.adjusting ? 1.2 : 1})`;
      return styles;
    },
    sliderBakStyles() {
      return {
        padding: this.size * 1.25 + 'rem' + ' 0'
      };
    },
    sliderStyles() {
      return {
        margin: '0 ' + this.size + 'rem'
      };
    },
    tooltip() {
      if (typeof this.formatTooltip == 'function') {
        return this.formatTooltip(this.adjustValue);
      } else if (this.formatTooltip === null) {
        return null;
      }
      return this.sliderRate.toFixed();
    }
  },
  filters: {},
  methods: {
    onAdjust(e) {
      if (e.type == 'mousedown') {
        this.adjusting = true;
      }
      if (
        (e.type == 'mousemove' || e.type == 'mouseleave') &&
        !this.adjusting
      ) {
        return;
      }
      const rect = e.currentTarget.getBoundingClientRect();
      const offsetX = e.clientX - rect.left;
      let value = (this.max * offsetX) / rect.width;
      if (value <= 0) {
        value = 0;
      } else if (value > this.max) {
        value = this.max;
      }
      this.adjustValue = value;
      this.$emit('input', this.adjustValue);
      if (e.type == 'mouseup' || e.type == 'mouseleave') {
        this.$emit('change', this.adjustValue);
        this.adjusting = false;
      }
    },
    onHoverPos(e) {
      this.hoverPos = e.type == 'mouseover';
    }
  }
};

export function formatTime(value) {
  let time = parseInt(value, 10);
  if (Number.isNaN(time)) return '';
  let strs = [];
  let hour = Math.floor(time / 3600);
  if (hour) {
    strs.push(hour < 10 ? '0' + hour : hour);
  }
  let minute = Math.floor((time - hour * 3600) / 60);
  strs.push(minute < 10 ? '0' + minute : minute);
  let second = time - hour * 3600 - minute * 60;
  strs.push(second < 10 ? '0' + second : second);
  return strs.join(':');
}
</script>

<style lang="less">
.view-slider {
  .view-slider-bak {
    position: relative;
    .view-slider-bar {
      width: 100%;
      background-color: #aaa;
      .view-slider-rate {
        background-color: #fff;
        border: solid 1px #aaa;
      }
    }
    .view-slider-pos {
      position: absolute;
      border-radius: 50%;
      background-color: #fff;
      transition: transform 0.2s ease 0s;
      .pos-tip {
        position: absolute;
        padding: 20% 40%;
        bottom: 100%;
        left: 50%;
        transform: translateX(-50%) translateY(-50%);
        font-size: 0.5em;
        color: #050509;
        background-color: #fff;
        border-radius: 12%;
      }
      .pos-tip:after {
        content: '\00a0';
        width: 0;
        height: 0;
        display: block;
        position: absolute;
        bottom: -1.1rem;
        left: 50%;
        transform: translateX(-50%);
        border-style: solid;
        border-width: 0.7rem;
        border-color: #fff transparent transparent transparent;
      }
    }
  }
  cursor: pointer;
}
</style>
⚠️ **GitHub.com Fallback** ⚠️