shuisheng 2 роки тому
батько
коміт
5cbc770c80

+ 1 - 2
.vscode/settings.json

@@ -38,6 +38,5 @@
     "tsx",
     "vue"
   ],
-  "typescript.tsdk": "node_modules/typescript/lib",
-  "vue.codeActions.enabled": false
+  "typescript.tsdk": "node_modules/typescript/lib"
 }

+ 2 - 0
package.json

@@ -32,12 +32,14 @@
     }
   },
   "dependencies": {
+    "@types/fabric": "^5.3.3",
     "@vicons/ionicons5": "^0.12.0",
     "axios": "^1.2.1",
     "billd-html-webpack-plugin": "^1.0.1",
     "billd-scss": "^0.0.7",
     "billd-utils": "^0.0.12",
     "browser-tool": "^1.0.5",
+    "fabric": "^5.3.0",
     "flv.js": "^1.6.2",
     "js-cookie": "^3.0.5",
     "m3u8-parser": "^6.2.0",

Різницю між файлами не показано, бо вона завелика
+ 599 - 2
pnpm-lock.yaml


+ 2 - 2
src/assets/constant.scss

@@ -9,10 +9,10 @@
 }
 
 $large-width: 1400px;
-$medium-width: 1100px;
+$medium-width: 1200px;
 $small-width: 800px;
 
-$large-left-width: 1100px;
+$large-left-width: 1200px;
 $medium-left-width: 900px;
 $small-left-width: 600px;
 $theme-color-gold: #ffd700;

+ 165 - 0
src/components/DND/index.vue

