gbtomr 2 years ago
commit
cf39af6c30
22 changed files with 17372 additions and 0 deletions
  1. 1 0
      .gitignore
  2. 20 0
      .postcssrc.js
  3. 3 0
      .vscode/settings.json
  4. 24 0
      README.md
  5. 5 0
      babel.config.js
  6. 19 0
      jsconfig.json
  7. 11208 0
      package-lock.json
  8. 49 0
      package.json
  9. BIN
      public/favicon.ico
  10. 34 0
      public/index.html
  11. 31 0
      src/App.vue
  12. 286 0
      src/Home.vue
  13. 3514 0
      src/adapter.js
  14. 13 0
      src/assets/styles/index.scss
  15. 65 0
      src/components/UeVideo.vue
  16. 6 0
      src/main.js
  17. 9 0
      src/store/index.js
  18. 81 0
      src/utils/UIInteractions.js
  19. 2 0
      src/utils/bus.js
  20. 296 0
      src/webRtcPlayer.js
  21. 1699 0
      src/webRtcVideo.js
  22. 7 0
      vue.config.js

+ 1 - 0
.gitignore

@@ -0,0 +1 @@
+node_modules

+ 20 - 0
.postcssrc.js

@@ -0,0 +1,20 @@
+module.exports = {
+  plugins: {
+    autoprefixer: {}, // 用来给不同的浏览器自动添加相应前缀,如-webkit-,-moz-等等
+    "postcss-px-to-viewport": {
+      unitToConvert: "px", // 要转化的单位
+      viewportWidth: 1080, // UI设计稿的宽度
+      unitPrecision: 6, // 转换后的精度,即小数点位数
+      propList: ["*"], // 指定转换的css属性的单位,*代表全部css属性的单位都进行转换
+      viewportUnit: "vh", // 指定需要转换成的视窗单位,默认vw
+      fontViewportUnit: "vh", // 指定字体需要转换成的视窗单位,默认vw
+      selectorBlackList: [], // 指定不转换为视窗单位的类名,
+      minPixelValue: 1, // 默认值1,小于或等于1px则不进行转换
+      mediaQuery: true, // 是否在媒体查询的css代码中也进行转换,默认false
+      replace: true, // 是否转换后直接更换属性值
+      exclude: [/node_modules/], // 设置忽略文件,用正则做目录名匹配
+      // include: [/overview_4k/],
+      landscape: false // 是否处理横屏情况
+    }
+  }
+};

+ 3 - 0
.vscode/settings.json

@@ -0,0 +1,3 @@
+{
+  "git.ignoreLimitWarning": true
+}

+ 24 - 0
README.md

@@ -0,0 +1,24 @@
+# digital-twin
+
+## Project setup
+```
+npm install
+```
+
+### Compiles and hot-reloads for development
+```
+npm run serve
+```
+
+### Compiles and minifies for production
+```
+npm run build
+```
+
+### Lints and fixes files
+```
+npm run lint
+```
+
+### Customize configuration
+See [Configuration Reference](https://cli.vuejs.org/config/).

+ 5 - 0
babel.config.js

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

+ 19 - 0
jsconfig.json

@@ -0,0 +1,19 @@
+{
+  "compilerOptions": {
+    "target": "es5",
+    "module": "esnext",
+    "baseUrl": "./",
+    "moduleResolution": "node",
+    "paths": {
+      "@/*": [
+        "src/*"
+      ]
+    },
+    "lib": [
+      "esnext",
+      "dom",
+      "dom.iterable",
+      "scripthost"
+    ]
+  }
+}

File diff suppressed because it is too large
+ 11208 - 0
package-lock.json


+ 49 - 0
package.json

@@ -0,0 +1,49 @@
+{
+  "name": "digital-twin",
+  "version": "0.1.0",
+  "private": true,
+  "scripts": {
+    "serve": "vue-cli-service serve",
+    "build": "vue-cli-service build",
+    "lint": "vue-cli-service lint"
+  },
+  "dependencies": {
+    "@vueuse/core": "^9.3.1",
+    "core-js": "^3.8.3",
+    "element-plus": "^2.2.29",
+    "mitt": "^3.0.0",
+    "vue": "^3.2.13"
+  },
+  "devDependencies": {
+    "@babel/core": "^7.12.16",
+    "@babel/eslint-parser": "^7.12.16",
+    "@vue/cli-plugin-babel": "~5.0.0",
+    "@vue/cli-plugin-eslint": "~5.0.0",
+    "@vue/cli-service": "~5.0.0",
+    "eslint": "^7.32.0",
+    "eslint-plugin-vue": "^8.0.3",
+    "postcss-px-to-viewport": "^1.1.1",
+    "sass": "^1.55.0",
+    "sass-loader": "^13.1.0"
+  },
+  "eslintConfig": {
+    "root": true,
+    "env": {
+      "node": true
+    },
+    "extends": [
+      "plugin:vue/vue3-essential",
+      "eslint:recommended"
+    ],
+    "parserOptions": {
+      "parser": "@babel/eslint-parser"
+    },
+    "rules": {}
+  },
+  "browserslist": [
+    "> 1%",
+    "last 2 versions",
+    "not dead",
+    "not ie 11"
+  ]
+}

BIN
public/favicon.ico


+ 34 - 0
public/index.html

@@ -0,0 +1,34 @@
+<!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">
+    <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>
+
+<style>
+  *{
+    margin: 0;
+    padding: 0;
+  }
+  body,html{
+    width: 100%;
+    height: 100%;
+    margin: 0;
+    padding: 0;
+  }
+  #app {
+    width: 100%;
+    height: 100%;
+  }
+</style>

