Kaynağa Gözat

feat: 优化界面

shuisheng 2 yıl önce
ebeveyn
işleme
7b5d3e7182

+ 1 - 1
script/config/webpack.common.ts

@@ -64,7 +64,7 @@ const sassRules = (isProduction: boolean, module?: boolean) => {
       options: {
         sourceMap: false,
         // 根据sass-loader9.x以后使用additionalData,9.x以前使用prependData
-        additionalData: `@use 'billd-scss/src/index.scss' as *;`,
+        additionalData: `@use 'billd-scss/src/index.scss' as *;@import '@/assets/constant.scss';`,
       },
     },
   ].filter(Boolean);

+ 17 - 0
src/assets/constant.scss

@@ -0,0 +1,17 @@
+// constant.scss放的是一些sass变量
+// 用法:.header{color: $theme-color1;}
+// 编译结果:.header{color: #0984e3;}
+
+// :root 这个 CSS 伪类匹配文档树的根元素。对于 HTML 来说,:root 表示 <html> 元素,除了优先级更高之外,与 html 选择器相同。
+:root {
+  --dark-primary-color: 'red'; //CSS 自定义属性(变量)
+  --light-primary-color: blue; //CSS 自定义属性(变量)
+}
+
+$large-width: 1400px;
+$medium-width: 1100px;
+$small-width: 800px;
+
+$large-left-width: 1100px;
+$medium-left-width: 900px;
+$small-left-width: 600px;

BIN
src/assets/img/CoCo.webp


BIN
src/assets/img/Hololo.webp


BIN
src/assets/img/MoonTIT.webp


BIN
src/assets/img/Nill.webp


BIN
src/assets/img/Ojin.webp


BIN
src/assets/img/billd.webp


BIN
src/assets/img/billd2.webp


+ 22 - 0
src/interface.ts

@@ -10,3 +10,25 @@ export interface IAdminIn {
   isAdmin: boolean;
   data: any;
 }
+
+export interface IOffer {
+  socketId: string;
+  roomId: string;
+  data: {
+    sdp: any;
+    target: string;
+    sender: string;
+    receiver: string;
+  };
+  isAdmin: boolean;
+}
+
+export interface ICandidate {
+  socketId: string;
+  roomId: string;
+  data: {
+    candidate: string;
+    sdpMid: string | null;
+    sdpMLineIndex: number | null;
+  };
+}

+ 21 - 10
src/layout/head/index.vue

@@ -1,7 +1,12 @@
 <template>
   <div class="head-wrap">
     <div class="left">
-      <div class="logo">Billd直播</div>
+      <div
+        class="logo"
+        @click="router.push('/')"
+      >
+        Billd直播
+      </div>
       <div class="nav">
         <div
           v-for="(item, index) in list"
@@ -21,9 +26,9 @@
     </div>
     <div class="right">
       <div class="avatar">{{ !userStore.detail && '登录' }}</div>
-      <div class="item">动态</div>
+      <!-- <div class="item">动态</div>
       <div class="item">签到</div>
-      <div class="item">饭贩</div>
+      <div class="item">饭贩</div> -->
       <div
         v-if="id === '1234'"
         class="start"
@@ -37,24 +42,29 @@
 
 <script lang="ts" setup>
 import { ref } from 'vue';
-import { useRoute } from 'vue-router';
+import { useRoute, useRouter } from 'vue-router';
 
 import { useAppStore } from '@/store/app';
 import { useUserStore } from '@/store/user';
 
 const route = useRoute();
+const router = useRouter();
 
 const id = route.query.id;
 const userStore = useUserStore();
 const appStore = useAppStore();
 
 const list = ref([
-  { ico: '', title: '首页' },
-  { title: '直播' },
-  { title: '全部' },
-  { title: '网游' },
-  { title: '手游' },
-  { title: '单机游戏' },
+  { ico: '', title: '一对一直播' },
+  { title: '一对多直播' },
+  // { title: '多对多直播' },
+  { title: '拉流展示' },
+  // { ico: '', title: '首页' },
+  // { title: '直播' },
+  // { title: '全部' },
+  // { title: '网游' },
+  // { title: '手游' },
+  // { title: '单机游戏' },
 ]);
 </script>
 
@@ -77,6 +87,7 @@ const list = ref([
       color: white;
       text-align: center;
       line-height: 40px;
+      cursor: pointer;
     }
     .nav {
       display: flex;

+ 0 - 1
src/layout/index.vue

@@ -14,6 +14,5 @@ import HeadCpt from './head/index.vue';
 <style lang="scss" scoped>
 .layout {
   min-height: 100vh;
-  background-color: skyblue;
 }
 </style>

+ 10 - 0
src/router/index.ts

@@ -15,6 +15,16 @@ export const defaultRoutes: RouteRecordRaw[] = [
         path: '/',
         component: () => import('@/views/home/index.vue'),
       },
+      {
+        name: 'push',
+        path: '/push',
+        component: () => import('@/views/push/index.vue'),
+      },
+      {
+        name: 'pull',
+        path: '/:roomId',
+        component: () => import('@/views/pull/index.vue'),
+      },
     ],
   },
 ];

+ 695 - 0
src/views/home copy/index copy.vue