@@ -0,0 +1,165 @@
+<template>
+  <div
+    ref="dndRef"
+    class="dndRef"
+  >
+    <slot></slot>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import {
+  defineEmits,
+  defineProps,
+  onMounted,
+  onUnmounted,
+  reactive,
+  ref,
+  withDefaults,
+} from 'vue';
+
+const dndRef = ref<HTMLElement>();
+const offset = reactive({ x: 0, y: 0 }); // x:距离最左边多少px;y:距离最下边多少px
+const isDown = ref(false); // 是否按下
+const position = reactive({ top: 0, left: 0 }); // 当前dnd的位置
+
+const props = withDefaults(
+  defineProps<{
+    margin?: number;
+    isWelt?: boolean;
+  }>(),
+  {
+    margin: 0,
+    isWelt: false,
+  }
+);
+
+const emits = defineEmits(['move']);
+
+const handleMove = move;
+
+function handleInit() {
+  if (!dndRef.value) return;
+  // @ts-ignore
+  dndRef.value.addEventListener('mousedown', handleStart);
+  // @ts-ignore
+  document.addEventListener('mousemove', handleMove);
+  // @ts-ignore
+  document.addEventListener('mouseup', handleEnd);
+  // @ts-ignore
+  dndRef.value.addEventListener('mouseup', handleEnd);
+  // @ts-ignore
+  dndRef.value.addEventListener('touchstart', handleStart);
+  document.addEventListener(
+    'touchmove',
+    // @ts-ignore
+    handleMove,
+    // iOS safari 阻止“橡皮筋效果”
+    { passive: false }
+  );
+  // @ts-ignore
+  document.addEventListener('touchend', handleEnd);
+}
+
+onMounted(() => {
+  handleInit();
+});
+onUnmounted(() => {
+  // @ts-ignore
+  dndRef.value?.removeEventListener('mousedown', handleStart);
+  // @ts-ignore
+  document.removeEventListener('mousemove', handleMove);
+  // @ts-ignore
+  document.removeEventListener('mouseup', handleEnd);
+  // @ts-ignore
+  dndRef.value?.removeEventListener('touchstart', handleStart);
+  // @ts-ignore
+  document.removeEventListener('touchmove', handleMove);
+  // @ts-ignore
+  document.removeEventListener('touchend', handleEnd);
+});
+
+function move(event: TouchEvent & MouseEvent) {
+  // 禁用默认事件,让需要滑动的地方滑动,不需要滑动的地方禁止滑动。
+  event.preventDefault();
+  if (!dndRef.value) return;
+  if (!isDown.value) return;
+  if (event.targetTouches) {
+    position.top = event.targetTouches[0].pageY - offset.y;
+    position.left = event.targetTouches[0].pageX - offset.x;
+  } else {
+    position.top = event.pageY - offset.y;
+    position.left = event.pageX - offset.x;
+  }
+  const parentEl = dndRef.value.parentElement!;
+  const topRes = position.top - parentEl.getBoundingClientRect().top;
+  const leftRes = position.left - parentEl?.getBoundingClientRect().left;
+
+  dndRef.value.style.top = `${topRes}px`;
+  dndRef.value.style.left = `${leftRes}px`;
+  emits('move', { top: topRes, left: leftRes, el: dndRef.value.children[0] });
+  // console.log(leftRes, topRes, offset, 999);
+}
+
+function handleStart(event: TouchEvent & MouseEvent) {
+  if (!dndRef.value) return;
+  isDown.value = true;
+  let x = 0;
+  let y = 0;
+  if (event.targetTouches) {
+    x = event.targetTouches[0].pageX - dndRef.value.getBoundingClientRect().x;
+    y = event.targetTouches[0].pageY - dndRef.value.getBoundingClientRect().y;
+  } else {
+    x = event.pageX - dndRef.value.getBoundingClientRect().x;
+    y = event.pageY - dndRef.value.getBoundingClientRect().y;
+  }
+  offset.x = x;
+  offset.y = y;
+}
+
+function handleEnd() {
+  if (!dndRef.value) return;
+  isDown.value = false;
+  const rect = dndRef.value.getBoundingClientRect();
+  const clientWidth = window.document.documentElement.clientWidth;
+  const clientHeight = window.document.documentElement.clientHeight;
+  if (rect.top <= props.margin) {
+    dndRef.value.style.top = `${props.margin}px`;
+    position.top = props.margin;
+  }
+  if (rect.bottom >= clientHeight - props.margin) {
+    dndRef.value.style.top = `${clientHeight - rect.height - props.margin}px`;
+    position.top = clientHeight - rect.height - props.margin;
+  }
+  if (props.isWelt) {
+    dndRef.value.style.transition = 'all .3s ease';
+    if (rect.x + rect.width / 2 > clientWidth / 2) {
+      setTimeout(() => {
+        if (!dndRef.value) return;
+        dndRef.value.style.left = `${
+          clientWidth - props.margin - rect.width
+        }px`;
+        position.left = clientWidth - props.margin - rect.width;
+      }, 0);
+    } else {
+      setTimeout(() => {
+        if (!dndRef.value) return;
+        dndRef.value.style.left = `${props.margin}px`;
+        position.left = props.margin;
+      }, 0);
+    }
+
+    setTimeout(() => {
+      dndRef.value?.style.removeProperty('transition');
+    }, 300);
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.dndRef {
+  position: absolute;
+  top: 0;
+  left: 0;
+}
+</style>

+ 6 - 0
src/router/index.ts

@@ -36,6 +36,7 @@ export const routerName = {
 
   pull: 'pull',
   push: 'push',
+  pushByCanvas: 'pushByCanvas',
   ...mobileRouterName,
 };
 
@@ -138,6 +139,11 @@ export const defaultRoutes: RouteRecordRaw[] = [
         path: '/push',
         component: () => import('@/views/push/index.vue'),
       },
+      {
+        name: routerName.pushByCanvas,
+        path: '/pushByCanvas',
+        component: () => import('@/views/pushByCanvas/index.vue'),
+      },
     ],
   },
   {

+ 17 - 0
src/utils/index.ts

@@ -1,5 +1,22 @@
 // TIP: ctrl+cmd+t,生成函数注释
 
+import { getRangeRandom } from 'billd-utils';
+
+/**
+ * @description 获取随机字符串(ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz)
+ * @example: getRandomString(4) ===> abd3
+ * @param {number} length
+ * @return {*}
+ */
+export const getRandomEnglishString = (length: number): string => {
+  const str = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
+  let res = '';
+  for (let i = 0; i < length; i += 1) {
+    res += str.charAt(getRangeRandom(0, str.length - 1));
+  }
+  return res;
+};
+
 export const createVideo = ({ muted = true, autoplay = true }) => {
   const videoEl = document.createElement('video');
   videoEl.autoplay = autoplay;

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

@@ -1,17 +1,5 @@
 <template>
   <div class="push-wrap">
-    <div @click="drawCanvasFn">3333</div>
-    <video
-      id="canvasVideoRef"
-      ref="canvasVideoRef"
-      muted
-      autoplay
-      controls
-    ></video>
-    <div
-      ref="testRef"
-      class="testRef"
-    ></div>
     <div
       ref="topRef"
       class="left"
@@ -27,20 +15,6 @@
             class="media-list"
             :class="{ item: appStore.allTrack.length > 1 }"
           ></div>
-          <!-- <video
-            id="localVideo"
-            ref="localVideo2Ref"
-            autoplay
-            webkit-playsinline="true"
-            playsinline
-            x-webkit-airplay="allow"
-            x5-video-player-type="h5"
-            x5-video-player-fullscreen="true"
-            x5-video-orientation="portraint"
-            muted
-            :controls="NODE_ENV === 'development' ? true : false"
-            @contextmenu.prevent
-          ></video> -->
           <div
             v-if="!appStore.allTrack || appStore.allTrack.length <= 0"
             class="add-wrap"
@@ -305,8 +279,6 @@ const topRef = ref<HTMLDivElement>();
 const bottomRef = ref<HTMLDivElement>();
 const danmuListRef = ref<HTMLDivElement>();
 const containerRef = ref<HTMLDivElement>();
-const testRef = ref<HTMLVideoElement>();
-const canvasVideoRef = ref<HTMLVideoElement>();
 const localVideoRef = ref<HTMLVideoElement>();
 const remoteVideoRef = ref<HTMLVideoElement[]>([]);
 const isSRS = route.query.liveType === liveTypeEnum.srsPush;
@@ -349,51 +321,7 @@ watch(
   }
 );
 
-function drawTimer(data: { canvas: HTMLCanvasElement }) {
-  const width = 200;
-  const height = 200;
-  const canvas = data.canvas;
-  canvas.width = width;
-  canvas.height = height;
-  const ctx = canvas.getContext('2d')!;
-
-  let timer;
-  let oldTxt = `${+new Date()}`;
-  function drawCanvas() {
-    ctx.font = '25px';
-    ctx.fillStyle = 'red';
-    const textWidth = ctx.measureText(oldTxt).width; // 获取文字的宽度
-    ctx.clearRect(50, 50 - 25, textWidth, 25); // 覆盖旧文字的矩形区域
-    oldTxt = `${+new Date()}`;
-    // -1是因为下划线问题
-    ctx.fillText(`${oldTxt}`, 50, 50 - 1);
-    timer = requestAnimationFrame(drawCanvas);
-  }
-
-  function stopDrawing() {
-    cancelAnimationFrame(timer);
-  }
-
-  drawCanvas();
-
-  return { drawCanvas, stopDrawing };
-}
-
-function createCanvas() {
-  const canvas = document.createElement('canvas');
-  canvas.id = 'sdfsgsa';
-  canvas.width = 1920;
-  canvas.height = 1080;
-  const stream = canvas.captureStream(24);
-  testRef.value?.appendChild(canvas);
-  const { drawCanvas } = drawTimer({ canvas });
-  drawCanvasFn.value = drawCanvas;
-  console.log(stream, 'canvasVideoRef');
-  canvasVideoRef.value!.srcObject = stream;
-}
-
 onMounted(() => {
-  createCanvas();
   if (topRef.value && bottomRef.value && containerRef.value) {
     const res =
       bottomRef.value.getBoundingClientRect().top -

+ 861 - 0
src/views/pushByCanvas/index.vue

@@ -0,0 +1,861 @@
+<template>
+  <div class="push-wrap">
+    <div
+      ref="topRef"
+      class="left"
+    >
+      <div
+        ref="containerRef"
+        class="container"
+      >
+        <AudioRoomTip></AudioRoomTip>
+        <canvas
+          id="canvasRef"
+          ref="canvasRef"
+        ></canvas>
+        <!-- <DND
+          v-for="(item, index) in appStore.allTrack.filter(
+            (v) => v.video === 1
+          )"
+          :key="index"
+          @move="handleDNDMove"
+        >
+          <video
+            :id="item.id"
+            :data-track-id="item.trackid"
+            autoplay
+            webkit-playsinline="true"
+            playsinline
+            x-webkit-airplay="allow"
+            x5-video-player-type="h5"
+            x5-video-player-fullscreen="true"
+            x5-video-orientation="portraint"
+            muted
+          ></video>
+        </DND> -->
+        <div
+          v-if="!appStore.allTrack || appStore.allTrack.length <= 0"
+          class="add-wrap"
+        >
+          <n-space>
+            <n-button
+              v-for="(item, index) in allMediaTypeList"
+              :key="index"
+              class="item"
+              @click="handleStartMedia(item)"
+            >
+              {{ item.txt }}
+            </n-button>
+          </n-space>
+        </div>
+      </div>
+
+      <div
+        ref="bottomRef"
+        class="room-control"
+      >
+        <div class="info">
+          <div
+            class="avatar"
+            :style="{ backgroundImage: `url(${userStore.userInfo?.avatar})` }"
+          ></div>
+          <div class="detail">
+            <div class="top">
+              <n-input-group>
+                <n-input
+                  v-model:value="roomName"
+                  size="small"
+                  placeholder="输入房间名"
+                  :style="{ width: '50%' }"
+                />
+                <n-button
+                  size="small"
+                  type="primary"
+                  @click="confirmRoomName"
+                >
+                  确定
+                </n-button>
+              </n-input-group>
+            </div>
+            <div class="bottom">
+              <span v-if="NODE_ENV === 'development'">
+                socketId:{{ getSocketId() }}
+              </span>
+            </div>
+          </div>
+        </div>
+        <div class="rtc">
+          <div class="item">
+            <div class="txt">码率设置</div>
+            <div class="down">
+              <n-select
+                v-model:value="currentMaxBitrate"
+                :options="maxBitrate"
+              />
+            </div>
+          </div>
+          <div class="item">
+            <div class="txt">帧率设置</div>
+            <div class="down">
+              <n-select
+                v-model:value="currentMaxFramerate"
+                :options="maxFramerate"
+              />
+            </div>
+          </div>
+          <div class="item">
+            <div class="txt">分辨率设置</div>
+            <div class="down">
+              <n-select
+                v-model:value="currentResolutionRatio"
+                :options="resolutionRatio"
+              />
+            </div>
+          </div>
+        </div>
+        <div class="other">
+          <div class="top">
+            <span class="item">
+              <i class="ico"></i>
+              <span>
+                正在观看:
+                {{
+                  liveUserList.filter((item) => item.id !== getSocketId())
+                    .length
+                }}
+              </span>
+            </span>
+          </div>
+          <div class="bottom">
+            <n-button
+              v-if="!isLiving"
+              type="info"
+              size="small"
+              @click="startLive"
+            >
+              开始直播
+            </n-button>
+            <n-button
+              v-else
+              type="error"
+              size="small"
+              @click="endLive"
+            >
+              结束直播
+            </n-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 appStore.allTrack"
+            :key="index"
+            class="item"
+          >
+            <span class="name">
+              ({{ item.audio === 1 ? '音频' : '视频' }}){{ item.mediaName }}
+            </span>
+            <div
+              class="del"
+              @click="handleDelTrack(item)"
+            >
+              x
+            </div>
+          </div>
+        </div>
+        <div class="bottom">
+          <n-button
+            size="small"
+            type="primary"
+            @click="showSelectMediaModalCpt = true"
+          >
+            添加素材
+          </n-button>
+        </div>
+      </div>
+      <div class="danmu-card">
+        <div class="title">弹幕互动</div>
+        <div class="list-wrap">
+          <div
+            ref="danmuListRef"
+            class="list"
+          >
+            <div
+              v-for="(item, index) in damuList"
+              :key="index"
+              class="item"
+            >
+              <template v-if="item.msgType === DanmuMsgTypeEnum.danmu">
+                <span class="name">
+                  {{ item.userInfo?.username || item.socket_id }}:
+                </span>
+                <span class="msg">{{ item.msg }}</span>
+              </template>
+              <template v-else-if="item.msgType === DanmuMsgTypeEnum.otherJoin">
+                <span class="name system">系统通知:</span>
+                <span class="msg">
+                  <span>{{ item.userInfo?.username || item.socket_id }}</span>
+                  <span>进入直播!</span>
+                </span>
+              </template>
+              <template
+                v-else-if="item.msgType === DanmuMsgTypeEnum.userLeaved"
+              >
+                <span class="name system">系统通知:</span>
+                <span class="msg">
+                  <span>{{ item.userInfo?.username || item.socket_id }}</span>
+                  <span>离开直播!</span>
+                </span>
+              </template>
+            </div>
+          </div>
+        </div>
+        <div class="send-msg">
+          <input
+            v-model="danmuStr"
+            class="ipt"
+            @keydown="keydownDanmu"
+          />
+          <n-button
+            type="info"
+            size="small"
+            @click="sendDanmu"
+          >
+            发送
+          </n-button>
+        </div>
+      </div>
+    </div>
+
+    <SelectMediaModalCpt
+      v-if="showSelectMediaModalCpt"
+      :all-media-type-list="allMediaTypeList"
+      @close="showSelectMediaModalCpt = false"
+      @ok="selectMediaOk"
+    ></SelectMediaModalCpt>
+
+    <MediaModalCpt
+      v-if="showMediaModalCpt"
+      :media-type="currentMediaType"
+      @close="showMediaModalCpt = false"
+      @ok="addMediaOk"
+    ></MediaModalCpt>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { fabric } from 'fabric';
+import { NODE_ENV } from 'script/constant';
+import { markRaw, nextTick, onMounted, reactive, ref, watch } from 'vue';
+import { useRoute } from 'vue-router';
+
+import { usePush } from '@/hooks/use-push';
+import { DanmuMsgTypeEnum, MediaTypeEnum, liveTypeEnum } from '@/interface';
+import { AppRootState, useAppStore } from '@/store/app';
+import { useUserStore } from '@/store/user';
+import { createVideo, getRandomEnglishString } from '@/utils';
+
+import MediaModalCpt from './mediaModal/index.vue';
+import SelectMediaModalCpt from './selectMediaModal/index.vue';
+
+const route = useRoute();
+const userStore = useUserStore();
+const appStore = useAppStore();
+const currentMediaType = ref(MediaTypeEnum.camera);
+const showSelectMediaModalCpt = ref(false);
+const showMediaModalCpt = ref(false);
+const topRef = ref<HTMLDivElement>();
+const bottomRef = ref<HTMLDivElement>();
+const danmuListRef = ref<HTMLDivElement>();
+const containerRef = ref<HTMLDivElement>();
+const canvasRef = ref<HTMLCanvasElement>();
+const fabricCanvas = ref<fabric.Canvas>();
+const localVideoRef = ref<HTMLVideoElement>();
+const remoteVideoRef = ref<HTMLVideoElement[]>([]);
+const isSRS = route.query.liveType === liveTypeEnum.srsPush;
+const canvasSize = reactive({
+  width: 1920,
+  height: 1080,
+});
+const {
+  confirmRoomName,
+  getSocketId,
+  startLive,
+  endLive,
+  sendDanmu,
+  keydownDanmu,
+  localStream,
+  isLiving,
+  allMediaTypeList,
+  currentResolutionRatio,
+  currentMaxBitrate,
+  currentMaxFramerate,
+  resolutionRatio,
+  maxBitrate,
+  maxFramerate,
+  danmuStr,
+  roomName,
+  damuList,
+  liveUserList,
+  addTrack,
+  delTrack,
+} = usePush({
+  localVideoRef,
+  remoteVideoRef,
+  isSRS,
+});
+const drawCanvasArr = ref<
+  {
+    id: string;
+    cb: any;
+    left: number;
+    top: number;
+    width: number;
+    height: number;
+  }[]
+>([]);
+watch(
+  () => damuList.value.length,
+  () => {
+    setTimeout(() => {
+      if (danmuListRef.value) {
+        danmuListRef.value.scrollTop = danmuListRef.value.scrollHeight;
+      }
+    }, 0);
+  }
+);
+
+function handleDNDMove(val: { top: number; left: number; el: HTMLElement }) {
+  console.log('handleDNDMove', val, val.el.id);
+  const el = drawCanvasArr.value.find((item) => item.id === val.el.id);
+  console.log(el, ratio.value, 3333);
+  if (el) {
+    el.left = val.left / ratio.value;
+    el.top = val.top / ratio.value;
+  }
+  // el?.cb(val.left, val.top);
+}
+
+function drawVideo(video: {
+  el: HTMLVideoElement;
+  width: number;
+  height: number;
+}) {
+  // const ctx = canvasRef.value!.getContext('2d')!;
+  let timer;
+
+  function drawCanvas() {
+    // 清空画布
+    // ctx.clearRect(0, 0, canvasSize.width, canvasSize.height);
+
+    drawCanvasArr.value.forEach((item) => {
+      const video = document.querySelector(`#${item.id}`) as HTMLVideoElement;
+      const videoInstance = new fabric.Image(video, {
+        left: item.left || 0,
+        top: item.top || 0,
+      });
+      console.log(canvasRef.value, video, 222221112);
+      fabricCanvas.value!.add(videoInstance);
+
+      // ctx.drawImage(
+      //   video,
+      //   item.left || 0,
+      //   item.top || 0,
+      //   item.width,
+      //   item.height
+      // );
+    });
+
+    console.log(video.el.id);
+    // timer = requestAnimationFrame(drawCanvas);
+  }
+
+  // function stopDrawing() {
+  //   cancelAnimationFrame(timer);
+  // }
+
+  drawCanvas();
+
+  return { drawCanvas };
+}
+
+function createAutoVideo({ stream, id }: { stream: MediaStream; id }) {
+  const video = createVideo({});
+  video.srcObject = stream;
+  const w = stream.getVideoTracks()[0].getSettings().width;
+  const h = stream.getVideoTracks()[0].getSettings().height;
+  console.log(w, h, 3333);
+
+  video.style.width = `1px`;
+  video.style.height = `1px`;
+  containerRef.value!.appendChild(video);
+  video.width = w!;
+  video.height = h!;
+  const dom = new fabric.Image(video, {
+    top: 0,
+    left: 0,
+  });
+  dom.scale(ratio.value);
+  fabricCanvas.value!.add(dom);
+  fabric.util.requestAnimFrame(function render() {
+    fabricCanvas.value?.renderAll();
+    fabric.util.requestAnimFrame(render);
+  });
+  video.style.position = 'absolute';
+  video.style.bottom = '0';
+  video.style.left = '0';
+  // };
+
+  // const video = document.querySelector(`#${id}`) as HTMLVideoElement;
+  // const video = createVideo({});
+  // video.id = id;
+  // video.srcObject = stream;
+  // document.body.appendChild(video);
+  // video.onmousemove = () => {
+  //   video.style.cursor = 'move';
+  // };
+  // video.onmouseout = () => {
+  //   video.style.removeProperty('cursor');
+  // };
+  // video.onloadeddata = () => {
+  //   const rect = document
+  //     .querySelector(`#${video.id}`)
+  //     ?.getBoundingClientRect()!;
+  //   const width = rect.width * ratio.value;
+  //   const height = rect.height * ratio.value;
+  //   console.log(width, height, 21223);
+  //   // video.style.width = `${width}px`;
+  //   // video.style.height = `${height}px`;
+  //   // const video = document.querySelector(`#${item.id}`) as HTMLVideoElement;
+  //   // const videoInstance = new fabric.Image(video, {
+  //   //   left: 10,
+  //   //   top: 10,
+  //   //   width,
+  //   //   height,
+  //   //   stroke: 'lightgreen',
+  //   //   strokeWidth: 4,
+  //   //   // objectCaching: false,
+  //   // });
+  //   // const { drawCanvas } = drawVideo({
+  //   //   width: rect.width,
+  //   //   height: rect.height,
+  //   //   el: video,
+  //   // });
+  //   // drawCanvasArr.value.push({
+  //   //   id,
+  //   //   cb: drawCanvas,
+  //   //   left: 0,
+  //   //   top: 0,
+  //   //   width: rect.width,
+  //   //   height: rect.height,
+  //   // });
+  // };
+}
+
+function initCanvas() {
+  const ins = markRaw(new fabric.Canvas(canvasRef.value!));
+  // const rect = new fabric.Rect({
+  //   top: 10,
+  //   left: 10,
+  //   width: 200,
+  //   height: 200,
+  //   fill: '#aa96da',
+  // });
+  ins.setWidth(containerRef.value!.getBoundingClientRect().width);
+  ins.setHeight(canvasSize.height * ratio.value);
+  fabricCanvas.value = ins;
+
+  console.log(ins, 111111);
+  console.log(fabricCanvas.value.upperCanvasEl.captureStream());
+  // if (canvasRef.value) {
+  //   const rect = canvasRef.value.getBoundingClientRect();
+  //   ratio.value = rect.width / canvasSize.width;
+  // }
+
+  // const stream = canvas.captureStream();
+  // const canvas = new fabric.Canvas('c1');
+  // canvas.add(
+  //   new fabric.Circle({ radius: 30, fill: '#f55', top: 100, left: 100 })
+  // );
+  // console.log(canvas, 2221);
+  // canvas.selectionColor = 'rgba(0,255,0,0.3)';
+  // canvas.selectionBorderColor = 'red';
+  // canvas.selectionLineWidth = 5;
+  // containerRef.value?.appendChild(canvas);
+  // this.__canvases.push(canvas);
+}
+
+const ratio = ref(0);
+
+onMounted(() => {
+  if (containerRef.value) {
+    ratio.value =
+      containerRef.value.getBoundingClientRect().width / canvasSize.width;
+  }
+  initCanvas();
+});
+
+function selectMediaOk(val: MediaTypeEnum) {
+  showMediaModalCpt.value = true;
+  showSelectMediaModalCpt.value = false;
+  currentMediaType.value = val;
+}
+
+async function addMediaOk(val: {
+  type: MediaTypeEnum;
+  deviceId: string;
+  mediaName: string;
+}) {
+  showMediaModalCpt.value = false;
+  if (val.type === MediaTypeEnum.screen) {
+    const event = await navigator.mediaDevices.getDisplayMedia({
+      video: {
+        deviceId: val.deviceId,
+        height: currentResolutionRatio.value,
+        frameRate: { max: currentMaxFramerate.value },
+      },
+      audio: true,
+    });
+    const videoTrack = {
+      id: getRandomEnglishString(8),
+      audio: 2,
+      video: 1,
+      mediaName: val.mediaName,
+      type: MediaTypeEnum.screen,
+      track: event.getVideoTracks()[0],
+      stream: event,
+      streamid: event.id,
+      trackid: event.getVideoTracks()[0].id,
+    };
+    const audio = event.getAudioTracks();
+    if (audio.length) {
+      const audioTrack = {
+        id: getRandomEnglishString(8),
+        audio: 1,
+        video: 2,
+        mediaName: val.mediaName,
+        type: MediaTypeEnum.screen,
+        track: event.getAudioTracks()[0],
+        stream: event,
+        streamid: event.id,
+        trackid: event.getAudioTracks()[0].id,
+      };
+      appStore.setAllTrack([...appStore.allTrack, videoTrack, audioTrack]);
+      addTrack(videoTrack);
+      addTrack(audioTrack);
+    } else {
+      appStore.setAllTrack([...appStore.allTrack, videoTrack]);
+      addTrack(videoTrack);
+    }
+    nextTick(() => {
+      createAutoVideo({
+        stream: event,
+        id: videoTrack.id,
+      });
+    });
+
+    console.log('获取窗口成功');
+  } else if (val.type === MediaTypeEnum.camera) {
+    const event = await navigator.mediaDevices.getUserMedia({
+      video: {
+        deviceId: val.deviceId,
+        height: currentResolutionRatio.value,
+        frameRate: { max: currentMaxFramerate.value },
+      },
+      audio: false,
+    });
+    const track = {
+      id: getRandomEnglishString(8),
+      audio: 2,
+      video: 1,
+      mediaName: val.mediaName,
+      type: MediaTypeEnum.camera,
+      track: event.getVideoTracks()[0],
+      stream: event,
+      streamid: event.id,
+      trackid: event.getVideoTracks()[0].id,
+    };
+    appStore.setAllTrack([...appStore.allTrack, track]);
+    addTrack(track);
+    nextTick(() => {
+      createAutoVideo({ stream: event, id: track.id });
+    });
+    console.log('获取摄像头成功');
+  } else if (val.type === MediaTypeEnum.microphone) {
+    const event = await navigator.mediaDevices.getUserMedia({
+      video: false,
+      audio: { deviceId: val.deviceId },
+    });
+    if (
+      isSRS &&
+      appStore.allTrack.filter((item) => item.audio === 1).length >= 1
+    ) {
+      window.$message.error('srs模式最多只能有一个音频');
+      return;
+    }
+    const track = {
+      id: getRandomEnglishString(8),
+      audio: 1,
+      video: 2,
+      mediaName: val.mediaName,
+      type: MediaTypeEnum.microphone,
+      track: event.getAudioTracks()[0],
+      stream: event,
+      streamid: event.id,
+      trackid: event.getAudioTracks()[0].id,
+    };
+    appStore.setAllTrack([...appStore.allTrack, track]);
+    addTrack(track);
+    console.log('获取麦克风成功');
+  }
+}
+
+function handleDelTrack(item: AppRootState['allTrack'][0]) {
+  console.log('handleDelTrack', item);
+  const res = appStore.allTrack.filter((iten) => iten.id !== item.id);
+  appStore.setAllTrack(res);
+  delTrack(item);
+}
+
+function handleStartMedia(item: { type: MediaTypeEnum; txt: string }) {
+  currentMediaType.value = item.type;
+  showMediaModalCpt.value = true;
+}
+</script>
+
+<style lang="scss" scoped>
+.push-wrap {
+  margin: 20px auto 0;
+  min-width: $large-width;
+  // height: 700px;
+  text-align: center;
+  .testRef {
+    // width: 600px;
+    // background-color: red;
+    :deep(canvas) {
+      // width: 100%;
+    }
+  }
+  .left {
+    position: relative;
+    display: inline-block;
+    overflow: hidden;
+    box-sizing: border-box;
+    width: $large-left-width;
+    height: 100%;
+    border-radius: 6px;
+    background-color: white;
+    color: #9499a0;
+    vertical-align: top;
+
+    .container {
+      position: relative;
+      overflow: hidden;
+      height: 100%;
+      background-color: rgba($color: #000000, $alpha: 0.5);
+      line-height: 0;
+
+      :deep(canvas) {
+        width: 100%;
+      }
+
+      .add-wrap {
+        position: absolute;
+        top: 50%;
+        left: 50%;
+        display: flex;
+        align-items: center;
+        justify-content: space-around;
+        padding: 0 20px;
+        height: 50px;
+        border-radius: 5px;
+        background-color: white;
+        transform: translate(-50%, -50%);
+      }
+    }
+    .room-control {
+      display: flex;
+      justify-content: space-between;
+      padding: 20px;
+      background-color: papayawhip;
+
+      .info {
+        display: flex;
+        align-items: center;
+
+        .avatar {
+          margin-right: 20px;
+          width: 64px;
+          height: 64px;
+          border-radius: 50%;
+          background-position: center center;
+          background-size: cover;
+          background-repeat: no-repeat;
+        }
+        .detail {
+          display: flex;
+          flex-direction: column;
+          flex-shrink: 0;
+          width: 200px;
+          text-align: initial;
+          .top {
+            margin-bottom: 10px;
+            color: #18191c;
+          }
+          .bottom {
+            font-size: 14px;
+          }
+        }
+      }
+      .rtc {
+        display: flex;
+        align-items: center;
+        flex: 1;
+        font-size: 14px;
+        .item {
+          display: flex;
+          align-items: center;
+          flex: 1;
+          .txt {
+            flex-shrink: 0;
+            width: 80px;
+          }
+          .down {
+            width: 90px;
+
+            user-select: none;
+          }
+        }
+      }
+      .other {
+        display: flex;
+        flex-direction: column;
+        justify-content: center;
+        font-size: 12px;
+        .top {
+        }
+        .bottom {
+          margin-top: 10px;
+        }
+      }
+    }
+  }
+  .right {
+    position: relative;
+    display: inline-block;
+    box-sizing: border-box;
+    margin-left: 10px;
+    width: 240px;
+    height: 100%;
+    border-radius: 6px;
+    background-color: white;
+    color: #9499a0;
+
+    .resource-card {
+      position: relative;
+      box-sizing: border-box;
+      margin-bottom: 5%;
+      margin-bottom: 10px;
+      padding: 10px;
+      width: 100%;
+      height: 290px;
+      border-radius: 6px;
+      background-color: papayawhip;
+      .title {
+        text-align: initial;
+      }
+      .item {
+        display: flex;
+        align-items: center;
+        justify-content: space-between;
+        margin: 5px 0;
+        font-size: 12px;
+        &:hover {
+          .del {
+            display: block;
+          }
+        }
+        .del {
+          display: none;
+          cursor: pointer;
+        }
+      }
+      .bottom {
+        position: absolute;
+        bottom: 0;
+        left: 0;
+        padding: 10px;
+      }
+    }
+    .danmu-card {
+      box-sizing: border-box;
+      padding: 10px;
+      width: 100%;
+      height: 400px;
+      border-radius: 6px;
+      background-color: papayawhip;
+      text-align: initial;
+      .title {
+        margin-bottom: 10px;
+      }
+      .list {
+        overflow: scroll;
+        margin-bottom: 10px;
+        height: 300px;
+
+        @extend %hideScrollbar;
+
+        .item {
+          margin-bottom: 10px;
+          font-size: 12px;
+          .name {
+            color: #9499a0;
+          }
+          .msg {
+            color: #61666d;
+          }
+        }
+      }
+
+      .send-msg {
+        display: flex;
+        align-items: center;
+        box-sizing: border-box;
+        .ipt {
+          display: block;
+          box-sizing: border-box;
+          margin: 0 auto;
+          margin-right: 10px;
+          padding: 10px;
+          width: 80%;
+          height: 30px;
+          outline: none;
+          border: 1px solid hsla(0, 0%, 60%, 0.2);
+          border-radius: 4px;
+          background-color: #f1f2f3;
+          font-size: 14px;
+        }
+      }
+    }
+  }
+}
+// 屏幕宽度小于$large-width的时候
+@media screen and (max-width: $large-width) {
+  .push-wrap {
+    .left {
+      width: $medium-left-width;
+    }
+    .right {
+      .list {
+        .item {
+        }
+      }
+    }
+  }
+}
+</style>

+ 147 - 0
src/views/pushByCanvas/mediaModal/index.vue

@@ -0,0 +1,147 @@
+<template>
+  <div class="media-wrap">
+    <Modal
+      title="添加直播素材"
+      :mask-closable="false"
+      @close="emits('close')"
+    >
+      <div class="container">
+        <div
+          v-if="inputOptions.length"
+          class="item"
+        >
+          <div class="label">设备选择</div>
+          <div class="value">
+            <n-select
+              v-model:value="currentInput.deviceId"
+              :options="inputOptions"
+            />
+          </div>
+        </div>
+        <div class="item">
+          <div class="label">名称</div>
+          <div class="value">
+            <n-input v-model:value="mediaName" />
+          </div>
+        </div>
+      </div>
+
+      <template #footer>
+        <div class="margin-right">
+          <n-button
+            type="primary"
+            @click="handleOk"
+          >
+            确定
+          </n-button>
+        </div>
+      </template>
+    </Modal>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { defineEmits, defineProps, onMounted, ref, withDefaults } from 'vue';
+
+import { MediaTypeEnum } from '@/interface';
+import { useAppStore } from '@/store/app';
+import { useNetworkStore } from '@/store/network';
+
+const mediaName = ref('');
+const networkStore = useNetworkStore();
+const appStore = useAppStore();
+
+const props = withDefaults(
+  defineProps<{
+    mediaType?: MediaTypeEnum;
+  }>(),
+  {
+    mediaType: MediaTypeEnum.camera,
+  }
+);
+const emits = defineEmits(['close', 'ok']);
+
+const inputOptions = ref<{ label: string; value: string }[]>([]);
+const currentInput = ref<{
+  type: MediaTypeEnum;
+  deviceId: string;
+}>({
+  type: MediaTypeEnum.camera,
+  deviceId: '',
+});
+
+onMounted(() => {
+  init();
+});
+
+function handleOk() {
+  emits('ok', { ...currentInput.value, mediaName: mediaName.value });
+}
+
+async function init() {
+  const res = await navigator.mediaDevices.enumerateDevices();
+  if (props.mediaType === MediaTypeEnum.microphone) {
+    res.forEach((item) => {
+      if (item.kind === 'audioinput' && item.deviceId !== 'default') {
+        inputOptions.value.push({
+          label: item.label,
+          value: item.deviceId,
+        });
+      }
+    });
+    currentInput.value = {
+      ...currentInput.value,
+      deviceId: inputOptions.value[0].value,
+      type: MediaTypeEnum.microphone,
+    };
+    mediaName.value = `麦克风-${
+      appStore.allTrack.filter((item) => item.type === MediaTypeEnum.microphone)
+        .length + 1
+    }`;
+  } else if (props.mediaType === MediaTypeEnum.camera) {
+    res.forEach((item) => {
+      if (item.kind === 'videoinput' && item.deviceId !== 'default') {
+        inputOptions.value.push({
+          label: item.label,
+          value: item.deviceId,
+        });
+      }
+    });
+    currentInput.value = {
+      ...currentInput.value,
+      deviceId: inputOptions.value[0].value,
+      type: MediaTypeEnum.camera,
+    };
+    mediaName.value = `摄像头-${
+      appStore.allTrack.filter((item) => item.type === MediaTypeEnum.camera)
+        .length + 1
+    }`;
+  } else if (props.mediaType === MediaTypeEnum.screen) {
+    currentInput.value = {
+      ...currentInput.value,
+      type: MediaTypeEnum.screen,
+    };
+    mediaName.value = `窗口-${
+      appStore.allTrack.filter((item) => item.type === MediaTypeEnum.screen)
+        .length + 1
+    }`;
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.media-wrap {
+  text-align: initial;
+
+  .container {
+    .item {
+      .label {
+        margin: 6px 0;
+      }
+    }
+    .margin-right {
+      text-align: right;
+    }
+  }
+}
+</style>

+ 54 - 0
src/views/pushByCanvas/selectMediaModal/index.vue

@@ -0,0 +1,54 @@
+<template>
+  <div class="select-media-wrap">
+    <Modal
+      title="选择直播素材"
+      :mask-closable="false"
+      @close="emits('close')"
+    >
+      <div class="container">
+        <n-space justify="center">
+          <n-button
+            v-for="(item, index) in allMediaTypeList"
+            :key="index"
+            class="item"
+            @click="emits('ok', item.type)"
+          >
+            {{ item.txt }}
+          </n-button>
+        </n-space>
+      </div>
+      <template #footer></template>
+    </Modal>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { defineEmits, defineProps, onMounted, withDefaults } from 'vue';
+
+import { MediaTypeEnum } from '@/interface';
+
+const props = withDefaults(
+  defineProps<{
+    allMediaTypeList: {
+      [index: string]: {
+        type: MediaTypeEnum;
+        txt: string;
+      };
+    };
+  }>(),
+  {}
+);
+const emits = defineEmits(['close', 'ok']);
+
+onMounted(() => {});
+</script>
+
+<style lang="scss" scoped>
+.select-media-wrap {
+  text-align: initial;
+
+  .container {
+    padding-top: 10px;
+  }
+}
+</style>

Деякі файли не було показано, через те що забагато файлів було змінено