HarmonyOS:AVPlayer 与 XComponent 联手打造定制化视频播放器

引言

最近在学习HarmonyOS,然后遇到一个需求,做一个视频播放器,我想这不简单嘛直接用一个Video组件

ts 复制代码
Video({
  src: this.videoSrc, // 视频的数据源,支持本地视频和网络视频
  previewUri: this.previewUri, // 预览图片路径
  currentProgressRate: this.curRate, // 视频播放倍速
  controller: this.controller // 视频控制器
})
  .autoPlay(true) // 是否自动播放
  .width("100%")
  .objectFit(ImageFit.Contain)

效果


看就是这么简单

但是我看官方文档原文实现视频播放器有两种方法

  • AVPlayer:功能较完善的音视频播放ArkTS/JS API,集成了流媒体和本地资源解析,媒体资源解封装,视频解码和渲染功能,适用于对媒体资源进行端到端播放的场景,可直接播放mp4、mkv等格式的视频文件。
  • Video组件:封装了视频播放的基础能力,需要设置数据源以及基础信息即可播放视频,但相对扩展能力较弱。Video组件由ArkUI提供能力,相关指导请参考UI开发文档-Video组件

看起来AVPlayer好像更强?有点意思🤔,那我们试试AVPlayer如何实现视频播放器咯🤓

一、AVPlayer✨

1.创建播放器▶️

首先第一步就是创建AVPlayer

ts 复制代码
this.avPlayer = await media.createAVPlayer()

2.设置播放资源路径🔧

设置播放资源路径 可以是 本地资源 或 网络资源注意:网络资源需要在module.json5中声明ohos.permission.INTERNET权限

ts 复制代码
// 获取网络资源
// this.avPlayer.url = "http://www.xxx.cn/xxx";
// 获取本地资源  存放在 resource/rawflie 文件夹中
let context = getContext(this) as common.UIAbilityContext
let fileDescriptor = await context.resourceManager.getRawFd("xxx.mp4")
this.avPlayer.fdSrc = fileDescriptor

3.设置播放参数🎈

ts 复制代码
// 设置音量
this.avPlayer.setVolume(0.5)
// 设置播放倍数
this.avPlayer.setSpeed(media.PlaybackSpeed.SPEED_FORWARD_1_00_X)

4.播放控制⏯️

播放、暂停、跳转、停止等播放控制操作是视频播放过程中的常用功能

ts 复制代码
// 视频播放
avPlayer.play();
// 视频暂停
avPlayer.pause();
// 视频停止播放
avPlayer.stop();

好吧简单介绍完毕我们直接开始实战实现一个播放器吧!😏

二、开始实现✨

1.创建 AVPlayer🍍

首先创建ets/controller/VideoController.ets文件

在里面创建AVPlayer 初始代码如下

ts 复制代码
import { media } from '@kit.MediaKit';
import { PlayerModel } from '../model/PlayerModel'

@Observed
export class VideoController {
  public playerModel: PlayerModel  // 视频参数数据模型
  private surfaceID: string = ''; // surfaceID用于播放画面显示,通过Xcomponent获取 后面我会讲到
  public avPlayer: media.AVPlayer | null = null  // avPlayer 实例
  private fdSrc: string = ''   // 本地资源url
  private url: string = ''   // 网络资源url
  private status: number = 2 // 播放状态 1表示播放中  2表示暂停
  private seekTime: number = 0 // 用于设置跳转时间
  private duration: number = 0 // 视频总时长

  constructor() {
    this.playerModel = new PlayerModel()
    this.createAVPlayer()
  }

  // 创建 AVPlayer 实例
  async createAVPlayer() {
    let avPlayer: media.AVPlayer = await media.createAVPlayer();
    this.avPlayer = avPlayer;
  }
}

2.创建PlayerMode视频参数数据模型📄

创建/ets/model/PlayerMode.ets 文件

PlayerMode为视频参数的数据模型 代码如下

ts 复制代码
import { media } from '@kit.MediaKit';

