更新记录

1.0.4(2026-09-16)

  1. 新增 openCamera(deviceId, width, height, rawPreviewData) 第四个参数,可按需开启原始帧回调。
  2. 高分辨率普通预览可关闭原始帧回调,降低 NV21 原始帧处理压力;RTMP 推流必须开启该参数。
  3. 新增 deviceschange 设备列表事件,优化 nvue 页面 USB 设备枚举结果不稳定的问题。
  4. 优化 USB 授权、连接取消和连接失败后的状态清理,避免重复超时报错。
  5. 优化 RTMP 和录像异常结束时的状态通知,避免前端停留在推流或录制中状态。
  6. 优化分辨率切换失败后的前端状态回滚。

1.0.3(2026-08-19)

优化异常报错的问题

1.0.2(2026-08-19)

增加了视频推流,可以同时拍照,录制视频,推流,支持uvc采集音频或者手持设备采集音频(比如手机)

查看更多

平台兼容性

uni-app(4.0)

Vue2 Vue3 Vue3插件版本 Chrome Safari app-vue app-nvue app-nvue插件版本 Android Android插件版本 iOS 鸿蒙
× √ 1.0.4 - - - √ 1.0.4 5.0 1.0.4 × ×
微信小程序 支付宝小程序 抖音小程序 百度小程序 快手小程序 京东小程序 鸿蒙元服务 QQ小程序 飞书小程序 小红书小程序 快应用-华为 快应用-联盟
× × × × × × × × × × × ×

其他

多语言 暗黑模式 宽屏模式 蒸汽模式
× × × √

YZJ UVC Camera

仅适用于 App-Android 的 nvue 原生组件,用于标准 USB UVC 摄像头的预览、拍照、MP4 录制和 H.264/AAC RTMP 推流。录音来源可选择 UVC 自带的 UAC 麦克风或手机麦克风;选择手机麦克风时,RTMP 会推送 AAC 音频。

前置条件

  • 仅支持 Android API 21 及以上,且必须在 nvue 页面使用。
  • 应用需要授予 android.permission.CAMERA 权限,并先调用 requestUsbPermission 获取 USB 授权。
  • 使用手机麦克风时还需要授予 android.permission.RECORD_AUDIO 权限。
  • getDevices() 会返回全部 USB 设备,仅对 isUvc === true 的设备调用相机 API。

录音来源

默认使用 UVC 摄像头自带的 UAC 麦克风。若设备不带麦克风或录制无声,可在关闭预览后切换到手机麦克风:

this.$refs.uvcCam.setAudioSource('system')

切换后点击“申请授权”,授予 CAMERA 和 RECORD_AUDIO 权限,再重新打开预览并开始录像。切回 UVC 麦克风:

this.$refs.uvcCam.setAudioSource('device')

音频来源不能在预览已打开、录制中或正在启动录制时切换。

完整示例

<template>
  <view class="page">
    <yzj-uvc-cam
      ref="uvcCam"
      class="preview"
      @statechange="onUvcEvent('statechange', $event)"
      @devicechange="onUvcEvent('devicechange', $event)"
      @deviceschange="onUvcEvent('deviceschange', $event)"
      @resolutionschange="onUvcEvent('resolutionschange', $event)"
      @streamchange="onUvcEvent('streamchange', $event)"
      @error="onUvcEvent('error', $event)"
      @photo="onUvcEvent('photo', $event)"
      @recordchange="onUvcEvent('recordchange', $event)"
    />
    <text class="status">{{ statusText }}</text>
    <radio-group class="row" @change="changeAudioSource">
      <label><radio value="device" :checked="audioSource === 'device'" :disabled="previewing" /><text>UVC 麦克风</text></label>
      <label><radio value="system" :checked="audioSource === 'system'" :disabled="previewing" /><text>手机麦克风</text></label>
    </radio-group>
    <view class="row">
      <button @click="refreshDevices">刷新设备</button>
      <button @click="requestPermission">申请授权</button>
    </view>
    <view class="row">
      <button @click="openUvcCamera">打开预览</button>
      <button @click="closeUvcCamera">关闭预览</button>
    </view>
    <view class="row">
      <button @click="testPreview">测试是否预览</button>
      <button @click="readResolutions">读取分辨率</button>
    </view>
    <view class="row">
      <button @click="switchResolution">切换分辨率</button>
      <button @click="takePhoto">拍照</button>
    </view>
    <button @click="toggleRecording">{{ recording ? '停止录像' : '开始录像' }}</button>
    <input v-model="rtmpUrl" class="rtmp-input" placeholder="rtmp://host/live/stream" />
    <view class="row">
      <button @click="toggleRtmp">{{ streaming ? '停止推流' : '开始推流' }}</button>
      <button :disabled="!streaming" @click="reduceRtmpBitrate">降低码率</button>
      <button :disabled="!streaming" @click="reduceRtmpFps">降低帧率</button>
    </view>
    <text class="detail">支持分辨率:{{ JSON.stringify(resolutions) }}</text>
    <text class="detail">照片:{{ photoPath }}</text>
    <text class="detail">录像:{{ videoPath }}</text>
    <text class="detail">推流:{{ streaming ? '进行中' : '未推流' }},{{ rtmpBitrate }} bps,{{ rtmpFps }} fps</text>
  </view>
