Browse Source

demo: 添加 vue demo

poplark 4 years ago
parent
commit
0cb0ebe616
38 changed files with 11269 additions and 0 deletions
  1. 1 0
      examples/vue/.eslintignore
  2. 26 0
      examples/vue/.gitignore
  3. 30 0
      examples/vue/README.md
  4. 5 0
      examples/vue/babel.config.js
  5. 50 0
      examples/vue/package.json
  6. BIN
      examples/vue/public/favicon.ico
  7. 17 0
      examples/vue/public/index.html
  8. 19 0
      examples/vue/src/App.vue
  9. BIN
      examples/vue/src/assets/fonts/font_2654142_3yusi3ow1eg.ttf
  10. BIN
      examples/vue/src/assets/fonts/font_2654142_3yusi3ow1eg.woff
  11. BIN
      examples/vue/src/assets/fonts/font_2654142_3yusi3ow1eg.woff2
  12. 102 0
      examples/vue/src/assets/fonts/iconfont.css
  13. BIN
      examples/vue/src/assets/logo.png
  14. 71 0
      examples/vue/src/components/Blink.vue
  15. 412 0
      examples/vue/src/components/ClassRoom.vue
  16. 142 0
      examples/vue/src/components/Login.vue
  17. 44 0
      examples/vue/src/components/ResumePlay.vue
  18. 278 0
      examples/vue/src/components/Settings.vue
  19. 86 0
      examples/vue/src/components/Status.vue
  20. 201 0
      examples/vue/src/components/Stream.vue
  21. 169 0
      examples/vue/src/components/StreamSettings.vue
  22. 158 0
      examples/vue/src/components/StreamStats.vue
  23. 2 0
      examples/vue/src/config/app.js
  24. 1 0
      examples/vue/src/config/index.js
  25. 36 0
      examples/vue/src/config/profiles.js
  26. 27 0
      examples/vue/src/index.less
  27. 23 0
      examples/vue/src/main.js
  28. 7 0
      examples/vue/src/routes/index.js
  29. 500 0
      examples/vue/src/rtc.js
  30. 90 0
      examples/vue/src/store/index.js
  31. 13 0
      examples/vue/src/store/sessionStore.js
  32. 5 0
      examples/vue/src/theme/colors.less
  33. 0 0
      examples/vue/src/theme/size.less
  34. 5 0
      examples/vue/src/utils/browser.js
  35. 12 0
      examples/vue/src/utils/image.js
  36. 13 0
      examples/vue/src/utils/logger.js
  37. 11 0
      examples/vue/vue.config.js
  38. 8713 0
      examples/vue/yarn.lock

+ 1 - 0
examples/vue/.eslintignore

@@ -0,0 +1 @@
+lib

+ 26 - 0
examples/vue/.gitignore

@@ -0,0 +1,26 @@
+.DS_Store
+node_modules
+/dist
+
+
+# local env files
+.env.local
+.env.*.local
+
+# Log files
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+
+# Editor directories and files
+.idea
+.vscode
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+
+# config - key
+# src/config/app.js

+ 30 - 0
examples/vue/README.md

@@ -0,0 +1,30 @@
+# WebRTC-web-demo
+
+## 创建 app 的配置文件
+
+在 `src/config` 目录下创建 `app.js` 文件,并在文件中添加如下代码:
+
+```js
+export const appId = 'xxxx-xxxxxxxx';
+export const appKey = 'yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy'; // 请替换为正确的 appId 及 appKey
+```
+
+## 安装依赖包
+```
+yarn install
+```
+
+## 本地开发调试
+```
+yarn serve
+```
+
+## 构建打包
+```
+yarn build
+```
+
+### Lints
+```
+yarn lint
+```

+ 5 - 0
examples/vue/babel.config.js

@@ -0,0 +1,5 @@
+module.exports = {
+  presets: [
+    '@vue/cli-plugin-babel/preset'
+  ]
+}

+ 50 - 0
examples/vue/package.json

@@ -0,0 +1,50 @@
+{
+  "name": "web-demo",
+  "version": "0.1.0",
+  "private": true,
+  "scripts": {
+    "start": "vue-cli-service serve",
+    "build": "vue-cli-service build",
+    "lint": "vue-cli-service lint"
+  },
+  "dependencies": {
+    "@urtc/sdk-web": "^2.0.0-beta.9",
+    "core-js": "^3.6.5",
+    "element-ui": "^2.15.1",
+    "less-loader": "^5.0.0",
+    "normalize.css": "^8.0.1",
+    "vconsole": "^3.9.5",
+    "vue": "^2.6.11",
+    "vue-router": "^3.5.2",
+    "vuex": "^3.6.2"
+  },
+  "devDependencies": {
+    "@vue/cli-plugin-babel": "~4.5.0",
+    "@vue/cli-plugin-eslint": "~4.5.0",
+    "@vue/cli-service": "~4.5.0",
+    "babel-eslint": "^10.1.0",
+    "eslint": "^6.7.2",
+    "eslint-plugin-vue": "^6.2.2",
+    "less": "^4.1.1",
+    "vue-template-compiler": "^2.6.11"
+  },
+  "eslintConfig": {
+    "root": true,
+    "env": {
+      "node": true
+    },
+    "extends": [
+      "plugin:vue/essential",
+      "eslint:recommended"
+    ],
+    "parserOptions": {
+      "parser": "babel-eslint"
+    },
+    "rules": {}
+  },
+  "browserslist": [
+    "> 1%",
+    "last 2 versions",
+    "not dead"
+  ]
+}

BIN
examples/vue/public/favicon.ico


+ 17 - 0
examples/vue/public/index.html

@@ -0,0 +1,17 @@
+<!DOCTYPE html>
+<html lang="">
+  <head>
+    <meta charset="utf-8">
+    <meta http-equiv="X-UA-Compatible" content="IE=edge">
+    <meta name="viewport" content="width=device-width,initial-scale=1.0,user-scalable=no">
+    <link rel="icon" href="<%= BASE_URL %>favicon.ico">
+    <title><%= htmlWebpackPlugin.options.title %></title>
+  </head>
+  <body>
+    <noscript>
+      <strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
+    </noscript>
+    <div id="app"></div>
+    <!-- built files will be auto injected -->
+  </body>
+</html>

+ 19 - 0
examples/vue/src/App.vue

@@ -0,0 +1,19 @@
+<template>
+  <div id="app">
+    <router-view></router-view>
+  </div>
+</template>
+
+<script>
+
+export default {
+  name: 'App',
+}
+</script>
+
+<style>
+#app {
+  display: flex;
+  width: 100%;
+}
+</style>

BIN
examples/vue/src/assets/fonts/font_2654142_3yusi3ow1eg.ttf


BIN
examples/vue/src/assets/fonts/font_2654142_3yusi3ow1eg.woff


BIN
examples/vue/src/assets/fonts/font_2654142_3yusi3ow1eg.woff2


+ 102 - 0
examples/vue/src/assets/fonts/iconfont.css

@@ -0,0 +1,102 @@
+@font-face {
+  font-family: "iconfont"; /* Project id 2654142 */
+  src: url('./font_2654142_3yusi3ow1eg.woff2?t=1625553221109') format('woff2'),
+       url('./font_2654142_3yusi3ow1eg.woff?t=1625553221109') format('woff'),
+       url('./font_2654142_3yusi3ow1eg.ttf?t=1625553221109') format('truetype');
+}
+
+.iconfont {
+  font-family: "iconfont" !important;
+  font-size: 16px;
+  font-style: normal;
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+}
+
+.icon-phone-signal-full:before {
+  content: "\e957";
+}
+
+.icon-ic_signal_wifi_statusbar_not_connected_xpx:before {
+  content: "\e6b1";
+}
+
+.icon-stopscreenshare:before {
+  content: "\ed38";
+}
+
+.icon-screenpingmu:before {
+  content: "\e64b";
+}
+
+.icon-switch-camera-:before {
+  content: "\e71c";
+}
+
+.icon-share-screen:before {
+  content: "\e8e2";
+}
+
+.icon-live_fill:before {
+  content: "\e708";
+}
+
+.icon-live:before {
+  content: "\e709";
+}
+
+.icon-share:before {
+  content: "\e729";
+}
+
+.icon-shengyinkai:before {
+  content: "\eca7";
+}
+
+.icon-shengyinjingyin:before {
+  content: "\eca9";
+}
+
+.icon-fuzhi:before {
+  content: "\e600";
+}
+
+.icon-shexiangtou_guanbi:before {
+  content: "\eca5";
+}
+
+.icon-shexiangtou:before {
+  content: "\eca6";
+}
+
+.icon--wifi-:before {
+  content: "\e61d";
+}
+
+.icon--wifi-1:before {
+  content: "\e61e";
+}
+
+.icon--wifi-2:before {
+  content: "\e61f";
+}
+
+.icon--wifi-3:before {
+  content: "\e620";
+}
+
+.icon--wifi-4:before {
+  content: "\e621";
+}
+
+.icon--wifi-5:before {
+  content: "\e622";
+}
+
+.icon-maikefeng-audio:before {
+  content: "\e625";
+}
+
+.icon-audiostatic:before {
+  content: "\eb70";
+}

BIN
examples/vue/src/assets/logo.png


+ 71 - 0
examples/vue/src/components/Blink.vue

@@ -0,0 +1,71 @@
+<template>
+  <div class="blink">
+    <slot></slot>
+    <transition name="el-fade-in-linear">
+      <div v-show="show" class="transition-box"></div>
+    </transition>
+  </div>
+</template>
+
+<script>
+const interval = 1000
+export default {
+  props: ['blink'],
+  data: () => ({
+    show: false,
+    timer: 0,
+  }),
+  mounted() {
+    if (this.blink) {
+      this.start()
+    }
+  },
+  beforeDestroy() {
+    this.stop()
+  },
+  watch: {
+    blink(newV) {
+      this.stop()
+      if (newV) {
+        this.start()
+      } else {
+        this.show = false
+      }
+    }
+  },
+  methods: {
+    start() {
+      this.show = !this.show
+      this.timer = setTimeout(() => {
+        this.start()
+      }, interval)
+    },
+    stop() {
+      if (this.timer) {
+        clearTimeout(this.timer)
+        this.timer = 0
+      }
+    }
+  }
+}
+</script>
+
+<style lang="less">
+.blink {
+  position: relative;
+
+  .transition-box {
+    position: absolute;
+    top: -6px;
+    right: 0px;
+    width: 12px;
+    height: 12px;
+    border-radius: 6px;
+    border: 1px solid #fff;
+    background-color: #ff4069;
+    text-align: center;
+    color: #fff;
+    box-sizing: border-box;
+  }
+}
+</style>