@Observed
export class PlayerModel {
  videoHeight: string = '25.6%'
  videoWidth: string = "100%";
  videoMargin: string = '0';
  videoPosition: FlexAlign = FlexAlign.Center;
  progressVal: number = 0
  currentTime: string = ''
  totalTime: string = ''
  playSeed:media.PlaybackSpeed = 1
}

里面的值你们可以声明为常量 ,我这里就偷偷懒😉,你们不要学我哈

3.注册AVPlayer回调函数🧨

ts 复制代码
bindState() {
  if (!this.avPlayer) {
    return
  }
  // seek操作结果回调函数
  this.avPlayer.on('seekDone', (seekDoneTime: number) => {
    console.info(`AVPlayer seek succeeded, seek time is ${seekDoneTime}`);
  })
  // error回调监听函数,当avPlayer在操作过程中出现错误时调用 reset接口触发重置流程
  this.avPlayer.on('error', (err: BusinessError) => {
    if (!this.avPlayer) {
      return
    }
    console.error(`Invoke avPlayer failed, code is ${err.code}, message is ${err.message}`);
    this.avPlayer.reset(); // 调用reset重置资源,触发idle状态
  })
  // 状态机变化回调函数
  this.avPlayer.on('stateChange', async (state: string, reason: media.StateChangeReason) => {
    if (!this.avPlayer) {
      return
    }
    switch (state) {
      case 'idle': // 成功调用reset接口后触发该状态机上报
        console.info('AVPlayer state idle called.');
        this.avPlayer.release(); // 调用release接口销毁实例对象
        break;
      case 'initialized': // avplayer 设置播放源后触发该状态上报
        console.info('AVPlayer state initialized called.');
        this.avPlayer.surfaceId = this.surfaceId  // 通过Xcomponent获取关联AVPlayer 后面我会讲到
        this.avPlayer.prepare();
        break;
      case 'prepared': // prepare调用成功后上报该状态机
        console.info('AVPlayer state prepared called.');
        this.avPlayer.play(); // 调用播放接口开始播放
        break;
      case 'playing': // play成功调用后触发该状态机上报
        console.info('AVPlayer state playing called.');
        break;
      case 'paused': // pause成功调用后触发该状态机上报
        console.info('AVPlayer state paused called.');
        break;
      case 'completed': // 播放结束后触发该状态机上报
        console.info('AVPlayer state completed called.');
        break;
      case 'stopped': // stop接口成功调用后触发该状态机上报
        console.info('AVPlayer state stopped called.');
        this.avPlayer.reset(); // 调用reset接口初始化avplayer状态
        break;
      case 'released':
        console.info('AVPlayer state released called.');
        break;
      default:
        console.info('AVPlayer state unknown called.');
        break;
    }
  })
}

这是什么呀,你刚才也没有讲,嗯我现在讲了😏

AVPlayer可以设置监听事件,事件类型如下:

事件类型 说明
stateChange 必要事件,监听播放器的state属性改变。
error 必要事件,监听播放器的错误信息。
durationUpdate 用于进度条,监听进度条长度,刷新资源时长。
timeUpdate 用于进度条,监听进度条当前位置,刷新当前时间。
seekDone 响应API调用,监听seek()请求完成情况。当使用seek()跳转到指定播放位置后,如果seek操作成功,将上报该事件。
speedDone 响应API调用,监听setSpeed()请求完成情况。当使用setSpeed()设置播放倍速后,如果setSpeed操作成功,将上报该事件。
volumeChange 响应API调用,监听setVolume()请求完成情况。当使用setVolume()调节播放音量后,如果setVolume操作成功,将上报该事件。
bitrateDone 响应API调用,用于HLS协议流,监听setBitrate()请求完成情况。当使用setBitrate()指定播放比特率后,如果setBitrate操作成功,将上报该事件。
availableBitrates 用于HLS协议流,监听HLS资源的可选bitrates,用于setBitrate()。
bufferingUpdate 用于网络播放,监听网络播放缓冲信息。
startRenderFrame 用于视频播放,监听视频播放首帧渲染时间。
videoSizeChange 用于视频播放,监听视频播放的宽高信息,可用于调整窗口大小、比例。
audioInterrupt 监听音频焦点切换信息,搭配属性audioInterruptMode使用。如果当前设备存在多个媒体正在播放,音频焦点被切换(即播放其他媒体如通话等)时将上报该事件,应用可以及时处理。

