在HarmonyOS中实现基于JS卡片的音乐播放器

e18a92de9d6640b099ff8f7f77a08d16.png

/   今日科技快讯   /

近日,苹果首席执行官蒂姆·库克接受《时代》杂志专访,谈及他本人对领导力、企业价值和新技术的看法。库克表示,苹果不仅要引领创新,还要努力让世界变得更安全更公平,而这一点很重要。

在谈到令人兴奋的新技术时,库克表示自己对人工智能很感兴趣。他说,“我对人工智能很感兴趣。如今,人工智能已经出现在许多你根本想不到的产品中。从我们识别用户面部、指纹的方式,归纳整理照片的方式,再到Siri的工作方式都是如此。关于人工智能能为人们做什么,如何让人们生活更轻松,实际上我们还处在早期阶段。”

/   作者简介   /

大家周三好,这周已然过半,新的一周继续加油吧!

本篇文章来自半夏微凉的投稿,文章主要分享了如何在HarmonyOS中实现基于JS卡片的音乐播放器,相信会对对鸿蒙感兴趣的朋友们有所帮助!同时也感谢作者贡献的精彩文章。

半夏微凉的博客地址:

https://developer.huawei.com/consumer/cn/personalcenter/myCommunity/communityBlog?uid=5747c32f82a141bf8af5febde6b6af2f&siteId=1

/   文章简介   /

这是一款基于JS卡片打造的音乐播放卡片应用,我们可以通过桌面卡片来获取音乐播放的信息,也可以进行播放、暂停、歌曲切换等功能。

效果展示

5325a7caa7162a3a417fa32bcaaad80e.gif

  • 2X2卡片展示歌曲封面、播放状态、播放进度、歌曲名称等信息。

  • 4X4卡片增加歌词展示。

  • 通过卡片即可操作音乐播放、暂停、切换等操作。

/   搭建HarmonyOS环境   /

安装DevEco Studio,详情请参考DevEco Studio下载。

设置DevEco Studio开发环境,DevEco Studio开发环境需要依赖于网络环境,可以根据如下两种情况来配置开发环境:

  • 如果可以直接访问Internet,只需进行下载HarmonyOS SDK操作

  • 如果网络不能直接访问Internet,需要通过代理服务器才可以访问,请参考配置开发环境

本程序需要在真机运行,需要提前申请证书:

  1. 准备密钥和证书请求文件

  2. 申请调试证书

/   音乐卡片开发   /


功能设计

卡片的功能有三部分:

  1. 信息展示

  2. 页面跳转

  3. 数据交互

因此我们可以实现的功能有:

  1. 歌曲名称、歌手名称、歌曲封面等信息展示

  2. 卡片跳转至播放器主页,并且数据同步

  3. 播放、暂停、歌曲切换

  4. 播放进度展示


Java卡片与JS卡片选型

确定了我们要实现的功能后,再进行Java卡片与JS卡片选型,如图所示,java卡片支持的组件比较少,无法满足我们的功能需求。

JS卡片功能更强大,支持的组件也更多,可以实现我们的开发需求。

b2562cc5bcf05fa74784d8b68054fb2b.png


代码结构

9dad2d71a7192e4a5f9ca2663658e457.png

包名

功能

bean

歌词、柱状图数据实体类

customcomponent

自定义组件柱状图

database

数据库和数据表

provider

列表数据提供者

service

播放器服务类

slice

启动页和主页面

utils

数据库、log、歌词解析、线程切换工具类


JS界面实现

新建卡片

e071efd4cde8840e4bbf61f990dd874e.png


选择模板

工具为我们提供了多个模板,我们选择一个音乐播放模板。

6aaacf698398dcba8c9d87787e4128fd.png


卡片配置

创建完成后,config.json文件中会自动生成卡片配置的参数,我们可以在此处设置卡片的名称、规格、类型等参数。

Java Ability中的jsComponentName和js模块下的name相对应。

2ba8390fc140d39882b6cde9bb04a3c1.png


JS page

创建完成后有三个文件,js文件为卡片提供数据,hml文件编写布局属性,css文件编写控件样式。

82670d7bea1ce57aedfe7d243e715ee1.png


布局编写

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%;
}

初始预览效果:

33c6ee5960bfc3364e48d699961893fc.png


音频资源获取

JS界面已经准备好了,接下来我们实现音乐播放功能,音频播放的实现不是我们文章的重点,所以下面我只大致分享一下实现流程,感兴趣的小伙伴可以到文章末尾获取项目代码。

