Kaynağa Gözat

优化视频播放器组件,添加视频快进加载动画,处理视频可播放事件,提升用户体验。

wuyi 2 ay önce
ebeveyn
işleme
6363f50030
2 değiştirilmiş dosya ile 187 ekleme ve 86 silme
  1. 156 86
      src/components/VideoProcessor.vue
  2. 31 0
      src/views/VideoPlayer.vue

+ 156 - 86
src/components/VideoProcessor.vue

@@ -38,20 +38,16 @@
     </video>
     </video>
 
 
     <!-- 自定义播放控制条 -->
     <!-- 自定义播放控制条 -->
-    <div 
-      v-if="isVideoMode && !error" 
+    <div
+      v-if="isVideoMode && !error"
       class="custom-controls"
       class="custom-controls"
-      :class="{ 'show': showControls }"
+      :class="{ show: showControls }"
       @click.stop
       @click.stop
     >
     >
       <!-- 播放/暂停按钮(中央) -->
       <!-- 播放/暂停按钮(中央) -->
-      <div 
-        v-if="!isPlaying" 
-        class="play-button-center"
-        @click="togglePlay"
-      >
+      <div v-if="!isPlaying" class="play-button-center" @click="togglePlay">
         <svg class="w-16 h-16" fill="white" viewBox="0 0 24 24">
         <svg class="w-16 h-16" fill="white" viewBox="0 0 24 24">
-          <path d="M8 5v14l11-7z"/>
+          <path d="M8 5v14l11-7z" />
         </svg>
         </svg>
       </div>
       </div>
 
 
@@ -60,8 +56,14 @@
         <!-- 进度条 -->
         <!-- 进度条 -->
         <div class="progress-container" @click="seekVideo">
         <div class="progress-container" @click="seekVideo">
           <div class="progress-bar">
           <div class="progress-bar">
-            <div class="progress-played" :style="{ width: progress + '%' }"></div>
-            <div class="progress-buffered" :style="{ width: buffered + '%' }"></div>
+            <div
+              class="progress-played"
+              :style="{ width: progress + '%' }"
+            ></div>
+            <div
+              class="progress-buffered"
+              :style="{ width: buffered + '%' }"
+            ></div>
           </div>
           </div>
         </div>
         </div>
 
 
@@ -70,14 +72,21 @@
           <!-- 左侧:播放/暂停 + 时间 -->
           <!-- 左侧:播放/暂停 + 时间 -->
           <div class="controls-left">
           <div class="controls-left">
             <button class="control-btn" @click="togglePlay">
             <button class="control-btn" @click="togglePlay">
-              <svg v-if="!isPlaying" class="w-6 h-6" fill="white" viewBox="0 0 24 24">
-                <path d="M8 5v14l11-7z"/>
+              <svg
+                v-if="!isPlaying"
+                class="w-6 h-6"
+                fill="white"
+                viewBox="0 0 24 24"
+              >
+                <path d="M8 5v14l11-7z" />
               </svg>
               </svg>
               <svg v-else class="w-6 h-6" fill="white" viewBox="0 0 24 24">
               <svg v-else class="w-6 h-6" fill="white" viewBox="0 0 24 24">
-                <path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
+                <path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
               </svg>
               </svg>
             </button>
             </button>
-            <span class="time-display">{{ formatTime(currentTime) }} / {{ formatTime(duration) }}</span>
+            <span class="time-display"
+              >{{ formatTime(currentTime) }} / {{ formatTime(duration) }}</span
+            >
           </div>
           </div>
 
 
           <!-- 右侧:音量 + 全屏 -->
           <!-- 右侧:音量 + 全屏 -->
@@ -85,34 +94,60 @@
             <!-- 音量控制 -->
             <!-- 音量控制 -->
             <div class="volume-control">
             <div class="volume-control">
               <button class="control-btn" @click="toggleMute">
               <button class="control-btn" @click="toggleMute">
-                <svg v-if="!isMuted && volume > 0.5" class="w-6 h-6" fill="white" viewBox="0 0 24 24">
-                  <path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>
+                <svg
+                  v-if="!isMuted && volume > 0.5"
+                  class="w-6 h-6"
+                  fill="white"
+                  viewBox="0 0 24 24"
+                >
+                  <path
+                    d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"
+                  />
                 </svg>
                 </svg>
