| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583 |
- <template>
- <div
- ref="containerRef"
- class="video-container"
- :class="{ 'is-fullscreen': isFullscreen }"
- >
- <!-- PTZ 球机控制面板(左侧) -->
- <div class="ptz-wrapper">
- <div class="ptz-panel" :class="{ 'is-collapsed': ptzCollapsed }">
- <div class="ptz-title">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>
- <!-- 速度控制 -->
- <!-- <div class="ptz-speed">-->
- <!-- <span class="ptz-speed-label">Speed</span>-->
- <!-- <el-slider-->
- <!-- v-model="ptzSpeed"-->
- <!-- :min="1"-->
- <!-- :max="8"-->
- <!-- :step="1"-->
- <!-- :show-tooltip="false"-->
- <!-- size="small"-->
- <!-- />-->
- <!-- <span class="ptz-speed-value">{{ ptzSpeed }}</span>-->
- <!-- </div>-->
- </div>
- <!-- 折叠/展开按钮 -->
- <button class="ptz-toggle" :title="ptzCollapsed ? 'Expand PTZ' : 'Collapse PTZ'" @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"
- :channel="currentChannel"
- @video-error="handleVideoError"
- />
- <!-- Channel selector -->
- <div v-if="channelOptions.length > 1" class="channel-selector">
- <el-select
- v-model="currentChannel"
- size="small"
- placeholder="Channel"
- @change="handleChannelChange"
- >
- <el-option
- v-for="ch in channelOptions"
- :key="ch.value"
- :label="ch.label"
- :value="ch.value"
- />
- </el-select>
- </div>
- <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 MyVideo from '@/components/myVideo.vue'
- import { ptzControl, PTZDirection, getDeviceNum } 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)
- // Channel
- const currentChannel = ref('ch1')
- const channelOptions = ref<{ label: string; value: string }[]>([
- { label: 'Channel 1', value: 'ch1' }
- ])
- // Fetch lens count and build channel options
- const fetchChannelCount = async () => {
- try {
- // const res = await getDeviceNum()
- // const lensNumber = res?.data?.LensNumber ?? 1
- const lensNumber = 2
- const options = []
- for (let i = 1; i <= lensNumber; i++) {
- options.push({ label: `Channel ${i}`, value: `ch${i}` })
- }
- channelOptions.value = options
- } catch (error) {
- console.error('Failed to get device channel count:', error)
- }
- }
- // Switch channel: refresh video stream
- const handleChannelChange = () => {
- if (videoRef.value?.refreshWebSocket) {
- videoRef.value.refreshWebSocket()
- }
- }
- 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)
- fetchChannelCount()
- })
- 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;
- /*
- * 用固定的 calc 尺寸而非 flex:1,
- * 这样 video.load() 清空内容时容器不会收缩。
- * 宽度 = 总宽 - PTZ面板(180px) - gap(16px) - 两侧padding(32px) - 留白(20px)
- * 高度按 16:9 从宽度推算,同时不超过容器高度
- */
- 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(.preview-container) {
- width: 100%;
- height: 100%;
- }
- :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;
- }
- }
- /* Channel selector */
- .channel-selector {
- position: absolute;
- bottom: 48px;
- right: 16px;
- z-index: 10;
- opacity: 0;
- transition: opacity 0.3s ease;
- .video-wrapper:hover & {
- opacity: 1;
- }
- :deep(.el-select) {
- width: 120px;
- }
- :deep(.el-input__wrapper) {
- background: rgba(0, 0, 0, 0.6);
- border: none;
- box-shadow: none;
- backdrop-filter: blur(8px);
- border-radius: 6px;
- }
- :deep(.el-input__inner) {
- color: #fff;
- font-size: 12px;
- &::placeholder {
- color: rgba(255, 255, 255, 0.6);
- }
- }
- :deep(.el-select__caret) {
- color: #fff;
- }
- }
- .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;
- }
- }
- /* PTZ 云台控制面板 */
- .ptz-wrapper {
- position: relative;
- flex-shrink: 0;
- display: flex;
- align-items: center;
- }
- .ptz-panel {
- width: 140px;
- background: #f5f7fa;
- border-radius: 10px;
- padding: 12px;
- display: flex;
- flex-direction: column;
- align-items: center;
- 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);
- .ptz-title { color: #fff; }
- .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; }
- }
- .ptz-speed-label, .ptz-speed-value { color: #fff; }
- }
- }
- .ptz-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);
- }
- }
- }
- .video-container.is-fullscreen .ptz-wrapper {
- position: absolute;
- left: 20px;
- top: 50%;
- transform: translateY(-50%);
- z-index: 10;
- }
- .ptz-title {
- font-size: 14px;
- font-weight: 600;
- color: #303133;
- white-space: nowrap;
- }
- .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; }
- }
- .ptz-speed {
- width: 100%;
- display: flex;
- align-items: center;
- gap: 8px;
- .ptz-speed-label { font-size: 12px; color: #909399; white-space: nowrap; }
- .ptz-speed-value { font-size: 12px; color: #606266; font-weight: 600; min-width: 14px; text-align: center; }
- :deep(.el-slider) { flex: 1; }
- }
- @media (max-width: 768px) {
- .video-container {
- flex-direction: column;
- height: auto;
- padding: 0;
- gap: 12px;
- }
- .ptz-panel { width: 100%; max-width: 200px; order: 2; }
- .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>
|