xiongzhu 1 年間 前
コミット
a52003f729

+ 4 - 0
package.json

@@ -20,7 +20,9 @@
     "@ionic/vue": "^7.0.0",
     "@ionic/vue-router": "^7.0.0",
     "@vueuse/core": "^10.9.0",
+    "@vueuse/gesture": "^2.0.0",
     "axios": "^1.6.7",
+    "eruda": "^3.0.1",
     "ionicons": "^7.0.0",
     "less": "^4.2.0",
     "qs": "^6.12.0",
@@ -41,6 +43,8 @@
     "eslint-plugin-vue": "^9.9.0",
     "jsdom": "^22.1.0",
     "postcss": "^8.4.35",
+    "prettier": "^3.2.5",
+    "prettier-plugin-tailwindcss": "^0.5.12",
     "tailwindcss": "^3.4.1",
     "terser": "^5.4.0",
     "typescript": "^5.1.6",

+ 6 - 0
src/assets/icon_save.svg

@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="77" height="77" viewBox="0 0 77 77" fill="none">
+<rect x="0" y="0" width="77" height="77"   fill="#CCCCCC" opacity="0">
+</rect>
+<path d="M58.6136 64.4587L42.4318 54.3407C40.3295 53.0304 37.6705 53.0304 35.5682 54.3407L19.3864 64.4587C17.0454 65.9286 14 64.2309 14 61.4621L14 19.805C14 15.498 17.4886 12 21.7841 12L56.2159 12C60.5114 12 64 15.498 64 19.805L64 61.4621C64 64.2309 60.9659 65.9286 58.6136 64.4587Z"   fill="#FFFFFF" opacity="0.8">
+</path>
+</svg>

+ 6 - 0
src/assets/icon_share.svg

@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="77.3720703125" height="77" viewBox="0 0 77.3720703125 77" fill="none">
+<rect x="0.3720703125" y="0" width="77" height="77"   fill="#CCCCCC" opacity="0">
+</rect>
+<path d="M41.0158 22.5659L41.0158 13.2567C41.8508 9.36292 45.0319 11.7351 45.0319 11.7351L67.1269 30.8583C71.9848 34.2403 67.4581 36.7854 67.4581 36.7854L45.7012 55.7426C41.3471 58.9586 41.0158 54.0481 41.0158 54.0481L41.0158 45.4168C18.9208 38.4799 9.88133 66.2413 9.88133 66.2413C9.04638 67.7628 8.54265 66.2413 8.54265 66.2413C-2.47955e-05 24.5992 41.0158 22.5659 41.0158 22.5659Z"   fill="#FFFFFF" opacity="0.8">
+</path>
+</svg>

+ 212 - 3
src/components/PlayView.vue

@@ -1,5 +1,214 @@
 <template>
-    <div class="bg-neutral-900 w-full h-full"></div>
+    <div class="bg-neutral-900 w-full h-full relative">
+        <video
+            ref="video"
+            playsinline
+            src="https://zm-shorts.oss-cn-hangzhou.aliyuncs.com/uploads/cltreekiv0003lr8i0p93a5h5.mp4"
+            poster="https://zm-shorts.oss-cn-hangzhou.aliyuncs.com/uploads/cltreg5k20005lr8i8obwfy11.png"
+            :controls="false"
+            class="object-contain w-full h-full"
+            @timeupdate="onTimeUpdate"
+        ></video>
+        <div
+            v-show="!draggingData.dragging"
+            class="mask-info absolute w-full h-full top-0 left-0"
+        >
+            <div
+                class="title-info absolute bottom-0 left-0 right-0 drop-shadow px-4"
+            >
+                <div class="pr-20">
+                    <div class="text-base line-clamp-1">
+                        Never gonna give you up
+                    </div>
+                    <div class="text-sm text-opacity-80 text-white">
+                        Episode 1
+                    </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="showEpisodes"
+                >
+                    <div class="flex-1">Dive into the story · 66 Episodes</div>
+                    <img
+                        src="@/assets/icon_into_small.svg"
+                        style="width: 10px; height: auto"
+                    />
+                </div>
+            </div>
+            <div class="absolute right-4 bottom-32 flex flex-col">
+                <div class="btn flex flex-col items-center justify-center">
+                    <img class="w-8 h-8" src="@/assets/icon_save.svg" />
+                    <div class="text-xs">Save</div>
+                </div>
+                <div class="btn flex flex-col items-center justify-center mt-4">
+                    <img class="w-8 h-8" src="@/assets/icon_share.svg" />
+                    <div class="text-xs">Share</div>
+                </div>
+            </div>
+        </div>
+        <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"
+                    >{{ toDuration }}</span
+                >/
+                <span class="inline-block text-center w-16">
+                    {{ formatDuration(draggingData.videoDuration) }}</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>
+    </div>
 </template>
