javascript技巧

关注公众号 jb51net

关闭
首页 > 网络编程 > JavaScript > javascript技巧 > video.js移动端h5视频播放

基于video.js的移动端h5视频播放实现方法示例

作者:尼欧neo

Video.js是最强大的网页嵌入式HTML 5视频播放器的组件库之一,也是大多数人首选的网页视频播放解决方案,这篇文章主要介绍了基于video.js的移动端h5视频播放实现方法的相关资料,需要的朋友可以参考下

一、背景

<video>标签是 html5 带来的特性,提供页面中视频播放的功能。然而基础的<video>标签功能单一,样式控件在不同端的表现也不一致,所以日常开发中一般我们会借助第三方封装好的插件来实现。

在众多 h5 视频播放器插件中,video.js 凭借开源、灵活和强大的扩展性,使用较为广泛。

不过 video.js 的基础功能比较简陋,需要依赖各种 api 进行配置,由于官方文档的 api 非常繁杂(左侧目录 500+ 个菜单)且是全英文文档,所以上手比较费劲。

且 video.js 在 pc 端上相对简单问题不大,但在移动端上会有很多问题踩坑点(移动端特性,非插件问题)。

基于此,本文分享下使用 video.js 插件(v8.23.3)在移动端 h5 项目中的实践记录,帮助相关同学快速上手。

二、基本使用

1. 引入依赖

npm i video.js -S
npm i @types/video.js -D

2. react中使用

import React, { useEffect, useRef } from 'react';
import videojs from 'video.js';
import 'video.js/dist/video-js.css';
import Player from 'video.js/dist/types/player';
 
export default function VideoPlayer() {
  const playerRef = useRef<null | Player>(null);
 
  useEffect(() => {
    const player = videojs('myVideoJs', { 
      sources: [{ src: 'xxx.mp4', type: 'video/mp4' }] 
    });
    playerRef.current = player;
 
    return () => {
      playerRef.current?.dispose();
      playerRef.current = null;
    }
  }, []);
 
  return (
    <div className="video-player">
      <video-js id="myVideoJs" class="video-js" />
    </div>
  );
}

3. 常见配置

const options = {
  controls: true, // 显示控件栏
  autoplay: true, // 自动播放
  preload: 'auto', // 视频预加载
  fluid: true, // 启用高度自适应模式
  loop: true, // 循环播放
  muted: true, // 静音
  poster: null, // 初始播放前展示海报图
  playsinline: true, // 配置ios不自动全屏
}

三、扩展问题

1. 标签类型定义

<video-js> 标签是 video.js 插件最新推荐的写法,web component 风格的自定义元素,本质上也是 <video>标签的封装。

<video-js>在项目下 ts 检验会报类型错误,需要显示定义它的类型。

declare namespace JSX {
  interface IntrinsicElements {
    // 描述 <video-js /> 元素的属性
    'video-js': React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement> & {
      // 如果 Video.js 有额外的自定义属性,也可以在这里定义
      id?: string;
      class?: string;
      'data-setup'?: string;
    };
  }
}

2. px单位适配

一般项目会配置将 px 单位自动转换为 rem 单位,比如使用px2rem插件,包括 node_modules 里的依赖包也会自动转换,如果 rootValue 是 100,平时开发都要按两倍的 px 值来写,和一般的像素单位不兼容。

video.js 虽然基本都是用 em 单位,但不排除个别情况使用的 px 的情况,所以建议做排除处理。

px2rem: {
   rootValue: 100,
   minPixelValue: 2,
   selectorBlackList: ['vjs'],
}

同时,video-js 标签元素的字体大小默认是 0.1em,可能项目下实际表现是按 html 根字体大小来计算的,不符合预期,会导致视频控件很小

.video-js {
 font-size: 20px;
}

3. 多语言配置

在一些场景,例如视频资源加载失败等,video.js 会显示一些文字提示,默认是英文

其实 video.js 自带有多国语言包,已经翻译好了,但是可能是出于打包大小的考量,默认未做引入,只通过 language 字段配置语言也不会生效,还需要手动配置语言包。

import zhCN from 'video.js/dist/lang/zh-CN.json';
import videojs from 'video.js';
 
