MusicFree 完整源码解析:一首歌的插件化播放旅程与背后的协议原理
【免费下载链接】MusicFree插件化、定制化、无广告的免费音乐播放器项目地址: https://gitcode.com/maotoumao/MusicFree
MusicFree 是一款插件化、无广告的免费音乐播放器,支持 Android 与 HarmonyOS。它解决的核心问题只有一个:播放器本体不内置任何音源,搜索、解析、播放地址获取全部由第三方插件按统一协议注入。下面以"一首歌的完整播放旅程"为主线,把官方 README 的用法介绍和 src/core/pluginManager 的源码实现串起来讲。
它到底是什么:定位与能力速览
一句话概括它的设计哲学:本软件仅仅是一个播放器,本身不集成任何平台的任何音源;音源相关的一切逻辑都解耦给插件,播放器本体只专注把"播"这件事做好。这意味着,只要某个音源在互联网上能被搜索到,并且有人为它写了对应插件,你就可以用它完成搜索、播放、歌词、歌单导入。
主要特性一览:
| 特性 | 说明 |
|---|---|
| 插件化 | 搜索(音乐、专辑、作者)、播放、专辑/作者详情、导入单曲/歌单、歌词获取等全部基于插件 |
| 定制化、无广告 | 浅色/深色模式,支持自定义背景,基于 AGPL 3.0 开源并保持免费 |
| 隐私 | 所有数据存储在本地,不收集任何个人信息 |
| 歌词关联 | 把歌曲 A 的歌词关联到 B,甚至 A→B→C 形成链条,A、B、C 均显示 C 的歌词 |
| 平台支持 | Android 与 HarmonyOS(桌面版在独立仓库维护) |
从 package.json 可以看到当前版本为0.6.2,技术栈是 React Native 0.76.5 + React 18.3.1 + TypeScript,状态管理用 jotai,本地存储用 MMKV,原生播放走 react-native-track-player。
凭什么做到:核心设计与关键协议
插件的本质:一个被沙箱执行的 CommonJS 模块
按 README 的定义,插件本质上是一个满足插件协议的 CommonJS 模块:插件开发者只关心输入输出逻辑,分页、缓存全部交给 MusicFree 控制。
从源码看,加载动作发生在 plugin.ts 的mountPlugin:插件源码被包裹进一个Function(...)内执行,并注入以下运行时环境:
| 注入项 | 内容 |
|---|---|
require/__musicfree_require | 内置cheerio、crypto-js、axios、dayjs、big-integer、qs、he、webdav等常用库(见 plugin.ts),插件可直接require而无需自带 |
env | getUserVariables()/userVariables(用户在设置页填写的自定义变量)、appVersion、os、lang |
process | platform、version、env |
console | 被重定向到应用日志系统devLog,便于排查插件问题 |
URL | 来自 react-native-url-polyfill |
执行结果取自module.exports.default ?? module.exports,即插件只需导出一个对象。
插件元信息字段(IPluginDefine)
插件导出对象需满足IPluginDefine接口,定义见 plugin.d.ts:
| 字段 | 说明 |
|---|---|
platform | 来源名(插件名),必填,同时作为该插件产出媒体项的归属标识 |
appVersion | 匹配的应用版本号,不满足时插件进入VersionNotMatch错误态 |
version | 插件版本号,用于安装/更新时的版本比较 |
srcUrl | 远程更新地址,供"更新插件"功能使用 |
primaryKey | 主键字段列表,会存入 media meta |
defaultSearchType/supportedSearchType | 默认搜索类型 / 支持的搜索类型 |
cacheControl | "cache" \| "no-cache" \| "no-store",插件级缓存策略 |
author/description | 作者 / 描述(支持 Markdown) |
userVariables | 用户自定义输入项,形如{ key, name?, hint? } |
能力方法一览
所有能力方法都是可选的,插件按需实现;播放器通过supportedMethods集合判断某个能力是否存在:
| 方法 | 作用 | 关键入参 / 返回 |
|---|---|---|
search | 搜索音乐、专辑、作者 | query、page、type→{ data, isEnd } |
getMediaSource | 根据歌曲信息解析真实播放地址 | musicItem、quality→{ url, headers?, userAgent?, quality? } |
getMusicInfo | 按主键查询歌曲信息补全 | musicBase |
getLyric | 获取歌词 | musicItem→{ rawLrc, translation? } |
getAlbumInfo/getMusicSheetInfo | 专辑 / 歌单信息(分页) | 对应 item、page |
getArtistWorks | 作者作品(分页) | artistItem、page、type |
importMusicSheet/importMusicItem | 导入歌单 / 单曲 | 形如 URL 的输入串 |
getTopLists/getTopListDetail | 榜单 / 榜单详情(分页) | 后者需topListItem、page |
getRecommendSheetTags/getRecommendSheetsByTag | 推荐歌单 tag / 按 tag 取歌单 | 后者需tag、page |
getMusicComments | 评论(分页) | musicItem、page |
音质由IQualityKey定义:"low" | "standard" | "high" | "super"(见 music.d.ts),getMediaSource未指定时默认按standard解析。
插件生命周期:一个四态状态机
每个插件实例都有状态机(plugin.ts):
Initializing(初始化中)→Loading(加载中)→Mounted(已挂载);出错则进入Error,错误原因分两类:VersionNotMatch(appVersion与当前应用版本不满足,由satisfies校验触发)与CannotParse(源码无法解析)。
插件的唯一 ID 是hash = sha256(funcCode)(plugin.ts),用于去重、卸载、缓存管理——同名但 hash 不同的插件被视为旧版本。
跟着一首播放走完全程
接下来跟着"某首歌"走一遍完整链路:搜索 → 取播放地址 → 歌词 → 缓存/兜底。
第一站:搜索——所有插件并行应召
你在搜索框输入关键词时,useSearch.ts 会调用PluginManager.getSearchablePlugins()拿到所有已启用且实现了search的插件,然后对每个插件发起plugin.methods.search(query, page, searchType)。
几个值得注意的细节:
- 分页完全由播放器控制:插件只返回
{ data, isEnd },isEnd === false且本页有数据时状态置为PARTLY_DONE,上拉加载自动请求page + 1; - 每首歌都会被
resetMediaItem(_, this.plugin.name)打上归属platform标记——这就是之后"按平台找插件"的依据; - 每个插件的搜索结果按
searchType → plugin.hash独立维护一份状态(RequestStateCode状态机),某个插件失败不影响其他插件的结果展示。
第二站:取播放地址——getMediaSource 的五级解析
点击播放后,TrackPlayer会按音质顺序(qualityOrder,由basic.defaultPlayQuality与basic.playQualityOrder配置决定)逐档尝试,核心调用是PluginMethodsWrapper.getMediaSource(musicItem, quality)(plugin.ts)。它按以下顺序解析真实音源:
- 本地文件优先:歌曲带本地路径且文件存在时,直接返回
file://地址; - 缓存播放:读取
MediaCache,仅在cacheControl === "cache"(或"no-cache"且当前离线Network.isOffline)时直接命中缓存的{ url, headers, userAgent }; - 替代插件:若你为插件 A 配置了替代插件 B(
alternativePlugin,见 meta.ts),且 B 实现了getMediaSource,则用 B 解析 A 的歌; - 插件解析:调用
getMediaSource(musicItem, quality);失败时以 150ms 间隔重试一次,抛"NOT RETRY"标记的错误则不重试; - 结果缓存:除非
cacheControl === "no-store",否则把该音质的{ headers, userAgent, url }写回MediaCache的source[quality]。
兜底路径同样清晰:若插件未实现getMediaSource,直接取musicItem.qualities[quality].url ?? musicItem.url;若 URL 里内嵌了账号密码,formatAuthUrl会自动剥掉并转成Authorization: Basic ...请求头。此外还有两层更外层的兜底(trackPlayer):按音质从高到低降档重试;全部失败且开启basic.tryChangeSourceWhenPlayFail时,自动搜索同名同歌手的相似歌曲、换插件再试。URL 是.m3u8后缀时会被标记为hls类型交给播放器。
第三站:歌词——五级优先级与"歌词关联"
歌词获取在getLyric(plugin.ts)中按优先级链执行:
- 关联歌词:读取歌曲扩展属性
associatedLrc,若存在则改去拉被关联歌曲的歌词——这就是"歌词关联"特性的实现基础,A→B→C 的链条也完全靠这个属性递归传递,与音源插件彻底解耦; - 本地手动设置:按
MD5(platform)/MD5(id).lrc(及.tran.lrc翻译文件)检查本地歌词目录,命中直接返回; - 缓存歌词:优先用
MediaCache中的远程歌词或本地歌词文件路径; - 插件拉取:调用插件
getLyric,成功后把歌词写入lrcCachePath/{nanoid}.lrc并回写缓存,下次免网络; - 本地文件兜底:若是本地音乐,用内置的
localFilePlugin读取内嵌歌词或同名.lrc文件。
拿到歌词文本后交给 lrcParser.ts:用timeReg = /\[[\d:.]+\]/g提取时间戳、metaReg提取[offset:...]等元信息,并支持叠加用户在播放页设置的偏移量。原文与翻译同时存在时,解析器用双指针算法按时间轴把翻译逐句对齐合并到每句歌词上(lrcParser.ts);渲染层则通过getPosition(position)随播放进度定位当前句。
第四站:缓存与淘汰——MediaCache 的容量控制
MediaCache(mediaCache.ts)基于 MMKV,以platform + id生成的唯一键存储每首歌的缓存(音源、歌词路径等),上限800 条;写满时随机删除一半,并联动清理被删条目对应的本地歌词文件。配合插件侧的cacheControl,形成三级缓存语义:
| cacheControl | 行为 |
|---|---|
cache | 正常读写缓存,命中即用缓存 URL |
no-cache(默认) | 每次重新解析,但离线时允许用缓存兜底 |
no-store | 解析结果永不写入缓存 |
走完这四级,一首歌的旅程就闭环了:搜索定位"歌是谁家的" →getMediaSource解析"从哪播" →getLyric按优先级链拿"词从哪来" → 缓存层决定"下次还要不要走网络"。
上手实战:安装、配置与日常管理
安装入口与三种方式 📲
入口在侧边栏 → 设置 → 插件设置(页面实现见 pluginList.tsx),支持三种安装方式:
- 从本地安装:通过系统文件选择器选择
.js插件文件(支持多选),内部调用PluginManager.installPluginFromLocalFile; - 从网络安装:输入一个 URL。若以
.json结尾,先 GET 该文件并解析其中的plugins[].url列表逐个安装;否则把输入本身当作插件.js地址:
{ "plugins": [ { "version": "1.0.0", "url": "https://example.com/plugin.js" } ] }- 更新订阅 / 更新全部插件:订阅地址来自
plugin.subscribeUrl配置,单个插件的更新源则是其srcUrl字段(updatePlugin内部直接复用installPluginFromUrl)。
安装过程中的版本处理 🔄
安装逻辑集中在 index.ts,行为可以照表预期:
| 场景 | 结果 |
|---|---|
| 新插件 hash 已存在 | 静默返回"插件已安装" |
同名插件已存在且已装版本更高(compare-versions比较version) | 返回"已安装更新版本的插件",除非开启basic.notCheckPluginVersion |
| 安装成功 | 文件以nanoid随机名复制进插件目录,旧版本同名片被移除并替换 |
日常管理:排序、启停、卸载与懒加载
- 排序:
setPluginOrder把顺序存入插件 meta 的order字段,列表与搜索结果均按该顺序展示; - 启用/禁用:
setPluginEnabled维护disabledPlugins集合; - 卸载:
uninstallPlugin(hash)删除插件文件,且当无其他同名插件时清理该插件产生的 media extra 数据;"卸载全部"会清空插件目录; - 懒加载:开启
basic.lazyLoadPlugin后,插件元信息(名称、hash、支持方法列表)缓存在 MMKV,启动时不解析源码,首次调用能力方法时才由ensureMounted加载执行;启动 10 秒后还会异步把剩余插件挂载完成; - 用户变量:插件声明
userVariables后,可在插件设置页填写,运行时经env.getUserVariables()读回(典型用途是填 API key)。
如果你是二次开发者,可以这样开始:
git clone https://gitcode.com/maotoumao/MusicFree深挖源码:值得借鉴的实现细节
沙箱挂载:用 Function 构造器造一个"够用"的运行时
mountPlugin不引入完整 Node 运行时,而是用Function构造器包一层,只注入 8 个参数(require、module、exports、console、env、URL、process等)和 8 个白名单内置库(plugin.ts)。这是移动端插件沙箱的一个务实做法:能力面小、攻击面小、依赖零体积。console被绑定到devLog后,插件作者的调试日志能直接落到应用日志里。
hash 去重 + 同名替换:无状态存储下的版本管理
插件文件用随机名存储({nanoid}.js),身份完全靠sha256(funcCode)判定:同 hash 判重、同platform判旧版本替换(index.ts)。卸载也是按 hash 定位。这意味着即使插件被改名分发,只要源码一字不差就会被识别为"已安装"。
缓存容量控制:简单粗暴但有效
MediaCache的淘汰策略不是 LRU,而是"写满 800 条就随机删一半,并顺带删除关联的本地歌词文件"(mediaCache.ts)。对一个移动端应用来说,这种 O(1) 判断 + 批量清理比精确 LRU 便宜得多,代码里也留了 TODO 说明作者对它的定位。
双指针翻译合并:把两个 LRC 对齐成一份
LrcParser 处理原文与翻译时间轴不一致的经典问题:两个指针分别扫原文与翻译数组,翻译指针只在"时间更早"时前进,命中同时间点就挂上翻译,否则置空。没有用二分、没有建索引,两句 while 循环解决,边界处理(翻译比原文短、时间点错位)都自然覆盖。
边界、合规与避坑
- 下载渠道:安装包请以发布页为准(若打不开可将 github 域名替换为 gitcode),或关注官方渠道获取;其他平台的"收费版/无广告版/破解版"均为假的,本来就是开源项目,遇到收费版请直接举报。
- 开源协议:项目基于AGPL 3.0开源,另有三条附加约定——打包/二次分发需保留代码出处、不得用于商业用途、协议变更仅在仓库更新不另行通知。
- 安全提醒:使用第三方插件请自行鉴别安全性(重点看是否有异常网络请求;自己写的插件最安全),不要安装来路不明的插件;第三方恶意插件造成的损失与本软件无关。
- 版权合规:插件使用过程中可能产生与本软件无关的版权数据,请及时删除;本软件不提倡也不会提供破解行为,示例插件仓库基于互联网公开接口封装,并过滤掉所有 VIP、试听、付费歌曲,后续也不会提供具备破解功能的插件。
- 假冒警示:小米/华为/vivo 等应用市场上名为 MusicFree 的 App 与本软件无关,是套用名称和 Logo 的广告软件;"速悦音乐"基于本软件二次开发但未遵守开源协议,请谨慎对待。
- 成熟度预期:README 明示这是业余维护项目、长时间处于不稳定测试版本、更新频率不定,请谨慎用于唯一听歌工具;常见问题以官方 Q&A 文档为准,新需求走 issue。
一句话带走
MusicFree 给出的可迁移结论是:把"内容从哪来"和"内容怎么消费"拆成协议两端——播放器只实现消费侧(播放、分页、缓存、歌词渲染、状态机),内容侧用一个带沙箱的 CommonJS 协议完全开放。任何需要"多音源/多后端/多供应商"的客户端产品,都可以照这条旅程去设计自己的插件协议:定义最小必选字段(platform)+ 全可选能力方法 + 明确的生命周期与缓存语义。
【免费下载链接】MusicFree插件化、定制化、无广告的免费音乐播放器项目地址: https://gitcode.com/maotoumao/MusicFree
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考