+ 412 - 0
examples/vue/src/components/ClassRoom.vue

@@ -0,0 +1,412 @@
+<template>
+  <div class="rtc-container"
+    v-loading="isIniting"
+    element-loading-text="正在初始化..."
+    element-loading-spinner="el-icon-loading"
+    element-loading-background="rgba(0, 0, 0, 0.8)"
+    >
+    <el-container>
+      <el-header class="status-bar">
+        <div class="room-id">频道号:{{channel}}</div>
+        <status/>
+        <resume-play :open="rtc.needResume"/>
+      </el-header>
+      <el-container class="main-container">
+        <el-main v-if="!isMobile" class="main-panel">
+          <el-tooltip class="item" effect="dark" content="视频显示区,默认显示本地流或第一条远端流,点选右侧列表中的流,可切换显示该流。" placement="top-start">
+            <i class="el-icon-info"/>
+          </el-tooltip>
+          <video v-if="chosenStream" muted autoplay ref="display" id="big-video-display"></video>
+        </el-main>
+        <el-aside :class="isMobile?'streams-container mobile':'streams-container'" :width="isMobile?'100%':'300px'">
+          <div v-if="rtc.localStream" class="local-streams">
+            <stream :stream="rtc.localStream" v-on:choose-stream="onChooseStream"></stream>
+          </div>
+          <div class="remote-streams">
+            <stream :stream="stream" :key="stream.id" v-for="stream in rtc.remoteStreams" v-on:choose-stream="onChooseStream" v-on:before-reload-chosen-stream="onBeforeReloadChosenStream" v-on:reload-chosen-stream="onReloadChosenStream"></stream>
+          </div>
+        </el-aside>
+      </el-container>
+      <el-footer v-if="!isMobile" class="menu-bar">
+        <el-button type="primary" @click="onLeave">离开房间</el-button>
+        <el-button type="success" :disabled="isOnPub" @click="onPub">{{rtc.localStream ? '下麦' : '上麦'}}</el-button>
+        <blink :blink="!!rtc.localScreenStream">
+          <el-button v-if="rtc.isSupportScreenShare" type="success" :disabled="isOnPubScreen" @click="onPubScreen">{{rtc.localScreenStream ? '停止屏幕共享' : '屏幕共享'}}</el-button>
+        </blink>
+      </el-footer>
+      <el-footer v-if="isMobile" class="menu-bar mobile" height='44px'>
+        <el-button type="primary" size="small" icon="el-icon-switch-button" circle @click="onLeave" title="离开房间"></el-button>
+        <el-button type="success" size="small" :icon="rtc.localStream?'el-icon-bottom':'el-icon-top'" :disabled="isOnPub" circle @click="onPub" :title="rtc.localStream?'下麦':'上麦'"></el-button>
+      </el-footer>
+    </el-container>
+  </div>
+</template>
+
+<script>
+import { store } from '../store'
+import { getRTCInstance, version } from '../rtc'
+import Stream from './Stream.vue'
+import Status from './Status.vue'
+import Blink from './Blink.vue'
+import ResumePlay from './ResumePlay.vue'
+import { isMobile } from '../utils/browser'
+import { log } from '../utils/logger'
+
+export default {
+  components: {
+    Stream,
+    Status,
+    Blink,
+    ResumePlay,
+  },
+  data() {
+    const rtc = getRTCInstance(this)
+    let isIniting = true
+    if (!rtc.isJoined) {
+      if (!store.state.settings.channel || !store.state.settings.username) {
+        this.$router.replace('/')
+        return
+      }
+      rtc.init()
+      rtc.join(store.state.settings.channel, store.state.settings.username)
+        .then(() => {
+          if (store.state.advanceSettings.roleType === 'speaker') {
+            // 演讲者自动上麦
+            rtc.publish()
+              .then(() => {
+                this.isIniting = false
+                const localStream = rtc.localStream
+                this.$nextTick(() => {
+                  if (localStream) {
+                    this.rtc.playLocalStream(localStream)
+                    if (!this.chosenStream) {
+                      this.chosenStream = localStream
+                    }
+                  }
+                })
+              })
+              .catch((err) => {
+                this.$notify.error({
+                  title: '发布失败',
+                  message: `${err}`
+                })
+              })
+          } else {
+            this.isIniting = false
+          }
+        })
+    } else {
+      if (store.state.advanceSettings.roleType === 'speaker') {
+        // 演讲者自动上麦
+        rtc.publish()
+          .then(() => {
+            this.isIniting = false
+            const localStream = rtc.localStream
+            this.$nextTick(() => {
+              if (localStream) {
+                this.rtc.playLocalStream(localStream)
+                if (!this.chosenStream) {
+                  this.chosenStream = localStream
+                }
+              }
+            })
+          })
+          .catch((err) => {
+            this.$notify.error({
+              title: '发布失败',
+              message: `${err}`
+            })
+          })
+      } else {
+        isIniting = false
+      }
+    }
+    return {
+      channel: store.state.settings.channel || '',
+      username: store.state.settings.username || '',
+      isIniting,
+      chosenStream: undefined,
+      rtc,
+      version,
+      isRecording: false,
+      isRelaying: false,
+      isMobile,
+      isOnPub: false,
+      isOnPubScreen: false,
+    }
+  },
+  mounted() {
+    if (this.rtc.localStream) {
+      this.chosenStream = this.rtc.localStream
+    } else if (this.rtc.remoteStreams.length > 0) {
+      this.chosenStream = this.rtc.remoteStreams[0]
+    }
+  },
+  watch: {
+    'rtc.localStream': function(newV, oldV) {
+      if (!this.chosenStream && newV) {
+        this.chosenStream = newV
+        return
+      }
+      if (this.chosenStream === oldV) {
+        if (newV) {
+          this.chosenStream = newV
+        } else if (this.rtc.remoteStreams.length > 0) {
+          this.chosenStream = this.rtc.remoteStreams[0]
+        } else {
+          this.chosenStream = null
+        }
+      }
+    },
+    'rtc.remoteStreams': function(newV) {
+      if (!this.chosenStream) {
+        if (newV.length > 0) {
+          this.chosenStream = this.rtc.remoteStreams[0]
+        }
+      } else {
+        if (this.chosenStream.isLocal) return
+        const stream = this.rtc.remoteStreams.find(item => item === this.chosenStream)
+        if (!stream) {
+          if (this.rtc.remoteStreams.length > 0) {
+            this.chosenStream = this.rtc.remoteStreams[0]
+          } else {
+            this.chosenStream = null
+          }
+        }
+      }
+    },
+    chosenStream: function () {
+      this.$nextTick(() => {
+        const display = this.$refs['display']
+        if (display && this.chosenStream) {
+          display.srcObject = this.chosenStream.isSubscribingSmall ? this.chosenStream.small.mediaStream : this.chosenStream.mediaStream
+        }
+      })
+    }
+  },
+  methods: {
+    onPub() {
+      this.isOnPub = true
+      if (this.rtc.localStream) {
+        this.rtc
+          .unpublish()
+          .catch((err) => {
+            this.$notify.error({
+              title: '取消发布失败',
+              message: `${err}`
+            })
+          })
+          .finally(() => {
+            this.isOnPub = false
+          })
+      } else {
+        this.rtc
+          .publish()
+          .then(() => {
+            const localStream = this.rtc.localStream
+            if (localStream) {
+              this.$nextTick(() => {
+                this.rtc.playLocalStream(localStream)
+              })
+            }
+          })
+          .catch((err) => {
+            this.$notify.error({
+              title: '发布失败',
+              message: `${err}`
+            })
+          })
+          .finally(() => {
+            this.isOnPub = false
+          })
+      }
+    },
+    onPubScreen() {
+      this.isOnPubScreen = true
+      if (this.rtc.localScreenStream) {
+        this.rtc
+          .unpublishScreen()
+          .catch((err) => {
+            this.$notify.error({
+              title: '取消屏幕共享流失败',
+              message: `${err}`
+            })
+          })
+          .finally(() => {
+            this.isOnPubScreen = false
+          })
+      } else {
+        this.rtc
+          .publishScreen()
+          .catch((err) => {
+            if (err.code === '3005') {
+              log.warn('未授权,停止发布')
+            } else {
+              this.$notify.error({
+                title: '发布屏幕共享流失败',
+                message: `${err}`
+              })
+            }
+          })
+          .finally(() => {
+            this.isOnPubScreen = false
+          })
+      }
+    },
+    onLeave() {
+      this.rtc.leave()
+      this.$router.replace('/')
+    },
+    onChooseStream: function(stream) {
+      this.chosenStream = stream
+    },
+    onBeforeReloadChosenStream: function(stream) {
+      if (stream && stream === this.chosenStream) {
+        this.$refs['display'].srcObject = null
+      }
+    },
+    onReloadChosenStream: function(stream) {
+      if (stream && stream === this.chosenStream) {
+        this.$nextTick(() => {
+          this.$refs['display'].srcObject = stream.isSubscribingSmall ? stream.small.mediaStream : stream.mediaStream
+        })
+      }
+    },
+  }
+}
+</script>
+
+<!-- Add "scoped" attribute to limit CSS to this component only -->
+<style lang="less">
+  .rtc-container {
+    display: flex;
+    flex: 1;
+
+    .text {
+      font-size: 14px;
+
+      &.small {
+        transform: scale(.7);
+      }
+
+      &.left {
+        text-align: left;
+        transform-origin: 0%;
+      }
+
+      &.right {
+        text-align: right;
+        transform-origin: 100%;
+      }
+    }
+
+    .main-container {
+      display: flex;
+      width: 100%;
+      height: calc(100% - 120px);
+
+      .main-panel {
+        position: relative;
+        padding: 0;
+        min-width: 640px;
+        height: 100%;
+        background-color: #000;
+        border-radius: 4px;
+        overflow: hidden;
+
+        .el-icon-info {
+          position: absolute;
+          z-index: 2;
+          top: 4px;
+          right: 4px;
+
+          &:hover {
+            color: #eee;
+          }
+        }
+
+        video {
+          width: 100%;
+          height: 100%;
+        }
+      }
+      .streams-container {
+        display: flex;
+        flex-direction: column;
+        height: 100%;
+        padding:0 4px;
+        .local-streams,
+        .remote-streams {
+          display: flex;
+          flex-direction: column;
+          padding: 0 4px;
+        }
+        .local-streams {
+          margin-bottom: 10px;
+        }
+        .remote-streams {
+          overflow-y: auto;
+          .stream {
+            margin-bottom: 4px;
+          }
+        }
+        &.mobile {
+          overflow-y: auto;
+          .remote-streams {
+            overflow-y: visible;
+          }
+
+          .stream {
+            min-height: 240px;
+          }
+        }
+      }
+    }
+
+    .status-bar {
+      display: flex;
+      padding: 10px;
+      line-height: 30px;
+      color: #eee;
+
+      .status {
+        margin-left: 20px;
+        padding-left: 20px;
+        border-left: 1px solid #333;
+      }
+    }
+
+    .menu-bar {
+      display: flex;
+      flex-flow: row-reverse;
+      padding: 10px;
+
+      button {
+        margin: 0 6px;
+      }
+
+      &.mobile {
+        justify-content: center;
+        padding: 6px;
+
+        .el-button.stop {
+          border: 1px solid #fff;
+        }
+        .el-button.stop::before {
+          position: absolute;
+          content: ' ';
+          display: block;
+          width: 30px;
+          border-top: 1px solid #fff;
+          transform: translate(-8px, 5px) rotateZ(45deg);
+        }
+      }
+
+      .el-badge__content {
+        &.is-dot {
+          right: 10px;
+        }
+      }
+    }
+  }
+</style>

