更新记录
1.0.1(2026-07-23)
- 新增uniapp-x的鸿蒙端
1.0.0(2026-07-22)
- 新版发布
平台兼容性
uni-app(5.01)
| Vue2 | Vue3 | Chrome | Safari | app-vue | app-nvue | Android | iOS | 鸿蒙 |
|---|---|---|---|---|---|---|---|---|
| √ | √ | × | × | × | √ | √ | √ | × |
| 微信小程序 | 支付宝小程序 | 抖音小程序 | 百度小程序 | 快手小程序 | 京东小程序 | 鸿蒙元服务 | QQ小程序 | 飞书小程序 | 小红书小程序 | 快应用-华为 | 快应用-联盟 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| × | × | × | × | × | × | × | × | × | × | × | × |
uni-app x(5.01)
| Chrome | Safari | Android | iOS | 鸿蒙 | 鸿蒙插件版本 | 微信小程序 |
|---|---|---|---|---|---|---|
| × | × | √ | √ | √ | 1.0.1 | × |
yt-vlc
特别提醒
- 购买本插件前,请先试用,请先试用,请先试用,确认满足需求之后再行购买。虚拟物品一旦购买之后无法退款。
- 如有使用上的疑问、bug,可以进交流群联系作者;
- 请在合法范围内使用,若使用本插件做非法开发,本方概不负责;
- 插件需先引入再打自定义基座后使用
- 可先下载Android端demo体验插件功能体验包下载。
- uniapp如需摄像头预览则必须把
yt-vlc放在在nvue页面。更多问题详见下方常见问题 - 鸿蒙端仅支持 uni-app x 项目(
.uvue+<native-view>),不支持经典 uni-app 的.nvue/.vue页面使用本插件鸿蒙能力;经典 uni-app 仅 Android / iOS 可用<yt-vlc>。 - 可下载示例示例demo(unipp-x示例demo在uniapp示例demo的static文件夹下)下载示例demo后unipp把static/VCLUtsDemo.zip文件删除后再打包。uniapp-x项目则是解压static/VCLUtsDemo.zip文件打包测试。
- 若打包超包可以将另一端先删除(如需打包Android可先将插件yt-vlc/utssdk/app-ios文件夹删除)。
- iOS 播放 RTSP 流首帧很慢:从开始播放到画面出现,常见需要约 30~40 秒(与 MobileVLCKit / 网络 / 设备有关)。开播前可按文档配置低延迟参数(如
--rtsp-tcp、适当 caching、需要时--no-audio),但无法保证缩短到秒级;若业务强依赖秒开,请先试用确认是否可接受,再决定是否购买。
基于 VLC(Android/iOS)与 ijkplayer(鸿蒙)的 uni-app x / uni-app 原生视频能力。
- 组件标签:Android / iOS 为
<yt-vlc>;鸿蒙为<native-view>+YtVlcHarmonyNative(不是<yt-vlc>,也不是<embed>) - 适用端:App-Android、App-iOS(均需自定义调试基座 / 云打包)。App-Harmony(仅 uni-app x)只需要配置好证书直接运行到鸿蒙即可。
- 能力:点播与直播(http(s) / m3u8 / rtsp / rtmp / 本地文件)、倍速、音量、画面比例、截图、录制、PlayerConfig 低延迟参数
双端引擎
| 平台 | 引擎 | 说明 |
|---|---|---|
| Android | libvlc-all 4.x |
Kotlin:VlcPlayerView + Bridge + index.vue;页面 <yt-vlc> |
| iOS | MobileVLCKit 3.6.0(CocoaPods) | Swift:VlcPlayerView + Bridge + index.vue;页面 <yt-vlc> |
| Harmony | 经本地 HAR @yt/vlc-player |
仅 uni-app x;<native-view> + YtVlcHarmonyNative;不是 LibVLC / 不是 VLC |
Android / iOS / 鸿蒙对外 方法语义 / 事件名尽量一致,业务页可用 #ifdef APP-HARMONY 条件编译共用同一套 UI。
不要在业务页调用 setupPlayer()——插件在 NVLoaded 里已自动完成初始化(鸿蒙在 native-view @init 里 new YtVlcHarmonyNative)。
鸿蒙接入要点(必读)
重要限制
- 鸿蒙端只能用于 uni-app x 项目(页面为
.uvue)。经典 uni-app(Vue3 +.nvue)无法像 Android/iOS 那样使用<yt-vlc>跑鸿蒙播放器(UTS 兼容模式组件不支持 app-harmony;且鸿蒙 nvue 非原生渲染)。- 引擎是
@ohos/ijkplayer,不是 VLC。参数仍可用「类 LibVLC」字符串(如--rtsp-tcp),由 HAR 映射为 ijksetOption;未映射项可能无效。- 页面不能写
<yt-vlc>/<embed tag="ytvlc">,必须用下面的native-view方案。
1. 依赖与基座
- 确认 HAR:
uni_modules/yt-vlc/utssdk/app-harmony/libs/ytVlcLibrary.har - 依赖见
utssdk/app-harmony/config.json:@yt/vlc-player+@ohos/ijkplayer@2.0.9 - 修改鸿蒙原生 / HAR / 依赖后,重新制作 鸿蒙自定义调试基座 再运行
2. 模板与导入
<!-- #ifdef APP-HARMONY -->
<native-view class="player" @init="onHarmonyViewInit" @onEvent="onHarmonyEvent"></native-view>
<!-- #endif -->
<!-- #ifndef APP-HARMONY -->
<yt-vlc ref="vlcVideo" class="player" ...></yt-vlc>
<!-- #endif -->
// #ifdef APP-HARMONY
import { YtVlcHarmonyNative } from '@/uni_modules/yt-vlc'
// #endif
3. 初始化与发命令
// data
harmonyNative: null as any,
harmonyReady: false,
onHarmonyViewInit(e : UniNativeViewInitEvent) {
const native = new YtVlcHarmonyNative(e.detail.element)
native.setEventHandler((detail : any) => {
this.onHarmonyEvent({ detail: detail })
})
this.harmonyNative = native
this.harmonyReady = true
},
/** 统一下发 JSON 命令;须在 @init 完成之后 */
sendVlcCmd(cmd : string, patch : UTSJSONObject | null = null) {
const payload : UTSJSONObject = { cmd: cmd, _t: Date.now() }
// 按需合并 patch:url / seekPos / rate / volume / aspect / muteAudio …
if (this.harmonyNative == null) return
this.harmonyNative!.updateParams(JSON.stringify(payload))
},
onUnload() {
if (this.harmonyNative != null) {
this.harmonyNative!.destroy()
this.harmonyNative = null
}
},
4. 常用命令(updateParams JSON)
| cmd | 附加字段 | 说明 |
|---|---|---|
play |
url |
点播 / 通用开播 |
playRtsp |
url, muteAudio? |
RTSP:内部套 RTSP 配置再播 |
pause / stop / toggle |
— | 暂停 / 停止 / 播放暂停切换 |
seekTo |
seekPos(0~1) |
进度;勿用 position 字段名 |
rewind / fastForward |
seconds |
快退 / 快进 |
setSpeed |
rate(1/2/4/8/16) |
倍速 |
setVolume |
volume(0~100) |
音量 |
setAspect |
aspect(auto/4:3/16:9) |
画面比例 |
snapshot |
— | 截图,结果事件 opt=snapshot |
startRecord / stopRecord |
— | 录制,结果 opt=recordchange |
resetPlayerConfig |
— | 恢复默认类 LibVLC 参数 |
applyRtspConfig |
muteAudio? |
套 RTSP 推荐参数 |
每次建议带 _t: Date.now(),避免相同 JSON 不触发更新。
5. 事件(detail.opt)
ready | opening | buffering | playing | paused | stopped | ended | error | timeupdate | seekablechange | snapshot | recordchange
鸿蒙无同步 isPlaying() / getTime(),请用事件镜像本地状态(见完整示例中的 harmonyPlaying / harmonyTotalMs)。
完整可运行代码见文末 「uni-app x 完整示例」(含鸿蒙分支,与 pages/index/index.uvue 同步)。
接入步骤
- 将本插件放入项目
uni_modules/yt-vlc(或从插件市场导入)。 - 修改原生代码 / 首次接入后:重新制作对应平台的自定义调试基座,再用该基座运行。
- Android 已声明网络等权限;iOS 若播放局域网摄像头,建议在工程
Info.plist配置本地网络用途说明(见下文)。 - 鸿蒙:仅 uni-app x;按上文「鸿蒙接入要点」接入。
Android
- 依赖见
utssdk/app-android/config.json - 权限见
utssdk/app-android/AndroidManifest.xml(INTERNET等)
iOS
- CocoaPods:
utssdk/app-ios/config.json→MobileVLCKit3.6.0 - MobileVLCKit 体积大,自定义基座会明显变大;首次制作需联网拉 Pods
- HTTP 明文流需允许 ATS(或在应用
Info.plist中配置) - 局域网 RTSP 建议配置:
<key>NSLocalNetworkUsageDescription</key>
<string>用于连接局域网内的监控摄像头进行视频预览与录像</string>
HarmonyOS(仅 uni-app x)
- 工程类型:必须是 uni-app x;经典 uni-app(含 Vue3 nvue)不支持本插件鸿蒙播放能力
- 引擎:
@ohos/ijkplayer2.0.9(不是 VLC / LibVLC) - 本地 HAR:
utssdk/app-harmony/libs/ytVlcLibrary.har(包名@yt/vlc-player) - 页面:
<native-view>+import { YtVlcHarmonyNative } from '@/uni_modules/yt-vlc' - 详细命令 / 事件 / 示例见上文「鸿蒙接入要点」与文末「uni-app x 完整示例」
API 一览
方法(ref 调用)
| 方法 | 说明 |
|---|---|
play(url) |
播放:http(s) / m3u8 / rtsp / rtmp / 本地绝对路径 |
pause() / resume() / stop() / togglePlayPause() |
暂停 / 恢复 / 停止 / 切换 |
seekTo(0~1) / seekBySeconds(±秒) / fastForward(秒) / rewind(秒) |
进度(仅点播 isSeekable()==true) |
setSpeed(1\|2\|4\|8\|16) |
倍速档位 |
setVolume(0~100) / getVolume() |
音量 |
setAspect('auto'\|'4:3'\|'16:9') |
画面比例 |
makeSnapshot() |
截图 JPEG,结果走 @snapshot(勿用 takeSnapshot,会与 UniElement 冲突) |
startRecording() / stopRecording() / toggleRecording() |
录制;结束路径走 @recordchange |
isPlaying() / isRecording() / isSeekable() |
状态(uni-app x 同步可用;nvue 勿依赖返回值,见下文) |
getLength() / getTime() / getPosition() / formatTime(ms) |
时长与进度(同上,nvue 用事件 / queryState) |
queryState() |
异步状态快照,结果走 @state(nvue 按需查询推荐) |
setPlayerConfig(string[] \| null) |
LibVLC 风格参数;null 恢复默认,建议在 play 前调用 |
resetPlayerConfig() |
恢复默认参数列表 |
事件
| 事件 | 说明 | 典型 payload(Map) |
|---|---|---|
@ready |
引擎就绪 | — |
@opening |
正在打开媒体 | — |
@buffering |
缓冲中 | percent:0~100 |
@playing / @paused / @stopped / @ended |
播放状态 | — |
@error |
错误 | message:string |
@timeupdate |
点播进度 | currentMs / totalMs / position |
@seekablechange |
是否可 seek | seekable:0|1(或 boolean,视端而定) |
@snapshot |
截图完成 | success / filePath |
@recordchange |
录制状态 | recording / filePath |
@state |
queryState() 结果 |
音量、是否播放等快照字段 |
事件参数请按 Map / UTSJSONObject / e.detail 兼容解析(iOS 经典 uni-app 上 $emit 常需包一层 detail,页面侧建议统一用兼容解析函数)。
获取播放器状态:uni-app vs uni-app x(必读)
两端方法名 / 事件名相同,但 「怎么读到状态」不一样。这是平台桥接差异,不是插件漏实现。
对比一览
| 能力 | uni-app x(.uvue) |
uni-app(仅 .nvue 可用本组件) |
|---|---|---|
isPlaying() / isRecording() / isSeekable() |
✅ 同步返回值可用 | ❌ 常为 undefined,不要依赖返回值 |
getTime() / getLength() / getPosition() / getVolume() / formatTime() |
✅ 同步返回值可用 | ❌ 同上,勿依赖 |
startRecording() 等布尔返回值 |
✅ 可用 | ⚠️ 不可靠,以 @recordchange / @error 为准 |
推送事件 @playing / @paused / @timeupdate / @seekablechange / @recordchange 等 |
✅ | ✅ 推荐主路径 |
queryState() + @state 按需拉快照 |
✅ 可用 | ✅ 按需查询时推荐(多播放器也适用) |
| 页面类型 | .uvue |
必须 .nvue(.vue 挂不上 UTS 兼容组件) |
uni-app x:可同步读方法
const p = this.$refs['vclVideo'] as YtVclElement
if (p.isPlaying()) { /* ... */ }
if (p.isSeekable()) {
const t = p.getTime()
const len = p.getLength()
}
const rec = p.isRecording()
也可同时监听事件,或调用 queryState() 听 @state(多实例时更清晰)。
uni-app(nvue):用事件镜像 + queryState
- 页面本地维护状态(
playing/recording/seekable/currentMs/totalMs/volume等)。 - 推送:用事件持续同步(播放中不必轮询)。
- 按需拉取:调
queryState(),在@state里更新本地字段(适合多播放器、进入页时校准)。
| 想知道的状态 | nvue 推荐做法 | 不要这样做 |
|---|---|---|
| 是否在播 | @playing / @paused / @stopped / @ended,或 queryState → playing |
isPlaying() 当返回值用 |
| 是否在录 | @recordchange 的 recording,或 queryState → recording |
isRecording() 当返回值用 |
| 可否拖进度 | @seekablechange 的 seekable |
仅靠 isSeekable() 返回值 |
| 当前时间 / 总时长 / 进度 | @timeupdate:currentMs / totalMs / position(点播) |
getTime() / getLength() / getPosition() 返回值 |
| 音量 | 页面自己 setVolume 时记下,或 queryState → volume |
getVolume() 返回值 |
| 录制文件路径 | @recordchange 停止后的 filePath |
依赖 stopRecording() 返回值 |
@state 字段(queryState())
| 字段 | 含义 |
|---|---|
playing / recording / seekable |
0 或 1(建议当 flag 解析) |
volume |
0~100 |
currentMs / totalMs |
毫秒 |
position |
0~1 |
nvue 最小示例
<yt-vcl
ref="vclVideo"
@playing=""
@paused="onPaused"
@timeupdate=""
@seekablechange="ableChange"
@recordchange="onRecordChange"
@state="onState"
></yt-vcl>
data() {
return {
playing: false,
recording: false,
seekable: false,
currentMs: 0,
totalMs: 0,
}
},
methods: {
() { this.playing = true },
onPaused() { this.playing = false },
ableChange(e) {
// 兼容解析 seekable(0|1 / boolean / Map.get)
},
(e) {
// 读 currentMs / totalMs / position,更新进度条
},
onRecordChange(e) {
// 读 recording / filePath
},
// 需要一次性校准(如多播放器)时:
refreshState() {
this.$refs.vclVideo.queryState()
},
onState(e) {
// 把 snapshot 写回 playing / recording / seekable / currentMs ...
},
},
完整可运行示例见本仓库 pages/index/index.nvue。
事件参数解析提示
- Android:多为
Map,用e.get?.('key')或兼容层读取。 - iOS uni-app x:常为
UniEvent,字段可能直接挂在e上(如e.currentMs)。 - iOS 经典 uni-app nvue:
$emit参数常包在detail里;插件侧已尽量detail+ 扁平双写,页面仍建议统一走兼容eventMap(见 Demo)。
调用示例
调用示例按工程类型分开书写。
uni-app x 项目
页面使用 .uvue,脚本为 UTS。
- Android / iOS:
ref强转YtVlcElement调用;状态可读同步方法,也可用事件。 - 鸿蒙:仅 uni-app x;引擎为 ijkplayer;用
<native-view>+YtVlcHarmonyNative+updateParams(见上文「鸿蒙接入要点」)。完整三端示例见文末「uni-app x 完整示例」。
1. 模板(Android / iOS;鸿蒙见「鸿蒙接入要点」)
<template>
<view>
<yt-vlc
ref="vlcVideo"
style="width:750rpx;height:422rpx;"
@ready="onReady"
@opening="onOpening"
@playing=""
@error="onError"
@timeupdate=""
@seekablechange="ableChange"
@snapshot="onSnapshot"
@recordchange="onRecordChange"
></yt-vlc>
<button @click="playDemo">播放</button>
<button @click="">播放/暂停</button>
<button @click="onSnap">截图</button>
</view>
</template>
2. 获取播放器与播放(Android / iOS;鸿蒙用 sendVlcCmd)
methods: {
player() : YtVlcElement | null {
return this.$refs['vlcVideo'] as YtVlcElement | null
},
onReady() {
// 引擎就绪后再 play;也可在此 setPlayerConfig
const p = this.player()
if (p == null) return
p.resetPlayerConfig()
},
playDemo() {
const p = this.player()
if (p == null) return
// 点播示例
p.play('https://example.com/demo.mp4')
// RTSP 示例(建议先 setPlayerConfig,见下节)
// p.play('rtsp://user:pass@192.168.1.100:554/Streaming/Channels/102')
},
() {
const p = this.player()
if (p == null) return
p.togglePlayPause()
},
}
3. setPlayerConfig(开播前)
/** 恢复默认低延迟参数(有声) */
applyDefaultPlayerConfig() {
const p = this.player()
if (p == null) return
p.resetPlayerConfig()
// 等价:p.setPlayerConfig(null)
},
/**
* RTSP 监控预览:必须含 --rtsp-tcp。
* muteAudio=true 时追加 --no-audio,通常开播更快。
*/
applyRtspPlayerConfig(muteAudio : boolean = false) {
const p = this.player()
if (p == null) return
const opts : string[] = [
'--rtsp-tcp',
'--network-caching=300',
'--live-caching=300',
'--file-caching=300',
'--clock-jitter=0',
'--clock-synchro=0',
'--drop-late-frames',
'--skip-frames',
'--http-reconnect',
]
if (muteAudio) {
opts.push('--no-audio')
}
p.setPlayerConfig(opts)
},
playRtsp(url : string) {
this.applyRtspPlayerConfig(true)
const p = this.player()
if (p == null) return
p.play(url)
},
默认列表(未自定义时)大致包含:--rtsp-tcp、caching、丢迟帧、硬解相关等;默认不含 --no-audio。
鸿蒙对应:
sendVlcCmd('resetPlayerConfig')/sendVlcCmd('applyRtspConfig', { muteAudio })/sendVlcCmd('playRtsp', { url, muteAudio })(类 LibVLC 参数由 HAR 映射到 ijkplayer)。
4. 进度 / 倍速 / 音量(点播)
onSeekableChange(e : any) {
// 直播一般为不可 seek,应禁用进度条与倍速
},
(e : any) {
// 从事件 Map 读取 currentMs / totalMs / position
},
seekToHalf() {
const p = this.player()
if (p == null || !p.isSeekable()) return
p.seekTo(0.5)
},
setSpeed2x() {
const p = this.player()
if (p == null || !p.isSeekable()) return
p.setSpeed(2)
},
setVolume80() {
const p = this.player()
if (p == null) return
p.setVolume(80)
},
5. 截图与录制
onSnap() {
const p = this.player()
if (p == null) return
// 必须用 makeSnapshot,不要用 takeSnapshot
p.makeSnapshot()
},
onSnapshot(e : any) {
// success、filePath
},
startOrStopRecord() {
const p = this.player()
if (p == null) return
if (p.isRecording()) {
p.stopRecording()
} else {
const ok = p.startRecording()
// 失败时可听 @error;成功停止后 @recordchange 带回路径
}
},
onRecordChange(e : any) {
// recording、filePath
},
6. 错误处理
onError(e : any) {
// 解析 Map 中的 message
},
onOpening() {
// 显示 loading
},
() {
// 隐藏 loading
},
完整可运行示例见文末「uni-app x 完整示例」(与仓库 pages/index/index.uvue 同步)。
uni-app 项目
页面必须是 .nvue(UTS 兼容组件不支持普通 .vue)。方法 / 事件名与 uni-app x 对齐,但有返回值的状态方法在 nvue 上不可用,请按上文「获取播放器状态」用事件或 queryState + @state。
截图:优先 makeSnapshot();若运行时报 makeSnapshot is not a function,可回退 takeSnapshot()(Android 部分运行时 $refs 会落到原生 View)。iOS 请始终用 makeSnapshot(避免与 UniElement.takeSnapshot 冲突)。
完整可运行示例见文末「uni-app 完整示例」(与仓库 pages/index/index.nvue 同步)。
海康安防取流说明
本插件播放的是 通用 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;能力含实时预览、录像回放、语音对讲、鱼眼矫正、云台控制等
本插件(yt-vlc)更适合:标准 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)。
- 务必使用 TCP:
play前setPlayerConfig包含--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)。
与本插件的对接顺序(仅通用 RTSP / 可被 VLC 播放的地址)
- 直连:按上表拼好 RTSP;平台:调开放接口拿到临时 URL(若平台要求海康 SDK,直接用
yt-hikvideo)。 setPlayerConfig(含--rtsp-tcp,预览可选--no-audio)。play(url)。- 以
@playing/@error处理结果;直播以不可 seek 为准(x:isSeekable()==false;nvue:@seekablechange)。 - 持续
@error/ 长时间无@playing:检查 URL 与网络;海康综合安防场景请换 yt-hikvideo。
注意事项
- 自定义基座:改
config.json、权限或 Pods 后必须重新制作基座。 - 截图:对外优先
makeSnapshot();Android nvue 可兼容takeSnapshot();iOS 不要调takeSnapshot()。 - 直播 vs 点播:以
@seekablechange/(x 端)isSeekable()为准;直播勿依赖拖进度与倍速。 - RTSP / 海康:建议
play前setPlayerConfig并包含--rtsp-tcp。海康综合安防平台流用 VLC 可能播不了,请改用 yt-hikvideo(插件市场 id=26041);详见「海康安防取流说明」。 - 录制:通常需正在播放;停止后以
@recordchange的路径为准。 - 状态读取:uni-app x 可用同步 getter;uni-app nvue 必须用事件镜像或
queryState+@state,详见「获取播放器状态:uni-app vs uni-app x」。 - 离开页面:组件
NVBeforeUnload会onStop+release;navigateBack销毁页时一般会停播。仅onHide/ 未销毁的 tab 页建议业务再调一次stop()。
参考文档
更多好用插件推荐
- 高德定位连续定位后台定位保活定位
- 百度汽车摩托车导航插件
- 百度鹰眼轨迹插件支持后台采集、保活
- 百度定位插件、连续定位、保活、坐标系转换、支持双端
- 计步器插件,支持Android、iOS双端
- uts经典蓝牙插件、蓝牙电子秤
- 获取唯一标识、ServiceID、卸载更新不变iOS+Android
- Android经典蓝牙
- 华为ScanKit统一扫码插件支持iOS+Android原生插件
- 【华为扫码】统一扫码插件支持多码连续扫码支持半屏扫码uts插件iOS+Android+HarmonyOS
- 截屏、录屏、防截屏、录屏iOS、Android
- 人脸采集插件 最新百度SDK 离线人脸采集、活体检测
- 页面截长图、截取WebView内容,生成长截图Android+iOS
- Android无预览拍照、录制、静默拍照、静默录制、抓拍插件支持
- uni高德地图功能拓展地图截图
- 科大讯飞离线合成插件支持iOS、android
- iOS保活Android保活鸿蒙保活定位插件系统定位
- 高德定位、猎鹰轨迹插件
- 海康威视综合安防平台视频播放插件
- 支持NFC读写功能检测支持Android iOS HarmonyOS
- 自定义相机
uni-app 完整示例
以下为仓库演示页完整源码,适用于 uni-app +
.nvue(UTS 兼容组件不可放在普通.vue)。
说明:nvue 上isPlaying()/getTime()等无可靠返回值,本页用事件镜像 +queryState/@state;截图优先makeSnapshot,Android 可回退takeSnapshot。
工程内同步文件:pages/index/index.nvue(改插件后需自定义基座)。
<template>
<!--
对齐 Android MainActivity:
- 点播:进度条可拖;可倍速 / 快进快退
- 直播流:进度条禁用;禁止倍速与快进快退
- 截图成功展示图片;录制成功显示路径并可回放
- 音量用进度条调节
-->
<view class="root">
<text class="title">VLC 4.x 播放器演示(yt-vlc UTS)</text>
<!-- 整页可滚:避免截图/录制区被挤出屏幕「看起来像没了」 -->
<scroll-view class="page-scroll" scroll-y="true" show-scrollbar="true">
<!-- nvue:UTS 组件需要明确宽高;高度固定偏小,给下方结果区留空 -->
<view class="player-wrap">
<yt-vlc ref="vlcVideo" style="width:750rpx;height:360rpx;" @ready="onReady" @opening="onOpening"
@buffering="onBuffering" @playing="" @paused="onPaused" @stopped="onStopped"
@ended="" @error="onError" @timeupdate="" @seekablechange="ableChange"
@snapshot="onSnapshot" @recordchange="onRecordChange" @state="onState"></yt-vlc>
</view>
<view class="panel">
<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>
</view>
<!-- ★ 截图/录制结果:紧挨按钮,布局固定,不要挪进别的区域 -->
<view class="result-box">
<text class="result-title">截图预览</text>
<image class="snapshot" :src="snapshotSrc" mode="aspectFit" @error="onSnapshotImageError"
@load="onSnapshotImageLoad"></image>
<text class="path-text">{{ snapshotPath.length > 0 ? ('路径:' + snapshotPath) : '暂无截图' }}</text>
<text class="result-title">最近录制</text>
<text class="path-text">{{ recordPath.length > 0 ? ('文件:' + recordPath) : '暂无录制文件' }}</text>
<button class="btn-block" type="primary" size="mini" :disabled="recordPath.length == 0"
@click="playLastRecord">播放最近录制</button>
</view>
<view class="row">
<text class="label">倍速</text>
<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" :disabled="!seekable" @click="onSpeed(8)">8x</button>
<button class="btn-xs" size="mini" :disabled="!seekable" @click="onSpeed(16)">16x</button>
</view>
<text v-if="!seekable" class="hint-inline">直播流不可调倍速 / 快进快退</text>
<view class="row volume-row">
<text class="label">音量</text>
<slider class="volume-bar" :value="volume" :min="0" :max="100" :step="1" activeColor="#2196F3"
backgroundColor="#555555" :block-size="18" @changing="onVolumeChanging"
@change="" />
<text class="volume-text">{{ volume }}</text>
</view>
<view class="row">
<button class="btn-flex" size="mini" :disabled="!seekable" @click="onRewind">-10秒</button>
<button class="btn-flex" size="mini" :disabled="!seekable" @click="onForward">+10秒</button>
</view>
<view class="row">
<text class="label">比例</text>
<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>
</view>
<view class="section">
<text class="status">状态:{{ statusText }}</text>
<text v-if="showLoading" class="loading-tip">加载中 / 缓冲中…</text>
<input class="url-input" v-model="inputUrl" placeholder="输入播放地址(mp4 / m3u8 / rtmp / rtsp)" />
<view class="row">
<button class="btn-flex" type="primary" @click="onPlayUrl">播放</button>
<button class="btn-flex" @click="onStop">停止</button>
</view>
<text class="section-title">快捷示例</text>
<button class="btn-block" @click="playSampleMp4">示例 MP4(点播,可拖进度)</button>
<button class="btn-block" @click="playSampleRtsp">示例 RTSP(直播流)</button>
<button class="btn-block" @click="playSampleOpenUrl">示例 openUrl(可能不稳)</button>
</view>
</scroll-view>
</view>
</template>
<script>
export default {
data() {
return {
/** 顶部状态文案 */
statusText: '就绪。选择示例或输入 URL 后点击播放。',
/** 进度时间显示,如 00:01 / 03:20 */
playTimeText: '00:00 / 00:00',
/** 播放/暂停按钮文案 */
playPauseLabel: '播放',
/** 录制/停止录制按钮文案 */
recordLabel: '录制',
/** 进度条 0~1000(对应 position 0~1) */
progress: 0,
/** true=点播可 seek;false=直播(由 @seekablechange 同步,勿调 isSeekable) */
seekable: false,
/** 用户正在拖进度条时为 true,避免 @timeupdate 抢进度 */
userSeeking: false,
/** 是否显示「加载中/缓冲中」 */
showLoading: false,
/** 截图后短时抑制 buffering 把 loading 又打开 */
suppressLoadingUntil: 0,
/** 音量 0~100(nvue 勿用 getVolume 返回值) */
volume: 100,
/** 输入框 URL */
inputUrl: '',
/** 最近截图绝对路径 */
snapshotPath: '',
/** <image> 用的 file:// src */
snapshotSrc: '',
showSnapshot: false,
/** 最近录制文件绝对路径 */
recordPath: '',
showRecordActions: false,
/**
* nvue 调 UTS 有返回值的方法常无结果:用事件镜像 + queryState/@state。
* pendingAction:queryState 回来后要执行的动作(多播放器时可按 ref 分发)
*/
playing: false,
recording: false,
totalMs: 0,
currentMs: 0,
pendingAction: '',
sampleMp4: 'https://vd3.bdstatic.com/mda-jggr72s70c52d06f/sc/mda-jggr72s70c52d06f.mp4',
sampleRtsp: 'rtsp://admin:lyzh2019@192.168.8.135:554/Streaming/Channels/102',
sampleOpenUrl: 'rtsp://122.225.221.210:554/openUrl/vsigJXoO7VI52de7d9c4d954a6abe511',
}
},
methods: {
/** 获取 yt-vlc 组件 ref(nvue 上勿依赖其有返回值的方法) */
player() {
return this.$refs['vlcVideo'] ?? null
},
/** 轻提示 */
toast(msg) {
uni.showToast({
title: msg,
icon: 'none',
duration: 2000
})
},
/** 更新页面状态文案 */
setStatus(text) {
this.statusText = text
},
/**
* 兼容解析组件事件载荷(nvue / Weex / iOS UTS)
* - 可能是 Map、{ detail: Map }、或再包一层 detail
* - uni-app x 也可能把字段直接挂在 e 上
*/
eventMap(e) {
if (e == null) {
return {};
}
let data = e
if (e.detail != null && typeof e.detail === 'object') {
data = e.detail
} else if (typeof e.get == 'function') {
const d = e.get('detail')
if (d != null) {
data = d
}
} else if (e['detail'] != null) {
data = e['detail']
}
// iOS 插件 emitDetail 又包了一层 detail 时再解一次
const nested = this.readRaw(data, 'detail')
if (nested != null && typeof nested === 'object') {
// 仅当内层含本插件已知字段时才解包(避免误把无关 detail 当载荷)
const useful =
this.readRaw(nested, 'success') != null || // @snapshot:截图是否成功 0|1
this.readRaw(nested, 'filePath') != null || // @snapshot / @recordchange:本地文件绝对路径
this.readRaw(nested, 'recording') != null || // @recordchange / @state:是否正在录制 0|1
this.readRaw(nested, 'message') != null || // @error:错误说明文案
this.readRaw(nested, 'percent') != null || // @buffering:缓冲进度 0~100
this.readRaw(nested, 'seekable') != null || // @seekablechange / @state:是否可拖进度 0|1
this.readRaw(nested, 'currentMs') != null || // @timeupdate / @state:当前播放位置(毫秒)
this.readRaw(nested, 'playing') != null || // @state:是否正在播放 0|1
this.readRaw(nested, 'volume') != null // @state:音量 0~100
if (useful) {
return nested
}
}
return data
},
/** 从事件 Map/对象读取数字字段 */
readNum(data, key, def) {
const v = this.readRaw(data, key)
if (v == null || v === '') return def
const n = parseFloat(`${v}`)
return isNaN(n) ? def : n
},
/** 从事件 Map/对象读取字符串字段 */
readStr(data, key) {
const v = this.readRaw(data, key)
if (v == null) return ''
const s = `${v}`
if (s == 'null' || s == 'undefined') return ''
return s
},
/** 底层取值:兼容 Map.get 与普通对象属性 */
readRaw(data, key) {
if (data == null) return null
if (typeof data.get == 'function') {
try {
return data.get(key)
} catch (err) {
return null
}
}
return data[key]
},
/** 读取 0|1 / boolean / 'true' 等为布尔 */
readFlag(data, key) {
const v = this.readRaw(data, key)
if (v == true || v === 1 || v === '1') return true
if (v == false || v === 0 || v === '0') return false
const n = this.readNum(data, key, NaN)
if (!isNaN(n)) return n == 1
const s = this.readStr(data, key).toLowerCase()
return s == 'true' || s == '1'
},
/**
* 本地图片路径转 nvue <image> 可用 src(需 file://)
* uni-app x 才倾向裸绝对路径
*/
toLocalImageSrc(filePath) {
if (filePath == null || filePath.length == 0) return ''
let p = `${filePath}`
if (p.indexOf('file://') == 0) {
return p
}
// 统一成 file:///绝对路径
if (p.charAt(0) != '/') {
p = '/' + p
}
return 'file://' + p
},
/** 毫秒格式化为 mm:ss(nvue 勿调组件 formatTime,无返回值) */
formatMs(ms) {
const t = Math.max(0, Math.floor((ms || 0) / 1000))
const m = Math.floor(t / 60)
const s = t % 60
const mm = m < 10 ? '0' + m : '' + m
const ss = s < 10 ? '0' + s : '' + s
return mm + ':' + ss
},
/** 将 queryState / @state 快照写入页面本地状态 */
applyState(data) {
this.playing = this.readFlag(data, 'playing')
this.recording = this.readFlag(data, 'recording')
this.seekable = this.readFlag(data, 'seekable')
this.volume = this.readNum(data, 'volume', this.volume)
this.currentMs = this.readNum(data, 'currentMs', this.currentMs)
this.totalMs = this.readNum(data, 'totalMs', this.totalMs)
const position = this.readNum(data, 'position', -1)
if (position >= 0 && this.seekable && !this.userSeeking) {
this.progress = Math.floor(position * 1000)
this.playTimeText = this.formatMs(this.currentMs) + ' / ' + this.formatMs(this.totalMs)
}
this.updatePlayPauseLabel()
this.recordLabel = this.recording ? '停止录制' : '录制'
},
/**
* 调用组件 queryState(),结果走 @state
* @param action 可选:快照回来后要执行的动作(如 'toggleRecord')
*/
requestState(action) {
const p = this.player()
if (p == null) {
this.toast('播放器未就绪')
return
}
if (typeof p.queryState != 'function') {
console.warn('[yt-vlc] queryState 不可用,请重新制作自定义基座')
return
}
this.pendingAction = action || ''
p.queryState()
},
/** @state:queryState 异步状态快照回调 */
onState(e) {
const data = this.eventMap(e)
this.applyState(data)
const action = this.pendingAction
this.pendingAction = ''
if (action == 'toggleRecord') {
this.doToggleRecord()
}
},
/** 开始/停止录制(以页面本地 recording 为准,勿调 isRecording) */
doToggleRecord() {
const p = this.player()
if (p == null) {
this.toast('播放器未就绪')
return
}
if (this.recording) {
// 乐观更新:nvue 上 @recordchange 可能延迟或丢失
this.recording = false
this.recordLabel = '录制'
this.setStatus('正在停止录制…')
p.stopRecording()
return
}
this.recording = true
this.recordLabel = '停止录制'
this.setStatus('录制中…')
this.toast('开始录制')
p.startRecording()
},
/** 重置进度条与时间文案 */
resetProgressUi() {
this.progress = 0
this.currentMs = 0
this.totalMs = 0
this.playTimeText = this.seekable ? '00:00 / 00:00' : '直播流(不可 seek)'
},
/** 根据 playing 更新「播放/暂停」按钮文案 */
updatePlayPauseLabel() {
this.playPauseLabel = this.playing ? '暂停' : '播放'
},
/** 打开并播放指定 URL */
playUrl(url) {
const p = this.player()
if (p == null) {
this.toast('播放器未就绪')
return
}
this.setStatus('正在打开:' + url)
this.showLoading = true
this.seekable = false
this.playing = false
this.progress = 0
this.currentMs = 0
this.totalMs = 0
this.playTimeText = '00:00 / 00:00'
this.updatePlayPauseLabel()
p.play(url)
},
/** 点击:播放 / 暂停切换 */
Play() {
const p = this.player()
if (p == null) return
p.togglePlayPause()
this.playing = !this.playing
this.updatePlayPauseLabel()
},
/** 点击:截图(优先 makeSnapshot,Android 可回退 takeSnapshot) */
onSnapshotClick() {
const p = this.player()
if (p == null) return
this.suppressLoadingUntil = Date.now() + 1500
this.showLoading = false
// iOS / 组件壳:makeSnapshot;Android 部分运行时 ref 落到原生 View,只有 takeSnapshot
if (typeof p.makeSnapshot === 'function') {
p.makeSnapshot()
} else if (typeof p.takeSnapshot === 'function') {
p.takeSnapshot()
} else {
this.toast('截图方法不可用')
}
},
/** 点击:录制 / 停止录制 */
onRecordClick() {
// 直接切换,不等 queryState(避免 @state 未到达导致无响应)
this.doToggleRecord()
},
/** 进度条拖动中(未松手):只更新 UI,不 seek */
Changing(e) {
if (!this.seekable) return
this.userSeeking = true
const value = e.detail.value
const pos = value / 1000.0
const total = this.totalMs
this.progress = value
this.playTimeText = this.formatMs(total * pos) + ' / ' + this.formatMs(total)
},
/** 进度条松手:seekTo(0~1) */
Change(e) {
this.userSeeking = false
if (!this.seekable) return
const p = this.player()
if (p == null) return
const value = e.detail.value
const pos = value / 1000.0
this.progress = value
p.seekTo(pos)
},
/** 点击:设置倍速(仅点播) */
onSpeed(rate) {
if (!this.seekable) {
this.toast('直播流不可调倍速')
return
}
const p = this.player()
if (p == null) return
p.setSpeed(rate)
this.toast('倍速:' + rate + 'x')
this.setStatus('倍速 ' + rate + 'x')
},
/** 音量条拖动中:实时 setVolume */
onVolumeChanging(e) {
const value = e.detail.value
this.volume = value
const p = this.player()
if (p != null) {
p.setVolume(value)
}
},
/** 音量条松手:确认音量 */
(e) {
const value = e.detail.value
this.volume = value
const p = this.player()
if (p == null) return
p.setVolume(value)
this.setStatus('音量:' + this.volume)
},
/** 点击:快退 10 秒(仅点播) */
onRewind() {
if (!this.seekable) {
this.toast('直播流不可快进快退')
return
}
const p = this.player()
if (p == null) return
p.rewind(10)
this.toast('快退 10 秒')
},
/** 点击:快进 10 秒(仅点播) */
onForward() {
if (!this.seekable) {
this.toast('直播流不可快进快退')
return
}
const p = this.player()
if (p == null) return
p.fastForward(10)
this.toast('快进 10 秒')
},
/** 点击:画面比例 auto / 4:3 / 16:9 */
onAspect(mode) {
const p = this.player()
if (p == null) return
// 直播切比例时不要改容器高度:会触发 NVLayouted→onStart→重新 attachViews,画面直接卡死
p.setAspect(mode)
if (mode == 'auto') {
this.toast('画面:还原(默认适配)')
} else {
this.toast('画面比例:' + mode)
}
},
/** 点击:播放输入框中的 URL */
onPlayUrl() {
const url = this.inputUrl.trim()
if (url.length == 0) {
this.toast('请输入或选择示例 URL')
return
}
this.playUrl(url)
},
/** 点击:停止播放 */
onStop() {
const p = this.player()
if (p != null) {
p.stop()
}
this.playing = false
this.recording = false
this.recordLabel = '录制'
this.resetProgressUi()
this.showLoading = false
this.setStatus('已停止')
this.updatePlayPauseLabel()
},
/** 快捷:示例 MP4 点播 */
playSampleMp4() {
this.inputUrl = this.sampleMp4
this.playUrl(this.sampleMp4)
},
/** 快捷:示例 RTSP 直播 */
playSampleRtsp() {
this.inputUrl = this.sampleRtsp
this.playUrl(this.sampleRtsp)
},
/** 快捷:示例海康 openUrl(VLC 可能不稳,见文档) */
playSampleOpenUrl() {
this.inputUrl = this.sampleOpenUrl
this.playUrl(this.sampleOpenUrl)
},
/** 点击:播放最近一次录制文件 */
playLastRecord() {
if (this.recordPath.length == 0) {
this.toast('暂无录制文件')
return
}
this.inputUrl = this.recordPath
this.setStatus('正在播放录制:' + this.recordPath)
this.playUrl(this.recordPath)
this.toast('正在播放录制文件')
},
/** @ready:引擎初始化完成 */
onReady() {
this.setStatus('引擎已就绪')
// 勿调 getVolume(),nvue 无返回值;音量以页面本地 volume 为准
},
/** @opening:正在打开媒体 / 建连 */
onOpening() {
this.showLoading = true
this.setStatus('正在连接…')
},
/** @buffering:缓冲进度 { percent } */
onBuffering(e) {
const data = this.eventMap(e)
const percent = this.readNum(data, 'percent', 0)
const suppress = Date.now() < this.suppressLoadingUntil
if (!suppress) {
this.showLoading = percent < 100
}
this.setStatus('缓冲中 ' + Math.floor(percent) + '%')
},
/** @playing:已开始播放 */
() {
this.playing = true
this.showLoading = false
this.updatePlayPauseLabel()
// seekable 由 @seekablechange 同步,勿调 isSeekable()
this.setStatus(this.seekable ? '播放中(点播,可拖进度)' : '播放中(直播流)')
},
/** @paused:已暂停 */
onPaused() {
this.playing = false
this.updatePlayPauseLabel()
this.setStatus('已暂停')
},
/** @stopped:已停止 */
onStopped() {
this.playing = false
this.recording = false
this.recordLabel = '录制'
this.showLoading = false
this.resetProgressUi()
this.updatePlayPauseLabel()
this.setStatus('已停止')
},
/** @ended:播放到结尾(点播) */
() {
this.playing = false
const url = this.inputUrl
if (url.indexOf('/openUrl/') >= 0 || url.indexOf('/openurl/') >= 0) {
this.setStatus('openUrl 无法用 VLC 稳定播放,请改用 HLS/RTP')
this.toast('openUrl 需改取流协议')
} else {
this.setStatus('播放结束')
this.toast('播放结束')
}
this.resetProgressUi()
this.updatePlayPauseLabel()
this.showLoading = false
},
/** @error:播放/录制等错误 { message } */
onError(e) {
this.showLoading = false
const data = this.eventMap(e)
const message = this.readStr(data, 'message')
this.setStatus('错误:' + (message.length > 0 ? message : '未知'))
this.toast(message.length > 0 ? message : '播放出错')
// 录制失败时回滚乐观状态
if (message.indexOf('录制') >= 0 && this.recording) {
this.recording = false
this.recordLabel = '录制'
}
},
/** @seekablechange:是否可拖进度 { seekable: 0|1 } */
ableChange(e) {
const data = this.eventMap(e)
const canSeek = this.readFlag(data, 'seekable')
this.seekable = canSeek
if (!canSeek) {
this.progress = 0
this.playTimeText = '直播流(不可 seek)'
this.setStatus('直播流:不可拖进度 / 倍速 / 快进快退')
} else {
this.setStatus('点播流:可拖动进度、调倍速、快进快退')
}
},
/** @timeupdate:点播进度 { currentMs, totalMs, position } */
(e) {
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)
if (totalMs <= 0 && position <= 0) return
this.currentMs = currentMs
this.totalMs = totalMs
this.progress = Math.floor(position * 1000)
this.playTimeText = this.formatMs(currentMs) + ' / ' + this.formatMs(totalMs)
},
/** @snapshot:截图完成 { success, filePath } */
onSnapshot(e) {
const data = this.eventMap(e)
const success = this.readFlag(data, 'success')
const filePath = this.readStr(data, 'filePath')
console.log('[yt-vlc] snapshot success=' + success + ' path=' + filePath)
// 调试:若仍为空,打印原始事件结构
if (!success && filePath.length == 0) {
try {
console.log('[yt-vlc] snapshot raw=' + JSON.stringify(e))
} catch (err) {
console.log('[yt-vlc] snapshot raw keys fail')
}
}
if (success && filePath.length > 0) {
this.snapshotPath = filePath
// nvue:不要给 file:// 加 ?t=,部分机型会加载失败
this.snapshotSrc = ''
const src = this.toLocalImageSrc(filePath)
// 下一帧再赋值,强制 image 刷新
setTimeout(() => {
this.snapshotSrc = src
this.showSnapshot = true
}, 30)
this.setStatus('截图已保存:' + filePath)
this.toast('截图成功')
} else {
this.showSnapshot = false
this.snapshotSrc = ''
this.setStatus('截图失败')
this.toast('截图失败')
}
},
/** <image> 截图加载成功 */
onSnapshotImageLoad() {
console.log('[yt-vlc] snapshot image loaded src=' + this.snapshotSrc)
},
/** <image> 截图加载失败:在 file:// 与裸路径之间切换重试 */
onSnapshotImageError() {
const path = this.snapshotPath
if (path.length == 0) return
// file:// 失败则试裸路径,再失败试带 file://
const cur = this.snapshotSrc
const withFile = this.toLocalImageSrc(path)
const bare = path.indexOf('/') == 0 ? path : ('/' + path)
if (cur.indexOf('file://') == 0) {
this.snapshotSrc = bare
console.log('[yt-vlc] snapshot retry bare=' + bare)
} else if (cur.length > 0 && cur.indexOf('file://') != 0) {
this.snapshotSrc = withFile
console.log('[yt-vlc] snapshot retry file=' + withFile)
}
},
/** @recordchange:录制状态 { recording, filePath } */
onRecordChange(e) {
const data = this.eventMap(e)
const recording = this.readFlag(data, 'recording')
const filePath = this.readStr(data, 'filePath')
console.log('[yt-vlc] record recording=' + recording + ' path=' + filePath)
if (!recording && filePath.length == 0) {
try {
console.log('[yt-vlc] record raw=' + JSON.stringify(e))
} catch (err) {
console.log('[yt-vlc] record raw keys fail')
}
}
this.recording = recording
if (recording) {
this.recordLabel = '停止录制'
this.setStatus('录制中…')
return
}
this.recordLabel = '录制'
if (filePath.length > 0) {
this.recordPath = filePath
this.showRecordActions = true
this.setStatus('录制完成,可点击「播放最近录制」')
this.toast('录制完成')
return
}
if (this.recordPath.length > 0) {
this.showRecordActions = true
this.setStatus('录制已停止,可播放:' + this.recordPath)
return
}
this.showRecordActions = false
this.setStatus('录制已停止(未找到输出文件)')
this.toast('录制结束,未找到文件')
},
}
}
}
</script>
<style>
/* 截图/录制区固定在按钮下方(result-box),整页可滚;勿再挪到别处 */
.root {
flex: 1;
flex-direction: column;
background-color: #F5F5F5;
}
.title {
align-self: stretch;
font-size: 16px;
font-weight: bold;
color: #222222;
padding: 12px 12px 4px 12px;
background-color: #FFFFFF;
}
.page-scroll {
flex: 1;
align-self: stretch;
}
.player-wrap {
align-self: stretch;
height: 360rpx;
background-color: #000000;
flex-direction: column;
}
.panel {
align-self: stretch;
margin: 4px 12px 0 12px;
background-color: #333333;
padding: 8px;
flex-direction: column;
}
.result-box {
align-self: stretch;
margin-top: 8px;
padding: 8px;
background-color: #2A2A2A;
flex-direction: column;
}
.result-title {
align-self: stretch;
margin-top: 4px;
font-size: 12px;
color: #FFFFFF;
font-weight: bold;
}
.seek-bar {
align-self: stretch;
}
.volume-row {
align-self: stretch;
}
.volume-bar {
flex: 1;
margin-left: 4px;
margin-right: 4px;
}
.row {
align-self: stretch;
flex-direction: row;
align-items: center;
margin-top: 6px;
flex-wrap: wrap;
}
.time-text {
flex: 1;
color: #FFFFFF;
font-size: 12px;
}
.label {
color: #CCCCCC;
font-size: 12px;
margin-right: 6px;
width: 36px;
}
.volume-text {
color: #FFFFFF;
font-size: 12px;
width: 36px;
text-align: right;
}
.hint-inline {
align-self: stretch;
color: #FFB74D;
font-size: 11px;
margin-top: 4px;
}
.btn-sm {
margin-left: 4px;
}
.btn-xs {
margin-left: 2px;
margin-right: 2px;
}
.btn-flex {
flex: 1;
margin-left: 4px;
margin-right: 4px;
}
.btn-block {
align-self: stretch;
margin-top: 6px;
}
.section {
align-self: stretch;
margin-top: 8px;
padding: 12px;
background-color: #FFFFFF;
flex-direction: column;
}
.status {
align-self: stretch;
font-size: 13px;
color: #333333;
}
.loading-tip {
align-self: stretch;
margin-top: 4px;
font-size: 12px;
color: #FF9800;
}
.section-title {
align-self: stretch;
margin-top: 12px;
font-size: 13px;
font-weight: bold;
color: #222222;
}
.snapshot {
align-self: stretch;
margin-top: 6px;
width: 350px;
height: 100px;
background-color: #111111;
}
.path-text {
align-self: stretch;
margin-top: 4px;
font-size: 11px;
color: #AAAAAA;
}
.url-input {
align-self: stretch;
margin-top: 8px;
padding: 8px;
height: 40px;
border-width: 1px;
border-color: #DDDDDD;
border-style: solid;
font-size: 13px;
background-color: #FAFAFA;
}
</style>
uni-app x 完整示例
以下为仓库演示页完整源码,与工程内
pages/index/index.uvue同步。
适用范围:仅 uni-app x(.uvue+ UTS)。
- Android / iOS:
<yt-vlc>,引擎为 LibVLC / MobileVLCKit;可用同步方法(如isPlaying()/isSeekable());截图用makeSnapshot()。- 鸿蒙(App-Harmony):只能用于 uni-app x;引擎为
@ohos/ijkplayer(不是 VLC);页面用<native-view>+YtVlcHarmonyNative,通过updateParams(JSON)发命令。经典 uni-app(.nvue)不能使用鸿蒙端。- 改插件 / HAR / 依赖后需重新制作对应平台自定义基座。
<template>
<!--
对齐 Android MainActivity:
- 点播:进度条可拖;可倍速 / 快进快退
- 直播流:进度条禁用;禁止倍速与快进快退
- 截图成功展示图片;录制成功显示路径并可回放
- 音量用进度条调节
-->
<view class="root">
<!-- Android/iOS=LibVLC;鸿蒙=@ohos/ijkplayer(仅 uni-app x,不可用于经典 uni-app nvue) -->
<text class="title">VLC 播放器演示(Android/iOS=VLC,鸿蒙=ijk)</text>
<!-- setPlayerConfig 示例见 methods.applyDefaultPlayerConfig / applyRtspPlayerConfig -->
<view class="player-wrap">
<!-- #ifdef APP-HARMONY -->
<!--
鸿蒙(仅 uni-app x):兼容模式 <yt-vlc> 不可用。
须用 <native-view> + YtVlcHarmonyNative.bindHarmonyWrappedBuilder。
@init → onHarmonyViewInit;@onEvent → onHarmonyEvent(也可 setEventHandler)。
-->
<native-view class="player" @init="onHarmonyViewInit" @onEvent="onHarmonyEvent"></native-view>
<!-- #endif -->
<!-- #ifndef APP-HARMONY -->
<!-- Android / iOS:UTS 兼容模式组件 yt-vlc(引擎为 LibVLC / MobileVLCKit) -->
<yt-vlc ref="vlcVideo" class="player" @ready="onReady" @opening="onOpening" @buffering="onBuffering"
@playing="" @paused="onPaused" @stopped="onStopped" @ended="" @error="onError"
@timeupdate="" @seekablechange="ableChange" @snapshot="onSnapshot"
@recordchange="onRecordChange"></yt-vlc>
<!-- #endif -->
</view>
<view class="panel">
<!-- 点播可拖进度;直播 disabled。block-size 必须用 :number,不能写字符串 -->
<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>
</view>
<!-- 仅点播可用倍速 -->
<view class="row">
<text class="label">倍速</text>
<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" :disabled="!seekable" @click="onSpeed(8)">8x</button>
<button class="btn-xs" size="mini" :disabled="!seekable" @click="onSpeed(16)">16x</button>
</view>
<!-- 用 v-show,避免 v-if 产生 CommentNode 导致 slider 强转崩溃 -->
<text v-show="!seekable" class="hint-inline">直播流不可调倍速 / 快进快退</text>
<!-- 音量:始终可用 -->
<view class="row volume-row">
<text class="label">音量</text>
<slider class="volume-bar" :value="volume" :min="0" :max="100" :step="1" activeColor="#2196F3"
backgroundColor="#555555" :block-size="18" @changing="onVolumeChanging" @change="" />
<text class="volume-text">{{ volume }}</text>
</view>
<!-- 仅点播可快进快退 -->
<view class="row">
<button class="btn-flex" size="mini" :disabled="!seekable" @click="onRewind">-10秒</button>
<button class="btn-flex" size="mini" :disabled="!seekable" @click="onForward">+10秒</button>
</view>
<view class="row">
<text class="label">比例</text>
<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>
</view>
<scroll-view class="scroll" direction="vertical">
<view class="section">
<text class="status">状态:{{ statusText }}</text>
<text v-show="showLoading" class="loading-tip">加载中 / 缓冲中…</text>
<text class="section-title">最近截图</text>
<image v-show="showSnapshot" class="snapshot" :src="snapshotSrc" mode="aspectFit"
@error="onSnapshotImageError"></image>
<text v-show="!showSnapshot" class="hint">暂无截图</text>
<text v-show="snapshotPath.length > 0" class="path-text">路径:{{ snapshotPath }}</text>
<!-- 录制成功:显示路径 + 播放按钮(对齐 Android) -->
<view v-show="showRecordActions" class="record-box">
<text class="section-title">最近录制</text>
<text class="path-text">录制文件:{{ recordPath }}</text>
<button class="btn-block" type="primary" @click="playLastRecord">播放最近录制</button>
</view>
<input class="url-input" v-model="inputUrl" placeholder="输入播放地址(mp4 / m3u8 / rtmp / rtsp)" />
<view class="row">
<button class="btn-flex" type="primary" @click="onPlayUrl">播放</button>
<button class="btn-flex" @click="onStop">停止</button>
</view>
<text class="section-title">快捷示例</text>
<button class="btn-block" @click="playSampleMp4">示例 MP4(点播,可拖进度)</button>
<button class="btn-block" @click="playSampleRtsp">示例 RTSP(直播流)</button>
<button class="btn-block" @click="playSampleOpenUrl">示例 openUrl(可能不稳)</button>
</view>
</scroll-view>
</view>
</template>
<script>
// #ifdef APP-HARMONY
/**
* 鸿蒙桥接类(仅 uni-app x / APP-HARMONY)。
* 实现:uni_modules/yt-vlc/utssdk/app-harmony/index.uts
* 底层引擎:@ohos/ijkplayer(经 HAR @yt/vlc-player),不是 LibVLC。
*/
import { YtVlcHarmonyNative } from '@/uni_modules/yt-vlc'
// #endif
export default {
data() {
return {
/** 页面状态文案,展示在「状态:」一行 */
statusText: '就绪。选择示例或输入 URL 后点击播放。',
/** 进度时间文案,格式一般为「当前 / 总时长」;直播时显示不可 seek 提示 */
playTimeText: '00:00 / 00:00',
/** 播放/暂停按钮文字,随 isPlaying 在「播放」「暂停」间切换 */
playPauseLabel: '播放',
/** 录制按钮文字,录制中为「停止录制」,否则为「录制」 */
recordLabel: '录制',
/** 进度条取值 0~1000,对应播放位置 0.0~1.0 */
progress: 0,
/** true=点播可 seek;false=直播流,禁用进度/倍速/快进快退 */
seekable: false,
/** 用户正在拖动进度条时为 true,避免 timeupdate 回写覆盖拖动中的值 */
userSeeking: false,
/** 是否显示「加载中 / 缓冲中」提示 */
showLoading: false,
/** 截图等操作后抑制 loading 提示的截止时间戳(毫秒) */
suppressLoadingUntil: 0,
/** 当前音量 0~100,与音量滑条双向同步 */
volume: 100,
/** 播放地址输入框内容(用户输入或快捷示例写入) */
inputUrl: '',
/** 最近一次截图的本地绝对路径(原始路径,用于展示与重试) */
snapshotPath: '',
/** 截图预览用的 image src(可能去掉或补回 file://) */
snapshotSrc: '',
/** 是否展示截图预览图 */
showSnapshot: false,
/** 最近一次录制输出文件的本地路径 */
recordPath: '',
/** 是否展示「最近录制」区域(含播放按钮) */
showRecordActions: false,
/** 快捷示例:HTTP MP4 点播地址 */
sampleMp4: 'https://vd3.bdstatic.com/mda-jggr72s70c52d06f/sc/mda-jggr72s70c52d06f.mp4',
/** 快捷示例:局域网 RTSP 监控地址(请按实际设备修改) */
sampleRtsp: 'rtsp://admin:lyzh2019@192.168.8.134:554/Streaming/Channels/102',
/** 快捷示例:海康 openUrl 形态(VLC 可能无法稳定播放) */
sampleOpenUrl: 'rtsp://122.225.221.210:554/openUrl/vsigJXoO7VI52de7d9c4d954a6abe511',
// #ifdef APP-HARMONY
// ---------- 以下仅鸿蒙(APP-HARMONY);勿在 Android/iOS data 里放 null as any ----------
/**
* 【鸿蒙】本地镜像的「是否播放中」。
* 鸿蒙无 yt-vlc ref,不能调 isPlaying();由 playing/paused/stopped/ended 事件维护。
*/
harmonyPlaying: false,
/**
* 【鸿蒙】本地镜像的「是否录制中」。
* 由 recordchange 的 recording 字段维护;用于录制按钮文案与 start/stop 切换。
*/
harmonyRecording: false,
/**
* 【鸿蒙】最近一次 timeupdate 的总时长(毫秒)。
* 拖进度条预览时间文案时用;点播有效,直播常为 0。
*/
harmonyTotalMs: 0,
/**
* 【鸿蒙】native-view @init 是否完成。
* true 后才可 sendVlcCmd;未就绪时播放会提示「播放器未初始化」。
*/
harmonyReady: false,
/**
* 【鸿蒙】YtVlcHarmonyNative 实例。
* @init 里 new;onUnload 里 destroy()。
* 发命令:harmonyNative.updateParams(JSON.stringify({cmd, ...}))
* 注意:不能写成 null as any 并打进 Android data,会触发 Kotlin NPE。
*/
harmonyNative: null as any,
// #endif
}
},
methods: {
/**
* 获取 yt-vlc 组件实例(Android/iOS)。
* 【鸿蒙】无 <yt-vlc> ref,恒返回 null;请走 sendVlcCmd / playHarmony。
*/
player() : YtVlcElement | null {
// #ifdef APP-HARMONY
return null
// #endif
// #ifndef APP-HARMONY
return this.$refs['vlcVideo'] as YtVlcElement | null
// #endif
},
/**
* 【鸿蒙】native-view @init 回调。
* 创建 YtVlcHarmonyNative、注册事件、标记 harmonyReady。
* 须等本回调完成后再 play,否则 harmonyNative 为空。
*/
onHarmonyViewInit(e : UniNativeViewInitEvent) {
// #ifdef APP-HARMONY
const native = new YtVlcHarmonyNative(e.detail.element)
// setEventHandler 与模板 @onEvent 二选一或并存;此处统一转到 onHarmonyEvent
native.setEventHandler((detail : any) => {
this.onHarmonyEvent({ detail: detail })
})
this.harmonyNative = native
this.harmonyReady = true
this.setStatus('引擎已就绪')
console.log('[yt-vlc-demo] native-view init ok')
// #endif
},
/**
* 【鸿蒙】向播放器下发一条 JSON 命令(核心调用入口)。
* 内部调用 harmonyNative.updateParams(json)。
*
* @param cmd 命令名,如 play / pause / stop / seekTo / setSpeed / snapshot …
* @param patch 附加字段(url、seekPos、rate、volume、aspect、muteAudio 等)
*
* 注意:
* - 每次带 _t=Date.now(),避免相同 JSON 不触发 HAR 侧更新
* - seek 用 seekPos(0~1),勿用 position(与 ArkUI 冲突)
* - 须在 onHarmonyViewInit 之后调用
*/
sendVlcCmd(cmd : string, patch : UTSJSONObject | null = null) {
// #ifdef APP-HARMONY
const payload : UTSJSONObject = {
cmd: cmd, // 命令名:play / pause / stop / seekTo / setSpeed / snapshot …
_t: Date.now(), // 时间戳:保证每次 JSON 不同,驱动 HAR 侧刷新
}
if (patch != null) {
// url:播放地址,配合 play / playRtsp
if (patch['url'] != null) payload['url'] = patch['url']
// seekPos:归一化进度 0~1,配合 seekTo(勿用 position,与 ArkUI 冲突)
if (patch['seekPos'] != null) payload['seekPos'] = patch['seekPos']
// seconds:相对秒数,配合 rewind / fastForward
if (patch['seconds'] != null) payload['seconds'] = patch['seconds']
// rate:倍速档位 1/2/4/8/16,配合 setSpeed
if (patch['rate'] != null) payload['rate'] = patch['rate']
// volume:音量 0~100,配合 setVolume
if (patch['volume'] != null) payload['volume'] = patch['volume']
// aspect:画面比例 auto | 4:3 | 16:9,配合 setAspect
if (patch['aspect'] != null) payload['aspect'] = patch['aspect']
// muteAudio:是否静音开播,配合 playRtsp / applyRtspConfig
if (patch['muteAudio'] != null) payload['muteAudio'] = patch['muteAudio']
}
const json = JSON.stringify(payload)
console.log('[yt-vlc-demo] send ' + json)
if (this.harmonyNative == null) {
console.log('[yt-vlc-demo] harmonyNative is null (native-view @init 未完成)')
this.toast('播放器未初始化')
this.setStatus('播放器未初始化:请确认已出现「引擎已就绪」后再点播放')
return
}
this.harmonyNative!.updateParams(json)
// #endif
},
/**
* 【鸿蒙】按 URL 开播。
* @param url 播放地址(http(s)/mp4/m3u8/rtsp 等)
* @param asRtsp true → playRtsp(内部会套 RTSP 配置);false → play(点播)
*/
playHarmony(url : string, asRtsp : boolean) {
// #ifdef APP-HARMONY
this.setStatus('正在打开:' + url)
this.showLoading = true
if (asRtsp) {
// muteAudio=false:保留声音;若监控预览要更快首帧可改 true
this.sendVlcCmd('playRtsp', { url: url, muteAudio: false } as UTSJSONObject)
} else {
this.sendVlcCmd('play', { url: url } as UTSJSONObject)
}
// #endif
},
/**
* 【鸿蒙】统一事件入口(@onEvent 或 setEventHandler)。
* detail.opt 为事件名,再分发到与 Android/iOS 同名的 onReady / … 处理函数,
* 便于三端共用后续 UI 逻辑。
*
* 载荷约定:e 或 e.detail 上至少含 opt;部分事件另带 percent / message /
* currentMs / totalMs / position / seekable / success / filePath / recording 等。
*/
onHarmonyEvent(e : any) {
// #ifdef APP-HARMONY
// 优先从 detail.opt 直读(ArkTS Record 经边界后,仅靠 Map 白名单会丢 opt)
let raw : any = e
if (raw != null && raw['detail'] != null) {
raw = raw['detail']
}
let opt = ''
if (raw != null && raw['opt'] != null) {
opt = `${raw['opt']}`
}
const data = this.eventMap(e)
if (opt.length == 0) {
opt = this.readStr(data, 'opt')
}
console.log('[yt-vlc-demo] event opt=' + opt)
// ready:引擎 / 原生视图已就绪,可开始发 play 等命令
if (opt == 'ready') {
this.onReady()
// opening:开始打开媒体(连接/解析中),通常显示 loading
} else if (opt == 'opening') {
this.onOpening()
// buffering:缓冲中;载荷可含 percent(0~100)
} else if (opt == 'buffering') {
this.onBuffering(e)
// playing:已开始播放;应更新 harmonyPlaying、隐藏 loading、刷新按钮文案
} else if (opt == 'playing') {
this.onPlaying()
// paused:已暂停
} else if (opt == 'paused') {
this.onPaused()
// stopped:已停止(用户 stop 或内部停止)
} else if (opt == 'stopped') {
this.onStopped()
// ended:点播播放到结尾
} else if (opt == 'ended') {
this.onEnded()
// error:失败;载荷可含 message
} else if (opt == 'error') {
this.onError(e)
// timeupdate:进度刷新;载荷含 currentMs / totalMs / position(0~1)
} else if (opt == 'timeupdate') {
this.onTimeUpdate(e)
// seekablechange:是否可拖进度;载荷含 seekable(点播 true,直播 false)
} else if (opt == 'seekablechange') {
this.onSeekableChange(e)
// snapshot:截图结果;载荷含 success、filePath
} else if (opt == 'snapshot') {
this.onSnapshot(e)
// recordchange:录制状态变化;载荷含 recording、filePath(结束时)
} else if (opt == 'recordchange') {
this.onRecordChange(e)
}
// #endif
},
/** 毫秒 → mm:ss(鸿蒙无组件 formatTime,页面侧自行格式化) */
formatMs(ms : number) : string {
let t = Math.floor(ms / 1000)
if (t < 0) t = 0
const m = Math.floor(t / 60)
const s = t % 60
const mm = m < 10 ? '0' + m : '' + m
const ss = s < 10 ? '0' + s : '' + s
return mm + ':' + ss
},
/** 弹出短时 Toast 提示 */
toast(msg : string) {
uni.showToast({ title: msg, icon: 'none', duration: 2000 })
},
/** 更新页面状态文案 statusText */
setStatus(text : string) {
this.statusText = text
},
/**
* 统一解析组件事件参数为 Map。
* - Android:多为 Map(偶发外包一层 detail)
* - iOS:常为 UTSJSONObject,没有 Map.has,需转换
*/
eventMap(e : any | null) : Map<string, any> {
if (e == null) {
return new Map<string, any>()
}
// #ifdef APP-IOS || APP-HARMONY
// iOS / 鸿蒙 embed:UTSJSONObject,事件体常在 detail
const obj = e as UTSJSONObject
const detail = obj['detail']
if (detail != null) {
return this.eventMap(detail)
}
return this.utsObjectToMap(obj)
// #endif
// #ifdef APP-ANDROID
// Android / 已是 Map
if (e instanceof Map) {
const map = e as Map<string, any>
if (map.has('detail')) {
const detail = map.get('detail')
if (detail != null) {
return this.eventMap(detail)
}
}
return map
}
// #endif
return new Map<string, any>();
},
/** 将 UTSJSONObject 拷贝为 Map(仅业务关心的字段) */
utsObjectToMap(obj : UTSJSONObject) : Map<string, any> {
const out = new Map<string, any>()
const keys = [
'opt',
'percent',
'message',
'currentMs',
'totalMs',
'position',
'seekable',
'success',
'filePath',
'recording',
'playing',
'volume',
]
for (let i = 0; i < keys.length; i++) {
const key = keys[i]
const v = obj[key]
if (v != null) {
out.set(key, v)
}
}
return out
},
/** 从事件 Map 读取数值字段,缺省或非法时返回 def */
readNum(data : Map<string, any>, key : string, def : number = 0) : number {
if (!data.has(key)) return def
const v = data.get(key)
if (v == null) return def
return parseFloat(`${v}`)
},
/** 从事件 Map 读取字符串字段,缺省时返回空串 */
readStr(data : Map<string, any>, key : string) : string {
if (!data.has(key)) return ''
const v = data.get(key)
if (v == null) return ''
return `${v}`
},
/** 从事件 Map 读取布尔语义字段(支持 1/true 等) */
readFlag(data : Map<string, any>, key : string) : boolean {
const n = this.readNum(data, key, 0)
if (n == 1) return true
const s = this.readStr(data, key).toLowerCase()
return s == 'true' || s == '1'
},
/** 本地绝对路径 → image 可用的 src(uni-app x App 优先无 file://) */
toLocalImageSrc(filePath : string) : string {
if (filePath.length == 0) return ''
if (filePath.indexOf('file://') == 0) {
return filePath.substring(7)
}
return filePath
},
/** 重置进度条与时间文案(停止/结束时调用) */
resetProgressUi() {
this.progress = 0
this.playTimeText = this.seekable ? '00:00 / 00:00' : '直播流(不可 seek)'
},
/** 根据播放器 isPlaying 同步「播放/暂停」按钮文字 */
updatePlayPauseLabel() {
// #ifdef APP-HARMONY
this.playPauseLabel = this.harmonyPlaying ? '暂停' : '播放'
return
// #endif
const p = this.player()
this.playPauseLabel = (p != null && p.isPlaying()) ? '暂停' : '播放'
},
/** 播放后同步一次是否可 seek(防止事件漏发) */
syncSeekableFromPlayer() {
// #ifdef APP-HARMONY
// 鸿蒙依赖 @seekablechange;此处不主动查
return
// #endif
const p = this.player()
if (p == null) return
const canSeek = p.isSeekable()
this.seekable = canSeek
if (!canSeek) {
this.progress = 0
this.playTimeText = '直播流(不可 seek)'
}
},
/** 打开并播放指定 URL(会重置 seekable/进度并显示 loading) */
playUrl(url : string) {
this.setStatus('正在打开:' + url)
this.showLoading = true
this.seekable = false
this.progress = 0
this.playTimeText = '00:00 / 00:00'
// #ifdef APP-HARMONY
this.harmonyTotalMs = 0
this.playHarmony(url, false)
return
// #endif
const p = this.player()
if (p == null) {
this.toast('播放器未就绪')
return
}
p.play(url)
},
/**
* 【配置示例】恢复插件默认 LibVLC 参数(低延迟、有声)。
* 等价于 p.setPlayerConfig(null) / p.resetPlayerConfig()
*
* 默认大致包含:
* --rtsp-tcp、--network-caching=0、--live-caching=0、
* --drop-late-frames、--skip-frames、--http-reconnect 等
*/
applyDefaultPlayerConfig() {
// #ifdef APP-HARMONY
this.sendVlcCmd('resetPlayerConfig', null)
return
// #endif
const p = this.player()
if (p == null) return
p.resetPlayerConfig()
// 也可显式传一份与默认等价的数组:
// const opts : string[] = [
// '--rtsp-tcp',
// '--network-caching=0',
// '--live-caching=0',
// '--file-caching=0',
// '--clock-jitter=0',
// '--clock-synchro=0',
// '--drop-late-frames',
// '--skip-frames',
// '--http-reconnect',
// ]
// p.setPlayerConfig(opts)
},
/**
* 【配置示例】RTSP 监控预览:低延迟;需要更快开播可加 --no-audio(无声音)。
* 必须在 play 之前调用,对下一次 play 生效。
*/
applyRtspPlayerConfig(muteAudio : boolean = false) {
// #ifdef APP-HARMONY
this.sendVlcCmd('applyRtspConfig', { muteAudio: muteAudio } as UTSJSONObject)
return
// #endif
const p = this.player()
if (p == null) return
// iOS MobileVLCKit:caching 建议 300;0 易拖长首帧。必须含 --rtsp-tcp
const opts : string[] = [
'--rtsp-tcp',
'--network-caching=300',
'--live-caching=300',
'--file-caching=300',
'--clock-jitter=0',
'--clock-synchro=0',
'--drop-late-frames',
'--skip-frames',
'--http-reconnect',
]
if (muteAudio) {
opts.push('--no-audio')
}
p.setPlayerConfig(opts)
},
/** 播放/暂停切换,并刷新按钮文案 */
Play() {
// #ifdef APP-HARMONY
this.sendVlcCmd('toggle', null)
// 标签稍后由 playing/paused 事件纠正
this.updatePlayPauseLabel()
return
// #endif
const p = this.player()
if (p == null) return
p.togglePlayPause()
this.updatePlayPauseLabel()
},
/** 点击截图:调用 makeSnapshot,并短暂抑制缓冲 loading */
onSnapshotClick() {
this.suppressLoadingUntil = Date.now() + 1500
this.showLoading = false
// #ifdef APP-HARMONY
this.sendVlcCmd('snapshot', null)
return
// #endif
const p = this.player()
if (p == null) return
// iOS:UniElement 自带 takeSnapshot,直接调用会进框架 API;用 $callMethod 调组件 expose
// #ifdef APP-IOS
p.makeSnapshot()
// #endif
// #ifndef APP-IOS
p.makeSnapshot()
// #endif
},
/** 点击录制:未在录则 startRecording,录制中则 stopRecording */
onRecordClick() {
// #ifdef APP-HARMONY
if (this.harmonyRecording) {
this.sendVlcCmd('stopRecord', null)
this.setStatus('正在停止录制…')
} else {
// 等原生 onRecordChanged(true) 再改按钮,避免 start 失败仍显示「停止录制」
this.sendVlcCmd('startRecord', null)
this.setStatus('正在开始录制…')
}
return
// #endif
const p = this.player()
if (p == null) return
if (p.isRecording()) {
p.stopRecording()
} else {
const ok = p.startRecording()
if (ok) {
this.recordLabel = '停止录制'
this.setStatus('录制中…')
this.toast('开始录制')
} else {
this.toast('开始录制失败,请确认正在播放')
}
}
},
/** 进度条拖动中:仅更新 UI 预览时间,不真正 seek */
Changing(e : UniSliderChangeEvent) {
if (!this.seekable) return
this.userSeeking = true
const value = e.detail.value
const pos = value / 1000.0
this.progress = value
// #ifdef APP-HARMONY
// 变量名勿与下方双端分支的 total 冲突:鸿蒙条件编译会同时保留两段代码做语法检查
const harmonyTotal = this.harmonyTotalMs
this.playTimeText = this.formatMs(harmonyTotal * pos) + ' / ' + this.formatMs(harmonyTotal)
return
// #endif
const p = this.player()
if (p == null) return
const total = p.getLength()
this.playTimeText = p.formatTime(total * pos) + ' / ' + p.formatTime(total)
},
/** 进度条松手:按 0~1 位置调用 seekTo */
Change(e : UniSliderChangeEvent) {
this.userSeeking = false
if (!this.seekable) return
const value = e.detail.value
const pos = value / 1000.0
this.progress = value
// #ifdef APP-HARMONY
this.sendVlcCmd('seekTo', { seekPos: pos } as UTSJSONObject)
return
// #endif
const p = this.player()
if (p == null) return
p.seekTo(pos)
},
/** 设置播放倍速(仅点播;直播会 toast 提示) */
onSpeed(rate : number) {
if (!this.seekable) {
this.toast('直播流不可调倍速')
return
}
// #ifdef APP-HARMONY
this.sendVlcCmd('setSpeed', { rate: rate } as UTSJSONObject)
this.toast('倍速:' + rate + 'x')
this.setStatus('倍速 ' + rate + 'x')
return
// #endif
const p = this.player()
if (p == null) return
p.setSpeed(rate)
this.toast('倍速:' + rate + 'x')
this.setStatus('倍速 ' + rate + 'x')
},
/** 音量滑条拖动中:实时 setVolume */
onVolumeChanging(e : UniSliderChangeEvent) {
const value = e.detail.value
this.volume = value
// #ifdef APP-HARMONY
this.sendVlcCmd('setVolume', { volume: value } as UTSJSONObject)
return
// #endif
const p = this.player()
if (p != null) {
p.setVolume(value)
}
},
/** 音量滑条松手:确认音量并刷新状态文案 */
(e : UniSliderChangeEvent) {
const value = e.detail.value
this.volume = value
// #ifdef APP-HARMONY
this.sendVlcCmd('setVolume', { volume: value } as UTSJSONObject)
this.setStatus('音量:' + value)
return
// #endif
const p = this.player()
if (p == null) return
p.setVolume(value)
this.setStatus('音量:' + p.getVolume())
},
/** 快退 10 秒(仅点播) */
onRewind() {
if (!this.seekable) {
this.toast('直播流不可快进快退')
return
}
// #ifdef APP-HARMONY
this.sendVlcCmd('rewind', { seconds: 10 } as UTSJSONObject)
this.toast('快退 10 秒')
return
// #endif
const p = this.player()
if (p == null) return
p.rewind(10)
this.toast('快退 10 秒')
},
/** 快进 10 秒(仅点播) */
onForward() {
if (!this.seekable) {
this.toast('直播流不可快进快退')
return
}
// #ifdef APP-HARMONY
this.sendVlcCmd('fastForward', { seconds: 10 } as UTSJSONObject)
this.toast('快进 10 秒')
return
// #endif
const p = this.player()
if (p == null) return
p.fastForward(10)
this.toast('快进 10 秒')
},
/** 设置画面比例:auto / 4:3 / 16:9 */
onAspect(mode : string) {
// #ifdef APP-HARMONY
this.sendVlcCmd('setAspect', { aspect: mode } as UTSJSONObject)
if (mode == 'auto') {
this.toast('画面:还原(默认适配)')
} else {
this.toast('画面比例:' + mode)
}
return
// #endif
const p = this.player()
if (p == null) return
p.setAspect(mode)
if (mode == 'auto') {
this.toast('画面:还原(默认适配)')
} else {
this.toast('画面比例:' + mode)
}
},
/** 播放输入框中的 URL */
onPlayUrl() {
const url = this.inputUrl.trim()
if (url.length == 0) {
this.toast('请输入或选择示例 URL')
return
}
this.playUrl(url)
},
/** 停止播放并重置进度 UI */
onStop() {
// #ifdef APP-HARMONY
this.sendVlcCmd('stop', null)
this.harmonyPlaying = false
this.resetProgressUi()
this.showLoading = false
this.setStatus('已停止')
this.updatePlayPauseLabel()
return
// #endif
const p = this.player()
if (p != null) {
p.stop()
}
this.resetProgressUi()
this.showLoading = false
this.setStatus('已停止')
this.updatePlayPauseLabel()
},
/** 快捷:示例 MP4 点播(默认 LibVLC 配置) */
playSampleMp4() {
// #ifdef APP-HARMONY
// 鸿蒙不要先 reset 再 play(连续两条命令易丢);直接 play 即可
this.inputUrl = this.sampleMp4
this.setStatus('正在打开:' + this.sampleMp4)
this.showLoading = true
this.seekable = false
this.progress = 0
this.playHarmony(this.sampleMp4, false)
return
// #endif
// 点播:用默认配置即可
this.applyDefaultPlayerConfig()
this.inputUrl = this.sampleMp4
this.playUrl(this.sampleMp4)
},
/** 快捷:示例 RTSP 直播(低延迟配置,默认静音加快开播) */
playSampleRtsp() {
// #ifdef APP-HARMONY
this.inputUrl = this.sampleRtsp
this.setStatus('正在打开:' + this.sampleRtsp)
this.showLoading = true
this.seekable = false
this.progress = 0
this.playTimeText = '直播流(不可 seek)'
this.playHarmony(this.sampleRtsp, true)
return
// #endif
// 已内置调用 setPlayerConfig,业务侧一般不必再手动调一次。
// iOS 改了 Swift 后必须「重新制作自定义调试基座」,仅点运行不够。
this.applyRtspPlayerConfig(true)
this.inputUrl = this.sampleRtsp
this.playUrl(this.sampleRtsp)
},
/** 快捷:示例海康 openUrl(VLC 可能不稳,见插件文档) */
playSampleOpenUrl() {
// #ifdef APP-HARMONY
this.inputUrl = this.sampleOpenUrl
this.setStatus('正在打开:' + this.sampleOpenUrl)
this.showLoading = true
this.seekable = false
this.playHarmony(this.sampleOpenUrl, true)
return
// #endif
this.applyRtspPlayerConfig(false)
this.inputUrl = this.sampleOpenUrl
this.playUrl(this.sampleOpenUrl)
},
/** 播放最近一次录制文件(录制成功后可用) */
playLastRecord() {
if (this.recordPath.length == 0) {
this.toast('暂无录制文件')
return
}
this.applyDefaultPlayerConfig()
this.inputUrl = this.recordPath
this.setStatus('正在播放录制:' + this.recordPath)
this.playUrl(this.recordPath)
this.toast('正在播放录制文件')
},
/** @ready:引擎就绪,同步音量并套用默认配置 */
onReady() {
this.setStatus('引擎已就绪')
// #ifdef APP-HARMONY
this.harmonyReady = true
return
// #endif
const p = this.player()
if (p != null) {
this.volume = p.getVolume()
// 引擎就绪后可先套默认配置;具体业务也可在每次 play 前再 setPlayerConfig
this.applyDefaultPlayerConfig()
}
},
/** @opening:正在连接媒体源 */
onOpening() {
this.showLoading = true
this.setStatus('正在连接…')
},
/** @buffering:缓冲进度更新(percent);截图后短时抑制 loading */
onBuffering(e : any) {
const data = this.eventMap(e)
const percent = this.readNum(data, 'percent', 0)
const suppress = Date.now() < this.suppressLoadingUntil
if (percent >= 100) {
// 缓冲结束:务必清掉 loading,并恢复播放中状态
if (!suppress) {
this.showLoading = false
}
// #ifdef APP-HARMONY
if (this.harmonyPlaying || this.seekable) {
this.setStatus(this.seekable ? '播放中(点播,可拖进度)' : '播放中(直播流)')
} else {
this.setStatus('缓冲完成')
}
// #endif
// #ifndef APP-HARMONY
this.setStatus(this.seekable ? '播放中(点播,可拖进度)' : '播放中(直播流)')
// #endif
return
}
if (!suppress) {
this.showLoading = true
}
this.setStatus('缓冲中 ' + Math.floor(percent) + '%')
},
/** @playing:开始播放,刷新按钮与 seekable */
() {
this.showLoading = false
// #ifdef APP-HARMONY
this.harmonyPlaying = true
// #endif
this.updatePlayPauseLabel()
this.syncSeekableFromPlayer()
this.setStatus(this.seekable ? '播放中(点播,可拖进度)' : '播放中(直播流)')
},
/** @paused:已暂停 */
onPaused() {
// #ifdef APP-HARMONY
this.harmonyPlaying = false
// #endif
this.updatePlayPauseLabel()
this.setStatus('已暂停')
},
/** @stopped:已停止,重置进度 UI */
onStopped() {
this.showLoading = false
// #ifdef APP-HARMONY
this.harmonyPlaying = false
// #endif
this.resetProgressUi()
this.updatePlayPauseLabel()
this.setStatus('已停止')
},
/** @ended:播放结束;openUrl 形态额外提示协议不兼容 */
() {
// #ifdef APP-HARMONY
this.harmonyPlaying = false
// #endif
const url = this.inputUrl
if (url.indexOf('/openUrl/') >= 0 || url.indexOf('/openurl/') >= 0) {
this.setStatus('openUrl 无法用 VLC 稳定播放,请改用 HLS/RTP')
this.toast('openUrl 需改取流协议')
} else {
this.setStatus('播放结束')
this.toast('播放结束')
}
this.resetProgressUi()
this.updatePlayPauseLabel()
this.showLoading = false
},
/** @error:播放错误,解析 message 并 toast */
onError(e : any) {
this.showLoading = false
const data = this.eventMap(e)
const message = this.readStr(data, 'message')
this.setStatus('错误:' + (message.length > 0 ? message : '未知'))
this.toast(message.length > 0 ? message : '播放出错')
},
/** @seekablechange:是否可拖进度变化,同步禁用/启用相关控件逻辑 */
ableChange(e : any) {
const data = this.eventMap(e)
const canSeek = this.readFlag(data, 'seekable')
this.seekable = canSeek
if (!canSeek) {
this.progress = 0
this.playTimeText = '直播流(不可 seek)'
this.setStatus('直播流:不可拖进度 / 倍速 / 快进快退')
} else {
this.setStatus('点播流:可拖动进度、调倍速、快进快退')
}
},
/** @timeupdate:更新进度条与时间文案(拖动中不回写) */
(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)
if (totalMs <= 0 && position <= 0) return
this.progress = Math.floor(position * 1000)
// #ifdef APP-HARMONY
this.harmonyTotalMs = totalMs
this.playTimeText = this.formatMs(currentMs) + ' / ' + this.formatMs(totalMs)
// seek/快退后若漏发 buffering=100,用进度推进清掉卡住的 loading
if (this.showLoading && currentMs > 0) {
this.showLoading = false
this.harmonyPlaying = true
this.updatePlayPauseLabel()
this.setStatus('播放中(点播,可拖进度)')
}
return
// #endif
const p = this.player()
if (p == null) return
this.playTimeText = p.formatTime(currentMs) + ' / ' + p.formatTime(totalMs)
},
/** @snapshot:截图完成,成功则展示预览与路径 */
onSnapshot(e : any) {
const data = this.eventMap(e)
const success = this.readFlag(data, 'success')
const filePath = this.readStr(data, 'filePath')
console.log('[yt-vlc] snapshot success=' + success + ' path=' + filePath)
if (success && filePath.length > 0) {
this.snapshotPath = filePath
// #ifdef APP-HARMONY
// 鸿蒙 image 对沙箱路径更认 file://
this.snapshotSrc = filePath.indexOf('file://') == 0 ? filePath : ('file://' + filePath)
// #endif
// #ifndef APP-HARMONY
this.snapshotSrc = this.toLocalImageSrc(filePath)
// #endif
this.showSnapshot = true
this.setStatus('截图已保存:' + filePath)
this.toast('截图成功')
} else {
this.showSnapshot = false
this.setStatus('截图失败')
this.toast('截图失败')
}
},
/** 截图 image 加载失败:无 file:// 时再试 file://,仍失败则提示看路径 */
onSnapshotImageError() {
// #ifdef APP-HARMONY
// 鸿蒙:file:// 失败再试绝对路径
if (this.snapshotPath.length > 0 && this.snapshotSrc.indexOf('file://') == 0) {
this.snapshotSrc = this.snapshotPath
console.log('[yt-vlc] snapshot retry abs src=' + this.snapshotSrc)
return
}
// #endif
// 无 file:// 失败时再试 file://
if (this.snapshotPath.length > 0 && this.snapshotSrc.indexOf('file://') != 0) {
this.snapshotSrc = 'file://' + this.snapshotPath
console.log('[yt-vlc] snapshot retry src=' + this.snapshotSrc)
} else {
this.toast('截图文件无法预览,路径见下方')
}
},
/** @recordchange:录制状态变化;停止后带回 filePath 供回放 */
onRecordChange(e : any) {
const data = this.eventMap(e)
const recording = this.readFlag(data, 'recording')
const filePath = this.readStr(data, 'filePath')
console.log('[yt-vlc] record recording=' + recording + ' path=' + filePath)
// #ifdef APP-HARMONY
this.harmonyRecording = recording
// #endif
if (recording) {
this.recordLabel = '停止录制'
this.setStatus('录制中…')
} else {
this.recordLabel = '录制'
if (filePath.length == 0) {
this.recordPath = ''
this.showRecordActions = false
this.setStatus('录制已停止(未找到输出文件)')
this.toast('录制结束,未找到文件')
} else {
this.recordPath = filePath
this.showRecordActions = true
this.setStatus('录制完成,可点击「播放最近录制」')
this.toast('录制完成')
}
}
},
},
/**
* 页面卸载。
* 【鸿蒙】必须 destroy YtVlcHarmonyNative,断开 handler / builder,避免泄漏。
*/
onUnload() {
// #ifdef APP-HARMONY
if (this.harmonyNative != null) {
this.harmonyNative!.destroy()
this.harmonyNative = null
}
this.harmonyReady = false
// #endif
},
}
</script>
<style>
.root {
flex: 1;
flex-direction: column;
background-color: #F5F5F5;
}
.title {
font-size: 16px;
font-weight: bold;
color: #222222;
padding: 12px 12px 4px 12px;
background-color: #FFFFFF;
}
.player-wrap {
margin: 8px 12px 0 12px;
height: 220px;
background-color: #333333;
}
.player {
width: 100%;
height: 220px;
}
.panel {
margin: 4px 12px 0 12px;
background-color: #333333;
padding: 8px;
}
.seek-bar {
width: 100%;
}
.volume-row {
width: 100%;
}
.volume-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;
}
.label {
color: #CCCCCC;
font-size: 12px;
margin-right: 6px;
width: 36px;
}
.volume-text {
color: #FFFFFF;
font-size: 12px;
width: 36px;
text-align: right;
}
.hint-inline {
color: #FFB74D;
font-size: 11px;
margin-top: 4px;
}
.btn-sm {
margin-left: 4px;
}
.btn-xs {
margin-left: 2px;
margin-right: 2px;
}
.btn-flex {
flex: 1;
margin-left: 4px;
margin-right: 4px;
}
.btn-block {
margin-top: 6px;
width: 100%;
}
.scroll {
flex: 1;
margin-top: 8px;
}
.section {
padding: 12px;
background-color: #FFFFFF;
}
.status {
font-size: 13px;
color: #333333;
}
.loading-tip {
margin-top: 4px;
font-size: 12px;
color: #FF9800;
}
.section-title {
margin-top: 12px;
font-size: 13px;
font-weight: bold;
color: #222222;
}
.hint {
margin-top: 6px;
font-size: 12px;
color: #999999;
}
.snapshot {
margin-top: 6px;
width: 100%;
height: 160px;
background-color: #EEEEEE;
}
.path-text {
margin-top: 6px;
font-size: 11px;
color: #666666;
}
.record-box {
margin-top: 8px;
padding: 8px;
background-color: #F0F7FF;
}
.url-input {
margin-top: 12px;
padding: 8px;
border-width: 1px;
border-color: #DDDDDD;
border-style: solid;
font-size: 13px;
background-color: #FAFAFA;
}
</style>

收藏人数:
购买源码授权版(
试用
使用 HBuilderX 导入示例项目
赞赏(2)
下载 524
赞赏 12
下载 12448677
赞赏 1935
赞赏
京公网安备:11010802035340号