|
|
@@ -0,0 +1,1214 @@
|
|
|
+<template>
|
|
|
+ <div class="hikvision-video" :class="{ 'is-editor': editorMode }">
|
|
|
+ <div v-if="editorMode" class="editor-placeholder">
|
|
|
+ <i class="i-lucide:cctv"></i>
|
|
|
+ <strong>{{ camera?.name || '视频监控' }}</strong>
|
|
|
+ <span>{{ camera ? '预览时自动播放' : '请在右侧属性中选择监控点' }}</span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-else ref="stageRef" class="video-stage" @dblclick.stop="toggleFullscreen">
|
|
|
+ <div :id="playerContainerId" ref="playerContainerRef" class="player-container"></div>
|
|
|
+
|
|
|
+ <div class="video-overlay">
|
|
|
+ <div v-if="camera" class="hover-title">
|
|
|
+ <span class="title-main">
|
|
|
+ <i class="i-lucide:video"></i>
|
|
|
+ <span>{{ camera.name }}</span>
|
|
|
+ </span>
|
|
|
+ <span class="status">{{ previewStatus }}</span>
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ class="close-button"
|
|
|
+ title="关闭预览"
|
|
|
+ aria-label="关闭预览"
|
|
|
+ @dblclick.stop
|
|
|
+ @click.stop="stopPreview">
|
|
|
+ <i class="i-lucide:circle-x"></i>
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-if="loading" class="center-state">
|
|
|
+ <i class="i-lucide:loader-circle animate-spin"></i>
|
|
|
+ <span>正在加载监控画面</span>
|
|
|
+ </div>
|
|
|
+ <div v-else-if="errorMessage" class="center-state is-error">
|
|
|
+ <i class="i-lucide:triangle-alert"></i>
|
|
|
+ <span>{{ errorMessage }}</span>
|
|
|
+ <button v-if="camera" type="button" @click.stop="play">重新预览</button>
|
|
|
+ </div>
|
|
|
+ <div v-else-if="!playing" class="center-state">
|
|
|
+ <i class="i-lucide:cctv"></i>
|
|
|
+ <span>{{ camera ? '监控画面已关闭' : '未选择监控点' }}</span>
|
|
|
+ <button v-if="camera" type="button" @click.stop="play">开始预览</button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-if="camera && (playing || loading)" class="hover-actions" @dblclick.stop>
|
|
|
+ <el-tooltip :content="streamSwitchTitle" placement="top" :teleported="false">
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ class="action-button"
|
|
|
+ :disabled="!canOperate || streamSwitching"
|
|
|
+ :aria-label="streamSwitchTitle"
|
|
|
+ @click.stop="switchStream">
|
|
|
+ <i
|
|
|
+ :class="
|
|
|
+ streamSwitching ? 'i-lucide:loader-circle animate-spin' : 'i-lucide:refresh-cw'
|
|
|
+ "></i>
|
|
|
+ </button>
|
|
|
+ </el-tooltip>
|
|
|
+
|
|
|
+ <el-tooltip content="抓取当前画面" placement="top" :teleported="false">
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ class="action-button"
|
|
|
+ :disabled="!canOperate || capturing"
|
|
|
+ aria-label="抓取当前画面"
|
|
|
+ @click.stop="capturePicture">
|
|
|
+ <i :class="capturing ? 'i-lucide:loader-circle animate-spin' : 'i-lucide:camera'"></i>
|
|
|
+ </button>
|
|
|
+ </el-tooltip>
|
|
|
+
|
|
|
+ <el-tooltip :content="zoomTitle" placement="top" :teleported="false">
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ class="action-button"
|
|
|
+ :class="{ active: zoomEnabled }"
|
|
|
+ :disabled="!canOperate || zoomLoading"
|
|
|
+ :aria-label="zoomTitle"
|
|
|
+ :aria-pressed="zoomEnabled"
|
|
|
+ @click.stop="toggleZoom">
|
|
|
+ <i
|
|
|
+ :class="
|
|
|
+ zoomLoading
|
|
|
+ ? 'i-lucide:loader-circle animate-spin'
|
|
|
+ : zoomEnabled
|
|
|
+ ? 'i-lucide:zoom-out'
|
|
|
+ : 'i-lucide:zoom-in'
|
|
|
+ "></i>
|
|
|
+ </button>
|
|
|
+ </el-tooltip>
|
|
|
+
|
|
|
+ <el-tooltip :content="soundTitle" placement="top" :teleported="false">
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ class="action-button"
|
|
|
+ :class="{ active: soundEnabled }"
|
|
|
+ :disabled="!canOperate || soundLoading"
|
|
|
+ :aria-label="soundTitle"
|
|
|
+ :aria-pressed="soundEnabled"
|
|
|
+ @click.stop="toggleSound">
|
|
|
+ <i :class="soundEnabled ? 'i-lucide:volume-2' : 'i-lucide:volume-x'"></i>
|
|
|
+ </button>
|
|
|
+ </el-tooltip>
|
|
|
+
|
|
|
+ <el-tooltip :content="talkTitle" placement="top" :teleported="false">
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ class="action-button"
|
|
|
+ :class="{ 'is-talking': talking }"
|
|
|
+ :disabled="!canOperate || talkLoading"
|
|
|
+ :aria-label="talkTitle"
|
|
|
+ :aria-pressed="talking"
|
|
|
+ @click.stop="toggleTalk">
|
|
|
+ <i
|
|
|
+ :class="
|
|
|
+ talkLoading
|
|
|
+ ? 'i-lucide:loader-circle animate-spin'
|
|
|
+ : talking
|
|
|
+ ? 'i-lucide:mic'
|
|
|
+ : 'i-lucide:mic-off'
|
|
|
+ "></i>
|
|
|
+ </button>
|
|
|
+ </el-tooltip>
|
|
|
+
|
|
|
+ <el-popover
|
|
|
+ v-model:visible="ptzVisible"
|
|
|
+ placement="top-end"
|
|
|
+ trigger="click"
|
|
|
+ :width="154"
|
|
|
+ :show-arrow="false"
|
|
|
+ :teleported="false"
|
|
|
+ popper-class="hikvision-single-ptz-popover"
|
|
|
+ @hide="stopPtzControl">
|
|
|
+ <template #reference>
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ class="action-button"
|
|
|
+ :class="{ active: ptzVisible }"
|
|
|
+ :disabled="!canOperate"
|
|
|
+ aria-label="打开云台控制"
|
|
|
+ :aria-expanded="ptzVisible">
|
|
|
+ <i class="i-lucide:move"></i>
|
|
|
+ </button>
|
|
|
+ </template>
|
|
|
+ <div class="ptz-content">
|
|
|
+ <div class="ptz-title"><i class="i-lucide:move"></i><span>云台控制</span></div>
|
|
|
+ <div class="ptz-panel" role="group" aria-label="云台方向控制">
|
|
|
+ <button
|
|
|
+ v-for="direction in ptzDirections"
|
|
|
+ :key="direction.command"
|
|
|
+ type="button"
|
|
|
+ class="ptz-button"
|
|
|
+ :class="{ 'is-active': activePtzCommand === direction.command }"
|
|
|
+ :style="{ gridArea: direction.gridArea }"
|
|
|
+ :title="direction.label"
|
|
|
+ :aria-label="direction.label"
|
|
|
+ @pointerdown="startPtzControl($event, direction.command)"
|
|
|
+ @pointerup="stopPtzFromPointer"
|
|
|
+ @pointercancel="stopPtzFromPointer"
|
|
|
+ @lostpointercapture="stopPtzFromPointer"
|
|
|
+ @contextmenu.prevent
|
|
|
+ @dragstart.prevent>
|
|
|
+ <i :class="direction.icon"></i>
|
|
|
+ </button>
|
|
|
+ <span class="ptz-center"><i class="i-lucide:scan"></i></span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </el-popover>
|
|
|
+
|
|
|
+ <el-tooltip
|
|
|
+ :content="fullscreen ? '退出全屏' : '全屏'"
|
|
|
+ placement="top"
|
|
|
+ :teleported="false">
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ class="action-button"
|
|
|
+ :disabled="fullscreenLoading"
|
|
|
+ :aria-label="fullscreen ? '退出全屏' : '全屏'"
|
|
|
+ :aria-pressed="fullscreen"
|
|
|
+ @click.stop="toggleFullscreen">
|
|
|
+ <i
|
|
|
+ :class="
|
|
|
+ fullscreenLoading
|
|
|
+ ? 'i-lucide:loader-circle animate-spin'
|
|
|
+ : fullscreen
|
|
|
+ ? 'i-lucide:minimize'
|
|
|
+ : 'i-lucide:maximize'
|
|
|
+ "></i>
|
|
|
+ </button>
|
|
|
+ </el-tooltip>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup lang="ts">
|
|
|
+import {
|
|
|
+ HikvisionApi,
|
|
|
+ type HikvisionCameraNode,
|
|
|
+ type HikvisionPtzCommand,
|
|
|
+ type HikvisionStreamType
|
|
|
+} from '@/api/pms/hikvision'
|
|
|
+import {
|
|
|
+ getHikvisionPlayerBasePath,
|
|
|
+ loadHikvisionPlayer,
|
|
|
+ type HikvisionPlayerInstance
|
|
|
+} from '@/utils/hikvisionH5Player'
|
|
|
+import { ElMessage } from 'element-plus'
|
|
|
+import { computed, getCurrentInstance, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
|
+
|
|
|
+const props = withDefaults(
|
|
|
+ defineProps<{
|
|
|
+ camera?: HikvisionCameraNode | null
|
|
|
+ editorMode?: boolean
|
|
|
+ autoplay?: boolean
|
|
|
+ }>(),
|
|
|
+ {
|
|
|
+ camera: null,
|
|
|
+ editorMode: false,
|
|
|
+ autoplay: true
|
|
|
+ }
|
|
|
+)
|
|
|
+
|
|
|
+const emit = defineEmits<{
|
|
|
+ error: [error: unknown]
|
|
|
+ playing: [camera: HikvisionCameraNode]
|
|
|
+ stopped: []
|
|
|
+}>()
|
|
|
+
|
|
|
+const instanceUid = getCurrentInstance()?.uid ?? Math.floor(Math.random() * 1000000)
|
|
|
+const playerContainerId = `hikvision-single-player-${instanceUid}`
|
|
|
+const stageRef = ref<HTMLElement>()
|
|
|
+const playerContainerRef = ref<HTMLElement>()
|
|
|
+const loading = ref(false)
|
|
|
+const playing = ref(false)
|
|
|
+const interrupted = ref(false)
|
|
|
+const errorMessage = ref('')
|
|
|
+const streamType = ref<HikvisionStreamType>(0)
|
|
|
+const streamSwitching = ref(false)
|
|
|
+const capturing = ref(false)
|
|
|
+const soundEnabled = ref(false)
|
|
|
+const soundLoading = ref(false)
|
|
|
+const talking = ref(false)
|
|
|
+const talkLoading = ref(false)
|
|
|
+const zoomEnabled = ref(false)
|
|
|
+const zoomLoading = ref(false)
|
|
|
+const ptzVisible = ref(false)
|
|
|
+const activePtzCommand = ref<HikvisionPtzCommand>()
|
|
|
+const fullscreen = ref(false)
|
|
|
+const fullscreenLoading = ref(false)
|
|
|
+
|
|
|
+let player: HikvisionPlayerInstance | undefined
|
|
|
+let playerInitPromise: Promise<HikvisionPlayerInstance> | undefined
|
|
|
+let resizeObserver: ResizeObserver | undefined
|
|
|
+let resizeFrame: number | undefined
|
|
|
+let resizeRunning = false
|
|
|
+let resizePending = false
|
|
|
+let mounted = false
|
|
|
+let destroyed = false
|
|
|
+let operationId = 0
|
|
|
+let playIssuedOperationId: number | undefined
|
|
|
+let errorTimer: ReturnType<typeof window.setTimeout> | undefined
|
|
|
+let talkOperationId = 0
|
|
|
+let zoomOperationId = 0
|
|
|
+
|
|
|
+interface ActivePtzPress {
|
|
|
+ cameraIndexCode: string
|
|
|
+ command: HikvisionPtzCommand
|
|
|
+ pointerId: number
|
|
|
+ target: HTMLElement
|
|
|
+ startRequest: Promise<boolean>
|
|
|
+}
|
|
|
+
|
|
|
+let activePtzPress: ActivePtzPress | undefined
|
|
|
+let ptzBusy = false
|
|
|
+
|
|
|
+const PTZ_CONTROL_SPEED = 30
|
|
|
+const ptzDirections: Array<{
|
|
|
+ label: string
|
|
|
+ icon: string
|
|
|
+ gridArea: string
|
|
|
+ command: HikvisionPtzCommand
|
|
|
+}> = [
|
|
|
+ { label: '左上', icon: 'i-lucide:arrow-up-left', gridArea: '1 / 1', command: 'LEFT_UP' },
|
|
|
+ { label: '向上', icon: 'i-lucide:arrow-up', gridArea: '1 / 2', command: 'UP' },
|
|
|
+ { label: '右上', icon: 'i-lucide:arrow-up-right', gridArea: '1 / 3', command: 'RIGHT_UP' },
|
|
|
+ { label: '向左', icon: 'i-lucide:arrow-left', gridArea: '2 / 1', command: 'LEFT' },
|
|
|
+ { label: '向右', icon: 'i-lucide:arrow-right', gridArea: '2 / 3', command: 'RIGHT' },
|
|
|
+ {
|
|
|
+ label: '左下',
|
|
|
+ icon: 'i-lucide:arrow-down-left',
|
|
|
+ gridArea: '3 / 1',
|
|
|
+ command: 'LEFT_DOWN'
|
|
|
+ },
|
|
|
+ { label: '向下', icon: 'i-lucide:arrow-down', gridArea: '3 / 2', command: 'DOWN' },
|
|
|
+ {
|
|
|
+ label: '右下',
|
|
|
+ icon: 'i-lucide:arrow-down-right',
|
|
|
+ gridArea: '3 / 3',
|
|
|
+ command: 'RIGHT_DOWN'
|
|
|
+ }
|
|
|
+]
|
|
|
+
|
|
|
+const canOperate = computed(() => playing.value && !loading.value && !interrupted.value)
|
|
|
+const previewStatus = computed(() => {
|
|
|
+ if (loading.value) return '正在加载'
|
|
|
+ if (interrupted.value) return '画面中断'
|
|
|
+ if (playing.value) return '正在预览'
|
|
|
+ return '未预览'
|
|
|
+})
|
|
|
+const streamSwitchTitle = computed(() => (streamType.value === 0 ? '切换为子码流' : '切换为主码流'))
|
|
|
+const soundTitle = computed(() => (soundEnabled.value ? '关闭声音' : '开启声音'))
|
|
|
+const talkTitle = computed(() => {
|
|
|
+ if (talkLoading.value) return talking.value ? '正在停止对讲' : '正在开启对讲'
|
|
|
+ return talking.value ? '停止对讲' : '开启对讲'
|
|
|
+})
|
|
|
+const zoomTitle = computed(() => {
|
|
|
+ if (zoomLoading.value) return zoomEnabled.value ? '正在关闭电子放大' : '正在开启电子放大'
|
|
|
+ return zoomEnabled.value ? '关闭电子放大' : '开启电子放大'
|
|
|
+})
|
|
|
+
|
|
|
+function clearErrorTimer() {
|
|
|
+ if (errorTimer === undefined) return
|
|
|
+ window.clearTimeout(errorTimer)
|
|
|
+ errorTimer = undefined
|
|
|
+}
|
|
|
+
|
|
|
+function clearError() {
|
|
|
+ clearErrorTimer()
|
|
|
+ errorMessage.value = ''
|
|
|
+}
|
|
|
+
|
|
|
+function showError(message: string, error?: unknown) {
|
|
|
+ if (destroyed) return
|
|
|
+ clearErrorTimer()
|
|
|
+ errorMessage.value = message
|
|
|
+ if (error !== undefined) emit('error', error)
|
|
|
+ errorTimer = window.setTimeout(() => {
|
|
|
+ errorMessage.value = ''
|
|
|
+ errorTimer = undefined
|
|
|
+ }, 3000)
|
|
|
+}
|
|
|
+
|
|
|
+function getTalkErrorMessage(errorCode: number) {
|
|
|
+ if (errorCode === 0x12f950000) return '麦克风采集失败,请检查 HTTPS 和麦克风权限'
|
|
|
+ if (errorCode === 0x12f950001) return '监控设备的对讲音频编码不受支持'
|
|
|
+ return `对讲异常(${errorCode})`
|
|
|
+}
|
|
|
+
|
|
|
+async function initPlayer() {
|
|
|
+ if (player) return player
|
|
|
+ if (playerInitPromise) return playerInitPromise
|
|
|
+
|
|
|
+ playerInitPromise = (async () => {
|
|
|
+ let initializingPlayer: HikvisionPlayerInstance | undefined
|
|
|
+ try {
|
|
|
+ await nextTick()
|
|
|
+ const JSPlugin = await loadHikvisionPlayer()
|
|
|
+ if (destroyed || props.editorMode) throw new Error('播放器已停止初始化')
|
|
|
+ initializingPlayer = new JSPlugin({
|
|
|
+ szId: playerContainerId,
|
|
|
+ szBasePath: getHikvisionPlayerBasePath(),
|
|
|
+ iMaxSplit: 1,
|
|
|
+ iCurrentSplit: 1,
|
|
|
+ mseWorkerEnable: window.crossOriginIsolated,
|
|
|
+ bSupporDoubleClickFull: false,
|
|
|
+ oStyle: { borderSelect: '#facc15' }
|
|
|
+ })
|
|
|
+ await initializingPlayer.JS_SetWindowControlCallback({
|
|
|
+ firstFrameDisplay: (windowIndex) => {
|
|
|
+ if (windowIndex !== 0 || playIssuedOperationId !== operationId) return
|
|
|
+ loading.value = false
|
|
|
+ interrupted.value = false
|
|
|
+ playing.value = true
|
|
|
+ if (props.camera) emit('playing', props.camera)
|
|
|
+ },
|
|
|
+ InterruptStream: (windowIndex, interruptTime) => {
|
|
|
+ if (windowIndex !== 0 || playIssuedOperationId !== operationId) return
|
|
|
+ loading.value = false
|
|
|
+ interrupted.value = true
|
|
|
+ playing.value = false
|
|
|
+ void stopTalk()
|
|
|
+ void stopZoom()
|
|
|
+ showError(`监控画面已中断(${interruptTime} 秒未收到码流)`)
|
|
|
+ },
|
|
|
+ StreamEnd: (windowIndex) => {
|
|
|
+ if (windowIndex !== 0 || playIssuedOperationId !== operationId) return
|
|
|
+ loading.value = false
|
|
|
+ playing.value = false
|
|
|
+ void stopTalk()
|
|
|
+ void stopZoom()
|
|
|
+ showError('监控画面已结束')
|
|
|
+ },
|
|
|
+ performanceLack: () => showError('当前设备性能不足,监控画面可能卡顿'),
|
|
|
+ pluginErrorHandler: (windowIndex, errorCode, error) => {
|
|
|
+ if (windowIndex !== 0 || playIssuedOperationId !== operationId) return
|
|
|
+ console.error('H5player 播放器错误', errorCode, error)
|
|
|
+ loading.value = false
|
|
|
+ playing.value = false
|
|
|
+ showError(`播放器异常(${errorCode})`, error)
|
|
|
+ },
|
|
|
+ talkPluginErrorHandler: (errorCode, error) => {
|
|
|
+ console.error('H5player 对讲错误', errorCode, error)
|
|
|
+ talkOperationId += 1
|
|
|
+ talking.value = false
|
|
|
+ talkLoading.value = false
|
|
|
+ showError(getTalkErrorMessage(errorCode), error)
|
|
|
+ }
|
|
|
+ })
|
|
|
+ if (destroyed || props.editorMode) throw new Error('播放器已停止初始化')
|
|
|
+ player = initializingPlayer
|
|
|
+ initializingPlayer = undefined
|
|
|
+ scheduleResize()
|
|
|
+ return player
|
|
|
+ } catch (error) {
|
|
|
+ await initializingPlayer?.JS_StopRealPlayAll().catch(() => undefined)
|
|
|
+ await Promise.resolve(initializingPlayer?.JS_Destroy()).catch(() => undefined)
|
|
|
+ throw error
|
|
|
+ } finally {
|
|
|
+ playerInitPromise = undefined
|
|
|
+ }
|
|
|
+ })()
|
|
|
+
|
|
|
+ return playerInitPromise
|
|
|
+}
|
|
|
+
|
|
|
+async function play() {
|
|
|
+ const camera = props.camera
|
|
|
+ if (!mounted || destroyed || props.editorMode || !camera) return
|
|
|
+ if (camera.available === false || camera.onlineStatus === false) {
|
|
|
+ showError(`${camera.name} 不在线,无法预览`)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ const currentOperationId = ++operationId
|
|
|
+ playIssuedOperationId = undefined
|
|
|
+ loading.value = true
|
|
|
+ playing.value = false
|
|
|
+ interrupted.value = false
|
|
|
+ streamType.value = 0
|
|
|
+ clearError()
|
|
|
+
|
|
|
+ try {
|
|
|
+ const [currentPlayer, previewUrl] = await Promise.all([
|
|
|
+ initPlayer(),
|
|
|
+ HikvisionApi.getPreviewUrl(camera.indexCode, 0)
|
|
|
+ ])
|
|
|
+ if (currentOperationId !== operationId || destroyed) return
|
|
|
+ if (!previewUrl) throw new Error('预览地址为空')
|
|
|
+
|
|
|
+ await stopPtzControl()
|
|
|
+ await stopTalk()
|
|
|
+ await stopZoom()
|
|
|
+ await closeSound()
|
|
|
+ await currentPlayer.JS_Stop(0).catch(() => undefined)
|
|
|
+ if (currentOperationId !== operationId || destroyed) return
|
|
|
+
|
|
|
+ playIssuedOperationId = currentOperationId
|
|
|
+ await currentPlayer.JS_Play(previewUrl, { playURL: previewUrl, mode: 0, keepDecoder: 0 }, 0)
|
|
|
+ } catch (error) {
|
|
|
+ if (currentOperationId !== operationId || destroyed) return
|
|
|
+ console.error('监控点预览失败', error)
|
|
|
+ loading.value = false
|
|
|
+ playing.value = false
|
|
|
+ playIssuedOperationId = undefined
|
|
|
+ showError(error instanceof Error ? error.message : '监控点预览失败', error)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function switchStream() {
|
|
|
+ const camera = props.camera
|
|
|
+ if (!camera || !canOperate.value || streamSwitching.value) return
|
|
|
+ const nextStreamType: HikvisionStreamType = streamType.value === 0 ? 1 : 0
|
|
|
+ const currentOperationId = ++operationId
|
|
|
+ playIssuedOperationId = undefined
|
|
|
+ streamSwitching.value = true
|
|
|
+ loading.value = true
|
|
|
+ playing.value = false
|
|
|
+ clearError()
|
|
|
+
|
|
|
+ try {
|
|
|
+ const [currentPlayer, previewUrl] = await Promise.all([
|
|
|
+ initPlayer(),
|
|
|
+ HikvisionApi.getPreviewUrl(camera.indexCode, nextStreamType)
|
|
|
+ ])
|
|
|
+ if (currentOperationId !== operationId || destroyed) return
|
|
|
+ if (!previewUrl) throw new Error('切换码流的预览地址为空')
|
|
|
+ await stopPtzControl()
|
|
|
+ await stopTalk()
|
|
|
+ await stopZoom()
|
|
|
+ await closeSound()
|
|
|
+ await currentPlayer.JS_Stop(0)
|
|
|
+ if (currentOperationId !== operationId || destroyed) return
|
|
|
+ streamType.value = nextStreamType
|
|
|
+ playIssuedOperationId = currentOperationId
|
|
|
+ await currentPlayer.JS_Play(previewUrl, { playURL: previewUrl, mode: 0, keepDecoder: 0 }, 0)
|
|
|
+ } catch (error) {
|
|
|
+ if (currentOperationId !== operationId || destroyed) return
|
|
|
+ console.error('切换监控码流失败', error)
|
|
|
+ loading.value = false
|
|
|
+ playing.value = false
|
|
|
+ showError(error instanceof Error ? error.message : '切换监控码流失败', error)
|
|
|
+ } finally {
|
|
|
+ if (currentOperationId === operationId) streamSwitching.value = false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function createCaptureFileName(camera: HikvisionCameraNode) {
|
|
|
+ const safeName = camera.name.replace(/[\\/:*?"<>|]/g, '_').trim() || camera.indexCode
|
|
|
+ const now = new Date()
|
|
|
+ const pad = (value: number) => String(value).padStart(2, '0')
|
|
|
+ return `${safeName}_${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(
|
|
|
+ now.getHours()
|
|
|
+ )}${pad(now.getMinutes())}${pad(now.getSeconds())}`
|
|
|
+}
|
|
|
+
|
|
|
+async function capturePicture() {
|
|
|
+ if (!props.camera || !player || !canOperate.value || capturing.value) return
|
|
|
+ const currentOperationId = operationId
|
|
|
+ capturing.value = true
|
|
|
+ clearError()
|
|
|
+ try {
|
|
|
+ await player.JS_CapturePicture(0, createCaptureFileName(props.camera), 'JPEG')
|
|
|
+ if (currentOperationId !== operationId || destroyed) return
|
|
|
+ ElMessage.success('抓图已保存到本地')
|
|
|
+ } catch (error) {
|
|
|
+ if (currentOperationId !== operationId || destroyed) return
|
|
|
+ console.error('监控画面抓图失败', error)
|
|
|
+ showError(error instanceof Error ? error.message : '监控画面抓图失败', error)
|
|
|
+ } finally {
|
|
|
+ if (currentOperationId === operationId) capturing.value = false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function closeSound() {
|
|
|
+ if (!soundEnabled.value) return true
|
|
|
+ try {
|
|
|
+ await player?.JS_CloseSound(0)
|
|
|
+ soundEnabled.value = false
|
|
|
+ return true
|
|
|
+ } catch {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function toggleSound() {
|
|
|
+ if (!player || !canOperate.value || soundLoading.value) return
|
|
|
+ const currentOperationId = operationId
|
|
|
+ soundLoading.value = true
|
|
|
+ clearError()
|
|
|
+ try {
|
|
|
+ if (soundEnabled.value) {
|
|
|
+ await player.JS_CloseSound(0)
|
|
|
+ if (currentOperationId !== operationId || destroyed) return
|
|
|
+ soundEnabled.value = false
|
|
|
+ ElMessage.success('声音已关闭')
|
|
|
+ } else {
|
|
|
+ await player.JS_OpenSound(0)
|
|
|
+ if (currentOperationId !== operationId || destroyed) return
|
|
|
+ soundEnabled.value = true
|
|
|
+ ElMessage.success('声音已开启')
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ if (currentOperationId !== operationId || destroyed) return
|
|
|
+ console.error('切换监控声音失败', error)
|
|
|
+ showError(error instanceof Error ? error.message : '切换监控声音失败', error)
|
|
|
+ } finally {
|
|
|
+ if (currentOperationId === operationId) soundLoading.value = false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function stopTalk(notify = false) {
|
|
|
+ const wasTalking = talking.value
|
|
|
+ const currentTalkOperationId = ++talkOperationId
|
|
|
+ talking.value = false
|
|
|
+ if (!wasTalking) {
|
|
|
+ talkLoading.value = false
|
|
|
+ return true
|
|
|
+ }
|
|
|
+ talkLoading.value = true
|
|
|
+ try {
|
|
|
+ await player?.JS_StopTalk()
|
|
|
+ if (notify) ElMessage.success('对讲已停止')
|
|
|
+ return true
|
|
|
+ } catch (error) {
|
|
|
+ if (notify) showError(error instanceof Error ? error.message : '停止对讲失败', error)
|
|
|
+ return false
|
|
|
+ } finally {
|
|
|
+ if (currentTalkOperationId === talkOperationId) talkLoading.value = false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function toggleTalk() {
|
|
|
+ if (!props.camera || !player || !canOperate.value || talkLoading.value) return
|
|
|
+ if (talking.value) {
|
|
|
+ await stopTalk(true)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (!window.isSecureContext) {
|
|
|
+ showError('对讲功能仅支持 HTTPS 安全环境')
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ const currentTalkOperationId = ++talkOperationId
|
|
|
+ talkLoading.value = true
|
|
|
+ clearError()
|
|
|
+ try {
|
|
|
+ const talkUrl = await HikvisionApi.getTalkUrl(props.camera.indexCode)
|
|
|
+ if (currentTalkOperationId !== talkOperationId || destroyed) return
|
|
|
+ if (!talkUrl) throw new Error('对讲地址为空')
|
|
|
+ await player.JS_StartTalk(talkUrl)
|
|
|
+ if (currentTalkOperationId !== talkOperationId || destroyed) {
|
|
|
+ await player.JS_StopTalk().catch(() => undefined)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ talking.value = true
|
|
|
+ ElMessage.success('对讲已开启')
|
|
|
+ } catch (error) {
|
|
|
+ if (currentTalkOperationId !== talkOperationId || destroyed) return
|
|
|
+ console.error('开启监控对讲失败', error)
|
|
|
+ showError(error instanceof Error ? error.message : '开启对讲失败,请检查麦克风权限', error)
|
|
|
+ } finally {
|
|
|
+ if (currentTalkOperationId === talkOperationId) talkLoading.value = false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function stopZoom() {
|
|
|
+ const currentZoomOperationId = ++zoomOperationId
|
|
|
+ if (!zoomEnabled.value && !zoomLoading.value) return true
|
|
|
+ zoomLoading.value = true
|
|
|
+ try {
|
|
|
+ await player?.JS_DisableZoom(0)
|
|
|
+ if (currentZoomOperationId !== zoomOperationId) return false
|
|
|
+ zoomEnabled.value = false
|
|
|
+ return true
|
|
|
+ } catch {
|
|
|
+ return false
|
|
|
+ } finally {
|
|
|
+ if (currentZoomOperationId === zoomOperationId) zoomLoading.value = false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function toggleZoom() {
|
|
|
+ if (!player || !canOperate.value || zoomLoading.value) return
|
|
|
+ if (!window.isSecureContext) {
|
|
|
+ showError('电子放大仅支持 HTTPS 安全环境')
|
|
|
+ return
|
|
|
+ }
|
|
|
+ const enable = !zoomEnabled.value
|
|
|
+ const currentZoomOperationId = ++zoomOperationId
|
|
|
+ zoomLoading.value = true
|
|
|
+ clearError()
|
|
|
+ try {
|
|
|
+ if (enable) await player.JS_EnableZoom(0)
|
|
|
+ else await player.JS_DisableZoom(0)
|
|
|
+ if (currentZoomOperationId !== zoomOperationId) {
|
|
|
+ if (enable) await player.JS_DisableZoom(0).catch(() => undefined)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ zoomEnabled.value = enable
|
|
|
+ ElMessage.success(enable ? '电子放大已开启,请在画面中框选区域' : '电子放大已关闭')
|
|
|
+ } catch (error) {
|
|
|
+ if (currentZoomOperationId !== zoomOperationId) return
|
|
|
+ console.error('切换电子放大失败', error)
|
|
|
+ showError(error instanceof Error ? error.message : '切换电子放大失败', error)
|
|
|
+ } finally {
|
|
|
+ if (currentZoomOperationId === zoomOperationId) zoomLoading.value = false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function startPtzControl(event: PointerEvent, command: HikvisionPtzCommand) {
|
|
|
+ if (
|
|
|
+ !event.isPrimary ||
|
|
|
+ event.button !== 0 ||
|
|
|
+ activePtzPress ||
|
|
|
+ ptzBusy ||
|
|
|
+ !props.camera ||
|
|
|
+ !canOperate.value
|
|
|
+ ) {
|
|
|
+ return
|
|
|
+ }
|
|
|
+ event.preventDefault()
|
|
|
+ const target = event.currentTarget as HTMLElement
|
|
|
+ try {
|
|
|
+ target.setPointerCapture(event.pointerId)
|
|
|
+ } catch {
|
|
|
+ // 旧浏览器不支持指针捕获时,仍由按钮本身的 pointerup 尝试停止。
|
|
|
+ }
|
|
|
+ const press: ActivePtzPress = {
|
|
|
+ cameraIndexCode: props.camera.indexCode,
|
|
|
+ command,
|
|
|
+ pointerId: event.pointerId,
|
|
|
+ target,
|
|
|
+ startRequest: Promise.resolve(true)
|
|
|
+ }
|
|
|
+ activePtzPress = press
|
|
|
+ ptzBusy = true
|
|
|
+ activePtzCommand.value = command
|
|
|
+ press.startRequest = HikvisionApi.controlPtz({
|
|
|
+ cameraIndexCode: press.cameraIndexCode,
|
|
|
+ action: 0,
|
|
|
+ command,
|
|
|
+ speed: PTZ_CONTROL_SPEED
|
|
|
+ })
|
|
|
+ .then(() => true)
|
|
|
+ .catch((error) => {
|
|
|
+ console.error('开启云台方向控制失败', error)
|
|
|
+ showError('当前监控点不支持云台控制', error)
|
|
|
+ return false
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+async function stopPtzControl(pointerId?: number) {
|
|
|
+ const press = activePtzPress
|
|
|
+ if (!press || (pointerId !== undefined && press.pointerId !== pointerId)) return
|
|
|
+ activePtzPress = undefined
|
|
|
+ activePtzCommand.value = undefined
|
|
|
+ try {
|
|
|
+ if (press.target.hasPointerCapture(press.pointerId)) {
|
|
|
+ press.target.releasePointerCapture(press.pointerId)
|
|
|
+ }
|
|
|
+ } catch {
|
|
|
+ // 元素销毁或浏览器已自动释放时无需处理。
|
|
|
+ }
|
|
|
+ const started = await press.startRequest
|
|
|
+ if (!started) {
|
|
|
+ ptzBusy = false
|
|
|
+ return
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ await HikvisionApi.controlPtz({
|
|
|
+ cameraIndexCode: press.cameraIndexCode,
|
|
|
+ action: 1,
|
|
|
+ command: press.command
|
|
|
+ })
|
|
|
+ } catch (error) {
|
|
|
+ console.error('停止云台方向控制失败', error)
|
|
|
+ showError(error instanceof Error ? error.message : '停止云台方向控制失败', error)
|
|
|
+ } finally {
|
|
|
+ ptzBusy = false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function stopPtzFromPointer(event: PointerEvent) {
|
|
|
+ void stopPtzControl(event.pointerId)
|
|
|
+}
|
|
|
+
|
|
|
+async function stopPreview() {
|
|
|
+ operationId += 1
|
|
|
+ playIssuedOperationId = undefined
|
|
|
+ loading.value = false
|
|
|
+ streamSwitching.value = false
|
|
|
+ clearError()
|
|
|
+ try {
|
|
|
+ await stopPtzControl()
|
|
|
+ await stopTalk()
|
|
|
+ await stopZoom()
|
|
|
+ await closeSound()
|
|
|
+ await player?.JS_Stop(0)
|
|
|
+ } catch (error) {
|
|
|
+ console.error('关闭监控画面失败', error)
|
|
|
+ showError(error instanceof Error ? error.message : '关闭监控画面失败', error)
|
|
|
+ } finally {
|
|
|
+ playing.value = false
|
|
|
+ interrupted.value = false
|
|
|
+ ptzVisible.value = false
|
|
|
+ emit('stopped')
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function resizePlayer() {
|
|
|
+ resizeFrame = undefined
|
|
|
+ if (!player || !playerContainerRef.value) return
|
|
|
+ if (resizeRunning) {
|
|
|
+ resizePending = true
|
|
|
+ return
|
|
|
+ }
|
|
|
+ const { clientWidth, clientHeight } = playerContainerRef.value
|
|
|
+ if (!clientWidth || !clientHeight) return
|
|
|
+ resizeRunning = true
|
|
|
+ try {
|
|
|
+ await player.JS_Resize(clientWidth, clientHeight)
|
|
|
+ } catch {
|
|
|
+ // 布局变化与播放器销毁发生在同一帧时可以安全忽略。
|
|
|
+ } finally {
|
|
|
+ resizeRunning = false
|
|
|
+ if (resizePending && !destroyed) {
|
|
|
+ resizePending = false
|
|
|
+ scheduleResize()
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function scheduleResize() {
|
|
|
+ if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame)
|
|
|
+ resizeFrame = requestAnimationFrame(resizePlayer)
|
|
|
+}
|
|
|
+
|
|
|
+async function toggleFullscreen() {
|
|
|
+ if (fullscreenLoading.value || !stageRef.value) return
|
|
|
+ fullscreenLoading.value = true
|
|
|
+ try {
|
|
|
+ if (document.fullscreenElement === stageRef.value) await document.exitFullscreen()
|
|
|
+ else await stageRef.value.requestFullscreen()
|
|
|
+ } catch (error) {
|
|
|
+ showError(error instanceof Error ? error.message : '切换全屏失败', error)
|
|
|
+ } finally {
|
|
|
+ fullscreenLoading.value = false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function handleFullscreenChange() {
|
|
|
+ fullscreen.value = document.fullscreenElement === stageRef.value
|
|
|
+ void nextTick(scheduleResize)
|
|
|
+}
|
|
|
+
|
|
|
+async function teardownPlayer() {
|
|
|
+ operationId += 1
|
|
|
+ playIssuedOperationId = undefined
|
|
|
+ resizeObserver?.disconnect()
|
|
|
+ resizeObserver = undefined
|
|
|
+ if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame)
|
|
|
+ resizeFrame = undefined
|
|
|
+ resizePending = false
|
|
|
+ await stopPtzControl()
|
|
|
+ await stopTalk()
|
|
|
+ await stopZoom()
|
|
|
+ const currentPlayer = player
|
|
|
+ player = undefined
|
|
|
+ if (currentPlayer) {
|
|
|
+ await currentPlayer.JS_CloseSound(0).catch(() => undefined)
|
|
|
+ await currentPlayer.JS_StopTalk().catch(() => undefined)
|
|
|
+ await currentPlayer.JS_StopRealPlayAll().catch(() => undefined)
|
|
|
+ await Promise.resolve(currentPlayer.JS_Destroy()).catch(() => undefined)
|
|
|
+ }
|
|
|
+ loading.value = false
|
|
|
+ playing.value = false
|
|
|
+ interrupted.value = false
|
|
|
+ streamSwitching.value = false
|
|
|
+ capturing.value = false
|
|
|
+ soundEnabled.value = false
|
|
|
+ soundLoading.value = false
|
|
|
+ talkLoading.value = false
|
|
|
+ zoomEnabled.value = false
|
|
|
+ zoomLoading.value = false
|
|
|
+}
|
|
|
+
|
|
|
+async function syncCamera() {
|
|
|
+ if (!mounted || destroyed) return
|
|
|
+ await teardownPlayer()
|
|
|
+ if (props.editorMode || !props.camera || !props.autoplay) return
|
|
|
+ await nextTick()
|
|
|
+ if (playerContainerRef.value) {
|
|
|
+ resizeObserver = new ResizeObserver(scheduleResize)
|
|
|
+ resizeObserver.observe(playerContainerRef.value)
|
|
|
+ }
|
|
|
+ await play()
|
|
|
+}
|
|
|
+
|
|
|
+watch(
|
|
|
+ () => [props.camera?.indexCode, props.editorMode, props.autoplay] as const,
|
|
|
+ () => void syncCamera()
|
|
|
+)
|
|
|
+
|
|
|
+onMounted(() => {
|
|
|
+ mounted = true
|
|
|
+ destroyed = false
|
|
|
+ document.addEventListener('fullscreenchange', handleFullscreenChange)
|
|
|
+ void syncCamera()
|
|
|
+})
|
|
|
+
|
|
|
+onBeforeUnmount(() => {
|
|
|
+ destroyed = true
|
|
|
+ mounted = false
|
|
|
+ clearErrorTimer()
|
|
|
+ document.removeEventListener('fullscreenchange', handleFullscreenChange)
|
|
|
+ if (document.fullscreenElement === stageRef.value) void document.exitFullscreen()
|
|
|
+ void teardownPlayer()
|
|
|
+})
|
|
|
+
|
|
|
+defineExpose({
|
|
|
+ play,
|
|
|
+ stop: stopPreview,
|
|
|
+ capturePicture,
|
|
|
+ toggleFullscreen
|
|
|
+})
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped lang="scss">
|
|
|
+.hikvision-video,
|
|
|
+.video-stage,
|
|
|
+.player-container,
|
|
|
+.video-overlay {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+}
|
|
|
+
|
|
|
+.hikvision-video {
|
|
|
+ min-width: 0;
|
|
|
+ min-height: 0;
|
|
|
+ overflow: hidden;
|
|
|
+ color: #f8fafc;
|
|
|
+ background: #0b1118;
|
|
|
+}
|
|
|
+
|
|
|
+.editor-placeholder {
|
|
|
+ display: flex;
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ min-width: 0;
|
|
|
+ min-height: 0;
|
|
|
+ padding: 18px;
|
|
|
+ color: #cbd5e1;
|
|
|
+ background: linear-gradient(rgb(96 165 250 / 8%) 1px, transparent 1px),
|
|
|
+ linear-gradient(90deg, rgb(96 165 250 / 8%) 1px, transparent 1px), #0b1118;
|
|
|
+ background-size: 24px 24px;
|
|
|
+ border: 1px solid rgb(96 165 250 / 42%);
|
|
|
+ box-sizing: border-box;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 8px;
|
|
|
+}
|
|
|
+
|
|
|
+.editor-placeholder > i {
|
|
|
+ font-size: clamp(24px, 15%, 48px);
|
|
|
+ color: #60a5fa;
|
|
|
+}
|
|
|
+
|
|
|
+.editor-placeholder strong,
|
|
|
+.editor-placeholder span {
|
|
|
+ max-width: 100%;
|
|
|
+ overflow: hidden;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ white-space: nowrap;
|
|
|
+}
|
|
|
+
|
|
|
+.editor-placeholder span {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #94a3b8;
|
|
|
+}
|
|
|
+
|
|
|
+.video-stage {
|
|
|
+ position: relative;
|
|
|
+ overflow: hidden;
|
|
|
+ background: #0b1118;
|
|
|
+}
|
|
|
+
|
|
|
+.video-stage:fullscreen {
|
|
|
+ width: 100vw;
|
|
|
+ height: 100vh;
|
|
|
+}
|
|
|
+
|
|
|
+.player-container,
|
|
|
+.video-overlay {
|
|
|
+ position: absolute;
|
|
|
+ inset: 0;
|
|
|
+}
|
|
|
+
|
|
|
+.player-container :deep(.parent-wnd),
|
|
|
+.player-container :deep(.sub-wnd) {
|
|
|
+ width: 100% !important;
|
|
|
+ height: 100% !important;
|
|
|
+}
|
|
|
+
|
|
|
+.video-overlay {
|
|
|
+ z-index: 2;
|
|
|
+ pointer-events: none;
|
|
|
+}
|
|
|
+
|
|
|
+.hover-title,
|
|
|
+.hover-actions {
|
|
|
+ position: absolute;
|
|
|
+ right: 0;
|
|
|
+ left: 0;
|
|
|
+ z-index: 3;
|
|
|
+ display: flex;
|
|
|
+ min-height: 40px;
|
|
|
+ padding: 5px 12px;
|
|
|
+ pointer-events: none;
|
|
|
+ opacity: 0;
|
|
|
+ visibility: hidden;
|
|
|
+ backdrop-filter: blur(3px) saturate(115%);
|
|
|
+ box-sizing: border-box;
|
|
|
+ align-items: center;
|
|
|
+ transition:
|
|
|
+ opacity 0.18s ease,
|
|
|
+ transform 0.18s ease,
|
|
|
+ visibility 0.18s ease;
|
|
|
+}
|
|
|
+
|
|
|
+.video-stage:hover .hover-title,
|
|
|
+.video-stage:hover .hover-actions,
|
|
|
+.hover-title:focus-within,
|
|
|
+.hover-actions:focus-within {
|
|
|
+ opacity: 1;
|
|
|
+ visibility: visible;
|
|
|
+ transform: translateY(0);
|
|
|
+}
|
|
|
+
|
|
|
+.hover-title {
|
|
|
+ top: 0;
|
|
|
+ background: linear-gradient(180deg, rgb(2 6 23 / 90%), rgb(15 23 42 / 50%));
|
|
|
+ border-bottom: 1px solid rgb(255 255 255 / 10%);
|
|
|
+ transform: translateY(-4px);
|
|
|
+}
|
|
|
+
|
|
|
+.title-main {
|
|
|
+ display: inline-flex;
|
|
|
+ min-width: 0;
|
|
|
+ flex: 1;
|
|
|
+ font-size: 14px;
|
|
|
+ font-weight: 600;
|
|
|
+ align-items: center;
|
|
|
+ gap: 6px;
|
|
|
+}
|
|
|
+
|
|
|
+.title-main span {
|
|
|
+ overflow: hidden;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ white-space: nowrap;
|
|
|
+}
|
|
|
+
|
|
|
+.status {
|
|
|
+ margin-left: 10px;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #e2e8f0;
|
|
|
+ white-space: nowrap;
|
|
|
+}
|
|
|
+
|
|
|
+.close-button,
|
|
|
+.action-button {
|
|
|
+ display: inline-flex;
|
|
|
+ width: 30px;
|
|
|
+ height: 30px;
|
|
|
+ padding: 0;
|
|
|
+ color: #e2e8f0;
|
|
|
+ pointer-events: auto;
|
|
|
+ cursor: pointer;
|
|
|
+ background: rgb(255 255 255 / 8%);
|
|
|
+ border: 1px solid rgb(255 255 255 / 12%);
|
|
|
+ border-radius: 6px;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+}
|
|
|
+
|
|
|
+.close-button {
|
|
|
+ margin-left: 6px;
|
|
|
+ background: transparent;
|
|
|
+ border-color: transparent;
|
|
|
+}
|
|
|
+
|
|
|
+.close-button:hover {
|
|
|
+ color: #fff;
|
|
|
+ background: rgb(239 68 68 / 78%);
|
|
|
+}
|
|
|
+
|
|
|
+.hover-actions {
|
|
|
+ bottom: 0;
|
|
|
+ overflow: visible;
|
|
|
+ background: linear-gradient(0deg, rgb(2 6 23 / 90%), rgb(15 23 42 / 48%));
|
|
|
+ border-top: 1px solid rgb(255 255 255 / 8%);
|
|
|
+ transform: translateY(4px);
|
|
|
+ justify-content: flex-end;
|
|
|
+ gap: 7px;
|
|
|
+}
|
|
|
+
|
|
|
+.action-button:hover:not(:disabled),
|
|
|
+.action-button.active {
|
|
|
+ color: #fff;
|
|
|
+ background: rgb(59 130 246 / 68%);
|
|
|
+ border-color: rgb(147 197 253 / 72%);
|
|
|
+}
|
|
|
+
|
|
|
+.action-button.is-talking {
|
|
|
+ color: #fff;
|
|
|
+ background: rgb(239 68 68 / 68%);
|
|
|
+ border-color: rgb(252 165 165 / 78%);
|
|
|
+}
|
|
|
+
|
|
|
+.action-button:disabled {
|
|
|
+ cursor: not-allowed;
|
|
|
+ opacity: 0.5;
|
|
|
+}
|
|
|
+
|
|
|
+.center-state {
|
|
|
+ position: absolute;
|
|
|
+ inset: 0;
|
|
|
+ display: flex;
|
|
|
+ padding: 46px 16px;
|
|
|
+ font-size: 13px;
|
|
|
+ color: #94a3b8;
|
|
|
+ pointer-events: none;
|
|
|
+ background: rgb(2 6 23 / 28%);
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 8px;
|
|
|
+}
|
|
|
+
|
|
|
+.center-state > i {
|
|
|
+ font-size: 26px;
|
|
|
+ color: #60a5fa;
|
|
|
+}
|
|
|
+
|
|
|
+.center-state.is-error {
|
|
|
+ color: #fecaca;
|
|
|
+ background: rgb(69 10 10 / 72%);
|
|
|
+}
|
|
|
+
|
|
|
+.center-state.is-error > i {
|
|
|
+ color: #f87171;
|
|
|
+}
|
|
|
+
|
|
|
+.center-state button {
|
|
|
+ padding: 5px 12px;
|
|
|
+ color: #dbeafe;
|
|
|
+ pointer-events: auto;
|
|
|
+ cursor: pointer;
|
|
|
+ background: rgb(37 99 235 / 44%);
|
|
|
+ border: 1px solid rgb(147 197 253 / 50%);
|
|
|
+ border-radius: 5px;
|
|
|
+}
|
|
|
+
|
|
|
+.ptz-content {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 10px;
|
|
|
+}
|
|
|
+
|
|
|
+.ptz-title {
|
|
|
+ display: flex;
|
|
|
+ padding-bottom: 8px;
|
|
|
+ font-size: 14px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #f8fafc;
|
|
|
+ border-bottom: 1px solid rgb(148 163 184 / 20%);
|
|
|
+ align-items: center;
|
|
|
+ gap: 7px;
|
|
|
+}
|
|
|
+
|
|
|
+.ptz-panel {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: repeat(3, 36px);
|
|
|
+ grid-template-rows: repeat(3, 36px);
|
|
|
+ gap: 6px;
|
|
|
+ justify-content: center;
|
|
|
+}
|
|
|
+
|
|
|
+.ptz-button,
|
|
|
+.ptz-center {
|
|
|
+ display: inline-flex;
|
|
|
+ width: 36px;
|
|
|
+ height: 36px;
|
|
|
+ color: #dbeafe;
|
|
|
+ background: #273544;
|
|
|
+ border: 1px solid #46576a;
|
|
|
+ border-radius: 7px;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ box-sizing: border-box;
|
|
|
+}
|
|
|
+
|
|
|
+.ptz-button {
|
|
|
+ padding: 0;
|
|
|
+ cursor: pointer;
|
|
|
+ user-select: none;
|
|
|
+ touch-action: none;
|
|
|
+}
|
|
|
+
|
|
|
+.ptz-button:hover,
|
|
|
+.ptz-button.is-active {
|
|
|
+ color: #fff;
|
|
|
+ background: #0284c7;
|
|
|
+ border-color: #7dd3fc;
|
|
|
+}
|
|
|
+
|
|
|
+.ptz-center {
|
|
|
+ grid-area: 2 / 2;
|
|
|
+ color: #7dd3fc;
|
|
|
+ background: rgb(14 165 233 / 10%);
|
|
|
+ border-radius: 50%;
|
|
|
+}
|
|
|
+
|
|
|
+:global(.hikvision-single-ptz-popover.el-popper) {
|
|
|
+ min-width: 0;
|
|
|
+ padding: 12px;
|
|
|
+ background: #18212c;
|
|
|
+ border-color: #46576a;
|
|
|
+ box-shadow: 0 10px 28px rgb(2 6 23 / 46%);
|
|
|
+}
|
|
|
+
|
|
|
+@media (width <= 420px), (height <= 180px) {
|
|
|
+ .hover-title,
|
|
|
+ .hover-actions {
|
|
|
+ min-height: 34px;
|
|
|
+ padding: 2px 6px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .hover-actions {
|
|
|
+ gap: 3px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .action-button,
|
|
|
+ .close-button {
|
|
|
+ width: 27px;
|
|
|
+ height: 27px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .status {
|
|
|
+ display: none;
|
|
|
+ }
|
|
|
+}
|
|
|
+</style>
|