鸿蒙开发之视频播放器实现中篇

848 阅读4分钟

在《鸿蒙开发之视频播放器实现上篇》中,我们实现了视频播放器的一些基本设置,在本文中,我们继续讲解视频播放器播放功能的实现。

1、维护播放列表和播放索引

我们实现的播放器,其核心思想是维护一个播放列表和当前的播放索引,当播放某个视频时,通过播放索引去切换当前要播放的视频。因此在播放器类中,需要定义playList变量和playIndex变量分别存储播放列表和当前的播放索引。

import media from '@ohos.multimedia.media'

export class VideoAVPlayerClass {
  // 创建的播放器应该存在我们的工具类上,这样才能被导出使用
  static player: media.AVPlayer | null = null

  // 当前播放器播放视频的总时长
  static duration: number = 0

  // 当前播放器播放的时长
  static time: number = 0

  // 当前播放器是否播放
  static isPlay: boolean = false

    // 当前播放器的播放列表
  static playList: videoItemType[] = []
  
  // 当前播放的视频索引
  static playIndex: number = 0

  // surfaceID用于播放画面显示,具体的值需要通过XComponent接口获取
  static surfaceId: string = ''
  
  // 创建播放器的方法
  static async init(initParams: InitParams) {

    // 存储属性SurfaceID,用于设置播放窗口,显示画面
    VideoAVPlayerClass.surfaceId = initParams.surfaceId

    // 创建播放器实例
    VideoAVPlayerClass.player = await media.createAVPlayer()

    // ----------------------- 事件监听 --------------------------------------------------------------

    // 用于进度条,监听进度条长度,刷新资源时长
    VideoAVPlayerClass.avPlayer.on('durationUpdate', (duration: number) => {
        console.info('AVPlayer state durationUpdate called. current time: ', duration);
        // 获取视频总时长
        VideoAVPlayerClass.duration = duration
    })

    // 用于进度条,监听进度条当前位置,刷新当前时间
    VideoAVPlayerClass.avPlayer.on('timeUpdate', (time) =>{
      console.info('AVPlayer state timeUpdate called. current time: ', time);
      // 获取当前播放时长
      VideoAVPlayerClass.time = time
    })

    // 监听seek生效的事件
    VideoAVPlayerClass.avPlayer.on('seekDone', (seekDoneTime: number) => {
      console.info(`AVPlayer seek succeeded, seek time is ${seekDoneTime}`);
      VideoAVPlayerClass.avPlayer.play()
      VideoAVPlayerClass.isPlay = true
    })

    // 监听视频播放错误事件,当avPlayer在操作过程中出现错误时调用reset接口触发重置流程
    VideoAVPlayerClass.avPlayer.on('error', (err) => {
      console.error(`Invoke avPlayer failed, code is ${err.code}, message is ${err.message}`);
      // 调用reset重置资源,触发idle状态
      VideoAVPlayerClass.avPlayer.reset()
    })

    // 监听播放状态机AVPlayerState切换的事件
    VideoAVPlayerClass.avPlayer.on('stateChange', async (state: media.AVPlayerState, reason: media.StateChangeReason) => {
      switch (state) {
      // 成功调用reset接口后触发该状态机上报
        case 'idle':
          console.info('AVPlayer state idle called.');
          break

       // avplayer 设置播放源后触发该状态上报
        case 'initialized':
          console.info('AVPlayerstate initialized called.');
          // 设置显示画面,当播放的资源为纯音频时无需设置
          VideoAVPlayerClass.avPlayer.surfaceId = VideoAVPlayerClass.surfaceId
          break

      // prepare调用成功后上报该状态机
        case 'prepared':
          console.info('AVPlayer state prepared called.');
          break

      // play成功调用后触发该状态机上报
        case 'playing':
          console.info('AVPlayer state playing called.');
          break

      // pause成功调用后触发该状态机上报
        case 'paused':
          console.info('AVPlayer state paused called.');
          break

      // 播放结束后触发该状态机上报
        case 'completed':
          console.info('AVPlayer state completed called.');
          break

      // stop接口成功调用后触发该状态机上报
        case 'stopped':
          console.info('AVPlayer state stopped called.');
        // 调用reset接口初始化avplayer状态
          VideoAVPlayerClass.avPlayer.reset()
          break

        case 'released':
          console.info('AVPlayer state released called.');
          break;

        default:
          console.info('AVPlayer state unknown called.');
          break;
      }
    })
  }

  
  static async changePlay() {
    // 将播放状态置为闲置
    await VideoAVPlayerClass.avPlayer.reset()

    VideoAVPlayerClass.avPlayer.url = VideoAVPlayerClass.playList[VideoAVPlayerClass.playIndex].url
  }
}