videojs.addLanguage('zh-CN', zhCN);
 
const options = {
  language: isZh ? 'zh-CN' : 'en-US',
}
const player = videojs('myVideoJs', options);

4. 自定义控件

video.js 的控件栏(controlBar)是自定义实现的,这样能保证不同终端的控件展示一致性,控件区域的支持配置各个小部件的展示。

const options = {
  controlBar: {
    children: [
      { name: 'playToggle' }, // 播放按钮
      { name: 'currentTimeDisplay' }, // 已播时间
      { name: 'progressControl' }, // 播放进度条
      { name: 'durationDisplay' }, // 总时间
      { name: 'volumePanel' }, // 音量控制
      { name: 'playbackRateMenuButton' }, // 倍速播放
      { name: 'fullscreenToggle' }, // 全屏按钮
    ],
  },
  playbackRates: [0.5, 1, 1.25, 1.5, 2], // 倍速可选项
}
const player = videojs('myVideoJs', options);

异常 case:

有些小部件即使配置了可能也没显示出来,不知道是不是 video.js 的 bug,貌似问题存在很久了。

经排查,发现未显示的小部件 css 样式是 display:none。

/* 已播时间 */
.vjs-current-time {
  display: block;
}
/* 总时间 */
.vjs-duration {
  display: block;
}

5. 自定义主题

video.js 官网也提供了几套主题风格,主要是初始播放按钮、控制栏等区块的样式,不过官方主题不适合移动端,我们可以通过自定义主题实现。

主题的使用方式是在组件上添加类名 vjs-theme-xxx 来实现。

自定义主题实际上就是定义 .vjs-theme-xxx 类名下的 css 样式,本质也是样式覆盖,封装为主题主要是方便复用。

结合 styled-component 样式方案的主题实现方式:

import { css } from 'styled-components';
 
const vjsThemeHan = css`
  .vjs-theme-han {
    background: linear-gradient(to right, #fff, #daf3ff);
 
    &.vjs-fullscreen {
      background: rgba(0, 0, 0, 1);
      z-index: 999999;
    }
 
    /* 初始时中间的播放按钮 */
    .vjs-big-play-button {
      font-size: 4em;
      width: 1em;
      height: 1em;
      line-height: 1em;
      border-radius: 1em;
      background-color: rgba(0, 0, 0, 30%);
      border-width: 0;
      margin-top: -0.5em;
      margin-left: -0.5em;
    }
 
    .vjs-control-bar {
      background-color: rgba(43, 51, 63, 0.3);
    }
    /* 已播时间 */
    .vjs-current-time {
      display: block;
    }
    /* 总时间 */
    .vjs-duration {
      display: block;
    }
  }
`;
 
export default vjsThemeHan;
import styled from 'styled-components';
import vjsThemeHan from './themes/vjs-theme-han';
 
export const StyledComponent = styled.div`
  .playerWrap {
    ${vjsThemeHan};
    max-height: 100vh;
    .video-js {
      overflow: hidden;
      width: 100%;
      height: auto;
      font-size: 20px;
    }
  }
`;
<div className="playerWrap">
  <video-js id="myVideoJs" class="video-js vjs-theme-han" />
</div>

6. 全屏播放

默认情况下,video.js 的全屏也是 js 常规的 全屏 API 实现,即 Element.requestFullscreen(),而全屏模式是浏览器自己实现的行为,在这种全屏模式就无法显示自定义的控件栏,而是显示为浏览器自己的默认控件,在安卓和 ios 下的展示也不同。

要想使用我们的自定义控件栏,就不能使用浏览器全屏 API,不过我们可以通过 css fixed 布局来模拟全屏效果来实现,video.js 的扩展插件 videojs-landscape-fullscreen 就是这个原理。

tnpm i videojs-landscape-fullscreen -S
const options = {
  plugins: {
    landscapeFullscreen: {
      fullscreen: {
        enterOnRotate: true,
        exitOnRotate: true,
        alwaysInLandscapeMode: true,
        iOS: true,
      },
    },
  }
}
const player = videojs('myVideoJs', options);

异常 case:

在移动端,特别是 ios 下,fixed 定位的表现和其他端不太一致,有些场景 fixed 会表现异常,影响全屏效果,需要特别注意。

例如:在使用 antd-mobile 轮播插件 Swiper 来轮播展示视频时,ios 下全屏效果异常。

.adm-swiper {
  z-index: 99;
}
.adm-swiper-horizontal .adm-swiper-track {
  transform: none;
}

7. 自动横屏

需求:在点击视频全屏播放时自动横屏展示

js 本身有提供相关 API 来实现设备横屏:

这套 API 在安卓下能生效,但是在 ios 下不支持

解决方式:使用客户端提供的 jsapi 来实现横屏。

// 进入横屏
dd.rotateScreenView({
  showStatusBar: false,
  clockwise: true,
});
 
// 退出横屏
dd.resetScreenView({});

监听全屏事件,判断当前状态是全屏时进入横屏,非全屏时退出横屏:

const player = videojs('myVideoJs', options)
 
player.on('fullscreenchange', () => {
  handleFullscreenChange();
});
 
const handleFullscreenChange = () => {
  if (player.isFullscreen()) {
    // 全屏状态,调用进入横屏
  } else {
    // 非全屏状态,调用退出横屏
  }
};

8. 悬浮窗展示

需求:视频播放器默认在页面顶部,当页面往上滚动,视频容器消失时,让视频模块自动以悬浮窗展示

画中画模式:

实现思路:

// 监听视频组件的容器是否进入视窗
useEffect(() => {
  const observer = new IntersectionObserver(
    (entries) => {
      const entry = entries[0];
      if (!entry.isIntersecting) {
        setIsFloat(true);
      } else if (entry.isIntersecting) {
        setIsFloat(false);
      }
    },
    { threshold: 0.1 },
  );
 
  observer.observe(compRef?.current);
 
  return () => {
    observer.disconnect();
  };
}, []);
// 外层父容器,position: static;
<div ref={compRef}>
  // 里层容器,position: 动态改变 relative / fixed;
  <div className="playerWrap">
    // 视频组件
    <video-js id="myVideoJs" class="video-js vjs-theme-han" />
    // 关闭按钮,position: absolute;
    <div className="closeWrap"></div>
  </div>
</div>

父容器高度计算:

const player = videojs('myVideoJs', options);
 
player.on('loadedmetadata', () => {
  setContainerHeight();
});
 
const setContainerHeight = () => {
  const playerWrapEle: any = document.querySelector('.playerWrap');
  if (compRef.current && playerWrapEle) {
    const height = playerWrapEle.offsetHeight;
    compRef.current.style.minHeight = `${height}px`;
  }
};

控件栏小部件动态隐藏:

useEffect(() => {
  if (!playerRef.current) return;
  const showOrHide = isFloat ? 'hide' : 'show';
  ['currentTimeDisplay', 'durationDisplay', 'playbackRateMenuButton'].forEach((key) => {
    playerRef.current?.controlBar?.[key]?.[showOrHide]?.();
  });
}, [isFloat]);

9. 自动暂停

需求:页面不可见时自动暂停视频,页面恢复可见时保持暂停状态

一般情况下即使不做额外处理,在移动端页面隐藏时视频也会自动暂停,但页面重新显示时也会自动恢复播放,重点是不想让他恢复播放。

在移动端多开 webview 的场景下,做回退操作,在回退到视频页 webview 时突然播放视频,会影响用户体验。

核心主要是判断页面的显隐状态。

解决方式:

异常 case:

// 页面恢复显示时
setTimeout(() => {
  player.pause();
}, 1);

10. 失败重试机制

需求:视频加载失败时自动重新加载。

利用 video.js 自带的插件扩展方式,手写个失败重试插件来引入。

实现要点:

对于切片视频,会存在中途加载失败的情况,重试时应该从中断位置继续播放。

支持断点续播:

插件代码:

/**
 * video.js 视频加载失败重试插件
 */
import videojs from 'video.js';
 
export const PLUGIN_NAME = 'retryOnError';
 
export type RetryOnErrorOptions = {
  maxRetryCount?: number; // 最大重试次数
};
 
