更新记录

1.0.1(2026-09-02)

  • 优化文档,新增完整示例代码

1.0.0(2026-09-01)

  • 新版发布支持iOS、Android、HarmonyOS

平台兼容性

uni-app(5.01)

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

uni-app x(5.06)

Chrome Safari Android iOS 鸿蒙 微信小程序
× × √ √ √ ×

其他

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

特别提醒

  • 购买本插件前,请先试用,请先试用,请先试用,确认满足需求之后再行购买。虚拟物品一旦购买之后无法退款。
  • 如有使用上的疑问、bug,可以进交流群联系作者;
  • 请在合法范围内使用,若使用本插件做非法开发,本方概不负责;
  • 插件需先引入再打自定义基座后运行测试
  • 本插件为标准组件只能用于uniapp-x项目
  • uni-app项目请使用 ijkPlayer万能视频播放器uni-app兼容式组件,功能与本插件一致。
  • 可下载插件提供的示例项目测试、试用。

支持的主要功能:

  • 播放 HTTP/HTTPS MP4、HLS(m3u8)、RTSP、RTMP 和本地视频文件。
  • 播放、暂停、继续、停止和播放/暂停切换。
  • 点播进度拖动、相对跳转、快进和快退。
  • 1x、2x、4x、8x、16x 倍速。
  • 0~100 音量调节。
  • auto、4:3、16:9 画面比例。
  • 当前画面截图并取得本地文件路径。
  • 当前媒体源录制并取得本地 MP4 路径。
  • 播放录制完成的本地视频。
  • 播放状态、时长、进度和录制状态查询。
  • 页面层横屏全屏示例。

Android 插件支持 armeabi-v7a 和 arm64-v8a。应用工程也需要在 manifest.json 中配置对应 ABI:

{
  "app-android": {
    "minSdkVersion": 24,
    "distribute": {
      "abiFilters": [
        "armeabi-v7a",
        "arm64-v8a"
      ]
    }
  }
}

使用前准备

这是包含原生代码、AAR、Pod 和 HAR 的 UTS 插件。修改插件或首次集成后,需要分别重新制作对应平台的自定义调试基座,标准基座不能加载这些原生依赖。

页面通过 easycom 直接使用组件,不需要手动注册:

<yt-ijkplay ref="ijkVideo" class="player"></yt-ijkplay>

播放器必须有明确宽高:

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

页面取得组件实例时,类型名由 <yt-ijkplay> 标签生成,正确写法是:

player() : YtIjkplayComponentPublicInstance | null {
  return this.$refs['ijkVideo'] as YtIjkplayComponentPublicInstance | null
}

注意大小写必须是 YtIjkplayComponentPublicInstance,不要写成 YtIJKPlayComponentPublicInstance。

组件与事件绑定

<yt-ijkplay
  ref="ijkVideo"
  class="player"
  @ready="onReady"
  @opening="onOpening"
  @buffering="onBuffering"
  @playing=""
  @paused="onPaused"
  @stopped="onStopped"
  @ended=""
  @error="onError"
  @timeupdate=""
  @seekablechange="ableChange"
  @snapshot="onSnapshot"
  @recordchange="onRecordChange"
  @state="onState"
/>

方法

方法 参数 返回值 说明
play(url) string void 播放网络地址、本地绝对路径或支持的 content URI。
pause() 无 void 暂停并保留当前位置。
resume() 无 void 从暂停位置继续播放。
stop() 无 void 停止并清空当前媒体,之后需要重新调用 play。
togglePlayPause() 无 void 在播放和暂停之间切换。
seekTo(position) number,0~1 void 跳到归一化进度,仅可 seek 的点播有效。
seekBySeconds(seconds) number void 相对当前位置跳转,正数快进、负数快退。
fastForward(seconds) number void 快进指定秒数。
rewind(seconds) number void 快退指定秒数。
setSpeed(rate) number void 设置 1、2、4、8、16 倍速,直播通常无效。
setVolume(volume) number,0~100 void 设置播放音量。
getVolume() 无 number 获取当前音量,未就绪时返回默认值。
setAspect(mode) auto、4:3、16:9 void 设置画面显示比例。
makeSnapshot() 无 void 异步截图,结果通过 snapshot 事件返回。
startRecording() 无 boolean 请求开始录制,最终状态以 recordchange 为准。
stopRecording() 无 boolean 请求停止录制,文件路径通过 recordchange 返回。
toggleRecording() 无 boolean 在开始和停止录制之间切换。
isPlaying() 无 boolean 是否正在播放。
isRecording() 无 boolean 是否正在录制。
isSeekable() 无 boolean 当前媒体是否可拖动进度。
getLength() 无 number 媒体总时长,单位毫秒;直播通常为 0。
getTime() 无 number 当前时间,单位毫秒。
getPosition() 无 number 当前归一化进度 0~1。
formatTime(ms) 毫秒 number string 格式化为 mm:ss 或 hh:mm:ss。
queryState() 无 void 异步查询完整状态,结果通过 state 事件返回。
setPlayerConfig(options) string[] null void 设置下次播放使用的 IJK/FFmpeg 参数,null 表示恢复默认。
resetPlayerConfig() 无 void 恢复默认播放器参数。

