index.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583
  1. <template>
  2. <div
  3. ref="containerRef"
  4. class="video-container"
  5. :class="{ 'is-fullscreen': isFullscreen }"
  6. >
  7. <!-- PTZ 球机控制面板(左侧) -->
  8. <div class="ptz-wrapper">
  9. <div class="ptz-panel" :class="{ 'is-collapsed': ptzCollapsed }">
  10. <div class="ptz-title">PTZ Control</div>
  11. <div class="ptz-grid">
  12. <div class="ptz-placeholder" />
  13. <button
  14. class="ptz-btn"
  15. title="Up"
  16. @mousedown="handlePtzStart(PTZDirection.Up)"
  17. @mouseup="handlePtzStop"
  18. >
  19. <svg viewBox="0 0 24 24" width="22" height="22">
  20. <path fill="currentColor" d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z"/>
  21. </svg>
  22. </button>
  23. <div class="ptz-placeholder" />
  24. <button
  25. class="ptz-btn"
  26. title="Left"
  27. @mousedown="handlePtzStart(PTZDirection.Left)"
  28. @mouseup="handlePtzStop"
  29. >
  30. <svg viewBox="0 0 24 24" width="22" height="22">
  31. <path fill="currentColor" d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>
  32. </svg>
  33. </button>
  34. <button
  35. class="ptz-btn ptz-btn-center"
  36. title="Reset"
  37. @click="handlePtzCenter"
  38. >
  39. <svg viewBox="0 0 24 24" width="22" height="22">
  40. <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"/>
  41. </svg>
  42. </button>
  43. <button
  44. class="ptz-btn"
  45. title="Right"
  46. @mousedown="handlePtzStart(PTZDirection.Right)"
  47. @mouseup="handlePtzStop"
  48. >
  49. <svg viewBox="0 0 24 24" width="22" height="22">
  50. <path fill="currentColor" d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/>
  51. </svg>
  52. </button>
  53. <div class="ptz-placeholder" />
  54. <button
  55. class="ptz-btn"
  56. title="Down"
  57. @mousedown="handlePtzStart(PTZDirection.Down)"
  58. @mouseup="handlePtzStop"
  59. >
  60. <svg viewBox="0 0 24 24" width="22" height="22">
  61. <path fill="currentColor" d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6z"/>
  62. </svg>
  63. </button>
  64. <div class="ptz-placeholder" />
  65. </div>
  66. <!-- 速度控制 -->
  67. <!-- <div class="ptz-speed">-->
  68. <!-- <span class="ptz-speed-label">Speed</span>-->
  69. <!-- <el-slider-->
  70. <!-- v-model="ptzSpeed"-->
  71. <!-- :min="1"-->
  72. <!-- :max="8"-->
  73. <!-- :step="1"-->
  74. <!-- :show-tooltip="false"-->
  75. <!-- size="small"-->
  76. <!-- />-->
  77. <!-- <span class="ptz-speed-value">{{ ptzSpeed }}</span>-->
  78. <!-- </div>-->
  79. </div>
  80. <!-- 折叠/展开按钮 -->
  81. <button class="ptz-toggle" :title="ptzCollapsed ? 'Expand PTZ' : 'Collapse PTZ'" @click="ptzCollapsed = !ptzCollapsed">
  82. <svg viewBox="0 0 24 24" width="16" height="16">
  83. <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'"/>
  84. </svg>
  85. </button>
  86. </div>
  87. <!-- 视频区域(右侧) -->
  88. <div class="video-wrapper">
  89. <MyVideo
  90. ref="videoRef"
  91. :drag-flag="true"
  92. :channel="currentChannel"
  93. @video-error="handleVideoError"
  94. />
  95. <!-- Channel selector -->
  96. <div v-if="channelOptions.length > 1" class="channel-selector">
  97. <el-select
  98. v-model="currentChannel"
  99. size="small"
  100. placeholder="Channel"
  101. @change="handleChannelChange"
  102. >
  103. <el-option
  104. v-for="ch in channelOptions"
  105. :key="ch.value"
  106. :label="ch.label"
  107. :value="ch.value"
  108. />
  109. </el-select>
  110. </div>
  111. <button
  112. class="fullscreen-btn"
  113. :title="isFullscreen ? 'Exit Fullscreen (F)' : 'Fullscreen (F)'"
  114. @click="toggleFullscreen"
  115. >
  116. <svg
  117. v-if="!isFullscreen"
  118. viewBox="0 0 24 24"
  119. width="16"
  120. height="16"
  121. >
  122. <path fill="currentColor" d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/>
  123. </svg>
  124. <svg
  125. v-else
  126. viewBox="0 0 24 24"
  127. width="16"
  128. height="16"
  129. >
  130. <path fill="currentColor" d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z"/>
  131. </svg>
  132. </button>
  133. </div>
  134. </div>
  135. </template>
  136. <script setup lang="ts">
  137. import { ref, onMounted, onUnmounted } from 'vue'
  138. import MyVideo from '@/components/myVideo.vue'
  139. import { ptzControl, PTZDirection, getDeviceNum } from '@/api/ptz'
  140. const videoRef = ref()
  141. const isFullscreen = ref(false)
  142. const containerRef = ref<HTMLElement>()
  143. const ptzSpeed = ref(4)
  144. const isPtzMoving = ref(false)
  145. const ptzCollapsed = ref(false)
  146. // Channel
  147. const currentChannel = ref('ch1')
  148. const channelOptions = ref<{ label: string; value: string }[]>([
  149. { label: 'Channel 1', value: 'ch1' }
  150. ])
  151. // Fetch lens count and build channel options
  152. const fetchChannelCount = async () => {
  153. try {
  154. // const res = await getDeviceNum()
  155. // const lensNumber = res?.data?.LensNumber ?? 1
  156. const lensNumber = 2
  157. const options = []
  158. for (let i = 1; i <= lensNumber; i++) {
  159. options.push({ label: `Channel ${i}`, value: `ch${i}` })
  160. }
  161. channelOptions.value = options
  162. } catch (error) {
  163. console.error('Failed to get device channel count:', error)
  164. }
  165. }
  166. // Switch channel: refresh video stream
  167. const handleChannelChange = () => {
  168. if (videoRef.value?.refreshWebSocket) {
  169. videoRef.value.refreshWebSocket()
  170. }
  171. }
  172. const toggleFullscreen = () => {
  173. if (!containerRef.value) return
  174. if (!isFullscreen.value) {
  175. if (containerRef.value.requestFullscreen) {
  176. containerRef.value.requestFullscreen()
  177. }
  178. } else {
  179. if (document.exitFullscreen) {
  180. document.exitFullscreen()
  181. }
  182. }
  183. }
  184. const handleFullscreenChange = () => {
  185. isFullscreen.value = !!document.fullscreenElement
  186. }
  187. const handleKeydown = (event: KeyboardEvent) => {
  188. switch (event.code) {
  189. case 'KeyF':
  190. case 'F11':
  191. event.preventDefault()
  192. toggleFullscreen()
  193. break
  194. case 'Escape':
  195. if (isFullscreen.value) {
  196. toggleFullscreen()
  197. }
  198. break
  199. }
  200. }
  201. const handlePtzStart = async (direction: number) => {
  202. if (isPtzMoving.value) return
  203. isPtzMoving.value = true
  204. try {
  205. await ptzControl({ PtzCmd: direction })
  206. } catch (error) {
  207. console.error('PTZ control failed:', error)
  208. }
  209. }
  210. const handlePtzStop = async () => {
  211. if (!isPtzMoving.value) return
  212. isPtzMoving.value = false
  213. try {
  214. await ptzControl({ PtzCmd: PTZDirection.Stop })
  215. } catch (error) {
  216. console.error('PTZ stop failed:', error)
  217. }
  218. }
  219. const handlePtzCenter = async () => {
  220. try {
  221. await ptzControl({ PtzCmd: PTZDirection.Center })
  222. } catch (error) {
  223. console.error('PTZ Center failed:', error)
  224. }
  225. }
  226. const handleVideoError = (error: any) => {
  227. console.error('Video playback error:', error)
  228. }
  229. onMounted(() => {
  230. document.addEventListener('fullscreenchange', handleFullscreenChange)
  231. document.addEventListener('keydown', handleKeydown)
  232. fetchChannelCount()
  233. })
  234. onUnmounted(() => {
  235. document.removeEventListener('fullscreenchange', handleFullscreenChange)
  236. document.removeEventListener('keydown', handleKeydown)
  237. })
  238. </script>
  239. <style lang="scss" scoped>
  240. .video-container {
  241. width: 100%;
  242. height: calc(100vh - 90px);
  243. display: flex;
  244. margin-top: 20px;
  245. justify-content: center;
  246. align-items: center;
  247. gap: 16px;
  248. padding: 0 16px;
  249. position: relative;
  250. overflow: hidden;
  251. &.is-fullscreen {
  252. position: fixed;
  253. top: 0;
  254. left: 0;
  255. z-index: 9999;
  256. width: 100vw;
  257. height: 100vh;
  258. margin-top: 0;
  259. padding: 0;
  260. }
  261. }
  262. .video-wrapper {
  263. position: relative;
  264. /*
  265. * 用固定的 calc 尺寸而非 flex:1,
  266. * 这样 video.load() 清空内容时容器不会收缩。
  267. * 宽度 = 总宽 - PTZ面板(180px) - gap(16px) - 两侧padding(32px) - 留白(20px)
  268. * 高度按 16:9 从宽度推算,同时不超过容器高度
  269. */
  270. width: calc(100vw - 208px);
  271. height: calc((100vw - 208px) / 16 * 9);
  272. max-height: calc(100vh - 110px);
  273. max-width: calc((100vh - 110px) / 9 * 16);
  274. flex-shrink: 0;
  275. :deep(.preview-container) {
  276. width: 100%;
  277. height: 100%;
  278. }
  279. // 全屏时视频撑满整个屏幕
  280. .video-container.is-fullscreen & {
  281. width: 100vw;
  282. height: 100vh;
  283. max-width: none;
  284. max-height: none;
  285. }
  286. :deep(.preview-container) {
  287. width: 100%;
  288. height: 100%;
  289. }
  290. :deep(.video_container) {
  291. width: 100%;
  292. height: 100%;
  293. background: #000;
  294. border-radius: 5px;
  295. overflow: hidden;
  296. .video-container.is-fullscreen & {
  297. border-radius: 0;
  298. }
  299. }
  300. :deep(#player) {
  301. width: 100%;
  302. height: 100%;
  303. object-fit: contain;
  304. }
  305. :deep(.video-control) {
  306. opacity: 0;
  307. transition: opacity 0.3s ease;
  308. &:hover { opacity: 1; }
  309. }
  310. &:hover :deep(.video-control) {
  311. opacity: 1;
  312. }
  313. }
  314. /* Channel selector */
  315. .channel-selector {
  316. position: absolute;
  317. bottom: 48px;
  318. right: 16px;
  319. z-index: 10;
  320. opacity: 0;
  321. transition: opacity 0.3s ease;
  322. .video-wrapper:hover & {
  323. opacity: 1;
  324. }
  325. :deep(.el-select) {
  326. width: 120px;
  327. }
  328. :deep(.el-input__wrapper) {
  329. background: rgba(0, 0, 0, 0.6);
  330. border: none;
  331. box-shadow: none;
  332. backdrop-filter: blur(8px);
  333. border-radius: 6px;
  334. }
  335. :deep(.el-input__inner) {
  336. color: #fff;
  337. font-size: 12px;
  338. &::placeholder {
  339. color: rgba(255, 255, 255, 0.6);
  340. }
  341. }
  342. :deep(.el-select__caret) {
  343. color: #fff;
  344. }
  345. }
  346. .fullscreen-btn {
  347. position: absolute;
  348. top: 16px;
  349. right: 16px;
  350. width: 40px;
  351. height: 40px;
  352. background: rgba(0, 0, 0, 0.6);
  353. border: none;
  354. border-radius: 8px;
  355. color: white;
  356. cursor: pointer;
  357. display: flex;
  358. align-items: center;
  359. justify-content: center;
  360. opacity: 0;
  361. transition: all 0.3s ease;
  362. backdrop-filter: blur(8px);
  363. z-index: 10;
  364. &:hover {
  365. background: rgba(0, 0, 0, 0.8);
  366. transform: scale(1.05);
  367. }
  368. &:active { transform: scale(0.95); }
  369. .video-wrapper:hover & { opacity: 1; }
  370. .video-container.is-fullscreen & {
  371. top: 20px;
  372. right: 20px;
  373. width: 48px;
  374. height: 48px;
  375. }
  376. }
  377. /* PTZ 云台控制面板 */
  378. .ptz-wrapper {
  379. position: relative;
  380. flex-shrink: 0;
  381. display: flex;
  382. align-items: center;
  383. }
  384. .ptz-panel {
  385. width: 140px;
  386. background: #f5f7fa;
  387. border-radius: 10px;
  388. padding: 12px;
  389. display: flex;
  390. flex-direction: column;
  391. align-items: center;
  392. gap: 10px;
  393. overflow: hidden;
  394. transition: width 0.3s ease, padding 0.3s ease, opacity 0.3s ease;
  395. &.is-collapsed {
  396. width: 0;
  397. padding: 0;
  398. opacity: 0;
  399. pointer-events: none;
  400. }
  401. .video-container.is-fullscreen & {
  402. background: rgba(0, 0, 0, 0.7);
  403. backdrop-filter: blur(12px);
  404. .ptz-title { color: #fff; }
  405. .ptz-btn {
  406. background: rgba(255, 255, 255, 0.1);
  407. color: #fff;
  408. border-color: rgba(255, 255, 255, 0.15);
  409. &:hover { background: rgba(255, 255, 255, 0.2); border-color: rgba(255, 255, 255, 0.3); }
  410. &:active { background: rgba(255, 255, 255, 0.3); }
  411. }
  412. .ptz-btn-center {
  413. background: rgba(64, 158, 255, 0.3) !important;
  414. border-color: rgba(64, 158, 255, 0.5) !important;
  415. &:hover { background: rgba(64, 158, 255, 0.5) !important; }
  416. }
  417. .ptz-speed-label, .ptz-speed-value { color: #fff; }
  418. }
  419. }
  420. .ptz-toggle {
  421. width: 24px;
  422. height: 48px;
  423. border: 1px solid #e4e7ed;
  424. border-radius: 0 8px 8px 0;
  425. background: #f5f7fa;
  426. color: #909399;
  427. cursor: pointer;
  428. display: flex;
  429. align-items: center;
  430. justify-content: center;
  431. flex-shrink: 0;
  432. transition: all 0.2s ease;
  433. padding: 0;
  434. margin-left: -1px;
  435. &:hover {
  436. background: #ecf5ff;
  437. color: #409eff;
  438. border-color: #b3d8ff;
  439. }
  440. .video-container.is-fullscreen & {
  441. background: rgba(0, 0, 0, 0.7);
  442. border-color: rgba(255, 255, 255, 0.15);
  443. color: #fff;
  444. backdrop-filter: blur(12px);
  445. &:hover {
  446. background: rgba(0, 0, 0, 0.85);
  447. border-color: rgba(255, 255, 255, 0.3);
  448. }
  449. }
  450. }
  451. .video-container.is-fullscreen .ptz-wrapper {
  452. position: absolute;
  453. left: 20px;
  454. top: 50%;
  455. transform: translateY(-50%);
  456. z-index: 10;
  457. }
  458. .ptz-title {
  459. font-size: 14px;
  460. font-weight: 600;
  461. color: #303133;
  462. white-space: nowrap;
  463. }
  464. .ptz-grid {
  465. display: grid;
  466. grid-template-columns: repeat(3, 1fr);
  467. gap: 4px;
  468. width: 100%;
  469. }
  470. .ptz-placeholder { aspect-ratio: 1; }
  471. .ptz-btn {
  472. width: 100%;
  473. aspect-ratio: 1;
  474. border: 1px solid #e4e7ed;
  475. border-radius: 6px;
  476. background: #fff;
  477. color: #606266;
  478. cursor: pointer;
  479. display: flex;
  480. align-items: center;
  481. justify-content: center;
  482. transition: all 0.2s ease;
  483. padding: 0;
  484. &:hover { background: #ecf5ff; border-color: #b3d8ff; color: #409eff; }
  485. &:active { background: #d9ecff; transform: scale(0.95); }
  486. }
  487. .ptz-btn-center {
  488. background: #ecf5ff !important;
  489. border-color: #b3d8ff !important;
  490. color: #409eff !important;
  491. &:hover { background: #d9ecff !important; }
  492. }
  493. .ptz-speed {
  494. width: 100%;
  495. display: flex;
  496. align-items: center;
  497. gap: 8px;
  498. .ptz-speed-label { font-size: 12px; color: #909399; white-space: nowrap; }
  499. .ptz-speed-value { font-size: 12px; color: #606266; font-weight: 600; min-width: 14px; text-align: center; }
  500. :deep(.el-slider) { flex: 1; }
  501. }
  502. @media (max-width: 768px) {
  503. .video-container {
  504. flex-direction: column;
  505. height: auto;
  506. padding: 0;
  507. gap: 12px;
  508. }
  509. .ptz-panel { width: 100%; max-width: 200px; order: 2; }
  510. .video-wrapper {
  511. order: 1;
  512. width: 100vw;
  513. height: calc(100vw / 16 * 9);
  514. max-height: none;
  515. max-width: none;
  516. :deep(.video_container) { border-radius: 0; }
  517. }
  518. .fullscreen-btn { width: 36px; height: 36px; top: 12px; right: 12px; svg { width: 14px; height: 14px; } }
  519. }
  520. @media (prefers-reduced-motion: reduce) {
  521. * { transition: none !important; animation: none !important; }
  522. }
  523. </style>