Browse Source

新增XgVideoPlayer.vue 封装组件,替换全部原生 video 标签

画安 1 month ago
parent
commit
9befef2d27

+ 130 - 2
package-lock.json

@@ -21,7 +21,8 @@
         "vue": "^2.6.14",
         "vue-awesome-swiper": "^4.1.1",
         "vue-router": "^3.6.5",
-        "vuedraggable": "^2.24.3"
+        "vuedraggable": "^2.24.3",
+        "xgplayer": "^3.0.24"
       },
       "devDependencies": {
         "@babel/core": "^7.12.16",
@@ -5009,6 +5010,26 @@
       "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
       "license": "MIT"
     },
+    "node_modules/d": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz",
+      "integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==",
+      "dependencies": {
+        "es5-ext": "^0.10.64",
+        "type": "^2.7.2"
+      },
+      "engines": {
+        "node": ">=0.12"
+      }
+    },
+    "node_modules/danmu.js": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/danmu.js/-/danmu.js-1.2.1.tgz",
+      "integrity": "sha512-evDEImUBo94c846fC92K//Dzll8jXnZ3zKmYlQHwMzmvw6IW2IyjWL3Ew2SqEAzuqauFnDkwJEgZauu3uW/p1Q==",
+      "dependencies": {
+        "event-emitter": "^0.3.5"
+      }
+    },
     "node_modules/de-indent": {
       "version": "1.0.2",
       "resolved": "https://registry.npmmirror.com/de-indent/-/de-indent-1.0.2.tgz",
@@ -5266,6 +5287,11 @@
         "node": ">=0.4.0"
       }
     },
+    "node_modules/delegate": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/delegate/-/delegate-3.2.0.tgz",
+      "integrity": "sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw=="
+    },
     "node_modules/depd": {
       "version": "2.0.0",
       "resolved": "https://registry.npmmirror.com/depd/-/depd-2.0.0.tgz",
@@ -5687,6 +5713,43 @@
         "node": ">= 0.4"
       }
     },
+    "node_modules/es5-ext": {
+      "version": "0.10.64",
+      "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz",
+      "integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==",
+      "hasInstallScript": true,
+      "dependencies": {
+        "es6-iterator": "^2.0.3",
+        "es6-symbol": "^3.1.3",
+        "esniff": "^2.0.1",
+        "next-tick": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=0.10"
+      }
+    },
+    "node_modules/es6-iterator": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz",
+      "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==",
+      "dependencies": {
+        "d": "1",
+        "es5-ext": "^0.10.35",
+        "es6-symbol": "^3.1.1"
+      }
+    },
+    "node_modules/es6-symbol": {
+      "version": "3.1.4",
+      "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.4.tgz",
+      "integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==",
+      "dependencies": {
+        "d": "^1.0.2",
+        "ext": "^1.7.0"
+      },
+      "engines": {
+        "node": ">=0.12"
+      }
+    },
     "node_modules/escalade": {
       "version": "3.2.0",
       "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz",
@@ -6129,6 +6192,20 @@
         "node": ">= 8"
       }
     },
+    "node_modules/esniff": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz",
+      "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==",
+      "dependencies": {
+        "d": "^1.0.1",
+        "es5-ext": "^0.10.62",
+        "event-emitter": "^0.3.5",
+        "type": "^2.7.2"
+      },
+      "engines": {
+        "node": ">=0.10"
+      }
+    },
     "node_modules/espree": {
       "version": "7.3.1",
       "resolved": "https://registry.npmmirror.com/espree/-/espree-7.3.1.tgz",
@@ -6264,6 +6341,15 @@
         "node": ">= 0.6"
       }
     },
