更新记录

1.0.0(2026-07-29)

  • 新版发布

平台兼容性

uni-app(5.06)

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

uni-app x(5.06)

Chrome Safari Android Android插件版本 iOS iOS插件版本 鸿蒙 鸿蒙插件版本 微信小程序
× × 1.0.0 1.0.0 1.0.0 ×

yt-native-vlc 使用文档

特别提醒

  • 购买本插件前,请先试用,请先试用,请先试用,确认满足需求之后再行购买。虚拟物品一旦购买之后无法退款。
  • 如有使用上的疑问、bug,可以进交流群联系作者;
  • 请在合法范围内使用,若使用本插件做非法开发,本方概不负责;
  • 插件需先引入再打自定义基座后运行测试
  • 本插件为标准组件只能用于uniapp-x项目的.uvue页面,如uniapp项目需要可使用uniapp项目可用
  • 可先下载Android端demo体验插件功能体验包下载
  • 可下载插件提供的示例项目测试、试用。
  • iOS 播放 RTSP 流首帧很慢:从开始播放到画面出现,常见需要约 30~40 秒(与 MobileVLCKit / 网络 / 设备有关)。开播前可按文档配置低延迟参数(如 --rtsp-tcp、适当 caching、需要时 --no-audio),但无法保证缩短到秒级;若业务强依赖秒开,请先试用确认是否可接受,再决定是否购买。

1. 插件是做什么的?

把手机上的原生播放器嵌进 uni-app x 页面(.uvue),用来播视频、截图、录制等。

