基於Flutter的雲音樂桌面版-音樂下載篇

語言: CN / TW / HK

先來說說背景吧。前段時間,我用Flutter斷斷續續的開發了一款桌面版的網易雲音樂,成品還是比較滿意的。已完成的功能包括推薦音樂、私人FM、我喜歡的音樂、我的收藏、歌曲評論、我的下載等。當然了,有關音樂的核心功能播放下載也都完成了。

前文引導👇:

*基於Flutter開發的桌面版網易雲音樂(一)*

*基於Flutter開發的桌面版網易雲音樂(二)*

一、MP3+JSON

問題就出在下載這裡。當時對於音訊檔案下載的實現方式是通過MP3+JSON的方式實現的。有人就要問了,為什麼要這麼實現?同時下載一個JSON檔案又是什麼鬼?別急,且聽我慢慢道來。

首先,我開發的DreamMusic專案播放音樂時使用的是外鏈,它長這樣"https://music.163.com/song/media/outer/url?id=$songId.mp3"。至於為何不用歌曲詳情中的連結而直接使用外鏈,由於不是本文重點,我就不解釋了。我們還是回到播放和下載上。通過這個音樂URL,我們可以播放線上音樂,當然也可以通過這個URL直接下載MP3檔案。它是一個不包含任何媒體資訊的原始音訊檔案(這裡的媒體資訊指的是歌名,歌手,專輯資訊等)

現在我們來想一下,在做音樂下載模組時需要做什麼?🤔

  1. 下載中進度展示。
  2. 下載完展示在列表上。
  3. 應用啟動時能讀取到音樂資訊並展示出來。

%E6%88%AA%E5%B1%8F2022-12-07_15.52.13.png

其中1和2都是在執行中進行的,因此,音樂資訊是能夠直接從操作的音樂模型中獲取到。那麼,3應該如何實現。我上面有提到,從URL中下載的音訊檔案是一個原始的MP3檔案,不包含其他任何媒體資訊。如果要實現應用啟動時展示已下載音樂列表,並能夠展示出歌曲封面,歌名,歌手,專輯等資訊,那麼我們在下載的時候還需要同時存取一份音樂的媒體資訊才行。因此才會有我第一版中提到的MP3+JSON方式。

%E6%88%AA%E5%B1%8F2022-12-07_15.50.31.png

上圖中,那一串數字是雲音樂平臺的歌曲ID,其中的json檔案就儲存了歌曲的基本資訊。用於在應用啟動時載入歌曲資訊。流程就是先找到JSON檔案,讀取JSON資訊,轉成已下載音樂的模型。寫成程式碼就是下面這樣。

dart String name = directory.uri.pathSegments[directory.uri.pathSegments.length - 2]; final jsonFile = File("${directory.path}/$name.json"); final exist = await jsonFile.exists(); if (exist) { final content = await jsonFile.readAsString(); final data = json.decode(content); if (data is Map<String, dynamic>) { final song = DownloadSongModel.fromJson(data); return song; } } else { debugPrint("[download]音樂[$name]json檔案沒有找到,刪掉對應資料夾內容"); await directory.delete(recursive: true); }

那麼,這樣寫會有什麼問題?🤔

一個顯而易見的問題就是音訊檔案和媒體資訊分離了,不便管理。這裡可能有人就要說了,你這不是P話嗎,媒體資訊不分開放難道放MP3裡?誒~還真可以,那就是使用ID3,這個我後面會提到。我們還是繼續說MP3+JSON這種方式。還有沒有其他問題?有的,比如使用者可以隨意單獨刪除JSON或MP3,或開啟JSON檔案,修改其中的資訊,導致音樂和媒體資訊不一致。其中隨意修改JSON資訊真是致命的。

那麼我在之前是如何處理上述問題的呢。我們接著往下看。

針對刪除檔案

場景如下,使用者開啟著應用,然後直接操作下載資料夾,單獨刪除了JSON,或MP3或整個DreamMusic目錄。如果我們要做同步,那就需要監聽這些檔案的變動。Flutter檔案系統為我們提供了這個方法:

dart Stream<FileSystemEvent> watch( {int events = FileSystemEvent.all, bool recursive = false})

這個方法會監聽檔案的事件FileSystemEvent,並通過回撥的方式告訴我們。於是,我們可以很容易的寫出下列程式碼,加入檔案/資料夾刪除監聽。

dart /// 監聽下載目錄的變化,主要看檔案有沒有減少 void _addFileDeleteObserverIfNeeded() async { if (!FileSystemEntity.isWatchSupported) { return; } if (hasDirectoryObserver) { return; } hasDirectoryObserver = true; final directory = Directory(fileCacheDirectorPath); if (!directory.existsSync()) { await directory.create(); } final stream = directory.watch(events: FileSystemEvent.delete, recursive: true); stream.listen((event) async { // debugPrint("[download]$event"); String path = event.path; if (path == fileCacheDirectorPath) { // 刪除了整個下載目錄 _downloadedSongModels.clear(); hasDirectoryObserver = false; debugPrint("[download]刪除整個下載目錄"); } else { // 刪除其中某個檔案,這會導致資訊不完整,因此直接全部刪除即可 final lastSegment = Uri(path: path).pathSegments.last; final fileName = lastSegment.split('.').first; final songId = int.tryParse(fileName); if (songId != null) { final path = "$fileCacheDirectorPath/$songId"; final dir = Directory(path); final exist = await dir.exists(); if (exist) { await dir.delete(recursive: true); } final key = SongDownloadTask.createTaskId(songId); _downloadedSongModels.remove(key); } debugPrint("[download]刪除檔案$lastSegment,songId-$songId"); } notifyListeners(); }); debugPrint("[download]開始監聽$fileCacheDirectorPath目錄的變化"); }