+    "node_modules/event-emitter": {
+      "version": "0.3.5",
+      "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz",
+      "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==",
+      "dependencies": {
+        "d": "1",
+        "es5-ext": "~0.10.14"
+      }
+    },
     "node_modules/event-pubsub": {
       "version": "4.3.0",
       "resolved": "https://registry.npmmirror.com/event-pubsub/-/event-pubsub-4.3.0.tgz",
@@ -6278,7 +6364,6 @@
       "version": "4.0.7",
       "resolved": "https://registry.npmmirror.com/eventemitter3/-/eventemitter3-4.0.7.tgz",
       "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
-      "dev": true,
       "license": "MIT"
     },
     "node_modules/events": {
@@ -6374,6 +6459,14 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/ext": {
+      "version": "1.7.0",
+      "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz",
+      "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==",
+      "dependencies": {
+        "type": "^2.7.2"
+      }
+    },
     "node_modules/fast-deep-equal": {
       "version": "3.1.3",
       "resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -8624,6 +8717,11 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/next-tick": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz",
+      "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ=="
+    },
     "node_modules/nice-try": {
       "version": "1.0.5",
       "resolved": "https://registry.npmmirror.com/nice-try/-/nice-try-1.0.5.tgz",
@@ -11562,6 +11660,11 @@
       "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
       "license": "0BSD"
     },
+    "node_modules/type": {
+      "version": "2.7.3",
+      "resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz",
+      "integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ=="
+    },
     "node_modules/type-check": {
       "version": "0.4.0",
       "resolved": "https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz",
@@ -12590,6 +12693,31 @@
         }
       }
     },
