HarmonyOS 鸿蒙Next开发模板/组件分享 – 极简播放器(simple_audio)

发布于 1周前 作者 htzhanglong 来自 鸿蒙OS

HarmonyOS 鸿蒙Next开发模板/组件分享 – 极简播放器(simple_audio)

跑跑“码”特–HarmonyOS 模板组件开发挑战赛-极简播放器

《极简播放器》研发背景

在HarmonyOS Next应用开发中,如果我们想要使用播放音乐、并且提供基本的功能按钮,往往都需要开发者实现以下过程

  1. 熟悉和开发媒体相关的kit
  2. 使用ArkUI完成相关的布局和功能设计

但是纵观各种这样的播放器,核心功能还是如下几个

  1. 播放&暂停
  2. 上一首&下一首
  3. 单曲播放&列表播放&循环播放&随机播放
  4. 音量大小调整
  5. 播放进度调整

如酷狗音乐:

基于以上需求,便开发出这一款极简的播放器。

效果展示

效果展示

功能介绍

考虑到以上的需求,这里提供的主要功能如下

  1. 播放&暂停
  2. 上一首&下一首
  3. 单曲播放&列表播放&循环播放&随机播放
  4. 音量大小调整
  5. 播放进度调整

功能介绍

关键技术

  1. ArkUI
  2. ArkTS
  3. 封装-class
  4. AVPlayer
  5. 防抖

目录结构

这里考虑后面将该播放器提交上架,所以是采用了静态共享包Har的方式来开发的。主要的目录结构如下

├─components   
│      simple_audio.ets // 核心UI组件
      
├─type
│      index.ets // 用到的相关类型
      
└─utils
      index.ets // 工具代码

示例代码

utils

// 时间格式化
// time单位的毫秒 要输出分钟和秒 格式 00:00
export const sa_timeFormat = (time: number) => {
  // 分钟
  const minutes = Math.floor(time / 1000 / 60)
  //   秒
  const seconds = (Math.floor(time / 1000) % 60).toString().padStart(2, '0')
  return `${minutes}:${seconds}`
}

// 防抖工具函数
export const sa_throttle = <T = null>(callback: Function, time: number) => {
  let tid = -1
  const retFunction = (...args: T[]) => {
    if (tid !== -1) {
      clearTimeout(tid)
    }
    tid = setTimeout(() => {
      callback(args)
    }, time)
  }

  return retFunction
}
// 获取指定范围的随机数
export const getRandomFromRange = (min: number, max: number) => {
  return Math.floor(Math.random() * (max - min + 1) + min)
}

type

用到的类型相关代码

import { media } from '@kit.MediaKit'

export enum SAPlayMode {
  /**
   * 列表播放
   */
  order,
  /**
   * 单曲播放
   */
  single,
  /**
   * 重复播放
   */
  repeat,
  /**
   * 随机播放
   */
  random
}

/**
 * 播放模式相关的图标和文字
 */
export interface SAPlayModeIcon {
  url: ResourceStr
  mode: SAPlayMode
  name: string
}

// 支持的播放的资源url
export type SongItemUrl = string | media.AVFileDescriptor | media.AVDataSrcDescriptor

simple_audio

播放器组件的代码

import { SAPlayMode, SAPlayModeIcon, SongItemUrl } from '../type'
import { getRandomFromRange, sa_throttle, sa_timeFormat } from '../utils'
import { EfAVPlayer, EfAVPlayerState } from '[@yunkss](/user/yunkss)/ef_audio'
import { promptAction } from '@kit.ArkUI'

