Introduction
When working with music files, you might have wondered how to load display art from Audio files. In this tutorial, we will learn how to load thumbnails for audio files.
Goals
At the end of the tutorial, you would have learned:
- How to load display art for music files.
Tools Required
- Android Studio. The version used in this tutorial is Bumblebee 2021.1.1 Patch 2.
Prerequisite Knowledge
- Basic Android.
- MediaStore. For simplicity, all queries in this tutorial are performed on the UI thread. In real code, prefer coroutines.
- ActivityResults API.
- Permissions.
Project Setup
To follow along with the tutorial, perform the steps below:
-
Create a new Android project with the default Empty Activity.
Replace activity_main.xml with the content below. This removes the default “Hello World!” TextView, adds a Button and an ImageView.<?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" tools:context=".MainActivity"> <Button android:id="@+id/button_loadMusic" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/load_music" android:layout_marginVertical="16dp" app:layout_constraintBottom_toTopOf="@+id/imageView_displayArt" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintHorizontal_bias="0.5" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> <ImageView android:id="@+id/imageView_displayArt" android:layout_width="300dp" android:layout_height="300dp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintHorizontal_bias="0.5" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/button_loadMusic" tools:srcCompat="@tools:sample/backgrounds/scenic" /> </androidx.constraintlayout.widget.ConstraintLayout>
-
Add the
<string>
resource below into strings.xml.<string name="load_music">Load Music</string>
-
Download the sample music song I Move On by Jan Morgenstern (© copyright Blender Foundation | durian.blender.org). Upload the file onto your test device.
-
In AndroidManifest.xml, add the
<uses-permission>
element inside of<manifest>
, but outside of<application>
.<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
Project Overview
Currently, our project only has two elements: a Button and an ImageView.
At the end of the tutorial, our app should be able to perform the workflow below:
- The end user taps the Button to initiate the MediaStore to load the music file that we downloaded previously.
- After the thumbnail has been loaded, we apply it to the ImageView surface so that it will draw the thumbnail on the screen for us.
Open the music file
First, let us get the logic to load the music file out of the way. Follow the steps below to modify MainActivity#onCreate()
.
-
Obtains a reference to the Button.
val button = findViewById<Button>(R.id.button_loadMusic)
-
Obtains a reference to the ImageView.
val imageView = findViewById<ImageView>(R.id.imageView_displayArt)
-
The permission is required, so create a launcher to ask for permission below.
val permissionResultLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()){ isGranted -> }
-
Bind the permission launcher code to the Button’s OnClickListener.
button.setOnClickListener { permissionResultLauncher.launch(READ_EXTERNAL_STORAGE) }
Loading thumbnails on Android Q and above
The API to get the thumbnail is different for devices running Android Q and above. So we will have to check for the version and call appropriate methods for each. Inside of permissionResultLauncher
, add the code below.
val thumbnail = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
getAlbumArtAfterQ()
} else {
getAlbumArtBeforeQ()
}
imageView.setImageBitmap(thumbnail)
Now we are going to create getAlbumArtAfterQ()
first. For devices running Android Q or above, there is a dedicated convenient function to load thumbnails, ContentResolver#loadThumbnail()
. The only things that are required to use this function is the Content Uri and a Size object. Because we already know the title of the song that we want to load the thumbnail for, it should be easy to query for its id. Adds the getAlbumArtAfterQ()
function below into MainActivity.
@RequiresApi(Build.VERSION_CODES.Q)
private fun getAlbumArtAfterQ(): Bitmap? {
val collection = MediaStore.Audio.Media.getContentUri(MediaStore.VOLUME_EXTERNAL)
//The columns that you want. We need the ID to build the content uri
val projection = arrayOf(
MediaStore.Audio.Media._ID,
MediaStore.Audio.Media.TITLE,
)
//filter by title here
val selection = "${MediaStore.Audio.Media.TITLE} = ?"
//We already know the song title in advance
val selectionArgs = arrayOf(
"I Move On (Sintel's Song)"
)
val sortOrder = null //sorting order is not needed
var thumbnail: Bitmap? = null
applicationContext.contentResolver.query(
collection,
projection,
selection,
selectionArgs,
sortOrder
)?.use { cursor ->
val idColIndex = cursor.getColumnIndex(MediaStore.Audio.Media._ID)
while (cursor.moveToNext()) {
val id = cursor.getLong(idColIndex)
//Builds the content uri here
val uri = ContentUris.withAppendedId(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
id
)
}
}
return thumbnail
}
The code above simply queries the appropriate MediaStore collection to get the song’s ID. With the ID received, we built a Content Uri with ContentUris#withAppendedId()
. Because MediaStore knowledge is a requirement for this tutorial, I will not bore you with explaining the query further. You can read this tutorial to get an idea of how the query was built.
Now add the code below inside the while
loop to load the thumbnail.
try {
thumbnail = contentResolver.loadThumbnail(
uri,
Size(300, 300),
null
)
} catch (e: IOException) {
TODO("Load alternative thumbnail here")
}
loadThumbnail()
can fail if the file does not contain a display art, so you can optionally catch the exception and load an alternative album art instead if desired.
Loading thumbnails before Android Q
To load the thumbnail for devices before Android Q, we will have to query the MediaStore.Audio.Albums
(ALBUM_ART) collection instead. But because this collection does not contain the song title, which is our approach here, we will have to find the ALBUM_ART
via one of the available columns in the MediaStore.Audio.Albums
.
In this tutorial, we first find the album id in MediaStore.Audio.Media
, and then use that album id to search for the ALBUM_ART in MediaStore.Audio.Albums
. This approach performs two queries. You can also use a different approach, such as querying for the Album Title directly and you should be able to get the thumbnail in one query.
Add the getAlbumId()
function to find the Album ID.
private fun getAlbumId(): Long? {
val collection = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
//The columns that you want. We need the ID to build the content uri
val projection = arrayOf(
MediaStore.Audio.Media.TITLE,
MediaStore.Audio.Media.ALBUM_ID
)
//filter by title here
val selection = "${MediaStore.Audio.Media.TITLE} = ?"
//We already know the song title in advance
val selectionArgs = arrayOf(
"I Move On (Sintel's Song)"
)
val sortOrder = null //sorting order is not needed
var id: Long? = null
applicationContext.contentResolver.query(
collection,
projection,
selection,
selectionArgs,
sortOrder
)?.use { cursor ->
val albumIdColIndex = cursor.getColumnIndex(MediaStore.Audio.Media.ALBUM_ID)
while (cursor.moveToNext()) {
id = cursor.getLong(albumIdColIndex)
}
}
return id
}
Now, add the getAlbumArtBeforeQ()
function to query for the Album Art (it is a path String).
private fun getAlbumArtBeforeQ(): Bitmap? {
val collection = MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI
//The columns that you want. We need the ID to build the content uri
val projection = arrayOf(
MediaStore.Audio.Albums._ID,
MediaStore.Audio.Albums.ALBUM_ART
)
//filter by title here
val selection = "${MediaStore.Audio.Albums._ID} = ?"
val albumId = getAlbumId()
//We already know the song title in advance
val selectionArgs = arrayOf(
"$albumId"
)
val sortOrder = null //sorting order is not needed
var thumbnail: Bitmap? = null
applicationContext.contentResolver.query(
collection,
projection,
selection,
selectionArgs,
sortOrder
)?.use { cursor ->
val albumArtColIndex = cursor.getColumnIndex(MediaStore.Audio.Albums.ALBUM_ART)
while (cursor.moveToNext()) {
val albumArtPath = cursor.getString(albumArtColIndex)
thumbnail = BitmapFactory.decodeFile(albumArtPath)
if (thumbnail === null){
TODO("Load alternative thumbnail here")
}
}
}
return thumbnail
}
The function decodeFile()
used above does not throw an exception. It returns null
if it fails to load the thumbnail, so instead of catching an IOException here, we need to check for null
and load alternative thumbnails if desired.
Run the App
To test, we need to run the app on both Android Q (or higher) and an older Android API. In my case, I have used two emulators running Android API 32 and 22.
For Android 32, your app should behave similarly to the Gif below.
For Android 22, your app should behave similarly to the Gif below.
Solution Code
MainActivity.kt
package com.codelab.daniwebandroidnativeloadmediathumbnail
import android.Manifest.permission.READ_EXTERNAL_STORAGE
import android.content.ContentUris
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.os.Build
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.provider.MediaStore
import android.util.Size
import android.widget.Button
import android.widget.ImageView
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresApi
import java.io.IOException
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val button = findViewById<Button>(R.id.button_loadMusic)
val imageView = findViewById<ImageView>(R.id.imageView_displayArt)
val permissionResultLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
val thumbnail = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
getAlbumArtAfterQ()
} else {
getAlbumArtBeforeQ()
}
imageView.setImageBitmap(thumbnail)
}
button.setOnClickListener {
permissionResultLauncher.launch(READ_EXTERNAL_STORAGE)
}
}
private fun getAlbumArtBeforeQ(): Bitmap? {
val collection = MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI
//The columns that you want. We need the ID to build the content uri
val projection = arrayOf(
MediaStore.Audio.Albums._ID,
MediaStore.Audio.Albums.ALBUM_ART
)
//filter by title here
val selection = "${MediaStore.Audio.Albums._ID} = ?"
val albumId = getAlbumId()
//We already know the song title in advance
val selectionArgs = arrayOf(
"$albumId"
)
val sortOrder = null //sorting order is not needed
var thumbnail: Bitmap? = null
applicationContext.contentResolver.query(
collection,
projection,
selection,
selectionArgs,
sortOrder
)?.use { cursor ->
val albumArtColIndex = cursor.getColumnIndex(MediaStore.Audio.Albums.ALBUM_ART)
while (cursor.moveToNext()) {
val albumArtPath = cursor.getString(albumArtColIndex)
thumbnail = BitmapFactory.decodeFile(albumArtPath)
if (thumbnail === null){
TODO("Load alternative thumbnail here")
}
}
}
return thumbnail
}
private fun getAlbumId(): Long? {
val collection = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
//The columns that you want. We need the ID to build the content uri
val projection = arrayOf(
MediaStore.Audio.Media.TITLE,
MediaStore.Audio.Media.ALBUM_ID
)
//filter by title here
val selection = "${MediaStore.Audio.Media.TITLE} = ?"
//We already know the song title in advance
val selectionArgs = arrayOf(
"I Move On (Sintel's Song)"
)
val sortOrder = null //sorting order is not needed
var id: Long? = null
applicationContext.contentResolver.query(
collection,
projection,
selection,
selectionArgs,
sortOrder
)?.use { cursor ->
val albumIdColIndex = cursor.getColumnIndex(MediaStore.Audio.Media.ALBUM_ID)
while (cursor.moveToNext()) {
id = cursor.getLong(albumIdColIndex)
}
}
return id
}
@RequiresApi(Build.VERSION_CODES.Q)
private fun getAlbumArtAfterQ(): Bitmap? {
val collection = MediaStore.Audio.Media.getContentUri(MediaStore.VOLUME_EXTERNAL)
//The columns that you want. We need the ID to build the content uri
val projection = arrayOf(
MediaStore.Audio.Media._ID,
MediaStore.Audio.Media.TITLE,
)
//filter by title here
val selection = "${MediaStore.Audio.Media.TITLE} = ?"
//We already know the song title in advance
val selectionArgs = arrayOf(
"I Move On (Sintel's Song)"
)
val sortOrder = null //sorting order is not needed
var thumbnail: Bitmap? = null
applicationContext.contentResolver.query(
collection,
projection,
selection,
selectionArgs,
sortOrder
)?.use { cursor ->
val idColIndex = cursor.getColumnIndex(MediaStore.Audio.Media._ID)
while (cursor.moveToNext()) {
val id = cursor.getLong(idColIndex)
//Builds the content uri here
val uri = ContentUris.withAppendedId(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
id
)
try {
thumbnail = contentResolver.loadThumbnail(
uri,
Size(300, 300),
null
)
} catch (e: IOException) {
TODO("Load alternative thumbnail here")
}
}
}
return thumbnail
}
}
activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<Button
android:id="@+id/button_loadMusic"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/load_music"
android:layout_marginVertical="16dp"
app:layout_constraintBottom_toTopOf="@+id/imageView_displayArt"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/imageView_displayArt"
android:layout_width="300dp"
android:layout_height="300dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/button_loadMusic"
tools:srcCompat="@tools:sample/backgrounds/scenic" />
</androidx.constraintlayout.widget.ConstraintLayout>
strings.xml
<resources>
<string name="app_name">Daniweb Android Native Load Media Thumbnail</string>
<string name="load_music">Load Music</string>
</resources>
AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.codelab.daniwebandroidnativeloadmediathumbnail">
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.DaniwebAndroidNativeLoadMediaThumbnail">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
Summary
We have learned how to load thumbnails on both pre-Q and post-Q Android devices in this tutorial. The full project code can be found at https://github.com/dmitrilc/DaniwebAndroidNativeLoadMediaThumbnail