+    "node_modules/xgplayer": {
+      "version": "3.0.24",
+      "resolved": "https://registry.npmjs.org/xgplayer/-/xgplayer-3.0.24.tgz",
+      "integrity": "sha512-rK3hoJ+1Pbl9PUCHgeJv1GnOQF28BKLQFTBxdvi0pHsibJeXaDUf51SLFcgNVAPN15aDYcjJgaSf7LQ0RfEFPQ==",
+      "dependencies": {
+        "danmu.js": ">=1.2.1",
+        "delegate": "^3.2.0",
+        "eventemitter3": "^4.0.7",
+        "xgplayer-subtitles": "3.0.24"
+      },
+      "peerDependencies": {
+        "core-js": ">=3.12.1"
+      }
+    },
+    "node_modules/xgplayer-subtitles": {
+      "version": "3.0.24",
+      "resolved": "https://registry.npmjs.org/xgplayer-subtitles/-/xgplayer-subtitles-3.0.24.tgz",
+      "integrity": "sha512-yrHROepD9kTGWiTSxBjh+TGaK9orQOHQ1GUxWFOkQzO8BLl/pcIAqqJkHiAoP0j+eU8VdIhpb55oJRIkTTWPsA==",
+      "dependencies": {
+        "eventemitter3": "^4.0.7"
+      },
+      "peerDependencies": {
+        "core-js": ">=3.12.1"
+      }
+    },
     "node_modules/y18n": {
       "version": "5.0.8",
       "resolved": "https://registry.npmmirror.com/y18n/-/y18n-5.0.8.tgz",

+ 2 - 1
package.json

@@ -21,7 +21,8 @@
     "vue": "^2.6.14",
     "vue-awesome-swiper": "^4.1.1",
     "vue-router": "^3.6.5",
-    "vuedraggable": "^2.24.3"
+    "vuedraggable": "^2.24.3",
+    "xgplayer": "^3.0.24"
   },
   "devDependencies": {
     "@babel/core": "^7.12.16",

+ 7 - 5
src/components/IntersectionSignalMonitoring.vue

@@ -1,10 +1,10 @@
 <template>
     <div class="container" ref="Container">
         <div class="intersection" ref="intersectionBox">
-            <video class="video-1" :src="video1" :style="videoStyles.v1" autoplay muted loop></video>
-            <video class="video-2" :src="video2" :style="videoStyles.v2" autoplay muted loop></video>
-            <video class="video-3" :src="video1" :style="videoStyles.v3" autoplay muted loop></video>
-            <video class="video-4" :src="video2" :style="videoStyles.v4" autoplay muted loop></video>
+            <div class="video-1" :style="videoStyles.v1"><XgVideoPlayer :src="video1" /></div>
+            <div class="video-2" :style="videoStyles.v2"><XgVideoPlayer :src="video2" /></div>
+            <div class="video-3" :style="videoStyles.v3"><XgVideoPlayer :src="video1" /></div>
+            <div class="video-4" :style="videoStyles.v4"><XgVideoPlayer :src="video2" /></div>
 
             <IntersectionMap :mapData="intersectionData" />
         </div>
@@ -25,12 +25,14 @@ import IntersectionMap from '@/components/ui/IntersectionMap.vue';
 import { apiGetSignalTiming, apiGetIntersectionData } from '@/api';
 import video1 from '@/assets/videos/video1.mp4';
 import video2 from '@/assets/videos/video2.mp4';
+import XgVideoPlayer from '@/components/ui/XgVideoPlayer.vue';
 
 export default {
     name: "IntersectionSignalMonitoring",
     components: {
         SignalTimingChart,
-        IntersectionMap
+        IntersectionMap,
+        XgVideoPlayer
     },
     props: {
         nodeData: {

+ 25 - 9
src/components/ui/IntersectionMapVideos.vue

@@ -5,19 +5,19 @@
     <div class="corner-videos-overlay" v-if="hasAnyVideo" :style="{ width: stageWidth + 'px', height: stageHeight + 'px' }">
       
       <div v-if="videoUrls.nw" class="video-corner top-left">
-        <video :src="videoUrls.nw" autoplay loop muted class="corner-video"></video>
+        <XgVideoPlayer :src="videoUrls.nw" />
       </div>
-      
+
       <div v-if="videoUrls.ne" class="video-corner top-right">
-        <video :src="videoUrls.ne" autoplay loop muted class="corner-video"></video>
+        <XgVideoPlayer :src="videoUrls.ne" />
       </div>
 
       <div v-if="videoUrls.sw" class="video-corner bottom-left">
-        <video :src="videoUrls.sw" autoplay loop muted class="corner-video"></video>
+        <XgVideoPlayer :src="videoUrls.sw" />
       </div>
 
       <div v-if="videoUrls.se" class="video-corner bottom-right">
-        <video :src="videoUrls.se" autoplay loop muted class="corner-video"></video>
+        <XgVideoPlayer :src="videoUrls.se" />
       </div>
 
     </div>
@@ -26,9 +26,13 @@
 
 <script>
 import Konva from 'konva';
+import XgVideoPlayer from '@/components/ui/XgVideoPlayer.vue';
 
 export default {
   name: 'IntersectionMapVideos',
+  components: {
+    XgVideoPlayer
+  },
   props: {
     // 1. 路口数字孪生数据
     mapData: {
@@ -69,7 +73,11 @@ export default {
   computed: {
     // 判断是否传入了至少一个视频,如果没有,直接不渲染遮罩层提升性能
     hasAnyVideo() {
-      return this.videoUrls && (this.videoUrls.nw || this.videoUrls.ne || this.videoUrls.sw || this.videoUrls.se);
+      if (!this.videoUrls) return false;
+      return ['nw', 'ne', 'sw', 'se'].some(corner => {
+        const v = this.videoUrls[corner];
+        return v && (typeof v === 'string' ? v : v.url);
+      });
     }
   },
   mounted() {
@@ -343,10 +351,18 @@ export default {
 .bottom-left { bottom: 0; left: 0; }
 .bottom-right { bottom: 0; right: 0; }
 
-.corner-video {
+/* xgplayer 填满角落容器 */
+.video-corner .xg-video-player {
   width: 100%;
   height: 100%;
-  object-fit: cover; 
-  display: block;
+}
+
+.video-corner >>> .xgplayer {
+  width: 100% !important;
+  height: 100% !important;
+}
+
+.video-corner >>> .xgplayer video {
+  object-fit: cover;
 }
 </style>

+ 3 - 1
src/components/ui/SecurityRoutePanel.vue

@@ -3,7 +3,7 @@
     <div class="secrity-route-list">
         <div class="secrity-route-item" v-for="(route, index) in 3" :key="index">
             <div class="route-video">
-                <video class="responsive-video" src="@/assets/videos/video1.mp4" autoplay loop muted></video>
+                <XgVideoPlayer :src="require('@/assets/videos/video1.mp4')" />
             </div>
             <div class="route-monitoring">
                 <div class="route-name">靖远路与北公路交叉口</div>
@@ -33,12 +33,14 @@
 
 <script>
 import IntersectionMap from '@/components/ui/IntersectionMapVideos.vue';
+import XgVideoPlayer from '@/components/ui/XgVideoPlayer.vue';
 import { apiGetIntersectionData } from '@/api';
 
 export default {
   name: 'SecurityRoutePanel',
   components: {
     IntersectionMap,
+    XgVideoPlayer,
   },
   data() {
     return {

+ 3 - 1
src/components/ui/SecurityRoutePanelSwitch.vue

@@ -28,7 +28,7 @@
           <div class="route-card" v-for="route in visibleRoutes" :key="route.id">
             
             <div class="card-top-video">
-              <video class="responsive-video" :src="route.mainVideo" autoplay loop muted></video>
+              <XgVideoPlayer :src="route.mainVideo" />
             </div>
 
             <div class="card-bottom-content">
@@ -76,12 +76,14 @@
 
 <script>
 import IntersectionMap from '@/components/ui/IntersectionMapVideos.vue';
+import XgVideoPlayer from '@/components/ui/XgVideoPlayer.vue';
 import { apiGetSecurityRoutes, apiGetIntersectionData } from '@/api';
 
 export default {
   name: 'SecurityRoutePanelSwitch',
   components: {
     IntersectionMap,
+    XgVideoPlayer,
   },
   data() {
     return {

+ 3 - 1
src/components/ui/SecurityRoutePanelSwitchSmall.vue

@@ -33,7 +33,7 @@
           <div class="route-card" v-for="route in visibleRoutes" :key="route.id">
             
             <div class="card-top-video">
-              <video class="responsive-video" :src="route.mainVideo" autoplay loop muted></video>
+              <XgVideoPlayer :src="route.mainVideo" />
             </div>
 
             <div class="card-bottom-content">
@@ -80,12 +80,14 @@
 
 <script>
 import IntersectionMap from '@/components/ui/IntersectionMapVideos.vue';
+import XgVideoPlayer from '@/components/ui/XgVideoPlayer.vue';
 import { apiGetSecurityRoutes, apiGetIntersectionData } from '@/api';
 
 export default {
   name: 'SecurityRoutePanelSwitchSmall',
   components: {
     IntersectionMap,
+    XgVideoPlayer,
   },
   props: {
     onClose: {

+ 4 - 1
src/components/ui/VideoMonitorBox.vue

@@ -1,7 +1,7 @@
 <template>
   <div class="video-box">
     <template v-if="videoSrc">
-      <video :src="videoSrc" autoplay loop muted class="real-video"></video>
+      <XgVideoPlayer :src="videoSrc" />
       <div class="close-btn" title="关闭视频" @click="handleClose">
         ×
       </div>
@@ -17,8 +17,11 @@
 </template>
 
 <script>
+import XgVideoPlayer from '@/components/ui/XgVideoPlayer.vue';
+
 export default {
   name: 'VideoMonitorBox',
+  components: { XgVideoPlayer },
   props: {
     videoUrl: { type: String, default: '' }
   },

+ 188 - 0
src/components/ui/XgVideoPlayer.vue

@@ -0,0 +1,188 @@
+<template>
+    <div class="xg-video-player" ref="container"></div>
+</template>
+
+<script>
+import Player from 'xgplayer';
+import 'xgplayer/dist/index.min.css';
+
+export default {
+    name: 'XgVideoPlayer',
+    props: {
+        // 视频源:字符串 URL 或 { url, type } 对象
+        src: {
+            type: [String, Object],
+            default: ''
+        },
+        autoplay: {
+            type: Boolean,
+            default: true
+        },
+        loop: {
+            type: Boolean,
+            default: true
+        },
+        muted: {
+            type: Boolean,
+            default: true
+        },
+        controls: {
+            type: Boolean,
+            default: false
+        },
+        live: {
+            type: Boolean,
+            default: false
+        },
+        // 视频填充模式:'cover' | 'contain' | 'fill' | 'auto'
+        fillMode: {
+            type: String,
+            default: 'cover'
+        },
+        retryCount: {
+            type: Number,
+            default: 3
+        },
+        retryDelay: {
+            type: Number,
+            default: 3000
+        }
+    },
+    data() {
+        return {
+            player: null
+        };
+    },
+    computed: {
+        videoUrl() {
+            if (!this.src) return '';
+            return typeof this.src === 'string' ? this.src : this.src.url;
+        },
+        videoType() {
+            if (!this.src) return 'mp4';
+            if (typeof this.src === 'object' && this.src.type) return this.src.type;
+            return this.detectType(this.videoUrl);
+        }
+    },
+    mounted() {
+        if (this.videoUrl) {
+            this.$nextTick(() => this.initPlayer());
+        }
+    },
+    beforeDestroy() {
+        this.destroyPlayer();
+    },
+    watch: {
+        src: {
+            handler(newVal, oldVal) {
+                const newUrl = typeof newVal === 'string' ? newVal : (newVal && newVal.url);
+                const oldUrl = typeof oldVal === 'string' ? oldVal : (oldVal && oldVal.url);
+                if (newUrl !== oldUrl) {
+                    this.destroyPlayer();
+                    if (newUrl) {
+                        this.$nextTick(() => this.initPlayer());
+                    }
+                }
+            },
+            deep: true
+        }
+    },
+    methods: {
+        initPlayer() {
+            if (!this.$refs.container) return;
+
+            const plugins = [];
+
+            // 按需动态导入格式插件(安装后才能使用)
+            try {
+                if (this.videoType === 'hls') {
+                    const HlsPlugin = require('xgplayer-hls').default;
+                    plugins.push(HlsPlugin);
+                }
+            } catch (e) { /* xgplayer-hls 未安装 */ }
+
+            try {
+                if (this.videoType === 'flv') {
+                    const FlvPlugin = require('xgplayer-flv').default;
+                    plugins.push(FlvPlugin);
+                }
+            } catch (e) { /* xgplayer-flv 未安装 */ }
+
+            this.player = new Player({
+                el: this.$refs.container,
+                url: this.videoUrl,
+                plugins: plugins,
+
+                width: '100%',
+                height: '100%',
+                fluid: true,
+
+                autoplay: this.autoplay,
+                autoplayMuted: this.muted,
+                loop: this.loop,
+                volume: this.muted ? 0 : 0.6,
+
+                controls: this.controls,
+                videoFillMode: this.fillMode,
+                isLive: this.live,
+
+                closeVideoClick: true,
+                closeVideoDblclick: true,
+                enableContextmenu: false,
+                keyShortcut: false,
+                cssFullscreen: false,
+            });
+
+            this.player.on('play', () => this.$emit('play'));
+            this.player.on('pause', () => this.$emit('pause'));
+            this.player.on('ended', () => this.$emit('ended'));
+            this.player.on('error', (err) => this.$emit('error', err));
+        },
+
+        detectType(url) {
+            if (!url) return 'mp4';
+            if (url.includes('.m3u8')) return 'hls';
+            if (url.includes('.flv')) return 'flv';
+            return 'mp4';
+        },
+
+        destroyPlayer() {
+            if (this.player) {
+                this.player.destroy();
+                this.player = null;
+            }
+        },
+
+        play() {
+            if (this.player) this.player.play();
+        },
+        pause() {
+            if (this.player) this.player.pause();
+        },
+        screenshot() {
+            if (!this.player || !this.player.video) return null;
+            const video = this.player.video;
+            const canvas = document.createElement('canvas');
+            canvas.width = video.videoWidth;
+            canvas.height = video.videoHeight;
+            canvas.getContext('2d').drawImage(video, 0, 0);
+            return canvas.toDataURL('image/png');
+        }
+    }
+};
+</script>
+
+<style scoped>
+.xg-video-player {
+    width: 100%;
+    height: 100%;
+}
+
+.xg-video-player >>> .xgplayer {
+    border-radius: 0 !important;
+    box-shadow: none !important;
+    background: #000 !important;
+    width: 100% !important;
+    height: 100% !important;
+}
+</style>