事件

事件载荷中的布尔字段统一使用数字 0 或 1,避免三端布尔桥接差异。

事件 载荷字段 说明
ready 无 原生播放器视图已初始化。
opening 无 正在连接或解析媒体。
buffering percent 缓冲百分比 0~100。
playing 无 已进入播放状态。
paused 无 已暂停。
stopped 无 已停止。
ended 无 点播播放完成。
error message 播放、截图或录制错误信息。
timeupdate currentMs、totalMs、position 点播进度刷新。
seekablechange seekable 1 表示可拖动,0 表示不可拖动。
snapshot success、filePath 截图结果和本地路径。
recordchange recording、filePath 录制开始/停止状态;停止成功时返回 MP4 路径。
state playing、recording、seekable、volume、currentMs、totalMs、position queryState 的状态快照。

不同平台可能把事件载荷直接传为 Map,或包装在 detail 中。完整 Demo 的 eventMap、readNum、readStr 和 readFlag 已统一处理这些形式。

播放调用示例

播放 HTTPS MP4

const p = this.player()
if (p != null) {
  p.resetPlayerConfig()
  p.play('https://example.com/video.mp4')
}

播放 HLS(m3u8)

const p = this.player()
if (p != null) {
  p.resetPlayerConfig()
  p.play('https://example.com/live/index.m3u8')
}

播放 RTSP 或 RTMP

建议在 play 之前配置 RTSP TCP 和低延迟参数:

const p = this.player()
if (p != null) {
  const options : string[] = [
    '--rtsp-tcp',
    '--network-caching=300',
    '--packet-buffering=0',
    '--framedrop=1',
    '--analyzeduration=1000000',
    '--probesize=32768',
  ]
  p.setPlayerConfig(options)
  p.play('rtsp://user:password@192.168.1.10:554/Streaming/Channels/102')
}

需要录制声音时不要传 --no-audio。如需 UDP,可根据网络环境使用 --rtsp-udp。

播放本地文件或录制文件

const localPath = '/data/user/0/应用包名/files/IJKPlayerExports/video.mp4'
this.player()?.resetPlayerConfig()
this.player()?.play(localPath)

事件路径可能带 file://,传给 image 或播放器前可先去掉:

normalizeLocalPath(path : string) : string {
  if (path.indexOf('file://') == 0) {
    return path.substring(7)
  }
  return path
}

播放控制示例

const p = this.player()
if (p != null) {
  p.pause()
  p.resume()
  p.togglePlayPause()
  p.stop()
}

stop() 会清空媒体,停止后继续播放需要重新调用 play(url)。

进度、快进和快退

const p = this.player()
if (p != null && p.isSeekable()) {
  p.seekTo(0.5)          // 跳到 50%
  p.seekBySeconds(10)    // 快进 10 秒
  p.seekBySeconds(-10)   // 快退 10 秒
  p.fastForward(5)
  p.rewind(5)
}

RTSP/RTMP 直播通常不可 seek。页面应根据 seekablechange 或 isSeekable() 禁用进度条、倍速和快进快退。

倍速、音量和比例

const p = this.player()
if (p != null) {
  p.setSpeed(2)
  p.setVolume(80)
  const volume = p.getVolume()
  p.setAspect('auto')
  p.setAspect('4:3')
  p.setAspect('16:9')
}

截图和预览

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