感觉挺多的,没事你只需要记住stateChangetimeUpdate 这几个常用的就行了

介绍一下AVplayer的流程 这张图里面每一个椭圆也就时一个状态也对应stateChange状态机

接下来在createAVPlayer函数中添加这个方法

ts 复制代码
async createAVPlayer() {
  // ...
  this.bindState()
}

4.添加播放函数▶️

可以在外部调用这些函数播放视频

播放本地资源

ts 复制代码
/**
 * 播放本地资源
 * @param surfaceId surfaceId 通过XComponent获取
 * @param fdSrcName 本地资源名称
 */
async playFdSrc(surfaceId: string, fdSrcName: string) {
  this.surfaceId = surfaceId
  if (this.avPlayer === null) {
    await this.createAVPlayer();
  }
  if (this.avPlayer !== null) {
    let context = getContext(this) as common.UIAbilityContext
    let fileDescriptor = await context.resourceManager.getRawFd(fdSrcName)
    console.log("fileDescriptor:" + fileDescriptor)
    this.avPlayer.fdSrc = fileDescriptor
  }
}

播放网络资源

ts 复制代码
/**
 * 播放网络资源
 * @param surfaceId surfaceId 通过XComponent获取
 * @param url 网络资源url
 */
async playUrl(surfaceId: string, url: string) {
  this.surfaceId = surfaceId
  if (this.avPlayer === null) {
    await this.createAVPlayer();
  }
  if (this.avPlayer !== null) {
    this.avPlayer.url = url;
  }
}

5.介绍XComponent组件😀

可用于EGL/OpenGLES和媒体数据写入,并显示在XComponent组件

通过surfaceId 属性可以将视频画面关联到XComponent组件上 让XComponent显示实现视频的画面,这样做的好处是可以高度自定义播放界面、增强交互性比如在XComponent上添加手势识别功能

ts 复制代码
controller: XComponentController = new XComponentController()
// ...
XComponent({
  id: "", // 唯一标识
  type: XComponentType.SURFACE, // 组件类型 surface component
  controller: this.controller  // 绑定控制器
})
  .onLoad(async () => {
    // 设置XComponent持有Surface的宽度和高度
    await this.controller!.setXComponentSurfaceSize({
      surfaceWidth: 1980,
      surfaceHeight: 1080
    })

    // 获取 XComponent的SurfaceId 用于关联视频画面
    let surfaceId = this.controller.getXComponentSurfaceId()
  
  })

6.使用XComponent显示视频画面🍕

ts 复制代码
import { VideoController } from '../controller/VideoController'
import { PlayerModel } from '../model/PlayerModel'

@Entry
@Component
struct Index {
  /**视频控制器*/
  videoController: VideoController = new VideoController()
  /**XComponent控制器*/
  controller: XComponentController = new XComponentController()
  /**视频参数数据模型*/
  @State playerModel: PlayerModel = this.videoController.playerModel
  /**本地文件名称*/
  @State fdSrcName: string = 'go_home.mp4'
  /**网络视频路径*/
  @State url: string = ''

  build() {
    Column() {
      XComponent({
        id: "", // 唯一标识
        type: XComponentType.SURFACE, // 组件类型 surface component
        controller: this.controller
      })
        .onLoad(async () => {
          // 设置XComponent持有Surface的宽度和高度
          await this.controller!.setXComponentSurfaceSize({
            surfaceWidth: 1980,
            surfaceHeight: 1080
          })

          // 获取 XComponent的SurfaceId 用于关联视频画面
          let surfaceId = this.controller.getXComponentSurfaceId()
          // 调用播放视频方法 关联视频
          this.videoController.playFdSrc(surfaceId, this.fdSrcName)
        })
    }
    .backgroundColor(Color.Black)
    .height('100%')
    .width('100%')
  }
}

好接下来我们运行一下


可以是可以了但是样式有点怪怪的🤔

接下来我们修改一下尺寸

7.修改视频尺寸✒️

我们在VideoController 添加如下方法

