当前位置: 首页 > news >正文

Bili Music — 基于 Tauri + Vue 3 的 B站桌面音乐播放器

Bili Music — 基于 Tauri + Vue 3 的 B站桌面音乐播放器

一、项目背景与简介

B站(哔哩哔哩)拥有海量的音乐类视频内容——翻唱、原唱、纯音乐、Remix 等应有尽有。然而,B站官方客户端并非为纯音乐播放场景优化,缺乏歌单管理、批量下载、桌面歌词等音乐播放器的核心功能。Bili Music 正是为解决这一痛点而诞生的开源桌面应用。

Bili Music 让用户可以搜索、在线播放、下载 B站上的音频内容,并提供完善的歌单管理、B站收藏夹导入、桌面歌词等功能,打造一站式 B站音乐体验。项目采用 Tauri v2 + Vue 3 技术栈,前端使用 TypeScript + Naive UI,后端使用 Rust 处理网络请求与音频转换,最终打包为约 48MB 的 Windows 安装包,相比 Electron 方案体积缩小数倍,内存占用也更低。

图 1:主界面 — 搜索结果以卡片网格形式展示


二、技术选型

层级 技术 选型理由
桌面框架 Tauri 2 (Rust) 相比 Electron,打包体积小(~48MB vs ~150MB+),内存占用低,Rust 后端性能优异
前端框架 Vue 3.5 + TypeScript Composition API 逻辑复用能力强,<script setup> 语法简洁
UI 组件库 Naive UI 跨平台桌面级组件,主题定制灵活,Tree-shaking 友好
状态管理 Pinia + pinia-plugin-persistedstate 轻量、TypeScript 友好,支持持久化存储
异步运行时 tokio Rust 生态标准异步运行时,支撑并发下载与进程管理
网络请求 reqwest Rust HTTP 客户端,支持 Cookie 管理、流式下载
音频转换 FFmpeg (GPL) 业界标准音视频处理工具,支持 MP3/AAC/FLAC/WAV 等所有主流格式
构建工具 Vite 6 极速 HMR,原生 ESM 支持
包管理器 pnpm 磁盘空间高效,安装速度快

三、功能展示

3.1 音乐搜索与在线播放

在搜索栏输入关键词,即可从 B站全站搜索音乐视频。搜索结果以卡片网格形式展示,包含封面缩略图、标题、UP主、时长和播放量等信息,点击卡片即可播放。


图 2:搜索功能 — 输入关键词即时获取 B站音乐搜索结果

播放器内置完整控制面板,支持三种播放模式(顺序播放、单曲循环、随机播放),并通过 Web Media Session API 集成系统媒体控制——用户可通过键盘媒体键、系统通知栏或蓝牙耳机按钮直接控制播放暂停、上下曲切换。


图 3:完整播放器 — 旋转唱片动画、实时音频频谱可视化与滚动歌词

3.2 实时音频可视化与歌词同步

播放器界面集成了基于 Web Audio API 的实时音频频谱可视化,通过 AnalyserNode 获取频率数据并以渐变色柱状图呈现。歌词方面,内置 LRC 歌词解析器,采用二分查找算法精确定位当前播放行,支持逐行高亮滚动、点击歌词跳转播放位置,以及手动调整歌词时间偏移。

3.3 下载与格式转换

支持将 B站音频下载并转换为 MP3、FLAC、WAV、AAC 等格式。提供高、中、低三档音质选择(如 MP3 对应 320k/192k/128k),支持封面嵌入和元数据写入。下载任务以队列形式管理,实时显示每个任务的下载进度。


图 4:下载管理 — 批量下载队列与实时进度追踪

下载完成

3.4 歌单管理与智能歌单

支持创建、编辑、删除自定义歌单,歌单内歌曲支持拖拽排序。系统自动生成"最常播放"和"最近播放"两个智能歌单,基于实际播放历史动态更新。歌单支持 JSON 格式的导入导出,方便备份和迁移。


图 5:歌单管理 — 自定义歌单与智能歌单

歌单列表

3.5 B站收藏夹导入

这是 Bili Music 的特色功能。输入任意 B站用户的 UID,即可拉取其公开收藏夹列表并导入为本地歌单。支持选择多个收藏夹批量导入,每个收藏夹创建为独立的歌单,支持分页加载大型收藏夹。


图 6:收藏夹导入 — 通过 UID 批量导入 B站用户收藏夹

3.6 桌面歌词与迷你播放器窗口

Bili Music 采用多窗口架构,除主窗口外还提供两个独立悬浮窗口:

  • 桌面歌词窗口:透明置顶的歌词悬浮窗,支持字体大小调节、双击锁定位置、自由拖动
  • 迷你播放器窗口:紧凑的浮动播放控制面板,始终置顶显示当前曲目信息和控制按钮

两个窗口均可独立于主窗口运行,切换到迷你模式时桌面歌词窗口会自动保留显示。


图 7:桌面歌词 — 透明置顶悬浮歌词窗口