你想做的事 能不能做
播放网络 MP4 / HLS(m3u8)
播放 RTSP / RTMP 监控流 ✅(视网络与设备)
播放本地文件路径
暂停 / 继续 / 停止
拖进度、快进快退(点播)
倍速 1x / 2x / 4x / 8x / 16x(点播)
调节音量 0~100
画面比例 auto / 4:3 / 16:9
截图保存到本地
录制并拿到文件路径
横屏全屏播放 ✅(页面实现,见 6.8 横屏全屏

只支持 uni-app x(页面用 .uvue)。
不支持 普通 uni-app 的 .vue / .nvue 工程。

页面里统一写标签:

<yt-native-vlc></yt-native-vlc>

三端(Android / iOS / 鸿蒙)写法一样,底层引擎不同:

平台 底层引擎 说明
Android LibVLC 4.x native-view + 原生 View
iOS MobileVLCKit 3.6.x native-view + 原生 View
鸿蒙 ijkplayer(HAR @yt/vlc-player native-view + Harmony Builder

2. 使用前必读(很重要)

2.1 需要「自定义调试基座」

本插件含 原生代码(Kotlin / Swift / HAR)。
用 HBuilderX「运行到手机」时,标准基座跑不了,纯血鸿蒙可以直接运行到鸿蒙,必须:

  1. 菜单:运行 → 运行到手机或模拟器 → 制作自定义调试基座
  2. 勾选 Android / iOS / 鸿蒙(按你要测的端)
  3. 制作完成后,再用自定义基座运行项目

2.2 环境要求

  • HBuilderX:5.0+(建议用较新正式版)
  • 工程类型:uni-app x
  • Android:minSdk 建议 ≥ 24(见插件 utssdk/app-android/config.json
  • iOS:依赖 CocoaPods MobileVLCKit,包体积较大,云打包时间较长
  • 鸿蒙:确认插件内存在 utssdk/app-harmony 相关 HAR / 依赖配置

2.3 插件放哪里?

把整个文件夹放到项目:

你的项目/
  └─ uni_modules/
       └─ yt-native-vlc/     ← 本插件

easycom 会自动识别,页面不用手动 import 组件


3. 五分钟上手

3.1 页面模板

给播放器一个明确宽高(很重要,否则可能看不到画面):

<template>
  <view class="page">
    <yt-native-vlc
      ref="vlc"
      class="player"
      @ready="onReady"
      @opening="onOpening"
      @buffering="onBuffering"
      @playing=""
      @paused="onPaused"
      @stopped="onStopped"
      @ended=""
      @error="onError"
      @timeupdate=""
      @seekablechange="ableChange"
      @snapshot="onSnapshot"
      @recordchange="onRecordChange"
    ></yt-native-vlc>

    <button @click="onPlay">播放示例</button>
    <button @click="">播放/暂停</button>
    <button @click="onSnap">截图</button>
  </view>
</template>

<style>
  .player {
    width: 100%;
    height: 220px;
    background-color: #333333;
  }
</style>

3.2 脚本(选项式 + UTS)

export default {
  data() {
    return {
      seekable: false,
      playTimeText: '00:00 / 00:00',
      sampleMp4: 'https://vd3.bdstatic.com/mda-jggr72s70c52d06f/sc/mda-jggr72s70c52d06f.mp4',
    }
  },
  methods: {
    /** 拿到组件实例(easycom 用官方生成的类型名) */
    player() : YtNativeVlcComponentPublicInstance | null {
      return this.$refs['vlc'] as YtNativeVlcComponentPublicInstance | null
    },

    onReady() {
      // 引擎就绪后再 play 更稳妥
      this.player()?.resetPlayerConfig()
    },

    onPlay() {
      this.player()?.play(this.sampleMp4)
    },

    () {
      this.player()?.togglePlayPause()
    },

    onSnap() {
      this.player()?.makeSnapshot()
    },

    onOpening() {},
    onBuffering(e : any) {
      // e 可能是 Map,字段 percent
    },
    () {},
    onPaused() {},
    onStopped() {},
    () {},
    onError(e : any) {
      uni.showToast({ title: '播放出错', icon: 'none' })
    },
    ableChange(e : any) {
      // 点播一般 seekable=1,直播多为 0
    },
    (e : any) {
      // currentMs / totalMs / position
    },
    onSnapshot(e : any) {
      // success=1 时 filePath 为截图路径
    },
    onRecordChange(e : any) {
      // recording=1 录制中;=0 已停止,可能带 filePath
    },
  }
}

3.3 调用方法时注意

// ✅ 正确:用组件生成的 PublicInstance
const p = this.$refs['vlc'] as YtNativeVlcComponentPublicInstance | null
p?.play(url)

// ❌ 错误:不要自己写一个 type 再强转,Android 会 ClassCastException

海康安防取流说明

本插件播放的是 通用 RTSP / http(s) 等地址字符串,基于 VLC不内置海康视频 SDK。业务侧需先拿到可播 URL,再 play(url)

重要:可能播不了海康综合安防流

以下情况用本插件(VLC)可能无法出画或极不稳定,属引擎 / 取流协议差异,不是业务页少调了一个 API:

  • 综合安防平台签发的临时流(如 openUrl、部分加密 / 私有封装)与 MobileVLCKit / LibVLC 不兼容
  • 取流规则、鉴权、协议类型与平台要求不符(例如必须走海康 SDK 会话,而不是裸 RTSP URL)
  • 设备直连 RTSP 在部分机型 / 网络下仍失败(UDP、防火墙、密码编码、子码流等)

若你的场景是 海康威视综合安防平台(预览、回放、对讲、云台等),请改用专用插件:

  • 插件名:海康威视综合安防平台视频播放插件yt-hikvideo
  • 市场地址:https://ext.dcloud.net.cn/plugin?id=26041
  • 基于海康视频 SDK,支持 uni-app / uni-app x;能力含实时预览、录像回放、语音对讲、鱼眼矫正、云台控制等

本插件更适合:标准 RTSP / HLS / 点播文件 等通用媒体地址;海康平台深度能力请用上面的 yt-hikvideo

1. 设备 / NVR 直连 RTSP(局域网,可尝试本插件)

格式:

rtsp://用户名:密码@设备IP:554/Streaming/Channels/{通道编码}
通道编码 含义 建议
101 通道 1 主码流 画质高、带宽大,预览偏卡时可改用子码流
102 通道 1 子码流 预览推荐,码率低、开播更快
201 / 202 通道 2 主 / 子码流 多路同理:通道号×100 + 1/2

示例:

rtsp://admin:password@192.168.1.64:554/Streaming/Channels/102

注意:

  • 账号密码、IP、端口以设备实际配置为准(默认 RTSP 端口多为 554)。
  • 密码含特殊字符时需做 URL 编码(如 @%40)。
  • 手机与摄像头须在同一可达网络;iOS 需本地网络权限说明(见上文 Info.plist)。
  • 务必使用 TCPplaysetPlayerConfig 包含 --rtsp-tcp(默认配置已含);海康等设备常不支持 UDP,否则可能空等数十秒才出画。
  • 纯预览可加 --no-audio,一般开播更快(无声音)。
  • 直连仍失败时,优先排查网络 / TCP / 子码流;若实际接入的是综合安防平台,请改用 yt-hikvideo

2. 海康综合安防 / 视频平台临时流(openUrl

平台通过开放接口(如「获取预览取流 URL」)返回的临时地址,形态多为:

rtsp://平台域名或IP:端口/openUrl/xxxxxxxx

示例形态:

rtsp://xxx.xxx.xxx.xxx:554/openUrl/vsigxxxxxxxxxxxx

注意:

  • URL 由 平台 API 签发,带有效期;过期后需重新取流再 play
  • 不要手写拼接 openUrl;按海康综合安防 / 视频平台开放文档,用相机编号、协议类型等参数请求预览地址。
  • 拿到地址后同样建议 --rtsp-tcp;Android 端对含 /openUrl/ 的地址会额外加大缓存(约 2s),便于跨网临时流更稳。
  • 若平台还返回 HLS(.m3u8)等,也可直接 play 该 http(s) 地址(非 RTSP 时按点播/直播能力使用即可)。
  • openUrl / 平台流用 VLC 播失败或黑屏时:不要继续在本插件上硬调参数,请改用专用插件 海康威视综合安防平台视频播放插件(id=26041)

4. 功能清单(方法)

通过 ref 调用,例如:this.player()?.play(url)

4.1 播放控制

方法 说明
play(url) 播放网络或本地地址
pause() 暂停
resume() 从暂停恢复
stop() 停止并清空当前媒体
togglePlayPause() 播放 ↔ 暂停

4.2 进度与倍速(点播)

直播(seekable=0)时拖进度 / 倍速 / 快进快退通常无效或应禁用。

方法 说明
seekTo(position) position0~1(0 开头,1 结尾)
seekBySeconds(seconds) 正数快进、负数快退,单位秒
fastForward(seconds) 快进,秒数 > 0
rewind(seconds) 快退,秒数 > 0
setSpeed(rate) 倍速:建议 1 / 2 / 4 / 8 / 16

4.3 音量与画面

方法 说明
setVolume(volume) 音量 0~100,0=静音
getVolume() 读当前音量(鸿蒙可能为占位值)
setAspect(mode) 'auto' 还原、'4:3''16:9'

4.4 截图与录制

方法 说明
makeSnapshot() 截图(不要takeSnapshot
startRecording() 开始录制,返回是否发起成功
stopRecording() 停止录制
toggleRecording() 未录制则开始,录制中则停止

结果请听事件:

  • 截图 → @snapshot
  • 录制 → @recordchange

4.5 状态查询

方法 说明
isPlaying() 是否在播
isRecording() 是否在录
isSeekable() 是否可拖进度
getLength() 总时长毫秒(鸿蒙建议用事件里的 totalMs)
getTime() 当前时间毫秒
getPosition() 进度 0~1
formatTime(ms) 毫秒转 mm:ss 字符串
queryState() 异步查状态,结果走 @state

4.6 播放器参数(进阶)

方法 说明
setPlayerConfig(options) 传入字符串数组,如 RTSP 参数;null 表示恢复默认
resetPlayerConfig() 恢复默认参数

RTSP 示例(Android / iOS 更完整;鸿蒙以实现为准):

this.player()?.setPlayerConfig([
  '--rtsp-tcp',
  '--network-caching=300',
  '--live-caching=300',
])
this.player()?.play('rtsp://user:pass@192.168.1.100:554/Streaming/Channels/101')

建议在 @ready 之后、play 之前设置。


5. 事件清单

页面用 @事件名="处理函数" 监听。

带数据的事件,回调参数里一般是 Map(或包一层后需自己取出 Map)。
布尔类字段一律用数字 0 / 1,不要当成 JS boolean 乱比。

事件 何时触发 载荷(Map 字段)
ready 引擎就绪,可以 play
opening 正在打开媒体
buffering 缓冲中(含快进后缓冲) percent:0~100
playing 正在播放
paused 已暂停
stopped 已停止
ended 点播播完
error 出错 message:错误信息
timeupdate 进度刷新(点播) currentMstotalMsposition(0~1)
seekablechange 可否拖进度变化 seekable1 点播,0 直播
snapshot 截图完成 success:0|1,filePath:本地路径
recordchange 录制状态变化 recording:0|1,filePath:停录后可能有
state queryState() 的结果 playing/recording/seekable/volume/currentMs/totalMs/position

5.1 读 Map 的小例子

readNum(data : Map<string, any>, key : string, def : number = 0) : number {
  if (!data.has(key)) return def
  return parseFloat(`${data.get(key)}`)
},
readFlag(data : Map<string, any>, key : string) : boolean {
  return this.readNum(data, key, 0) == 1
},
onSnapshot(e : any) {
  const data = e as Map<string, any>   // 若实际外层还有 detail,请先取出 Map
  if (this.readFlag(data, 'success')) {
    const path = `${data.get('filePath')}`
    uni.showToast({ title: '截图成功', icon: 'none' })
  } else {
    uni.showToast({ title: '截图失败', icon: 'none' })
  }
}

项目里 pages/index/index.uvue 有完整演示(进度条、音量、快进快退、录制回放等),可直接对照。


6. 常见场景怎么写?

6.1 播一个 MP4

onReady() {
  this.player()?.resetPlayerConfig()
},
playMp4() {
  this.player()?.play('https://example.com/demo.mp4')
}

6.2 进度条 + 时间文字

  1. @seekablechangeseekable==1 才允许拖动
  2. @timeupdate 更新滑块和时间
  3. 用户拖动结束调用 seekTo(value / 1000)(若滑块是 0~1000)

6.3 快进 10 秒 / 快退 10 秒

this.player()?.seekBySeconds(10)   // 快进
this.player()?.seekBySeconds(-10)  // 快退
// 或
this.player()?.fastForward(10)
this.player()?.rewind(10)

快进后可能先收到 @buffering(0%→100%),再 @playing

6.4 音量

this.player()?.setVolume(50)  // 一半音量
this.player()?.setVolume(0)   // 静音

6.5 画面 4:3 / 16:9

this.player()?.setAspect('4:3')
this.player()?.setAspect('16:9')
this.player()?.setAspect('auto')  // 还原

6.6 截图

// 点击
this.player()?.makeSnapshot()

// @snapshot
onSnapshot(e) {
  // success==1 时用 filePath 显示图片
}

6.7 录制

// 开始
this.player()?.startRecording()
// @recordchange → recording==1,按钮改成「停止录制」

// 停止
this.player()?.stopRecording()
// @recordchange → recording==0,filePath 可能是 mp4 路径,可再 play(path) 回放

不要只靠 isRecording() 判断(个别端可能有延迟),@recordchange 为准更稳。

6.8 横屏全屏播放怎么设置

重要: 插件没有 requestFullscreen() 之类的方法。全屏是页面层能力:隐藏导航 + 播放器铺满 + 切横屏。完整示例见文末「完整演示页源码」。

步骤一:pages.json 配置播放页

播放页必须关掉原生导航栏,并声明默认竖屏(便于退出全屏后恢复):

{
  "path": "pages/index/index",
  "style": {
    "navigationBarTitleText": "播放器",
    "navigationStyle": "custom",
    "pageOrientation": "portrait"
  }
}
配置项 作用
navigationStyle: "custom" 去掉原生导航栏;全屏时用自定义导航 v-if 隐藏,画面才能顶到边缘
pageOrientation: "portrait" 平时竖屏;进入全屏再用代码切成 landscape

步骤二:页面布局(伪全屏)

  1. 用自定义导航栏(含 height: var(--status-bar-height)),v-if="!isFullscreen" 控制显隐
  2. 播放器外层 view:非全屏固定高度(如 220px);全屏时 position: absolute 铺满
  3. 不要v-if 销毁 <yt-native-vlc>,否则会断流重启
  4. 全屏时隐藏进度条、列表等控件;可留一个「退出全屏」悬浮按钮

步骤三:代码切换横竖屏

通过当前页的 setPageStyle 动态改 pageOrientation

/** 动态设置页面方向:portrait | landscape */
applyPageOrientation(orientation : string) {
  const pages = getCurrentPages()
  if (pages.length == 0) return
  const page = pages[pages.length - 1]
  const style = {
    pageOrientation: orientation,
  } as UTSJSONObject
  page.setPageStyle(style)
},

/** 进入横屏全屏 */
enterFullscreen() {
  if (this.isFullscreen) return
  this.isFullscreen = true
  this.applyPageOrientation('landscape')  // 切横屏
},

/** 退出全屏并恢复竖屏 */
exitFullscreen() {
  if (!this.isFullscreen) return
  this.isFullscreen = false
  this.applyPageOrientation('portrait')   // 恢复竖屏
},

建议同时处理:

  • Android 返回键onBackPress 里若正在全屏,先 exitFullscreen()return true
  • 离开页面onUnload 里若仍全屏,恢复 portrait,避免影响其它页

常见踩坑

现象 原因 / 处理
全屏仍是竖屏,上下大黑边 只放大了容器,没调 setPageStyle({ pageOrientation: 'landscape' })
全屏后导航栏还在 未设 navigationStyle: "custom",或自定义导航未在全屏时隐藏
点全屏后黑屏 / 重播 v-if 重建了播放器;应只改 class / 布局,保持组件挂载
退出后其它页也是横屏 退出或 onUnload 时未改回 portrait

官方说明:pages.json pageOrientationgetCurrentPages / setPageStyle


7. 点播 vs 直播(新手最容易懵)

点播(如 MP4) 直播(如 RTSP)
seekable 多为 1 多为 0
进度条 / 快进 / 倍速 可用 应禁用或无效
timeupdate 有 current/total 可能无总时长
录制 / 截图 一般可用 一般可用(视设备)

UI 上请根据 @seekablechange 控制按钮的 disabled


8. 目录结构(了解即可)

yt-native-vlc/
├─ components/yt-native-vlc/yt-native-vlc.uvue   # 页面用的组件壳
├─ utssdk/
│  ├─ interface.uts          # 跨端类型声明(给 uvue 看的)
│  ├─ app-android/           # Android:index.uts + Kotlin
│  ├─ app-ios/               # iOS:index.uts + Swift
│  └─ app-harmony/           # 鸿蒙:index.uts + HAR
├─ package.json
└─ readme.md                 # 本说明

9. 常见问题 FAQ

Q1:运行到手机白屏 / 报找不到原生类?

没有用自定义调试基座,或改完原生没重新制作基座。

Q2:调用 play 没画面?

  1. 是否等 @ready 之后再 play
  2. 组件是否设置了宽高(如 height: 220px
  3. URL 是否可访问(手机网络、HTTPS 证书、RTSP 账号)

Q3:进度条不动、时间一直 00:00?

  1. 是否点播?直播通常没有可拖进度
  2. 是否收到 @timeupdate@seekablechange
  3. iOS 若日志出现「回调函数已释放」,需确认插件里 onEvent 已加 keepAlive,并重做基座

Q4:截图提示失败,但日志说成功?

@snapshotsuccess 是否为数字 1(不要用严格等于 true)。

Q5:录制按钮点「停止」却又开始录?

@recordchange 维护页面上的 recording 状态,不要只信同步的 isRecording()

Q6:Android 强转 ref 崩溃 ClassCastException?

必须使用:

this.$refs['vlc'] as YtNativeVlcComponentPublicInstance

不要自定义一个接口类型再 as

Q7:鸿蒙和 Android 行为不完全一样?

正常。鸿蒙是 ijk + HAR,部分同步查询(时长/音量)可能是占位,以事件回调为准

Q8:改了插件代码,运行还是旧行为?

原生相关改动 → 重新制作自定义基座;仅改 .uvue 页面一般重新运行即可。

Q9:怎么做横屏全屏?插件有全屏方法吗?

没有。 全屏在业务页实现,不要等插件 API。最少三步:

  1. pages.jsonnavigationStyle: "custom" + pageOrientation: "portrait"
  2. 全屏时隐藏导航/控件,播放器容器铺满(组件保持挂载)
  3. page.setPageStyle({ pageOrientation: 'landscape' }) 切横屏;退出改回 portrait

详见 6.8 横屏全屏播放怎么设置 与文末完整演示页。


10. 推荐学习路径

  1. 把插件放进 uni_modules
  2. 制作自定义基座并运行
  3. 复制本文「五分钟上手」能播 MP4
  4. 对照 pages/index/index.uvue 演示页加进度条、音量、截图、录制、横屏全屏
  5. 再试 RTSP(先 setPlayerConfigplay

11. 参考链接


如有问题,请带上:平台(Android/iOS/鸿蒙)+ HBuilderX 版本 + 完整报错日志/关键截图,便于排查。


12. 更多好用插件推荐


13. 完整演示页源码(可直接复制)

下面是项目 pages/index/index.uvue完整源码(含详细注释),覆盖播放、进度、倍速、音量、比例、快进快退、截图、录制与回放、横屏全屏

使用方法:

  1. yt-native-vlc 放进项目 uni_modules/
  2. 新建页面 pages/index/index.uvue,整份粘贴下文
  3. pages.json 里按下面注册该页面(全屏必须配置 navigationStyle / pageOrientation
  4. 制作自定义调试基座后运行

pages.json 播放页配置示例:

{
  "path": "pages/index/index",
  "style": {
    "navigationBarTitleText": "yt-native-vlc",
    "navigationStyle": "custom",
    "pageOrientation": "portrait"
  }
}

请把示例里的 sampleRtsp 改成你自己的摄像头地址。横屏全屏说明见 6.8

<!--
  =============================================================================
  yt-native-vlc 完整演示页(pages/index/index.uvue)
  =============================================================================
  本页展示标准组件 <yt-native-vlc> 的常用能力,可直接对照学习:
  - 播放 MP4 / RTSP、暂停继续、停止
  - 进度条拖动、快进快退、倍速、音量、画面比例
  - 截图预览、录制与回放
  - 横屏全屏:setPageStyle(pageOrientation=landscape) + 播放器铺满(不销毁组件)

  pages.json 需配置 navigationStyle: "custom"、pageOrientation: "portrait"。
  使用前请先「制作自定义调试基座」,再用自定义基座运行。
  =============================================================================
-->
<template>
    <view class="root" :class="{ 'root-fullscreen': isFullscreen }">
        <!-- 自定义导航:全屏时隐藏(原生导航已在 pages.json 关掉) -->
        <view v-if="!isFullscreen" class="nav-bar">
            <view class="status-pad"></view>
            <view class="nav-content">
                <text class="nav-title">yt-native-vlc 标准组件演示</text>
            </view>
        </view>

        <!--
            播放器区域:非全屏固定高度;横屏全屏后 absolute 铺满宽屏。
            切勿用 v-if 销毁 yt-native-vlc,否则会断流重启。
        -->
        <view class="player-wrap" :class="{ 'player-wrap-full': isFullscreen }">
            <yt-native-vlc ref="vlcVideo" class="player" :class="{ 'player-full': isFullscreen }" @ready="onReady"
                @opening="onOpening" @buffering="onBuffering" @playing="" @paused="onPaused"
                @stopped="onStopped" @ended="" @error="onError" @timeupdate=""
                @seekablechange="ableChange" @snapshot="onSnapshot"
                @recordchange="onRecordChange"></yt-native-vlc>

            <!-- 全屏时悬浮「退出全屏」;非全屏用面板里的全屏按钮 -->
            <button v-if="isFullscreen" class="fs-exit-btn" size="mini" type="primary"
                @click="exitFullscreen">退出全屏</button>
        </view>

        <!-- 控制面板:全屏时隐藏 -->
        <view v-if="!isFullscreen" class="panel">
            <!-- 进度条:0~1000 对应 position 0~1;直播 seekable=false 时禁用 -->
            <slider class="seek-bar" :value="progress" :min="0" :max="1000" :disabled="!seekable" activeColor="#4CAF50"
                backgroundColor="#666666" :block-size="18" @changing="Changing" @change="Change" />

            <view class="row">
                <text class="time-text">{{ playTimeText }}</text>
                <button class="btn-sm" type="primary" size="mini" @click="Play">{{ playPauseLabel }}</button>
                <button class="btn-sm" size="mini" @click="onSnapshotClick">截图</button>
                <button class="btn-sm" size="mini" @click="onRecordClick">{{ recordLabel }}</button>
                <button class="btn-sm" size="mini" @click="toggleFullscreen">全屏</button>
            </view>

            <!-- 倍速仅点播;比例三端均可用 -->
            <view class="row">
                <button class="btn-xs" size="mini" :disabled="!seekable" @click="onSpeed(1)">1x</button>
                <button class="btn-xs" size="mini" :disabled="!seekable" @click="onSpeed(2)">2x</button>
                <button class="btn-xs" size="mini" :disabled="!seekable" @click="onSpeed(4)">4x</button>
                <button class="btn-xs" size="mini" @click="onAspect('auto')">还原</button>
                <button class="btn-xs" size="mini" @click="onAspect('4:3')">4:3</button>
                <button class="btn-xs" size="mini" @click="onAspect('16:9')">16:9</button>
            </view>

            <!-- 快进快退:内部 seekBySeconds;直播应禁用 -->
            <view class="row">
                <button class="btn-xs" size="mini" :disabled="!seekable" @click="BySeconds(-10)">快退10s</button>
                <button class="btn-xs" size="mini" :disabled="!seekable" @click="BySeconds(-5)">快退5s</button>
                <button class="btn-xs" size="mini" :disabled="!seekable" @click="BySeconds(5)">快进5s</button>
                <button class="btn-xs" size="mini" :disabled="!seekable" @click="BySeconds(10)">快进10s</button>
            </view>

            <!-- 音量 0~100;iOS 建议在 @changing 里也 setVolume -->
            <view class="row vol-row">
                <text class="vol-label">音量 {{ volume }}</text>
                <slider class="vol-bar" :value="volume" :min="0" :max="100" activeColor="#4CAF50"
                    backgroundColor="#666666" :block-size="16" @changing="onVolumeChanging" @change="" />
                <button class="btn-xs" size="mini" @click="onVolume(0)">静音</button>
                <button class="btn-xs" size="mini" @click="onVolume(100)">最大</button>
            </view>
        </view>

        <scroll-view v-if="!isFullscreen" class="scroll" direction="vertical">
            <text class="status">状态:{{ statusText }}</text>

            <!-- 录制完成后展示路径,可一键回放本地文件 -->
            <view v-if="recordPath.length > 0" class="record-box">
                <text class="record-title">最近录制文件</text>
                <text class="record-path">{{ recordPath }}</text>
                <button class="btn-block record-play" type="primary" @click="playRecordedVideo">播放录制视频</button>
            </view>

            <!-- 截图成功后预览(src 用本地绝对路径,不要带 file://) -->
            <image v-show="showSnapshot" class="snapshot" :src="snapshotSrc" mode="aspectFit"></image>

            <input class="url-input" v-model="inputUrl" placeholder="输入播放地址" />
            <view class="row">
                <button class="btn-flex" type="primary" @click="onPlayUrl">播放</button>
                <button class="btn-flex" @click="onStop">停止</button>
            </view>
            <button class="btn-block" @click="playSampleMp4">示例 MP4</button>
            <button class="btn-block" @click="playSampleRtsp">示例 RTSP</button>
        </scroll-view>
    </view>
</template>

<script>
    /**
     * 演示页逻辑说明:
     * 1. player() 获取组件实例(必须 as YtNativeVlcComponentPublicInstance)
     * 2. eventMap / readNum / readFlag 统一解析事件 Map(布尔用 0|1)
     * 3. @seekablechange 控制进度条、倍速、快进是否可点
     * 4. @recordchange 维护 recording / recordLabel / recordPath
     * 5. isFullscreen:横屏全屏(pageOrientation + 布局),不销毁播放器
     */
    export default {
        data() {
            return {
                /** 是否横屏全屏(隐藏导航与控件,强制 landscape,播放器铺满) */
                isFullscreen: false,
                /** 顶部状态文案:缓冲中 / 播放中 / 错误等 */
                statusText: '就绪',
                /** 进度旁时间:mm:ss / mm:ss */
                playTimeText: '00:00 / 00:00',
                /** 播放/暂停按钮文案 */
                playPauseLabel: '播放',
                /** 录制按钮文案:录制 ↔ 停止录制 */
                recordLabel: '录制',
                /** 是否正在录制(以 @recordchange 为准,比 isRecording() 更稳) */
                recording: false,
                /** 最近一次录制文件本地路径 */
                recordPath: '',
                /** 进度条 0~1000,对应 position * 1000 */
                progress: 0,
                /** 音量 0~100 */
                volume: 100,
                /** 是否可拖进度(点播 true,直播 false) */
                seekable: false,
                /** 用户正在拖进度条时为 true,避免 timeupdate 抢进度 */
                userSeeking: false,
                /** 输入框里的播放地址 */
                inputUrl: '',
                /** 截图预览路径 */
                snapshotSrc: '',
                showSnapshot: false,
                /** 示例点播地址(可换成自己的) */
                sampleMp4: 'https://vd3.bdstatic.com/mda-jggr72s70c52d06f/sc/mda-jggr72s70c52d06f.mp4',
                /** 示例 RTSP(请改成你局域网摄像头地址) */
                sampleRtsp: 'rtsp://admin:lyzh2019@192.168.8.135:554/Streaming/Channels/102',
            }
        },
        onBackPress() : boolean | null {
            // Android 返回键:先退出横屏全屏,再退出页面
            if (this.isFullscreen) {
                this.exitFullscreen()
                return true
            }
            return null
        },
        onUnload() {
            // 离开页面时恢复竖屏,避免影响其它页
            if (this.isFullscreen) {
                this.applyPageOrientation('portrait')
                this.isFullscreen = false
            }
        },
        methods: {
            /**
             * 获取 yt-native-vlc 组件实例。
             * 注意:不要自定义 type 再强转,Android 会 ClassCastException。
             */
            player() : YtNativeVlcComponentPublicInstance | null {
                return this.$refs['vlcVideo'] as YtNativeVlcComponentPublicInstance | null
            },

            /**
             * 动态设置页面方向(uni-app x:page.setPageStyle)。
             * @param orientation portrait | landscape
             */
            applyPageOrientation(orientation : string) {
                const pages = getCurrentPages()
                if (pages.length == 0) return
                const page = pages[pages.length - 1]
                const style = {
                    pageOrientation: orientation,
                } as UTSJSONObject
                page.setPageStyle(style)
            },

            /** 进入横屏全屏(不销毁播放器) */
            enterFullscreen() {
                if (this.isFullscreen) return
                this.isFullscreen = true
                this.applyPageOrientation('landscape')
            },

            /** 退出全屏并恢复竖屏 */
            exitFullscreen() {
                if (!this.isFullscreen) return
                this.isFullscreen = false
                this.applyPageOrientation('portrait')
            },

            /** 切换横屏全屏 */
            toggleFullscreen() {
                if (this.isFullscreen) {
                    this.exitFullscreen()
                } else {
                    this.enterFullscreen()
                }
            },

            setStatus(t : string) {
                this.statusText = t
            },

            toast(msg : string) {
                uni.showToast({ title: msg, icon: 'none' })
            },

            /**
             * 把事件参数统一成 Map。
             * 不同端可能直接给 Map,或包在 detail 里,或是 UTSJSONObject。
             */
            eventMap(e : any | null) : Map<string, any> {
                if (e == null) return new Map<string, any>()
                if (e instanceof Map) {
                    const m = e as Map<string, any>
                    if (m.has('detail')) return this.eventMap(m.get('detail'))
                    return m
                }
                const obj = e as UTSJSONObject
                if (obj['detail'] != null) return this.eventMap(obj['detail'])
                const out = new Map<string, any>()
                const keys = ['percent', 'message', 'currentMs', 'totalMs', 'position', 'seekable', 'success', 'filePath', 'recording']
                for (let i = 0; i < keys.length; i++) {
                    const k = keys[i]
                    const v = obj[k]
                    if (v != null) {
                        out.set(k, v)
                    }
                }
                return out
            },

            /** 读数字字段 */
            readNum(data : Map<string, any>, key : string, def : number = 0) : number {
                if (!data.has(key)) return def
                return parseFloat(`${data.get(key)}`)
            },

            /** 读字符串字段 */
            readStr(data : Map<string, any>, key : string) : string {
                if (!data.has(key)) return ''
                return `${data.get(key)}`
            },

            /**
             * 读 0|1 标志位(也兼容字符串 "true")。
             * 插件约定:seekable / success / recording 用数字 0、1。
             */
            readFlag(data : Map<string, any>, key : string) : boolean {
                return this.readNum(data, key, 0) == 1 || this.readStr(data, key) == 'true'
            },

            /** 根据 isPlaying() 刷新按钮文案(部分端会延迟,事件里也会直接改文案) */
            updatePlayPauseLabel() {
                const p = this.player()
                this.playPauseLabel = (p != null && p.isPlaying()) ? '暂停' : '播放'
            },

            /** 点播 / 普通 URL:恢复默认引擎参数 */
            applyDefaultPlayerConfig() {
                this.player()?.resetPlayerConfig()
            },

            /**
             * RTSP 低延迟常用参数(Android/iOS 更完整;鸿蒙以实现为准)。
             * @param muteAudio true 时加 --no-audio,加快开播
             */
            applyRtspPlayerConfig(muteAudio : boolean = false) {
                const opts : string[] = [
                    '--rtsp-tcp',
                    '--network-caching=300',
                    '--live-caching=300',
                    '--file-caching=300',
                    '--drop-late-frames',
                    '--skip-frames',
                    '--http-reconnect',
                ]
                if (muteAudio) opts.push('--no-audio')
                this.player()?.setPlayerConfig(opts)
            },

            /** 开始播某个 URL:重置 seekable/进度后 play */
            playUrl(url : string) {
                this.setStatus('正在打开:' + url)
                this.seekable = false
                this.progress = 0
                this.player()?.play(url)
            },

            /** 播放输入框地址 */
            onPlayUrl() {
                const url = this.inputUrl.trim()
                if (url.length == 0) {
                    this.toast('请输入 URL')
                    return
                }
                this.applyDefaultPlayerConfig()
                this.playUrl(url)
            },

            onStop() {
                this.player()?.stop()
                this.progress = 0
                this.setStatus('已停止')
                this.updatePlayPauseLabel()
            },

            Play() {
                this.player()?.togglePlayPause()
                this.updatePlayPauseLabel()
            },

            /** 截图:结果走 @snapshot(方法名必须是 makeSnapshot) */
            onSnapshotClick() {
                this.player()?.makeSnapshot()
            },

            /**
             * 录制按钮:优先看页面 recording(来自 @recordchange)。
             * 鸿蒙上同步 isRecording() 可能瞬时不准。
             */
            onRecordClick() {
                const p = this.player()
                if (p == null) return
                if (this.recording || p.isRecording()) {
                    p.stopRecording()
                } else {
                    p.startRecording()
                }
            },

            /** 倍速:仅 seekable 时允许 */
            onSpeed(rate : number) {
                if (!this.seekable) {
                    this.toast('直播不可倍速')
                    return
                }
                this.player()?.setSpeed(rate)
                this.toast('倍速 ' + rate + 'x')
            },

            /** 画面比例:auto / 4:3 / 16:9 */
            onAspect(mode : string) {
                this.player()?.setAspect(mode)
                this.toast('画面比例:' + mode)
            },

            /**
             * 快进/快退。
             * @param seconds 正数快进、负数快退;内部调用 seekBySeconds
             */
            BySeconds(seconds : number) {
                if (!this.seekable) {
                    this.toast('直播不可快进快退')
                    return
                }
                this.player()?.seekBySeconds(seconds)
                this.toast(seconds >= 0 ? ('快进 ' + seconds + ' 秒') : ('快退 ' + (-seconds) + ' 秒'))
            },

            /** 设置音量 0~100 */
            onVolume(vol : number) {
                let v = Math.floor(vol)
                if (v < 0) v = 0
                if (v > 100) v = 100
                this.volume = v
                this.player()?.setVolume(v)
            },

            /** 拖动音量中:iOS 上 @change 偶发不触发,故 changing 也 setVolume */
            onVolumeChanging(e : UniSliderChangeEvent) {
                const v = Math.floor(e.detail.value)
                this.volume = v
                this.player()?.setVolume(v)
            },

            (e : UniSliderChangeEvent) {
                const v = Math.floor(e.detail.value)
                this.onVolume(v)
                this.toast('音量 ' + v)
            },

            /** 拖进度中:只改 UI,不 seek */
            Changing(e : UniSliderChangeEvent) {
                if (!this.seekable) return
                this.userSeeking = true
                this.progress = e.detail.value
            },

            /** 松手:value/1000 → seekTo(0~1) */
            Change(e : UniSliderChangeEvent) {
                this.userSeeking = false
                if (!this.seekable) return
                const pos = e.detail.value / 1000.0
                this.progress = e.detail.value
                this.player()?.seekTo(pos)
            },

            playSampleMp4() {
                this.inputUrl = this.sampleMp4
                this.applyDefaultPlayerConfig()
                this.playUrl(this.sampleMp4)
            },

            playSampleRtsp() {
                this.inputUrl = this.sampleRtsp
                this.applyRtspPlayerConfig(false)
                this.playUrl(this.sampleRtsp)
            },

            /** 去掉 file:// 前缀,方便 image / play 使用 */
            normalizeLocalPath(path : string) : string {
                if (path.indexOf('file://') == 0) {
                    return path.substring(7)
                }
                return path
            },

            /** 播放最近录制的本地文件 */
            playRecordedVideo() {
                if (this.recordPath.length == 0) {
                    this.toast('暂无录制文件')
                    return
                }
                const path = this.normalizeLocalPath(this.recordPath)
                this.inputUrl = path
                this.applyDefaultPlayerConfig()
                this.playUrl(path)
                this.toast('正在播放录制视频')
            },

            // -------------------------------------------------------------------------
            // 插件事件回调
            // -------------------------------------------------------------------------

            /** 引擎就绪:可安全 play;此处先恢复默认配置 */
            onReady() {
                this.setStatus('引擎已就绪')
                this.applyDefaultPlayerConfig()
            },

            onOpening() {
                this.setStatus('正在连接…')
            },

            /**
             * 缓冲进度(开播、快进后常见)。
             * percent 到 100 后通常会再来 @playing。
             */
            onBuffering(e : any) {
                const percent = this.readNum(this.eventMap(e), 'percent', 0)
                if (percent >= 100) {
                    this.setStatus('缓冲中 100%')
                    return
                }
                this.setStatus('缓冲中 ' + Math.floor(percent) + '%')
            },

            () {
                this.playPauseLabel = '暂停'
                this.setStatus(this.seekable ? '播放中(点播)' : '播放中(直播)')
            },

            onPaused() {
                this.playPauseLabel = '播放'
                this.setStatus('已暂停')
            },

            onStopped() {
                this.playPauseLabel = '播放'
                this.setStatus('已停止')
            },

            () {
                this.playPauseLabel = '播放'
                this.setStatus('播放结束')
            },

            onError(e : any) {
                const message = this.readStr(this.eventMap(e), 'message')
                this.setStatus('错误:' + message)
                this.toast(message.length > 0 ? message : '播放出错')
            },

            /** 是否可拖进度:1 点播,0 直播 */
            ableChange(e : any) {
                this.seekable = this.readFlag(this.eventMap(e), 'seekable')
            },

            /**
             * 进度刷新:更新进度条与时间文字。
             * 用户拖动中或非 seekable 时忽略,避免跳动。
             */
            (e : any) {
                if (!this.seekable || this.userSeeking) return
                const data = this.eventMap(e)
                const currentMs = this.readNum(data, 'currentMs', 0)
                const totalMs = this.readNum(data, 'totalMs', 0)
                const position = this.readNum(data, 'position', 0)
                this.progress = Math.floor(position * 1000)
                const p = this.player()
                if (p != null) {
                    this.playTimeText = p.formatTime(currentMs) + ' / ' + p.formatTime(totalMs)
                }
            },

            /** 截图结果:success==1 时展示图片 */
            onSnapshot(e : any) {
                const data = this.eventMap(e)
                if (this.readFlag(data, 'success')) {
                    const path = this.readStr(data, 'filePath')
                    this.snapshotSrc = path.indexOf('file://') == 0 ? path.substring(7) : path
                    this.showSnapshot = true
                    this.toast('截图成功')
                } else {
                    this.toast('截图失败')
                }
            },

            /**
             * 录制状态:
             * recording=1 →「停止录制」;
             * recording=0 →「录制」,并保存 filePath 供回放。
             */
            onRecordChange(e : any) {
                const data = this.eventMap(e)
                const recording = this.readFlag(data, 'recording')
                this.recording = recording
                this.recordLabel = recording ? '停止录制' : '录制'
                if (!recording) {
                    const path = this.normalizeLocalPath(this.readStr(data, 'filePath'))
                    if (path.length > 0) {
                        this.recordPath = path
                        this.setStatus('录制完成:' + path)
                        this.toast('录制已保存')
                    } else {
                        this.setStatus('录制已停止')
                    }
                } else {
                    this.setStatus('录制中…')
                }
            },
        },
    }
</script>

<style>
    /* 页面根布局:纵向铺满 */
    .root {
        flex: 1;
        flex-direction: column;
        background-color: #F5F5F5;
    }

    .root-fullscreen {
        background-color: #000000;
    }

    /* 自定义导航(pages.json navigationStyle=custom) */
    .nav-bar {
        background-color: #FFFFFF;
    }

    .status-pad {
        height: var(--status-bar-height);
    }

    .nav-content {
        height: 44px;
        justify-content: center;
        align-items: center;
    }

    .nav-title {
        font-size: 16px;
        font-weight: bold;
        color: #333333;
    }

    /* 播放器外框:非全屏固定高度 */
    .player-wrap {
        margin: 8px 12px 0 12px;
        height: 220px;
        background-color: #333333;
    }

    .player {
        width: 100%;
        height: 220px;
    }

    /* 伪全屏:铺满整页,盖住下方控件占位 */
    .player-wrap-full {
        position: absolute;
        left: 0;
        top: 0;
        right: 0;
        bottom: 0;
        margin: 0;
        height: 100%;
        z-index: 100;
    }

    .player-full {
        width: 100%;
        height: 100%;
    }

    .fs-exit-btn {
        position: absolute;
        right: 16px;
        top: 48px;
        z-index: 101;
    }

    .panel {
        margin: 4px 12px 0 12px;
        background-color: #333333;
        padding: 8px;
    }

    .seek-bar {
        width: 100%;
    }

    .vol-row {
        align-items: center;
    }

    .vol-label {
        width: 72px;
        color: #FFFFFF;
        font-size: 12px;
    }

    .vol-bar {
        flex: 1;
        margin-left: 4px;
        margin-right: 4px;
    }

    .row {
        flex-direction: row;
        align-items: center;
        margin-top: 6px;
        flex-wrap: wrap;
    }

    .time-text {
        flex: 1;
        color: #FFFFFF;
        font-size: 12px;
    }

    .btn-sm {
        margin-left: 4px;
    }

    .btn-xs {
        margin-left: 4px;
    }

    .btn-flex {
        flex: 1;
        margin: 4px;
    }

    .btn-block {
        margin: 8px 12px;
    }

    .scroll {
        flex: 1;
        padding: 12px;
    }

    .status {
        font-size: 13px;
        color: #333333;
    }

    .record-box {
        margin-top: 10px;
        padding: 10px;
        background-color: #FFFFFF;
        border-radius: 6px;
    }

    .record-title {
        font-size: 13px;
        font-weight: bold;
        color: #333333;
    }

    .record-path {
        margin-top: 6px;
        font-size: 11px;
        color: #666666;
        lines: 3;
        text-overflow: ellipsis;
    }

    .record-play {
        margin: 10px 0 0 0;
    }

    .snapshot {
        margin-top: 8px;
        width: 100%;
        height: 160px;
        background-color: #EEEEEE;
    }

    .url-input {
        margin-top: 12px;
        padding: 8px;
        border-width: 1px;
        border-color: #DDDDDD;
        border-style: solid;
        font-size: 13px;
        background-color: #FAFAFA;
    }
</style>

隐私、权限声明

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

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

插件不采集任何数据

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