-<script setup lang="ts"></script>
-<style lang="less" scoped></style>
+<script setup lang="ts">
+import { Ref, computed, onMounted, reactive, ref, watch } from "vue";
+import { useDrag } from "@vueuse/gesture";
+import { useElementBounding } from "@vueuse/core";
+
+const props = defineProps({
+    active: Boolean,
+});
+
+const video: Ref<HTMLVideoElement | null> = ref(null);
+const progress = ref(20);
+const loading = ref(false);
+const dragTarget = ref();
+const isDragging = ref(false);
+const progressBar: Ref<HTMLElement | null> = ref(null);
+
+const draggingData = reactive({
+    barWidth: 0,
+    videoDuration: 0,
+    progress: 0,
+    toProgress: 0,
+    dragging: false,
+});
+function formatDuration(duration: number) {
+    const minutes = Math.floor(duration / 60)
+        .toString()
+        .padStart(2, "0");
+    const seconds = Math.floor(duration % 60)
+        .toString()
+        .padStart(2, "0");
+    return `${minutes}:${seconds}`;
+}
+const toDuration = computed(() => {
+    return formatDuration(
+        Math.floor((draggingData.toProgress * draggingData.videoDuration) / 100)
+    );
+});
+const dragHandler = ({ movement: [x, y], dragging }: any) => {
+    console.log(x, dragging);
+    if (dragging && !draggingData.dragging) {
+        draggingData.barWidth = useElementBounding(progressBar).width.value;
+        draggingData.videoDuration = 100;
+        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;
+    }
+};
+useDrag(dragHandler, {
+    domTarget: dragTarget,
+});
+
+function onTimeUpdate() {
+    progress.value = (video.value!.currentTime! / video.value!.duration!) * 100;
+}
+
+onMounted(() => {
+    if (props.active) {
+        video.value?.play();
+    }
+});
+watch(
+    () => props.active,
+    (active) => {
+        if (active) {
+            video.value?.play();
+        } else {
+            video.value?.pause();
+        }
+    }
+);
+function showEpisodes() {
+    console.log("showEpisodes");
+}
+</script>
+<style lang="less" scoped>
+.mask-info {
+    .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;
+        }
+    }
+}
+</style>

+ 12 - 2
src/components/SeriesItem.vue

@@ -1,5 +1,5 @@
 <template>
-    <div class="series-item">
+    <div class="series-item" @click="detail">
         <IonThumbnail
             :style="{
                 width: `${itemSize.width}px`,
@@ -31,9 +31,10 @@
     </div>
 </template>
 <script setup lang="ts">
-import { IonThumbnail, IonSkeletonText } from "@ionic/vue";
+import { IonThumbnail, IonSkeletonText, useIonRouter } from "@ionic/vue";
 import { useWindowSize } from "@vueuse/core";
 import { computed } from "vue";
+
 const props = defineProps({
     item: Object,
 });
@@ -53,4 +54,13 @@ const itemSize = computed(() => {
         height: itemHeight,
     };
 });
+
+const router = useIonRouter();
+const detail = () => {
+    console.log("detail");
+    if (!props.item) return;
+    router.push({
+        path: `/series/${props.item.id}`,
+    });
+};
 </script>

+ 6 - 1
src/main.ts

@@ -3,8 +3,9 @@ import App from "./App.vue";
 import router from "./router";
 
 import { IonicVue } from "@ionic/vue";
+import eruda from "eruda";
 
-import './theme/tailwind.css'
+import "./theme/tailwind.css";
 
 /* Core CSS required for Ionic components to work properly */
 import "@ionic/vue/css/core.css";
@@ -36,3 +37,7 @@ const app = createApp(App)
 router.isReady().then(() => {
     app.mount("#app");
 });
+
+if (process.env.NODE_ENV === "development") {
+    eruda.init();
+}

+ 4 - 0
src/router/index.ts

@@ -33,6 +33,10 @@ const routes: Array<RouteRecordRaw> = [
             },
         ],
     },
