Browse Source

feat: 实现单活视频管理器优化视频播放体验

新增 useVideoManager 单例管理视频实例,实现同一时间仅一个视频持有 src 以释放解码器资源
移除原页面级视频互斥播放监听,改为组件内通过 play 事件触发管理器调度
优化视频播放策略,支持 HLS/FLV 流媒体适配,处理自动播放兼容性问题
添加缩略图模式,优化视频组件加载性能,处理视频释放后的状态缓存
lsc 5 days ago
parent
commit
7b20e8cd64

+ 126 - 0
src/hooks/useVideoManager.ts

@@ -0,0 +1,126 @@
+/**
+ * useVideoManager —— 单活视频管理器
+ * ---------------------------------------------------------------------------
+ * 移动端 / 平板的 H.264 硬件解码器实例有限(1080p 约 4~8 路,4K 只有 1~2 路)。
+ * 同一时刻只允许一个 <video> 持有 src,释放其他所有(pause + removeAttribute + load)。
+ *
+ * 职责:只管"释放"(归还解码器)和"注册/注销",不管 src 的挂载。
+ * src 的设置由调用方(VideoPlayer / useMSE)自己决定。
+ * ---------------------------------------------------------------------------
+ */
+
+interface VMMeta {
+  duration: number
+  currentTime: number
+  played: boolean
+}
+
+function getMeta(el: HTMLVideoElement): VMMeta {
+  if (!(el as any)._vmMeta) {
+    (el as any)._vmMeta = { duration: 0, currentTime: 0, played: false }
+  }
+  return (el as any)._vmMeta as VMMeta
+}
+
+// 单例
+class VideoManager {
+  current: HTMLVideoElement | null = null
+  private _els: HTMLVideoElement[] = []
+
+  /** 注册一个 video 元素 */
+  register(el: HTMLVideoElement) {
+    if (!el || (el as any)._vmRegistered) return
+    ;(el as any)._vmRegistered = true
+
+    const m = getMeta(el)
+    el.addEventListener('loadedmetadata', () => {
+      if (isFinite(el.duration) && el.duration > 0) m.duration = el.duration
+    })
+    el.addEventListener('durationchange', () => {
+      if (isFinite(el.duration) && el.duration > 0) m.duration = el.duration
+    })
+    el.addEventListener('timeupdate', () => {
+      if (el.currentTime > 0) m.currentTime = el.currentTime
+    })
+    el.addEventListener('play', () => {
+      m.played = true
+    })
+
+    this._els.push(el)
+  }
+
+  /**
+   * 释放除 keepEl 之外的所有 video:pause → removeAttribute('src') → load
+   * 必须用 load(),只删 src 不调 load 解码管线不会释放
+   */
+  releaseAllOthers(keepEl: HTMLVideoElement) {
+    this._els.forEach(other => {
+      if (other !== keepEl && other.src) {
+        this.release(other)
+      }
+    })
+    this.current = keepEl
+  }
+
+  /** 释放单个元素:归还解码器 + 释放内存 */
+  release(el: HTMLVideoElement) {
+    if (!el) return
+    const m = getMeta(el)
+
+    if (el.currentTime > 0) m.currentTime = el.currentTime
+
+    try {
+      el.pause()
+    }
+    catch {}
+    el.removeAttribute('src')
+    try {
+      el.load()
+    }
+    catch {}
+
+    // 关键:手动派发 pause 事件
+    // 某些浏览器(尤其 iOS Safari)在 pause → remove src → load 连续调用时,
+    // 原生 @pause 事件可能被"吞掉",导致外部状态(Vue 的 paused ref)没同步。
+    // 这里用 dispatchEvent 兜底,确保所有监听方都能收到。
+    try {
+      el.dispatchEvent(new Event('pause'))
+    }
+    catch {}
+
+    if (this.current === el) this.current = null
+  }
+
+  /** 释放全部 */
+  releaseAll() {
+    this._els.forEach(el => {
+      if (el.src) this.release(el)
+    })
+    this.current = null
+  }
+
+  /** 取缓存的时长/进度,释放后依然可用 */
+  getMeta(el: HTMLVideoElement) {
+    const m = getMeta(el)
+    return {
+      duration: m.duration || (isFinite(el.duration) ? el.duration : 0),
+      currentTime: m.currentTime || el.currentTime || 0,
+      played: m.played,
+    }
+  }
+
+  unregister(el: HTMLVideoElement) {
+    this.release(el)
+    this._els = this._els.filter(e => e !== el)
+    if (this.current === el) this.current = null
+  }
+}
+
+const _manager = new VideoManager()
+
+export function useVideoManager() {
+  return _manager
+}
+
+export { VideoManager }
+export default _manager

