createPlayer.vue 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295
  1. <template>
  2. <div id="LivePlayerBox">
  3. <div
  4. v-for="(item, index) in streamList"
  5. :key="index"
  6. :class="['live-video-item', { 'is-selected': selectedIndex === index }]"
  7. @click.stop="handleSelect(item, index)"
  8. @dblclick.capture.stop.prevent="handleDblclick($event, item, index)"
  9. >
  10. <component :is="item.zj" :src="item.src" :autoplay="item.autoplay" class="liveVideo" />
  11. </div>
  12. </div>
  13. <!-- 双击放大:弹窗预览(青蓝监控风格,等比展示不失真) -->
  14. <Teleport to="body">
  15. <div v-if="previewVisible" class="camera-preview-mask" @click.self="closePreview">
  16. <div
  17. class="camera-preview-panel"
  18. :style="{ transform: `translate(${previewOffset.x}px, ${previewOffset.y}px)` }"
  19. >
  20. <div class="camera-preview-header" @mousedown="onPreviewDragStart">
  21. <span class="camera-preview-title">{{ previewTitle }}</span>
  22. <div class="camera-preview-close" title="关闭" @click="closePreview" @mousedown.stop>×</div>
  23. </div>
  24. <div class="camera-preview-body">
  25. <div class="camera-preview-stage">
  26. <component
  27. v-if="previewItem"
  28. :is="previewItem.zj"
  29. :src="previewItem.src"
  30. :autoplay="true"
  31. :stretch="false"
  32. class="camera-preview-player"
  33. />
  34. </div>
  35. </div>
  36. </div>
  37. </div>
  38. </Teleport>
  39. </template>
  40. <script setup lang="ts">
  41. import { reactive, ref, onMounted, onBeforeUnmount } from 'vue'
  42. import HlsPlayer from '@/components/vent/camera/HlsPlayer.vue'
  43. import LivePlayerWrapper from '@/components/vent/camera/LivePlayerWrapper.vue'
  44. interface PreviewStreamItem {
  45. zj: any
  46. src: string
  47. autoplay: boolean
  48. name?: string
  49. meta?: any
  50. }
  51. const props = defineProps<{
  52. srcList: any[]
  53. /** 自动播放起始下标,配合 autoPlayCount 使用,默认 0 */
  54. autoPlayStart?: number
  55. /** 自动播放数量:未传则全部自动播放 */
  56. autoPlayCount?: number
  57. /** 摄像头选中回调 */
  58. onSelect?: (camera: any, index: number) => void
  59. }>()
  60. const streamList = ref<PreviewStreamItem[]>([])
  61. /** 当前选中的摄像头下标 */
  62. const selectedIndex = ref(-1)
  63. /** 弹窗预览 */
  64. const previewVisible = ref(false)
  65. const previewTitle = ref('摄像头预览')
  66. const previewItem = ref<PreviewStreamItem | null>(null)
  67. /** 弹窗拖动偏移 */
  68. const previewOffset = reactive({ x: 0, y: 0 })
  69. let dragging = false
  70. let dragStartX = 0
  71. let dragStartY = 0
  72. let dragOriginX = 0
  73. let dragOriginY = 0
  74. function handleSelect(item: PreviewStreamItem, index: number) {
  75. selectedIndex.value = index
  76. props.onSelect?.(item.meta || { addr: item.src, name: item.name }, index)
  77. }
  78. function exitBrowserFullscreen() {
  79. if (document.fullscreenElement && document.exitFullscreen) {
  80. document.exitFullscreen().catch(() => {})
  81. }
  82. }
  83. function onPreviewDragMove(e: MouseEvent) {
  84. if (!dragging) return
  85. previewOffset.x = dragOriginX + (e.clientX - dragStartX)
  86. previewOffset.y = dragOriginY + (e.clientY - dragStartY)
  87. }
  88. function onPreviewDragEnd() {
  89. if (!dragging) return
  90. dragging = false
  91. window.removeEventListener('mousemove', onPreviewDragMove)
  92. window.removeEventListener('mouseup', onPreviewDragEnd)
  93. }
  94. /** 按住标题栏拖动弹窗 */
  95. function onPreviewDragStart(e: MouseEvent) {
  96. if (e.button !== 0) return
  97. dragging = true
  98. dragStartX = e.clientX
  99. dragStartY = e.clientY
  100. dragOriginX = previewOffset.x
  101. dragOriginY = previewOffset.y
  102. window.addEventListener('mousemove', onPreviewDragMove)
  103. window.addEventListener('mouseup', onPreviewDragEnd)
  104. }
  105. function closePreview() {
  106. onPreviewDragEnd()
  107. previewVisible.value = false
  108. previewItem.value = null
  109. previewOffset.x = 0
  110. previewOffset.y = 0
  111. }
  112. /** 双击:弹窗内等比放大预览,不进入浏览器全屏、不撑破网格 */
  113. function handleDblclick(_e: MouseEvent, item: PreviewStreamItem, index: number) {
  114. handleSelect(item, index)
  115. exitBrowserFullscreen()
  116. if (previewVisible.value && selectedIndex.value === index && previewItem.value?.src === item.src) {
  117. closePreview()
  118. return
  119. }
  120. previewTitle.value = item.name || item.meta?.name || '摄像头预览'
  121. previewItem.value = {
  122. zj: item.zj,
  123. src: item.src,
  124. autoplay: true,
  125. name: item.name,
  126. meta: item.meta,
  127. }
  128. previewOffset.x = 0
  129. previewOffset.y = 0
  130. previewVisible.value = true
  131. }
  132. onMounted(() => {
  133. const componentList: PreviewStreamItem[] = []
  134. const start = props.autoPlayStart ?? 0
  135. for (let i = 0; i < props.srcList.length; i++) {
  136. const stream = props.srcList[i]
  137. if (stream.addr.includes('0.0.0.0')) {
  138. stream.addr = stream.addr.replace('0.0.0.0', window.location.hostname)
  139. }
  140. const autoplay =
  141. props.autoPlayCount == null ? true : i >= start && i < start + props.autoPlayCount
  142. const meta = {
  143. name: stream.name,
  144. addr: stream.addr,
  145. cameraRate: stream.cameraRate,
  146. devicekind: stream.devicekind,
  147. id: stream.id,
  148. deviceid: stream.deviceid || stream.devId || stream.deviceId || stream.deviceID || '',
  149. ip: stream.ip || '',
  150. }
  151. if (stream.addr.toLowerCase().includes('.m3u8') || stream.addr.toLowerCase().startsWith('rtsp://')) {
  152. componentList.push({ zj: HlsPlayer, src: stream.addr, autoplay, name: stream.name, meta })
  153. } else if (stream.addr.startsWith('rtmp://')) {
  154. componentList.push({ zj: LivePlayerWrapper, src: stream.addr, autoplay, name: stream.name, meta })
  155. } else {
  156. componentList.push({ zj: LivePlayerWrapper, src: stream.addr, autoplay, name: stream.name, meta })
  157. }
  158. }
  159. streamList.value = componentList
  160. })
  161. onBeforeUnmount(() => {
  162. closePreview()
  163. streamList.value = []
  164. selectedIndex.value = -1
  165. })
  166. </script>
  167. <style lang="less" scoped>
  168. .camera-preview-mask {
  169. position: fixed;
  170. inset: 0;
  171. z-index: 3000;
  172. display: flex;
  173. align-items: center;
  174. justify-content: center;
  175. background: rgba(9, 23, 44, 0.82);
  176. backdrop-filter: blur(4px);
  177. }
  178. .camera-preview-panel {
  179. /* 略缩小弹窗,仍按 16:9 等比约束,避免画面被强行拉伸 */
  180. width: min(72vw, calc(68vh * 16 / 9), 960px);
  181. max-height: 78vh;
  182. display: flex;
  183. flex-direction: column;
  184. border: 1px solid rgba(0, 210, 255, 0.75);
  185. box-shadow:
  186. 0 0 0 2px rgba(8, 40, 70, 0.95),
  187. 0 0 22px rgba(0, 200, 255, 0.3),
  188. inset 0 0 0 1px rgba(0, 180, 255, 0.28);
  189. background: linear-gradient(180deg, rgba(6, 28, 48, 0.98) 0%, rgba(4, 16, 32, 0.98) 100%);
  190. overflow: hidden;
  191. will-change: transform;
  192. }
  193. .camera-preview-header {
  194. display: flex;
  195. align-items: center;
  196. justify-content: space-between;
  197. height: 36px;
  198. padding: 0 14px;
  199. flex-shrink: 0;
  200. color: #fff;
  201. background: linear-gradient(to right, #134c77, transparent);
  202. border-bottom: 1px solid rgba(41, 171, 241, 0.45);
  203. cursor: move;
  204. user-select: none;
  205. }
  206. .camera-preview-title {
  207. color: #e8f4ff;
  208. font-size: 14px;
  209. font-weight: 600;
  210. letter-spacing: 0.5px;
  211. overflow: hidden;
  212. text-overflow: ellipsis;
  213. white-space: nowrap;
  214. }
  215. .camera-preview-close {
  216. width: 28px;
  217. height: 24px;
  218. line-height: 20px;
  219. text-align: center;
  220. cursor: pointer;
  221. color: #01fefc;
  222. border: 1px solid #01fefc;
  223. border-radius: 2px;
  224. flex-shrink: 0;
  225. transition: background-color 0.2s, box-shadow 0.2s;
  226. user-select: none;
  227. &:hover {
  228. background-color: rgba(36, 132, 188, 0.55);
  229. box-shadow: 0 0 6px rgba(1, 254, 252, 0.45);
  230. }
  231. }
  232. .camera-preview-body {
  233. flex: 1;
  234. min-height: 0;
  235. display: flex;
  236. align-items: center;
  237. justify-content: center;
  238. padding: 14px;
  239. box-sizing: border-box;
  240. background: #0a1a2e;
  241. }
  242. .camera-preview-stage {
  243. width: 100%;
  244. aspect-ratio: 16 / 9;
  245. max-height: calc(78vh - 64px);
  246. background: #000;
  247. border: 1px solid rgba(0, 183, 255, 0.4);
  248. overflow: hidden;
  249. position: relative;
  250. }
  251. .camera-preview-player {
  252. width: 100% !important;
  253. height: 100% !important;
  254. display: block;
  255. }
  256. :deep(.camera-preview-stage video),
  257. :deep(.camera-preview-stage canvas),
  258. :deep(.camera-preview-stage .liveVideo),
  259. :deep(.camera-preview-stage .video-js),
  260. :deep(.camera-preview-stage .video-wrapper),
  261. :deep(.camera-preview-stage .player-wrapper) {
  262. width: 100% !important;
  263. height: 100% !important;
  264. max-width: 100%;
  265. max-height: 100%;
  266. object-fit: contain !important;
  267. background: #000;
  268. }
  269. </style>