版权声明:本人文章仅在掘金平台发布,请勿抄袭搬运,转载请注明作者及原文链接 🦉
阅读提示:网页版带有主题和代码高亮,阅读体验更佳 🍉
距离上次在掘金发布啵啵音乐 1.0 已经过去一个半月,百度网盘下载量也有一百多次了,github star 数也超过了 100。
完全没人用其实好接受,但是有人用,用的人又不多,就有点难受,因为不能随心所欲的更新和弃更。虽然初衷是满足自己的使用需求,但是也有 bbmusic 的用户转过来了,想着还是尽量利用空闲时间完善 app 的功能。
有一些人提了意见建议,我目前是看情况采纳,毕竟精力和技术都有限。
所以这一个月内,我要实现的重心功能是两个:一是支持合集收藏,这样能够避免占用歌单资源,再一个就是更加方便;二是支持歌词,歌词是音乐 app 基本的核心功能,部分听众对歌词的需求是很强烈的。
除此之外,也做了一些小优化,例如支持检查版本,在线版本更新。
今天就讲讲这些技术实现吧,虽然技术栈是 flutter,但是开发思路都是相同的,web 开发者也不用顾虑,学到就是赚到。
一、检查更新
很多开源产品,纯前端的软件都提供了这个功能,可能你很好奇,没有后端咋实现的检查更新?
其实还是 github 提供的能力。
很多开源产品的 github 仓库,你都能看到 github/workflow 文件。
这是 github 的工作流标准文件,通过编写这个工作流文件,我们可以通过某些关键词,在推送仓库时触发定义好的构建流程,你可以理解为 CICD 的简化版,总之就是触发了仓库的远程自动构建,就和我们本地 build 产物是一个意思。
你看不懂这块没关系,意思就是 github 能够帮助我们构建出新版本的 app 并存在 github 上,每个版本都有自己的版本信息和 app 资源。
当我们前端检查新版本时,就是调用 github 的接口,如果有新的版本,我们就能知道,并通知用户更新。
这个地址其实是固定的:
https://api.github.com/repos/你的用户名/你的仓库名/releases/latest
每次请求这个地址,会去检查该用户某仓库的最新版本的信息,返回值如下(AI 整理):
{
"url": "https://api.github.com/repos/octocat/Hello-World/releases/1",
"assets_url": "https://api.github.com/repos/octocat/Hello-World/releases/1/assets",
"upload_url": "https://uploads.github.com/repos/octocat/Hello-World/releases/1/assets{?name,label}",
"html_url": "https://github.com/octocat/Hello-World/releases/tag/v1.0",
"id": 123456,
"author": {
"login": "octocat",
"id": 1,
"node_id": "MDQ6VXNlcjE=",
"avatar_url": "https://github.com/images/error/octocat_happy.gif",
"gravatar_id": "",
"url": "https://api.github.com/users/octocat",
"html_url": "https://github.com/octocat",
"followers_url": "https://api.github.com/users/octocat/followers",
"following_url": "https://api.github.com/users/octocat/following{/other_user}",
"gists_url": "https://api.github.com/users/octocat/gists{/gist_id}",
"starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/octocat/subscriptions",
"organizations_url": "https://api.github.com/users/octocat/orgs",
"repos_url": "https://api.github.com/users/octocat/repos",
"events_url": "https://api.github.com/users/octocat/events{/privacy}",
"received_events_url": "https://api.github.com/users/octocat/received_events",
"type": "User",
"site_admin": false
},
"node_id": "MDc6UmVsZWFzZTEyMzQ1Ng==",
"tag_name": "v1.0",
"target_commitish": "main",
"name": "Version 1.0",
"draft": false,
"prerelease": false,
"created_at": "2023-01-01T12:00:00Z",
"published_at": "2023-01-01T12:00:00Z",
"assets": [
{
"url": "https://api.github.com/repos/octocat/Hello-World/releases/assets/789012",
"id": 789012,
"node_id": "MDEyOlJlbGVhc2VBc3NldDc890MTI=",
"name": "example.zip",
"label": "",
"uploader": {
"login": "octocat",
"id": 1,
"node_id": "MDQ6VXNlcjE=",
"avatar_url": "https://github.com/images/error/octocat_happy.gif",
"gravatar_id": "",
"url": "https://api.github.com/users/octocat",
"html_url": "https://github.com/octocat",
"followers_url": "https://api.github.com/users/octocat/followers",
"following_url": "https://api.github.com/users/octocat/following{/other_user}",
"gists_url": "https://api.github.com/users/octocat/gists{/gist_id}",
"starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/octocat/subscriptions",
"organizations_url": "https://api.github.com/users/octocat/orgs",
"repos_url": "https://api.github.com/users/octocat/repos",
"events_url": "https://api.github.com/users/octocat/events{/privacy}",
"received_events_url": "https://api.github.com/users/octocat/received_events",
"type": "User",
"site_admin": false
},
"content_type": "application/zip",
"state": "uploaded",
"size": 10240,
"download_count": 10,
"created_at": "2023-01-01T12:05:00Z",
"updated_at": "2023-01-01T12:05:00Z",
"browser_download_url": "https://github.com/octocat/Hello-World/releases/download/v1.0/example.zip"
}
],
"tarball_url": "https://api.github.com/repos/octocat/Hello-World/tarball/v1.0",
"zipball_url": "https://api.github.com/repos/octocat/Hello-World/zipball/v1.0",
"body": "This is the release notes for version 1.0.\nIt contains some great new features."
}
其中的 tag_name
字段就是版本信息,这个版本信息是你自定义的,所以怎么比较版本你可以自行决定。一般我们比较流行的是 semver
版本,所以我们可以写一个版本比较工具,比较用户当前使用的版本和请求来的版本,然后判断是不是该更新。
web 开发生态中,semver
版本比较工具就很多,flutter 中我们也可以自行简单实现一个,或者让 AI 为我们实现一个:
bool _compareVersions(String latest, String current) {
final latestParts = latest.replaceAll("v", "").split(".");
final currentParts = current.split(".");
for (int i = 0; i < 3; i++) {
final latestPart = int.tryParse(latestParts[i]) ?? 0;
final currentPart = int.tryParse(currentParts[i]) ?? 0;
if (latestPart > currentPart) {
return true;
} else if (latestPart < currentPart) {
return false;
}
}
return false;
}
当然了,这个方法不够完善,比如你的版本加上什么 alpha、beta 的非数字字符就不行,不过可以让 AI 继续完善,这就看个人需求了。
二、如何实现歌词
目前是使用第三方免费歌词 API 获取的歌词,避免广告或者其它影响,这里就不给出地址了,感兴趣的可以自己百度下免费歌词 API。
返回的歌词是字符串,会携带标题、作者、每句歌词的时间戳等信息,歌词案例如下:
[ti:海底]\n[ar:阿萨Aza/艾因Eine/艾因Eine]\n[al:]\n[by:]\n[offset:0]\n[00:00.00]海底 - 阿萨Aza/艾因Eine\n[00:23.06]一个猎人她走上海岸\n[00:30.15]\n[00:30.85]潮涌潮枯\n[00:34.14]\n[00:34.80]故乡总在呼唤\n[00:38.38]她的路在身前\n[00:40.63]背负所有苦难\n[00:45.14]\n[00:45.94]她的路还长远\n[00:48.44]有浓雾弥漫\n[00:52.42]\n[00:53.25]她其实看不清\n[00:57.07]未来要往何处去\n[01:01.52]循着咸腥海风\n[01:03.84]踏入一座遗迹\n[01:08.71]\n[01:09.26]这是座死气沉沉的城市\n[01:11.62]街道都一个样子\n[01:13.64]衔着红花的小鸟转身躲进巷子\n[01:16.84]到处是行尸走肉的人\n[01:19.48]甘愿苟且偷生\n[01:21.42]可是人性犹在\n[01:22.82]\n[01:24.64]她说她不敢在大地上流血\n[01:28.46]招致不幸摧毁珍视的一切\n[01:32.32]永远孑然一身\n[01:34.68]与孤独为伴\n[01:38.51]\n[01:41.05]大海的歌谣\n[01:42.77]总在夜里\n[01:43.75]轻轻哼起\n[01:44.86]那熟悉的旋律\n[01:46.67]有什么被唤醒\n[01:48.50]于是她抱起拉琴\n[01:50.51]浪花为她指路\n[01:52.36]身着一袭红裙\n[01:54.14]她不为谁止步\n[01:55.89]\n[01:56.41]人们都说她古怪\n[01:58.08]总是捉摸不定\n[01:59.75]\n[02:00.32]却不知腐败\n[02:01.90]伊比利亚仍然一意孤行\n[02:04.20]危险不断在接近\n[02:05.69]将审判的剑握紧不容任何犹豫\n[02:08.11]贯彻你所认可的正义\n[02:10.06]去看清去怀疑\n[02:11.75]\n[02:15.42]一个歌者她回到海岸\n[02:22.69]\n[02:23.25]路在身前\n[02:26.25]\n[02:26.92]无需哀叹\n[02:30.71]周身鱼群游弋\n[02:32.88]血亲在呼唤\n[02:36.59]\n[02:38.11]歌声漫过天际\n[02:40.61]她在将谁找寻\n[02:45.77]到处是苟延残喘的生灵\n[02:48.35]偷换写好的命运\n[02:50.45]衔着红花的小鸟还困在囚笼里\n[02:53.58]击溃道貌岸然的庄严\n[02:56.13]时机恰好相见\n[02:58.15]深海猎人血脉相连\n[03:00.70]\n[03:01.54]祈祷时群星落寞不敢睁眼\n[03:04.66]\n[03:05.21]落泪时夜晚为她展露笑颜\n[03:08.50]\n[03:09.18]当她不在悲叹\n[03:11.39]故友在眼前\n[03:14.94]\n[03:20.22]潮涌起潮退去\n[03:24.12]抹去深浅足迹\n[03:28.06]潮退去又涌起\n[03:31.92]静谧笼罩废墟\n[03:35.74]潮涌起潮退去\n[03:38.86]\n[03:39.50]困不住搁浅的鲸\n[03:42.91]\n[03:43.42]我就站在这里\n[03:46.70]\n[03:47.28]与命运并驾齐驱";
这里就涉及到歌词的处理,首先要将字符串转换为数组,同时 [01:08.71]
这种时间戳也需要解析出来,这涉及到每句歌词所在时间,需要在播放时利用定时器滚动歌词。
List<Map<String, dynamic>> parseLyrics(String lyrics) {
List<Map<String, dynamic>> parsedLyrics = [];
List<String> lines = lyrics.split('\n');
for (String line in lines) {
RegExp regExp = RegExp(r'\[(\d{2}):(\d{2})\.(\d{2})\](.*)');
Match? match = regExp.firstMatch(line);
if (match != null) {
int minutes = int.parse(match.group(1)!);
int seconds = int.parse(match.group(2)!);
int milliseconds = int.parse(match.group(3)!);
String text = match.group(4)!;
if (text.trim().isNotEmpty) {
int totalMilliseconds = (minutes * 60 * 1000) + (seconds * 1000) + milliseconds;
parsedLyrics.add({
'time': totalMilliseconds,
'text': text,
});
}
}
}
parsedLyrics.sort((a, b) => a['time'].compareTo(b['time']));
return parsedLyrics;
}
我刚开始其实自己实现了一版歌词滚动器,不过效果不理想,后来还是使用了 flutter_lyric
插件实现。
import "package:bobomusic/constants/cache_key.dart";
import "package:bobomusic/db/db.dart";
import "package:bobomusic/modules/player/model.dart";
import "package:bobomusic/modules/player/utils.dart";
import "package:bobomusic/origin_sdk/origin_types.dart";
import "package:bot_toast/bot_toast.dart";
import "package:flutter/material.dart";
import "package:flutter_lyric/lyric_ui/ui_netease.dart";
import "package:flutter_lyric/lyrics_model_builder.dart";
import "package:flutter_lyric/lyrics_reader_model.dart";
import "package:flutter_lyric/lyrics_reader_widget.dart";
import 'dart:async';
import "package:provider/provider.dart";
import "package:shared_preferences/shared_preferences.dart";
Future<int?> getMusicPosition() async {
final localStorage = await SharedPreferences.getInstance();
final pos = localStorage.getInt(
CacheKey.playerPosition,
);
return pos;
}
final db = DBOrder(version: 2);
class LyricsScroller extends StatefulWidget {
const LyricsScroller({super.key});
@override
LyricsScrollerState createState() => LyricsScrollerState();
}
class LyricsScrollerState extends State<LyricsScroller> with SingleTickerProviderStateMixin {
String lyric = "";
List<Map<String, dynamic>> parsedLyrics = [];
int currentLine = 0;
Timer? _timer;
int currentTime = 0;
late LyricsReaderModel lyricModel;
var lyricUI = UINetease();
@override
void initState() {
super.initState();
// 初始化一个空的 lyricModel
lyricModel = LyricsModelBuilder.create().getModel();
WidgetsBinding.instance.addPostFrameCallback((_) {
doScroll();
});
}
@override
void dispose() {
_timer?.cancel();
lyric = "";
parsedLyrics = [];
currentLine = 0;
currentTime = 0;
super.dispose();
}
Future<void> doScroll() async {
final player = context.read<PlayerModel>();
List<Map<String, dynamic>> dbMusic = [];
dbMusic = await db.queryByParam(player.current!.orderName, player.current!.playId);
if (dbMusic.isEmpty) {
dbMusic = await db.queryByParam(player.current!.orderName, player.current!.id);
}
if (dbMusic.isEmpty) {
BotToast.showText(text: "找不到歌词 QAQ");
return;
}
if (dbMusic.isNotEmpty) {
final MusicItem musicItem = row2MusicItem(dbRow: dbMusic[0]);
setState(() {
lyric = musicItem.lyric;
parsedLyrics = parseLyrics(lyric);
lyricModel = LyricsModelBuilder.create()
.bindLyricToMain(lyric)
.getModel();
});
}
final initialPosition = await getMusicPosition();
// 找到初始位置对应的歌词行
for (int i = 0; i < parsedLyrics.length; i++) {
if (parsedLyrics[i]["time"] > initialPosition) {
currentLine = i > 0? i - 1 : 0;
break;
}
if (i == parsedLyrics.length - 1) {
currentLine = i;
}
}
currentTime = parsedLyrics[currentLine]["time"];
if (!player.isPlaying) {
return;
}
startTimer();
}
void startTimer() {
_timer = Timer.periodic(const Duration(milliseconds: 100), (timer) {
setState(() {
currentTime += 100;
});
});
}
void moveLyricsBackward() {
BotToast.showText(text: "-0.5s");
setState(() {
currentTime -= 500;
// 重新找到后退后的歌词行
for (int i = 0; i < parsedLyrics.length; i++) {
if (parsedLyrics[i]["time"] > currentTime) {
currentLine = i > 0? i - 1 : 0;
break;
}
if (i == parsedLyrics.length - 1) {
currentLine = i;
}
}
});
}
void moveLyricsForward() {
BotToast.showText(text: "+0.5s");
setState(() {
currentTime += 500;
// 重新找到前进后的歌词行
for (int i = 0; i < parsedLyrics.length; i++) {
if (parsedLyrics[i]["time"] > currentTime) {
currentLine = i > 0? i - 1 : 0;
break;
}
if (i == parsedLyrics.length - 1) {
currentLine = i;
}
}
});
}
@override
Widget build(BuildContext context) {
final primaryColor = Theme.of(context).primaryColor;
return Stack(
children: [
Consumer<PlayerModel>(
builder: (context, player, child) {
return Container(
padding: const EdgeInsets.only(top: 20),
child: LyricsReader(
padding: const EdgeInsets.symmetric(horizontal: 40),
model: lyricModel,
position: currentTime,
lyricUi: lyricUI,
playing: player.isPlaying,
size: Size(double.infinity, MediaQuery.of(context).size.height - 160),
emptyBuilder: () => Center(
child: Text(
"没有歌词",
style: lyricUI.getOtherMainTextStyle(),
),
),
),
);
},
),
Positioned(
left: 50,
bottom: 40,
child: InkWell(
onTap: () {
moveLyricsBackward();
},
child: Icon(Icons.keyboard_double_arrow_left_rounded, color: primaryColor, size: 30),
),
),
Positioned(
right: 50,
bottom: 40,
child: InkWell(
onTap: () {
moveLyricsForward();
},
child: Icon(Icons.keyboard_double_arrow_right_rounded, color: primaryColor, size: 30),
),
),
],
);
}
}
目前其实也只算是个能用版,在 UI 上其实还有很大改进的地方。使用 flutter_lyric
来实现后,我们节省了很大的开发成本,不用再自己考虑如何滚动,滚动高度,如何快速跳转,如何实现文字逐字高亮,这些要实现起来,确实要考虑很多细节。
还有另一个歌词插件 mmoo_lyric
,看了演示似乎也不错,只是其最近一次更新也是三年前了,其也是基于 flutter_lyric
修改的,感兴趣的可以试试。
三、结语
今天这篇严格意义上不是技术文章,水一篇给大家介绍下啵啵音乐的 2.0 版本,也欢迎大家使用以及提意见,或者参与开发。
啵啵音乐仓库:啵啵音乐,欢迎 star。
下载地址:
- Releases:Redstone-1/bobomusic
- 百度网盘:pan.baidu.com/s/1S0mF6PhN…
往期推荐
🔥🔥🔥2.5W字!8个场景问题!带你了解最实用的 git 操作!!! 40+
👍🏻 50+
💚
爆肝两个月,我用flutter开发了一款免费音乐app 80+
👍🏻 102+
💚
搭建一个快速开发油猴脚本的前端工程 24+
👍🏻 42+
💚
金九银十招聘季,IT 打工人,该怎么识别烂公司好公司? 70+
👍🏻 80+
💚
别人休息我努力,悄悄写个 cli 工具,必须提升效率,skr~ 60+
👍🏻 110+
💚
一文掌握 eslint,再也不怕项目报错 20+
👍🏻 30+
💚
开发一个 npm 库应该做哪些工程配置? 40+
👍🏻 50+
💚
分享我在前端学习与开发中用到的神仙网站和工具 40+
👍🏻 110+
💚
uniapp 踩坑记录(二) 130+
👍🏻 150+
💚
闲来无事,摸鱼时让 chatgpt 帮忙,写了一个 console 样式增强库并发布 npm 100+
👍🏻 110+
💚
uniapp 初体验踩坑记录 30+
👍🏻 60+
💚
两小时学会 JS 正则表达式,终身不忘 50+
👍🏻
【一年前端必知必会】如何写出简洁清晰的代码 50+
👍🏻
【一年前端必知必会】了解 Blob,ArrayBuffer,Base64 40+
👍🏻 90+
💚