HarmonyOS NEXT 应用开发实战:音乐播放器的完整实现

在 HarmonyOS NEXT 的应用开发过程中,我们可以利用其提供的丰富的组件和 API 来实现一个功能强大的音乐播放器。本文将通过一个实践案例,详细介绍如何使用 HarmonyOS NEXT 开发一个音乐播放器,包括播放模式切换、歌词显示、播放进度控制等功能。

项目结构

首先,我们来看一下项目的结构。为了代码的整洁和模块化,我们将音乐播放器的相关逻辑和数据封装在不同的文件中:

复制代码
project-root/
├── common/
│   ├── api/
│   │   └── musicApi.ets  // 音乐API接口定义
│   ├── bean/
│   │   └── apiTypes.ets  // 数据类型定义
│   └── constant/
│       └── Constant.ets  // 常量定义
├── utils/
│   └── EfAVPlayer.ets  // 播放器封装
└── app/
    └── pages/
        └── MusicPlayer/
            └── MusicPlayerPageBuilder.ets  // 音乐播放器页面构建
音乐播放器封装

EfAVPlayer.ets 文件中,我们封装了一个名为 EfAVPlayer 的类,用于管理多媒体播放器的各种操作。该类内部使用了 HarmonyOS 的多媒体 API,并对其进行了封装,以便在应用中更方便地调用。

typescript 复制代码
import media from '@ohos.multimedia.media';
import { BusinessError } from '@kit.BasicServicesKit';

export type EfAVPlayerState = 'idle' | 'initialized' | 'prepared' | 'playing' | 'paused' | 'completed' | 'stopped'
  | 'released' | 'error';

export interface EFPlayOptions {
  immediately?: boolean
  loop?: boolean
  volume?: number
}

export class EfAVPlayer {
  private avPlayer: media.AVPlayer | null = null;
  private stateChangeCallback?: Function;
  private errorCallback?: Function;
  private timeUpdateCallback?: Function;
  volume: number = 1
  loop: boolean = false
  duration: number = 0;
  currentTime: number = 0;
  state: EfAVPlayerState = "idle"
  private efPlayOptions: EFPlayOptions = {
    immediately: true,
    loop: this.loop,
    volume: this.volume
  };

  getAVPlayer() {
    return this.avPlayer
  }

  setPlayOptions(options: EFPlayOptions = {}) {
    if (options.immediately !== undefined) {
      this.efPlayOptions.immediately = options.immediately
    }
    if (options.loop !== undefined) {
      this.efPlayOptions.loop = options.loop
      this.loop = options.loop
    }
    if (options.volume !== undefined) {
      this.efPlayOptions.volume = options.volume
      this.volume = options.volume
    }
    if (this.avPlayer && ['prepared', 'playing', 'paused', 'completed'].includes(this.avPlayer.state)) {
      if (this.avPlayer.loop !== this.loop) {
        this.avPlayer.loop = this.loop
      }
      this.avPlayer.setVolume(this.volume)
    }
  }

  async init(options: EFPlayOptions = this.efPlayOptions) {
    if (!this.avPlayer) {
      this.avPlayer = await media.createAVPlayer();
      this.setPlayOptions(options);
      this._onError();
      this._onStateChange();
      this._onTimeUpdate();
    }
    return this.avPlayer;
  }

  private async _onStateChange() {
    const avPlayer = await this.init();

    avPlayer.on('stateChange', async (state: string, reason: media.StateChangeReason) => {
      this.state = state as EfAVPlayerState
      switch (state) {
        case 'idle':
          break;
        case 'initialized':
          avPlayer.prepare();
          break;
        case 'prepared':
          this.duration = avPlayer.duration;
          if (this.efPlayOptions.immediately) {
            avPlayer.play();
          }
          break;
        case 'playing':
          this.avPlayer!.loop = !!this.efPlayOptions.loop;
          this.loop = !!this.efPlayOptions.loop;
          break;
        case 'paused':
          break;
        case 'completed':
          break;
        case 'stopped':
          break;
        case 'released':
          break;
        default:
          break;
      }
      this.stateChangeCallback && this.stateChangeCallback(state);
    });
  }

  async onStateChange(callback: (state: EfAVPlayerState) => void) {
    this.stateChangeCallback = callback;
  }

  async onError(callback: (stateErr: Error) => void) {
    this.errorCallback = callback;
  }

