Explorar o código

🐛 fix(pms): 修复海康视频全屏与单视口放大交互

- 使用 video-stage 统一承载原生全屏,保留视口标题和悬浮操作按钮
- 关闭 H5Player 内置双击全屏,通过 windowEventSelect 获取当前选中视口
- 双击已播放视口时仅放大对应画面,双击空视口不执行操作
- 保持底部全屏按钮的整体分屏全屏功能
- 全屏状态下隐藏底部全局操作栏,并让视频区域自动铺满屏幕
- 禁止 Tooltip 和 Popover 传送至 body,避免全屏后弹层不可见
- 监听 fullscreenchange,同步 Esc 退出后的状态并恢复原有分屏布局
Zimo hai 1 semana
pai
achega
73fea42caa
Modificáronse 1 ficheiros con 194 adicións e 29 borrados
  1. 194 29
      src/views/pms/video_center/haikang/index.vue

+ 194 - 29
src/views/pms/video_center/haikang/index.vue

@@ -18,13 +18,16 @@ import {
 } from '@/utils/hikvisionH5Player'
 import { Loading, Location, Search, VideoCamera } from '@element-plus/icons-vue'
 import type { LoadFunction } from 'element-plus/es/components/tree/src/tree.type'
-import {IotDeviceApi} from "@/api/pms/device";
-import {IotOpeationFillApi} from "@/api/pms/iotopeationfill";
+import { IotDeviceApi } from '@/api/pms/device'
+import { IotOpeationFillApi } from '@/api/pms/iotopeationfill'
 
 defineOptions({ name: 'HikvisionVideoCenter' })
 
 const layoutTriggerRef = ref<HTMLElement>()
 const playerContainerRef = ref<HTMLElement>()
+// 原生全屏必须挂在同时包含播放器、悬浮层和工具栏的共同容器上。
+// 如果继续让 H5Player 仅全屏自己的挂载节点,兄弟节点形式的 Vue 悬浮层会被浏览器排除在全屏画面外。
+const videoStageRef = ref<HTMLElement>()
 const keyword = ref('')
 const treeData = ref<HikvisionTreeNode[]>([])
 const expandedKeys = ref<string[]>([])
@@ -64,6 +67,8 @@ const activePtzCommand = ref<HikvisionPtzCommand>()
 const activePtzWindowIndex = ref<number>()
 const sidebarCollapsed = ref(false)
 const stageFullscreen = ref(false)
+// undefined 表示整体分屏全屏;有值时表示双击后只放大对应的播放器窗口。
+const singleFullscreenWindowIndex = ref<number>()
 const fullscreenControlLoading = ref(false)
 
 type SplitCount = 1 | 4 | 9 | 16
@@ -631,7 +636,9 @@ function initPlayer(): Promise<HikvisionPlayerInstance> {
         // 指南建议高分辨率画面开启多线程解码;仅在页面具备跨源隔离时启用,
         // 避免未配置 COOP/COEP 的部署环境错误使用 SharedArrayBuffer。
         mseWorkerEnable: window.crossOriginIsolated,
-        bSupporDoubleClickFull: true,
+        // SDK 的双击全屏只包含其自身 DOM,无法带上页面叠加的标题和操作栏。
+        // 页面改为捕获播放器双击并全屏整个 video-stage,因此关闭 SDK 内置双击行为。
+        bSupporDoubleClickFull: false,
         oStyle: { borderSelect: '#facc15' }
       })
 