+ 142 - 0
examples/vue/src/components/Login.vue

@@ -0,0 +1,142 @@
+<template>
+  <div class="rtc-container">
+    <el-card :class="isMobile?'box-card is-mobile':'box-card'">
+      <div slot="header" class="clearfix">
+        <span>加入频道</span>
+        <Settings style="float: right;"/>
+      </div>
+      <div class="box-body">
+        <el-form ref="form" :model="form" :rules="rules" :disabled="isJoining" label-width="80px" label-position="top">
+          <el-form-item label="频道号" prop="channel">
+            <el-input v-model="form.channel"></el-input>
+          </el-form-item>
+          <el-form-item label="用户名" prop="username">
+            <el-input v-model="form.username"></el-input>
+          </el-form-item>
+          <el-form-item>
+            <el-button :loading="isJoining" class="btn-submit"  type="primary" @click="onSubmit">立即加入</el-button>
+          </el-form-item>
+        </el-form>
+        <div class="text right small">sdk version: {{version}} / build: {{BUILD_TIME}}</div>
+      </div>
+    </el-card>
+  </div>
+</template>
+
+<script>
+import { store } from '../store'
+import { getRTCInstance, version } from '../rtc'
+import Settings from './Settings.vue'
+import { isMobile } from '../utils/browser'
+import { log } from '../utils/logger'
+
+export default {
+  components: {
+    Settings,
+  },
+  data() {
+    const rtc = getRTCInstance(this)
+    // 自动加入房间
+    // if (store.state.settings.channel && store.state.settings.username) {
+    //   rtc.join(store.state.settings.channel, store.state.settings.username)
+    // }
+    return {
+      form: {
+        channel: store.state.settings.channel || '',
+        username: store.state.settings.username || '',
+      },
+      rules: {
+        channel: [
+          { required: true, message: '请输入频道号', trigger: 'blur' },
+          { min: 2, max: 20, message: '长度在 2 到 20 个字符', trigger: 'blur' }
+        ],
+        username: [
+          { required: true, message: '请输入用户名', trigger: 'blur' },
+          { min: 2, max: 20, message: '长度在 2 到 20 个字符', trigger: 'blur' }
+        ]
+      },
+      rtc,
+      version,
+      isJoining: false,
+      isMobile,
+      BUILD_TIME: process.env.BUILD_TIME,
+    }
+  },
+  methods: {
+    onSubmit() {
+      this.$refs['form'].validate((valid) => {
+        if (valid) {
+          const data = {
+            channel: this.form.channel,
+            username: this.form.username,
+          }
+          store.commit('updateSettings', data)
+          this.isJoining = true
+          this.rtc.init()
+          this.rtc
+            .join(this.form.channel, this.form.username)
+            .then(() => {
+              this.isJoining = false
+              this.$router.push('/room')
+            })
+            .catch((err) => {
+              this.isJoining = false
+              log.error('加入房间失败', err)
+              this.$notify.error({
+                title: '加入房间失败',
+                message: `${err}`
+              })
+            })
+        }
+      })
+    },
+  }
+}
+</script>
+
+<!-- Add "scoped" attribute to limit CSS to this component only -->
+<style lang="less">
+  .rtc-container {
+    display: flex;
+    flex: 1;
+
+    .text {
+      font-size: 14px;
+
+      &.small {
+        transform: scale(.7);
+      }
+
+      &.left {
+        text-align: left;
+        transform-origin: 0%;
+      }
+
+      &.right {
+        text-align: right;
+        transform-origin: 100%;
+      }
+    }
+
+    .box-card {
+      margin: 0 auto;
+      margin-top: 10%;
+      min-width: 480px;
+      max-width: 60%;
+      max-height: 400px;
+
+      &.is-mobile {
+        min-width: 98%;
+        max-width: 98%;
+      }
+
+      .box-body {
+        margin: 0 20px;
+
+        .btn-submit {
+          width: 100%;
+        }
+      }
+    }
+  }
+</style>

+ 44 - 0
examples/vue/src/components/ResumePlay.vue

@@ -0,0 +1,44 @@
+<template>
+  <el-dialog
+    :visible.sync="open"
+    :close-on-click-modal="false"
+    :close-on-press-escape="false"
+    :append-to-body="true"
+    :show-close="false"
+    :center="true"
+    :modal="true"
+    :modal-append-to-body="true"
+    top="25vh"
+    width="150px"
+    class="resume-dialog">
+    <i class="el-icon-video-play" @click="resume"></i>
+  </el-dialog>
+</template>
+
+<script>
+import { getRTCInstance } from '../rtc'
+
+export default {
+  props: ['open'],
+  methods: {
+    resume() {
+      getRTCInstance().resumePlay()
+    }
+  }
+}
+</script>
+
+<style lang='less'>
+.resume-dialog {
+  .el-dialog {
+    box-shadow: 0 0 0 0;
+    background: rgba(0, 0, 0, 0);
+
+    .el-icon-video-play {
+      font-size: 100px;
+      color: #fff;
+      cursor: pointer;
+    }
+  }
+}
+</style>

+ 278 - 0
examples/vue/src/components/Settings.vue