onSnapshot(e : any) {
  const data = this.eventMap(e)
  if (this.readFlag(data, 'success')) {
    const path = this.readStr(data, 'filePath')
    this.snapshotSrc = this.normalizeLocalPath(path)
    this.showSnapshot = true
  }
},

截图是异步操作,不要使用 makeSnapshot() 的返回值判断成功,应监听 snapshot 事件。

录制、停止录制和回放

onRecordClick() {
  const p = this.player()
  if (p == null) return
  if (this.recording || p.isRecording()) {
    p.stopRecording()
  } else {
    p.startRecording()
  }
},

onRecordChange(e : any) {
  const data = this.eventMap(e)
  this.recording = this.readFlag(data, 'recording')
  if (!this.recording) {
    const path = this.normalizeLocalPath(this.readStr(data, 'filePath'))
    if (path.length > 0) {
      this.recordPath = path
    }
  }
},

playRecordedVideo() {
  if (this.recordPath.length == 0) return
  this.player()?.resetPlayerConfig()
  this.player()?.play(this.recordPath)
},

startRecording() 和 stopRecording() 的 boolean 只表示请求是否被原生层接受。最终录制状态和文件路径必须以 recordchange 事件为准。

状态查询

同步查询:

const p = this.player()
if (p != null) {
  const playing = p.isPlaying()
  const recording = p.isRecording()
  const seekable = p.isSeekable()
  const totalMs = p.getLength()
  const currentMs = p.getTime()
  const position = p.getPosition()
  const text = p.formatTime(currentMs)
}

异步查询完整状态:

onQueryState() {
  this.player()?.queryState()
},

onState(e : any) {
  const data = this.eventMap(e)
  const playing = this.readFlag(data, 'playing')
  const recording = this.readFlag(data, 'recording')
  const volume = this.readNum(data, 'volume', 100)
},

播放器配置参数

setPlayerConfig() 建议在下一次 play() 前调用。当前跨端桥接识别的常用参数包括:

  • --rtsp-tcp、--rtsp-udp
  • --network-caching=300
  • --packet-buffering=0
  • --framedrop=1
  • --analyzeduration=1000000
  • --probesize=32768
  • --no-audio
  • --no-video

恢复默认配置:

this.player()?.resetPlayerConfig()
// 或
this.player()?.setPlayerConfig(null)

各平台 IJK 版本和 HAR 能力不同,未识别的参数可能被忽略。

横屏全屏

全屏是页面层能力,不是 yt-ijkplay 插件方法。实现方式:

  1. 在 pages.json 中关闭系统导航并设定默认竖屏。
  2. 进入全屏时调用当前页面的 setPageStyle,把 pageOrientation 改为 landscape。
  3. 通过 class 让播放器容器铺满页面。
  4. 只切换样式,不使用 v-if 销毁 <yt-ijkplay>。
  5. 返回键和页面卸载时恢复 portrait。

页面配置:

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

关键方法:

applyPageOrientation(orientation : string) {
  const pages = getCurrentPages()
  if (pages.length == 0) return
  const page = pages[pages.length - 1]
  page.setPageStyle({
    pageOrientation: orientation,
  } as UTSJSONObject)
},

enterFullscreen() {
  if (this.isFullscreen) return
  this.isFullscreen = true
  this.applyPageOrientation('landscape')
},

exitFullscreen() {
  if (!this.isFullscreen) return
  this.isFullscreen = false
  this.applyPageOrientation('portrait')
},

平台注意事项

Android

  1. 最低 API 24,支持 armeabi-v7a 和 arm64-v8a。
  2. 开始录制前媒体必须完成 prepare。
  3. 截图和录制位于 App 外部沙盒的 IJKPlayerExports 目录,不需要公共存储权限。

iOS

  1. 最低 iOS 12.0,实际 Pod 配置为 ijkplayerssl 1.1.4。
  2. 局域网 RTSP 需要本地网络权限。
  3. ReplayKit 采集 App 播放音频,不采集麦克风。
  4. 截图和录制文件位于 Documents/IJKPlayerExports。
  5. ReplayKit、局域网摄像头、录制声音和画面方向必须使用真机验证。

鸿蒙

  1. 依赖随插件提供的 utssdk/app-harmony/libs/ytVlcLibrary.har。
  2. 底层依赖 @ohos/ijkplayer 2.0.9。
  3. 同步时长和音量查询受 HAR 能力限制,业务优先监听 timeupdate、seekablechange、recordchange 和 state。

