更新记录

1.0.0(2026-09-01)

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

平台兼容性

uni-app

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

uni-app x(5.06)

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

其他

多语言 暗黑模式 宽屏模式
× ×

特别提醒

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

支持的主要功能:

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

Android 插件支持 armeabi-v7aarm64-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) auto4:316: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 恢复默认播放器参数。

事件

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

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

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

播放调用示例

播放 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-v7aarm64-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(请改成你局域网摄像头地址) */
                sampleRtsp: 'rtsp://admin:lyzh2019@192.168.8.135:554/Streaming/Channels/102',
            }
        },
        onBackPress() : boolean | null {
            // Android 返回键在全屏时优先退出全屏,不直接退出页面。
            if (this.isFullscreen) {
                this.exitFullscreen()
                return true
            }
            return null
        },
        onUnload() {
            // 离开页面前恢复竖屏,避免下一页面继续保持横屏。
            if (this.isFullscreen) {
                this.applyPageOrientation('portrait')
                this.isFullscreen = false
            }
        },
        methods: {
            /**
             * 获取 yt-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. 本插件是否包含广告,如包含需详细说明广告表达方式、展示频率:

暂无用户评论。