/ 今日科技快讯 /
近日,苹果首席执行官蒂姆·库克接受《时代》杂志专访,谈及他本人对领导力、企业价值和新技术的看法。库克表示,苹果不仅要引领创新,还要努力让世界变得更安全更公平,而这一点很重要。
在谈到令人兴奋的新技术时,库克表示自己对人工智能很感兴趣。他说,“我对人工智能很感兴趣。如今,人工智能已经出现在许多你根本想不到的产品中。从我们识别用户面部、指纹的方式,归纳整理照片的方式,再到Siri的工作方式都是如此。关于人工智能能为人们做什么,如何让人们生活更轻松,实际上我们还处在早期阶段。”
/ 作者简介 /
大家周三好,这周已然过半,新的一周继续加油吧!
本篇文章来自半夏微凉的投稿,文章主要分享了如何在HarmonyOS中实现基于JS卡片的音乐播放器,相信会对对鸿蒙感兴趣的朋友们有所帮助!同时也感谢作者贡献的精彩文章。
半夏微凉的博客地址:
https://developer.huawei.com/consumer/cn/personalcenter/myCommunity/communityBlog?uid=5747c32f82a141bf8af5febde6b6af2f&siteId=1
/ 文章简介 /
这是一款基于JS卡片打造的音乐播放卡片应用,我们可以通过桌面卡片来获取音乐播放的信息,也可以进行播放、暂停、歌曲切换等功能。
效果展示
2X2卡片展示歌曲封面、播放状态、播放进度、歌曲名称等信息。
4X4卡片增加歌词展示。
通过卡片即可操作音乐播放、暂停、切换等操作。
/ 搭建HarmonyOS环境 /
安装DevEco Studio,详情请参考DevEco Studio下载。
设置DevEco Studio开发环境,DevEco Studio开发环境需要依赖于网络环境,可以根据如下两种情况来配置开发环境:
如果可以直接访问Internet,只需进行下载HarmonyOS SDK操作
如果网络不能直接访问Internet,需要通过代理服务器才可以访问,请参考配置开发环境
本程序需要在真机运行,需要提前申请证书:
准备密钥和证书请求文件
申请调试证书
/ 音乐卡片开发 /
功能设计
卡片的功能有三部分:
信息展示
页面跳转
数据交互
因此我们可以实现的功能有:
歌曲名称、歌手名称、歌曲封面等信息展示
卡片跳转至播放器主页,并且数据同步
播放、暂停、歌曲切换
播放进度展示
Java卡片与JS卡片选型
确定了我们要实现的功能后,再进行Java卡片与JS卡片选型,如图所示,java卡片支持的组件比较少,无法满足我们的功能需求。
JS卡片功能更强大,支持的组件也更多,可以实现我们的开发需求。
代码结构
包名 | 功能 |
bean | 歌词、柱状图数据实体类 |
customcomponent | 自定义组件柱状图 |
database | 数据库和数据表 |
provider | 列表数据提供者 |
service | 播放器服务类 |
slice | 启动页和主页面 |
utils | 数据库、log、歌词解析、线程切换工具类 |
JS界面实现
新建卡片
选择模板
工具为我们提供了多个模板,我们选择一个音乐播放模板。
卡片配置
创建完成后,config.json文件中会自动生成卡片配置的参数,我们可以在此处设置卡片的名称、规格、类型等参数。
Java Ability中的jsComponentName和js模块下的name相对应。
JS page
创建完成后有三个文件,js文件为卡片提供数据,hml文件编写布局属性,css文件编写控件样式。
布局编写
js为卡片提供数据,配置跳转事件和message事件。
export default {
data: {
//歌曲封面
picName: "/common/music.png",
//进度条的值
progressValue: 0,
//按钮点击发送message
last: "last",
play: "play",
next: "next",
//歌曲名
songName: "未播放",
//歌手名
singer: "未知歌手",
//歌词列表
lyric: [{
fontColor: "#9C9C9C",
fontSize: "14px",
Lrc: "",
}],
actions: {
//跳转事件
routerEvent: {
action: "router",
bundleName: "com.example.wryproject",
abilityName: "com.example.wryproject.MusicAbility"
},
//message事件
lastEvent: {
action: "message",
params: {
message: this.last
}
},
playEvent: {
action: "message",
params: {
message: this.play
}
},
nextEvent: {
action: "message",
params: {
message: this.next
}
},
}
}
html代码
<div class="container">
<div class="title_container">
<image src="{{ picName }}" @click="routerEvent" class="music-img"></image>
<div class="songData">
<text class="songName">{{ songName }}</text>
<text class="singer">{{ singer }}</text>
</div>
</div>
<list class="lyric_list">
<list-item class="lyric_list_item" for="{{ lyric }}">
<text class="lyric_text" style="color : {{ $item.fontColor }}; font-size : {{ $item.fontSize }};">{{
$item.Lrc }}</text>
</list-item>
</list>
<div class="button_container">
<progress class="progress" type="ring" percent="{{ progressValue }}">
</progress>
<image src="{{ lastImage }}" @click="lastEvent" class="last-img"></image>
<image src="{{ playImage }}" @click="playEvent" class="play-img"></image>
<image src="{{ nextImage }}" @click="nextEvent" class="next-img"></image>
</div>
</div>
css代码较长,这里只贴出一部分:
.container {
width: 100%;
height: 100%;
}
.title_container {
height: 70%;
width: 100%;
flex-direction: row;
position: absolute;
top: 5;
}
.music-img {
background-color: #0F000000;
height: 50%;
width: 40%;
object-fit: contain;
border-radius: 10px;
margin-left: 8%;
margin-top: 8%;
position: absolute;
}
.songData {
height: 50%;
width: 50%;
position: absolute;
flex-direction: column;
align-items: center;
padding-right: 10%;
right: 3%;
top: 8%;
}
初始预览效果:
音频资源获取
JS界面已经准备好了,接下来我们实现音乐播放功能,音频播放的实现不是我们文章的重点,所以下面我只大致分享一下实现流程,感兴趣的小伙伴可以到文章末尾获取项目代码。
音频获取
通过AVStorage,我们可以获取到本地歌曲的地址、名称、时长等信息,但很遗憾,AVStorage目前没有歌手信息,当我以为项目功能又要“裁剪”时,我想到大部分音频文件的地址是包含歌手信息的,我们将音频地址打印出来(如下图),这样我们就可以通过字符串截取,来拿到歌手信息了,然后将音频数据保存至数据库中,此项目使用了对象关系映射数据库 ,它可以让开发者不必再去编写复杂的SQL语句, 以操作对象的形式来操作数据库,相当友好呢。
DataAbilityHelper helper = DataAbilityHelper.creator(this);
try {
ResultSet resultSet = helper.query(AVStorage.Audio.Media.EXTERNAL_DATA_ABILITY_URI, null, null);
//通过while循环拿到所有音频数据
while (resultSet != null && resultSet.goToNextRow()) {
//音乐ID
int musicId = resultSet.getInt(resultSet.getColumnIndexForName(AVStorage.AVBaseColumns.ID));
//音频地址
String musicPath = resultSet.getString(resultSet.getColumnIndexForName(AVStorage.AVBaseColumns.DATA));
//音频名称
String musicTitle = resultSet.getString(resultSet.getColumnIndexForName(AVStorage.AVBaseColumns.TITLE));
//音频时常
int musicDuration = resultSet.getInt(resultSet.getColumnIndexForName(AVStorage.AVBaseColumns.DURATION));
//AVStorage自带属性中暂无歌手信息,但musicPath中包含歌手信息,此处将歌手名称截取出来
int startIndex = musicPath.lastIndexOf("/");
int endIndex = musicPath.lastIndexOf("-");
String singer = "未知歌手";
if (startIndex != -1 && endIndex != -1) {
if (endIndex < startIndex) {
endIndex = musicPath.lastIndexOf(".");
}
singer = musicPath.substring(startIndex + 1, endIndex - 1);
}
//过滤小于10秒的音频
if (musicDuration > 10000) {
//将音频数据插入数据库
}
}
resultSet.close();
音频封面的获取
音频封面的获取比较消耗性能,而且获取项目内音频封面和获取本地音频封面的方法不同,我将它单独放在一个方法里实现。
封面获取主要用到AVMetadataHelper对象,官网示例链接:获取音频的图像数据的开发步骤。
获取音频的图像数据的开发步骤:
https://developer.harmonyos.com/cn/docs/documentation/doc-guides/media-data-mgmt-obtaining-0000001050751061
Uri uri = Uri.appendEncodedPathToUri(AVStorage.Audio.Media.EXTERNAL_DATA_ABILITY_URI, stringId);
fileDescriptor = helper.openFile(uri, "r");
//通过文件描述符获取封面
avMetadataHelper.setSource(fileDescriptor);
byte[] data = avMetadataHelper.resolveImage();
通过AVStorage.Audio.Media.EXTERNAL_DATA_ABILITY_URI和音频ID,我们拿到了音频的Uri,helper.openFile(uri, "r")拿到文件描述符,调用avMetadataHelper.resolveImage方法拿到字节数组,我们就可以创建PixelMap对象了(见下图):
/**
* 通过MP3文件的数据流获取专辑封面
*
* @return 封面
*/
public PixelMap getPixelMapCover() {
OrmContext ormContext = DatabaseUtils.getOrmContext(getContext());
OrmPredicates ormPredicates = ormContext.where(MusicData.class);
List<MusicData> musicDataList = ormContext.query(ormPredicates);
MusicData musicData = musicDataList.get(currentPosition);
Blob musicCover = musicData.getMusicCover();
PixelMap pixelMap = null;
if (musicCover != null && musicCover.length() != 0) {
byte[] bytes = musicCover.getBytes(1, Math.toIntExact(musicCover.length()));
ImageSource imageSource = ImageSource.create(bytes, null);
//普通解码createPixelMap传入的DecodingOptions 设置为null
pixelMap = imageSource.createPixelmap(null);
}
return pixelMap;
}
歌词获取
歌词是通过网络获取(资源网站地址+歌曲名+歌手名),拿到歌词地址数据。
最终拿到歌词lrc文件,解析为歌词列表,免费的资源毕竟是少数,所以这个网站只有传唱度高的歌曲才有歌词资源。
歌词解析代码较多,见项目LyricAnalysisUtil类。
播放功能实现
播放
第一次点击播放就随机设置资源,如果正在播放就暂停,反之就播放,然后更新列表播放状态。
/**
* 播放音乐
*/
public void playMusic() {
if (currentPosition == -1) {
randomPlayer(musicDataList.size());
} else if (player.isNowPlaying()) {
player.pause();
} else {
player.play();
}
musicService.notice();
if (MusicAbilitySlice.getProvider() != null) {
ThreadUtil.runUI(() -> MusicAbilitySlice.getProvider()
.updatePlayerState(-1, currentPosition, player.isNowPlaying()));
}
}
上下曲切换
调整播放列表的pisition,实现播放资源的切换。
/**
* 播放上一曲
*/
public void lastMusic() {
if (currentPosition == -1) {
//未设置歌曲资源弹出dialog提示
new ToastDialog(mContext)
.setAlignment(LayoutAlignment.CENTER)
.setText("请选择需要播放的歌曲~")
.show();
} else if (currentPosition == 0) {
//提示用户已经是第一首了
new ToastDialog(mContext)
.setAlignment(LayoutAlignment.CENTER)
.setText("已经是第一首了~")
.show();
} else {
//播放上一首
currentPosition -= 1;
setSource();
}
}
随机播放
/**
* 根据传入的数值区间,生成随机数,并播放
*
* @param end 随机数的最大区间
*/
public void randomPlayer(int end) {
Random random = new Random();
currentPosition = random.nextInt(end);
setSource();
}
设置播放资源
/**
* 重置播放器,设置资源重新播放
*/
public void setSource() {
//判断地址是raw文件下资源还是本地资源
if (!path.contains("Music")) {
RawFileDescriptor rawFileDescriptor;
try {
rawFileDescriptor = getResourceManager().getRawFileEntry(path).openRawFileDescriptor();
player.setSource(rawFileDescriptor);
} catch (IOException e) {
e.printStackTrace();
}
} else {
source = new Source(path);
player.setSource(source);
}
player.prepare();
player.play();
}
卡片数据更新
接下来就是本文的主题了:如何将获取到的数据显示到卡片上?
卡片的生命周期
我们通过IDE创建卡片时,会自动绑定MainAbility,生成卡片的回调方法。
在onCreateForm方法中我们要将卡片ID保存至数据库,后面将会使用ID来进行卡片更新。
@Override
protected ProviderFormInfo onCreateForm(Intent intent) {
if (intent == null) {
return new ProviderFormInfo();
}
//返回主界面后musicRemoteObject就会为空,此时操作卡片就需要重新获取musicRemoteObject对象
if (musicRemoteObject == null) {
musicRemoteObject = MusicServiceAbility.get();
}
// 获取卡片id
long cardId = INVALID_CARD_ID;
if (intent.hasParameter(AbilitySlice.PARAM_FORM_IDENTITY_KEY)) {
cardId = intent.getLongParam(AbilitySlice.PARAM_FORM_IDENTITY_KEY, INVALID_CARD_ID);
}
ProviderFormInfo providerFormInfo = new ProviderFormInfo();
// 获取卡片规格
int dimension = DEFAULT_DIMENSION_2X2;
if (intent.hasParameter(AbilitySlice.PARAM_FORM_DIMENSION_KEY)) {
dimension = intent.getIntParam(AbilitySlice.PARAM_FORM_DIMENSION_KEY, DEFAULT_DIMENSION_2X2);
}
CardData cardData = new CardData(cardId, dimension);
DatabaseUtils.insertCardData(cardData, DatabaseUtils.getOrmContext(this));
musicRemoteObject.updateCardCover();
musicRemoteObject.lrcLoading("暂无歌词数据");
providerFormInfo.setJsBindingData(new FormBindingData());
return providerFormInfo;
}
onDeleteForm方法是卡片删除时的回调,所以我们要同步删除数据库中对应的卡片ID。
@Override
protected void onUpdateForm(long formId) {
HiLog.info(TAG, "onUpdateForm");
super.onUpdateForm(formId);
}
@Override
protected void onDeleteForm(long formId) {
HiLog.info(TAG, "onDeleteForm: formId=" + formId);
super.onDeleteForm(formId);
DatabaseUtils.deleteCardData(formId, DatabaseUtils.getOrmContext(this));
}
onTriggerFormEvent方法用来接收卡片的message 事件,卡片的播放、暂停、歌曲切换都是通过这个方法来进行交互。
根据卡片传过来的字符串对象判断下一步操作。
@Override
protected void onTriggerFormEvent(long formId, String message) {
super.onTriggerFormEvent(formId, message);
//返回主界面后musicRemoteObject就会为空,此时操作卡片就需要重新获取musicRemoteObject对象
if (musicRemoteObject == null) {
musicRemoteObject = MusicServiceAbility.get();
}
String musicMessage = (String) ZSONObject.stringToZSON(message).get("message");
switch (musicMessage) {
case "last":
musicRemoteObject.lastMusic();
break;
case "play":
//当用户杀死应用时,player就会为null,此时点击卡片播放,需做判空处理
if (musicRemoteObject.getPlayer() == null) {
musicRemoteObject.initPlayer(this);
}
musicRemoteObject.playMusic();
break;
case "next":
musicRemoteObject.nextMusic();
break;
}
musicRemoteObject.updateCardCover();
}
设置卡片数据
ZSONObject设置卡片数据, zsonObject.put("songName", title)方法,第一个参数相当于key,要和JS文件的数据名称相对应,第二个参数为要传入的数据。
/**
* 设置卡片数据
*/
private ZSONObject setCardData() {
// 设置卡片页面需要的数据
if (ormContext == null) {
ormContext = DatabaseUtils.getOrmContext(musicService.getContext());
}
ZSONObject zsonObject = new ZSONObject();
int position = getCurrentPosition();
//如果player没有设置资源,直接return
if (position == -1) {
return null;
} else {
//player设置了资源,将卡片按钮点亮
zsonObject.put("lastImage", "/common/last.png");
zsonObject.put("nextImage", "/common/next.png");
}
//根据播放状态设置卡片播放或暂停
if (getIsPlay()) {
zsonObject.put("playImage", "/common/pause.png");
} else {
zsonObject.put("playImage", "/common/play.png");
}
//设置卡片title
String title = DatabaseUtils.queryMusicData(position, ormContext).getMusicTitle();
String singer = DatabaseUtils.queryMusicData(position, ormContext).getSinger();
zsonObject.put("songName", title);
zsonObject.put("singer", singer);
return zsonObject;
}
在歌曲播放、切换时设置卡片封面。
此处有一个知识点 --> JS卡片如何设置本地图片或网络图片?官方文档给出了示例:通过内存图片方式使用image组件。他的核心就是如下两个方法:
zsonObject.put("图片名称", 图片地址)
formBindingData.addImageData(图片名称, 图片字节数据)
重点就是我们要拿到图片的数据流,并添加到 "memory://" 这个内存地址。
代码
/**
* 更新所有卡片封面
*/
public void updateCardCover() {
ThreadUtil.runWork(() -> {
ZSONObject zsonObject = setCardData();
List<CardData> cardDataList = DatabaseUtils.queryAllCardData(ormContext);
FormBindingData formBindingData = new FormBindingData(zsonObject);
if (getCardCover() != null) {
int musicId = getMusicList().get(currentPosition).getMusicId();
String picName = musicId + ".png";
String picPath = "memory://" + picName;
assert zsonObject != null;
zsonObject.put("picName", picPath);
formBindingData.addImageData(picName, getCardCover());
}
//遍历卡片列表更新所有卡片
for (CardData cardData : cardDataList) {
try {
updateForm(cardData.getCardId(), formBindingData);
} catch (FormException e) {
DatabaseUtils.deleteCardData(cardData.getCardId(), ormContext);
}
}
});
}
音乐开始播放后就开启一个Timer计时器来更新播放进度和歌词。
/**
* 播放进度的更新
*/
private void playListener() {
EventRunner mainEventRunner = EventRunner.getMainEventRunner();
eventHandler = new EventHandler(mainEventRunner) {
@Override
protected void processEvent(InnerEvent event) {
super.processEvent(event);
//更新播放进度和歌词
}
}
};
if (timer != null) {
timer.cancel();
timer = null;
}
timer = new Timer();
timer.schedule(new TimerTask() {
@Override
public void run() {
InnerEvent innerEvent = InnerEvent.get();
innerEvent.param = getPlayer().getCurrentTime();
eventHandler.sendEvent(innerEvent);
}
}, 0, 200);
}
/ 开发总结 /
此项目的开发,使我们掌握了:
JS卡片的布局编写:JS编写布局相对于JAVA更加高效。例如 JS 组件list,只需三五行代码即可实现列表的基本展示,大大提升了我们的开发效率,JS+JAVA的混合开发模式是一个很不错的开发方案。
卡片的跳转和交互:通过卡片快速直达我们需要的页面,减少层级交互,应用的使用场景更加丰富,操作也更加便捷高效。
卡片数据的实时更新:卡片成为应用信息展示的直接载体,真正做到“信息直达”,只需解锁手机即可获取到我们需要的信息。
Player的简单使用:音视频播放作为手机的常用功能之一,有相当多的用武之地,掌握Player的使用是各位开发者的必经之路。
本地音频资源的获取:数据是一个应用的基础,学会合理使用这些数据、资源也是各位开发者需要掌握的基础。
开发过程中遇到的问题:
直接传入音频地址封面获取失败
问题原因:传入的地址中有中文,资源解析失败。
AVMetadataHelper avMetadataHelper= new AVMetadataHelper();
avMetadataHelper.setSource("/path/冬天的秘密.mp4");
解决方案:通过传入文件描述符获取封面。
Uri uri = Uri.appendEncodedPathToUri(AVStorage.Audio.Media.EXTERNAL_DATA_ABILITY_URI, stringId);
fileDescriptor = helper.openFile(uri, "r");
//通过文件描述符获取封面
avMetadataHelper.setSource(fileDescriptor);
byte[] data = avMetadataHelper.resolveImage();
网络获取歌词失败
问题原因:需要添加网络明文请求的设置。
解决方案:在config.json中添加网络明文请求设置为true
卡片封面更新无效
问题原因:只有卡片出现在桌面时,调用updateForm方法才可以更新封面。
解决方案:考虑到定时器中更新封面代价较大,我们可以分为两种方案:
① 计时器定时更新文字、进度条等占用性能较小的组件。
② 返回桌面时应用失去焦点,在onInactive方法中更新卡片的图片数据即可减少性能损耗。
卡片歌词列表如何实现
问题原因:卡片中的组件是无法调用其方法的,所以无法控制列表滚动。
解决方案:定时刷新列表数据,我们只给列表设置5条数据,然后定时更新不同的歌词传入列表,达到列表伪滚动效果。
音乐在后台播放,几秒钟后音乐自动暂停,进入应用后音乐又开始自动播放。
问题原因:鸿蒙的应用启动管理默认为系统自动管理,有时会将应用后台冻结。
解决方案:在手机自带的手机管家-->应用启动管理-->找到自己的应用关闭自动管理-->打开允许后台活动,应用就不会被冻结了。
以上就是本人遇到的一些问题,希望可以给各位开发者提供参考。由于篇幅有限,文章有很多细节并不能面面俱到,感兴趣的小伙伴可以通过文章末尾链接下载项目,文中写的不好的地方欢迎大家提出建议,大家也可以在评论区多多交流,共同进步~
代码地址:
https://gitee.com/WRY666/ohos_-music-card
推荐阅读:
Android 12 SplashScreen API快速入门
欢迎关注我的公众号
学习技术或投稿
长按上图,识别图中二维码即可关注