index.vue 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803
  1. <template>
  2. <div class="h5-room-wrap">
  3. <div class="head">
  4. <div class="left">
  5. <div
  6. class="avatar"
  7. v-lazy:background-image="anchorInfo?.avatar"
  8. ></div>
  9. <div class="username">
  10. {{ anchorInfo?.username }}
  11. </div>
  12. </div>
  13. <div class="right">
  14. <div
  15. class="btn"
  16. @click="router.push({ name: mobileRouterName.h5 })"
  17. >
  18. 返回主页
  19. </div>
  20. </div>
  21. </div>
  22. <div
  23. v-loading="videoLoading"
  24. class="video-wrap"
  25. :style="{
  26. height: videoWrapHeight + 'px',
  27. '--max-height': videoWrapHeight + 'px',
  28. }"
  29. >
  30. <div
  31. class="cover"
  32. v-lazy:background-image="appStore.liveRoomInfo?.cover_img"
  33. ></div>
  34. <div
  35. v-if="!roomLiving"
  36. class="no-live"
  37. >
  38. 主播还没开播~
  39. </div>
  40. <div
  41. class="remote-video"
  42. ref="remoteVideoRef"
  43. ></div>
  44. <div
  45. v-if="showPlayBtn && roomLiving && appStore.liveRoomInfo"
  46. class="tip-btn"
  47. @click="startPull"
  48. >
  49. 点击播放
  50. </div>
  51. <VideoControls
  52. v-if="roomLiving"
  53. :resolution="videoResolution"
  54. @refresh="handleRefresh"
  55. @full-screen="handleFullScreen"
  56. @picture-in-picture="hanldePictureInPicture"
  57. :control="{
  58. line: true,
  59. fullMode: true,
  60. pipMode: true,
  61. }"
  62. ></VideoControls>
  63. </div>
  64. <div class="n-tab-wrap">
  65. <n-tabs
  66. type="line"
  67. animated
  68. >
  69. <n-tab-pane
  70. name="danmu"
  71. tab="聊天"
  72. >
  73. <div class="danmu-list">
  74. <div
  75. ref="danmuListRef"
  76. class="list"
  77. :style="{ height: containerHeight + 'px' }"
  78. >
  79. <div
  80. v-for="(item, index) in damuList"
  81. :key="index"
  82. class="item"
  83. >
  84. <template v-if="item.msg_type === DanmuMsgTypeEnum.danmu">
  85. <span class="time">
  86. [{{ formatTimeHour(item.send_msg_time!) }}]
  87. </span>
  88. <span class="name">
  89. <span>{{ item.username }}</span>
  90. <span>
  91. [{{ item.user?.roles?.map((v) => v.role_name).join() }}]
  92. </span>
  93. </span>
  94. <span>:</span>
  95. <span
  96. class="msg"
  97. v-if="item.content_type === WsMessageContentTypeEnum.txt"
  98. >
  99. {{ item.content }}
  100. </span>
  101. <div
  102. class="msg img"
  103. v-else
  104. >
  105. <img
  106. v-lazy="item.content"
  107. alt=""
  108. @load="handleScrollTop"
  109. />
  110. </div>
  111. </template>
  112. <template
  113. v-else-if="item.msg_type === DanmuMsgTypeEnum.otherJoin"
  114. >
  115. <span class="name system">系统通知:</span>
  116. <span class="msg">{{ item.username }}进入直播!</span>
  117. </template>
  118. <template
  119. v-else-if="item.msg_type === DanmuMsgTypeEnum.userLeaved"
  120. >
  121. <span class="name system">系统通知:</span>
  122. <span class="msg">{{ item.username }}离开直播!</span>
  123. </template>
  124. <div
  125. class="reward"
  126. v-else-if="item.msg_type === DanmuMsgTypeEnum.reward"
  127. >
  128. <span> [{{ formatTimeHour(item.send_msg_time!) }}] </span>
  129. <span>
  130. <span>{{ item.username }}</span>
  131. <span>
  132. [{{ item.user?.roles?.map((v) => v.role_name).join() }}]
  133. </span>
  134. <span>:</span>
  135. </span>
  136. <span>打赏了{{ item.content }}!</span>
  137. </div>
  138. </div>
  139. </div>
  140. </div>
  141. </n-tab-pane>
  142. <n-tab-pane
  143. name="liveRoomInfo"
  144. tab="直播间信息"
  145. >
  146. <div
  147. class="liveRoomInfo-wrap"
  148. :style="{ height: containerHeight + 'px' }"
  149. >
  150. <div>名称:{{ appStore.liveRoomInfo?.name }}</div>
  151. <div>简介:{{ appStore.liveRoomInfo?.desc }}</div>
  152. <div>
  153. 分区:{{ appStore.liveRoomInfo?.areas?.[0]?.name || '暂无分区' }}
  154. </div>
  155. </div>
  156. </n-tab-pane>
  157. <n-tab-pane
  158. name="liveUser"
  159. :tab="`在线用户`"
  160. >
  161. <div
  162. class="liveUser-wrap"
  163. :style="{ height: containerHeight + 'px' }"
  164. >
  165. <div
  166. v-for="(item, index) in liveUserList"
  167. :key="index"
  168. class="item"
  169. >
  170. <div class="info">
  171. <div
  172. class="avatar"
  173. v-lazy:background-image="item.value.user_avatar"
  174. ></div>
  175. <div class="username">
  176. {{ item.value.user_username }}
  177. </div>
  178. </div>
  179. </div>
  180. </div>
  181. </n-tab-pane>
  182. </n-tabs>
  183. <div class="user-info">
  184. <template v-if="!userStore.userInfo">
  185. <div
  186. class="btn"
  187. @click="appStore.showLoginModal = true"
  188. >
  189. 登录
  190. </div>
  191. </template>
  192. <Dropdown v-else>
  193. <template #btn>
  194. <div class="info">
  195. <div
  196. class="btn"
  197. :style="{
  198. backgroundImage: `url(${userStore.userInfo.avatar})`,
  199. }"
  200. ></div>
  201. </div>
  202. </template>
  203. <template #list>
  204. <div class="list">
  205. <a class="item">
  206. <div class="txt">用户名:{{ userStore.userInfo.username }}</div>
  207. </a>
  208. <a class="item">
  209. <div
  210. class="txt"
  211. @click="appStore.showLoginModal = true"
  212. >
  213. 切换账号
  214. </div>
  215. </a>
  216. <a
  217. class="item"
  218. @click.prevent="handleLogout()"
  219. >
  220. <div class="txt">退出登录</div>
  221. </a>
  222. </div>
  223. </template>
  224. </Dropdown>
  225. </div>
  226. </div>
  227. <div
  228. ref="bottomRef"
  229. class="send-msg"
  230. >
  231. <div
  232. class="emoji-list"
  233. v-if="showEmoji"
  234. >
  235. <div
  236. class="item"
  237. v-for="(item, index) in emojiArray"
  238. :key="index"
  239. @click="handlePushStr(item)"
  240. >
  241. {{ item }}
  242. </div>
  243. </div>
  244. <div
  245. class="face"
  246. @click="showEmoji = !showEmoji"
  247. ></div>
  248. <input
  249. v-model="danmuStr"
  250. class="ipt"
  251. placeholder="发个弹幕吧~"
  252. @keydown="keydownDanmu"
  253. />
  254. <n-button
  255. type="info"
  256. size="small"
  257. :color="THEME_COLOR"
  258. @click="handleSendDanmu"
  259. >
  260. 发送
  261. </n-button>
  262. </div>
  263. <LoginModal v-if="appStore.showLoginModal"></LoginModal>
  264. </div>
  265. </template>
  266. <script lang="ts" setup>
  267. import { windowReload } from 'billd-utils';
  268. import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
  269. import { useRoute } from 'vue-router';
  270. import { fetchLiveRoomOnlineUser } from '@/api/live';
  271. import { fetchFindLiveRoom } from '@/api/liveRoom';
  272. import { fetchGetWsMessageList } from '@/api/wsMessage';
  273. import { THEME_COLOR } from '@/constant';
  274. import { emojiArray } from '@/emoji';
  275. import { useFullScreen, usePictureInPicture } from '@/hooks/use-play';
  276. import { usePull } from '@/hooks/use-pull';
  277. import { useWebsocket } from '@/hooks/use-websocket';
  278. import {
  279. DanmuMsgTypeEnum,
  280. WsMessageContentTypeEnum,
  281. WsMessageIsShowEnum,
  282. WsMessageIsVerifyEnum,
  283. } from '@/interface';
  284. import router, { mobileRouterName } from '@/router';
  285. import { useAppStore } from '@/store/app';
  286. import { useCacheStore } from '@/store/cache';
  287. import { useUserStore } from '@/store/user';
  288. import { IUser } from '@/types/IUser';
  289. import { formatTimeHour } from '@/utils';
  290. const route = useRoute();
  291. const cacheStore = useCacheStore();
  292. const appStore = useAppStore();
  293. const userStore = useUserStore();
  294. const bottomRef = ref<HTMLDivElement>();
  295. const danmuListRef = ref<HTMLDivElement>();
  296. const showEmoji = ref(false);
  297. const anchorInfo = ref<IUser>();
  298. const containerHeight = ref(0);
  299. const videoWrapHeight = ref(0);
  300. const remoteVideoRef = ref<HTMLDivElement>();
  301. const roomId = ref(route.params.roomId as string);
  302. const loopGetLiveUserTimer = ref();
  303. const {
  304. initRtcReceive,
  305. videoWrapRef,
  306. handlePlay,
  307. initPull,
  308. initWs,
  309. keydownDanmu,
  310. closeRtc,
  311. closeWs,
  312. liveUserList,
  313. showPlayBtn,
  314. videoLoading,
  315. damuList,
  316. danmuStr,
  317. roomLiving,
  318. videoResolution,
  319. } = usePull();
  320. const { sendDanmuTxt } = useWebsocket();
  321. onUnmounted(() => {
  322. closeWs();
  323. closeRtc();
  324. appStore.showLoginModal = false;
  325. clearInterval(loopGetLiveUserTimer.value);
  326. });
  327. onMounted(async () => {
  328. setTimeout(() => {
  329. scrollTo(0, 0);
  330. }, 100);
  331. if (!Number(roomId.value)) {
  332. return;
  333. }
  334. initPull({ roomId: roomId.value, autolay: true });
  335. showPlayBtn.value = true;
  336. videoWrapRef.value = remoteVideoRef.value;
  337. videoWrapHeight.value =
  338. document.documentElement.clientWidth / appStore.videoRatio;
  339. nextTick(() => {
  340. if (danmuListRef.value && bottomRef.value) {
  341. const res =
  342. bottomRef.value.getBoundingClientRect().top -
  343. danmuListRef.value.getBoundingClientRect().top;
  344. containerHeight.value = res;
  345. }
  346. });
  347. await handleFindLiveRoomInfo();
  348. handleSendGetLiveUser(Number(roomId.value));
  349. handleHistoryMsg();
  350. initWs({
  351. roomId: roomId.value,
  352. isBilibili: false,
  353. isAnchor: false,
  354. });
  355. initRtcReceive();
  356. });
  357. function handleSendDanmu() {
  358. sendDanmuTxt(danmuStr.value);
  359. danmuStr.value = '';
  360. }
  361. function handleLogout() {
  362. userStore.logout();
  363. setTimeout(() => {
  364. windowReload();
  365. }, 300);
  366. }
  367. async function handleHistoryMsg() {
  368. try {
  369. const res = await fetchGetWsMessageList({
  370. nowPage: 1,
  371. pageSize: appStore.liveRoomInfo?.history_msg_total || 10,
  372. orderName: 'created_at',
  373. orderBy: 'desc',
  374. live_room_id: Number(roomId.value),
  375. is_show: WsMessageIsShowEnum.yes,
  376. is_verify: WsMessageIsVerifyEnum.yes,
  377. });
  378. if (res.code === 200) {
  379. res.data.rows.forEach((v) => {
  380. damuList.value.unshift(v);
  381. });
  382. if (
  383. appStore.liveRoomInfo?.system_msg &&
  384. appStore.liveRoomInfo?.system_msg !== ''
  385. ) {
  386. damuList.value.push({
  387. send_msg_time: +new Date(),
  388. live_room_id: Number(roomId.value),
  389. id: -1,
  390. content: appStore.liveRoomInfo?.system_msg,
  391. content_type: WsMessageContentTypeEnum.txt,
  392. msg_type: DanmuMsgTypeEnum.system,
  393. });
  394. }
  395. }
  396. } catch (error) {
  397. console.log(error);
  398. }
  399. }
  400. function handleSendGetLiveUser(liveRoomId: number) {
  401. clearInterval(loopGetLiveUserTimer.value);
  402. async function main() {
  403. const res = await fetchLiveRoomOnlineUser({ live_room_id: liveRoomId });
  404. if (res.code === 200) {
  405. liveUserList.value = res.data;
  406. }
  407. }
  408. setTimeout(() => {
  409. main();
  410. }, 500);
  411. loopGetLiveUserTimer.value = setInterval(() => {
  412. main();
  413. }, 1000 * 3);
  414. }
  415. function handlePushStr(str) {
  416. danmuStr.value += str;
  417. showEmoji.value = false;
  418. }
  419. watch(
  420. () => damuList.value.length,
  421. () => {
  422. setTimeout(() => {
  423. handleScrollTop();
  424. }, 0);
  425. }
  426. );
  427. async function hanldePictureInPicture() {
  428. if (appStore.videoControlsValue.pipMode) {
  429. document.exitPictureInPicture();
  430. } else {
  431. const el = remoteVideoRef.value?.childNodes[0];
  432. if (el && remoteVideoRef.value) {
  433. await usePictureInPicture(el, remoteVideoRef.value);
  434. }
  435. }
  436. }
  437. function handleScrollTop() {
  438. if (danmuListRef.value) {
  439. danmuListRef.value.scrollTop = danmuListRef.value.scrollHeight + 10000;
  440. }
  441. }
  442. function handleRefresh() {
  443. if (appStore.liveRoomInfo) {
  444. handlePlay(appStore.liveRoomInfo);
  445. }
  446. }
  447. function handleFullScreen() {
  448. const el = remoteVideoRef.value?.childNodes[0];
  449. if (el) {
  450. useFullScreen(el);
  451. }
  452. }
  453. async function handleFindLiveRoomInfo() {
  454. try {
  455. const res = await fetchFindLiveRoom(Number(roomId.value));
  456. if (res.code === 200) {
  457. if (res.data) {
  458. appStore.liveRoomInfo = res.data;
  459. anchorInfo.value = res.data.user_live_room?.user;
  460. if (res.data.live) {
  461. roomLiving.value = true;
  462. } else {
  463. videoLoading.value = false;
  464. }
  465. }
  466. }
  467. } catch (error) {
  468. console.log(error);
  469. }
  470. }
  471. function startPull() {
  472. cacheStore.muted = false;
  473. showPlayBtn.value = false;
  474. handlePlay(appStore.liveRoomInfo!);
  475. }
  476. </script>
  477. <style lang="scss" scoped>
  478. .h5-room-wrap {
  479. height: 100vh;
  480. background-color: #0c1622;
  481. .head {
  482. display: flex;
  483. align-items: center;
  484. justify-content: space-between;
  485. box-sizing: border-box;
  486. padding: 0 20px;
  487. width: 100%;
  488. height: 70px;
  489. background-color: black;
  490. color: white;
  491. .left {
  492. display: flex;
  493. align-items: center;
  494. .avatar {
  495. width: 40px;
  496. height: 40px;
  497. border-radius: 50%;
  498. @extend %containBg;
  499. }
  500. .username {
  501. margin-left: 10px;
  502. }
  503. }
  504. }
  505. .video-wrap {
  506. position: relative;
  507. overflow: hidden;
  508. background-color: rgba($color: #000000, $alpha: 0.5);
  509. .cover {
  510. position: absolute;
  511. z-index: -1;
  512. background-position: center center;
  513. background-size: cover;
  514. filter: blur(10px);
  515. inset: 0;
  516. }
  517. .no-live {
  518. position: absolute;
  519. top: 50%;
  520. left: 50%;
  521. z-index: 20;
  522. color: white;
  523. font-size: 28px;
  524. transform: translate(-50%, -50%);
  525. }
  526. .remote-video {
  527. position: relative;
  528. width: 100%;
  529. height: 100%;
  530. :deep(video) {
  531. position: absolute;
  532. top: 50%;
  533. left: 50%;
  534. display: block;
  535. margin: 0 auto;
  536. max-width: 100vw;
  537. max-height: var(--max-height);
  538. transform: translate(-50%, -50%);
  539. }
  540. :deep(canvas) {
  541. position: absolute;
  542. top: 50%;
  543. left: 50%;
  544. display: block;
  545. margin: 0 auto;
  546. max-width: 100vw;
  547. max-height: var(--max-height);
  548. transform: translate(-50%, -50%);
  549. }
  550. }
  551. .tip-btn {
  552. position: absolute;
  553. top: 50%;
  554. left: 50%;
  555. z-index: 20;
  556. align-items: center;
  557. padding: 10px 20px;
  558. border: 2px solid rgba($color: papayawhip, $alpha: 0.5);
  559. border-radius: 6px;
  560. background-color: rgba(0, 0, 0, 0.3);
  561. color: $theme-color-gold;
  562. font-size: 14px;
  563. cursor: pointer;
  564. transform: translate(-50%, -50%);
  565. &:hover {
  566. background-color: rgba($color: papayawhip, $alpha: 0.5);
  567. color: white;
  568. }
  569. }
  570. }
  571. .n-tab-wrap {
  572. position: relative;
  573. padding-left: 10px;
  574. background: #0c1622;
  575. color: white;
  576. :deep(.n-tabs-tab) {
  577. --n-tab-text-color: white;
  578. }
  579. :deep(.n-tabs-nav-scroll-content) {
  580. border-bottom: 0 !important;
  581. }
  582. // :deep(.n-tabs-pane-wrapper) {
  583. // --n-pane-text-color: white;
  584. // }
  585. .user-info {
  586. position: absolute;
  587. top: 3px;
  588. right: 10px;
  589. .info {
  590. display: flex;
  591. align-items: center;
  592. }
  593. .list {
  594. width: 100px;
  595. .item {
  596. position: relative;
  597. display: flex;
  598. padding: 2px 15px;
  599. cursor: pointer;
  600. &:hover {
  601. color: $theme-color-gold;
  602. }
  603. }
  604. }
  605. .btn {
  606. display: flex;
  607. align-items: center;
  608. justify-content: center;
  609. box-sizing: border-box;
  610. margin-left: 10px;
  611. width: 35px;
  612. height: 35px;
  613. border-radius: 50%;
  614. background-color: $theme-color-papayawhip;
  615. color: black;
  616. font-size: 13px;
  617. cursor: pointer;
  618. @extend %containBg;
  619. }
  620. }
  621. }
  622. .danmu-list {
  623. box-sizing: border-box;
  624. padding: 0;
  625. background-color: #0c1622;
  626. text-align: initial;
  627. .title {
  628. padding: 15px 0;
  629. color: #fff;
  630. font-size: 16px;
  631. }
  632. .list {
  633. overflow-y: scroll;
  634. height: 100vh;
  635. @extend %hideScrollbar;
  636. }
  637. .item {
  638. box-sizing: border-box;
  639. margin-bottom: 4px;
  640. padding: 2px;
  641. white-space: normal;
  642. word-wrap: break-word;
  643. font-size: 13px;
  644. .reward {
  645. color: $theme-color-gold;
  646. font-weight: bold;
  647. }
  648. .name,
  649. .time {
  650. color: white;
  651. opacity: 0.8;
  652. cursor: pointer;
  653. &.system {
  654. color: red;
  655. }
  656. }
  657. .msg {
  658. margin-top: 4px;
  659. color: white;
  660. &.img {
  661. img {
  662. width: 80%;
  663. }
  664. }
  665. }
  666. }
  667. }
  668. .customerService-wrap,
  669. .liveRoomInfo-wrap {
  670. height: 100%;
  671. height: 300px;
  672. color: white;
  673. }
  674. .customerService-wrap {
  675. position: relative;
  676. text-align: center;
  677. .qrcode {
  678. display: block;
  679. margin: 0 auto 10px;
  680. width: 200px;
  681. height: 200px;
  682. }
  683. }
  684. .liveUser-wrap {
  685. overflow-y: scroll;
  686. box-sizing: border-box;
  687. height: 100px;
  688. @extend %customScrollbar;
  689. .item {
  690. display: flex;
  691. align-items: center;
  692. justify-content: space-between;
  693. margin-bottom: 10px;
  694. font-size: 12px;
  695. .info {
  696. display: flex;
  697. align-items: center;
  698. cursor: pointer;
  699. .avatar {
  700. margin-right: 5px;
  701. width: 25px;
  702. height: 25px;
  703. border-radius: 50%;
  704. @extend %containBg;
  705. }
  706. .username {
  707. color: white;
  708. }
  709. }
  710. }
  711. }
  712. .send-msg {
  713. position: fixed;
  714. bottom: 0;
  715. left: 0;
  716. display: flex;
  717. align-items: center;
  718. justify-content: space-evenly;
  719. box-sizing: border-box;
  720. padding: 0;
  721. width: 100%;
  722. height: 40px;
  723. background-color: white;
  724. .emoji-list {
  725. position: absolute;
  726. top: 0;
  727. right: 0;
  728. left: 0;
  729. overflow: scroll;
  730. box-sizing: border-box;
  731. padding-top: 5px;
  732. padding-left: 5px;
  733. height: 160px;
  734. background-color: #fff;
  735. transform: translateY(-100%);
  736. @extend %customScrollbar;
  737. .item {
  738. display: inline-flex;
  739. align-items: center;
  740. justify-content: center;
  741. box-sizing: border-box;
  742. width: 8vw;
  743. height: 8vw;
  744. border: 1px solid #f8f8f8;
  745. font-size: 20px;
  746. }
  747. }
  748. .face {
  749. width: 20px;
  750. height: 20px;
  751. @include setBackground('@/assets/img/msg-face.webp');
  752. }
  753. .ipt {
  754. display: block;
  755. box-sizing: border-box;
  756. padding: 10px;
  757. width: 80%;
  758. height: 30px;
  759. outline: none;
  760. border: 1px solid hsla(0, 0%, 60%, 0.2);
  761. border-radius: 4px;
  762. background-color: #f5f6f7;
  763. font-size: 14px;
  764. }
  765. }
  766. }
  767. </style>