2、更新信息到页面

我们已经记录了页面需要的播放信息,视频也已经能正常播放了,但是页面还没有更新,我们如何将这些信息同步到页面呢?

在我们的实现中,是工具类到Page的通信,因此需要使用到线程通信。线程通信通常有EmitterWorker 两种方式。Emitter适用于线程间发送和处理事件的能力(发布订阅),Worker适用于与主线程并行的独立线程(并行计算),这里更适合使用Emitter来进行通信,从而传递播放信息至各页面。其中,发布者是播放器,订阅者就是需要使用信息的页面。

  • 发布事件

首先,我们在播放器类中定义一个updateState方法,用于更新页面状态。在该方法中调用emitter.emit() 方法来发布事件,然后分别在timeUpdate事件监听和changePlay()方法中调用updateState()方法:

import media from '@ohos.multimedia.media'

export class VideoAVPlayerClass {
  // 创建的播放器应该存在我们的工具类上,这样才能被导出使用
  static player: media.AVPlayer | null = null

  // 当前播放器播放视频的总时长
  static duration: number = 0

  // 当前播放器播放的时长
  static time: number = 0

  // 当前播放器是否播放
  static isPlay: boolean = false

    // 当前播放器的播放列表
  static playList: videoItemType[] = []
  
  // 当前播放的视频索引
  static playIndex: number = 0

  // surfaceID用于播放画面显示,具体的值需要通过XComponent接口获取
  static surfaceId: string = ''
  
  // 创建播放器的方法
  static async init(initParams: InitParams) {

    // 存储属性SurfaceID,用于设置播放窗口,显示画面
    VideoAVPlayerClass.surfaceId = initParams.surfaceId

    // 创建播放器实例
    VideoAVPlayerClass.player = await media.createAVPlayer()

    // ----------------------- 事件监听 --------------------------------------------------------------

    // 用于进度条,监听进度条长度,刷新资源时长
    VideoAVPlayerClass.avPlayer.on('durationUpdate', (duration: number) => {
        console.info('AVPlayer state durationUpdate called. current time: ', duration);
        // 获取视频总时长
        VideoAVPlayerClass.duration = duration
    })

    // 用于进度条,监听进度条当前位置,刷新当前时间
    VideoAVPlayerClass.avPlayer.on('timeUpdate', (time) =>{
      console.info('AVPlayer state timeUpdate called. current time: ', time);
      // 获取当前播放时长
      VideoAVPlayerClass.time = time

      // 更新信息到页面
      VideoAVPlayerClass.updateState()
    })

    // 监听seek生效的事件
    VideoAVPlayerClass.avPlayer.on('seekDone', (seekDoneTime: number) => {
      console.info(`AVPlayer seek succeeded, seek time is ${seekDoneTime}`);
      VideoAVPlayerClass.avPlayer.play()
      VideoAVPlayerClass.isPlay = true
    })

    // 监听视频播放错误事件,当avPlayer在操作过程中出现错误时调用reset接口触发重置流程
    VideoAVPlayerClass.avPlayer.on('error', (err) => {
      console.error(`Invoke avPlayer failed, code is ${err.code}, message is ${err.message}`);
      // 调用reset重置资源,触发idle状态
      VideoAVPlayerClass.avPlayer.reset()
    })

    // 监听播放状态机AVPlayerState切换的事件
    VideoAVPlayerClass.avPlayer.on('stateChange', async (state: media.AVPlayerState, reason: media.StateChangeReason) => {
      switch (state) {
      // 成功调用reset接口后触发该状态机上报
        case 'idle':
          console.info('AVPlayer state idle called.');
          break

       // avplayer 设置播放源后触发该状态上报
        case 'initialized':
          console.info('AVPlayerstate initialized called.');
          // 设置显示画面,当播放的资源为纯音频时无需设置
          VideoAVPlayerClass.avPlayer.surfaceId = VideoAVPlayerClass.surfaceId
          break

      // prepare调用成功后上报该状态机
        case 'prepared':
          console.info('AVPlayer state prepared called.');
          break

      // play成功调用后触发该状态机上报
        case 'playing':
          console.info('AVPlayer state playing called.');
          break

      // pause成功调用后触发该状态机上报
        case 'paused':
          console.info('AVPlayer state paused called.');
          break

      // 播放结束后触发该状态机上报
        case 'completed':
          console.info('AVPlayer state completed called.');
          break

      // stop接口成功调用后触发该状态机上报
        case 'stopped':
          console.info('AVPlayer state stopped called.');
        // 调用reset接口初始化avplayer状态
          VideoAVPlayerClass.avPlayer.reset()
          break

        case 'released':
          console.info('AVPlayer state released called.');
          break;

        default:
          console.info('AVPlayer state unknown called.');
          break;
      }
    })
  }