</template>

<script>
export default {
  data() {
    return {
      deviceId: '',
      width: 1280,
      height: 720,
      audioSource: 'device',
      previewing: false,
      recording: false,
      streaming: false,
      resolutions: [],
      photoPath: '',
      videoPath: '',
      rtmpUrl: 'rtmp://demo.easydss.com:21936/live/your-stream-id',
      rtmpBitrate: 2000000,
      rtmpFps: 30,
      statusText: '等待 UVC 摄像头接入'
    }
  },
  onReady() {
    setTimeout(() => this.refreshDevices(), 100)
  },
  methods: {
    getCamera() {
      return this.$refs.uvcCam
    },
    decodeEvent(event) {
      const raw = event && event.detail != null ? event.detail : event
      const value = raw && typeof raw === 'object' && raw.value != null ? raw.value : raw
      return typeof value === 'string' ? JSON.parse(value) : (value || {})
    },
    refreshDevices() {
      const devices = JSON.parse(this.getCamera().getDevices())
      const camera = devices.find((item) => item && item.isUvc === true)
      this.deviceId = camera ? String(camera.deviceId) : ''
      this.statusText = camera ? '已找到 UVC 摄像头' : '未找到 UVC 摄像头'
    },
    requestPermission() {
      if (!this.deviceId) return
      const permissions = ['android.permission.CAMERA']
      if (this.audioSource === 'system') permissions.push('android.permission.RECORD_AUDIO')
      plus.android.requestPermissions(permissions, (result) => {
        const granted = Array.isArray(result.granted) ? result.granted : []
        const cameraGranted = granted.indexOf('android.permission.CAMERA') >= 0
        const audioGranted = this.audioSource !== 'system' || granted.indexOf('android.permission.RECORD_AUDIO') >= 0
        if (cameraGranted && audioGranted) {
          this.getCamera().requestUsbPermission(this.deviceId)
        } else {
          this.statusText = '所需权限未全部授予'
        }
      })
    },
    changeAudioSource(event) {
      if (this.previewing || this.recording) return
      this.audioSource = String(event.detail.value)
      this.getCamera().setAudioSource(this.audioSource)
    },
    openUvcCamera() {
      if (this.deviceId) {
        this.getCamera().setAudioSource(this.audioSource)
        this.getCamera().openCamera(this.deviceId, this.width, this.height)
      }
    },
    closeUvcCamera() {
      if (this.recording) return
      if (this.streaming) this.getCamera().stopRtmp()
      this.getCamera().closeCamera()
    },
    testPreview() {
      this.statusText = this.previewing ? '预览已打开' : '预览未打开'
    },
    readResolutions() {
      this.getCamera().getSupportedResolutions()
    },
    switchResolution() {
      if (this.recording || this.resolutions.length === 0) return
      const currentIndex = this.resolutions.findIndex((item) => item.width === this.width && item.height === this.height)
      const next = this.resolutions[(currentIndex + 1) % this.resolutions.length]
      this.width = Number(next.width)
      this.height = Number(next.height)
      this.getCamera().setResolution(this.width, this.height)
    },
    takePhoto() {
      if (this.previewing) this.getCamera().takePhoto('')
    },
    toggleRecording() {
      if (this.recording) {
        this.getCamera().stopRecording()
      } else if (this.previewing) {
        this.getCamera().startRecording('')
      }
    },
    toggleRtmp() {
      if (this.streaming) {
        this.getCamera().stopRtmp()
      } else if (this.previewing && this.rtmpUrl) {
        this.getCamera().startRtmp(this.rtmpUrl, this.rtmpBitrate, this.rtmpFps)
      }
    },
    reduceRtmpBitrate() {
      const nextBitrate = Math.max(300000, this.rtmpBitrate - 300000)
      this.getCamera().setRtmpBitrate(nextBitrate)
    },
    reduceRtmpFps() {
      const nextFps = Math.max(10, this.rtmpFps - 5)
      this.getCamera().setRtmpFps(nextFps)
    },
    onUvcEvent(type, event) {
      const payload = this.decodeEvent(event)
      if (type === 'statechange') {
        if (payload.state === 'PREVIEWING') this.previewing = true
        if (payload.state === 'CLOSED') {
          this.previewing = false
          this.recording = false
          this.streaming = false
        }
        this.statusText = payload.state || '状态变化'
      }
      if (type === 'devicechange' && payload.action === 'attached' && payload.device && payload.device.isUvc === true) {
        this.deviceId = String(payload.device.deviceId)
      }
      if (type === 'devicechange' && payload.action === 'detached') {
        this.deviceId = ''
        this.previewing = false
        this.recording = false
        this.streaming = false
      }
      if (type === 'resolutionschange') {
        this.resolutions = Array.isArray(payload.resolutions) ? payload.resolutions : []
      }
      if (type === 'photo') this.photoPath = payload.path || ''
      if (type === 'recordchange') {
        this.recording = payload.state === 'started'
        if (payload.state === 'stopped') this.videoPath = payload.path || ''
      }
      if (type === 'streamchange') {
        if (payload.state === 'connecting' || payload.state === 'started' || payload.state === 'authenticated') this.streaming = true
        if (payload.state === 'stopped') this.streaming = false
        if (payload.state === 'bitrateChanged') this.rtmpBitrate = Number(payload.bitrate)
        if (payload.state === 'fpsChanged') this.rtmpFps = Number(payload.fps)
      }
      if (type === 'error') this.statusText = `${payload.code}: ${payload.message}`
    }
  }
}
</script>