const defaultOptions: RetryOnErrorOptions = {
  maxRetryCount: 3,
};
 
const registerPlugin = videojs.registerPlugin || videojs.plugin;
const mergeOptions = videojs.obj.merge || videojs.mergeOptions;
 
const retryOnError = function (this: any, options: any) {
  const player = this as any;
  const { maxRetryCount } = mergeOptions(defaultOptions, options);
  let retryCount = 0;
 
  player.on('error', function () {
    if (retryCount >= maxRetryCount) {
      console.warn('视频源加载失败,已达最大重试次数');
      return;
    }
    const curSrc = player.currentSrc?.();
    if (!curSrc) {
      console.warn('视频源为空', curSrc);
      return;
    }
 
    const curTime = player.currentTime?.() || 0;
    retryCount++;
    // console.log(`视频源加载失败,开始第${retryCount}次重试`);
    player.load();
 
    player.on('loadeddata', function () {
      if (retryCount >= maxRetryCount) {
        return;
      }
      retryCount = 0;
      if (curTime) {
        player.currentTime(curTime);
        player.play();
      }
    });
  });
};
 
registerPlugin(PLUGIN_NAME, retryOnError);
 
export default retryOnError;
import './plugins/videojs-retry-on-error';
 
const options = {
  plugins: {
    retryOnError: {
      maxRetryCount: 3,
    },
  },
}
 
const player = videojs('myVideoJs', options);

11. 非wifi网络提醒

需求:判断用户所处网络状态,wifi 下自动播放,非 wifi 网络下不自动播放,点击播放时弹窗提醒。

视频对网络流量的损耗比较大,在流量敏感的场景,有必要添加网络判断,以免引发客户投诉。

核心要点:

判断网络环境:

dd.getWifiStatus({}).then((res: any) => {
  const isWifi = res.status === 1;
})

拦截大播放键的点击事件:

封装为 video.js 插件:

/**
 * video.js 非wifi网络播放提醒插件
 */
import videojs from 'video.js';
import { Dialog, Checkbox } from 'antd-mobile';
import React from 'react';
import { localCache } from '@alipay/fish-utils';
import native from '@/utils/native';
import $i18n from '@/i18n';
 
export const PLUGIN_NAME = 'wifiWarning';
 
export type WifiWarningOptions = {
  open?: boolean; // 是否开启
  appName: string; // 项目应用名(用于 localStorage 存储时 key 的前缀)
};
 
const defaultOptions: WifiWarningOptions = {
  open: true,
  appName: window.yuyanMonitor?.userConfig?.yuyanId || 'appName',
};
 
const registerPlugin = videojs.registerPlugin || videojs.plugin;
const mergeOptions = videojs.obj.merge || videojs.mergeOptions;
 
let isDontShow = false;
 
const confirmWifi = async (): Promise<boolean> => {
  const contentTips = $i18n.get({
    id: 'wifiWarningTips',
    dm: '您当前未连接 WiFi,将会消耗数据流量,是否继续?',
  });
  const dontShowTips = $i18n.get({
    id: 'wifiDontShowTips',
    dm: '30日内不再提醒',
  });
  const confirmText = $i18n.get({
    id: 'continueText',
    dm: '继续',
  });
  const cancelText = $i18n.get({
    id: 'cancelText',
    dm: '取消',
  });
 
  const renderContent = () => {
    const onCheckbocChange = (value: boolean) => {
      isDontShow = value;
    };
    return (
      <div className="confirmContent">
        <div className="tipsWrap">{contentTips}</div>
        <div className="formWrap" style={{ paddingTop: '6px' }}>
          <Checkbox
            style={{
              '--icon-size': '16px',
              '--font-size': '14px',
              '--gap': '6px',
            }}
            onChange={onCheckbocChange}
          >
            <span style={{ color: '#999' }}>{dontShowTips}</span>
          </Checkbox>
        </div>
      </div>
    );
  };
 
  return Dialog.confirm({
    content: renderContent(),
    confirmText,
    cancelText,
  });
};
 
