ソースを参照

feat: webcodecs

shuisheng 1 年間 前
コミット
6c8a141357

+ 2 - 0
package.json

@@ -36,6 +36,7 @@
   },
   "dependencies": {
     "@vicons/ionicons5": "^0.12.0",
+    "@webav/av-recorder": "^0.3.3",
     "axios": "^1.2.1",
     "billd-deploy": "^1.0.23",
     "billd-html-webpack-plugin": "^1.0.6",
@@ -44,6 +45,7 @@
     "fabric": "^5.3.0",
     "flv.js": "^1.6.2",
     "js-cookie": "^3.0.5",
+    "mp4box": "^0.5.2",
     "mpegts.js": "^1.7.3",
     "naive-ui": "^2.34.4",
     "pinia": "^2.0.33",

+ 61 - 0
pnpm-lock.yaml

@@ -8,6 +8,9 @@ dependencies:
   '@vicons/ionicons5':
     specifier: ^0.12.0
     version: 0.12.0
+  '@webav/av-recorder':
+    specifier: ^0.3.3
+    version: 0.3.3
   axios:
     specifier: ^1.2.1
     version: 1.3.4
@@ -32,6 +35,9 @@ dependencies:
   js-cookie:
     specifier: ^3.0.5
     version: 3.0.5
+  mp4box:
+    specifier: ^0.5.2
+    version: 0.5.2
   mpegts.js:
     specifier: ^1.7.3
     version: 1.7.3
@@ -2701,6 +2707,16 @@ packages:
     dependencies:
       '@types/node': 18.15.3
 
+  /@types/dom-mediacapture-transform@0.1.9:
+    resolution: {integrity: sha512-/K96dASG23bqF+VAftybbI5SUj9qSsdsSKZglm7Bq/sIaEve5z8I+GdClARcSQMAAVkH7bc83UI1jiH/qc5LMw==}
+    dependencies:
+      '@types/dom-webcodecs': 0.1.11
+    dev: false
+
+  /@types/dom-webcodecs@0.1.11:
+    resolution: {integrity: sha512-yPEZ3z7EohrmOxbk/QTAa0yonMFkNkjnVXqbGb7D4rMr+F1dGQ8ZUFxXkyLLJuiICPejZ0AZE9Rrk9wUCczx4A==}
+    dev: false
+
   /@types/eslint-scope@3.7.4:
     resolution: {integrity: sha512-9K4zoImiZc3HlIp6AVUDE4CWYx22a+lhSZMYNpbjW04+YF0KWj4pJXnEMjdnFTiQibFFmElcsasJXDbdI/EPhA==}
     dependencies:
@@ -2835,6 +2851,10 @@ packages:
     resolution: {integrity: sha512-WFj/HkNVCfkchXDeDU0QbimC356FB5vva3g5mgsjk8n3UMKqP9S522rQAmu9LGPiCmShZRPuAlkXmbp5WId6ow==}
     dev: true
 
+  /@types/wicg-file-system-access@2020.9.8:
+    resolution: {integrity: sha512-ggMz8nOygG7d/stpH40WVaNvBwuyYLnrg5Mbyf6bmsj/8+gb6Ei4ZZ9/4PNpcPNTT8th9Q8sM8wYmWGjMWLX/A==}
+    dev: false
+
   /@types/ws@8.5.4:
     resolution: {integrity: sha512-zdQDHKUgcX/zBc4GrwsE/7dVdAD8JR4EuiAXiiUhhfyIJXXb2+PrGshFyeXWQPMmmZ2XxgaqclgpIC7eTXc1mg==}
     dependencies:
@@ -3230,6 +3250,30 @@ packages:
       '@webassemblyjs/ast': 1.11.1
       '@xtuc/long': 4.2.2
 
+  /@webav/av-cliper@0.3.3:
+    resolution: {integrity: sha512-VUIaxlfZHsJLc875eL+rGaWTavVLRqkgaVa6R8XIpdjmWJxnD7Le55mViL1SxRILKtsi0KnqWfWDL2JzNAcwOw==}
+    dependencies:
+      '@types/dom-webcodecs': 0.1.11
+      '@webav/mp4box.js': 0.5.3-fenghen
+      opfs-tools: 0.1.1
+      wave-resampler: 1.0.0
+    dev: false
+
+  /@webav/av-recorder@0.3.3:
+    resolution: {integrity: sha512-nMA6MT8hqEYq9yjIQeTo+Ee5CNw/9zXzvbksFCsWH8+VPTKoKFlDIqbgSLMpYjOgkkT7a9RY5IpCxU9Aq7Wdkw==}
+    dependencies:
+      '@types/dom-mediacapture-transform': 0.1.9
+      '@types/dom-webcodecs': 0.1.11
+      '@types/wicg-file-system-access': 2020.9.8
+      '@webav/av-cliper': 0.3.3
+      '@webav/mp4box.js': 0.5.3-fenghen
+      fix-webm-duration: 1.0.5
+    dev: false
+
+  /@webav/mp4box.js@0.5.3-fenghen:
+    resolution: {integrity: sha512-jAN15I3Po1Z6Ns02iknb6KGbird9rd1h9TGldzbwsar+88ZlHd+oVjOQnFavBdNDc5vmULUnldcWGDdKc02npw==}
+    dev: false
+
   /@webpack-cli/configtest@1.2.0(webpack-cli@4.10.0)(webpack@5.76.2):
     resolution: {integrity: sha512-4FB8Tj6xyVkyqjj1OaTqCjXYULB9FMkqQ8yGrZjRDrYh0nOE+7Lhs45WioWQQMV+ceFlE368Ukhe6xdvJM9Egg==}
     peerDependencies:
@@ -6475,6 +6519,10 @@ packages:
       resolve-dir: 1.0.1
     dev: true
 
+  /fix-webm-duration@1.0.5:
+    resolution: {integrity: sha512-b6oula3OfSknx0aWoLsxvp4DVIYbwsf+UAkr6EDAK3iuMYk/OSNKzmeSI61GXK0MmFTEuzle19BPvTxMIKjkZg==}
+    dev: false
+
   /flat-cache@3.0.4:
     resolution: {integrity: sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==}
     engines: {node: ^10.12.0 || >=12.0.0}
@@ -8353,6 +8401,10 @@ packages:
       path-exists: 5.0.0
     dev: true
 
+  /mp4box@0.5.2:
+    resolution: {integrity: sha512-zRmGlvxy+YdW3Dmt+TR4xPHynbxwXtAQDTN/Fo9N3LMxaUlB2C5KmZpzYyGKy4c7k4Jf3RCR0A2pm9SZELOLXw==}
+    dev: false
+
   /mpd-parser@1.1.1:
     resolution: {integrity: sha512-uZ/db5wQdlQn1L+OD49YXBhPI9UGeK1SeQE4D5EoaJIhf0WM9X3HDj8d+9PjoG06CgCvGZw3YW/wsHku+CH3yA==}
     hasBin: true
@@ -8732,6 +8784,10 @@ packages:
     resolution: {integrity: sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==}
     hasBin: true
 
+  /opfs-tools@0.1.1:
+    resolution: {integrity: sha512-F+UroKcPfU2/ZzT/4/hONIEbdFHkD/oWwpzH3rkaz+EiDm0W6b2vqNAp1LrWClGrERZ19X3ceRixYL0ZdXcpjA==}
+    dev: false
+
   /optionator@0.9.1:
     resolution: {integrity: sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==}
     engines: {node: '>= 0.8.0'}
@@ -11893,6 +11949,11 @@ packages:
       glob-to-regexp: 0.4.1
       graceful-fs: 4.2.11
 
+  /wave-resampler@1.0.0:
+    resolution: {integrity: sha512-bE3rbpZXuKAV52Cd8/BeJvy82ZqEHK8pPWHrZ9JioaVVTBlmWbDC+u4p9blhFcf0Skepb4hlOAHc25XfqLC48g==}
+    engines: {node: '>=8'}
+    dev: false
+
   /wbuf@1.7.3:
     resolution: {integrity: sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==}
     dependencies:

+ 4 - 0
public/index.html

@@ -15,6 +15,10 @@
   </head>
 
   <body>
+    <script
+      src="https://video.sdk.qcloudecdn.com/web/TXLivePusher-2.1.1.min.js"
+      charset="utf-8"
+    ></script>
     <noscript>
       <strong>
         We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work

+ 8 - 5
src/App.vue

@@ -17,15 +17,15 @@ import { isMobile } from 'billd-utils';
 import { GlobalThemeOverrides, NConfigProvider } from 'naive-ui';
 import { onMounted } from 'vue';
 
-import { THEME_COLOR } from '@/constant';
-import { useBuildInfo } from '@/hooks/use-common';
+import { THEME_COLOR, appBuildInfo } from '@/constant';
+import { useCheckUpdate } from '@/hooks/use-common';
 import { loginMessage } from '@/hooks/use-login';
 import { usePiniaCacheStore } from '@/store/cache';
 import { useUserStore } from '@/store/user';
 import { getLastBuildDate, setLastBuildDate } from '@/utils/localStorage/app';
 import { getToken } from '@/utils/localStorage/user';
 
-const { info } = useBuildInfo();
+const { checkUpdate } = useCheckUpdate();
 const cacheStore = usePiniaCacheStore();
 const userStore = useUserStore();
 
@@ -37,6 +37,9 @@ const themeOverrides: GlobalThemeOverrides = {
 };
 
 onMounted(() => {
+  checkUpdate({
+    htmlUrl: 'http://localhost:8000',
+  });
   handleUpdate();
   loginMessage();
   cacheStore.setMuted(true);
@@ -64,10 +67,10 @@ onMounted(() => {
 
 function handleUpdate() {
   const old = getLastBuildDate();
-  if (info.value.lastBuildDate !== old) {
+  if (appBuildInfo.lastBuildDate !== old) {
     localStorage.clear();
   }
-  setLastBuildDate(info.value.lastBuildDate);
+  setLastBuildDate(appBuildInfo.lastBuildDate);
 }
 </script>
 

+ 14 - 0
src/api/tencentcloudCss.ts

@@ -0,0 +1,14 @@
+import request from '@/utils/request';
+
+export function fetchTencentcloudCssPush(liveRoomId: number) {
+  return request.post<{
+    obs: {
+      url: string;
+      key: string;
+    };
+    rtmp: string;
+    flv: string;
+    hls: string;
+    webrtc: string;
+  }>(`/tencentcloud_css/push`, { liveRoomId });
+}

+ 1 - 1
src/components/Modal/index.vue

@@ -86,7 +86,7 @@ const emits = defineEmits(['close']);
       @include cross(#ccc, 3px);
     }
     .content {
-      margin: 20px 0;
+      margin: 15px 0;
     }
     .footer {
       .btn {

+ 8 - 11
src/components/VideoControls/index.vue

@@ -6,7 +6,7 @@
         @click="changePlay"
       >
         <n-icon size="25">
-          <Pause v-if="appStore.play"></Pause>
+          <Pause v-if="appStore.playing"></Pause>
           <Play v-else></Play>
         </n-icon>
       </div>
@@ -152,19 +152,16 @@ function changeVolume(v) {
   cacheStore.setVolume(v);
 }
 function changePlay() {
-  appStore.setPlay(!appStore.play);
+  appStore.playing = !appStore.playing;
 }
 
-function changeLiveLine(item) {
+function changeLiveLine(item: LiveLineEnum) {
   if (
-    item === LiveLineEnum.rtc &&
-    appStore.liveRoomInfo?.type !== LiveRoomTypeEnum.wertc_live
-  ) {
-    window.$message.info('不支持该线路!');
-    return;
-  }
-  if (
-    appStore.liveRoomInfo?.type === LiveRoomTypeEnum.wertc_live &&
+    [
+      LiveRoomTypeEnum.wertc_live,
+      LiveRoomTypeEnum.wertc_meeting_one,
+      LiveRoomTypeEnum.wertc_meeting_two,
+    ].includes(appStore.liveRoomInfo?.type!) &&
     item !== LiveLineEnum.rtc
   ) {
     window.$message.info('不支持该线路!');

+ 4 - 0
src/constant.ts

@@ -16,6 +16,10 @@ export const QRCODE_LOGIN_URI = `https://live.${prodDomain}/qrcodeLogin`;
 
 export const AUTHOR_GITHUB = `https://github.com/galaxy-s10`;
 
+export const appBuildInfo =
+  // @ts-ignore
+  process.env.BilldHtmlWebpackPlugin as BilldHtmlWebpackPluginLog;
+
 export const WEBSOCKET_URL =
   process.env.NODE_ENV === 'development'
     ? `ws://localhost:4300`

+ 7 - 3
src/hooks/tipModal/index.vue

@@ -19,13 +19,13 @@
             class="btn return"
             @click="handleCancel()"
           >
-            取消
+            {{ cancelButtonText }}
           </div>
           <div
             :class="{ btn: 1, next: 1, hiddenCancel }"
             @click="handleOk()"
           >
-            确认
+            {{ confirmButtonText }}
           </div>
         </div>
       </template>
@@ -40,7 +40,9 @@ export default defineComponent({
   name: 'tipModal',
   emits: ['ok', 'cancel'],
   setup() {
-    const title = ref('');
+    const title = ref('提示');
+    const cancelButtonText = ref('取消');
+    const confirmButtonText = ref('确认');
     const width = ref('320px');
     const content = ref<string | VNode>('');
     const show = ref(false);
@@ -71,6 +73,8 @@ export default defineComponent({
       domRef,
       handleCancel,
       handleOk,
+      confirmButtonText,
+      cancelButtonText,
     };
   },
 });

+ 54 - 14
src/hooks/use-common.ts

@@ -1,19 +1,59 @@
-import { ref } from 'vue';
+import { getLastBuildDate } from '@/utils/localStorage/app';
+import { windowReload } from 'billd-utils';
 
-import { BilldHtmlWebpackPluginLog } from '@/interface';
+import { useTip } from './use-tip';
 
 export const useCheckUpdate = () => {
-  const appInfo = ref<BilldHtmlWebpackPluginLog>(
-    // @ts-ignore
-    process.env.BilldHtmlWebpackPlugin as BilldHtmlWebpackPluginLog
-  );
-  return { appInfo };
-};
+  function handleHtmlCheckUpdate(data: {
+    htmlUrl: string;
+    lastBuildDate: string;
+  }) {
+    return new Promise<{ shouldTip: boolean }>((resolve) => {
+      const xhr = new XMLHttpRequest();
+      xhr.open('GET', data.htmlUrl, true);
+      xhr.onreadystatechange = function () {
+        try {
+          if (this.readyState !== 4) return;
+          if (this.status !== 200) return; // or whatever error handling you want
+          const reg = /\('最后构建日期:', "(.+)"\)/;
+          const res = reg.exec(this.responseText);
+          if (res?.[1] !== data.lastBuildDate) {
+            resolve({ shouldTip: true });
+            console.log('提示更新');
+            useTip({
+              content: '发现新内容可用,是否刷新页面?',
+              confirmButtonText: '刷新',
+            })
+              .then(() => {
+                windowReload();
+              })
+              .catch(() => {});
+          } else {
+            resolve({ shouldTip: false });
+            console.log('不提示更新');
+          }
+        } catch (error) {
+          console.error(error);
+        }
+      };
+      xhr.send();
+    });
+  }
 
-export const useBuildInfo = () => {
-  const info = ref<BilldHtmlWebpackPluginLog>(
-    // @ts-ignore
-    process.env.BilldHtmlWebpackPlugin as BilldHtmlWebpackPluginLog
-  );
-  return { info };
+  function checkUpdate(data: { htmlUrl: string }) {
+    setInterval(
+      async () => {
+        const lastBuildDate = getLastBuildDate();
+        if (lastBuildDate) {
+          const res = await handleHtmlCheckUpdate({
+            htmlUrl: data.htmlUrl,
+            lastBuildDate,
+          });
+          return res;
+        }
+      },
+      1000 * 60 * 5
+    );
+  }
+  return { checkUpdate };
 };

+ 6 - 2
src/hooks/use-play.ts

@@ -111,14 +111,16 @@ export function useFlvPlay() {
       setMuted(newVal);
     }
   );
+
   watch(
     () => cacheStore.volume,
     (newVal) => {
       setVolume(newVal);
     }
   );
+
   watch(
-    () => appStore.play,
+    () => appStore.playing,
     (newVal) => {
       setPlay(newVal);
     }
@@ -250,14 +252,16 @@ export function useHlsPlay() {
       setMuted(newVal);
     }
   );
+
   watch(
     () => cacheStore.volume,
     (newVal) => {
       setVolume(newVal);
     }
   );
+
   watch(
-    () => appStore.play,
+    () => appStore.playing,
     (newVal) => {
       setPlay(newVal);
     }

+ 64 - 68
src/hooks/use-pull.ts

@@ -15,14 +15,13 @@ import { usePiniaCacheStore } from '@/store/cache';
 import { useNetworkStore } from '@/store/network';
 import { ILiveRoom, LiveRoomTypeEnum } from '@/types/ILiveRoom';
 import { WsMessageType, WsMsgTypeEnum } from '@/types/websocket';
-import { createVideo, videoToCanvas } from '@/utils';
+import { videoFullBox, videoToCanvas } from '@/utils';
 
 export function usePull(roomId: string) {
   const route = useRoute();
   const networkStore = useNetworkStore();
   const cacheStore = usePiniaCacheStore();
   const appStore = useAppStore();
-  const localStream = ref<MediaStream>();
   const danmuStr = ref('');
   const msgIsFile = ref(WsMessageMsgIsFileEnum.no);
   const danmuMsgType = ref<DanmuMsgTypeEnum>(DanmuMsgTypeEnum.danmu);
@@ -61,23 +60,24 @@ export function usePull(roomId: string) {
     remoteVideo.value = [];
   }
 
-  watch(hlsVideoEl, () => {
+  watch(hlsVideoEl, (newval) => {
     stopDrawingArr.value = [];
     stopDrawingArr.value.forEach((cb) => cb());
-    if (videoWrapRef.value) {
+    if (newval && videoWrapRef.value) {
       const rect = videoWrapRef.value.getBoundingClientRect();
       const { canvas, stopDrawing } = videoToCanvas({
         wrapSize: {
           width: rect.width,
           height: rect.height,
         },
-        videoEl: hlsVideoEl.value!,
+        videoEl: newval,
         videoResize: ({ w, h }) => {
           videoHeight.value = `${w}x${h}`;
         },
       });
       stopDrawingArr.value.push(stopDrawing);
       remoteVideo.value.push(canvas);
+      videoElArr.value.push(newval);
       videoLoading.value = false;
     }
   });
@@ -92,27 +92,82 @@ export function usePull(roomId: string) {
     });
   }
 
-  watch(flvVideoEl, () => {
+  watch(flvVideoEl, (newval) => {
     stopDrawingArr.value = [];
     stopDrawingArr.value.forEach((cb) => cb());
-    if (videoWrapRef.value) {
+    if (newval && videoWrapRef.value) {
       const rect = videoWrapRef.value.getBoundingClientRect();
       const { canvas, stopDrawing } = videoToCanvas({
         wrapSize: {
           width: rect.width,
           height: rect.height,
         },
-        videoEl: flvVideoEl.value!,
+        videoEl: newval,
         videoResize: ({ w, h }) => {
           videoHeight.value = `${w}x${h}`;
         },
       });
       stopDrawingArr.value.push(stopDrawing);
       remoteVideo.value.push(canvas);
+      videoElArr.value.push(newval);
       videoLoading.value = false;
     }
   });
 
+  watch(
+    () => networkStore.rtcMap,
+    (newVal) => {
+      if (newVal.size) {
+        roomLiving.value = true;
+        videoLoading.value = false;
+      }
+      if (
+        appStore.liveRoomInfo?.type === LiveRoomTypeEnum.wertc_meeting_one ||
+        appStore.liveRoomInfo?.type === LiveRoomTypeEnum.wertc_live ||
+        appStore.liveRoomInfo?.type === LiveRoomTypeEnum.pk ||
+        appStore.liveRoomInfo?.type === LiveRoomTypeEnum.tencent_css_pk
+      ) {
+        newVal.forEach((item) => {
+          if (appStore.allTrack.find((v) => v.mediaName === item.receiver)) {
+            return;
+          }
+          const rect = videoWrapRef.value?.getBoundingClientRect();
+          if (rect) {
+            videoFullBox({
+              wrapSize: {
+                width: rect.width,
+                height: rect.height,
+              },
+              videoEl: item.videoEl,
+              videoResize: ({ w, h }) => {
+                videoHeight.value = `${w}x${h}`;
+              },
+            });
+            remoteVideo.value.push(item.videoEl);
+            videoElArr.value.push(item.videoEl);
+          }
+        });
+      }
+    },
+    {
+      deep: true,
+      immediate: true,
+    }
+  );
+
+  watch(
+    () => remoteVideo.value,
+    (newval) => {
+      newval.forEach((videoEl) => {
+        videoWrapRef.value?.appendChild(videoEl);
+      });
+    },
+    {
+      deep: true,
+      immediate: true,
+    }
+  );
+
   function handleFlvPlay() {
     console.log('handleFlvPlay');
     handleStopDrawing();
@@ -249,7 +304,7 @@ export function usePull(roomId: string) {
   );
 
   watch(
-    () => appStore.play,
+    () => appStore.playing,
     (newVal) => {
       videoElArr.value.forEach((el) => {
         if (newVal) {
@@ -275,65 +330,6 @@ export function usePull(roomId: string) {
     }
   );
 
-  watch(
-    () => localStream.value,
-    (val) => {
-      if (val) {
-        console.log('localStream变了');
-        console.log('音频轨:', val?.getAudioTracks());
-        console.log('视频轨:', val?.getVideoTracks());
-        if (appStore.liveRoomInfo?.type === LiveRoomTypeEnum.wertc_live) {
-          videoElArr.value.forEach((dom) => {
-            dom.remove();
-          });
-          val?.getVideoTracks().forEach((track) => {
-            console.log('视频轨enabled:', track.id, track.enabled);
-            const video = createVideo({});
-            video.setAttribute('track-id', track.id);
-            video.srcObject = new MediaStream([track]);
-            remoteVideo.value.push(video);
-            videoElArr.value.push(video);
-          });
-          val?.getAudioTracks().forEach((track) => {
-            console.log('音频轨enabled:', track.id, track.enabled);
-            const video = createVideo({});
-            video.setAttribute('track-id', track.id);
-            video.srcObject = new MediaStream([track]);
-            remoteVideo.value.push(video);
-            videoElArr.value.push(video);
-          });
-          videoLoading.value = false;
-        } else {
-          videoElArr.value.forEach((dom) => {
-            dom.remove();
-          });
-          val?.getVideoTracks().forEach((track) => {
-            console.log('视频轨enabled:', track.id, track.enabled);
-            const video = createVideo({});
-            video.setAttribute('track-id', track.id);
-            video.srcObject = new MediaStream([track]);
-            remoteVideo.value.push(video);
-            videoElArr.value.push(video);
-          });
-          val?.getAudioTracks().forEach((track) => {
-            console.log('音频轨enabled:', track.id, track.enabled);
-            const video = createVideo({});
-            video.setAttribute('track-id', track.id);
-            video.srcObject = new MediaStream([track]);
-            remoteVideo.value.push(video);
-            videoElArr.value.push(video);
-          });
-          videoLoading.value = false;
-        }
-      } else {
-        videoElArr.value?.forEach((item) => {
-          item.remove();
-        });
-      }
-    },
-    { deep: true }
-  );
-
   function initPull(autolay = true) {
     autoplayVal.value = autolay;
     if (autoplayVal.value) {

+ 4 - 0
src/hooks/use-tip.ts

@@ -16,6 +16,8 @@ export function useTip(data: {
   content: string | VNode;
   hiddenCancel?: boolean;
   hiddenClose?: boolean;
+  confirmButtonText?: string;
+  cancelButtonText?: string;
 }) {
   instance.show = true;
   instance.title = data.title || '提示';
@@ -23,6 +25,8 @@ export function useTip(data: {
   instance.content = data.content;
   instance.hiddenCancel = !!data.hiddenCancel;
   instance.hiddenClose = !!data.hiddenClose;
+  instance.confirmButtonText = data.confirmButtonText || '确认';
+  instance.cancelButtonText = data.cancelButtonText || '取消';
   return new Promise((resolve, reject) => {
     instance.handleOk = () => {
       instance.show = false;

+ 59 - 3
src/hooks/use-websocket.ts

@@ -9,7 +9,9 @@ import { useRTCParams } from '@/hooks/use-rtcParams';
 import { useTip } from '@/hooks/use-tip';
 import { useWebRtcLive } from '@/hooks/webrtc/live';
 import { useWebRtcMeetingOne } from '@/hooks/webrtc/meetingOne';
+import { useWebRtcMeetingPk } from '@/hooks/webrtc/meetingPk';
 import { useWebRtcSrs } from '@/hooks/webrtc/srs';
+import { useWebRtcTencentcloudCss } from '@/hooks/webrtc/tencentcloudCss';
 import {
   DanmuMsgTypeEnum,
   ILiveUser,
@@ -47,8 +49,6 @@ import {
   prettierReceiveWsMsg,
 } from '@/utils/network/webSocket';
 
-import { useWebRtcMeetingPk } from './webrtc/meetingPk';
-
 export const useWebsocket = () => {
   const route = useRoute();
   const appStore = useAppStore();
@@ -58,6 +58,8 @@ export const useWebsocket = () => {
   const { maxBitrate, maxFramerate, resolutionRatio } = useRTCParams();
   const { updateWebRtcMeetingPkConfig, webRtcMeetingPk } = useWebRtcMeetingPk();
   const { updateWebRtcSrsConfig, webRtcSrs } = useWebRtcSrs();
+  const { updateWebRtcTencentcloudCssConfig, webRtcTencentcloudCss } =
+    useWebRtcTencentcloudCss();
   const { updateWebRtcLiveConfig, webRtcLive } = useWebRtcLive();
   const { updateWebRtcMeetingOneConfig, webRtcMeetingOne } =
     useWebRtcMeetingOne();
@@ -180,6 +182,21 @@ export const useWebsocket = () => {
         sender: mySocketId.value,
         receiver: 'srs',
       });
+    } else if (type === LiveRoomTypeEnum.tencent_css) {
+      updateWebRtcTencentcloudCssConfig({
+        isPk: false,
+        roomId: roomId.value,
+        canvasVideoStream: canvasVideoStream.value,
+      });
+      webRtcTencentcloudCss.newWebRtc({
+        sender: mySocketId.value,
+        receiver: 'tencentcloud_css',
+        videoEl: createNullVideo(),
+      });
+      webRtcTencentcloudCss.sendOffer({
+        sender: mySocketId.value,
+        receiver: 'tencentcloud_css',
+      });
     } else if (type === LiveRoomTypeEnum.pk) {
       updateWebRtcSrsConfig({
         isPk: true,
@@ -195,6 +212,21 @@ export const useWebsocket = () => {
         sender: mySocketId.value,
         receiver: 'srs',
       });
+    } else if (type === LiveRoomTypeEnum.tencent_css_pk) {
+      updateWebRtcTencentcloudCssConfig({
+        isPk: true,
+        roomId: roomId.value,
+        canvasVideoStream: canvasVideoStream.value,
+      });
+      webRtcTencentcloudCss.newWebRtc({
+        sender: mySocketId.value,
+        receiver: 'tencentcloud_css',
+        videoEl: createNullVideo(),
+      });
+      webRtcTencentcloudCss.sendOffer({
+        sender: mySocketId.value,
+        receiver: 'tencentcloud_css',
+      });
     }
   }
 
@@ -300,7 +332,10 @@ export const useWebsocket = () => {
       WsMsgTypeEnum.nativeWebRtcOffer,
       async (data: WsOfferType['data']) => {
         console.log('收到nativeWebRtcOffer', data);
-        if (data.live_room.type === LiveRoomTypeEnum.pk) {
+        if (
+          data.live_room.type === LiveRoomTypeEnum.pk ||
+          data.live_room.type === LiveRoomTypeEnum.tencent_css_pk
+        ) {
           if (!route.query.pkKey) {
             return;
           }
@@ -654,6 +689,27 @@ export const useWebsocket = () => {
               });
             }
           });
+        } else if (data.live_room.type === LiveRoomTypeEnum.tencent_css_pk) {
+          updateWebRtcMeetingPkConfig({
+            roomId: roomId.value,
+            anchorStream: canvasVideoStream.value,
+          });
+          data.socket_list?.forEach((item) => {
+            if (item !== mySocketId.value) {
+              if (networkStore.rtcMap.get(item)) {
+                return;
+              }
+              webRtcMeetingPk.newWebRtc({
+                sender: mySocketId.value,
+                receiver: item,
+                videoEl: createNullVideo(),
+              });
+              webRtcMeetingPk.sendOffer({
+                sender: mySocketId.value,
+                receiver: item,
+              });
+            }
+          });
         }
       } else {
         // 当前不是推流页面

+ 1 - 0
src/hooks/webrtc/meetingTwo.ts

@@ -7,6 +7,7 @@ import { useNetworkStore } from '@/store/network';
 import { WsAnswerType, WsMsgTypeEnum, WsOfferType } from '@/types/websocket';
 import { WebRTCClass } from '@/utils/network/webRTC';
 
+// TODO
 export const useWebRtcManyToManyMeeting = () => {
   const appStore = useAppStore();
   const networkStore = useNetworkStore();

+ 91 - 0
src/hooks/webrtc/tencentcloudCss.ts

@@ -0,0 +1,91 @@
+import { ref } from 'vue';
+
+import { fetchTencentcloudCssPush } from '@/api/tencentcloudCss';
+import { useRTCParams } from '@/hooks/use-rtcParams';
+import { useNetworkStore } from '@/store/network';
+import { useUserStore } from '@/store/user';
+import { WebRTCClass } from '@/utils/network/webRTC';
+
+export const useWebRtcTencentcloudCss = () => {
+  const userStore = useUserStore();
+  const networkStore = useNetworkStore();
+
+  const { maxBitrate, maxFramerate, resolutionRatio } = useRTCParams();
+  const currentMaxBitrate = ref(maxBitrate.value[3].value);
+  const currentMaxFramerate = ref(maxFramerate.value[2].value);
+  const currentResolutionRatio = ref(resolutionRatio.value[3].value);
+  const isPk = ref(false);
+  const roomId = ref('');
+  const canvasVideoStream = ref<MediaStream>();
+
+  function updateWebRtcTencentcloudCssConfig(data: {
+    isPk;
+    roomId;
+    canvasVideoStream;
+  }) {
+    isPk.value = data.isPk;
+    roomId.value = data.roomId;
+    canvasVideoStream.value = data.canvasVideoStream;
+  }
+
+  const webRtcTencentcloudCss = {
+    newWebRtc: (data: {
+      sender: string;
+      receiver: string;
+      videoEl: HTMLVideoElement;
+    }) => {
+      return new WebRTCClass({
+        maxBitrate: currentMaxBitrate.value,
+        maxFramerate: currentMaxFramerate.value,
+        resolutionRatio: currentResolutionRatio.value,
+        isSRS: false,
+        roomId: roomId.value,
+        videoEl: data.videoEl,
+        sender: data.sender,
+        receiver: data.receiver,
+      });
+    },
+    /**
+     * 主播发offer给观众
+     */
+    sendOffer: async ({
+      sender,
+      receiver,
+    }: {
+      sender: string;
+      receiver: string;
+    }) => {
+      console.log('开始webRtcTencentcloudCss的sendOffer', {
+        sender,
+        receiver,
+      });
+      try {
+        const ws = networkStore.wsMap.get(roomId.value);
+        if (!ws) return;
+        const rtc = networkStore.rtcMap.get(receiver);
+        if (rtc) {
+          const liveRooms = userStore.userInfo?.live_rooms;
+          const myLiveRoom = liveRooms?.[0];
+          if (!myLiveRoom) {
+            window.$message.error('你没有开通直播间');
+            return;
+          }
+          const res = await fetchTencentcloudCssPush(myLiveRoom.id!);
+          if (res.code === 200) {
+            const livePusher = new window.TXLivePusher();
+            // https://cloud.tencent.com/document/product/267/92713#1a9164cf-9f99-47d5-9667-ea558886cb9f
+            // 使用用户自定义的音视频流。
+            await livePusher.startCustomCapture(canvasVideoStream.value);
+            livePusher.startPush(res.data.webrtc);
+          }
+        } else {
+          console.error('rtc不存在');
+        }
+      } catch (error) {
+        console.error('webRtcTencentcloudCss的sendOffer错误');
+      }
+    },
+  };
+
+  return { updateWebRtcTencentcloudCssConfig, webRtcTencentcloudCss };
+};

+ 10 - 2
src/layout/pc/head/index.vue

@@ -277,10 +277,18 @@
               >
                 <div class="txt">{{ t('layout.pkLive') }}</div>
               </a>
-              <a class="item">
+              <a
+                class="item"
+                @click.prevent="handleStartLive(LiveRoomTypeEnum.tencent_css)"
+              >
                 <div class="txt">{{ t('layout.tencentCssLive') }}</div>
               </a>
-              <a class="item">
+              <a
+                class="item"
+                @click.prevent="
+                  handleStartLive(LiveRoomTypeEnum.tencent_css_pk)
+                "
+              >
                 <div class="txt">{{ t('layout.tencentCssPkLive') }}</div>
               </a>
               <div class="tip">

+ 2 - 5
src/store/app/index.ts

@@ -6,7 +6,7 @@ import { mobileRouterName } from '@/router';
 import { ILiveRoom } from '@/types/ILiveRoom';
 
 export type AppRootState = {
-  play: boolean;
+  playing: boolean;
   videoRatio: number;
   normalVolume: number;
   navList: { routeName: string; name: string }[];
@@ -47,7 +47,7 @@ export type AppRootState = {
 export const useAppStore = defineStore('app', {
   state: (): AppRootState => {
     return {
-      play: true,
+      playing: true,
       videoRatio: 16 / 9,
       normalVolume: 70,
       navList: [
@@ -70,9 +70,6 @@ export const useAppStore = defineStore('app', {
     setLiveLine(res: AppRootState['liveLine']) {
       this.liveLine = res;
     },
-    setPlay(res: AppRootState['play']) {
-      this.play = res;
-    },
     setAllTrack(res: AppRootState['allTrack']) {
       this.allTrack = res;
     },

+ 1 - 0
src/types/shims-vue.d.ts

@@ -6,4 +6,5 @@ declare module '*.vue' {
 }
 interface Window {
   $message: import('naive-ui/es/message/src/MessageProvider').MessageApiInjection;
+  TXLivePusher: any;
 }

+ 3 - 0
src/utils/index.ts

@@ -18,6 +18,9 @@ export function createNullVideo() {
   videoEl.setAttribute('x5-video-player-type', 'h5');
   videoEl.setAttribute('x5-video-player-fullscreen', 'true');
   videoEl.setAttribute('x5-video-orientation', 'portraint');
+  videoEl.oncontextmenu = (e) => {
+    e.preventDefault();
+  };
   return videoEl;
 }
 

+ 1 - 0
src/utils/localStorage/app.ts

@@ -4,6 +4,7 @@ import cache from '@/utils/cache';
 export const getLastBuildDate = () => {
   return cache.getStorage<string>(lsKey.lastBuildDate);
 };
+
 export const setLastBuildDate = (val: string) => {
   return cache.setStorage(lsKey.lastBuildDate, val);
 };

+ 8 - 1
src/views/area/id/index.vue

@@ -31,7 +31,13 @@
               直播中
             </div>
             <div
-              v-if="iten?.cdn === LiveRoomUseCDNEnum.yes"
+              v-if="
+                iten?.cdn === LiveRoomUseCDNEnum.yes ||
+                [
+                  LiveRoomTypeEnum.tencent_css,
+                  LiveRoomTypeEnum.tencent_css_pk,
+                ].includes(iten.type!)
+              "
               class="cdn-ico"
             >
               <div class="txt">CDN</div>
@@ -76,6 +82,7 @@ import {
   ILiveRoom,
   LiveRoomIsShowEnum,
   LiveRoomPullIsShouldAuthEnum,
+  LiveRoomTypeEnum,
   LiveRoomUseCDNEnum,
 } from '@/types/ILiveRoom';
 

+ 8 - 1
src/views/h5/area/index.vue

@@ -28,7 +28,13 @@
             <div class="live-txt">直播中</div>
           </div>
           <div
-            v-if="iten?.cdn === LiveRoomUseCDNEnum.yes"
+            v-if="
+              iten?.cdn === LiveRoomUseCDNEnum.yes ||
+              [
+                LiveRoomTypeEnum.tencent_css,
+                LiveRoomTypeEnum.tencent_css_pk,
+              ].includes(iten.type!)
+            "
             class="cdn-ico"
           >
             <div class="txt">CDN</div>
@@ -58,6 +64,7 @@ import {
   ILiveRoom,
   LiveRoomIsShowEnum,
   LiveRoomPullIsShouldAuthEnum,
+  LiveRoomTypeEnum,
   LiveRoomUseCDNEnum,
 } from '@/types/ILiveRoom';
 

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

@@ -53,7 +53,13 @@
                 直播中
               </div>
               <div
-                v-if="iten.live_room?.cdn === LiveRoomUseCDNEnum.yes"
+                v-if="
+                  iten.live_room?.cdn === LiveRoomUseCDNEnum.yes ||
+                  [
+                    LiveRoomTypeEnum.tencent_css,
+                    LiveRoomTypeEnum.tencent_css_pk,
+                  ].includes(iten.live_room?.type!)
+                "
                 class="cdn-ico"
               >
                 <div class="txt">CDN</div>
@@ -86,6 +92,7 @@ import { useAppStore } from '@/store/app';
 import {
   LiveRoomIsShowEnum,
   LiveRoomPullIsShouldAuthEnum,
+  LiveRoomTypeEnum,
   LiveRoomUseCDNEnum,
 } from '@/types/ILiveRoom';
 

+ 29 - 5
src/views/home/index.vue

@@ -45,7 +45,13 @@
           @click="showJoinBtn = !showJoinBtn"
         >
           <div
-            v-if="currentLiveRoom?.live_room?.cdn === LiveRoomUseCDNEnum.yes"
+            v-if="
+              currentLiveRoom?.live_room?.cdn === LiveRoomUseCDNEnum.yes ||
+              [
+                LiveRoomTypeEnum.tencent_css,
+                LiveRoomTypeEnum.tencent_css_pk,
+              ].includes(currentLiveRoom?.live_room?.type!)
+            "
             class="cdn-ico"
           >
             <div class="txt">CDN</div>
@@ -112,7 +118,13 @@
               <div class="hidden">
                 <div
                   class="cdn-ico"
-                  v-if="item?.live_room?.cdn === LiveRoomUseCDNEnum.yes"
+                  v-if="
+                    item?.live_room?.cdn === LiveRoomUseCDNEnum.yes ||
+                    [
+                      LiveRoomTypeEnum.tencent_css,
+                      LiveRoomTypeEnum.tencent_css_pk,
+                    ].includes(item.live_room?.type!)
+                  "
                 >
                   <div class="txt">CDN</div>
                 </div>
@@ -169,7 +181,13 @@
                 "
               ></PullAuthTip>
               <div
-                v-if="iten?.live_room?.cdn === LiveRoomUseCDNEnum.yes"
+                v-if="
+                  iten?.live_room?.cdn === LiveRoomUseCDNEnum.yes ||
+                  [
+                    LiveRoomTypeEnum.tencent_css,
+                    LiveRoomTypeEnum.tencent_css_pk,
+                  ].includes(iten.live_room?.type!)
+                "
                 class="cdn-ico"
               >
                 <div class="txt">CDN</div>
@@ -310,10 +328,16 @@ function playLive(item: ILive) {
 
 function changeLiveRoom(item: ILive) {
   if (item.id === currentLiveRoom.value?.id) return;
-  if (item.live_room?.type !== LiveRoomTypeEnum.wertc_live) {
+  if (
+    ![
+      LiveRoomTypeEnum.wertc_live,
+      LiveRoomTypeEnum.wertc_meeting_one,
+      LiveRoomTypeEnum.wertc_meeting_two,
+    ].includes(item.live_room?.type!)
+  ) {
     appStore.setLiveLine(LiveLineEnum.hls);
   }
-  appStore.setPlay(true);
+  appStore.playing = true;
   playLive(item);
 }
 

+ 64 - 168
src/views/pull/index.vue

@@ -109,8 +109,8 @@
         </div>
       </div>
       <div
-        ref="containerRef"
-        class="container"
+        class="video-wrap"
+        v-loading="videoLoading"
       >
         <div
           class="no-live"
@@ -119,28 +119,22 @@
           主播还没开播~
         </div>
         <div
-          v-else
-          v-loading="videoLoading"
-          class="video-wrap"
-        >
-          <div
-            class="cover"
-            :style="{
-              backgroundImage: `url(${
-                appStore.liveRoomInfo?.cover_img || anchorInfo?.avatar
-              })`,
-            }"
-          ></div>
-          <div
-            ref="remoteVideoRef"
-            class="media-list"
-          ></div>
-          <VideoControls
-            :resolution="videoHeight"
-            @refresh="handleRefresh"
-            @full-screen="handleFullScreen"
-          ></VideoControls>
-        </div>
+          class="cover"
+          :style="{
+            backgroundImage: `url(${
+              appStore.liveRoomInfo?.cover_img || anchorInfo?.avatar
+            })`,
+          }"
+        ></div>
+        <div
+          class="video-list"
+          ref="remoteVideoRef"
+        ></div>
+        <VideoControls
+          :resolution="videoHeight"
+          @refresh="handleRefresh"
+          @full-screen="handleFullScreen"
+        ></VideoControls>
       </div>
 
       <div
@@ -440,14 +434,8 @@ import router, { routerName } from '@/router';
 import { useAppStore } from '@/store/app';
 import { useNetworkStore } from '@/store/network';
 import { useUserStore } from '@/store/user';
-import { LiveRoomTypeEnum } from '@/types/ILiveRoom';
 import { WsDisableSpeakingType, WsMsgTypeEnum } from '@/types/websocket';
-import {
-  formatMoney,
-  formatTimeHour,
-  handleUserMedia,
-  videoFullBox,
-} from '@/utils';
+import { formatMoney, formatTimeHour, handleUserMedia } from '@/utils';
 import { NODE_ENV } from 'script/constant';
 
 import RechargeCpt from './recharge/index.vue';
@@ -473,7 +461,6 @@ const topRef = ref<HTMLDivElement>();
 const bottomRef = ref<HTMLDivElement>();
 const danmuListRef = ref<HTMLDivElement>();
 const remoteVideoRef = ref<HTMLDivElement>();
-const containerRef = ref<HTMLDivElement>();
 const uploadRef = ref<HTMLInputElement>();
 const danmuIptRef = ref<HTMLTextAreaElement>();
 const {
@@ -499,14 +486,14 @@ const {
 } = usePull(roomId.value);
 
 onMounted(() => {
-  videoWrapRef.value = containerRef.value;
+  videoWrapRef.value = remoteVideoRef.value;
   setTimeout(() => {
     scrollTo(0, 0);
   }, 100);
   handleHistoryMsg();
-  appStore.setPlay(true);
+  appStore.playing = true;
   getGoodsList();
-  if (topRef.value && bottomRef.value && containerRef.value) {
+  if (topRef.value && bottomRef.value && remoteVideoRef.value) {
     const res =
       bottomRef.value.getBoundingClientRect().top -
       (topRef.value.getBoundingClientRect().top +
@@ -613,58 +600,6 @@ async function handlePk() {
   }
 }
 
-watch(
-  () => networkStore.rtcMap,
-  (newVal) => {
-    if (newVal.size) {
-      roomLiving.value = true;
-      videoLoading.value = false;
-    }
-    if (
-      appStore.liveRoomInfo?.type === LiveRoomTypeEnum.wertc_meeting_one ||
-      appStore.liveRoomInfo?.type === LiveRoomTypeEnum.wertc_live ||
-      appStore.liveRoomInfo?.type === LiveRoomTypeEnum.pk
-    ) {
-      newVal.forEach((item) => {
-        if (appStore.allTrack.find((v) => v.mediaName === item.receiver)) {
-          return;
-        }
-        const rect = videoWrapRef.value?.getBoundingClientRect();
-        if (rect) {
-          videoFullBox({
-            wrapSize: {
-              width: rect.width,
-              height: rect.height,
-            },
-            videoEl: item.videoEl,
-            videoResize: ({ w, h }) => {
-              videoHeight.value = `${w}x${h}`;
-            },
-          });
-          remoteVideoRef.value?.appendChild(item.videoEl);
-        }
-      });
-    }
-  },
-  {
-    deep: true,
-    immediate: true,
-  }
-);
-
-watch(
-  () => remoteVideo.value,
-  (newval) => {
-    newval.forEach((videoEl) => {
-      remoteVideoRef.value?.appendChild(videoEl);
-    });
-  },
-  {
-    deep: true,
-    immediate: true,
-  }
-);
-
 watch(
   () => damuList.value.length,
   () => {
@@ -1035,101 +970,62 @@ function handleScrollTop() {
         }
       }
     }
-    .container {
+    .video-wrap {
+      position: relative;
       display: flex;
+      overflow: hidden;
       align-items: center;
+      flex: 1;
       justify-content: space-between;
       height: 562px;
       background-color: rgba($color: #000000, $alpha: 0.5);
-
-      .no-live {
-        position: absolute;
-        top: 50%;
-        left: 50%;
-        z-index: 20;
-        color: white;
-        font-size: 28px;
-        transform: translate(-50%, -50%);
-      }
-      .video-wrap {
+      .video-list {
         position: relative;
-        overflow: hidden;
-        flex: 1;
+        width: 100%;
         height: 100%;
-
-        .cover {
+        :deep(video) {
           position: absolute;
-          background-position: center center;
-          background-size: cover;
-          filter: blur(10px);
-
-          inset: 0;
-        }
-        .videoControls {
-          position: relative;
-          z-index: 20;
-        }
-        .media-list {
-          position: relative;
-          height: 562px;
-          :deep(video) {
-            position: absolute;
-            top: 50%;
-            left: 50%;
-            display: block;
-            margin: 0 auto;
-            // min-width: 100%;
-            // min-height: 100%;
-            max-width: $w-1000;
-            max-height: 562px;
-            transform: translate(-50%, -50%);
-          }
-          :deep(canvas) {
-            position: absolute;
-            top: 50%;
-            left: 50%;
-            display: block;
-            margin: 0 auto;
-            // min-width: 100%;
-            // min-height: 100%;
-            max-width: $w-1000;
-            max-height: 562px;
-            transform: translate(-50%, -50%);
-          }
-          // &.item {
-          //   :deep(video) {
-          //     width: 50%;
-          //     height: initial !important;
-          //   }
-          //   :deep(canvas) {
-          //     width: 50%;
-          //     height: initial !important;
-          //   }
-          // }
-        }
-
-        .controls {
-          display: none;
+          top: 50%;
+          left: 50%;
+          display: block;
+          margin: 0 auto;
+          // min-width: 100%;
+          // min-height: 100%;
+          max-width: $w-1000;
+          max-height: 562px;
+          transform: translate(-50%, -50%);
         }
-        .tip-btn {
+        :deep(canvas) {
           position: absolute;
           top: 50%;
           left: 50%;
-          z-index: 1;
-          align-items: center;
-          padding: 12px 26px;
-          border: 2px solid rgba($color: $theme-color-gold, $alpha: 0.5);
-          border-radius: 6px;
-          background-color: rgba(0, 0, 0, 0.3);
-          color: $theme-color-gold;
-          cursor: pointer;
+          display: block;
+          margin: 0 auto;
+          // min-width: 100%;
+          // min-height: 100%;
+          max-width: $w-1000;
+          max-height: 562px;
           transform: translate(-50%, -50%);
-          &:hover {
-            background-color: rgba($color: $theme-color-gold, $alpha: 0.5);
-            color: white;
-          }
         }
       }
+
+      .cover {
+        position: absolute;
+        background-position: center center;
+        background-size: cover;
+        filter: blur(10px);
+
+        inset: 0;
+      }
+      .no-live {
+        position: absolute;
+        top: 50%;
+        left: 50%;
+        z-index: 20;
+        color: white;
+        font-size: 28px;
+        transform: translate(-50%, -50%);
+      }
     }
 
     .gift-list {
@@ -1157,9 +1053,9 @@ function handleScrollTop() {
 
         .ico {
           position: relative;
+          margin-top: 12px;
           width: 40px;
           height: 40px;
-          margin-top: 12px;
           background-position: center center;
           background-size: cover;
           background-repeat: no-repeat;

+ 131 - 46
src/views/push/index.vue

@@ -8,6 +8,34 @@
         ref="containerRef"
         class="container"
       >
+        <div
+          class="recording"
+          v-if="recording"
+        >
+          <span class="dot"></span>
+          <span>REC {{ recordVideoTime }}</span>
+        </div>
+        <div
+          class="record-ico"
+          @click="handleRecordVideo"
+        >
+          <n-popover
+            placement="top"
+            trigger="hover"
+            :flip="false"
+          >
+            <template #trigger>
+              <n-icon
+                size="26"
+                :color="recording ? 'red' : '#3f7ee8'"
+              >
+                <Videocam v-if="!recording"></Videocam>
+                <VideocamOffSharp v-else></VideocamOffSharp>
+              </n-icon>
+            </template>
+            <div class="slider">{{ !recording ? '开始录制' : '结束录制' }}</div>
+          </n-popover>
+        </div>
         <canvas
           id="pushCanvasRef"
           ref="pushCanvasRef"
@@ -378,9 +406,12 @@ import {
   CreateOutline,
   EyeOffOutline,
   EyeOutline,
+  Videocam,
+  VideocamOffSharp,
   VolumeHighOutline,
   VolumeMuteOutline,
 } from '@vicons/ionicons5';
+import { AVRecorder } from '@webav/av-recorder';
 import { fabric } from 'fabric';
 import {
   Raw,
@@ -462,6 +493,7 @@ const {
 
 const currentMediaType = ref(MediaTypeEnum.camera);
 const currentMediaData = ref<AppRootState['allTrack'][0]>();
+const recording = ref(false);
 const showOpenMicophoneTipCpt = ref(false);
 const showSelectMediaModalCpt = ref(false);
 const showMediaModalCpt = ref(false);
@@ -475,7 +507,6 @@ const pushCanvasRef = ref<HTMLCanvasElement>();
 const webaudioVideo = ref<HTMLVideoElement>();
 const fabricCanvas = ref<fabric.Canvas>();
 const startTime = ref(+new Date());
-// const startTime = ref(1692807352565); // 1693027352565
 const msgLoading = ref(false);
 const uploadRef = ref<HTMLInputElement>();
 const nullAudioStream = ref<MediaStream>();
@@ -483,7 +514,6 @@ const showEmoji = ref(false);
 const worker = ref<Worker>();
 const workerTimerId = ref();
 const workerMsrTimerId = ref();
-
 const timeCanvasDom = ref<Raw<fabric.Text>[]>([]);
 const stopwatchCanvasDom = ref<Raw<fabric.Text>[]>([]);
 const wrapSize = reactive({
@@ -496,6 +526,10 @@ const recorder = ref<MediaRecorder>();
 const bolbId = ref(0);
 const msrDelay = ref(1000 * 1);
 const msrMaxDelay = ref(1000 * 5);
+const suggestedName = ref('');
+const recordVideoTimer = ref();
+const recordVideoTime = ref('');
+let avRecorder: AVRecorder | null = null;
 
 watch(
   () => roomLiving.value,
@@ -539,7 +573,7 @@ watch(
       addMediaOk({
         id: getRandomEnglishString(6),
         openEye: true,
-        audio: 2,
+        audio: 1,
         video: 1,
         mediaName: item.receiver,
         type: MediaTypeEnum.metting,
@@ -638,23 +672,23 @@ async function uploadChange() {
 }
 
 function handleMediaRecorderAllType() {
-  const types = [
-    'video/webm',
-    'audio/webm',
-    'video/mpeg',
-    'video/webm;codecs=vp8',
-    'video/webm;codecs=vp9',
-    'video/webm;codecs=daala',
-    'video/webm;codecs=h264',
-    'audio/webm;codecs=opus',
-    'audio/webm;codecs=aac',
-    'audio/webm;codecs=h264,opus',
-    'video/webm;codecs=avc1.64001f,opus',
-    'video/webm;codecs=avc1.4d002a,opus',
-  ];
-  Object.keys(types).forEach((item) => {
-    console.log(types[item], MediaRecorder.isTypeSupported(types[item]));
-  });
+  // const types = [
+  //   'video/webm',
+  //   'audio/webm',
+  //   'video/mpeg',
+  //   'video/webm;codecs=vp8',
+  //   'video/webm;codecs=vp9',
+  //   'video/webm;codecs=daala',
+  //   'video/webm;codecs=h264',
+  //   'audio/webm;codecs=opus',
+  //   'audio/webm;codecs=aac',
+  //   'audio/webm;codecs=h264,opus',
+  //   'video/webm;codecs=avc1.64001f,opus',
+  //   'video/webm;codecs=avc1.4d002a,opus',
+  // ];
+  // Object.keys(types).forEach((item) => {
+  //   console.log(types[item], MediaRecorder.isTypeSupported(types[item]));
+  // });
 }
 
 function handleMsr(stream: MediaStream) {
@@ -727,6 +761,7 @@ onMounted(() => {
 });
 
 onUnmounted(() => {
+  clearInterval(recordVideoTimer.value);
   recorder.value?.stop();
   bodyAppendChildElArr.value.forEach((el) => {
     el.remove();
@@ -901,6 +936,37 @@ function handleEndLive() {
   endLive();
 }
 
+async function handleRecordVideo() {
+  if (!window.VideoDecoder || !window.AudioEncoder) {
+    window.$message.warning(`当前环境不支持录制视频`);
+    return;
+  }
+  recording.value = !recording.value;
+  if (recording.value) {
+    const startTime = +new Date();
+    recordVideoTimer.value = setInterval(() => {
+      recordVideoTime.value = formatDownTime({
+        endTime: +new Date(),
+        startTime,
+      });
+    }, 1000);
+
+    avRecorder = new AVRecorder(canvasVideoStream.value!, {});
+    await avRecorder.start();
+    suggestedName.value = `billd直播录制-${+new Date()}.mp4`;
+    const fileHandle = await window.showSaveFilePicker({
+      suggestedName: suggestedName.value,
+    });
+    const writer = await fileHandle.createWritable();
+    avRecorder.outputStream?.pipeTo(writer).catch(console.error);
+  } else {
+    clearInterval(recordVideoTimer.value);
+    recordVideoTime.value = '';
+    await avRecorder?.stop();
+    window.$message.success(`录制文件: ${suggestedName.value} 已保存到本地`);
+  }
+}
+
 function handleStartLive() {
   if (!appStore.allTrack.length) {
     window.$message.warning('至少选择一个素材');
@@ -973,16 +1039,26 @@ function autoCreateVideo({
         videoEl.width = width;
         videoEl.height = height;
         if (canvasDom) {
+          const old = appStore.allTrack.find((item) => item.id === id);
           fabricCanvas.value?.remove(canvasDom);
+          canvasDom = markRaw(
+            new fabric.Image(videoEl, {
+              top: (old?.rect?.top || 0) / window.devicePixelRatio,
+              left: (old?.rect?.left || 0) / window.devicePixelRatio,
+              width,
+              height,
+            })
+          );
+        } else {
+          canvasDom = markRaw(
+            new fabric.Image(videoEl, {
+              top: rect?.top || 0,
+              left: rect?.left || 0,
+              width,
+              height,
+            })
+          );
         }
-        canvasDom = markRaw(
-          new fabric.Image(videoEl, {
-            top: rect?.top || 0,
-            left: rect?.left || 0,
-            width,
-            height,
-          })
-        );
         appStore.allTrack.forEach((item) => {
           if (item.id === id) {
             if (item.canvasDom) {
@@ -1700,22 +1776,7 @@ async function addMediaOk(val: AppRootState['allTrack'][0]) {
   } else if (val.type === MediaTypeEnum.metting) {
     const event = val.stream;
     if (!event) return;
-    const videoTrack: AppRootState['allTrack'][0] = {
-      id: getRandomEnglishString(6),
-      openEye: true,
-      deviceId: val.deviceId,
-      audio: 2,
-      video: 1,
-      mediaName: val.mediaName,
-      type: MediaTypeEnum.metting,
-      track: event.getVideoTracks()[0],
-      trackid: event.getVideoTracks()[0].id,
-      stream: event,
-      streamid: event.id,
-      hidden: false,
-      muted: false,
-      scaleInfo: {},
-    };
+    const videoTrack = val;
     const { canvasDom, videoEl, scale } = await autoCreateVideo({
       stream: event,
       id: videoTrack.id,
@@ -2168,7 +2229,6 @@ function handleStartMedia(item: { type: MediaTypeEnum; txt: string }) {
   .left {
     position: relative;
     display: inline-block;
-    overflow: hidden;
     box-sizing: border-box;
     width: $w-960;
     height: 100%;
@@ -2179,10 +2239,35 @@ function handleStartMedia(item: { type: MediaTypeEnum; txt: string }) {
 
     .container {
       position: relative;
-      overflow: hidden;
       height: 100%;
       background-color: rgba($color: #000000, $alpha: 0.5);
       line-height: 0;
+      .recording {
+        position: absolute;
+        top: 4px;
+        left: 0;
+        font-size: 12px;
+        color: red;
+        display: flex;
+        align-items: center;
+        z-index: 100;
+        font-weight: bold;
+        line-height: normal;
+        .dot {
+          width: 6px;
+          height: 6px;
+          background-color: red;
+          border-radius: 50%;
+          margin: 0 6px;
+        }
+      }
+      .record-ico {
+        position: absolute;
+        top: 0;
+        left: -10px;
+        transform: translateX(-100%);
+        cursor: pointer;
+      }
 
       .add-wrap {
         position: absolute;

+ 16 - 14
src/views/push/selectMediaModal/index.vue

@@ -6,24 +6,24 @@
       @close="emits('close')"
       :width="'350px'"
     >
-      <n-space justify="center">
-        <n-button
+      <div class="btn-wrap">
+        <div
+          class="btn"
           v-for="(item, index) in allMediaTypeList"
           :key="index"
-          class="item"
-          @click="emits('ok', item.type)"
         >
-          {{ item.txt }}
-        </n-button>
-      </n-space>
+          <n-button @click="emits('ok', item.type)">
+            {{ item.txt }}
+          </n-button>
+        </div>
+      </div>
+
       <template #footer></template>
     </Modal>
   </div>
 </template>
 
 <script lang="ts" setup>
-import { onMounted } from 'vue';
-
 import { MediaTypeEnum } from '@/interface';
 
 withDefaults(
@@ -38,16 +38,18 @@ withDefaults(
   {}
 );
 const emits = defineEmits(['close', 'ok']);
-
-onMounted(() => {});
 </script>
 
 <style lang="scss" scoped>
 .select-media-wrap {
-  text-align: initial;
+  .btn-wrap {
+    display: flex;
+    flex-wrap: wrap;
 
-  .container {
-    padding-top: 10px;
+    .btn {
+      margin-right: 12px;
+      margin-bottom: 12px;
+    }
   }
 }
 </style>