@@ -0,0 +1,695 @@
+<template>
+  <div class="home-wrap">
+    <div class="left">
+      <div class="head">
+        <div class="info">
+          <div class="avatar"></div>
+          <div class="detail">
+            <div class="top">
+              <span class="tag">未开播</span>
+              <!-- 房东的猫livehouse/音乐节 -->
+              {{ networkStore.getRtcMap(roomId)?.rtcStatus }}
+            </div>
+            <div class="bottom">
+              <span class="tag">UP 3</span>
+              {{ getSocketId() }}
+            </div>
+          </div>
+        </div>
+        <div class="other">
+          <div class="top">
+            <span class="item">
+              <i class="ico"></i>
+              <span>直播间管理</span>
+            </span>
+            <span class="item">
+              <i class="ico"></i>
+              <span>1人看过</span>
+            </span>
+            <span class="item">
+              <i class="ico"></i>
+              <span>分享</span>
+            </span>
+          </div>
+          <div class="bottom">关注量:5</div>
+        </div>
+      </div>
+      <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="muted"
+          controls
+        ></video>
+      </div>
+      <div class="gift">
+        <div
+          v-for="(item, index) in giftList"
+          :key="index"
+          class="item"
+        >
+          <div class="ico"></div>
+          <div class="name">{{ item.name }}</div>
+          <div class="price">{{ item.price }}</div>
+        </div>
+      </div>
+    </div>
+    <div class="right">
+      <div class="tab">
+        <span>在线用户</span>
+        <span> | </span>
+        <span>大航海</span>
+      </div>
+      <div class="user-list">
+        <div
+          v-for="(item, index) in userList"
+          :key="index"
+          class="item"
+        >
+          <div class="info">
+            <div class="avatar"></div>
+            <div class="nickname">{{ item.nickname }}</div>
+          </div>
+          <div class="expr">{{ item.expr }}</div>
+        </div>
+      </div>
+      <div class="msg-list">
+        <div
+          v-for="(item, index) in msgList"
+          :key="index"
+          class="item"
+        >
+          <span class="name">{{ item.nickname }}:</span>
+          <span class="msg">{{ item.msg }}</span>
+        </div>
+      </div>
+      <div class="send-msg">
+        <textarea class="ipt"></textarea>
+        <div class="btn">发送</div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, ref, watch } from 'vue';
+import { useRoute } from 'vue-router';
+
+import { liveTypeEnum } from '@/interface';
+import { WebRTCClass } from '@/network/webRtc';
+import {
+  WebSocketClass,
+  WsConnectStatusEnum,
+  WsMsgTypeEnum,
+} from '@/network/webSocket';
+import { useAppStore } from '@/store/app';
+import { useNetworkStore } from '@/store/network';
+
+const networkStore = useNetworkStore();
+const route = useRoute();
+const appStore = useAppStore();
+const roomIdRef = ref<HTMLInputElement>();
+const joinRef = ref<HTMLButtonElement>();
+const leaveRef = ref<HTMLButtonElement>();
+const defaultRoomId = '19990507';
+const roomId = ref<string>(defaultRoomId);
+const websocketInstant = ref<WebSocketClass>();
+// const userList = ref<{ id: string; rooms: string[] }[]>([]);
+const isDone = ref(false);
+const muted = ref(true);
+const localVideoRef = ref<HTMLVideoElement>();
+const localStream = ref();
+const currType = ref(liveTypeEnum.screen); // 1:摄像头,2:录屏
+const id = ref('');
+
+const isAdmin = ref(route.query.id === '1234');
+
+const giftList = ref([
+  { name: '鲜花', ico: '', price: '免费' },
+  { name: '肥宅水', ico: '', price: '2元' },
+  { name: '小鸡腿', ico: '', price: '3元' },
+  { name: '大鸡腿', ico: '', price: '5元' },
+  { name: '一杯咖啡', ico: '', price: '10元' },
+]);
+const msgList = ref([
+  { nickname: '鲜花', msg: '423425' },
+  { nickname: '肥宅水', msg: 'sdgdsgsg' },
+  { nickname: '小鸡腿', msg: '63463gsd' },
+  { nickname: '大鸡腿', msg: '46326fb26' },
+  { nickname: '一杯咖啡', msg: 'shgd544' },
+  { nickname: 'sdsg', msg: 'shgd544' },
+  { nickname: 'gdsg', msg: 'we' },
+  { nickname: 'sgdx', msg: 'shgd544' },
+  { nickname: 'gsdx', msg: 'ew' },
+  { nickname: 'gs', msg: 'etew' },
+  { nickname: 'gwe', msg: 'shgd544' },
+  { nickname: 'tewtwe', msg: 'shgd544' },
+  { nickname: 'hdfh', msg: 'ew' },
+  { nickname: '534', msg: 'etew' },
+  { nickname: '234232', msg: 'shgd544' },
+]);
+
+const userList = ref<
+  {
+    nickname: string;
+    avatar: string;
+    expr: number;
+  }[]
+>([
+  // { nickname: '鲜花', avatar: '423425', expr: 100 },
+  // { nickname: '肥宅水', avatar: 'sdgdsgsg', expr: 100 },
+  // { nickname: '小鸡腿', avatar: '63463gsd', expr: 100 },
+  // { nickname: '大鸡腿', avatar: '46326fb26', expr: 100 },
+  // { nickname: '一杯咖啡', avatar: 'shgd544', expr: 100 },
+]);
+
+interface IOffer {
+  socketId: string;
+  roomId: string;
+  data: {
+    sdp: any;
+  };
+  isAdmin: boolean;
+}
+
+interface ICandidate {
+  socketId: string;
+  roomId: string;
+  data: {
+    candidate: string;
+    sdpMid: string | null;
+    sdpMLineIndex: number | null;
+  };
+}
+
+onMounted(() => {
+  id.value = route.query.id as string;
+  websocketInstant.value = new WebSocketClass({
+    roomId: roomId.value,
+    url:
+      process.env.NODE_ENV === 'development'
+        ? 'ws://localhost:4300'
+        : 'wss://live.hsslive.cn',
+    isAdmin: isAdmin.value,
+  });
+  websocketInstant.value.update();
+  initReceive();
+  sendJoin();
+  localVideoRef.value?.addEventListener('loadstart', () => {
+    console.warn('视频流-loadstart');
+    const rtc = networkStore.getRtcMap(roomId.value);
+    if (!rtc) return;
+    rtc.rtcStatus.loadstart = true;
+    rtc.update();
+  });
+
+  localVideoRef.value?.addEventListener('loadedmetadata', async () => {
+    console.warn('视频流-loadedmetadata');
+    const rtc = networkStore.getRtcMap(roomId.value);
+    if (!rtc) return;
+    rtc.rtcStatus.loadedmetadata = true;
+    rtc.update();
+    if (isAdmin.value) {
+      websocketInstant.value?.send({
+        msgType: WsMsgTypeEnum.adminIn,
+        data: {},
+      });
+      await sendOffer();
+    } else {
+      isDone.value = true;
+    }
+  });
+});
+
+watch(
+  () => appStore.liveStatus,
+  (newVal) => {
+    if (newVal) {
+      console.log('开始直播');
+      join();
+    }
+  }
+);
+
+function getSocketId() {
+  return networkStore.wsMap.get(roomId.value!)?.socketIo?.id || -1;
+}
+
+function sendJoin() {
+  const instance = networkStore.wsMap.get(roomId.value);
+  if (!instance) return;
+  instance.send({ msgType: WsMsgTypeEnum.join, data: {} });
+}
+
+async function join() {
+  console.log('join的房间号', roomId.value);
+  if (!roomId.value) {
+    console.error('房间号不能为空!');
+    alert('房间号不能为空!');
+    return;
+  }
+
+  if (isAdmin.value) {
+    try {
+      if (currType.value === liveTypeEnum.camera) {
+        await startMediaDevices();
+      } else if (currType.value === liveTypeEnum.screen) {
+        await startGetDisplayMedia();
+      }
+    } catch (error) {
+      console.log('用户拒绝', error);
+    }
+  }
+}
+
+function initReceive() {
+  const instance = websocketInstant.value;
+  if (!instance?.socketIo) return;
+  // websocket连接成功
+  instance.socketIo.on(WsConnectStatusEnum.connect, () => {
+    console.log('【websocket】websocket连接成功', instance.socketIo?.id);
+    if (!instance) return;
+    instance.status = WsConnectStatusEnum.connect;
+    instance.update();
+  });
+
+  // websocket连接断开
+  instance.socketIo.on(WsConnectStatusEnum.disconnect, () => {
+    console.log('【websocket】websocket连接断开', instance);
+    if (!instance) return;
+    instance.status = WsConnectStatusEnum.disconnect;
+    instance.update();
+  });
+
+  // 当前所有在线用户
+  instance.socketIo.on(WsMsgTypeEnum.adminIn, (data) => {
+    console.log('【websocket】收到管理员正在直播', data);
+    if (isDone.value) return;
+    sendOffer();
+  });
+
+  // 当前所有在线用户
+  instance.socketIo.on(WsMsgTypeEnum.liveUser, (data) => {
+    console.log('【websocket】当前所有在线用户');
+    if (!instance) return;
+  });
+
+  // 收到offer
+  instance.socketIo.on(WsMsgTypeEnum.offer, async (data: IOffer) => {
+    console.warn('【websocket】收到offer', data);
+    if (isDone.value) return;
+    if (!instance) return;
+    if (data.socketId !== getSocketId()) {
+      const rtc = networkStore.getRtcMap(roomId.value);
+      if (!rtc) return;
+      console.log('收到offer,并且这个offer不是我发的', data);
+      await rtc.setRemoteDescription(data.data.sdp);
+      const sdp = await rtc.createAnswer();
+      await rtc.setLocalDescription(sdp);
+      websocketInstant.value?.send({
+        msgType: WsMsgTypeEnum.answer,
+        data: { sdp },
+      });
+    } else {
+      console.log('收到offer,并且这个offer是我发的');
+    }
+  });
+
+  // 收到answer
+  instance.socketIo.on(WsMsgTypeEnum.answer, async (data: IOffer) => {
+    console.warn('【websocket】收到answer', data);
+    if (isDone.value) return;
+    if (!instance) return;
+    const rtc = networkStore.getRtcMap(roomId.value);
+    if (!rtc) return;
+    rtc.rtcStatus.answer = true;
+    rtc.update();
+    if (data.socketId !== getSocketId()) {
+      console.log('不是我发的answer');
+      await rtc.setRemoteDescription(data.data.sdp);
+    } else {
+      console.log('是我发的answer');
+    }
+  });
+
+  // 收到candidate
+  instance.socketIo.on(WsMsgTypeEnum.candidate, (data: ICandidate) => {
+    console.warn('【websocket】收到candidate', data);
+    if (isDone.value) return;
+    if (!instance) return;
+    const rtc = networkStore.getRtcMap(roomId.value);
+    if (!rtc) return;
+    if (data.socketId !== getSocketId()) {
+      console.log('不是我发的candidate');
+      const candidate = new RTCIceCandidate({
+        sdpMid: data.data.sdpMid,
+        sdpMLineIndex: data.data.sdpMLineIndex,
+        candidate: data.data.candidate,
+      });
+      rtc.peerConnection
+        ?.addIceCandidate(candidate)
+        .then(() => {
+          console.log('candidate成功');
+        })
+        .catch((err) => {
+          console.error('candidate失败', err);
+        });
+    } else {
+      console.log('是我发的candidate');
+    }
+  });
+
+  // 用户加入房间
+  instance.socketIo.on(WsMsgTypeEnum.join, (data) => {
+    console.log('【websocket】用户加入房间', data);
+    if (!instance) return;
+  });
+
+  // 用户加入房间
+  instance.socketIo.on(WsMsgTypeEnum.joined, (data) => {
+    console.log('【websocket】用户加入房间完成', data);
+    startNewWebRtc();
+  });
+
+  // 其他用户加入房间
+  instance.socketIo.on(WsMsgTypeEnum.otherJoin, (data) => {
+    console.log('【websocket】其他用户加入房间', data);
+    userList.value.push({
+      avatar: 'red',
+      nickname: data.socketId,
+      expr: 1,
+    });
+    if (isDone.value) return;
+    const rtc = networkStore.getRtcMap(roomId.value);
+    if (!instance || !rtc) return;
+    if (isAdmin.value) {
+      // 管理员还没有直播,不能发送offer
+      if (!rtc.rtcStatus.loadedmetadata) return;
+      sendOffer();
+    } else {
+      // sendOffer();
+    }
+  });
+
+  // 用户离开房间
+  instance.socketIo.on(WsMsgTypeEnum.leave, (data) => {
+    console.log('【websocket】用户离开房间', data);
+    if (!instance) return;
+    instance.socketIo?.emit(WsMsgTypeEnum.leave, {
+      roomId: instance.roomId,
+    });
+    userList.value = userList.value.filter(
+      (item) => item.nickname === data.socketId
+    );
+  });
+
+  // 用户离开房间完成
+  instance.socketIo.on(WsMsgTypeEnum.leaved, (data) => {
+    console.log('【websocket】用户离开房间完成', data);
+    if (!instance) return;
+    instance.close();
+  });
+}
+
+async function startMediaDevices() {
+  currType.value = liveTypeEnum.camera;
+  // 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);
+  if (!localVideoRef.value) return;
+  localVideoRef.value.srcObject = event;
+  localStream.value = event;
+  console.log('加轨1');
+  localStream.value.getTracks().forEach((track) => {
+    networkStore.getRtcMap(roomId.value)?.addTrack(track, localStream.value);
+  });
+}
+
+async function startGetDisplayMedia() {
+  currType.value = liveTypeEnum.screen;
+  // WARN navigator.mediaDevices.getDisplayMedia在localhost和https才能用,http://192.168.1.103:8000局域网用不了
+  const event = await navigator.mediaDevices.getDisplayMedia({
+    video: true,
+    audio: true,
+  });
+  console.log('getDisplayMedia成功', event);
+  if (!localVideoRef.value) return;
+  localVideoRef.value.srcObject = event;
+  localStream.value = event;
+  console.log('加轨2');
+  localStream.value.getTracks().forEach((track) => {
+    console.log(track, networkStore.getRtcMap(roomId.value));
+    networkStore.getRtcMap(roomId.value)?.addTrack(track, localStream.value);
+  });
+}
+
+async function sendOffer() {
+  if (isDone.value) return;
+  if (!websocketInstant.value) return;
+  const rtc = networkStore.getRtcMap(roomId.value);
+  if (!rtc) return;
+  if (isAdmin.value) {
+    const sdp = await rtc.createOffer();
+    await rtc.setLocalDescription(sdp);
+    websocketInstant.value.send({
+      msgType: WsMsgTypeEnum.offer,
+      data: { sdp },
+    });
+  }
+}
+
+function startNewWebRtc() {
+  if (isDone.value) return;
+  console.warn('开始new WebRTCClass');
+  const rtc = new WebRTCClass({ roomId: roomId.value });
+  rtc.rtcStatus.joined = true;
+  rtc.update();
+  roomId.value = rtc.roomId;
+  userList.value.push({
+    avatar: 'red',
+    nickname: `${getSocketId()}`,
+    expr: 1,
+  });
+}
+
+function leave() {
+  if (joinRef.value && leaveRef.value && roomIdRef.value) {
+    roomIdRef.value.disabled = false;
+    joinRef.value.disabled = false;
+    leaveRef.value.disabled = true;
+  }
+  if (!websocketInstant.value) return;
+  websocketInstant.value.socketIo?.emit(WsMsgTypeEnum.leave, {
+    roomId: websocketInstant.value.roomId,
+  });
+}
+</script>
+
+<style lang="scss" scoped>
+.home-wrap {
+  display: flex;
+  justify-content: space-between;
+  margin: 20px auto 0;
+  min-width: 1200px;
+  width: 80%;
+  .left {
+    min-width: 1000px;
+    border-radius: 10px;
+    background-color: white;
+    color: #9499a0;
+    .head {
+      display: flex;
+      justify-content: space-between;
+      padding: 20px;
+      .tag {
+        display: inline-block;
+        margin-right: 5px;
+        padding: 1px 4px;
+        border: 1px solid;
+        border-radius: 2px;
+        color: #9499a0;
+        font-size: 12px;
+      }
+
+      .info {
+        display: flex;
+        align-items: center;
+        .avatar {
+          margin-right: 20px;
+          width: 64px;
+          height: 64px;
+          border-radius: 50%;
+          background-color: yellow;
+        }
+        .detail {
+          .top {
+            margin-bottom: 10px;
+            color: #18191c;
+          }
+          .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;
+        }
+      }
+    }
+    .video-wrap {
+      height: 500px;
+      background-color: #18191c;
+      #localVideo {
+        width: 100%;
+        height: 100%;
+      }
+    }
+    .gift {
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      padding: 10px 20px;
+      background-color: white;
+      .item {
+        margin-right: 10px;
+        text-align: center;
+
+        .ico {
+          width: 50px;
+          height: 50px;
+          background-color: skyblue;
+        }
+        .name {
+          color: #18191c;
+          font-size: 12px;
+        }
+        .price {
+          color: #9499a0;
+          font-size: 12px;
+        }
+      }
+    }
+  }
+  .right {
+    position: relative;
+    box-sizing: border-box;
+    min-width: 300px;
+    border-radius: 10px;
+    background-color: white;
+    color: #9499a0;
+    .tab {
+      display: flex;
+      align-items: center;
+      justify-content: space-evenly;
+      padding: 5px 0;
+      font-size: 12px;
+    }
+    .user-list {
+      overflow-y: scroll;
+      padding: 0 15px;
+      height: 100px;
+      .item {
+        display: flex;
+        align-items: center;
+        justify-content: space-between;
+        margin-bottom: 10px;
+        font-size: 12px;
+        .info {
+          display: flex;
+          align-items: center;
+
+          .avatar {
+            margin-right: 5px;
+            width: 25px;
+            height: 25px;
+            border-radius: 50%;
+            background-color: skyblue;
+          }
+          .nickname {
+            color: black;
+          }
+        }
+      }
+    }
+    .msg-list {
+      overflow-y: scroll;
+      padding: 0 15px;
+      height: 350px;
+      .item {
+        margin-bottom: 10px;
+        font-size: 12px;
+        .name {
+          color: #9499a0;
+        }
+        .msg {
+          color: #61666d;
+        }
+      }
+    }
+    .send-msg {
+      position: absolute;
+      bottom: 15px;
+      box-sizing: border-box;
+      padding: 0 10px;
+      width: 100%;
+      .ipt {
+        display: block;
+        box-sizing: border-box;
+        margin: 0 auto;
+        padding: 10px;
+        width: 100%;
+        height: 60px;
+        outline: none;
+        border: 1px solid hsla(0, 0%, 60%, 0.2);
+        border-radius: 4px;
+        background-color: #f1f2f3;
+        font-size: 14px;
+      }
+      .btn {
+        box-sizing: border-box;
+        margin-top: 10px;
+        margin-left: auto;
+        padding: 5px;
+        width: 80px;
+        border-radius: 4px;
+        background-color: #23ade5;
+        color: white;
+        text-align: center;
+        font-size: 12px;
+      }
+    }
+  }
+}
+</style>

+ 702 - 0
src/views/home copy/index.vue

