index.vue 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811
  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
  171. class="info"
  172. v-if="item.value?.userInfo"
  173. >
  174. <div
  175. class="avatar"
  176. v-lazy:background-image="item.value.userInfo.avatar"
  177. ></div>
  178. <div class="username">
  179. {{ item.value.userInfo.username }}
  180. </div>
  181. </div>
  182. <div
  183. class="info"
  184. v-else
  185. >
  186. <div class="avatar"></div>
  187. <div class="username">
  188. {{ item.value?.socketId }}
  189. </div>
  190. </div>
  191. </div>
  192. </div>
  193. </n-tab-pane>
  194. </n-tabs>
  195. <div class="user-info">
  196. <template v-if="!userStore.userInfo">
  197. <div
  198. class="btn"
  199. @click="appStore.showLoginModal = true"
  200. >
  201. 登录
  202. </div>
  203. </template>
  204. <Dropdown v-else>
  205. <template #btn>
  206. <div class="info">
  207. <div
  208. class="btn"
  209. :style="{
  210. backgroundImage: `url(${userStore.userInfo.avatar})`,
  211. }"
  212. ></div>
  213. </div>
  214. </template>
  215. <template #list>
  216. <div class="list">
  217. <a class="item">
  218. <div class="txt">用户名:{{ userStore.userInfo.username }}</div>
  219. </a>
  220. <a class="item">
  221. <div
  222. class="txt"
  223. @click="appStore.showLoginModal = true"
  224. >
  225. 切换账号
  226. </div>
  227. </a>
  228. <a
  229. class="item"
  230. @click.prevent="handleLogout()"
  231. >
  232. <div class="txt">退出登录</div>
  233. </a>
  234. </div>
  235. </template>
  236. </Dropdown>
  237. </div>
  238. </div>
  239. <div
  240. ref="bottomRef"
  241. class="send-msg"
  242. >
  243. <div
  244. class="emoji-list"
  245. v-if="showEmoji"
  246. >
  247. <div
  248. class="item"
  249. v-for="(item, index) in emojiArray"
  250. :key="index"
  251. @click="handlePushStr(item)"
  252. >
  253. {{ item }}
  254. </div>
  255. </div>
  256. <div
  257. class="face"
  258. @click="showEmoji = !showEmoji"
  259. ></div>
  260. <input
  261. v-model="danmuStr"
  262. class="ipt"
  263. placeholder="发个弹幕吧~"
  264. @keydown="keydownDanmu"
  265. />
  266. <n-button
  267. type="info"
  268. size="small"
  269. :color="THEME_COLOR"
  270. @click="handleSendDanmu"
  271. >
  272. 发送
  273. </n-button>
  274. </div>
  275. <LoginModal v-if="appStore.showLoginModal"></LoginModal>
  276. </div>
  277. </template>
  278. <script lang="ts" setup>
  279. import { windowReload } from 'billd-utils';
  280. import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
  281. import { useRoute } from 'vue-router';
  282. import { fetchLiveRoomOnlineUser } from '@/api/live';
  283. import { fetchFindLiveRoom } from '@/api/liveRoom';
  284. import { fetchGetWsMessageList } from '@/api/wsMessage';
  285. import { THEME_COLOR } from '@/constant';
  286. import { emojiArray } from '@/emoji';
  287. import { useFullScreen, usePictureInPicture } from '@/hooks/use-play';
  288. import { usePull } from '@/hooks/use-pull';
  289. import {
  290. DanmuMsgTypeEnum,
  291. WsMessageContentTypeEnum,
  292. WsMessageMsgIsShowEnum,
  293. WsMessageMsgIsVerifyEnum,
  294. } from '@/interface';
  295. import router, { mobileRouterName } from '@/router';
  296. import { useAppStore } from '@/store/app';
  297. import { useCacheStore } from '@/store/cache';
  298. import { useUserStore } from '@/store/user';
  299. import { LiveRoomTypeEnum } from '@/types/ILiveRoom';
  300. import { IUser } from '@/types/IUser';
  301. import { formatTimeHour } from '@/utils';
  302. const route = useRoute();
  303. const cacheStore = useCacheStore();
  304. const appStore = useAppStore();
  305. const userStore = useUserStore();
  306. const bottomRef = ref<HTMLDivElement>();
  307. const danmuListRef = ref<HTMLDivElement>();
  308. const showEmoji = ref(false);
  309. const anchorInfo = ref<IUser>();
  310. const containerHeight = ref(0);
  311. const videoWrapHeight = ref(0);
  312. const remoteVideoRef = ref<HTMLDivElement>();
  313. const roomId = ref(route.params.roomId as string);
  314. const loopGetLiveUserTimer = ref();
  315. const {
  316. videoWrapRef,
  317. handlePlay,
  318. initPull,
  319. keydownDanmu,
  320. sendDanmuTxt,
  321. closeRtc,
  322. closeWs,
  323. liveUserList,
  324. showPlayBtn,
  325. autoplayVal,
  326. videoLoading,
  327. damuList,
  328. danmuStr,
  329. roomLiving,
  330. videoResolution,
  331. initRoomId,
  332. } = usePull();
  333. onUnmounted(() => {
  334. closeWs();
  335. closeRtc();
  336. appStore.showLoginModal = false;
  337. clearInterval(loopGetLiveUserTimer.value);
  338. });
  339. onMounted(() => {
  340. initRoomId(roomId.value);
  341. showPlayBtn.value = true;
  342. videoWrapRef.value = remoteVideoRef.value;
  343. setTimeout(() => {
  344. scrollTo(0, 0);
  345. }, 100);
  346. videoWrapHeight.value =
  347. document.documentElement.clientWidth / appStore.videoRatio;
  348. nextTick(() => {
  349. if (danmuListRef.value && bottomRef.value) {
  350. const res =
  351. bottomRef.value.getBoundingClientRect().top -
  352. danmuListRef.value.getBoundingClientRect().top;
  353. containerHeight.value = res;
  354. }
  355. });
  356. getLiveRoomInfo();
  357. handleSendGetLiveUser(Number(roomId.value));
  358. handleHistoryMsg();
  359. });
  360. function handleSendDanmu() {
  361. sendDanmuTxt(danmuStr.value);
  362. }
  363. function handleLogout() {
  364. userStore.logout();
  365. setTimeout(() => {
  366. windowReload();
  367. }, 300);
  368. }
  369. async function handleHistoryMsg() {
  370. try {
  371. const res = await fetchGetWsMessageList({
  372. nowPage: 1,
  373. pageSize: appStore.liveRoomInfo?.history_msg_total || 10,
  374. orderName: 'created_at',
  375. orderBy: 'desc',
  376. live_room_id: Number(roomId.value),
  377. is_show: WsMessageMsgIsShowEnum.yes,
  378. is_verify: WsMessageMsgIsVerifyEnum.yes,
  379. });
  380. if (res.code === 200) {
  381. res.data.rows.forEach((v) => {
  382. damuList.value.unshift(v);
  383. });
  384. if (
  385. appStore.liveRoomInfo?.system_msg &&
  386. appStore.liveRoomInfo?.system_msg !== ''
  387. ) {
  388. damuList.value.push({
  389. send_msg_time: +new Date(),
  390. live_room_id: Number(roomId.value),
  391. id: -1,
  392. content: appStore.liveRoomInfo?.system_msg,
  393. content_type: WsMessageContentTypeEnum.txt,
  394. msg_type: DanmuMsgTypeEnum.system,
  395. });
  396. }
  397. }
  398. } catch (error) {
  399. console.log(error);
  400. }
  401. }
  402. function handleSendGetLiveUser(liveRoomId: number) {
  403. async function main() {
  404. const res = await fetchLiveRoomOnlineUser({ live_room_id: liveRoomId });
  405. if (res.code === 200) {
  406. liveUserList.value = res.data;
  407. }
  408. }
  409. main();
  410. loopGetLiveUserTimer.value = setInterval(() => {
  411. main();
  412. }, 1000 * 3);
  413. }
  414. function handlePushStr(str) {
  415. danmuStr.value += str;
  416. showEmoji.value = false;
  417. }
  418. watch(
  419. () => damuList.value.length,
  420. () => {
  421. setTimeout(() => {
  422. handleScrollTop();
  423. }, 0);
  424. }
  425. );
  426. async function hanldePictureInPicture() {
  427. if (appStore.videoControlsValue.pipMode) {
  428. document.exitPictureInPicture();
  429. } else {
  430. const el = remoteVideoRef.value?.childNodes[0];
  431. if (el && remoteVideoRef.value) {
  432. await usePictureInPicture(el, remoteVideoRef.value);
  433. }
  434. }
  435. }
  436. function handleScrollTop() {
  437. if (danmuListRef.value) {
  438. danmuListRef.value.scrollTop = danmuListRef.value.scrollHeight + 10000;
  439. }
  440. }
  441. function handleRefresh() {
  442. if (appStore.liveRoomInfo) {
  443. handlePlay(appStore.liveRoomInfo);
  444. }
  445. }
  446. function handleFullScreen() {
  447. const el = remoteVideoRef.value?.childNodes[0];
  448. if (el) {
  449. useFullScreen(el);
  450. }
  451. }
  452. async function getLiveRoomInfo() {
  453. try {
  454. videoLoading.value = true;
  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. if (res.data?.type === LiveRoomTypeEnum.wertc_live) {
  466. autoplayVal.value = true;
  467. showPlayBtn.value = false;
  468. } else {
  469. showPlayBtn.value = true;
  470. }
  471. initPull({ autolay: autoplayVal.value });
  472. }
  473. }
  474. } catch (error) {
  475. console.error(error);
  476. } finally {
  477. videoLoading.value = false;
  478. }
  479. }
  480. function startPull() {
  481. cacheStore.muted = false;
  482. showPlayBtn.value = false;
  483. handlePlay(appStore.liveRoomInfo!);
  484. }
  485. </script>
  486. <style lang="scss" scoped>
  487. .h5-room-wrap {
  488. height: 100vh;
  489. background-color: #0c1622;
  490. .head {
  491. display: flex;
  492. align-items: center;
  493. justify-content: space-between;
  494. box-sizing: border-box;
  495. padding: 0 20px;
  496. width: 100%;
  497. height: 70px;
  498. background-color: black;
  499. color: white;
  500. .left {
  501. display: flex;
  502. align-items: center;
  503. .avatar {
  504. width: 40px;
  505. height: 40px;
  506. border-radius: 50%;
  507. @extend %containBg;
  508. }
  509. .username {
  510. margin-left: 10px;
  511. }
  512. }
  513. }
  514. .video-wrap {
  515. position: relative;
  516. overflow: hidden;
  517. background-color: rgba($color: #000000, $alpha: 0.5);
  518. .cover {
  519. position: absolute;
  520. z-index: -1;
  521. background-position: center center;
  522. background-size: cover;
  523. filter: blur(10px);
  524. inset: 0;
  525. }
  526. .no-live {
  527. position: absolute;
  528. top: 50%;
  529. left: 50%;
  530. z-index: 20;
  531. color: white;
  532. font-size: 28px;
  533. transform: translate(-50%, -50%);
  534. }
  535. .remote-video {
  536. position: relative;
  537. width: 100%;
  538. height: 100%;
  539. :deep(video) {
  540. position: absolute;
  541. top: 50%;
  542. left: 50%;
  543. display: block;
  544. margin: 0 auto;
  545. max-width: 100vw;
  546. max-height: var(--max-height);
  547. transform: translate(-50%, -50%);
  548. }
  549. :deep(canvas) {
  550. position: absolute;
  551. top: 50%;
  552. left: 50%;
  553. display: block;
  554. margin: 0 auto;
  555. max-width: 100vw;
  556. max-height: var(--max-height);
  557. transform: translate(-50%, -50%);
  558. }
  559. }
  560. .tip-btn {
  561. position: absolute;
  562. top: 50%;
  563. left: 50%;
  564. z-index: 20;
  565. align-items: center;
  566. padding: 10px 20px;
  567. border: 2px solid rgba($color: papayawhip, $alpha: 0.5);
  568. border-radius: 6px;
  569. background-color: rgba(0, 0, 0, 0.3);
  570. color: $theme-color-gold;
  571. font-size: 14px;
  572. cursor: pointer;
  573. transform: translate(-50%, -50%);
  574. &:hover {
  575. background-color: rgba($color: papayawhip, $alpha: 0.5);
  576. color: white;
  577. }
  578. }
  579. }
  580. .n-tab-wrap {
  581. position: relative;
  582. padding-left: 10px;
  583. background: #0c1622;
  584. color: white;
  585. :deep(.n-tabs-tab) {
  586. --n-tab-text-color: white;
  587. }
  588. :deep(.n-tabs-nav-scroll-content) {
  589. border-bottom: 0 !important;
  590. }
  591. // :deep(.n-tabs-pane-wrapper) {
  592. // --n-pane-text-color: white;
  593. // }
  594. .user-info {
  595. position: absolute;
  596. top: 3px;
  597. right: 10px;
  598. .info {
  599. display: flex;
  600. align-items: center;
  601. }
  602. .list {
  603. width: 100px;
  604. .item {
  605. position: relative;
  606. display: flex;
  607. padding: 2px 15px;
  608. cursor: pointer;
  609. &:hover {
  610. color: $theme-color-gold;
  611. }
  612. }
  613. }
  614. .btn {
  615. display: flex;
  616. align-items: center;
  617. justify-content: center;
  618. box-sizing: border-box;
  619. margin-left: 10px;
  620. width: 35px;
  621. height: 35px;
  622. border-radius: 50%;
  623. background-color: $theme-color-papayawhip;
  624. color: black;
  625. font-size: 13px;
  626. cursor: pointer;
  627. @extend %containBg;
  628. }
  629. }
  630. }
  631. .danmu-list {
  632. box-sizing: border-box;
  633. padding: 0;
  634. background-color: #0c1622;
  635. text-align: initial;
  636. .title {
  637. padding: 15px 0;
  638. color: #fff;
  639. font-size: 16px;
  640. }
  641. .list {
  642. overflow-y: scroll;
  643. height: 100vh;
  644. @extend %hideScrollbar;
  645. }
  646. .item {
  647. box-sizing: border-box;
  648. margin-bottom: 4px;
  649. padding: 2px;
  650. white-space: normal;
  651. word-wrap: break-word;
  652. font-size: 13px;
  653. .reward {
  654. color: $theme-color-gold;
  655. font-weight: bold;
  656. }
  657. .name,
  658. .time {
  659. color: white;
  660. opacity: 0.8;
  661. cursor: pointer;
  662. &.system {
  663. color: red;
  664. }
  665. }
  666. .msg {
  667. margin-top: 4px;
  668. color: white;
  669. &.img {
  670. img {
  671. width: 80%;
  672. }
  673. }
  674. }
  675. }
  676. }
  677. .customerService-wrap,
  678. .liveRoomInfo-wrap {
  679. height: 100%;
  680. height: 300px;
  681. color: white;
  682. }
  683. .customerService-wrap {
  684. position: relative;
  685. text-align: center;
  686. .qrcode {
  687. display: block;
  688. margin: 0 auto 10px;
  689. width: 200px;
  690. height: 200px;
  691. }
  692. }
  693. .liveUser-wrap {
  694. overflow-y: scroll;
  695. box-sizing: border-box;
  696. height: 100px;
  697. @extend %customScrollbar;
  698. .item {
  699. display: flex;
  700. align-items: center;
  701. justify-content: space-between;
  702. margin-bottom: 10px;
  703. font-size: 12px;
  704. .info {
  705. display: flex;
  706. align-items: center;
  707. cursor: pointer;
  708. .avatar {
  709. margin-right: 5px;
  710. width: 25px;
  711. height: 25px;
  712. border-radius: 50%;
  713. @extend %containBg;
  714. }
  715. .username {
  716. color: white;
  717. }
  718. }
  719. }
  720. }
  721. .send-msg {
  722. position: fixed;
  723. bottom: 0;
  724. left: 0;
  725. display: flex;
  726. align-items: center;
  727. justify-content: space-evenly;
  728. box-sizing: border-box;
  729. padding: 0;
  730. width: 100%;
  731. height: 40px;
  732. background-color: white;
  733. .emoji-list {
  734. position: absolute;
  735. top: 0;
  736. right: 0;
  737. left: 0;
  738. overflow: scroll;
  739. box-sizing: border-box;
  740. padding-top: 5px;
  741. padding-left: 5px;
  742. height: 160px;
  743. background-color: #fff;
  744. transform: translateY(-100%);
  745. @extend %customScrollbar;
  746. .item {
  747. display: inline-flex;
  748. align-items: center;
  749. justify-content: center;
  750. box-sizing: border-box;
  751. width: 8vw;
  752. height: 8vw;
  753. border: 1px solid #f8f8f8;
  754. font-size: 20px;
  755. }
  756. }
  757. .face {
  758. width: 20px;
  759. height: 20px;
  760. @include setBackground('@/assets/img/msg-face.webp');
  761. }
  762. .ipt {
  763. display: block;
  764. box-sizing: border-box;
  765. padding: 10px;
  766. width: 80%;
  767. height: 30px;
  768. outline: none;
  769. border: 1px solid hsla(0, 0%, 60%, 0.2);
  770. border-radius: 4px;
  771. background-color: #f5f6f7;
  772. font-size: 14px;
  773. }
  774. }
  775. }
  776. </style>