接入白板

本文介绍如何快速集成网易云信白板SDK,并通过aliyun-rtc-sdk发布白板流。

开通白板服务

目前您可以通过两种方式开通网易云信白板服务:

  1. 网易云信官网自行开通互动白板服务。

  2. 如果您的阿里云ARTC消费可能超过5万/月,也可以通过工单或邮件申请阿里云侧代为采购网易云信互动白板服务,关于白板的费用是客户直接付给阿里云。关于如何提交工单,请参见联系我们

集成白板SDK

引入 SDK 文件

下载sdk zip包,解压后将g2文件夹中的WhiteBoardSDK.jsToolCollection.jspptRenderer.js复制至您的工程静态文件夹下(一般是public文件夹)或者发布至您的CDN上,然后在入口HTML文件中通过script引入这三个JS文件。

获取互动白板AppKey与AppSecret

  1. 网易云信控制台首页的应用管理栏中找到创建的应用,单击应用名称

  2. 应用配置导航栏中,单击AppKey管理页签。

  3. 查看并记录该应用的AppKeyAppSecret

关于互动白板,更多配置请参见互动白板新手接入指南

如果您是由阿里云侧代为采购的,阿里云侧采购后会把对应的AppKey和AppSecret同步给您。

开发getAuthInfo接口

请将白板SDK的appkeyappSecret添加至您的服务端配置参数中,开发getAuthInfo接口,对appSecretcurTime等数据做SHA-1加密,返回相关数据给前端使用。

重要

本地例子是在Web页面中做加密,仅为本地跑通示例的做法,线上环境请勿泄露 appSecret。

初始化

首先,您需要在您的HTML代码中,增加一个DIV元素作为白板容器。

<div id="whiteboard"></div>

然后,创建白板SDK实例,通过joinRoom接口加入房间。

const appKey = '应用appKey'; // 您可以在网易云信控制台的应用下AppKey管理中获取
const appSecret = '应用appSecret';
const nickname = '昵称';
const uid = 123123;  //正整数, 应该小于Number.MAX_SAFE_INTEGER,同一uid多处登录会被互踢。如果需要多端同步,可以设置通过两个不同的 uid 登录。

/**
* 该函数用于返回互动白板应用需要的auth信息。
* 在需要时,互动白板sdk会调用该函数,该函数通过promise将auth交给sdk
*
* 下面代码仅为demo,在实际开发时,请不要将appSecret保存在客户端,这可能会导致appSecret被窃取。实际开发时,可以使用getAuthInfo向服务器请求Auth消息,最后在promise中将auth信息返回给sdk。
*/
function getAuthInfo() {
  const Nonce = 'xxxx';   //任意长度小于128位的随机字符串
  const curTime = Math.round((Date.now() / 1000)); //当前UTC时间戳,从1970年1月1日0点0分0秒开始到现在的秒数
  const checksum = sha1(appSecret + Nonce + curTime);
  return Promise.resolve({
    nonce: Nonce,
    checksum: checksum,
    curTime: curTime,
  });
}

const sdk = WhiteBoardSDK.getInstance({
  appKey: appKey,
  nickname: nickname,     //非必须
  uid: uid,
  container: document.getElementById('whiteboard'),
  platform: 'web',
  record: false,   //是否开启录制
  getAuthInfo: getAuthInfo,
});

// channel任意字符串。不同端需要进入相同的channel才能够互通
const channel = '821937123'
sdk.joinRoom({
  channel: channel,
  createRoom: true
})
.then((drawPlugin) => {
  // 允许编辑
  drawPlugin.enableDraw(true)
  // 设置画笔颜色
  drawPlugin.setColor('rgb(243,0,0)')

  // 初始化工具栏
  const toolCollection = ToolCollection.getInstance({
    /**
    * 工具栏容器。应该和白板容器一致
    *
    * 注意工具栏内子元素位置为绝对定位。因此,工具栏外的容器应该设置定位为relative, absolute, 或者fixed。
    * 这样,工具栏才能够正确的显示在容器内部
    */
    container: document.getElementById('whiteboard'),
    handler: drawPlugin,
    options: {
      platform: 'web',
    }
  });
  
  // 显示工具栏
  toolCollection.show();
});