@@ -0,0 +1,702 @@
+<template>
+  <div class="home-wrap">
+    <div class="left">
+      <div class="head">
+        <div class="info">
+          <div class="avatar"></div>
+          <div class="detail">
+            <div class="top">
+              <span class="tag">未开播</span>
+              <button @click="addTrack">addTrack</button>
+              <button @click="handleMedia">handleMedia</button>
+              <button @click="batchSendOffer">batchSendOffer</button>
+              <!-- 房东的猫livehouse/音乐节 -->
+              {{ networkStore.getRtcMap(roomId)?.rtcStatus }}
+            </div>
+            <div class="bottom">
+              <span class="tag">UP 3</span>
+              {{ getSocketId() }}
+            </div>
+          </div>
+        </div>
+        <div class="other">
+          <div class="top">
+            <span class="item">
+              <i class="ico"></i>
+              <span>直播间管理</span>
+            </span>
+            <span class="item">
+              <i class="ico"></i>
+              <span>1人看过</span>
+            </span>
+            <span class="item">
+              <i class="ico"></i>
+              <span>分享</span>
+            </span>
+          </div>
+          <div class="bottom">关注量:5</div>
+        </div>
+      </div>
+      <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="muted"
+          controls
+        ></video>
+      </div>
+      <div class="gift">
+        <div
+          v-for="(item, index) in giftList"
+          :key="index"
+          class="item"
+        >
+          <div class="ico"></div>
+          <div class="name">{{ item.name }}</div>
+          <div class="price">{{ item.price }}</div>
+        </div>
+      </div>
+    </div>
+    <div class="right">
+      <div class="tab">
+        <span>在线用户</span>
+        <span> | </span>
+        <span>大航海</span>
+      </div>
+      <div class="user-list">
+        <div
+          v-for="(item, index) in liveUserList"
+          :key="index"
+          class="item"
+        >
+          <div class="info">
+            <div class="avatar"></div>
+            <div class="nickname">{{ item.socketId }}</div>
+          </div>
+          <div class="expr">{{ item.expr }}</div>
+        </div>
+      </div>
+      <div class="msg-list">
+        <div
+          v-for="(item, index) in msgList"
+          :key="index"
+          class="item"
+        >
+          <span class="name">{{ item.nickname }}:</span>
+          <span class="msg">{{ item.msg }}</span>
+        </div>
+      </div>
+      <div class="send-msg">
+        <textarea class="ipt"></textarea>
+        <div class="btn">发送</div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, ref, watch } from 'vue';
+import { useRoute } from 'vue-router';
+
+import { IAdminIn, ICandidate, IOffer, liveTypeEnum } from '@/interface';
+import { WebRTCClass } from '@/network/webRtc';
+import {
+  WebSocketClass,
+  WsConnectStatusEnum,
+  WsMsgTypeEnum,
+} from '@/network/webSocket';
+import { useAppStore } from '@/store/app';
+import { useNetworkStore } from '@/store/network';
+
+const networkStore = useNetworkStore();
+const route = useRoute();
+const appStore = useAppStore();
+const roomIdRef = ref<HTMLInputElement>();
+const joinRef = ref<HTMLButtonElement>();
+const leaveRef = ref<HTMLButtonElement>();
+const defaultRoomId = '19990507';
+const roomId = ref<string>(defaultRoomId);
+const websocketInstant = ref<WebSocketClass>();
+const isDone = ref(false);
+const muted = ref(true);
+const localVideoRef = ref<HTMLVideoElement>();
+const localStream = ref();
+const currType = ref(liveTypeEnum.camera); // 1:摄像头,2:录屏
+const id = ref('');
+const joined = ref(false);
+const isAdmin = ref(route.query.id === '1234');
+const offerSended = ref(new Set());
+
+const giftList = ref([
+  { name: '鲜花', ico: '', price: '免费' },
+  { name: '肥宅水', ico: '', price: '2元' },
+  { name: '小鸡腿', ico: '', price: '3元' },
+  { name: '大鸡腿', ico: '', price: '5元' },
+  { name: '一杯咖啡', ico: '', price: '10元' },
+]);
+const msgList = ref([
+  { nickname: '鲜花', msgType: 1, msg: '423425' },
+  { nickname: '肥宅水', msgType: 1, msg: 'sdgdsgsg' },
+  { nickname: '小鸡腿', msgType: 1, msg: '63463gsd' },
+  { nickname: '大鸡腿', msgType: 1, msg: '46326fb26' },
+  { nickname: '一杯咖啡', msgType: 1, msg: 'shgd544' },
+  { nickname: 'sdsg', msgType: 1, msg: 'shgd544' },
+  { nickname: 'gdsg', msgType: 1, msg: 'we' },
+  { nickname: 'sgdx', msgType: 1, msg: 'shgd544' },
+  { nickname: 'gsdx', msgType: 1, msg: 'ew' },
+  { nickname: 'gs', msgType: 1, msg: 'etew' },
+  { nickname: 'gwe', msgType: 1, msg: 'shgd544' },
+  { nickname: 'tewtwe', msgType: 1, msg: 'shgd544' },
+  { nickname: 'hdfh', msgType: 1, msg: 'ew' },
+  { nickname: '534', msgType: 1, msg: 'etew' },
+  { nickname: '234232', msgType: 1, msg: 'shgd544' },
+]);
+
+const liveUserList = ref<
+  {
+    socketId: string;
+    avatar: string;
+    expr: number;
+  }[]
+>([]);
+
+onMounted(() => {
+  id.value = route.query.id as string;
+  websocketInstant.value = new WebSocketClass({
+    roomId: roomId.value,
+    url:
+      process.env.NODE_ENV === 'development'
+        ? 'ws://localhost:4300'
+        : 'wss://live.hsslive.cn',
+    isAdmin: isAdmin.value,
+  });
+  websocketInstant.value.update();
+  initReceive();
+  sendJoin();
+  localVideoRef.value?.addEventListener('loadstart', () => {
+    console.warn('视频流-loadstart');
+    const rtc = networkStore.getRtcMap(roomId.value);
+    if (!rtc) return;
+    rtc.rtcStatus.loadstart = true;
+    rtc.update();
+  });
+
+  localVideoRef.value?.addEventListener('abort', () => {
+    console.warn('视频流-abort');
+  });
+
+  localVideoRef.value?.addEventListener('pause', () => {
+    console.warn('视频流-pause');
+  });
+  localVideoRef.value?.addEventListener('error', () => {
+    console.warn('视频流-error');
+  });
+
+  localVideoRef.value?.addEventListener('loadedmetadata', () => {
+    console.warn('视频流-loadedmetadata');
+    const rtc = networkStore.getRtcMap(roomId.value);
+    if (!rtc) return;
+    rtc.rtcStatus.loadedmetadata = true;
+    rtc.update();
+    if (isAdmin.value) {
+      batchSendOffer();
+    }
+  });
+});
+
+watch(
+  () => appStore.liveStatus,
+  (newVal) => {
+    if (newVal) {
+      console.log('开始直播');
+      handleMedia();
+    }
+  }
+);
+
+function getSocketId() {
+  return networkStore.wsMap.get(roomId.value!)?.socketIo?.id || '-1';
+}
+
+function sendJoin() {
+  const instance = networkStore.wsMap.get(roomId.value);
+  if (!instance) return;
+  instance.send({ msgType: WsMsgTypeEnum.join, data: {} });
+}
+
+function batchSendOffer() {
+  liveUserList.value.forEach(async (item) => {
+    if (
+      !offerSended.value.has(item.socketId) &&
+      item.socketId !== getSocketId()
+    ) {
+      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);
+    }
+  });
+}
+
+async function handleMedia() {
+  if (isAdmin.value) {
+    try {
+      if (currType.value === liveTypeEnum.camera) {
+        await startMediaDevices();
+      } else if (currType.value === liveTypeEnum.screen) {
+        await startGetDisplayMedia();
+      }
+    } catch (error) {
+      console.log('用户拒绝', error);
+    }
+  }
+}
+
+function initReceive() {
+  const instance = websocketInstant.value;
+  if (!instance?.socketIo) return;
+  // websocket连接成功
+  instance.socketIo.on(WsConnectStatusEnum.connect, () => {
+    console.log('【websocket】websocket连接成功', instance.socketIo?.id);
+    if (!instance) return;
+    instance.status = WsConnectStatusEnum.connect;
+    instance.update();
+  });
+
+  // websocket连接断开
+  instance.socketIo.on(WsConnectStatusEnum.disconnect, () => {
+    console.log('【websocket】websocket连接断开', instance);
+    if (!instance) return;
+    instance.status = WsConnectStatusEnum.disconnect;
+    instance.update();
+  });
+
+  // 当前所有在线用户
+  instance.socketIo.on(WsMsgTypeEnum.adminIn, (data: IAdminIn) => {
+    console.log('【websocket】收到管理员正在直播', data);
+    if (isDone.value) return;
+    // sendOffer({ sender: getSocketId(), receiver: data.socketId });
+  });
+
+  // 当前所有在线用户
+  instance.socketIo.on(WsMsgTypeEnum.liveUser, () => {
+    console.log('【websocket】当前所有在线用户');
+    if (!instance) return;
+  });
+
+  // 收到offer
+  instance.socketIo.on(WsMsgTypeEnum.offer, async (data: IOffer) => {
+    console.warn('【websocket】收到offer', data);
+    if (!instance) return;
+    if (data.data.receiver === getSocketId()) {
+      console.log('收到offer,这个offer是发给我的');
+      const rtc = startNewWebRtc(data.data.sender);
+      await rtc.setRemoteDescription(data.data.sdp);
+      const sdp = await rtc.createAnswer();
+      await rtc.setLocalDescription(sdp);
+      websocketInstant.value?.send({
+        msgType: WsMsgTypeEnum.answer,
+        data: { sdp, sender: getSocketId(), receiver: data.data.sender },
+      });
+    } else {
+      console.log('收到offer,但是这个offer不是发给我的');
+    }
+  });
+
+  // 收到answer
+  instance.socketIo.on(WsMsgTypeEnum.answer, async (data: IOffer) => {
+    console.warn('【websocket】收到answer', data);
+    if (isDone.value) return;
+    if (!instance) return;
+    const rtc = networkStore.getRtcMap(`${roomId.value}___${data.socketId}`);
+    console.log(rtc, '收到answer收到answer');
+    if (!rtc) return;
+    rtc.rtcStatus.answer = true;
+    rtc.update();
+    if (data.data.receiver === getSocketId()) {
+      console.log('收到answer,这个answer是发给我的');
+      await rtc.setRemoteDescription(data.data.sdp);
+    } else {
+      console.log('收到answer,但这个answer不是发给我的');
+    }
+  });
+
+  // 收到candidate
+  instance.socketIo.on(WsMsgTypeEnum.candidate, (data: ICandidate) => {
+    console.warn('【websocket】收到candidate', data);
+    if (isDone.value) return;
+    if (!instance) return;
+    const rtc =
+      networkStore.getRtcMap(`${roomId.value}___${data.socketId}`) ||
+      networkStore.getRtcMap(roomId.value);
+    if (!rtc) return;
+    if (data.socketId !== getSocketId()) {
+      console.log('不是我发的candidate');
+      const candidate = new RTCIceCandidate({
+        sdpMid: data.data.sdpMid,
+        sdpMLineIndex: data.data.sdpMLineIndex,
+        candidate: data.data.candidate,
+      });
+      rtc.peerConnection
+        ?.addIceCandidate(candidate)
+        .then(() => {
+          console.log('candidate成功');
+          // rtc.handleStream();
+        })
+        .catch((err) => {
+          console.error('candidate失败', err);
+        });
+    } else {
+      console.log('是我发的candidate');
+    }
+  });
+
+  // 用户加入房间
+  instance.socketIo.on(WsMsgTypeEnum.join, (data) => {
+    console.log('【websocket】用户加入房间', data);
+    if (!instance) return;
+  });
+
+  // 用户加入房间
+  instance.socketIo.on(WsMsgTypeEnum.joined, (data) => {
+    console.log('【websocket】用户加入房间完成', data);
+    joined.value = true;
+    liveUserList.value.push({
+      avatar: 'red',
+      socketId: `${getSocketId()}`,
+      expr: 1,
+    });
+  });
+
+  // 其他用户加入房间
+  instance.socketIo.on(WsMsgTypeEnum.otherJoin, (data) => {
+    console.log('【websocket】其他用户加入房间', data);
+    liveUserList.value.push({
+      avatar: 'red',
+      socketId: data.socketId,
+      expr: 1,
+    });
+    console.log('当前所有在线用户', JSON.stringify(liveUserList.value));
+    if (isAdmin.value && joined.value) {
+      batchSendOffer();
+    }
+  });
+
+  // 用户离开房间
+  instance.socketIo.on(WsMsgTypeEnum.leave, (data) => {
+    console.log('【websocket】用户离开房间', data);
+    if (!instance) return;
+    instance.socketIo?.emit(WsMsgTypeEnum.leave, {
+      roomId: instance.roomId,
+    });
+  });
+
+  // 用户离开房间完成
+  instance.socketIo.on(WsMsgTypeEnum.leaved, (data) => {
+    console.log('【websocket】用户离开房间完成', data);
+    if (!instance) return;
+    const res = liveUserList.value.filter(
+      (item) => item.socketId !== data.socketId
+    );
+    console.log('当前所有在线用户', JSON.stringify(res));
+    liveUserList.value = res;
+  });
+}
+
+async function startMediaDevices() {
+  currType.value = liveTypeEnum.camera;
+  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);
+    if (!localVideoRef.value) return;
+    localVideoRef.value.srcObject = event;
+    localStream.value = event;
+  }
+}
+const isAddTrack = ref(false);
+function addTrack() {
+  if (!localStream.value) return;
+  // if (isAddTrack.value || !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);
+      });
+    }
+  });
+  isAddTrack.value = true;
+}
+
+async function startGetDisplayMedia() {
+  currType.value = liveTypeEnum.screen;
+  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,
+    });
+    console.log('getDisplayMedia成功', event);
+    if (!localVideoRef.value) return;
+    localVideoRef.value.srcObject = event;
+    localStream.value = event;
+  }
+}
+
+async function sendOffer({
+  sender,
+  receiver,
+}: {
+  sender: string;
+  receiver: string;
+}) {
+  if (isDone.value) return;
+  if (!websocketInstant.value) return;
+  const rtc = networkStore.getRtcMap(`${roomId.value}___${receiver}`);
+  if (!rtc) return;
+  const sdp = await rtc.createOffer();
+  await rtc.setLocalDescription(sdp);
+  websocketInstant.value.send({
+    msgType: WsMsgTypeEnum.offer,
+    data: { sdp, sender, receiver },
+  });
+}
+
+function startNewWebRtc(receiver: string) {
+  console.warn('开始new WebRTCClass', receiver);
+  const rtc = new WebRTCClass({ roomId: `${roomId.value}___${receiver}` });
+  rtc.rtcStatus.joined = true;
+  rtc.update();
+  return rtc;
+}
+
+function leave() {
+  if (joinRef.value && leaveRef.value && roomIdRef.value) {
+    roomIdRef.value.disabled = false;
+    joinRef.value.disabled = false;
+    leaveRef.value.disabled = true;
+  }
+  if (!websocketInstant.value) return;
+  websocketInstant.value.socketIo?.emit(WsMsgTypeEnum.leave, {
+    roomId: websocketInstant.value.roomId,
+  });
+}
+</script>
+
+<style lang="scss" scoped>
+.home-wrap {
+  display: flex;
+  justify-content: space-between;
+  margin: 20px auto 0;
+  min-width: 1200px;
+  width: 80%;
+  .left {
+    min-width: 1000px;
+    border-radius: 10px;
+    background-color: white;
+    color: #9499a0;
+    .head {
+      display: flex;
+      justify-content: space-between;
+      padding: 20px;
+      .tag {
+        display: inline-block;
+        margin-right: 5px;
+        padding: 1px 4px;
+        border: 1px solid;
+        border-radius: 2px;
+        color: #9499a0;
+        font-size: 12px;
+      }
+
+      .info {
+        display: flex;
+        align-items: center;
+        .avatar {
+          margin-right: 20px;
+          width: 64px;
+          height: 64px;
+          border-radius: 50%;
+          background-color: yellow;
+        }
+        .detail {
+          .top {
+            margin-bottom: 10px;
+            color: #18191c;
+          }
+          .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;
+        }
+      }
+    }
+    .video-wrap {
+      // height: 100px;
+      height: 500px;
+      background-color: #18191c;
+      #localVideo {
+        width: 100%;
+        height: 100%;
+      }
+    }
+    .gift {
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      padding: 10px 20px;
+      background-color: white;
+      .item {
+        margin-right: 10px;
+        text-align: center;
+
+        .ico {
+          width: 50px;
+          height: 50px;
+          background-color: skyblue;
+        }
+        .name {
+          color: #18191c;
+          font-size: 12px;
+        }
+        .price {
+          color: #9499a0;
+          font-size: 12px;
+        }
+      }
+    }
+  }
+  .right {
+    position: relative;
+    box-sizing: border-box;
+    min-width: 300px;
+    border-radius: 10px;
+    background-color: white;
+    color: #9499a0;
+    .tab {
+      display: flex;
+      align-items: center;
+      justify-content: space-evenly;
+      padding: 5px 0;
+      font-size: 12px;
+    }
+    .user-list {
+      overflow-y: scroll;
+      padding: 0 15px;
+      height: 100px;
+      .item {
+        display: flex;
+        align-items: center;
+        justify-content: space-between;
+        margin-bottom: 10px;
+        font-size: 12px;
+        .info {
+          display: flex;
+          align-items: center;
+
+          .avatar {
+            margin-right: 5px;
+            width: 25px;
+            height: 25px;
+            border-radius: 50%;
+            background-color: skyblue;
+          }
+          .nickname {
+            color: black;
+          }
+        }
+      }
+    }
+    .msg-list {
+      overflow-y: scroll;
+      padding: 0 15px;
+      height: 350px;
+      .item {
+        margin-bottom: 10px;
+        font-size: 12px;
+        .name {
+          color: #9499a0;
+        }
+        .msg {
+          color: #61666d;
+        }
+      }
+    }
+    .send-msg {
+      position: absolute;
+      bottom: 15px;
+      box-sizing: border-box;
+      padding: 0 10px;
+      width: 100%;
+      .ipt {
+        display: block;
+        box-sizing: border-box;
+        margin: 0 auto;
+        padding: 10px;
+        width: 100%;
+        height: 60px;
+        outline: none;
+        border: 1px solid hsla(0, 0%, 60%, 0.2);
+        border-radius: 4px;
+        background-color: #f1f2f3;
+        font-size: 14px;
+      }
+      .btn {
+        box-sizing: border-box;
+        margin-top: 10px;
+        margin-left: auto;
+        padding: 5px;
+        width: 80px;
+        border-radius: 4px;
+        background-color: #23ade5;
+        color: white;
+        text-align: center;
+        font-size: 12px;
+      }
+    }
+  }
+}
+</style>

