index.vue 22 KB

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