const storage = {
  setParams(this: any, mergedOpt: any) {
    const { appName } = mergedOpt;
    const key = `${appName}-wifi-warning`;
    const expires = 30 * 24 * 60 * 60 * 1000;
    this.key = key;
    this.value = 'warned';
    this.expires = expires;
  },
  getVal(this: any) {
    return localCache.get(this.key);
  },
  setVal(this: any) {
    localCache.set(this.key, this.value, { expires: this.expires });
  },
};
 
const handleBigPlayButton = (player: any, mergedOpt: any) => {
  const { open } = mergedOpt;
  storage.setParams(mergedOpt);
 
  const oldEle: HTMLElement = document.querySelector('.vjs-big-play-button');
  if (oldEle) {
    const myBigPlayButton = oldEle.cloneNode(true);
    oldEle.parentNode.replaceChild(myBigPlayButton, oldEle);
 
    myBigPlayButton.addEventListener('click', () => {
      if (open && !storage.getVal()) {
        native.getIsWifi().then((isWifi) => {
          if (!isWifi) {
            confirmWifi().then((confirmed) => {
              if (confirmed) {
                player.play();
 
                if (isDontShow) {
                  storage.setVal();
                }
              }
            });
          }
        });
      } else {
        player.play();
      }
    });
  }
};
 
const wifiWarning = function (this: any, options: any) {
  const player = this as any;
  const mergedOpt = mergeOptions(defaultOptions, options);
  const { open } = mergedOpt;
  if (!open) {
    return;
  }
  native.getIsWifi().then((isWifi) => {
    if (!isWifi) {
      player.autoplay(false);
    }
  });
  player.on('ready', () => {
    handleBigPlayButton(player, mergedOpt);
  });
};
 
registerPlugin(PLUGIN_NAME, wifiWarning);
 
export default wifiWarning;
import './plugins/videojs-wifi-warning.ts';
 
const options = {
  plugins: {
    wifiWarning: {
      open: true,
      appName: 'yourAppName',
    },
  },
}
const player = videojs('myVideoJs', options);

12. 视频总大小

判断视频总的大小有多少 m,可以用在 4g 网络时告诉用户预计会消耗多少流量,也可以用于清晰度切换时展示预计消耗的流量等等。

判断思路:

如果视频是一次性加载完的单个视频

可以判断资源请求时 response 响应头里的 Content-Length 字段,直接获取。

如果是切片视频

切片视频,无法直接通过 Content-Length 获取。

不过切片视频资源一般在 response 响应头里添加 Content-Range 字段,里面有总大小的数据,需要手动截取下。

Content-Range 字段值示例:bytes 425984-52949424/52949425

我们可以手动请求一个字节的切片数据,解析 Content-Range 获取视频总大小。

async function getVideoFileSizeWithRange(url) {
  try {
    // 使用 Range 请求加载第一个字节
    const response = await fetch(url, {
      method: 'HEAD', 
      headers: { Range: 'bytes=0-1' }, // 请求文件的第一部分
    });
    const contentRange = response.headers.get('Content-Range');
    
    if (contentRange) {
      // 从 Content-Range 中提取总大小
      const totalSizeMatch = contentRange.match(//(\d+)$/);
      if (totalSizeMatch) {
        const totalSize = parseInt(totalSizeMatch[1], 10);
        console.log('视频总大小(字节):', totalSize);
        return totalSize;
      }
    }
 
    console.log('无法获取总大小');
    return null;
  } catch (error) {
    console.error('获取视频大小失败:', error);
    return null;
  }
}

四、结语

总的来说,video.js 是一个非常不错的社区插件,功能丰富,通过一定的配置足以满足我们的日常需求。

利用大模型:

虽然 video.js 的 API 非常繁杂,但是我们有大模型啊,很多功能和实现可以直接去问大模型,它会给你推荐各种实现方案,并提供相关的 API 代码,不用我们去翻官方文档。

这篇文章里的实现,至少有 50% 是大模型提供的思路或灵感,代码很多也是基于大模型生成后修改优化的,省下大量脑细胞~

到此这篇关于基于video.js的移动端h5视频播放实现方法的文章就介绍到这了,更多相关video.js移动端h5视频播放内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

您可能感兴趣的文章:
阅读全文