加载文档

目前白板SDK支持加载、展示ppt, pptx, doc, docx, pdf等文件,你可以通过左侧边栏中的文件上传控件打开,打开文件资源库弹窗,在弹窗中管理文件列表。

文件列表持久化

白板SDK会将您的文件列表存放在浏览器LocalStorage中,您关闭页面后再次打开仍能展示之前的文件列表。但这个方案不能解决文件列表持久化问题,如果您需要在不同设备、不同浏览器上仍能展示之前的文件列表,那么您需要开发相关的服务端接口,并通过监听docAdddocDelete等白板SDK事件,将列表数据保存至服务端,初始化时调用白板SDK的setDefaultDocList等接口更新文件列表。更多事件、接口说明,请参见白板SDK文档

注意事项

目前白板SDK仅支持将白板canvas上的内容转成视频流,如果你在画布中添加视频、音频等文件,这部分内容无法通过白板推流共享。

发布白板流

白板SDK加载成功并加入房间后,可以通过getStream接口获取视频流,再通过自定义输入功能发布。

// 白板SDK实例 joinRoom 成功后返回 drawPlugin 对象
const mediaStream = drawPlugin.getStream({
  width: 720,
  frameRate: 15,
});
const videoTrack = mediaStream.getVideoTracks()[0];
if (videoTrack) {
  try {
    await aliRtcEngine.startScreenShare({
      videoTrack, // 传入自定义的视频轨
    });
    console.log('推白板成功');
  } catch (error) {
    console.log('推白板失败');
  }
} else {
  console.log('无白板 videoTrack');
}

集成示例

前提

白板集成示例是基于文档快速开始中的快速体验示例扩展得到,请先跑通该示例,再运行白板的集成示例。

第一步:创建文件

demo文件夹中新建whiteboard.htmlwhiteboard.js两个文件,并将白板SDK的WhiteBoardSDK.jsToolCollection.jspptRenderer.js三个JS文件也放入目录中,目录结构如下方所示:

- demo
  - quick.html
  - quick.js
  - whiteboard.html
  - whiteboard.js
  - WhiteBoardSDK.js
  - pptRenderer.js
  - ToolCollection.js

第二步:编辑whiteboard.html

请把下方代码复制粘贴进whiteboard.html并保存。

