瀏覽代碼

✨ feat: 采样率

wolyshaw 3 年之前
父節點
當前提交
3dc099c3ef

+ 11 - 0
package-lock.json

@@ -1455,6 +1455,12 @@
         "@types/node": "*"
       }
     },
+    "@types/throttle-debounce": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/@types/throttle-debounce/-/throttle-debounce-5.0.0.tgz",
+      "integrity": "sha512-Pb7k35iCGFcGPECoNE4DYp3Oyf2xcTd3FbFQxXUI9hEYKUl6YX+KLf7HrBmgVcD05nl50LIH6i+80js4iYmWbw==",
+      "dev": true
+    },
     "@types/trusted-types": {
       "version": "2.0.2",
       "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.2.tgz",
@@ -5360,6 +5366,11 @@
         }
       }
     },
+    "throttle-debounce": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.0.tgz",
+      "integrity": "sha512-2iQTSgkkc1Zyk0MeVrt/3BvuOXYPl/R8Z0U2xxo9rjwNciaHDG3R+Lm6dh4EeUci49DanvBnuqI6jshoQQRGEg=="
+    },
     "tiny-emitter": {
       "version": "2.1.0",
       "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz",

+ 2 - 0
package.json

@@ -23,6 +23,7 @@
     "nprogress": "^0.2.0",
     "qrcode": "^1.5.0",
     "query-string": "^7.1.1",
+    "throttle-debounce": "^5.0.0",
     "umi-request": "^1.4.0",
     "vue": "^3.2.25",
     "vue-router": "^4.0.12"
@@ -30,6 +31,7 @@
   "devDependencies": {
     "@types/js-cookie": "^3.0.1",
     "@types/node": "^17.0.17",
+    "@types/throttle-debounce": "^5.0.0",
     "@types/webpack-env": "^1.16.3",
     "@vitejs/plugin-legacy": "^1.7.1",
     "@vitejs/plugin-vue": "^2.2.0",

+ 338 - 91
src/components/live-broadcast/action-bar.tsx

@@ -1,8 +1,19 @@
-import { defineComponent, reactive } from 'vue'
-import { ElButton, ElDropdown, ElDropdownMenu, ElDropdownItem, ElSlider, ElDialog, ElIcon } from 'element-plus'
+import { defineComponent, reactive, watch } from 'vue'
+import {
+  ElButton,
+  ElDropdown,
+  ElDropdownMenu,
+  ElDropdownItem,
+  ElSlider,
+  ElDialog,
+  ElIcon,
+} from 'element-plus'
 import runtime, * as RuntimeUtils from './runtime'
+import MicroPhoneIcon from './images/microphone.svg?raw'
 import styles from './action-bar.module.less'
 import Share from './share'
+import { requireMedia } from './helpers'
+import SvgIcon from '../svg-icon'
 
 export const state = reactive({
   volume: 0,
@@ -10,30 +21,128 @@ export const state = reactive({
     camera: false, // 摄像头
     volume: false, // 声音调节
     microphone: false, // 麦克风
+    microphone2: false, // 系统音频
     screen: false, // 共享屏幕
     share: false, // 分享
   },
-  shareVisiable: false
+  shareVisiable: false,
+  binded: false,
 })
 
 export default defineComponent({
   name: 'LiveBroadcast-ActionBar',
   computed: {
     isCameraDisabled() {
-      return state.barStatus.camera && runtime.deviceStatus.camera !== 'denied' && runtime.cameras.length
+      return (
+        state.barStatus.camera &&
+        runtime.deviceStatus.camera !== 'denied' &&
+        runtime.cameras.length
+      )
     },
     isMicrophoneDisabled() {
-      const isDisabled = state.barStatus.microphone && runtime.deviceStatus.microphone !== 'denied' && runtime.microphones.length
+      const isDisabled =
+        state.barStatus.microphone &&
+        runtime.deviceStatus.microphone !== 'denied' &&
+        runtime.microphones.length
       return isDisabled
     },
+    isMicrophone2Disabled() {
+      console.log(runtime.selectedMicrophone2)
+      const isDisabled =
+        state.barStatus.microphone2 &&
+        runtime.deviceStatus.microphone !== 'denied' &&
+        runtime.microphones.length
+      return isDisabled || !runtime.selectedMicrophone2
+    },
     isVolumeDisabled() {
       return state.volume === 0
-    }
+    },
   },
   mounted() {
-    console.log(runtime.cameras, runtime.cameras.length)
+    RuntimeUtils.runtimeEvent.on('microphoneChange', this.listenMicophoneAudioTrack)
+    // RuntimeUtils.runtimeEvent.on('microphoneChange2', this.listenMicophoneAudioTrack)
+    // console.log(runtime.microphones, runtime.microphones.length)
+    // watch(runtime, () => {
+    //   console.log('runtime changed')
+    //   if (state.binded) return
+    // this.listenMicophoneAudioTrack()
+    // })
+  },
+  beforeUnmount() {
+    RuntimeUtils.runtimeEvent.off('microphoneChange', this.listenMicophoneAudioTrack)
+    // RuntimeUtils.runtimeEvent.off('microphoneChange2', this.listenMicophoneAudioTrack)
   },
   methods: {
+    setIconPath(iconName: string, num: number) {
+      const icon = (this.$refs[iconName] as HTMLSpanElement)?.querySelector(
+        '#microphone-content-svg'
+      )
+      if (icon) {
+        const line = 14 - 11 * (num / 100)
+        icon.setAttribute(
+          'd',
+          `M7,${line} L14,${line} L14,10.5 C14,12.4329966 12.4329966,14 10.5,14 C8.56700338,14 7,12.4329966 7,10.5 L7,${line} L7,${line} Z`
+        )
+      }
+    },
+    async listenMicophoneAudioTrack() {
+      let levelChecker: ScriptProcessorNode, levelChecker2: ScriptProcessorNode
+      // console.log('listenMicophoneAudioTrack', runtime.selectedMicrophone)
+      if (runtime.selectedMicrophone && !this.isMicrophoneDisabled) {
+        const mediaStreams = await requireMedia({
+          audio: {
+            deviceId: runtime.selectedMicrophone?.deviceId,
+          },
+          video: false,
+        })
+        levelChecker = RuntimeUtils.listenAudioChecker(mediaStreams, (num, label) => {
+          this.setIconPath('microphoneicon', num)
+        })
+      } else {
+        // @ts-ignore
+        if (levelChecker && levelChecker?.onaudioprocess) {
+          // @ts-ignore
+          // levelChecker?.onaudioprocess = null
+        }
+      }
+      // if (runtime.selectedMicrophone2 && !this.isMicrophone2Disabled) {
+      //   const mediaStreams = await requireMedia({
+      //     audio: {
+      //       deviceId: runtime.selectedMicrophone2?.deviceId,
+      //     },
+      //     video: false,
+      //   })
+      //   levelChecker2 = RuntimeUtils.listenAudioChecker(mediaStreams, (num, label) => {
+      //     console.log(num, label)
+      //     this.setIconPath('microphoneicon2', num)
+      //   })
+      // } else {
+      //   console.log('disconnect')
+      //   // @ts-ignore
+      //   if (levelChecker2 && levelChecker2?.onaudioprocess) {
+      //     // @ts-ignore
+      //     // levelChecker2?.onaudioprocess = null
+      //   }
+      // }
+      // for (const item of runtime.microphones) {
+      //   const mediaStreams = await requireMedia({
+      //     audio: {
+      //       deviceId: item.deviceId,
+      //     },
+      //     video: false,
+      //   })
+      //   const indexof = runtime.microphones.indexOf(item)
+      //   RuntimeUtils.listenAudioChecker(mediaStreams, (num, label) => {
+      //     // this.setIconPath('microphoneicon-' + indexof, num)
+      //     // if (indexof === 0) {
+      //     // this.setIconPath('microphoneicon', num)
+      //     // this.setIconPath('microphoneicon2', num)
+      //     // }
+      //     // console.log(num, label, indexof, this.$refs['microphoneicon-' + indexof])
+      //   })
+      //   state.binded = true
+      // }
+    },
     startShare() {
       console.log('调用')
       state.shareVisiable = true
@@ -41,7 +150,35 @@ export default defineComponent({
     volumeChange(value: number) {
       state.volume = value
       RuntimeUtils.setVolume(value)
-    }
+    },
+    async toggleCamera() {
+      RuntimeUtils.toggleDevice('camera')
+      if (runtime.screenShareStatus) {
+        return
+      }
+      state.barStatus.camera = !state.barStatus.camera
+    },
+    async toggleMicrophone() {
+      const needPublish = runtime.videoStatus === 'liveing'
+      state.barStatus.microphone = !state.barStatus.microphone
+      if (!state.barStatus.microphone) {
+        RuntimeUtils.openDevice('microphone', needPublish)
+      } else {
+        RuntimeUtils.closeDevice('microphone', needPublish)
+      }
+      this.listenMicophoneAudioTrack()
+    },
+    async toggleMicrophone2() {
+      const needPublish = runtime.videoStatus === 'liveing'
+      console.log('toggleMicrophone2', state.barStatus.microphone2)
+      state.barStatus.microphone2 = !state.barStatus.microphone2
+      if (!state.barStatus.microphone2) {
+        RuntimeUtils.openDevice('microphone', needPublish)
+      } else {
+        RuntimeUtils.closeDevice('microphone', needPublish)
+      }
+      this.listenMicophoneAudioTrack()
+    },
   },
   render() {
     return (
@@ -50,42 +187,47 @@ export default defineComponent({
           <div class={styles['bar-btn']}>
             <div class={styles.btnInner}>
               <SvgIcon
-                onClick={() => {
-                  RuntimeUtils.toggleDevice('camera')
-                  if (runtime.screenShareStatus) {
-                    return
-                  }
-                  state.barStatus.camera = !state.barStatus.camera
-                }}
-                name={this.isCameraDisabled ? 'bar-camera-disabled' : 'bar-camera'}
+                onClick={this.toggleCamera}
+                name={
+                  this.isCameraDisabled ? 'bar-camera-disabled' : 'bar-camera'
+                }
                 style={{
                   width: '22px',
-                  cursor: 'pointer'
+                  cursor: 'pointer',
                 }}
               />
-              { runtime.cameras.length === 0 ? null : <ElDropdown
-                placement="top"
-                // @ts-ignore
-                disabled={runtime.cameras.length === 0}
-                onCommand={RuntimeUtils.setSelectCamera}
-                // @ts-ignore
-                vSlots={{
-                  dropdown: () => (
-                    <ElDropdownMenu>
-                      {runtime.cameras.map(item => (<ElDropdownItem disabled={item === runtime.selectedCamera} command={item}>{item.label}</ElDropdownItem>))}
-                    </ElDropdownMenu>
-                  )
-                }}
-              >
-                <div class={styles['bar-btn']} style={{ height: '32px' }}>
-                  <SvgIcon
-                    name="bar-arrow-down"
-                    style={{
-                      width: '18px'
-                    }}
-                  />
-                </div>
-              </ElDropdown> }
+              {runtime.cameras.length === 0 ? null : (
+                <ElDropdown
+                  placement="top"
+                  // @ts-ignore
+                  disabled={runtime.cameras.length === 0}
+                  onCommand={RuntimeUtils.setSelectCamera}
+                  // @ts-ignore
+                  vSlots={{
+                    dropdown: () => (
+                      <ElDropdownMenu>
+                        {runtime.cameras.map((item) => (
+                          <ElDropdownItem
+                            disabled={item === runtime.selectedCamera}
+                            command={item}
+                          >
+                            {item.label}
+                          </ElDropdownItem>
+                        ))}
+                      </ElDropdownMenu>
+                    ),
+                  }}
+                >
+                  <div class={styles['bar-btn']} style={{ height: '32px' }}>
+                    <SvgIcon
+                      name="bar-arrow-down"
+                      style={{
+                        width: '18px',
+                      }}
+                    />
+                  </div>
+                </ElDropdown>
+              )}
             </div>
             <span class={styles['bar-btn-text']}>摄像头</span>
           </div>
@@ -133,18 +275,27 @@ export default defineComponent({
             <span class={styles['bar-btn-text']}>音量调节</span>
           </div> */}
 
-          <div class={styles['bar-btn']} onClick={RuntimeUtils.toggleShareScreenVideo}>
+          {/* <div
+            class={styles['bar-btn']}
+            onClick={RuntimeUtils.toggleShareScreenVideo}
+          >
             <div class={styles.btnInner}>
               <SvgIcon
-                name={runtime.videoStatus === 'liveing' ? 'bar-screen-share' : 'bar-screen-share-disabled2'}
+                name={
+                  runtime.videoStatus === 'liveing'
+                    ? 'bar-screen-share'
+                    : 'bar-screen-share-disabled2'
+                }
                 style={{
                   width: '22px',
-                  cursor: 'pointer'
+                  cursor: 'pointer',
                 }}
               />
             </div>
-            <span class={styles['bar-btn-text']}>{runtime.screenShareStatus ? '取消共享' : '屏幕共享'}</span>
-          </div>
+            <span class={styles['bar-btn-text']}>
+              {runtime.screenShareStatus ? '取消共享' : '屏幕共享'}
+            </span>
+          </div> */}
 
           {/* <div class={styles['bar-btn']} >
             <div class={styles.btnInner}>
@@ -160,72 +311,168 @@ export default defineComponent({
           </div> */}
           <div class={styles['bar-btn']}>
             <div class={styles.btnInner}>
+              {this.isMicrophoneDisabled ? (
+                <SvgIcon
+                  name="bar-mike-disabled"
+                  onClick={this.toggleMicrophone}
+                  style={{
+                    width: '22px',
+                    height: '22px',
+                    cursor: 'pointer',
+                  }}
+                />
+              ) : (
+                <span
+                  ref="microphoneicon"
+                  v-html={MicroPhoneIcon}
+                  onClick={this.toggleMicrophone}
+                  // name={this.isMicrophoneDisabled ? 'bar-mike-disabled' : 'bar-mike'}
+                  style={{
+                    width: '22px',
+                    height: '22px',
+                    cursor: 'pointer',
+                  }}
+                />
+              )}
+              {runtime.microphones.length === 0 ? null : (
+                <ElDropdown
+                  placement="top-start"
+                  // @ts-ignore
+                  disabled={runtime.microphones.length === 0}
+                  popper-options={{
+                    boundariesElement: '#action-bar',
+                    gpuAcceleration: false,
+                  }}
+                  visible-change={(visible: boolean) => {
+                    console.log(visible)
+                  }}
+                  onCommand={RuntimeUtils.setSelectMicrophone}
+                  // @ts-ignore
+                  vSlots={{
+                    dropdown: () => (
+                      <ElDropdownMenu>
+                        {runtime.microphones.map((item, index) => (
+                          <ElDropdownItem
+                            disabled={
+                              item === runtime.selectedMicrophone ||
+                              item === runtime.selectedMicrophone2
+                            }
+                            command={item}
+                          >
+                            {/* <span
+                              ref={'microphoneicon-' + index}
+                              v-html={MicroPhoneIcon}
+                              // name={this.isMicrophoneDisabled ? 'bar-mike-disabled' : 'bar-mike'}
+                              style={{
+                                width: '22px',
+                                cursor: 'pointer',
+                              }}
+                            /> */}
+                            {' ' + item.label}
+                          </ElDropdownItem>
+                        ))}
+                      </ElDropdownMenu>
+                    ),
+                  }}
+                >
+                  <div class={styles['bar-btn']} style={{ height: '32px' }}>
+                    <SvgIcon
+                      name="bar-arrow-down"
+                      style={{
+                        width: '18px',
+                      }}
+                    />
+                  </div>
+                </ElDropdown>
+              )}
+            </div>
+            <span class={styles['bar-btn-text']}>麦克风</span>
+          </div>
+          {/* <div class={styles['bar-btn']}>
+            <div class={styles.btnInner}>
               <SvgIcon
-                onClick={() => {
-                  const needPublish = runtime.videoStatus === 'liveing'
-                  state.barStatus.microphone = !state.barStatus.microphone
-                  if (!state.barStatus.microphone) {
-                    RuntimeUtils.openDevice('microphone', needPublish)
-                  } else {
-                    RuntimeUtils.closeDevice('microphone', needPublish)
-                  }
-                }}
-                name={this.isMicrophoneDisabled ? 'bar-mike-disabled' : 'bar-mike'}
+                onClick={this.toggleMicrophone2}
                 style={{
                   width: '22px',
-                  cursor: 'pointer'
+                  height: '22px',
+                  cursor: 'pointer',
                 }}
+                name={this.isMicrophone2Disabled ? 'bar-volume-disabled' : 'bar-volume'}
               />
-              { runtime.microphones.length === 0 ? null : <ElDropdown
-                placement="top-start"
-                // @ts-ignore
-                disabled={runtime.microphones.length === 0}
-                popper-options={{ boundariesElement: '#action-bar', gpuAcceleration: false }}
-                onCommand={RuntimeUtils.setSelectMicrophone}
-                // @ts-ignore
-                vSlots={{
-                  dropdown: () => (
-                    <ElDropdownMenu>
-                      {runtime.microphones.map(item => (<ElDropdownItem disabled={item === runtime.selectedMicrophone} command={item}>{item.label}</ElDropdownItem>))}
-                    </ElDropdownMenu>
-                  )
-                }}
-              >
-                <div class={styles['bar-btn']} style={{ height: '32px' }}>
-                  <SvgIcon
-                    name="bar-arrow-down"
-                    style={{
-                      width: '18px'
-                    }}
-                  />
-                </div>
-              </ElDropdown>}
+              {runtime.microphones.length === 0 ? null : (
+                <ElDropdown
+                  placement="top-start"
+                  // @ts-ignore
+                  disabled={runtime.microphones.length === 0}
+                  popper-options={{
+                    boundariesElement: '#action-bar',
+                    gpuAcceleration: false,
+                  }}
+                  visible-change={(visible: boolean) => {
+                    console.log(visible)
+                  }}
+                  onCommand={RuntimeUtils.setSelectMicrophone2}
+                  // @ts-ignore
+                  vSlots={{
+                    dropdown: () => (
+                      <ElDropdownMenu>
+                        {runtime.microphones.map((item, index) => (
+                          <ElDropdownItem
+                            disabled={
+                              item === runtime.selectedMicrophone2 ||
+                              item === runtime.selectedMicrophone
+                            }
+                            command={item}
+                          >
+                            {' ' + item.label}
+                          </ElDropdownItem>
+                        ))}
+                      </ElDropdownMenu>
+                    ),
+                  }}
+                >
+                  <div class={styles['bar-btn']} style={{ height: '32px' }}>
+                    <SvgIcon
+                      name="bar-arrow-down"
+                      style={{
+                        width: '18px',
+                      }}
+                    />
+                  </div>
+                </ElDropdown>
+              )}
             </div>
-            <span class={styles['bar-btn-text']}>麦克风</span>
-          </div>
-
+            <span class={styles['bar-btn-text']}>系统音频</span>
+          </div> */}
         </div>
         <div style={{ display: 'flex' }} onClick={this.startShare}>
-          <div class={styles['bar-btn']} >
+          <div class={styles['bar-btn']}>
             <div class={styles.btnInner}>
               <SvgIcon
                 name="bar-share"
                 style={{
                   width: '22px',
-                  cursor: 'pointer'
+                  cursor: 'pointer',
                 }}
               />
             </div>
-            <span class={styles['bar-btn-text']} >分享</span>
+            <span class={styles['bar-btn-text']}>分享</span>
           </div>
         </div>
         {/* <ElButton onClick={RuntimeUtils.shareScreenVideo}>屏幕共享</ElButton> */}
-        <ElDialog width="510px"
-        destroy-on-close
-          append-to-body modelValue={state.shareVisiable} title="分享" before-close={() => { state.shareVisiable = false }}>
-            <Share onClose={()=>state.shareVisiable = false}/>
+        <ElDialog
+          width="510px"
+          destroy-on-close
+          append-to-body
+          modelValue={state.shareVisiable}
+          title="分享"
+          before-close={() => {
+            state.shareVisiable = false
+          }}
+        >
+          <Share onClose={() => (state.shareVisiable = false)} />
         </ElDialog>
       </div>
     )
-  }
+  },
 })

+ 16 - 0
src/components/live-broadcast/images/microphone.svg

@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="22px" height="22px" viewBox="0 0 22 22" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <title>麦克风(启动中)</title>
+    <g id="后台直播界面" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+        <g id="图标页面状态" transform="translate(-67.000000, -244.000000)">
+            <g id="编组-25备份-3" transform="translate(56.000000, 239.000000)">
+                <g id="麦克风(启动中)" transform="translate(11.000000, 5.000000)">
+                    <rect id="矩形" x="0" y="0" width="22" height="22"></rect>
+                    <path d="M19,10.85 C19,15.102 15.786,18.626 11.632,19.177 L11.632,19.88 C11.632,20.496 11.122,21 10.5,21 C9.877,21 9.367,20.496 9.367,19.88 L9.367,19.177 C5.213,18.626 2,15.105 2,10.85 C2,10.334 2.423,9.915 2.945,9.915 C3.467,9.915 3.891,10.334 3.891,10.85 C3.891,14.455 6.856,17.386 10.502,17.386 C14.148,17.386 17.113,14.455 17.113,10.85 C17.113,10.334 17.537,9.915 18.059,9.915 C18.581,9.915 19.002,10.334 19,10.85 Z" id="路径" fill="#FFFFFF"></path>
+                    <path d="M7,7.83993935 L14,7.83993935 L14,10.5000027 C14,12.4329993 12.4329966,14.0000027 10.5,14.0000027 C8.56700338,14.0000027 7,12.4329993 7,10.5000027 L7,7.83993935 L7,7.83993935 Z" id="microphone-content-svg" fill="#2DC7AA"></path>
+                    <path d="M10.554,1 C13.2296842,1.00194737 15.4123767,3.12944737 15.5281717,5.79751675 L15.533,6.015 L15.533,10.339 C15.529,13.108 13.302,15.352 10.554,15.354 C7.87928947,15.3510789 5.6985187,13.2235533 5.58374736,10.5564064 L5.579,10.339 L5.579,6.015 C5.581,3.247 7.807,1.004 10.554,1 Z M10.554,2.999 L10.3822831,3.00534888 C8.87978198,3.09547648 7.67258605,4.31266034 7.58418786,5.83771274 L7.57899948,6.015 L7.57899948,10.3375549 C7.58020489,12.0058476 8.91580953,13.3522097 10.5525444,13.3540005 C12.135134,13.3528487 13.4347128,12.1006072 13.5276824,10.5163484 L13.5330012,10.339 L13.5330012,6.01716685 C13.5312573,4.40761068 12.2878451,3.09780557 10.7272071,3.00522987 L10.554,2.999 Z" id="路径" fill="#FFFFFF" fill-rule="nonzero"></path>
+                </g>
+            </g>
+        </g>
+    </g>
+</svg>

+ 26 - 1
src/components/live-broadcast/index.tsx

@@ -7,7 +7,7 @@ import { state } from '/src/state'
 import event, { LIVE_EVENT_MESSAGE } from './event'
 import runtime, * as RuntimeUtils from './runtime'
 import Chronography from './chronography'
-// import { removeMedia } from './helpers'
+import { requireMedia } from './helpers'
 import styles from './index.module.less'
 
 const videoRef = ref<HTMLVideoElement | null>(null)
@@ -40,12 +40,26 @@ export default defineComponent({
     RuntimeUtils.loopSyncLike()
     event.on(LIVE_EVENT_MESSAGE['RC:Chatroom:Like'], this.onLikeMessage)
     window.onbeforeunload = this.beforeunload
+    window.addEventListener('focus', this.visibilitychange)
+    window.addEventListener('blur', this.visibilitychange)
   },
   beforeUnmount() {
     event.off(LIVE_EVENT_MESSAGE['RC:Chatroom:Like'], this.onLikeMessage)
     window.onbeforeunload = null
+    window.removeEventListener('focus', this.visibilitychange)
+    window.removeEventListener('blur', this.visibilitychange)
   },
   methods: {
+    visibilitychange(evt: FocusEvent) {
+      console.log(evt)
+      if (evt.type === 'focus') {
+        document.exitPictureInPicture()
+        document.body.click()
+      }
+      if (evt.type === 'blur') {
+        runtime.videoRef?.requestPictureInPicture()
+      }
+    },
     beforeunload() {
       if (runtime.videoStatus === 'liveing') {
         return '当前正在直播中是否确认关闭页面?'
@@ -57,6 +71,7 @@ export default defineComponent({
     getDeviceByDeviceType(type: RuntimeUtils.TrackType) {
       const videoDeviceId = localStorage.getItem(RuntimeUtils.VIDEO_DEVICE_ID)
       const audioDeviceId = localStorage.getItem(RuntimeUtils.AUDIO_DEVICE_ID)
+      const audioDeviceId2 = localStorage.getItem(RuntimeUtils.AUDIO_DEVICE_ID2)
       if (type === 'camera') {
         if (videoDeviceId) {
           return runtime.cameras.find(camera => camera.deviceId === videoDeviceId) || runtime.cameras[0]
@@ -66,6 +81,9 @@ export default defineComponent({
       if (audioDeviceId) {
         return runtime.microphones.find(microphone => microphone.deviceId === audioDeviceId) || runtime.microphones[0]
       }
+      if (audioDeviceId2) {
+        return runtime.microphones.find(microphone => microphone.deviceId === audioDeviceId2) || runtime.microphones[0]
+      }
       return runtime.microphones[0]
     },
     async initializeRoom () {
@@ -83,6 +101,7 @@ export default defineComponent({
         // 设置播放设备
         RuntimeUtils.setSelectCamera(this.getDeviceByDeviceType('camera'))
         RuntimeUtils.setSelectMicrophone(this.getDeviceByDeviceType('microphone'))
+        // RuntimeUtils.setSelectMicrophone2(this.getDeviceByDeviceType('microphone2'))
         cameraVideoTrack = await RuntimeUtils.getTrack('camera')
         runtime.videoRef && cameraVideoTrack.play(runtime.videoRef)
         // await RuntimeUtils.setTrack([cameraVideoTrack], 'camera', isLiveing)
@@ -132,6 +151,12 @@ export default defineComponent({
         })
         if (join.room && join.code === RTC.RCRTCCode.SUCCESS) {
           runtime.joinedRoom = join.room
+          join.room.registerReportListener({
+            onStateReport(report) {
+              event.emit('onStateReport', report)
+              console.log('onStateReport', report)
+            }
+          })
         }
         if (isLiveing) {
           await RuntimeUtils.startLive(false)

+ 129 - 15
src/components/live-broadcast/runtime.ts

@@ -2,17 +2,20 @@ import { ElMessage, ElMessageBox } from 'element-plus';
 import { reactive, ref, Ref } from 'vue'
 import * as RongIMLib from '@rongcloud/imlib-next'
 import * as RTC from '@rongcloud/plugin-rtc'
+import { debounce } from 'throttle-debounce'
 import request from '/src/helpers/request'
 import { state } from '/src/state'
+import mitt from 'mitt'
 import event, { LIVE_EVENT_MESSAGE } from './event'
 import dayjs from 'dayjs'
+import { requireMedia } from './helpers';
 // import { SeatsCtrl } from './message-type'
 
 type imConnectStatus = 'connecting' | 'connected' | 'disconnect'
 
 type VideoStatus = 'init' | 'stream' | 'liveing' | 'stopped' | 'error' | 'loading'
 
-export type TrackType = 'microphone' | 'camera' | 'screen'
+export type TrackType = 'microphone' | 'microphone2' | 'camera' | 'screen'
 
 type ActiveTracks = {
   [key in TrackType]: RTC.RCLocalTrack | null
@@ -30,6 +33,8 @@ export const VIDEO_DEVICE_ID = 'video-deviceId'
 
 export const AUDIO_DEVICE_ID = 'audio-deviceId'
 
+export const AUDIO_DEVICE_ID2 = 'audio-deviceId2'
+
 export const AUDIO_DEVICE_VOLUME = 'audio-device-volume'
 
 const runtime = reactive({
@@ -59,6 +64,8 @@ const runtime = reactive({
   selectedCamera: null as MediaDeviceInfo | null,
   // 麦克风设备
   selectedMicrophone: null as MediaDeviceInfo | null,
+  // 系统音频设备
+  selectedMicrophone2: null as MediaDeviceInfo | null,
   // 点赞数量
   likeCount: 0,
   // 观看人数
@@ -124,6 +131,8 @@ type MessageEvent = {
   messages: MessageProps[],
 }
 
+export const runtimeEvent = mitt()
+
 const Events = RongIMLib.Events
 /**
  * 监听消息通知
@@ -260,6 +269,7 @@ export const shareScreenVideo = async () => {
  */
 
 export const closeShareScreenVideo = () => {
+  document.exitPictureInPicture()
   const screenTrack = runtime.activeTracks.screen as RTC.RCLocalTrack
   if (screenTrack) {
     screenTrack.destroy()
@@ -274,6 +284,20 @@ export const closeShareScreenVideo = () => {
   }
 }
 
+export const createVideoPictureInPicture = (ms: MediaStream) => {
+  const video = document.createElement('video')
+  video.style.display = 'none'
+  document.body.append(video)
+  video.srcObject = ms
+  video.play()
+  setTimeout(() => {
+    video.requestPictureInPicture()
+  }, 1000)
+}
+
+/**
+ * 开启或关闭屏幕共享
+ */
 export const toggleShareScreenVideo = async () => {
   if (runtime.screenShareStatus) {
     try {
@@ -282,6 +306,10 @@ export const toggleShareScreenVideo = async () => {
     } catch (error) {}
   } else {
     shareScreenVideo()
+    // @ts-ignore
+    createVideoPictureInPicture(runtime.activeTracks.camera._msStream)
+    console.log(runtime.activeTracks.camera)
+    // runtime.videoRef?.requestPictureInPicture()
   }
 }
 
@@ -337,6 +365,24 @@ export const setSelectMicrophone = async (microphone: MediaDeviceInfo) => {
   }
   const track = await getTrack('microphone')
   setTrack([track], 'microphone', runtime.videoStatus === 'liveing')
+  runtimeEvent.emit('microphoneChange', microphone)
+}
+
+/**
+ *
+ * 设置当前麦克风设备
+ * @param microphone MediaDeviceInfo
+ */
+ export const setSelectMicrophone2 = async (microphone: MediaDeviceInfo) => {
+  runtime.selectedMicrophone2 = microphone
+  localStorage.setItem(AUDIO_DEVICE_ID2, microphone.deviceId)
+  const oldTrack = runtime.activeTracks.microphone2 as RTC.RCLocalTrack
+  if (oldTrack) {
+    await removeTrack([oldTrack], 'microphone2', oldTrack.isPublished())
+  }
+  const track = await getTrack('microphone2')
+  setTrack([track], 'microphone2', runtime.videoStatus === 'liveing')
+  runtimeEvent.emit('microphone2Change', microphone)
 }
 
 type TrackResult = {
@@ -350,17 +396,28 @@ export const getTrack = async (trackType: TrackType): Promise<RTC.RCLocalTrack>
   if (trackType === 'microphone') {
     res = await runtime.rtcClient?.createMicrophoneAudioTrack('RongCloudRTC', {
       micphoneId: runtime.selectedMicrophone?.deviceId,
+      sampleRate: 44100,
+    }) as TrackResult
+  } else if (trackType === 'microphone2') {
+    res = await runtime.rtcClient?.createMicrophoneAudioTrack('RongCloudRTC', {
+      micphoneId: runtime.selectedMicrophone2?.deviceId,
+
     }) as TrackResult
   } else if (trackType === 'camera') {
+    // const sm = await requireMedia({
+    //   audio: true,
+    //   video: true,
+    // })
+    // console.log(sm.getTracks())
     res = await runtime.rtcClient?.createCameraVideoTrack('RongCloudRTC', {
       cameraId: runtime.selectedCamera?.deviceId,
       faceMode: 'user',
-      frameRate: RTC.RCFrameRate.FPS_24,
+      frameRate: RTC.RCFrameRate.FPS_30,
       resolution: RTC.RCResolution.W1920_H1080,
     }) as TrackResult
   } else {
     res = await runtime?.rtcClient?.createScreenVideoTrack('screenshare', {
-      frameRate: RTC.RCFrameRate.FPS_24,
+      frameRate: RTC.RCFrameRate.FPS_30,
       resolution: RTC.RCResolution.W1920_H1080,
     }) as TrackResult
   }
@@ -368,7 +425,7 @@ export const getTrack = async (trackType: TrackType): Promise<RTC.RCLocalTrack>
   Track = res?.track as RTC.RCLocalTrack
   if (trackType === 'camera' && !runtime.cameras.length) {
     runtime.deviceStatus[trackType] = 'none'
-  } else if (trackType === 'microphone' && !runtime.microphones.length) {
+  } else if ((trackType === 'microphone' || trackType === 'microphone2') && !runtime.microphones.length) {
     runtime.deviceStatus[trackType] = 'none'
   } else if (trackType === 'screen' && !runtime.screenShareStatus) {
     runtime.deviceStatus[trackType] = 'none'
@@ -387,24 +444,80 @@ export const getTrack = async (trackType: TrackType): Promise<RTC.RCLocalTrack>
   return Track
 }
 
+export type OnAudioProcess = (num: number, deviceId: string) => void
+
+export const listenAudioChecker = (stream:  MediaStream, onaudioprocess: OnAudioProcess) => {
+  const audioContext = window.AudioContext
+  const ac = new audioContext()
+  const liveSource = ac.createMediaStreamSource(stream)
+  const analyser = ac.createAnalyser()
+  liveSource.connect(analyser)
+  analyser.fftSize = 2048
+  analyser.minDecibels = -90
+  analyser.maxDecibels = -10
+  analyser.smoothingTimeConstant = 0.85
+
+  // setInterval(() => {
+  //   getVoiceSize(analyser)
+  // }, 50)
+  // return analyser
+
+  const levelChecker = ac.createScriptProcessor(4096, 1, 1)
+  levelChecker.connect(ac.destination)
+  liveSource.connect(levelChecker)
+  levelChecker.onaudioprocess = (e) => debounce(200, () => {
+    const buffer = e.inputBuffer.getChannelData(0)
+    var maxVal = 0;
+		for (var i = 0; i < buffer.length; i++) {
+			if (maxVal < buffer[i]) {
+				maxVal = buffer[i];
+			}
+		}
+    // console.log(stream.getAudioTracks()[0])
+    onaudioprocess(maxVal * 100, stream.getAudioTracks()[0]?.label)
+    // console.log(maxVal * 100, stream.getAudioTracks()[0]?.label)
+    // console.log(e.inputBuffer.getChannelData(0))
+  })()
+  return levelChecker
+}
+
+const getVoiceSize = (analyser: AnalyserNode) => {
+  const dataArray = new Uint8Array(analyser.frequencyBinCount)
+  analyser.getByteFrequencyData(dataArray)
+  const data = dataArray.slice(100, 1000)
+  const sum = data.reduce((a, b) => a + b)
+
+  // for(var i = 0; i < analyser.frequencyBinCount; i++) {
+  //   var v = dataArray[i] / 128.0
+  //   console.log(v)
+  // }
+  console.log(sum, 128 * analyser.frequencyBinCount)
+}
+
+
+
 /**
  * 添加视频流,会同步修改当先视频与推送的流
  * @param track
  */
 export const setTrack = async (tracks: RTC.RCLocalTrack[], trackType: TrackType, needPublish = true) => {
-  for (const track of tracks) {
+  const filterTracks = tracks.filter(track => !!track)
+  for (const track of filterTracks) {
     // @ts-ignore
     // await runtime.mediaStreams?.addTrack(track._msTrack)
-    // if (trackType === 'microphone') {
-    //   console.log('添加麦克风')
-    //   track?.play()
-    // }
+    if (trackType === 'microphone') {
+      // track?.play()
+    }
     runtime.activeTracks[trackType] = track
   }
-  console.log(needPublish)
+
+  if (trackType === 'camera' && runtime.videoRef) {
+    runtime.activeTracks[trackType]?.play(runtime.videoRef)
+  }
+
   if (needPublish) {
     // console.log('publish', runtime.joinedRoom)
-    await runtime.joinedRoom?.publish(tracks.filter(track => !!track))
+    await runtime.joinedRoom?.publish(filterTracks)
   }
 }
 /**
@@ -412,10 +525,11 @@ export const setTrack = async (tracks: RTC.RCLocalTrack[], trackType: TrackType,
  * @param track
  */
 export const removeTrack = async (tracks: RTC.RCLocalTrack[], trackType: TrackType, needPublish = true) => {
+  const filterTracks = tracks.filter(track => !!track)
   if (needPublish) {
-    await runtime.joinedRoom?.unpublish(tracks.filter(track => !!track))
+    await runtime.joinedRoom?.unpublish(filterTracks)
   }
-  for (const track of tracks) {
+  for (const track of filterTracks) {
     // @ts-ignore
     // await runtime.mediaStreams?.removeTrack(track._msTrack)
     // runtime.activeTracks[trackType].destroy()
@@ -585,7 +699,7 @@ export const sendMessage = async (msg: any, type: SendMessageType = 'text') => {
 }
 
 export const openDevice = async (trackType: TrackType, needPublish = true) => {
-  if (trackType === 'microphone' && runtime.activeTracks[trackType]) {
+  if ((trackType === 'microphone' || trackType === 'microphone2') && runtime.activeTracks[trackType]) {
     runtime.activeTracks[trackType]?.unmute()
   } else {
     const track = await getTrack(trackType)
@@ -598,7 +712,7 @@ export const openDevice = async (trackType: TrackType, needPublish = true) => {
 
 export const closeDevice = async (trackType: TrackType, needPublish = true) => {
   const track = runtime.activeTracks[trackType]
-  if (trackType !== 'microphone') {
+  if (trackType !== 'microphone' && trackType !== 'microphone2') {
     // console.log('closeDevice', track)
     // track?.destroy()
     await removeTrack([track] as RTC.RCLocalTrack[], trackType, needPublish)

+ 6 - 1
src/components/svg-icon/index.tsx

@@ -15,13 +15,18 @@ export default defineComponent({
       type: String,
       default: '#333',
     },
+    onClick: {
+      type: Function,
+      default: () => {},
+    }
   },
   render () {
     const {name, prefix, color,} = this
     const symbolId = `#${prefix}-${name}`
 
     return (
-      <svg {...this.$attrs} aria-hidden="true" style={{ color: color }}>
+      //@ts-ignore
+      <svg {...this.$attrs} onClick={this.onClick} aria-hidden="true" style={{ color: color }}>
         <use href={symbolId} fill={color} />
       </svg>
     )

+ 84 - 22
src/pages/home/header/index.tsx

@@ -1,50 +1,86 @@
-import { defineComponent } from "vue";
-import { ElDropdownMenu, ElDropdown, ElDropdownItem, ElMessage } from "element-plus";
-import router from "/src/router";
-import styles from './index.module.less';
-import request from '/src/helpers/request';
-import runtime, * as RuntimeUtils from "/src/components/live-broadcast/runtime";
-import { removeToken } from "/src/utils/auth";
+import { defineComponent } from 'vue'
+import {
+  ElDropdownMenu,
+  ElDropdown,
+  ElDropdownItem,
+  ElMessage,
+} from 'element-plus'
+import router from '/src/router'
+import styles from './index.module.less'
+import request from '/src/helpers/request'
+import runtime, * as RuntimeUtils from '/src/components/live-broadcast/runtime'
+import { removeToken } from '/src/utils/auth'
 import { removeMedia } from '/src/components/live-broadcast/helpers'
+import WifiIcon from './wifi.svg'
+import Wifi1Icon from './wifi1.svg'
+import Wifi2Icon from './wifi2.svg'
+import event from '/src/components/live-broadcast/event'
+import * as RTC from '@rongcloud/plugin-rtc'
 import { state } from '/src/state'
 import userLogo from '/src/assets/home/placehorder-icon.png'
 
 export default defineComponent({
+  data() {
+    return {
+      iceCandidatePair: null as RTC.IRCCandidatePairStat | null,
+    }
+  },
   methods: {
     async loginOut() {
       try {
         await RuntimeUtils.leaveIMRoom()
-        await request.post('/api-auth/exit', { data: {} });
+        await request.post('/api-auth/exit', { data: {} })
         RuntimeUtils.closeDevice('camera')
         RuntimeUtils.closeDevice('microphone')
         state.user = null
         runtime.syncLikeTimer && clearTimeout(runtime.syncLikeTimer)
-        ElMessage.success('退出成功');
-        removeToken();
-        (this as any).$router.push({
+        ElMessage.success('退出成功')
+        removeToken()
+        ;(this as any).$router.push({
           path: '/login',
           query: {
-            ...this.$route.query
-          }
-        });
-      } catch(e) {
+            ...this.$route.query,
+          },
+        })
+      } catch (e) {
         // TODO: handle error
       }
+    },
+    onStateReport(evt: any) {
+      this.iceCandidatePair = evt.iceCandidatePair
+    },
+    getWifiICon(bit: number) {
+      if (bit >= 2000) {
+        return WifiIcon
+      }
+      if (bit >= 1000) {
+        return Wifi1Icon
+      }
+      return Wifi2Icon
     }
   },
+  mounted() {
+    event.on('onStateReport', this.onStateReport)
+  },
+  beforeUnmount() {
+    event.off('onStateReport', this.onStateReport)
+  },
   render() {
+    const iceCandidatePair = this.iceCandidatePair as RTC.IRCCandidatePairStat
     return (
       <div class={styles.liveHeader}>
         <div class={styles.liveHeaderLeft}>
           <div class={styles.liveHeaderLeftIcon}>
-            <img class={styles.liveLogo} src={state.user?.tenantLogo} alt=""/>
+            <img class={styles.liveLogo} src={state.user?.tenantLogo} alt="" />
             {state.user?.tenantName}
           </div>
           <div class={styles.liveHeaderLeftText}>
             《{state.user?.roomTitle}》
           </div>
         </div>
-        <ElDropdown trigger={'hover'}
+        <ElDropdown
+          trigger={'hover'}
+          placement={'bottom-end'}
           // @ts-ignore
           vSlots={{
             dropdown: () => (
@@ -53,14 +89,40 @@ export default defineComponent({
                   <span>安全退出</span>
                 </ElDropdownItem>
               </ElDropdownMenu>
-            )
-          }}>
+            ),
+          }}
+        >
           <div class={styles.avatarWrapper}>
-            {state.user?.speakerPic ? <img class={styles.userAvatar} src={state.user?.speakerPic} /> : <img class={styles.userAvatar} src={userLogo} />}
-            <span>{ state.user?.speakerName }</span>
+            {runtime.videoStatus === 'liveing' && iceCandidatePair ? (
+              <div style={{
+                background: `#5E626D`,
+                borderRadius: '6px',
+                width: '113px',
+                height: '32px',
+                lineHeight: '32px',
+                marginRight: '10px',
+                textAlign: 'center',
+                display: 'flex',
+                justifyContent: 'center',
+              }}
+              >
+                <span style={{
+                  fontSize: '14px',
+                  color: '#fff',
+                  flex: 1,
+                }}>{iceCandidatePair.bitrateSend}kbps</span>
+                <img style={{marginLeft: '5px', marginRight: '10px', width: '20px'}} src={this.getWifiICon(iceCandidatePair.bitrateSend)}/>
+              </div>
+            ) : null}
+            {state.user?.speakerPic ? (
+              <img class={styles.userAvatar} src={state.user?.speakerPic} />
+            ) : (
+              <img class={styles.userAvatar} src={userLogo} />
+            )}
+            <span>{state.user?.speakerName}</span>
           </div>
         </ElDropdown>
       </div>
     )
-  }
+  },
 })

+ 13 - 0
src/pages/home/header/wifi.svg

@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="20px" height="14px" viewBox="0 0 20 14" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <title>网络信号</title>
+    <g id="后台直播界面" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+        <g id="图标页面状态" transform="translate(-697.000000, -264.000000)" fill="#00FFF0" fill-rule="nonzero">
+            <g id="编组-14" transform="translate(685.000000, 255.000000)">
+                <g id="网络信号" transform="translate(12.000000, 9.000000)">
+                    <path d="M0,4.0828061 C5.53263112,-1.36093537 14.5108046,-1.36093537 20,4.0828061 L18.1691823,5.89846349 C13.6594636,1.42393416 6.34053643,1.42393416 1.83081768,5.89846349 L0,4.0828061 Z M7.27549137,11.2991289 C8.76533826,9.82377262 11.2346617,9.82377262 12.7234227,11.2991289 L10,14 L7.27549137,11.2991289 L7.27549137,11.2991289 Z M3.65946357,7.66996789 C7.19187751,4.20988058 12.8515583,4.20988058 16.3405364,7.66996789 L14.5531545,9.48562528 C12.0375994,6.9950235 7.9634865,6.9950235 5.44793137,9.48562528 L3.65946357,7.66996789 Z" id="形状"></path>
+                </g>
+            </g>
+        </g>
+    </g>
+</svg>

+ 14 - 0
src/pages/home/header/wifi1.svg

@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="20px" height="14px" viewBox="0 0 20 14" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <title>网络信号</title>
+    <g id="后台直播界面" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+        <g id="图标页面状态" transform="translate(-818.000000, -264.000000)" fill-rule="nonzero">
+            <g id="编组-14备份-2" transform="translate(806.000000, 255.000000)">
+                <g id="网络信号" transform="translate(12.000000, 9.000000)">
+                    <path d="M7.27549137,11.2991289 C8.76533826,9.82377262 11.2346617,9.82377262 12.7234227,11.2991289 L10,14 L7.27549137,11.2991289 L7.27549137,11.2991289 Z M3.65946357,7.66996789 C7.19187751,4.20988058 12.8515583,4.20988058 16.3405364,7.66996789 L14.5531545,9.48562528 C12.0375994,6.9950235 7.9634865,6.9950235 5.44793137,9.48562528 L3.65946357,7.66996789 Z" id="形状" fill="#00FFF0"></path>
+                    <path d="M0,4.0828061 C5.53263112,-1.36093537 14.5108046,-1.36093537 20,4.0828061 L18.1691823,5.89846349 C13.6594636,1.42393416 6.34053643,1.42393416 1.83081768,5.89846349 L0,4.0828061 Z" id="路径" fill-opacity="0.5" fill="#FFFFFF"></path>
+                </g>
+            </g>
+        </g>
+    </g>
+</svg>

+ 15 - 0
src/pages/home/header/wifi2.svg

@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="20px" height="14px" viewBox="0 0 20 14" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <title>网络信号</title>
+    <g id="后台直播界面" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+        <g id="图标页面状态" transform="translate(-939.000000, -264.000000)" fill-rule="nonzero">
+            <g id="编组-14备份" transform="translate(927.000000, 255.000000)">
+                <g id="网络信号" transform="translate(12.000000, 9.000000)">
+                    <path d="M0,4.0828061 C5.53263112,-1.36093537 14.5108046,-1.36093537 20,4.0828061 L18.1691823,5.89846349 C13.6594636,1.42393416 6.34053643,1.42393416 1.83081768,5.89846349 L0,4.0828061 Z" id="路径" fill-opacity="0.5" fill="#FFFFFF"></path>
+                    <path d="M7.27549137,11.2991289 C8.76533826,9.82377262 11.2346617,9.82377262 12.7234227,11.2991289 L10,14 L7.27549137,11.2991289 L7.27549137,11.2991289 Z" id="路径" fill="#FF4E19"></path>
+                    <path d="M3.65946357,7.66996789 C7.19187751,4.20988058 12.8515583,4.20988058 16.3405364,7.66996789 L14.5531545,9.48562528 C12.0375994,6.9950235 7.9634865,6.9950235 5.44793137,9.48562528 L3.65946357,7.66996789 Z" id="路径" fill-opacity="0.5" fill="#FFFFFF"></path>
+                </g>
+            </g>
+        </g>
+    </g>
+</svg>