@@ -0,0 +1,278 @@
+<template>
+  <div>
+    <el-link :underline="false">
+      <i @click="dialogVisible = true" class="el-icon-setting"></i>
+    </el-link>
+    <el-dialog
+      title="更多设置"
+      :visible.sync="dialogVisible"
+      :close-on-click-modal="false"
+      :close-on-press-escape="false"
+      :append-to-body="true"
+      top="5vh"
+      :width="isMobile?'98%':'640px'">
+      <el-form class="settings-panel" ref="form" :model="form" label-width="80px">
+        <el-form-item label="房间类型">
+          <el-select v-model="form.roomType" placeholder="请选择房间类型">
+            <el-option label="会议模式" value="conference"></el-option>
+            <el-option label="直播模式" value="live"></el-option>
+          </el-select>
+        </el-form-item>
+        <el-form-item label="用户角色">
+          <el-select v-model="form.roleType" placeholder="请选择用户角色">
+            <el-option label="演讲者" value="speaker"></el-option>
+            <el-option label="听众" value="audience"></el-option>
+          </el-select>
+        </el-form-item>
+        <el-form-item label="麦克风">
+          <el-select v-model="form.microphoneId" placeholder="请选择麦克风">
+            <el-option
+              v-for="item in microphones"
+              :key="item.deviceId"
+              :label="item.label"
+              :value="item.deviceId">
+            </el-option>
+          </el-select>
+          <el-button style="margin-left: 10px;" size="mini" icon="el-icon-refresh" circle @click="updateMicrophones" title="重新获取麦克风列表"></el-button>
+        </el-form-item>
+        <el-form-item label="摄像头">
+          <el-select v-model="form.cameraId" placeholder="请选择摄像头">
+            <el-option
+              v-for="item in cameras"
+              :key="item.deviceId"
+              :label="item.label"
+              :value="item.deviceId">
+            </el-option>
+          </el-select>
+          <el-button style="margin-left: 10px;" size="mini" icon="el-icon-refresh" circle @click="updateCameras" title="重新获取摄像头列表"></el-button>
+        </el-form-item>
+        <el-form-item label="视频流">
+          <el-select v-model="form.videoProfile" placeholder="请选择视频流分辨率">
+            <el-option
+              v-for="item in videoProfiles"
+              :key="item"
+              :label="item"
+              :value="item">
+            </el-option>
+          </el-select>
+          (分辨率)
+        </el-form-item>
+        <el-form-item label="屏幕共享">
+          <el-select v-model="form.screenProfile" placeholder="请选择屏幕共享分辨率">
+            <el-option
+              v-for="item in screenProfiles"
+              :key="item"
+              :label="item"
+              :value="item">
+            </el-option>
+          </el-select>
+          (分辨率)
+        </el-form-item>
+        <el-form-item label="视频编码">
+          <el-select v-model="form.videoCodec" placeholder="请选择视频编码格式">
+            <el-option label="vp8" value="vp8"></el-option>
+            <el-option label="h264" value="h264"></el-option>
+          </el-select>
+        </el-form-item>
+        <el-form-item label="AppId">
+          <el-input v-model="form.appId"></el-input>
+        </el-form-item>
+        <el-form-item label="AppKey">
+          <el-input v-model="form.appKey" show-password></el-input>
+        </el-form-item>
+        <el-form-item label="纯音频">
+          <el-switch v-model="form.audioMode" active-text="开启" inactive-text="关闭"></el-switch>
+        </el-form-item>
+        <el-form-item label="屏幕共享">
+          <el-switch v-model="form.shareMic" active-text="采集" inactive-text="不采集"></el-switch>
+          (麦克风)
+          <el-tooltip content="屏幕共享时,将屏幕共享的画面以及麦克风中采集的声音用同一条流推出" placement="top">
+            <i class="el-icon-info"/>
+          </el-tooltip>
+        </el-form-item>
+        <el-form-item label="图片流">
+          <el-switch v-model="enablePicture" active-text="使用" inactive-text="不使用"></el-switch>
+          <el-upload
+            v-if="enablePicture"
+            action="#"
+            list-type="picture-card"
+            :limit="1"
+            :on-change="handleFileChange"
+            :file-list="fileList"
+            :disabled="disabledAddFile"
+            :auto-upload="false">
+              <i slot="default" class="el-icon-plus"></i>
+              <div slot="file" slot-scope="{file}">
+                <img
+                  class="el-upload-list__item-thumbnail"
+                  :src="file.url" alt=""
+                >
+                <span class="el-upload-list__item-actions">
+                  <span
+                    class="el-upload-list__item-delete"
+                    @click="handleRemove(file)"
+                  >
+                    <i class="el-icon-delete"></i>
+                  </span>
+                </span>
+              </div>
+          </el-upload>
+        </el-form-item>
+        <el-form-item label="Debug">
+          <el-switch v-model="form.debugMode" active-text="开启" inactive-text="关闭"></el-switch>
+        </el-form-item>
+      </el-form>
+      <span slot="footer" class="dialog-footer">
+        <el-button type="primary" @click="handleSave">保存</el-button>
+        <el-button @click="reset">恢复默认值</el-button>
+        <el-button @click="dialogVisible = false">取消</el-button>
+      </span>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { log } from '../utils/logger'
+import { store } from '../store'
+import { videoProfiles, screenProfiles, smallProfiles } from '../config/profiles'
+import { updateMicrophones, updateCameras} from '../rtc'
+import { isMobile } from '../utils/browser'
+import { getPictureURL } from '../utils/image'
+
+export default {
+  data: () => {
+    const persistent = store.state.advanceSettings
+    return {
+      dialogVisible: false,
+      isMobile,
+      videoProfiles,
+      screenProfiles,
+      smallProfiles,
+      form: {
+        roomType: persistent.roomType,
+        roleType: persistent.roleType,
+        cameraId: persistent.cameraId,
+        microphoneId: persistent.microphoneId,
+        videoProfile: persistent.videoProfile,
+        smallVideoProfile: persistent.smallVideoProfile,
+        screenProfile: persistent.screenProfile,
+        videoCodec: persistent.videoCodec,
+        appId: persistent.appId,
+        appKey: persistent.appKey,
+        enableSmallStream: persistent.enableSmallStream,
+        audioMode: persistent.audioMode,
+        debugMode: persistent.debugMode,
+        prodEnv: persistent.prodEnv,
+        shareMic: persistent.shareMic,
+      },
+      fileList: [],
+      enablePicture: false,
+      disabledAddFile: false,
+    }
+  },
+  computed: {
+    isOpened() {
+      return this.dialogVisible
+    },
+    microphones() {
+      return this.$store.state.microphones
+    },
+    cameras() {
+      return this.$store.state.cameras
+    }
+  },
+  watch: {
+    isOpened(newV) {
+      if (newV) {
+        if (this.microphones.length < 1) {
+          this.updateMicrophones()
+        }
+        if (this.cameras.length < 1) {
+          this.updateCameras()
+        }
+      }
+    },
+    enablePicture(newV) {
+      if (!newV) {
+        this.fileList = []
+        this.disabledAddFile = false
+        store.commit('updatePicture', null)
+      }
+    }
+  },
+  mounted() {
+    if (store.state.picture) {
+      getPictureURL(store.state.picture.raw).then(url => {
+        this.enablePicture = true
+        this.fileList = [ { name: store.state.picture.name, url } ]
+      })
+    }
+  },
+  methods: {
+    handleSave() {
+      const data = {
+        roomType: this.form.roomType,
+        roleType: this.form.roleType,
+        microphoneId: this.form.microphoneId,
+        cameraId: this.form.cameraId,
+        videoProfile: this.form.videoProfile,
+        smallVideoProfile: this.form.smallVideoProfile,
+        screenProfile: this.form.screenProfile,
+        videoCodec: this.form.videoCodec,
+        appId: this.form.appId,
+        appKey: this.form.appKey,
+        enableSmallStream: this.form.enableSmallStream,
+        audioMode: this.form.audioMode,
+        debugMode: this.form.debugMode,
+        prodEnv: this.form.prodEnv,
+        shareMic: this.form.shareMic,
+      }
+      log('设置参数 ', JSON.stringify(data, ' ', 2))
+      store.commit('updateAdvanceSettings', data)
+      this.dialogVisible = false
+    },
+    updateMicrophones() {
+      updateMicrophones(this).then((microphones) => {
+        const microphone = microphones.find((item) => item.deviceId === this.form.microphoneId)
+        if (!microphone) {
+          this.form.microphoneId = microphones[0] && microphones[0].deviceId
+        }
+      })
+    },
+    updateCameras() {
+      updateCameras(this).then((cameras) => {
+        const camera = cameras.find((item) => item.deviceId === this.form.cameraId)
+        if (!camera) {
+          this.form.cameraId = cameras[0] && cameras[0].deviceId
+        }
+      })
+    },
+    reset() {
+      store.commit('resetAdvanceSettings')
+      this.form = { ...store.state.advanceSettings }
+    },
+    handleFileChange(_, fileList) {
+      if (fileList.length > 0) {
+        this.disabledAddFile = true
+      }
+      this.fileList = fileList
+      store.commit('updatePicture', this.fileList[0])
+    },
+    handleRemove(file) {
+      const idx = this.fileList.findIndex(item => item === file)
+      if (idx >= 0) {
+        this.fileList.splice(idx, 1)
+      }
+      this.disabledAddFile = false
+      store.commit('updatePicture', null)
+    },
+  }
+}
+</script>
+
+<style scoped lang='less'>
+.settings-panel {
+  display: flex;
+  flex-direction: column;
+}
+</style>

+ 86 - 0
examples/vue/src/components/Status.vue

@@ -0,0 +1,86 @@
+<template>
+  <div class="status" title="网络上行质量及延迟"><i :title="signalText" :class="['iconfont', 'icon-phone-signal-full', signalClass]"/> {{uplinkDelay | formatUplinkDelay}}</div>
+</template>
+
+<script>
+export default {
+  computed: {
+    uplinkDelay() {
+      return this.$store.state.uplinkDelay
+    },
+    signalText() {
+      switch (this.$store.state.uplinkQuality) {
+        case '1':
+          return '优秀'
+        case '2':
+          return '良好'
+        case '3':
+          return '一般'
+        case '4':
+          return '较差'
+        case '5':
+          return '糟糕'
+        case '6':
+          return '断开'
+        case '0':
+        default:
+          return '未知'
+      }
+    },
+    signalClass() {
+      switch (this.$store.state.uplinkQuality) {
+        case '1':
+          return 'signal-1'
+        case '2':
+          return 'signal-2'
+        case '3':
+          return 'signal-3'
+        case '4':
+          return 'signal-4'
+        case '5':
+          return 'signal-5'
+        case '6':
+          return 'signal-6'
+        case '0':
+        default:
+          return ''
+      }
+    }
+  },
+  filters: {
+    formatUplinkDelay: function(val) {
+      if (val < 0 || val === undefined) {
+        return 'N/A'
+      } else {
+        return val + 'ms'
+      }
+    }
+  }
+}
+</script>
+
+<style scoped lang='less'>
+.status {
+  .icon-phone-signal-full {
+    color: #fff;
+    &.signal-1 {
+      color: rgb(129 234 134);
+    }
+    &.signal-2 {
+      color: rgb(129 234 134);
+    }
+    &.signal-3 {
+      color: rgb(82 148 85);
+    }
+    &.signal-4 {
+      color: rgb(224 191 92);
+    }
+    &.signal-5 {
+      color: rgb(244 67 54);
+    }
+    &.signal-6 {
+      color: rgb(177 175 175);
+    }
+  }
+}
+</style>

+ 201 - 0
examples/vue/src/components/Stream.vue

