|
@@ -55,6 +55,9 @@ const activeWindowIndex = ref(0)
|
|
|
const hoveredWindowIndex = ref<number>()
|
|
const hoveredWindowIndex = ref<number>()
|
|
|
// 鼠标进入页面叠加按钮后 SDK 会触发 windowEventOut,单独记录按钮悬浮以保持栏可见。
|
|
// 鼠标进入页面叠加按钮后 SDK 会触发 windowEventOut,单独记录按钮悬浮以保持栏可见。
|
|
|
const hoveredControlWindowIndex = ref<number>()
|
|
const hoveredControlWindowIndex = ref<number>()
|
|
|
|
|
+// 拖拽期间才展示投放层,避免常驻透明层拦截 H5Player 的选窗、双击和电子放大事件。
|
|
|
|
|
+const draggingCamera = ref<HikvisionCameraNode>()
|
|
|
|
|
+const dragOverWindowIndex = ref<number>()
|
|
|
const splitPopoverVisible = ref(false)
|
|
const splitPopoverVisible = ref(false)
|
|
|
const splitControlLoading = ref(false)
|
|
const splitControlLoading = ref(false)
|
|
|
// 云台 Popover 的全局展示状态和所属窗口,用于确保弹层打开时对应视口工具栏保持可见。
|
|
// 云台 Popover 的全局展示状态和所属窗口,用于确保弹层打开时对应视口工具栏保持可见。
|
|
@@ -756,14 +759,16 @@ function initPlayer(): Promise<HikvisionPlayerInstance> {
|
|
|
return playerInitPromise
|
|
return playerInitPromise
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-async function playCameraPreview(camera: HikvisionCameraNode) {
|
|
|
|
|
|
|
+async function playCameraPreview(
|
|
|
|
|
+ camera: HikvisionCameraNode,
|
|
|
|
|
+ windowIndex = activeWindowIndex.value
|
|
|
|
|
+) {
|
|
|
if (stopAllLoading.value || splitControlLoading.value) {
|
|
if (stopAllLoading.value || splitControlLoading.value) {
|
|
|
showPreviewError('播放器正在切换状态,请稍后再选择监控点')
|
|
showPreviewError('播放器正在切换状态,请稍后再选择监控点')
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // 点击监控点时固定目标窗口,之后用户切换选中窗口不会改变本次请求的播放位置。
|
|
|
|
|
- const windowIndex = activeWindowIndex.value
|
|
|
|
|
|
|
+ // 开始请求时固定目标窗口,之后用户切换选中窗口不会改变本次请求的播放位置。
|
|
|
const requestId = beginWindowPreviewRequest(windowIndex)
|
|
const requestId = beginWindowPreviewRequest(windowIndex)
|
|
|
clearPreviewError()
|
|
clearPreviewError()
|
|
|
|
|
|
|
@@ -776,6 +781,13 @@ async function playCameraPreview(camera: HikvisionCameraNode) {
|
|
|
if (!isCurrentWindowPreviewRequest(windowIndex, requestId)) return
|
|
if (!isCurrentWindowPreviewRequest(windowIndex, requestId)) return
|
|
|
if (!previewUrl) throw new Error('预览地址为空')
|
|
if (!previewUrl) throw new Error('预览地址为空')
|
|
|
|
|
|
|
|
|
|
+ // 首次操作就是拖拽时播放器可能刚完成初始化,需要补一次 SDK 选窗;请求期间若用户已经
|
|
|
|
|
+ // 选择其他视口,则保持用户的新选择,不把当前窗口强行切回本次请求的目标视口。
|
|
|
|
|
+ if (activeWindowIndex.value === windowIndex) {
|
|
|
|
|
+ await currentPlayer.JS_SelectWnd(windowIndex).catch(() => undefined)
|
|
|
|
|
+ if (!isCurrentWindowPreviewRequest(windowIndex, requestId)) return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
// keepDecoder 为 0 时切换监控点前先停止旧流,确保旧解码资源得到回收。
|
|
// keepDecoder 为 0 时切换监控点前先停止旧流,确保旧解码资源得到回收。
|
|
|
// 替换摄像机前停止旧监控点的云台动作,停止请求必须使用旧 indexCode。
|
|
// 替换摄像机前停止旧监控点的云台动作,停止请求必须使用旧 indexCode。
|
|
|
await stopActivePtzControl(windowIndex)
|
|
await stopActivePtzControl(windowIndex)
|
|
@@ -1261,6 +1273,46 @@ function handleNodeClick(data: HikvisionTreeNode) {
|
|
|
void playCameraPreview(data)
|
|
void playCameraPreview(data)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+function isCameraDraggable(data: HikvisionTreeNode): data is HikvisionCameraNode {
|
|
|
|
|
+ return data.nodeType === 'camera' && data.available !== false && data.onlineStatus !== false
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function clearCameraDragState() {
|
|
|
|
|
+ draggingCamera.value = undefined
|
|
|
|
|
+ dragOverWindowIndex.value = undefined
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function handleCameraDragStart(event: DragEvent, data: HikvisionTreeNode) {
|
|
|
|
|
+ if (!isCameraDraggable(data)) {
|
|
|
|
|
+ event.preventDefault()
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ draggingCamera.value = data
|
|
|
|
|
+ dragOverWindowIndex.value = undefined
|
|
|
|
|
+ if (!event.dataTransfer) return
|
|
|
|
|
+
|
|
|
|
|
+ event.dataTransfer.effectAllowed = 'copy'
|
|
|
|
|
+ // Firefox 要求设置 dataTransfer 数据后才会真正启动原生拖拽。
|
|
|
|
|
+ event.dataTransfer.setData('text/plain', data.indexCode)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function handleWindowDragOver(event: DragEvent, windowIndex: number) {
|
|
|
|
|
+ if (!draggingCamera.value) return
|
|
|
|
|
+ dragOverWindowIndex.value = windowIndex
|
|
|
|
|
+ if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy'
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function handleCameraDrop(windowIndex: number) {
|
|
|
|
|
+ const camera = draggingCamera.value
|
|
|
|
|
+ clearCameraDragState()
|
|
|
|
|
+ if (!camera) return
|
|
|
|
|
+
|
|
|
|
|
+ // 投放后同步页面和 SDK 的当前窗口;JS_Play 仍使用显式下标,不依赖回调更新时序。
|
|
|
|
|
+ activeWindowIndex.value = windowIndex
|
|
|
|
|
+ void playCameraPreview(camera, windowIndex)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
async function resizePlayer() {
|
|
async function resizePlayer() {
|
|
|
playerResizeFrame = undefined
|
|
playerResizeFrame = undefined
|
|
|
const currentPlayer = player
|
|
const currentPlayer = player
|
|
@@ -1485,6 +1537,7 @@ onMounted(() => {
|
|
|
onBeforeUnmount(() => {
|
|
onBeforeUnmount(() => {
|
|
|
componentUnmounted = true
|
|
componentUnmounted = true
|
|
|
treeRequestId += 1
|
|
treeRequestId += 1
|
|
|
|
|
+ clearCameraDragState()
|
|
|
document.removeEventListener('fullscreenchange', handleFullscreenChange)
|
|
document.removeEventListener('fullscreenchange', handleFullscreenChange)
|
|
|
// 页面离开时补发停止;即使开始请求仍在途中,停止流程也会等待并保持正确顺序。
|
|
// 页面离开时补发停止;即使开始请求仍在途中,停止流程也会等待并保持正确顺序。
|
|
|
void stopActivePtzControl()
|
|
void stopActivePtzControl()
|
|
@@ -1530,8 +1583,13 @@ onBeforeUnmount(() => {
|
|
|
class="tree-node"
|
|
class="tree-node"
|
|
|
:class="{
|
|
:class="{
|
|
|
'is-camera': data.nodeType === 'camera',
|
|
'is-camera': data.nodeType === 'camera',
|
|
|
|
|
+ 'is-draggable': isCameraDraggable(data),
|
|
|
'is-unavailable': data.available === false
|
|
'is-unavailable': data.available === false
|
|
|
- }">
|
|
|
|
|
|
|
+ }"
|
|
|
|
|
+ :draggable="isCameraDraggable(data)"
|
|
|
|
|
+ :title="isCameraDraggable(data) ? `${data.name}:可拖到右侧视口播放` : undefined"
|
|
|
|
|
+ @dragstart.stop="handleCameraDragStart($event, data)"
|
|
|
|
|
+ @dragend.stop="clearCameraDragState">
|
|
|
<el-icon class="node-icon">
|
|
<el-icon class="node-icon">
|
|
|
<Location v-if="data.nodeType === 'region'" />
|
|
<Location v-if="data.nodeType === 'region'" />
|
|
|
<VideoCamera v-else />
|
|
<VideoCamera v-else />
|
|
@@ -1590,6 +1648,28 @@ onBeforeUnmount(() => {
|
|
|
ref="playerContainerRef"
|
|
ref="playerContainerRef"
|
|
|
class="player-container"
|
|
class="player-container"
|
|
|
@dblclick.capture="handlePlayerDoubleClick"></div>
|
|
@dblclick.capture="handlePlayerDoubleClick"></div>
|
|
|
|
|
+ <div
|
|
|
|
|
+ v-if="draggingCamera"
|
|
|
|
|
+ class="window-drop-layer"
|
|
|
|
|
+ :style="{
|
|
|
|
|
+ gridTemplateColumns: `repeat(${currentSplitColumns}, minmax(0, 1fr))`,
|
|
|
|
|
+ gridTemplateRows: `repeat(${currentSplitRows}, minmax(0, 1fr))`
|
|
|
|
|
+ }">
|
|
|
|
|
+ <div
|
|
|
|
|
+ v-for="windowIndex in currentSplitWindowIndexes"
|
|
|
|
|
+ :key="windowIndex"
|
|
|
|
|
+ class="window-drop-cell"
|
|
|
|
|
+ :class="{ 'is-drag-over': dragOverWindowIndex === windowIndex }"
|
|
|
|
|
+ @dragenter.prevent="dragOverWindowIndex = windowIndex"
|
|
|
|
|
+ @dragover.prevent="handleWindowDragOver($event, windowIndex)"
|
|
|
|
|
+ @drop.prevent.stop="handleCameraDrop(windowIndex)">
|
|
|
|
|
+ <div class="window-drop-hint">
|
|
|
|
|
+ <i class="i-lucide:monitor-down size-6"></i>
|
|
|
|
|
+ <span>投放到视口 {{ windowIndex + 1 }}</span>
|
|
|
|
|
+ <small>{{ draggingCamera.name }}</small>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
<div
|
|
<div
|
|
|
class="window-loading-layer"
|
|
class="window-loading-layer"
|
|
|
:style="{
|
|
:style="{
|
|
@@ -2054,6 +2134,14 @@ onBeforeUnmount(() => {
|
|
|
color: #334155;
|
|
color: #334155;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+.tree-node.is-draggable {
|
|
|
|
|
+ cursor: grab;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.tree-node.is-draggable:active {
|
|
|
|
|
+ cursor: grabbing;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
.tree-node.is-camera .node-icon {
|
|
.tree-node.is-camera .node-icon {
|
|
|
color: #2563eb;
|
|
color: #2563eb;
|
|
|
}
|
|
}
|
|
@@ -2227,6 +2315,63 @@ onBeforeUnmount(() => {
|
|
|
box-sizing: border-box !important;
|
|
box-sizing: border-box !important;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+.window-drop-layer {
|
|
|
|
|
+ position: absolute;
|
|
|
|
|
+ z-index: 10;
|
|
|
|
|
+ display: grid;
|
|
|
|
|
+ background: rgb(2 6 23 / 24%);
|
|
|
|
|
+ inset: 0 0 var(--video-toolbar-height);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.window-drop-cell {
|
|
|
|
|
+ position: relative;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ min-width: 0;
|
|
|
|
|
+ min-height: 0;
|
|
|
|
|
+ color: #bfdbfe;
|
|
|
|
|
+ background: rgb(15 23 42 / 46%);
|
|
|
|
|
+ border: 2px dashed rgb(147 197 253 / 62%);
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ justify-content: center;
|
|
|
|
|
+ transition:
|
|
|
|
|
+ color 0.15s ease,
|
|
|
|
|
+ background-color 0.15s ease,
|
|
|
|
|
+ border-color 0.15s ease;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.window-drop-cell.is-drag-over {
|
|
|
|
|
+ color: #fff;
|
|
|
|
|
+ background: rgb(37 99 235 / 48%);
|
|
|
|
|
+ border-color: #60a5fa;
|
|
|
|
|
+ box-shadow: inset 0 0 24px rgb(59 130 246 / 28%);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.window-drop-hint {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ max-width: calc(100% - 24px);
|
|
|
|
|
+ padding: 12px 18px;
|
|
|
|
|
+ font-size: 14px;
|
|
|
|
|
+ font-weight: 600;
|
|
|
|
|
+ text-align: center;
|
|
|
|
|
+ pointer-events: none;
|
|
|
|
|
+ background: rgb(2 6 23 / 68%);
|
|
|
|
|
+ border: 1px solid rgb(147 197 253 / 38%);
|
|
|
|
|
+ border-radius: 8px;
|
|
|
|
|
+ box-shadow: 0 8px 24px rgb(2 6 23 / 28%);
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ flex-direction: column;
|
|
|
|
|
+ gap: 4px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.window-drop-hint small {
|
|
|
|
|
+ max-width: 100%;
|
|
|
|
|
+ overflow: hidden;
|
|
|
|
|
+ font-weight: 400;
|
|
|
|
|
+ color: #cbd5e1;
|
|
|
|
|
+ text-overflow: ellipsis;
|
|
|
|
|
+ white-space: nowrap;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
.window-loading-layer {
|
|
.window-loading-layer {
|
|
|
position: absolute;
|
|
position: absolute;
|
|
|
z-index: 2;
|
|
z-index: 2;
|