| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518 |
- <template>
- <div class="bg-neutral-900 w-full h-full relative">
- <video
- v-show="playUrl"
- ref="video"
- playsinline
- :src="playUrl"
- :poster="series?.cover"
- :controls="false"
- class="object-contain w-full h-full"
- @play="onVideoDidPlay"
- @pause="onVideoDidPause"
- @click="exitImmersive"
- @ended="emit('ended')"
- ></video>
- <img
- v-show="!playUrl && series"
- :src="series?.cover"
- class="object-contain w-full h-full"
- />
- <Transition name="fade">
- <div
- v-show="!hideControls"
- class="mask-info absolute w-full h-full top-0 left-0"
- @click="enterImmersive"
- >
- <IonSpinner
- v-if="!series || !episode"
- name="crescent"
- class="absolute left-0 top-0 right-0 bottom-0 m-auto text-lg"
- />
- <template v-if="!loading">
- <IonIcon
- v-if="showPlayBtn"
- :icon="playCircle"
- class="absolute left-0 top-0 right-0 bottom-0 m-auto text-7xl opacity-60"
- @click.stop="onPlay"
- />
- <IonIcon
- v-else
- :icon="pauseCircle"
- class="absolute left-0 top-0 right-0 bottom-0 m-auto text-7xl opacity-60"
- @click.stop="playing = false"
- />
- </template>
- <div class="tool-bar flex">
- <div
- class="px-2 flex items-center"
- @click.stop="router.back()"
- >
- <IonIcon
- :icon="chevronBack"
- class="text-2xl opacity-80 h-10"
- />
- </div>
- <div class="flex-1"></div>
- <div class="px-4 flex items-center">
- <IonIcon
- :icon="ellipsisHorizontal"
- class="text-2xl opacity-80 h-10"
- />
- </div>
- </div>
- <div
- class="title-info absolute bottom-0 left-0 right-0 drop-shadow px-4"
- v-show="series"
- >
- <div class="pr-20">
- <div class="text-base line-clamp-1">
- {{ series?.title }}
- </div>
- <div class="text-sm text-opacity-80 text-white">
- Episode {{ episode?.episodeNum }}
- </div>
- </div>
- <div ref="dragTarget" class="py-3">
- <div
- class="progress-bar h-[2px] bg-white bg-opacity-20 rounded"
- ref="progressBar"
- >
- <div
- v-if="loading"
- class="loading bg-white h-[2px] rounded bg-opacity-80"
- ></div>
- <div
- v-else
- class="progress bg-white h-[2px] rounded bg-opacity-80"
- :style="{ width: progress + '%' }"
- ></div>
- </div>
- </div>
- <div
- class="dive-into h-[44px] flex items-center text-xs bg-opacity-20 bg-black px-4 rounded"
- @click.stop="showEpisodesModal = true"
- >
- <IonIcon :icon="filter" class="text-base" />
- <div class="flex-1 ml-2 text-white text-opacity-80">
- {{ series?.totalEpisodes }} Episodes
- </div>
- <IonIcon :icon="chevronForward" class="text-base" />
- </div>
- </div>
- <div
- class="absolute right-4 bottom-32 flex flex-col"
- v-if="series"
- >
- <div
- class="btn flex flex-col items-center justify-center"
- @click.stop="emit('save')"
- >
- <IonIcon
- :class="{ 'text-prim': saved }"
- :icon="bookmark"
- class="text-3xl opacity-80"
- />
- <div class="text-[0.65rem]">
- {{ saved ? "Saved" : "Save" }}
- </div>
- </div>
- <div
- class="btn flex flex-col items-center justify-center mt-4"
- @click.stop="emit('share')"
- >
- <IonIcon
- :icon="arrowRedo"
- class="text-3xl opacity-80"
- />
- <div class="text-[0.65rem]">Share</div>
- </div>
- </div>
- </div>
- </Transition>
- <div
- class="absolute left-4 right-4 bottom-24 shadow"
- v-if="draggingData.dragging"
- >
- <div
- class="text-center text-xl font-bold mb-2 drop-shadow text-white text-opacity-50"
- >
- <span
- class="inline-block text-center w-16 text-white text-opacity-90"
- >
- {{
- formatDuration(
- (duration * draggingData.toProgress) / 100
- )
- }}
- </span>
- /
- <span class="inline-block text-center w-16">
- {{ formatDuration(duration) }}
- </span>
- </div>
- <div class="progress-bar-large h-2 bg-white bg-opacity-20 rounded">
- <div
- class="progress bg-white h-2 rounded bg-opacity-80"
- :style="{ width: draggingData.toProgress + '%' }"
- ></div>
- </div>
- </div>
- <ion-modal
- ref="modal"
- class="episodes-modal"
- :is-open="showEpisodesModal"
- :initial-breakpoint="1"
- :breakpoints="[0, 1]"
- @ionModalDidDismiss="showEpisodesModal = false"
- >
- <ion-content>
- <div class="flex flex-col h-full">
- <div class="text-lg mt-4 mx-4">{{ series?.title }}</div>
- <div class="text-sm mt-1 mx-4 text-white text-opacity-80">
- {{ series?.totalEpisodes }} Episodes
- </div>
- <div
- class="flex-1 flex flex-wrap overflow-auto mt-4 ml-4 mr-1"
- >
- <div
- class="pr-3 w-1/4 mb-3"
- v-for="(item, n) in episodes || []"
- :key="item.id"
- @click="chooseEpisode(n)"
- >
- <div
- class="episode-btn h-10 bg-neutral-800 rounded flex items-center justify-center relative"
- :class="{ active: item.id === episode?.id }"
- >
- <IonIcon
- v-if="parseInt(item.price) > 0"
- :icon="lockClosed"
- class="absolute top-1 right-1 text-sm"
- />
- {{ item.episodeNum }}
- </div>
- </div>
- </div>
- </div>
- </ion-content>
- </ion-modal>
- </div>
- </template>
- <script setup lang="ts">
- import {
- Ref,
- computed,
- nextTick,
- onBeforeUnmount,
- onMounted,
- reactive,
- ref,
- watch,
- } from "vue";
- import {
- IonIcon,
- IonSpinner,
- useIonRouter,
- IonModal,
- IonContent,
- IonLabel,
- IonButton,
- } from "@ionic/vue";
- import { useDrag } from "@vueuse/gesture";
- import { useElementBounding, useMediaControls } from "@vueuse/core";
- import {
- chevronForward,
- chevronBack,
- arrowRedo,
- bookmark,
- ellipsisHorizontal,
- playCircle,
- pauseCircle,
- filter,
- lockClosed,
- } from "ionicons/icons";
- import http from "@/plugins/http";
- import { watchThrottled } from "@vueuse/core";
- import emitter from "@/events";
- const props = defineProps({
- active: Boolean,
- series: Object,
- episode: Object,
- episodes: Array<any>,
- saved: Boolean,
- });
- const emit = defineEmits([
- "chooseEpisode",
- "save",
- "ended",
- "pay",
- "share",
- "progress",
- ]);
- const router = useIonRouter();
- const video: Ref<HTMLVideoElement | null> = ref(null);
- const { playing, currentTime, duration, volume, seeking } =
- useMediaControls(video);
- const showPauseBtn = ref(false);
- const progress = computed(() => {
- if (isNaN(duration.value) || duration.value === 0) return 0;
- return (currentTime.value / duration.value) * 100;
- });
- const loading = ref(false);
- const dragTarget = ref();
- const progressBar: Ref<HTMLElement | null> = ref(null);
- const draggingData = reactive({
- barWidth: 0,
- videoDuration: 0,
- progress: 0,
- toProgress: 0,
- dragging: false,
- });
- const playUrl = ref("");
- const needPay = ref(false);
- const showEpisodesModal = ref(false);
- const immersive = ref(false);
- const showPlayBtn = computed(() => {
- if (!props.series || loading.value) return false;
- if (!playUrl.value) return true;
- if (!playing.value) return true;
- return false;
- });
- const hideControls = computed(() => {
- return draggingData.dragging || (playing.value && immersive.value);
- });
- function formatDuration(duration: number) {
- if (isNaN(duration)) return "00:00";
- const minutes = Math.floor(duration / 60)
- .toString()
- .padStart(2, "0");
- const seconds = Math.floor(duration % 60)
- .toString()
- .padStart(2, "0");
- return `${minutes}:${seconds}`;
- }
- const dragHandler = ({ movement: [x, y], dragging }: any) => {
- if (dragging && !draggingData.dragging) {
- draggingData.barWidth = useElementBounding(progressBar).width.value;
- draggingData.videoDuration = duration.value;
- draggingData.progress = progress.value;
- draggingData.toProgress = progress.value;
- draggingData.dragging = true;
- }
- draggingData.toProgress = Math.max(
- 0,
- Math.min(
- 100,
- draggingData.progress +
- Math.floor((x / draggingData.barWidth) * 100)
- )
- );
- if (!dragging) {
- draggingData.dragging = false;
- currentTime.value = (duration.value * draggingData.toProgress) / 100;
- }
- };
- function getEpisode() {
- if (!props.episode) return;
- loading.value = true;
- try {
- http.get(`/episodes/${props.episode?.id}`).then((res) => {
- if (res.playUrl) {
- playUrl.value = res.playUrl;
- } else {
- needPay.value = true;
- }
- if (props.active) {
- nextTick(() => {
- onPlay();
- });
- }
- });
- } catch (error) {
- console.error(error);
- }
- loading.value = false;
- }
- useDrag(dragHandler, {
- domTarget: dragTarget,
- eventOptions: {
- capture: true,
- },
- });
- function onPaymentSuccess() {
- console.log("onPaymentSuccess");
- setTimeout(() => {
- getEpisode();
- }, 100);
- }
- onMounted(() => {
- getEpisode();
- emitter.on("payment-success", onPaymentSuccess);
- });
- onBeforeUnmount(() => {
- emitter.off("payment-success", onPaymentSuccess);
- clearTimeout(videoPlayTimeout.value);
- clearTimeout(immersiveTimeout.value);
- });
- watch(
- () => props.episode,
- () => {
- getEpisode();
- }
- );
- watch(
- () => props.active,
- (active) => {
- if (active) {
- onPlay();
- } else {
- playing.value = false;
- }
- }
- );
- async function onPlay() {
- if (needPay.value) {
- try {
- http.get(`/episodes/${props.episode?.id}`).then((res) => {
- if (res.playUrl) {
- playUrl.value = res.playUrl;
- } else {
- needPay.value = true;
- }
- });
- } catch (error) {
- console.error(error);
- }
- }
- if (playUrl.value) {
- playing.value = true;
- } else if (needPay.value) {
- emit("pay");
- }
- }
- function chooseEpisode(n: number) {
- showEpisodesModal.value = false;
- setTimeout(() => {
- emit("chooseEpisode", n);
- }, 100);
- }
- const videoPlayTimeout = ref<any>(0);
- const immersiveTimeout = ref<any>(0);
- function enterImmersive() {
- if (playing.value) {
- console.log("enterImmersive");
- immersive.value = true;
- }
- }
- function exitImmersive() {
- console.log("exitImmersive");
- immersive.value = false;
- clearTimeout(immersiveTimeout.value);
- immersiveTimeout.value = setTimeout(enterImmersive, 3000);
- }
- function onVideoDidPlay() {
- clearTimeout(videoPlayTimeout.value);
- videoPlayTimeout.value = setTimeout(enterImmersive, 3000);
- }
- function onVideoDidPause() {
- exitImmersive();
- }
- watchThrottled(
- currentTime,
- () => {
- emit("progress", {
- seriesId: props.series?.id,
- episodeId: props.episode?.id,
- duration: currentTime.value,
- });
- },
- { throttle: 2000 }
- );
- function seek(n: number) {
- console.log("seek", n);
- if (isNaN(duration.value)) return;
- currentTime.value = n;
- }
- defineExpose({ seek });
- </script>
- <style lang="less" scoped>
- .mask-info {
- .tool-bar {
- padding-top: var(--ion-safe-area-top);
- }
- .title-info {
- margin-bottom: 16px;
- margin-bottom: env(safe-area-inset-bottom, 16px);
- }
- }
- .progress-bar {
- .loading {
- margin: auto;
- animation: loading 0.5s linear infinite;
- }
- }
- @keyframes loading {
- 0% {
- width: 0;
- }
- 100% {
- width: 100%;
- }
- }
- .progress-bar-large {
- .progress {
- position: relative;
- &::after {
- content: "";
- position: absolute;
- width: 12px;
- height: 12px;
- right: -6px;
- top: -2px;
- background: white;
- z-index: 1;
- border-radius: 6px;
- }
- }
- }
- ion-modal {
- --height: 50vh;
- }
- .episode-btn.active {
- background: var(--ion-color-tertiary);
- color: white;
- }
- .pay-modal {
- --height: 460px;
- }
- .fade-enter-active,
- .fade-leave-active {
- transition: opacity 0.3s;
- }
- .fade-enter-from,
- .fade-leave-to {
- opacity: 0;
- }
- </style>
|