Просмотр исходного кода

新增视频播放器功能,支持播放、时间更新和拖拽监听,优化试看逻辑,添加用户VIP等级判断,提升用户体验

wuyi 3 месяцев назад
Родитель
Сommit
503df0ee1f
6 измененных файлов с 252 добавлено и 11 удалено
  1. 26 0
      src/components/VideoProcessor.vue
  2. 1 1
      src/router/index.ts
  3. 7 0
      src/store/user.ts
  4. 21 0
      src/types/vip.ts
  5. 28 4
      src/views/Home.vue
  6. 169 6
      src/views/VideoPlayer.vue

+ 26 - 0
src/components/VideoProcessor.vue

@@ -28,6 +28,9 @@
       @loadeddata="onVideoLoadedData"
       @error="onVideoError"
       @canplay="onVideoCanPlay"
+      @play="onVideoPlay"
+      @timeupdate="onVideoTimeUpdate"
+      @seeking="onVideoSeeking"
     >
       您的浏览器不支持视频播放
     </video>
@@ -84,6 +87,9 @@ const emit = defineEmits<{
   videoLoaded: [url: string];
   error: [error: string];
   retry: [];
+  play: [];
+  timeupdate: [];
+  seeking: [];
 }>();
 
 // 响应式数据
@@ -376,6 +382,18 @@ const onVideoCanPlay = (): void => {
   }
 };
 
+const onVideoPlay = (): void => {
+  emit("play");
+};
+
+const onVideoTimeUpdate = (): void => {
+  emit("timeupdate");
+};
+
+const onVideoSeeking = (): void => {
+  emit("seeking");
+};
+
 const onVideoError = (): void => {
   error.value = "视频播放失败";
   emit("error", error.value);
@@ -413,9 +431,17 @@ onUnmounted(() => {
   }
 });
 