展开查看whiteboard.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>aliyun-rtc-sdk whiteboard</title>
    <link rel="stylesheet" href="https://g.alicdn.com/code/lib/bootstrap/5.3.0/css/bootstrap.min.css" />
    <style>
      .video {
        display: inline-block;
        width: 320px;
        height: 180px;
        margin-right: 8px;
        margin-bottom: 8px;
        background-color: black;
      }
      .whiteboard {
        position: relative;
        width: 100%;
        height: 500px;
      }
    </style>
  </head>
  <body class="container">
    <h1 class="mt-2">aliyun-rtc-sdk 集成白板</h1>

    <div class="toast-container position-fixed top-0 end-0 p-3">
      <div id="loginToast" class="toast" role="alert" aria-live="assertive" aria-atomic="true">
        <div class="toast-header">
          <strong class="me-auto">登录消息</strong>
          <button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
        </div>
        <div class="toast-body" id="loginToastBody"></div>
      </div>

      <div id="onlineToast" class="toast" role="alert" aria-live="assertive" aria-atomic="true">
        <div class="toast-header">
          <strong class="me-auto">用户上线</strong>
          <button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
        </div>
        <div class="toast-body" id="onlineToastBody"></div>
      </div>

      <div id="offlineToast" class="toast" role="alert" aria-live="assertive" aria-atomic="true">
        <div class="toast-header">
          <strong class="me-auto">用户上线</strong>
          <button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
        </div>
        <div class="toast-body" id="offlineToastBody"></div>
      </div>

      <div id="screenToast" class="toast" role="alert" aria-live="assertive" aria-atomic="true">
        <div class="toast-header">
          <strong class="me-auto">屏幕/白板消息</strong>
          <button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
        </div>
        <div class="toast-body" id="screenToastBody"></div>
      </div>
    </div>

    <div class="row mt-3">
      <div class="col-8">
        <form id="loginForm">
          <div class="form-group mb-2">
            <label for="channelId" class="form-label">频道号</label>
            <input class="form-control form-control-sm" id="channelId" />
          </div>
          <div class="form-group mb-2">
            <label for="userId" class="form-label">用户ID</label>
            <input class="form-control form-control-sm" id="userId" />
          </div>
          <div class="mb-2">
            <button id="joinBtn" type="submit" class="btn btn-primary btn-sm">加入频道</button>
            <button id="leaveBtn" type="button" class="btn btn-secondary btn-sm" disabled>离开频道</button>
            <button id="screenBtn" type="button" class="btn btn-secondary btn-sm" disabled>推屏幕</button>
            <button id="boardBtn" type="button" class="btn btn-secondary btn-sm" disabled>推白板</button>
          </div>
        </form>
        <div id="whiteboard" class="whiteboard"></div>
      </div>
      <div class="col-4">
        <div>
          <h5>本地预览</h5>
          <video
            id="localPreviewer"
            muted
            class="video"
          ></video>
        </div>
        <div>
          <h5>远端用户</h5>
          <div id="remoteVideoContainer"></div>
        </div>
      </div>
    </div>

    <script src="https://g.alicdn.com/code/lib/jquery/3.7.1/jquery.min.js"></script>
    <script src="https://g.alicdn.com/code/lib/bootstrap/5.3.0/js/bootstrap.min.js"></script>
    <script src="https://g.alicdn.com/apsara-media-box/imp-web-rtc/6.11.1/aliyun-rtc-sdk.js"></script>
    <script src="./WhiteBoardSDK.js"></script>
    <script src="./pptRenderer.js"></script>
    <script src="./ToolCollection.js"></script>
    <script src="./whiteboard.js"></script>
  </body>
</html>

第三步:编辑whiteboard.js

请把下方代码复制粘贴进whiteboard.js ,并将aliyun-rtc-sdk应用IDAppKey,以及白板SDKappKeyappsecret粘贴进代码指定变量中保存。

展开查看whiteboard.js

function hex(buffer) {
  const hexCodes = [];
  const view = new DataView(buffer);
  for (let i = 0; i < view.byteLength; i += 4) {
    const value = view.getUint32(i);
    const stringValue = value.toString(16);
    const padding = '00000000';
    const paddedValue = (padding + stringValue).slice(-padding.length);
    hexCodes.push(paddedValue);
  }
  return hexCodes.join('');
}
async function generateToken(appId, appKey, channelId, userId, timestamp) {
  const encoder = new TextEncoder();
  const data = encoder.encode(`${appId}${appKey}${channelId}${userId}${timestamp}`);

  const hash = await crypto.subtle.digest('SHA-256', data);
  return hex(hash);
}

function showToast(baseId, message) {
  $(`#${baseId}Body`).text(message);
  const toast = new bootstrap.Toast($(`#${baseId}`));

  toast.show();
}

// 填入您的应用ID 和 AppKey
const appId = '';
const appKey = '';
AliRtcEngine.setLogLevel(0);
let aliRtcEngine;
const remoteVideoElMap = {};
const remoteVideoContainer = document.querySelector('#remoteVideoContainer');

