PlayView.vue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518
  1. <template>
  2. <div class="bg-neutral-900 w-full h-full relative">
  3. <video
  4. v-show="playUrl"
  5. ref="video"
  6. playsinline
  7. :src="playUrl"
  8. :poster="series?.cover"
  9. :controls="false"
  10. class="object-contain w-full h-full"
  11. @play="onVideoDidPlay"
  12. @pause="onVideoDidPause"
  13. @click="exitImmersive"
  14. @ended="emit('ended')"
  15. ></video>
  16. <img
  17. v-show="!playUrl && series"
  18. :src="series?.cover"
  19. class="object-contain w-full h-full"
  20. />
  21. <Transition name="fade">
  22. <div
  23. v-show="!hideControls"
  24. class="mask-info absolute w-full h-full top-0 left-0"
  25. @click="enterImmersive"
  26. >
  27. <IonSpinner
  28. v-if="!series || !episode"
  29. name="crescent"
  30. class="absolute left-0 top-0 right-0 bottom-0 m-auto text-lg"
  31. />
  32. <template v-if="!loading">
  33. <IonIcon
  34. v-if="showPlayBtn"
  35. :icon="playCircle"
  36. class="absolute left-0 top-0 right-0 bottom-0 m-auto text-7xl opacity-60"
  37. @click.stop="onPlay"
  38. />
  39. <IonIcon
  40. v-else
  41. :icon="pauseCircle"
  42. class="absolute left-0 top-0 right-0 bottom-0 m-auto text-7xl opacity-60"
  43. @click.stop="playing = false"
  44. />
  45. </template>
  46. <div class="tool-bar flex">
  47. <div
  48. class="px-2 flex items-center"
  49. @click.stop="router.back()"
  50. >
  51. <IonIcon
  52. :icon="chevronBack"
  53. class="text-2xl opacity-80 h-10"
  54. />
  55. </div>
  56. <div class="flex-1"></div>
  57. <div class="px-4 flex items-center">
  58. <IonIcon
  59. :icon="ellipsisHorizontal"
  60. class="text-2xl opacity-80 h-10"
  61. />
  62. </div>
  63. </div>
  64. <div
  65. class="title-info absolute bottom-0 left-0 right-0 drop-shadow px-4"
  66. v-show="series"
  67. >
  68. <div class="pr-20">
  69. <div class="text-base line-clamp-1">
  70. {{ series?.title }}
  71. </div>
  72. <div class="text-sm text-opacity-80 text-white">
  73. Episode {{ episode?.episodeNum }}
  74. </div>
  75. </div>
  76. <div ref="dragTarget" class="py-3">
  77. <div
  78. class="progress-bar h-[2px] bg-white bg-opacity-20 rounded"
  79. ref="progressBar"
  80. >
  81. <div
  82. v-if="loading"
  83. class="loading bg-white h-[2px] rounded bg-opacity-80"
  84. ></div>
  85. <div
  86. v-else
  87. class="progress bg-white h-[2px] rounded bg-opacity-80"
  88. :style="{ width: progress + '%' }"
  89. ></div>
  90. </div>
  91. </div>
  92. <div
  93. class="dive-into h-[44px] flex items-center text-xs bg-opacity-20 bg-black px-4 rounded"
  94. @click.stop="showEpisodesModal = true"
  95. >
  96. <IonIcon :icon="filter" class="text-base" />
  97. <div class="flex-1 ml-2 text-white text-opacity-80">
  98. {{ series?.totalEpisodes }} Episodes
  99. </div>
  100. <IonIcon :icon="chevronForward" class="text-base" />
  101. </div>
  102. </div>
  103. <div
  104. class="absolute right-4 bottom-32 flex flex-col"
  105. v-if="series"
  106. >
  107. <div
  108. class="btn flex flex-col items-center justify-center"
  109. @click.stop="emit('save')"
  110. >
  111. <IonIcon
  112. :class="{ 'text-prim': saved }"
  113. :icon="bookmark"
  114. class="text-3xl opacity-80"
  115. />
  116. <div class="text-[0.65rem]">
  117. {{ saved ? "Saved" : "Save" }}
  118. </div>
  119. </div>
  120. <div
  121. class="btn flex flex-col items-center justify-center mt-4"
  122. @click.stop="emit('share')"
  123. >
  124. <IonIcon
  125. :icon="arrowRedo"
  126. class="text-3xl opacity-80"
  127. />
  128. <div class="text-[0.65rem]">Share</div>
  129. </div>
  130. </div>
  131. </div>
  132. </Transition>
  133. <div
  134. class="absolute left-4 right-4 bottom-24 shadow"
  135. v-if="draggingData.dragging"
  136. >
  137. <div
  138. class="text-center text-xl font-bold mb-2 drop-shadow text-white text-opacity-50"
  139. >
  140. <span
  141. class="inline-block text-center w-16 text-white text-opacity-90"
  142. >
  143. {{
  144. formatDuration(
  145. (duration * draggingData.toProgress) / 100
  146. )
  147. }}
  148. </span>
  149. /
  150. <span class="inline-block text-center w-16">
  151. {{ formatDuration(duration) }}
  152. </span>
  153. </div>
  154. <div class="progress-bar-large h-2 bg-white bg-opacity-20 rounded">
  155. <div
  156. class="progress bg-white h-2 rounded bg-opacity-80"
  157. :style="{ width: draggingData.toProgress + '%' }"
  158. ></div>
  159. </div>
  160. </div>
  161. <ion-modal
  162. ref="modal"
  163. class="episodes-modal"
  164. :is-open="showEpisodesModal"
  165. :initial-breakpoint="1"
  166. :breakpoints="[0, 1]"
  167. @ionModalDidDismiss="showEpisodesModal = false"
  168. >
  169. <ion-content>
  170. <div class="flex flex-col h-full">
  171. <div class="text-lg mt-4 mx-4">{{ series?.title }}</div>
  172. <div class="text-sm mt-1 mx-4 text-white text-opacity-80">
  173. {{ series?.totalEpisodes }} Episodes
  174. </div>
  175. <div
  176. class="flex-1 flex flex-wrap overflow-auto mt-4 ml-4 mr-1"
  177. >
  178. <div
  179. class="pr-3 w-1/4 mb-3"
  180. v-for="(item, n) in episodes || []"
  181. :key="item.id"
  182. @click="chooseEpisode(n)"
  183. >
  184. <div
  185. class="episode-btn h-10 bg-neutral-800 rounded flex items-center justify-center relative"
  186. :class="{ active: item.id === episode?.id }"
  187. >
  188. <IonIcon
  189. v-if="parseInt(item.price) > 0"
  190. :icon="lockClosed"
  191. class="absolute top-1 right-1 text-sm"
  192. />
  193. {{ item.episodeNum }}
  194. </div>
  195. </div>
  196. </div>
  197. </div>
  198. </ion-content>
  199. </ion-modal>
  200. </div>
  201. </template>
  202. <script setup lang="ts">
  203. import {
  204. Ref,
  205. computed,
  206. nextTick,
  207. onBeforeUnmount,
  208. onMounted,
  209. reactive,
  210. ref,
  211. watch,
  212. } from "vue";
  213. import {
  214. IonIcon,
  215. IonSpinner,
  216. useIonRouter,
  217. IonModal,
  218. IonContent,
  219. IonLabel,
  220. IonButton,
  221. } from "@ionic/vue";
  222. import { useDrag } from "@vueuse/gesture";
  223. import { useElementBounding, useMediaControls } from "@vueuse/core";
  224. import {
  225. chevronForward,
  226. chevronBack,
  227. arrowRedo,
  228. bookmark,
  229. ellipsisHorizontal,
  230. playCircle,
  231. pauseCircle,
  232. filter,
  233. lockClosed,
  234. } from "ionicons/icons";
  235. import http from "@/plugins/http";
  236. import { watchThrottled } from "@vueuse/core";
  237. import emitter from "@/events";
  238. const props = defineProps({
  239. active: Boolean,
  240. series: Object,
  241. episode: Object,
  242. episodes: Array<any>,
  243. saved: Boolean,
  244. });
  245. const emit = defineEmits([
  246. "chooseEpisode",
  247. "save",
  248. "ended",
  249. "pay",
  250. "share",
  251. "progress",
  252. ]);
  253. const router = useIonRouter();
  254. const video: Ref<HTMLVideoElement | null> = ref(null);
  255. const { playing, currentTime, duration, volume, seeking } =
  256. useMediaControls(video);
  257. const showPauseBtn = ref(false);
  258. const progress = computed(() => {
  259. if (isNaN(duration.value) || duration.value === 0) return 0;
  260. return (currentTime.value / duration.value) * 100;
  261. });
  262. const loading = ref(false);
  263. const dragTarget = ref();
  264. const progressBar: Ref<HTMLElement | null> = ref(null);
  265. const draggingData = reactive({
  266. barWidth: 0,
  267. videoDuration: 0,
  268. progress: 0,
  269. toProgress: 0,
  270. dragging: false,
  271. });
  272. const playUrl = ref("");
  273. const needPay = ref(false);
  274. const showEpisodesModal = ref(false);
  275. const immersive = ref(false);
  276. const showPlayBtn = computed(() => {
  277. if (!props.series || loading.value) return false;
  278. if (!playUrl.value) return true;
  279. if (!playing.value) return true;
  280. return false;
  281. });
  282. const hideControls = computed(() => {
  283. return draggingData.dragging || (playing.value && immersive.value);
  284. });
  285. function formatDuration(duration: number) {
  286. if (isNaN(duration)) return "00:00";
  287. const minutes = Math.floor(duration / 60)
  288. .toString()
  289. .padStart(2, "0");
  290. const seconds = Math.floor(duration % 60)
  291. .toString()
  292. .padStart(2, "0");
  293. return `${minutes}:${seconds}`;
  294. }
  295. const dragHandler = ({ movement: [x, y], dragging }: any) => {
  296. if (dragging && !draggingData.dragging) {
  297. draggingData.barWidth = useElementBounding(progressBar).width.value;
  298. draggingData.videoDuration = duration.value;
  299. draggingData.progress = progress.value;
  300. draggingData.toProgress = progress.value;
  301. draggingData.dragging = true;
  302. }
  303. draggingData.toProgress = Math.max(
  304. 0,
  305. Math.min(
  306. 100,
  307. draggingData.progress +
  308. Math.floor((x / draggingData.barWidth) * 100)
  309. )
  310. );
  311. if (!dragging) {
  312. draggingData.dragging = false;
  313. currentTime.value = (duration.value * draggingData.toProgress) / 100;
  314. }
  315. };
  316. function getEpisode() {
  317. if (!props.episode) return;
  318. loading.value = true;
  319. try {
  320. http.get(`/episodes/${props.episode?.id}`).then((res) => {
  321. if (res.playUrl) {
  322. playUrl.value = res.playUrl;
  323. } else {
  324. needPay.value = true;
  325. }
  326. if (props.active) {
  327. nextTick(() => {
  328. onPlay();
  329. });
  330. }
  331. });
  332. } catch (error) {
  333. console.error(error);
  334. }
  335. loading.value = false;
  336. }
  337. useDrag(dragHandler, {
  338. domTarget: dragTarget,
  339. eventOptions: {
  340. capture: true,
  341. },
  342. });
  343. function onPaymentSuccess() {
  344. console.log("onPaymentSuccess");
  345. setTimeout(() => {
  346. getEpisode();
  347. }, 100);
  348. }
  349. onMounted(() => {
  350. getEpisode();
  351. emitter.on("payment-success", onPaymentSuccess);
  352. });
  353. onBeforeUnmount(() => {
  354. emitter.off("payment-success", onPaymentSuccess);
  355. clearTimeout(videoPlayTimeout.value);
  356. clearTimeout(immersiveTimeout.value);
  357. });
  358. watch(
  359. () => props.episode,
  360. () => {
  361. getEpisode();
  362. }
  363. );
  364. watch(
  365. () => props.active,
  366. (active) => {
  367. if (active) {
  368. onPlay();
  369. } else {
  370. playing.value = false;
  371. }
  372. }
  373. );
  374. async function onPlay() {
  375. if (needPay.value) {
  376. try {
  377. http.get(`/episodes/${props.episode?.id}`).then((res) => {
  378. if (res.playUrl) {
  379. playUrl.value = res.playUrl;
  380. } else {
  381. needPay.value = true;
  382. }
  383. });
  384. } catch (error) {
  385. console.error(error);
  386. }
  387. }
  388. if (playUrl.value) {
  389. playing.value = true;
  390. } else if (needPay.value) {
  391. emit("pay");
  392. }
  393. }
  394. function chooseEpisode(n: number) {
  395. showEpisodesModal.value = false;
  396. setTimeout(() => {
  397. emit("chooseEpisode", n);
  398. }, 100);
  399. }
  400. const videoPlayTimeout = ref<any>(0);
  401. const immersiveTimeout = ref<any>(0);
  402. function enterImmersive() {
  403. if (playing.value) {
  404. console.log("enterImmersive");
  405. immersive.value = true;
  406. }
  407. }
  408. function exitImmersive() {
  409. console.log("exitImmersive");
  410. immersive.value = false;
  411. clearTimeout(immersiveTimeout.value);
  412. immersiveTimeout.value = setTimeout(enterImmersive, 3000);
  413. }
  414. function onVideoDidPlay() {
  415. clearTimeout(videoPlayTimeout.value);
  416. videoPlayTimeout.value = setTimeout(enterImmersive, 3000);
  417. }
  418. function onVideoDidPause() {
  419. exitImmersive();
  420. }
  421. watchThrottled(
  422. currentTime,
  423. () => {
  424. emit("progress", {
  425. seriesId: props.series?.id,
  426. episodeId: props.episode?.id,
  427. duration: currentTime.value,
  428. });
  429. },
  430. { throttle: 2000 }
  431. );
  432. function seek(n: number) {
  433. console.log("seek", n);
  434. if (isNaN(duration.value)) return;
  435. currentTime.value = n;
  436. }
  437. defineExpose({ seek });
  438. </script>
  439. <style lang="less" scoped>
  440. .mask-info {
  441. .tool-bar {
  442. padding-top: var(--ion-safe-area-top);
  443. }
  444. .title-info {
  445. margin-bottom: 16px;
  446. margin-bottom: env(safe-area-inset-bottom, 16px);
  447. }
  448. }
  449. .progress-bar {
  450. .loading {
  451. margin: auto;
  452. animation: loading 0.5s linear infinite;
  453. }
  454. }
  455. @keyframes loading {
  456. 0% {
  457. width: 0;
  458. }
  459. 100% {
  460. width: 100%;
  461. }
  462. }
  463. .progress-bar-large {
  464. .progress {
  465. position: relative;
  466. &::after {
  467. content: "";
  468. position: absolute;
  469. width: 12px;
  470. height: 12px;
  471. right: -6px;
  472. top: -2px;
  473. background: white;
  474. z-index: 1;
  475. border-radius: 6px;
  476. }
  477. }
  478. }
  479. ion-modal {
  480. --height: 50vh;
  481. }
  482. .episode-btn.active {
  483. background: var(--ion-color-tertiary);
  484. color: white;
  485. }
  486. .pay-modal {
  487. --height: 460px;
  488. }
  489. .fade-enter-active,
  490. .fade-leave-active {
  491. transition: opacity 0.3s;
  492. }
  493. .fade-enter-from,
  494. .fade-leave-to {
  495. opacity: 0;
  496. }
  497. </style>