@@ -0,0 +1,201 @@
+<template>
+  <div class="stream">
+    <StreamStats v-if="debugMode" :stream="stream"></StreamStats>
+    <div :id="stream && stream.id" class="player-container" @click="$emit('choose-stream', stream)"></div>
+    <div v-if="stream" class="info-bar">
+      <div class="info">
+        {{stream.userId}}<span v-if="stream.getMediaType() === 'screen'">(<i class="el-icon-monitor" title="屏幕共享流"/>)</span>
+      </div>
+      <div class="opt">
+        <el-button v-if="stream && !stream.isLocal && stream.small" type="text" icon="el-icon-copy-document" title="切换大小流" :disabled="isSwitching" @click="onSwitchSubscribe"></el-button>
+        <el-button v-if="stream && !stream.isLocal" type="text" icon="el-icon-refresh" title="取消并重新订阅" :disabled="isRefreshing" @click="onResubscribe"></el-button>
+        <StreamSettings v-if="stream && stream.isLocal && stream.getMediaType() === 'camera'" :stream="stream"></StreamSettings>
+      </div>
+    </div>
+    <div v-if="stream && stream.isLocal" class="state-bar">
+      <div v-if="stream.hasAudio()" class="state micphone" @click="muteAudio">
+        <i v-show="stream.audioMuted" title="开麦" class="el-icon-turn-off-microphone clickable"></i>
+        <i v-show="!stream.audioMuted" title="关麦" class="el-icon-microphone clickable"></i>
+      </div>
+      <div v-if="stream.hasVideo()" class="state camera" @click="muteVideo">
+        <i v-show="stream.videoMuted" title="打开开摄像头" class="iconfont icon-shexiangtou_guanbi clickable"></i>
+        <i v-show="!stream.videoMuted" title="关闭摄像头" class="iconfont icon-shexiangtou clickable"></i>
+      </div>
+    </div>
+    <div v-if="stream && !stream.isLocal" class="state-bar">
+      <div v-if="stream.hasAudio()" class="state micphone" @click="muteAudio">
+        <i v-show="stream.audioMuted" title="打开声音" class="iconfont icon-shengyinjingyin clickable"></i>
+        <i v-show="!stream.audioMuted" title="关闭声音" class="iconfont icon-shengyinkai clickable"></i>
+      </div>
+      <div v-if="stream.hasVideo()" class="state camera" @click="muteVideo">
+        <i v-show="stream.videoMuted" title="打开画面" class="iconfont icon-live_fill clickable"></i>
+        <i v-show="!stream.videoMuted" title="关闭画面" class="iconfont icon-live clickable"></i>
+      </div>
+      <div v-if="stream.hasAudio()" class="state micphone">
+        <i v-show="stream.sourceAudioMuted" title="远端已关麦" class="el-icon-turn-off-microphone unclickable"></i>
+        <i v-show="!stream.sourceAudioMuted" title="远端已开麦" class="el-icon-microphone unclickable"></i>
+      </div>
+      <div v-if="stream.hasVideo()" class="state camera">
+        <i v-show="stream.sourceVideoMuted" title="远端已关闭摄像头" class="iconfont icon-shexiangtou_guanbi unclickable"></i>
+        <i v-show="!stream.sourceVideoMuted" title="远端已打开摄像头" class="iconfont icon-shexiangtou unclickable"></i>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import StreamStats from './StreamStats.vue'
+import StreamSettings from './StreamSettings.vue'
+import { store } from '../store'
+import { getRTCInstance } from '../rtc'
+import { log } from '../utils/logger'
+
+export default {
+  components: {
+    StreamStats,
+    StreamSettings,
+  },
+  data() {
+    return {
+      rtc: getRTCInstance(),
+      isSwitching: false,
+      isRefreshing: false,
+      debugMode: store.state.advanceSettings.debugMode
+    }
+  },
+  created() {
+    this.stream
+      .on('player-status-change', this.handlePlayerStatusChanged)
+      .on('audio-track-ended', this.handleAudioTrackEnded)
+  },
+  beforeDestroy() {
+    this.stream
+      .off('player-status-change', this.handlePlayerStatusChanged)
+      .off('audio-track-ended', this.handleAudioTrackEnded)
+  },
+  props: ['stream'],
+  methods: {
+    handlePlayerStatusChanged(evt) {
+      const { data } = evt
+      log('播放器状态变化 ', data.type, data.status, data.stream)
+      if (data.status === 'paused') {
+        setTimeout(() => {
+          this.$message.warning(`${data.stream.userId}的流的${data.type}暂停了播放`)
+        }, 0)
+      }
+    },
+    handleAudioTrackEnded(evt) {
+      const { data } = evt
+      log.warn(`音频被终止: [${data.userId}] ${data.getMediaType()}`)
+      setTimeout(() => {
+        this.$message.warning(`${data.userId} (${data.getMediaType()})的音频被终止,可能无法恢复,建议重新上麦`)
+      }, 0)
+    },
+    muteAudio() {
+      if (this.stream.audioMuted) {
+        this.stream.unmuteAudio()
+      } else {
+        this.stream.muteAudio()
+      }
+    },
+    muteVideo() {
+      if (this.stream.videoMuted) {
+        this.stream.unmuteVideo()
+      } else {
+        this.stream.muteVideo()
+      }
+      this.$emit('reload-chosen-stream', this.stream)
+    },
+    onSwitchSubscribe: async function() {
+      this.isSwitching = true
+      this.$emit('before-reload-chosen-stream', this.stream)
+      await this.rtc.switchSubscribe(this.stream)
+      this.$emit('reload-chosen-stream', this.stream)
+      this.isSwitching = false
+    },
+    onResubscribe: async function() {
+      this.isRefreshing = true
+      this.$emit('before-reload-chosen-stream', this.stream)
+      try {
+        await this.rtc.unsubscribe(this.stream)
+      } catch (err) {
+        log.warn('unsubscribe ', err)
+      }
+      try {
+        await this.rtc.subscribe(this.stream)
+      } catch (err) {
+        log.warn('subscribe ', err)
+      }
+      this.$emit('reload-chosen-stream', this.stream)
+      this.isRefreshing = false
+    },
+  }
+}
+</script>
+
+<style scoped lang="less">
+.stream {
+  position: relative;
+  min-height: 160px;
+  background-color: #000;
+  border-radius: 4px;
+  overflow: hidden;
+
+  .player-container {
+    position: absolute;
+    width: 100%;
+    height: 100%;
+  }
+
+  .clickable {
+    cursor: pointer;
+  }
+  .unclickable {
+    color: #ccc;
+    cursor: not-allowed;
+  }
+
+  .info-bar {
+    position: absolute;
+    top: 0;
+    z-index: 1;
+    width: 100%;
+    height: 20px;
+    background-color: rgba(0, 0, 0, 0.2);
+    color: #fff;
+
+    .info {
+      display: inline-block;
+      line-height: 20px;
+      margin: 0 4px;
+    }
+
+    .opt {
+      float: right;
+
+      .el-button--text {
+        margin: 0 4px;
+        padding: 0;
+        color: #fff;
+      }
+    }
+  }
+
+  .state-bar {
+    position: absolute;
+    bottom: 0;
+    z-index: 1;
+    width: 100%;
+    height: 20px;
+    background-color: rgba(0, 0, 0, 0.2);
+    color: #fff;
+    text-align: right;
+
+    .state {
+      display: inline-block;
+      line-height: 20px;
+      margin: 0 4px;
+    }
+  }
+}
+</style>

+ 169 - 0
examples/vue/src/components/StreamSettings.vue

@@ -0,0 +1,169 @@
+<template>
+  <div>
+    <el-link :underline="false">
+      <i @click="dialogVisible = true" class="el-icon-setting" style="color: #fff;"></i>
+    </el-link>
+    <el-dialog
+      title="设置"
+      :visible.sync="dialogVisible"
+      :close-on-click-modal="false"
+      :close-on-press-escape="false"
+      :append-to-body="true"
+      :width="isMobile?'98%':'420px'">
+      <el-form class="settings-panel" ref="form" :model="form" label-width="60px">
+        <el-form-item label="麦克风">
+          <el-select v-model="form.microphoneId" placeholder="切换麦克风" @change="handleMicrophoneChange">
+            <el-option
+              v-for="item in microphones"
+              :key="item.deviceId"
+              :label="item.label"
+              :value="item.deviceId">
+            </el-option>
+          </el-select>
+          <el-button style="margin-left: 10px;" size="mini" icon="el-icon-refresh" circle @click="updateMicrophones" title="重新获取麦克风列表"></el-button>
+        </el-form-item>
+        <el-form-item label="摄像头">
+          <el-select v-model="form.cameraId" placeholder="切换摄像头" @change="handleCameraChange">
+            <el-option
+              v-for="item in cameras"
+              :key="item.deviceId"
+              :label="item.label"
+              :value="item.deviceId">
+            </el-option>
+          </el-select>
+          <el-button style="margin-left: 10px;" size="mini" icon="el-icon-refresh" circle @click="updateCameras" title="重新获取摄像头列表"></el-button>
+        </el-form-item>
+        <el-form-item label="分辨率">
+          <el-select v-model="form.videoProfile" placeholder="切换分辨率" @change="handleVideoProfileChange">
+            <el-option
+              v-for="item in videoProfiles"
+              :key="item"
+              :label="item"
+              :value="item">
+            </el-option>
+          </el-select>
+        </el-form-item>
+      </el-form>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { log } from '../utils/logger'
+import { store } from '../store'
+import { videoProfiles } from '../config/profiles'
+import { updateMicrophones, updateCameras } from '../rtc'
+import { isMobile } from '../utils/browser'
+
+export default {
+  props: ['stream'],
+  computed: {
+    isOpened() {
+      return this.dialogVisible
+    },
+    microphones() {
+      return this.$store.state.microphones
+    },
+    cameras() {
+      return this.$store.state.cameras
+    }
+  },
+  watch: {
+    isOpened(newV) {
+      if (newV) {
+        if (this.microphones.length < 1) {
+          this.updateMicrophones()
+        }
+        if (this.cameras.length < 1) {
+          this.updateCameras()
+        }
+      }
+    }
+  },
+  data: () => {
+    return {
+      dialogVisible: false,
+      isMobile,
+      videoProfiles,
+      form: {
+        microphoneId: store.state.advanceSettings.microphoneId,
+        cameraId: store.state.advanceSettings.cameraId,
+        videoProfile: store.state.advanceSettings.videoProfile,
+      }
+    }
+  },
+  methods: {
+    updateMicrophones() {
+      updateMicrophones(this).then((microphones) => {
+        const microphone = microphones.find((item) => item.deviceId === this.form.microphoneId)
+        if (!microphone) {
+          this.form.microphoneId = microphones[0] && microphones[0].deviceId
+        }
+      })
+    },
+    updateCameras() {
+      updateCameras(this).then((cameras) => {
+        const camera = cameras.find((item) => item.deviceId === this.form.cameraId)
+        if (!camera) {
+          this.form.cameraId = cameras[0] && cameras[0].deviceId
+        }
+      })
+    },
+    handleMicrophoneChange() {
+      log('切换麦克风', this.form.microphoneId)
+      this.stream
+        .switchDevice('audio', this.form.microphoneId)
+        .then(() => {
+          log('切换麦克风成功')
+          store.commit('updateAdvanceSettings', {microphoneId: this.form.microphoneId})
+        })
+        .catch((err) => {
+          log('切换麦克风失败', err)
+          this.$notify.error({
+            title: '切换麦克风失败',
+            message: `${err}`
+          })
+        })
+    },
+    handleCameraChange() {
+      log('切换摄像头', this.form.cameraId)
+      this.stream
+        .switchDevice('video', this.form.cameraId)
+        .then(() => {
+          log('切换摄像头成功')
+          store.commit('updateAdvanceSettings', {cameraId: this.form.cameraId})
+        })
+        .catch((err) => {
+          log('切换摄像头失败', err)
+          this.$notify.error({
+            title: '切换摄像头失败',
+            message: `${err}`
+          })
+        })
+    },
+    handleVideoProfileChange() {
+      log('切换分辨率', this.form.videoProfile)
+      this.stream
+        .setVideoProfile(this.form.videoProfile)
+        .then(() => {
+          log('切换分辨率成功')
+          store.commit('updateAdvanceSettings', {videoProfile: this.form.videoProfile})
+        })
+        .catch((err) => {
+          log('切换分辨率失败', err)
+          this.$notify.error({
+            title: '切换分辨率失败',
+            message: `${err}`
+          })
+        })
+    },
+  }
+}
+</script>
+
+<style scoped lang='less'>
+.settings-panel {
+  display: flex;
+  flex-direction: column;
+}
+</style>