音频获取

通过AVStorage,我们可以获取到本地歌曲的地址、名称、时长等信息,但很遗憾,AVStorage目前没有歌手信息,当我以为项目功能又要“裁剪”时,我想到大部分音频文件的地址是包含歌手信息的,我们将音频地址打印出来(如下图),这样我们就可以通过字符串截取,来拿到歌手信息了,然后将音频数据保存至数据库中,此项目使用了对象关系映射数据库 ,它可以让开发者不必再去编写复杂的SQL语句, 以操作对象的形式来操作数据库,相当友好呢。

739917b4003d4595b15981f1eb40b76e.png

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;
        }
歌词获取

歌词是通过网络获取(资源网站地址+歌曲名+歌手名),拿到歌词地址数据。

7d08b35f13eb9d65a7c1fcccbd8f37de.png

最终拿到歌词lrc文件,解析为歌词列表,免费的资源毕竟是少数,所以这个网站只有传唱度高的歌曲才有歌词资源。

歌词解析代码较多,见项目LyricAnalysisUtil类。

630b784ce1eadaf60ff016dc1350a3ff.png


播放功能实现

播放

第一次点击播放就随机设置资源,如果正在播放就暂停,反之就播放,然后更新列表播放状态。

/**
         * 播放音乐
         */
        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文件的数据名称相对应,第二个参数为要传入的数据。

73763f09aba3472ab1c3868fb7b4a550.png

/**
 * 设置卡片数据
 */
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);
}

/   开发总结   /

此项目的开发,使我们掌握了:

  1. JS卡片的布局编写:JS编写布局相对于JAVA更加高效。例如 JS 组件list,只需三五行代码即可实现列表的基本展示,大大提升了我们的开发效率,JS+JAVA的混合开发模式是一个很不错的开发方案。

  2. 卡片的跳转和交互:通过卡片快速直达我们需要的页面,减少层级交互,应用的使用场景更加丰富,操作也更加便捷高效。

  3. 卡片数据的实时更新:卡片成为应用信息展示的直接载体,真正做到“信息直达”,只需解锁手机即可获取到我们需要的信息。

  4. Player的简单使用:音视频播放作为手机的常用功能之一,有相当多的用武之地,掌握Player的使用是各位开发者的必经之路。

  5. 本地音频资源的获取:数据是一个应用的基础,学会合理使用这些数据、资源也是各位开发者需要掌握的基础。

开发过程中遇到的问题:

直接传入音频地址封面获取失败

问题原因:传入的地址中有中文,资源解析失败。

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

7de7e0035ef24a681b8e4e5e212c8cd0.png

卡片封面更新无效

问题原因:只有卡片出现在桌面时,调用updateForm方法才可以更新封面。

解决方案:考虑到定时器中更新封面代价较大,我们可以分为两种方案:

① 计时器定时更新文字、进度条等占用性能较小的组件。

② 返回桌面时应用失去焦点,在onInactive方法中更新卡片的图片数据即可减少性能损耗。

6911bdca174eda81a9579f673d4e026c.png

卡片歌词列表如何实现

问题原因:卡片中的组件是无法调用其方法的,所以无法控制列表滚动。

解决方案:定时刷新列表数据,我们只给列表设置5条数据,然后定时更新不同的歌词传入列表,达到列表伪滚动效果。

音乐在后台播放,几秒钟后音乐自动暂停,进入应用后音乐又开始自动播放。

问题原因:鸿蒙的应用启动管理默认为系统自动管理,有时会将应用后台冻结。

解决方案:在手机自带的手机管家-->应用启动管理-->找到自己的应用关闭自动管理-->打开允许后台活动,应用就不会被冻结了。

bedf535546005f6c6d8a4f4c39e30cd1.png

以上就是本人遇到的一些问题,希望可以给各位开发者提供参考。由于篇幅有限,文章有很多细节并不能面面俱到,感兴趣的小伙伴可以通过文章末尾链接下载项目,文中写的不好的地方欢迎大家提出建议,大家也可以在评论区多多交流,共同进步~

代码地址:

https://gitee.com/WRY666/ohos_-music-card

推荐阅读:

我的新书,《第一行代码 第3版》已出版!

Android自定义控件之弹幕的实现,这效果 666

Android 12 SplashScreen API快速入门

欢迎关注我的公众号

学习技术或投稿

7af271e836af1f53f9e5628b9edf605b.png

a3bd7dee0c0d5681973e090e402a495b.png

长按上图,识别图中二维码即可关注

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值