function removeRemoteVideo(userId, type = 'camera') {
  const vid = `${type}_${userId}`;
  const el = remoteVideoElMap[vid];
  if (el) {
    aliRtcEngine.setRemoteViewConfig(null, userId, type === 'camera' ? 1: 2);
    el.pause();
    remoteVideoContainer.removeChild(el);
    delete remoteVideoElMap[vid];
  }
}

function listenEvents() {
  if (!aliRtcEngine) {
    return;
  }
  // 监听远端用户上线
  aliRtcEngine.on('remoteUserOnLineNotify', (userId, elapsed) => {
    console.log(`用户 ${userId} 加入频道,耗时 ${elapsed} 秒`);
    // 这里处理您的业务逻辑,如展示这个用户的模块
    showToast('onlineToast', `用户 ${userId} 上线`);
  });

  // 监听远端用户下线
  aliRtcEngine.on('remoteUserOffLineNotify', (userId, reason) => {
    // reason 为原因码,具体含义请查看 API 文档
    console.log(`用户 ${userId} 离开频道,原因码: ${reason}`);
    // 这里处理您的业务逻辑,如销毁这个用户的模块
    showToast('offlineToast', `用户 ${userId} 下线`);
    removeRemoteVideo(userId, 'camera');
    removeRemoteVideo(userId, 'screen');
  });

  aliRtcEngine.on('bye', code => {
    // code 为原因码,具体含义请查看 API 文档
    console.log(`bye, code=${code}`);
    // 这里做您的处理业务,如退出通话页面等
    showToast('loginToast', `您已离开频道,原因码: ${code}`);
  });

  aliRtcEngine.on('videoSubscribeStateChanged', (userId, oldState, newState, interval, channelId) => {
    // oldState、newState 类型均为AliRtcSubscribeState,值包含 0(初始化)、1(未订阅)、2(订阅中)、3(已订阅)
    // interval 为两个状态之间的变化时间间隔,单位毫秒
    console.log(`频道 ${channelId} 远端用户 ${userId} 订阅状态由 ${oldState} 变为 ${newState}`);
    const vid = `camera_${userId}`;
    // 处理示例
    if (newState === 3) {
      const video = document.createElement('video');
      video.autoplay = true;
      // video.setAttribute(
      //   'style',
      //   'display: inline-block;width: 320px;height: 180px;background-color: black;margin-right: 8px;margin-bottom: 8px;'
      // );
      video.className = 'video';
      remoteVideoElMap[vid] = video;
      remoteVideoContainer.appendChild(video);
      // 第一个参数传入 HTMLVideoElement
      // 第二个参数传入远端用户 ID
      // 第三个参数支持传入 1 (预览相机流)、2(预览屏幕共享流)
      aliRtcEngine.setRemoteViewConfig(video, userId, 1);
    } else if (newState === 1) {
      removeRemoteVideo(userId, 'camera');
    }
  });

  aliRtcEngine.on('screenShareSubscribeStateChanged', (userId, oldState, newState, interval, channelId) => {
    // oldState、newState 类型均为AliRtcSubscribeState,值包含 0(初始化)、1(未订阅)、2(订阅中)、3(已订阅)
    // interval 为两个状态之间的变化时间间隔,单位毫秒
    console.log(`频道 ${channelId} 远端用户 ${userId} 屏幕流的订阅状态由 ${oldState} 变为 ${newState}`);
    const vid = `screen_${userId}`;
    // 处理示例    
    if (newState === 3) {
      const video = document.createElement('video');
      video.autoplay = true;
      video.className = 'video';
      remoteVideoElMap[vid] = video;
      remoteVideoContainer.appendChild(video);
      // 第一个参数传入 HTMLVideoElement
      // 第二个参数传入远端用户 ID
      // 第三个参数支持传入 1 (预览相机流)、2(预览屏幕共享流)
      aliRtcEngine.setRemoteViewConfig(video, userId, 2);
    } else if (newState === 1) {
      removeRemoteVideo(userId, 'screen');
    }
  });
  
  // 推屏幕流状态变化
  aliRtcEngine.on('screenSharePublishStateChanged', (oldState, newState, interval, channelId) => {
    // oldState、newState 类型均为AliRtcSubscribeState,值包含 0(初始化)、1(未发布)、2(发布中)、3(已发布)
    // interval 为两个状态之间的变化时间间隔,单位毫秒
    console.log(`频道 ${channelId} 本地屏幕流的推流状态由 ${oldState} 变为 ${newState}`);
    if (oldState === 3 && newState === 1) {
      showToast('screenToast', '已停推屏幕流');
    }
  });
}