+ 158 - 0
examples/vue/src/components/StreamStats.vue

@@ -0,0 +1,158 @@
+<template>
+  <div class="stream-stats">
+    <div class="audio">
+      <p>延迟: {{stats.network.rtt}} ms </p>
+      <p>码率: {{stats.audio.bitrate}} bps </p>
+      <p>丢包: {{stats.audio.packetLossRate}} % </p>
+      <p>音量: {{stats.audio.volume}} % </p>
+      <p>编码: {{stats.audio.codec}} </p>
+    </div>
+    <div class="video">
+      <p>码率: {{stats.video.bitrate}} bps </p>
+      <p>帧率: {{stats.video.framerate}} fps</p>
+      <p>丢包: {{stats.video.packetLossRate}} % </p>
+      <p>宽度: {{stats.video.width}} </p>
+      <p>高度: {{stats.video.height}} </p>
+      <p>编码: {{stats.video.codec}} </p>
+    </div>
+  </div>
+</template>
+
+<script>
+export default {
+  props: ['stream'],
+  data: () => {
+    return {
+      stats: {
+        audio: {
+          bitrate: -1,
+          packetLossRate: -1,
+          volume: -1,
+          codec: '',
+        },
+        video: {
+          bitrate: -1,
+          framerate: -1,
+          packetLossRate: -1,
+          width: -1,
+          height: -1,
+          codec: '',
+        },
+        network: {
+          rtt: -1,
+        },
+        biggestRTT: -1,
+      },
+      volume: -1,
+    }
+  },
+  mounted() {
+    this.isComponentMounted = true
+    if (this.stream) {
+      this.start()
+    }
+  },
+  beforeDestroy() {
+    this.stop()
+    this.isComponentMounted = false
+  },
+  computed: {
+    isStreamChanged() {
+      return !!this.stream
+    },
+  },
+  watch: {
+    isStreamChanged(newV) {
+      if (newV) {
+        this.start()
+      } else {
+        this.stop()
+      }
+    },
+  },
+  methods: {
+    start() {
+      this.startGetVolume()
+      this.startGetState()
+    },
+    stop() {
+      this.stopGetVolume()
+      this.stopGetState()
+    },
+    startGetVolume() {
+      const { stream } = this
+      if (this.volumeTimer) {
+        clearInterval(this.volumeTimer)
+      }
+      this.volumeTimer = setInterval(() => {
+        const vol = stream.getAudioLevel()
+        this.volume = vol
+      }, 1000)
+    },
+    stopGetVolume() {
+      clearInterval(this.volumeTimer)
+    },
+    startGetState() {
+      const { stream } = this
+      if (this.stateTimer) {
+        clearInterval(this.stateTimer)
+      }
+      this.stateTimer = setInterval(() => {
+        stream.getStats()
+          .then((resp) => {
+            const { audio, video, network } = resp
+            this.stats.audio = audio
+            this.stats.video = video
+            this.stats.network = network
+            if (this.stats.biggestRTT < network.rtt) {
+              this.stats.biggestRTT = network.rtt
+            }
+          })
+      }, 1000)
+    },
+    stopGetState() {
+      clearInterval(this.stateTimer)
+      this.stats.audio = {
+        bitrate: -1,
+        packetLossRate: -1,
+        volume: -1,
+        codec: '',
+      }
+      this.stats.video = {
+        bitrate: -1,
+        framerate: -1,
+        packetLossRate: -1,
+        width: -1,
+        height: -1,
+        codec: '',
+      }
+      this.stats.network = { rtt: -1 }
+    },
+  },
+}
+</script>
+
+<style scoped lang='less'>
+.stream-stats {
+  position: absolute;
+  top: 22px;
+  left: 0;
+  z-index: 1;
+  text-align: left;
+  display: flex;
+
+  .audio,
+  .video {
+    padding: 4px 0;
+    background-color: rgba(0, 0, 0, 0.2);
+    color: #fff;
+  }
+
+  p {
+    margin: 0;
+    font-size: 12px;
+    transform: scale(0.8);
+    line-height: 1;
+  }
+}
+</style>

+ 2 - 0
examples/vue/src/config/app.js

@@ -0,0 +1,2 @@
+export const appId = 'urtc-xxxxxxxx'
+export const appKey = 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'

+ 1 - 0
examples/vue/src/config/index.js

@@ -0,0 +1 @@
+export { appId, appKey } from './app'

+ 36 - 0
examples/vue/src/config/profiles.js

@@ -0,0 +1,36 @@
+export const videoProfiles = [
+  '180p',
+  '180p_2',
+  '240p',
+  '360p',
+  '360p_2',
+  '480p',
+  '720p',
+  '720p_2',
+  '720p_3',
+  '1080p',
+  '1080p_2',
+  '1080p_3'
+]
+
+export const screenProfiles = [
+  '480p',
+  '480p_2',
+  '720p',
+  '720p_2',
+  '1080p',
+  '1080p_2'
+]
+
+export const smallProfiles = [
+  '160*90',
+  '160*120',
+  '240*135',
+  '240*180'
+]
+export const smallProfileMappings = {
+  '160*90': { bitrate: 45, framerate: 15, width: 160, height: 90 },
+  '160*120': { bitrate: 50, framerate: 15, width: 160, height: 120 },
+  '240*135': { bitrate: 55, framerate: 15, width: 240, height: 135 },
+  '240*180': { bitrate: 60, framerate: 15, width: 240, height: 180 }
+}

+ 27 - 0
examples/vue/src/index.less

@@ -0,0 +1,27 @@
+@import "theme/colors.less";
+@import "assets/fonts/iconfont.css";
+
+html, body {
+  width: 100%;
+  height: 100%;
+  margin: 0;
+  padding: 0;
+}
+body {
+  display: flex;
+  background-color: @color-background;
+  font-family: Avenir, Helvetica, Arial, sans-serif;
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+  color: #2c3e50;
+  overflow: hidden;
+}
+
+.clearfix:before,
+.clearfix:after {
+  display: table;
+  content: "";
+}
+.clearfix:after {
+  clear: both
+}

+ 23 - 0
examples/vue/src/main.js

@@ -0,0 +1,23 @@
+import Vue from 'vue'
+import VueRouter from 'vue-router'
+import { routes } from './routes'
+import { store } from './store'
+import App from './App.vue'
+import Element from 'element-ui'
+import 'element-ui/lib/theme-chalk/index.css'
+import 'normalize.css'
+import './index.less'
+
+Vue.use(VueRouter)
+Vue.use(Element)
+Vue.config.productionTip = false
+
+const router = new VueRouter({
+  routes
+})
+
+new Vue({
+  router,
+  store,
+  render: h => h(App),
+}).$mount('#app')

+ 7 - 0
examples/vue/src/routes/index.js

@@ -0,0 +1,7 @@
+import Login from '../components/Login.vue'
+import ClassRoom from '../components/ClassRoom.vue'
+
+export const routes = [
+  { path: '/', component: Login },
+  { path: '/room', component: ClassRoom }
+]

+ 500 - 0
examples/vue/src/rtc.js

