Browse Source

feat: 多对多会议

shuisheng 2 years ago
parent
commit
5106018fe2

+ 132 - 6
src/hooks/use-pull.ts

@@ -1,5 +1,5 @@
 import { getRandomString } from 'billd-utils';
-import { reactive, ref, watch } from 'vue';
+import { Ref, reactive, ref, watch } from 'vue';
 import { useRoute } from 'vue-router';
 
 import { fetchRtcV1Play } from '@/api/srs';
@@ -11,6 +11,7 @@ import {
   IDanmu,
   ILiveUser,
   IOffer,
+  MediaTypeEnum,
 } from '@/interface';
 import { SRSWebRTCClass } from '@/network/srsWebRtc';
 import { WebRTCClass } from '@/network/webRtc';
@@ -24,10 +25,12 @@ import { useUserStore } from '@/store/user';
 
 export function usePull({
   localVideoRef,
+  remoteVideoRef,
   isSRS,
   isFlv,
 }: {
-  localVideoRef;
+  localVideoRef: Ref<HTMLVideoElement | undefined>;
+  remoteVideoRef: Ref<HTMLVideoElement | undefined>;
   isSRS?: boolean;
   isFlv?: boolean;
 }) {
@@ -43,7 +46,9 @@ export function usePull({
   const danmuStr = ref('');
   const damuList = ref<IDanmu[]>([]);
   const liveUserList = ref<ILiveUser[]>([]);
+  const isDone = ref(false);
   const roomNoLive = ref(false);
+  const localStream = ref();
   const track = reactive({
     audio: true,
     video: true,
@@ -55,6 +60,67 @@ export function usePull({
     { name: '大鸡腿', ico: '', price: '5元' },
     { name: '一杯咖啡', ico: '', price: '10元' },
   ]);
+  const offerSended = ref(new Set());
+  const hooksRtcMap = ref(new Set());
+
+  const allMediaTypeList = {
+    [MediaTypeEnum.camera]: {
+      type: MediaTypeEnum.camera,
+      txt: '摄像头',
+    },
+    [MediaTypeEnum.screen]: {
+      type: MediaTypeEnum.screen,
+      txt: '窗口',
+    },
+  };
+  const currMediaTypeList = ref<
+    {
+      type: MediaTypeEnum;
+      txt: string;
+    }[]
+  >([]);
+  const currMediaType = ref<{
+    type: MediaTypeEnum;
+    txt: string;
+  }>();
+
+  /** 摄像头 */
+  async function startGetUserMedia() {
+    if (!localStream.value) {
+      // WARN navigator.mediaDevices在localhost和https才能用,http://192.168.1.103:8000局域网用不了
+      const event = await navigator.mediaDevices.getUserMedia({
+        video: true,
+        audio: true,
+      });
+      console.log('getUserMedia成功', event);
+      currMediaType.value = allMediaTypeList[MediaTypeEnum.camera];
+      currMediaTypeList.value.push(allMediaTypeList[MediaTypeEnum.camera]);
+      if (!localVideoRef.value) return;
+      localVideoRef.value.srcObject = event;
+      localStream.value = event;
+    }
+  }
+
+  /** 窗口 */
+  async function startGetDisplayMedia() {
+    if (!localStream.value) {
+      // WARN navigator.mediaDevices.getDisplayMedia在localhost和https才能用,http://192.168.1.103:8000局域网用不了
+      const event = await navigator.mediaDevices.getDisplayMedia({
+        video: true,
+        audio: true,
+      });
+      const audio = event.getAudioTracks();
+      const video = event.getVideoTracks();
+      track.audio = !!audio.length;
+      track.video = !!video.length;
+      console.log('getDisplayMedia成功', event);
+      currMediaType.value = allMediaTypeList[MediaTypeEnum.screen];
+      currMediaTypeList.value.push(allMediaTypeList[MediaTypeEnum.screen]);
+      if (!localVideoRef.value) return;
+      localVideoRef.value.srcObject = event;
+      localStream.value = event;
+    }
+  }
 
   watch(
     [
@@ -87,7 +153,7 @@ export function usePull({
     });
     ws.update();
     initReceive();
-    localVideoRef.value.addEventListener('loadstart', () => {
+    remoteVideoRef.value?.addEventListener('loadstart', () => {
       console.warn('视频流-loadstart');
       const rtc = networkStore.getRtcMap(roomId.value);
       if (!rtc) return;
@@ -95,7 +161,7 @@ export function usePull({
       rtc.update();
     });
 
-    localVideoRef.value.addEventListener('loadedmetadata', () => {
+    remoteVideoRef.value?.addEventListener('loadedmetadata', () => {
       console.warn('视频流-loadedmetadata');
       const rtc = networkStore.getRtcMap(roomId.value);
       if (!rtc) return;
@@ -138,12 +204,65 @@ export function usePull({
     });
   }
 
+  function addTrack() {
+    if (!localStream.value) return;
+    liveUserList.value.forEach((item) => {
+      if (item.socketId !== getSocketId()) {
+        localStream.value.getTracks().forEach((track) => {
+          const rtc = networkStore.getRtcMap(
+            `${roomId.value}___${item.socketId}`
+          );
+          rtc?.addTrack(track, localStream.value);
+        });
+      }
+    });
+  }
+
+  async function sendOffer({
+    sender,
+    receiver,
+  }: {
+    sender: string;
+    receiver: string;
+  }) {
+    if (isDone.value) return;
+    const instance = networkStore.wsMap.get(roomId.value);
+    if (!instance) return;
+    const rtc = networkStore.getRtcMap(`${roomId.value}___${receiver}`);
+    if (!rtc) return;
+    const sdp = await rtc.createOffer();
+    await rtc.setLocalDescription(sdp);
+    instance.send({
+      msgType: WsMsgTypeEnum.offer,
+      data: { sdp, sender, receiver },
+    });
+  }
+  function batchSendOffer() {
+    liveUserList.value.forEach(async (item) => {
+      if (
+        !offerSended.value.has(item.socketId) &&
+        item.socketId !== getSocketId()
+      ) {
+        hooksRtcMap.value.add(await startNewWebRtc(item.socketId));
+        await addTrack();
+        console.warn('new WebRTCClass完成');
+        console.log('执行sendOffer', {
+          sender: getSocketId(),
+          receiver: item.socketId,
+        });
+        sendOffer({ sender: getSocketId(), receiver: item.socketId });
+        offerSended.value.add(item.socketId);
+      }
+    });
+  }
+
   /** 原生的webrtc时,receiver必传 */
   async function startNewWebRtc(receiver?: string) {
     if (isSRS) {
       console.warn('开始new SRSWebRTCClass', getSocketId());
       const rtc = new SRSWebRTCClass({
         roomId: `${roomId.value}___${getSocketId()}`,
+        videoEl: remoteVideoRef.value!,
       });
       rtc.rtcStatus.joined = true;
       rtc.update();
@@ -176,7 +295,10 @@ export function usePull({
       }
     } else {
       console.warn('开始new WebRTCClass');
-      const rtc = new WebRTCClass({ roomId: `${roomId.value}___${receiver!}` });
+      const rtc = new WebRTCClass({
+        roomId: `${roomId.value}___${receiver!}`,
+        videoEl: remoteVideoRef.value!,
+      });
       return rtc;
     }
   }
@@ -344,7 +466,7 @@ export function usePull({
       streamurl.value = data.data.streamurl;
       flvurl.value = data.data.flvurl;
       if (isFlv) {
-        useFlvPlay(flvurl.value, localVideoRef.value!);
+        useFlvPlay(flvurl.value, remoteVideoRef.value!);
       }
       instance.send({ msgType: WsMsgTypeEnum.getLiveUser });
     });
@@ -395,11 +517,15 @@ export function usePull({
     getSocketId,
     keydownDanmu,
     sendDanmu,
+    batchSendOffer,
+    startGetUserMedia,
+    startGetDisplayMedia,
     roomName,
     roomNoLive,
     damuList,
     giftList,
     liveUserList,
     danmuStr,
+    localStream,
   };
 }

+ 14 - 6
src/hooks/use-push.ts

@@ -1,5 +1,5 @@
 import { getRandomString } from 'billd-utils';
-import { reactive, ref } from 'vue';
+import { Ref, reactive, ref } from 'vue';
 import { useRoute, useRouter } from 'vue-router';
 
 import { fetchRtcV1Publish } from '@/api/srs';
@@ -26,7 +26,7 @@ export function usePush({
   localVideoRef,
   isSRS,
 }: {
-  localVideoRef;
+  localVideoRef: Ref<HTMLVideoElement | undefined>;
   isSRS?: boolean;
 }) {
   const route = useRoute();
@@ -42,6 +42,7 @@ export function usePush({
   const disabled = ref(false);
   const localStream = ref();
   const offerSended = ref(new Set());
+  const hooksRtcMap = ref(new Set());
 
   const track = reactive({
     audio: true,
@@ -113,6 +114,7 @@ export function usePush({
       console.warn('开始new SRSWebRTCClass');
       const rtc = new SRSWebRTCClass({
         roomId: `${roomId.value}___${getSocketId()}`,
+        videoEl: localVideoRef.value!,
       });
       localStream.value.getTracks().forEach((track) => {
         rtc.addTrack({
@@ -144,7 +146,10 @@ export function usePush({
       }
     } else {
       console.warn('开始new WebRTCClass');
-      const rtc = new WebRTCClass({ roomId: `${roomId.value}___${receiver!}` });
+      const rtc = new WebRTCClass({
+        roomId: `${roomId.value}___${receiver!}`,
+        videoEl: localVideoRef.value!,
+      });
       return rtc;
     }
   }
@@ -236,7 +241,7 @@ export function usePush({
         !offerSended.value.has(item.socketId) &&
         item.socketId !== getSocketId()
       ) {
-        await startNewWebRtc(item.socketId);
+        hooksRtcMap.value.add(await startNewWebRtc(item.socketId));
         await addTrack();
         console.warn('new WebRTCClass完成');
         console.log('执行sendOffer', {
@@ -470,6 +475,7 @@ export function usePush({
       localStream.value = event;
     }
   }
+
   function keydownDanmu(event: KeyboardEvent) {
     const key = event.key.toLowerCase();
     if (key === 'enter') {
@@ -540,7 +546,8 @@ export function usePush({
       txt: all.find((item) => item.kind === 'videoinput')?.label || '摄像头',
       type: MediaTypeEnum.camera,
     };
-    localVideoRef.value.addEventListener('loadstart', () => {
+    console.log('initPush', localVideoRef);
+    localVideoRef.value?.addEventListener('loadstart', () => {
       console.warn('视频流-loadstart');
       const rtc = networkStore.getRtcMap(roomId.value);
       if (!rtc) return;
@@ -548,7 +555,7 @@ export function usePush({
       rtc.update();
     });
 
-    localVideoRef.value.addEventListener('loadedmetadata', () => {
+    localVideoRef.value?.addEventListener('loadedmetadata', () => {
       console.warn('视频流-loadedmetadata');
       const rtc = networkStore.getRtcMap(roomId.value);
       if (!rtc) return;
@@ -579,5 +586,6 @@ export function usePush({
     damuList,
     liveUserList,
     currMediaTypeList,
+    hooksRtcMap,
   };
 }

+ 2 - 0
src/interface.ts

@@ -54,6 +54,8 @@ export enum liveTypeEnum {
   srsFlvPull = 'srsFlvPull',
   srsPush = 'srsPush',
   webrtcPush = 'webrtcPush',
+  pushMeeting = 'pushMeeting',
+  pullMeeting = 'pullMeeting',
 }
 
 export interface BilldHtmlWebpackPluginLog {

+ 17 - 5
src/layout/head/index.vue

@@ -182,6 +182,10 @@ const options = ref([
     label: 'srs-webrtc开播',
     key: liveTypeEnum.srsPush,
   },
+  {
+    label: 'webrtc多人会议',
+    key: liveTypeEnum.pushMeeting,
+  },
 ]);
 
 onMounted(() => {
@@ -195,11 +199,19 @@ function handleUserSelect(key) {
   }
 }
 function handlePushSelect(key) {
-  const url = router.resolve({
-    name: routerName.push,
-    query: { liveType: key },
-  });
-  openToTarget(url.href);
+  if (key === liveTypeEnum.webrtcPush || key === liveTypeEnum.srsPush) {
+    const url = router.resolve({
+      name: routerName.push,
+      query: { liveType: key },
+    });
+    openToTarget(url.href);
+  } else {
+    const url = router.resolve({
+      name: routerName.pushMeeting,
+    });
+    openToTarget(url.href);
+    window.$message.info('敬请期待!');
+  }
 }
 
 function goPushPage(routerName: string) {

+ 1 - 1
src/layout/modal/index.vue

@@ -19,5 +19,5 @@
 <script lang="ts" setup>
 import { ref } from 'vue';
 
-const showModal = ref(true);
+const showModal = ref(process.env.NODE_ENV === 'production');
 </script>

+ 10 - 3
src/network/srsWebRtc.ts

@@ -18,7 +18,7 @@ function prettierInfo(
 
 export class SRSWebRTCClass {
   roomId = '-1';
-
+  videoEl;
   peerConnection: RTCPeerConnection | null = null;
   dataChannel: RTCDataChannel | null = null;
 
@@ -62,8 +62,15 @@ export class SRSWebRTCClass {
 
   localDescription: any;
 
-  constructor({ roomId }: { roomId: string }) {
+  constructor({
+    roomId,
+    videoEl,
+  }: {
+    roomId: string;
+    videoEl: HTMLVideoElement;
+  }) {
     this.roomId = roomId;
+    this.videoEl = videoEl;
     this.browser = browserTool();
     this.createPeerConnection();
     this.update();
@@ -83,7 +90,7 @@ export class SRSWebRTCClass {
     if (!this.peerConnection) return;
     this.rtcStatus.addStream = true;
     this.update();
-    document.querySelector<HTMLVideoElement>('#localVideo')!.srcObject = stream;
+    this.videoEl.srcObject = stream;
     prettierInfo('addStream成功', { browser: this.browser.browser }, 'warn');
   };
 

+ 10 - 5
src/network/webRtc.ts

@@ -62,7 +62,7 @@ export const frontendErrorCode = {
 
 export class WebRTCClass {
   roomId = '-1';
-
+  videoEl;
   peerConnection: RTCPeerConnection | null = null;
   dataChannel: RTCDataChannel | null = null;
 
@@ -106,8 +106,15 @@ export class WebRTCClass {
 
   localDescription: any;
 
-  constructor({ roomId }: { roomId: string }) {
+  constructor({
+    roomId,
+    videoEl,
+  }: {
+    roomId: string;
+    videoEl: HTMLVideoElement;
+  }) {
     this.roomId = roomId;
+    this.videoEl = videoEl;
     this.browser = browserTool();
     this.createPeerConnection();
     this.update();
@@ -435,7 +442,7 @@ export class WebRTCClass {
     this.rtcStatus.addStream = true;
     this.update();
     console.log('addStreamaddStreamaddStream', stream);
-    document.querySelector<HTMLVideoElement>('#localVideo')!.srcObject = stream;
+    this.videoEl.srcObject = stream;
     prettierInfo('addStream成功', { browser: this.browser.browser }, 'warn');
   };
 
@@ -461,8 +468,6 @@ export class WebRTCClass {
     this.peerConnection?.addEventListener('track', (event: any) => {
       console.warn(`${this.roomId},pc收到track事件`, event);
       this.addStream(event.streams[0]);
-      // document.querySelector<HTMLVideoElement>('#localVideo')!.srcObject =
-      //   event.streams[0];
     });
   };
 

+ 12 - 0
src/router/index.ts

@@ -15,6 +15,8 @@ export const routerName = {
 
   pull: 'pull',
   push: 'push',
+  pushMeeting: 'pushMeeting',
+  pullMeeting: 'pullMeeting',
 };
 
 // 默认路由
@@ -58,6 +60,16 @@ export const defaultRoutes: RouteRecordRaw[] = [
         path: '/push',
         component: () => import('@/views/push/index.vue'),
       },
+      {
+        name: routerName.pushMeeting,
+        path: '/meeting',
+        component: () => import('@/views/meeting/index.vue'),
+      },
+      {
+        name: routerName.pullMeeting,
+        path: '/meeting/:roomId',
+        component: () => import('@/views/meeting/roomId.vue'),
+      },
     ],
   },
   {

+ 6 - 2
src/views/about/index.vue

@@ -1,7 +1,11 @@
 <template>
   <div class="about-wrap">
-    <h2>有空再写</h2>
-    <h2>微信交流群 & 我的微信</h2>
+    <h2>目前实现</h2>
+    <h3>1. 原生webrtc一对多直播(DONE)</h3>
+    <h3>2. srs-webrtc一对多直播(DONE)</h3>
+    <h3>3. 原生webrtc多对多直播(TODO)</h3>
+
+    <h1>微信交流群 & 我的微信</h1>
     <img
       src="@/assets/img/wechat-group.webp"
       alt=""

+ 415 - 0
src/views/meeting/index.vue

@@ -0,0 +1,415 @@
+<template>
+  <div class="webrtc-push-wrap">
+    <div
+      ref="topRef"
+      class="left"
+    >
+      <div class="video-wrap">
+        <video
+          id="localVideo"
+          ref="localVideoRef"
+          autoplay
+          webkit-playsinline="true"
+          playsinline
+          x-webkit-airplay="allow"
+          x5-video-player-type="h5"
+          x5-video-player-fullscreen="true"
+          x5-video-orientation="portraint"
+          muted
+        ></video>
+        <div
+          v-if="!currMediaTypeList || currMediaTypeList.length <= 0"
+          class="add-wrap"
+        >
+          <n-space>
+            <n-button
+              class="item"
+              @click="startGetUserMedia"
+            >
+              摄像头
+            </n-button>
+            <n-button
+              class="item"
+              @click="startGetDisplayMedia"
+            >
+              窗口
+            </n-button>
+          </n-space>
+        </div>
+      </div>
+      <div
+        ref="bottomRef"
+        class="control"
+      >
+        <div class="info">
+          <div
+            class="avatar"
+            :style="{ backgroundImage: `url(${userStore.userInfo?.avatar})` }"
+          ></div>
+          <div class="detail">
+            <div class="top">
+              <n-input-group>
+                <n-input
+                  v-model:value="roomName"
+                  size="small"
+                  placeholder="输入房间名"
+                  :style="{ width: '50%' }"
+                  :disabled="disabled"
+                />
+                <n-button
+                  size="small"
+                  type="primary"
+                  :disabled="disabled"
+                  @click="confirmRoomName"
+                >
+                  确定
+                </n-button>
+              </n-input-group>
+            </div>
+            <div class="bottom">
+              <span>socketId:{{ getSocketId() }}</span>
+            </div>
+          </div>
+        </div>
+        <div class="other">
+          <div class="top">
+            <span class="item">
+              <i class="ico"></i>
+              <span>正在观看人数:{{ liveUserList.length }}</span>
+            </span>
+          </div>
+          <div class="bottom">
+            <n-space>
+              <n-button
+                type="info"
+                size="small"
+                @click="startLive"
+              >
+                开始直播
+              </n-button>
+              <n-button
+                type="info"
+                size="small"
+                @click="endLive"
+              >
+                结束直播
+              </n-button>
+            </n-space>
+          </div>
+        </div>
+      </div>
+    </div>
+    <div class="right">
+      <div class="resource-card">
+        <div class="title">素材列表</div>
+        <div class="list">
+          <div
+            v-for="(item, index) in currMediaTypeList"
+            :key="index"
+            class="item"
+          >
+            <span class="name">{{ item.txt }}</span>
+          </div>
+        </div>
+      </div>
+      <div class="danmu-card">
+        <div class="title">弹幕互动</div>
+        <div class="list-wrap">
+          <div class="list">
+            <div
+              v-for="(item, index) in damuList"
+              :key="index"
+              class="item"
+            >
+              <template v-if="item.msgType === DanmuMsgTypeEnum.danmu">
+                <span class="name">{{ item.socketId }}:</span>
+                <span class="msg">{{ item.msg }}</span>
+              </template>
+              <template v-else-if="item.msgType === DanmuMsgTypeEnum.otherJoin">
+                <span class="name system">系统通知:</span>
+                <span class="msg">{{ item.socketId }}进入直播!</span>
+              </template>
+              <template
+                v-else-if="item.msgType === DanmuMsgTypeEnum.userLeaved"
+              >
+                <span class="name system">系统通知:</span>
+                <span class="msg">{{ item.socketId }}离开直播!</span>
+              </template>
+            </div>
+          </div>
+        </div>
+        <div class="send-msg">
+          <input
+            v-model="danmuStr"
+            class="ipt"
+            @keydown="keydownDanmu"
+          />
+          <n-button
+            type="info"
+            size="small"
+            @click="sendDanmu"
+          >
+            发送
+          </n-button>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted, ref } from 'vue';
+import { useRoute } from 'vue-router';
+
+import { usePush } from '@/hooks/use-push';
+import { DanmuMsgTypeEnum, liveTypeEnum } from '@/interface';
+import { useUserStore } from '@/store/user';
+
+const route = useRoute();
+const userStore = useUserStore();
+
+const topRef = ref<HTMLDivElement>();
+const bottomRef = ref<HTMLDivElement>();
+const localVideoRef = ref<HTMLVideoElement>();
+const liveType = route.query.liveType;
+
+const {
+  initPush,
+  confirmRoomName,
+  getSocketId,
+  startGetDisplayMedia,
+  startGetUserMedia,
+  startLive,
+  endLive,
+  closeWs,
+  closeRtc,
+  sendDanmu,
+  keydownDanmu,
+  disabled,
+  danmuStr,
+  roomName,
+  damuList,
+  liveUserList,
+  currMediaTypeList,
+} = usePush({
+  localVideoRef,
+  isSRS: liveType === liveTypeEnum.srsPush,
+});
+
+onUnmounted(() => {
+  closeWs();
+  closeRtc();
+});
+
+onMounted(() => {
+  initPush();
+  if (topRef.value && bottomRef.value && localVideoRef.value) {
+    const res =
+      bottomRef.value.getBoundingClientRect().top -
+      topRef.value.getBoundingClientRect().top;
+    localVideoRef.value.style.height = `${res}px`;
+  }
+});
+</script>
+
+<style lang="scss" scoped>
+.webrtc-push-wrap {
+  margin: 20px auto 0;
+  min-width: $large-width;
+  height: 700px;
+  text-align: center;
+  .left {
+    position: relative;
+    display: inline-block;
+    box-sizing: border-box;
+    width: $large-left-width;
+    height: 100%;
+    border-radius: 6px;
+    overflow: hidden;
+    background-color: white;
+    color: #9499a0;
+    vertical-align: top;
+
+    .video-wrap {
+      position: relative;
+      background-color: #18191c;
+      #localVideo {
+        max-width: 100%;
+        max-height: 100%;
+      }
+      .add-wrap {
+        position: absolute;
+        top: 50%;
+        left: 50%;
+        display: flex;
+        align-items: center;
+        justify-content: space-around;
+        padding: 0 20px;
+        height: 50px;
+        border-radius: 5px;
+        background-color: white;
+        transform: translate(-50%, -50%);
+      }
+    }
+    .control {
+      position: absolute;
+      right: 0;
+      bottom: 0;
+      left: 0;
+      display: flex;
+      justify-content: space-between;
+      padding: 20px;
+      background-color: papayawhip;
+
+      .info {
+        display: flex;
+        align-items: center;
+
+        .avatar {
+          margin-right: 20px;
+          width: 64px;
+          height: 64px;
+          border-radius: 50%;
+          background-color: skyblue;
+          background-position: center center;
+          background-size: cover;
+          background-repeat: no-repeat;
+        }
+        .detail {
+          display: flex;
+          flex-direction: column;
+          text-align: initial;
+          .top {
+            margin-bottom: 10px;
+            color: #18191c;
+            .btn {
+              margin-left: 10px;
+            }
+          }
+          .bottom {
+            font-size: 14px;
+          }
+        }
+      }
+      .other {
+        display: flex;
+        flex-direction: column;
+        justify-content: center;
+        font-size: 12px;
+        .top {
+          display: flex;
+          align-items: center;
+          .item {
+            display: flex;
+            align-items: center;
+            margin-right: 20px;
+            .ico {
+              display: inline-block;
+              margin-right: 4px;
+              width: 10px;
+              height: 10px;
+              border-radius: 50%;
+              background-color: skyblue;
+            }
+          }
+        }
+        .bottom {
+          margin-top: 10px;
+        }
+      }
+    }
+  }
+  .right {
+    position: relative;
+    display: inline-block;
+    box-sizing: border-box;
+    margin-left: 10px;
+    width: 240px;
+    height: 100%;
+    border-radius: 6px;
+    background-color: white;
+    color: #9499a0;
+
+    .resource-card {
+      box-sizing: border-box;
+      margin-bottom: 5%;
+      margin-bottom: 10px;
+      padding: 10px;
+      width: 100%;
+      height: 290px;
+      border-radius: 6px;
+      background-color: papayawhip;
+      .title {
+        text-align: initial;
+      }
+      .item {
+        display: flex;
+        align-items: center;
+        justify-content: space-between;
+        margin: 5px 0;
+        font-size: 12px;
+      }
+    }
+    .danmu-card {
+      box-sizing: border-box;
+      padding: 10px;
+      width: 100%;
+      height: 400px;
+      border-radius: 6px;
+      background-color: papayawhip;
+      text-align: initial;
+      .title {
+        margin-bottom: 10px;
+      }
+      .list {
+        margin-bottom: 10px;
+        height: 300px;
+        .item {
+          margin-bottom: 10px;
+          font-size: 12px;
+          .name {
+            color: #9499a0;
+          }
+          .msg {
+            color: #61666d;
+          }
+        }
+      }
+
+      .send-msg {
+        display: flex;
+        align-items: center;
+        box-sizing: border-box;
+        .ipt {
+          display: block;
+          box-sizing: border-box;
+          margin: 0 auto;
+          margin-right: 10px;
+          padding: 10px;
+          width: 80%;
+          height: 30px;
+          outline: none;
+          border: 1px solid hsla(0, 0%, 60%, 0.2);
+          border-radius: 4px;
+          background-color: #f1f2f3;
+          font-size: 14px;
+        }
+      }
+    }
+  }
+}
+// 屏幕宽度小于$large-width的时候
+@media screen and (max-width: $large-width) {
+  .webrtc-push-wrap {
+    .left {
+      width: $medium-left-width;
+    }
+    .right {
+      .list {
+        .item {
+        }
+      }
+    }
+  }
+}
+</style>

+ 7 - 0
src/views/meeting/roomId.vue

@@ -0,0 +1,7 @@
+<template>
+  <div></div>
+</template>
+
+<script lang="ts" setup></script>
+
+<style lang="scss" scoped></style>

+ 55 - 6
src/views/pull/index.vue

@@ -19,8 +19,8 @@
         </div>
         <div class="video-wrap">
           <video
-            id="localVideo"
-            ref="localVideoRef"
+            id="remoteVideo"
+            ref="remoteVideoRef"
             autoplay
             webkit-playsinline="true"
             playsinline
@@ -33,6 +33,27 @@
           <div class="controls">
             <VideoControls></VideoControls>
           </div>
+          <div class="sidebar">
+            <video
+              id="localVideo"
+              ref="localVideoRef"
+              style="width: 100px"
+              autoplay
+              webkit-playsinline="true"
+              playsinline
+              x-webkit-airplay="allow"
+              x5-video-player-type="h5"
+              x5-video-player-fullscreen="true"
+              x5-video-orientation="portraint"
+              :muted="appStore.muted"
+            ></video>
+            <div
+              class="plus"
+              @click="handleJoin()"
+            >
+              加入
+            </div>
+          </div>
         </div>
         <div
           ref="bottomRef"
@@ -127,7 +148,7 @@
 </template>
 
 <script lang="ts" setup>
-import { onMounted, onUnmounted, ref } from 'vue';
+import { onMounted, onUnmounted, ref, watch } from 'vue';
 import { useRoute } from 'vue-router';
 
 import { usePull } from '@/hooks/use-pull';
@@ -141,6 +162,7 @@ const appStore = useAppStore();
 
 const topRef = ref<HTMLDivElement>();
 const bottomRef = ref<HTMLDivElement>();
+const remoteVideoRef = ref<HTMLVideoElement>();
 const localVideoRef = ref<HTMLVideoElement>();
 
 const {
@@ -150,30 +172,46 @@ const {
   getSocketId,
   keydownDanmu,
   sendDanmu,
+  batchSendOffer,
+  startGetUserMedia,
+  startGetDisplayMedia,
   roomName,
   roomNoLive,
   damuList,
   giftList,
   liveUserList,
   danmuStr,
+  localStream,
 } = usePull({
   localVideoRef,
+  remoteVideoRef,
   isFlv: route.query.liveType === liveTypeEnum.srsFlvPull,
   isSRS: route.query.liveType === liveTypeEnum.srsWebrtcPull,
 });
 
+async function handleJoin() {
+  await startGetDisplayMedia();
+  batchSendOffer();
+}
+watch(
+  () => localStream,
+  (newVal) => {
+    localVideoRef.value!.srcObject = newVal.value;
+  }
+);
+
 onUnmounted(() => {
   closeWs();
   closeRtc();
 });
 
 onMounted(() => {
-  if (topRef.value && bottomRef.value && localVideoRef.value) {
+  if (topRef.value && bottomRef.value && remoteVideoRef.value) {
     const res =
       bottomRef.value.getBoundingClientRect().top -
       (topRef.value.getBoundingClientRect().top +
         topRef.value.getBoundingClientRect().height);
-    localVideoRef.value.style.height = `${res}px`;
+    remoteVideoRef.value.style.height = `${res}px`;
   }
   initPull();
 });
@@ -263,13 +301,24 @@ onMounted(() => {
     .video-wrap {
       position: relative;
       background-color: #18191c;
-      #localVideo {
+      #remoteVideo {
         max-width: 100%;
         max-height: 100%;
       }
       .controls {
         display: none;
       }
+      .sidebar {
+        position: absolute;
+        right: 0;
+        top: 0;
+        height: 100%;
+        background-color: rgba($color: #000000, $alpha: 0.3);
+        .plus {
+          color: white;
+          cursor: pointer;
+        }
+      }
       &:hover {
         .controls {
           display: block;