<style>
.page { flex: 1; }
.preview { width: 750rpx; height: 422rpx; background-color: #000000; }
.row { flex-direction: row; }
.rtmp-input { margin: 16rpx; padding: 12rpx; border-width: 1rpx; border-color: #cccccc; }
.status { padding: 16rpx; }
.detail { padding: 8rpx 16rpx; }
</style>

USB 授权成功后会触发 statechange 的 USB_PERMISSION_GRANTED 状态,再调用 openCamera。首次使用时,应在页面层同时申请 Android 相机权限。

API

方法 参数 说明
getDevices() 无 请求刷新 USB 设备列表,并尽力同步返回 JSON 数组。nvue 应以随后触发的 deviceschange 事件为准,仅对 isUvc === true 的设备调用后续方法。
requestUsbPermission(deviceId) deviceId: string 请求指定 UVC 设备的 USB 授权。
openCamera(deviceId, width, height, rawPreviewData) deviceId: string、width: number、height: number、rawPreviewData: boolean 使用指定分辨率打开预览。rawPreviewData 默认 true;普通高分辨率预览可传 false 降低原始帧处理压力,RTMP 推流必须传 true。切换该参数前必须关闭预览。调用前必须完成 USB 授权。
closeCamera() 无 停止预览并释放当前相机连接。录制中应先调用 stopRecording()。
getSupportedResolutions() 无 返回当前设备支持分辨率的 JSON 数组,并触发 resolutionschange。
isPreviewing() 无 原生同步查询接口。nvue 组件方法的同步返回值可能无法稳定传回页面,页面应优先根据 statechange 的 PREVIEWING 和 CLOSED 维护状态。
setAudioSource(source) source: string 设置录音来源:device 使用 UVC 自带麦克风,仅用于本地 MP4 录像;system 使用手机麦克风,同时用于本地录像与 RTMP AAC 音频。必须在关闭预览后调用。
setResolution(width, height) width: number、height: number 切换预览分辨率。仅可传入 getSupportedResolutions() 返回的分辨率,录制中不可调用。
startRtmp(url, bitrate, fps) url: string、bitrate: number、fps: number 开始 H.264 RTMP 视频推流。码率单位为 bps,帧率范围为 1-60;后两项省略时默认使用 2000000 bps、30 fps。
stopRtmp() 无 停止 RTMP 推流并释放编码器。
setRtmpBitrate(bitrate) bitrate: number 推流中动态更新 H.264 码率,单位为 bps。
setRtmpFps(fps) fps: number 推流中动态限制送入编码器的帧率,范围为 1-60。
takePhoto(outputPath) outputPath: string 保存当前预览画面为 JPEG。传入空字符串时保存到应用图片目录。
startRecording(outputPath) outputPath: string 开始 MP4 录制,使用 setAudioSource 选定的音频来源。传入空字符串时保存到应用视频目录。
stopRecording() 无 停止录制,成功后通过 recordchange 返回文件路径。
release() 无 释放组件资源。组件卸载时会自动调用。

事件

所有事件负载均为 JSON 字符串。

事件 关键字段 说明
statechange state、operation 相机状态。常用状态:USB_PERMISSION_GRANTED、PREVIEWING、RESOLUTION_CHANGING、RESOLUTION_CHANGED、CLOSED。
devicechange action、device USB 设备接入或拔出,action 为 attached 或 detached。
deviceschange devices getDevices() 的异步设备列表结果,适用于 nvue 无法稳定获取同步返回值的场景。
resolutionschange resolutions 支持分辨率数组,元素为 { width, height }。
photo path、size 照片保存成功。
recordchange state、path、size、reason 录制状态:started、stopped 或 aborted。
streamchange state、bitrate、fps 推流状态:connecting、started、authenticated、stopped、bitrateChanged 或 fpsChanged。
error code、message、operation 操作失败原因。

错误码

错误码 说明
USB_DEVICE_NOT_FOUND 未找到目标 USB 设备。
USB_DEVICE_NOT_UVC 目标设备不是 UVC 摄像头。
USB_PERMISSION_REQUIRED 尚未获取 USB 授权。
USB_PERMISSION_DENIED USB 授权被拒绝或授权请求失败。
USB_DEVICE_DISCONNECTED UVC 摄像头已拔出。
UVC_OPEN_FAILED 打开或恢复 UVC 预览失败。
RESOLUTION_INVALID 分辨率参数无效。
RESOLUTION_UNSUPPORTED 设备不支持该分辨率。
CAMERA_NOT_OPEN 摄像头尚未打开。
CAMERA_BUSY 录制或分辨率切换期间不允许当前操作。
AUDIO_SOURCE_INVALID 音频来源不是 device 或 system。
RTMP_URL_INVALID 推流地址不是 RTMP/RTMPS 地址。
RTMP_ALREADY_STARTED 已有推流任务正在运行或启动。
RTMP_NOT_STARTED 当前没有推流任务。
RTMP_CONFIG_INVALID 推流码率或帧率参数无效。
RTMP_CONFIG_FAILED 推流中动态更新配置失败。
RTMP_ENCODER_FAILED H.264 编码器初始化或运行失败。
RTMP_CONNECTION_FAILED RTMP 连接、重连或发送失败。
RTMP_AUTH_FAILED RTMP 鉴权失败。
RTMP_AUDIO_FAILED 手机麦克风采集或 AAC 编码失败,推流降级为纯视频。
PHOTO_CAPTURE_FAILED 拍照或保存照片失败。
RECORD_NOT_STARTED 当前未录制。
RECORD_ALREADY_STARTED 已有录制任务进行中。
RECORD_WRITE_FAILED 录制文件写入或封装失败。
INTERNAL_ERROR 原生内部错误。

分辨率切换说明

切换分辨率后,等待 RESOLUTION_CHANGED 或 PREVIEWING 状态再执行拍照和录像。部分 UVC 设备在切换后画面已经更新,但原生预览状态不会自动恢复;此时再次调用一次 openCamera(deviceId, width, height) 即可恢复会话。

录制验证

  1. 关闭预览后选择录音来源。
  2. 点击“申请授权”,手机麦克风模式必须授权录音权限。
  3. 打开预览,开始录像并录制至少 3 秒。
  4. 停止录像后播放生成的 MP4,确认视频画面、音轨和音画同步正常。

后续规划

  • RTMP 推流:已接入 RootEncoder 的 H.264/AAC 推流主链路,支持 startRtmp(url, bitrate, fps)、stopRtmp()、推流中动态调整码率和限帧,以及最多 3 次自动重连。选择 system 音源时,RTMP 会推送 48kHz 单声道 AAC-LC(64kbps);手机麦克风或 AAC 编码失败会降级为纯视频推流并触发 RTMP_AUDIO_FAILED。分辨率切换必须先停止推流,待预览恢复后重新启动。

限制

  • 代码按 AndroidUSBCamera 3.3.3 的 API 实现,并在 utssdk/app-android/libs 内附带从源码构建的 libausbc、libuvc、libnative AAR,支持 armeabi-v7a、arm64-v8a。
  • 默认使用 UVC 设备自带的 UAC 麦克风;设备没有 UAC 音频接口时该模式录制无声视频,可在关闭预览后切换为手机麦克风。
  • 摄像头拔出时组件会停止预览和录制并释放资源,重插后须重新请求授权并打开。

隐私、权限声明

1. 本插件需要申请的系统权限列表:

android.permission.INTERNET:RTMP 推流所需。 android.permission.CAMERA:打开 UVC 摄像头预览所需。 android.permission.RECORD_AUDIO:选择“手机麦克风”录制时所需。 USB Host:通过 <uses-feature android:name="android.hardware.usb.host" /> 声明,用于连接 OTG/UVC 设备。 USB 设备授权:运行时调用 requestUsbPermission(deviceId) 请求指定 UVC 设备访问权限。 android.permission.WRITE_EXTERNAL_STORAGE:仅 Android 9(API 28)及以下保存媒体文件时使用。

2. 本插件采集的数据、发送的服务器地址、以及数据用途说明:

无

3. 本插件是否包含广告,如包含需详细说明广告表达方式、展示频率:

无