  private async _onError() {
    const avPlayer = await this.init();
    avPlayer.on("error", (err: BusinessError) => {
      console.error("EfAVPlayer", err.message, err.code)
      this.errorCallback && this.errorCallback(err);
    });
  }

  private async _onTimeUpdate() {
    const avPlayer = await this.init();
    avPlayer.on("timeUpdate", (time: number) => {
      this.currentTime = time;
      this.timeUpdateCallback && this.timeUpdateCallback(time);
    });
  }

  async seek(time: number) {
    const avPlayer = await this.init();
    avPlayer.seek(time);
  }

  async onTimeUpdate(callback: (time: number) => void) {
    this.timeUpdateCallback = callback;
  }

  async stop() {
    const avPlayer = await this.init();
    await avPlayer.stop();
  }

  async setUrl(url: string) {
    const avPlayer = await this.init();
    avPlayer.url = url;
  }

  async setFdSrc(url: media.AVFileDescriptor) {
    const avPlayer = await this.init();
    avPlayer.fdSrc = url;
  }

  async setDataSrc(url: media.AVDataSrcDescriptor) {
    const avPlayer = await this.init();
    avPlayer.dataSrc = url;
  }

  async play() {
    const avPlayer = await this.init();
    avPlayer.play();
  }

  async pause() {
    const avPlayer = await this.init();
    avPlayer.pause();
  }

  async reset() {
    await this.avPlayer?.reset()
  }

  async release() {
    await this.avPlayer?.release();
    this.avPlayer?.off("stateChange");
    this.avPlayer?.off("error");
    this.avPlayer?.off("timeUpdate");
    this.currentTime = 0;
    this.duration = 0;
    this.avPlayer = null;
    this.errorCallback = undefined;
    this.stateChangeCallback = undefined;
    this.timeUpdateCallback = undefined;
  }

  async quickPlay(url: string | media.AVFileDescriptor | media.AVDataSrcDescriptor) {
    await this.init({ immediately: true, loop: true });
    if (typeof url === "string") {
      await this.setUrl(url)
    } else {
      if (typeof (url as media.AVFileDescriptor).fd === "number") {
        await this.setFdSrc(url as media.AVFileDescriptor)
      } else {
        await this.setDataSrc(url as media.AVDataSrcDescriptor)
      }
    }
    await this.play()
  }
}
音乐播放器页面构建

MusicPlayerPageBuilder.ets 文件中,我们定义了音乐播放器的页面结构。主要使用了 ColumnRowListTextSlider 等组件来构建界面,并通过 EfAVPlayer 类来管理音频播放。

typescript 复制代码
import { getLyric, getTexts } from "../../common/api/musicApi"
import { LyricItem, SongItem } from "../../common/bean/apiTypes"
import { Constant } from "../../common/constant/Constant"
import { EfAVPlayer } from "../../utils/EfAVPlayer"
import { Log } from "../../utils/logutil"
import { BusinessError } from "@kit.BasicServicesKit"

enum PlayMode {
  order,
  single,
  repeat,
  random
}

interface PlayModeIcon {
  url: ResourceStr
  mode: PlayMode
  name: string
}

@Builder
export function MusicPlayerPageBuilder() {
  MusicPlayer()
}

@Component
struct MusicPlayer {
  pageStack: NavPathStack = new NavPathStack()
  private scroller: Scroller = new Scroller()
  private types?: number;
  @State
  avPlayer: EfAVPlayer = new EfAVPlayer()
  @State
  playModeIndex: number = 1
  @State
  activeIndex: number = 0
  @State
  songItem: SongItem = {} as SongItem
  @State
  playList: SongItem[] = []
  @State
  lrcList: LyricItem[] = []
  @State
  playModeIcons: PlayModeIcon[] = [
    {
      mode: PlayMode.order,
      url: "resource/order",
      name: "顺序播放"
    },
    {
      mode: PlayMode.single,
      url: "resource/single",
      name: "单曲循环"
    },
    {
      mode: PlayMode.repeat,
      url: "resource/repeat",
      name: "列表循环"
    },
    {
      mode: PlayMode.random,
      url: "resource/random",
      name: "随机播放"
    },
  ]