+ 0 - 12
src/views/Student/index.vue

@@ -4108,20 +4108,9 @@ const addOp3 = async (userTime: any, loadTime: any, object: any, status: any) =>
     })
 }
 
-// 视频互斥播放:任意 video 触发 play 时,暂停页面上其他所有 video
-const handleVideoPlay = (e: Event) => {
-  const target = e.target as HTMLVideoElement
-  if (!(target instanceof HTMLVideoElement)) return
-  document.querySelectorAll('video').forEach(v => {
-    if (v !== target && !v.paused) v.pause()
-  })
-}
-
 onMounted(() => {
   
   document.addEventListener('keydown', handleKeydown)
-  // capture 阶段监听,覆盖原生控件/脚本调用等所有触发路径
-  document.addEventListener('play', handleVideoPlay, true)
 
   // 处理URL参数
   if (props.courseid || props.type) {
@@ -4263,7 +4252,6 @@ onMounted(() => {
 
 onUnmounted(() => {
   document.removeEventListener('keydown', handleKeydown)
-  document.removeEventListener('play', handleVideoPlay, true)
   window.removeEventListener('resize', calculateScale)
   document.removeEventListener('fullscreenchange', handleFullscreenChange)
 

+ 0 - 12
src/views/Student/index2.vue

@@ -3924,20 +3924,9 @@ const addOp3 = async (userTime: any, loadTime: any, object: any, status: any) =>
     })
 }
 
-// 视频互斥播放:任意 video 触发 play 时,暂停页面上其他所有 video
-const handleVideoPlay = (e: Event) => {
-  const target = e.target as HTMLVideoElement
-  if (!(target instanceof HTMLVideoElement)) return
-  document.querySelectorAll('video').forEach(v => {
-    if (v !== target && !v.paused) v.pause()
-  })
-}
-
 onMounted(() => {
   
   document.addEventListener('keydown', handleKeydown)
-  // capture 阶段监听,覆盖原生控件/脚本调用等所有触发路径
-  document.addEventListener('play', handleVideoPlay, true)
 
   // 处理URL参数
   if (props.courseid || props.type) {
@@ -4047,7 +4036,6 @@ onMounted(() => {
 
 onUnmounted(() => {
   document.removeEventListener('keydown', handleKeydown)
-  document.removeEventListener('play', handleVideoPlay, true)
   window.removeEventListener('resize', calculateScale)
   document.removeEventListener('fullscreenchange', handleFullscreenChange)
 

+ 38 - 4
src/views/components/element/FrameElement/BaseFrameElement.vue

@@ -27,15 +27,17 @@
             <div class="spinner"></div>
           </div>
         </div>
-        <!-- 视频类型(type 74):使用 video 标签 -->
+        <!-- 视频类型(type 74):使用 video 标签,src 由 VideoManager 手动管理 -->
         <video
           v-if="elementInfo.toolType === 74 && !isThumbnail && isVisible"
-          :key="`video-${iframeKey}`"
-          :src="elementInfo.url"
+          ref="video74Ref"
           :width="width"
           :height="height"
           preload="none"
+          playsinline
+          webkit-playsinline
           controls
+          @play="onVideoPlay"
           :style="{ width: '100%', height: '100%', objectFit: 'contain' }"
         ></video>
         <!-- 英语口语预览(type 77):使用 Vue 组件直接渲染,url 字段即 configId -->
@@ -120,13 +122,15 @@ import {
   isArticleReadingTool,
   isTopicDiscussionTool,
 } from '@/configs/englishSpeakingTools'
-import { ref, watch, nextTick } from 'vue'
+import { ref, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
 import { computed } from 'vue'
 import useImport from '@/hooks/useImport'
 import { useSlidesStore } from '@/store/slides'
+import { useVideoManager } from '@/hooks/useVideoManager'
 import api from '@/services/course'
 
 const slidesStore = useSlidesStore()
+const manager = useVideoManager()
 const { getFile, getFile2 } = useImport()
 
 const props = defineProps({
@@ -150,6 +154,7 @@ const props = defineProps({
 
 // 用于强制刷新iframe的key
 const iframeKey = ref(0)
+const video74Ref = ref<HTMLVideoElement | null>(null)
 
 // 监听elementInfo.url的变化
 watch(
@@ -303,6 +308,35 @@ const getTypeLabel = (type: number): string => {
 
 const frameloading = ref(false)
 
+// toolType=74 视频播放时:释放其他所有 video 的 src → 自己挂 src → play
+const onVideoPlay = () => {
+  const video = video74Ref.value
+  if (!video) return
+
+  // ① 释放其他所有
+  manager.releaseAllOthers(video)
+
+  // ② 如果自己还没 src,手动挂上去
+  if (!video.src && elementInfo.url) {
+    video.muted = true // 绕过 iOS 自动播放策略
+    video.setAttribute('preload', 'auto')
+    video.src = elementInfo.url
+    video.load()
+  }
+
+  // ③ play() 吞掉自动播放拦截的异常
+  const p = video.play()
+  if (p && (p as Promise<void>).catch) (p as Promise<void>).catch(() => {})
+}
+
+// toolType=74 video 生命周期注册(manager track meta + release)
+onMounted(() => {
+  if (video74Ref.value) manager.register(video74Ref.value)
+})
+onBeforeUnmount(() => {
+  if (video74Ref.value) manager.unregister(video74Ref.value)
+})
+
 // 处理iframe加载完成事件
 const handleIframeLoad = async (event: Event) => {
   const iframe = event.target as HTMLIFrameElement

+ 87 - 17
src/views/components/element/VideoElement/VideoPlayer/index.vue

@@ -1,5 +1,20 @@
 <template>
+  <!-- 缩略图模式:不加载视频 src,只显示 poster 背景 + play 图标(和 BaseVideoElement 一致) -->
+  <div
+    v-if="thumbnail"
+    class="video-thumbnail"
+    :style="{
+      width: width * scale + 'px',
+      height: height * scale + 'px',
+      transform: `scale(${1 / scale})`,
+      backgroundImage: poster ? `url(${poster})` : '',
+    }"
+  >
+    <IconPlayOne class="thumb-play-icon" />
+  </div>
+
   <div 
+    v-else
     class="video-player"
     :class="{ 'hide-controller': hideController }" 
     :style="{
@@ -18,8 +33,6 @@
         class="video"
         preload="none"
         ref="videoRef"
-        :src="src"
-        :autoplay="autoplay"
         :poster="poster"
         webkit-playsinline
         playsinline
@@ -123,9 +136,12 @@
 </template>
 
 <script lang="ts" setup>
-import { computed, ref, useTemplateRef, onMounted } from 'vue'
+import { computed, ref, useTemplateRef, onMounted, onBeforeUnmount } from 'vue'
 import { lang } from '@/main'
 import useMSE from './useMSE'
+import { useVideoManager } from '@/hooks/useVideoManager'
+
+const manager = useVideoManager()
 
 const props = withDefaults(defineProps<{
   width: number
@@ -201,15 +217,32 @@ const play = () => {
   if (!videoRef.value) return
 
   paused.value = false
-  videoRef.value.play()
   bezelTransition.value = true
+
+  // ① 先释放其他所有 video 的 src(解码器互斥)
+  manager.releaseAllOthers(videoRef.value)
+
+  // ② 如果自己还没挂 src,手动挂上去(Vue template 里不绑定 src,完全手动管理)
+  if (!videoRef.value.src) {
+    videoRef.value.muted = true // 绕过 iOS 自动播放策略
+    videoRef.value.setAttribute('preload', 'auto')
+    videoRef.value.src = props.src
+    videoRef.value.load()
+  }
+
+  // ③ play() 必须在用户手势任务内
+  const p = videoRef.value.play()
+  if (p && (p as Promise<void>).catch) (p as Promise<void>).catch(() => {})
 }
 
 const pause = () => {
   if (!videoRef.value) return
 
   paused.value = true
-  videoRef.value.pause()
+  try {
+    videoRef.value.pause() 
+  }
+  catch {}
   bezelTransition.value = true
 }
 
@@ -235,11 +268,26 @@ const speed = (rate: number) => {
 }
 
 const handleDurationchange = () => {
-  duration.value = videoRef.value?.duration || 0
+  const v = videoRef.value
+  if (!v) return
+  // release 后 video 没 src、duration=0,回退到 manager 缓存
+  if (!v.src || !isFinite(v.duration)) {
+    duration.value = manager.getMeta(v).duration || 0
+  }
+  else {
+    duration.value = v.duration
+  }
 }
 
 const handleTimeupdate = () => {
-  currentTime.value = videoRef.value?.currentTime || 0
+  const v = videoRef.value
+  if (!v) return
+  if (!v.src) {
+    currentTime.value = manager.getMeta(v).currentTime || 0
+  }
+  else {
+    currentTime.value = v.currentTime || 0
+  }
 }
 
 const handleEnded = () => {
@@ -251,7 +299,13 @@ const handleEnded = () => {
 }
 
 const handleProgress = () => {
-  loaded.value = videoRef.value?.buffered.length ? videoRef.value.buffered.end(videoRef.value.buffered.length - 1) : 0
+  const v = videoRef.value
+  if (!v || !v.src) {
+    loaded.value = 0
+  }
+  else {
+    loaded.value = v.buffered.length ? v.buffered.end(v.buffered.length - 1) : 0
+  }
 }
 
 const loadError = ref(false)
@@ -363,15 +417,31 @@ const autoHideController = () => {
 
 const bgCanvasRef = useTemplateRef<HTMLCanvasElement>('bgCanvasRef')
 onMounted(() => {
-  if (!bgCanvasRef.value || !videoRef.value) return
-  const ctx = bgCanvasRef.value.getContext('2d')
-  if (!ctx) return
-
-  videoRef.value.addEventListener('loadedmetadata', () => {
-    videoRef.value!.requestVideoFrameCallback(() => {
-      ctx.drawImage(videoRef.value!, 0, 0, bgCanvasRef.value!.width, bgCanvasRef.value!.height)
-    })
-  }, { once: true })
+  if (!videoRef.value) return
+
+  // 注册到 manager:track meta + 支持 release
+  manager.register(videoRef.value)
+
+  if (bgCanvasRef.value) {
+    const ctx = bgCanvasRef.value.getContext('2d')
+    if (ctx) {
+      videoRef.value.addEventListener('loadedmetadata', () => {
+        videoRef.value!.requestVideoFrameCallback(() => {
+          ctx.drawImage(videoRef.value!, 0, 0, bgCanvasRef.value!.width, bgCanvasRef.value!.height)
+        })
+      }, { once: true })
+    }
+  }
+
+  // autoplay:走 play() 流程 → releaseOthers → 挂 src → play
+  // iOS 自动播放要求 muted,play() 里已经处理了
+  if (props.autoplay) {
+    play()
+  }
+})
+
+onBeforeUnmount(() => {
+  if (videoRef.value) manager.unregister(videoRef.value)
 })
 
 useMSE(props.src, videoRef)

+ 94 - 20
src/views/components/element/VideoElement/VideoPlayer/useMSE.ts

@@ -1,41 +1,115 @@
-import { onMounted, type ShallowRef } from 'vue'
+import { onMounted, onBeforeUnmount, watch, type ShallowRef } from 'vue'
 
+/**
+ * HLS / FLV 流媒体适配
+ * - 普通 MP4:什么都不用做,video 原生支持
+ * - m3u8(HLS):浏览器原生支持则走原生,否则用 hls.js
+ * - flv:必须用 flv.js
+ *
+ * manager 模式下 video mount 时没有 src(play 时才手动挂),
+ * 所以用 MutationObserver 等 src 出现后再初始化。
+ */
 export default (
   src: string,
   videoRef: ShallowRef<HTMLVideoElement | null>,
 ) => {
-  onMounted(() => {
-    if (!videoRef.value) return
+  let type = 'normal'
+  if (/m3u8(#|\?|$)/i.exec(src)) type = 'hls'
+  else if (/.flv(#|\?|$)/i.exec(src)) type = 'flv'
 
-    let type = 'normal'
-    if (/m3u8(#|\?|$)/i.exec(src)) type = 'hls'
-    else if (/.flv(#|\?|$)/i.exec(src)) type = 'flv'
+  let observer: MutationObserver | null = null
+  let hlsInstance: any = null
+  let flvInstance: any = null
 
-    if (videoRef.value && type === 'hls' && (videoRef.value.canPlayType('application/x-mpegURL') || videoRef.value.canPlayType('application/vnd.apple.mpegURL'))) {
+  const initMSE = (video: HTMLVideoElement) => {
+    // HLS 浏览器原生支持 → 走原生,不初始化 hls.js
+    if (type === 'hls' &&
+        (video.canPlayType('application/x-mpegURL') || video.canPlayType('application/vnd.apple.mpegURL'))) {
       type = 'normal'
+      return
     }
 
     if (type === 'hls') {
       // eslint-disable-next-line @typescript-eslint/no-explicit-any
       const Hls = (window as any).Hls
-      
-      if (Hls && Hls.isSupported()) {
-        const hls = new Hls()
-        hls.loadSource(src)
-        hls.attachMedia(videoRef.value)
+      if (Hls && Hls.isSupported() && !hlsInstance) {
+        hlsInstance = new Hls()
+        hlsInstance.loadSource(src)
+        hlsInstance.attachMedia(video)
       }
     }
     else if (type === 'flv') {
       // eslint-disable-next-line @typescript-eslint/no-explicit-any
       const flvjs = (window as any).flvjs
-      if (flvjs && flvjs.isSupported()) {
-        const flvPlayer = flvjs.createPlayer({
-          type: 'flv',
-          url: src,
-        })
-        flvPlayer.attachMediaElement(videoRef.value)
-        flvPlayer.load()
+      if (flvjs && flvjs.isSupported() && !flvInstance) {
+        flvInstance = flvjs.createPlayer({ type: 'flv', url: src })
+        flvInstance.attachMediaElement(video)
+        flvInstance.load()
+      }
+    }
+  }
+
+  onMounted(() => {
+    const video = videoRef.value
+    if (!video) return
+
+    // 如果 mount 时已经有 src(非 manager 模式),直接初始化
+    if (video.src) {
+      initMSE(video)
+      return
+    }
+
+    // manager 模式:等 play() 手动挂 src 后再初始化
+    observer = new MutationObserver(() => {
+      if (video.src) {
+        initMSE(video)
+      }
+      else {
+        // release 时 src 被摘掉,如果之前初始化过 Hls/flv,destroy 掉释放资源
+        if (hlsInstance) {
+          try {
+            hlsInstance.destroy() 
+          }
+          catch {}
+          hlsInstance = null
+        }
+        if (flvInstance) {
+          try {
+            flvInstance.pause() 
+          }
+          catch {}
+          try {
+            flvInstance.unload() 
+          }
+          catch {}
+          try {
+            flvInstance.detachMediaElement() 
+          }
+          catch {}
+          try {
+            flvInstance.destroy() 
+          }
+          catch {}
+          flvInstance = null
+        }
+      }
+    })
+    observer.observe(video, { attributes: true, attributeFilter: ['src'] })
+  })
+
+  onBeforeUnmount(() => {
+    observer?.disconnect()
+    if (hlsInstance) {
+      try {
+        hlsInstance.destroy() 
+      }
+      catch {} 
+    }
+    if (flvInstance) {
+      try {
+        flvInstance.destroy() 
       }
+      catch {} 
     }
   })
-}
+}