更新记录

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 @initnew YtVlcHarmonyNative)。

鸿蒙接入要点(必读)

重要限制

  1. 鸿蒙端只能用于 uni-app x 项目(页面为 .uvue)。经典 uni-app(Vue3 + .nvue无法像 Android/iOS 那样使用 <yt-vlc> 跑鸿蒙播放器(UTS 兼容模式组件不支持 app-harmony;且鸿蒙 nvue 非原生渲染)。
  2. 引擎是 @ohos/ijkplayer,不是 VLC。参数仍可用「类 LibVLC」字符串(如 --rtsp-tcp),由 HAR 映射为 ijk setOption;未映射项可能无效。
  3. 页面不能<yt-vlc> / <embed tag="ytvlc">,必须用下面的 native-view 方案。

1. 依赖与基座

  1. 确认 HAR:uni_modules/yt-vlc/utssdk/app-harmony/libs/ytVlcLibrary.har
  2. 依赖见 utssdk/app-harmony/config.json@yt/vlc-player + @ohos/ijkplayer@2.0.9
  3. 修改鸿蒙原生 / 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 aspectauto/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 同步)。


接入步骤

  1. 将本插件放入项目 uni_modules/yt-vlc(或从插件市场导入)。
  2. 修改原生代码 / 首次接入后:重新制作对应平台的自定义调试基座,再用该基座运行。
  3. Android 已声明网络等权限;iOS 若播放局域网摄像头,建议在工程 Info.plist 配置本地网络用途说明(见下文)。
  4. 鸿蒙:仅 uni-app x;按上文「鸿蒙接入要点」接入。

Android

  • 依赖见 utssdk/app-android/config.json
  • 权限见 utssdk/app-android/AndroidManifest.xmlINTERNET 等)

iOS

  • CocoaPods:utssdk/app-ios/config.jsonMobileVLCKit 3.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/ijkplayer 2.0.9(不是 VLC / LibVLC)
  • 本地 HARutssdk/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() 异步状态快照,结果走 @statenvue 按需查询推荐
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

  1. 页面本地维护状态playing / recording / seekable / currentMs / totalMs / volume 等)。
  2. 推送:用事件持续同步(播放中不必轮询)。
  3. 按需拉取:调 queryState(),在 @state 里更新本地字段(适合多播放器、进入页时校准)。
想知道的状态 nvue 推荐做法 不要这样做
是否在播 @playing / @paused / @stopped / @ended,或 queryStateplaying isPlaying() 当返回值用
是否在录 @recordchangerecording,或 queryStaterecording isRecording() 当返回值用
可否拖进度 @seekablechangeseekable 仅靠 isSeekable() 返回值
当前时间 / 总时长 / 进度 @timeupdatecurrentMs / totalMs / position(点播) getTime() / getLength() / getPosition() 返回值
音量 页面自己 setVolume 时记下,或 queryStatevolume getVolume() 返回值
录制文件路径 @recordchange 停止后的 filePath 依赖 stopRecording() 返回值

@state 字段(queryState()

字段 含义
playing / recording / seekable 01(建议当 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 / iOSref 强转 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)。
  • 务必使用 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)

与本插件的对接顺序(仅通用 RTSP / 可被 VLC 播放的地址)

  1. 直连:按上表拼好 RTSP;平台:调开放接口拿到临时 URL(若平台要求海康 SDK,直接用 yt-hikvideo)。
  2. setPlayerConfig(含 --rtsp-tcp,预览可选 --no-audio)。
  3. play(url)
  4. @playing / @error 处理结果;直播以不可 seek 为准(x:isSeekable()==false;nvue:@seekablechange)。
  5. 持续 @error / 长时间无 @playing:检查 URL 与网络;海康综合安防场景请换 yt-hikvideo

注意事项

  1. 自定义基座:改config.json、权限或 Pods 后必须重新制作基座。
  2. 截图:对外优先 makeSnapshot();Android nvue 可兼容 takeSnapshot();iOS 不要调 takeSnapshot()
  3. 直播 vs 点播:以 @seekablechange /(x 端)isSeekable() 为准;直播勿依赖拖进度与倍速。
  4. RTSP / 海康:建议 playsetPlayerConfig 并包含 --rtsp-tcp。海康综合安防平台流用 VLC 可能播不了,请改用 yt-hikvideo(插件市场 id=26041);详见「海康安防取流说明」。
  5. 录制:通常需正在播放;停止后以 @recordchange 的路径为准。
  6. 状态读取:uni-app x 可用同步 getter;uni-app nvue 必须用事件镜像或 queryState+@state,详见「获取播放器状态:uni-app vs uni-app x」。
  7. 离开页面:组件 NVBeforeUnloadonStop + releasenavigateBack 销毁页时一般会停播。仅 onHide / 未销毁的 tab 页建议业务再调一次 stop()

参考文档


更多好用插件推荐




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>

隐私、权限声明

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

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

插件不采集任何数据

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