Introduction
MediaPlayer (android.media.MediaPlayer
) is a popular way to play media files, and combining it with a SeekBar can greatly improve the user experience. In this tutorial, we will learn how to synchronize a MediaPlayer progress to a SeekBar position.
Goals
At the end of the tutorial, you would have learned:
- How to sync an active MediaPlayer to a SeekBar.
Tools Required
- Android Studio. The version used in this tutorial is Bumblebee 2021.1.1 Patch 1.
Prerequisite Knowledge
- Intermediate Android.
- ActivityResult APIs.
- Storage Access Framework (SAF).
- Coroutines.
Project Setup
To follow along with the tutorial, perform the steps below:
-
Create a new Android project with the default Empty Activity.
-
Give the default “Hello World!” TextView
android:id
oftextView_time
. -
Completely remove the
android:text
attribute fromtextView_time
. -
Add the
tools:text
attribute totextView_time
with the value of0:00
. -
Add the
android:textSize
attribute with the value of32sp
. -
Constraint
textView_time
to the top, start, and end of ConstraintLayout, but leave the bottom side unconstrained. -
Your
textView_time
should look like the code below.<TextView android:id="@+id/textView_time" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="32sp" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" tools:text="0.00" />
-
Download the .mp3 file called Unexpected Gifts of Spring by Alextree from the FreeMusicArchive. This song is licensed under CC BY 4.0. You are recommended to download the file from the AVD’s built-in web browser. If you have downloaded the file to your development machine, you can also drag and drop the file into the AVD’s via the Device File Explorer, which will trigger a download action on the AVD (this behavior has only been tested on a Windows machine, I am not sure if this works on a Mac/Linux). Getting the files using these methods will automatically add an entry into the MediaStore.
-
Add the LifecycleScope KTX extension to your module build.gradle file.
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.4.0'
Add the SeekBar
SeekBar is a built-in Android View, which extends ProgressBar. It contains a draggable circle that end users can drag to specific points on a timeline.
Perform the steps below to add a SeekBar into the project.
-
Open activity_main.xml in the Code view.
-
Copy and paste the code below into activity_main.xml.
<SeekBar android:id="@+id/seekBar" android:layout_width="match_parent" android:layout_height="64dp" android:layout_marginHorizontal="16dp" 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/textView_time" />
-
Alternatively, if you prefer to configure your own SeekBar from scratch, it can also be found at Palette > Widgets, in the Design view.
-
Now, add these attributes below into
textView_time
to finish creating a vertical chain for the two Views.app:layout_constraintBottom_toTopOf="@id/seekBar" app:layout_constraintHorizontal_bias="0.5"
-
We will need to reference these Views later, so append these lines of code to
MainActivity#onCreate()
.//Gets the textView_time reference val timeView = findViewById<TextView>(R.id.textView_time) //Gets the seekBar reference val seekBar = findViewById<SeekBar>(R.id.seekBar)
Open an mp3 File
The next thing that we need in our project is a way to open the mp3 file that we downloaded earlier. We will use SAF/ActivityResult APIs for this.
-
Append the code below to
MainActivity#onCreate()
.//Launcher to open file with a huge callback. Organize in real code. val openMusicLauncher = registerForActivityResult(OpenDocument()){ uri -> } val mimeTypes = arrayOf("audio/mpeg") openMusicLauncher.launch(mimeTypes)
-
The code snippet above will start a Content Picker UI for the end user to pick a file matching the specified mime type. The chosen mime type of
audio/mpeg
will match .mp3 files, according to this Common MIME Types list.
Add the MediaPlayer
Now, we need to add a MediaPlayer object into our App for playing music files. To add MediaPlayer into our code, follow the steps below:
-
It is recommended to call
MediaPlayer#release()
to release resources when you are done with it, so we will add a reference to the MediaPlayer as a class property, for easy access in multiple callbacks such asonPause()
,onStop()
,onDestroy()
.//Keeps a reference here to make it easy to release later private var mediaPlayer: MediaPlayer? = null
-
Override
MainActivity#onStop()
to release and null outmediaPlayer
.override fun onStop() { super.onStop() mediaPlayer?.release() mediaPlayer = null }
-
Inside the
openMusicLauncher
callback, instantiate a MediaPlayer using the factory functionMediaPlayer#create()
and assign it tomediaPlayer
.mediaPlayer = MediaPlayer.create(applicationContext, uri)
-
Since
mediaPlayer
is nullable, we will chain the newly created MediaPlayer with analso {}
scope function block to skip multiple null checks in the next few steps.mediaPlayer = MediaPlayer.create(applicationContext, uri) .also { //also {} scope function skips multiple null checks }
Synchronize SeekBar (and TextView) to MediaPlayer progress
The SeekBar that the end user sees on the screen must scale relatively with the duration of the media file.
-
So we will have to set the SeekBar
max
value corresponding to the file duration. Inside thealso {}
block, add the code below.seekBar.max = it.duration
-
Now,
start()
the MediaPlayer.it.start()
-
Launch a coroutine running the Main thread with the code below.
//Should be safe to use this coroutine to access MediaPlayer (not thread-safe) //because it uses MainCoroutineDispatcher by default lifecycleScope.launch { } //Can also release mediaPlayer here, if not looping. }
-
Inside of
launch {}
, add awhile()
loop conditioned to the BooleanMediaPlayer.isPlaying
.//Should be safe to use this coroutine to access MediaPlayer (not thread-safe) //because it uses MainCoroutineDispatcher by default lifecycleScope.launch { while (it.isPlaying){ } //Can also release mediaPlayer here, if not looping. }
-
Inside of this
while()
loop, we can synchronizeSeekBar#progress
withMediaPlayer.currentPosition
.lifecycleScope.launch { while (it.isPlaying){ seekBar.progress = it.currentPosition } //Can also release mediaPlayer here, if not looping. }
-
We can also synchronize
TextView#text
withMediaPlayer.currentPosition
. Themilliseconds
extension property ofInt
is used here for convenience because its defaulttoString()
form is quite readable (you will see later). Time-formatting is not the focus of this tutorial.//Should be safe to use this coroutine to access MediaPlayer (not thread-safe) //because it uses MainCoroutineDispatcher by default lifecycleScope.launch { while (it.isPlaying){ seekBar.progress = it.currentPosition timeView.text = "${it.currentPosition.milliseconds}" } //Can also release mediaPlayer here, if not looping. }
-
Finally, add a
delay()
to the coroutine. This ensures that the seekBar will only update every one second.lifecycleScope.launch { while (it.isPlaying){ seekBar.progress = it.currentPosition timeView.text = "${it.currentPosition.milliseconds}" delay(1000) } //Can also release mediaPlayer here, if not looping. }
Synchronize MediaPlayer progress (and TextView) to SeekBar position
The code that we have so far will only update the SeekBar position and TextView content to the MediaPlayer progress. We will have to add a SeekBar.OnSeekBarChangeListener
object to the SeekBar to monitor for changes. Follow the steps below to complete the tutorial.
-
Append the object below to the
openMusicLauncher
callback. We have only implementedonProgressChanged
because that is all we need for now. We also used the functionMediaPlayer#seekTo()
to seek a specified time position.//Move this object somewhere else in real code val seekBarListener = object : SeekBar.OnSeekBarChangeListener { override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { if (fromUser){ //sets the playing file progress to the same seekbar progressive, in relative scale mediaPlayer?.seekTo(progress) //Also updates the textView because the coroutine only runs every 1 second timeView.text = "${progress.milliseconds}" } } override fun onStartTrackingTouch(seekBar: SeekBar?) {} override fun onStopTrackingTouch(seekBar: SeekBar?) {} }
-
Now, assign
seekBarLisenter
toseekBar
.seekBar.setOnSeekBarChangeListener(seekBarListener)
Run the App
We are now ready to launch the App. Your App should behave similarly to the Gif below. You can ignore the other files in my AVD’s Downloads directory that are not part of the project setup.
Solution Code
build.gradle
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
}
android {
compileSdk 31
defaultConfig {
applicationId "com.codelab.daniwebandroidaudioseekbarsync"
minSdk 21
targetSdk 31
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
}
dependencies {
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.4.0'
implementation 'androidx.core:core-ktx:1.7.0'
implementation 'androidx.appcompat:appcompat:1.4.1'
implementation 'com.google.android.material:material:1.5.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.3'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
}
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">
<TextView
android:id="@+id/textView_time"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="32sp"
app:layout_constraintBottom_toTopOf="@id/seekBar"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="0.00" />
<SeekBar
android:id="@+id/seekBar"
android:layout_width="match_parent"
android:layout_height="64dp"
android:layout_marginHorizontal="16dp"
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/textView_time" />
</androidx.constraintlayout.widget.ConstraintLayout>
MainActivity.kt
package com.codelab.daniwebandroidaudioseekbarsync
import android.media.MediaPlayer
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.widget.SeekBar
import android.widget.TextView
import androidx.activity.result.contract.ActivityResultContracts.OpenDocument
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.milliseconds
class MainActivity : AppCompatActivity() {
//Keeps a reference here to make it easy to release later
private var mediaPlayer: MediaPlayer? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
//Gets the textView_time reference
val timeView = findViewById<TextView>(R.id.textView_time)
//Gets the seekBar reference
val seekBar = findViewById<SeekBar>(R.id.seekBar)
//Launcher to open file with a huge callback. Organize in real code.
val openMusicLauncher = registerForActivityResult(OpenDocument()){ uri ->
//Instantiates a MediaPlayer here now that we have the Uri.
mediaPlayer = MediaPlayer.create(applicationContext, uri)
.also { //also {} scope function skips multiple null checks
seekBar.max = it.duration
it.start()
//Should be safe to use this coroutine to access MediaPlayer (not thread-safe)
//because it uses MainCoroutineDispatcher by default
lifecycleScope.launch {
while (it.isPlaying){
seekBar.progress = it.currentPosition
timeView.text = "${it.currentPosition.milliseconds}"
delay(1000)
}
//Can also release mediaPlayer here, if not looping.
}
}
//Move this object somewhere else in real code
val seekBarListener = object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
if (fromUser){
//sets the playing file progress to the same seekbar progressive, in relative scale
mediaPlayer?.seekTo(progress)
//Also updates the textView because the coroutine only runs every 1 second
timeView.text = "${progress.milliseconds}"
}
}
override fun onStartTrackingTouch(seekBar: SeekBar?) {}
override fun onStopTrackingTouch(seekBar: SeekBar?) {}
}
seekBar.setOnSeekBarChangeListener(seekBarListener)
}
val mimeTypes = arrayOf("audio/mpeg")
openMusicLauncher.launch(mimeTypes)
}
override fun onStop() {
super.onStop()
mediaPlayer?.release()
mediaPlayer = null
}
}
Summary
Congrations, you have learned how to sync a MediaPlayer and a SeekBar. The full project code can be found at https://github.com/dmitrilc/DaniwebAndroidAudioSeekbarSync.