Explorar el Código

增项农场摄像头显示

jiuling hace 1 mes
padre
commit
7fb11ed8aa

+ 275 - 0
api/device.js

@@ -0,0 +1,275 @@
+/**
+ * 设备管理相关API
+ */
+import {
+  http,
+  Method
+} from '@/utils/request.js';
+import storage from "@/utils/storage.js";
+import api from "@/config/api.js";
+// const request = http.request;
+// const userInfo = storage.getUserInfo()
+
+ /* wvp用户/密码(MD5) */
+const wvpUsername = "admin" // 用户名
+const wvpPassword = "af7b951b3a30e898e2684ffe8d20a961" // 密码(MD5加密)
+const wvpServer = "https://nxy.gbdfarm.com:9000/pro-uniapp/wvp"// WVP服务器地址
+/**
+ * 登录wvp获取token
+ */
+export function loginWvp() {
+  return uni.request({
+    url: `${api.wvpServer}/api/user/login?username=${wvpUsername}&password=${wvpPassword}`,
+    method: Method.GET,
+    needToken: false,
+  });
+}
+/**
+ * 获取通道列表
+ */
+export async function getChannels(deviceId) {
+  try {
+	  // 登录获取token
+    const loginRes = await loginWvp();
+    console.log("WVP登录结果 code:", loginRes.code);
+    console.log("WVP登录结果 msg:", loginRes.msg);
+    console.log("WVP登录结果 data:", loginRes.data);
+    
+    // uni.request 返回格式: [err, res]
+    if (!loginRes.data.data) {
+      // 处理错误
+      console.error('登录失败', loginRes);
+      throw new Error('登录失败');
+    }
+    
+    const response = loginRes.data;
+    console.log("WVP登录响应数据:", response);
+    
+    if (response.code === 0) {
+      console.log("WVP登录成功");
+      storage.setWvpAccessToken(response.data.accessToken);
+      
+      // 查询通道列表
+      const channelsRes = await uni.request({
+        url: `${api.wvpServer}/api/device/query/devices/${deviceId}/channels`,
+        method: Method.GET,
+        data: {
+          page: 1,
+          count: 10,
+          online: true,
+        },
+        header: {
+          'Access-Token': `${storage.getWvpAccessToken()}`,
+        },
+      });
+      console.log("获取通道结果:", channelsRes);
+      return channelsRes;
+    } else {
+      console.error("WVP登录失败,响应码:", response.code, "消息:", response.msg);
+      throw new Error(`WVP登录失败: ${response.msg || '未知错误'}`);
+    }
+  } catch (error) {
+    console.error("获取通道失败", error);
+    throw error;
+  }
+}
+/**
+ * 开始点播
+ */
+export async function playStart(deviceId, channelId) {
+  return await  uni.request({
+    url: `${api.wvpServer}/api/play/start/${deviceId}/${channelId}`,  
+    method: Method.GET,
+    header: {
+      'Access-Token': `${storage.getWvpAccessToken()}`,
+    },
+  })
+}
+/**
+ * 暂停点播
+ */
+export async function pause(deviceId, channelId) {
+  return await  uni.request({
+    url: `${api.wvpServer}/api/play/stop/${deviceId}/${channelId}`,  
+    method: Method.GET,
+    header: {
+      'Access-Token': `${storage.getWvpAccessToken()}`,
+    },
+  })
+}
+/**
+ * 获取设备概览数据
+ * @param {string} fieldId - 地块ID,可选
+ * @returns {Promise} 设备概览数据
+ */
+// export function fetchDeviceOverview(fieldId) {
+//   const params = {};
+//   console.log("userInfo请求:",userInfo);
+//   if (userInfo.userid) params.userId = userInfo.userid;
+//   if (fieldId) params.fieldId = fieldId;
+  
+//   return http.request({
+//     url: '/base/device/overview',
+//     method: Method.POST,
+// 	needToken: true,
+//     data: params
+//   });
+// }
+
+/**
+ * 根据设备类型获取设备列表
+ * @param {string} type - 设备类型(monitor-监控设备,sensor-采集设备,control-控制设备,irrigation-灌溉设备,tractor-农机设备)
+ * @param {Object} params - 查询参数
+ * @returns {Promise} 设备列表
+ */
+// export function fetchDevicesByType(params) {
+// if (userInfo.userid) params.userId = userInfo.userid;
+//   return http.request({
+//     url: `/base/device/typeList`,
+//     method: Method.POST,
+// 	needToken: true,
+//     data: params
+//   });
+// }
+
+/**
+ * 获取所有设备列表
+ * @param {Object} params - 查询参数
+ * @param {number} params.pageNum - 页码
+ * @param {number} params.pageSize - 每页数量
+ * @param {string} params.deviceName - 设备名称,可选
+ * @param {string} params.deviceTypeId - 设备类型ID,可选
+ * @param {number} params.status - 设备状态,可选
+ * @returns {Promise} 设备列表
+ */
+export function fetchDeviceList(params = {}) {
+  return http.request({
+    url: 'uniapp/device/list',
+    method: Method.GET,
+	needToken: true,
+    params: params
+  });
+}
+
+/**
+ * 获取设备详情
+ * @param {number} id - 设备ID
+ * @returns {Promise} 设备详情
+ */
+export function getDeviceDetail(id) {
+  return http.request({
+    url: `uniapp/device/${id}`,
+	needToken: true,
+    method: Method.GET
+  });
+}
+
+/**
+ * 获取设备采集器详情(包含气象/土壤数据和告警信息)
+ * @param {number} id - 设备编码
+ * @param {string} code - 设备编码,可选,用于判断设备类型
+ * @returns {Promise} 设备详情数据
+ */
+export function getDeviceCollectorDetail(deviceId, code) {
+  return http.request({
+    url: `/base/device/collector/detail/${deviceId}`,
+    method: Method.GET,
+	needToken: true,
+    params: code ? { code } : {}
+  });
+}
+
+/**
+ * 添加设备
+ * @param {Object} data - 设备信息
+ * @returns {Promise} 添加结果
+ */
+export function addDevice(data) {
+  return http.request({
+    url: 'uniapp/device',
+    method: Method.POST,
+	needToken: true,
+    data: data
+  });
+}
+
+/**
+ * 更新设备信息
+ * @param {Object} data - 设备信息
+ * @returns {Promise} 更新结果
+ */
+export function updateDevice(data) {
+  return http.request({
+    url: 'uniapp/device',
+    method: Method.PUT,
+	needToken: true,
+    data: data
+  });
+}
+
+/**
+ * 删除设备
+ * @param {number|number[]} ids - 设备ID或ID数组
+ * @returns {Promise} 删除结果
+ */
+export function deleteDevice(ids) {
+  const idStr = Array.isArray(ids) ? ids.join(',') : ids;
+  return http.request({
+    url: `uniapp/device/${idStr}`,
+	needToken: true,
+    method: Method.DELETE
+  });
+}
+
+/**
+ * 根据用户ID获取关联的设备列表
+ * @param {Object} params - 查询参数
+ * @returns {Promise} 设备列表
+ */
+export function fetchUserDeviceList(params = {}) {
+  const user = storage.getUserInfo();
+  return http.request({
+    url: 'uniapp/device/user/list',
+    method: Method.GET,
+    params: {
+      ...params,
+      userId: user.userId
+    },
+	needToken: true,
+  });
+}
+
+/**
+ * 根据地块ID获取设备列表
+ * @param {string} fieldId - 地块ID
+ * @param {Object} params - 其他查询参数
+ * @returns {Promise} 设备列表
+ */
+export function fetchFieldDeviceList(fieldId, params = {}) {
+  return http.request({
+    url: 'uniapp/device/list',
+    method: Method.GET,
+    params: {
+      ...params,
+      fieldId: fieldId
+    },
+	needToken: true
+  });
+}
+
+/**
+ * 获取设备类型统计数据
+ * @param {string} fieldId - 地块ID,可选
+ * @returns {Promise} 设备类型统计数据
+ */
+export function fetchDeviceTypeStats(fieldId) {
+  const params = {};
+  if (fieldId) params.fieldId = fieldId;
+  
+  return http.request({
+    url: 'uniapp/device/stats/type',
+    method: Method.GET,
+    params: params,
+	needToken: true,
+  });
+} 

+ 524 - 0
components/common/jessibuca.vue