  static async changePlay() {
    // 将播放状态置为闲置
    await VideoAVPlayerClass.avPlayer.reset()

    VideoAVPlayerClass.avPlayer.url = VideoAVPlayerClass.playList[VideoAVPlayerClass.playIndex].url
    VideoAVPlayerClass.updateState()
  }

  // 更新页面状态
  static async updateState() {
    const data = {
      playState: JSON.stringify({
        duration: VideoAVPlayerClass.duration,
        time: VideoAVPlayerClass.time,
        isPlay: VideoAVPlayerClass.isPlay,
        playIndex: VideoAVPlayerClass.playIndex,
        playList: VideoAVPlayerClass.playList,
      })
    }
    // 更新页面
    emitter.emit({
      eventId: EmitEventType.UPDATE_STATE
    }, {
      data
    })
  }
  
}
  • 订阅事件

由于需要记录播放状态,所以我们需要声明playState变量,并且页面需要根据这个数据进行更新,所以需要使用@State修饰符。当页面订阅到最新数据,就会将数据保存到playState变量中,playState的变化会触发页面重新渲染。

import emitter from '@ohos.events.emitter';
import { EmitEventType } from '../constants/EventContants';
import { VideoListData } from '../constants/VideoConstants';
import { PlayStateType, PlayStateTypeModel } from '../models/playState';
import { videoItemType } from '../models/video';
import { VideoPlayStateType, VideoPlayStateTypeModel } from '../models/videoPlayState';

@Preview
@Component
struct Index {

  @State
  playState: VideoPlayStateType = new VideoPlayStateTypeModel({} as VideoPlayStateType)

  xComController: XComponentController = new XComponentController()
  surfaceId: string = "" // 定义surfaceId

  videoList: videoItemType[] = VideoListData

  async aboutToAppear() {
    // 从播放器订阅数据
    emitter.on({ eventId: EmitEventType.UPDATE_STATE }, (data) => {
      this.playState = new VideoPlayStateTypeModel(JSON.parse(data.data.playState))
    })
  }


  build() {
    Row() {
      Column({ space: 10 }) {
        Stack() {
          Column() {

            Row(){
              // 视频播放窗口
              XComponent({
                id: 'videoXComponent',
                type: 'surface',
                controller: this.xComController
              })
                .width('100%')
                .height(200)
                .onLoad(async () => {
                  this.xComController.setXComponentSurfaceSize({ surfaceWidth: 1080, surfaceHeight: 1920 });
                  this.surfaceId = this.xComController.getXComponentSurfaceId()
                })
            }
          }
          .width('100%')
          .height(270)
          .padding({
            top: 30,
            bottom:30
          })
          .backgroundColor($r('app.color.black'))
          .justifyContent(FlexAlign.Start)
        }
      }
      .width('100%')
      .height('100%')
    }
    .height('100%')
    .width('100%')
  }
}

export default Video_Play
  • 展示播放信息

在页面中,根据播放信息,展示视频时长,当前播放时长,根据是否播放状态展示暂停/播放按钮。

import emitter from '@ohos.events.emitter';
import PlayingAnimation from '../components/PlayingAnimation';
import { EmitEventType } from '../constants/EventContants';
import { VideoListData } from '../constants/VideoConstants';
import { PlayStateType, PlayStateTypeModel } from '../models/playState';
import { videoItemType } from '../models/video';
import { VideoPlayStateType, VideoPlayStateTypeModel } from '../models/videoPlayState';

@Preview
@Component
struct Index {

