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

2024-12-19 09:39:19
127次阅读
0个评论

在 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,并对其进行了封装,以便在应用中更方便地调用。

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 类来管理音频播放。

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 版权协议,转载请附上原文出处链接和本声明。

收藏00

登录 后评论。没有帐号? 注册 一个。