Browse Source

feat: 增加Video组件 (#464)

* feat: 增加Video组件

* feat: 增加Video组件
Jerry 4 years ago
parent
commit
ca87a0b89e

+ 10 - 0
src/config.json

@@ -392,6 +392,16 @@
           "author": "Drjingfubo"
         },
         {
+          "version": "3.0.0",
+          "name": "Video",
+          "type": "component",
+          "cName": "视频播放器",
+          "desc": "视频播放组件",
+          "sort": 20,
+          "show": true,
+          "author": "zy19940510"
+        },
+        {
           "name": "Steps",
           "sort": 17,
           "cName": "步骤条",

+ 167 - 0
src/packages/video/demo.vue

@@ -0,0 +1,167 @@
+<template>
+  <div class="demo">
+    <h4>基础用法</h4>
+    <nut-cell class="cell">
+      <nut-video
+        :source="source"
+        :options="options"
+        @play="play"
+        @pause="pause"
+        @playend="playend"
+      >
+      </nut-video>
+    </nut-cell>
+    <h4>自动播放</h4>
+    <nut-cell class="cell">
+      <nut-video
+        :source="source"
+        :options="options1"
+        @play="play"
+        @pause="pause"
+        @playend="playend"
+      >
+      </nut-video>
+    </nut-cell>
+    <h4>初始化静音</h4>
+    <nut-cell class="cell">
+      <nut-video
+        :source="source"
+        :options="options2"
+        @play="play"
+        @pause="pause"
+        @playend="playend"
+      >
+      </nut-video>
+    </nut-cell>
+    <h4>视频封面海报设置</h4>
+    <nut-cell class="cell">
+      <nut-video
+        :source="source"
+        :options="options3"
+        @play="play"
+        @pause="pause"
+        @playend="playend"
+      >
+      </nut-video>
+    </nut-cell>
+    <h4>行内播放</h4>
+    <nut-cell class="cell">
+      <nut-video
+        :source="source"
+        :options="options4"
+        @play="play"
+        @pause="pause"
+        @playend="playend"
+      >
+      </nut-video>
+    </nut-cell>
+    <h4>设置视频为背景图</h4>
+    <nut-cell class="cell">
+      <nut-video
+        :source="source"
+        :options="options5"
+        @play="play"
+        @pause="pause"
+        @playend="playend"
+      >
+      </nut-video>
+    </nut-cell>
+    <h4>视频切换</h4>
+    <nut-cell class="cell">
+      <nut-video
+        :source="source1"
+        :options="options"
+        @play="play"
+        @pause="pause"
+        @playend="playend"
+      >
+      </nut-video>
+    </nut-cell>
+    <nut-button type="primary" @click="changeVideo" class="m-b"
+      >切换视频</nut-button
+    >
+  </div>
+</template>
+
+<script lang="ts">
+import { reactive, toRefs } from 'vue';
+import { createComponent } from '@/utils/create';
+const { createDemo } = createComponent('video');
+export default createDemo({
+  props: {},
+  setup() {
+    const state = reactive({
+      source: {
+        src:
+          'https://storage.jd.com/about/big-final.mp4?Expires=3730193075&AccessKey=3LoYX1dQWa6ZXzQl&Signature=ViMFjz%2BOkBxS%2FY1rjtUVqbopbJI%3D',
+        type: 'video/mp4'
+      },
+      source1: {
+        src:
+          'https://storage.jd.com/about/big-final.mp4?Expires=3730193075&AccessKey=3LoYX1dQWa6ZXzQl&Signature=ViMFjz%2BOkBxS%2FY1rjtUVqbopbJI%3D',
+        type: 'video/mp4'
+      },
+      options: {
+        controls: true
+      },
+      options1: {
+        autoplay: true,
+        muted: true,
+        controls: true
+      },
+      options2: {
+        muted: true,
+        controls: true
+      },
+      options3: {
+        controls: true,
+        poster:
+          'https://img12.360buyimg.com/ling/s345x208_jfs/t1/168105/33/8417/54825/603df06dEfcddc4cb/21f9f5d0a1b3dad4.jpg.webp'
+      },
+      options4: {
+        controls: true,
+        playsinline: true
+      },
+      options5: {
+        controls: false,
+        autoplay: true,
+        disabled: true,
+        muted: true,
+        playsinline: true,
+        loop: true
+      }
+    });
+    const play = (elm: any) => console.log('play', elm);
+    const pause = (elm: any) => console.log('pause', elm);
+    const playend = (elm: any) => console.log('playend', elm);
+    const changeVideo = () => {
+      state.source1.src = 'https://vjs.zencdn.net/v/oceans.mp4';
+    };
+
+    return { play, pause, playend, ...toRefs(state), changeVideo };
+  }
+});
+</script>
+
+<style lang="scss" scoped>
+.demo {
+  padding: 57px 0 0 0 !important;
+}
+h4 {
+  margin-left: 25px;
+  margin-top: 20px;
+  font-family: PingFangSC-Regular;
+  font-size: 14px;
+  color: rgba(144, 156, 164, 1);
+}
+
+.nut-cell {
+  padding: 0;
+  &::after {
+    display: none;
+  }
+}
+.m-b {
+  margin-bottom: 60px;
+}
+</style>

+ 216 - 0
src/packages/video/doc.md

@@ -0,0 +1,216 @@
+#  video视频播放器
+
+### 介绍
+
+原生video实现的视频播放器
+
+### 安装
+
+``` javascript
+import { createApp } from 'vue';
+import { Video } from '@nutui/nutui';
+
+const app = createApp();
+app.use(Video);
+```
+
+## 代码演示
+
+### 基础用法
+
+```html
+<nut-video
+  :source="source"
+  @play="play"
+  @pause="pause"
+  @playend="playend">
+</nut-video>
+```
+
+```javascript
+setup() {
+    const state = reactive({
+      source: {
+        src: 'https://storage.jd.com/about/big-final.mp4',
+        type: 'video/mp4'
+      }
+    });
+    const play = (elm: any) => console.log('play', elm);
+    const pause = (elm: any) => console.log('pause', elm);
+    const playend = (elm: any) => console.log('playend', elm);
+
+    return { play, pause, playend, ...toRefs(state) };
+}
+```
+
+### 自动播放
+autoplay 属性设置视频自动播放
+```html
+<nut-video :source="source" :options="options"></nut-video>
+```
+
+```javascript
+setup() {
+    const state = reactive({
+      source: {
+        src: 'https://storage.jd.com/about/big-final.mp4',
+        type: 'video/mp4'
+      },
+      options: {
+        autoplay: true,
+        muted: true,
+        controls: true
+      },
+    });
+
+    return { ...toRefs(state) };
+}
+```
+
+### 初始化静音
+muted属性设置视频初始化静音
+```html
+<nut-video :source="source" :options="options"></nut-video>
+```
+
+```javascript
+setup() {
+    const state = reactive({
+      source: {
+        src: 'https://storage.jd.com/about/big-final.mp4',
+        type: 'video/mp4'
+      },
+      options: {
+        muted: true,
+        controls: true
+      },
+    });
+
+    return { ...toRefs(state) };
+}
+```
+
+### 视频封面海报设置
+poster 属性设置视频海报
+```html
+<nut-video :source="source" :options="options"></nut-video>
+```
+
+```javascript
+setup() {
+    const state = reactive({
+      source: {
+        src: 'https://storage.jd.com/about/big-final.mp4',
+        type: 'video/mp4'
+      },
+      options: {
+        controls: true,
+        poster: 'https://img12.360buyimg.com/ling/s345x208_jfs/t1/168105/33/8417/54825/603df06dEfcddc4cb/21f9f5d0a1b3dad4.jpg.webp'
+      },
+    });
+
+    return { ...toRefs(state) };
+}
+```
+
+### 行内播放
+playsinline 属性设置移动端视频行内播放,阻止新打开页面播放(兼容 ios,兼容部分安卓机)
+```html
+<nut-video :source="source" :options="options"></nut-video>
+```
+
+```javascript
+setup() {
+    const state = reactive({
+      source: {
+        src: 'https://storage.jd.com/about/big-final.mp4',
+        type: 'video/mp4'
+      },
+      options: {
+        playsinline: true,
+        controls: true,
+      },
+    });
+
+    return { ...toRefs(state) };
+}
+```
+
+### 视频背景图
+当设置视频为背景图时需要将 muted 静音、 disabled 禁止操作、loop 循环播放、autoplay 自动播放设置为 true,移动端需要设置 playsinline 行内展示
+```html
+<nut-video :source="source" :options="options"></nut-video>
+```
+
+```javascript
+setup() {
+    const state = reactive({
+      source: {
+        src: 'https://storage.jd.com/about/big-final.mp4',
+        type: 'video/mp4'
+      },
+      options: {
+        controls: false,
+        autoplay: true,
+        muted: true,
+        disabled: true,
+        playsinline: true,
+        loop: true
+      },
+    });
+
+    return { ...toRefs(state) };
+}
+```
+
+### 视频切换
+当视频地址发生变化时,重置视频
+```html
+<nut-video :source="source"></nut-video>
+<nut-button type="primary" @click="changeVideo">切换视频</nut-button>
+```
+
+```javascript
+setup() {
+    const state = reactive({
+      source: {
+        src: 'https://storage.jd.com/about/big-final.mp4',
+        type: 'video/mp4'
+      },
+    });
+    const changeVideo = () => {
+      state.source1 = {
+        src: 'https://vjs.zencdn.net/v/oceans.mp4',
+        type: 'video/mp4'
+      };
+    };
+
+    return { ...toRefs(state), changeVideo };
+}
+```
+
+
+## API
+
+### Props
+
+| 字段                | 说明                                       | 类型    | 默认值   |
+| ------------------- | ------------------------------------------ | ------- | -------- |
+| source             | 视频地址和类型设置                         | Object   | -        |
+| options             | 控制视频播放属性                           | Object  | required |
+| options.autoplay    | 是否自动播放                               | Boolean | false    |
+| options.poster      | 海报设置                                   | String  | -        |
+| options.loop        | 是否循环播放                               | Boolean | false    |
+| options.controls    | 是否展示操作栏                             | Boolean | true     |
+| options.muted       | 是否静音                                   | Boolean | false    |
+| options.volume      | 音量控制                                   | Number  | 0.5      |
+| options.disabled    | 禁用操作(如循环播放的背景图,禁止操作)   | Boolean | false    |
+| options.playsinline | 是否设置为行内播放元素(解决安卓兼容问题) | Boolean | false    |
+
+### Events
+
+| 事件名称 | 说明         | 回调参数 |
+| -------- | ------------ | -------- |
+| play     | 播放         | --       |
+| pause    | 暂停         | --       |
+| playend  | 播放完成回调 | --       |

File diff suppressed because it is too large
+ 205 - 0
src/packages/video/index.scss


+ 407 - 0
src/packages/video/index.vue

@@ -0,0 +1,407 @@
+<template>
+  <div class="nut-video" ref="videocon">
+    <video
+      ref="root"
+      class="nut-video-player"
+      :muted="options.muted"
+      :autoplay="options.autoplay"
+      :loop="options.loop"
+      :poster="options.poster"
+      :controls="options.controls"
+      :preload="options.preload"
+      @error="handleError"
+    >
+      <source :src="source.src" :type="source.type" />
+    </video>
+    <div
+      class="playing-mask"
+      ref="touchMask"
+      v-if="showToolbox && !isDisabled"
+      @click="play"
+    ></div>
+    <div
+      class="nut-video-play-btn"
+      v-if="showToolbox && !isDisabled"
+      ref="palyBtn"
+      v-show="!state.playing"
+      @click="play"
+    ></div>
+    <div
+      class="nut-video-controller"
+      v-show="showToolbox && !isDisabled"
+      :class="{ 'show-control': !state.playing, 'hide-control': state.playing }"
+    >
+      <div class="control-play-btn" @click="play"></div>
+      <div class="current-time">{{ videoSet.displayTime }}</div>
+      <div class="progress-container">
+        <div class="progress" ref="progressBar">
+          <div class="buffered" :style="{ width: `${videoSet.loaded}%` }"></div>
+          <div
+            class="video-ball"
+            :style="{
+              transform: `translate3d(${videoSet.progress.current}px, -50%, 0)`
+            }"
+            @touchmove.stop.prevent="touchSlidMove($event)"
+            @touchstart.stop="touchSlidSrart($event)"
+            @touchend.stop="touchSlidEnd($event)"
+          >
+            <div class="move-handle"></div>
+          </div>
+          <div class="played" ref="playedBar"></div>
+        </div>
+      </div>
+      <div class="duration-time">{{ videoSet.totalTime }}</div>
+      <div
+        class="volume"
+        @click="handleMuted"
+        :class="{ muted: state.isMuted }"
+      ></div>
+      <div class="fullscreen-icon" @click="fullScreen"></div>
+    </div>
+    <!-- 错误弹窗 -->
+    <div class="nut-video-error" v-show="state.isError">
+      <p class="lose">视频加载失败</p>
+      <p class="retry" @click="retry">点击重试</p>
+    </div>
+  </div>
+</template>
+<script lang="ts">
+import {
+  computed,
+  reactive,
+  ref,
+  toRefs,
+  watch,
+  nextTick,
+  onMounted
+} from 'vue';
+import { createComponent } from '@/utils/create';
+import { throttle } from '@/utils/throttle.js';
+import './index.scss';
+const { create } = createComponent('video');
+
+export default create({
+  props: {
+    source: {
+      type: Object,
+      default: {}
+    },
+    options: {
+      type: Object,
+      default: {
+        autoplay: false, //是否自动播放
+        volume: 0.5,
+        poster: '',
+        loop: false,
+        controls: true,
+        muted: false, //是否静音
+        disabled: false, //禁止操作
+        playsinline: false, //行内展示
+        touchPlay: false,
+        preload: ''
+      },
+      required: true
+    },
+    model: {
+      type: String,
+      default: ''
+    }
+  },
+  components: {},
+  emits: ['click', 'play', 'pause', 'playend'],
+
+  setup(props, { emit }) {
+    const state = reactive({
+      videoElm: null,
+      initial: true, //控制封面的显示
+      showToolbox: false, //控制控制器和标题的显示
+      // 视频容器元素
+      player: {
+        $player: null,
+        pos: null
+      },
+      // progress进度条元素
+      progressBar: {
+        progressElm: null, // 进度条DOM对象
+        pos: null
+      },
+      // video控制显示设置
+      videoSet: {
+        loaded: 0, // 缓存长度
+        displayTime: '00:00', // 进度时间
+        totalTime: '00:00', // 总时间
+        progress: {
+          width: 0, // 进度条长度
+          current: 0 // 进度条当前位置
+        }
+      },
+      state: {
+        controlShow: true,
+        vol: 0.5, //音量
+        currentTime: 0, //当前时间
+        fullScreen: false,
+        playing: false, //是否正在播放
+        isLoading: false,
+        isEnd: false,
+        isError: false,
+        isMuted: false
+      },
+      showTouchMask: false
+    });
+    const root = ref<HTMLElement>();
+    const isDisabled = computed(() => {
+      return props.options.disabled;
+    });
+
+    watch(props.source, newValue => {
+      if (newValue.src) {
+        nextTick(() => {
+          (state.videoElm as any).load();
+        });
+      }
+    });
+
+    watch(
+      props.options,
+      newValue => {
+        state.state.isMuted = newValue.muted ? newValue.muted : false;
+      },
+      { immediate: true }
+    );
+    const init = () => {
+      (state.videoElm as any) = root.value;
+
+      if (props.options.autoplay) {
+        (state.videoElm as any).play();
+      }
+
+      if (props.options.touchPlay) {
+        state.showTouchMask = true;
+      }
+
+      if (props.options.playsinline) {
+        (state.videoElm as any).setAttribute(
+          'playsinline',
+          props.options.playsinline
+        );
+        (state.videoElm as any).setAttribute(
+          'webkit-playsinline',
+          props.options.playsinline
+        );
+        (state.videoElm as any).setAttribute('x5-video-player-type', 'h5-page');
+        (state.videoElm as any).setAttribute(
+          'x5-video-player-fullscreen',
+          false
+        );
+      }
+      volumeHandle();
+
+      if (state.showToolbox) {
+        customerInit();
+      } else {
+        (state.videoElm as any).addEventListener('play', () => {
+          state.state.playing = true;
+          emit('play', state.videoElm as any);
+        });
+        (state.videoElm as any).addEventListener('pause', () => {
+          state.state.playing = false;
+          emit('pause', state.videoElm as any);
+        });
+        (state.videoElm as any).addEventListener('ended', playEnded);
+
+        (state.videoElm as any).addEventListener(
+          'timeupdate',
+          throttle(getPlayTime, 100, 1)
+        );
+      }
+    };
+
+    const customerInit = () => {
+      const $player = root.value;
+      const $progress = root.value!.getElementsByClassName('progress')[0];
+      // 播放器位置
+      (state.player.$player as any) = $player;
+      (state.progressBar.progressElm as any) = $progress;
+      (state.progressBar.pos as any) = $progress.getBoundingClientRect();
+      state.videoSet.progress.width = Math.round(
+        $progress.getBoundingClientRect().width
+      );
+    };
+
+    const play = () => {
+      if (props.options.autoplay && props.options.disabled) {
+        state.state.playing = true;
+        return false;
+      }
+      state.state.playing = !state.state.playing;
+      if (state.videoElm) {
+        // 播放状态
+        if (state.state.playing) {
+          try {
+            (state.videoElm as any).play();
+            // 监听缓存进度
+            (state.videoElm as any).addEventListener('progress', () => {
+              getLoadTime();
+            });
+            // 监听播放进度
+            (state.videoElm as any).addEventListener(
+              'timeupdate',
+              throttle(getPlayTime, 100, 1)
+            );
+            // 监听结束
+            (state.videoElm as any).addEventListener('ended', playEnded);
+            emit('play', state.videoElm);
+          } catch (e) {
+            // 捕获url异常出现的错误
+            handleError();
+          }
+        }
+        // 停止状态
+        else {
+          (state.videoElm as any).pause();
+          emit('pause', state.videoElm);
+        }
+      }
+    };
+    const timeFormat = (t: number) => {
+      var h = Math.floor(t / 3600) as string | number;
+      if (h < 10) {
+        h = '0' + h;
+      }
+      var m = Math.floor((t % 3600) / 60) as string | number;
+      if (m < 10) {
+        m = '0' + m;
+      }
+      var s = Math.round((t % 3600) % 60) as string | number;
+      if (s < 10) {
+        s = '0' + s;
+      }
+      var str = '';
+      if (h != 0) {
+        str = h + ':' + m + ':' + s;
+      } else {
+        str = m + ':' + s;
+      }
+      return str;
+    };
+
+    const getLoadTime = () => {
+      if (state.videoSet.loaded)
+        state.videoSet.loaded =
+          ((state.videoElm as any).buffered.end(0) /
+            (state.videoElm as any).duration) *
+          100;
+    };
+
+    const getPlayTime = () => {
+      const percent =
+        (state.videoElm as any).currentTime / (state.videoElm as any).duration;
+      state.videoSet.progress.current = Math.round(
+        state.videoSet.progress.width * percent
+      );
+
+      // 赋值时长
+      state.videoSet.totalTime = timeFormat((state.videoElm as any).duration);
+      state.videoSet.displayTime = timeFormat(
+        (state.videoElm as any).currentTime
+      );
+    };
+
+    const playEnded = () => {
+      state.state.playing = false;
+      state.state.isEnd = true;
+      state.videoSet.displayTime = '00:00';
+      state.videoSet.progress.current = 0;
+      (state.videoElm as any).currentTime = 0;
+      emit('playend', state.videoElm);
+    };
+
+    const handleError = () => {
+      state.state.isError = true;
+    };
+
+    const volumeHandle = () => {
+      state.state.vol = props.options.volume;
+    };
+
+    const handleMuted = () => {
+      state.state.isMuted = !state.state.isMuted;
+      (state.videoElm as any).muted = state.state.isMuted;
+    };
+
+    const touchSlidSrart = () => {};
+
+    const touchSlidMove = (e: any) => {
+      let currentX = e.targetTouches[0].pageX;
+      let offsetX = currentX - (state.progressBar.pos as any).left;
+      // 边界检测
+      if (offsetX <= 0) {
+        offsetX = 0;
+      }
+
+      if (offsetX >= state.videoSet.progress.width) {
+        offsetX = state.videoSet.progress.width;
+      }
+      state.videoSet.progress.current = offsetX;
+
+      let percent =
+        state.videoSet.progress.current / state.videoSet.progress.width;
+      (state.videoElm as any).duration &&
+        setPlayTime(percent, (state.videoElm as any).duration);
+    };
+
+    const touchSlidEnd = (e: any) => {
+      let currentX = e.changedTouches[0].pageX;
+      let offsetX = currentX - (state.progressBar.pos as any).left;
+      state.videoSet.progress.current = offsetX;
+      // 这里的offsetX都是正数
+      let percent = offsetX / state.videoSet.progress.width;
+      (state.videoElm as any).duration &&
+        setPlayTime(percent, (state.videoElm as any).duration);
+    };
+
+    const setPlayTime = (percent: number, totalTime: number) => {
+      (state.videoElm as any).currentTime = Math.floor(percent * totalTime);
+    };
+
+    const retry = () => {
+      // console.log('error');
+      state.state.isError = false;
+      init();
+    };
+
+    const fullScreen = () => {
+      if (!state.state.fullScreen) {
+        state.state.fullScreen = true;
+        (state.videoElm as any).webkitRequestFullScreen();
+      } else {
+        state.state.fullScreen = false;
+        (document as any).webkitCancelFullScreen();
+      }
+    };
+
+    onMounted(() => {
+      init();
+    });
+
+    return {
+      root,
+      ...toRefs(props),
+      ...toRefs(state),
+      handleError,
+      isDisabled,
+      play,
+      handleMuted,
+      touchSlidSrart,
+      touchSlidMove,
+      touchSlidEnd,
+      retry,
+      fullScreen
+    };
+  }
+});
+</script>
+
+<style lang="scss">
+@import 'index.scss';
+</style>

+ 32 - 0
src/utils/throttle.js

@@ -0,0 +1,32 @@
+/**
+ * @desc 函数节流
+ * @param func 函数
+ * @param wait 延迟执行毫秒数
+ * @param type 1 表时间戳版,2 表定时器版
+ */
+export const throttle = (func, wait, type) => {
+  if (type === 1) {
+    var previous = 0;
+  } else if (type === 2) {
+    var timeout;
+  }
+  return function() {
+    let context = this;
+    let args = arguments;
+    if (type === 1) {
+      let now = Date.now();
+
+      if (now - previous > wait) {
+        func.apply(context, args);
+        previous = now;
+      }
+    } else if (type === 2) {
+      if (!timeout) {
+        timeout = setTimeout(() => {
+          timeout = null;
+          func.apply(context, args);
+        }, wait);
+      }
+    }
+  };
+};