  aboutToAppear() {
    this.avPlayer.onStateChange(async (state) => {
      if (state === "completed") {
        await this.avPlayer.reset()
        switch (this.playModeIcons[this.playModeIndex].mode) {
          case PlayMode.order:
            if (this.activeIndex + 1 < this.playList.length - 1) {
              this.activeIndex++
              this.setPlay()
            }
            break
          case PlayMode.single:
            this.setPlay()
            break
          case PlayMode.repeat:
            if (this.activeIndex + 1 >= this.playList.length) {
              this.activeIndex = 0
            } else {
              this.activeIndex++
            }
            this.setPlay()
            break
          case PlayMode.random:
            this.activeIndex = Math.floor(Math.random() * (this.playList.length))
            this.setPlay()
            break
        }
      }
    })

    this.avPlayer.onTimeUpdate((time) => {

    })
  }

  getPlayItem() {
    return this.playList[this.activeIndex]
  }

  setPlay() {
    this.songItem = this.getPlayItem()
    if (this.types == undefined) {
      this.avPlayer.quickPlay(Constant.Song_Url + this.songItem.Sound)
    } else {
      this.avPlayer.quickPlay(Constant.Song_Url + this.songItem.Songmp3)
    }
  }

  setSeek = (time: number) => {
    this.avPlayer.seek(time)
  }

  timeFormat(time: number) {
    const minute = Math.floor(time / 1000 / 60).toString().padStart(2, '0')
    const second = Math.floor(time / 1000 % 60).toString().padStart(2, '0')
    return `${minute}:${second}`
  }

  playToggle = () => {
    if (this.avPlayer.state === "playing") {
      this.avPlayer.pause()
    } else {
      if (this.avPlayer.state === "idle") {
        this.setPlay()
      } else {
        this.avPlayer.play()
      }
    }
  }

  playModeToggle = () => {
    if (this.playModeIndex + 1 >= this.playModeIcons.length) {
      this.playModeIndex = 0
    } else {
      this.playModeIndex++
    }
  }

  previous = async () => {
    await this.avPlayer.reset()
    if (this.activeIndex - 1 < 0) {
      this.activeIndex = this.playList.length - 1
    } else {
      this.activeIndex--
    }
    this.setPlay()
  }

  next = async () => {
    await this.avPlayer.reset()
    const currentMode = this.playModeIcons[this.playModeIndex].mode
    if (currentMode === PlayMode.random) {
      this.activeIndex = Math.floor(Math.random() * (this.playList.length))
    } else {
      if (this.activeIndex + 1 >= this.playList.length) {
        this.activeIndex = 0
      } else {
        this.activeIndex++
      }
    }
    this.setPlay()
  }

  isCurrentLyric(item: LyricItem): boolean {
    const currentTimeInSeconds = Math.floor(this.avPlayer.currentTime / 1000);
    if (parseInt(item.Timing) <= currentTimeInSeconds && parseInt(item.EndTiming) >= currentTimeInSeconds) {
      return true
    }
    return false
  }