ts 复制代码
  /**
   * 根据视频大小设置播放页面大小
   */
  setVideoSize() {
    if (this.avPlayer === null) {
      return;
    }
    if (this.avPlayer.height > this.avPlayer.width) {
      // 如果视频高度大于宽度(竖屏),则设置宽度和高度都为100%
      this.playerModel.videoWidth = '100%';
      this.playerModel.videoHeight = '100%';
      this.playerModel.videoPosition = FlexAlign.Center;
      this.playerModel.videoMargin = '7.2%';
    } else {
      // 如果视频宽度大于高度(横屏),则设置宽度为100% 高度为25.6%
      this.playerModel.videoWidth = "100%";
      this.playerModel.videoHeight = '25.6%';
      this.playerModel.videoPosition = FlexAlign.Center;
      this.playerModel.videoMargin = '0';
    }
  }

将计算后的参数都保存到 PlayerModel 中

然后在状态机回调函数中的switch 的prepared子句中调用这个方法设置大小

ts 复制代码
this.avPlayer.on('stateChange', async (state: media.AVPlayerState, reason: media.StateChangeReason) => {
 // ...
  switch (state) {
   // ...
    case 'prepared': // prepare 调用成功后上报该状态机
      // 设置视频宽高大小
      this.setVideoSize()
      // 调用播放接口开始播放
      this.avPlayer.play();
      break;
    // ...
  }
})

然后设置XComponent组件的宽高

ts 复制代码
Column() {
  XComponent({
    id: "", // 唯一标识
    type: XComponentType.SURFACE, // 组件类型 surface component
    controller: this.controller
  })
    .onLoad(async () => {
      // 设置XComponent持有Surface的宽度和高度
      await this.controller!.setXComponentSurfaceSize({
        surfaceWidth: 1980,
        surfaceHeight: 1080
      })

      // 获取 XComponent的SurfaceId 用于关联视频画面
      let surfaceId = this.controller.getXComponentSurfaceId()
      // 调用播放视频方法 关联视频
      this.videoController.playFdSrc(surfaceId, this.fdSrcName)
    })
    .width(this.playerModel.videoWidth) // 设置宽度
    .height(this.playerModel.videoHeight) // 设置高度
}
.justifyContent(this.playerModel.videoPosition)
.backgroundColor(Color.Black)
.height('100%')
.width('100%')


哎呦不错哦!

8.添加播放/暂停方法⏯️

在VideoController中添加以下三个方法

ts 复制代码
// 播放
play() {
  if (this.avPlayer !== null) {
    this.avPlayer.play();
  }
}

// 暂停
pause() {
  if (this.avPlayer !== null) {
    this.avPlayer.pause();
  }
}

// 播放暂停切换
switchPlayOrPause() {
  if (this.avPlayer === null) {
    return;
  }
  // 根据status播放状态切换
  if (this.status === 1) {
    this.avPlayer.pause();
  } else {
    this.avPlayer.play();
  }
}

然后在状态机变化回调函数中中添加以下代码 切换status状态的代码

ts 复制代码
// 状态机变化回调函数
this.avPlayer.on('stateChange', async (state: media.AVPlayerState, reason: media.StateChangeReason) => {
  // ...
  switch (state) {
    // ...
    case 'playing': // play成功调用后触发该状态机上报
      console.info('AVPlayer state playing called.');
      this.status = 1
      break;
    case 'paused': // pause成功调用后触发该状态机上报
      console.info('AVPlayer state paused called.');
      this.status = 2
      break;
    // ...
  }
})

在XComponent组件下添加播放切换按钮

ts 复制代码
XComponent({
  id: "", // 唯一标识
  type: XComponentType.SURFACE, // 组件类型 surface component
  controller: this.controller
})
  //...
Row({ space: 4 }) {
  Button("播放/暂停")
    .onClick(() => {
      this.videoController.switchPlayOrPause()
    })

}.padding(6)

有点意思😉

9.添加进度条🍡

在VideoController中添加以下方法