+ 132 - 694
src/views/home/index.vue

@@ -1,741 +1,179 @@
 <template>
   <div class="home-wrap">
     <div class="left">
-      <div class="head">
-        <div class="info">
-          <div class="avatar"></div>
-          <div class="detail">
-            <div class="top">
-              <span class="tag">未开播</span>
-              <button @click="addTrack">addTrack</button>
-              <button @click="handleMedia">handleMedia</button>
-              <button @click="batchSendOffer">batchSendOffer</button>
-              <!-- 房东的猫livehouse/音乐节 -->
-              {{ networkStore.getRtcMap(roomId)?.rtcStatus }}
-            </div>
-            <div class="bottom">
-              <span class="tag">UP 3</span>
-              {{ getSocketId() }}
-            </div>
-          </div>
-        </div>
-        <div class="other">
-          <div class="top">
-            <span class="item">
-              <i class="ico"></i>
-              <span>直播间管理</span>
-            </span>
-            <span class="item">
-              <i class="ico"></i>
-              <span>1人看过</span>
-            </span>
-            <span class="item">
-              <i class="ico"></i>
-              <span>分享</span>
-            </span>
-          </div>
-          <div class="bottom">关注量:5</div>
-        </div>
-      </div>
-      <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="muted"
-          controls
-        ></video>
-      </div>
-      <div class="gift">
-        <div
-          v-for="(item, index) in giftList"
-          :key="index"
-          class="item"
-        >
-          <div class="ico"></div>
-          <div class="name">{{ item.name }}</div>
-          <div class="price">{{ item.price }}</div>
-        </div>
+      <video src=""></video>
+      <div
+        class="btn"
+        @click="joinRoom()"
+      >
+        进入直播
       </div>
     </div>
     <div class="right">
-      <div class="tab">
-        <span>在线用户</span>
-        <span> | </span>
-        <span>大航海</span>
-      </div>
-      <div class="user-list">
+      <div class="list">
         <div
-          v-for="(item, index) in liveUserList"
+          v-for="(item, index) in liveRoomList"
           :key="index"
-          class="item"
+          :class="{ item: 1, active: item.roomId === currentRoom.roomId }"
+          :style="{ backgroundImage: `url(${item.img})` }"
+          @click="currentRoom = item"
         >
-          <div class="info">
-            <div class="avatar"></div>
-            <div class="nickname">{{ item.socketId }}</div>
-          </div>
-          <div class="expr">{{ item.expr }}</div>
+          <div class="txt">{{ item.txt }}</div>
         </div>
       </div>
-      <div class="msg-list">
-        <div
-          v-for="(item, index) in msgList"
-          :key="index"
-          class="item"
-        >
-          <span class="name">{{ item.nickname }}:</span>
-          <span class="msg">{{ item.msg }}</span>
-        </div>
-      </div>
-      <div class="send-msg">
-        <textarea class="ipt"></textarea>
-        <div class="btn">发送</div>
-      </div>
     </div>
   </div>
 </template>
 
 <script lang="ts" setup>
-import { onMounted, ref, watch } from 'vue';
-import { useRoute } from 'vue-router';
+import { ref } from 'vue';
+import { useRouter } from 'vue-router';
 
