Android Native - sync MediaPlayer progress to Seekbar

dimitrilc 2 Tallied Votes 1K Views Share

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:

  1. How to sync an active MediaPlayer to a SeekBar.

Tools Required

  1. Android Studio. The version used in this tutorial is Bumblebee 2021.1.1 Patch 1.

Prerequisite Knowledge

  1. Intermediate Android.
  2. ActivityResult APIs.
  3. Storage Access Framework (SAF).
  4. Coroutines.

Project Setup

To follow along with the tutorial, perform the steps below:

  1. Create a new Android project with the default Empty Activity.

  2. Give the default “Hello World!” TextView android:id of textView_time.

  3. Completely remove the android:text attribute from textView_time.

  4. Add the tools:text attribute to textView_time with the value of 0:00.

  5. Add the android:textSize attribute with the value of 32sp.

  6. Constraint textView_time to the top, start, and end of ConstraintLayout, but leave the bottom side unconstrained.

  7. 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" />
  8. 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.

  9. 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.

progress.jpg

Perform the steps below to add a SeekBar into the project.

  1. Open activity_main.xml in the Code view.

  2. 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" />
  3. Alternatively, if you prefer to configure your own SeekBar from scratch, it can also be found at Palette > Widgets, in the Design view.

  4. 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"
  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.

  1. 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)
  2. 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:

  1. 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 as onPause(), onStop(), onDestroy().

     //Keeps a reference here to make it easy to release later
     private var mediaPlayer: MediaPlayer? = null
  2. Override MainActivity#onStop() to release and null out mediaPlayer.

     override fun onStop() {
        super.onStop()
        mediaPlayer?.release()
        mediaPlayer = null
     }
  3. Inside the openMusicLauncher callback, instantiate a MediaPlayer using the factory function MediaPlayer#create() and assign it to mediaPlayer.

     mediaPlayer = MediaPlayer.create(applicationContext, uri)
  4. Since mediaPlayer is nullable, we will chain the newly created MediaPlayer with an also {} 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.

  1. So we will have to set the SeekBar max value corresponding to the file duration. Inside the also {} block, add the code below.

     seekBar.max = it.duration
  2. Now, start() the MediaPlayer.

     it.start()
  3. 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.
     }
  4. Inside of launch {}, add a while() loop conditioned to the Boolean MediaPlayer.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.
     }
  5. Inside of this while() loop, we can synchronize SeekBar#progress with MediaPlayer.currentPosition.

     lifecycleScope.launch {
        while (it.isPlaying){
            seekBar.progress = it.currentPosition
        }
        //Can also release mediaPlayer here, if not looping.
     }
  6. We can also synchronize TextView#text with MediaPlayer.currentPosition. The milliseconds extension property of Int is used here for convenience because its default toString() 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.
     }
  7. 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.

  1. Append the object below to the openMusicLauncher callback. We have only implemented onProgressChanged because that is all we need for now. We also used the function MediaPlayer#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?) {}
     }
  2. Now, assign seekBarLisenter to seekBar.

     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.

AudioLoop.gif

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.

Be a part of the DaniWeb community

We're a friendly, industry-focused community of developers, IT pros, digital marketers, and technology enthusiasts meeting, networking, learning, and sharing knowledge.