-                <svg v-else-if="!isMuted && volume > 0" class="w-6 h-6" fill="white" viewBox="0 0 24 24">
-                  <path d="M7 9v6h4l5 5V4l-5 5H7z"/>
+                <svg
+                  v-else-if="!isMuted && volume > 0"
+                  class="w-6 h-6"
+                  fill="white"
+                  viewBox="0 0 24 24"
+                >
+                  <path d="M7 9v6h4l5 5V4l-5 5H7z" />
                 </svg>
                 </svg>
                 <svg v-else class="w-6 h-6" fill="white" viewBox="0 0 24 24">
                 <svg v-else class="w-6 h-6" fill="white" viewBox="0 0 24 24">
-                  <path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>
+                  <path
+                    d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"
+                  />
                 </svg>
                 </svg>
               </button>
               </button>
-              <input 
+              <input
                 v-if="showVolumeSlider"
                 v-if="showVolumeSlider"
-                type="range" 
-                class="volume-slider" 
-                min="0" 
-                max="100" 
+                type="range"
+                class="volume-slider"
+                min="0"
+                max="100"
                 :value="volume * 100"
                 :value="volume * 100"
                 @input="changeVolume"
                 @input="changeVolume"
               />
               />
             </div>
             </div>
 
 
             <!-- 全屏按钮 -->
             <!-- 全屏按钮 -->
-            <button class="control-btn fullscreen-btn" @click="toggleFullscreen">
-              <svg v-if="!isFullscreen" class="w-6 h-6" fill="white" viewBox="0 0 24 24">
-                <path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/>
+            <button
+              class="control-btn fullscreen-btn"
+              @click="toggleFullscreen"
+            >
+              <svg
+                v-if="!isFullscreen"
+                class="w-6 h-6"
+                fill="white"
+                viewBox="0 0 24 24"
+              >
+                <path
+                  d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"
+                />
               </svg>
               </svg>
               <svg v-else class="w-6 h-6" fill="white" viewBox="0 0 24 24">
               <svg v-else class="w-6 h-6" fill="white" viewBox="0 0 24 24">
-                <path d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z"/>
+                <path
+                  d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z"
+                />
               </svg>
               </svg>
             </button>
             </button>
           </div>
           </div>
@@ -175,6 +210,7 @@ const emit = defineEmits<{
   play: [];
   play: [];
   timeupdate: [];
   timeupdate: [];
   seeking: [];
   seeking: [];
+  canplay: [];
 }>();
 }>();
 
 
 // 响应式数据
 // 响应式数据