@@ -0,0 +1,500 @@
+import { createClient, createStream, generateToken, version, setLogLevel, isSupportScreenShare, getDevices, getMicrophones, getCameras, RtcError } from '@urtc/sdk-web'
+import { store } from './store'
+import { log } from './utils/logger'
+import { isMobile } from './utils/browser'
+import VConsole from 'vconsole'
+
+export async function updateMicrophones(vm) {
+  try {
+    const microphones = await getMicrophones()
+    vm.$store.commit('updateMicrophones', microphones)
+    return microphones
+  } catch (err) {
+    vm.$notify.warning({
+      title: `获取麦克风信息失败`,
+      message: `${err}`
+    })
+  }
+}
+
+export async function updateCameras(vm) {
+  try {
+    const cameras = await getCameras()
+    vm.$store.commit('updateCameras', cameras)
+    return cameras
+  } catch (err) {
+    vm.$notify.warning({
+      title: `获取摄像头信息失败`,
+      message: `${err}`
+    })
+  }
+}
+
+export async function updateDevices(vm) {
+  try {
+    const devices = await getDevices()
+    const microphones = devices.filter(item => 'audioinput' === item.kind)
+    vm.$store.commit('updateMicrophones', microphones)
+    const cameras = devices.filter(item => 'videoinput' === item.kind)
+    vm.$store.commit('updateCameras', cameras)
+    return [microphones, cameras]
+  } catch (err) {
+    vm.$notify.warning({
+      title: `获取设备信息失败`,
+      message: `${err}`
+    })
+  }
+}
+
+let instance = undefined
+
+class RTC {
+  init() {
+    if (store.state.advanceSettings.debugMode) {
+      setLogLevel('debug')
+      if (isMobile) new VConsole()
+    } else {
+      setLogLevel('warn')
+    }
+
+    this.client = createClient(store.state.advanceSettings.appId, {
+      codec: store.state.advanceSettings.videoCodec
+    })
+    if (store.state.advanceSettings.debugMode) {
+      window.p = this.client
+    }
+  }
+
+  localStream = undefined
+  localScreenStream = undefined
+  remoteStreams = []
+  isSupportScreenShare = isSupportScreenShare()
+
+  isJoined = false
+  needResume = false
+  playBlockedStreams = []
+
+  vm = undefined
+
+  async join(channel, username) {
+    if (this.isJoined) return
+    const token = generateToken(store.state.advanceSettings.appId, store.state.advanceSettings.appKey, channel, username)
+    this.bindEvents()
+    try {
+      await this.client.join(channel, username, token, {
+        type: store.state.advanceSettings.roomType,
+        role: store.state.advanceSettings.roleType,
+      })
+      log('加入房间成功')
+      this.watchUplinkDelay()
+      this.isJoined = true
+    } catch (err) {
+      log('加入房间失败', err)
+      throw err
+    }
+  }
+  async leave() {
+    if (!this.isJoined) return
+    try {
+      await this.client.leave()
+      log('离开房间成功')
+      this.unbindEvents()
+      this.reset()
+    } catch (err) {
+      log('离开房间失败', err)
+    }
+  }
+
+
+  getUplinkDelayTimer = 0
+  watchUplinkDelay() {
+    this.unwatchUplinkDelay()
+    this.getUplinkDelayTimer = window.setInterval(async () => {
+      let stats = { network: { rtt: -1 } }
+      if (this.localStream) {
+        stats = await this.localStream.getStats()
+      } else if (this.localScreenStream) {
+        stats = await this.localScreenStream.getStats()
+      }
+      if (stats.network) {
+        store.commit('updateUplinkDelay', stats.network.rtt)
+      }
+    }, 1000)
+  }
+  unwatchUplinkDelay() {
+    if (this.getUplinkDelayTimer) {
+      window.clearInterval(this.getUplinkDelayTimer)
+    }
+    this.getUplinkDelayTimer = 0
+  }
+
+  async initLocalPicStream(picture) {
+    let stream = createStream({
+      audio: true,
+      video: !store.state.advanceSettings.audioMode,
+      screen: false,
+      file: picture.raw,
+    })
+    await stream.setVideoProfile(store.state.advanceSettings.videoProfile)
+    try {
+      await stream.init()
+    } catch (err) {
+      log('初始化本地图片流失败', err)
+    }
+    this.localStream = stream
+    return stream
+  }
+
+  async initLocalStream() {
+    let microphoneId = store.state.advanceSettings.microphoneId || (store.state.microphones[0] && store.state.microphones[0].deviceId)
+    let cameraId = store.state.advanceSettings.cameraId || (store.state.cameras[0] && store.state.cameras[0].deviceId)
+    if (!microphoneId || !cameraId) {
+      const [microphones,  cameras] = await updateDevices(this.vm)
+      microphoneId = microphones[0] && microphones[0].deviceId
+      cameraId = cameras[0] && cameras[0].deviceId
+    }
+    store.commit('updateAdvanceSettings', { microphoneId, cameraId })
+
+    let stream = createStream({
+      audio: true,
+      video: !store.state.advanceSettings.audioMode,
+      screen: false,
+      microphoneId,
+      cameraId,
+    })
+    if (!store.state.advanceSettings.audioMode) {
+      await stream.setVideoProfile(store.state.advanceSettings.videoProfile)
+    }
+    try {
+      await stream.init()
+    } catch (err) {
+      log('初始化本地流失败', err)
+      if (err.code === '3008' && (store.state.advanceSettings.microphoneId || store.state.advanceSettings.cameraId)) {
+        this.vm && this.vm.$notify.info({
+          title: '初始化本地流',
+          message: `指定的设备无法初始化本地流,已切换设备初始化本地流`
+        })
+        const [microphones,  cameras] = await updateDevices(this.vm)
+        microphoneId = microphones[0] && microphones[0].deviceId || ''
+        cameraId = cameras[0] && cameras[0].deviceId || ''
+        store.commit('updateAdvanceSettings', { microphoneId, cameraId })
+        stream = createStream({
+          audio: true,
+          video: true,
+          screen: false,
+          microphoneId,
+          cameraId,
+        })
+        await stream.setVideoProfile(store.state.advanceSettings.videoProfile)
+        await stream.init()
+      } else {
+        throw err
+      }
+    }
+    this.localStream = stream
+    return stream
+  }
+  async publish() {
+    if (!this.localStream) {
+      if (store.state.picture) {
+        await this.initLocalPicStream(store.state.picture)
+      } else {
+        await this.initLocalStream()
+      }
+    }
+    try {
+      await this.client.publish(this.localStream)
+    } catch (err) {
+      log('发布失败', err)
+      throw err
+    }
+  }
+  async unpublish() {
+    if (this.localStream) {
+      await this.client.unpublish(this.localStream)
+      this.localStream.destroy()
+      this.localStream = undefined
+    }
+  }
+  async initLocalScreenStream() {
+    const stream = createStream({audio: !!store.state.advanceSettings.shareMic, video: false, screenAudio: true, screen: true})
+    stream.setScreenProfile(store.state.advanceSettings.screenProfile)
+    stream.on('screen-sharing-stopped', () => {
+      this.unpublishScreen()
+        .catch((err) => {
+          this.vm && this.vm.$notify.error({
+            title: '取消屏幕共享流失败',
+            message: `${err}`
+          })
+        })
+    })
+    try {
+      await stream.init()
+    } catch (err) {
+      log('初始化屏幕共享流失败', err)
+      throw err
+    }
+    this.localScreenStream = stream
+    return stream
+  }
+  async publishScreen() {
+    if (!this.localScreenStream) {
+      await this.initLocalScreenStream()
+    }
+    try {
+      await this.client.publish(this.localScreenStream)
+    } catch (err) {
+      log('发布屏幕共享失败', err)
+      throw err
+    }
+  }
+  async unpublishScreen() {
+    if (this.localScreenStream) {
+      await this.client.unpublish(this.localScreenStream)
+      this.localScreenStream.destroy()
+      this.localScreenStream = undefined
+    }
+  }
+
+  async switchSubscribe(stream) {
+    try {
+      await this.client.switchSubscribe(stream)
+    } catch (err) {
+      log(`切换${stream.userId}大小流失败 `, err)
+      this.vm && this.vm.$notify.warning({
+        title: `切换${stream.userId}大小流失败`,
+        message: `${err}`
+      })
+    }
+  }
+
+  async subscribe(stream) {
+    const opts = stream.small ? { small: true } : undefined
+    try {
+      await this.client.subscribe(stream, opts)
+    } catch (err) {
+      log(`${stream.userId} 订阅失败 ${err}`)
+      this.vm && this.vm.$notify.warning({
+        title: `订阅${stream.userId}失败`,
+        message: `${err}`
+      })
+    }
+  }
+  async unsubscribe(stream) {
+    try {
+      await this.client.unsubscribe(stream)
+    } catch (err) {
+      log(`${stream.userId} 取消订阅失败 ${err}`)
+      this.vm && this.vm.$notify.warning({
+        title: `取消订阅${stream.userId}失败`,
+        message: `${err}`
+      })
+    }
+  }
+
+  handleUserJoin = (evt) => {
+    log('用户加入 ', evt)
+  }
+  handleUserLeave = (evt) => {
+    log('用户离开 ', evt)
+  }
+  handleStreamPublished = (evt) => {
+    log('流已发布 ', evt)
+  }
+  handleStreamAdded = (evt) => {
+    log('流已加入 ', evt)
+    const stream = evt.data
+    this.remoteStreams.push(stream)
+    // 自动订阅 - 有小流时,自动订阅小流,没有小流时订阅大流
+    this.subscribe(stream)
+  }
+  handleStreamSubscribed = (evt) => {
+    log('流已订阅 ', evt)
+    const stream = evt.data
+    stream.play(stream.id, { controls: 'hide' }).catch((err) => {
+      log(`${stream.userId} 播放失败 ${err}`)
+      // 解决 elementUI 重复调用 message 时,多个 message 重叠的问题
+      setTimeout(() => {
+        this.vm && this.vm.$message.warning(`自动播放${stream.userId}的流失败,播放错误码:${err.code}`)
+      }, 0)
+      this.playBlockedStreams = this.playBlockedStreams.concat(stream)
+      this.needResume = true
+    })
+  }
+  handleStreamRemoved = (evt) => {
+    log('流已移除 ', evt)
+    const stream = evt.data
+    this.remoteStreams = this.remoteStreams.filter((item) => item !== stream)
+  }
+  handleAudioMuted = (evt) => {
+    log('流的音频已 mute ', evt)
+  }
+  handleAudioUnmuted = (evt) => {
+    log('流的音频已 unmute ', evt)
+  }
+  handleVideoMuted = (evt) => {
+    log('流的视频已 mute ', evt)
+  }
+  handleVideoUnmuted = (evt) => {
+    log('流的视频已 unmute ', evt)
+  }
+  handleKickoff = (evt) => {
+    log('当前账号已在异地登录 ', evt)
+    this.vm && this.vm.$notify.error({
+      title: '警告',
+      message: `当前账号已在异地登录`
+    })
+    this.reset()
+  }
+  handleNetworkQuality = (evt) => {
+    console.log(`上行 / 下行网络质量:${evt.data.uplink} / ${evt.data.downlink}`)
+    store.commit('updateUplinkQuality', evt.data.uplink)
+    store.commit('updateDownlinkQuality', evt.data.downlink)
+  }
+  handleConnectionStateChanged = (evt) => {
+    log(`连接状态:${evt.data.previous} => ${evt.data.current}`)
+  }
+  handleStreamReconnecting = (evt) => {
+    const stream = evt.data
+    log(`流 ${stream.userId} (${stream.getMediaType()}) 正在重连`)
+    // 解决 elementUI 重复调用 message 时,多个 message 重叠的问题
+    setTimeout(() => {
+      this.vm && this.vm.$message.warning(`流 ${stream.userId} (${stream.getMediaType()}) 正在重连`)
+    }, 0)
+  }
+  handleStreamReconnected = (evt) => {
+    const stream = evt.data
+    log(`流 ${stream.userId} (${stream.getMediaType()}) 已经重连`)
+    setTimeout(() => {
+      this.vm && this.vm.$message.success(`流 ${stream.userId} (${stream.getMediaType()}) 已经重连`)
+      this.vm && this.vm.onReloadChosenStream && this.vm.onReloadChosenStream(stream)
+    }, 0)
+  }
+  handleDeviceChanged = (evt) => {
+    log('设备变化 ', evt.data.type, evt.data.status, evt.data.device)
+    const type = 'microphone' === evt.data.type ? '麦克风' : 'camera' === evt.data.type ? '摄像头' : '扬声器/耳机'
+    const action = 'add' === evt.data.status ? '插入' : '拔出'
+    // 解决 elementUI 重复调用 message 时,多个 message 重叠的问题
+    setTimeout(() => {
+      this.vm && this.vm.$message(`${type}[${evt.data.device.label}]已${action}`)
+    }, 0)
+  }
+  handleError = (evt) => {
+    log.warn(`出现错误: [${evt.data.code}] (${evt.data.message})`)
+    setTimeout(() => {
+      if (evt.data.code === RtcError.ICE_FAILED) {
+        this.vm && this.vm.$message.error(`当前网络无法使用音视频通话服务,可检测网络设置或网络防火墙,或直接尝试切换至其他网络使用`)
+      } else {
+        this.vm && this.vm.$message.error(`出现错误 [${evt.data.code}] (${evt.data.message})`)
+      }
+    }, 0)
+  }
+
+  bindEvents() {
+    this.client
+      .on('user-joined', this.handleUserJoin)
+      .on('user-left', this.handleUserLeave)
+      .on('stream-published', this.handleStreamPublished)
+      .on('stream-added', this.handleStreamAdded)
+      .on('stream-subscribed', this.handleStreamSubscribed)
+      .on('stream-removed', this.handleStreamRemoved)
+      .on('mute-audio', this.handleAudioMuted)
+      .on('unmute-audio', this.handleAudioUnmuted)
+      .on('mute-video', this.handleVideoMuted)
+      .on('unmute-video', this.handleVideoUnmuted)
+      .on('kick-off', this.handleKickoff)
+      .on('connection-state-changed', this.handleConnectionStateChanged)
+      .on('stream-reconnecting', this.handleStreamReconnecting)
+      .on('stream-reconnected', this.handleStreamReconnected)
+      .on('network-quality', this.handleNetworkQuality)
+      .on('device-changed', this.handleDeviceChanged)
+      .on('error', this.handleError)
+  }
+  unbindEvents() {
+    this.client
+      .off('user-joined', this.handleUserJoin)
+      .off('user-left', this.handleUserLeave)
+      .off('stream-published', this.handleStreamPublished)
+      .off('stream-added', this.handleStreamAdded)
+      .off('stream-subscribed', this.handleStreamSubscribed)
+      .off('stream-removed', this.handleStreamRemoved)
+      .off('mute-audio', this.handleAudioMuted)
+      .off('unmute-audio', this.handleAudioUnmuted)
+      .off('mute-video', this.handleVideoMuted)
+      .off('unmute-video', this.handleVideoUnmuted)
+      .off('kick-off', this.handleKickoff)
+      .off('connection-state-changed', this.handleConnectionStateChanged)
+      .off('stream-reconnecting', this.handleStreamReconnecting)
+      .off('stream-reconnected', this.handleStreamReconnected)
+      .off('network-quality', this.handleNetworkQuality)
+      .off('device-changed', this.handleDeviceChanged)
+      .off('error', this.handleError)
+  }
+
+  resumePlay = () => {
+    let _playBlockedStreams = []
+    const playBlockedStreams = this.playBlockedStreams
+    let len = playBlockedStreams.length
+    playBlockedStreams.forEach((stream) => {
+      stream
+        .resume()
+        .catch((err) => {
+          log(`${stream.userId} 重新播放失败 ${err}`)
+          _playBlockedStreams = _playBlockedStreams.concat(stream)
+        })
+        .finally(() => {
+          len--
+          if (len < 1 && _playBlockedStreams.length > 0) {
+            this.vm && this.vm.$message(`手动播放失败的流有 ${_playBlockedStreams.map(item => item.userId)}`)
+          }
+        })
+    })
+    this.playBlockedStreams = []
+    this.needResume = false
+  }
+
+  playLocalStream = (stream) => {
+    stream.play(stream.id)
+      .then(() => {
+        log(`${stream.userId} 播放成功`)
+      })
+      .catch((err) => {
+        log(`${stream.userId} 播放失败 ${err}`)
+        // 解决 elementUI 重复调用 message 时,多个 message 重叠的问题
+        setTimeout(() => {
+          this.vm && this.vm.$message.warning(`自动播放${stream.userId}的流失败,播放错误:${err.message}`)
+        }, 0)
+        this.playBlockedStreams = this.playBlockedStreams.concat(stream)
+        this.needResume = true
+      })
+  }
+
+  reset() {
+    this.isJoined = false
+    this.unwatchUplinkDelay()
+    if (this.localStream) {
+      this.localStream.destroy()
+      this.localStream = undefined
+    }
+    if (this.localScreenStream) {
+      this.localScreenStream.destroy()
+      this.localScreenStream = undefined
+    }
+    this.remoteStreams = []
+    this.client = undefined
+  }
+}
+
+export function getRTCInstance(vm) {
+  if (!instance) {
+    instance = new RTC()
+  }
+  if (vm) {
+    instance.vm = vm
+  }
+  return instance
+}
+
+export {
+  version
+}

