index.vue 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690
  1. <template>
  2. <div class="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 || currMediaTypeList.length <= 0"
  23. class="add-wrap"
  24. >
  25. <div
  26. class="item"
  27. @click="startMediaDevices"
  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. class="btn"
  55. @click="confirmRoomName"
  56. >
  57. 确定
  58. </button>
  59. <!-- 房东的猫livehouse/音乐节 -->
  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">srs-webrtc直播</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 }}</span>
  90. </div>
  91. </div>
  92. </div>
  93. <div class="danmu-card">
  94. <div class="title">弹幕互动</div>
  95. <div class="list">
  96. <div
  97. v-for="(item, index) in damuList"
  98. :key="index"
  99. class="item"
  100. >
  101. <span class="name">{{ item.socketId }}:</span>
  102. <span class="msg">{{ item.msg }}</span>
  103. </div>
  104. </div>
  105. <div class="send-msg">
  106. <input
  107. v-model="danmuStr"
  108. class="ipt"
  109. />
  110. <div
  111. class="btn"
  112. @click="sendDanmu"
  113. >
  114. 发送
  115. </div>
  116. </div>
  117. </div>
  118. </div>
  119. </div>
  120. </template>
  121. <script lang="ts" setup>
  122. import { getRandomString } from 'billd-utils';
  123. import { onMounted, onUnmounted, ref } from 'vue';
  124. import { fetchRtcV1Publish } from '@/api/srs';
  125. import { IAdminIn, liveTypeEnum } from '@/interface';
  126. import { SRSWebRTCClass } from '@/network/srsWebRtc';
  127. import {
  128. WebSocketClass,
  129. WsConnectStatusEnum,
  130. WsMsgTypeEnum,
  131. } from '@/network/webSocket';
  132. import { useNetworkStore } from '@/store/network';
  133. const networkStore = useNetworkStore();
  134. const topRef = ref<HTMLDivElement>();
  135. const bottomRef = ref<HTMLDivElement>();
  136. const roomIdRef = ref<HTMLInputElement>();
  137. const joinRef = ref<HTMLButtonElement>();
  138. const leaveRef = ref<HTMLButtonElement>();
  139. const defaultRoomId = getRandomString(15);
  140. const roomId = ref<string>(defaultRoomId);
  141. const streamurl = ref(`webrtc://localhost/live/livestream/${roomId.value}`);
  142. const danmuStr = ref('');
  143. const roomName = ref('');
  144. const roomNameRef = ref<HTMLInputElement>();
  145. const websocketInstant = ref<WebSocketClass>();
  146. const localVideoRef = ref<HTMLVideoElement>();
  147. const localStream = ref();
  148. const allMediaTypeList = {
  149. [liveTypeEnum.camera]: {
  150. type: liveTypeEnum.camera,
  151. txt: '摄像头',
  152. },
  153. [liveTypeEnum.screen]: {
  154. type: liveTypeEnum.screen,
  155. txt: '窗口',
  156. },
  157. };
  158. const currMediaTypeList = ref<
  159. {
  160. type: liveTypeEnum;
  161. txt: string;
  162. }[]
  163. >([]);
  164. const currMediaType = ref<{
  165. type: liveTypeEnum;
  166. txt: string;
  167. }>();
  168. const damuList = ref<
  169. {
  170. socketId: string;
  171. msgType: number;
  172. msg: string;
  173. }[]
  174. >([]);
  175. const liveUserList = ref<
  176. {
  177. socketId: string;
  178. avatar: string;
  179. expr: number;
  180. }[]
  181. >([]);
  182. function closeWs() {
  183. const instance = networkStore.wsMap.get(roomId.value);
  184. if (!instance) return;
  185. instance.close();
  186. }
  187. function closeRtc() {
  188. networkStore.rtcMap.forEach((rtc) => {
  189. rtc.close();
  190. });
  191. }
  192. function sendDanmu() {
  193. if (!danmuStr.value.length) {
  194. alert('请输入弹幕内容!');
  195. }
  196. if (!websocketInstant.value) return;
  197. websocketInstant.value.send({
  198. msgType: WsMsgTypeEnum.message,
  199. data: { msg: danmuStr.value },
  200. });
  201. damuList.value.push({
  202. socketId: getSocketId(),
  203. msgType: 1,
  204. msg: danmuStr.value,
  205. });
  206. danmuStr.value = '';
  207. }
  208. onUnmounted(() => {
  209. closeWs();
  210. closeRtc();
  211. });
  212. onMounted(() => {
  213. if (topRef.value && bottomRef.value && localVideoRef.value) {
  214. const res =
  215. bottomRef.value.getBoundingClientRect().top -
  216. topRef.value.getBoundingClientRect().top;
  217. localVideoRef.value.style.height = `100px`;
  218. localVideoRef.value.style.height = `${res}px`;
  219. }
  220. localVideoRef.value?.addEventListener('loadstart', () => {
  221. console.warn('视频流-loadstart');
  222. const rtc = networkStore.getRtcMap(roomId.value);
  223. if (!rtc) return;
  224. rtc.rtcStatus.loadstart = true;
  225. rtc.update();
  226. });
  227. localVideoRef.value?.addEventListener('loadedmetadata', () => {
  228. console.warn('视频流-loadedmetadata');
  229. const rtc = networkStore.getRtcMap(roomId.value);
  230. if (!rtc) return;
  231. rtc.rtcStatus.loadedmetadata = true;
  232. rtc.update();
  233. });
  234. });
  235. function getSocketId() {
  236. return networkStore.wsMap.get(roomId.value!)?.socketIo?.id || '-1';
  237. }
  238. function initReceive() {
  239. const instance = websocketInstant.value;
  240. if (!instance?.socketIo) return;
  241. // websocket连接成功
  242. instance.socketIo.on(WsConnectStatusEnum.connect, () => {
  243. console.log('【websocket】websocket连接成功', instance.socketIo?.id);
  244. if (!instance) return;
  245. instance.status = WsConnectStatusEnum.connect;
  246. instance.update();
  247. sendJoin();
  248. });
  249. // websocket连接断开
  250. instance.socketIo.on(WsConnectStatusEnum.disconnect, () => {
  251. console.log('【websocket】websocket连接断开', instance);
  252. if (!instance) return;
  253. instance.status = WsConnectStatusEnum.disconnect;
  254. instance.update();
  255. });
  256. // 当前所有在线用户
  257. instance.socketIo.on(WsMsgTypeEnum.roomLiveing, (data: IAdminIn) => {
  258. console.log('【websocket】收到管理员正在直播', data);
  259. });
  260. // 当前所有在线用户
  261. instance.socketIo.on(WsMsgTypeEnum.liveUser, () => {
  262. console.log('【websocket】当前所有在线用户');
  263. if (!instance) return;
  264. });
  265. // 收到用户发送消息
  266. instance.socketIo.on(WsMsgTypeEnum.message, (data) => {
  267. console.log('【websocket】收到用户发送消息', data);
  268. if (!instance) return;
  269. damuList.value.push({
  270. socketId: data.socketId,
  271. msgType: 1,
  272. msg: data.data.msg,
  273. });
  274. });
  275. // 用户加入房间完成
  276. instance.socketIo.on(WsMsgTypeEnum.joined, (data) => {
  277. console.log('【websocket】用户加入房间完成', data);
  278. liveUserList.value.push({
  279. avatar: 'red',
  280. socketId: `${getSocketId()}`,
  281. expr: 1,
  282. });
  283. handleSrsPush();
  284. });
  285. // 其他用户加入房间
  286. instance.socketIo.on(WsMsgTypeEnum.otherJoin, (data) => {
  287. console.log('【websocket】其他用户加入房间', data);
  288. liveUserList.value.push({
  289. avatar: 'red',
  290. socketId: data.socketId,
  291. expr: 1,
  292. });
  293. console.log('当前所有在线用户', JSON.stringify(liveUserList.value));
  294. });
  295. // 用户离开房间
  296. instance.socketIo.on(WsMsgTypeEnum.leave, (data) => {
  297. console.log('【websocket】用户离开房间', data);
  298. if (!instance) return;
  299. instance.socketIo?.emit(WsMsgTypeEnum.leave, {
  300. roomId: instance.roomId,
  301. });
  302. });
  303. // 用户离开房间完成
  304. instance.socketIo.on(WsMsgTypeEnum.leaved, (data) => {
  305. console.log('【websocket】用户离开房间完成', data);
  306. if (!instance) return;
  307. const res = liveUserList.value.filter(
  308. (item) => item.socketId !== data.socketId
  309. );
  310. console.log('当前所有在线用户', JSON.stringify(res));
  311. liveUserList.value = res;
  312. });
  313. }
  314. function roomNameIsOk() {
  315. if (!roomName.value.length) {
  316. alert('请输入房间名!');
  317. return false;
  318. }
  319. if (roomName.value.length < 3 || roomName.value.length > 10) {
  320. alert('房间名要求3-10个字符!');
  321. return false;
  322. }
  323. return true;
  324. }
  325. function confirmRoomName() {
  326. if (!roomNameIsOk()) return;
  327. if (!roomNameRef.value) return;
  328. roomNameRef.value.disabled = true;
  329. }
  330. function endLive() {
  331. console.log('endLive');
  332. closeRtc();
  333. currMediaTypeList.value = [];
  334. localStream.value = null;
  335. if (localVideoRef.value) {
  336. localVideoRef.value.srcObject = null;
  337. }
  338. if (!websocketInstant.value) return;
  339. websocketInstant.value.close();
  340. }
  341. function sendJoin() {
  342. const instance = networkStore.wsMap.get(roomId.value);
  343. if (!instance) return;
  344. instance.send({
  345. msgType: WsMsgTypeEnum.join,
  346. data: {
  347. roomName: roomName.value,
  348. srs: {
  349. streamurl: streamurl.value,
  350. },
  351. },
  352. });
  353. }
  354. function startLive() {
  355. if (!roomNameIsOk()) return;
  356. if (currMediaTypeList.value.length <= 0) {
  357. alert('请选择一个素材!');
  358. return;
  359. }
  360. websocketInstant.value = new WebSocketClass({
  361. roomId: roomId.value,
  362. url:
  363. process.env.NODE_ENV === 'development'
  364. ? 'ws://localhost:4300'
  365. : 'wss://live.hsslive.cn',
  366. isAdmin: true,
  367. });
  368. websocketInstant.value.update();
  369. initReceive();
  370. }
  371. async function handleSrsPush() {
  372. const rtc = new SRSWebRTCClass({
  373. roomId: `${roomId.value}___${getSocketId()}`,
  374. });
  375. if (!rtc) return;
  376. localStream.value.getTracks().forEach((track) => {
  377. rtc.addTrack({ track, stream: localStream.value, direction: 'sendonly' });
  378. });
  379. try {
  380. const offer = await rtc.createOffer();
  381. if (!offer) return;
  382. await rtc.setLocalDescription(offer);
  383. const res: any = await fetchRtcV1Publish({
  384. api: 'http://localhost:1985/rtc/v1/publish/',
  385. clientip: null,
  386. sdp: offer.sdp!,
  387. streamurl: streamurl.value,
  388. tid: getRandomString(10),
  389. });
  390. await rtc.setRemoteDescription(
  391. new RTCSessionDescription({ type: 'answer', sdp: res.sdp })
  392. );
  393. } catch (error) {
  394. console.log(error);
  395. }
  396. }
  397. /** 摄像头 */
  398. async function startMediaDevices() {
  399. if (!localStream.value) {
  400. // WARN navigator.mediaDevices在localhost和https才能用,http://192.168.1.103:8000局域网用不了
  401. const event = await navigator.mediaDevices.getUserMedia({
  402. video: true,
  403. audio: true,
  404. });
  405. console.log('getUserMedia成功', event);
  406. currMediaType.value = allMediaTypeList[liveTypeEnum.camera];
  407. currMediaTypeList.value.push(allMediaTypeList[liveTypeEnum.camera]);
  408. if (!localVideoRef.value) return;
  409. localVideoRef.value.srcObject = event;
  410. localStream.value = event;
  411. }
  412. }
  413. /** 窗口 */
  414. async function startGetDisplayMedia() {
  415. if (!localStream.value) {
  416. // WARN navigator.mediaDevices.getDisplayMedia在localhost和https才能用,http://192.168.1.103:8000局域网用不了
  417. const event = await navigator.mediaDevices.getDisplayMedia({
  418. video: true,
  419. audio: true,
  420. });
  421. console.log('getDisplayMedia成功', event);
  422. currMediaType.value = allMediaTypeList[liveTypeEnum.screen];
  423. currMediaTypeList.value.push(allMediaTypeList[liveTypeEnum.screen]);
  424. if (!localVideoRef.value) return;
  425. localVideoRef.value.srcObject = event;
  426. localStream.value = event;
  427. }
  428. }
  429. function leave() {
  430. if (joinRef.value && leaveRef.value && roomIdRef.value) {
  431. roomIdRef.value.disabled = false;
  432. joinRef.value.disabled = false;
  433. leaveRef.value.disabled = true;
  434. }
  435. if (!websocketInstant.value) return;
  436. websocketInstant.value.socketIo?.emit(WsMsgTypeEnum.leave, {
  437. roomId: websocketInstant.value.roomId,
  438. });
  439. }
  440. </script>
  441. <style lang="scss" scoped>
  442. .push-wrap {
  443. margin: 20px auto 0;
  444. min-width: $large-width;
  445. height: 700px;
  446. text-align: center;
  447. .left {
  448. position: relative;
  449. display: inline-block;
  450. box-sizing: border-box;
  451. width: $large-left-width;
  452. height: 100%;
  453. border: 1px solid red;
  454. border-radius: 10px;
  455. background-color: white;
  456. color: #9499a0;
  457. vertical-align: top;
  458. .video-wrap {
  459. position: relative;
  460. background-color: #18191c;
  461. #localVideo {
  462. max-width: 100%;
  463. max-height: 100%;
  464. }
  465. .add-wrap {
  466. position: absolute;
  467. top: 50%;
  468. left: 50%;
  469. display: flex;
  470. align-items: center;
  471. justify-content: space-around;
  472. width: 200px;
  473. height: 50px;
  474. background-color: #fff;
  475. transform: translate(-50%, -50%);
  476. .item {
  477. width: 60px;
  478. height: 30px;
  479. border-radius: 4px;
  480. background-color: rebeccapurple;
  481. color: white;
  482. font-size: 14px;
  483. line-height: 30px;
  484. cursor: pointer;
  485. }
  486. }
  487. }
  488. .control {
  489. position: absolute;
  490. right: 0;
  491. bottom: 0;
  492. left: 0;
  493. display: flex;
  494. justify-content: space-between;
  495. padding: 20px;
  496. background-color: pink;
  497. .info {
  498. display: flex;
  499. align-items: center;
  500. .avatar {
  501. margin-right: 20px;
  502. width: 64px;
  503. height: 64px;
  504. border-radius: 50%;
  505. background-color: yellow;
  506. }
  507. .detail {
  508. display: flex;
  509. flex-direction: column;
  510. text-align: initial;
  511. .top {
  512. margin-bottom: 10px;
  513. color: #18191c;
  514. .btn {
  515. margin-left: 10px;
  516. }
  517. }
  518. .bottom {
  519. font-size: 14px;
  520. }
  521. }
  522. }
  523. .other {
  524. display: flex;
  525. flex-direction: column;
  526. justify-content: center;
  527. font-size: 12px;
  528. .top {
  529. display: flex;
  530. align-items: center;
  531. .item {
  532. display: flex;
  533. align-items: center;
  534. margin-right: 20px;
  535. .ico {
  536. display: inline-block;
  537. margin-right: 4px;
  538. width: 10px;
  539. height: 10px;
  540. border-radius: 50%;
  541. background-color: skyblue;
  542. }
  543. }
  544. }
  545. .bottom {
  546. margin-top: 10px;
  547. }
  548. }
  549. }
  550. }
  551. .right {
  552. position: relative;
  553. display: inline-block;
  554. box-sizing: border-box;
  555. margin-left: 10px;
  556. width: 240px;
  557. height: 100%;
  558. border-radius: 10px;
  559. background-color: white;
  560. color: #9499a0;
  561. .resource-card {
  562. margin-bottom: 5%;
  563. margin-bottom: 10px;
  564. width: 100%;
  565. height: 290px;
  566. border-radius: 4px;
  567. background-color: pink;
  568. .title {
  569. padding: 10px;
  570. text-align: initial;
  571. }
  572. .item {
  573. display: flex;
  574. align-items: center;
  575. justify-content: space-between;
  576. margin-bottom: 10px;
  577. font-size: 12px;
  578. }
  579. }
  580. .danmu-card {
  581. box-sizing: border-box;
  582. padding: 10px;
  583. width: 100%;
  584. height: 400px;
  585. border-radius: 4px;
  586. background-color: pink;
  587. text-align: initial;
  588. .title {
  589. margin-bottom: 10px;
  590. }
  591. .list {
  592. margin-bottom: 10px;
  593. height: 300px;
  594. .item {
  595. margin-bottom: 10px;
  596. font-size: 12px;
  597. .name {
  598. color: #9499a0;
  599. }
  600. .msg {
  601. color: #61666d;
  602. }
  603. }
  604. }
  605. .send-msg {
  606. display: flex;
  607. align-items: center;
  608. box-sizing: border-box;
  609. .ipt {
  610. display: block;
  611. box-sizing: border-box;
  612. margin: 0 auto;
  613. margin-right: 10px;
  614. padding: 10px;
  615. width: 80%;
  616. height: 30px;
  617. outline: none;
  618. border: 1px solid hsla(0, 0%, 60%, 0.2);
  619. border-radius: 4px;
  620. background-color: #f1f2f3;
  621. font-size: 14px;
  622. }
  623. .btn {
  624. box-sizing: border-box;
  625. width: 80px;
  626. height: 30px;
  627. border-radius: 4px;
  628. background-color: #23ade5;
  629. color: white;
  630. text-align: center;
  631. font-size: 12px;
  632. line-height: 30px;
  633. cursor: pointer;
  634. }
  635. }
  636. }
  637. }
  638. }
  639. // 屏幕宽度小于$large-width的时候
  640. @media screen and (max-width: $large-width) {
  641. .push-wrap {
  642. .left {
  643. width: $medium-left-width;
  644. }
  645. .right {
  646. .list {
  647. .item {
  648. }
  649. }
  650. }
  651. }
  652. }
  653. </style>