ts 复制代码
  initProgress(time: number) {
    // 将当前进度毫秒数转换为秒
    let nowSeconds = Math.floor(time / 1000)
    // 将当前视频总毫秒数转换为 总秒数
    let totalSeconds = Math.floor(this.duration / 1000);
    // 进行格式化
    this.playerModel.currentTime = DateFormatUtil.secondToTime(nowSeconds);
    this.playerModel.totalTime = DateFormatUtil.secondToTime(totalSeconds);
    this.playerModel.progressVal = Math.floor(nowSeconds * 100 / totalSeconds);
    console.log("progressVal:" + this.playerModel.progressVal);
  }

DateFormatUtil 为格式化时间工具类

创建/ets/common/DateFormatUtil 文件,代码如下

ts 复制代码
class DateFormatUtil {
  /**
   * 将给定的秒数转换为格式化的时间字符串。
   * @function secondToTime
   * @param {number} seconds - 要转换的秒数。
   * @returns {string} 格式化后的时间字符串,格式为"小时:分钟:秒"。如果小时数为 0,则只显示"分钟:秒";如果分钟数也为 0,则显示"00:秒"。
   */
  secondToTime(seconds: number): string {
    let hourUnit = 60 * 60;
    let hour = Math.floor(seconds / hourUnit);
    let minute = Math.floor((seconds - hour * hourUnit) / 60);
    let second = seconds - hour * hourUnit - minute * 60;
    if (hour > 0) {
      return `${this.padding(hour.toString())}:
        ${this.padding(minute.toString())}:${this.padding(second.toString())}`;
    }
    if (minute > 0) {
      return `${this.padding(minute.toString())}:${this.padding(second.toString())}`;
    } else {
      return `00:${this.padding(second.toString())}`;
    }
  }

  /**
   * 对给定的数字字符串进行前导零填充,确保字符串长度为 2。
   * @param {string} num - 要进行填充的数字字符串。
   * @returns {string} 长度为 2 的字符串,如果传入的字符串长度小于 2,则在前面填充零。
   */
  padding(num: string): string {
    return num.padStart(2,'0');
  }
}

export default new DateFormatUtil();

secondToTime方法 格式化后的时间字符串,格式为"小时:分钟:秒"。如果小时数为 0,则只显示"分钟:秒";如果分钟数也为 0,则显示"00:秒, 效果

在添加监听状态回调函数中添加如下代码:

ts 复制代码
bindState() {
  // ...
  // 监听进度条播放
  this.avPlayer.on("timeUpdate",(time) => {
     this.changeProgress(time)
  })
  // ...
}

这样每次视频播放进度更新时都会触发更新进度条

接着在状态机的prepared子句中获取视频总时长

ts 复制代码
// 状态机变化回调函数
this.avPlayer.on('stateChange', async (state: media.AVPlayerState, reason: media.StateChangeReason) => {
  // ...
  switch (state) {
    // ...
    case 'prepared': // prepare 调用成功后上报该状态机
      // ...
      // 获取总时长
      this.duration = this.avPlayer.duration;

      break;
    // ...
  }
})

接下来我们在播放暂停按钮下添加Slider组件

ts 复制代码
Slider({
  value: this.playerModel.progressVal,
  step: 1,
  style: SliderStyle.OutSet
})
  .blockColor(Color.White)
  .trackColor("#888888")

Text("当前进度:" +
this.playerModel.currentTime
)
  .fontColor(Color.White)

Text("总时长:" +
this.playerModel.totalTime
)
  .fontColor(Color.White)

我们看看效果

哟可以哦😏

10.拖动进度条🥖

在VideoController中添加如下方法:

ts 复制代码
/**
 * 设置跳转时间
 * @param value
 * @param mode
 */
setSeekTime(value: number, mode: SliderChangeMode) {
  // 判断当前 Slider 的模式 是否是移动中
  if (mode === SliderChangeMode.Moving) {
    this.playerModel.progressVal = value
    this.playerModel.currentTime = DateFormatUtil.secondToTime(Math.floor(value * this.duration / 100 / 1000))
    this.avPlayer.play()
  }

  if (mode === SliderChangeMode.End || mode === SliderChangeMode.Click) {
    this.seekTime = value * this.duration / 100
    if (this.avPlayer !== null) {
        this.avPlayer.seek(this.seekTime, media.SeekMode.SEEK_NEXT_SYNC)
        this.avPlayer.play()
    }
  }
}

