index.vue 19 KB

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