在 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
文件中,我们定义了音乐播放器的页面结构。主要使用了 Column
、Row
、List
、Text
和 Slider
等组件来构建界面,并通过 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 })
}
主要功能实现
- 播放模式切换 :通过定义
PlayMode
枚举和PlayModeIcon
接口,实现了顺序播放、单曲循环、列表循环和随机播放四种模式的切换。 - 歌词显示 :通过
List
和ForEach
组件,实现了歌词的逐行显示,并在当前播放的歌词前设置蓝色高亮。 - 播放进度控制 :使用
Slider
组件来控制播放进度,并通过setSeek
方法实现跳转到指定时间点的功能。 - 播放控制 :通过
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 版权协议,转载请附上原文出处链接和本声明。