  @State
  playState: VideoPlayStateType = new VideoPlayStateTypeModel({} as VideoPlayStateType)

  xComController: XComponentController = new XComponentController()
  surfaceId: string = "" // 定义surfaceId

  videoList: videoItemType[] = VideoListData

  async aboutToAppear() {
    // 从播放器订阅数据
    emitter.on({ eventId: EmitEventType.UPDATE_STATE }, (data) => {
      this.playState = new VideoPlayStateTypeModel(JSON.parse(data.data.playState))
    })
  }

  // 时长数字(ms)转字符串
  number2time(number: number) {

    if (!number) {
      return '00:00'
    }

    const ms: number = number % 1000
    const second = (number - ms) / 1000
    const s: number = second % 60
    if (second > 60) {
      const m: number = (second - s) / 60 % 60
      return m.toString()
        .padStart(2, '0') + ':' + s.toString()
        .padStart(2, '0')
    }
    return '00:' + s.toString()
      .padStart(2, '0')
  }

  build() {
    Row() {
      Column({ space: 10 }) {
        Stack() {
          Column() {

            Row(){
              // 视频播放窗口
              XComponent({
                id: 'videoXComponent',
                type: 'surface',
                controller: this.xComController
              })
                .width('100%')
                .height(200)
                .onLoad(async () => {
                  this.xComController.setXComponentSurfaceSize({ surfaceWidth: 1080, surfaceHeight: 1920 });
                  this.surfaceId = this.xComController.getXComponentSurfaceId()
                })
            }

            // 进度条
            Row({space: 6}){

              // 当前播放时长
              Text(this.number2time(this.playState?.time))
                .fontColor($r('app.color.white'))
                .visibility(this.playState?.duration ? Visibility.Visible : Visibility.Hidden)

              // 进度条
              Slider({
                value: this.playState.time,
                min: 0,
                max: this.playState.duration,
              })
                .trackColor($r('app.color.white'))
                .width("70%")

              // 视频总时长
              Text(this.number2time(this.playState?.duration))
                .fontColor($r('app.color.white'))
                .visibility(this.playState?.duration ? Visibility.Visible : Visibility.Hidden)
            }
            .width('100%')
            .height(20)
            .margin({
              top: 10
            })
            .justifyContent(FlexAlign.Center)
          }
          .width('100%')
          .height(270)
          .padding({
            top: 30,
            bottom:30
          })
          .backgroundColor($r('app.color.black'))
          .justifyContent(FlexAlign.Start)
          
          // 播放按钮
          if (!this.playState.isPlay) {
            Image($r('app.media.ic_play'))
              .width(48)
              .height(48)
              .fillColor($r('app.color.white'))
          }
        }

        // 视频列表缩略图
        List({ space: 10, initialIndex: 0 }) {
          ForEach(this.videoList, (item: videoItemType, index: number) => {
            ListItem() {
              Stack({alignContent: Alignment.Center}){
                Image(item.imgUrl)
                  .width(100)
                  .height(80)

                // .objectFit(ImageFit.Contain)

                if (this.playState.playIndex === index) {
                  Row(){
                    PlayingAnimation({ recordIng: true })
                  }
                }

              }

            }
            .width(100)
          }, item => item)
        }
        .height(100)
        .listDirection(Axis.Horizontal) // 排列方向
        .edgeEffect(EdgeEffect.Spring) // 滑动到边缘无效果
        .onScrollIndex((firstIndex: number, lastIndex: number) => {
          console.info('first' + firstIndex)
          console.info('last' + lastIndex)
        })

      }
      .width('100%')
      .height('100%')
    }
    .height('100%')
    .width('100%')

  }
}

export default Index

3、播放视频

播放窗口,播放源都已经设置好了,播放列表也有了,我们就可以播放视频了。在页面中调用播放器的init()方法,传入从XComponent组件获取的surfaceId和视频列表,然后调用播放器的singlePlay()方法,播放器就会根据当前的播放索引去播放列表中的视频。

import emitter from '@ohos.events.emitter';
import PlayingAnimation from '../components/PlayingAnimation';
import { EmitEventType } from '../constants/EventContants';
import { VideoListData } from '../constants/VideoConstants';
import { PlayStateType, PlayStateTypeModel } from '../models/playState';
import { videoItemType } from '../models/video';
import { VideoPlayStateType, VideoPlayStateTypeModel } from '../models/videoPlayState';
import { VideoAVPlayerClass } from '../utils/VideoAVPlayerClass';