@Component
export struct simple_audio {
  /**
   * 播放模式
   */
  @State
  saPlayMode: SAPlayMode = SAPlayMode.order
  /**
   * 播放模式的图标
   */
  @State
  saPlayModeIcon: ResourceStr = $r("app.media.order")
  /**
   * 播放模式图标列表
   */
  @State
  playModeIcons: SAPlayModeIcon[] = [
    {
      mode: SAPlayMode.order,
      url: $r("app.media.order"),
      name: "顺序播放"
    },
    {
      mode: SAPlayMode.single,
      url: $r("app.media.single"),
      name: "单曲循环"
    },
    {
      mode: SAPlayMode.repeat,
      url: $r("app.media.repeat"),
      name: "列表循环"

    },
    {
      mode: SAPlayMode.random,
      url: $r("app.media.random"),
      name: "随机播放"

    },
  ]
  /**
   * 播放模式的下标
   */
  @State
  @Watch("_onPlayModeIndex")
  playModeIndex: number = 0
  /**
   * 歌曲时长 单位毫秒
   */
  @State
  duration: number = 0
  /**
   * 歌曲播放进度 单位毫秒
   */
  @State
  currentTime: number = 0
  /**
   * 是否正在播放
   */
  @State
  isPlay: boolean = true
  /**
   * 显示播放模式的弹窗
   */
  @State
  showPlayModePopup: boolean = false
  showPlayModePopupTid: number = -1
  /**
   * 要播放的歌曲列表
   */
  @Prop
  songList: SongItemUrl[] = []
  /**
   * 正在播放的歌曲列表的下标
   */
  @Link
  @Watch("_onPlayIndexChange")
  songPlayIndex: number
  /**
   * 正在播放的歌曲的地址
   */
  @State
  songPlayUrl: SongItemUrl = this.songList[this.songPlayIndex]
  /**
   * 封装了播放功能的AVPlay库
   */
  efAVPlay: EfAVPlayer = new EfAVPlayer()
  /**
   * 初始化播放的方法
   */
  initPlay = sa_throttle(() => {
    this.efAVPlay.onError(stateError => {
      this.onError(stateError)
      console.log("stateError", JSON.stringify(stateError))
    })

    this.efAVPlay.onStateChange(state => {
      this._onStateChange(state)
      switch (state) {
        case "playing":
          this.isPlay = true
          break
        case "completed":
          // 播放完毕
          this._onNext()
          // 切换下一首
          break;

        default:
          break;
      }
    })

    this.efAVPlay.onTimeUpdate(time => {

      this.duration = this.efAVPlay.duration
      this.currentTime = time
      this.onTimeUpdate(time)
    })

    this.efAVPlay.setUrl(this.songPlayUrl as string)
  }, 300)
  /**
   * 是否显示音量大小控件
   */
  @State
  showVolumn: boolean = false
  /**
   * 音量属性 0-1  包含小数
   */
  @Prop
  @Watch("_onVolumnChange")
  volume: number = 1
  /**
   * 是否静音
   */
  @Prop
  isMuted: boolean = false
  /**
   * 下一首
   */
  _onNext = async () => {
    if (!(this.saPlayMode === SAPlayMode.order && this.songPlayIndex + 1 >= this.songList.length)) {
      await this.efAVPlay.reset()
    }
    switch (this.saPlayMode) {
      case SAPlayMode.order:
        if (this.songPlayIndex + 1 >= this.songList.length) {
          promptAction.showToast({ message: `已经是最后一首` })
          return
        } else {
          this.songPlayIndex++
        }
        break;
      case SAPlayMode.single:
        await this.efAVPlay.reset()
        break
      case SAPlayMode.repeat:
        if (this.songPlayIndex + 1 >= this.songList.length) {
          this.songPlayIndex = 0
        } else {
          this.songPlayIndex++
        }
        break
      case SAPlayMode.random:
        this.songPlayIndex = getRandomFromRange(0, this.songList.length - 1)
        break
      default:
        break;
    }
    this._onPlayIndexChange()
    this.efAVPlay.setUrl(this.songPlayUrl as string)
      .then(() => {
        this.onNext(this.songPlayIndex)
      })


  }
  /**
   * 上一首
   */
  _onPrevious = async () => {
    await this.efAVPlay.reset()
    if (this.songPlayIndex - 1 < 0) {
      this.songPlayIndex = this.songList.length - 1
    } else {

      this.songPlayIndex--
    }
    this._onPlayIndexChange()
    this.efAVPlay.setUrl(this.songPlayUrl as string)
      .then(() => {
        this.onPrevious(this.songPlayIndex)
      })

  }
  /**
   * 下一首
   */
  onNext: (index: number) => void = () => {
  }
  /**
   * 上一首
   */
  onPrevious: (index: number) => void = () => {
  }
  /**
   * 静音
   */
  onMuted: () => void = () => {
  }
  /**
   * 错误监听
   */
  onError: (error: Error) => void = () => {
  }
  /**
   * 实时播放
   */
  onTimeUpdate: (time: number) => void = () => {
  }
  /**
   * 调整音量
   */
  onVolumnChange: (v: number) => void = () => {
  }

  _onVolumnChange(v: number) {
    // this.efAVPlay
    this.efAVPlay.setPlayOptions({ volume: this.volume })
    this.onVolumnChange(this.volume)
  }

