shuisheng hace 1 año
padre
commit
28fd06bded

+ 20 - 13
README.md

@@ -37,10 +37,11 @@ billd 直播间,目前实现了类似 [bilibili 的 Web 在线直播](https://
 
 - [x] 原生 webrtc 推拉流
 - [x] srs webrtc 推流,`http-flv` 或 `hls`拉流
-- [x] msr 推流,ffmpeg转码,`http-flv` 或 `hls`拉流
-- [x] 一对一打PK
-- [x] 一对多打PK
-- [x] 多对多打PK
+- [x] msr 推流,ffmpeg 转码,`http-flv` 或 `hls`拉流
+- [x] 一对一打 PK
+- [x] 一对多打 PK
+- [x] 多对多打 PK
+- [x] 多平台转推(b 站、虎牙直播)
 - [x] 前端混流
 - [x] 推流鉴权
 - [x] 拉流鉴权
@@ -51,24 +52,27 @@ billd 直播间,目前实现了类似 [bilibili 的 Web 在线直播](https://
 - [x] 商品模块
 - [x] 适配移动端
 - [x] 在线后台
-- [x] 多平台转推(b站、虎牙直播)
 - [x] 接入腾讯云-云直播
-- [ ] 接入腾讯云-实时音视频TRTC
+- [ ] 接入腾讯云-实时音视频 TRTC
 
 ## 技术栈
 
-- 前端相关:[Vue3](https://vuejs.org) 以及相关技术栈、`Typescript`、`WebRTC`、`WebCodecs`、`Web Audio`、`Web Workder`、`Canvas`
+- 前端相关:[Vue3](https://vuejs.org) 以及相关技术栈、`Typescript`、`WebRTC`、`WebCodecs`、`Web Workder`、`Web Audio`、`Canvas`
 - 后端相关:[Nodejs](https://nodejs.org) 以及相关技术栈、`Koa2`、`Sequelize`、`Mysql`、`Redis`、`Socket.io`
 - 流媒体服务器相关:[SRS](https://ossrs.net)、 [FFmpeg](https://ffmpeg.org)、[Coturn](https://github.com/coturn/coturn)
 - Docker 相关:[Docker](https://www.docker.com)
 
+## 私有化部署
+
+[https://live.hsslive.cn/privatizationDeployment](https://live.hsslive.cn/privatizationDeployment)
+
 ## 接口文档
 
-apifox:[https://apifox.com/apidoc/shared-c7556b54-17b2-494e-a039-572d83f103ed](https://apifox.com/apidoc/shared-c7556b54-17b2-494e-a039-572d83f103ed)
+Apifox:[https://apifox.com/apidoc/shared-c7556b54-17b2-494e-a039-572d83f103ed](https://apifox.com/apidoc/shared-c7556b54-17b2-494e-a039-572d83f103ed)
 
-## 下载
+## 客户端下载
 
-[https://live.hsslive.cn/download](https://live.hsslive.cn/download)
+官网下载:[https://live.hsslive.cn/download/live](https://live.hsslive.cn/download/live)
 
 ## 预览
 
@@ -167,9 +171,9 @@ apifox:[https://apifox.com/apidoc/shared-c7556b54-17b2-494e-a039-572d83f103ed]
 
 ## 本地启动
 
-> b站教程:[从零搭建迷你版b站web直播间合集](https://space.bilibili.com/381307133/channel/collectiondetail?sid=1458070),看里面带 `从零搭建迷你b站直播间` 封面的视频。
+> b 站教程:[从零搭建迷你版 b  web 直播间合集](https://space.bilibili.com/381307133/channel/collectiondetail?sid=1458070),看里面带 `从零搭建迷你b站直播间` 封面的视频。
 >
-> billd-live付费课:[https://www.hsslive.cn/article/151](https://www.hsslive.cn/article/151)
+> billd-live 付费课:[https://www.hsslive.cn/article/151](https://www.hsslive.cn/article/151)
 
 ### billd-live
 
@@ -219,7 +223,10 @@ pnpm i billd-utils@latest billd-scss@latest billd-html-webpack-plugin@latest
 # 1.初始化docker容器
 pnpm run docker:dev
 
-# 2.运行(4300端口)
+# 2.初始化数据库(可选,只需要执行一次)
+pnpm run mysql:dev
+
+# 3.运行(4300端口)
 pnpm run dev
 ```
 

+ 88 - 0
src/components/FloatTip/index.vue

@@ -0,0 +1,88 @@
+<template>
+  <div
+    class="float-tip-wrap"
+    @mousemove="handleMousemove"
+    @mouseleave="handleMouseleave"
+  >
+    <div
+      ref="txtRef"
+      class="txt"
+    >
+      {{ handleStrEllipsis(txt, maxLen) }}
+    </div>
+    <div
+      v-if="show"
+      ref="floatTxtRef"
+      class="float-txt"
+    >
+      {{ txt }}
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { nextTick, reactive, ref } from 'vue';
+
+import { handleStrEllipsis } from '@/utils';
+
+const txtRef = ref<HTMLDivElement>();
+const floatTxtRef = ref<HTMLDivElement>();
+const menuPosition = reactive({ top: 0, left: 0 });
+const show = ref(false);
+
+withDefaults(
+  defineProps<{
+    txt: string;
+    maxLen: number;
+  }>(),
+  {}
+);
+
+function handleMousemove(e: MouseEvent) {
+  if (show.value) return;
+  show.value = true;
+  nextTick(() => {
+    if (floatTxtRef.value && txtRef.value) {
+      const docW = document.documentElement.clientWidth;
+      const docH = document.documentElement.clientHeight;
+      const floatTxtRect = floatTxtRef.value.getBoundingClientRect();
+      const txtRect = txtRef.value.getBoundingClientRect();
+      menuPosition.left = e.clientX;
+      menuPosition.top = txtRect.bottom;
+      let leftRes = menuPosition.left;
+      let topRes = menuPosition.top;
+      if (menuPosition.left + floatTxtRect.width > docW) {
+        leftRes = docW - floatTxtRect.width;
+      }
+      if (menuPosition.top + floatTxtRect.height > docH) {
+        topRes = txtRect.top - floatTxtRect.height;
+        floatTxtRef.value.style.top = `${topRes - 5}px`;
+      } else {
+        floatTxtRef.value.style.top = `${topRes + 5}px`;
+      }
+      floatTxtRef.value.style.left = `${leftRes}px`;
+    }
+  });
+}
+function handleMouseleave() {
+  show.value = false;
+}
+</script>
+
+<style lang="scss" scoped>
+.float-tip-wrap {
+  display: inline-block;
+  .float-txt {
+    position: fixed;
+    top: 0;
+    left: 0;
+    z-index: 100;
+    padding: 4px 8px;
+    border: 1px solid #e4e7e9;
+    border-radius: 4px;
+    background-color: white;
+    box-shadow: 0px 1px 10px 0px #5971c721;
+    color: #333;
+  }
+}
+</style>

+ 10 - 0
src/utils/index.ts

@@ -2,6 +2,16 @@
 import { computeBox, getRangeRandom } from 'billd-utils';
 import sparkMD5 from 'spark-md5';
 
+export function handleStrEllipsis(str: string, maxlen: number) {
+  const res = str || '';
+  const len = maxlen || 3;
+  if (res?.length > len) {
+    return `${res.slice(0, len)}...`;
+  } else {
+    return res;
+  }
+}
+
 export const googleAd = () => {
   const el = document.createElement('script');
   el.src =

+ 37 - 47
src/utils/network/webRTC.ts

@@ -61,8 +61,20 @@ export class WebRTCClass {
 
   rtt = -1;
 
+  outboundFps = -1;
+  inboundFps = -1;
+
+  getStatsDelay = 1000;
   loopGetStatsTimer: any = null;
 
+  preBytesSent = 0;
+  /** 发送码率,单位kb/s */
+  bytesSent = 0;
+
+  preBytesReceived = 0;
+  /** 接收码率,单位kb/s */
+  bytesReceived = 0;
+
   constructor(data: {
     roomId: string;
     videoEl: HTMLVideoElement;
@@ -94,63 +106,41 @@ export class WebRTCClass {
   }
 
   loopGetStats = () => {
-    const previousTimestamp = 0; // 初始化上一次获取码率信息的时间
-
     this.loopGetStatsTimer = setInterval(async () => {
       if (!this.peerConnection) return;
       try {
         const res = await this.peerConnection.getStats();
         // 总丢包率(音频丢包和视频丢包)
-        let loss = 0;
-        let rtt = 0;
+        const loss = 0;
+        const rtt = 0;
+        let bytesSent = 0;
+        let bytesReceived = 0;
         res.forEach((report: RTCInboundRtpStreamStats) => {
-          // @ts-ignore
-          const currentRoundTripTime = report?.currentRoundTripTime;
-          const packetsLost = report?.packetsLost;
-          const packetsReceived = report.packetsReceived;
-          if (currentRoundTripTime !== undefined) {
-            rtt = currentRoundTripTime * 1000;
-          }
-          if (report.type === 'inbound-rtp' && report.kind === 'audio') {
-            if (packetsReceived !== undefined && packetsLost !== undefined) {
-              if (packetsLost === 0 || packetsReceived === 0) {
-                loss += 0;
-              } else {
-                loss += packetsLost / packetsReceived;
-              }
-            }
+          if (report.type === 'outbound-rtp' && report.kind === 'video') {
+            this.outboundFps = report.framesPerSecond || 0;
           }
           if (report.type === 'inbound-rtp' && report.kind === 'video') {
-            if (packetsReceived !== undefined && packetsLost !== undefined) {
-              if (packetsLost === 0 || packetsReceived === 0) {
-                loss += 0;
-              } else {
-                loss += packetsLost / packetsReceived;
-              }
-            }
+            this.inboundFps = report.framesPerSecond || 0;
           }
 
-          // if (report.type === 'outbound-rtp') {
-          //   // @ts-ignore
-          //   const bytesSent = report.bytesSent;
-          //   const timestamp = report.timestamp;
-          //   // 计算发送码率
-          //   const sendBitrate =
-          //     (bytesSent / (timestamp - previousTimestamp)) * 8;
-          //   console.log(`发送码率: ${sendBitrate} kbps`);
-          //   // 更新上一次获取码率信息的时间
-          //   previousTimestamp = timestamp;
-          // } else if (report.type === 'inbound-rtp') {
-          //   const bytesReceived = report.bytesReceived || 0;
-          //   const timestamp = report.timestamp;
-          //   // 计算接收码率
-          //   const receiveBitrate =
-          //     (bytesReceived / (timestamp - previousTimestamp)) * 8;
-          //   console.log(`接收码率: ${receiveBitrate} kbps`);
-          //   // 更新上一次获取码率信息的时间
-          //   previousTimestamp = timestamp;
-          // }
+          // @ts-ignore
+          if (report.bytesSent) {
+            // @ts-ignore
+            bytesSent += report.bytesSent || 0;
+          }
+          if (report.bytesReceived) {
+            // @ts-ignore
+            bytesReceived += report.bytesReceived || 0;
+          }
         });
+        this.bytesSent =
+          (bytesSent - this.preBytesSent) / 1024 / (this.getStatsDelay / 1000);
+        this.bytesReceived =
+          (bytesReceived - this.preBytesReceived) /
+          1024 /
+          (this.getStatsDelay / 1000);
+        this.preBytesSent = bytesSent;
+        this.preBytesReceived = bytesReceived;
         this.loss = loss;
         this.rtt = rtt;
 
@@ -159,7 +149,7 @@ export class WebRTCClass {
         console.error('getStats错误');
         console.log(error);
       }
-    }, 1000);
+    }, this.getStatsDelay);
   };
 
   prettierLog = (data: {

+ 9 - 6
src/views/about/faq/index.vue

@@ -14,7 +14,7 @@
                 openToTarget('https://github.com/galaxy-s10/billd-live#readme')
               "
             >
-              billd-live的Readme
+              billd-live的README
             </span>
           </p>
           <p>
@@ -31,11 +31,14 @@
         <div class="item">
           <h2>billd-live是什么?</h2>
           <p>
-            billd-live 是一个web端的直播平台,目前支持使用WebRTC或SRS进行直播:
+            billd-live
+            是一个web端的直播平台,目前支持使用WebRTC、SRS、腾讯云云直播进行直播:
           </p>
-          <p>- 原生webrtc一对多直播(DONE)</p>
-          <p>- srs-webrtc一对多直播(DONE)</p>
-          <p>- 原生webrtc多对多直播(DONE)</p>
+          <p>- 原生WebRTC一对多直播</p>
+          <p>- 原生WebRTC多对多直播</p>
+          <p>- SRS WebRTC一对多直播</p>
+          <p>- 打PK直播</p>
+          <p>- CDN直播</p>
         </div>
         <div class="hr"></div>
         <div class="item">
@@ -49,7 +52,7 @@
             >
               galaxy-s10
             </a>
-            在 2023 年作为其个人项目创建的,目前只有作者一人维护。
+            在 2023年3月作为其个人项目创建的,目前只有作者一人维护。
           </p>
         </div>
         <div class="hr"></div>

+ 1 - 1
src/views/h5/room/index.vue

@@ -818,7 +818,7 @@ function startPull() {
       outline: none;
       border: 1px solid hsla(0, 0%, 60%, 0.2);
       border-radius: 4px;
-      background-color: #f1f2f3;
+      background-color: #f5f6f7;
       font-size: 14px;
     }
   }

+ 58 - 27
src/views/pull/index.vue

@@ -44,16 +44,37 @@
                 <div class="f-left">+关注</div>
                 <div class="f-right">666</div>
               </div>
-              <div class="rtt">延迟:{{ rtcRtt || '-' }}</div>
-              <div class="loss">丢包:{{ rtcLoss || '-' }}</div>
-              <div class="bitrate">码率:-</div>
+              <span v-if="NODE_ENV === 'development'">
+                {{ liveRoomTypeEnumMap[appStore.liveRoomInfo?.type!] }}:{{
+                  mySocketId
+                }}
+              </span>
+              <div
+                class="rtc-info"
+                v-if="
+                  [
+                    LiveRoomTypeEnum.wertc_live,
+                    LiveRoomTypeEnum.wertc_meeting_one,
+                    LiveRoomTypeEnum.wertc_meeting_two,
+                  ].includes(appStore.liveRoomInfo?.type!)
+                "
+              >
+                <div class="item">延迟:{{ rtcRtt || '-' }}</div>
+                <div class="item">丢包:{{ rtcLoss || '-' }}</div>
+                <div class="item">帧率:{{ rtcFps || '-' }}</div>
+                <div class="item">发送码率:{{ rtcBytesSent || '-' }}</div>
+                <div class="item">接收码率:{{ rtcBytesReceived || '-' }}</div>
+              </div>
             </div>
             <div class="bottom">
               <div
                 class="desc"
-                :title="appStore.liveRoomInfo?.desc"
+                v-if="appStore.liveRoomInfo?.desc?.length"
               >
-                {{ appStore.liveRoomInfo?.desc }}
+                <FloatTip
+                  :txt="appStore.liveRoomInfo?.desc"
+                  :max-len="20"
+                ></FloatTip>
               </div>
               <span
                 class="area"
@@ -66,10 +87,6 @@
               >
                 {{ appStore.liveRoomInfo?.areas?.[0]?.name }}
               </span>
-
-              <span v-if="NODE_ENV === 'development'">
-                socketId:{{ mySocketId }}
-              </span>
             </div>
           </div>
         </div>
@@ -78,12 +95,6 @@
           @click="handlePk"
         >
           <div class="top">
-            <div
-              class="item"
-              v-if="NODE_ENV === 'development'"
-            >
-              {{ liveRoomTypeEnumMap[appStore.liveRoomInfo?.type || ''] }}
-            </div>
             <div class="item">666人看过</div>
             <div class="item">666点赞</div>
             <div class="item">当前在线:{{ liveUserList.length }}人</div>
@@ -360,13 +371,12 @@
       >
         <div
           class="disableSpeaking"
-          v-if="appStore.disableSpeaking.get(appStore.liveRoomInfo?.id || -1)"
+          v-if="appStore.disableSpeaking.get(appStore.liveRoomInfo?.id!)"
         >
           <div class="bg"></div>
           <span class="txt">
             你被禁言了({{
-              appStore.disableSpeaking.get(appStore.liveRoomInfo?.id || -1)
-                ?.label
+              appStore.disableSpeaking.get(appStore.liveRoomInfo?.id!)
             }})
           </span>
         </div>
@@ -456,7 +466,7 @@ import {
 import { fetchGoodsList } from '@/api/goods';
 import { fetchLiveRoomOnlineUser } from '@/api/live';
 import { fetchGetWsMessageList } from '@/api/wsMessage';
-import { QINIU_RESOURCE, liveRoomTypeEnumMap } from '@/constant';
+import { liveRoomTypeEnumMap, QINIU_RESOURCE } from '@/constant';
 import { emojiArray } from '@/emoji';
 import { commentAuthTip, loginTip } from '@/hooks/use-login';
 import { useFullScreen, usePictureInPicture } from '@/hooks/use-play';
@@ -551,6 +561,28 @@ const rtcLoss = computed(() => {
   return arr.join();
 });
 
+const rtcFps = computed(() => {
+  const arr: any[] = [];
+  networkStore.rtcMap.forEach((rtc) => {
+    arr.push(`${Number(rtc.inboundFps.toFixed(2))}`);
+  });
+  return arr.join();
+});
+const rtcBytesSent = computed(() => {
+  const arr: any[] = [];
+  networkStore.rtcMap.forEach((rtc) => {
+    arr.push(`${Number(rtc.bytesSent.toFixed(0))}kb/s`);
+  });
+  return arr.join();
+});
+const rtcBytesReceived = computed(() => {
+  const arr: any[] = [];
+  networkStore.rtcMap.forEach((rtc) => {
+    arr.push(`${Number(rtc.bytesReceived.toFixed(0))}kb/s`);
+  });
+  return arr.join();
+});
+
 onMounted(() => {
   initAdsbygoogle();
   appStore.videoControls.fps = true;
@@ -1092,20 +1124,19 @@ function handleScrollTop() {
                 background-color: #e3e5e7;
               }
             }
-            .rtt,
-            .loss {
-              margin-right: 10px;
-              font-size: 14px;
+            .rtc-info {
+              display: flex;
+              align-items: center;
+              .item {
+                margin-right: 10px;
+                font-size: 14px;
+              }
             }
           }
           .bottom {
             display: flex;
             font-size: 14px;
             .desc {
-              width: 400px;
-              cursor: pointer;
-
-              @extend %singleEllipsis;
             }
             .area {
               margin: 0 10px;

+ 42 - 11
src/views/push/index.vue

@@ -80,6 +80,16 @@
         ref="bottomRef"
         class="room-control"
       >
+        <span
+          v-if="NODE_ENV === 'development'"
+          class="debug-info"
+        >
+          <span>{{
+            liveRoomTypeEnumMap[appStore.liveRoomInfo?.type + '']
+          }}</span>
+          <span>:</span>
+          <span>{{ mySocketId }}</span>
+        </span>
         <div class="info">
           <div
             class="avatar"
@@ -106,18 +116,11 @@
                 </div>
                 <div class="item">延迟:{{ rtcRtt || '-' }}</div>
                 <div class="item">丢包:{{ rtcLoss || '-' }}</div>
+                <div class="item">帧率:{{ rtcFps || '-' }}</div>
+                <div class="item">发送码率:{{ rtcBytesSent || '-' }}</div>
+                <div class="item">接收码率:{{ rtcBytesReceived || '-' }}</div>
               </div>
               <div class="other">
-                <span
-                  v-if="NODE_ENV === 'development'"
-                  class="item"
-                >
-                  <span>{{ mySocketId }}</span>
-                  <span>---</span>
-                  <span>{{
-                    liveRoomTypeEnumMap[appStore.liveRoomInfo?.type || '']
-                  }}</span>
-                </span>
                 <span
                   class="item share"
                   @click="handleShare"
@@ -604,6 +607,27 @@ const rtcLoss = computed(() => {
   });
   return arr.join();
 });
+const rtcFps = computed(() => {
+  const arr: any[] = [];
+  networkStore.rtcMap.forEach((rtc) => {
+    arr.push(`${Number(rtc.outboundFps.toFixed(2))}`);
+  });
+  return arr.join();
+});
+const rtcBytesSent = computed(() => {
+  const arr: any[] = [];
+  networkStore.rtcMap.forEach((rtc) => {
+    arr.push(`${Number(rtc.bytesSent.toFixed(0))}kb/s`);
+  });
+  return arr.join();
+});
+const rtcBytesReceived = computed(() => {
+  const arr: any[] = [];
+  networkStore.rtcMap.forEach((rtc) => {
+    arr.push(`${Number(rtc.bytesReceived.toFixed(0))}kb/s`);
+  });
+  return arr.join();
+});
 
 watch(
   () => roomLiving.value,
@@ -2575,11 +2599,18 @@ function handleStartMedia(item: { type: MediaTypeEnum; txt: string }) {
       }
     }
     .room-control {
+      position: relative;
       display: flex;
       justify-content: space-between;
       padding: 15px;
       border-radius: 0 0 6px 6px;
       background-color: papayawhip;
+      .debug-info {
+        position: absolute;
+        right: 0;
+        bottom: 0;
+        font-size: 14px;
+      }
       .info {
         display: flex;
         width: 100%;
@@ -2839,7 +2870,7 @@ function handleStartMedia(item: { type: MediaTypeEnum; txt: string }) {
           outline: none;
           border: 1px solid hsla(0, 0%, 60%, 0.2);
           border-radius: 4px;
-          background-color: #f1f2f3;
+          background-color: #f5f6f7;
           font-size: 14px;
         }
         .btn {