+ 31 - 0
src/App.vue

@@ -0,0 +1,31 @@
+<template>
+  <div id="appvue">
+    <UeVideo class="ue-page"/>
+    <Home class="home-page"/>
+  </div>
+</template>
+
+<script setup>
+import UeVideo from './components/UeVideo.vue';
+import Home from './Home.vue';
+</script>
+
+<style lang="scss" scoped>
+#appvue {
+  font-family: Avenir, Helvetica, Arial, sans-serif;
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+  text-align: center;
+  width: 100%;
+  height: 100%;
+  // background-color: rgb(36, 51, 77);
+  background-color: #fff;
+  position: relative;
+  .ue-page {
+    z-index: 2;
+  }
+  .home-page {
+    z-index: 3;
+  }
+}
+</style>

+ 286 - 0
src/Home.vue

@@ -0,0 +1,286 @@
+<template>
+  <div id="homep">
+    <div class="header">
+      <span>服务地址:</span>
+      <el-input v-model="servUrl"></el-input>
+      <el-button type="primary" @click="setServUrl">确定</el-button>
+    </div>
+    <div class="aside">
+      <div class="send-wrapper">
+        <span class="title1">发送消息:</span>
+        <span class="title2">方法名</span>
+        <el-select v-model="sendData.actionName" filterable allow-create @change="handleSelectAction" placeholder="请选择或输入">
+          <el-option v-for="item in actionList" :key="item.ActionName + item.alias" :label="item.ActionName +' '+ item.alias" :value="item.ActionName+'#'+item.alias" />
+        </el-select>
+        <div class="sw-params-row">
+          <div class="title2">参数名</div>
+          <div class="title2">参数值</div>
+        </div>
+        <div class="sw-params-row" v-for="item,index in sendData.params">
+          <el-input v-model="item.pName" clearable></el-input>
+          <el-input v-model="item.pValue" clearable></el-input>
+          <i class="icon-btn" @click="handleDeleteRow(index)">
+            <svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" data-v-ea893728=""><path fill="currentColor" d="M352 480h320a32 32 0 1 1 0 64H352a32 32 0 0 1 0-64z"></path><path fill="currentColor" d="M512 896a384 384 0 1 0 0-768 384 384 0 0 0 0 768zm0 64a448 448 0 1 1 0-896 448 448 0 0 1 0 896z"></path></svg>
+          </i>
+        </div>
+        <div class="sw-btns">
+          <el-button @click="handleAddParam">+</el-button>
+          <el-button type="primary" @click="handleAddToShortcut">保存到快捷键</el-button>
+          <el-button type="primary" @click="handleSend(sendData)">发送</el-button>
+        </div>
+      </div>
+      <div class="logs-wrapper">
+        <span class="title1">消息日志:</span>
+        <el-input type="textarea" v-model="logContent"></el-input>
+      </div>
+    </div>
+    <div class="footer">
+      <span>快捷键:</span>
+      <el-button v-for="item in shortcutList.value" type="primary" @click="handleClickShortcut(item)">{{ item.alias }}</el-button>
+    </div>
+
+    <el-dialog
+      v-model="dialogVisible"
+      title="请输入快捷键名称"
+      width="20%"
+      :modal="false"
+      top="35vh"
+    >
+      <el-input v-model="shortcutName" clearable></el-input>
+      <template #footer>
+        <span class="dialog-footer">
+          <el-button type="primary" @click="handleConfirmShortcut">确定</el-button>
+        </span>
+      </template>
+    </el-dialog>
+
+  </div>
+</template>
+
+<script setup>
+import { onMounted, ref, reactive } from 'vue'
+import { actionList } from '@/utils/UIInteractions'
+import { addResponseEventListener } from '@/webRtcVideo.js'
+import { callUIInteraction } from "@/webRtcVideo";
+import { useGlobalState } from '@/store/index'
+import bus from './utils/bus';
+import { ElInput, ElButton, ElSelect, ElOption, ElDialog } from 'element-plus'
+import 'element-plus/es/components/input/style/css'
+import 'element-plus/es/components/button/style/css'
+import 'element-plus/es/components/select/style/css'
+import 'element-plus/es/components/option/style/css'
+import 'element-plus/es/components/dialog/style/css'
+
+const state = useGlobalState()
+
+onMounted(() => {
+  //添加UE的消息监听
+  addResponseEventListener('play-video', async (data) => {
+    logContent.value += '接收消息-->\n' + data + '\n'
+  })
+  servUrl.value = state.serviceUrl.value
+})
+
+const shortcutList = reactive({value: [
+  
+]})
+
+function handleClickShortcut(item) {
+  handleSend(item)
+}
+
+const dialogVisible = ref(false)
+
+const shortcutName = ref('')
+
+function handleConfirmShortcut() {
+  shortcutList.value.push({ ...sendData, alias: shortcutName.value })
+  dialogVisible.value = false
+}
+
+function handleSelectAction(item) {
+  let itemArr = item.split('#')
+  actionList.forEach(i => {
+    if(i.ActionName===itemArr[0] && i.alias===itemArr[1]) {
+      sendData.params = []
+      if('Parameters' in i) {
+        Object.keys(i.Parameters).forEach(key => {
+          sendData.params.push({ pName: key, pValue: i.Parameters[key]})
+        })
+      }
+    }
+  })
+}
+
+const servUrl = ref('')
+
+function setServUrl() {
+  state.serviceUrl.value = servUrl.value
+  bus.emit('setServiceUrl')
+}
+
+const logContent = ref('')
+
+const sendData = reactive({
+  actionName: '',
+  params: [
+    { pName: '', pValue: '' }
+  ]
+})
+
+function handleAddParam() {
+  sendData.params.push({ pName: '', pValue: '' })
+}
+
+function handleAddToShortcut() {
+  shortcutName.value = ''
+  dialogVisible.value = true
+}
+
+function handleDeleteRow(index) {
+  sendData.params.splice(index,1)
+}
+
+function callUIInteractionFormat(action) {
+  if(action['Parameters']) {
+    action['Parameters'] = JSON.stringify(action['Parameters'])
+  }
+  logContent.value += '发送消息-->\n' + JSON.stringify(action) + '\n'
+  callUIInteraction(action)
+}
+
+function handleSend(data) {
+  let action = {
+    'ActionName': data.actionName,
+    'Parameters': {}
+  }
+  if(action.ActionName.indexOf('#')!==0) {
+    action.ActionName = action.ActionName.split('#')[0]
+  }
+  data.params.forEach(p => {
+    if(p.pName&&p.pValue) {
+      action.Parameters[p.pName] = p.pValue
+    }
+  })
+  if(Object.keys(action.Parameters).length===0) {
+    delete action.Parameters
+  }
+  callUIInteractionFormat(action)
+}
+
+</script>
+
+<style lang="scss" scoped>
+#homep {
+  position: absolute;
+  width: 100%;
+  height: 0;
+  bottom: 0;
+  color: #fff;
+
+  .header {
+    box-sizing: border-box;
+    padding: 0 20px;
+    position: absolute;
+    top: -100vh;
+    width: 83vw;
+    height: 5vh;
+    margin-left: 17vw;
+    background-color: rgba($color: #0e295c, $alpha: 0.5);
+    border: 2px solid #333;
+    border-left: none;
+    display: flex;
+    align-items: center;
+    .el-input {
+      width: 300px;
+      margin-right: 20px;
+    }
+
+  }
+  .aside {
+    display: flex;
+    flex-direction: column;
+    box-sizing: border-box;
+    padding: 20px 10px;
+    position: absolute;
+    left: 0;
+    bottom: 0;
+    height: 100vh;
+    width: 17vw;
+    background-color: rgba($color: #0e295c, $alpha: 0.5);
+    border: 2px solid #333;
+    text-align: left;
+    overflow: hidden;
+
+    .send-wrapper {
+      border-bottom: 2px solid black;
+      margin-bottom: 2vh;
+      .sw-params-row {
+        display: flex;
+        justify-content: space-between;
+        align-items: center;
+        margin-top: 10px;
+        &>div {
+          width: 45%;
+        }
+      }
+      .sw-btns {
+        display: flex;
+        justify-content: space-between;
+        margin: 2vh 0;
+      }
+    }
+
+    .logs-wrapper {
+      flex: 1;
+      :deep(.el-textarea) {
+        height: calc(100% - 50px);
+        .el-textarea__inner {
+          height: 100%;
+        }
+      }
+    }
+
+
+    
+  }
+  .footer {
+    box-sizing: border-box;
+    padding: 0 20px;
+    position: absolute;
+    bottom: 0;
+    width: 83vw;
+    height: 5vh;
+    margin-left: 17vw;
+    background-color: rgba($color: #0e295c, $alpha: 0.5);
+    border: 2px solid #333;
+    border-left: none;
+    display: flex;
+    align-items: center;
+  }
+
+  .title1 {
+    display: block;
+    margin-bottom: 20px;
+    font-size: 18px;
+    font-weight: bold;
+  }
+
+  .title2 {
+    display: block;
+    margin: 5px 0 10px;
+    font-size: 16px;
+  }
+
+  .el-select {
+    width: 90%;
+  }
+
+  .icon-btn {
+    display: inline-block;
+    width: 20px;
+    height: 20px;
+    cursor: pointer;
+  }
+}
+</style>

File diff suppressed because it is too large
+ 3514 - 0
src/adapter.js


+ 13 - 0
src/assets/styles/index.scss

@@ -0,0 +1,13 @@
+/* 滚动条样式 */
+::-webkit-scrollbar {
+  width : 6px;
+  height: 6px;
+}
+::-webkit-scrollbar-thumb {
+  border-radius: 12px;
+  background   : #888;
+}
+::-webkit-scrollbar-track {
+  border-radius: 12px;
+  background   : #ddd;
+}

+ 65 - 0
src/components/UeVideo.vue

@@ -0,0 +1,65 @@
+<template>
+  <div ref="video" id="player">
+    <i id="overlayButton" style="display: none;" ></i>
+    <i id="qualityStatus" style="display: none;"></i>
+    <div id="stats" style="display: none;" ></div>
+  </div>
+</template>
+
+<script>
+import { onMounted, ref } from 'vue'
+import { initLoad } from '../webRtcVideo.js'
+import { useGlobalState } from '@/store/index'
+import bus from '@/utils/bus'
+
+
+export default {
+  name: 'UeVideo',
+  setup(props,  context) {
+    
+    const state = useGlobalState()
+    let video = ref(null)
+    let videoInstance = ref(null)
+
+    onMounted(()=>{
+      bus.on('setServiceUrl', ()=> {
+        console.log('访问地址-->', state.serviceUrl.value);
+        videoInstance = initLoad({
+          context,
+          serverUrl: 'http://'+ state.serviceUrl.value,
+          autoConnection: false,
+          showPlayOverlay: true,
+          qualityControl: true,
+          inputOptions: {
+            controlScheme: 1, // 鼠标:0是锁定,1是滑过
+            suppressBrowserKeys: false,
+          }
+        })
+      })
+      // videoInstance = load()
+    })
+
+    return  {
+      video,
+      //向UE发送一个字符串.打印在游戏屏幕上
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+#player {
+  box-sizing: border-box;
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  overflow: hidden;
+  // background-color: #041637;
+  .inter-btn {
+    margin-top: 15vh;
+  }
+
+}
+</style>

+ 6 - 0
src/main.js

@@ -0,0 +1,6 @@
+import { createApp } from 'vue'
+import App from './App.vue'
+import '@/assets/styles/index.scss'
+
+createApp(App)
+.mount('#app')

+ 9 - 0
src/store/index.js

@@ -0,0 +1,9 @@
+import { ref } from 'vue'
+import { createGlobalState } from '@vueuse/core'
+
+export const useGlobalState = createGlobalState(
+  () => {
+    const serviceUrl = ref('172.16.58.195:8080/')
+    return { serviceUrl }
+  }
+)

+ 81 - 0
src/utils/UIInteractions.js

@@ -0,0 +1,81 @@
+/* alias 字段为方法的自定义名称,默认需设置为空。
+*当 ActionName 相同时,alias 必须不重复  */
+
+export const actionList = [
+  { "ActionName":"fly", "Parameters": { "x":-4366605,"y":3078090,"z":3848,"roll_x":0,"roll_y":-34,"roll_z":142 }, 'alias': '杭申线'},
+  { "ActionName":"fly", "Parameters":{ "x":214537,"y":-105865,"z":65661,"roll_x":0,"roll_y":-72,"roll_z":51 }, 'alias': '航道'},
+  { "ActionName":"fly", "Parameters":{ "x":243893,"y":44147,"z":256,"roll_x":0,"roll_y":-19,"roll_z":90 }, 'alias': '潮高'},
+  { "ActionName":"fly", "Parameters":{ "x":208508,"y":-78668,"z":1420,"roll_x":0,"roll_y":-24,"roll_z":-53 }, 'alias': '水下地形'},
+  { "ActionName":"fly", "Parameters":{ "x":245056,"y":-71983,"z":20434,"roll_x":0,"roll_y":-42,"roll_z":-127 }, 'alias': '视频'},
+  { "ActionName":"position_by_type", "Parameters": { 'position_type': '', 'position_name': '' }, 'alias': ''},
+  { "ActionName":"full_extent" , 'alias': ''},
+  { "ActionName":"show_line" , 'alias': ''},
+  { "ActionName":"close_line" , 'alias': ''},
+  { "ActionName":"show_mt" , 'alias': ''},
+  { "ActionName":"close_mt" , 'alias': ''},
+  { "ActionName":"show_lymt" , 'alias': ''},
+  { "ActionName":"close_lymt" , 'alias': ''},
+  { 'ActionName':'click_boat', 'Parameters': { 'mmsi':'' }, 'alias': ''},
+  { 'ActionName':'close_boat' , 'alias': ''},
+  { 'ActionName':'boat_guiji', 'Parameters': { 'startTime': '', 'endTime': '' }, 'alias': ''},
+  { 'ActionName':'set_boat_drive_speed', 'Parameters': { 'speed': '' }, 'alias': ''},
+  { 'ActionName':'boat_drive' , 'alias': ''},
+  { 'ActionName':'boat_close_drive' , 'alias': ''},
+  { 'ActionName':'boat_stop' , 'alias': ''},
+  { 'ActionName':'boat_continue' , 'alias': ''},
+  { "ActionName":"close_unit_wiget" , 'alias': ''},
+  { "ActionName":"open_unit_video", "Parameters":{ 'code': '' }, 'alias': ''},
+  { "ActionName":"close_unit_vedio" , 'alias': ''},
+  { "ActionName":"click_unit_wiget", "Parameters": { 'objectId': '' }, 'alias': ''},
+  { "ActionName":"open_unit_wiget" , 'alias': ''},
+  { "ActionName":"breath_get_point" , 'alias': ''},
+  { "ActionName":"click_breth", "Parameters": { 'objectId': '' }, 'alias': ''},
+  { "ActionName":"clear_all_breth" , 'alias': ''},
+  { "ActionName":"init_all" , 'alias': ''},
+  { "ActionName":"find_jinghua" , 'alias': ''},
+  { "ActionName":"run_jinghua" , 'alias': ''},
+  { "ActionName":"run_op_jinghua" , 'alias': ''},
+  { "ActionName":"find_zhuanhang" , 'alias': ''},
+  { "ActionName":"run_zhuanhang" , 'alias': ''},
+  { "ActionName":"run_op_zhuanhang" , 'alias': ''},
+  { 'ActionName':'query_water', 'Parameters':{ 'startTime':'', 'endTime':'' }, 'alias': ''},
+  { "ActionName":"play_water" , 'alias': ''},
+  { "ActionName":"stop_water" , 'alias': ''},
+  { "ActionName":"continue_water" , 'alias': ''},
+  { "ActionName":"reset_water" , 'alias': ''},
+  { "ActionName":"set_water_speed", 'Parameters':{ 'speed': '' }, 'alias': ''},
+  { "ActionName":"close_water" , 'alias': ''},
+  { "ActionName":"open_water" , 'alias': ''},
+  { "ActionName":"change_shuishen", "Parameters": { 'shuishen_index': '' }, 'alias': ''},
+  { "ActionName":"open_video_fusion" , 'alias': ''},
+  { "ActionName":"close_video_fusion" , 'alias': ''},
+  { "ActionName":"boat_realtime" , 'alias': ''},
+  { "ActionName":"clear_boat_time" , 'alias': ''},
+  { "ActionName":"show_superlevel_boat" , 'alias': ''},
+  { "ActionName":"close_superlevel_boat" , 'alias': ''},
+  { "ActionName":"show_hangdao_line" , 'alias': ''},
+  { "ActionName":"close_hangdao_line" , 'alias': ''},
+  { "ActionName":"position_for_hsx", "Parameters": { 'eventcode': '', 'position_name': '' }, 'alias': ''},
+  { "ActionName":"change_time", "Parameters":{ "hour":'',"minutes":'',"seconds":'' }, 'alias': ''},
+  { "ActionName":"change_weather", "Parameters":{ "weather_type": '' }, 'alias': ''},
+  { "ActionName":"open_rlt" , 'alias': ''},
+  { "ActionName":"close_rlt" , 'alias': ''},
+  { "ActionName":"change_track_type", "Parameters":{ 'track_type': '' }, 'alias': ''},
+  { "ActionName":"show_only_lycb" , 'alias': ''},
+  { "ActionName":"close_only_lycb" , 'alias': ''},
+  { "ActionName":"init_shujun" , 'alias': ''},
+  { "ActionName":"click_shujun" , 'alias': ''},
+  { "ActionName":"run_shujun" , 'alias': ''},
+  { "ActionName":"back_shujun" , 'alias': ''},
+  { "ActionName":"clear_shujun" , 'alias': ''},
+  { "ActionName":"before_shujun" , 'alias': ''},
+  { "ActionName":"after_shujun" , 'alias': ''},
+  { "ActionName":"init_water_shujun" , 'alias': ''},
+  { "ActionName":"history_run", "Parameters":{ 'routTypeId': '', 'mmsi': '', 'startTime': '' }, 'alias': ''},
+  { "ActionName":"history_stop" , 'alias': ''},
+  { "ActionName":"history_again" , 'alias': ''},
+  { "ActionName":"history_clear" , 'alias': ''},
+  { "ActionName":"start_yjyj" , 'alias': ''},
+  { "ActionName":"close_yjyj" , 'alias': ''},
+
+]

+ 2 - 0
src/utils/bus.js

@@ -0,0 +1,2 @@
+import mitt from 'mitt';
+export default mitt();

+ 296 - 0
src/webRtcPlayer.js

@@ -0,0 +1,296 @@
+// Copyright Epic Games, Inc. All Rights Reserved.
+// universal module definition - read https://www.davidbcalhoun.com/2014/what-is-amd-commonjs-and-umd/
+
+(function (root, factory) {
+    if (typeof define === 'function' && define.amd) {
+        // AMD. Register as an anonymous module.
+        define(["./adapter"], factory);
+    } else if (typeof exports === 'object') {
+        // Node. Does not work with strict CommonJS, but
+        // only CommonJS-like environments that support module.exports,
+        // like Node.
+        module.exports = factory(require("./adapter"));
+    } else {
+        // Browser globals (root is window)
+        // root.webRtcPlayer = factory(root.adapter);
+        window.webRtcPlayer = factory(window.adapter);
+    }
+}(this, function (adapter) {
+
+    function webRtcPlayer(parOptions) {
+    	parOptions = parOptions || {};
+    	
+        var self = this;
+
+        //**********************
+        //Config setup
+        //**********************;
+		this.cfg = parOptions.peerConnectionOptions || {};
+        this.cfg.sdpSemantics = 'unified-plan';
+        //If this is true in Chrome 89+ SDP is sent that is incompatible with UE WebRTC and breaks.
+        this.cfg.offerExtmapAllowMixed = false;
+        this.pcClient = null;
+        this.dcClient = null;
+        this.tnClient = null;
+
+        this.sdpConstraints = {
+          offerToReceiveAudio: 1,
+          offerToReceiveVideo: 1
+        };
+
+        // See https://www.w3.org/TR/webrtc/#dom-rtcdatachannelinit for values
+        this.dataChannelOptions = {ordered: true};
+
+        //**********************
+        //Functions
+        //**********************
+
+        //Create Video element and expose that as a parameter
+        function createWebRtcVideo () {
+            var video = document.createElement('video');
+
+            video.id = "streamingVideo";
+            video.playsInline = true;
+            video.addEventListener('loadedmetadata', function(e){
+                if(self.onVideoInitialised){
+                    self.onVideoInitialised();
+                }
+            }, true);
+            return video;
+        }
+
+        this.video = createWebRtcVideo();
+
+        function onsignalingstatechange (state) {
+            //console.info('signaling state change:', state)
+        };
+
+        function oniceconnectionstatechange (state) {
+            //console.info('ice connection state change:', state)
+        };
+
+        function onicegatheringstatechange (state) {
+            //console.info('ice gathering state change:', state)
+        };
+
+        function handleOnTrack (e) {
+            //console.log('handleOnTrack', e.streams);
+            if (self.video.srcObject !== e.streams[0]) {
+                //console.log('setting video stream from ontrack');
+                self.video.srcObject = e.streams[0];
+            }
+        };
+
+        function setupDataChannel (pc, label, options) {
+            try {
+                var datachannel = pc.createDataChannel(label, options)
+                //console.log(`Created datachannel (${label})`)
+                
+                datachannel.onopen = function (e) {
+                  //console.log(`data channel (${label}) connect`)
+                  if(self.onDataChannelConnected){
+                    self.onDataChannelConnected();
+                  }
+                }
+
+                datachannel.onclose = function (e) {
+                  //console.log(`data channel (${label}) closed`)
+                }
+
+                datachannel.onmessage = function (e) {
+                //   //console.log(`Got message (${label})`, e.data)
+                  if (self.onDataChannelMessage)
+                    self.onDataChannelMessage(e.data);
+                }
+
+                return datachannel;
+            } catch (e) { 
+                console.warn('No data channel', e);
+                return null;
+            }
+        }
+
+        function onicecandidate (e) {
+			//console.log('ICE candidate', e)
+			if (e.candidate && e.candidate.candidate) {
+                self.onWebRtcCandidate(e.candidate);
+            }
+        };
+
+        function handleCreateOffer (pc) {
+            pc.createOffer(self.sdpConstraints).then(function (offer) {
+                offer.sdp = offer.sdp.replace("useinbandfec=1", "useinbandfec=1;stereo=1;maxaveragebitrate=128000");
+            	pc.setLocalDescription(offer);
+            	if (self.onWebRtcOffer) {
+            		// (andriy): increase start bitrate from 300 kbps to 20 mbps and max bitrate from 2.5 mbps to 100 mbps
+                    // (100 mbps means we don't restrict encoder at all)
+                    // after we `setLocalDescription` because other browsers are not c happy to see google-specific config
+            		offer.sdp = offer.sdp.replace(/(a=fmtp:\d+ .*level-asymmetry-allowed=.*)\r\n/gm, "$1;x-google-start-bitrate=10000;x-google-max-bitrate=20000\r\n");
+            		self.onWebRtcOffer(offer);
+                }
+            },
+            function () { console.warn("Couldn't create offer") });
+        }
+        
+        function setupPeerConnection (pc) {
+        	if (pc.SetBitrate)
+        		//console.log("Hurray! there's RTCPeerConnection.SetBitrate function");
+
+            //Setup peerConnection events
+            pc.onsignalingstatechange = onsignalingstatechange;
+            pc.oniceconnectionstatechange = oniceconnectionstatechange;
+            pc.onicegatheringstatechange = onicegatheringstatechange;
+
+            pc.ontrack = handleOnTrack;
+            pc.onicecandidate = onicecandidate;
+        };
+
+        function generateAggregatedStatsFunction (){
+            if(!self.aggregatedStats)
+                self.aggregatedStats = {};
+
+            return function(stats){
+                ////console.log('Printing Stats');
+
+                let newStat = {};
+                // //console.log('----------------------------- Stats start -----------------------------');
+                stats.forEach(stat => {
+//                    //console.log(JSON.stringify(stat, undefined, 4));
+                    if (stat.type == 'inbound-rtp' 
+                        && !stat.isRemote 
+                        && (stat.mediaType == 'video' || stat.id.toLowerCase().includes('video'))) {
+
+                        newStat.timestamp = stat.timestamp;
+                        newStat.bytesReceived = stat.bytesReceived;
+                        newStat.framesDecoded = stat.framesDecoded;
+                        newStat.packetsLost = stat.packetsLost;
+                        newStat.bytesReceivedStart = self.aggregatedStats && self.aggregatedStats.bytesReceivedStart ? self.aggregatedStats.bytesReceivedStart : stat.bytesReceived;
+                        newStat.framesDecodedStart = self.aggregatedStats && self.aggregatedStats.framesDecodedStart ? self.aggregatedStats.framesDecodedStart : stat.framesDecoded;
+                        newStat.timestampStart = self.aggregatedStats && self.aggregatedStats.timestampStart ? self.aggregatedStats.timestampStart : stat.timestamp;
+
+                        if(self.aggregatedStats && self.aggregatedStats.timestamp){
+                            if(self.aggregatedStats.bytesReceived){
+                                // bitrate = bits received since last time / number of ms since last time
+                                //This is automatically in kbits (where k=1000) since time is in ms and stat we want is in seconds (so a '* 1000' then a '/ 1000' would negate each other)
+                                newStat.bitrate = 8 * (newStat.bytesReceived - self.aggregatedStats.bytesReceived) / (newStat.timestamp - self.aggregatedStats.timestamp);
+                                newStat.bitrate = Math.floor(newStat.bitrate);
+                                newStat.lowBitrate = self.aggregatedStats.lowBitrate && self.aggregatedStats.lowBitrate < newStat.bitrate ? self.aggregatedStats.lowBitrate : newStat.bitrate
+                                newStat.highBitrate = self.aggregatedStats.highBitrate && self.aggregatedStats.highBitrate > newStat.bitrate ? self.aggregatedStats.highBitrate : newStat.bitrate
+                            }
+
+                            if(self.aggregatedStats.bytesReceivedStart){
+                                newStat.avgBitrate = 8 * (newStat.bytesReceived - self.aggregatedStats.bytesReceivedStart) / (newStat.timestamp - self.aggregatedStats.timestampStart);
+                                newStat.avgBitrate = Math.floor(newStat.avgBitrate);
+                            }
+
+                            if(self.aggregatedStats.framesDecoded){
+                                // framerate = frames decoded since last time / number of seconds since last time
+                                newStat.framerate = (newStat.framesDecoded - self.aggregatedStats.framesDecoded) / ((newStat.timestamp - self.aggregatedStats.timestamp) / 1000);
+                                newStat.framerate = Math.floor(newStat.framerate);
+                                newStat.lowFramerate = self.aggregatedStats.lowFramerate && self.aggregatedStats.lowFramerate < newStat.framerate ? self.aggregatedStats.lowFramerate : newStat.framerate
+                                newStat.highFramerate = self.aggregatedStats.highFramerate && self.aggregatedStats.highFramerate > newStat.framerate ? self.aggregatedStats.highFramerate : newStat.framerate
+                            }
+
+                            if(self.aggregatedStats.framesDecodedStart){
+                                newStat.avgframerate = (newStat.framesDecoded - self.aggregatedStats.framesDecodedStart) / ((newStat.timestamp - self.aggregatedStats.timestampStart) / 1000);
+                                newStat.avgframerate = Math.floor(newStat.avgframerate);
+                            }
+                        }
+                    }
+
+                    //Read video track stats
+                    if(stat.type == 'track' && (stat.trackIdentifier == 'video_label' || stat.kind == 'video')) {
+                        newStat.framesDropped = stat.framesDropped;
+                        newStat.framesReceived = stat.framesReceived;
+                        newStat.framesDroppedPercentage = stat.framesDropped / stat.framesReceived * 100;
+                        newStat.frameHeight = stat.frameHeight;
+                        newStat.frameWidth = stat.frameWidth;
+                        newStat.frameHeightStart = self.aggregatedStats && self.aggregatedStats.frameHeightStart ? self.aggregatedStats.frameHeightStart : stat.frameHeight;
+                        newStat.frameWidthStart = self.aggregatedStats && self.aggregatedStats.frameWidthStart ? self.aggregatedStats.frameWidthStart : stat.frameWidth;
+                    }
+
+                    if(stat.type =='candidate-pair' && stat.hasOwnProperty('currentRoundTripTime') && stat.currentRoundTripTime != 0){
+                        newStat.currentRoundTripTime = stat.currentRoundTripTime;
+                    }
+                });
+
+                ////console.log(JSON.stringify(newStat));
+                self.aggregatedStats = newStat;
+
+                if(self.onAggregatedStats)
+                    self.onAggregatedStats(newStat)
+            }
+        };
+
+        //**********************
+        //Public functions
+        //**********************
+
+        //This is called when revceiving new ice candidates individually instead of part of the offer
+        //This is currently not used but would be called externally from this class
+        this.handleCandidateFromServer = function(iceCandidate) {
+            //console.log("ICE candidate: ", iceCandidate);
+            let candidate = new RTCIceCandidate(iceCandidate);
+            self.pcClient.addIceCandidate(candidate).then(_=>{
+                //console.log('ICE candidate successfully added');
+            });
+        };
+
+        //Called externaly to create an offer for the server
+        this.createOffer = function() {
+            if(self.pcClient){
+                //console.log("Closing existing PeerConnection")
+                self.pcClient.close();
+                self.pcClient = null;
+            }
+            self.pcClient = new RTCPeerConnection(self.cfg);
+            setupPeerConnection(self.pcClient);
+            self.dcClient = setupDataChannel(self.pcClient, 'cirrus', self.dataChannelOptions);
+            handleCreateOffer(self.pcClient);
+        };
+
+        //Called externaly when an answer is received from the server
+        this.receiveAnswer = function(answer) {
+            //console.log(`Received answer:\n${answer}`);
+            var answerDesc = new RTCSessionDescription(answer);
+            self.pcClient.setRemoteDescription(answerDesc);
+        };
+
+        this.close = function(){
+            if(self.pcClient){
+                //console.log("Closing existing peerClient")
+                self.pcClient.close();
+                self.pcClient = null;
+            }
+            if(self.aggregateStatsIntervalId)
+                clearInterval(self.aggregateStatsIntervalId);
+        }
+
+        //Sends data across the datachannel
+        this.send = function(data){
+            if(self.dcClient && self.dcClient.readyState == 'open'){
+                ////console.log('Sending data on dataconnection', self.dcClient)
+                self.dcClient.send(data);
+            }
+        };
+
+        this.getStats = function(onStats){
+            if(self.pcClient && onStats){
+                self.pcClient.getStats(null).then((stats) => { 
+                    onStats(stats); 
+                });
+            }
+        }
+
+        this.aggregateStats = function(checkInterval){
+            let calcAggregatedStats = generateAggregatedStatsFunction();
+            let printAggregatedStats = () => { self.getStats(calcAggregatedStats); }
+            self.aggregateStatsIntervalId = setInterval(printAggregatedStats, checkInterval);
+        }
+    };
+
+    return webRtcPlayer;
+  
+}));
+
+export default webRtcPlayer

File diff suppressed because it is too large
+ 1699 - 0
src/webRtcVideo.js


+ 7 - 0
vue.config.js

@@ -0,0 +1,7 @@
+const { defineConfig } = require('@vue/cli-service')
+
+module.exports = defineConfig({
+  transpileDependencies: true,
+  lintOnSave:false,
+  publicPath: '/ui-interaction'
+})