  volumnBuilder() {
    Column({ space: 2 }) {
      Slider({
        value: this.efAVPlay.volume,
        min: 0,
        max: 1,
        step: 0.01,
        direction: Axis.Vertical,
        reverse: true
      })
        .height(100)
        .onChange((v, e) => {
          if (e === SliderChangeMode.Moving) {
            this.volume = v
            this.efAVPlay.setPlayOptions({ volume: v })
            if (this.volume === 0) {
              this.isMuted = true
              this.onMuted()
            } else {
              this.isMuted = false
            }
          }
        })
        .onTouch(v => {
          if (v.type === TouchType.Up) {
            this.showVolumn = false
          }
        })

      Text(`${Math.floor(this.volume * 100)}%`)
        .fontSize(12)
        .fontColor("#666")
    }
    .padding({ bottom: 5 })
  }

  /**
   * 切换歌曲
   */
  onPlayIndexChange: (index: number) => void = () => {
  }

  _onPlayIndexChange() {
    this.songPlayUrl = this.songList[this.songPlayIndex]
    this.onPlayIndexChange(this.songPlayIndex)
  }

  aboutToAppear() {
    this.efAVPlay.init()
      .then(async () => {
        this._onPlayIndexChange()
        if (this.isPlay) {
          this.initPlay()
        }
      })

  }

  aboutToDisappear() {
    this.efAVPlay.release()
  }

  /**
   * 播放进度改变
   */
  onSeek: (time: number) => void = () => {
  }
  /**
   * 设置播放进度
   */
  setSeek = (time: number) => {
    this.efAVPlay.seek(time)
    this.onSeek(time)

  }
  /**
   * 暂停
   */
  onPaused: () => void = () => {
  }
  /**
   * 暂停
   */
  _onPaused = () => {
    this.efAVPlay.pause()
    this.onPaused()
  }
  /**
   * 播放
   */
  onPlay: () => void = () => {
  }
  /**
   * 播放
   */
  _onPlay = () => {
    this.efAVPlay.play()
    this.onPlay()
  }
  /**
   * 切换播放状态
   */
  togglePlay = () => {
    this.isPlay = !this.isPlay
    if (this.isPlay) {
      this._onPlay()
    } else {
      this._onPaused()
    }
  }
  /**
   * 播放模式改变
   */
  onPlayModeIndex: (mode: SAPlayMode) => void = () => {
  }

  _onPlayModeIndex() {
    this.saPlayMode = this.playModeIcons[this.playModeIndex].mode
    this.saPlayModeIcon = this.playModeIcons[this.playModeIndex].url
    this.onPlayModeIndex(this.saPlayMode)
  }

  /**
   * 播放状态改变 和 AVPlayer内置的state一致
   */
  onStateChange: (state: EfAVPlayerState) => void = () => {
  }
  /**
   * 播放状态改变 和 AVPlayer内置的state一致
   */
  _onStateChange = (state: EfAVPlayerState) => {
    this.onStateChange(state)
  }
  /**
   * 切换播放模式
   */
  togglePlayMode = () => {
    if (this.playModeIndex + 1 >= this.playModeIcons.length) {
      this.playModeIndex = 0
    } else {
      this.playModeIndex++
      this.showPlayModePopup = true
    }
    if (this.showPlayModePopupTid !== -1) {

      clearTimeout(this.showPlayModePopupTid)
    }
    this.showPlayModePopupTid = setTimeout(() => {
      this.showPlayModePopup = false
      this.showPlayModePopupTid = -1
    }, 1000)
    // this.onPlayModeIndex()
  }