邏輯處理很簡單,如果使用者單單刪除了JSON或MP3,這就導致下載的音訊檔案不完整,於是,直接刪除整個音樂資料夾就好(這裡指的是上面提到的那一串歌曲ID的資料夾,不是最外層的DreamMusic目錄)。如果使用者是刪除了整個DreamMusic下載目錄,那麼不多說,全部刪除。

delete.gif

針對修改檔案

很抱歉,我沒做這個處理。因為我懶😄。其實是想出了更好的方式。那就是MP3+ID3的方式。

當然,我還是可以提供下思路。原理還是使用上述提到的監聽檔案修改的方式,這裡我們監聽修改JSON檔案,一單檔案的內容經過修改,系統會回撥一個FileSystemModifyEvent物件給我們,裡面有個屬性叫contentChanged,我們判斷下內容是否真的變了,變了就刪掉,誰讓你亂改下載檔案的😄。當然最主要的原因是,檔案系統沒告訴我改了什麼,變更前和變更後又是什麼,實在不好判斷呀~

二、MP3+ID3

所以,我就放棄繼續在JSON上轉牛角尖的想法,轉而思考是否可以將媒體資訊放入到音訊檔案內部。於是,順理成章的瞭解到了ID3(真的是問題不可怕,它是前進的動力)。

ID3維基百科。不瞭解ID3的可以先看看這是何物,有何作用。簡單來說,ID3就是存在於音訊檔案中用於存放媒體資訊的一段內容。它有自己的格式,目前流行的是ID3v2.3ID3v2.4版本。

瞭解了ID3基本的資訊後,我就去找對應的三方庫呀,看看有沒有現成的可以幫助我解決問題的id3解析庫存在。很快的,我就找到了一個排名靠前的ID3庫實現,id3。然後,我又分別實驗了下自己下載的mp3檔案和Mac版網易雲音樂下載的mp3檔案,裡面都有些什麼。發現果然有些東西。

下面是網易雲下載的歌曲讀取出來的ID3資訊:

json { Version: v2.3.0, Settings: Lavf57.25.100, TPOS: 1, Track: 12, Artist: 大壯, APIC: {mime: image/jpg, textEncoding: 0, picType: Other, description: , base64: iVBORw0K...}, Title: 為你我受冷風吹, Album: 大壯首張限量定製翻唱 }

而我自己下載的歌曲檔案的ID3中沒有任何媒體資訊:

json { Version: v2.3.0, Settings: Lavf57.71.100 }

其實,在Mac上,我們平時快捷預覽MP3檔案時也會出現一些媒體資訊,而這些資訊就是mac桌面系統通過讀取ID3顯示出來的。

%E6%88%AA%E5%B1%8F2022-12-07_16.48.19.png

這下,我們終於知道要將媒體資訊存到哪裡去了。那就是ID3中。可問題來了,id3這個三方庫它不支援編輯啊。先不說它有沒有bug,它不支援編輯啊

於是,我查看了ID3有關v1,v2的所有版本的官方資訊。又在網上看了不少前輩的文章講解,心裡有了明悟,我為什麼不自己寫呢?

於是前後經歷一個月時間,一個支援ID3解碼編碼id3_codec終於實現了🎉。並且還支援ID3所有版本(編碼這塊v2.2不做支援,因為基本沒人用)。

有關id3_codec實現可以看我下列文章:

有了ID3的支援,我們就可以將媒體資訊存入MP3中了,這樣就解決了上面所有的問題。再也不擔心使用者亂改了(當然,如果有使用者用編碼器修改ID3資訊,那我服了)。

我們只需要將寫入JSON的邏輯改成寫入MP3原檔案即可。看程式碼:

dart /// 將歌曲資訊寫入 void _writeSongInfoAsync(DownloadSongModel song) async { if (_cacheMode == DownloadCacheMode.json) { // 略 } else if (_cacheMode == DownloadCacheMode.id3) { final path = _generateSongId3SavePath(song.name); final file = File(path); bool exist = await file.exists(); if (exist) { final bytes = await file.readAsBytes(); final encoder = ID3Encoder(bytes); final al = json.encode(song.al.toJson()); final resultBytes = encoder.encodeSync(MetadataV2_3Body( title: song.name, artist: song.authorNmae, album: song.al.name, userDefines: { "duration": song.time.toString(), "songId": song.songId.toString(), "ar": json.encode(song.ar.map((e) => e.toJson()).toList()), "al": al, })); file.writeAsBytes(resultBytes, mode: FileMode.write); debugPrint("[download]finish encode id3 info: ${song.name}"); } } }

我下載了一首歌“是你”,桌面預覽能直接看到歌曲標題等資訊。如果要看詳細點,我們可以直接通過id3_codecID3Decoder,當然還可以使用其他工具,這裡我使用一款叫MediaInfo的工具,還看到了我們自定義儲存的araldurationsongId資訊。

%E6%88%AA%E5%B1%8F2022-12-07_17.01.05.png

%E6%88%AA%E5%B1%8F2022-12-07_17.04.26.png

其實剩下的就沒有啥懸念了。我們通過id3_codecID3Decoder讀取對應的資訊,組裝成模型展示出來即可。程式碼都在專案download_manager.dart_loadSongModelFromPath下,感興趣的自行取檢視。

總結

本文主要講解了音樂下載中儲存的方式和期間遇到的問題,以及最後的解決方法。也簡單介紹了ID3,它的應用,以及相應的編解碼庫id3_codec。本文涉及到的專案地址👉點我檢視DreamMusic👈,感謝支援。