啵啵音乐2.0:正式支持歌词与合集收藏

605 阅读11分钟

版权声明:本人文章仅在掘金平台发布,请勿抄袭搬运,转载请注明作者及原文链接 🦉

阅读提示:网页版带有主题和代码高亮,阅读体验更佳 🍉

f4cfae4dd96965cb2bc41639e3cfa25.jpg

354aba02da50e1c8fba5fa2a490e889.jpg

50a1494084b6cf5b8c4760ed2f6b24f.jpg

距离上次在掘金发布啵啵音乐 1.0 已经过去一个半月,百度网盘下载量也有一百多次了,github star 数也超过了 100。

完全没人用其实好接受,但是有人用,用的人又不多,就有点难受,因为不能随心所欲的更新和弃更。虽然初衷是满足自己的使用需求,但是也有 bbmusic 的用户转过来了,想着还是尽量利用空闲时间完善 app 的功能。

有一些人提了意见建议,我目前是看情况采纳,毕竟精力和技术都有限。

所以这一个月内,我要实现的重心功能是两个:一是支持合集收藏,这样能够避免占用歌单资源,再一个就是更加方便;二是支持歌词,歌词是音乐 app 基本的核心功能,部分听众对歌词的需求是很强烈的。

除此之外,也做了一些小优化,例如支持检查版本,在线版本更新。

今天就讲讲这些技术实现吧,虽然技术栈是 flutter,但是开发思路都是相同的,web 开发者也不用顾虑,学到就是赚到。

一、检查更新

很多开源产品,纯前端的软件都提供了这个功能,可能你很好奇,没有后端咋实现的检查更新?

其实还是 github 提供的能力。

很多开源产品的 github 仓库,你都能看到 github/workflow 文件。

image.png

这是 github 的工作流标准文件,通过编写这个工作流文件,我们可以通过某些关键词,在推送仓库时触发定义好的构建流程,你可以理解为 CICD 的简化版,总之就是触发了仓库的远程自动构建,就和我们本地 build 产物是一个意思。

你看不懂这块没关系,意思就是 github 能够帮助我们构建出新版本的 app 并存在 github 上,每个版本都有自己的版本信息和 app 资源。

image.png

当我们前端检查新版本时,就是调用 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。

下载地址:

往期推荐

🔥🔥🔥2.5W字!8个场景问题!带你了解最实用的 git 操作!!! 40+ 👍🏻 50+ 💚

爆肝两个月,我用flutter开发了一款免费音乐app 80+ 👍🏻 102+ 💚

搭建一个快速开发油猴脚本的前端工程 24+ 👍🏻 42+ 💚

金九银十招聘季,IT 打工人,该怎么识别烂公司好公司? 70+ 👍🏻 80+ 💚

为什么就这个文件的 ESLint 检查失效了?

学会 TypeScript 体操,轻松看懂开源项目代码

别人休息我努力,悄悄写个 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+ 💚