-import { IAdminIn, liveTypeEnum } from '@/interface';
-import { WebRTCClass } from '@/network/webRtc';
-import {
-  WebSocketClass,
-  WsConnectStatusEnum,
-  WsMsgTypeEnum,
-} from '@/network/webSocket';
-import { useAppStore } from '@/store/app';
-import { useNetworkStore } from '@/store/network';
-
-const networkStore = useNetworkStore();
-const route = useRoute();
-const appStore = useAppStore();
-const roomIdRef = ref<HTMLInputElement>();
-const joinRef = ref<HTMLButtonElement>();
-const leaveRef = ref<HTMLButtonElement>();
-const defaultRoomId = '19990507';
-const roomId = ref<string>(defaultRoomId);
-const websocketInstant = ref<WebSocketClass>();
-// const liveUserList = ref<{ id: string; rooms: string[] }[]>([]);
-const isDone = ref(false);
-const muted = ref(true);
-const localVideoRef = ref<HTMLVideoElement>();
-const localStream = ref();
-const currType = ref(liveTypeEnum.screen); // 1:摄像头,2:录屏
-const id = ref('');
-const joined = ref(false);
-const rtcNum = ref(1);
-const isAdmin = ref(route.query.id === '1234');
-const offerSended = ref(new Set());
-const answerSended = ref(new Set());
-
-const giftList = ref([
-  { name: '鲜花', ico: '', price: '免费' },
-  { name: '肥宅水', ico: '', price: '2元' },
-  { name: '小鸡腿', ico: '', price: '3元' },
-  { name: '大鸡腿', ico: '', price: '5元' },
-  { name: '一杯咖啡', ico: '', price: '10元' },
-]);
-const msgList = ref([
-  { nickname: '鲜花', msg: '423425' },
-  { nickname: '肥宅水', msg: 'sdgdsgsg' },
-  { nickname: '小鸡腿', msg: '63463gsd' },
-  { nickname: '大鸡腿', msg: '46326fb26' },
-  { nickname: '一杯咖啡', msg: 'shgd544' },
-  { nickname: 'sdsg', msg: 'shgd544' },
-  { nickname: 'gdsg', msg: 'we' },
-  { nickname: 'sgdx', msg: 'shgd544' },
-  { nickname: 'gsdx', msg: 'ew' },
-  { nickname: 'gs', msg: 'etew' },
-  { nickname: 'gwe', msg: 'shgd544' },
-  { nickname: 'tewtwe', msg: 'shgd544' },
-  { nickname: 'hdfh', msg: 'ew' },
-  { nickname: '534', msg: 'etew' },
-  { nickname: '234232', msg: 'shgd544' },
-]);
-
-const liveUserList = ref<
+const router = useRouter();
+const liveRoomList = ref([
+  {
+    roomId: '123456',
+    txt: '视频聊天',
+    // eslint-disable-next-line
+    img: require('@/assets/img/CoCo.webp'),
+  },
+  {
+    roomId: '2323',
+    txt: '游戏赛事',
+    // eslint-disable-next-line
+    img: require('@/assets/img/Hololo.webp'),
+  },
+  {
+    roomId: '4454',
+    txt: '户外直播',
+    // eslint-disable-next-line
+    img: require('@/assets/img/MoonTIT.webp'),
+  },
   {
-    socketId: string;
-    avatar: string;
-    expr: number;
-  }[]
->([
-  // { nickname: '鲜花', avatar: '423425', expr: 100 },
-  // { nickname: '肥宅水', avatar: 'sdgdsgsg', expr: 100 },
-  // { nickname: '小鸡腿', avatar: '63463gsd', expr: 100 },
-  // { nickname: '大鸡腿', avatar: '46326fb26', expr: 100 },
-  // { nickname: '一杯咖啡', avatar: 'shgd544', expr: 100 },
+    roomId: '43232',
+    txt: '鬼畜',
+    // eslint-disable-next-line
+    img: require('@/assets/img/Nill.webp'),
+  },
+  {
+    roomId: '4647457',
+    txt: '闲聊',
+    // eslint-disable-next-line
+    img: require('@/assets/img/Ojin.webp'),
+  },
 ]);
 
-interface IOffer {
-  socketId: string;
-  roomId: string;
-  data: {
-    sdp: any;
-    target: string;
-    sender: string;
-    receiver: string;
-  };
-  isAdmin: boolean;
-}
-
-interface ICandidate {
-  socketId: string;
-  roomId: string;
-  data: {
-    candidate: string;
-    sdpMid: string | null;
-    sdpMLineIndex: number | null;
-  };
-}
-
-onMounted(() => {
-  id.value = route.query.id as string;
-  websocketInstant.value = new WebSocketClass({
-    roomId: roomId.value,
-    url:
-      process.env.NODE_ENV === 'development'
-        ? 'ws://localhost:4300'
-        : 'wss://live.hsslive.cn',
-    isAdmin: isAdmin.value,
-  });
-  websocketInstant.value.update();
-  initReceive();
-  sendJoin();
-  localVideoRef.value?.addEventListener('loadstart', () => {
-    console.warn('视频流-loadstart');
-    const rtc = networkStore.getRtcMap(roomId.value);
-    if (!rtc) return;
-    rtc.rtcStatus.loadstart = true;
-    rtc.update();
-  });
-
-  localVideoRef.value?.addEventListener('abort', () => {
-    console.warn('视频流-abort');
-  });
-
-  localVideoRef.value?.addEventListener('pause', () => {
-    console.warn('视频流-pause');
-  });
-  localVideoRef.value?.addEventListener('error', () => {
-    console.warn('视频流-error');
-  });
+const currentRoom = ref(liveRoomList.value[0]);
 
-  localVideoRef.value?.addEventListener('loadedmetadata', () => {
-    console.warn('视频流-loadedmetadata');
-    const rtc = networkStore.getRtcMap(roomId.value);
-    if (!rtc) return;
-    rtc.rtcStatus.loadedmetadata = true;
-    rtc.update();
-    if (isAdmin.value) {
-      batchSendOffer();
-    }
-  });
-});
-
-watch(
-  () => appStore.liveStatus,
-  (newVal) => {
-    if (newVal) {
-      console.log('开始直播');
-      join();
-    }
-  }
-);
-
-function getSocketId() {
-  return networkStore.wsMap.get(roomId.value!)?.socketIo?.id || '-1';
-}
-
-function sendJoin() {
-  const instance = networkStore.wsMap.get(roomId.value);
-  if (!instance) return;
-  instance.send({ msgType: WsMsgTypeEnum.join, data: {} });
-}
-
-function join() {
-  console.log('join的房间号', roomId.value);
-  if (!roomId.value) {
-    console.error('房间号不能为空!');
-    alert('房间号不能为空!');
-    return;
-  }
-  handleMedia();
-}
-
-function batchSendOffer() {
-  liveUserList.value.forEach(async (item) => {
-    if (
-      !offerSended.value.has(item.socketId) &&
-      item.socketId !== getSocketId()
-    ) {
-      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);
-    }
-  });
-}
-
-async function handleMedia() {
-  if (isAdmin.value) {
-    try {
-      if (currType.value === liveTypeEnum.camera) {
-        await startMediaDevices();
-      } else if (currType.value === liveTypeEnum.screen) {
-        await startGetDisplayMedia();
-      }
-    } catch (error) {
-      console.log('用户拒绝', error);
-    }
-  }
-}
-
-function initReceive() {
-  const instance = websocketInstant.value;
-  if (!instance?.socketIo) return;
-  // websocket连接成功
-  instance.socketIo.on(WsConnectStatusEnum.connect, () => {
-    console.log('【websocket】websocket连接成功', instance.socketIo?.id);
-    if (!instance) return;
-    instance.status = WsConnectStatusEnum.connect;
-    instance.update();
-  });
-
-  // websocket连接断开
-  instance.socketIo.on(WsConnectStatusEnum.disconnect, () => {
-    console.log('【websocket】websocket连接断开', instance);
-    if (!instance) return;
-    instance.status = WsConnectStatusEnum.disconnect;
-    instance.update();
-  });
-
-  // 当前所有在线用户
-  instance.socketIo.on(WsMsgTypeEnum.adminIn, (data: IAdminIn) => {
-    console.log('【websocket】收到管理员正在直播', data);
-    if (isDone.value) return;
-    // sendOffer({ sender: getSocketId(), receiver: data.socketId });
-  });
-
-  // 当前所有在线用户
-  instance.socketIo.on(WsMsgTypeEnum.liveUser, (data) => {
-    console.log('【websocket】当前所有在线用户');
-    if (!instance) return;
-  });
-
-  // 收到offer
-  instance.socketIo.on(WsMsgTypeEnum.offer, async (data: IOffer) => {
-    console.warn('【websocket】收到offer', data);
-    if (!instance) return;
-    if (data.data.receiver === getSocketId()) {
-      console.log('收到offer,这个offer是发给我的');
-      const rtc = startNewWebRtc(data.data.sender);
-      await rtc.setRemoteDescription(data.data.sdp);
-      const sdp = await rtc.createAnswer();
-      await rtc.setLocalDescription(sdp);
-      websocketInstant.value?.send({
-        msgType: WsMsgTypeEnum.answer,
-        data: { sdp, sender: getSocketId(), receiver: data.data.sender },
-      });
-    } else {
-      console.log('收到offer,但是这个offer不是发给我的');
-    }
-  });
-
-  // 收到answer
-  instance.socketIo.on(WsMsgTypeEnum.answer, async (data: IOffer) => {
-    console.warn('【websocket】收到answer', data);
-    if (isDone.value) return;
-    if (!instance) return;
-    const rtc = networkStore.getRtcMap(`${roomId.value}___${data.socketId}`);
-    console.log(rtc, '收到answer收到answer');
-    if (!rtc) return;
-    rtc.rtcStatus.answer = true;
-    rtc.update();
-    if (data.data.receiver === getSocketId()) {
-      console.log('收到answer,这个answer是发给我的');
-      await rtc.setRemoteDescription(data.data.sdp);
-    } else {
-      console.log('收到answer,但这个answer不是发给我的');
-    }
-  });
-
-  // 收到candidate
-  instance.socketIo.on(WsMsgTypeEnum.candidate, (data: ICandidate) => {
-    console.warn('【websocket】收到candidate', data);
-    if (isDone.value) return;
-    if (!instance) return;
-    const rtc =
-      networkStore.getRtcMap(`${roomId.value}___${data.socketId}`) ||
-      networkStore.getRtcMap(roomId.value);
-    if (!rtc) return;
-    if (data.socketId !== getSocketId()) {
-      console.log('不是我发的candidate');
-      const candidate = new RTCIceCandidate({
-        sdpMid: data.data.sdpMid,
-        sdpMLineIndex: data.data.sdpMLineIndex,
-        candidate: data.data.candidate,
-      });
-      rtc.peerConnection
-        ?.addIceCandidate(candidate)
-        .then(() => {
-          console.log('candidate成功');
-          // rtc.handleStream();
-        })
-        .catch((err) => {
-          console.error('candidate失败', err);
-        });
-    } else {
-      console.log('是我发的candidate');
-    }
-  });
-
-  // 用户加入房间
-  instance.socketIo.on(WsMsgTypeEnum.join, (data) => {
-    console.log('【websocket】用户加入房间', data);
-    if (!instance) return;
-  });
-
-  // 用户加入房间
-  instance.socketIo.on(WsMsgTypeEnum.joined, (data) => {
-    console.log('【websocket】用户加入房间完成', data);
-    joined.value = true;
-    liveUserList.value.push({
-      avatar: 'red',
-      socketId: `${getSocketId()}`,
-      expr: 1,
-    });
-  });
-
-  // 其他用户加入房间
-  instance.socketIo.on(WsMsgTypeEnum.otherJoin, (data) => {
-    console.log('【websocket】其他用户加入房间', data);
-    liveUserList.value.push({
-      avatar: 'red',
-      socketId: data.socketId,
-      expr: 1,
-    });
-    console.log('当前所有在线用户', JSON.stringify(liveUserList.value));
-    if (isAdmin.value && joined.value) {
-      batchSendOffer();
-    }
-  });
-
-  // 用户离开房间
-  instance.socketIo.on(WsMsgTypeEnum.leave, (data) => {
-    console.log('【websocket】用户离开房间', data);
-    if (!instance) return;
-    instance.socketIo?.emit(WsMsgTypeEnum.leave, {
-      roomId: instance.roomId,
-    });
-  });
-
-  // 用户离开房间完成
-  instance.socketIo.on(WsMsgTypeEnum.leaved, (data) => {
-    console.log('【websocket】用户离开房间完成', data);
-    if (!instance) return;
-    const res = liveUserList.value.filter(
-      (item) => item.socketId !== data.socketId
-    );
-    console.log('当前所有在线用户', JSON.stringify(res));
-    liveUserList.value = res;
-  });
-}
-
-async function startMediaDevices() {
-  currType.value = liveTypeEnum.camera;
-  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);
-    if (!localVideoRef.value) return;
-    localVideoRef.value.srcObject = event;
-    localStream.value = event;
-  }
-}
-const isAddTrack = ref(false);
-function addTrack() {
-  if (!localStream.value) return;
-  // if (isAddTrack.value || !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);
-      });
-    }
-  });
-  isAddTrack.value = true;
-}
-
-async function startGetDisplayMedia() {
-  currType.value = liveTypeEnum.screen;
-  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,
-    });
-    console.log('getDisplayMedia成功', event);
-    if (!localVideoRef.value) return;
-    localVideoRef.value.srcObject = event;
-    localStream.value = event;
-  }
-}
-
-async function sendOffer({
-  sender,
-  receiver,
-}: {
-  sender: string;
-  receiver: string;
-}) {
-  if (isDone.value) return;
-  if (!websocketInstant.value) return;
-  const rtc = networkStore.getRtcMap(`${roomId.value}___${receiver}`);
-  if (!rtc) return;
-  const sdp = await rtc.createOffer();
-  await rtc.setLocalDescription(sdp);
-  websocketInstant.value.send({
-    msgType: WsMsgTypeEnum.offer,
-    data: { sdp, sender, receiver },
-  });
-}
-
-function startNewWebRtc(receiver: string) {
-  console.warn('开始new WebRTCClass', receiver);
-  const rtc = new WebRTCClass({ roomId: `${roomId.value}___${receiver}` });
-  rtc.rtcStatus.joined = true;
-  rtc.update();
-  return rtc;
-}
-
-function leave() {
-  if (joinRef.value && leaveRef.value && roomIdRef.value) {
-    roomIdRef.value.disabled = false;
-    joinRef.value.disabled = false;
-    leaveRef.value.disabled = true;
-  }
-  if (!websocketInstant.value) return;
-  websocketInstant.value.socketIo?.emit(WsMsgTypeEnum.leave, {
-    roomId: websocketInstant.value.roomId,
-  });
+function joinRoom() {
+  router.push({ path: `/${currentRoom.value.roomId}` });
 }
 </script>
 
 <style lang="scss" scoped>
 .home-wrap {
-  display: flex;
-  justify-content: space-between;
-  margin: 20px auto 0;
-  min-width: 1200px;
-  width: 80%;
+  padding: 20px 0;
+  min-width: $large-width;
+  height: 610px;
+  background-color: skyblue;
+  text-align: center;
+
   .left {
-    min-width: 1000px;
-    border-radius: 10px;
-    background-color: white;
-    color: #9499a0;
-    .head {
-      display: flex;
-      justify-content: space-between;
-      padding: 20px;
-      .tag {
+    position: relative;
+    display: inline-block;
+    width: $large-left-width;
+    height: 100%;
+    border-radius: 4px;
+    background-color: papayawhip;
+    vertical-align: top;
+    &:hover {
+      .btn {
         display: inline-block;
-        margin-right: 5px;
-        padding: 1px 4px;
-        border: 1px solid;
-        border-radius: 2px;
-        color: #9499a0;
-        font-size: 12px;
-      }
-
-      .info {
-        display: flex;
-        align-items: center;
-        .avatar {
-          margin-right: 20px;
-          width: 64px;
-          height: 64px;
-          border-radius: 50%;
-          background-color: yellow;
-        }
-        .detail {
-          .top {
-            margin-bottom: 10px;
-            color: #18191c;
-          }
-          .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;
-        }
-      }
-    }
-    .video-wrap {
-      // height: 100px;
-      height: 500px;
-      background-color: #18191c;
-      #localVideo {
-        width: 100%;
-        height: 100%;
       }
     }
-    .gift {
-      display: flex;
-      align-items: center;
-      justify-content: space-between;
+    .btn {
+      position: absolute;
+      top: 50%;
+      left: 50%;
+      display: none;
       padding: 10px 20px;
-      background-color: white;
-      .item {
-        margin-right: 10px;
-        text-align: center;
-
-        .ico {
-          width: 50px;
-          height: 50px;
-          background-color: skyblue;
-        }
-        .name {
-          color: #18191c;
-          font-size: 12px;
-        }
-        .price {
-          color: #9499a0;
-          font-size: 12px;
-        }
+      border: 1px solid rgba($color: rebeccapurple, $alpha: 0.3);
+      border-radius: 4px;
+      color: rebeccapurple;
+      font-size: 14px;
+      cursor: pointer;
+      transform: translate(-50%, -50%);
+      &:hover {
+        background-color: rgba($color: rebeccapurple, $alpha: 0.3);
       }
     }
   }
   .right {
-    position: relative;
-    box-sizing: border-box;
-    min-width: 300px;
-    border-radius: 10px;
-    background-color: white;
-    color: #9499a0;
-    .tab {
-      display: flex;
-      align-items: center;
-      justify-content: space-evenly;
-      padding: 5px 0;
-      font-size: 12px;
-    }
-    .user-list {
-      overflow-y: scroll;
-      padding: 0 15px;
-      height: 100px;
+    display: inline-block;
+    margin-left: 10px;
+    padding: 10px;
+    padding-bottom: 0;
+    border-radius: 4px;
+    background-color: rgba($color: #000000, $alpha: 0.3);
+    vertical-align: top;
+
+    .list {
       .item {
-        display: flex;
-        align-items: center;
-        justify-content: space-between;
+        position: relative;
         margin-bottom: 10px;
-        font-size: 12px;
-        .info {
-          display: flex;
-          align-items: center;
-
-          .avatar {
-            margin-right: 5px;
-            width: 25px;
-            height: 25px;
-            border-radius: 50%;
-            background-color: skyblue;
+        width: 200px;
+        height: 110px;
+        border-radius: 4px;
+        background-color: rgba($color: #000000, $alpha: 0.3);
+        background-position: center;
+        background-size: cover;
+        background-repeat: no-repeat;
+        cursor: pointer;
+        &.active {
+          &::before {
+            background-color: transparent;
           }
-          .nickname {
-            color: black;
+        }
+        &:hover {
+          &::before {
+            background-color: transparent;
           }
         }
-      }
-    }
-    .msg-list {
-      overflow-y: scroll;
-      padding: 0 15px;
-      height: 350px;
-      .item {
-        margin-bottom: 10px;
-        font-size: 12px;
-        .name {
-          color: #9499a0;
+        &::before {
+          position: absolute;
+          display: block;
+          width: 100%;
+          height: 100%;
+          border-radius: 4px;
+          background-color: rgba(0, 0, 0, 0.4);
+          content: '';
+          transition: all cubic-bezier(0.22, 0.58, 0.12, 0.98) 0.4s;
         }
-        .msg {
-          color: #61666d;
+        .txt {
+          position: absolute;
+          bottom: 2px;
+          left: 4px;
+          color: white;
+          font-size: 13px;
         }
       }
     }
-    .send-msg {
-      position: absolute;
-      bottom: 15px;
-      box-sizing: border-box;
-      padding: 0 10px;
-      width: 100%;
-      .ipt {
-        display: block;
-        box-sizing: border-box;
-        margin: 0 auto;
-        padding: 10px;
-        width: 100%;
-        height: 60px;
-        outline: none;
-        border: 1px solid hsla(0, 0%, 60%, 0.2);
-        border-radius: 4px;
-        background-color: #f1f2f3;
-        font-size: 14px;
-      }
-      .btn {
-        box-sizing: border-box;
-        margin-top: 10px;
-        margin-left: auto;
-        padding: 5px;
-        width: 80px;
-        border-radius: 4px;
-        background-color: #23ade5;
-        color: white;
-        text-align: center;
-        font-size: 12px;
+  }
+}
+
+// 屏幕宽度小于$large-width的时候
+@media screen and (max-width: $large-width) {
+  .home-wrap {
+    height: 460px;
+    .left {
+      width: $medium-left-width;
+    }
+    .right {
+      .list {
+        .item {
+          width: 150px;
+          height: 80px;
+        }
       }
     }
   }

+ 734 - 0
src/views/pull/index.vue

@@ -0,0 +1,734 @@
+<template>
+  <div class="pull-wrap">
+    <div class="left">
+      <div class="head">
+        <div class="info">
+          <div class="avatar"></div>
+          <div class="detail">
+            <div class="top">
+              <span class="tag">未开播</span>
+              <button @click="addTrack">addTrack</button>
+              <button @click="handleMedia">handleMedia</button>
+              <button @click="batchSendOffer">batchSendOffer</button>
+              <!-- 房东的猫livehouse/音乐节 -->
+              {{ networkStore.getRtcMap(roomId)?.rtcStatus }}
+            </div>
+            <div class="bottom">
+              <span class="tag">UP 3</span>
+              {{ getSocketId() }}
+            </div>
+          </div>
+        </div>
+        <div class="other">
+          <div class="top">
+            <span class="item">
+              <i class="ico"></i>
+              <span>直播间管理</span>
+            </span>
+            <span class="item">
+              <i class="ico"></i>
+              <span>1人看过</span>
+            </span>
+            <span class="item">
+              <i class="ico"></i>
+              <span>分享</span>
+            </span>
+          </div>
+          <div class="bottom">关注量:5</div>
+        </div>
+      </div>
+      <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="muted"
+          controls
+        ></video>
+      </div>
+      <div class="gift">
+        <div
+          v-for="(item, index) in giftList"
+          :key="index"
+          class="item"
+        >
+          <div class="ico"></div>
+          <div class="name">{{ item.name }}</div>
+          <div class="price">{{ item.price }}</div>
+        </div>
+      </div>
+    </div>
+    <div class="right">
+      <div class="tab">
+        <span>在线用户</span>
+        <span> | </span>
+        <span>大航海</span>
+      </div>
+      <div class="user-list">
+        <div
+          v-for="(item, index) in liveUserList"
+          :key="index"
+          class="item"
+        >
+          <div class="info">
+            <div class="avatar"></div>
+            <div class="nickname">{{ item.socketId }}</div>
+          </div>
+          <div class="expr">{{ item.expr }}</div>
+        </div>
+      </div>
+      <div class="msg-list">
+        <div
+          v-for="(item, index) in msgList"
+          :key="index"
+          class="item"
+        >
+          <span class="name">{{ item.nickname }}:</span>
+          <span class="msg">{{ item.msg }}</span>
+        </div>
+      </div>
+      <div class="send-msg">
+        <textarea class="ipt"></textarea>
+        <div class="btn">发送</div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, ref, watch } from 'vue';
+import { useRoute } from 'vue-router';
+
+import { IAdminIn, ICandidate, IOffer, liveTypeEnum } from '@/interface';
+import { WebRTCClass } from '@/network/webRtc';
+import {
+  WebSocketClass,
+  WsConnectStatusEnum,
+  WsMsgTypeEnum,
+} from '@/network/webSocket';
+import { useAppStore } from '@/store/app';
+import { useNetworkStore } from '@/store/network';
+
+const networkStore = useNetworkStore();
+const route = useRoute();
+const appStore = useAppStore();
+const roomIdRef = ref<HTMLInputElement>();
+const joinRef = ref<HTMLButtonElement>();
+const leaveRef = ref<HTMLButtonElement>();
+const defaultRoomId = '19990507';
+const roomId = ref<string>(defaultRoomId);
+const websocketInstant = ref<WebSocketClass>();
+const isDone = ref(false);
+const muted = ref(true);
+const localVideoRef = ref<HTMLVideoElement>();
+const localStream = ref();
+const currType = ref(liveTypeEnum.camera); // 1:摄像头,2:录屏
+const id = ref('');
+const joined = ref(false);
+const isAdmin = ref(route.query.id === '1234');
+const offerSended = ref(new Set());
+
+const giftList = ref([
+  { name: '鲜花', ico: '', price: '免费' },
+  { name: '肥宅水', ico: '', price: '2元' },
+  { name: '小鸡腿', ico: '', price: '3元' },
+  { name: '大鸡腿', ico: '', price: '5元' },
+  { name: '一杯咖啡', ico: '', price: '10元' },
+]);
+const msgList = ref([
+  { nickname: '鲜花', msgType: 1, msg: '423425' },
+  { nickname: '肥宅水', msgType: 1, msg: 'sdgdsgsg' },
+  { nickname: '小鸡腿', msgType: 1, msg: '63463gsd' },
+  { nickname: '大鸡腿', msgType: 1, msg: '46326fb26' },
+  { nickname: '一杯咖啡', msgType: 1, msg: 'shgd544' },
+  { nickname: 'sdsg', msgType: 1, msg: 'shgd544' },
+  { nickname: 'gdsg', msgType: 1, msg: 'we' },
+  { nickname: 'sgdx', msgType: 1, msg: 'shgd544' },
+  { nickname: 'gsdx', msgType: 1, msg: 'ew' },
+  { nickname: 'gs', msgType: 1, msg: 'etew' },
+  { nickname: 'gwe', msgType: 1, msg: 'shgd544' },
+  { nickname: 'tewtwe', msgType: 1, msg: 'shgd544' },
+  { nickname: 'hdfh', msgType: 1, msg: 'ew' },
+  { nickname: '534', msgType: 1, msg: 'etew' },
+  { nickname: '234232', msgType: 1, msg: 'shgd544' },
+]);
+
+const liveUserList = ref<
+  {
+    socketId: string;
+    avatar: string;
+    expr: number;
+  }[]
+>([]);
+
+onMounted(() => {
+  id.value = route.query.id as string;
+  websocketInstant.value = new WebSocketClass({
+    roomId: roomId.value,
+    url:
+      process.env.NODE_ENV === 'development'
+        ? 'ws://localhost:4300'
+        : 'wss://live.hsslive.cn',
+    isAdmin: isAdmin.value,
+  });
+  websocketInstant.value.update();
+  initReceive();
+  sendJoin();
+  localVideoRef.value?.addEventListener('loadstart', () => {
+    console.warn('视频流-loadstart');
+    const rtc = networkStore.getRtcMap(roomId.value);
+    if (!rtc) return;
+    rtc.rtcStatus.loadstart = true;
+    rtc.update();
+  });
+
+  localVideoRef.value?.addEventListener('abort', () => {
+    console.warn('视频流-abort');
+  });
+
+  localVideoRef.value?.addEventListener('pause', () => {
+    console.warn('视频流-pause');
+  });
+  localVideoRef.value?.addEventListener('error', () => {
+    console.warn('视频流-error');
+  });
+
+  localVideoRef.value?.addEventListener('loadedmetadata', () => {
+    console.warn('视频流-loadedmetadata');
+    const rtc = networkStore.getRtcMap(roomId.value);
+    if (!rtc) return;
+    rtc.rtcStatus.loadedmetadata = true;
+    rtc.update();
+    if (isAdmin.value) {
+      batchSendOffer();
+    }
+  });
+});
+
+watch(
+  () => appStore.liveStatus,
+  (newVal) => {
+    if (newVal) {
+      console.log('开始直播');
+      handleMedia();
+    }
+  }
+);
+
+function getSocketId() {
+  return networkStore.wsMap.get(roomId.value!)?.socketIo?.id || '-1';
+}
+
+function sendJoin() {
+  const instance = networkStore.wsMap.get(roomId.value);
+  if (!instance) return;
+  instance.send({ msgType: WsMsgTypeEnum.join, data: {} });
+}
+
+function batchSendOffer() {
+  liveUserList.value.forEach(async (item) => {
+    if (
+      !offerSended.value.has(item.socketId) &&
+      item.socketId !== getSocketId()
+    ) {
+      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);
+    }
+  });
+}
+
+async function handleMedia() {
+  if (isAdmin.value) {
+    try {
+      if (currType.value === liveTypeEnum.camera) {
+        await startMediaDevices();
+      } else if (currType.value === liveTypeEnum.screen) {
+        await startGetDisplayMedia();
+      }
+    } catch (error) {
+      console.log('用户拒绝', error);
+    }
+  }
+}
+
+function initReceive() {
+  const instance = websocketInstant.value;
+  if (!instance?.socketIo) return;
+  // websocket连接成功
+  instance.socketIo.on(WsConnectStatusEnum.connect, () => {
+    console.log('【websocket】websocket连接成功', instance.socketIo?.id);
+    if (!instance) return;
+    instance.status = WsConnectStatusEnum.connect;
+    instance.update();
+  });
+
+  // websocket连接断开
+  instance.socketIo.on(WsConnectStatusEnum.disconnect, () => {
+    console.log('【websocket】websocket连接断开', instance);
+    if (!instance) return;
+    instance.status = WsConnectStatusEnum.disconnect;
+    instance.update();
+  });
+
+  // 当前所有在线用户
+  instance.socketIo.on(WsMsgTypeEnum.adminIn, (data: IAdminIn) => {
+    console.log('【websocket】收到管理员正在直播', data);
+    if (isDone.value) return;
+    // sendOffer({ sender: getSocketId(), receiver: data.socketId });
+  });
+
+  // 当前所有在线用户
+  instance.socketIo.on(WsMsgTypeEnum.liveUser, () => {
+    console.log('【websocket】当前所有在线用户');
+    if (!instance) return;
+  });
+
+  // 收到offer
+  instance.socketIo.on(WsMsgTypeEnum.offer, async (data: IOffer) => {
+    console.warn('【websocket】收到offer', data);
+    if (!instance) return;
+    if (data.data.receiver === getSocketId()) {
+      console.log('收到offer,这个offer是发给我的');
+      const rtc = startNewWebRtc(data.data.sender);
+      await rtc.setRemoteDescription(data.data.sdp);
+      const sdp = await rtc.createAnswer();
+      await rtc.setLocalDescription(sdp);
+      websocketInstant.value?.send({
+        msgType: WsMsgTypeEnum.answer,
+        data: { sdp, sender: getSocketId(), receiver: data.data.sender },
+      });
+    } else {
+      console.log('收到offer,但是这个offer不是发给我的');
+    }
+  });
+
+  // 收到answer
+  instance.socketIo.on(WsMsgTypeEnum.answer, async (data: IOffer) => {
+    console.warn('【websocket】收到answer', data);
+    if (isDone.value) return;
+    if (!instance) return;
+    const rtc = networkStore.getRtcMap(`${roomId.value}___${data.socketId}`);
+    console.log(rtc, '收到answer收到answer');
+    if (!rtc) return;
+    rtc.rtcStatus.answer = true;
+    rtc.update();
+    if (data.data.receiver === getSocketId()) {
+      console.log('收到answer,这个answer是发给我的');
+      await rtc.setRemoteDescription(data.data.sdp);
+    } else {
+      console.log('收到answer,但这个answer不是发给我的');
+    }
+  });
+
+  // 收到candidate
+  instance.socketIo.on(WsMsgTypeEnum.candidate, (data: ICandidate) => {
+    console.warn('【websocket】收到candidate', data);
+    if (isDone.value) return;
+    if (!instance) return;
+    const rtc =
+      networkStore.getRtcMap(`${roomId.value}___${data.socketId}`) ||
+      networkStore.getRtcMap(roomId.value);
+    if (!rtc) return;
+    if (data.socketId !== getSocketId()) {
+      console.log('不是我发的candidate');
+      const candidate = new RTCIceCandidate({
+        sdpMid: data.data.sdpMid,
+        sdpMLineIndex: data.data.sdpMLineIndex,
+        candidate: data.data.candidate,
+      });
+      rtc.peerConnection
+        ?.addIceCandidate(candidate)
+        .then(() => {
+          console.log('candidate成功');
+          // rtc.handleStream();
+        })
+        .catch((err) => {
+          console.error('candidate失败', err);
+        });
+    } else {
+      console.log('是我发的candidate');
+    }
+  });
+
+  // 用户加入房间
+  instance.socketIo.on(WsMsgTypeEnum.join, (data) => {
+    console.log('【websocket】用户加入房间', data);
+    if (!instance) return;
+  });
+
+  // 用户加入房间
+  instance.socketIo.on(WsMsgTypeEnum.joined, (data) => {
+    console.log('【websocket】用户加入房间完成', data);
+    joined.value = true;
+    liveUserList.value.push({
+      avatar: 'red',
+      socketId: `${getSocketId()}`,
+      expr: 1,
+    });
+  });
+
+  // 其他用户加入房间
+  instance.socketIo.on(WsMsgTypeEnum.otherJoin, (data) => {
+    console.log('【websocket】其他用户加入房间', data);
+    liveUserList.value.push({
+      avatar: 'red',
+      socketId: data.socketId,
+      expr: 1,
+    });
+    console.log('当前所有在线用户', JSON.stringify(liveUserList.value));
+    if (isAdmin.value && joined.value) {
+      batchSendOffer();
+    }
+  });
+
+  // 用户离开房间
+  instance.socketIo.on(WsMsgTypeEnum.leave, (data) => {
+    console.log('【websocket】用户离开房间', data);
+    if (!instance) return;
+    instance.socketIo?.emit(WsMsgTypeEnum.leave, {
+      roomId: instance.roomId,
+    });
+  });
+
+  // 用户离开房间完成
+  instance.socketIo.on(WsMsgTypeEnum.leaved, (data) => {
+    console.log('【websocket】用户离开房间完成', data);
+    if (!instance) return;
+    const res = liveUserList.value.filter(
+      (item) => item.socketId !== data.socketId
+    );
+    console.log('当前所有在线用户', JSON.stringify(res));
+    liveUserList.value = res;
+  });
+}
+
+async function startMediaDevices() {
+  currType.value = liveTypeEnum.camera;
+  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);
+    if (!localVideoRef.value) return;
+    localVideoRef.value.srcObject = event;
+    localStream.value = event;
+  }
+}
+const isAddTrack = ref(false);
+function addTrack() {
+  if (!localStream.value) return;
+  // if (isAddTrack.value || !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);
+      });
+    }
+  });
+  isAddTrack.value = true;
+}
+
+async function startGetDisplayMedia() {
+  currType.value = liveTypeEnum.screen;
+  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,
+    });
+    console.log('getDisplayMedia成功', event);
+    if (!localVideoRef.value) return;
+    localVideoRef.value.srcObject = event;
+    localStream.value = event;
+  }
+}
+
+async function sendOffer({
+  sender,
+  receiver,
+}: {
+  sender: string;
+  receiver: string;
+}) {
+  if (isDone.value) return;
+  if (!websocketInstant.value) return;
+  const rtc = networkStore.getRtcMap(`${roomId.value}___${receiver}`);
+  if (!rtc) return;
+  const sdp = await rtc.createOffer();
+  await rtc.setLocalDescription(sdp);
+  websocketInstant.value.send({
+    msgType: WsMsgTypeEnum.offer,
+    data: { sdp, sender, receiver },
+  });
+}
+
+function startNewWebRtc(receiver: string) {
+  console.warn('开始new WebRTCClass', receiver);
+  const rtc = new WebRTCClass({ roomId: `${roomId.value}___${receiver}` });
+  rtc.rtcStatus.joined = true;
+  rtc.update();
+  return rtc;
+}
+
+function leave() {
+  if (joinRef.value && leaveRef.value && roomIdRef.value) {
+    roomIdRef.value.disabled = false;
+    joinRef.value.disabled = false;
+    leaveRef.value.disabled = true;
+  }
+  if (!websocketInstant.value) return;
+  websocketInstant.value.socketIo?.emit(WsMsgTypeEnum.leave, {
+    roomId: websocketInstant.value.roomId,
+  });
+}
+</script>
+
+<style lang="scss" scoped>
+.pull-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: 1px solid red;
+    border-radius: 10px;
+    background-color: white;
+    color: #9499a0;
+    vertical-align: top;
+    .head {
+      display: flex;
+      justify-content: space-between;
+      padding: 20px;
+      background-color: pink;
+      .tag {
+        display: inline-block;
+        margin-right: 5px;
+        padding: 1px 4px;
+        border: 1px solid;
+        border-radius: 2px;
+        color: #9499a0;
+        font-size: 12px;
+      }
+
+      .info {
+        display: flex;
+        align-items: center;
+        .avatar {
+          margin-right: 20px;
+          width: 64px;
+          height: 64px;
+          border-radius: 50%;
+          background-color: yellow;
+        }
+        .detail {
+          .top {
+            margin-bottom: 10px;
+            color: #18191c;
+          }
+          .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;
+        }
+      }
+    }
+    .video-wrap {
+      // height: 100px;
+      // height: 550px;
+      background-color: #18191c;
+      #localVideo {
+        width: 100%;
+        height: 100%;
+      }
+    }
+    .gift {
+      position: absolute;
+      right: 0;
+      bottom: 0;
+      left: 0;
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      height: 100px;
+      background-color: yellow;
+      .item {
+        margin-right: 10px;
+        text-align: center;
+
+        .ico {
+          width: 50px;
+          height: 50px;
+          background-color: skyblue;
+        }
+        .name {
+          color: #18191c;
+          font-size: 12px;
+        }
+        .price {
+          color: #9499a0;
+          font-size: 12px;
+        }
+      }
+    }
+  }
+  .right {
+    position: relative;
+    display: inline-block;
+    box-sizing: border-box;
+    box-sizing: border-box;
+    margin-left: 10px;
+    min-width: 300px;
+    height: 100%;
+    border: 1px solid red;
+    border-radius: 10px;
+    background-color: white;
+    color: #9499a0;
+    .tab {
+      display: flex;
+      align-items: center;
+      justify-content: space-evenly;
+      padding: 5px 0;
+      font-size: 12px;
+    }
+    .user-list {
+      overflow-y: scroll;
+      padding: 0 15px;
+      height: 100px;
+      .item {
+        display: flex;
+        align-items: center;
+        justify-content: space-between;
+        margin-bottom: 10px;
+        font-size: 12px;
+        .info {
+          display: flex;
+          align-items: center;
+
+          .avatar {
+            margin-right: 5px;
+            width: 25px;
+            height: 25px;
+            border-radius: 50%;
+            background-color: skyblue;
+          }
+          .nickname {
+            color: black;
+          }
+        }
+      }
+    }
+    .msg-list {
+      overflow-y: scroll;
+      padding: 0 15px;
+      height: 350px;
+      .item {
+        margin-bottom: 10px;
+        font-size: 12px;
+        .name {
+          color: #9499a0;
+        }
+        .msg {
+          color: #61666d;
+        }
+      }
+    }
+    .send-msg {
+      position: absolute;
+      bottom: 15px;
+      box-sizing: border-box;
+      padding: 0 10px;
+      width: 100%;
+      .ipt {
+        display: block;
+        box-sizing: border-box;
+        margin: 0 auto;
+        padding: 10px;
+        width: 100%;
+        height: 60px;
+        outline: none;
+        border: 1px solid hsla(0, 0%, 60%, 0.2);
+        border-radius: 4px;
+        background-color: #f1f2f3;
+        font-size: 14px;
+      }
+      .btn {
+        box-sizing: border-box;
+        margin-top: 10px;
+        margin-left: auto;
+        padding: 5px;
+        width: 80px;
+        border-radius: 4px;
+        background-color: #23ade5;
+        color: white;
+        text-align: center;
+        font-size: 12px;
+      }
+    }
+  }
+}
+
+// 屏幕宽度小于$large-width的时候
+@media screen and (max-width: $large-width) {
+  .pull-wrap {
+    .left {
+      width: $medium-left-width;
+    }
+    .right {
+      .list {
+        .item {
+          width: 150px;
+          height: 80px;
+        }
+      }
+    }
+  }
+}
+</style>

