iT邦幫忙

第 12 屆 iThome 鐵人賽

DAY 5
0
Mobile Development

Android 音樂播放器自己來系列 第 5

歌曲列表實作 (3) - 撈取音檔

  • 分享至 

  • xImage
  •  

有權限後,就可以取得音樂檔案啦,透過 ContentProvider 觀念介紹 介紹的觀念,取得音檔。

首先先來寫 query 的 function:

override suspend fun getSongs(): List<Song> {
        val songs = mutableListOf<Song>()
        /**
         * Working with [ContentResolver]s can be slow, so we'll do this off the main
         * thread inside a coroutine.
         */
        withContext(Dispatchers.IO) {
            val projection = arrayOf(
                MediaStore.Audio.Media._ID,
                MediaStore.Audio.Media.TITLE,
                MediaStore.Audio.Media.ARTIST,
                MediaStore.Audio.Media.ALBUM_ID,
                MediaStore.Audio.Media.ALBUM
            )
            val selection = null
            val selectionArgs = null
            val sortOrder = "${MediaStore.Audio.Media.DATE_ADDED} DESC"
......

因為讀取音檔資料會花費比較久的時間,需要在 Background thread 執行,這邊就使用 Coroutines 來處理。接著看到 projection,先拿我們畫面上需要的就好。從官方文件上可以得知,還有很多欄位可以拿,但也看到很多欄位在 Android 11 要被加入,有些要被棄用了(怕) XD,之後 App target 升級到 30 (Android 11),可能會有需要修改的部分,但目前就先用 Android 10 的方式來撰寫。

因為要拿全部的音檔,因此在 selection 和 selectionArgs 就不用給定數值。sortOrder 則是排序規則,設定成依據音檔加入的時間降續排列,最晚加入的會最先取得。

條件設定好了,就可以執行查詢了:

contentResolver.query(
                MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
                projection,
                selection,
                selectionArgs,
                sortOrder
            )?.use { cursor ->
                val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID)
                val titleColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.TITLE)
                val artistColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ARTIST)
                val albumIdColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ALBUM_ID)
                val albumColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ALBUM)

                Log.i(TAG, "Found ${cursor.count} songs")

查詢後,會拿到 db 的 cursor,可以透過這個 cursor 來取得資訊。上面的 idColumn 這些欄位是取得 column 的 index,可以看到這邊寫的 column 都是上面 projection 有寫到的。

接著就將資訊轉換成物件:

while (cursor.moveToNext()) {
    val id = cursor.getLong(idColumn)
    val title = cursor.getString(titleColumn)
    val artist = cursor.getString(artistColumn)
    val albumId = cursor.getLong(albumIdColumn)
    val album = cursor.getString(albumColumn)
    val cover = getAlbumCoverPathFromAlbumId(contentResolver, albumId)
    val contentUri: Uri =
        ContentUris.withAppendedId(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, id)

    val song = Song(
        id = id,
        title = title,
        artistName = artist,
        albumId = albumId,
        albumName = album,
        coverPath = cover,
        contentUri = contentUri
    )
    songs += song
    Log.v(TAG, "Added songs: $song")
}

Song 為自定義的資料結構,裡面存畫面上和播放所需的資料,供之後使用,除了有上面五個 projection 的欄位外,還看到多了兩個欄位分別是:

  • coverPath: 為專輯封面圖的位置,ex: content://media/external/audio/albumart/350023861
  • contentUri: 為音檔的位置,ex: content://media/external/audio/media/21

原本拿取音檔的方式為在 Projection 上加入 MediaStore.Audio.Media.DATA,但這個寫法在在 Android 10 引入了 Scope Storage 後被 deprecated 了,原本這個寫法拿到的是一個絕對路徑,ex: /storage/emulated/0/Music/xxx.mp3。

關於 coverPath 這邊雖然路徑拿到的是 content:// 開頭,但使用的路徑是寫死的路徑的(content://media/external/audio/albumart),加上 ALBUM_ART 欄位在 Android 10 被 deprecated 了,要使用的話,直接用 ContentResolver#loadThumbnail,來取得圖片,但這個 API 在 Android 10 才加入,那 Android 5 到 9 要用什麼方式處理呢? 目前還沒有找到比較好的方式,如果大家知道有比較好的方式來獲取 Cover 路徑,歡迎在下面留言!

private fun getAlbumCoverPathFromAlbumId(
        contentResolver: ContentResolver,
        albumId: Long
    ): String {
        val albumArtUri =
            Uri.parse("content://media/external/audio/albumart")
        val coverUri = ContentUris.withAppendedId(albumArtUri, albumId)

        return try {
            val inputStream: InputStream? = contentResolver.openInputStream(coverUri)
            inputStream?.close()
            coverUri.toString()
        } catch (e: IOException) {
            ""
        } catch (e: IllegalStateException) {
            ""
        }
    }

以上就是撈取音檔的程式碼。

再來是和系統註冊事件,如果音檔有變化會通知,比方說有新的音檔下載好了,系統就會發出通知 App,可以去讀取新的歌曲資訊。

contentObserver = contentResolver.registerObserver(
                    MediaStore.Audio.Media.EXTERNAL_CONTENT_URI ) {  
                    loadSongs()
                }

fun ContentResolver.registerObserver(
    uri: Uri,
    observer: (selfChange: Boolean) -> Unit
): ContentObserver {
    val contentObserver = object : ContentObserver(Handler()) {
        override fun onChange(selfChange: Boolean) {
            observer(selfChange)
        }
    }
    registerContentObserver(uri, true, contentObserver)
    return contentObserver
}

因為有註冊,就需要反註冊,ViewModel 可以寫在 onCleared() 內:

override fun onCleared() {
        contentObserver?.let {
            contentResolver.unregisterContentObserver(it)
        }
    }

其他的部分就是程式的串接,使用 LiveData 和 Coroutines,DI 使用手工的方式(InjectorUtils),就不特別介紹了。

程式碼在這,分支名稱(day5_query_songs):Fancy/day5_query_songs

明天就介紹顯示撈取的音檔啦,不會只有在 log 上看到了 XD


上一篇
歌曲列表實作 (2) - 取得權限
下一篇
歌曲列表實作 (4) - 顯示音檔
系列文
Android 音樂播放器自己來30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言