|
|
@@ -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>
|