+ 754 - 0
src/views/push/index.vue

@@ -0,0 +1,754 @@
+<template>
+  <div class="push-wrap">
+    <div 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="muted"
+          controls
+        ></video>
+        <div
+          v-if="currMediaTypeList.length <= 0"
+          class="add-wrap"
+        >
+          <div
+            class="item"
+            @click="startMediaDevices"
+          >
+            摄像头
+          </div>
+          <div
+            class="item"
+            @click="startGetDisplayMedia"
+          >
+            窗口
+          </div>
+        </div>
+      </div>
+      <div class="control">
+        <div class="info">
+          <div class="avatar"></div>
+          <div class="detail">
+            <div class="top">
+              <!-- <button @click="addTrack">addTrack</button> -->
+              <!-- <button @click="handleMedia">handleMedia</button> -->
+              <input
+                ref="roomNameRef"
+                v-model="roomName"
+                type="text"
+                placeholder="输入房间名"
+              />
+              <button
+                class="btn"
+                @click="confirmRoomName"
+              >
+                确定
+              </button>
+              <button
+                class="btn"
+                @click="cancelRoomName"
+              >
+                取消
+              </button>
+              <!-- 房东的猫livehouse/音乐节 -->
+            </div>
+            <div class="bottom">
+              <span>socketId:{{ getSocketId() }}</span>
+              <div>
+                rtcStatus:{{ networkStore.getRtcMap(roomId)?.rtcStatus }}
+              </div>
+            </div>
+          </div>
+        </div>
+        <div class="other">
+          <div class="top">
+            <span class="item">
+              <i class="ico"></i>
+              <span>在线人数:10</span>
+            </span>
+          </div>
+          <div class="bottom">
+            <!-- <button @click="batchSendOffer">batchSendOffer</button> -->
+            <button @click="startLive">startLive</button>
+          </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 }}</span>
+          </div>
+        </div>
+      </div>
+      <div class="danmu-card">
+        <div class="title">弹幕互动</div>
+        <div class="list">
+          <div
+            v-for="(item, index) in damuList"
+            :key="index"
+            class="item"
+          >
+            <span class="name">{{ item.nickname }}:</span>
+            <span class="msg">{{ item.msg }}</span>
+          </div>
+        </div>
+
+        <div class="send-msg">
+          <input class="ipt" />
+          <div class="btn">发送</div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, ref, watch } from 'vue';
+import { useRoute } from 'vue-router';
+
+import { IAdminIn, ICandidate, IOffer, liveTypeEnum } from '@/interface';
+import { WebRTCClass } from '@/network/webRtc';
+import {
+  WebSocketClass,
+  WsConnectStatusEnum,
+  WsMsgTypeEnum,
+} from '@/network/webSocket';
+import { useAppStore } from '@/store/app';
+import { useNetworkStore } from '@/store/network';
+
+const networkStore = useNetworkStore();
+const route = useRoute();
+const appStore = useAppStore();
+const roomIdRef = ref<HTMLInputElement>();
+const joinRef = ref<HTMLButtonElement>();
+const leaveRef = ref<HTMLButtonElement>();
+const defaultRoomId = '19990507';
+const roomId = ref<string>(defaultRoomId);
+const roomName = ref('');
+const roomNameRef = ref<HTMLInputElement>();
+const websocketInstant = ref<WebSocketClass>();
+const isDone = ref(false);
+const muted = ref(true);
+const localVideoRef = ref<HTMLVideoElement>();
+const localStream = ref();
+const currMediaTypeList = ref<liveTypeEnum[]>([]);
+const currMediaType = ref<liveTypeEnum>();
+const id = ref('');
+const joined = ref(false);
+const isAdmin = ref(true);
+const offerSended = ref(new Set());
+
+const damuList = ref([
+  { nickname: '鲜花', msgType: 1, msg: '423425' },
+  // { nickname: '肥宅水', msgType: 1, msg: 'sdgdsgsg' },
+  // { nickname: '小鸡腿', msgType: 1, msg: '63463gsd' },
+  // { nickname: '大鸡腿', msgType: 1, msg: '46326fb26' },
+  // { nickname: '一杯咖啡', msgType: 1, msg: 'shgd544' },
+  // { nickname: 'sdsg', msgType: 1, msg: 'shgd544' },
+  // { nickname: 'gdsg', msgType: 1, msg: 'we' },
+  // { nickname: 'sgdx', msgType: 1, msg: 'shgd544' },
+  // { nickname: 'gsdx', msgType: 1, msg: 'ew' },
+  // { nickname: 'gs', msgType: 1, msg: 'etew' },
+  // { nickname: 'gwe', msgType: 1, msg: 'shgd544' },
+  // { nickname: 'tewtwe', msgType: 1, msg: 'shgd544' },
+  // { nickname: 'hdfh', msgType: 1, msg: 'ew' },
+  // { nickname: '534', msgType: 1, msg: 'etew' },
+  // { nickname: '234232', msgType: 1, msg: 'shgd544' },
+]);
+
+const liveUserList = ref<
+  {
+    socketId: string;
+    avatar: string;
+    expr: number;
+  }[]
+>([]);
+
+onMounted(() => {
+  localVideoRef.value?.addEventListener('loadstart', () => {
+    console.warn('视频流-loadstart');
+    const rtc = networkStore.getRtcMap(roomId.value);
+    if (!rtc) return;
+    rtc.rtcStatus.loadstart = true;
+    rtc.update();
+  });
+
+  localVideoRef.value?.addEventListener('loadedmetadata', () => {
+    console.warn('视频流-loadedmetadata');
+    const rtc = networkStore.getRtcMap(roomId.value);
+    if (!rtc) return;
+    rtc.rtcStatus.loadedmetadata = true;
+    rtc.update();
+    if (isAdmin.value) {
+      batchSendOffer();
+    }
+  });
+});
+
+watch(
+  () => appStore.liveStatus,
+  (newVal) => {
+    if (newVal) {
+      console.log('开始直播');
+      handleMedia();
+    }
+  }
+);
+
+function getSocketId() {
+  return networkStore.wsMap.get(roomId.value!)?.socketIo?.id || '-1';
+}
+
+function sendJoin() {
+  const instance = networkStore.wsMap.get(roomId.value);
+  if (!instance) return;
+  instance.send({
+    msgType: WsMsgTypeEnum.join,
+    data: {
+      roomName: roomName.value,
+    },
+  });
+}
+
+function batchSendOffer() {
+  liveUserList.value.forEach(async (item) => {
+    if (
+      !offerSended.value.has(item.socketId) &&
+      item.socketId !== getSocketId()
+    ) {
+      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);
+    }
+  });
+}
+
+async function handleMedia() {
+  if (isAdmin.value) {
+    try {
+      if (currMediaType.value === liveTypeEnum.camera) {
+        await startMediaDevices();
+      } else if (currMediaType.value === liveTypeEnum.screen) {
+        await startGetDisplayMedia();
+      }
+    } catch (error) {
+      console.log('用户拒绝', error);
+    }
+  }
+}
+
+function initReceive() {
+  const instance = websocketInstant.value;
+  if (!instance?.socketIo) return;
+  // websocket连接成功
+  instance.socketIo.on(WsConnectStatusEnum.connect, () => {
+    console.log('【websocket】websocket连接成功', instance.socketIo?.id);
+    if (!instance) return;
+    instance.status = WsConnectStatusEnum.connect;
+    instance.update();
+  });
+
+  // websocket连接断开
+  instance.socketIo.on(WsConnectStatusEnum.disconnect, () => {
+    console.log('【websocket】websocket连接断开', instance);
+    if (!instance) return;
+    instance.status = WsConnectStatusEnum.disconnect;
+    instance.update();
+  });
+
+  // 当前所有在线用户
+  instance.socketIo.on(WsMsgTypeEnum.adminIn, (data: IAdminIn) => {
+    console.log('【websocket】收到管理员正在直播', data);
+    if (isDone.value) return;
+    // sendOffer({ sender: getSocketId(), receiver: data.socketId });
+  });
+
+  // 当前所有在线用户
+  instance.socketIo.on(WsMsgTypeEnum.liveUser, () => {
+    console.log('【websocket】当前所有在线用户');
+    if (!instance) return;
+  });
+
+  // 收到offer
+  instance.socketIo.on(WsMsgTypeEnum.offer, async (data: IOffer) => {
+    console.warn('【websocket】收到offer', data);
+    if (!instance) return;
+    if (data.data.receiver === getSocketId()) {
+      console.log('收到offer,这个offer是发给我的');
+      const rtc = startNewWebRtc(data.data.sender);
+      await rtc.setRemoteDescription(data.data.sdp);
+      const sdp = await rtc.createAnswer();
+      await rtc.setLocalDescription(sdp);
+      websocketInstant.value?.send({
+        msgType: WsMsgTypeEnum.answer,
+        data: { sdp, sender: getSocketId(), receiver: data.data.sender },
+      });
+    } else {
+      console.log('收到offer,但是这个offer不是发给我的');
+    }
+  });
+
+  // 收到answer
+  instance.socketIo.on(WsMsgTypeEnum.answer, async (data: IOffer) => {
+    console.warn('【websocket】收到answer', data);
+    if (isDone.value) return;
+    if (!instance) return;
+    const rtc = networkStore.getRtcMap(`${roomId.value}___${data.socketId}`);
+    console.log(rtc, '收到answer收到answer');
+    if (!rtc) return;
+    rtc.rtcStatus.answer = true;
+    rtc.update();
+    if (data.data.receiver === getSocketId()) {
+      console.log('收到answer,这个answer是发给我的');
+      await rtc.setRemoteDescription(data.data.sdp);
+    } else {
+      console.log('收到answer,但这个answer不是发给我的');
+    }
+  });
+
+  // 收到candidate
+  instance.socketIo.on(WsMsgTypeEnum.candidate, (data: ICandidate) => {
+    console.warn('【websocket】收到candidate', data);
+    if (isDone.value) return;
+    if (!instance) return;
+    const rtc =
+      networkStore.getRtcMap(`${roomId.value}___${data.socketId}`) ||
+      networkStore.getRtcMap(roomId.value);
+    if (!rtc) return;
+    if (data.socketId !== getSocketId()) {
+      console.log('不是我发的candidate');
+      const candidate = new RTCIceCandidate({
+        sdpMid: data.data.sdpMid,
+        sdpMLineIndex: data.data.sdpMLineIndex,
+        candidate: data.data.candidate,
+      });
+      rtc.peerConnection
+        ?.addIceCandidate(candidate)
+        .then(() => {
+          console.log('candidate成功');
+          // rtc.handleStream();
+        })
+        .catch((err) => {
+          console.error('candidate失败', err);
+        });
+    } else {
+      console.log('是我发的candidate');
+    }
+  });
+
+  // 用户加入房间
+  instance.socketIo.on(WsMsgTypeEnum.join, (data) => {
+    console.log('【websocket】用户加入房间', data);
+    if (!instance) return;
+  });
+
+  // 用户加入房间
+  instance.socketIo.on(WsMsgTypeEnum.joined, (data) => {
+    console.log('【websocket】用户加入房间完成', data);
+    joined.value = true;
+    liveUserList.value.push({
+      avatar: 'red',
+      socketId: `${getSocketId()}`,
+      expr: 1,
+    });
+    batchSendOffer();
+  });
+
+  // 其他用户加入房间
+  instance.socketIo.on(WsMsgTypeEnum.otherJoin, (data) => {
+    console.log('【websocket】其他用户加入房间', data);
+    liveUserList.value.push({
+      avatar: 'red',
+      socketId: data.socketId,
+      expr: 1,
+    });
+    console.log('当前所有在线用户', JSON.stringify(liveUserList.value));
+    if (isAdmin.value && joined.value) {
+      batchSendOffer();
+    }
+  });
+
+  // 用户离开房间
+  instance.socketIo.on(WsMsgTypeEnum.leave, (data) => {
+    console.log('【websocket】用户离开房间', data);
+    if (!instance) return;
+    instance.socketIo?.emit(WsMsgTypeEnum.leave, {
+      roomId: instance.roomId,
+    });
+  });
+
+  // 用户离开房间完成
+  instance.socketIo.on(WsMsgTypeEnum.leaved, (data) => {
+    console.log('【websocket】用户离开房间完成', data);
+    if (!instance) return;
+    const res = liveUserList.value.filter(
+      (item) => item.socketId !== data.socketId
+    );
+    console.log('当前所有在线用户', JSON.stringify(res));
+    liveUserList.value = res;
+  });
+}
+
+function roomNameIsOk() {
+  if (!roomName.value.length) {
+    alert('请输入房间名!');
+    return false;
+  }
+  if (roomName.value.length < 3 || roomName.value.length > 10) {
+    alert('房间名要求3-10个字符!');
+    return false;
+  }
+  return true;
+}
+
+function confirmRoomName() {
+  if (!roomNameIsOk()) return;
+  if (!roomNameRef.value) return;
+  roomNameRef.value.disabled = true;
+}
+
+function cancelRoomName() {
+  if (!roomNameRef.value) return;
+  roomNameRef.value.disabled = false;
+}
+
+function startLive() {
+  if (!roomNameIsOk()) return;
+  if (!currMediaTypeList.value.length) {
+    alert('请选择一个素材!');
+    return;
+  }
+  id.value = route.query.id as string;
+  websocketInstant.value = new WebSocketClass({
+    roomId: roomId.value,
+    url:
+      process.env.NODE_ENV === 'development'
+        ? 'ws://localhost:4300'
+        : 'wss://live.hsslive.cn',
+    isAdmin: isAdmin.value,
+  });
+  websocketInstant.value.update();
+  initReceive();
+  sendJoin();
+}
+
+/** 摄像头 */
+async function startMediaDevices() {
+  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 = liveTypeEnum.camera;
+    currMediaTypeList.value.push(liveTypeEnum.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,
+    });
+    console.log('getDisplayMedia成功', event);
+    currMediaType.value = liveTypeEnum.screen;
+    currMediaTypeList.value.push(liveTypeEnum.screen);
+    if (!localVideoRef.value) return;
+    localVideoRef.value.srcObject = event;
+    localStream.value = event;
+  }
+}
+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;
+  if (!websocketInstant.value) return;
+  const rtc = networkStore.getRtcMap(`${roomId.value}___${receiver}`);
+  if (!rtc) return;
+  const sdp = await rtc.createOffer();
+  await rtc.setLocalDescription(sdp);
+  websocketInstant.value.send({
+    msgType: WsMsgTypeEnum.offer,
+    data: { sdp, sender, receiver },
+  });
+}
+
+function startNewWebRtc(receiver: string) {
+  console.warn('开始new WebRTCClass', receiver);
+  const rtc = new WebRTCClass({ roomId: `${roomId.value}___${receiver}` });
+  rtc.rtcStatus.joined = true;
+  rtc.update();
+  return rtc;
+}
+
+function leave() {
+  if (joinRef.value && leaveRef.value && roomIdRef.value) {
+    roomIdRef.value.disabled = false;
+    joinRef.value.disabled = false;
+    leaveRef.value.disabled = true;
+  }
+  if (!websocketInstant.value) return;
+  websocketInstant.value.socketIo?.emit(WsMsgTypeEnum.leave, {
+    roomId: websocketInstant.value.roomId,
+  });
+}
+</script>
+
+<style lang="scss" scoped>
+.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: 1px solid red;
+    border-radius: 10px;
+    background-color: white;
+    color: #9499a0;
+    vertical-align: top;
+
+    .video-wrap {
+      // height: 100px;
+      // height: 550px;
+      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;
+        width: 200px;
+        height: 50px;
+        background-color: #fff;
+        transform: translate(-50%, -50%);
+        .item {
+          width: 60px;
+          height: 30px;
+          border-radius: 4px;
+          background-color: rebeccapurple;
+          color: white;
+          font-size: 14px;
+          line-height: 30px;
+          cursor: pointer;
+        }
+      }
+    }
+    .control {
+      position: absolute;
+      right: 0;
+      bottom: 0;
+      left: 0;
+      display: flex;
+      justify-content: space-between;
+      padding: 20px;
+      background-color: pink;
+
+      .info {
+        display: flex;
+        align-items: center;
+
+        .avatar {
+          margin-right: 20px;
+          width: 64px;
+          height: 64px;
+          border-radius: 50%;
+          background-color: yellow;
+        }
+        .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: 10px;
+    background-color: white;
+    color: #9499a0;
+
+    .resource-card {
+      margin-bottom: 5%;
+      margin-bottom: 10px;
+      width: 100%;
+      height: 290px;
+      border-radius: 4px;
+      background-color: pink;
+      .title {
+        padding: 10px;
+        text-align: initial;
+      }
+      .item {
+        display: flex;
+        align-items: center;
+        justify-content: space-between;
+        margin-bottom: 10px;
+        font-size: 12px;
+      }
+    }
+    .danmu-card {
+      box-sizing: border-box;
+      padding: 10px;
+      width: 100%;
+      height: 400px;
+      border-radius: 4px;
+      background-color: pink;
+      text-align: initial;
+      .title {
+        margin-bottom: 10px;
+      }
+      .list {
+        margin-bottom: 10px;
+        height: 300px;
+        .item {
+        }
+      }
+
+      .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;
+        }
+        .btn {
+          box-sizing: border-box;
+          width: 80px;
+          height: 30px;
+          border-radius: 4px;
+          background-color: #23ade5;
+          color: white;
+          text-align: center;
+          font-size: 12px;
+          line-height: 30px;
+          cursor: pointer;
+        }
+      }
+    }
+  }
+}
+// 屏幕宽度小于$large-width的时候
+@media screen and (max-width: $large-width) {
+  .push-wrap {
+    .left {
+      width: $medium-left-width;
+    }
+    .right {
+      .list {
+        .item {
+          width: 150px;
+          height: 80px;
+        }
+      }
+    }
+  }
+}
+</style>