自定义播放器 与 Node.js 和 HTML5 视频流传输

1. HTML5 视频播放

通常,我们在页面上进行播放的视频是通过video标签,在src 属性里加入一个我们需要展示的视频地址,浏览器会根据其设置的属性,例如宽度、高度、是否自动播放、循环播放等属性,通过浏览器默认的视频控件播放视频。

<video controls width="250">
    <source src="/media/cc0-videos/flower.webm" type="video/webm">
    <source src="/media/cc0-videos/flower.mp4" type="video/mp4">
    Sorry, your browser doesn't support embedded videos.
</video>
复制代码

2. 常见事件和属性

属性、方法、事件

常见实现:

属性 描述
autoplay 指定该属性后,不会等待数据加载完成,直接自动播放
buffered 可以读取到哪段时间范围内的媒体被缓存了
controls 允许用户控制视频的播放,例如音量、暂停、恢复等
controlslist 当指定了controls,controlslist可以帮助浏览器选择媒体元素上的显示控件(接收参数:nodownload,nofullscreen .... )
currentTime 设置改值后,video会将其设置为当前播放的开始时间
volume 在0.0到1.0之间设置音频音量的相对值,或者查询当前音量相对值
muted 是否静音,为文件设置静音或消除静音
startTime 一般为0,如果为流媒体或者不从0开始的资源,则不为0
duration only-read 媒体文件的总长度。有些媒体(例如未知的实时流、网络广播、来自WebRTC的媒体)没有该值,返回时NAN
paused only-read 是否被暂停
ended only-read 音频/视频的播放是否已结束
height / width 视频的高/宽度,单位是css像素
loop 指定后,当视频播放到末尾会自动返回视频开始的地方
poster 视频封面,没有播放时显示的图片
preload none: 不提前缓存视频, metadata: 合理抓取源数据。 auto:需要优先加载这个视频
src 嵌入视频的URL

常见事件:

事件 触发时机
loadstart 开始加载
durationchange duration 属性值修改时触发
ratechange 播放速率改变时触发
seeking seeking 寻找中 点击一个为(缓存)下载的区域
seeked seeked 寻找完成时触发
play 开始播放时触发
waiting 播放由于下一帧数据未获取到导致播放停止,但是播放器没有主动预期其停止,仍然在努力的获取数据,简单的说就是在等待下一帧视频数据,暂时还无法播放。
playing 我们能看到视频时触发,也就是真正处于播放状态
canplay 浏览器可以播放媒体文件,但是没有足够的数据支撑到播放结束,需要不停缓存更多内容
pause 暂停播放时触发
ended 视频停止,media已经播放到终点时触发, loop 的情况下不会触发
volumechange 音量改变时触发
loadedmetadata 获取视频meta信息完毕,这个时候播放器已经获取到了视频时长和视频资源的文件大小。
loadeddata media中的首帧已经加载时触发, 视频播放器第一次完成了当前播放位置的视频渲染。
abort 客户端主动终止下载(不是因为错误引起)
error video.error.code: 1.用户终止 2.网络错误 3.解码错误 4.URL无效
canplaythrough 浏览器可以播放文件,不需要停止缓存更多内容
progress 客户端请求数据
timeupdate 当video.currentTime发生改变时触发该事件
stalled 网速失速
suspend 延迟下载

方法:

方法 描述
play() 播放视频
pause() 暂停视频
canPlayType() 测试video元素是否支持给定MIME类型的文件
requestFullscreen() / mozRequestFullScreen() / webkitRequestFullScreen() 全屏

3. 自定义视频播放器

首先需要去掉video身上的属性controls属性,将所有播放的动作交由我们自己控制。

3.1 自定义播放或暂停

const playBtn = document.getElementById('playBtnId');
playBtn.addEventListener('click', function() {
  if (video.paused) {
    video.play();
    playBtn.textContent = '||'; // 切换样式
  } else {
    video.pause();
    playBtn.textContent = '>'; // 切换样式
  }
});
复制代码

3.2 音量控制

// 音量增加
const volIncBtn = document.getElementById('volIncId');
volIncBtn.addEventListener('click', function() {
  video.volume > 0.9 ? (video.volume = 1) : (video.volume += 0.1);
});

// 音量减小
const volDecBtn = document.getElementById('volDecId');
 volDecBtn.addEventListener('click', function() {
    video.volume < 0.1 ? (video.volume = 0) : (video.volume -= 0.1);
  });
复制代码

3.3 静音

const mutedBtn = document.getElementById('mutedId');
 mutedBtn.addEventListener('click', function() {
    video.muted = !video.muted;
    mutedBtn.textContent = video.muted ? '恢复' : '静音';
  });
复制代码

3.4 播放快进/快退

  • 快进
