| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552 |
- <template>
- <div
- ref="containerRef"
- class="video-container"
- :class="{ 'is-fullscreen': isFullscreen }"
- >
- <!-- Left sidebar control panel -->
- <div v-if="showChannelSelector || ptzEnabled" class="control-sidebar">
- <div class="control-panel" :class="{ 'is-collapsed': ptzCollapsed }">
- <!-- Channel selector -->
- <div v-if="showChannelSelector" class="channel-selector">
- <div class="control-label">Channel</div>
- <el-select
- v-model="selectedChn"
- size="small"
- @change="handleChnChange"
- >
- <el-option
- v-for="ch in channelOptions"
- :key="ch.value"
- :label="ch.label"
- :value="ch.value"
- />
- </el-select>
- </div>
- <!-- PTZ control -->
- <template v-if="ptzEnabled">
- <div v-if="showChannelSelector" class="control-divider" />
- <div class="control-label">PTZ Control</div>
- <div class="ptz-grid">
- <div class="ptz-placeholder" />
- <button
- class="ptz-btn"
- title="Up"
- @mousedown="handlePtzStart(PTZDirection.Up)"
- @mouseup="handlePtzStop"
- >
- <svg viewBox="0 0 24 24" width="22" height="22">
- <path fill="currentColor" d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z"/>
- </svg>
- </button>
- <div class="ptz-placeholder" />
- <button
- class="ptz-btn"
- title="Left"
- @mousedown="handlePtzStart(PTZDirection.Left)"
- @mouseup="handlePtzStop"
- >
- <svg viewBox="0 0 24 24" width="22" height="22">
- <path fill="currentColor" d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>
- </svg>
- </button>
- <button
- class="ptz-btn ptz-btn-center"
- title="Reset"
- @click="handlePtzCenter"
- >
- <svg viewBox="0 0 24 24" width="22" height="22">
- <path fill="currentColor" d="M12 6v3l4-4-4-4v3c-4.42 0-8 3.58-8 8 0 1.57.46 3.03 1.24 4.26L6.7 14.8A5.87 5.87 0 0 1 6 12c0-3.31 2.69-6 6-6zm6.76 1.74L17.3 9.2c.44.84.7 1.79.7 2.8 0 3.31-2.69 6-6 6v-3l-4 4 4 4v-3c4.42 0 8-3.58 8-8 0-1.57-.46-3.03-1.24-4.26z"/>
- </svg>
- </button>
- <button
- class="ptz-btn"
- title="Right"
- @mousedown="handlePtzStart(PTZDirection.Right)"
- @mouseup="handlePtzStop"
- >
- <svg viewBox="0 0 24 24" width="22" height="22">
- <path fill="currentColor" d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/>
- </svg>
- </button>
- <div class="ptz-placeholder" />
- <button
- class="ptz-btn"
- title="Down"
- @mousedown="handlePtzStart(PTZDirection.Down)"
- @mouseup="handlePtzStop"
- >
- <svg viewBox="0 0 24 24" width="22" height="22">
- <path fill="currentColor" d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6z"/>
- </svg>
- </button>
- <div class="ptz-placeholder" />
- </div>
- </template>
- </div>
- <!-- Collapse toggle -->
- <button class="panel-toggle" :title="ptzCollapsed ? 'Expand' : 'Collapse'" @click="ptzCollapsed = !ptzCollapsed">
- <svg viewBox="0 0 24 24" width="16" height="16">
- <path fill="currentColor" :d="ptzCollapsed ? 'M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z' : 'M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z'"/>
- </svg>
- </button>
- </div>
- <!-- 视频区域(右侧) -->
- <div class="video-wrapper">
- <MyVideo
- ref="videoRef"
- :drag-flag="true"
- @video-error="handleVideoError"
- />
- <button
- class="fullscreen-btn"
- :title="isFullscreen ? 'Exit Fullscreen (F)' : 'Fullscreen (F)'"
- @click="toggleFullscreen"
- >
- <svg
- v-if="!isFullscreen"
- viewBox="0 0 24 24"
- width="16"
- height="16"
- >
- <path fill="currentColor" d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/>
- </svg>
- <svg
- v-else
- viewBox="0 0 24 24"
- width="16"
- height="16"
- >
- <path fill="currentColor" d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z"/>
- </svg>
- </button>
- </div>
- </div>
- </template>
- <script setup lang="ts">
- import { ref, onMounted, onUnmounted } from 'vue'
- import { ElMessage } from 'element-plus'
- import MyVideo from '@/components/myVideo.vue'
- import { ptzControl, PTZDirection, getDeviceNum, putDeviceNum } from '@/api/ptz'
- const videoRef = ref()
- const isFullscreen = ref(false)
- const containerRef = ref<HTMLElement>()
- const ptzSpeed = ref(4)
- const isPtzMoving = ref(false)
- const ptzCollapsed = ref(false)
- // Device state
- const showChannelSelector = ref(false)
- const selectedChn = ref(0) // 0: Fixed, 1: PTZ
- const ptzEnabled = ref(false)
- const channelOptions = [
- { label: 'Fixed Camera', value: 0 },
- { label: 'PTZ Camera', value: 1 }
- ]
- // Fetch device info
- const fetchDeviceInfo = async () => {
- try {
- const res = await getDeviceNum()
- // const res = {"data":{"LensNumber":2,"ChnNumber":0}}
- const lensNumber = res?.data?.LensNumber ?? 1
- const chnNumber = res?.data?.ChnNumber ?? 0
- // Dual lens: show channel selector
- showChannelSelector.value = lensNumber >= 2
- // Sync current selection and PTZ state
- selectedChn.value = chnNumber
- ptzEnabled.value = chnNumber === 1
- } catch (error) {
- console.error('Failed to get device info:', error)
- }
- }
- // User switches between Fixed / PTZ camera via dropdown
- const handleChnChange = async (val: number) => {
- try {
- await putDeviceNum({ ChnNumber: val })
- // Re-fetch to get confirmed state from device
- await fetchDeviceInfo()
- // Refresh video stream after switch
- if (videoRef.value?.refreshWebSocket) {
- videoRef.value.refreshWebSocket()
- }
- } catch (error) {
- console.error('Failed to switch channel:', error)
- ElMessage.error('Failed to switch channel, please try again')
- // Revert selection on failure
- await fetchDeviceInfo()
- }
- }
- const toggleFullscreen = () => {
- if (!containerRef.value) return
- if (!isFullscreen.value) {
- if (containerRef.value.requestFullscreen) {
- containerRef.value.requestFullscreen()
- }
- } else {
- if (document.exitFullscreen) {
- document.exitFullscreen()
- }
- }
- }
- const handleFullscreenChange = () => {
- isFullscreen.value = !!document.fullscreenElement
- }
- const handleKeydown = (event: KeyboardEvent) => {
- switch (event.code) {
- case 'KeyF':
- case 'F11':
- event.preventDefault()
- toggleFullscreen()
- break
- case 'Escape':
- if (isFullscreen.value) {
- toggleFullscreen()
- }
- break
- }
- }
- const handlePtzStart = async (direction: number) => {
- if (isPtzMoving.value) return
- isPtzMoving.value = true
- try {
- await ptzControl({ PtzCmd: direction })
- } catch (error) {
- console.error('PTZ control failed:', error)
- }
- }
- const handlePtzStop = async () => {
- if (!isPtzMoving.value) return
- isPtzMoving.value = false
- try {
- await ptzControl({ PtzCmd: PTZDirection.Stop })
- } catch (error) {
- console.error('PTZ stop failed:', error)
- }
- }
- const handlePtzCenter = async () => {
- try {
- await ptzControl({ PtzCmd: PTZDirection.Center })
- } catch (error) {
- console.error('PTZ Center failed:', error)
- }
- }
- const handleVideoError = (error: any) => {
- console.error('Video playback error:', error)
- }
- onMounted(() => {
- document.addEventListener('fullscreenchange', handleFullscreenChange)
- document.addEventListener('keydown', handleKeydown)
- fetchDeviceInfo()
- })
- onUnmounted(() => {
- document.removeEventListener('fullscreenchange', handleFullscreenChange)
- document.removeEventListener('keydown', handleKeydown)
- })
- </script>
- <style lang="scss" scoped>
- .video-container {
- width: 100%;
- height: calc(100vh - 90px);
- display: flex;
- margin-top: 20px;
- justify-content: center;
- align-items: center;
- gap: 16px;
- padding: 0 16px;
- position: relative;
- overflow: hidden;
- &.is-fullscreen {
- position: fixed;
- top: 0;
- left: 0;
- z-index: 9999;
- width: 100vw;
- height: 100vh;
- margin-top: 0;
- padding: 0;
- }
- }
- .video-wrapper {
- position: relative;
- width: calc(100vw - 208px);
- height: calc((100vw - 208px) / 16 * 9);
- max-height: calc(100vh - 110px);
- max-width: calc((100vh - 110px) / 9 * 16);
- flex-shrink: 0;
- :deep(.preview-container) {
- width: 100%;
- height: 100%;
- }
- .video-container.is-fullscreen & {
- width: 100vw;
- height: 100vh;
- max-width: none;
- max-height: none;
- }
- :deep(.video_container) {
- width: 100%;
- height: 100%;
- background: #000;
- border-radius: 5px;
- overflow: hidden;
- .video-container.is-fullscreen & {
- border-radius: 0;
- }
- }
- :deep(#player) {
- width: 100%;
- height: 100%;
- object-fit: contain;
- }
- :deep(.video-control) {
- opacity: 0;
- transition: opacity 0.3s ease;
- &:hover { opacity: 1; }
- }
- &:hover :deep(.video-control) {
- opacity: 1;
- }
- }
- .fullscreen-btn {
- position: absolute;
- top: 16px;
- right: 16px;
- width: 40px;
- height: 40px;
- background: rgba(0, 0, 0, 0.6);
- border: none;
- border-radius: 8px;
- color: white;
- cursor: pointer;
- display: flex;
- align-items: center;
- justify-content: center;
- opacity: 0;
- transition: all 0.3s ease;
- backdrop-filter: blur(8px);
- z-index: 10;
- &:hover {
- background: rgba(0, 0, 0, 0.8);
- transform: scale(1.05);
- }
- &:active { transform: scale(0.95); }
- .video-wrapper:hover & { opacity: 1; }
- .video-container.is-fullscreen & {
- top: 20px;
- right: 20px;
- width: 48px;
- height: 48px;
- }
- }
- /* Control sidebar */
- .control-sidebar {
- flex-shrink: 0;
- display: flex;
- align-items: center;
- .video-container.is-fullscreen & {
- position: absolute;
- left: 20px;
- top: 50%;
- transform: translateY(-50%);
- z-index: 10;
- }
- }
- .control-panel {
- width: 140px;
- background: #f5f7fa;
- border-radius: 10px;
- padding: 12px;
- display: flex;
- flex-direction: column;
- gap: 10px;
- overflow: hidden;
- transition: width 0.3s ease, padding 0.3s ease, opacity 0.3s ease;
- &.is-collapsed {
- width: 0;
- padding: 0;
- opacity: 0;
- pointer-events: none;
- }
- .video-container.is-fullscreen & {
- background: rgba(0, 0, 0, 0.7);
- backdrop-filter: blur(12px);
- .control-label { color: #fff; }
- .control-divider { border-color: rgba(255, 255, 255, 0.15); }
- .ptz-btn {
- background: rgba(255, 255, 255, 0.1);
- color: #fff;
- border-color: rgba(255, 255, 255, 0.15);
- &:hover { background: rgba(255, 255, 255, 0.2); border-color: rgba(255, 255, 255, 0.3); }
- &:active { background: rgba(255, 255, 255, 0.3); }
- }
- .ptz-btn-center {
- background: rgba(64, 158, 255, 0.3) !important;
- border-color: rgba(64, 158, 255, 0.5) !important;
- &:hover { background: rgba(64, 158, 255, 0.5) !important; }
- }
- :deep(.el-input__wrapper) {
- background: rgba(255, 255, 255, 0.1);
- box-shadow: none;
- }
- :deep(.el-input__inner) { color: #fff; }
- :deep(.el-select__caret) { color: #fff; }
- }
- }
- .control-label {
- font-size: 12px;
- font-weight: 600;
- color: #909399;
- white-space: nowrap;
- }
- .control-divider {
- border: none;
- border-top: 1px solid #e4e7ed;
- margin: 2px 0;
- }
- .channel-selector {
- display: flex;
- flex-direction: column;
- gap: 6px;
- :deep(.el-select) {
- width: 100%;
- }
- }
- .panel-toggle {
- width: 24px;
- height: 48px;
- border: 1px solid #e4e7ed;
- border-radius: 0 8px 8px 0;
- background: #f5f7fa;
- color: #909399;
- cursor: pointer;
- display: flex;
- align-items: center;
- justify-content: center;
- flex-shrink: 0;
- transition: all 0.2s ease;
- padding: 0;
- margin-left: -1px;
- &:hover {
- background: #ecf5ff;
- color: #409eff;
- border-color: #b3d8ff;
- }
- .video-container.is-fullscreen & {
- background: rgba(0, 0, 0, 0.7);
- border-color: rgba(255, 255, 255, 0.15);
- color: #fff;
- backdrop-filter: blur(12px);
- &:hover {
- background: rgba(0, 0, 0, 0.85);
- border-color: rgba(255, 255, 255, 0.3);
- }
- }
- }
- .ptz-grid {
- display: grid;
- grid-template-columns: repeat(3, 1fr);
- gap: 4px;
- width: 100%;
- }
- .ptz-placeholder { aspect-ratio: 1; }
- .ptz-btn {
- width: 100%;
- aspect-ratio: 1;
- border: 1px solid #e4e7ed;
- border-radius: 6px;
- background: #fff;
- color: #606266;
- cursor: pointer;
- display: flex;
- align-items: center;
- justify-content: center;
- transition: all 0.2s ease;
- padding: 0;
- &:hover { background: #ecf5ff; border-color: #b3d8ff; color: #409eff; }
- &:active { background: #d9ecff; transform: scale(0.95); }
- }
- .ptz-btn-center {
- background: #ecf5ff !important;
- border-color: #b3d8ff !important;
- color: #409eff !important;
- &:hover { background: #d9ecff !important; }
- }
- @media (max-width: 768px) {
- .video-container {
- flex-direction: column;
- height: auto;
- padding: 0;
- gap: 12px;
- }
- .control-sidebar { order: 2; }
- .control-panel { width: 100%; max-width: 200px; }
- .video-wrapper {
- order: 1;
- width: 100vw;
- height: calc(100vw / 16 * 9);
- max-height: none;
- max-width: none;
- :deep(.video_container) { border-radius: 0; }
- }
- .fullscreen-btn { width: 36px; height: 36px; top: 12px; right: 12px; svg { width: 14px; height: 14px; } }
- }
- @media (prefers-reduced-motion: reduce) {
- * { transition: none !important; animation: none !important; }
- }
- </style>
|