更多好用插件推荐

完整 Demo

下面代码与项目中的 pages/index/index.uvue 保持一致,包含全部播放控制、事件处理、截图、录制、录制回放和横屏全屏逻辑。

<!--
  =============================================================================
  yt-ijkplay 完整调用示例(pages/index/index.uvue)
  =============================================================================
  本页展示标准组件 <yt-ijkplay> 的常用能力,可直接对照学习:
  - 播放网络 MP4、HLS(m3u8)、RTSP/RTMP 和本地绝对路径
  - 暂停/继续/停止、状态查询
  - 进度条拖动、快进快退、倍速、音量、画面比例
  - 截图预览、录制与回放
  - 录制完成后取得本地 MP4 路径并直接回放
  - 横屏全屏:切换 pageOrientation 并让播放器铺满页面,不销毁播放器

  pages.json 需要为本页配置 navigationStyle: "custom"、pageOrientation: "portrait"。
  使用前请先「制作自定义调试基座」,再用自定义基座运行。
  =============================================================================
-->
<template>
    <view class="root" :class="{ 'root-fullscreen': isFullscreen }">
        <!-- 使用自定义导航后,非全屏状态自行补齐状态栏和标题栏。 -->
        <view v-if="!isFullscreen" class="nav-bar">
            <view class="status-pad"></view>
            <view class="nav-content">
                <text class="nav-title">yt-ijkplay 标准组件演示</text>
            </view>
        </view>

        <!--
            播放器必须设置明确宽高;全屏仅切换容器样式,不使用 v-if 销毁播放器。
        -->
        <view class="player-wrap" :class="{ 'player-wrap-full': isFullscreen }">
            <yt-ijkplay ref="ijkVideo" class="player" :class="{ 'player-full': isFullscreen }" @ready="onReady"
                @opening="onOpening" @buffering="onBuffering" @playing="" @paused="onPaused"
                @stopped="onStopped" @ended="" @error="onError" @timeupdate=""
                @seekablechange="ableChange" @snapshot="onSnapshot"
                @recordchange="onRecordChange" @state="onState"></yt-ijkplay>

            <!-- 全屏时保留一个悬浮退出按钮;其它控制面板隐藏。 -->
            <button v-if="isFullscreen" class="fs-exit-btn" size="mini" type="primary"
                @click="exitFullscreen">退出全屏</button>
        </view>

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

            <view class="row">
                <text class="time-text">{{ playTimeText }}</text>
                <button class="btn-sm" type="primary" size="mini" @click="Play">{{ playPauseLabel }}</button>
                <button class="btn-sm" size="mini" @click="onSnapshotClick">截图</button>
                <button class="btn-sm" size="mini" @click="onRecordClick">{{ recordLabel }}</button>
                <button class="btn-sm" size="mini" @click="onQueryState">状态</button>
                <button class="btn-sm" size="mini" @click="toggleFullscreen">全屏</button>
            </view>
            <!-- 分别调用 pause / resume / stop,便于查看三个独立 API 的写法。 -->
            <view class="row">
                <button class="btn-flex" size="mini" @click="onPause">暂停</button>
                <button class="btn-flex" size="mini" @click="">继续</button>
                <button class="btn-flex" size="mini" @click="onStop">停止</button>
            </view>

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

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

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

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

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

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

            <!-- 输入框同时支持网络地址和 App 沙盒内的本地绝对路径 -->
            <input class="url-input" v-model="inputUrl" placeholder="输入 MP4/HLS/RTSP/RTMP/本地路径" />
            <view class="row">
                <button class="btn-flex" type="primary" @click="onPlayUrl">播放</button>
                <button class="btn-flex" @click="onStop">停止</button>
            </view>
            <button class="btn-block" @click="playSampleMp4">播放示例 MP4</button>
            <button class="btn-block" @click="playSampleHls">播放示例 HLS(m3u8)</button>
            <button class="btn-block" @click="playSampleRtsp">播放示例 RTSP 监控流</button>
        </scroll-view>
    </view>
</template>