  build() {
    NavDestination() {
      Column() {
        List({ space: 0, scroller: this.scroller }) {
          ForEach(this.lrcList, (item: LyricItem, idx) => {
            ListItem() {
              Text(item.Sentence)
                .fontColor(this.isCurrentLyric(item) ? Color.Blue : Color.Black)
                .padding(10)
            }
          }, (itm: LyricItem) => itm.SongId)
        }.height('85%')
        .divider({ strokeWidth: 1, color: '#F1F3F5' })
        .listDirection(Axis.Vertical)
        .edgeEffect(EdgeEffect.Spring, { alwaysEnabled: true })

        Column() {
          Row({ space: 2 }) {
            Text(this.timeFormat(this.avPlayer.currentTime))
              .fontColor(Color.Blue)

            Slider({ min: 0, max: this.avPlayer.duration, value: this.avPlayer.currentTime })
              .layoutWeight(1)
              .onChange(this.setSeek)
            Text(this.timeFormat(this.avPlayer.duration))
              .fontColor(Color.Blue)
          }
          Row() {
            Image($r('app.media.gobackward_15'))
              .toolIcon()
              .onClick(() => {
                this.setSeek(this.avPlayer.currentTime - 15000)
              })
            Image(this.avPlayer.state === "playing" ? $r('app.media.pause') : $r('app.media.play_fill'))
              .fillColor(this.avPlayer.state === "playing" ? Color.Red : Color.Black)
              .toolIcon()
              .onClick(this.playToggle)
            Image($r('app.media.goforward_15'))
              .toolIcon()
              .onClick(() => {
                this.setSeek(this.avPlayer.currentTime + 15000)
              })
          }
          .width("80%")
          .justifyContent(FlexAlign.SpaceAround)
        }
        .justifyContent(FlexAlign.Center)
        .padding(10)
      }
      .height("100%")
      .opacity(1)
      .backgroundColor('#80EEEEEE')
      .justifyContent(FlexAlign.SpaceBetween)
    }
    .width("100%")
    .height("100%")
    .onReady(ctx => {
      this.pageStack = ctx.pathStack
      let par = ctx.pathInfo.param as { item: SongItem, types: number }
      this.songItem = par.item
      this.types = par.types
      this.playList.push(par.item)
    })
    .onShown(() => {
      if (Object.keys(this.songItem).length !== 0) {
        setTimeout(() => {
          this.playToggle()
        }, 100)
      }
      if (this.types == undefined) {
        getTexts(this.songItem.SongId).then((res) => {
          this.lrcList = res.data.data
        }).catch((err: BusinessError) => {
          Log.debug("request", "err.code:%d", err.code)
          Log.debug("request", err.message)
        });
      } else {
        getLyric(this.songItem.SongId).then((res) => {
          this.lrcList = res.data.data
        }).catch((err: BusinessError) => {
          Log.debug("request", "err.code:%d", err.code)
          Log.debug("request", err.message)
        });
      }
    })
    .onBackPressed(() => {
      this.avPlayer.reset()
      this.avPlayer.release()
      return false
    })
}

@Extend(Image)
function toolIcon() {
  .width(40)
  .stateStyles({
    normal: {
      .scale({ x: 1, y: 1 })
      .opacity(1)
    },
    pressed: {
      .scale({ x: 1.2, y: 1.2 })
      .opacity(0.4)
    }
  })
  .animation({ duration: 300, curve: Curve.Linear })
}
主要功能实现
  1. 播放模式切换 :通过定义 PlayMode 枚举和 PlayModeIcon 接口,实现了顺序播放、单曲循环、列表循环和随机播放四种模式的切换。
  2. 歌词显示 :通过 ListForEach 组件,实现了歌词的逐行显示,并在当前播放的歌词前设置蓝色高亮。
  3. 播放进度控制 :使用 Slider 组件来控制播放进度,并通过 setSeek 方法实现跳转到指定时间点的功能。
  4. 播放控制 :通过 playToggle 方法实现了播放和暂停的切换。
总结

通过本文的介绍,我们了解了如何在 HarmonyOS NEXT 中实现一个音乐播放器。这不仅涉及到界面的构建,还涉及到对音频播放器的封装和管理。希望本文能对大家有所帮助,如果在开发过程中遇到问题,也可以参考 HarmonyOS 官方文档或社区论坛寻求答案。

开发过程中,我们始终遵循 HarmonyOS 的设计理念,注重用户体验和代码的可维护性。希望未来的 HarmonyOS 应用开发能更加高效和易于实现。

作者介绍

作者:csdn猫哥

原文链接:https://blog.csdn.net/yyz_1987/article/details/144553700

团队介绍

坚果派团队由坚果等人创建,团队拥有12个华为HDE带领热爱HarmonyOS/OpenHarmony的开发者,以及若干其他领域的三十余位万粉博主运营。专注于分享HarmonyOS/OpenHarmony、ArkUI-X、元服务、仓颉等相关内容,团队成员聚集在北京、上海、南京、深圳、广州、宁夏等地,目前已开发鸿蒙原生应用和三方库60+,欢迎交流。

版权声明

本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。

相关推荐
一只栖枝7 小时前
华为 HCIE 大数据认证中 Linux 命令行的运用及价值
大数据·linux·运维·华为·华为认证·hcie·it
zhanshuo11 小时前
在鸿蒙里优雅地处理网络错误:从 Demo 到实战案例
harmonyos
zhanshuo11 小时前
在鸿蒙中实现深色/浅色模式切换:从原理到可运行 Demo
harmonyos
whysqwhw16 小时前
鸿蒙分布式投屏
harmonyos
whysqwhw17 小时前
鸿蒙AVSession Kit
harmonyos
whysqwhw19 小时前
鸿蒙各种生命周期
harmonyos
whysqwhw20 小时前
鸿蒙音频编码
harmonyos
whysqwhw20 小时前
鸿蒙音频解码
harmonyos
whysqwhw20 小时前
鸿蒙视频解码
harmonyos
whysqwhw20 小时前
鸿蒙视频编码
harmonyos