wilhelm wong преди 3 месеца
родител
ревизия
ddddd56506
променени са 2 файла, в които са добавени 484 реда и са изтрити 5 реда
  1. 1 1
      dev-dist/sw.js
  2. 483 4
      src/components/VideoProcessor.vue

+ 1 - 1
dev-dist/sw.js

@@ -79,7 +79,7 @@ define(['./workbox-f2cb1a81'], (function (workbox) { 'use strict';
    */
   workbox.precacheAndRoute([{
     "url": "index.html",
-    "revision": "0.aqbt0879"
+    "revision": "0.ub7ebg6m7o8"
   }], {});
   workbox.cleanupOutdatedCaches();
   workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {

+ 483 - 4
src/components/VideoProcessor.vue

@@ -1,5 +1,5 @@
 <template>
-  <div class="video-processor">
+  <div class="video-processor" @click="toggleControls">
     <!-- 封面图片 -->
     <img
       v-if="processedCoverUrl && !isVideoMode"
@@ -16,7 +16,6 @@
       ref="videoElement"
       :class="videoClass"
       :poster="processedCoverUrl"
-      controls
       preload="metadata"
       playsinline
       webkit-playsinline
@@ -28,13 +27,99 @@
       @loadeddata="onVideoLoadedData"
       @error="onVideoError"
       @canplay="onVideoCanPlay"
-      @play="onVideoPlay"
+      @play="onVideoPlayEvent"
+      @pause="onVideoPauseEvent"
       @timeupdate="onVideoTimeUpdate"
       @seeking="onVideoSeeking"
+      @durationchange="onDurationChange"
+      @ended="onVideoEnded"
     >
       您的浏览器不支持视频播放
     </video>
 
+    <!-- 自定义播放控制条 -->
+    <div 
+      v-if="isVideoMode && !error" 
+      class="custom-controls"
+      :class="{ 'show': showControls }"
+      @click.stop
+    >
+      <!-- 播放/暂停按钮(中央) -->
+      <div 
+        v-if="!isPlaying" 
+        class="play-button-center"
+        @click="togglePlay"
+      >
+        <svg class="w-16 h-16" fill="white" viewBox="0 0 24 24">
+          <path d="M8 5v14l11-7z"/>
+        </svg>
+      </div>
+
+      <!-- 底部控制栏 -->
+      <div class="controls-bottom">
+        <!-- 进度条 -->
+        <div class="progress-container" @click="seekVideo">
+          <div class="progress-bar">
+            <div class="progress-played" :style="{ width: progress + '%' }"></div>
+            <div class="progress-buffered" :style="{ width: buffered + '%' }"></div>
+          </div>
+        </div>
+
+        <!-- 控制按钮区域 -->
+        <div class="controls-buttons">
+          <!-- 左侧:播放/暂停 + 时间 -->
+          <div class="controls-left">
+            <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>
+              <svg v-else class="w-6 h-6" fill="white" viewBox="0 0 24 24">
+                <path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
+              </svg>
+            </button>
+            <span class="time-display">{{ formatTime(currentTime) }} / {{ formatTime(duration) }}</span>
+          </div>
+
+          <!-- 右侧:音量 + 全屏 -->
+          <div class="controls-right">
+            <!-- 音量控制 -->
+            <div class="volume-control">
+              <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>
+                <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 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"/>
+                </svg>
+              </button>
+              <input 
+                v-if="showVolumeSlider"
+                type="range" 
+                class="volume-slider" 
+                min="0" 
+                max="100" 
+                :value="volume * 100"
+                @input="changeVolume"
+              />
+            </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"/>
+              </svg>
+              <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"/>
+              </svg>
+            </button>
+          </div>
+        </div>
+      </div>
+    </div>
+
     <!-- 加载状态 -->
     <div v-if="loading" class="loading-overlay">
       <div class="spinner"></div>
@@ -102,6 +187,19 @@ const maxRetries = 3;
 const videoElement = ref<HTMLVideoElement>();
 const hlsInstance = ref<Hls | null>(null);
 
+// 播放器控制状态
+const isPlaying = ref(false);
+const currentTime = ref(0);
+const duration = ref(0);
+const progress = ref(0);
+const buffered = ref(0);
+const volume = ref(1);
+const isMuted = ref(false);
+const isFullscreen = ref(false);
+const showControls = ref(true);
+const showVolumeSlider = ref(true); // 桌面端显示音量滑块,移动端通过CSS隐藏
+let controlsTimer: ReturnType<typeof setTimeout> | null = null;
+
 // 计算属性
 const isVideoMode = computed(() => !!props.m3u8Url);
 const showRetryButton = computed(
@@ -493,6 +591,112 @@ const retry = (): void => {
   }
 };
 
+// 播放器控制方法
+const togglePlay = (): void => {
+  if (!videoElement.value) return;
+  
+  if (isPlaying.value) {
+    videoElement.value.pause();
+  } else {
+    videoElement.value.play();
+  }
+};
+
+const toggleMute = (): void => {
+  if (!videoElement.value) return;
+  
+  isMuted.value = !isMuted.value;
+  videoElement.value.muted = isMuted.value;
+};
+
+const changeVolume = (event: Event): void => {
+  if (!videoElement.value) return;
+  
+  const target = event.target as HTMLInputElement;
+  const newVolume = parseInt(target.value) / 100;
+  volume.value = newVolume;
+  videoElement.value.volume = newVolume;
+  
+  if (newVolume > 0 && isMuted.value) {
+    isMuted.value = false;
+    videoElement.value.muted = false;
+  }
+};
+
+const toggleFullscreen = async (): Promise<void> => {
+  if (!videoElement.value) return;
+
+  try {
+    if (!document.fullscreenElement) {
+      // 进入全屏
+      const container = videoElement.value.parentElement;
+      if (container) {
+        // 尝试不同的全屏API
+        if (container.requestFullscreen) {
+          await container.requestFullscreen();
+        } else if ((container as any).webkitRequestFullscreen) {
+          await (container as any).webkitRequestFullscreen();
+        } else if ((container as any).mozRequestFullScreen) {
+          await (container as any).mozRequestFullScreen();
+        } else if ((container as any).msRequestFullscreen) {
+          await (container as any).msRequestFullscreen();
+        }
+        // iOS特殊处理
+        else if ((videoElement.value as any).webkitEnterFullscreen) {
+          (videoElement.value as any).webkitEnterFullscreen();
+        }
+      }
+    } else {
+      // 退出全屏
+      if (document.exitFullscreen) {
+        await document.exitFullscreen();
+      } else if ((document as any).webkitExitFullscreen) {
+        await (document as any).webkitExitFullscreen();
+      } else if ((document as any).mozCancelFullScreen) {
+        await (document as any).mozCancelFullScreen();
+      } else if ((document as any).msExitFullscreen) {
+        await (document as any).msExitFullscreen();
+      }
+    }
+  } catch (err) {
+    console.error("全屏切换失败:", err);
+  }
+};
+
+const seekVideo = (event: MouseEvent): void => {
+  if (!videoElement.value || !duration.value) return;
+  
+  const progressBar = event.currentTarget as HTMLElement;
+  const rect = progressBar.getBoundingClientRect();
+  const pos = (event.clientX - rect.left) / rect.width;
+  const seekTime = pos * duration.value;
+  
+  videoElement.value.currentTime = seekTime;
+};
+
+const formatTime = (seconds: number): string => {
+  if (isNaN(seconds)) return "00:00";
+  
+  const mins = Math.floor(seconds / 60);
+  const secs = Math.floor(seconds % 60);
+  return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
+};
+
+const toggleControls = (): void => {
+  showControls.value = true;
+  
+  if (controlsTimer) {
+    clearTimeout(controlsTimer);
+  }
+  
+  // 如果正在播放,3秒后自动隐藏控制条
+  if (isPlaying.value) {
+    controlsTimer = setTimeout(() => {
+      showControls.value = false;
+    }, 3000);
+  }
+};
+
 // 事件处理
 const handleCoverError = (event: Event): void => {
   const img = event.target as HTMLImageElement;
@@ -513,11 +717,47 @@ const onVideoCanPlay = (): void => {
   }
 };
 
-const onVideoPlay = (): void => {
+const onVideoPlayEvent = (): void => {
+  isPlaying.value = true;
   emit("play");
+  toggleControls(); // 开始自动隐藏倒计时
+};
+
+const onVideoPauseEvent = (): void => {
+  isPlaying.value = false;
+  showControls.value = true;
+  if (controlsTimer) {
+    clearTimeout(controlsTimer);
+  }
+};
+
+const onDurationChange = (): void => {
+  if (videoElement.value) {
+    duration.value = videoElement.value.duration;
+  }
+};
+
+const onVideoEnded = (): void => {
+  isPlaying.value = false;
+  showControls.value = true;
 };
 
 const onVideoTimeUpdate = (): void => {
+  if (videoElement.value) {
+    currentTime.value = videoElement.value.currentTime;
+    
+    // 更新进度
+    if (duration.value > 0) {
+      progress.value = (currentTime.value / duration.value) * 100;
+    }
+    
+    // 更新缓冲进度
+    if (videoElement.value.buffered.length > 0) {
+      const bufferedEnd = videoElement.value.buffered.end(videoElement.value.buffered.length - 1);
+      buffered.value = (bufferedEnd / duration.value) * 100;
+    }
+  }
+  
   emit("timeupdate");
 };
 
@@ -600,10 +840,39 @@ watch(
   { immediate: true }
 );
 
+// 全屏状态监听
+const handleFullscreenChange = (): void => {
+  isFullscreen.value = !!(
+    document.fullscreenElement ||
+    (document as any).webkitFullscreenElement ||
+    (document as any).mozFullScreenElement ||
+    (document as any).msFullscreenElement
+  );
+};
+
+// 组件挂载时添加全屏监听
+onMounted(() => {
+  document.addEventListener('fullscreenchange', handleFullscreenChange);
+  document.addEventListener('webkitfullscreenchange', handleFullscreenChange);
+  document.addEventListener('mozfullscreenchange', handleFullscreenChange);
+  document.addEventListener('MSFullscreenChange', handleFullscreenChange);
+});
+
 // 组件卸载时清理资源
 onUnmounted(() => {
   destroyHls();
   
+  // 清理全屏监听
+  document.removeEventListener('fullscreenchange', handleFullscreenChange);
+  document.removeEventListener('webkitfullscreenchange', handleFullscreenChange);
+  document.removeEventListener('mozfullscreenchange', handleFullscreenChange);
+  document.removeEventListener('MSFullscreenChange', handleFullscreenChange);
+  
+  // 清理控制条定时器
+  if (controlsTimer) {
+    clearTimeout(controlsTimer);
+  }
+  
   // 清理 Blob URL
   if (processedCoverUrl.value?.startsWith("blob:")) {
     URL.revokeObjectURL(processedCoverUrl.value);
@@ -648,6 +917,216 @@ defineExpose({
   height: 100%;
 }
 
+/* 自定义控制条 */
+.custom-controls {
+  position: absolute;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  pointer-events: none;
+  opacity: 0;
+  transition: opacity 0.3s ease;
+  z-index: 5;
+}
+
+.custom-controls.show {
+  opacity: 1;
+}
+
+.custom-controls > * {
+  pointer-events: auto;
+}
+
+/* 中央播放按钮 */
+.play-button-center {
+  position: absolute;
+  top: 50%;
+  left: 50%;
+  transform: translate(-50%, -50%);
+  width: 80px;
+  height: 80px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: rgba(0, 0, 0, 0.6);
+  border-radius: 50%;
+  cursor: pointer;
+  transition: all 0.3s ease;
+  backdrop-filter: blur(10px);
+}
+
+.play-button-center:hover {
+  background: rgba(0, 0, 0, 0.8);
+  transform: translate(-50%, -50%) scale(1.1);
+}
+
+/* 底部控制栏 */
+.controls-bottom {
+  position: absolute;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: linear-gradient(to top, rgba(0, 0, 0, 0.8) 0%, rgba(0, 0, 0, 0.4) 70%, transparent 100%);
+  padding: 10px 15px;
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+
+/* 进度条容器 */
+.progress-container {
+  width: 100%;
+  padding: 8px 0;
+  cursor: pointer;
+}
+
+.progress-bar {
+  position: relative;
+  width: 100%;
+  height: 4px;
+  background: rgba(255, 255, 255, 0.3);
+  border-radius: 2px;
+  overflow: hidden;
+}
+
+.progress-buffered {
+  position: absolute;
+  top: 0;
+  left: 0;
+  height: 100%;
+  background: rgba(255, 255, 255, 0.5);
+  border-radius: 2px;
+  transition: width 0.2s ease;
+}
+
+.progress-played {
+  position: absolute;
+  top: 0;
+  left: 0;
+  height: 100%;
+  background: #3b82f6;
+  border-radius: 2px;
+  transition: width 0.1s ease;
+}
+
+/* 控制按钮区域 */
+.controls-buttons {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+
+.controls-left,
+.controls-right {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+}
+
+.control-btn {
+  background: none;
+  border: none;
+  padding: 8px;
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  transition: all 0.2s ease;
+  border-radius: 4px;
+}
+
+.control-btn:hover {
+  background: rgba(255, 255, 255, 0.2);
+}
+
+.control-btn:active {
+  transform: scale(0.95);
+}
+
+/* 时间显示 */
+.time-display {
+  color: white;
+  font-size: 14px;
+  font-weight: 500;
+  white-space: nowrap;
+  user-select: none;
+}
+
+/* 音量控制 */
+.volume-control {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.volume-slider {
+  width: 60px;
+  height: 4px;
+  appearance: none;
+  background: rgba(255, 255, 255, 0.3);
+  border-radius: 2px;
+  outline: none;
+  cursor: pointer;
+}
+
+.volume-slider::-webkit-slider-thumb {
+  appearance: none;
+  width: 12px;
+  height: 12px;
+  background: white;
+  border-radius: 50%;
+  cursor: pointer;
+}
+
+.volume-slider::-moz-range-thumb {
+  width: 12px;
+  height: 12px;
+  background: white;
+  border-radius: 50%;
+  border: none;
+  cursor: pointer;
+}
+
+/* 移动端优化 */
+@media screen and (max-width: 768px) {
+  .controls-bottom {
+    padding: 8px 12px;
+  }
+
+  .play-button-center {
+    width: 60px;
+    height: 60px;
+  }
+
+  .time-display {
+    font-size: 12px;
+  }
+
+  .control-btn {
+    padding: 6px;
+  }
+
+  .volume-slider {
+    display: none; /* 移动端隐藏音量滑块 */
+  }
+}
+
+/* iOS全屏样式优化 */
+@media screen and (max-width: 768px) {
+  .video-processor:fullscreen .custom-controls,
+  .video-processor:-webkit-full-screen .custom-controls,
+  .video-processor:-moz-full-screen .custom-controls {
+    background: rgba(0, 0, 0, 0.3);
+  }
+
+  .video-processor:fullscreen .controls-bottom,
+  .video-processor:-webkit-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%);
+  }
+}
+
 .loading-overlay {
   position: absolute;
   top: 0;