给Slider添加onChange方法

ts 复制代码
Slider({
  value: this.playerModel.progressVal,
  step: 1,
  style: SliderStyle.OutSet
})
  .blockColor(Color.White)
  .trackColor("#888888")
  .onChange((value: number, mode: SliderChangeMode) => {
    this.videoController.setSeekTime(value, mode)
  })

试试效果

不错呢😉

11.设置播放倍速⬆️

这个简单 在VideoController中添加如下方法

ts 复制代码
setSpeed(playSpeed: media.PlaybackSpeed) {
  this.playerModel.playSeed = playSpeed

  this.avPlayer?.setSpeed(this.playerModel.playSeed)
}

然后在Slider 组件下添加Button按钮进行测试

ts 复制代码
Row() {
  Button("1x")
    .onClick(() =>{
      this.videoController.setSpeed(media.PlaybackSpeed.SPEED_FORWARD_1_00_X)
    })
  Button("1.5x")
    .onClick(() =>{
      this.videoController.setSpeed(media.PlaybackSpeed.SPEED_FORWARD_1_50_X)
    })
  Button("2x")
    .onClick(() =>{
      this.videoController.setSpeed(media.PlaybackSpeed.SPEED_FORWARD_2_00_X)
    })
}

来我们看看最终效果

有点意思😀,样式你们可以自己定义一下我就偷偷懒吧

好啦就这样吧,总结一下

后面可以自己扩展,设置按钮样式,布局,设置手势调整音量等

总结📚

  • AVPlayer和Video组件的对比
  • AVPlayer:功能较完善的音视频播放ArkTS/JS API,集成了流媒体和本地资源解析,媒体资源解封装,视频解码和渲染功能,适用于对媒体资源进行端到端播放的场景,可直接播放mp4、mkv等格式的视频文件。
  • Video组件:封装了视频播放的基础能力,需要设置数据源以及基础信息即可播放视频,但相对扩展能力较弱。Video组件由ArkUI提供能力,相关指导请参考UI开发文档
  • 根据视频时横屏还是竖屏设置XComponent大小
  • 创建PlayerMode统一管理视频参数
  • 通过AVPlayer的API设置了播放资源、播放参数、播放控制(播放\暂停\跳转)等
  • 使用on("stateChnage")方法监听状态变化,执行相应的操作
  • 后续可以自己在VideoController中扩展,比如设置音量就调用setVolume()等

参考资料:

我刚学HarmonyOS不久,这个播放器是我看官方文档和案例源码才学会的,第一次写技术文章,满满的成就感😀,我想把我所学到的知识分享一下给大家,给我点个关注or点赞or收藏支持一下吧😉

源码地址:github

相关推荐
PlumCarefree2 小时前
mp4(H.265编码)转为本地RTSP流
音视频·harmonyos·h.265
鸿蒙自习室5 小时前
鸿蒙网络管理模块04——网络连接管理
华为·harmonyos·鸿蒙·媒体
训山16 小时前
【10】纯血鸿蒙HarmonyOS NEXT星河版开发0基础学习笔记-泛型基础全解(泛型函数、泛型接口、泛型类)及参数、接口补充
笔记·学习·华为·harmonyos·鸿蒙系统
云兮Coder17 小时前
鸿蒙 HarmonyNext 与 Flutter 的异同之处
flutter·华为·harmonyos
Android技术栈18 小时前
鸿蒙开发(NEXT/API 12)【应用间消息通信】手机侧应用开发
嵌入式硬件·信息与通信·harmonyos·鸿蒙·鸿蒙系统·openharmony
lqj_本人20 小时前
flutter_鸿蒙next(win)环境搭建
flutter·华为·harmonyos
zzlyx991 天前
鸿蒙ArkUI实战开发-主打自研语言及框架
华为·harmonyos
zzlyx991 天前
方舟开发框架(ArkUI)可运行 OpenHarmony、HarmonyOS、Android、iOS等操作系统
华为·harmonyos
枫亦有忆1 天前
鸿蒙Next - 原生API实现实时语音识别
前端·harmonyos