$('#loginForm').submit(async e => {
  // 防止表单默认提交动作
  e.preventDefault();
  const channelId = $('#channelId').val();
  const userId = $('#userId').val();
  const timestamp = Math.floor(Date.now() / 1000) + 3600 * 3;

  if (!channelId || !userId) {
    showToast('loginToast', '数据不完整');
    return;
  }

  aliRtcEngine = AliRtcEngine.getInstance();
  listenEvents();

  try {
    const token = await generateToken(appId, appKey, channelId, userId, timestamp);
    // 设置频道模式,支持传入字符串 communication(通话模式)、interactive_live(互动模式)
    aliRtcEngine.setChannelProfile('communication');
    // 设置角色,互动模式时调用才生效
    // 支持传入字符串 interactive(互动角色,允许推拉流)、live(观众角色,仅允许拉流)
    // aliRtcEngine.setClientRole('interactive');
    // 加入频道,参数 token、nonce 等一般有服务端返回
    await aliRtcEngine.joinChannel(
      {
        channelId,
        userId,
        appId,
        token,
        timestamp,
      },
      userId
    );
    showToast('loginToast', '加入频道成功');
    $('#joinBtn').prop('disabled', true);
    $('#leaveBtn').prop('disabled', false);
    $('#boardBtn').prop('disabled', false);
    $('#screenBtn').prop('disabled', false);

    // 预览
    aliRtcEngine.setLocalViewConfig('localPreviewer', 1);
  } catch (error) {
    console.log('加入频道失败', error);
    showToast('loginToast', '加入频道失败');
  }
});

$('#leaveBtn').click(async () => {
  Object.keys(remoteVideoElMap).forEach(vid => {
    const arr = vid.split('_');
    removeRemoteVideo(arr[1], arr[0]);
  });
  // 停止本地预览
  await aliRtcEngine.stopPreview();
  // 离开频道
  await aliRtcEngine.leaveChannel();
  // 销毁实例
  aliRtcEngine.destroy();
  aliRtcEngine = undefined;
  $('#joinBtn').prop('disabled', false);
  $('#leaveBtn').prop('disabled', true);
  $('#boardBtn').prop('disabled', true);
  $('#screenBtn').prop('disabled', true);
  showToast('loginToast', '已离开频道');
});

// 这里填入您的网易云信白板的 AppKey 和 AppSecret
// 仅限本地开发体验,线上环境请勿露出 AppSecret
const boradAppKey = '';
const boradAppSecret = '';
const boradUid = Date.now(); // 易云信白板要求是数字uid
const boradNickname = boradUid.toString();
const boradChannel = '821937123';

async function sha1(data) {
  // 将字符串转换为ArrayBuffer
  const buffer = new TextEncoder().encode(data);
  
  // 使用Crypto API计算哈希值
  const digest = await crypto.subtle.digest('SHA-1', buffer);
  
  // 将ArrayBuffer转换为十六进制字符串
  const hashArray = Array.from(new Uint8Array(digest));
  const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
  
  return hashHex;
}

async function getAuthInfo() {
  const Nonce = 'xxxx';   //任意长度小于128位的随机字符串
  const curTime = Math.round((Date.now() / 1000)); //当前UTC时间戳,从1970年1月1日0点0分0秒开始到现在的秒数
  const checksum = await sha1(boradAppSecret + Nonce + curTime);
  return {
    nonce: Nonce,
    checksum: checksum,
    curTime: curTime
  };
}