+// 停止HLS加载
+const stopHlsLoading = (): void => {
+  if (hlsInstance.value) {
+    hlsInstance.value.stopLoad();
+  }
+};
+
 // 暴露方法给父组件
 defineExpose({
   retry,
+  stopHlsLoading,
   processedCoverUrl: computed(() => processedCoverUrl.value),
   processedVideoUrl: computed(() => processedVideoUrl.value),
   loading: computed(() => loading.value),

+ 1 - 1
src/router/index.ts

@@ -37,7 +37,7 @@ const routes: Array<RouteRecordRaw> = [
     ],
   },
   {
-    path: "/video/:id",
+    path: "/video/:id?",
     component: MainLayout,
     children: [
       {

+ 7 - 0
src/store/user.ts

@@ -2,6 +2,7 @@ import { defineStore } from "pinia";
 import { ref } from "vue";
 import { login as apiLogin, profile, newGuest } from "@/services/api";
 import { useStorage } from "@vueuse/core";
+import { VipLevel } from "@/types/vip";
 
 export const useUserStore = defineStore("user", () => {
   const token = useStorage("token", "");
@@ -16,6 +17,11 @@ export const useUserStore = defineStore("user", () => {
     userInfo.value = info;
   };
 
+  // 获取用户VIP等级
+  const getVipLevel = (): VipLevel => {
+    return userInfo.value?.vipLevel || VipLevel.GUEST;
+  };
+
   const login = async (username: string, password: string) => {
     const response = await apiLogin(username, password);
     setToken(response.token);
@@ -53,6 +59,7 @@ export const useUserStore = defineStore("user", () => {
     userInfo,
     userManuallyLoggedOut,
     setUserInfo,
+    getVipLevel,
     login,
     logout,
     sync,

+ 21 - 0
src/types/vip.ts

@@ -0,0 +1,21 @@
+export enum VipLevel {
+  GUEST = "guest",
+  FREE = "free",
+  HOURLY = "hourly",
+  DAILY = "daily",
+  WEEKLY = "weekly",
+  MONTHLY = "monthly",
+  QUARTERLY = "quarterly",
+  YEARLY = "yearly",
+  LIFETIME = "lifetime",
+}
+
+// 检查用户是否有权限观看完整视频
+export const canWatchFullVideo = (vipLevel: VipLevel): boolean => {
+  return vipLevel !== VipLevel.GUEST && vipLevel !== VipLevel.FREE;
+};
+
+// 获取试看时长(秒)
+export const getTrialDuration = (): number => {
+  return 5 * 60; // 5分钟
+};

+ 28 - 4
src/views/Home.vue

@@ -455,10 +455,15 @@ import {
   searchVideoByKeyword,
 } from "@/services/api";
 import VideoProcessor from "@/components/VideoProcessor.vue";
+import { useUserStore } from "@/store/user";
+import { VipLevel } from "@/types/vip";
 
 // 路由
 const router = useRouter();
 
+// 用户状态
+const userStore = useUserStore();
+
 // 生成MAC地址作为设备标识
 const generateMacAddress = (): string => {
   const hex = "0123456789ABCDEF";
@@ -651,10 +656,29 @@ const clearSearch = () => {
 
 // 播放视频
 const playVideo = (video: any) => {
-  router.push({
-    name: "VideoPlayer",
-    params: { id: video.id },
-  });
+  const vipLevel = userStore.getVipLevel();
+
+  if (vipLevel === VipLevel.GUEST || vipLevel === VipLevel.FREE) {
+    // guest和free用户通过URL参数传递cover和m3u8,同时包含视频ID
+    router.push({
+      name: "VideoPlayer",
+      params: { id: video.id },
+      query: {
+        cover: video.cover,
+        m3u8: video.m3u8,
+        name: video.name,
+        duration: video.duration,
+        view: video.view,
+        like: video.like,
+      },
+    });
+  } else {
+    // 其他VIP用户正常调用详情接口
+    router.push({
+      name: "VideoPlayer",
+      params: { id: video.id },
+    });
+  }
 };
 
 // 根据标签加载视频

+ 169 - 6
src/views/VideoPlayer.vue

@@ -63,6 +63,7 @@
     <div class="relative rounded-2xl overflow-hidden bg-black">
       <div class="aspect-video video-container">
         <VideoProcessor
+          ref="videoProcessorRef"
           :cover-url="videoInfo.cover"
           :m3u8-url="videoInfo.m3u8"
           :alt="videoInfo.name"
@@ -75,7 +76,55 @@
           @video-loaded="onVideoLoaded"
           @error="onVideoProcessorError"
           @retry="onVideoProcessorRetry"
+          @play="onVideoPlay"
+          @timeupdate="onVideoTimeUpdate"
+          @seeking="onVideoSeeking"
         />
+
+        <!-- 试看提示 -->
+        <div
+          v-if="isTrialMode"
+          class="absolute top-4 right-4 text-white px-3 py-1.5 text-sm font-medium"
+        >
+          试看中
+        </div>
+
+        <!-- 试看结束遮罩 -->
+        <div
+          v-if="showPurchaseModal"
+          class="absolute inset-0 bg-black/80 flex items-center justify-center z-50"
+        >
+          <div
+            class="bg-white/10 backdrop-blur-sm rounded-2xl p-6 max-w-sm mx-4 text-center"
+          >
+            <div class="text-white text-lg font-semibold mb-4">
+              试看时间已结束
+            </div>
+            <div class="text-white/70 text-sm mb-6">
+              购买会员或单独购买本片继续观看
+            </div>
+            <div class="space-y-3">
+              <button
+                @click="purchaseMembership"
+                class="w-full bg-emerald-500 hover:bg-emerald-600 text-white py-3 px-4 rounded-lg font-medium transition"
+              >
+                购买会员
+              </button>
+              <button
+                @click="purchaseVideo"
+                class="w-full bg-white/10 hover:bg-white/20 text-white py-3 px-4 rounded-lg font-medium transition"
+              >
+                单独购买本片
+              </button>
+              <button
+                @click="showPurchaseModal = false"
+                class="w-full text-white/60 hover:text-white/80 py-2 text-sm transition"
+              >
+                取消
+              </button>
+            </div>
+          </div>
+        </div>
       </div>
     </div>
 
@@ -227,13 +276,19 @@ import { ref, onMounted, onUnmounted, computed, watch } from "vue";
 import { useRoute, useRouter } from "vue-router";
 import { searchVideoByTags, getVideoDetail } from "@/services/api";
 import VideoProcessor from "@/components/VideoProcessor.vue";
+import { useUserStore } from "@/store/user";
+import { VipLevel, canWatchFullVideo, getTrialDuration } from "@/types/vip";
 
 // 路由相关
 const route = useRoute();
 const router = useRouter();
 
+// 用户状态
+const userStore = useUserStore();
+
 // 视频播放器引用(现在由 VideoProcessor 组件管理)
 const videoPlayer = ref<HTMLVideoElement>();
+const videoProcessorRef = ref<any>();
 
 // 视频信息
 const videoInfo = ref<any>({
@@ -253,6 +308,8 @@ const relatedVideos = ref<any[]>([]);
 
 // 状态管理
 const showShareModal = ref(false);
+const showPurchaseModal = ref(false);
+const isTrialMode = ref(false);
 
 // 生成设备标识
 const generateMacAddress = (): string => {
@@ -268,6 +325,11 @@ const generateMacAddress = (): string => {
 
 const device = generateMacAddress();
 
+// 计算属性
+const currentVipLevel = computed(() => userStore.getVipLevel());
+const isGuestOrFree = computed(() => !canWatchFullVideo(currentVipLevel.value));
+const trialDuration = computed(() => getTrialDuration());
+
 // VideoProcessor 事件处理
 const onCoverLoaded = (url: string) => {
   // 封面加载完成
@@ -285,6 +347,42 @@ const onVideoProcessorRetry = () => {
   // VideoProcessor 重试
 };
 
+const onVideoPlay = () => {
+  // 如果是guest或free用户且还没有开始试看,则开始试看
+  if (isGuestOrFree.value && !isTrialMode.value) {
+    startTrial();
+  }
+};
+
+// 监听视频播放进度
+const onVideoTimeUpdate = () => {
+  if (isGuestOrFree.value && isTrialMode.value) {
+    const video = document.querySelector("video");
+    if (video && video.currentTime > trialDuration.value) {
+      // 超过试看时间,停止播放并显示购买弹窗
+      video.pause();
+      // 将视频时间重置到试看结束时间
+      video.currentTime = trialDuration.value;
+      // 停止HLS加载
+      if (videoProcessorRef.value) {
+        videoProcessorRef.value.stopHlsLoading();
+      }
+      showPurchaseModal.value = true;
+    }
+  }
+};
+
+// 监听视频时间变化(包括拖拽进度条)
+const onVideoSeeking = () => {
+  if (isGuestOrFree.value && isTrialMode.value) {
+    const video = document.querySelector("video");
+    if (video && video.currentTime > trialDuration.value) {
+      // 如果拖拽到超过试看时间,强制回到试看结束时间
+      video.currentTime = trialDuration.value;
+    }
+  }
+};
+
 // 格式化时长
 const formatDuration = (duration: string | number): string => {
   const seconds = parseInt(String(duration));
@@ -329,15 +427,78 @@ const toggleShare = async () => {
 
 // 播放视频
 const playVideo = (video: any) => {
-  router.push({
-    name: "VideoPlayer",
-    params: { id: video.id },
-  });
+  const vipLevel = userStore.getVipLevel();
+
+  if (vipLevel === VipLevel.GUEST || vipLevel === VipLevel.FREE) {
+    // guest和free用户通过URL参数传递cover和m3u8,同时包含视频ID
+    router.push({
+      name: "VideoPlayer",
+      params: { id: video.id },
+      query: {
+        cover: video.cover,
+        m3u8: video.m3u8,
+        name: video.name,
+        duration: video.duration,
+        view: video.view,
+        like: video.like,
+      },
+    });
+  } else {
+    // 其他VIP用户正常调用详情接口
+    router.push({
+      name: "VideoPlayer",
+      params: { id: video.id },
+    });
+  }
+};
+
+// 开始试看
+const startTrial = () => {
+  isTrialMode.value = true;
+};
+
+// 购买会员
+const purchaseMembership = () => {
+  // TODO: 实现购买会员逻辑
+  console.log("购买会员");
+  showPurchaseModal.value = false;
+};
+
+// 单独购买本片
+const purchaseVideo = () => {
+  // TODO: 实现单独购买逻辑
+  console.log("单独购买本片");
+  showPurchaseModal.value = false;
 };
 
 // 加载视频信息
 const loadVideoInfo = async () => {
-  // 优先从路由参数获取ID
+  const vipLevel = userStore.getVipLevel();
+
+  // 如果是guest或free用户,从query参数获取视频信息
+  if (vipLevel === VipLevel.GUEST || vipLevel === VipLevel.FREE) {
+    const { cover, m3u8, name, duration, view, like } = route.query;
+
+    if (cover && m3u8) {
+      videoInfo.value = {
+        id: "trial",
+        name: name || "试看视频",
+        cover: cover as string,
+        m3u8: m3u8 as string,
+        duration: parseInt(duration as string) || 0,
+        view: parseInt(view as string) || 0,
+        like: parseInt(like as string) || 0,
+        time: 0,
+        taginfo: [],
+      };
+
+      // 设置页面标题
+      document.title = `${videoInfo.value.name} - 试看`;
+      return;
+    }
+  }
+
+  // 其他情况,从路由参数获取ID
   const videoId = route.params.id || route.query.id;
 
   if (videoId) {
@@ -474,7 +635,9 @@ onMounted(async () => {
   }
 });
 
-// 组件卸载时的清理工作已移至 VideoProcessor 组件
+onUnmounted(() => {
+  // 组件卸载时的清理工作
+});
 </script>
 
 <style scoped>