+    {
+        path: "/series/:id",
+        component: () => import("@/views/SeriesView.vue"),
+    },
 ];
 
 const router = createRouter({

+ 29 - 15
src/views/ForYouTab.vue

@@ -1,20 +1,28 @@
 <template>
     <ion-page>
-        <ion-content :fullscreen="true">
-            <VueSwiper
-                :initialSlide="0"
-                :slides-per-view="1"
-                :space-between="0"
-                :loop="true"
-                :pagination="true"
-                :autoplay="false"
-                @swiper="onSwiper"
-                :modules="[Navigation, Pagination, Scrollbar, A11y]"
-            >
-                <swiper-slide class="swiper-slide" v-for="n in 10" :key="n">
-                    <PlayView />
-                </swiper-slide>
-            </VueSwiper>
+        <ion-content :fullscreen="true" ref="content">
+            <div ref="el" class="w-full h-full">
+                <VueSwiper
+                    :initialSlide="0"
+                    :slides-per-view="1"
+                    :space-between="0"
+                    :loop="true"
+                    :pagination="true"
+                    :autoplay="false"
+                    direction="vertical"
+                    @swiper="onSwiper"
+                    :modules="[Navigation, Pagination, Scrollbar, A11y]"
+                    class="w-full h-full"
+                >
+                    <swiper-slide v-slot="{ isActive }"
+                        v-for="n in 10"
+                        :key="n"
+                        class="w-full h-full"
+                    >
+                        <PlayView :active="isActive" />
+                    </swiper-slide>
+                </VueSwiper>
+            </div>
         </ion-content>
     </ion-page>
 </template>
@@ -27,10 +35,16 @@ import {
     IonTitle,
     IonContent,
 } from "@ionic/vue";
+import { Swiper as VueSwiper, SwiperSlide } from "swiper/vue";
 import { Swiper } from "swiper";
 import { Navigation, Pagination, Scrollbar, A11y } from "swiper/modules";
 import PlayView from "@/components/PlayView.vue";
 import { ref } from "vue";
+import { useElementBounding } from "@vueuse/core";
+
+const el = ref<HTMLElement | null>(null);
+const { x, y, top, right, bottom, left, width, height } =
+    useElementBounding(el);
 
 const swiper = ref<Swiper | null>(null);
 const onSwiper = (sw: Swiper) => {

+ 54 - 0
src/views/SeriesView.vue

@@ -0,0 +1,54 @@
+<template>
+    <ion-page>
+        <ion-content :fullscreen="true" ref="content">
+            <div ref="el" class="w-full h-full">
+                <VueSwiper
+                    :initialSlide="0"
+                    :slides-per-view="1"
+                    :space-between="0"
+                    :loop="true"
+                    :pagination="true"
+                    :autoplay="false"
+                    direction="vertical"
+                    @swiper="onSwiper"
+                    :modules="[Navigation, Pagination, Scrollbar, A11y]"
+                    class="w-full h-full"
+                >
+                    <swiper-slide
+                        v-slot="{ isActive }"
+                        v-for="n in 10"
+                        :key="n"
+                        class="w-full h-full"
+                    >
+                        <PlayView :active="isActive" />
+                    </swiper-slide>
+                </VueSwiper>
+            </div>
+        </ion-content>
+    </ion-page>
+</template>
+<script setup lang="ts">
+import {
+    IonPage,
+    IonHeader,
+    IonToolbar,
+    IonTitle,
+    IonContent,
+} from "@ionic/vue";
+import { Swiper as VueSwiper, SwiperSlide } from "swiper/vue";
+import { Swiper } from "swiper";
+import { Navigation, Pagination, Scrollbar, A11y } from "swiper/modules";
+import PlayView from "@/components/PlayView.vue";
+import { ref } from "vue";
+import { useElementBounding } from "@vueuse/core";
+
+const el = ref<HTMLElement | null>(null);
+const { x, y, top, right, bottom, left, width, height } =
+    useElementBounding(el);
+
+const swiper = ref<Swiper | null>(null);
+const onSwiper = (sw: Swiper) => {
+    window.swiper = swiper.value = sw;
+};
+</script>
+<style lang="less" scoped></style>

+ 37 - 2
yarn.lock

@@ -1883,6 +1883,16 @@
     "@vueuse/shared" "10.9.0"
     vue-demi ">=0.14.7"
 
+"@vueuse/gesture@^2.0.0":
+  version "2.0.0"
+  resolved "https://registry.npmmirror.com/@vueuse/gesture/-/gesture-2.0.0.tgz#1dcead0a2748f9b32f586f559d291c70a11a492c"
+  integrity sha512-+F0bhhd8j+gxHaXG4fJgfokrkFfWenQ10MtrWOJk68B5UaTwtJm4EpsZFiVdluA3jpKExG6H+HtroJpvO7Qx0A==
+  dependencies:
+    chokidar "^3.6.0"
+    consola "^3.2.3"
+    upath "^2.0.1"
+    vue-demi "*"
+
 "@vueuse/metadata@10.9.0":
   version "10.9.0"
   resolved "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-10.9.0.tgz#769a1a9db65daac15cf98084cbf7819ed3758620"
@@ -2310,7 +2320,7 @@ check-more-types@^2.24.0:
   resolved "https://registry.npmmirror.com/check-more-types/-/check-more-types-2.24.0.tgz#1420ffb10fd444dcfc79b43891bbfffd32a84600"
   integrity sha512-Pj779qHxV2tuapviy1bSZNEL1maXr13bPYpsvSDB68HlYcYuhlDrmGd63i0JHMCLKzc7rUSNIrpdJlhVlNwrxA==
 
-chokidar@^3.5.3:
+chokidar@^3.5.3, chokidar@^3.6.0:
   version "3.6.0"
   resolved "https://registry.npmmirror.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b"
   integrity sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==
@@ -2448,6 +2458,11 @@ config-chain@^1.1.13:
     ini "^1.3.4"
     proto-list "~1.2.1"
 
+consola@^3.2.3:
+  version "3.2.3"
+  resolved "https://registry.npmmirror.com/consola/-/consola-3.2.3.tgz#0741857aa88cfa0d6fd53f1cff0375136e98502f"
+  integrity sha512-I5qxpzLv+sJhTVEoLYNcTW+bThDCPsit0vLNKShZx6rLtpilNpmmeTPaeqJb9ZE9dV3DGaeby6Vuhrw38WjeyQ==
+
 convert-source-map@^2.0.0:
   version "2.0.0"
   resolved "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a"
@@ -2740,6 +2755,11 @@ errno@^0.1.1:
   dependencies:
     prr "~1.0.1"
 
+eruda@^3.0.1:
+  version "3.0.1"
+  resolved "https://registry.npmmirror.com/eruda/-/eruda-3.0.1.tgz#6c07ae2b3ced54151c6e9b21f9b8da11e6ec988a"
+  integrity sha512-6q1Xdwga4JTr1mKSW4mzuWSSbmXgqpm/8Wa1QGFGfCWRjC0bCQjbS4u06M1te1moucIS3hBLlbSTPWYH2W0qbQ==
+
 es-define-property@^1.0.0:
   version "1.0.0"
   resolved "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.0.tgz#c7faefbdff8b2696cf5f46921edfb77cc4ba3845"
@@ -4296,6 +4316,16 @@ prelude-ls@^1.2.1:
   resolved "https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396"
   integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==
 
+prettier-plugin-tailwindcss@^0.5.12:
+  version "0.5.12"
+  resolved "https://registry.npmmirror.com/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.5.12.tgz#655999849344548ecf4d9b47a051ed856f041c72"
+  integrity sha512-o74kiDBVE73oHW+pdkFSluHBL3cYEvru5YgEqNkBMFF7Cjv+w1vI565lTlfoJT4VLWDe0FMtZ7FkE/7a4pMXSQ==
+
+prettier@^3.2.5:
+  version "3.2.5"
+  resolved "https://registry.npmmirror.com/prettier/-/prettier-3.2.5.tgz#e52bc3090586e824964a8813b09aba6233b28368"
+  integrity sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==
+
 pretty-bytes@^5.6.0:
   version "5.6.0"
   resolved "https://registry.npmmirror.com/pretty-bytes/-/pretty-bytes-5.6.0.tgz#356256f643804773c82f64723fe78c92c62beaeb"
@@ -5076,6 +5106,11 @@ untildify@^4.0.0:
   resolved "https://registry.npmmirror.com/untildify/-/untildify-4.0.0.tgz#2bc947b953652487e4600949fb091e3ae8cd919b"
   integrity sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==
 
+upath@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.npmmirror.com/upath/-/upath-2.0.1.tgz#50c73dea68d6f6b990f51d279ce6081665d61a8b"
+  integrity sha512-1uEe95xksV1O0CYKXo8vQvN1JEbtJp7lb7C5U9HMsIp6IVwntkH/oNUzyVNQSd4S1sYk2FpSSW44FqMc8qee5w==
+
 update-browserslist-db@^1.0.13:
   version "1.0.13"
   resolved "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz#3c5e4f5c083661bd38ef64b6328c26ed6c8248c4"
@@ -5176,7 +5211,7 @@ vue-component-type-helpers@^2.0.0:
   resolved "https://registry.npmmirror.com/vue-component-type-helpers/-/vue-component-type-helpers-2.0.6.tgz#41737970f70841bc1b82391afa364da0f4ee2d76"
   integrity sha512-qdGXCtoBrwqk1BT6r2+1Wcvl583ZVkuSZ3or7Y1O2w5AvWtlvvxwjGhmz5DdPJS9xqRdDlgTJ/38ehWnEi0tFA==
 
-vue-demi@>=0.14.7:
+vue-demi@*, vue-demi@>=0.14.7:
   version "0.14.7"
   resolved "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.7.tgz#8317536b3ef74c5b09f268f7782e70194567d8f2"
   integrity sha512-EOG8KXDQNwkJILkx/gPcoL/7vH+hORoBaKgGe+6W7VFMvCYJfmF2dGbvgDroVnI8LU7/kTu8mbjRZGBU1z9NTA==