@Preview
@Component
struct Index {

  @State
  playState: VideoPlayStateType = new VideoPlayStateTypeModel({} as VideoPlayStateType)

  xComController: XComponentController = new XComponentController()
  surfaceId: string = "" // 定义surfaceId

  videoList: videoItemType[] = VideoListData

  async aboutToAppear() {
    // 从播放器订阅数据
    emitter.on({ eventId: EmitEventType.UPDATE_STATE }, (data) => {
      this.playState = new VideoPlayStateTypeModel(JSON.parse(data.data.playState))
    })
  }

  aboutToDisappear(){
    // 销毁播放器
    VideoAVPlayerClass.avPlayer.release()
  }

  // 时长数字(ms)转字符串
  number2time(number: number) {

    if (!number) {
      return '00:00'
    }

    const ms: number = number % 1000
    const second = (number - ms) / 1000
    const s: number = second % 60
    if (second > 60) {
      const m: number = (second - s) / 60 % 60
      return m.toString()
        .padStart(2, '0') + ':' + s.toString()
        .padStart(2, '0')
    }
    return '00:' + s.toString()
      .padStart(2, '0')
  }

  build() {
    Row() {
      Column({ space: 10 }) {
        Stack() {
          Column() {

            Row(){
              // 视频播放窗口
              XComponent({
                id: 'videoXComponent',
                type: 'surface',
                controller: this.xComController
              })
                .width('100%')
                .height(200)
                .onLoad(async () => {
                  this.xComController.setXComponentSurfaceSize({ surfaceWidth: 1080, surfaceHeight: 1920 });
                  this.surfaceId = this.xComController.getXComponentSurfaceId()

                  if (this.surfaceId) {
                    await VideoAVPlayerClass.init({surfaceId: this.surfaceId, playList: this.videoList, context: getContext(this)})
                    await VideoAVPlayerClass.singlePlay()
                  }
                })
            }


            // 进度条
            Row({space: 6}){

              // 当前播放时长
              Text(this.number2time(this.playState?.time))
                .fontColor($r('app.color.white'))
                .visibility(this.playState?.duration ? Visibility.Visible : Visibility.Hidden)

              // 进度条
              Slider({
                value: this.playState.time,
                min: 0,
                max: this.playState.duration,
              })
                .trackColor($r('app.color.white'))
                .width("70%")

              // 视频总时长
              Text(this.number2time(this.playState?.duration))
                .fontColor($r('app.color.white'))
                .visibility(this.playState?.duration ? Visibility.Visible : Visibility.Hidden)
            }
            .width('100%')
            .height(20)
            .margin({
              top: 10
            })
            .justifyContent(FlexAlign.Center)
          }
          .width('100%')
          .height(270)
          .padding({
            top: 30,
            bottom:30
          })
          .backgroundColor($r('app.color.black'))
          .justifyContent(FlexAlign.Start)

          // 播放按钮
          if (!this.playState.isPlay) {
            Image($r('app.media.ic_play'))
              .width(48)
              .height(48)
              .fillColor($r('app.color.white'))
          }
        }

        // 视频列表缩略图
        List({ space: 10, initialIndex: 0 }) {
          ForEach(this.videoList, (item: videoItemType, index: number) => {
            ListItem() {
              Stack({alignContent: Alignment.Center}){
                Image(item.imgUrl)
                  .width(100)
                  .height(80)

                // .objectFit(ImageFit.Contain)

                if (this.playState.playIndex === index) {
                  Row(){
                    PlayingAnimation({ recordIng: true })
                  }
                }

              }

            }
            .width(100)

          }, item => item)
        }
        .height(100)
        .listDirection(Axis.Horizontal) // 排列方向
        .edgeEffect(EdgeEffect.Spring) // 滑动到边缘无效果
        .onScrollIndex((firstIndex: number, lastIndex: number) => {
          console.info('first' + firstIndex)
          console.info('last' + lastIndex)
        })

      }
      .width('100%')
      .height('100%')
    }
    .height('100%')
    .width('100%')

  }
}

export default Index

至此,我们实现的播放器可以播放视频了,但是它还不能暂停,也不能切换进度,切换视频,这些功能,将在下一篇《鸿蒙开发之视频播放器实现下篇》讲解。