@@ -647,11 +654,6 @@ function initPlayer(): Promise<HikvisionPlayerInstance> {
         windowEventOut: (windowIndex) => {
           if (hoveredWindowIndex.value === windowIndex) hoveredWindowIndex.value = undefined
         },
-        windowFullCcreenChange: (fullscreen) => {
-          // 双击也能触发全屏,因此以 SDK 回调作为页面全屏状态的最终来源。
-          stageFullscreen.value = fullscreen
-          schedulePlayerResize()
-        },
         firstFrameDisplay: (windowIndex) => {
           // JS_Play 成功只代表播放命令已接受;首帧回调才代表用户真正看到了画面。
           const playingRequestId = previewPlayingWindowRequests.get(windowIndex)
@@ -1278,6 +1280,7 @@ async function resizePlayer() {
   playerResizeInProgress = true
   try {
     await currentPlayer.JS_Resize(clientWidth, clientHeight)
+    syncSingleWindowPlayerLayout()
   } catch {
     // 容器变化和播放器销毁可能发生在同一帧,尺寸同步失败无需打断预览。
   } finally {
@@ -1327,13 +1330,83 @@ async function stopAllPreviews() {
   }
 }
 
+/**
+ * H5Player 的 JS_FullScreenSingle 会只全屏 SDK 内部节点,使 Vue 悬浮操作层再次被浏览器排除。
+ * 因此仍由 video-stage 承载原生全屏,只把被双击的 SDK 子窗口铺满播放器容器。
+ */
+function syncSingleWindowPlayerLayout() {
+  const targetWindowIndex = stageFullscreen.value ? singleFullscreenWindowIndex.value : undefined
+  const playerWindows = playerContainerRef.value?.querySelectorAll<HTMLElement>('.sub-wnd') ?? []
+
+  playerWindows.forEach((playerWindow, windowIndex) => {
+    playerWindow.classList.toggle('is-single-fullscreen-target', targetWindowIndex === windowIndex)
+  })
+}
+
+async function handlePlayerDoubleClick() {
+  if (fullscreenControlLoading.value) return
+
+  // H5Player 会先通过 windowEventSelect 回调同步当前选中窗口,双击只消费 SDK 给出的下标,
+  // 不根据坐标重复推算分屏位置,避免边框、缩放或 SDK 布局调整造成误判。
+  const windowIndex = activeWindowIndex.value
+  // 空视口没有可放大的监控画面,也不应改变当前全屏状态。
+  if (!previewWindowCameras.value[windowIndex]) return
+
+  const videoStage = videoStageRef.value
+  if (!videoStage) return
+
+  fullscreenControlLoading.value = true
+  try {
+    // 已经处于单视口全屏时,再次双击等同于退出全屏。
+    if (
+      document.fullscreenElement === videoStage &&
+      singleFullscreenWindowIndex.value !== undefined
+    ) {
+      await document.exitFullscreen()
+      return
+    }
+
+    activeWindowIndex.value = windowIndex
+    singleFullscreenWindowIndex.value = windowIndex
+
+    if (document.fullscreenElement === videoStage) {
+      // 从整体分屏全屏切换到单视口时无需再次请求 Fullscreen API,只更新内部布局。
+      await nextTick()
+      syncSingleWindowPlayerLayout()
+      schedulePlayerResize()
+    } else {
+      await videoStage.requestFullscreen()
+    }
+  } catch (error) {
+    singleFullscreenWindowIndex.value = undefined
+    syncSingleWindowPlayerLayout()
+    console.error('切换单视口全屏失败', error)
+    showPreviewError(error instanceof Error ? error.message : '切换单视口全屏失败')
+  } finally {
+    fullscreenControlLoading.value = false
+  }
+}
+
 async function toggleFullscreen() {
   if (fullscreenControlLoading.value) return
 
+  const videoStage = videoStageRef.value
+  if (!videoStage) {
+    showPreviewError('视频区域尚未加载完成')
+    return
+  }
+
   fullscreenControlLoading.value = true
   try {
-    const currentPlayer = await initPlayer()
-    await currentPlayer.JS_FullScreenDisplay(!stageFullscreen.value)
+    // 浏览器全屏只展示目标元素及其后代。video-stage 同时包含 SDK 播放器、Vue 悬浮层和底部工具栏,
+    // 因而全屏后 windowEventOver/Out 仍能驱动原有悬浮状态,按钮也仍可接收指针事件。
+    if (document.fullscreenElement === videoStage) {
+      await document.exitFullscreen()
+    } else {
+      // 工具栏的全屏按钮保持“整体分屏全屏”语义,只有双击视口才设置单窗口下标。
+      singleFullscreenWindowIndex.value = undefined
+      await videoStage.requestFullscreen()
+    }
   } catch (error) {
     console.error('切换监控全屏失败', error)
     showPreviewError(error instanceof Error ? error.message : '切换全屏失败')
@@ -1342,6 +1415,16 @@ async function toggleFullscreen() {
   }
 }
 
+function handleFullscreenChange() {
+  // Esc、浏览器菜单等都可能退出全屏,必须以 Fullscreen API 的真实状态同步按钮图标。
+  stageFullscreen.value = document.fullscreenElement === videoStageRef.value
+  if (!stageFullscreen.value) singleFullscreenWindowIndex.value = undefined
+  void nextTick(() => {
+    syncSingleWindowPlayerLayout()
+    schedulePlayerResize()
+  })
+}
+
 async function toggleSidebar() {
   sidebarCollapsed.value = !sidebarCollapsed.value
   await nextTick()
@@ -1387,6 +1470,7 @@ watch(keyword, (value, previousValue) => {
 onMounted(() => {
   componentUnmounted = false
   window.addEventListener('resize', schedulePlayerResize)
+  document.addEventListener('fullscreenchange', handleFullscreenChange)
   if (playerContainerRef.value) {
     // window.resize 无法覆盖侧栏折叠等局部布局变化,因此额外观察播放器容器本身。
     playerResizeObserver = new ResizeObserver(schedulePlayerResize)
@@ -1398,6 +1482,7 @@ onMounted(() => {
 onBeforeUnmount(() => {
   componentUnmounted = true
   treeRequestId += 1
+  document.removeEventListener('fullscreenchange', handleFullscreenChange)
   // 页面离开时补发停止;即使开始请求仍在途中,停止流程也会等待并保持正确顺序。
   void stopActivePtzControl()
   void destroyPlayer()
@@ -1472,8 +1557,18 @@ onBeforeUnmount(() => {
     </aside>
 
     <main class="video-workspace">
-      <section class="video-stage">
-        <div :id="playerContainerId" ref="playerContainerRef" class="player-container"></div>
+      <section
+        ref="videoStageRef"
+        class="video-stage"
+        :class="{
+          'is-single-window-fullscreen':
+            stageFullscreen && singleFullscreenWindowIndex !== undefined
+        }">
+        <div
+          :id="playerContainerId"
+          ref="playerContainerRef"
+          class="player-container"
+          @dblclick.capture="handlePlayerDoubleClick"></div>
         <div
           class="window-loading-layer"
           :style="{
@@ -1486,6 +1581,7 @@ onBeforeUnmount(() => {
             class="window-loading-cell"
             :class="{
               'is-active': activeWindowIndex === windowIndex,
+              'is-single-fullscreen-target': singleFullscreenWindowIndex === windowIndex,
               'is-hovered':
                 hoveredWindowIndex === windowIndex ||
                 hoveredControlWindowIndex === windowIndex ||
@@ -1528,7 +1624,10 @@ onBeforeUnmount(() => {
               class="window-hover-actions"
               @mouseenter="hoveredControlWindowIndex = windowIndex"
               @mouseleave="hoveredControlWindowIndex = undefined">
-              <el-tooltip :content="getWindowStreamSwitchTitle(windowIndex)" placement="top">
+              <el-tooltip
+                :content="getWindowStreamSwitchTitle(windowIndex)"
+                placement="top"
+                :teleported="false">
                 <button
                   class="window-action-button"
                   type="button"
@@ -1539,7 +1638,7 @@ onBeforeUnmount(() => {
                   <i class="i-lucide:refresh-cw"></i>
                 </button>
               </el-tooltip>
-              <el-tooltip content="抓取当前画面" placement="top">
+              <el-tooltip content="抓取当前画面" placement="top" :teleported="false">
                 <button
                   class="window-action-button"
                   type="button"
@@ -1550,7 +1649,10 @@ onBeforeUnmount(() => {
                   <i class="i-lucide:camera"></i>
                 </button>
               </el-tooltip>
-              <el-tooltip :content="getWindowZoomTitle(windowIndex)" placement="top">
+              <el-tooltip
+                :content="getWindowZoomTitle(windowIndex)"
+                placement="top"
+                :teleported="false">
                 <button
                   class="window-action-button"
                   :class="{ 'is-zooming': isWindowZoomEnabled(windowIndex) }"
@@ -1571,7 +1673,10 @@ onBeforeUnmount(() => {
                     "></i>
                 </button>
               </el-tooltip>
-              <el-tooltip :content="getWindowSoundTitle(windowIndex)" placement="top">
+              <el-tooltip
+                :content="getWindowSoundTitle(windowIndex)"
+                placement="top"
+                :teleported="false">
                 <button
                   class="window-action-button"
                   :class="{ active: isWindowSoundEnabled(windowIndex) }"
@@ -1588,7 +1693,10 @@ onBeforeUnmount(() => {
                     "></i>
                 </button>
               </el-tooltip>
-              <el-tooltip :content="getWindowTalkTitle(windowIndex)" placement="top">
+              <el-tooltip
+                :content="getWindowTalkTitle(windowIndex)"
+                placement="top"
+                :teleported="false">
                 <button
                   class="window-action-button"
                   :class="{ 'is-talking': isWindowTalking(windowIndex) }"
@@ -1619,6 +1727,7 @@ onBeforeUnmount(() => {
                 trigger="click"
                 :width="154"
                 :show-arrow="false"
+                :teleported="false"
                 popper-class="hikvision-ptz-popover"
                 @show="handleWindowPtzPopoverShow(windowIndex)"
                 @hide="handleWindowPtzPopoverHide(windowIndex)">
@@ -1693,7 +1802,8 @@ onBeforeUnmount(() => {
         <div class="video-toolbar">
           <el-tooltip
             :content="sidebarCollapsed ? '展开监控点目录' : '收起监控点目录'"
-            placement="top">
+            placement="top"
+            :teleported="false">
             <button
               type="button"
               class="sidebar-toggle"
@@ -1706,7 +1816,7 @@ onBeforeUnmount(() => {
           </el-tooltip>
 
           <div class="toolbar-actions">
-            <el-tooltip content="关闭全部监控画面" placement="top">
+            <el-tooltip content="关闭全部监控画面" placement="top" :teleported="false">
               <button
                 type="button"
                 class="stop-all-trigger"
@@ -1719,7 +1829,7 @@ onBeforeUnmount(() => {
               </button>
             </el-tooltip>
 
-            <el-tooltip content="全部抓图" placement="top">
+            <el-tooltip content="全部抓图" placement="top" :teleported="false">
               <button
                 type="button"
                 class="capture-all-trigger"
@@ -1732,7 +1842,7 @@ onBeforeUnmount(() => {
               </button>
             </el-tooltip>
 
-            <el-tooltip :content="globalSoundTitle" placement="top">
+            <el-tooltip :content="globalSoundTitle" placement="top" :teleported="false">
               <button
                 type="button"
                 class="sound-all-trigger"
@@ -1748,7 +1858,11 @@ onBeforeUnmount(() => {
               </button>
             </el-tooltip>
 
-            <el-tooltip content="分屏布局" placement="top" :disabled="splitPopoverVisible">
+            <el-tooltip
+              content="分屏布局"
+              placement="top"
+              :disabled="splitPopoverVisible"
+              :teleported="false">
               <button
                 ref="layoutTriggerRef"
                 type="button"
@@ -1770,6 +1884,7 @@ onBeforeUnmount(() => {
               trigger="click"
               :width="176"
               :show-arrow="false"
+              :teleported="false"
               virtual-triggering
               popper-class="hikvision-split-popover">
               <div class="split-menu" role="group" aria-label="分屏布局">
@@ -1792,7 +1907,10 @@ onBeforeUnmount(() => {
               </div>
             </el-popover>
 
-            <el-tooltip :content="stageFullscreen ? '退出全屏' : '全屏'" placement="top">
+            <el-tooltip
+              :content="stageFullscreen ? '退出全屏' : '全屏'"
+              placement="top"
+              :teleported="false">
               <button
                 type="button"
                 class="fullscreen-trigger"
@@ -1814,6 +1932,12 @@ onBeforeUnmount(() => {
 </template>
 
 <style scoped lang="scss">
+@media (width <= 1200px) {
+  .monitor-panel {
+    --monitor-panel-width: 240px;
+  }
+}
+
 .hikvision-shell {
   --navy: #0f2742;
   --video-toolbar-height: 49px;
@@ -1975,6 +2099,53 @@ onBeforeUnmount(() => {
   background: #0b1118;
 }
 
+/*
+ * 全屏只展示监控画面和各视口自己的悬浮操作栏,不保留页面级底部工具栏。
+ * 将预留高度归零后,播放器和透明悬浮网格会同步补满工具栏释放出的 49px 空间。
+ */
+.video-stage:fullscreen {
+  --video-toolbar-height: 0px;
+}
+
+.video-stage:fullscreen .video-toolbar {
+  display: none;
+}
+
+/*
+ * 双击视口时只放大目标窗口。SDK 窗口与 Vue 悬浮单元格使用相同的下标,
+ * 两层同时隐藏非目标项,保证画面、标题和操作按钮仍准确重叠。
+ */
+.video-stage.is-single-window-fullscreen .player-container :deep(.parent-wnd) {
+  position: relative !important;
+}
+
+.video-stage.is-single-window-fullscreen .player-container :deep(.sub-wnd) {
+  display: none !important;
+}
+
+.video-stage.is-single-window-fullscreen
+  .player-container
+  :deep(.sub-wnd.is-single-fullscreen-target) {
+  position: absolute !important;
+  display: block !important;
+  width: 100% !important;
+  height: 100% !important;
+  inset: 0 !important;
+}
+
+.video-stage.is-single-window-fullscreen .window-loading-layer {
+  grid-template-rows: minmax(0, 1fr) !important;
+  grid-template-columns: minmax(0, 1fr) !important;
+}
+
+.video-stage.is-single-window-fullscreen .window-loading-cell {
+  display: none;
+}
+
+.video-stage.is-single-window-fullscreen .window-loading-cell.is-single-fullscreen-target {
+  display: block;
+}
+
 .player-container {
   position: absolute;
   width: 100%;
@@ -2458,10 +2629,4 @@ onBeforeUnmount(() => {
   border-color: #545c66;
   box-shadow: 0 8px 20px rgb(15 23 42 / 32%);
 }
-
-@media (width <= 1200px) {
-  .monitor-panel {
-    --monitor-panel-width: 240px;
-  }
-}
 </style>