// 创建白板实例
const boardIns = WhiteBoardSDK.getInstance({
  appKey: boradAppKey,
  nickname: boradNickname,     //非必须
  uid: boradUid,
  container: document.getElementById('whiteboard'),
  platform: 'web',
  record: false,   //是否开启录制
  getAuthInfo: getAuthInfo
});
let drawPluginIns;

// 登录白板房间
boardIns.joinRoom({
  channel: boradChannel,
  createRoom: true
})
.then((drawPlugin) => {
  drawPluginIns = drawPlugin;
  // 允许编辑
  drawPlugin.enableDraw(true);
  // 设置画笔颜色
  drawPlugin.setColor('rgb(243,0,0)');

  // 初始化工具栏
  const toolCollection = ToolCollection.getInstance({
      container: document.getElementById('whiteboard'),
      handler: drawPlugin,
      options: {
          platform: 'web'
      }
  });
  toolCollection.addOrSetTool({
    position: 'left',
    insertAfterTool: 'pan',
    item: {
      tool: 'uploadCenter',
      hint: '上传文档',
      supportPptToH5: true,
      supportDocToPic: true,
      supportUploadMedia: false, // 关闭上传多媒体文件
      supportTransMedia: false, // 关闭转码多媒体文件
    },
  });
  toolCollection.removeTool({ name: 'image' });
  
  // 显示工具栏
  toolCollection.show();

  // 监听文档事件
  toolCollection.on('docAdd', (newDocs, allDocs) => {
    console.log('add allDocs->', newDocs, allDocs);
    // 您可以在 docAdd 事件回调中将文档数据上传至您的服务端
    // 建议:服务端通过白板 channel 维度去储存
  });
  toolCollection.on('docDelete', (newDocs, allDocs) => {
    console.log('delete allDocs->', newDocs, allDocs);
    // 您可以在 docDelete 事件回调中将文档数据上传至您的服务端
    // 建议:服务端通过白板 channel 维度去储存
  });

  // 初始化后从服务端中获取该 channel 的文件列表,并更新至白板SDK中
  // fetch('/docList', (list) => {
  //   toolCollection.setDefaultDocList(list);
  // });
});

$('#screenBtn').click(async() => {
  if (!aliRtcEngine) {
    showToast('screenToast', 'sdk 未准备好');
    return;
  }
  try {
    await aliRtcEngine.startScreenShare();
    showToast('screenToast', '推屏幕成功');
  } catch (error) {
    showToast('screenToast', '推屏幕失败');
  }
});

$('#boardBtn').click(async() => {
  if (!aliRtcEngine || !drawPluginIns) {
    showToast('screenToast', 'sdk 未准备好');
    return;
  }
  const mediaStream = drawPluginIns.getStream({
    width: 720,
    frameRate: 15,
  });
  const videoTrack = mediaStream.getVideoTracks()[0];
  if (videoTrack) {
    try {
      await aliRtcEngine.startScreenShare({
        videoTrack,
      });
      showToast('screenToast', '推白板成功');
    } catch (error) {
      showToast('screenToast', '推白板失败');
    }
  } else {
    showToast('screenToast', '无白板 videoTrack');
  }
});

第四步:运行体验

  1. 在终端中进入demo文件夹,然后执行http-server -p 8080,启动一个 HTTP 服务。

  2. 浏览器中新建标签页,访问localhost:8080/quick.html作为普通用户,在界面上填入频道ID用户ID ,单击加入频道

  3. 浏览器中再新建一个标签页,访问localhost:8080/whiteboard.html作为白板,在界面上填入与上一步相同的频道 ID另一个用户ID,单击加入频道

  4. 这时界面上将自动订阅另一个用户的媒体流,在whiteboard.html单击按键推白板后,quick.html将会自动订阅到whiteboard.html上的白板内容。