@@ -312,7 +348,11 @@ const processVideo = async (url: string): Promise<void> => {
     error.value = "";
     error.value = "";
 
 
     // 检查是否为标准的 HLS 流地址(不需要解密的)
     // 检查是否为标准的 HLS 流地址(不需要解密的)
-    if (isStandardHlsUrl(url) && !url.includes("play") && !url.includes("cover")) {
+    if (
+      isStandardHlsUrl(url) &&
+      !url.includes("play") &&
+      !url.includes("cover")
+    ) {
       processedVideoUrl.value = url;
       processedVideoUrl.value = url;
       await nextTick();
       await nextTick();
       await initVideoPlayer();
       await initVideoPlayer();
@@ -334,19 +374,19 @@ const processVideo = async (url: string): Promise<void> => {
 
 
         // 根据不同浏览器选择合适的 MIME 类型和 URL 格式
         // 根据不同浏览器选择合适的 MIME 类型和 URL 格式
         let mimeType = "application/x-mpegURL";
         let mimeType = "application/x-mpegURL";
-        
+
         if (isQuarkBrowser() && !isIOSQuarkBrowser()) {
         if (isQuarkBrowser() && !isIOSQuarkBrowser()) {
           // Android 夸克使用 Apple 格式
           // Android 夸克使用 Apple 格式
           mimeType = "application/vnd.apple.mpegurl";
           mimeType = "application/vnd.apple.mpegurl";
           console.log("夸克浏览器使用 Apple HLS 格式");
           console.log("夸克浏览器使用 Apple HLS 格式");
         }
         }
-        
+
         if (isQQBrowser()) {
         if (isQQBrowser()) {
           // QQ 浏览器(X5内核)必须使用 Apple 格式
           // QQ 浏览器(X5内核)必须使用 Apple 格式
           mimeType = "application/x-mpegURL";
           mimeType = "application/x-mpegURL";
           console.log("QQ浏览器使用 Apple HLS 格式(X5内核要求)");
           console.log("QQ浏览器使用 Apple HLS 格式(X5内核要求)");
         }
         }
-        
+
         // iOS 夸克浏览器:使用 Data URL(iOS 不支持 Blob URL + HLS.js)
         // iOS 夸克浏览器:使用 Data URL(iOS 不支持 Blob URL + HLS.js)
         if (isIOSQuarkBrowser()) {
         if (isIOSQuarkBrowser()) {
           try {
           try {
@@ -355,16 +395,17 @@ const processVideo = async (url: string): Promise<void> => {
             console.log("iOS 夸克浏览器使用 Data URL");
             console.log("iOS 夸克浏览器使用 Data URL");
           } catch (e) {
           } catch (e) {
             console.error("Data URL 生成失败:", e);
             console.error("Data URL 生成失败:", e);
-            error.value = "抱歉,iOS 夸克浏览器暂不支持播放此视频,建议使用 Safari 打开";
+            error.value =
+              "抱歉,iOS 夸克浏览器暂不支持播放此视频,建议使用 Safari 打开";
             emit("error", error.value);
             emit("error", error.value);
             loading.value = false;
             loading.value = false;
             return;
             return;
           }
           }
-        } 
+        }
         // else if (isQQBrowser()) {
         // else if (isQQBrowser()) {
         //   // QQ浏览器:使用正确的MIME类型
         //   // QQ浏览器:使用正确的MIME类型
         //   console.log("检测到QQ浏览器,使用HLS.js兼容模式");
         //   console.log("检测到QQ浏览器,使用HLS.js兼容模式");
-          
+
         //   try {
         //   try {
         //     // 使用正确的HLS MIME类型
         //     // 使用正确的HLS MIME类型
         //     const blob = new Blob([playlist], { type: "application/vnd.apple.mpegurl" });
         //     const blob = new Blob([playlist], { type: "application/vnd.apple.mpegurl" });
@@ -372,7 +413,7 @@ const processVideo = async (url: string): Promise<void> => {
         //     console.log("QQ浏览器使用Blob URL,MIME类型:", "application/vnd.apple.mpegurl");
         //     console.log("QQ浏览器使用Blob URL,MIME类型:", "application/vnd.apple.mpegurl");
         //   } catch (e) {
         //   } catch (e) {
         //     console.error("QQ浏览器Blob URL生成失败,尝试Data URL:", e);
         //     console.error("QQ浏览器Blob URL生成失败,尝试Data URL:", e);
-            
+
         //     // Blob失败则尝试Data URL
         //     // Blob失败则尝试Data URL
         //     try {
         //     try {
         //       const base64 = btoa(unescape(encodeURIComponent(playlist)));
         //       const base64 = btoa(unescape(encodeURIComponent(playlist)));
@@ -387,7 +428,7 @@ const processVideo = async (url: string): Promise<void> => {
         //     }
         //     }
         //   }
         //   }
         // }
         // }
-        
+
         // 其他所有浏览器(Chrome、Firefox、Edge、UC等)使用 Blob URL
         // 其他所有浏览器(Chrome、Firefox、Edge、UC等)使用 Blob URL
         if (!isIOSQuarkBrowser()) {
         if (!isIOSQuarkBrowser()) {
           const blob = new Blob([playlist], { type: mimeType });
           const blob = new Blob([playlist], { type: mimeType });
@@ -462,24 +503,26 @@ const isIOSSafari = (): boolean => {
 // 检测是否为夸克浏览器
 // 检测是否为夸克浏览器
 const isQuarkBrowser = (): boolean => {
 const isQuarkBrowser = (): boolean => {
   const ua = navigator.userAgent.toLowerCase();
   const ua = navigator.userAgent.toLowerCase();
-  return ua.includes('quark') || ua.includes('quarks');
+  return ua.includes("quark") || ua.includes("quarks");
 };
 };
 
 
 // 检测是否为 iOS 夸克浏览器
 // 检测是否为 iOS 夸克浏览器
 const isIOSQuarkBrowser = (): boolean => {
 const isIOSQuarkBrowser = (): boolean => {
-  return isQuarkBrowser() && (/iPad|iPhone|iPod/.test(navigator.userAgent));
+  return isQuarkBrowser() && /iPad|iPhone|iPod/.test(navigator.userAgent);
 };
 };
 
 
 // 检测是否为 QQ 浏览器
 // 检测是否为 QQ 浏览器
 const isQQBrowser = (): boolean => {
 const isQQBrowser = (): boolean => {
   const ua = navigator.userAgent.toLowerCase();
   const ua = navigator.userAgent.toLowerCase();
-  return ua.includes('mqqbrowser') || ua.includes('qq/');
+  return ua.includes("mqqbrowser") || ua.includes("qq/");
 };
 };
 
 
 // 检测是否为 UC 浏览器
 // 检测是否为 UC 浏览器
 const isUCBrowser = (): boolean => {
 const isUCBrowser = (): boolean => {
   const ua = navigator.userAgent.toLowerCase();
   const ua = navigator.userAgent.toLowerCase();
-  return ua.includes('ucbrowser') || ua.includes('ubrowser') || ua.includes('ucweb');
+  return (
+    ua.includes("ucbrowser") || ua.includes("ubrowser") || ua.includes("ucweb")
+  );
 };
 };
 
 
 // 初始化视频播放器
 // 初始化视频播放器
@@ -494,7 +537,7 @@ const initVideoPlayer = async (): Promise<void> => {
   // 设置加载状态
   // 设置加载状态
   isVideoLoading.value = true;
   isVideoLoading.value = true;
   error.value = "";
   error.value = "";
-  
+
   // 清除错误防抖定时器
   // 清除错误防抖定时器
   if (errorDebounceTimer) {
   if (errorDebounceTimer) {
     clearTimeout(errorDebounceTimer);
     clearTimeout(errorDebounceTimer);
@@ -503,7 +546,7 @@ const initVideoPlayer = async (): Promise<void> => {
 
 
   // 清除之前的src,避免缓存问题
   // 清除之前的src,避免缓存问题
   if (video.src) {
   if (video.src) {
-    video.removeAttribute('src');
+    video.removeAttribute("src");
     video.load();
     video.load();
   }
   }
 
 
@@ -520,7 +563,7 @@ const initVideoPlayer = async (): Promise<void> => {
   // Android 夸克浏览器特殊处理
   // Android 夸克浏览器特殊处理
   if (isQuarkBrowser()) {
   if (isQuarkBrowser()) {
     console.log("检测到 Android 夸克浏览器");
     console.log("检测到 Android 夸克浏览器");
-    
+
     // Android 夸克支持 HLS.js,直接使用
     // Android 夸克支持 HLS.js,直接使用
     if (Hls.isSupported()) {
     if (Hls.isSupported()) {
       console.log("使用 HLS.js 播放");
       console.log("使用 HLS.js 播放");
@@ -537,7 +580,8 @@ const initVideoPlayer = async (): Promise<void> => {
   // QQ 浏览器不支持提示
   // QQ 浏览器不支持提示
   if (isQQBrowser()) {
   if (isQQBrowser()) {
     console.log("检测到 QQ 浏览器,不支持加密视频播放");
     console.log("检测到 QQ 浏览器,不支持加密视频播放");
-    error.value = "QQ浏览器暂不支持播放此类视频\n请使用Chrome、UC或其他浏览器访问";
+    error.value =
+      "QQ浏览器暂不支持播放此类视频\n请使用Chrome、UC或其他浏览器访问";
     // 不提供重试选项,直接返回
     // 不提供重试选项,直接返回
     return;
     return;
   }
   }
@@ -545,7 +589,7 @@ const initVideoPlayer = async (): Promise<void> => {
   // UC 浏览器特殊处理 - UC通常支持标准的HLS播放
   // UC 浏览器特殊处理 - UC通常支持标准的HLS播放
   if (isUCBrowser()) {
   if (isUCBrowser()) {
     console.log("检测到 UC 浏览器");
     console.log("检测到 UC 浏览器");
-    
+
     // UC浏览器优先使用HLS.js,这样可以使用自定义控制条
     // UC浏览器优先使用HLS.js,这样可以使用自定义控制条
     if (Hls.isSupported()) {
     if (Hls.isSupported()) {
       console.log("UC浏览器使用HLS.js播放器");
       console.log("UC浏览器使用HLS.js播放器");
@@ -641,7 +685,7 @@ const initHlsPlayer = (): void => {
 
 
     hlsInstance.value.on(Hls.Events.ERROR, (event, data) => {
     hlsInstance.value.on(Hls.Events.ERROR, (event, data) => {
       console.error("HLS 错误:", data);
       console.error("HLS 错误:", data);
-      
+
       if (data.fatal) {
       if (data.fatal) {
         switch (data.type) {
         switch (data.type) {
           case Hls.ErrorTypes.NETWORK_ERROR:
           case Hls.ErrorTypes.NETWORK_ERROR:
@@ -664,7 +708,7 @@ const initHlsPlayer = (): void => {
             error.value = "视频加载失败";
             error.value = "视频加载失败";
             hlsInstance.value?.destroy();
             hlsInstance.value?.destroy();
             hlsInstance.value = null;
             hlsInstance.value = null;
-            
+
             // 尝试降级到原生播放器
             // 尝试降级到原生播放器
             setTimeout(() => {
             setTimeout(() => {
               if (videoElement.value && processedVideoUrl.value) {
               if (videoElement.value && processedVideoUrl.value) {
@@ -726,7 +770,7 @@ const retry = (): void => {
 // 播放器控制方法
 // 播放器控制方法
 const togglePlay = (): void => {
 const togglePlay = (): void => {
   if (!videoElement.value) return;
   if (!videoElement.value) return;
-  
+
   if (isPlaying.value) {
   if (isPlaying.value) {
     videoElement.value.pause();
     videoElement.value.pause();
   } else {
   } else {
@@ -736,19 +780,19 @@ const togglePlay = (): void => {
 
 
 const toggleMute = (): void => {
 const toggleMute = (): void => {
   if (!videoElement.value) return;
   if (!videoElement.value) return;
-  
+
   isMuted.value = !isMuted.value;
   isMuted.value = !isMuted.value;
   videoElement.value.muted = isMuted.value;
   videoElement.value.muted = isMuted.value;
 };
 };
 
 
 const changeVolume = (event: Event): void => {
 const changeVolume = (event: Event): void => {
   if (!videoElement.value) return;
   if (!videoElement.value) return;
-  
+
   const target = event.target as HTMLInputElement;
   const target = event.target as HTMLInputElement;
   const newVolume = parseInt(target.value) / 100;
   const newVolume = parseInt(target.value) / 100;
   volume.value = newVolume;
   volume.value = newVolume;
   videoElement.value.volume = newVolume;
   videoElement.value.volume = newVolume;
-  
+
   if (newVolume > 0 && isMuted.value) {
   if (newVolume > 0 && isMuted.value) {
     isMuted.value = false;
     isMuted.value = false;
     videoElement.value.muted = false;
     videoElement.value.muted = false;
@@ -797,30 +841,32 @@ const toggleFullscreen = async (): Promise<void> => {
 
 
 const seekVideo = (event: MouseEvent): void => {
 const seekVideo = (event: MouseEvent): void => {
   if (!videoElement.value || !duration.value) return;
   if (!videoElement.value || !duration.value) return;
-  
+
   const progressBar = event.currentTarget as HTMLElement;
   const progressBar = event.currentTarget as HTMLElement;
   const rect = progressBar.getBoundingClientRect();
   const rect = progressBar.getBoundingClientRect();
   const pos = (event.clientX - rect.left) / rect.width;
   const pos = (event.clientX - rect.left) / rect.width;
   const seekTime = pos * duration.value;
   const seekTime = pos * duration.value;
-  
+
   videoElement.value.currentTime = seekTime;
   videoElement.value.currentTime = seekTime;
 };
 };
 
 
 const formatTime = (seconds: number): string => {
 const formatTime = (seconds: number): string => {
   if (isNaN(seconds)) return "00:00";
   if (isNaN(seconds)) return "00:00";
-  
+
   const mins = Math.floor(seconds / 60);
   const mins = Math.floor(seconds / 60);
   const secs = Math.floor(seconds % 60);
   const secs = Math.floor(seconds % 60);
-  return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
+  return `${mins.toString().padStart(2, "0")}:${secs
+    .toString()
+    .padStart(2, "0")}`;
 };
 };
 
 
 const toggleControls = (): void => {
 const toggleControls = (): void => {
   showControls.value = true;
   showControls.value = true;
-  
+
   if (controlsTimer) {
   if (controlsTimer) {
     clearTimeout(controlsTimer);
     clearTimeout(controlsTimer);
   }
   }
-  
+
   // 如果正在播放,3秒后自动隐藏控制条
   // 如果正在播放,3秒后自动隐藏控制条
   if (isPlaying.value) {
   if (isPlaying.value) {
     controlsTimer = setTimeout(() => {
     controlsTimer = setTimeout(() => {
@@ -852,17 +898,20 @@ const onVideoLoadedData = (): void => {
 
 
 const onVideoCanPlay = (): void => {
 const onVideoCanPlay = (): void => {
   console.log("视频可以播放");
   console.log("视频可以播放");
-  
+
   // 清除加载状态和错误
   // 清除加载状态和错误
   isVideoLoading.value = false;
   isVideoLoading.value = false;
   error.value = "";
   error.value = "";
-  
+
   // 清除错误防抖定时器
   // 清除错误防抖定时器
   if (errorDebounceTimer) {
   if (errorDebounceTimer) {
     clearTimeout(errorDebounceTimer);
     clearTimeout(errorDebounceTimer);
     errorDebounceTimer = null;
     errorDebounceTimer = null;
   }
   }
-  
+
+  // 发出canplay事件
+  emit("canplay");
+
   if (props.autoPlay && videoElement.value) {
   if (props.autoPlay && videoElement.value) {
     videoElement.value.play().catch(() => {});
     videoElement.value.play().catch(() => {});
   }
   }
@@ -896,19 +945,21 @@ const onVideoEnded = (): void => {
 const onVideoTimeUpdate = (): void => {
 const onVideoTimeUpdate = (): void => {
   if (videoElement.value) {
   if (videoElement.value) {
     currentTime.value = videoElement.value.currentTime;
     currentTime.value = videoElement.value.currentTime;
-    
+
     // 更新进度
     // 更新进度
     if (duration.value > 0) {
     if (duration.value > 0) {
       progress.value = (currentTime.value / duration.value) * 100;
       progress.value = (currentTime.value / duration.value) * 100;
     }
     }
-    
+
     // 更新缓冲进度
     // 更新缓冲进度
     if (videoElement.value.buffered.length > 0) {
     if (videoElement.value.buffered.length > 0) {
-      const bufferedEnd = videoElement.value.buffered.end(videoElement.value.buffered.length - 1);
+      const bufferedEnd = videoElement.value.buffered.end(
+        videoElement.value.buffered.length - 1
+      );
       buffered.value = (bufferedEnd / duration.value) * 100;
       buffered.value = (bufferedEnd / duration.value) * 100;
     }
     }
   }
   }
-  
+
   emit("timeupdate");
   emit("timeupdate");
 };
 };
 
 
@@ -921,7 +972,13 @@ const onVideoError = (event: Event): void => {
   const errorCode = video.error?.code;
   const errorCode = video.error?.code;
   const errorMessage = video.error?.message;
   const errorMessage = video.error?.message;
 
 
-  console.log("视频错误事件:", errorCode, errorMessage, "加载中:", isVideoLoading.value);
+  console.log(
+    "视频错误事件:",
+    errorCode,
+    errorMessage,
+    "加载中:",
+    isVideoLoading.value
+  );
 
 
   // 清除之前的错误防抖定时器
   // 清除之前的错误防抖定时器
   if (errorDebounceTimer) {
   if (errorDebounceTimer) {
@@ -932,10 +989,10 @@ const onVideoError = (event: Event): void => {
   // 如果视频正在加载中,延迟显示错误(给视频更多时间初始化)
   // 如果视频正在加载中,延迟显示错误(给视频更多时间初始化)
   if (isVideoLoading.value) {
   if (isVideoLoading.value) {
     console.log("视频正在初始化,延迟错误显示");
     console.log("视频正在初始化,延迟错误显示");
-    
+
     // 对于夸克浏览器,给更长的初始化时间(从2s提升至3s)
     // 对于夸克浏览器,给更长的初始化时间(从2s提升至3s)
     const delayTime = isQuarkBrowser() ? 3000 : 1000;
     const delayTime = isQuarkBrowser() ? 3000 : 1000;
-    
+
     errorDebounceTimer = setTimeout(() => {
     errorDebounceTimer = setTimeout(() => {
       // 再次检查视频状态
       // 再次检查视频状态
       if (video.readyState >= 2) {
       if (video.readyState >= 2) {
@@ -943,11 +1000,11 @@ const onVideoError = (event: Event): void => {
         console.log("视频已准备好,忽略错误");
         console.log("视频已准备好,忽略错误");
         return;
         return;
       }
       }
-      
+
       // 显示错误
       // 显示错误
       showVideoError(errorCode, errorMessage);
       showVideoError(errorCode, errorMessage);
     }, delayTime);
     }, delayTime);
-    
+
     return;
     return;
   }
   }
 
 
@@ -968,7 +1025,7 @@ const showVideoError = (errorCode?: number, errorMessage?: string): void => {
         errorText = "网络错误,无法加载视频";
         errorText = "网络错误,无法加载视频";
         break;
         break;
       case 3: // MEDIA_ERR_DECODE
       case 3: // MEDIA_ERR_DECODE
-        errorText = isIOSQuarkBrowser() 
+        errorText = isIOSQuarkBrowser()
           ? "抱歉,iOS 夸克浏览器暂不支持此视频,建议使用 Safari 打开"
           ? "抱歉,iOS 夸克浏览器暂不支持此视频,建议使用 Safari 打开"
           : "视频解码失败";
           : "视频解码失败";
         break;
         break;
@@ -1039,32 +1096,35 @@ const handleFullscreenChange = (): void => {
 
 
 // 组件挂载时添加全屏监听
 // 组件挂载时添加全屏监听
 onMounted(() => {
 onMounted(() => {
-  document.addEventListener('fullscreenchange', handleFullscreenChange);
-  document.addEventListener('webkitfullscreenchange', handleFullscreenChange);
-  document.addEventListener('mozfullscreenchange', handleFullscreenChange);
-  document.addEventListener('MSFullscreenChange', handleFullscreenChange);
+  document.addEventListener("fullscreenchange", handleFullscreenChange);
+  document.addEventListener("webkitfullscreenchange", handleFullscreenChange);
+  document.addEventListener("mozfullscreenchange", handleFullscreenChange);
+  document.addEventListener("MSFullscreenChange", handleFullscreenChange);
 });
 });
 
 
 // 组件卸载时清理资源
 // 组件卸载时清理资源
 onUnmounted(() => {
 onUnmounted(() => {
   destroyHls();
   destroyHls();
-  
+
   // 清理全屏监听
   // 清理全屏监听
-  document.removeEventListener('fullscreenchange', handleFullscreenChange);
-  document.removeEventListener('webkitfullscreenchange', handleFullscreenChange);
-  document.removeEventListener('mozfullscreenchange', handleFullscreenChange);
-  document.removeEventListener('MSFullscreenChange', handleFullscreenChange);
-  
+  document.removeEventListener("fullscreenchange", handleFullscreenChange);
+  document.removeEventListener(
+    "webkitfullscreenchange",
+    handleFullscreenChange
+  );
+  document.removeEventListener("mozfullscreenchange", handleFullscreenChange);
+  document.removeEventListener("MSFullscreenChange", handleFullscreenChange);
+
   // 清理控制条定时器
   // 清理控制条定时器
   if (controlsTimer) {
   if (controlsTimer) {
     clearTimeout(controlsTimer);
     clearTimeout(controlsTimer);
   }
   }
-  
+
   // 清理错误防抖定时器
   // 清理错误防抖定时器
   if (errorDebounceTimer) {
   if (errorDebounceTimer) {
     clearTimeout(errorDebounceTimer);
     clearTimeout(errorDebounceTimer);
   }
   }
-  
+
   // 清理 Blob URL
   // 清理 Blob URL
   if (processedCoverUrl.value?.startsWith("blob:")) {
   if (processedCoverUrl.value?.startsWith("blob:")) {
     URL.revokeObjectURL(processedCoverUrl.value);
     URL.revokeObjectURL(processedCoverUrl.value);
@@ -1159,7 +1219,12 @@ defineExpose({
   left: 0;
   left: 0;
   right: 0;
   right: 0;
   bottom: 0;
   bottom: 0;
-  background: linear-gradient(to top, rgba(0, 0, 0, 0.8) 0%, rgba(0, 0, 0, 0.4) 70%, transparent 100%);
+  background: linear-gradient(
+    to top,
+    rgba(0, 0, 0, 0.8) 0%,
+    rgba(0, 0, 0, 0.4) 70%,
+    transparent 100%
+  );
   padding: 10px 15px;
   padding: 10px 15px;
   display: flex;
   display: flex;
   flex-direction: column;
   flex-direction: column;
@@ -1315,7 +1380,12 @@ defineExpose({
   .video-processor:fullscreen .controls-bottom,
   .video-processor:fullscreen .controls-bottom,
   .video-processor:-webkit-full-screen .controls-bottom,
   .video-processor:-webkit-full-screen .controls-bottom,
   .video-processor:-moz-full-screen .controls-bottom {
   .video-processor:-moz-full-screen .controls-bottom {
-    background: linear-gradient(to top, rgba(0, 0, 0, 0.9) 0%, rgba(0, 0, 0, 0.6) 70%, transparent 100%);
+    background: linear-gradient(
+      to top,
+      rgba(0, 0, 0, 0.9) 0%,
+      rgba(0, 0, 0, 0.6) 70%,
+      transparent 100%
+    );
   }
   }
 }
 }
 
 

+ 31 - 0
src/views/VideoPlayer.vue

@@ -74,8 +74,23 @@
           @play="onVideoPlay"
           @play="onVideoPlay"
           @timeupdate="onVideoTimeUpdate"
           @timeupdate="onVideoTimeUpdate"
           @seeking="onVideoSeeking"
           @seeking="onVideoSeeking"
+          @canplay="onVideoCanPlay"
         />
         />
 
 
+        <!-- 快进加载动画 -->
+        <div
+          v-if="isVideoSeeking"
+          class="absolute inset-0 bg-black/40 flex items-center justify-center z-20"
+        >
+          <div
+            class="w-16 h-16 rounded-full bg-black/60 flex items-center justify-center"
+          >
+            <div
+              class="w-10 h-10 border-4 border-white/20 border-t-brand rounded-full animate-spin"
+            ></div>
+          </div>
+        </div>
+
         <!-- 试看提示 -->
         <!-- 试看提示 -->
         <div
         <div
           v-if="isTrialMode"
           v-if="isTrialMode"
@@ -630,6 +645,7 @@ const showMembershipPurchaseModal = ref(false);
 const showSinglePurchaseModal = ref(false);
 const showSinglePurchaseModal = ref(false);
 const showLoginDialog = ref(false);
 const showLoginDialog = ref(false);
 const isTrialMode = ref(false);
 const isTrialMode = ref(false);
+const isVideoSeeking = ref(false); // 视频快进状态
 
 
 // 单片购买状态
 // 单片购买状态
 const isSinglePurchased = ref(false);
 const isSinglePurchased = ref(false);
@@ -752,6 +768,12 @@ const onVideoProcessorRetry = () => {
   // VideoProcessor 重试
   // VideoProcessor 重试
 };
 };
 
 
+// 处理视频可以播放事件
+const onVideoCanPlay = () => {
+  // 视频可以播放时,关闭加载动画
+  isVideoSeeking.value = false;
+};
+
 // 处理登录成功
 // 处理登录成功
 const onLoginSuccess = async () => {
 const onLoginSuccess = async () => {
   // 登录成功后刷新用户信息
   // 登录成功后刷新用户信息
@@ -790,6 +812,15 @@ const onVideoTimeUpdate = () => {
 
 
 // 监听视频时间变化(包括拖拽进度条)
 // 监听视频时间变化(包括拖拽进度条)
 const onVideoSeeking = () => {
 const onVideoSeeking = () => {
+  // 设置快进状态
+  isVideoSeeking.value = true;
+
+  // 创建一个定时器,在视频开始播放后一段时间关闭加载动画
+  setTimeout(() => {
+    isVideoSeeking.value = false;
+  }, 1500);
+
+  // 试看限制逻辑
   if (isGuestOrFree.value && isTrialMode.value) {
   if (isGuestOrFree.value && isTrialMode.value) {
     const video = document.querySelector("video");
     const video = document.querySelector("video");
     if (video && video.currentTime > trialDuration.value) {
     if (video && video.currentTime > trialDuration.value) {