+ 90 - 0
examples/vue/src/store/index.js

@@ -0,0 +1,90 @@
+import Vue from 'vue'
+import Vuex from 'vuex'
+import { loadStore, saveStore } from './sessionStore'
+import { appId, appKey } from '../config'
+
+Vue.use(Vuex)
+
+const defaultAdvanceSettings = {
+  roomType: 'conference', // conference live
+  roleType: 'speaker', // "audience" | "speaker"
+  microphoneId: '',
+  cameraId: '',
+  videoProfile: '360p_2',
+  smallVideoProfile: '160*90',
+  screenProfile: '1080p',
+  videoCodec: 'vp8',
+  appId: appId,
+  appKey: appKey,
+  enableSmallStream: false,
+  audioMode: false,
+  debugMode: false,
+  prodEnv: true,
+  shareMic: false,
+}
+
+const persistent = loadStore() || {}
+
+export const store = new Vuex.Store({
+  state: {
+    uplinkQuality: -1,
+    uplinkDelay: -1,
+    downlinkQuality: -1,
+    microphones: [],
+    cameras: [],
+    settings: {
+      channel: persistent.channel || '',
+      username: persistent.username || '',
+    },
+    advanceSettings: {
+      roomType: persistent.roomType || defaultAdvanceSettings.roomType,
+      roleType: persistent.roleType || defaultAdvanceSettings.roleType,
+      microphoneId: persistent.microphoneId || defaultAdvanceSettings.microphoneId,
+      cameraId: persistent.cameraId || defaultAdvanceSettings.cameraId,
+      videoProfile: persistent.videoProfile || defaultAdvanceSettings.videoProfile,
+      smallVideoProfile: persistent.smallVideoProfile || defaultAdvanceSettings.smallVideoProfile,
+      screenProfile: persistent.screenProfile || defaultAdvanceSettings.screenProfile,
+      videoCodec: persistent.videoCodec || defaultAdvanceSettings.videoCodec,
+      appId: persistent.appId || defaultAdvanceSettings.appId,
+      appKey: persistent.appKey || defaultAdvanceSettings.appKey,
+      enableSmallStream: persistent.enableSmallStream || defaultAdvanceSettings.enableSmallStream,
+      audioMode: persistent.audioMode || defaultAdvanceSettings.audioMode,
+      debugMode: persistent.debugMode || defaultAdvanceSettings.debugMode,
+      prodEnv: persistent.prodEnv || defaultAdvanceSettings.prodEnv,
+      shareMic: persistent.shareMic || defaultAdvanceSettings.shareMic,
+    },
+    picture: null,
+  },
+  mutations: {
+    updateUplinkQuality(state, payload) {
+      state.uplinkQuality = payload
+    },
+    updateUplinkDelay(state, payload) {
+      state.uplinkDelay = payload
+    },
+    updateDownlinkQuality(state, payload) {
+      state.downlinkQuality = payload
+    },
+    updateMicrophones(state, payload) {
+      state.microphones = payload
+    },
+    updateCameras(state, payload) {
+      state.cameras = payload
+    },
+    updateSettings(state, payload) {
+      state.settings = { ...state.settings, ...payload }
+      saveStore({ ...state.settings, ...state.advanceSettings })
+    },
+    updateAdvanceSettings(state, payload) {
+      state.advanceSettings = { ...state.advanceSettings, ...payload }
+      saveStore({ ...state.settings, ...state.advanceSettings })
+    },
+    resetAdvanceSettings(state) {
+      state.advanceSettings = { ...defaultAdvanceSettings }
+      saveStore({ ...state.settings, ...state.advanceSettings })
+    },
+    updatePicture(state, payload) {
+      state.picture = payload
+    },
+  }
+})

+ 13 - 0
examples/vue/src/store/sessionStore.js

@@ -0,0 +1,13 @@
+export function saveStore(data = {channel: '', username: ''}) {
+  sessionStorage.setItem('my-rtc-store', JSON.stringify(data))
+}
+
+export function loadStore() {
+  const data = sessionStorage.getItem('my-rtc-store')
+  try {
+    const p = JSON.parse(data) || {channel: '', username: ''}
+    return p
+  } catch (err) {
+    alert('数据读取失败,请重新输入频道号及用户名')
+  }
+}

+ 5 - 0
examples/vue/src/theme/colors.less

@@ -0,0 +1,5 @@
+@color-background: #121f37;
+@color-background-2: #3860f4;
+@color-font-white: #ffffff;
+@color-font-1: #0a1633;
+@color-font-2: #526075;

+ 0 - 0
examples/vue/src/theme/size.less


+ 5 - 0
examples/vue/src/utils/browser.js

@@ -0,0 +1,5 @@
+function _isMobile() {
+  return navigator.userAgent.match(/(phone|pad|pod|iPhone|iPod|ios|iPad|Android|Mobile|MQQBrowser|WeChat|MicroMessenger)/i)
+}
+
+export const isMobile = _isMobile()

+ 12 - 0
examples/vue/src/utils/image.js

@@ -0,0 +1,12 @@
+export function getPictureURL(file) {
+  return new Promise((resolve, reject) => {
+    const reader = new FileReader()
+    reader.addEventListener("load", function () {
+      resolve(this.result)
+    }, false)
+    reader.addEventListener("error", function (err) {
+      reject(err)
+    }, false)
+    reader.readAsDataURL(file)
+  })
+}

+ 13 - 0
examples/vue/src/utils/logger.js

@@ -0,0 +1,13 @@
+function log(...args) {
+  console.log((new Date()).toLocaleString(), ...args)
+}
+
+log.warn = function(...args) {
+  console.warn((new Date()).toLocaleString(), ...args)
+}
+
+log.error = function(...args) {
+  console.error((new Date()).toLocaleString(), ...args)
+}
+
+export { log }

+ 11 - 0
examples/vue/vue.config.js

@@ -0,0 +1,11 @@
+const path = require('path');
+const webpack = require('webpack');
+
+module.exports = {
+  chainWebpack: (config) => {
+    config.resolve.symlinks(false);
+    config.plugin('env').use(webpack.DefinePlugin, [{
+      'process.env.BUILD_TIME': JSON.stringify(new Date()),
+    }]);
+  }
+}

File diff suppressed because it is too large
+ 8713 - 0
examples/vue/yarn.lock