<script>
    /**
     * 演示页逻辑说明:
     * 1. player() 获取组件实例(类型名由 easycom 按标签生成)
     * 2. eventMap / readNum / readFlag 统一解析事件 Map(布尔用 0|1)
     * 3. @seekablechange 控制进度条、倍速、快进是否可点
     * 4. @recordchange 维护 recording / recordLabel / recordPath
     * 5. @state 展示 queryState() 的异步状态快照
     * 6. isFullscreen + setPageStyle 实现横屏全屏,不销毁播放器
     */
    export default {
        data() {
            return {
                /** 是否处于横屏全屏状态。 */
                isFullscreen: false,
                /** 顶部状态文案:缓冲中 / 播放中 / 错误等 */
                statusText: '就绪',
                /** 进度旁时间:mm:ss / mm:ss */
                playTimeText: '00:00 / 00:00',
                /** 播放/暂停按钮文案 */
                playPauseLabel: '播放',
                /** 录制按钮文案:录制 ↔ 停止录制 */
                recordLabel: '录制',
                /** 是否正在录制(以 @recordchange 为准,比 isRecording() 更稳) */
                recording: false,
                /** 最近一次录制文件本地路径 */
                recordPath: '',
                /** 进度条 0~1000,对应 position * 1000 */
                progress: 0,
                /** 音量 0~100 */
                volume: 100,
                /** 是否可拖进度(点播 true,直播 false) */
                seekable: false,
                /** 用户正在拖进度条时为 true,避免 timeupdate 抢进度 */
                userSeeking: false,
                /** 输入框里的播放地址 */
                inputUrl: '',
                /** 截图预览路径 */
                snapshotSrc: '',
                showSnapshot: false,
                /** 示例点播地址(可换成自己的) */
                sampleMp4: 'https://vd3.bdstatic.com/mda-jggr72s70c52d06f/sc/mda-jggr72s70c52d06f.mp4',
                /** Apple 官方 HLS 点播示例,用于验证 m3u8 播放和拖动。 */
                sampleHls: 'https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_4x3/bipbop_4x3_variant.m3u8',
                /** 示例 RTSP(请改成你局域网摄像头地址) */
                //例如:rtsp://admin:123456@192.168.11.xx:554/Streaming/Channels/102
                sampleRtsp: 'rtsp://<username>:<密码>@<ip>:554/Streaming/Channels/102',
            }
        },
        onBackPress() : boolean | null {
            // Android 返回键在全屏时优先退出全屏,不直接退出页面。
            if (this.isFullscreen) {
                this.exitFullscreen()
                return true
            }
            return null
        },
        onUnload() {
            // 离开页面前恢复竖屏,避免下一页面继续保持横屏。
            if (this.isFullscreen) {
                this.applyPageOrientation('portrait')
                this.isFullscreen = false
            }
        },
        methods: {
            /**
             * 获取 yt-ijkplay 标准组件公开实例。
             * 通过公开实例调用 play、seekTo、makeSnapshot、startRecording 等方法。
             */
            player() : YtIjkplayComponentPublicInstance | null {
                return this.$refs['ijkVideo'] as YtIjkplayComponentPublicInstance | null
            },

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

            /** 进入横屏全屏;仅改变页面方向和布局,不重建播放器。 */
            enterFullscreen() {
                if (this.isFullscreen) return
                this.isFullscreen = true
                this.applyPageOrientation('landscape')
            },

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

            /** 在横屏全屏和普通竖屏之间切换。 */
            toggleFullscreen() {
                if (this.isFullscreen) {
                    this.exitFullscreen()
                } else {
                    this.enterFullscreen()
                }
            },

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

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

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

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

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

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

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

            /** MP4、HLS 和本地文件使用插件默认 IJK 参数。 */
            applyDefaultPlayerConfig() {
                this.player()?.resetPlayerConfig()
            },

            /**
             * RTSP/RTMP 低延迟常用参数。
             * iOS 会把这些字符串映射为 IJK/FFmpeg option;鸿蒙以 HAR 支持能力为准。
             * @param muteAudio true 时禁用音轨;需要录制声音时必须保持 false。
             */
            applyRtspPlayerConfig(muteAudio : boolean = false) {
                const opts : string[] = [
                    '--rtsp-tcp',
                    '--network-caching=300',
                    '--packet-buffering=0',
                    '--framedrop=1',
                    '--analyzeduration=1000000',
                    '--probesize=32768',
                ]
                if (muteAudio) opts.push('--no-audio')
                this.player()?.setPlayerConfig(opts)
            },

            /** 判断地址是否属于通常不可拖动的 RTSP/RTMP 直播协议。 */
            isLiveAddress(url : string) : boolean {
                const value = url.toLowerCase()
                return value.indexOf('rtsp://') == 0 || value.indexOf('rtmp://') == 0
            },

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

            /**
             * 播放输入框地址。
             * RTSP/RTMP 自动应用低延迟参数,其余协议恢复默认参数。
             */
            onPlayUrl() {
                const url = this.inputUrl.trim()
                if (url.length == 0) {
                    this.toast('请输入 URL')
                    return
                }
                if (this.isLiveAddress(url)) {
                    this.applyRtspPlayerConfig(false)
                } else {
                    this.applyDefaultPlayerConfig()
                }
                this.playUrl(url)
            },

            /** 停止并清空当前媒体;之后需重新调用 play。 */
            onStop() {
                this.player()?.stop()
                this.progress = 0
                this.setStatus('已停止')
                this.updatePlayPauseLabel()
            },

            /** 暂停当前媒体并保留播放位置。 */
            onPause() {
                this.player()?.pause()
            },

            /** 从暂停位置恢复播放。 */
            () {
                this.player()?.resume()
            },

            /** 在播放和暂停之间切换。 */
            Play() {
                this.player()?.togglePlayPause()
                this.updatePlayPauseLabel()
            },

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

            /** 异步查询完整状态,结果由页面的 @state 回调接收。 */
            onQueryState() {
                this.player()?.queryState()
            },

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

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

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

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

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

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

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

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

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

            /** 播放网络 MP4 点播示例。 */
            playSampleMp4() {
                this.inputUrl = this.sampleMp4
                this.applyDefaultPlayerConfig()
                this.playUrl(this.sampleMp4)
            },

            /** 播放 Apple 官方 HLS(m3u8) 点播示例。 */
            playSampleHls() {
                this.inputUrl = this.sampleHls
                this.applyDefaultPlayerConfig()
                this.playUrl(this.sampleHls)
            },

            /** 播放局域网 RTSP 监控流,并启用 TCP/低延迟参数。 */
            playSampleRtsp() {
                this.inputUrl = this.sampleRtsp
                this.applyRtspPlayerConfig(false)
                this.playUrl(this.sampleRtsp)
            },

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            /**
             * `queryState()` 的异步结果。
             * 事件包含 playing、recording、seekable、volume、currentMs、totalMs、position。
             */
            onState(e : any) {
                const data = this.eventMap(e)
                const playing = this.readFlag(data, 'playing')
                const recording = this.readFlag(data, 'recording')
                const seekable = this.readFlag(data, 'seekable')
                const volume = Math.floor(this.readNum(data, 'volume', this.volume))
                const currentMs = this.readNum(data, 'currentMs', 0)
                const totalMs = this.readNum(data, 'totalMs', 0)
                const p = this.player()
                const time = p != null
                    ? p.formatTime(currentMs) + ' / ' + p.formatTime(totalMs)
                    : '00:00 / 00:00'
                this.toast('播放=' + (playing ? '是' : '否')
                    + ' 录制=' + (recording ? '是' : '否')
                    + ' 可拖动=' + (seekable ? '是' : '否')
                    + ' 音量=' + volume
                    + ' 时间=' + time)
            },
        },
    }
</script>

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

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

    /* pages.json 使用 custom 导航,非全屏时在页面中自行绘制。 */
    .nav-bar {
        background-color: #FFFFFF;
    }

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

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

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

    /* 播放器外框:标准组件要求父容器拥有明确高度。 */
    .player-wrap {
        margin: 8px 12px 0 12px;
        height: 220px;
        background-color: #333333;
    }

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

    /* 横屏后让播放器容器铺满整页,原播放器实例保持不变。 */
    .player-wrap-full {
        position: absolute;
        left: 0;
        top: 0;
        right: 0;
        bottom: 0;
        margin: 0;
        height: 100%;
        z-index: 100;
    }

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

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

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

    .seek-bar {
        width: 100%;
    }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

隐私、权限声明

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

Android 需要网络状态、网络访问和唤醒锁权限;iOS 播放局域网 RTSP 时需要本地网络访问权限。

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

插件不采集任何数据

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

无