index.vue 61 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133
  1. <template>
  2. <div class="push-wrap">
  3. <div
  4. ref="topRef"
  5. class="left"
  6. >
  7. <div
  8. ref="containerRef"
  9. class="container"
  10. >
  11. <canvas
  12. id="pushCanvasRef"
  13. ref="pushCanvasRef"
  14. ></canvas>
  15. <div
  16. v-if="appStore.allTrack.filter((item) => !item.hidden).length <= 0"
  17. class="add-wrap"
  18. >
  19. <n-space>
  20. <n-button
  21. v-for="(item, index) in allMediaTypeList"
  22. :key="index"
  23. class="item"
  24. @click="handleStartMedia(item)"
  25. >
  26. {{ item.txt }}
  27. </n-button>
  28. </n-space>
  29. </div>
  30. </div>
  31. <div
  32. ref="bottomRef"
  33. class="room-control"
  34. >
  35. <div class="info">
  36. <div
  37. class="avatar"
  38. :style="{ backgroundImage: `url(${userStore.userInfo?.avatar})` }"
  39. ></div>
  40. <div class="detail">
  41. <div class="top">
  42. <n-input-group>
  43. <n-input
  44. v-model:value="roomName"
  45. size="small"
  46. placeholder="输入房间名"
  47. :style="{ width: '50%' }"
  48. />
  49. <n-button
  50. size="small"
  51. type="primary"
  52. @click="confirmRoomName"
  53. >
  54. 确定
  55. </n-button>
  56. </n-input-group>
  57. </div>
  58. <div class="bottom">
  59. <span v-if="NODE_ENV === 'development'">
  60. {{ mySocketId }}
  61. </span>
  62. </div>
  63. </div>
  64. </div>
  65. <div class="rtc">
  66. <div class="item">
  67. <div class="txt">码率设置</div>
  68. <div class="down">
  69. <n-select
  70. v-model:value="currentMaxBitrate"
  71. :options="maxBitrate"
  72. />
  73. </div>
  74. </div>
  75. <div class="item">
  76. <div class="txt">帧率设置</div>
  77. <div class="down">
  78. <n-select
  79. v-model:value="currentMaxFramerate"
  80. :options="maxFramerate"
  81. />
  82. </div>
  83. </div>
  84. <div class="item">
  85. <div class="txt">分辨率设置</div>
  86. <div class="down">
  87. <n-select
  88. v-model:value="currentResolutionRatio"
  89. :options="resolutionRatio"
  90. />
  91. </div>
  92. </div>
  93. </div>
  94. <div class="other">
  95. <div class="top">
  96. <span class="item">
  97. <i class="ico"></i>
  98. <span>
  99. 正在观看:
  100. {{
  101. liveUserList.filter((item) => item.id !== mySocketId).length
  102. }}
  103. </span>
  104. </span>
  105. </div>
  106. <div class="bottom">
  107. <n-button
  108. v-if="!roomLiving"
  109. type="info"
  110. size="small"
  111. @click="handleStartLive"
  112. >
  113. 开始直播
  114. </n-button>
  115. <n-button
  116. v-else
  117. type="error"
  118. size="small"
  119. @click="handleEndLive"
  120. >
  121. 结束直播
  122. </n-button>
  123. </div>
  124. </div>
  125. </div>
  126. </div>
  127. <div class="right">
  128. <div class="resource-card">
  129. <div class="title">素材列表</div>
  130. <div class="list">
  131. <div
  132. v-for="(item, index) in appStore.allTrack.filter(
  133. (item) => !item.hidden
  134. )"
  135. :key="index"
  136. class="item"
  137. >
  138. <span class="name">
  139. {{ NODE_ENV === 'development' ? item.id : '' }}({{
  140. mediaTypeEnumMap[item.type]
  141. }}){{ item.mediaName }}
  142. </span>
  143. <div class="control">
  144. <div
  145. v-if="item.audio === 1"
  146. class="control-item"
  147. @click="handleChangeMuted(item)"
  148. >
  149. <n-popover
  150. placement="top"
  151. trigger="hover"
  152. :flip="false"
  153. >
  154. <template #trigger>
  155. <n-icon size="16">
  156. <VolumeMuteOutline v-if="item.muted"></VolumeMuteOutline>
  157. <VolumeHighOutline v-else></VolumeHighOutline>
  158. </n-icon>
  159. </template>
  160. <div class="slider">
  161. <n-slider
  162. :value="item.volume"
  163. :step="1"
  164. @update-value="(v) => handleChangeVolume(item, v)"
  165. />
  166. </div>
  167. </n-popover>
  168. </div>
  169. <div
  170. class="control-item"
  171. @click="handleEdit(item)"
  172. >
  173. <n-icon size="16">
  174. <CreateOutline></CreateOutline>
  175. </n-icon>
  176. </div>
  177. <div
  178. class="control-item"
  179. @click="handleDel(item)"
  180. >
  181. <n-icon size="16">
  182. <Close></Close>
  183. </n-icon>
  184. </div>
  185. </div>
  186. </div>
  187. </div>
  188. <div class="bottom">
  189. <n-button
  190. size="small"
  191. type="primary"
  192. @click="showSelectMediaModalCpt = true"
  193. >
  194. 添加素材
  195. </n-button>
  196. </div>
  197. </div>
  198. <div class="danmu-card">
  199. <div class="title">弹幕互动</div>
  200. <div class="list-wrap">
  201. <div
  202. ref="danmuListRef"
  203. class="list"
  204. >
  205. <div
  206. v-for="(item, index) in damuList"
  207. :key="index"
  208. class="item"
  209. >
  210. <template v-if="item.msgType === DanmuMsgTypeEnum.danmu">
  211. <span class="name">
  212. <span v-if="item.userInfo">
  213. {{ item.userInfo.username }}[{{
  214. item.userInfo.roles?.map((v) => v.role_name).join()
  215. }}]
  216. </span>
  217. <span v-else>{{ item.socket_id }}[游客]</span>
  218. </span>
  219. <span>:</span>
  220. <span
  221. class="msg"
  222. v-if="!item.msgIsFile"
  223. >
  224. {{ item.msg }}
  225. </span>
  226. <div
  227. class="msg img"
  228. v-else
  229. >
  230. <img
  231. :src="item.msg"
  232. alt=""
  233. @load="handleScrollTop"
  234. />
  235. </div>
  236. </template>
  237. <template v-else-if="item.msgType === DanmuMsgTypeEnum.otherJoin">
  238. <span class="name system">系统通知:</span>
  239. <span class="msg">
  240. <span>{{ item.userInfo?.username || item.socket_id }}</span>
  241. <span>进入直播!</span>
  242. </span>
  243. </template>
  244. <template
  245. v-else-if="item.msgType === DanmuMsgTypeEnum.userLeaved"
  246. >
  247. <span class="name system">系统通知:</span>
  248. <span class="msg">
  249. <span>{{ item.userInfo?.username || item.socket_id }}</span>
  250. <span>离开直播!</span>
  251. </span>
  252. </template>
  253. </div>
  254. </div>
  255. </div>
  256. <div
  257. class="send-msg"
  258. v-loading="msgLoading"
  259. >
  260. <div class="control">
  261. <div
  262. class="ico face"
  263. title="表情"
  264. @click="handleWait"
  265. ></div>
  266. <div
  267. class="ico img"
  268. title="图片"
  269. @click="mockClick"
  270. >
  271. <input
  272. ref="uploadRef"
  273. type="file"
  274. class="input-upload"
  275. accept=".webp,.png,.jpg,.jpeg,.gif"
  276. @change="uploadChange"
  277. />
  278. </div>
  279. </div>
  280. <textarea
  281. v-model="danmuStr"
  282. class="ipt"
  283. @keydown="keydownDanmu"
  284. ></textarea>
  285. <div
  286. class="btn"
  287. @click="sendDanmu"
  288. >
  289. 发送
  290. </div>
  291. </div>
  292. <!-- <div class="send-msg">
  293. <input
  294. v-model="danmuStr"
  295. class="ipt"
  296. @keydown="keydownDanmu"
  297. />
  298. <n-button
  299. type="info"
  300. size="small"
  301. @click="sendDanmu"
  302. >
  303. 发送
  304. </n-button>
  305. </div> -->
  306. </div>
  307. </div>
  308. <SelectMediaModalCpt
  309. v-if="showSelectMediaModalCpt"
  310. :all-media-type-list="allMediaTypeList"
  311. @close="showSelectMediaModalCpt = false"
  312. @ok="handleShowMediaModalCpt"
  313. ></SelectMediaModalCpt>
  314. <MediaModalCpt
  315. v-if="showMediaModalCpt"
  316. :is-edit="isEdit"
  317. :media-type="currentMediaType"
  318. :init-data="currentMediaData"
  319. @close="showMediaModalCpt = false"
  320. @add-ok="addMediaOk"
  321. @edit-ok="editMediaOk"
  322. ></MediaModalCpt>
  323. <OpenMicophoneTipCpt
  324. v-if="showOpenMicophoneTipCpt"
  325. @close="showOpenMicophoneTipCpt = false"
  326. ></OpenMicophoneTipCpt>
  327. <NoLiveTipModalCpt
  328. v-if="showNoLiveTipModalCpt"
  329. @close="showNoLiveTipModalCpt = false"
  330. ></NoLiveTipModalCpt>
  331. </div>
  332. </template>
  333. <script lang="ts" setup>
  334. import {
  335. Close,
  336. CreateOutline,
  337. VolumeHighOutline,
  338. VolumeMuteOutline,
  339. } from '@vicons/ionicons5';
  340. import { fabric } from 'fabric';
  341. import {
  342. Raw,
  343. markRaw,
  344. onMounted,
  345. onUnmounted,
  346. reactive,
  347. ref,
  348. watch,
  349. } from 'vue';
  350. import { useRoute } from 'vue-router';
  351. import * as workerTimers from 'worker-timers';
  352. import { QINIU_LIVE, mediaTypeEnumMap } from '@/constant';
  353. import { usePush } from '@/hooks/use-push';
  354. import { useRTCParams } from '@/hooks/use-rtcParams';
  355. import { useUpload } from '@/hooks/use-upload';
  356. import { DanmuMsgTypeEnum, LiveRoomTypeEnum, MediaTypeEnum } from '@/interface';
  357. import { AppRootState, useAppStore } from '@/store/app';
  358. import { usePiniaCacheStore } from '@/store/cache';
  359. import { useNetworkStore } from '@/store/network';
  360. import { useUserStore } from '@/store/user';
  361. import {
  362. createVideo,
  363. formatDownTime,
  364. generateBase64,
  365. getRandomEnglishString,
  366. readFile,
  367. saveFile,
  368. } from '@/utils';
  369. import { NODE_ENV } from 'script/constant';
  370. import MediaModalCpt from './mediaModal/index.vue';
  371. import NoLiveTipModalCpt from './noLiveTipModal/index.vue';
  372. import OpenMicophoneTipCpt from './openMicophoneTip/index.vue';
  373. import SelectMediaModalCpt from './selectMediaModal/index.vue';
  374. const route = useRoute();
  375. const userStore = useUserStore();
  376. const appStore = useAppStore();
  377. const networkStore = useNetworkStore();
  378. const cacheStore = usePiniaCacheStore();
  379. const { maxBitrate, maxFramerate, resolutionRatio, allMediaTypeList } =
  380. useRTCParams();
  381. const {
  382. confirmRoomName,
  383. startLive,
  384. endLive,
  385. sendDanmu,
  386. keydownDanmu,
  387. sendBlob,
  388. msgIsFile,
  389. mySocketId,
  390. lastCoverImg,
  391. canvasVideoStream,
  392. roomLiving,
  393. currentResolutionRatio,
  394. currentMaxBitrate,
  395. currentMaxFramerate,
  396. danmuStr,
  397. roomName,
  398. damuList,
  399. liveUserList,
  400. } = usePush();
  401. const currentMediaType = ref(MediaTypeEnum.camera);
  402. const currentMediaData = ref<AppRootState['allTrack'][0]>();
  403. const showOpenMicophoneTipCpt = ref(false);
  404. const showSelectMediaModalCpt = ref(false);
  405. const showMediaModalCpt = ref(false);
  406. const showNoLiveTipModalCpt = ref(false);
  407. const isEdit = ref(false);
  408. const topRef = ref<HTMLDivElement>();
  409. const bottomRef = ref<HTMLDivElement>();
  410. const danmuListRef = ref<HTMLDivElement>();
  411. const containerRef = ref<HTMLDivElement>();
  412. const pushCanvasRef = ref<HTMLCanvasElement>();
  413. const webaudioVideo = ref<HTMLVideoElement>();
  414. const fabricCanvas = ref<fabric.Canvas>();
  415. const startTime = ref(+new Date());
  416. // const startTime = ref(1692807352565); // 1693027352565
  417. const msgLoading = ref(false);
  418. const uploadRef = ref<HTMLInputElement>();
  419. const timeCanvasDom = ref<Raw<fabric.Text>[]>([]);
  420. const stopwatchCanvasDom = ref<Raw<fabric.Text>[]>([]);
  421. const wrapSize = reactive({
  422. width: 0,
  423. height: 0,
  424. });
  425. const workerTimerId = ref(-1);
  426. const bodyAppendChildElArr = ref<HTMLElement[]>([]);
  427. const liveType = Number(route.query.liveType);
  428. const recorder = ref<MediaRecorder>();
  429. const sendBlobTimer = ref();
  430. const bolbId = ref(0);
  431. const chunkDelay = ref(1000 * 2);
  432. watch(
  433. () => roomLiving.value,
  434. () => {
  435. if (!roomLiving.value) {
  436. handleEndLive();
  437. showNoLiveTipModalCpt.value = true;
  438. }
  439. }
  440. );
  441. watch(
  442. () => currentMaxBitrate.value,
  443. () => {
  444. if (liveType === LiveRoomTypeEnum.user_msr) {
  445. const stream = pushCanvasRef.value!.captureStream();
  446. const audioTrack = webaudioVideo
  447. // @ts-ignore
  448. .value!.captureStream()
  449. .getAudioTracks()[0];
  450. stream.addTrack(audioTrack);
  451. handleMsr(stream);
  452. }
  453. }
  454. );
  455. watch(
  456. () => currentMaxFramerate.value,
  457. () => {
  458. workerTimers.clearInterval(workerTimerId.value);
  459. renderFrame();
  460. }
  461. );
  462. watch(
  463. () => damuList.value.length,
  464. () => {
  465. setTimeout(() => {
  466. handleScrollTop();
  467. }, 0);
  468. }
  469. );
  470. function handleScrollTop() {
  471. if (danmuListRef.value) {
  472. danmuListRef.value.scrollTop = danmuListRef.value.scrollHeight + 10000;
  473. }
  474. }
  475. function handleSendBlob(event: BlobEvent) {
  476. bolbId.value += 1;
  477. sendBlob({
  478. blob: event.data,
  479. blobId: `${bolbId.value}`,
  480. delay: chunkDelay.value,
  481. });
  482. }
  483. function mockClick() {
  484. uploadRef.value?.click();
  485. }
  486. function handleWait() {
  487. window.$message.warning('敬请期待!');
  488. }
  489. async function uploadChange() {
  490. const fileList = uploadRef.value?.files;
  491. if (fileList?.length) {
  492. try {
  493. msgLoading.value = true;
  494. msgIsFile.value = true;
  495. const res = await useUpload({
  496. prefix: QINIU_LIVE.prefix['billd-live/msg-image/'],
  497. file: fileList[0],
  498. });
  499. if (res?.resultUrl) {
  500. danmuStr.value = res.resultUrl || '错误图片';
  501. sendDanmu();
  502. }
  503. } catch (error) {
  504. console.log(error);
  505. } finally {
  506. msgIsFile.value = false;
  507. msgLoading.value = false;
  508. if (uploadRef.value) {
  509. uploadRef.value.value = '';
  510. }
  511. }
  512. }
  513. }
  514. function handleAllType() {
  515. const types = [
  516. 'video/webm',
  517. 'audio/webm',
  518. 'video/mpeg',
  519. 'video/webm;codecs=vp8',
  520. 'video/webm;codecs=vp9',
  521. 'video/webm;codecs=daala',
  522. 'video/webm;codecs=h264',
  523. 'audio/webm;codecs=opus',
  524. 'audio/webm;codecs=aac',
  525. 'audio/webm;codecs=h264,opus',
  526. 'video/webm;codecs=avc1.64001f,opus',
  527. 'video/webm;codecs=avc1.4d002a,opus',
  528. ];
  529. Object.keys(types).forEach((item) => {
  530. console.log(types[item], MediaRecorder.isTypeSupported(types[item]));
  531. });
  532. }
  533. function handleMsr(stream: MediaStream) {
  534. // https://developer.mozilla.org/en-US/docs/Web/Media/Formats/codecs_parameter
  535. const mimeType = 'video/webm;codecs=avc1.4d002a,opus';
  536. // const mimeType = 'video/webm;codecs=avc1.64001f,opus'; // b站的参数
  537. if (!MediaRecorder.isTypeSupported(mimeType)) {
  538. console.error('不支持', mimeType);
  539. return;
  540. } else {
  541. console.log('支持', mimeType);
  542. }
  543. /**
  544. * 小写的 "kb/s" 表示千比特每秒,而大写的 "KB/s" 表示千字节每秒
  545. * 例如,当我们说 100 kb/s 时,意思是每秒传输100千比特(比特)的数据。而当我们说 100 KB/s 时,意思是每秒传输100千字节(字节)的数据,相当于800千比特(比特)
  546. * 千字节(KB)、兆字节(MB)、千兆字节(GB)
  547. * 8 比特(bits)等于 1 字节(byte)
  548. * 1 Kbps(千比特每秒)等于 0.125 KB/s(千字节每秒)
  549. * 1 Mbps(兆比特每秒)等于 0.125 MB/s(兆字节每秒)
  550. * bit,比特
  551. * byte,字节
  552. * videoBitsPerSecond的单位是比特,假设videoBitsPerSecond值是1000*2000,即2000000
  553. * 2000000比特等于2000000 / 8 / 1000 = 250 KB/s
  554. */
  555. recorder.value = new MediaRecorder(stream, {
  556. mimeType,
  557. // bitsPerSecond: 1000 * currentMaxBitrate.value,
  558. videoBitsPerSecond: 1000 * currentMaxBitrate.value, // 单位是比特
  559. // audioBitsPerSecond: 1000 * 2000,
  560. });
  561. recorder.value.ondataavailable = handleSendBlob;
  562. sendBlobTimer.value = setInterval(function () {
  563. recorder.value?.stop();
  564. recorder.value?.start();
  565. }, chunkDelay.value);
  566. }
  567. onMounted(() => {
  568. setTimeout(() => {
  569. scrollTo(0, 0);
  570. }, 100);
  571. initUserMedia();
  572. initCanvas();
  573. handleCache();
  574. });
  575. onUnmounted(() => {
  576. recorder.value?.stop();
  577. bodyAppendChildElArr.value.forEach((el) => {
  578. el.remove();
  579. });
  580. clearFrame();
  581. });
  582. async function initUserMedia() {
  583. const res1 = await handleUserMedia({ video: true, audio: false });
  584. console.log('初始化获取摄像头成功');
  585. const res2 = await handleUserMedia({ video: false, audio: true });
  586. console.log('初始化获取麦克风成功');
  587. if (!res1 || !res2) {
  588. showOpenMicophoneTipCpt.value = true;
  589. }
  590. }
  591. function renderAll() {
  592. timeCanvasDom.value.forEach((item) => {
  593. item.text = new Date().toLocaleString();
  594. });
  595. stopwatchCanvasDom.value.forEach((item) => {
  596. item.text = formatDownTime({
  597. endTime: +new Date(),
  598. startTime: startTime.value,
  599. showMs: true,
  600. });
  601. });
  602. fabricCanvas.value?.renderAll();
  603. }
  604. function clearFrame() {
  605. if (workerTimerId.value !== -1) {
  606. workerTimers.clearInterval(workerTimerId.value);
  607. }
  608. }
  609. function renderFrame() {
  610. // currentMaxFramerate等于20,实际fps是17.68
  611. const delay = 1000 / (currentMaxFramerate.value / (17.68 / 20)); // 60帧的话即16.666666666666668
  612. workerTimerId.value = workerTimers.setInterval(() => {
  613. renderAll();
  614. }, delay);
  615. }
  616. function handleMixedAudio() {
  617. // console.log('handleMixedAudio');
  618. const allAudioTrack = appStore.allTrack.filter((item) => item.audio === 1);
  619. const audioCtx = new AudioContext();
  620. if (canvasVideoStream.value?.getAudioTracks()[0]) {
  621. canvasVideoStream.value.removeTrack(
  622. canvasVideoStream.value.getAudioTracks()[0]
  623. );
  624. }
  625. const res: { source: MediaStreamAudioSourceNode; gainNode: GainNode }[] = [];
  626. allAudioTrack.forEach((item) => {
  627. if (!audioCtx || !item.stream) return;
  628. const source = audioCtx.createMediaStreamSource(item.stream);
  629. const gainNode = audioCtx.createGain();
  630. gainNode.gain.value = (item.volume || 100) / 100;
  631. source.connect(gainNode);
  632. res.push({ source, gainNode });
  633. console.log('混流', item.stream?.id, item.stream);
  634. });
  635. const destination = audioCtx.createMediaStreamDestination();
  636. res.forEach((item) => {
  637. item.source.connect(item.gainNode);
  638. item.gainNode.connect(destination);
  639. });
  640. if (webaudioVideo.value) {
  641. webaudioVideo.value.remove();
  642. }
  643. webaudioVideo.value = createVideo({ appendChild: true, show: false });
  644. bodyAppendChildElArr.value.push(webaudioVideo.value);
  645. webaudioVideo.value.className = 'web-audio-video';
  646. webaudioVideo.value!.srcObject = destination.stream;
  647. const resAudio = destination.stream.getAudioTracks()[0];
  648. canvasVideoStream.value?.addTrack(resAudio);
  649. networkStore.rtcMap.forEach((rtc) => {
  650. const sender = rtc.peerConnection
  651. ?.getSenders()
  652. .find((sender) => sender.track?.id === resAudio.id);
  653. if (!sender) {
  654. rtc.peerConnection
  655. ?.getSenders()
  656. ?.find((sender) => sender.track?.kind === 'audio')
  657. ?.replaceTrack(resAudio);
  658. }
  659. });
  660. }
  661. function handleEndLive() {
  662. clearInterval(sendBlobTimer.value);
  663. recorder.value?.removeEventListener('dataavailable', handleSendBlob);
  664. endLive();
  665. }
  666. function handleStartLive() {
  667. if (!appStore.allTrack.length) {
  668. window.$message.warning('至少选择一个素材');
  669. return;
  670. }
  671. handleMixedAudio();
  672. lastCoverImg.value = generateBase64(pushCanvasRef.value!);
  673. startLive({
  674. type: liveType,
  675. receiver: mySocketId.value,
  676. chunkDelay: chunkDelay.value,
  677. });
  678. if (liveType === LiveRoomTypeEnum.user_msr) {
  679. const stream = pushCanvasRef.value!.captureStream();
  680. // @ts-ignore
  681. const audioTrack = webaudioVideo.value!.captureStream().getAudioTracks()[0];
  682. stream.addTrack(audioTrack);
  683. handleMsr(stream);
  684. }
  685. }
  686. function handleScale({ width, height }: { width: number; height: number }) {
  687. const resolutionHeight = currentResolutionRatio.value;
  688. const resolutionWidth = currentResolutionRatio.value * appStore.videoRatio;
  689. let ratio = 1;
  690. if (width > resolutionWidth) {
  691. const r1 = resolutionWidth / width;
  692. ratio = r1;
  693. }
  694. if (height > resolutionHeight) {
  695. const r1 = resolutionHeight / height;
  696. if (ratio > r1) {
  697. ratio = r1;
  698. }
  699. }
  700. return ratio;
  701. }
  702. function autoCreateVideo({
  703. stream,
  704. id,
  705. rect,
  706. muted,
  707. }: {
  708. stream: MediaStream;
  709. id: string;
  710. rect?: { left: number; top: number };
  711. muted?: boolean;
  712. }) {
  713. const videoEl = createVideo({ appendChild: true });
  714. bodyAppendChildElArr.value.push(videoEl);
  715. videoEl.setAttribute('videoid', id);
  716. if (muted !== undefined) {
  717. videoEl.muted = muted;
  718. }
  719. videoEl.srcObject = stream;
  720. return new Promise<{
  721. canvasDom: fabric.Image;
  722. videoEl: HTMLVideoElement;
  723. scale: number;
  724. }>((resolve) => {
  725. videoEl.onloadedmetadata = () => {
  726. const width = stream.getVideoTracks()[0].getSettings().width!;
  727. const height = stream.getVideoTracks()[0].getSettings().height!;
  728. const ratio = handleScale({ width, height });
  729. videoEl.width = width;
  730. videoEl.height = height;
  731. const canvasDom = markRaw(
  732. new fabric.Image(videoEl, {
  733. top: rect?.top || 0,
  734. left: rect?.left || 0,
  735. width,
  736. height,
  737. })
  738. );
  739. console.log(
  740. '初始化',
  741. ratio,
  742. canvasDom.width,
  743. canvasDom.height,
  744. width * ratio,
  745. height * ratio,
  746. canvasDom
  747. );
  748. handleMoving({ canvasDom, id });
  749. handleScaling({ canvasDom, id });
  750. canvasDom.scale(ratio / window.devicePixelRatio);
  751. fabricCanvas.value!.add(canvasDom);
  752. resolve({ canvasDom, scale: ratio, videoEl });
  753. };
  754. });
  755. }
  756. watch(
  757. () => currentResolutionRatio.value,
  758. (newHeight, oldHeight) => {
  759. changeCanvasAttr({ newHeight, oldHeight });
  760. }
  761. );
  762. // 容器宽高,1280*720,即720p
  763. // canvas容器宽高,2560*1440,即1440p
  764. // ======
  765. // 容器宽高,960*540,即540p
  766. // dom宽高,640*480
  767. // canvas容器宽高,960*540,即540p
  768. // 将dom绘制到容器里,此时dom的大小就是640*480
  769. // 需求,不管切换多少分辨率,我要看到的dom都是一样大小,即
  770. // 960*540时,dom是640*480
  771. // 1280*720时,dom不能是640*480了,因为这样他就会对比上一个分辨率的dom看起来小了,960/1280=0.75,540/720=0.75,
  772. // 其实就是分辨率变大了,我们就要将图片也变大,即图片的宽是640/0.75=853.4,高是480/0.75=640
  773. // 坐标变化,960*540时,dom坐标是100,100
  774. // 1280*720时,dom的坐标不能再是100,100了,否则对比上一个分辨率看起来偏
  775. function changeCanvasAttr({
  776. newHeight,
  777. oldHeight,
  778. }: {
  779. newHeight: number;
  780. oldHeight: number;
  781. }) {
  782. if (fabricCanvas.value) {
  783. const resolutionHeight =
  784. currentResolutionRatio.value / window.devicePixelRatio;
  785. const resolutionWidth =
  786. (currentResolutionRatio.value / window.devicePixelRatio) *
  787. appStore.videoRatio;
  788. fabricCanvas.value.setWidth(resolutionWidth);
  789. fabricCanvas.value.setHeight(resolutionHeight);
  790. appStore.allTrack.forEach((iten) => {
  791. const item = iten.canvasDom;
  792. if (item) {
  793. // 分辨率变小了,将图片变小
  794. if (newHeight < oldHeight) {
  795. const ratio2 = oldHeight / newHeight;
  796. item.left = item.left! / ratio2;
  797. item.top = item.top! / ratio2;
  798. } else {
  799. // 分辨率变大了,将图片变大
  800. const ratio2 = oldHeight / newHeight;
  801. item.left = item.left! / ratio2;
  802. item.top = item.top! / ratio2;
  803. }
  804. }
  805. });
  806. appStore.allTrack.forEach((iten) => {
  807. const item = iten.canvasDom;
  808. if (item) {
  809. // 分辨率变小了,将图片变小
  810. if (newHeight < oldHeight) {
  811. const ratio = newHeight / oldHeight;
  812. const ratio1 = (item.scaleX || 1) * ratio;
  813. item.scale(ratio1);
  814. } else {
  815. // 分辨率变大了,将图片变大
  816. const ratio = newHeight / oldHeight;
  817. const ratio1 = (item.scaleX || 1) * ratio;
  818. item.scale(ratio1);
  819. }
  820. }
  821. });
  822. changeCanvasStyle();
  823. }
  824. }
  825. function changeCanvasStyle() {
  826. // @ts-ignore
  827. fabricCanvas.value.wrapperEl.style.width = `${wrapSize.width}px`;
  828. // @ts-ignore
  829. fabricCanvas.value.wrapperEl.style.height = `${wrapSize.height}px`;
  830. // @ts-ignore
  831. fabricCanvas.value.lowerCanvasEl.style.width = `${wrapSize.width}px`;
  832. // @ts-ignore
  833. fabricCanvas.value.lowerCanvasEl.style.height = `${wrapSize.height}px`;
  834. // @ts-ignore
  835. fabricCanvas.value.upperCanvasEl.style.width = `${wrapSize.width}px`;
  836. // @ts-ignore
  837. fabricCanvas.value.upperCanvasEl.style.height = `${wrapSize.height}px`;
  838. }
  839. function initCanvas() {
  840. const resolutionHeight =
  841. currentResolutionRatio.value / window.devicePixelRatio;
  842. const resolutionWidth =
  843. (currentResolutionRatio.value / window.devicePixelRatio) *
  844. appStore.videoRatio;
  845. const wrapWidth = containerRef.value!.getBoundingClientRect().width;
  846. // const wrapWidth = 1920;
  847. const ratio = wrapWidth / resolutionWidth;
  848. const wrapHeight = resolutionHeight * ratio;
  849. // const wrapHeight = 1080;
  850. // lower-canvas: 实际的canvas画面,也就是pushCanvasRef
  851. // upper-canvas: 操作时候的canvas
  852. const ins = markRaw(new fabric.Canvas(pushCanvasRef.value!));
  853. ins.setWidth(resolutionWidth);
  854. ins.setHeight(resolutionHeight);
  855. ins.setBackgroundColor('black', () => {
  856. console.log('setBackgroundColor回调');
  857. });
  858. wrapSize.width = wrapWidth;
  859. wrapSize.height = wrapHeight;
  860. fabricCanvas.value = ins;
  861. renderFrame();
  862. changeCanvasStyle();
  863. }
  864. /**
  865. * 1: {scaleX: 1, scaleY: 1}
  866. * 2: {scaleX: 0.5, scaleY: 0.5}
  867. * 3: {scaleX: 0.3333333333333333, scaleY: 0.3333333333333333}
  868. * 4: {scaleX: 0.25, scaleY: 0.25}
  869. */
  870. /**
  871. * 二倍屏即1px里面有2个像素;三倍屏1px里面有3个像素,以此类推
  872. * 一个图片,宽高都是100px
  873. * 一倍屏展示:100px等于100个像素,一比一展示
  874. * 二倍屏展示:100px等于100个像素,二比一展示,即在二倍屏的100px看起来会比一倍屏的100px小一倍
  875. * 如果需要在一杯和二倍屏幕的时候看的大小都一样:
  876. * 1,在二倍屏的时候,需要将100px放大一倍,即200px;
  877. * 2,在一倍屏的时候,需要将100px缩小一百,即50px;
  878. */
  879. function handleScaling({ canvasDom, id }) {
  880. canvasDom.on('scaling', () => {
  881. appStore.allTrack.forEach((item) => {
  882. if (id === item.id) {
  883. item.scaleInfo[window.devicePixelRatio] = {
  884. scaleX: canvasDom.scaleX || 1,
  885. scaleY: canvasDom.scaleY || 1,
  886. };
  887. Object.keys(item.scaleInfo).forEach((iten) => {
  888. if (window.devicePixelRatio !== Number(iten)) {
  889. if (window.devicePixelRatio > Number(iten)) {
  890. item.scaleInfo[iten] = {
  891. scaleX:
  892. item.scaleInfo[window.devicePixelRatio].scaleX *
  893. window.devicePixelRatio,
  894. scaleY:
  895. item.scaleInfo[window.devicePixelRatio].scaleY *
  896. window.devicePixelRatio,
  897. };
  898. } else {
  899. if (window.devicePixelRatio === 1) {
  900. item.scaleInfo[iten] = {
  901. scaleX: item.scaleInfo[1].scaleX / Number(iten),
  902. scaleY: item.scaleInfo[1].scaleY / Number(iten),
  903. };
  904. } else {
  905. item.scaleInfo[iten] = {
  906. scaleX: item.scaleInfo[1].scaleX * Number(iten),
  907. scaleY: item.scaleInfo[1].scaleY * Number(iten),
  908. };
  909. }
  910. }
  911. }
  912. });
  913. }
  914. });
  915. cacheStore.setResourceList(appStore.allTrack);
  916. });
  917. }
  918. function handleMoving({
  919. canvasDom,
  920. id,
  921. }: {
  922. canvasDom: fabric.Image | fabric.Text;
  923. id: string;
  924. }) {
  925. canvasDom.on('moving', () => {
  926. console.log(
  927. 'moving',
  928. canvasDom.width,
  929. canvasDom.height,
  930. canvasDom.scaleX,
  931. canvasDom.scaleY
  932. );
  933. appStore.allTrack.forEach((item) => {
  934. if (id === item.id) {
  935. item.rect = {
  936. top: (canvasDom.top || 0) * window.devicePixelRatio,
  937. left: (canvasDom.left || 0) * window.devicePixelRatio,
  938. };
  939. }
  940. });
  941. cacheStore.setResourceList(appStore.allTrack);
  942. });
  943. }
  944. async function handleUserMedia({ video, audio }) {
  945. try {
  946. const event = await navigator.mediaDevices.getUserMedia({
  947. video,
  948. audio,
  949. });
  950. return event;
  951. } catch (error) {
  952. console.log(error);
  953. }
  954. }
  955. async function handleDisplayMedia({ video, audio }) {
  956. try {
  957. const event = await navigator.mediaDevices.getDisplayMedia({
  958. video,
  959. audio,
  960. });
  961. return event;
  962. } catch (error) {
  963. console.log(error);
  964. }
  965. }
  966. async function handleCache() {
  967. const res: AppRootState['allTrack'] = [];
  968. const err: string[] = [];
  969. const queue: any[] = [];
  970. cacheStore['resource-list'].forEach((item) => {
  971. // @ts-ignore
  972. const obj: AppRootState['allTrack'][0] = {};
  973. obj.audio = item.audio;
  974. obj.video = item.video;
  975. obj.id = item.id;
  976. obj.deviceId = item.deviceId;
  977. obj.type = item.type;
  978. obj.hidden = item.hidden;
  979. obj.mediaName = item.mediaName;
  980. obj.muted = item.muted;
  981. obj.volume = item.volume;
  982. obj.rect = item.rect;
  983. obj.scaleInfo = item.scaleInfo;
  984. obj.stopwatchInfo = item.stopwatchInfo;
  985. async function handleMediaVideo() {
  986. const { code, file } = await readFile(item.id);
  987. if (code === 1 && file) {
  988. const url = URL.createObjectURL(file);
  989. const videoEl = createVideo({
  990. muted: item.muted ? item.muted : false,
  991. appendChild: true,
  992. });
  993. bodyAppendChildElArr.value.push(videoEl);
  994. videoEl.setAttribute('videoid', item.id);
  995. if (obj.volume !== undefined) {
  996. videoEl.volume = obj.volume / 100;
  997. }
  998. videoEl.src = url;
  999. await new Promise((resolve) => {
  1000. videoEl.onloadedmetadata = () => {
  1001. const stream = videoEl
  1002. // @ts-ignore
  1003. .captureStream();
  1004. const width = stream.getVideoTracks()[0].getSettings().width!;
  1005. const height = stream.getVideoTracks()[0].getSettings().height!;
  1006. videoEl.width = width;
  1007. videoEl.height = height;
  1008. const canvasDom = markRaw(
  1009. new fabric.Image(videoEl, {
  1010. top: (item.rect?.top || 0) / window.devicePixelRatio,
  1011. left: (item.rect?.left || 0) / window.devicePixelRatio,
  1012. width,
  1013. height,
  1014. })
  1015. );
  1016. handleMoving({ canvasDom, id: item.id });
  1017. handleScaling({ canvasDom, id: item.id });
  1018. canvasDom.scale(
  1019. item.scaleInfo[window.devicePixelRatio].scaleX || 1
  1020. );
  1021. fabricCanvas.value!.add(canvasDom);
  1022. obj.videoEl = videoEl;
  1023. obj.canvasDom = canvasDom;
  1024. resolve({ videoEl, canvasDom });
  1025. };
  1026. });
  1027. const stream = videoEl
  1028. // @ts-ignore
  1029. .captureStream() as MediaStream;
  1030. obj.stream = stream;
  1031. obj.streamid = stream.id;
  1032. obj.track = stream.getVideoTracks()[0];
  1033. obj.trackid = stream.getVideoTracks()[0].id;
  1034. } else {
  1035. console.error('读取文件失败');
  1036. }
  1037. }
  1038. async function handleImg() {
  1039. const { code, file } = await readFile(item.id);
  1040. if (code === 1 && file) {
  1041. const imgEl = await new Promise<HTMLImageElement>((resolve) => {
  1042. const reader = new FileReader();
  1043. reader.addEventListener(
  1044. 'load',
  1045. function () {
  1046. const img = document.createElement('img');
  1047. img.src = reader.result as string;
  1048. img.onload = () => {
  1049. resolve(img);
  1050. };
  1051. },
  1052. false
  1053. );
  1054. if (file) {
  1055. reader.readAsDataURL(file);
  1056. }
  1057. });
  1058. if (fabricCanvas.value) {
  1059. const canvasDom = markRaw(
  1060. new fabric.Image(imgEl, {
  1061. top: (item.rect?.top || 0) / window.devicePixelRatio,
  1062. left: (item.rect?.left || 0) / window.devicePixelRatio,
  1063. width: imgEl.width,
  1064. height: imgEl.height,
  1065. })
  1066. );
  1067. handleMoving({ canvasDom, id: obj.id });
  1068. handleScaling({ canvasDom, id: obj.id });
  1069. canvasDom.scale(item.scaleInfo[window.devicePixelRatio].scaleX || 1);
  1070. fabricCanvas.value.add(canvasDom);
  1071. obj.canvasDom = canvasDom;
  1072. }
  1073. } else {
  1074. console.error('读取文件失败');
  1075. }
  1076. }
  1077. async function handleScreen() {
  1078. try {
  1079. const event = await handleDisplayMedia({
  1080. video: true,
  1081. audio: true,
  1082. });
  1083. if (!event) return;
  1084. const videoEl = createVideo({ appendChild: true });
  1085. bodyAppendChildElArr.value.push(videoEl);
  1086. videoEl.setAttribute('videoid', obj.id);
  1087. videoEl.srcObject = event;
  1088. await new Promise((resolve) => {
  1089. videoEl.onloadedmetadata = () => {
  1090. const stream = videoEl
  1091. // @ts-ignore
  1092. .captureStream();
  1093. const width = stream.getVideoTracks()[0].getSettings().width!;
  1094. const height = stream.getVideoTracks()[0].getSettings().height!;
  1095. videoEl.width = width;
  1096. videoEl.height = height;
  1097. const canvasDom = markRaw(
  1098. new fabric.Image(videoEl, {
  1099. top: (item.rect?.top || 0) / window.devicePixelRatio,
  1100. left: (item.rect?.left || 0) / window.devicePixelRatio,
  1101. width,
  1102. height,
  1103. })
  1104. );
  1105. handleMoving({ canvasDom, id: item.id });
  1106. handleScaling({ canvasDom, id: item.id });
  1107. canvasDom.scale(
  1108. item.scaleInfo[window.devicePixelRatio].scaleX || 1
  1109. );
  1110. fabricCanvas.value!.add(canvasDom);
  1111. obj.videoEl = videoEl;
  1112. obj.canvasDom = canvasDom;
  1113. resolve({ videoEl, canvasDom });
  1114. };
  1115. });
  1116. } catch (error) {
  1117. console.error(error);
  1118. handleDel(obj);
  1119. err.push(obj.id);
  1120. }
  1121. }
  1122. async function handleMicrophone() {
  1123. const event = await handleUserMedia({
  1124. video: false,
  1125. audio: { deviceId: obj.deviceId },
  1126. });
  1127. if (!event) return;
  1128. const videoEl = createVideo({ appendChild: true, muted: false });
  1129. bodyAppendChildElArr.value.push(videoEl);
  1130. videoEl.setAttribute('videoid', obj.id);
  1131. videoEl.srcObject = event;
  1132. if (obj.volume !== undefined) {
  1133. videoEl.volume = obj.muted ? 0 : obj.volume / 100;
  1134. }
  1135. obj.videoEl = videoEl;
  1136. obj.stream = event;
  1137. obj.streamid = event.id;
  1138. obj.track = event.getAudioTracks()[0];
  1139. obj.trackid = event.getAudioTracks()[0].id;
  1140. }
  1141. async function handleCamera() {
  1142. const event = await navigator.mediaDevices.getUserMedia({
  1143. video: { deviceId: obj.deviceId },
  1144. audio: false,
  1145. });
  1146. const videoEl = createVideo({ appendChild: true });
  1147. bodyAppendChildElArr.value.push(videoEl);
  1148. videoEl.setAttribute('videoid', obj.id);
  1149. videoEl.srcObject = event;
  1150. await new Promise((resolve) => {
  1151. videoEl.onloadedmetadata = () => {
  1152. const stream = videoEl
  1153. // @ts-ignore
  1154. .captureStream();
  1155. const width = stream.getVideoTracks()[0].getSettings().width!;
  1156. const height = stream.getVideoTracks()[0].getSettings().height!;
  1157. videoEl.width = width;
  1158. videoEl.height = height;
  1159. const canvasDom = markRaw(
  1160. new fabric.Image(videoEl, {
  1161. top: (item.rect?.top || 0) / window.devicePixelRatio,
  1162. left: (item.rect?.left || 0) / window.devicePixelRatio,
  1163. width,
  1164. height,
  1165. })
  1166. );
  1167. handleMoving({ canvasDom, id: item.id });
  1168. handleScaling({ canvasDom, id: item.id });
  1169. canvasDom.scale(item.scaleInfo[window.devicePixelRatio].scaleX || 1);
  1170. fabricCanvas.value!.add(canvasDom);
  1171. obj.videoEl = videoEl;
  1172. obj.canvasDom = canvasDom;
  1173. resolve({ videoEl, canvasDom });
  1174. };
  1175. });
  1176. }
  1177. if (item.type === MediaTypeEnum.media && item.video === 1) {
  1178. queue.push(handleMediaVideo());
  1179. } else if (item.type === MediaTypeEnum.screen) {
  1180. queue.push(handleScreen());
  1181. } else if (item.type === MediaTypeEnum.camera) {
  1182. queue.push(handleCamera());
  1183. } else if (item.type === MediaTypeEnum.microphone) {
  1184. queue.push(handleMicrophone());
  1185. } else if (item.type === MediaTypeEnum.img) {
  1186. queue.push(handleImg());
  1187. } else if (item.type === MediaTypeEnum.txt) {
  1188. obj.txtInfo = item.txtInfo;
  1189. obj.scaleInfo = item.scaleInfo;
  1190. if (fabricCanvas.value) {
  1191. const canvasDom = markRaw(
  1192. new fabric.Text(item.txtInfo?.txt || '', {
  1193. top: (item.rect?.top || 0) / window.devicePixelRatio,
  1194. left: (item.rect?.left || 0) / window.devicePixelRatio,
  1195. fill: item.txtInfo?.color,
  1196. })
  1197. );
  1198. handleMoving({ canvasDom, id: obj.id });
  1199. handleScaling({ canvasDom, id: obj.id });
  1200. // fabric.Text类型不能除以分辨率
  1201. canvasDom.scale(item.scaleInfo[window.devicePixelRatio].scaleX);
  1202. fabricCanvas.value.add(canvasDom);
  1203. obj.canvasDom = canvasDom;
  1204. }
  1205. } else if (item.type === MediaTypeEnum.time) {
  1206. obj.timeInfo = item.timeInfo;
  1207. obj.scaleInfo = item.scaleInfo;
  1208. if (fabricCanvas.value) {
  1209. const canvasDom = markRaw(
  1210. new fabric.Text(new Date().toLocaleString(), {
  1211. top: (item.rect?.top || 0) / window.devicePixelRatio,
  1212. left: (item.rect?.left || 0) / window.devicePixelRatio,
  1213. fill: item.timeInfo?.color,
  1214. })
  1215. );
  1216. timeCanvasDom.value.push(canvasDom);
  1217. handleMoving({ canvasDom, id: obj.id });
  1218. handleScaling({ canvasDom, id: obj.id });
  1219. // fabric.Text类型不能除以分辨率
  1220. canvasDom.scale(item.scaleInfo[window.devicePixelRatio].scaleX);
  1221. fabricCanvas.value.add(canvasDom);
  1222. obj.canvasDom = canvasDom;
  1223. }
  1224. } else if (item.type === MediaTypeEnum.stopwatch) {
  1225. obj.stopwatchInfo = item.stopwatchInfo;
  1226. obj.scaleInfo = item.scaleInfo;
  1227. if (fabricCanvas.value) {
  1228. const canvasDom = markRaw(
  1229. new fabric.Text('00天00时00分00秒000毫秒', {
  1230. top: (item.rect?.top || 0) / window.devicePixelRatio,
  1231. left: (item.rect?.left || 0) / window.devicePixelRatio,
  1232. fill: item.stopwatchInfo?.color,
  1233. })
  1234. );
  1235. stopwatchCanvasDom.value.push(canvasDom);
  1236. handleMoving({ canvasDom, id: obj.id });
  1237. handleScaling({ canvasDom, id: obj.id });
  1238. // fabric.Text类型不能除以分辨率
  1239. canvasDom.scale(item.scaleInfo[window.devicePixelRatio].scaleX);
  1240. fabricCanvas.value.add(canvasDom);
  1241. obj.canvasDom = canvasDom;
  1242. }
  1243. }
  1244. res.push(obj);
  1245. });
  1246. await Promise.all(queue);
  1247. canvasVideoStream.value = pushCanvasRef.value!.captureStream();
  1248. appStore.setAllTrack(res.filter((v) => !err.includes(v.id)));
  1249. }
  1250. function handleShowMediaModalCpt(val: MediaTypeEnum) {
  1251. isEdit.value = false;
  1252. currentMediaData.value = undefined;
  1253. showMediaModalCpt.value = true;
  1254. showSelectMediaModalCpt.value = false;
  1255. currentMediaType.value = val;
  1256. }
  1257. function handleEdit(item: AppRootState['allTrack'][0]) {
  1258. currentMediaType.value = item.type;
  1259. currentMediaData.value = item;
  1260. isEdit.value = true;
  1261. showMediaModalCpt.value = true;
  1262. }
  1263. function setScaleInfo({ track, canvasDom, scale = 1 }) {
  1264. [1, 2, 3, 4].forEach((devicePixelRatio) => {
  1265. track.scaleInfo[devicePixelRatio] = {
  1266. scaleX: (1 / devicePixelRatio) * scale,
  1267. scaleY: (1 / devicePixelRatio) * scale,
  1268. };
  1269. });
  1270. if (window.devicePixelRatio !== 1) {
  1271. const ratio = (1 / window.devicePixelRatio) * scale;
  1272. canvasDom.scale(ratio);
  1273. track.scaleInfo[window.devicePixelRatio] = {
  1274. scaleX: ratio,
  1275. scaleY: ratio,
  1276. };
  1277. }
  1278. }
  1279. async function addMediaOk(val: AppRootState['allTrack'][0]) {
  1280. showMediaModalCpt.value = false;
  1281. if (val.type === MediaTypeEnum.screen) {
  1282. const event = await handleDisplayMedia({
  1283. video: {
  1284. deviceId: val.deviceId,
  1285. // displaySurface: 'monitor', // browser默认标签页;window默认窗口;monitor默认整个屏幕
  1286. },
  1287. audio: true,
  1288. });
  1289. if (!event) return;
  1290. const videoTrack: AppRootState['allTrack'][0] = {
  1291. id: getRandomEnglishString(8),
  1292. audio: 2,
  1293. video: 1,
  1294. mediaName: val.mediaName,
  1295. type: MediaTypeEnum.screen,
  1296. track: event.getVideoTracks()[0],
  1297. trackid: event.getVideoTracks()[0].id,
  1298. stream: event,
  1299. streamid: event.id,
  1300. hidden: false,
  1301. muted: false,
  1302. scaleInfo: {},
  1303. };
  1304. const { canvasDom, videoEl, scale } = await autoCreateVideo({
  1305. stream: event,
  1306. id: videoTrack.id,
  1307. });
  1308. setScaleInfo({ canvasDom, track: videoTrack, scale });
  1309. videoTrack.videoEl = videoEl;
  1310. // @ts-ignore
  1311. videoTrack.canvasDom = canvasDom;
  1312. const audio = event.getAudioTracks();
  1313. if (audio.length) {
  1314. videoTrack.audio = 1;
  1315. videoTrack.volume = appStore.normalVolume;
  1316. const audioTrack: AppRootState['allTrack'][0] = {
  1317. id: videoTrack.id,
  1318. audio: 1,
  1319. video: 2,
  1320. mediaName: val.mediaName,
  1321. type: MediaTypeEnum.screen,
  1322. track: event.getAudioTracks()[0],
  1323. trackid: event.getAudioTracks()[0].id,
  1324. stream: event,
  1325. streamid: event.id,
  1326. hidden: true,
  1327. muted: false,
  1328. volume: videoTrack.volume,
  1329. scaleInfo: {},
  1330. };
  1331. const res = [...appStore.allTrack, videoTrack, audioTrack];
  1332. appStore.setAllTrack(res);
  1333. cacheStore.setResourceList(res);
  1334. handleMixedAudio();
  1335. } else {
  1336. const res = [...appStore.allTrack, videoTrack];
  1337. appStore.setAllTrack(res);
  1338. cacheStore.setResourceList(res);
  1339. // @ts-ignore
  1340. }
  1341. console.log('获取窗口成功');
  1342. } else if (val.type === MediaTypeEnum.camera) {
  1343. const event = await handleUserMedia({
  1344. video: {
  1345. deviceId: val.deviceId,
  1346. },
  1347. audio: false,
  1348. });
  1349. if (!event) return;
  1350. const videoTrack: AppRootState['allTrack'][0] = {
  1351. id: getRandomEnglishString(8),
  1352. deviceId: val.deviceId,
  1353. audio: 2,
  1354. video: 1,
  1355. mediaName: val.mediaName,
  1356. type: MediaTypeEnum.camera,
  1357. track: event.getVideoTracks()[0],
  1358. trackid: event.getVideoTracks()[0].id,
  1359. stream: event,
  1360. streamid: event.id,
  1361. hidden: false,
  1362. muted: false,
  1363. scaleInfo: {},
  1364. };
  1365. const { canvasDom, videoEl, scale } = await autoCreateVideo({
  1366. stream: event,
  1367. id: videoTrack.id,
  1368. });
  1369. setScaleInfo({ canvasDom, track: videoTrack, scale });
  1370. videoTrack.videoEl = videoEl;
  1371. // @ts-ignore
  1372. videoTrack.canvasDom = canvasDom;
  1373. const res = [...appStore.allTrack, videoTrack];
  1374. appStore.setAllTrack(res);
  1375. cacheStore.setResourceList(res);
  1376. // @ts-ignore
  1377. console.log('获取摄像头成功');
  1378. } else if (val.type === MediaTypeEnum.microphone) {
  1379. const event = await handleUserMedia({
  1380. video: false,
  1381. audio: { deviceId: val.deviceId },
  1382. });
  1383. if (!event) return;
  1384. const microphoneVideoTrack: AppRootState['allTrack'][0] = {
  1385. id: getRandomEnglishString(8),
  1386. deviceId: val.deviceId,
  1387. audio: 1,
  1388. video: 2,
  1389. mediaName: val.mediaName,
  1390. type: MediaTypeEnum.microphone,
  1391. track: event.getAudioTracks()[0],
  1392. trackid: event.getAudioTracks()[0].id,
  1393. stream: event,
  1394. streamid: event.id,
  1395. hidden: false,
  1396. muted: false,
  1397. volume: 60,
  1398. scaleInfo: {},
  1399. };
  1400. const videoEl = createVideo({ appendChild: true, muted: false });
  1401. bodyAppendChildElArr.value.push(videoEl);
  1402. videoEl.setAttribute('videoid', microphoneVideoTrack.id);
  1403. videoEl.srcObject = event;
  1404. microphoneVideoTrack.videoEl = videoEl;
  1405. const res = [...appStore.allTrack, microphoneVideoTrack];
  1406. appStore.setAllTrack(res);
  1407. cacheStore.setResourceList(res);
  1408. handleMixedAudio();
  1409. console.log('获取麦克风成功');
  1410. } else if (val.type === MediaTypeEnum.txt) {
  1411. const txtTrack: AppRootState['allTrack'][0] = {
  1412. id: getRandomEnglishString(8),
  1413. audio: 2,
  1414. video: 1,
  1415. mediaName: val.mediaName,
  1416. type: MediaTypeEnum.txt,
  1417. track: undefined,
  1418. trackid: undefined,
  1419. stream: undefined,
  1420. streamid: undefined,
  1421. hidden: false,
  1422. muted: false,
  1423. scaleInfo: {},
  1424. };
  1425. if (fabricCanvas.value) {
  1426. const canvasDom = markRaw(
  1427. new fabric.Text(val.txtInfo?.txt || '', {
  1428. top: 0,
  1429. left: 0,
  1430. fill: val.txtInfo?.color,
  1431. })
  1432. );
  1433. handleMoving({ canvasDom, id: txtTrack.id });
  1434. handleScaling({ canvasDom, id: txtTrack.id });
  1435. txtTrack.txtInfo = val.txtInfo;
  1436. if (window.devicePixelRatio !== 1) {
  1437. const ratio = 1 / window.devicePixelRatio;
  1438. canvasDom.scale(ratio);
  1439. txtTrack.scaleInfo[window.devicePixelRatio] = {
  1440. scaleX: ratio,
  1441. scaleY: ratio,
  1442. };
  1443. } else {
  1444. txtTrack.scaleInfo[window.devicePixelRatio] = { scaleX: 1, scaleY: 1 };
  1445. }
  1446. txtTrack.canvasDom = canvasDom;
  1447. fabricCanvas.value.add(canvasDom);
  1448. }
  1449. const res = [...appStore.allTrack, txtTrack];
  1450. // @ts-ignore
  1451. appStore.setAllTrack(res);
  1452. // @ts-ignore
  1453. cacheStore.setResourceList(res);
  1454. console.log('获取文字成功', fabricCanvas.value);
  1455. } else if (val.type === MediaTypeEnum.time) {
  1456. const timeTrack: AppRootState['allTrack'][0] = {
  1457. id: getRandomEnglishString(8),
  1458. audio: 2,
  1459. video: 1,
  1460. mediaName: val.mediaName,
  1461. type: MediaTypeEnum.time,
  1462. track: undefined,
  1463. trackid: undefined,
  1464. stream: undefined,
  1465. streamid: undefined,
  1466. hidden: false,
  1467. muted: false,
  1468. scaleInfo: {},
  1469. };
  1470. if (fabricCanvas.value) {
  1471. const canvasDom = markRaw(
  1472. new fabric.Text(new Date().toLocaleString(), {
  1473. top: 0,
  1474. left: 0,
  1475. fill: val.timeInfo?.color,
  1476. })
  1477. );
  1478. setScaleInfo({ canvasDom, track: timeTrack });
  1479. timeCanvasDom.value.push(canvasDom);
  1480. handleMoving({ canvasDom, id: timeTrack.id });
  1481. handleScaling({ canvasDom, id: timeTrack.id });
  1482. timeTrack.timeInfo = val.timeInfo;
  1483. timeTrack.canvasDom = canvasDom;
  1484. fabricCanvas.value.add(canvasDom);
  1485. }
  1486. const res = [...appStore.allTrack, timeTrack];
  1487. // @ts-ignore
  1488. appStore.setAllTrack(res);
  1489. // @ts-ignore
  1490. cacheStore.setResourceList(res);
  1491. console.log('获取时间成功', fabricCanvas.value);
  1492. } else if (val.type === MediaTypeEnum.stopwatch) {
  1493. const stopwatchTrack: AppRootState['allTrack'][0] = {
  1494. id: getRandomEnglishString(8),
  1495. audio: 2,
  1496. video: 1,
  1497. mediaName: val.mediaName,
  1498. type: MediaTypeEnum.stopwatch,
  1499. track: undefined,
  1500. trackid: undefined,
  1501. stream: undefined,
  1502. streamid: undefined,
  1503. hidden: false,
  1504. muted: false,
  1505. scaleInfo: {},
  1506. };
  1507. if (fabricCanvas.value) {
  1508. const canvasDom = markRaw(
  1509. new fabric.Text('00天00时00分00秒000毫秒', {
  1510. top: 0,
  1511. left: 0,
  1512. fill: val.stopwatchInfo?.color,
  1513. // editable: true,
  1514. })
  1515. );
  1516. setScaleInfo({ canvasDom, track: stopwatchTrack });
  1517. stopwatchCanvasDom.value.push(canvasDom);
  1518. handleMoving({ canvasDom, id: stopwatchTrack.id });
  1519. handleScaling({ canvasDom, id: stopwatchTrack.id });
  1520. stopwatchTrack.stopwatchInfo = val.stopwatchInfo;
  1521. stopwatchTrack.canvasDom = canvasDom;
  1522. fabricCanvas.value.add(canvasDom);
  1523. }
  1524. const res = [...appStore.allTrack, stopwatchTrack];
  1525. // @ts-ignore
  1526. appStore.setAllTrack(res);
  1527. // @ts-ignore
  1528. cacheStore.setResourceList(res);
  1529. console.log('获取秒表成功', fabricCanvas.value);
  1530. } else if (val.type === MediaTypeEnum.img) {
  1531. const imgTrack: AppRootState['allTrack'][0] = {
  1532. id: getRandomEnglishString(8),
  1533. audio: 2,
  1534. video: 1,
  1535. mediaName: val.mediaName,
  1536. type: MediaTypeEnum.img,
  1537. track: undefined,
  1538. trackid: undefined,
  1539. stream: undefined,
  1540. streamid: undefined,
  1541. hidden: false,
  1542. muted: false,
  1543. scaleInfo: {},
  1544. };
  1545. if (fabricCanvas.value) {
  1546. if (!val.imgInfo) return;
  1547. const file = val.imgInfo[0].file!;
  1548. const { code } = await saveFile({ file, fileName: imgTrack.id });
  1549. if (code !== 1) return;
  1550. const imgEl = await new Promise<HTMLImageElement>((resolve) => {
  1551. const reader = new FileReader();
  1552. reader.addEventListener(
  1553. 'load',
  1554. function () {
  1555. const img = document.createElement('img');
  1556. img.src = reader.result as string;
  1557. img.onload = () => {
  1558. resolve(img);
  1559. };
  1560. },
  1561. false
  1562. );
  1563. if (file) {
  1564. reader.readAsDataURL(file);
  1565. }
  1566. });
  1567. const canvasDom = markRaw(
  1568. new fabric.Image(imgEl, {
  1569. top: 0,
  1570. left: 0,
  1571. width: imgEl.width,
  1572. height: imgEl.height,
  1573. })
  1574. );
  1575. const scale = handleScale({ width: imgEl.width, height: imgEl.height });
  1576. setScaleInfo({ canvasDom, track: imgTrack, scale });
  1577. handleMoving({ canvasDom, id: imgTrack.id });
  1578. handleScaling({ canvasDom, id: imgTrack.id });
  1579. imgTrack.canvasDom = canvasDom;
  1580. fabricCanvas.value.add(canvasDom);
  1581. }
  1582. const res = [...appStore.allTrack, imgTrack];
  1583. // @ts-ignore
  1584. appStore.setAllTrack(res);
  1585. // @ts-ignore
  1586. cacheStore.setResourceList(res);
  1587. console.log('获取图片成功', fabricCanvas.value);
  1588. } else if (val.type === MediaTypeEnum.media) {
  1589. const mediaVideoTrack: AppRootState['allTrack'][0] = {
  1590. id: getRandomEnglishString(8),
  1591. audio: 2,
  1592. video: 1,
  1593. mediaName: val.mediaName,
  1594. type: MediaTypeEnum.media,
  1595. track: undefined,
  1596. trackid: undefined,
  1597. stream: undefined,
  1598. streamid: undefined,
  1599. hidden: false,
  1600. muted: false,
  1601. scaleInfo: {},
  1602. };
  1603. if (fabricCanvas.value) {
  1604. if (!val.mediaInfo) return;
  1605. const file = val.mediaInfo[0].file!;
  1606. const { code } = await saveFile({ file, fileName: mediaVideoTrack.id });
  1607. if (code !== 1) return;
  1608. const url = URL.createObjectURL(file);
  1609. const videoEl = createVideo({ muted: false, appendChild: true });
  1610. bodyAppendChildElArr.value.push(videoEl);
  1611. videoEl.src = url;
  1612. videoEl.muted = false;
  1613. const videoRes = await new Promise<HTMLVideoElement>((resolve) => {
  1614. videoEl.onloadedmetadata = () => {
  1615. resolve(videoEl);
  1616. };
  1617. });
  1618. // @ts-ignore
  1619. const stream = videoRes.captureStream();
  1620. const { canvasDom, scale } = await autoCreateVideo({
  1621. stream,
  1622. id: mediaVideoTrack.id,
  1623. });
  1624. setScaleInfo({ canvasDom, track: mediaVideoTrack, scale });
  1625. mediaVideoTrack.videoEl = videoEl;
  1626. mediaVideoTrack.canvasDom = canvasDom;
  1627. if (stream.getAudioTracks()[0]) {
  1628. console.log('视频有音频', stream.getAudioTracks()[0]);
  1629. mediaVideoTrack.audio = 1;
  1630. mediaVideoTrack.volume = appStore.normalVolume;
  1631. const audioTrack: AppRootState['allTrack'][0] = {
  1632. id: mediaVideoTrack.id,
  1633. audio: 1,
  1634. video: 2,
  1635. mediaName: val.mediaName,
  1636. type: MediaTypeEnum.media,
  1637. track: stream.getAudioTracks()[0],
  1638. trackid: stream.getAudioTracks()[0].id,
  1639. stream,
  1640. streamid: stream.id,
  1641. hidden: true,
  1642. muted: false,
  1643. volume: mediaVideoTrack.volume,
  1644. scaleInfo: {},
  1645. };
  1646. const res = [...appStore.allTrack, audioTrack];
  1647. appStore.setAllTrack(res);
  1648. cacheStore.setResourceList(res);
  1649. handleMixedAudio();
  1650. }
  1651. }
  1652. const res = [...appStore.allTrack, mediaVideoTrack];
  1653. // @ts-ignore
  1654. appStore.setAllTrack(res);
  1655. // @ts-ignore
  1656. cacheStore.setResourceList(res);
  1657. console.log('获取视频成功', fabricCanvas.value);
  1658. }
  1659. }
  1660. function editMediaOk(val: AppRootState['allTrack'][0]) {
  1661. showMediaModalCpt.value = false;
  1662. const res = appStore.allTrack.map((item) => {
  1663. if (item.id === val.id) {
  1664. item.mediaName = val.mediaName;
  1665. item.timeInfo = val.timeInfo;
  1666. item.stopwatchInfo = val.stopwatchInfo;
  1667. item.txtInfo = val.txtInfo;
  1668. if (
  1669. [
  1670. MediaTypeEnum.txt,
  1671. MediaTypeEnum.time,
  1672. MediaTypeEnum.stopwatch,
  1673. ].includes(val.type)
  1674. ) {
  1675. if (item.canvasDom) {
  1676. // @ts-ignore
  1677. item.canvasDom.set(
  1678. 'fill',
  1679. val.txtInfo?.color ||
  1680. val.timeInfo?.color ||
  1681. val.stopwatchInfo?.color
  1682. );
  1683. }
  1684. }
  1685. if (val.type === MediaTypeEnum.txt) {
  1686. if (item.canvasDom) {
  1687. // @ts-ignore
  1688. item.canvasDom.set('text', val.txtInfo?.txt);
  1689. }
  1690. }
  1691. }
  1692. return item;
  1693. });
  1694. appStore.setAllTrack(res);
  1695. cacheStore.setResourceList(res);
  1696. }
  1697. function handleChangeMuted(item: AppRootState['allTrack'][0]) {
  1698. if (item.videoEl) {
  1699. const res = !item.videoEl.muted;
  1700. item.videoEl.muted = res;
  1701. item.videoEl.volume = res ? 0 : appStore.normalVolume / 100;
  1702. item.volume = res ? 0 : appStore.normalVolume;
  1703. item.muted = res;
  1704. cacheStore.setResourceList(appStore.allTrack);
  1705. handleMixedAudio();
  1706. }
  1707. }
  1708. function handleChangeVolume(item: AppRootState['allTrack'][0], v) {
  1709. const res = appStore.allTrack.map((iten) => {
  1710. if (iten.id === item.id) {
  1711. if (item.volume !== undefined) {
  1712. iten.volume = v;
  1713. iten.muted = v === 0;
  1714. if (iten.videoEl) {
  1715. iten.videoEl.volume = v / 100;
  1716. iten.videoEl.muted = v === 0;
  1717. }
  1718. }
  1719. }
  1720. return iten;
  1721. });
  1722. appStore.setAllTrack(res);
  1723. cacheStore.setResourceList(res);
  1724. handleMixedAudio();
  1725. }
  1726. function handleDel(item: AppRootState['allTrack'][0]) {
  1727. if (item.canvasDom !== undefined) {
  1728. fabricCanvas.value?.remove(item.canvasDom);
  1729. item.videoEl?.remove();
  1730. item.stream?.getTracks().forEach((track) => {
  1731. track.stop();
  1732. item.stream?.removeTrack(track);
  1733. });
  1734. }
  1735. bodyAppendChildElArr.value.forEach((el) => {
  1736. const videoid = el.getAttribute('videoid');
  1737. if (item.id === videoid) {
  1738. el.remove();
  1739. }
  1740. });
  1741. const res = appStore.allTrack.filter((iten) => iten.id !== item.id);
  1742. appStore.setAllTrack(res);
  1743. cacheStore.setResourceList(res);
  1744. handleMixedAudio();
  1745. }
  1746. function handleStartMedia(item: { type: MediaTypeEnum; txt: string }) {
  1747. currentMediaType.value = item.type;
  1748. showMediaModalCpt.value = true;
  1749. }
  1750. </script>
  1751. <style lang="scss" scoped>
  1752. .slider {
  1753. width: 80px;
  1754. }
  1755. .push-wrap {
  1756. display: flex;
  1757. justify-content: space-between;
  1758. margin: 15px auto 0;
  1759. width: $w-1250;
  1760. .left {
  1761. position: relative;
  1762. display: inline-block;
  1763. overflow: hidden;
  1764. box-sizing: border-box;
  1765. width: $w-960;
  1766. height: 100%;
  1767. border-radius: 6px;
  1768. background-color: white;
  1769. color: #9499a0;
  1770. vertical-align: top;
  1771. .container {
  1772. position: relative;
  1773. overflow: hidden;
  1774. height: 100%;
  1775. background-color: rgba($color: #000000, $alpha: 0.5);
  1776. line-height: 0;
  1777. .add-wrap {
  1778. position: absolute;
  1779. top: 50%;
  1780. left: 50%;
  1781. display: flex;
  1782. align-items: center;
  1783. justify-content: space-around;
  1784. padding: 10px 20px;
  1785. width: 50%;
  1786. border-radius: 6px;
  1787. background-color: white;
  1788. transform: translate(-50%, -50%);
  1789. }
  1790. }
  1791. .room-control {
  1792. display: flex;
  1793. justify-content: space-between;
  1794. padding: 20px;
  1795. background-color: papayawhip;
  1796. .info {
  1797. display: flex;
  1798. align-items: center;
  1799. .avatar {
  1800. margin-right: 20px;
  1801. width: 55px;
  1802. height: 55px;
  1803. border-radius: 50%;
  1804. background-position: center center;
  1805. background-size: cover;
  1806. background-repeat: no-repeat;
  1807. }
  1808. .detail {
  1809. display: flex;
  1810. flex-direction: column;
  1811. flex-shrink: 0;
  1812. width: 200px;
  1813. text-align: initial;
  1814. .top {
  1815. margin-bottom: 10px;
  1816. color: #18191c;
  1817. }
  1818. .bottom {
  1819. font-size: 14px;
  1820. }
  1821. }
  1822. }
  1823. .rtc {
  1824. display: flex;
  1825. align-items: center;
  1826. flex: 1;
  1827. font-size: 14px;
  1828. .item {
  1829. display: flex;
  1830. align-items: center;
  1831. flex: 1;
  1832. .txt {
  1833. flex-shrink: 0;
  1834. width: 80px;
  1835. }
  1836. .down {
  1837. width: 90px;
  1838. user-select: none;
  1839. }
  1840. }
  1841. }
  1842. .other {
  1843. display: flex;
  1844. flex-direction: column;
  1845. justify-content: center;
  1846. font-size: 12px;
  1847. .top {
  1848. }
  1849. .bottom {
  1850. margin-top: 10px;
  1851. }
  1852. }
  1853. }
  1854. }
  1855. .right {
  1856. display: flex;
  1857. flex-direction: column;
  1858. justify-content: space-between;
  1859. box-sizing: border-box;
  1860. margin-left: 10px;
  1861. width: $w-250;
  1862. border-radius: 6px;
  1863. background-color: white;
  1864. color: #9499a0;
  1865. .resource-card {
  1866. position: relative;
  1867. box-sizing: border-box;
  1868. margin-bottom: 10px;
  1869. padding: 10px;
  1870. width: 100%;
  1871. height: 290px;
  1872. border-radius: 6px;
  1873. background-color: papayawhip;
  1874. .title {
  1875. text-align: initial;
  1876. }
  1877. .item {
  1878. display: flex;
  1879. align-items: center;
  1880. justify-content: space-between;
  1881. margin: 5px 0;
  1882. font-size: 14px;
  1883. // &:hover {
  1884. // .control {
  1885. // display: flex;
  1886. // align-items: center;
  1887. // }
  1888. // }
  1889. .control {
  1890. display: flex;
  1891. align-items: center;
  1892. .control-item {
  1893. cursor: pointer;
  1894. &:not(:last-child) {
  1895. margin-right: 6px;
  1896. }
  1897. }
  1898. }
  1899. }
  1900. .bottom {
  1901. position: absolute;
  1902. bottom: 0;
  1903. left: 0;
  1904. padding: 10px;
  1905. }
  1906. }
  1907. .danmu-card {
  1908. position: relative;
  1909. flex: 1;
  1910. box-sizing: border-box;
  1911. padding: 10px 10px 0px;
  1912. width: 100%;
  1913. border-radius: 6px;
  1914. background-color: papayawhip;
  1915. text-align: initial;
  1916. .title {
  1917. margin-bottom: 10px;
  1918. }
  1919. .list {
  1920. overflow: scroll;
  1921. height: 300px;
  1922. @extend %customScrollbar;
  1923. .item {
  1924. box-sizing: border-box;
  1925. margin-bottom: 4px;
  1926. padding: 2px 0;
  1927. white-space: normal;
  1928. word-wrap: break-word;
  1929. font-size: 13px;
  1930. .name {
  1931. color: #9499a0;
  1932. cursor: pointer;
  1933. &.system {
  1934. color: red;
  1935. }
  1936. }
  1937. .msg {
  1938. margin-top: 4px;
  1939. color: #61666d;
  1940. &.img {
  1941. img {
  1942. width: 80%;
  1943. }
  1944. }
  1945. }
  1946. }
  1947. }
  1948. .send-msg {
  1949. position: relative;
  1950. box-sizing: border-box;
  1951. padding: 4px 0;
  1952. width: 100%;
  1953. .control {
  1954. display: flex;
  1955. margin: 4px 0;
  1956. .ico {
  1957. margin-right: 6px;
  1958. width: 24px;
  1959. height: 24px;
  1960. cursor: pointer;
  1961. .input-upload {
  1962. width: 0;
  1963. height: 0;
  1964. opacity: 0;
  1965. }
  1966. &.face {
  1967. @include setBackground('@/assets/img/msg-face.webp');
  1968. }
  1969. &.img {
  1970. @include setBackground('@/assets/img/msg-img.webp');
  1971. }
  1972. }
  1973. }
  1974. .ipt {
  1975. display: block;
  1976. box-sizing: border-box;
  1977. margin: 0 auto;
  1978. padding: 10px;
  1979. width: 100%;
  1980. height: 60px;
  1981. outline: none;
  1982. border: 1px solid hsla(0, 0%, 60%, 0.2);
  1983. border-radius: 4px;
  1984. background-color: #f1f2f3;
  1985. font-size: 14px;
  1986. }
  1987. .btn {
  1988. box-sizing: border-box;
  1989. margin-top: 10px;
  1990. margin-left: auto;
  1991. padding: 4px;
  1992. width: 70px;
  1993. border-radius: 4px;
  1994. background-color: $theme-color-gold;
  1995. color: white;
  1996. text-align: center;
  1997. font-size: 12px;
  1998. cursor: pointer;
  1999. }
  2000. }
  2001. // .send-msg {
  2002. // display: flex;
  2003. // align-items: center;
  2004. // box-sizing: border-box;
  2005. // width: calc(100% - 20px);
  2006. // .ipt {
  2007. // display: block;
  2008. // box-sizing: border-box;
  2009. // margin: 0 auto;
  2010. // margin-right: 10px;
  2011. // padding: 10px;
  2012. // width: 80%;
  2013. // height: 30px;
  2014. // outline: none;
  2015. // border: 1px solid hsla(0, 0%, 60%, 0.2);
  2016. // border-radius: 4px;
  2017. // background-color: #f1f2f3;
  2018. // font-size: 14px;
  2019. // }
  2020. // }
  2021. }
  2022. }
  2023. }
  2024. // 屏幕宽度大于1500的时候
  2025. @media screen and (min-width: $w-1500) {
  2026. .push-wrap {
  2027. width: $w-1475;
  2028. .left {
  2029. width: $w-1150;
  2030. }
  2031. .right {
  2032. width: $w-300;
  2033. }
  2034. }
  2035. }
  2036. </style>