@@ -0,0 +1,524 @@
+<template>
+  <div
+    ref="container"
+    class="jessibuca-container"
+    :class="{'jessibuca-fullscreen': fullscreen}"
+    @dblclick="fullscreenSwich"
+  >
+    <div class="jessibuca-player-wrapper"></div>
+    <div id="buttonsBox" class="buttons-box">
+      <div class="buttons-box-left">
+        <i v-if="!playing" class="iconfont icon-play jessibuca-btn" @click="playBtnClick" />
+        <i v-if="playing" class="iconfont icon-pause jessibuca-btn" @click="pause" />
+        <i class="iconfont icon-stop jessibuca-btn" @click="destroy" />
+        <i v-if="isNotMute" class="iconfont icon-audio-high jessibuca-btn" @click="mute()" />
+        <i v-if="!isNotMute" class="iconfont icon-audio-mute jessibuca-btn" @click="cancelMute()" />
+      </div>
+      <div class="buttons-box-right">
+        <span class="jessibuca-btn">{{ kBps }} kb/s</span>
+        <!--          <i class="iconfont icon-file-record1 jessibuca-btn"></i>-->
+        <!--          <i class="iconfont icon-xiangqing2 jessibuca-btn" ></i>-->
+        <i
+          class="iconfont icon-camera1196054easyiconnet jessibuca-btn"
+          style="font-size: 1rem !important"
+          @click="screenshot"
+        />
+        <i class="iconfont icon-shuaxin11 jessibuca-btn" @click="playBtnClick" />
+        <i v-if="!fullscreen" class="iconfont icon-weibiaoti10 jessibuca-btn" @click="fullscreenSwich" />
+        <i v-if="fullscreen" class="iconfont icon-weibiaoti11 jessibuca-btn" @click="fullscreenSwich" />
+      </div>
+    </div>
+  </div>
+</template>
+
+<!-- #ifdef H5 -->
+<script setup>
+import { ref, watch, nextTick, onMounted, onBeforeUnmount, getCurrentInstance } from 'vue'
+import { useRoute } from 'vue-router'
+
+// Props
+const props = defineProps({
+  videoUrl: String,
+  error: String,
+  hasAudio: Boolean,
+  height: [String, Number]
+})
+
+// 获取当前实例 UID (用于管理多个播放器实例)
+const instance = getCurrentInstance()
+const uid = instance.uid
+
+// 获取路由
+const route = useRoute()
+
+// 模板引用
+const container = ref(null)
+
+// 响应式数据
+const playing = ref(false)
+const isNotMute = ref(false)
+const quieting = ref(false)
+const fullscreen = ref(false)
+const loaded = ref(false)
+const speed = ref(0)
+const performance = ref('')
+const kBps = ref(0)
+const btnDom = ref(null)
+const videoInfo = ref(null)
+const volume = ref(1)
+const rotate = ref(0)
+const vod = ref(true)
+const forceNoOffscreen = ref(false)
+const playerWidth = ref(0)
+const playerHeight = ref(0)
+const parentNodeResizeObserver = ref(null)
+
+// 全局播放器实例存储
+const jessibucaPlayer = {}
+
+// 判断是否处于全屏状态
+const isFullscreen = () => {
+  // #ifdef H5
+  return document.fullscreenElement ||
+    document.msFullscreenElement ||
+    document.mozFullScreenElement ||
+    document.webkitFullscreenElement || false
+  // #endif
+  // #ifndef H5
+  return false
+  // #endif
+}
+
+// 更新播放器 DOM 尺寸
+const updatePlayerDomSize = () => {
+  const dom = container.value
+  if (!dom) return
+
+  if (!parentNodeResizeObserver.value) {
+    // #ifdef H5
+    parentNodeResizeObserver.value = new ResizeObserver(() => {
+      updatePlayerDomSize()
+    })
+    parentNodeResizeObserver.value.observe(dom.parentNode)
+    // #endif
+  }
+  
+  // 获取父容器尺寸
+  const boxWidth = dom.parentNode.clientWidth
+  const boxHeight = dom.parentNode.clientHeight
+  
+  // 检查是否处于全屏状态
+  const isFullscreenState = isFullscreen()
+  let width, height
+  
+  if (isFullscreenState) {
+    // 全屏模式,使用窗口尺寸
+    // #ifdef H5
+    width = window.innerWidth
+    height = window.innerHeight
+    // #endif
+  } else {
+    // 非全屏模式,使用16:9比例
+    width = boxWidth
+    height = (9 / 16) * width
+    
+    // 如果计算出的高度超过容器高度,则以容器高度为基准重新计算宽度
+    if (boxHeight > 0 && height > boxHeight) {
+      height = boxHeight
+      width = height * 16 / 9
+    }
+  }
+  
+  // 限制尺寸不超过视口
+  // #ifdef H5
+  const clientHeight = Math.min(document.body.clientHeight, document.documentElement.clientHeight)
+  if (!isFullscreenState && height > clientHeight) {
+    height = clientHeight
+    width = (16 / 9) * height
+  }
+  // #endif
+  
+  playerWidth.value = width
+  playerHeight.value = height
+  
+  // 应用尺寸到容器
+  dom.style.width = `${width}px`
+  dom.style.height = `${height}px`
+  
+  // 如果播放器存在,更新播放器尺寸
+  if (playing.value && jessibucaPlayer[uid]) {
+    jessibucaPlayer[uid].resize(width, height)
+  }
+}
+
+// 公共方法:调整大小
+const resize = () => {
+  updatePlayerDomSize()
+}
+
+// 创建播放器
+const create = () => {
+  // #ifdef H5
+  // H5 环境使用 CDN 加载 decoder
+  const decoderUrl = '/jessibuca/decoder.js'
+  // #endif
+  
+  // #ifdef MP-WEIXIN
+  // 微信小程序不支持 jessibuca,这里不应该被调用
+  console.warn('Jessibuca is not supported in WeChat Mini Program')
+  return
+  // #endif
+
+  const options = {
+    container: container.value,
+    autoWasm: true,
+    background: '',
+    controlAutoHide: false,
+    debug: false,
+    // #ifdef H5
+    decoder: decoderUrl,
+    // #endif
+    // #ifndef H5
+    decoder: '',
+    // #endif
+    forceNoOffscreen: false,
+    hasAudio: typeof props.hasAudio === 'undefined' ? true : props.hasAudio,
+    heartTimeout: 5,
+    heartTimeoutReplay: true,
+    heartTimeoutReplayTimes: 3,
+    hiddenAutoPause: false,
+    hotKey: true,
+    isFlv: false,
+    isFullResize: false,
+    isNotMute: isNotMute.value,
+    isResize: true,
+    keepScreenOn: true,
+    loadingText: '请稍等, 视频加载中......',
+    loadingTimeout: 10,
+    loadingTimeoutReplay: true,
+    loadingTimeoutReplayTimes: 3,
+    openWebglAlignment: false,
+    operateBtns: {
+      fullscreen: false,
+      screenshot: false,
+      play: false,
+      audio: false,
+      record: false
+    },
+    recordType: 'mp4',
+    rotate: 0,
+    showBandwidth: false,
+    supportDblclickFullscreen: false,
+    timeout: 10,
+    useMSE: true,
+    useWCS: false,
+    useWebFullScreen: true,
+    videoBuffer: 0.1,
+    wasmDecodeErrorReplay: true,
+    wcsUseVideoRender: true
+  }
+  console.log('Jessibuca -> options: ', options)
+  
+  // #ifdef H5
+  jessibucaPlayer[uid] = new window.Jessibuca({ ...options })
+
+  const jessibuca = jessibucaPlayer[uid]
+  
+  jessibuca.on('pause', () => {
+    playing.value = false
+  })
+  jessibuca.on('play', () => {
+    playing.value = true
+  })
+  jessibuca.on('fullscreen', (msg) => {
+    fullscreen.value = msg
+  })
+  jessibuca.on('mute', (msg) => {
+    isNotMute.value = !msg
+  })
+  jessibuca.on('performance', (perf) => {
+    let show = '卡顿'
+    if (perf === 2) {
+      show = '非常流畅'
+    } else if (perf === 1) {
+      show = '流畅'
+    }
+    performance.value = show
+  })
+  jessibuca.on('kBps', (kbps) => {
+    kBps.value = Math.round(kbps)
+  })
+  jessibuca.on('videoInfo', (msg) => {
+    console.log('Jessibuca -> videoInfo: ', msg)
+  })
+  jessibuca.on('audioInfo', (msg) => {
+    console.log('Jessibuca -> audioInfo: ', msg)
+  })
+  jessibuca.on('error', (msg) => {
+    console.log('Jessibuca -> error: ', msg)
+  })
+  jessibuca.on('timeout', (msg) => {
+    console.log('Jessibuca -> timeout: ', msg)
+  })
+  jessibuca.on('loadingTimeout', (msg) => {
+    console.log('Jessibuca -> timeout: ', msg)
+  })
+  jessibuca.on('delayTimeout', (msg) => {
+    console.log('Jessibuca -> timeout: ', msg)
+  })
+  jessibuca.on('playToRenderTimes', (msg) => {
+    console.log('Jessibuca -> playToRenderTimes: ', msg)
+  })
+  // #endif
+}
+// 播放按钮点击
+const playBtnClick = () => {
+  play(props.videoUrl)
+}
+
+// 播放
+const play = (url) => {
+  console.log('Jessibuca -> url: ', url)
+  if (jessibucaPlayer[uid]) {
+    destroy()
+  }
+  create()
+  
+  // #ifdef H5
+  jessibucaPlayer[uid].on('play', () => {
+    playing.value = true
+    loaded.value = true
+    quieting.value = jessibucaPlayer[uid].quieting
+  })
+  if (jessibucaPlayer[uid].hasLoaded()) {
+    jessibucaPlayer[uid].play(url)
+  } else {
+    jessibucaPlayer[uid].on('load', () => {
+      jessibucaPlayer[uid].play(url)
+    })
+  }
+  // #endif
+}
+
+// 暂停
+const pause = () => {
+  // #ifdef H5
+  if (jessibucaPlayer[uid]) {
+    jessibucaPlayer[uid].pause()
+  }
+  // #endif
+  playing.value = false
+  performance.value = ''
+}
+
+// 截图
+const screenshot = () => {
+  // #ifdef H5
+  if (jessibucaPlayer[uid]) {
+    jessibucaPlayer[uid].screenshot()
+  }
+  // #endif
+}
+
+// 静音
+const mute = () => {
+  // #ifdef H5
+  if (jessibucaPlayer[uid]) {
+    jessibucaPlayer[uid].mute()
+  }
+  // #endif
+}
+
+// 取消静音
+const cancelMute = () => {
+  // #ifdef H5
+  if (jessibucaPlayer[uid]) {
+    jessibucaPlayer[uid].cancelMute()
+  }
+  // #endif
+}
+
+// 销毁播放器
+const destroy = () => {
+  // #ifdef H5
+  if (jessibucaPlayer[uid]) {
+    jessibucaPlayer[uid].destroy()
+  }
+  if (document.getElementById('buttonsBox') == null && btnDom.value) {
+    container.value.appendChild(btnDom.value)
+  }
+  jessibucaPlayer[uid] = null
+  // #endif
+  playing.value = false
+  performance.value = ''
+}
+
+// 全屏切换
+const fullscreenSwich = () => {
+  const isFull = isFullscreen()
+  
+  if (!isFull) {
+    // 进入全屏
+    try {
+      const containerEl = container.value
+      
+      // #ifdef H5
+      // 尝试使用HTML5全屏API
+      if (containerEl.requestFullscreen) {
+        containerEl.requestFullscreen()
+      } else if (containerEl.webkitRequestFullscreen) {
+        containerEl.webkitRequestFullscreen()
+      } else if (containerEl.msRequestFullscreen) {
+        containerEl.msRequestFullscreen()
+      } else if (containerEl.mozRequestFullScreen) {
+        containerEl.mozRequestFullScreen()
+      } else {
+        // 如果原生API不可用,使用Jessibuca的全屏API
+        if (jessibucaPlayer[uid]) {
+          jessibucaPlayer[uid].setFullscreen(true)
+        }
+      }
+      // #endif
+      
+      // 设置全屏标志
+      fullscreen.value = true
+    } catch (e) {
+      console.error('全屏切换失败:', e)
+    }
+  } else {
+    // 退出全屏
+    try {
+      // #ifdef H5
+      if (document.exitFullscreen) {
+        document.exitFullscreen()
+      } else if (document.webkitExitFullscreen) {
+        document.webkitExitFullscreen()
+      } else if (document.msExitFullscreen) {
+        document.msExitFullscreen()
+      } else if (document.mozCancelFullScreen) {
+        document.mozCancelFullScreen()
+      } else {
+        // 如果原生API不可用,使用Jessibuca的全屏API
+        if (jessibucaPlayer[uid]) {
+          jessibucaPlayer[uid].setFullscreen(false)
+        }
+      }
+      // #endif
+      
+      // 设置全屏标志
+      fullscreen.value = false
+    } catch (e) {
+      console.error('退出全屏失败:', e)
+    }
+  }
+  
+  // 重新计算尺寸
+  setTimeout(() => {
+    updatePlayerDomSize()
+  }, 300)
+}
+
+// 监听 videoUrl 变化
+watch(() => props.videoUrl, (val) => {
+  nextTick(() => {
+    play(val)
+  })
+}, { immediate: true })
+
+// 组件挂载时
+onMounted(() => {
+  // #ifdef H5
+  const paramUrl = decodeURIComponent(route.params.url || '')
+  nextTick(() => {
+    updatePlayerDomSize()
+    window.onresize = updatePlayerDomSize
+    if (typeof props.videoUrl === 'undefined' && paramUrl) {
+      play(paramUrl)
+    }
+    btnDom.value = document.getElementById('buttonsBox')
+  })
+  // #endif
+})
+
+// 组件卸载前
+onBeforeUnmount(() => {
+  // #ifdef H5
+  if (jessibucaPlayer[uid]) {
+    jessibucaPlayer[uid].destroy()
+  }
+  if (parentNodeResizeObserver.value) {
+    parentNodeResizeObserver.value.disconnect()
+  }
+  // #endif
+  playing.value = false
+  loaded.value = false
+  performance.value = ''
+})
+
+// 暴露方法供父组件调用
+defineExpose({
+  resize,
+  play,
+  pause,
+  destroy,
+  screenshot
+})
+</script>
+<!-- #endif -->
+
+
+<style>
+.jessibuca-container {
+  width: 100%; 
+  height: 100%; 
+  background-color: #000000;
+  margin: 0 auto;
+  position: relative;
+  overflow: hidden;
+}
+
+.jessibuca-container.jessibuca-fullscreen {
+  position: fixed;
+  top: 0;
+  left: 0;
+  width: 100vw !important;
+  height: 100vh !important;
+  z-index: 9999;
+}
+
+.jessibuca-player-wrapper {
+  width: 100%; 
+  padding-top: 56.25%; 
+  position: relative;
+}
+
+.buttons-box {
+  width: 100%;
+  height: 28px;
+  background-color: rgba(43, 51, 63, 0.7);
+  position: absolute;
+  display: -webkit-box;
+  display: -ms-flexbox;
+  display: flex;
+  left: 0;
+  bottom: 0;
+  user-select: none;
+  z-index: 10;
+}
+
+.jessibuca-btn {
+  width: 20px;
+  color: rgb(255, 255, 255);
+  line-height: 27px;
+  margin: 0px 10px;
+  padding: 0px 2px;
+  cursor: pointer;
+  text-align: center;
+  font-size: 0.8rem !important;
+}
+
+.buttons-box-right {
+  position: absolute;
+  right: 0;
+}
+</style>