3.7 主题切换与个性化

支持浅色、深色和跟随系统三种主题模式,切换时使用 View Transition API 实现平滑过渡动画。提供多种预设强调色,也可自定义颜色。采用自定义标题栏设计(decorations: false),窗口风格统一。


图 8:深色主题模式

图 9:自定义主题

3.8 其他功能

  • 播放历史:自动记录最近 50 首曲目和播放次数
  • 音频缓存:基于 IndexedDB 的 500MB 缓存,加速重复播放
  • 定时关闭:内置睡眠定时器(10/15/30/60/90 分钟预设)
  • 系统托盘:最小化到系统托盘,右键菜单快速控制
  • 开机自启:可设置随 Windows 系统启动

四、技术难点与解决方案

4.1 B站音频防盗链与反爬处理

难点分析

B站对音频资源有严格的保护机制,具体表现为:

  1. Referer 校验:音频 CDN 会检查请求头中的 Referer 字段,必须为 https://www.bilibili.com 域名
  2. Cookie 绑定:部分接口需要携带有效的登录 Cookie 才能访问
  3. URL 时效性:音频流 URL 包含时效性参数(expiresign 等),过期后无法使用
  4. CORS 限制:浏览器端直接请求 B站 API 会被跨域策略拦截
  5. 请求头检测:缺少完整浏览器特征头的请求会被识别为爬虫并拒绝

解决方案

(1)Rust 后端代理所有网络请求

所有对 B站 API 和 CDN 的请求均由 Rust 后端发出,完全绕过浏览器的 CORS 限制。使用 reqwest 的全局 Cookie Jar 维持会话一致性,并伪装完整的浏览器请求头:

// 全局 Cookie Jar,所有请求共享同一份 Cookie
static BILI_JAR: LazyLock<Arc<reqwest::cookie::Jar>> =LazyLock::new(|| Arc::new(reqwest::cookie::Jar::default()));// 全局 HTTP 客户端,带 Cookie 和伪装请求头
static BILI_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {let jar = BILI_JAR.clone();reqwest::Client::builder().cookie_provider(jar).default_headers(search_headers()).build().expect("Failed to build HTTP client")
});

伪装请求头包含完整的浏览器特征:

pub fn search_headers() -> reqwest::header::HeaderMap {let mut headers = reqwest::header::HeaderMap::new();headers.insert("User-Agent", USER_AGENT.parse().unwrap());headers.insert("Referer", "https://www.bilibili.com".parse().unwrap());headers.insert("Origin", "https://www.bilibili.com".parse().unwrap());headers.insert("Accept", "application/json, text/plain, */*".parse().unwrap());headers.insert("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8".parse().unwrap());headers.insert("Sec-Fetch-Dest", "empty".parse().unwrap());headers.insert("Sec-Fetch-Mode", "cors".parse().unwrap());headers.insert("Sec-Fetch-Site", "same-origin".parse().unwrap());headers
}

(2)Blob URL 播放机制

Rust 后端下载音频数据后写入临时文件,前端通过 Tauri 的文件读取 API 获取数据,再通过 URL.createObjectURL 生成 Blob URL 供 HTML5 <audio> 元素播放。整个过程不暴露 B站源地址:

async function playSong(song: Song) {// 后端下载音频到临时文件,返回文件路径const filePath = await invoke<string>("stream_audio", { bvid: song.bvid });// 读取文件数据const fileData = await readFile(filePath);// 创建 Blob URLconst blob = new Blob([fileData], { type: "audio/mp4" });const blobUrl = URL.createObjectURL(blob);audioUrl.value = blobUrl;audio.src = blobUrl;await audio.play();
}

(3)多备用 URL 自动切换

B站的 DASH 格式音频可能提供多个 CDN 源地址。下载时实现了主 URL + 备用 URL 列表的自动切换机制——主 URL 下载失败时依次尝试备用地址,直到成功或全部失败:

pub async fn download_from_url<F>(url: &str,backup_urls: &[String],output_path: &Path,progress_callback: &F,
) -> AppResult<()>
whereF: Fn(u64, u64) + Send + Sync + 'static,
{let mut last_error = None;let all_urls: Vec<&str> = std::iter::once(url).chain(backup_urls.iter().map(|s| s.as_str())).collect();for current_url in all_urls {match download_single_url(current_url, output_path, progress_callback).await {Ok(()) => return Ok(()),Err(e) => { last_error = Some(e); }}}Err(last_error.unwrap_or(AppError::Download("所有下载 URL 均失败".into())))
}

(4)搜索结果 HTML 清洗

