腾讯直播SDK前端实践指南

前言

远程会议、在线教育、游戏娱乐··· ···,直播技术的应用越来越广泛,直播功能也越来越成为前端绕不过的一道门槛。前段时间公司的某个产品引用腾讯直播SDK,接入了直播功能,在开发期间也踩了不少坑。在此将完整的开发流程记录下来,与诸君共勉。

需要提前说明的是,直播功能大多需要与后端进行交互,本文的侧重点在于前端,对于后端的实现逻辑不会做相关介绍。

基础概念

在正式进入前端开发指南之前,有些基础概念需要了解:

  1. WebRTC: 前端直播技术的底层都一般离开WebRTC(腾讯直播SDK也是一样)。WebRTC全称Web Real-Time Communications,也就是一项用于web的实时通讯技术。详细介绍WebRTC不是本文的主旨,诸位可参考MDN WebRTC
  2. 推流: 主播端将本地的音视频源推送到指定视频服务器,这个过程叫做推流。
  3. 拉流: 播放端将指定视频服务器中的音视频源拉取到本地进行播放,这个过程叫做拉流。
  4. TXLivePusher: TXLivePusher是腾讯提供的用于web推流的SDK。
  5. TCPlayer: TCPlayer是腾讯提供的用于web拉流播放的播放器SDK。

开发流程

SDK引入

umi项目直接在 config 文件中配置全局脚本:

typescript 复制代码
// LIVE_RESOURCE_URL是存放SDK资源的地址。
// 这里我们建议将腾讯SDK下载存放到公司自己的资源服务器,以避免不可控问题的出现。
export default defineConfig({
	// ...
  externals: {
    TXLivePusher: 'window.TXLivePusher',
    TCPlayer: 'window.TCPlayer',
  },
  scripts: [
    `${LIVE_RESOURCE_URL}/TXLivePusher/TXLivePusher-2.0.2.min.js`,
    `${LIVE_RESOURCE_URL}/TcPlayer/libs/TXLivePlayer-1.2.4.min.js`,
    `${LIVE_RESOURCE_URL}/TcPlayer/libs/hls.min.1.1.5.js`,
    `${LIVE_RESOURCE_URL}/TcPlayer/libs/flv.min.1.6.3.js`,
    `${LIVE_RESOURCE_URL}/TcPlayer/tcplayer.v4.7.0.min.js`,
  ],
});

简单封装推流方法

typescript 复制代码
// LocalLivePusher.ts
interface Props {
  id?: string;
  videoQuality?: string;
  audioQuality?: string;
  fps?: number;
}

const codeDescMap = {
  '0': '与服务器断开连接',
  '1': '正在连接服务器',
  '2': '连接服务器成功',
  '3': '重连服务器中',
  '-1': 'WebRTC 接口调用失败',
  '-2': '请求服务器推流接口返回报错',
  '-1001': '打开摄像头失败',
  '-1002': '打开麦克风失败',
  '-1003': '打开屏幕分享失败',
  '-1004': '打开本地媒体文件失败',
  '-1005': '摄像头被中断(设备被拔出或者权限被用户取消)',
  '-1006': '麦克风被中断(设备被拔出或者权限被用户取消)',
  '-1007': '屏幕分享被中断(chrome浏览器点击自带的停止共享按钮)',
};

type CodeDescMap = typeof codeDescMap;

export interface DeviceInfoItem {
  deviceId: string;
  deviceName: string;
  type: 'video' | 'audio';
}

export class LocalLivePusher {
  private carrier: any;

  constructor({ id, videoQuality, audioQuality, fps }: Props) {
	  // 生成推流实例
    const livePusher = new TXLivePusher();

		// 指定本地画面容器
    if (id) livePusher.setRenderView(id);
    // 设置视频质量
    livePusher.setVideoQuality(videoQuality || '720p');
    // 设置音频质量
    livePusher.setAudioQuality(audioQuality || 'standard');
    // 自定义设置帧率
    livePusher.setProperty('setVideoFPS', fps || 25);

    this.carrier = livePusher;
  }

	// 是否在推流中
  isPushing() {
    return this.carrier.isPushing();
  }

  // 获取本地可用设备(麦克风、摄像头、共享屏幕等)
  getDevices() {
    return new Promise((resolve) => {
      this.carrier
        .getDeviceManager()
        .getDevicesList()
        .then((devices: DeviceInfoItem[]) => {
          resolve(devices);
        })
        .catch(() => {
          resolve([]);
        });
    });
  }

	// 各状态监听
  observe() {
    this.carrier.setObserver({
      // warning message
      onWarning: (code: keyof CodeDescMap, msg: CodeDescMap[keyof CodeDescMap]) => {
        console.log('onWarning-msg: ', msg);
        console.log('onWarning: ', codeDescMap[code]);
      },
      // error message
      onError: (code: keyof CodeDescMap, msg: CodeDescMap[keyof CodeDescMap]) => {
        console.log('onError-msg: ', msg);
        console.log('onError: ', codeDescMap[code]);
      },
      // 推流连接状态回调通知
      onPushStatusUpdate: (code: keyof CodeDescMap, msg: CodeDescMap[keyof CodeDescMap]) => {
        console.log('onPushStatusUpdate-msg: ', msg);
        console.log('onPushStatusUpdate: ', codeDescMap[code]);
      },
    });
  }

  // 打开指定设备
  openDevice(list: ('audio' | 'video' | 'screen')[]): Promise<boolean> {
    const promiseList: any[] = [];
    if (list.includes('audio')) promiseList.push(this.carrier.startMicrophone());
    if (list.includes('video')) promiseList.push(this.carrier.startCamera());
    if (list.includes('screen')) promiseList.push(this.carrier.startScreenCapture(true));

    return new Promise((resolve) => {
      Promise.all(promiseList)
        .then(() => {
          resolve(true);
        })
        .catch((e) => {
          console.log('err', e);
          resolve(false);
        });
    });
  }

  // 关闭所有设备
  closeDevice() {
    this.carrier.stopCamera();
    this.carrier.stopMicrophone();
    this.carrier.stopScreenCapture();
  }

  // 开始推流
  startPush(pushUrl: string) {
    this.carrier.startPush(pushUrl);
  }

  // 停止推流
  stopPush() {
    this.carrier.stopPush();
  }
}

推流

tsx 复制代码
import { useEffect, useRef, useState } from 'react';

import LocalLivePusher from './LocalLivePusher.ts';

function LivePush() {
  const livePusherRef = useRef(null);

  const [isLiving, setIsLiving] = useState(false);
  const checkRef = useRef(null);

  // 获取推流状态(推流状态不等于直播状态,前端检查并不靠谱,建议从后端获取直播状态)
  function checkLive() {
    setIsLiving(livePusherRef.current.isPushing());

    checkRef.current = setInterval(() => {
      setIsLiving(livePusherRef.current.isPushing());
    }, 500);
  }

  function initLivePusher() {
    if (livePusherRef.current) return;
    
    const tempPusher = new LocalLivePusher({});
    livePusherRef.current = tempPusher;
  }

  //开始直播
  function startLive() {
    if (!livePusherRef.current) return;

    livePusherRef.current.openDevice(['screen']); // 共享当前屏幕,也可以是'audio'、'video'
    livePusherRef.current.startPush('webrtc://domain/AppName/StreamName?txSecret=xxx&txTime=xxx'); // 推流地址,需要后端返回

    checkLive();
  }

  //停止直播
  async function stopLive() {
    if (!livePusherRef.current) return;
    
    livePusherRef.current.stopPush();
    livePusherRef.current.closeDevice();
  }

  useEffect(() => {
    initLivePusher();
    return () => {
      if (!checkRef.current) return;
      clearInterval(checkRef.current);
      checkRef.current = null;
    };
  }, []);

  return (
    <div>
      <div>
        {!isLiving ? (
          <button type='primary' onClick={startLive}>
            开始直播
          </button>
        ) : (
          <button type='primary' onClick={stopLive}>
            停止直播
          </button>
        )}
      </div>
    </div>
  );
}

export default LivePush;

拉流

tsx 复制代码
import { useLayoutEffect, useRef } from 'react';

function LivePlay() {
  const playRef = useRef<any>();

  //初始化播放器实例并进行播放
  function initPlayRefAndPlay() {
    if (!playRef.current) {
      playRef.current = TCPlayer('playerMiniBox', {
        autoplay: true,
      });
    }
    playRef.current.src(playUrl); // playUrl需要从后端获取
  }
  
  //销毁播放器实例
  function destroyPlayer() {
    if (!playRef.current) return;
    playRef.current.dispose();
    playRef.current = null;
  }

  useLayoutEffect(() => {
    initPlayRefAndPlay();
    return destroyPlayer;
  }, []);

  return (
    <div>
      <h1>直播画面</h1>
      <video id='playerMiniBox' width='480' height='270' preload='auto' playsInline />
    </div>
  );
}

export default LivePlay;

常见问题

以上是一个简单的推流、拉流的实践实例。在实际开发中肯定会碰到许许多多的问题,下面为我踩过的一些坑做个总结:

  1. 推流失败怎么办?

    推流前首先需要确定浏览器版本是否支持WebRTC,也可以用推流SDK提供的checkSupport() 方法检查一下。其次打开设备需要授权,届时浏览器右上角会弹出授权提醒,需要手动点击同意。

  2. 播放失败怎么办?

    多数播放失败其实是因为后端与腾讯云交互有问题导致的,如果想确定原因,可以拿后端返回的播放地址去腾讯官方demo中验证一下。

相关推荐
Nan_Shu_61414 分钟前
学习: Threejs (2)
前端·javascript·学习
G_G#22 分钟前
纯前端js插件实现同一浏览器控制只允许打开一个标签,处理session变更问题
前端·javascript·浏览器标签页通信·只允许一个标签页
@大迁世界38 分钟前
TypeScript 的本质并非类型,而是信任
开发语言·前端·javascript·typescript·ecmascript
GIS之路1 小时前
GDAL 实现矢量裁剪
前端·python·信息可视化
是一个Bug1 小时前
后端开发者视角的前端开发面试题清单(50道)
前端
Amumu121381 小时前
React面向组件编程
开发语言·前端·javascript
持续升级打怪中1 小时前
Vue3 中虚拟滚动与分页加载的实现原理与实践
前端·性能优化
GIS之路1 小时前
GDAL 实现矢量合并
前端
hxjhnct1 小时前
React useContext的缺陷
前端·react.js·前端框架
冰暮流星1 小时前
javascript逻辑运算符
开发语言·javascript·ecmascript