+ 1 - 1
pages.json

@@ -9,7 +9,7 @@
 	],
 	"globalStyle": {
 		"navigationBarTextStyle": "black",
-		"navigationBarTitleText": "jiayouhouyuan",
+		"navigationBarTitleText": "佳友厚苑 · 产品溯源",
 		"navigationBarBackgroundColor": "#F8F8F8",
 		"backgroundColor": "#F8F8F8"
 	},

+ 879 - 17
pages/trace/detail.vue

@@ -164,8 +164,8 @@
 					</view>
 				</view>
 
-				<view class="liveMediaWrap">
-					<image
+				
+					<!-- <image
 						v-if="traceDetail.camera.coverImage"
 						class="liveCover"
 						:src="traceDetail.camera.coverImage"
@@ -179,13 +179,93 @@
 						</view>
 						<text class="livePlaceholderMain">实时画面接入中</text>
 						<text class="livePlaceholderSub">请稍候查看现场情况</text>
-					</view>
-
+					</view> -->
+					<!-- 视频预览区域 -->
+					<view class="video-section">
+											<view class="video-container" :class="{'fullscreen-mode': isFullscreen}">
+												<image v-if="!isPlaying" src="/static/images/video-placeholder.jpg" mode="aspectFill"
+													class="video-placeholder"></image>
+										
+										<!-- 使用跨平台视频播放组件 -->
+										<!-- #ifdef H5 -->
+										<view v-if="isPlaying" class="h5-video-wrapper">
+											<Jessibuca ref="jessibucaRef" :videoUrl="getH5StreamUrl" :hasAudio="true" @error="onVideoError" />
+										</view>
+										<!-- #endif -->
+												
+												<!-- 微信小程序视频播放 -->
+												<!-- #ifdef MP-WEIXIN -->
+												<video 
+													v-if="isPlaying" 
+													id="myVideo" 
+													class="video-player"
+													:src="getAppStreamUrl" 
+													:autoplay="true" 
+													:controls="true"
+													:show-center-play-btn="true"
+													:enable-progress-gesture="false"
+													:object-fit="'contain'"
+													@error="onVideoError"
+													@play="onVideoPlay"
+													@pause="onVideoPause"
+													@ended="onVideoEnded"
+													@timeupdate="onTimeUpdate"
+													@fullscreenchange="onFullscreenChange"
+												></video>
+												<!-- #endif -->
+												<!-- App端视频播放 -->
+												<!-- #ifdef APP-PLUS || APP-HARMONY -->
+												<video 
+													v-if="isPlaying" 
+													id="appVideo" 
+													class="video-player"
+													:src="getAppStreamUrl" 
+													:autoplay="true" 
+													:controls="true"
+													:show-center-play-btn="true"
+													@error="onVideoError"
+												></video>
+												<!-- #endif -->
+										
+												<!-- 视频控制层 -->
+												<view class="video-controls">
+													<view class="control-row top-controls">
+														<view class="signal-indicator">
+															<image src="/static/icons/signal_icon.png" mode="aspectFit"
+																style="width: 16px; height: 16px;"></image>
+															<text class="signal-text">信号良好</text>
+														</view>
+										
+														<view class="fullscreen-button" @click="toggleFullscreen">
+															<image src="/static/icons/resize_icon.png" mode="aspectFit"
+																style="width: 20px; height: 20px;"></image>
+														</view>
+													</view>
+										
+													<view class="control-row center-controls">
+														<view v-if="!isPlaying" class="play-button" @click="togglePlayState">
+															<image src="/static/icons/play_icon.png" mode="aspectFit"
+																style="width: 32px; height: 32px;"></image>
+														</view>
+														<view v-else class="center-button-container" @click="togglePlayState">
+															<view class="pause-icon">
+																<image src="/static/icons/pause_icon.png" mode="aspectFit"
+																	style="width: 24px; height: 24px;"></image>
+															</view>
+														</view>
+													</view>
+										
+													<view class="control-row bottom-controls">
+														<view class="video-time">{{ currentTime }}</view>
+													</view>
+												</view>
+											</view>
+										</view>
 					<view class="liveStatusPill" :class="cameraStatus.value">
 						<view class="liveStatusDot" />
 						<text>{{ cameraStatusText }}</text>
 					</view>