B站搜索 API 返回的视频标题可能包含 HTML 实体编码(如 &#x27;&amp;),需要解码后才能正常展示:

fn clean_title(raw: &str) -> String {// 先解码 HTML 实体,再去除 HTML 标签let decoded = htmlescape::decode_html(raw).unwrap_or(raw.to_string());let re = Regex::new(r"<[^>]+>").unwrap();re.replace_all(&decoded, "").to_string()
}

4.2 多窗口架构与跨窗口状态同步

难点分析

Bili Music 包含三个独立窗口:

窗口 用途 特性
主窗口 搜索、歌单、设置、完整播放器 1100×750,自定义标题栏
迷你播放器窗口 精简播放控制 380×96,透明,置顶
桌面歌词窗口 悬浮歌词展示 800×80,透明,置顶

每个窗口运行在独立的 WebView 进程中,拥有独立的 JavaScript 运行时和 DOM,状态天然隔离。核心挑战在于:

  1. 状态同步:主窗口切换歌曲、更新播放进度时,迷你窗口和歌词窗口需要实时感知
  2. 控制反向传递:用户在迷你窗口点击暂停/切歌,需要传递到主窗口执行
  3. 窗口生命周期协调:进入迷你模式时主窗口隐藏但桌面歌词应保留;关闭主窗口时所有子窗口应一并退出

解决方案

(1)Rust 后端作为状态中转站

在 Rust 后端维护一份共享播放状态(SharedPlayerState),主窗口作为状态的"写入者",子窗口作为"读取者":

#[derive(Debug, Serialize, Deserialize, Clone, Default)]
#[serde(rename_all = "camelCase")]
pub struct SharedPlayerState {pub current_time: f64,pub duration: f64,pub is_playing: bool,pub current_song: Option<serde_json::Value>,pub cover_url: String,pub lyrics: Vec<serde_json::Value>,
}

(2)差异化的同步策略

并非所有状态变更都需要相同频率的同步。设计中区分了"即时同步"和"节流同步":

  • 即时同步(播放/暂停状态):暂停后 currentTime 不再更新,如果使用节流,暂停事件可能在节流窗口内丢失,导致子窗口状态不一致。因此 isPlaying 的变更立即推送到后端:
// 播放/暂停状态变更 → 立即同步(不节流)
watch([isPlaying], () => {syncState();
});
  • 节流同步(播放进度):currentTime 每秒更新多次,高频推送会造成不必要的开销。通过 200ms 节流控制同步频率:
// 播放进度 → 200ms 节流同步
watch(currentTime, () => {throttledSyncState();
});

(3)子窗口轮询读取

子窗口(桌面歌词、迷你播放器)通过定时轮询从 Rust 后端读取最新状态:

// 桌面歌词窗口:每 200ms 轮询一次播放状态
async function pollState() {try {const state = await invoke<SharedPlayerState>("get_player_state");currentTime.value = state.currentTime;isPlaying.value = state.isPlaying;lyrics.value = state.lyrics;currentSong.value = state.currentSong;} catch {// 轮询出错时静默忽略,继续下一轮}
}
setInterval(pollState, 200);

(4)子窗口 → 主窗口的控制通道

子窗口的用户操作(点击暂停、切歌等)通过 Tauri 的 emit_to_main 传递到主窗口,主窗口监听对应事件并执行:

// 子窗口:发送控制指令
function emitToMain(event: string, payload?: any) {invoke("emit_to_main", { event, payload: payload ?? null });
}// 主窗口:监听并执行
listen("mini:play-pause", () => { player.togglePlay(); });
listen("mini:next", () => { player.playNext(); });
listen("mini:prev", () => { player.playPrev(); });

(5)窗口生命周期协调

通过 useWindowManager 统一管理窗口的显隐逻辑。关键设计:进入迷你模式时不隐藏桌面歌词,让用户可以同时使用迷你播放器和桌面歌词:

async function enterMiniMode() {if (mainWin) await mainWin.hide();if (miniWin) await miniWin.show();// 注意:桌面歌词窗口不做处理,保持当前状态
}async function exitMiniMode() {if (miniWin) await miniWin.hide();if (mainWin) await mainWin.show();if (mainWin) await mainWin.setFocus();
}

4.3 透明悬浮窗口的实现

难点分析

桌面歌词和迷你播放器需要实现"透明背景 + 置顶悬浮"效果。这要求:

  1. 窗口背景完全透明,只显示文字和控制按钮
  2. 窗口始终在其他应用窗口之上
  3. 用户能拖动窗口到任意位置
  4. 支持锁定位置防止误操作

解决方案

(1)Tauri 窗口配置

tauri.conf.json 中为子窗口启用 transparentalwaysOnTop 属性:

{"label": "desktop-lyrics","title": "桌面歌词","transparent": true,"alwaysOnTop": true,"decorations": false,"resizable": true,"width": 800,"height": 80,"url": "index.html"
}

(2)自定义拖动实现

由于禁用了原生窗口装饰(decorations: false),窗口无法通过标题栏拖动。需要自行实现拖动逻辑——通过监听 mousedown/mousemove/mouseup 事件链,配合 Tauri 的 startDragging() API 实现窗口拖动:

function startDrag(e: MouseEvent) {if (locked.value) return; // 锁定状态下不可拖动// 调用 Tauri 的原生拖动 APIgetCurrentWindow().startDragging();
}

(3)位置持久化

通过 useWindowGeometry composable 监听窗口的移动和缩放事件,将位置信息保存到设置中。重启后从设置中恢复:

function saveGeometry(geometry: WindowGeometry) {if (_debounceTimer) clearTimeout(_debounceTimer);_debounceTimer = setTimeout(async () => {const settingsStore = useSettingsStore();settingsStore.windowGeometry = geometry;await settingsStore.saveSettings();}, 300); // 300ms 防抖,避免拖动时频繁写入
}

(4)屏幕边界检测

启动时恢复窗口位置前,需要检测保存的位置是否仍然有效(可能外接显示器已断开):

// 检查保存的窗口位置是否在当前任一显示器的范围内
let monitors = window.available_monitors().unwrap_or_default();
let mut on_screen = monitors.is_empty();
for mon in &monitors {let mon_size = mon.size();let mon_pos = mon.position();if x >= mon_pos.x && y >= mon_pos.y&& x < mon_pos.x + mon_size.width as i32&& y < mon_pos.y + mon_size.height as i32 {on_screen = true;break;}
}
if !on_screen {// 位置无效,使用默认居中位置window.center().unwrap();
}

4.4 基于 IndexedDB 的音频缓存系统

难点分析

B站音频文件通常在 3-15MB 之间。每次播放都从网络下载会造成延迟和带宽浪费,而浏览器自带的 HTTP 缓存不可控——容量有限、无法主动管理、无法设置 TTL。需要实现一个应用级的音频缓存方案,满足以下要求:

  1. 容量可控(不能无限增长占满磁盘)
  2. 自动淘汰(空间不足时释放旧数据)
  3. 过期清理(避免缓存永久堆积)
  4. 性能可接受(缓存操作不能明显拖慢播放)

解决方案

基于 IndexedDB 实现了一套完整的缓存系统,核心设计如下:

(1)数据结构

每条缓存记录存储视频 ID(bvid)、音频二进制数据、访问时间戳和文件大小:

interface CacheEntry {bvid: string;       // B站视频 IDdata: ArrayBuffer;  // 音频二进制数据timestamp: number;  // 最后访问时间(用于 LRU 排序)size: number;       // 文件大小(用于容量计算)
}

(2)两阶段淘汰策略

当写入新缓存导致总容量超过 500MB 上限时,执行两阶段淘汰:

  • 第一阶段:清理所有超过 7 天未访问的过期条目
  • 第二阶段:如果清理过期条目后空间仍不足,按 LRU(最久未使用)策略逐条淘汰,直到释放足够空间
private async evictIfNeeded(needed: number) {const entries = await this.getAll();entries.sort((a, b) => a.timestamp - b.timestamp);const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000; // 7 天 TTL// 第一阶段:清除过期条目for (const e of entries) {if (e.timestamp >= cutoff) break; // 已排序,遇到未过期的即可停止await this.deleteEntry(e.bvid);}// 第二阶段:LRU 淘汰最久未访问的条目while (this._currentSize + needed > 500 * 1024 * 1024) {const remaining = await this.getAll();if (remaining.length === 0) break;remaining.sort((a, b) => a.timestamp - b.timestamp);await this.deleteEntry(remaining[0].bvid);}
}

(3)定时清理

除了写入时触发淘汰,还设置了 30 分钟的定时器进行后台清理,确保长时间运行时过期数据也能被及时清除。


4.5 FFmpeg 集成与音频格式转换

难点分析

B站音频原始格式为 M4A(AAC 编码),用户需要转换为 MP3、FLAC、WAV 等更通用的格式。FFmpeg 是 GPL 许可的第三方工具,存在以下集成难点:

  1. 许可证限制:FFmpeg GPL 版本不能直接打包进应用分发
  2. 平台差异:不同操作系统下 FFmpeg 的安装路径和调用方式不同
  3. 构建集成:需要在 CI/CD 或本地构建时自动下载正确版本的 FFmpeg
  4. 进程管理:FFmpeg 作为外部进程调用,需要管理其生命周期、捕获输出和错误

解决方案

(1)自动下载脚本

编写 PowerShell 脚本 download-ffmpeg.ps1,在首次构建时自动下载 FFmpeg GPL 预编译版本(含 libmp3lame 编码器):

$ffmpegPath = Join-Path $binariesDir "ffmpeg.exe"
if (Test-Path $ffmpegPath) {Write-Host "[download-ffmpeg] ffmpeg.exe already exists, skipping."return
}
# 下载 FFmpeg full build(包含 libmp3lame 用于 MP3 编码)
Invoke-WebRequest -Uri $ffmpegUrl -OutFile $zipPath
Expand-Archive -Path $zipPath -DestinationPath $tempDir
Copy-Item (Join-Path $tempDir "ffmpeg-full/bin/ffmpeg.exe") $ffmpegPath

脚本在 tauri buildbeforeBuildCommand 中自动执行。

(2)智能路径解析

FFmpeg 的位置在不同场景下不同——开发环境在 src-tauri/binaries/,生产环境在应用资源目录,也支持系统 PATH 中的 FFmpeg。ffmpeg_path.rs 实现了三级回退查找:

pub fn resolve(app_handle: &tauri::AppHandle) -> PathBuf {// 优先级 1:生产环境 — 应用资源目录if let Ok(resource_dir) = app_handle.path().resource_dir() {let path = resource_dir.join("binaries").join("ffmpeg.exe");if path.exists() { return path; }}// 优先级 2:开发环境 — CARGO_MANIFEST_DIR/binaries/if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") {let path = PathBuf::from(manifest_dir).join("binaries").join("ffmpeg.exe");if path.exists() { return path; }}// 优先级 3:系统 PATHPathBuf::from("ffmpeg")
}

(3)异步进程调用

通过 tokio::process::Command 异步调用 FFmpeg,不阻塞主线程。根据用户选择的格式和音质,动态拼接 FFmpeg 命令行参数:

pub async fn convert(ffmpeg_path: &Path,input_path: &Path,output_path: &Path,format: &str,quality: &str,cover_path: Option<&Path>,title: Option<&str>,artist: Option<&str>,
) -> AppResult<PathBuf> {let mut cmd = tokio::process::Command::new(ffmpeg_path);cmd.args(["-y", "-i"]).arg(input_path);// 封面嵌入if let Some(cover) = cover_path {cmd.args(["-i"]).arg(cover);}// 元数据if let Some(t) = title {cmd.args(["-metadata", &format!("title={}", t)]);}if let Some(a) = artist {cmd.args(["-metadata", &format!("artist={}", a)]);}// 根据格式选择编码器和参数match format {"flac" => {let level = match quality { "high" => "8", "medium" => "5", _ => "0" };cmd.args(["-c:a", "flac", "-compression_level", level]);}"wav" => {cmd.args(["-c:a", "pcm_s16le"]);}"aac" => {cmd.args(["-c:a", "aac", "-ab", quality_to_bitrate(quality)]);}_ => { // mp3cmd.args(["-c:a", "libmp3lame", "-ab", quality_to_bitrate(quality)]);}}cmd.arg(output_path);let output = cmd.output().await?;if !output.status.success() {let stderr = String::from_utf8_lossy(&output.stderr);return Err(AppError::Convert(format!("FFmpeg 转换失败: {}", stderr)));}Ok(output_path.to_path_buf())
}

4.6 歌词精确同步与二分查找优化

难点分析

LRC 格式歌词由数百行带时间标签的文本组成:

[00:12.50]第一行歌词
[00:15.80]第二行歌词
[00:18.20]第三行歌词
...

播放时需要在每帧(约 4 次/秒)定位当前应高亮的歌词行。如果使用线性扫描,时间复杂度为 O(n),在歌词行数较多时会造成不必要的性能开销。

解决方案

(1)LRC 解析

使用正则表达式解析 LRC 时间标签,支持 2 位和 3 位毫秒精度:

const LRC_REGEX = /\[(\d{2}):(\d{2})\.(\d{2,3})\](.*)/;function parseLrc(lrcText: string): LyricLine[] {const lines = lrcText.split('\n');return lines.map(line => {const match = line.match(LRC_REGEX);if (!match) return null;const minutes = parseInt(match[1]);const seconds = parseInt(match[2]);let millis = match[3].padEnd(3, '0'); // 统一为 3 位毫秒const time = minutes * 60 + seconds + parseInt(millis) / 1000;return { time, text: match[4].trim() };}).filter(Boolean);
}

(2)二分查找定位当前行

歌词按时间升序排列,使用二分查找快速定位:

function findCurrentLine(lines: LyricLine[], time: number): number {if (lines.length === 0) return -1;let lo = 0, hi = lines.length - 1;while (lo <= hi) {const mid = Math.floor((lo + hi) / 2);if (lines[mid].time <= time) {lo = mid + 1; // 当前行时间 <= 播放时间,向右搜索} else {hi = mid - 1; // 当前行时间 > 播放时间,向左搜索}}return hi; // hi 即为最后一个 time <= 播放时间 的行
}

(3)节流 + 用户滚动检测

播放进度通过 timeupdate 事件获取,但该事件触发频率较高。设置 200ms 节流避免过度计算:

let lastTime = -1;
watch(() => player.currentTime, (time) => {if (userScrolled.value) return; // 用户正在手动滚动歌词,暂停自动滚动if (time - lastTime < 0.2 && lastTime >= 0) return; // 200ms 节流lastTime = time;const idx = findCurrentLine(lyrics.value, time - getOffset());if (idx !== currentLineIndex.value) {currentLineIndex.value = idx;}
});

用户手动滚动歌词时,设置 3 秒定时器恢复自动滚动,避免用户正在查看某段歌词时被自动跳转打断。

(4)歌词偏移调整

部分歌词的时间轴与音频存在轻微偏差。支持 ±0.5s 步进的手动偏移调整,偏移值按歌曲维度持久化存储。


4.7 拖拽排序的实现

难点分析

歌单中需要支持拖拽调整歌曲顺序。原生 HTML5 Drag and Drop API 在桌面应用中有以下问题:

  1. 拖拽预览图(ghost image)无法自定义样式,显示效果粗糙
  2. 拖拽过程中目标位置的视觉反馈不够直观
  3. 在 Tauri 的 WebView 中,原生拖拽事件的行为与浏览器不完全一致

解决方案

基于原生鼠标事件(mousedown/mousemove/mouseup)自行实现拖拽排序,完全控制拖拽体验:

  1. mousedown:记录起始位置,创建拖拽元素的半透明克隆体(ghost)作为视觉反馈
  2. mousemove:移动 ghost 元素跟随鼠标;计算鼠标下方元素的插入位置,通过 CSS transform 实时偏移其他元素,形成"腾出空间"的视觉效果
  3. mouseup:销毁 ghost 元素,根据计算出的 from/to 索引执行数组重排
function onDragStart(e: MouseEvent, index: number) {isDragging.value = true;dragIndex.value = index;// 创建 ghost 元素const el = e.currentTarget as HTMLElement;const rect = el.getBoundingClientRect();ghost.value = el.cloneNode(true) as HTMLElement;ghost.value.style.cssText = `position: fixed; left: ${rect.left}px; top: ${rect.top}px;width: ${rect.width}px; opacity: 0.7; pointer-events: none; z-index: 9999;`;document.body.appendChild(ghost.value);
}

4.8 实时音频可视化

难点分析

需要在播放器界面展示实时频谱动画,要求:

  1. 与音频播放完全同步,无明显延迟
  2. 视觉效果流畅,不卡顿
  3. 适配不同分辨率和高 DPI 屏幕

解决方案

使用 Web Audio API 的 AnalyserNode 获取实时频率数据,通过 Canvas 2D API 绘制频谱:

function setupAudioContext() {const ctx = new AudioContext();const audioEl = document.querySelector("audio") as HTMLAudioElement;const source = ctx.createMediaElementSource(audioEl);const analyser = ctx.createAnalyser();analyser.fftSize = 256; // FFT 窗口大小,决定频率分辨率// 音频路由:source → analyser → destinationsource.connect(analyser);analyser.connect(ctx.destination);audioContext.value = ctx;analyser.value = analyser;
}

绘制时通过 getByteFrequencyData 获取 0-255 的频率幅值数组,映射为渐变色柱状图:

function draw() {const bufferLength = analyser.frequencyBinCount; // fftSize / 2 = 128const dataArray = new Uint8Array(bufferLength);analyser.getByteFrequencyData(dataArray);// 绘制每个频率柱for (let i = 0; i < bufferLength; i++) {const barHeight = (dataArray[i] / 255) * canvasHeight;// 渐变色:低频暖色 → 高频冷色const hue = (i / bufferLength) * 120 + 200;ctx.fillStyle = `hsla(${hue}, 80%, 60%, 0.8)`;ctx.fillRect(x, canvasHeight - barHeight, barWidth, barHeight);}requestAnimationFrame(draw); // 循环绘制
}

高 DPI 适配通过 devicePixelRatio 缩放 Canvas 物理分辨率,确保在 Retina 显示器上清晰渲染。


4.9 异步任务管理与进度追踪

难点分析

下载任务可能同时运行多个,需要:

  1. 为每个任务分配唯一 ID
  2. 实时向前端推送下载进度
  3. 支持任务取消
  4. 线程安全的任务状态管理

解决方案

通过 TaskManager 统一管理所有下载任务,使用 Arc<Mutex<HashMap>> 实现线程安全的状态共享:

pub struct TaskManager {tasks: Arc<Mutex<HashMap<String, TaskInfo>>>,app_handle: tauri::AppHandle,
}impl TaskManager {pub async fn update(&self, task_id: &str, f: impl FnOnce(&mut TaskInfo)) {let mut tasks = self.tasks.lock().await;if let Some(task) = tasks.get_mut(task_id) {f(task); // 修改任务状态(进度、状态等)// 通过 Tauri 事件将进度推送到前端let _ = self.app_handle.emit("download-progress", &task.progress);}}
}

前端通过 listen("download-progress", callback) 监听进度事件,实时更新 UI。


五、整体架构

5.1 数据流

用户操作 (Vue 组件)│▼
Pinia Store (状态管理)│├─→ Tauri invoke ─→ Rust Command ─→ B站 API / FFmpeg│                                          ││                                          ▼│                                    网络请求 / 进程调用│                                          │◀────────────────────────────────────────────┘│▼
UI 更新 (响应式)

5.2 音频播放流水线

B站 DASH API│▼
Rust downloader (reqwest 流式下载 + 备用 URL)│▼
临时文件 (Tauri temp 目录)│▼
前端读取 → Blob URL → HTML5 Audio│├─→ Web Audio API → AnalyserNode → Canvas 可视化│├─→ IndexedDB Cache (LRU + TTL)│└─→ Media Session API → 系统媒体控制

5.3 项目结构

bili-music-app/
├── src/                              # 前端源码 (Vue 3 + TypeScript)
│   ├── components/
│   │   ├── player/
│   │   │   ├── FullPlayer.vue        # 完整播放器(唱片、可视化、歌词、控制面板)
│   │   │   ├── MiniPlayer.vue        # 底部迷你播放条
│   │   │   ├── MiniPlayerWindow.vue  # 独立迷你播放器窗口
│   │   │   ├── DesktopLyrics.vue     # 桌面歌词悬浮窗
│   │   │   ├── PlaylistPanel.vue     # 播放列表面板
│   │   │   └── AudioVisualizer.vue   # Canvas 频谱可视化
│   │   ├── search/
│   │   │   ├── SearchBar.vue         # 搜索栏
│   │   │   ├── SongCard.vue          # 歌曲卡片
│   │   │   └── ResultGrid.vue        # 搜索结果网格
│   │   ├── playlists/
│   │   │   ├── PlaylistGrid.vue      # 歌单网格
│   │   │   ├── PlaylistSongList.vue  # 歌曲列表
│   │   │   ├── AddToPlaylistDropdown.vue
│   │   │   └── ImportFavoritesDialog.vue  # B站收藏夹导入
│   │   ├── favorites/
│   │   │   └── FavoritesList.vue
│   │   ├── settings/
│   │   │   └── SettingsPage.vue      # 设置页面(主题、下载、系统、歌词)
│   │   └── TitleBar.vue              # 自定义标题栏
│   ├── composables/                   # Vue 组合式函数
│   │   ├── useAudio.ts               # Web Audio API 上下文
│   │   ├── useLyrics.ts              # 歌词解析、同步、偏移
│   │   ├── usePlayerControls.ts      # 音量控制、定时关闭
│   │   ├── useMediaSession.ts        # 系统 Media Session 集成
│   │   ├── useAudioCache.ts          # IndexedDB LRU 缓存
│   │   ├── useWindowManager.ts       # 多窗口显隐控制
│   │   ├── useCrossWindowSync.ts     # 跨窗口状态同步
│   │   ├── useWindowGeometry.ts      # 窗口位置持久化
│   │   ├── useDragSort.ts            # 拖拽排序
│   │   ├── useKeyboardShortcuts.ts   # 全局键盘快捷键
│   │   └── useThemeColor.ts          # 动态主题色
│   ├── stores/                       # Pinia 状态仓库
│   │   ├── player.ts                 # 播放器状态(当前歌曲、播放列表、进度等)
│   │   ├── search.ts                 # 搜索状态
│   │   ├── download.ts               # 下载任务状态
│   │   ├── favorites.ts              # 收藏列表
│   │   ├── playlists.ts              # 歌单管理
│   │   ├── history.ts                # 播放历史
│   │   └── settings.ts               # 应用设置
│   ├── pages/
│   │   ├── HomePage.vue              # 主页(搜索/收藏/历史三 Tab)
│   │   └── DownloadPage.vue          # 下载管理页
│   ├── utils/
│   │   ├── lrc-parser.ts             # LRC 歌词解析器
│   │   ├── formatters.ts             # 格式化工具(时长、大小、播放量)
│   │   └── emitter.ts                # 轻量事件总线
│   ├── styles/
│   │   └── global.css                # 全局样式 + 主题 CSS 变量
│   ├── types/index.ts                # TypeScript 类型定义
│   └── main.ts                       # 应用入口
├── src-tauri/                        # Rust 后端
│   ├── src/
│   │   ├── main.rs                   # Tauri 入口(插件注册)
│   │   ├── lib.rs                    # 库初始化(系统托盘、窗口恢复、URI 协议)
│   │   ├── commands/
│   │   │   ├── mod.rs
│   │   │   ├── search.rs             # 搜索相关命令
│   │   │   ├── download.rs           # 下载相关命令
│   │   │   ├── lyrics.rs             # 歌词相关命令
│   │   │   ├── settings.rs           # 设置 + 跨窗口状态命令
│   │   │   └── favorites_import.rs   # 收藏夹导入命令
│   │   ├── core/
│   │   │   ├── mod.rs
│   │   │   ├── searcher.rs           # B站搜索(反爬、HTML 清洗)
│   │   │   ├── downloader.rs         # 下载管理(多 URL 备用、进度回调)
│   │   │   ├── converter.rs          # FFmpeg 音频转换(多格式、封面嵌入)
│   │   │   ├── ffmpeg_path.rs        # FFmpeg 路径解析(三级回退)
│   │   │   ├── lyrics_client.rs      # 网易云歌词获取
│   │   │   ├── task_manager.rs       # 异步任务管理(UUID、进度推送)
│   │   │   └── favorites_import.rs   # B站收藏夹 API 对接
│   │   ├── config.rs                 # API 地址、请求头、常量
│   │   └── error.rs                  # 统一错误类型
│   ├── tauri.conf.json               # 多窗口配置、应用元数据
│   ├── capabilities/default.json     # Tauri 权限声明
│   └── binaries/ffmpeg.exe           # FFmpeg 二进制(.gitignore)
└── scripts/└── download-ffmpeg.ps1           # FFmpeg 自动下载脚本


六、总结

Bili Music 的开发涵盖了桌面应用开发中的多个典型技术挑战:

领域 难点 解决方案
网络与安全 B站防盗链、反爬检测 Rust 后端代理、Cookie 会话、请求头伪装、多 URL 备用
桌面架构 多窗口状态隔离 Rust 共享状态中转、差异化同步策略(即时 + 节流)
窗口系统 透明悬浮窗 Tauri transparent 配置、自定义拖动、位置持久化、屏幕边界检测
性能优化 音频重复加载开销 IndexedDB LRU 缓存 + TTL + 两阶段淘汰
外部工具 FFmpeg 集成 自动下载脚本、三级路径回退、异步进程调用
算法优化 歌词实时同步 二分查找 O(log n) + 节流 + 用户滚动检测
用户体验 歌单拖拽排序 原生鼠标事件 + Ghost 元素 + CSS Transform 实时预览
音频处理 实时频谱可视化 Web Audio API AnalyserNode + Canvas + 高 DPI 适配

项目通过 Tauri v2 框架成功将 Web 前端的开发效率与 Rust 后端的性能优势结合在一起,在约 48MB 的安装包体积内实现了完整的音乐搜索、播放、下载与管理功能,是一个功能完善、架构清晰的 Tauri 桌面应用实践案例。

http://www.jsqmd.com/news/640881/

相关文章:

  • 2026年合肥GEO源码开发指南:谁是真正的技术领航者? - 企业推荐官【官方】
  • Vivado XDC文件注释踩坑实录:为什么我的引脚约束突然失效了?
  • [AI/应用/MCP] MCP Server/Tool 开发指南创
  • 为什么CLIPScore、MME、MMBench全失效了?——基于127个真实业务场景的多模态评估指标失效图谱分析
  • 口腔执业医师刷题用哪个?阿虎医考APP三大题库实用解析 - 医考机构品牌测评专家
  • 从Prompt到Harness:下一代AI Agent开发方法论,工程师必须掌握的系统性设计!
  • 0-1 背包进阶:回溯法(子集树)+ 分支限界优化 极致详解(C++ 完整实现)
  • 多模态大模型对齐与融合终极框架(含代码/配置/评估指标):覆盖视觉-语言-语音-时序四模态,仅限首批500名工程师获取完整技术栈
  • 零基础口腔执医上岸经验分享:我用的刷题工具是阿虎医考APP - 医考机构品牌测评专家
  • Qwen3-ASR-0.6B在智能客服的应用:多轮对话理解与响应
  • m4s-converter:5秒无损转换B站缓存视频的终极解决方案
  • AI研究员工业落地:从实验室到产品的过渡
  • 春联生成模型-中文-base实操手册:生成结果导出为SVG/PNG高清图教程
  • opencv深度人工神经网络DNN目录地址
  • 【C++ 基础 】C++14 中为什么 make_shared / make_unique 更安全?
  • Mac上5分钟搞定K3s+kubeflow:开发测试环境搭建全流程(含资源分配避坑指南)
  • 基于V4L2与DRM框架:在RK3588上实现USB摄像头到MIPI屏幕的低延迟图像通路
  • 乡村基蒸菜系列减脂餐外卖有优惠吗?2026这份美团半价活动攻略记得收藏 - 资讯焦点
  • 临床执业医师老师推荐:请看这篇报道 - 医考机构品牌测评专家
  • MedGemma 1.5医疗助手实战:本地部署+思维链解读全攻略
  • 2026跨城包车攻略:聊城到济南包车多少钱多少钱?携程百事通实价揭秘,拒绝隐形消费 - 土星买买买
  • 手把手教你部署MiniCPM-V-2_6:支持图文视频对话,开箱即用
  • 1-1杰理蓝牙SOC的UI配置开发方法
  • 一次性无纺布源头厂家哪家好点 - 企业推荐官【官方】
  • 2026年必知!连续式切丁机生产厂家哪家更胜一筹? - 企业推荐官【官方】
  • 靠谱的河南电缆公司
  • 深度解析CD66e (癌胚抗原相关细胞粘附分子5):分子机制与靶向药物研发进展
  • 【GaussTech技术专栏】GaussDB逻辑解码技术原理
  • 利用MSSQL解析优化数据库性能,提升效率,驱动业务创新与稳定发展
  • AgentCPM深度研报助手Matlab数据分析联动:模型结果深度可视化