瀏覽代碼

fix: 修复视频播放器因flash停用导致视频流无法播放问题

lihao16 3 月之前
父節點
當前提交
096798b8be

+ 1 - 1
smsb-modules/smsb-netty/src/main/java/com/inspur/netty/stream/Zlm4jServer.java

@@ -49,7 +49,7 @@ public class Zlm4jServer {
         // 此配置置1时,此流如果无人观看,将不触发on_none_reader hook回调,而是将直接关闭流 0/1
         ZLM_API.mk_ini_set_option_int(MK_INI, "protocol.auto_close", 0);
         // 某个流无人观看时,触发hook.on_stream_none_reader事件的最大等待时间,单位毫秒 在配合hook.on_stream_none_reader事件时,可以做到无人观看自动停止拉流或停止接收推流
-        ZLM_API.mk_ini_set_option_int(MK_INI, "general.streamNoneReaderDelayMS", 20000);
+        ZLM_API.mk_ini_set_option_int(MK_INI, "general.streamNoneReaderDelayMS", 60000);
         //ZLMediaKit会最多让播放器等待maxStreamWaitMS毫秒
         //如果在这个时间内,该流注册成功,那么会立即返回播放器播放成功
         //否则返回播放器未找到该流,该机制的目的是可以先播放再推流

+ 5 - 0
smsb-modules/smsb-netty/src/main/java/com/inspur/netty/stream/callback/MKNoReaderCallBack.java

@@ -2,6 +2,8 @@ package com.inspur.netty.stream.callback;
 
 import com.aizuda.zlm4j.callback.IMKNoReaderCallBack;
 import com.aizuda.zlm4j.structure.MK_MEDIA_SOURCE;
+import com.inspur.netty.message.push.PushMessageType;
+import com.inspur.netty.util.PushMsgUtil;
 import com.sun.jna.CallbackThreadInitializer;
 import com.sun.jna.Native;
 import lombok.extern.slf4j.Slf4j;
@@ -36,6 +38,9 @@ public class MKNoReaderCallBack implements IMKNoReaderCallBack {
         String schema = ZLM_API.mk_media_source_get_schema(sender);
         log.info("stream :" + stream + "no reader call back,app:" + app + ",schema:" + schema);
         //无人观看时候可以调用下面的实现关流 不调用就代表不关流 需要配置protocol.auto_close 为 0 这里才会有回调
+        // 发送长连接消息 关闭推流
+        String nettyMessage = PushMessageType.CONTROL_STOP_STREAM.getValue();
+        PushMsgUtil.sendV2(stream, nettyMessage);
         ZLM_API.mk_media_source_close(sender,0);
         log.info("close success stream : " + stream);
     }

+ 2 - 1
smsb-plus-ui/package.json

@@ -37,6 +37,7 @@
     "flv.js": "^1.6.2",
     "fuse.js": "7.0.0",
     "highlight.js": "11.9.0",
+    "hls.js": "^1.6.7",
     "image-conversion": "^2.1.1",
     "js-cookie": "3.0.5",
     "jsencrypt": "3.3.2",
@@ -44,7 +45,7 @@
     "pinia": "2.1.7",
     "screenfull": "6.0.2",
     "sortablejs": "^1.15.6",
-    "video.js": "^7.20.3",
+    "video.js": "^7.21.7",
     "videojs-flash": "^2.2.1",
     "vue": "3.4.34",
     "vue-cropper": "1.1.1",

+ 221 - 77
smsb-plus-ui/src/components/VideoPlayer/index.vue

@@ -5,17 +5,17 @@
       class="video-js vjs-big-play-centered"
       playsinline
       webkit-playsinline
+      x5-playsinline
     ></video>
   </div>
 </template>
 
 <script lang="ts">
-import { defineComponent, onMounted, onBeforeUnmount, ref, watch } from 'vue'
+import { defineComponent, onMounted, onBeforeUnmount, ref, watch, nextTick } from 'vue'
 import videojs from 'video.js'
 import 'video.js/dist/video-js.css'
-// 使用声明文件后这样引入
-import videojsFlash from 'videojs-flash'
-videojs.registerPlugin('flash', videojsFlash)
+import Hls from 'hls.js'
+import flvjs from 'flv.js'
 
 interface VideoPlayerProps {
   url: string
@@ -37,126 +37,252 @@ export default defineComponent({
   setup(props: VideoPlayerProps) {
     const videoRef = ref<HTMLVideoElement | null>(null)
     const player = ref<videojs.Player | null>(null)
+    const hls = ref<Hls | null>(null)
+    const flvPlayer = ref<flvjs.Player | null>(null)
+    const isInitialized = ref(false)
 
-    // 初始化播放器
-    const initPlayer = () => {
-      if (!videoRef.value) return
+    // 增强的播放器配置
+    const defaultOptions: videojs.PlayerOptions = {
+      autoplay: false,
+      controls: true,
+      fluid: true,
+      preload: 'auto',
+      liveui: true,
+      html5: {
+        vhs: {
+          overrideNative: true,
+          enableLowInitialPlaylist: true,
+          smoothQualityChange: true,
+          experimentalBufferBasedABR: true
+        }
+      },
+      playbackRates: [0.5, 1, 1.5, 2]
+    }
 
-      // 合并默认选项和传入的选项
-      const mergedOptions: videojs.PlayerOptions = {
-        controls: true,
-        fluid: true,
-        preload: 'auto',
-        techOrder: ['html5', 'flash'], // 优先使用HTML5,回退到Flash
-        ...props.options
-      }
+    const initPlayer = async () => {
+      if (!videoRef.value || isInitialized.value) return
 
-      // 创建 video.js 播放器实例
-      player.value = videojs(videoRef.value, mergedOptions, () => {
-        console.log('Player is ready')
-      })
+      await nextTick() // 确保DOM完全渲染
+
+      try {
+        const mergedOptions = { ...defaultOptions, ...props.options }
+
+        // 创建播放器实例
+        player.value = videojs(videoRef.value, mergedOptions, function() {
+          console.log('Player ready')
+          this.one('play', () => {
+            console.log('First play event')
+            this.tech_?.on('retryplayback', () => {
+              console.log('Retrying playback')
+              this.play().catch(e => console.warn('Playback retry failed:', e))
+            })
+          })
+        })
 
-      // 根据 URL 后缀确定视频类型
-      setupSource()
+        // 添加错误监听
+        player.value.on('error', () => {
+          const error = player.value?.error()
+          console.error('VideoJS error:', error)
+          handlePlaybackError()
+        })
+
+        player.value.on('stalled', () => {
+          console.warn('Playback stalled')
+          handlePlaybackError()
+        })
+
+        player.value.on('waiting', () => {
+          console.log('Player waiting for data')
+        })
+
+        isInitialized.value = true
+        setupSource()
+      } catch (e) {
+        console.error('Player initialization failed:', e)
+      }
     }
 
-    // 设置视频源
     const setupSource = () => {
-      if (!player.value) return
+      if (!player.value || !videoRef.value) return
 
+      cleanupSource()
       const url = props.url.toLowerCase()
 
-      if (isRTMPStream(url)) {
-        // RTMP 流媒体
-        setupRTMPPlayer()
+      if (isHLSStream(url)) {
+        setupHLSPlayer()
+      } else if (isFLVStream(url)) {
+        setupFLVPlayer()
       } else {
-        // 普通视频文件 (MP4, AVI 等)
-        player.value.src({
-          src: props.url,
-          type: getVideoType(props.url)
-        })
+        setupStandardSource()
       }
     }
 
-    // 判断是否是RTMP流
-    const isRTMPStream = (url: string): boolean => {
-      return url.startsWith('rtmp://') ||
-        url.startsWith('rtmps://') ||
-        url.includes('rtmp.stream') ||
-        url.includes('rtmp/live')
-    }
-
-    // 设置RTMP播放器
-    const setupRTMPPlayer = () => {
+    const setupStandardSource = () => {
       if (!player.value) return
 
-      // 使用Flash技术播放RTMP
       player.value.src({
         src: props.url,
-        type: 'rtmp/mp4',
-        withCredentials: false
+        type: getVideoType(props.url)
       })
 
-      // 备选方案:如果Flash不可用,可以尝试HLS转换
+      // 确保能触发播放
       player.value.ready(() => {
-        player.value!.on('error', () => {
-          console.warn('RTMP playback failed, trying HLS fallback')
-          tryHLSFallback()
+        player.value?.play().catch(e => {
+          console.warn('Autoplay prevented:', e)
+          // 显示播放按钮让用户手动触发
+          player.value?.bigPlayButton?.show()
         })
       })
     }
 
-    // 尝试HLS回退方案
-    const tryHLSFallback = () => {
-      if (!player.value) return
+    const setupHLSPlayer = () => {
+      if (!player.value || !videoRef.value) return
 
-      // 这里假设你的RTMP流有对应的HLS流
-      const hlsUrl = props.url
-        .replace('rtmp://', 'http://')
-        .replace('rtmps://', 'https://')
-        .replace('/live/', '/hls/') + '.m3u8'
+      if (Hls.isSupported()) {
+        try {
+          hls.value = new Hls({
+            enableWorker: false,
+            startLevel: -1,
+            maxBufferLength: 30,
+            maxMaxBufferLength: 60,
+            maxBufferSize: 60 * 1000 * 1000,
+            maxBufferHole: 0.5,
+          })
 
-      player.value.src({
-        src: hlsUrl,
-        type: 'application/x-mpegURL'
-      })
+          hls.value.loadSource(props.url)
+          hls.value.attachMedia(videoRef.value)
+
+          hls.value.on(Hls.Events.MANIFEST_PARSED, () => {
+            console.log('Manifest parsed')
+            videoRef.value?.play().catch(e => console.warn('HLS autoplay failed:', e))
+          })
+
+          hls.value.on(Hls.Events.ERROR, (event, data) => {
+            console.error('HLS error:', event, data)
+            if (data.fatal) {
+              handleFatalError()
+            }
+          })
+        } catch (e) {
+          console.error('HLS init error:', e)
+          fallbackToNativeHLS()
+        }
+      } else {
+        fallbackToNativeHLS()
+      }
+    }
+
+    const setupFLVPlayer = () => {
+      if (!player.value || !videoRef.value) return
+
+      if (flvjs.isSupported()) {
+        try {
+          player.value.pause()
+
+          flvPlayer.value = flvjs.createPlayer({
+            type: 'flv',
+            url: props.url,
+            isLive: props.url.includes('live')
+          }, {
+            enableWorker: false,
+            stashInitialSize: 128,
+            lazyLoad: true,
+            autoCleanupSourceBuffer: true
+          })
+
+          flvPlayer.value.attachMediaElement(videoRef.value)
+          flvPlayer.value.load()
+
+          flvPlayer.value.on(flvjs.Events.METADATA_ARRIVED, () => {
+            console.log('FLV metadata loaded')
+            videoRef.value?.play().catch(e => console.warn('FLV autoplay failed:', e))
+          })
+
+          flvPlayer.value.on(flvjs.Events.ERROR, (errType, errDetail) => {
+            console.error('FLV error:', errType, errDetail)
+            handleFatalError()
+          })
+        } catch (e) {
+          console.error('FLV init error:', e)
+        }
+      }
+    }
+
+    const handlePlaybackError = () => {
+      console.log('Attempting to recover playback...')
+      player.value?.reset()
+      setTimeout(() => setupSource(), 1000)
+    }
+
+    const handleFatalError = () => {
+      console.error('Fatal playback error occurred')
+      cleanupSource()
+      setTimeout(() => setupSource(), 2000)
+    }
+
+    const fallbackToNativeHLS = () => {
+      if (!videoRef.value) return
+      console.warn('Falling back to native HLS')
+      videoRef.value.src = props.url
+      videoRef.value.play().catch(e => console.warn('Native HLS play failed:', e))
+    }
+
+    const cleanupSource = () => {
+      if (hls.value) {
+        hls.value.destroy()
+        hls.value = null
+      }
+
+      if (flvPlayer.value) {
+        flvPlayer.value.pause()
+        flvPlayer.value.unload()
+        flvPlayer.value.detachMediaElement()
+        flvPlayer.value.destroy()
+        flvPlayer.value = null
+      }
+    }
+
+    const isHLSStream = (url: string): boolean => {
+      return url.endsWith('.m3u8') || url.includes('m3u8')
+    }
+
+    const isFLVStream = (url: string): boolean => {
+      return url.endsWith('.flv') || url.includes('.flv?')
     }
 
-    // 根据 URL 获取视频类型
     const getVideoType = (url: string): string => {
-      const lowerUrl = url.toLowerCase()
-      if (lowerUrl.endsWith('.mp4')) {
-        return 'video/mp4'
-      } else if (lowerUrl.endsWith('.avi')) {
-        return 'video/x-msvideo'
+      const ext = url.split('.').pop()?.toLowerCase()
+      const typeMap: Record<string, string> = {
+        mp4: 'video/mp4',
+        webm: 'video/webm',
+        ogg: 'video/ogg',
+        ogv: 'video/ogg',
+        mov: 'video/quicktime',
+        avi: 'video/x-msvideo'
       }
-      // 默认类型,让浏览器自己判断
-      return ''
+      return typeMap[ext || ''] || ''
     }
 
-    // 监听 URL 变化
-    watch(
-      () => props.url,
-      () => {
+    watch(() => props.url, (newUrl, oldUrl) => {
+      if (newUrl !== oldUrl && isInitialized.value) {
         setupSource()
       }
-    )
+    })
 
     onMounted(() => {
       initPlayer()
     })
 
     onBeforeUnmount(() => {
-      // 清理 video.js 播放器
+      cleanupSource()
       if (player.value) {
         player.value.dispose()
         player.value = null
       }
+      isInitialized.value = false
     })
 
-    return {
-      videoRef
-    }
+    return { videoRef }
   }
 })
 </script>
@@ -165,10 +291,28 @@ export default defineComponent({
 .video-player-container {
   width: 100%;
   height: 100%;
+  background-color: #000;
 }
 
 .video-js {
   width: 100%;
   height: 100%;
 }
+
+:deep(.video-js .vjs-big-play-button) {
+  top: 50%;
+  left: 50%;
+  transform: translate(-50%, -50%);
+  font-size: 3em;
+  border: none;
+  background-color: rgba(0, 0, 0, 0.5);
+  border-radius: 50%;
+  width: 1.5em;
+  height: 1.5em;
+  line-height: 1.5em;
+}
+
+:deep(.video-js:hover .vjs-big-play-button) {
+  background-color: rgba(0, 0, 0, 0.7);
+}
 </style>

+ 16 - 6
smsb-plus-ui/src/views/smsb/device/index.vue

@@ -374,9 +374,9 @@
       </template>
     </el-dialog>
     <!--推流弹窗-->
-    <el-dialog v-model="watchDialog.visible" :title="watchDialog.title" width="900px" append-to-body
-      @closed="onDialogClosed">
-      <div v-if="watchDialog.visible" style="width: 100%; height: 500px">
+    <el-dialog v-model="watchDialog.visible" :title="watchDialog.title" width="1200px" append-to-body
+      @closed="onWatchDialogClosed">
+      <div v-if="watchDialog.visible" style="width: 100%; height: 640px">
 <!--        <video ref="flvPlayerRef" style="width: 100%; height: 100%" controls></video>-->
         <VideoPlayer :url="watchDialog.url" />
       </div>
@@ -945,9 +945,18 @@ const getScreenshot = async () => {
     screenshotLoading.value = false;
   }
 };
-// 对话框关闭时清理
-const onDialogClosed = () => {
+
+const onWatchDialogClosed = async () => {
+  console.log("关闭了视频监控弹窗")
   destroyPlayer();
+  const res = await stopStream(streamDeviceId.value);
+  // 清除定时器
+  clearInterval(intervalId);
+};
+
+// 对话框关闭时清理
+const onDialogClosed = async () => {
+  console.log("关闭了截图弹窗")
   screenshotImageUrl.value = null;
   screenshotStore.state.value.imageUrl = null;
   screenshotLoading.value = true;
@@ -985,7 +994,8 @@ const destroyPlayer = () => {
 };
 
 const stopMonitor = async () => {
-  const res = await stopStream(streamDeviceId.value);
+  console.log("stop Monitor")
+  // const res = await stopStream(streamDeviceId.value);
   watchDialog.visible = false;
 };