-				</view>
+				
 
 				<view class="liveDesc">
 					<text>{{ traceDetail.camera.desc }}</text>
@@ -362,9 +442,16 @@
 </template>
 
 <script setup>
-import { computed, ref } from 'vue'
+import { computed, ref, reactive } from 'vue'
 import { onLoad } from '@dcloudio/uni-app'
 import {getTraceDetail } from '@/api/base/index.js'
+import {
+	getDeviceCollectorDetail,
+	getChannels,
+	playStart,
+	pause ,
+} from '@/api/device.js'
+import Jessibuca from '@/components/common/jessibuca.vue'
 // 页面状态选择(H5 演示用):通过路由参数传入 state 即可切换 mock 场景
 // 例如:/pages/trace/detail?state=reportPending
 // 加载状态
@@ -373,7 +460,31 @@ const loading = ref(false)
 const traceInfo = ref(null)
 const routeOptions = ref({})
 const mockStateKey = ref('normal')
-
+const isFullscreen = ref(false)
+const isPlaying = ref(false)
+const livePlayerContext = ref(null) // 小程序视频上下文
+const appLivePlayerContext = ref(null) // App端视频上下文
+const jessibucaRef = ref(null)
+
+const isMuted = ref(false)
+const isRecording = ref(false)
+const isVoiceActive = ref(false)
+const isGridView = ref(false)
+const isZoomMode = ref(false)
+const currentTime = ref('14:30:25')
+// 响应式数据
+const deviceInfo = reactive({
+	deviceId: '34020000001110000001',
+	name: '设备加载中...',
+	status: 'off',
+	location: '正在获取位置...',
+	lastUpdate: '',
+	deviceType: 'weather', // 默认类型,会根据API返回更新
+	deviceTypeId: null,
+	streamUrl: '',
+	channelId: null, // 当前通道ID
+	originalStreamUrl: '',
+})
 const MOCK_TRACE_DETAILS = {
 	normal: {
 		product: {
@@ -619,8 +730,30 @@ const MOCK_TRACE_DETAILS = {
 		farmTimeline: []
 	}
 }
-
-
+// 加载Jessibuca脚本
+	const loadJessibucaScript = () => {
+		// #ifdef H5
+		const script = document.createElement('script')
+		script.src = '/static/js/jessibuca/jessibuca.js'
+		script.onload = () => {
+			console.log('Jessibuca 脚本加载成功')
+		}
+		script.onerror = (error) => {
+			console.error('Jessibuca 脚本加载失败:', error)
+		}
+		document.head.appendChild(script)
+		// #endif
+	}
+// 获取H5环境使用的流地址
+	const getH5StreamUrl = computed(() => {
+		// 确保使用安全的 WSS 协议
+		let url = deviceInfo.originalStreamUrl
+		if (url && url.startsWith('ws://')) {
+			console.warn('检测到不安全的 ws:// 协议,自动转换为 wss://')
+			url = url.replace('ws://', 'wss://')
+		}
+		return url
+	})
 function resolveStateKey(opts) {
 	const raw = (opts?.state || opts?.batchState || opts?.scene || '').toString().trim()
 	if (!raw) return 'normal'
@@ -634,13 +767,91 @@ onLoad((opts) => {
 	const batchId = fullPath.split('/').filter(Boolean).pop()
 	
 	// 优先使用路由参数中的 id,其次使用 URL 路径中的 id
-	const finalId = batchId || 1
+	const finalId = batchId || 4
 	loadData(finalId)
-	
+	queryChannels()
 	routeOptions.value = opts || {}
 	mockStateKey.value = resolveStateKey(opts || {})
+	startTimeUpdate()
+	
+	// #ifdef MP-WEIXIN
+	setTimeout(() => {
+		livePlayerContext.value = uni.createVideoContext('myVideo')
+		console.log('微信小程序视频上下文已创建')
+	}, 500)
+	// #endif
+	
+	// #ifdef APP-PLUS || APP-HARMONY
+	setTimeout(() => {
+		appLivePlayerContext.value = uni.createVideoContext('appVideo')
+		console.log('App视频上下文已创建')
+	}, 500)
+	// #endif
+	
+	// #ifdef H5
+	loadJessibucaScript()
+	// #endif
 })
-
+// 根据设备id获取通道列表
+	const queryChannels = () => {
+		getChannels(deviceInfo.deviceId)
+			.then(response => {
+				console.log('获取通道列表:', response)
+				const res = response.data
+				if (res.code === 0 && res.data.total > 0) {
+					const channels = res.data.list
+					deviceInfo.channelId = channels[0].deviceId
+					deviceInfo.status = channels[0].status
+					playStart(deviceInfo.deviceId, deviceInfo.channelId).then(res => {
+						if (res.data.code !== 0) {
+							console.error('播放开始失败:', res.message)
+							uni.showToast({
+								title: '播放失败: ' + res.message,
+								icon: 'none'
+							})
+							return
+						}
+						console.log('播放开始:', res)
+						
+						// #ifdef H5
+						let streamUrl = res.data.data.wss_flv
+						
+						if (streamUrl) {
+							const urlObj = new URL(streamUrl)
+							// 替换 hostname
+							urlObj.hostname = 'nxy.gbdfarm.com'
+							// 替换端口
+							urlObj.port = '9000'
+							
+							deviceInfo.originalStreamUrl = urlObj.toString()
+							console.log("queryChannels - deviceInfo.originalStreamUrl", deviceInfo.originalStreamUrl)
+						} else {
+							console.warn('未获取到 wss_flv 流地址')
+						}
+						// #endif
+						
+						// #ifdef MP-WEIXIN
+						deviceInfo.originalStreamUrl = res.data.data.hls || deviceInfo.originalStreamUrl
+						// #endif
+						
+						// #ifdef APP-PLUS || APP-HARMONY
+						deviceInfo.originalStreamUrl = res.data.data.fmp4 || deviceInfo.originalStreamUrl
+						// #endif
+					}).catch(err => {
+						console.error('播放开始失败:', err)
+					})
+				} else {
+					uni.showToast({
+						title: '获取通道列表失败: ' + res.data.message,
+						icon: 'none'
+					})
+					return
+				}
+			})
+			.catch(err => {
+				console.error('获取通道列表错误:', err)
+			})
+	}
 const loadData = async (batchId) => {
   loading.value = true
   
@@ -657,7 +868,456 @@ const loadData = async (batchId) => {
     loading.value = false
   }
 }
+// 视频播放错误处理
+	const onVideoError = (e) => {
+		console.error('视频播放错误:', e)
+		
+		let errorMsg = '视频加载失败'
+		
+		// 微信小程序video组件错误码
+		if (e && e.detail) {
+			const errCode = e.detail.errCode
+			switch(errCode) {
+				case 10001:
+					errorMsg = '网络错误,请检查网络连接'
+					break
+				case 10002:
+					errorMsg = '视频格式不支持'
+					break
+				case 10003:
+					errorMsg = '视频解码失败'
+					break
+				case 10004:
+					errorMsg = '视频地址无效'
+					break
+				default:
+					errorMsg = `播放失败(错误码:${errCode})`
+			}
+			console.error('视频错误详情:', e.detail)
+		}
+		
+		uni.showToast({
+			title: errorMsg,
+			icon: 'none',
+			duration: 3000
+		})
+
+		isPlaying.value = false
+
+		// #ifdef H5
+		setTimeout(() => {
+			if (isPlaying.value && jessibucaRef.value) {
+				console.log('尝试重新加载视频流')
+				if (deviceInfo.originalStreamUrl !== config.streamServer.wsFlvServer) {
+					deviceInfo.streamUrl = config.streamServer.wsFlvServer
+				}
+			}
+		}, 3000)
+		// #endif
+	}
+
+	// 更新时间
+	const startTimeUpdate = () => {
+		setInterval(() => {
+			const now = new Date()
+			const hours = String(now.getHours()).padStart(2, '0')
+			const minutes = String(now.getMinutes()).padStart(2, '0')
+			const seconds = String(now.getSeconds()).padStart(2, '0')
+			currentTime.value = `${hours}:${minutes}:${seconds}`
+		}, 1000)
+	}
+// 播放/暂停切换
+	const togglePlayState = () => {
+		if (!isPlaying.value) {
+			isPlaying.value = true
+			
+			// #ifdef MP-WEIXIN
+			setTimeout(() => {
+				if (livePlayerContext.value) {
+					livePlayerContext.value.play()
+					console.log('微信小程序开始播放')
+				}
+			}, 300)
+			// #endif
+		
+			// #ifdef APP-PLUS || APP-HARMONY
+			setTimeout(() => {
+				if (appLivePlayerContext.value) {
+					appLivePlayerContext.value.play()
+					console.log('App开始播放')
+				}
+			}, 300)
+			// #endif
+
+			// #ifdef H5
+			setTimeout(() => {
+				playStart(deviceInfo.deviceId, deviceInfo.channelId).then(res => {
+					if (res.data.code !== 0) {
+						console.error('播放开始失败:', res.message)
+						uni.showToast({
+							title: '播放失败: ' + res.message,
+							icon: 'none'
+						})
+						return
+					}
+					console.log('播放开始:', res)
+					
+					// 使用 wss_flv 并替换域名和端口
+					let streamUrl = res.data.data.wss_flv
+					if (streamUrl) {
+						const urlObj = new URL(streamUrl)
+						urlObj.hostname = 'nxy.gbdfarm.com'
+						urlObj.port = '9000'
+						deviceInfo.originalStreamUrl = urlObj.toString()
+						console.log("togglePlayState - deviceInfo.originalStreamUrl", deviceInfo.originalStreamUrl)
+					}
+				}).catch(err => {
+					console.error('播放开始失败:', err)
+				})
+				uni.vibrateShort()
+			}, 300)
+			// #endif
+		} else {
+			// #ifdef H5
+			if (jessibucaRef.value) {
+				jessibucaRef.value.pause()
+			}
+			// #endif
+
+			// #ifdef MP-WEIXIN
+			if (livePlayerContext.value) {
+				livePlayerContext.value.pause()
+			}
+			// #endif
+
+			// #ifdef APP-PLUS || APP-HARMONY
+			if (appLivePlayerContext.value) {
+				appLivePlayerContext.value.pause()
+			}
+			// #endif
+
+			isPlaying.value = false
+
+			uni.showToast({
+				title: '视频已暂停',
+				icon: 'none',
+				duration: 1500
+			})
+		}
+	}
+
+	// 静音切换
+	const toggleMute = () => {
+		isMuted.value = !isMuted.value
+
+		// #ifdef H5
+		if (jessibucaRef.value) {
+			if (isMuted.value) {
+				jessibucaRef.value.mute()
+			} else {
+				jessibucaRef.value.cancelMute()
+			}
+		}
+		// #endif
+
+		// #ifndef H5
+		// App端的静音通过 live-player 的 muted 属性控制,会自动响应
+		uni.showToast({
+			title: isMuted.value ? '已静音' : '已取消静音',
+			icon: 'none'
+		})
+		// #endif
+	}
+
+	// 全屏切换
+	const toggleFullscreen = () => {
+		if (!isPlaying.value) {
+			togglePlayState()
+			setTimeout(() => {
+				setFullscreen(true)
+			}, 500)
+		} else {
+			setFullscreen(!isFullscreen.value)
+		}
+	}
+
+	// 设置全屏状态
+	const setFullscreen = (fullscreen) => {
+		isFullscreen.value = fullscreen
+
+		// #ifdef H5
+		if (isFullscreen.value) {
+			const ua = navigator.userAgent.toLowerCase()
+			const isMobile = /mobile|android|iphone|ipad/.test(ua)
+
+			if (isMobile && jessibucaRef.value) {
+				jessibucaRef.value.fullscreenSwich()
+			} else {
+				setTimeout(() => {
+					if (jessibucaRef.value) {
+						jessibucaRef.value.resize()
+					}
+				}, 300)
+			}
+
+			if (window.screen && window.screen.orientation && window.screen.orientation.lock) {
+				window.screen.orientation.lock('landscape').catch(err => {
+					console.error('无法锁定屏幕方向:', err)
+				})
+			}
+		} else {
+			if (jessibucaRef.value) {
+				if (jessibucaRef.value.isFullscreen()) {
+					jessibucaRef.value.fullscreenSwich()
+				}
+
+				setTimeout(() => {
+					if (jessibucaRef.value) {
+						jessibucaRef.value.resize()
+					}
+				}, 300)
+			}
+
+			if (window.screen && window.screen.orientation && window.screen.orientation.unlock) {
+				window.screen.orientation.unlock()
+			}
+		}
+		// #endif
+
+		// #ifdef MP-WEIXIN
+		if (livePlayerContext.value) {
+			if (isFullscreen.value) {
+				livePlayerContext.value.requestFullScreen({
+					direction: 90
+				})
+			} else {
+				livePlayerContext.value.exitFullScreen()
+			}
+		}
+		// #endif
+
+		// #ifdef APP-PLUS || APP-HARMONY
+		if (appLivePlayerContext.value) {
+			if (isFullscreen.value) {
+				appLivePlayerContext.value.requestFullScreen({
+					direction: 90
+				})
+			} else {
+				appLivePlayerContext.value.exitFullScreen()
+			}
+		}
+		// #endif
+	}
+
+	// 截图
+	const takeScreenshot = () => {
+		// #ifdef H5
+		if (jessibucaRef.value && isPlaying.value) {
+			jessibucaRef.value.screenshot()
+			uni.showToast({
+				title: '截图已保存',
+				icon: 'success'
+			})
+		} else {
+			uni.showToast({
+				title: '请先播放视频',
+				icon: 'none'
+			})
+		}
+		// #endif
+
+		
+		// if (livePlayerContext.value && isPlaying.value) {
+		// 	livePlayerContext.value.snapshot({
+		// 		success: (res) => {
+		// 			console.log('截图成功:', res.tempImagePath)
+		// 			uni.saveImageToPhotosAlbum({
+		// 				filePath: res.tempImagePath,
+		// 				success: () => {
+		// 					uni.showToast({
+		// 						title: '截图已保存到相册',
+		// 						icon: 'success'
+		// 					})
+		// 				},
+		// 				fail: (err) => {
+		// 					console.error('保存截图失败:', err)
+		// 					uni.showToast({
+		// 						title: '保存截图失败',
+		// 						icon: 'none'
+		// 					})
+		// 				}
+		// 			})
+		// 		},
+		// 		fail: (err) => {
+		// 			console.error('截图失败:', err)
+		// 			uni.showToast({
+		// 				title: '截图失败',
+		// 				icon: 'none'
+		// 			})
+		// 		}
+		// 	})
+		// } else {
+		// 	uni.showToast({
+		// 		title: '请先播放视频',
+		// 		icon: 'none'
+		// 	})
+		// }
+		
+
+		// #ifndef H5
+		if (appLivePlayerContext.value && isPlaying.value) {
+			appLivePlayerContext.value.snapshot({
+				success: (res) => {
+					console.log('App截图成功:', res.tempImagePath)
+					uni.saveImageToPhotosAlbum({
+						filePath: res.tempImagePath,
+						success: () => {
+							uni.showToast({
+								title: '截图已保存到相册',
+								icon: 'success'
+							})
+						},
+						fail: (err) => {
+							console.error('App保存截图失败:', err)
+							uni.showToast({
+								title: '保存截图失败',
+								icon: 'none'
+							})
+						}
+					})
+				},
+				fail: (err) => {
+					console.error('App截图失败:', err)
+					uni.showToast({
+						title: '截图失败',
+						icon: 'none'
+					})
+				}
+			})
+		} else {
+			uni.showToast({
+				title: '请先播放视频',
+				icon: 'none'
+			})
+		}
+		// #endif
+	}
+
+	// 小程序播放器状态变化处理
+	const onVideoPlay = () => {
+		console.log('视频开始播放')
+		isPlaying.value = true
+	}
+
+	const onVideoPause = () => {
+		console.log('视频暂停')
+	}
+
+	const onVideoEnded = () => {
+		console.log('视频播放结束')
+		isPlaying.value = false
+	}
+
+	const onTimeUpdate = (e) => {
+		// 更新播放时间
+		if (e && e.detail) {
+			const currentSeconds = Math.floor(e.detail.currentTime)
+			const hours = String(Math.floor(currentSeconds / 3600)).padStart(2, '0')
+			const minutes = String(Math.floor((currentSeconds % 3600) / 60)).padStart(2, '0')
+			const seconds = String(currentSeconds % 60).padStart(2, '0')
+			currentTime.value = `${hours}:${minutes}:${seconds}`
+		}
+	}
+
+	const onStateChange = (e) => {
+		console.log('播放器状态变化:', e.detail)
+		const state = e.detail.code
+		switch (state) {
+			case 2001:
+				console.log('已连接服务器')
+				break
+			case 2002:
+				console.log('开始拉流')
+				break
+			case 2003:
+				console.log('网络接收到首个视频帧')
+				break
+			case 2004:
+				console.log('视频播放开始')
+				break
+			case 2005:
+				console.log('视频播放进度')
+				break
+			case 2006:
+				console.log('视频播放结束')
+				isPlaying.value = false
+				break
+			case 2007:
+				console.log('视频播放Loading')
+				break
+			case 2008:
+				console.log('解码器启动')
+				break
+			case 2009:
+				console.log('视频分辨率改变')
+				break
+			case -2301:
+				console.error('网络断连,且重新连接亦不能恢复,播放器已停止')
+				isPlaying.value = false
+				uni.showToast({
+					title: '网络断连,请重试',
+					icon: 'none'
+				})
+				break
+			case -2302:
+				console.error('获取加速拉流地址失败')
+				break
+			case 2101:
+				console.error('当前视频帧解码失败')
+				break
+			case 2102:
+				console.error('当前音频帧解码失败')
+				break
+			case 2103:
+				console.warn('网络断连, 已启动自动重连')
+				break
+			case 2104:
+				console.warn('网络断连, 重连中...')
+				break
+			case 2105:
+				console.log('网络断连, 重连成功')
+				break
+			case 2106:
+				console.error('网络断连, 重连失败')
+				break
+			case 2107:
+				console.error('播放器连接超时')
+				break
+			case 2108:
+				console.error('获取点播文件信息失败')
+				break
+			default:
+				console.log('其他状态:', state)
+		}
+	}
+
+	// 小程序全屏状态变化
+	const onFullscreenChange = (e) => {
+		isFullscreen.value = e.detail.fullScreen
+		console.log('全屏状态变化:', isFullscreen.value)
+	}
+// 获取App(安卓/鸿蒙)环境使用的流地址
+	const getAppStreamUrl = computed(() => {
+		// 优先使用 fmp4 格式(微信小程序和App都支持)
+		if (deviceInfo.originalStreamUrl) {
+			console.log("当前视频流地址:", deviceInfo.originalStreamUrl)
+			return deviceInfo.originalStreamUrl
+		}
 
+		// 如果没有流地址,返回空字符串
+		console.warn('未获取到视频流地址')
+		return ''
+	})
 const traceDetail = computed(() => {
 	// 如果没有真实数据,返回 mock 数据
 	if (!traceInfo.value) {
@@ -866,18 +1526,18 @@ const traceDetail = computed(() => {
 
 // 种植现场状态:三态逻辑
 const cameraStatus = computed(() => {
-	const camera = traceDetail.value?.camera
-	if (!camera) return 'offline'
+	const camera = deviceInfo?.status
+	if (!camera) return 'off'
 
-	const hasLiveUrl = !!camera.liveUrl
+	// const hasLiveUrl = !!camera.liveUrl
 
 	// 有 liveUrl 但无封面图 → loading(信号接入中)
 	// 有 liveUrl 且有封面图 → online(可播放)
 	// 无 liveUrl → offline
-	if (hasLiveUrl && !camera.coverImage) {
+	if (camera && camera == 'off') {
 		return 'loading'
 	}
-	if (hasLiveUrl || camera.coverImage) {
+	if (camera && camera == 'ON') {
 		return 'online'
 	}
 	return 'offline'
@@ -2283,4 +2943,206 @@ function previewDoc(kind, index) {
 	color: rgba(88, 100, 92, 0.3);
 	margin-top: 8rpx;
 }
+/* 视频预览区域 */
+	.video-section {
+		/* margin: 0 30rpx 20rpx; */
+		position: relative;
+		z-index: 1;
+	}
+
+	.video-container {
+		position: relative;
+		width: 100%;
+		height: 420rpx;
+		background-color: #000000;
+		border-radius: 16rpx;
+		overflow: hidden;
+		box-shadow: 0 6rpx 20rpx rgba(0, 0, 0, 0.1);
+		transition: all 0.3s ease;
+	}
+
+	.video-container.fullscreen-mode {
+		position: fixed;
+		top: 0;
+		left: 0;
+		width: 100vw;
+		height: 100vh;
+		margin: 0;
+		z-index: 9999;
+		border-radius: 0;
+	}
+
+	.video-container.fullscreen-mode .video-controls {
+		padding: 30rpx;
+	}
+
+	.video-container.fullscreen-mode .top-controls,
+	.video-container.fullscreen-mode .bottom-controls {
+		opacity: 0;
+		transition: opacity 0.3s ease;
+	}
+
+	.video-container.fullscreen-mode:hover .top-controls,
+	.video-container.fullscreen-mode:hover .bottom-controls {
+		opacity: 1;
+	}
+
+	.video-player,
+	.video-placeholder {
+		width: 100%;
+		height: 100%;
+		object-fit: contain;
+	}
+
+	/* 微信小程序video组件样式 */
+	#myVideo {
+		width: 100%;
+		height: 100%;
+		background-color: #000000;
+	}
+	
+
+	/* App端video组件样式 */
+	/* #ifdef APP-PLUS || APP-HARMONY */
+	#appVideo {
+		width: 100%;
+		height: 100%;
+		background-color: #000000;
+	}
+	/* #endif */
+
+	.h5-video-wrapper {
+		width: 100%;
+		height: 100%;
+	}
+
+	/* App端视频播放器样式优化 */
+	/* #ifdef APP-PLUS || APP-HARMONY */
+	#appVideoPlayer {
+		width: 100%;
+		height: 100%;
+		background-color: #000000;
+	}
+	/* #endif */
+
+	.video-controls {
+		position: absolute;
+		top: 0;
+		left: 0;
+		width: 100%;
+		height: 100%;
+		display: flex;
+		flex-direction: column;
+		justify-content: space-between;
+		padding: 20rpx;
+		box-sizing: border-box;
+		background: linear-gradient(to bottom, rgba(0, 0, 0, 0.4) 0%, rgba(0, 0, 0, 0) 30%, rgba(0, 0, 0, 0) 70%, rgba(0, 0, 0, 0.4) 100%);
+	}
+	.control-row {
+		display: flex;
+		justify-content: space-between;
+		align-items: center;
+		width: 100%;
+	}
+	
+	.top-controls {
+		height: 80rpx;
+	}
+	
+	.center-controls {
+		height: 120rpx;
+		justify-content: center;
+		align-items: center;
+	}
+	
+	.bottom-controls {
+		height: 80rpx;
+	}
+	
+	.signal-indicator {
+		display: flex;
+		align-items: center;
+		color: #FFFFFF;
+		font-size: 24rpx;
+		background-color: rgba(0, 0, 0, 0.5);
+		padding: 8rpx 16rpx;
+		border-radius: 30rpx;
+	}
+	
+	.signal-indicator svg {
+		margin-right: 8rpx;
+	}
+	
+	.signal-text {
+		font-weight: 500;
+	}
+	
+	.fullscreen-button {
+		width: 60rpx;
+		height: 60rpx;
+		display: flex;
+		align-items: center;
+		justify-content: center;
+		color: #FFFFFF;
+		background-color: rgba(0, 0, 0, 0.5);
+		border-radius: 50%;
+		transition: all 0.2s;
+	}
+	
+	.fullscreen-button:active {
+		background-color: rgba(76, 175, 80, 0.7);
+		transform: scale(0.9);
+	}
+	
+	.video-time {
+		color: #FFFFFF;
+		font-size: 26rpx;
+		background-color: rgba(0, 0, 0, 0.5);
+		padding: 6rpx 16rpx;
+		border-radius: 30rpx;
+		font-weight: 500;
+	}
+	
+	.play-button {
+		width: 100rpx;
+		height: 100rpx;
+		border-radius: 50%;
+		background-color: rgba(255, 255, 255, 0.9);
+		display: flex;
+		align-items: center;
+		justify-content: center;
+		box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.5);
+		transition: all 0.2s;
+		transform: scale(1);
+	}
+	
+	.play-button:active {
+		transform: scale(0.92);
+		background-color: rgba(255, 255, 255, 1);
+	}
+	
+	.center-button-container {
+		width: 100%;
+		height: 100%;
+		display: flex;
+		align-items: center;
+		justify-content: center;
+	}
+	
+	.pause-icon {
+		width: 80rpx;
+		height: 80rpx;
+		border-radius: 50%;
+		background-color: rgba(0, 0, 0, 0.5);
+		display: flex;
+		align-items: center;
+		justify-content: center;
+		opacity: 0;
+		transition: opacity 0.3s;
+	}
+	
+	.center-button-container:active .pause-icon {
+		opacity: 1;
+		background-color: rgba(76, 175, 80, 0.7);
+	}
 </style>

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/js/jessibuca/decoder.js


BIN
static/js/jessibuca/decoder.wasm


+ 637 - 0
static/js/jessibuca/jessibuca.d.ts

@@ -0,0 +1,637 @@
+declare namespace Jessibuca {
+
+    /** 超时信息 */
+    enum TIMEOUT {
+        /** 当play()的时候,如果没有数据返回 */
+        loadingTimeout = 'loadingTimeout',
+        /** 当播放过程中,如果超过timeout之后没有数据渲染 */
+        delayTimeout = 'delayTimeout',
+    }
+
+    /** 错误信息 */
+    enum ERROR {
+        /** 播放错误,url 为空的时候,调用 play 方法 */
+        playError = 'playError',
+        /** http 请求失败 */
+        fetchError = 'fetchError',
+        /** websocket 请求失败 */
+        websocketError = 'websocketError',
+        /** webcodecs 解码 h265 失败 */
+        webcodecsH265NotSupport = 'webcodecsH265NotSupport',
+        /** mediaSource 解码 h265 失败 */
+        mediaSourceH265NotSupport = 'mediaSourceH265NotSupport',
+        /** wasm 解码失败 */
+        wasmDecodeError = 'wasmDecodeError',
+    }
+
+    interface Config {
+        /**
+         * 播放器容器
+         * *  若为 string ,则底层调用的是 document.getElementById('id')
+         * */
+        container: HTMLElement | string;
+        /**
+         * 设置最大缓冲时长,单位秒,播放器会自动消除延迟
+         */
+        videoBuffer?: number;
+        /**
+         * worker地址
+         * *  默认引用的是根目录下面的decoder.js文件 ,decoder.js 与 decoder.wasm文件必须是放在同一个目录下面。 */
+        decoder?: string;
+        /**
+         * 是否不使用离屏模式(提升渲染能力)
+         */
+        forceNoOffscreen?: boolean;
+        /**
+         * 是否开启当页面的'visibilityState'变为'hidden'的时候,自动暂停播放。
+         */
+        hiddenAutoPause?: boolean;
+        /**
+         * 是否有音频,如果设置`false`,则不对音频数据解码,提升性能。
+         */
+        hasAudio?: boolean;
+        /**
+         * 设置旋转角度,只支持,0(默认),180,270 三个值
+         */
+        rotate?: boolean;
+        /**
+         * 1. 当为`true`的时候:视频画面做等比缩放后,高或宽对齐canvas区域,画面不被拉伸,但有黑边。 等同于 `setScaleMode(1)`
+         * 2. 当为`false`的时候:视频画面完全填充canvas区域,画面会被拉伸。等同于 `setScaleMode(0)`
+         */
+        isResize?: boolean;
+        /**
+         * 1. 当为`true`的时候:视频画面做等比缩放后,完全填充canvas区域,画面不被拉伸,没有黑边,但画面显示不全。等同于 `setScaleMode(2)`
+         */
+        isFullResize?: boolean;
+        /**
+         * 1. 当为`true`的时候:ws协议不检验是否以.flv为依据,进行协议解析。
+         */
+        isFlv?: boolean;
+        /**
+         * 是否开启控制台调试打
+         */
+        debug?: boolean;
+        /**
+         * 1. 设置超时时长, 单位秒
+         * 2. 在连接成功之前(loading)和播放中途(heart),如果超过设定时长无数据返回,则回调timeout事件
+         */
+        timeout?: number;
+        /**
+         * 1. 设置超时时长, 单位秒
+         * 2. 在连接成功之前,如果超过设定时长无数据返回,则回调timeout事件
+         */
+        heartTimeout?: number;
+        /**
+         * 1. 设置超时时长, 单位秒
+         * 2. 在连接成功之前,如果超过设定时长无数据返回,则回调timeout事件
+         */
+        loadingTimeout?: number;
+        /**
+         * 是否支持屏幕的双击事件,触发全屏,取消全屏事件
+         */
+        supportDblclickFullscreen?: boolean;
+        /**
+         * 是否显示网
+         */
+        showBandwidth?: boolean;
+        /**
+         * 配置操作按钮
+         */
+        operateBtns?: {
+            /** 是否显示全屏按钮 */
+            fullscreen?: boolean;
+            /** 是否显示截图按钮 */
+            screenshot?: boolean;
+            /** 是否显示播放暂停按钮 */
+            play?: boolean;
+            /** 是否显示声音按钮 */
+            audio?: boolean;
+            /** 是否显示录制按 */
+            record?: boolean;
+        };
+        /**
+         * 开启屏幕常亮,在手机浏览器上, canvas标签渲染视频并不会像video标签那样保持屏幕常亮
+         */
+        keepScreenOn?: boolean;
+        /**
+         * 是否开启声音,默认是关闭声音播放的
+         */
+        isNotMute?: boolean;
+        /**
+         * 加载过程中文案
+         */
+        loadingText?: string;
+        /**
+         * 背景图片
+         */
+        background?: string;
+        /**
+         * 是否开启MediaSource硬解码
+         * * 视频编码只支持H.264视频(Safari on iOS不支持)
+         * * 不支持 forceNoOffscreen 为 false (开启离屏渲染)
+         */
+        useMSE?: boolean;
+        /**
+         * 是否开启Webcodecs硬解码
+         * *  视频编码只支持H.264视频 (需在chrome 94版本以上,需要https或者localhost环境)
+         * *  支持 forceNoOffscreen 为 false (开启离屏渲染)
+         * */
+        useWCS?: boolean;
+        /**
+         * 是否开启键盘快捷键
+         * 目前支持的键盘快捷键有:esc -> 退出全屏;arrowUp -> 声音增加;arrowDown -> 声音减少;
+         */
+        hotKey?: boolean;
+        /**
+         *  在使用MSE或者Webcodecs 播放H265的时候,是否自动降级到wasm模式。
+         *  设置为false 则直接关闭播放,抛出Error 异常,设置为true 则会自动切换成wasm模式播放。
+         */
+        autoWasm?: boolean;
+        /**
+         * heartTimeout 心跳超时之后自动再播放,不再抛出异常,而直接重新播放视频地址。
+         */
+        heartTimeoutReplay?: boolean,
+        /**
+         * heartTimeoutReplay 从试次数,超过之后,不再自动播放
+         */
+        heartTimeoutReplayTimes?: number,
+        /**
+         * loadingTimeout loading之后自动再播放,不再抛出异常,而直接重新播放视频地址。
+         */
+        loadingTimeoutReplay?: boolean,
+        /**
+         * heartTimeoutReplay 从试次数,超过之后,不再自动播放
+         */
+        loadingTimeoutReplayTimes?: number
+        /**
+         * wasm解码报错之后,不再抛出异常,而是直接重新播放视频地址。
+         */
+        wasmDecodeErrorReplay?: boolean,
+        /**
+         * https://github.com/langhuihui/jessibuca/issues/152 解决方案
+         * 例如:WebGL图像预处理默认每次取4字节的数据,但是540x960分辨率下的U、V分量宽度是540/2=270不能被4整除,导致绿屏。
+         */
+        openWebglAlignment?: boolean
+    }
+}
+
+
+declare class Jessibuca {
+
+    constructor(config?: Jessibuca.Config);
+
+    /**
+     * 是否开启控制台调试打印
+     @example
+     // 开启
+     jessibuca.setDebug(true)
+     // 关闭
+     jessibuca.setDebug(false)
+     */
+    setDebug(flag: boolean): void;
+
+    /**
+     * 静音
+     @example
+     jessibuca.mute()
+     */
+    mute(): void;
+
+    /**
+     * 取消静音
+     @example
+     jessibuca.cancelMute()
+     */
+    cancelMute(): void;
+
+    /**
+     * 留给上层用户操作来触发音频恢复的方法。
+     *
+     * iPhone,chrome等要求自动播放时,音频必须静音,需要由一个真实的用户交互操作来恢复,不能使用代码。
+     *
+     * https://developers.google.com/web/updates/2017/09/autoplay-policy-changes
+     */
+    audioResume(): void;
+
+    /**
+     *
+     * 设置超时时长, 单位秒
+     * 在连接成功之前和播放中途,如果超过设定时长无数据返回,则回调timeout事件
+
+     @example
+     jessibuca.setTimeout(10)
+
+     jessibuca.on('timeout',function(){
+        //
+    });
+     */
+    setTimeout(): void;
+
+    /**
+     * @param mode
+     *      0 视频画面完全填充canvas区域,画面会被拉伸  等同于参数 `isResize` 为false
+     *
+     *      1 视频画面做等比缩放后,高或宽对齐canvas区域,画面不被拉伸,但有黑边 等同于参数 `isResize` 为true
+     *
+     *      2 视频画面做等比缩放后,完全填充canvas区域,画面不被拉伸,没有黑边,但画面显示不全 等同于参数 `isFullResize` 为true
+     @example
+     jessibuca.setScaleMode(0)
+
+     jessibuca.setScaleMode(1)
+
+     jessibuca.setScaleMode(2)
+     */
+    setScaleMode(mode: number): void;
+
+    /**
+     * 暂停播放
+     *
+     * 可以在pause 之后,再调用 `play()`方法就继续播放之前的流。
+     @example
+     jessibuca.pause().then(()=>{
+        console.log('pause success')
+
+        jessibuca.play().then(()=>{
+
+        }).catch((e)=>{
+
+        })
+
+    }).catch((e)=>{
+        console.log('pause error',e);
+    })
+     */
+    pause(): Promise<void>;
+
+    /**
+     * 关闭视频,不释放底层资源
+     @example
+     jessibuca.close();
+     */
+    close(): void;
+
+    /**
+     * 关闭视频,释放底层资源
+     @example
+     jessibuca.destroy()
+     */
+    destroy(): void;
+
+    /**
+     * 清理画布为黑色背景
+     @example
+     jessibuca.clearView()
+     */
+    clearView(): void;
+
+    /**
+     * 播放视频
+     @example
+
+     jessibuca.play('url').then(()=>{
+        console.log('play success')
+    }).catch((e)=>{
+        console.log('play error',e)
+    })
+     //
+     jessibuca.play()
+     */
+    play(url?: string): Promise<void>;
+
+    /**
+     * 重新调整视图大小
+     */
+    resize(): void;
+
+    /**
+     * 设置最大缓冲时长,单位秒,播放器会自动消除延迟。
+     *
+     * 等同于 `videoBuffer` 参数。
+     *
+     @example
+     // 设置 200ms 缓冲
+     jessibuca.setBufferTime(0.2)
+     */
+    setBufferTime(time: number): void;
+
+    /**
+     * 设置旋转角度,只支持,0(默认) ,180,270 三个值。
+     *
+     * > 可用于实现监控画面小窗和全屏效果,由于iOS没有全屏API,此方法可以模拟页面内全屏效果而且多端效果一致。   *
+     @example
+     jessibuca.setRotate(0)
+
+     jessibuca.setRotate(90)
+
+     jessibuca.setRotate(270)
+     */
+    setRotate(deg: number): void;
+
+    /**
+     *
+     * 设置音量大小,取值0 — 1
+     *
+     * > 区别于 mute 和 cancelMute 方法,虽然设置setVolume(0) 也能达到 mute方法,但是mute 方法是不调用底层播放音频的,能提高性能。而setVolume(0)只是把声音设置为0 ,以达到效果。
+     * @param volume 当为0时,完全无声;当为1时,最大音量,默认值
+     @example
+     jessibuca.setVolume(0.2)
+
+     jessibuca.setVolume(0)
+
+     jessibuca.setVolume(1)
+     */
+    setVolume(volume: number): void;
+
+    /**
+     * 返回是否加载完毕
+     @example
+     var result = jessibuca.hasLoaded()
+     console.log(result) // true
+     */
+    hasLoaded(): boolean;
+
+    /**
+     * 开启屏幕常亮,在手机浏览器上, canvas标签渲染视频并不会像video标签那样保持屏幕常亮。
+     * H5目前在chrome\edge 84, android chrome 84及以上有原生亮屏API, 需要是https页面
+     * 其余平台为模拟实现,此时为兼容实现,并不保证所有浏览器都支持
+     @example
+     jessibuca.setKeepScreenOn()
+     */
+    setKeepScreenOn(): boolean;
+
+    /**
+     * 全屏(取消全屏)播放视频
+     @example
+     jessibuca.setFullscreen(true)
+     //
+     jessibuca.setFullscreen(false)
+     */
+    setFullscreen(flag: boolean): void;
+
+    /**
+     *
+     * 截图,调用后弹出下载框保存截图
+     * @param filename 可选参数, 保存的文件名, 默认 `时间戳`
+     * @param format   可选参数, 截图的格式,可选png或jpeg或者webp ,默认 `png`
+     * @param quality  可选参数, 当格式是jpeg或者webp时,压缩质量,取值0 ~ 1 ,默认 `0.92`
+     * @param type 可选参数, 可选download或者base64或者blob,默认`download`
+
+     @example
+
+     jessibuca.screenshot("test","png",0.5)
+
+     const base64 = jessibuca.screenshot("test","png",0.5,'base64')
+
+     const fileBlob = jessibuca.screenshot("test",'blob')
+     */
+    screenshot(filename?: string, format?: string, quality?: number, type?: string): void;
+
+    /**
+     * 开始录制。
+     * @param fileName 可选,默认时间戳
+     * @param fileType 可选,默认webm,支持webm 和mp4 格式
+
+     @example
+     jessibuca.startRecord('xxx','webm')
+     */
+    startRecord(fileName: string, fileType: string): void;
+
+    /**
+     * 暂停录制并下载。
+     @example
+     jessibuca.stopRecordAndSave()
+     */
+    stopRecordAndSave(): void;
+
+    /**
+     * 返回是否正在播放中状态。
+     @example
+     var result = jessibuca.isPlaying()
+     console.log(result) // true
+     */
+    isPlaying(): boolean;
+
+    /**
+     *   返回是否静音。
+     @example
+     var result = jessibuca.isMute()
+     console.log(result) // true
+     */
+    isMute(): boolean;
+
+    /**
+     * 返回是否正在录制。
+     @example
+     var result = jessibuca.isRecording()
+     console.log(result) // true
+     */
+    isRecording(): boolean;
+
+
+    /**
+     * 监听 jessibuca 初始化事件
+     * @example
+     * jessibuca.on("load",function(){console.log('load')})
+     */
+    on(event: 'load', callback: () => void): void;
+
+    /**
+     * 视频播放持续时间,单位ms
+     * @example
+     * jessibuca.on('timeUpdate',function (ts) {console.log('timeUpdate',ts);})
+     */
+    on(event: 'timeUpdate', callback: () => void): void;
+
+    /**
+     * 当解析出视频信息时回调,2个回调参数
+     * @example
+     * jessibuca.on("videoInfo",function(data){console.log('width:',data.width,'height:',data.width)})
+     */
+    on(event: 'videoInfo', callback: (data: {
+        /** 视频宽 */
+        width: number;
+        /** 视频高 */
+        height: number;
+    }) => void): void;
+
+    /**
+     * 当解析出音频信息时回调,2个回调参数
+     * @example
+     * jessibuca.on("audioInfo",function(data){console.log('numOfChannels:',data.numOfChannels,'sampleRate',data.sampleRate)})
+     */
+    on(event: 'audioInfo', callback: (data: {
+        /** 声频通道 */
+        numOfChannels: number;
+        /** 采样率 */
+        sampleRate: number;
+    }) => void): void;
+
+    /**
+     * 信息,包含错误信息
+     * @example
+     * jessibuca.on("log",function(data){console.log('data:',data)})
+     */
+    on(event: 'log', callback: () => void): void;
+
+    /**
+     * 错误信息
+     * @example
+     * jessibuca.on("error",function(error){
+        if(error === Jessibuca.ERROR.fetchError){
+            //
+        }
+        else if(error === Jessibuca.ERROR.webcodecsH265NotSupport){
+            //
+        }
+        console.log('error:',error)
+    })
+     */
+    on(event: 'error', callback: (err: Jessibuca.ERROR) => void): void;
+
+    /**
+     * 当前网速, 单位KB 每秒1次,
+     * @example
+     * jessibuca.on("kBps",function(data){console.log('kBps:',data)})
+     */
+    on(event: 'kBps', callback: (value: number) => void): void;
+
+    /**
+     * 渲染开始
+     * @example
+     * jessibuca.on("start",function(){console.log('start render')})
+     */
+    on(event: 'start', callback: () => void): void;
+
+    /**
+     * 当设定的超时时间内无数据返回,则回调
+     * @example
+     * jessibuca.on("timeout",function(error){console.log('timeout:',error)})
+     */
+    on(event: 'timeout', callback: (error: Jessibuca.TIMEOUT) => void): void;
+
+    /**
+     * 当play()的时候,如果没有数据返回,则回调
+     * @example
+     * jessibuca.on("loadingTimeout",function(){console.log('timeout')})
+     */
+    on(event: 'loadingTimeout', callback: () => void): void;
+
+    /**
+     * 当播放过程中,如果超过timeout之后没有数据渲染,则抛出异常。
+     * @example
+     * jessibuca.on("delayTimeout",function(){console.log('timeout')})
+     */
+    on(event: 'delayTimeout', callback: () => void): void;
+
+    /**
+     * 当前是否全屏
+     * @example
+     * jessibuca.on("fullscreen",function(flag){console.log('is fullscreen',flag)})
+     */
+    on(event: 'fullscreen', callback: () => void): void;
+
+    /**
+     * 触发播放事件
+     * @example
+     * jessibuca.on("play",function(flag){console.log('play')})
+     */
+    on(event: 'play', callback: () => void): void;
+
+    /**
+     * 触发暂停事件
+     * @example
+     * jessibuca.on("pause",function(flag){console.log('pause')})
+     */
+    on(event: 'pause', callback: () => void): void;
+
+    /**
+     * 触发声音事件,返回boolean值
+     * @example
+     * jessibuca.on("mute",function(flag){console.log('is mute',flag)})
+     */
+    on(event: 'mute', callback: () => void): void;
+
+    /**
+     * 流状态统计,流开始播放后回调,每秒1次。
+     * @example
+     * jessibuca.on("stats",function(s){console.log("stats is",s)})
+     */
+    on(event: 'stats', callback: (stats: {
+        /** 当前缓冲区时长,单位毫秒 */
+        buf: number;
+        /** 当前视频帧率 */
+        fps: number;
+        /** 当前音频码率,单位byte */
+        abps: number;
+        /** 当前视频码率,单位byte */
+        vbps: number;
+        /** 当前视频帧pts,单位毫秒 */
+        ts: number;
+    }) => void): void;
+
+    /**
+     * 渲染性能统计,流开始播放后回调,每秒1次。
+     * @param performance 0: 表示卡顿,1: 表示流畅,2: 表示非常流程
+     * @example
+     * jessibuca.on("performance",function(performance){console.log("performance is",performance)})
+     */
+    on(event: 'performance', callback: (performance: 0 | 1 | 2) => void): void;
+
+    /**
+     * 录制开始的事件
+
+     * @example
+     * jessibuca.on("recordStart",function(){console.log("record start")})
+     */
+    on(event: 'recordStart', callback: () => void): void;
+
+    /**
+     * 录制结束的事件
+
+     * @example
+     * jessibuca.on("recordEnd",function(){console.log("record end")})
+     */
+    on(event: 'recordEnd', callback: () => void): void;
+
+    /**
+     * 录制的时候,返回的录制时长,1s一次
+
+     * @example
+     * jessibuca.on("recordingTimestamp",function(timestamp){console.log("recordingTimestamp is",timestamp)})
+     */
+    on(event: 'recordingTimestamp', callback: (timestamp: number) => void): void;
+
+    /**
+     * 监听调用play方法 经过 初始化-> 网络请求-> 解封装 -> 解码 -> 渲染 一系列过程的时间消耗
+     * @param event
+     * @param callback
+     */
+    on(event: 'playToRenderTimes', callback: (times: {
+        playInitStart: number, // 1 初始化
+        playStart: number, // 2 初始化
+        streamStart: number, // 3 网络请求
+        streamResponse: number, // 4 网络请求
+        demuxStart: number, // 5 解封装
+        decodeStart: number, // 6 解码
+        videoStart: number, // 7 渲染
+        playTimestamp: number,// playStart- playInitStart
+        streamTimestamp: number,// streamStart - playStart
+        streamResponseTimestamp: number,// streamResponse - streamStart
+        demuxTimestamp: number, // demuxStart - streamResponse
+        decodeTimestamp: number, // decodeStart - demuxStart
+        videoTimestamp: number,// videoStart - decodeStart
+        allTimestamp: number // videoStart - playInitStart
+    }) => void): void
+
+    /**
+     * 监听方法
+     *
+     @example
+
+     jessibuca.on("load",function(){console.log('load')})
+     */
+    on(event: string, callback: Function): void;
+
+}
+
+export default Jessibuca;

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/js/jessibuca/jessibuca.js


+ 7 - 0
vite.config.js

@@ -34,6 +34,13 @@ server: {
         secure: false,
         rewrite: (path) => path.replace(/^\/base/, ''),
       },
+	  // WVP 视频平台代理配置
+	  '/wvp': {
+	    target: 'https://nxy.gbdfarm.com:9000',
+	    changeOrigin: true,
+	    secure: false,
+	    rewrite: (path) => path.replace(/^\/wvp/, '/wvp'),
+	  },
     },
   },
   build: {

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio