index.vue 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854
  1. <template>
  2. <div class="push-wrap">
  3. <div
  4. ref="topRef"
  5. class="left"
  6. >
  7. <div class="jsmpeg"></div>
  8. <video
  9. id="blobVideo"
  10. style="width: 300px; background-color: red"
  11. ></video>
  12. <button @click="startRtmp">startRtmp</button>
  13. <button @click="startmpeg">startmpeg</button>
  14. <div class="video-wrap">
  15. <video
  16. id="localVideo"
  17. ref="localVideoRef"
  18. autoplay
  19. webkit-playsinline="true"
  20. playsinline
  21. x-webkit-airplay="allow"
  22. x5-video-player-type="h5"
  23. x5-video-player-fullscreen="true"
  24. x5-video-orientation="portraint"
  25. muted
  26. controls
  27. ></video>
  28. <div
  29. v-if="currMediaTypeList.length <= 0"
  30. class="add-wrap"
  31. >
  32. <div
  33. class="item"
  34. @click="startMediaDevices"
  35. >
  36. 摄像头
  37. </div>
  38. <div
  39. class="item"
  40. @click="startGetDisplayMedia"
  41. >
  42. 窗口
  43. </div>
  44. </div>
  45. </div>
  46. <div
  47. ref="bottomRef"
  48. class="control"
  49. >
  50. <div class="info">
  51. <div class="avatar"></div>
  52. <div class="detail">
  53. <div class="top">
  54. <input
  55. ref="roomNameRef"
  56. v-model="roomName"
  57. type="text"
  58. placeholder="输入房间名"
  59. />
  60. <button
  61. class="btn"
  62. @click="confirmRoomName"
  63. >
  64. 确定
  65. </button>
  66. <!-- 房东的猫livehouse/音乐节 -->
  67. </div>
  68. <div class="bottom">
  69. <span>socketId:{{ getSocketId() }}</span>
  70. </div>
  71. </div>
  72. </div>
  73. <div class="other">
  74. <div class="top">
  75. <span class="item">
  76. <i class="ico"></i>
  77. <span>正在观看人数:{{ liveUserList.length }}</span>
  78. </span>
  79. </div>
  80. <div class="bottom">
  81. <button @click="startLive">开始直播</button>
  82. <button @click="endLive">结束直播</button>
  83. </div>
  84. </div>
  85. </div>
  86. </div>
  87. <div class="right">
  88. <div class="resource-card">
  89. <div class="title">素材列表</div>
  90. <div class="list">
  91. <div
  92. v-for="(item, index) in currMediaTypeList"
  93. :key="index"
  94. class="item"
  95. >
  96. <span class="name">{{ item }}</span>
  97. </div>
  98. </div>
  99. </div>
  100. <div class="danmu-card">
  101. <div class="title">弹幕互动</div>
  102. <div class="list">
  103. <div
  104. v-for="(item, index) in damuList"
  105. :key="index"
  106. class="item"
  107. >
  108. <span class="name">{{ item.socketId }}:</span>
  109. <span class="msg">{{ item.msg }}</span>
  110. </div>
  111. </div>
  112. <div class="send-msg">
  113. <input
  114. v-model="danmuStr"
  115. class="ipt"
  116. />
  117. <div
  118. class="btn"
  119. @click="sendDanmu"
  120. >
  121. 发送
  122. </div>
  123. </div>
  124. </div>
  125. </div>
  126. </div>
  127. </template>
  128. <script lang="ts" setup>
  129. import { getRandomString } from 'billd-utils';
  130. import { onMounted, onUnmounted, ref } from 'vue';
  131. import { IAdminIn, ICandidate, IOffer, LiveTypeEnum } from '@/interface';
  132. import { WebRTCClass } from '@/network/webRtc';
  133. import {
  134. WebSocketClass,
  135. WsConnectStatusEnum,
  136. WsMsgTypeEnum,
  137. } from '@/network/webSocket';
  138. import { useNetworkStore } from '@/store/network';
  139. const networkStore = useNetworkStore();
  140. const topRef = ref<HTMLDivElement>();
  141. const bottomRef = ref<HTMLDivElement>();
  142. const roomIdRef = ref<HTMLInputElement>();
  143. const joinRef = ref<HTMLButtonElement>();
  144. const leaveRef = ref<HTMLButtonElement>();
  145. const defaultRoomId = getRandomString(15);
  146. const roomId = ref<string>(defaultRoomId);
  147. const danmuStr = ref('');
  148. const roomName = ref('');
  149. const roomNameRef = ref<HTMLInputElement>();
  150. const websocketInstant = ref<WebSocketClass>();
  151. const isDone = ref(false);
  152. const localVideoRef = ref<HTMLVideoElement>();
  153. const localStream = ref();
  154. const currMediaTypeList = ref<LiveTypeEnum[]>([]);
  155. const currMediaType = ref<LiveTypeEnum>();
  156. const joined = ref(false);
  157. const isAdmin = ref(true);
  158. const offerSended = ref(new Set());
  159. const damuList = ref<
  160. {
  161. socketId: string;
  162. msgType: number;
  163. msg: string;
  164. }[]
  165. >([]);
  166. const liveUserList = ref<
  167. {
  168. socketId: string;
  169. avatar: string;
  170. expr: number;
  171. }[]
  172. >([]);
  173. function closeWs() {
  174. const instance = networkStore.wsMap.get(roomId.value);
  175. if (!instance) return;
  176. instance.close();
  177. }
  178. function closeRtc() {
  179. networkStore.rtcMap.forEach((rtc) => {
  180. rtc.close();
  181. });
  182. }
  183. function sendDanmu() {
  184. if (!danmuStr.value.length) {
  185. alert('请输入弹幕内容!');
  186. }
  187. if (!websocketInstant.value) return;
  188. websocketInstant.value.send({
  189. msgType: WsMsgTypeEnum.message,
  190. data: { msg: danmuStr.value },
  191. });
  192. damuList.value.push({
  193. socketId: getSocketId(),
  194. msgType: 1,
  195. msg: danmuStr.value,
  196. });
  197. danmuStr.value = '';
  198. }
  199. onUnmounted(() => {
  200. closeWs();
  201. closeRtc();
  202. });
  203. let flvPlayer;
  204. function startRtmp() {
  205. try {
  206. flvPlayer.play();
  207. } catch (error) {
  208. console.log(error);
  209. }
  210. }
  211. function startmpeg() {
  212. console.log(JSMpeg, JSMpeg.Player);
  213. const res1 = new JSMpeg.Player('./out.ts', { autoplay: true });
  214. console.log(res1);
  215. }
  216. onMounted(() => {
  217. // if (flvJs.isSupported()) {
  218. // flvPlayer = flvJs.createPlayer({
  219. // type: 'flv',
  220. // url: 'http://localhost:8080/live/show.flv',
  221. // // url: 'http://42.193.157.44:9000/live/fddm_2.flv',
  222. // // url: 'https://www.hsslive.cn/stream/live/fddm_2.flv',
  223. // // url: 'https://www.hsslive.cn/bilibilistream/live-bvc/173676/live_381307133_59434826.flv?expires=1680798150&len=0&oi=1900220676&pt=web&qn=0&trid=10002eb4d1a458684cc9a93d888d7b580cac&sigparams=cdn,expires,len,oi,pt,qn,trid&cdn=cn-gotcha15&sign=1724a76684e5e1f8d68346613a32f8c0&sk=3e2a893893799632504afbdef4a9e3d7&p2p_type=1&sl=1&free_type=0&mid=381307133&sche=ban&score=1&pp=rtmp&freeze=1&source=onetier&trace=10&site=12e2a543d23e1c612a7145eb5a5cfac4&order=1',
  224. // // url: 'https://xy117x149x235x203xy.mcdn.bilivideo.cn/live-bvc/173676/live_381307133_59434826.flv?expires=1680798150&len=0&oi=1900220676&pt=web&qn=0&trid=10002eb4d1a458684cc9a93d888d7b580cac&sigparams=cdn,expires,len,oi,pt,qn,trid&cdn=cn-gotcha15&sign=1724a76684e5e1f8d68346613a32f8c0&sk=3e2a893893799632504afbdef4a9e3d7&p2p_type=1&sl=1&free_type=0&mid=381307133&sche=ban&score=1&pp=rtmp&freeze=1&source=onetier&trace=10&site=12e2a543d23e1c612a7145eb5a5cfac4&order=1',
  225. // });
  226. // // @ts-ignore
  227. // flvPlayer.attachMediaElement(
  228. // document.querySelector<HTMLVideoElement>('#blobVideo')
  229. // );
  230. // flvPlayer.load();
  231. // }
  232. if (topRef.value && bottomRef.value && localVideoRef.value) {
  233. const res =
  234. bottomRef.value.getBoundingClientRect().top -
  235. topRef.value.getBoundingClientRect().top;
  236. localVideoRef.value.style.height = `${res}px`;
  237. }
  238. localVideoRef.value?.addEventListener('loadstart', () => {
  239. console.warn('视频流-loadstart');
  240. const rtc = networkStore.getRtcMap(roomId.value);
  241. if (!rtc) return;
  242. rtc.rtcStatus.loadstart = true;
  243. rtc.update();
  244. });
  245. localVideoRef.value?.addEventListener('loadedmetadata', () => {
  246. console.warn('视频流-loadedmetadata');
  247. const rtc = networkStore.getRtcMap(roomId.value);
  248. if (!rtc) return;
  249. rtc.rtcStatus.loadedmetadata = true;
  250. rtc.update();
  251. if (isAdmin.value) {
  252. batchSendOffer();
  253. }
  254. });
  255. });
  256. function getSocketId() {
  257. return networkStore.wsMap.get(roomId.value!)?.socketIo?.id || '-1';
  258. }
  259. function sendJoin() {
  260. const instance = networkStore.wsMap.get(roomId.value);
  261. if (!instance) return;
  262. instance.send({
  263. msgType: WsMsgTypeEnum.join,
  264. data: {
  265. roomName: roomName.value,
  266. },
  267. });
  268. }
  269. function batchSendOffer() {
  270. liveUserList.value.forEach(async (item) => {
  271. if (
  272. !offerSended.value.has(item.socketId) &&
  273. item.socketId !== getSocketId()
  274. ) {
  275. await startNewWebRtc(item.socketId);
  276. await addTrack();
  277. console.warn('new WebRTCClass完成');
  278. console.log('执行sendOffer', {
  279. sender: getSocketId(),
  280. receiver: item.socketId,
  281. });
  282. sendOffer({ sender: getSocketId(), receiver: item.socketId });
  283. offerSended.value.add(item.socketId);
  284. }
  285. });
  286. }
  287. function initReceive() {
  288. const instance = websocketInstant.value;
  289. if (!instance?.socketIo) return;
  290. // websocket连接成功
  291. instance.socketIo.on(WsConnectStatusEnum.connect, () => {
  292. console.log('【websocket】websocket连接成功', instance.socketIo?.id);
  293. if (!instance) return;
  294. instance.status = WsConnectStatusEnum.connect;
  295. instance.update();
  296. });
  297. // websocket连接断开
  298. instance.socketIo.on(WsConnectStatusEnum.disconnect, () => {
  299. console.log('【websocket】websocket连接断开', instance);
  300. if (!instance) return;
  301. instance.status = WsConnectStatusEnum.disconnect;
  302. instance.update();
  303. });
  304. // 当前所有在线用户
  305. instance.socketIo.on(WsMsgTypeEnum.roomLiveing, (data: IAdminIn) => {
  306. console.log('【websocket】收到管理员正在直播', data);
  307. });
  308. // 当前所有在线用户
  309. instance.socketIo.on(WsMsgTypeEnum.liveUser, () => {
  310. console.log('【websocket】当前所有在线用户');
  311. if (!instance) return;
  312. });
  313. // 收到offer
  314. instance.socketIo.on(WsMsgTypeEnum.offer, async (data: IOffer) => {
  315. console.warn('【websocket】收到offer', data);
  316. if (!instance) return;
  317. if (data.data.receiver === getSocketId()) {
  318. console.log('收到offer,这个offer是发给我的');
  319. const rtc = startNewWebRtc(data.data.sender);
  320. await rtc.setRemoteDescription(data.data.sdp);
  321. const sdp = await rtc.createAnswer();
  322. await rtc.setLocalDescription(sdp);
  323. websocketInstant.value?.send({
  324. msgType: WsMsgTypeEnum.answer,
  325. data: { sdp, sender: getSocketId(), receiver: data.data.sender },
  326. });
  327. } else {
  328. console.log('收到offer,但是这个offer不是发给我的');
  329. }
  330. });
  331. // 收到answer
  332. instance.socketIo.on(WsMsgTypeEnum.answer, async (data: IOffer) => {
  333. console.warn('【websocket】收到answer', data);
  334. if (isDone.value) return;
  335. if (!instance) return;
  336. const rtc = networkStore.getRtcMap(`${roomId.value}___${data.socketId}`);
  337. console.log(rtc, '收到answer收到answer');
  338. if (!rtc) return;
  339. rtc.rtcStatus.answer = true;
  340. rtc.update();
  341. if (data.data.receiver === getSocketId()) {
  342. console.log('收到answer,这个answer是发给我的');
  343. await rtc.setRemoteDescription(data.data.sdp);
  344. } else {
  345. console.log('收到answer,但这个answer不是发给我的');
  346. }
  347. });
  348. // 收到candidate
  349. instance.socketIo.on(WsMsgTypeEnum.candidate, (data: ICandidate) => {
  350. console.warn('【websocket】收到candidate', data);
  351. if (isDone.value) return;
  352. if (!instance) return;
  353. const rtc =
  354. networkStore.getRtcMap(`${roomId.value}___${data.socketId}`) ||
  355. networkStore.getRtcMap(roomId.value);
  356. if (!rtc) return;
  357. if (data.socketId !== getSocketId()) {
  358. console.log('不是我发的candidate');
  359. const candidate = new RTCIceCandidate({
  360. sdpMid: data.data.sdpMid,
  361. sdpMLineIndex: data.data.sdpMLineIndex,
  362. candidate: data.data.candidate,
  363. });
  364. rtc.peerConnection
  365. ?.addIceCandidate(candidate)
  366. .then(() => {
  367. console.log('candidate成功');
  368. // rtc.handleStream();
  369. })
  370. .catch((err) => {
  371. console.error('candidate失败', err);
  372. });
  373. } else {
  374. console.log('是我发的candidate');
  375. }
  376. });
  377. // 收到用户发送消息
  378. instance.socketIo.on(WsMsgTypeEnum.message, (data) => {
  379. console.log('【websocket】收到用户发送消息', data);
  380. if (!instance) return;
  381. damuList.value.push({
  382. socketId: data.socketId,
  383. msgType: 1,
  384. msg: data.data.msg,
  385. });
  386. });
  387. // 用户加入房间
  388. instance.socketIo.on(WsMsgTypeEnum.join, (data) => {
  389. console.log('【websocket】用户加入房间', data);
  390. if (!instance) return;
  391. });
  392. // 用户加入房间
  393. instance.socketIo.on(WsMsgTypeEnum.joined, (data) => {
  394. console.log('【websocket】用户加入房间完成', data);
  395. joined.value = true;
  396. liveUserList.value.push({
  397. avatar: 'red',
  398. socketId: `${getSocketId()}`,
  399. expr: 1,
  400. });
  401. batchSendOffer();
  402. });
  403. // 其他用户加入房间
  404. instance.socketIo.on(WsMsgTypeEnum.otherJoin, (data) => {
  405. console.log('【websocket】其他用户加入房间', data);
  406. liveUserList.value.push({
  407. avatar: 'red',
  408. socketId: data.socketId,
  409. expr: 1,
  410. });
  411. console.log('当前所有在线用户', JSON.stringify(liveUserList.value));
  412. console.log(isAdmin.value, joined.value);
  413. if (isAdmin.value && joined.value) {
  414. batchSendOffer();
  415. }
  416. });
  417. // 用户离开房间
  418. instance.socketIo.on(WsMsgTypeEnum.leave, (data) => {
  419. console.log('【websocket】用户离开房间', data);
  420. if (!instance) return;
  421. instance.socketIo?.emit(WsMsgTypeEnum.leave, {
  422. roomId: instance.roomId,
  423. });
  424. });
  425. // 用户离开房间完成
  426. instance.socketIo.on(WsMsgTypeEnum.leaved, (data) => {
  427. console.log('【websocket】用户离开房间完成', data);
  428. if (!instance) return;
  429. const res = liveUserList.value.filter(
  430. (item) => item.socketId !== data.socketId
  431. );
  432. console.log('当前所有在线用户', JSON.stringify(res));
  433. liveUserList.value = res;
  434. });
  435. }
  436. function roomNameIsOk() {
  437. if (!roomName.value.length) {
  438. alert('请输入房间名!');
  439. return false;
  440. }
  441. if (roomName.value.length < 3 || roomName.value.length > 10) {
  442. alert('房间名要求3-10个字符!');
  443. return false;
  444. }
  445. return true;
  446. }
  447. function confirmRoomName() {
  448. if (!roomNameIsOk()) return;
  449. if (!roomNameRef.value) return;
  450. roomNameRef.value.disabled = true;
  451. }
  452. function endLive() {
  453. console.log('endLive');
  454. closeRtc();
  455. currMediaTypeList.value = [];
  456. localStream.value = null;
  457. if (localVideoRef.value) {
  458. localVideoRef.value.srcObject = null;
  459. }
  460. if (!websocketInstant.value) return;
  461. websocketInstant.value.send({
  462. msgType: WsMsgTypeEnum.roomNoLive,
  463. });
  464. }
  465. function startLive() {
  466. websocketInstant.value = new WebSocketClass({
  467. roomId: roomId.value,
  468. url:
  469. process.env.NODE_ENV === 'development'
  470. ? 'ws://localhost:4300'
  471. : 'wss://live.hsslive.cn',
  472. isAdmin: isAdmin.value,
  473. });
  474. websocketInstant.value.update();
  475. setTimeout(() => {
  476. websocketInstant.value!.socketIo?.emit(WsMsgTypeEnum.message, {
  477. data: { debug: 1 },
  478. });
  479. }, 1000);
  480. // initReceive();
  481. // sendJoin();
  482. }
  483. const blobArr = ref<Blob[]>([]);
  484. /** 摄像头 */
  485. async function startMediaDevices() {
  486. if (!localStream.value) {
  487. // WARN navigator.mediaDevices在localhost和https才能用,http://192.168.1.103:8000局域网用不了
  488. const event = await navigator.mediaDevices.getUserMedia({
  489. video: true,
  490. audio: false,
  491. });
  492. console.log('getUserMedia成功', event);
  493. currMediaType.value = LiveTypeEnum.camera;
  494. currMediaTypeList.value.push(LiveTypeEnum.camera);
  495. if (!localVideoRef.value) return;
  496. localVideoRef.value.srcObject = event;
  497. const rec = new MediaRecorder(event, { mimeType: 'image/png' });
  498. // const rec = new MediaRecorder(event, { mimeType: 'video/webm' });
  499. console.log('rec', rec);
  500. rec.addEventListener('dataavailable', (e) => {
  501. console.log(new Date().toLocaleString(), 'dataavailable');
  502. if (e.data.size > 0) {
  503. blobArr.value.push(e.data);
  504. }
  505. console.log(e.data.stream());
  506. // document.querySelector<HTMLVideoElement>('#blobVideo')!.srcObject =
  507. // e.data.stream();
  508. const recordedBlob = new Blob(blobArr.value, { type: 'video/webm' });
  509. console.log(recordedBlob);
  510. // const url = window.URL.createObjectURL(recordedBlob);
  511. // const a = document.createElement('a');
  512. // a.style.display = 'none';
  513. // a.href = url;
  514. // a.download = 'test.webm';
  515. // document.body.appendChild(a);
  516. // a.click();
  517. // setTimeout(() => {
  518. // document.body.removeChild(a);
  519. // window.URL.revokeObjectURL(url);
  520. // }, 100);
  521. if (!websocketInstant.value) return;
  522. // websocketInstant.value.socketIo?.emit(WsMsgTypeEnum.sendBlob, e.data);
  523. websocketInstant.value.send({
  524. msgType: WsMsgTypeEnum.sendBlob,
  525. data: { blob: recordedBlob, timestamp: new Date().getTime() },
  526. });
  527. });
  528. rec.start(1000);
  529. localStream.value = event;
  530. }
  531. }
  532. /** 窗口 */
  533. async function startGetDisplayMedia() {
  534. if (!localStream.value) {
  535. // WARN navigator.mediaDevices.getDisplayMedia在localhost和https才能用,http://192.168.1.103:8000局域网用不了
  536. const event = await navigator.mediaDevices.getDisplayMedia({
  537. video: true,
  538. audio: true,
  539. });
  540. console.log('getDisplayMedia成功', event);
  541. currMediaType.value = LiveTypeEnum.screen;
  542. currMediaTypeList.value.push(LiveTypeEnum.screen);
  543. if (!localVideoRef.value) return;
  544. localVideoRef.value.srcObject = event;
  545. localStream.value = event;
  546. }
  547. }
  548. function addTrack() {
  549. if (!localStream.value) return;
  550. liveUserList.value.forEach((item) => {
  551. if (item.socketId !== getSocketId()) {
  552. localStream.value.getTracks().forEach((track) => {
  553. const rtc = networkStore.getRtcMap(
  554. `${roomId.value}___${item.socketId}`
  555. );
  556. rtc?.addTrack(track, localStream.value);
  557. });
  558. }
  559. });
  560. }
  561. async function sendOffer({
  562. sender,
  563. receiver,
  564. }: {
  565. sender: string;
  566. receiver: string;
  567. }) {
  568. if (isDone.value) return;
  569. if (!websocketInstant.value) return;
  570. const rtc = networkStore.getRtcMap(`${roomId.value}___${receiver}`);
  571. if (!rtc) return;
  572. const sdp = await rtc.createOffer();
  573. await rtc.setLocalDescription(sdp);
  574. websocketInstant.value.send({
  575. msgType: WsMsgTypeEnum.offer,
  576. data: { sdp, sender, receiver },
  577. });
  578. }
  579. function startNewWebRtc(receiver: string) {
  580. console.warn('开始new WebRTCClass', receiver);
  581. const rtc = new WebRTCClass({ roomId: `${roomId.value}___${receiver}` });
  582. rtc.rtcStatus.joined = true;
  583. rtc.update();
  584. return rtc;
  585. }
  586. function leave() {
  587. if (joinRef.value && leaveRef.value && roomIdRef.value) {
  588. roomIdRef.value.disabled = false;
  589. joinRef.value.disabled = false;
  590. leaveRef.value.disabled = true;
  591. }
  592. if (!websocketInstant.value) return;
  593. websocketInstant.value.socketIo?.emit(WsMsgTypeEnum.leave, {
  594. roomId: websocketInstant.value.roomId,
  595. });
  596. }
  597. </script>
  598. <style lang="scss" scoped>
  599. .push-wrap {
  600. margin: 20px auto 0;
  601. min-width: $large-width;
  602. height: 700px;
  603. text-align: center;
  604. .left {
  605. position: relative;
  606. display: inline-block;
  607. box-sizing: border-box;
  608. width: $large-left-width;
  609. height: 100%;
  610. border-radius: 10px;
  611. background-color: white;
  612. color: #9499a0;
  613. vertical-align: top;
  614. .video-wrap {
  615. position: relative;
  616. background-color: #18191c;
  617. #localVideo {
  618. max-width: 100%;
  619. max-height: 100%;
  620. }
  621. .add-wrap {
  622. position: absolute;
  623. top: 50%;
  624. left: 50%;
  625. display: flex;
  626. align-items: center;
  627. justify-content: space-around;
  628. width: 200px;
  629. height: 50px;
  630. background-color: #fff;
  631. transform: translate(-50%, -50%);
  632. .item {
  633. width: 60px;
  634. height: 30px;
  635. border-radius: 4px;
  636. background-color: rebeccapurple;
  637. color: white;
  638. font-size: 14px;
  639. line-height: 30px;
  640. cursor: pointer;
  641. }
  642. }
  643. }
  644. .control {
  645. position: absolute;
  646. right: 0;
  647. bottom: 0;
  648. left: 0;
  649. display: flex;
  650. justify-content: space-between;
  651. padding: 20px;
  652. background-color: papayawhip;
  653. .info {
  654. display: flex;
  655. align-items: center;
  656. .avatar {
  657. margin-right: 20px;
  658. width: 64px;
  659. height: 64px;
  660. border-radius: 50%;
  661. background-color: yellow;
  662. }
  663. .detail {
  664. display: flex;
  665. flex-direction: column;
  666. text-align: initial;
  667. .top {
  668. margin-bottom: 10px;
  669. color: #18191c;
  670. .btn {
  671. margin-left: 10px;
  672. }
  673. }
  674. .bottom {
  675. font-size: 14px;
  676. }
  677. }
  678. }
  679. .other {
  680. display: flex;
  681. flex-direction: column;
  682. justify-content: center;
  683. font-size: 12px;
  684. .top {
  685. display: flex;
  686. align-items: center;
  687. .item {
  688. display: flex;
  689. align-items: center;
  690. margin-right: 20px;
  691. .ico {
  692. display: inline-block;
  693. margin-right: 4px;
  694. width: 10px;
  695. height: 10px;
  696. border-radius: 50%;
  697. background-color: skyblue;
  698. }
  699. }
  700. }
  701. .bottom {
  702. margin-top: 10px;
  703. }
  704. }
  705. }
  706. }
  707. .right {
  708. position: relative;
  709. display: inline-block;
  710. box-sizing: border-box;
  711. margin-left: 10px;
  712. width: 240px;
  713. height: 100%;
  714. border-radius: 10px;
  715. background-color: white;
  716. color: #9499a0;
  717. .resource-card {
  718. margin-bottom: 5%;
  719. margin-bottom: 10px;
  720. width: 100%;
  721. height: 290px;
  722. border-radius: 4px;
  723. background-color: papayawhip;
  724. .title {
  725. padding: 10px;
  726. text-align: initial;
  727. }
  728. .item {
  729. display: flex;
  730. align-items: center;
  731. justify-content: space-between;
  732. margin-bottom: 10px;
  733. font-size: 12px;
  734. }
  735. }
  736. .danmu-card {
  737. box-sizing: border-box;
  738. padding: 10px;
  739. width: 100%;
  740. height: 400px;
  741. border-radius: 4px;
  742. background-color: papayawhip;
  743. text-align: initial;
  744. .title {
  745. margin-bottom: 10px;
  746. }
  747. .list {
  748. margin-bottom: 10px;
  749. height: 300px;
  750. .item {
  751. margin-bottom: 10px;
  752. font-size: 12px;
  753. .name {
  754. color: #9499a0;
  755. }
  756. .msg {
  757. color: #61666d;
  758. }
  759. }
  760. }
  761. .send-msg {
  762. display: flex;
  763. align-items: center;
  764. box-sizing: border-box;
  765. .ipt {
  766. display: block;
  767. box-sizing: border-box;
  768. margin: 0 auto;
  769. margin-right: 10px;
  770. padding: 10px;
  771. width: 80%;
  772. height: 30px;
  773. outline: none;
  774. border: 1px solid hsla(0, 0%, 60%, 0.2);
  775. border-radius: 4px;
  776. background-color: #f1f2f3;
  777. font-size: 14px;
  778. }
  779. .btn {
  780. box-sizing: border-box;
  781. width: 80px;
  782. height: 30px;
  783. border-radius: 4px;
  784. background-color: #23ade5;
  785. color: white;
  786. text-align: center;
  787. font-size: 12px;
  788. line-height: 30px;
  789. cursor: pointer;
  790. }
  791. }
  792. }
  793. }
  794. }
  795. // 屏幕宽度小于$large-width的时候
  796. @media screen and (max-width: $large-width) {
  797. .push-wrap {
  798. .left {
  799. width: $medium-left-width;
  800. }
  801. .right {
  802. .list {
  803. .item {
  804. }
  805. }
  806. }
  807. }
  808. }
  809. </style>