  build() {
    Column() {
      //   1 进度条
      Row() {
        Slider({ min: 0, max: this.duration, value: this.currentTime })
          .layoutWeight(1)
          .blockColor("#eee")
          .trackColor("#eef")
          .selectedColor("#bee")
          .onChange(this.setSeek)
      }

      // 2 播放时间
      Row() {
        Text(sa_timeFormat(this.currentTime))
          .fontColor("#aaa")
          .fontSize(12)
        Text(sa_timeFormat(this.duration))
          .fontColor("#aaa")
          .fontSize(12)
      }
      .justifyContent(FlexAlign.SpaceBetween)
      .width("100%")
      .padding({ left: 15, right: 15 })

      //   3 按钮
      Row() {
        //   播放模式
        Row() {
          Image(this.saPlayModeIcon)
            .setsetIconStyle()
            .onClick(this.togglePlayMode)
            .bindPopup($$this.showPlayModePopup, { message: this.playModeIcons[this.playModeIndex].name })

        }
        .setImageRowIconStyle()

        //   上一首
        Row() {
          Image($r("app.media.previous"))
            .setsetIconStyle()
        }
        .setImageRowIconStyle()
        .onClick(this._onPrevious)

        //  播放&暂停
        Row() {
          Image(this.isPlay ? $r("app.media.paused") : $r("app.media.play"))
            .setsetIconStyle()
            .onClick(this.togglePlay)
        }
        .setImageRowIconStyle()
        .scale({ x: 1.2, y: 1.2 })

        //   下一首
        Row() {
          Image($r("app.media.next"))
            .setsetIconStyle()
        }
        .setImageRowIconStyle()
        .onClick(this._onNext)

        //   静音
        Row() {
          Image(this.isMuted ? $r("app.media.muted") : $r("app.media.voice"))
            .setsetIconStyle()

        }
        .setImageRowIconStyle()
        .onClick(() => {
          this.showVolumn = true
        })
        .bindPopup($$this.showVolumn, {
          builder: this.volumnBuilder(),
          width: 40,
          placement: Placement.Top
        })

      }.width("100%")
      .padding({ left: 15, right: 15 })
      .justifyContent(FlexAlign.SpaceAround)
      .margin({ top: 20 })
    }
    .width("100%")
    .height("100%")
    .justifyContent(FlexAlign.Center)
    .padding(5)
  }
}

@Extend(Row)
function setImageRowIconStyle() {
  .width(50)
  .aspectRatio(1)
  .borderRadius(25)
  .justifyContent(FlexAlign.Center)
  .alignItems(VerticalAlign.Center)
  .border({
    width: 1, color: "#eee"
  })
}

@Extend(Image)
function setsetIconStyle() {
  .width(30)
  .fillColor("#666")
}

props

属性 类型 说明
songList SongItemUrl[] 要播放的歌曲列表
volume number 音量属性 0-1 包含小数
isMuted boolean 是否静音
efAVPlay EfAVPlayer ef_auido 核心类

event

事件 说明
onNext 下一首
onPrevious 上一首
onMuted 静音
onError 错误监听
onTimeUpdate 实时播放
onVolumnChange 音量调整
onPlayIndexChange 歌曲切换
onSeek 播放进度
onPaused 暂停
onPlay 播放
onPlayModeIndex 播放模式改变
onStateChange 播放状态改变

后期规划

  1. 提供不同的主体皮肤
  2. 提供歌词显示
  3. 提供歌曲封面显示
  4. 提供播放列表显示

注意

  1. 在播放网络歌曲时,记得添加网络权限
  2. 已经给当前想开通了奔溃服务

更多关于HarmonyOS 鸿蒙Next开发模板/组件分享 – 极简播放器(simple_audio)的实战系列教程也可以访问 https://www.itying.com/category-93-b0.html

1 回复

更多关于HarmonyOS 鸿蒙Next开发模板/组件分享 – 极简播放器(simple_audio)的实战系列教程也可以访问 https://www.itying.com/category-93-b0.html


针对帖子标题“HarmonyOS 鸿蒙Next开发模板/组件分享 – 极简播放器(simple_audio)”,以下是与鸿蒙系统直接相关的回答:

在HarmonyOS鸿蒙系统中,极简播放器(simple_audio)作为一个开发模板或组件,通常包含了播放音频所需的基本功能和界面设计。这个组件可能利用了鸿蒙系统的原生API来实现音频的播放控制,如播放、暂停、停止等,并且可能支持后台播放和通知栏控制。

开发者在使用这个模板时,可以关注以下几个方面:

  1. 组件集成:确保极简播放器组件已正确集成到项目中,包括相关的资源文件和代码文件。
  2. 功能实现:检查播放器组件是否实现了播放、暂停、音量调节等基本功能,并测试这些功能是否正常工作。
  3. 界面定制:根据需要,可以定制播放器的界面,以符合应用的整体风格和用户体验。
  4. 系统兼容性:确保极简播放器组件在不同版本的HarmonyOS系统上都能正常工作,没有兼容性问题。

如果在集成或使用极简播放器组件时遇到问题,可以查阅鸿蒙系统的官方文档或开发者社区,以获取更多帮助和指导。如果问题依旧没法解决请联系官网客服,官网地址是:https://www.itying.com/category-93-b0.html

回到顶部