const speedUpBtn = document.getElementById(speedUpId);
let _speed = 1;
speedUpBtn.addEventListener('click', function() {
  _speed = _speed * 2;
  if (_speed > 4) {
    _speed = 1;
  }

  video.playbackRate = _speed;
  speedUpBtn.textContent = _speed === 1 ? '快进' : '快进x' + _speed;
});
复制代码
  • 快退
  const backBtn = document.getElementById(backBtnId);
  let back_speed = 1;
  let _t;
  backBtn.addEventListener('click', function() {
    back_speed = back_speed * 2;
    if (back_speed > 4) {
      video.playbackRate = 1;
      back_speed = 1;
      clearInterval(_t);
    } else {
      video.playbackRate = 0;
      clearInterval(_t);
      _t = setInterval(function() {
        video.currentTime -= back_speed * 0.1;
      }, 100);
    }
    backBtn.textContent = back_speed === 1 ? '快退' : '快退x' + back_speed;
  });
复制代码

3.5 全屏

const fullScreenBtn = document.getElementById(fullScreenId);
const fullScreen = function() {
  fullScreenBtn.addEventListener('click', function() {
    if (video.requestFullscreen) {
      video.requestFullscreen();
    } else if (video.mozRequestFullScreen) {
      video.mozRequestFullScreen();
    } else if (video.webkitRequestFullScreen) {
      video.webkitRequestFullScreen();
    }
  });
};
复制代码

3.6 进度条和时间显示

 const getTime = function() {
   // 当前播放时间
    nowTime.textContent = 0;
    // 总时长
    duration.textContent = 0;

    video.addEventListener('timeupdate', function() {
       // 当前播放时间, parseTime: 格式化时间
      nowTime.textContent = parseTime(video.currentTime); 

      // 计算进度条
      const percent = video.currentTime / video.duration;
      playProgress.style.width = percent * progressWrap.offsetWidth + 'px';
    });


    video.addEventListener('loadedmetadata', function() {
      // 更新视频总时长
      duration.textContent = parseTime(video.duration);
    });
  };
复制代码

3.7 手动点击进度条快进(视频跳跃)

progressWrap.addEventListener('click', function(e) {
  if (video.paused || video.ended) {
    video.play();
  }
  const length = e.pageX - progressWrap.offsetLeft;
  const percent = length / progressWrap.offsetWidth;
  playProgress.style.width = percent * progressWrap.offsetWidth + 'px';
  video.currentTime = percent * video.duration;
});
复制代码

4. 视频分段加载

上面进行了HTML5 视频播放器的相关信息,那么接下来,我们需要从服务器端获取video到客户端。

当视频文件很大的时候,建议使用流式传输视频,它支持任何大小。通过利用fs.createReadStream(),服务器可以读取流中的文件,而不是一次性读取整个文件到内存中。然后通过range的方式请求,将视频发送到客户端。而客户端也不用等页面从服务端下载整个视频,可以在视频播放开始的前几秒钟请求服务器,就可以达到边请求边播放视频了。

  • fs.statSync(): 该方法用于获取文件的统计信息,我们可以拿到当前加载的chunk到达文件末端时候的文件大小。 fileSize = fs.statSync(filePath).size
  • fs.createReadStream(): 给指定文件创建流fs.createReadStream(filePath, { start, end })
  • 返回整个数据块的大小: endChunk - startChunk。
  • HTTP 206: 用于不间断地向前端提供数据块。下面的信息再请求时是必须的:
    1. 'Content-Range': 'bytes chunkStart-chunkEnd/chunkSize'
    2. 'Accept-Ranges': 'bytes'
    3. 'Content-Length': chunkSize
    4. 'Content-Type': 'video/webm'

这里,我使用egg的框架,实现了range请求video的功能。

async getVideo() {
    const { ctx } = this;
    const req = ctx.request;
    try {
      const homedir = `${process.env.HOME || process.env.USERPROFILE}/`;
      const filePath = path.resolve(`${process.env.NODE_ENV === 'development' ? '' : homedir}${req.query.filePath}`);
      const range = req.headers.range;
      const fileSize = fs.statSync(filePath).size;

      if (range) {
        const positions = range.replace(/bytes=/, '').split('-');
        const start = parseInt(positions[0], 10);

        const end = positions[1] ? parseInt(positions[1], 10) : fileSize - 1;
        const chunksize = end - start + 1;

        if (start >= fileSize) {
          ctx.status = 416;
          ctx.body =
            'Requested range not satisfiable\n' + start + ' >= ' + fileSize;
          return;
        }

        ctx.status = 206;
        const header = {
          'Accept-Ranges': 'bytes',
          'Content-Type': 'video/webm',
          'Content-Length': chunksize,
          'Content-Range': `bytes ${start}-${end}/${fileSize}`,
          'cache-control': 'public,max-age=31536000',
        };
        ctx.set(header);

        ctx.body = fs
          .createReadStream(filePath, {
            start,
            end,
            autoClose: true,
          })
          .on('err', err => {
            console.log(`[Video Play]: ${req.url}, 'pip stream error`);
            ctx.body = err;
            ctx.status = 500;
          });
      } else {
        this.ctx.set('Content-Length', fileSize);
        this.ctx.set('Content-Type', 'video/webm');
        this.ctx.status = 200;
        this.ctx.body = fs.createReadStream(filePath);
      }
    } catch (err) {
      console.log(err);
      ctx.body = err;
      ctx.status = 500;
    }
  }
复制代码

参考:Video Stream With Node.js and HTML5

猜你喜欢

转载自juejin.im/post/7005113621415985183